[번역] 최신 기술 – Event Sourcing 처음 적용하기

원문: https://msdn.microsoft.com/magazine/mt422577

뭐든 큰 변화없이 언제나 뻔하다고 생각하면 어느새 신경도 쓰지 않게 됩니다. 데이터 저장소를 생각할 때 우리는 당연히 데이터의 현재 상태가 저장되어 있겠거니 합니다. 보험, 금융과 같이 큰 규모의 프로젝트는 모든 이력을 정확히 추적하고 기록해야하지만, 그렇지 않은 대부분의 어플리케이션과 웹사이트는 현재 상태만 저장해도 충분합니다.

이처럼 현재 상태를 저장하는 방식은, 시스템의 현재 상태를 스냅샷으로 찍어서 보존한다고 표현할 수 있습니다. 데이터는 보통 관계형데이터베이스에 저장합니다. 이렇게만 해도 새 트랜젝션을 만들고 과거의 트랜젝션 결과를 가져올 수 있습니다. 여기까지가 지난 수 십년간의 ‘뻔한 것’이었습니다.

오늘날 비즈니스는 따라오기 벅찰 정도로 빠르게 변하고 있습니다. 그래서 비즈니스와 도메인에서 일어나는 이벤트를 정확하게 추적해야 하는 경우도 많아졌습니다. 이벤트소싱(ES; Event Sourcing)은 스토리지 설계와 데이터를 저장하고 가져오는 방식에 영향을 주는 패턴입니다. 또한, 도메인에서 비즈니스 이벤트를 저장하고 보는 수준에 그치지 않고 데이터 프로젝션까지 즉시 만들 수 있는 패턴입니다.

이벤트소싱은 비즈니스를 기록하고 살펴보기에 똑똑하고 멋진 방법입니다. 데이터 저장 모델로는 꽤 새로운 이론이며 관계형 모델이 처음 등장했을 때만큼 참신합니다. 최근 등장한 NoSQL 보다 큰 변화를 줄 수도 있습니다. 물론 이벤트소싱은 현재 활발히 사용 중인 관계형이나 NoSQL을 대체하는게 목적은 아니며, 여러분은 이벤트소싱을 이들 둘의 상위 개념으로 구현할 수 있습니다. 이벤트소싱은 특정 시점의 상태로만 취급했던 데이터를 이벤트 단위로 다룹니다. 이벤트소싱을 사용할 수록 우리는 데이터에 대한 시각도 새로워질 것입니다.

이벤트소싱을 적용하면 무엇이 좋은가?

현재 상태만 저장하는 저장 모델에서 한 단계 발전한 형태로 갱신 이력을 추적하는 모델이 있습니다. 서점관리 프로그램을 생각해봅시다. 책마다 설명 속성이 있고 여러분은 속성 수정 권한이 있습니다. 이 때 속성에 대한 수정 이력을 보존해야할까요?

요구사항은 상황마다 다르겠지만, 이번 예시에서는 이러한 변경내역 추적이 중요한 기능이라고 합시다. 어떻게 구현할 수 있을까요? 한가지 방법은, 현재 상태를 저장하는 테이블 하나와 변경 내역을 저장하는 별도의 테이블로 구성하는 것입니다. 업데이트할 때마다 하나의 레코드가 추가되며 업데이트 기록에는 변경한 컬럼과 변경한 내용을 저장합니다.

다른 방법으로 시도해볼까요? 하나의 테이블에 하나의 책에 대해서도 여러 개의 레코드를 기록합니다. 각 레코드는 그림 1과 같이 타임스탬프와 현재 상태를 순서대로 저장합니다.

Multiple Records Hold Entity History
그림 1 엔티티 변경 내역을 여러 개의 레코드로 저장하는 형태

위와 같이 구성하면 현재 상태를 가져오기 위한 별도의 API를 만들어야 합니다. 단순히 레코드ID로 쿼리해서는 최신 상태를 가져올 수 없고 타임스탬프 상의 최신이거나 업데이트카운트가 가장 큰 값을 가져오도록 만들어야 합니다. 입력한 데이터 엔티티에 대한 모든 이벤트는 하나의 흐름으로 표현할 수 있는데, 이처럼 이벤트를 흐름으로 표현하는 것이 이벤트소싱의 핵심입니다. 그러므로 이벤트를 원활하게 추적하는 시스템을 구현하고 싶다면 이벤트소싱이 정답입니다.

기존의 개념 중 이벤트소싱과 관련있어 보이지만 다른 개념도 있습니다. 이벤트소싱이 로깅이나 감시 기능과 유사하다고 생각할 수 있지만, 로깅은 예외 상황이나 프로파일링까지 고려한다는 점에서 다릅니다. 이벤트소싱은 비즈니스 이벤트에 대해서만 다룹니다. 그러므로, 로깅 기능을 구성할 때처럼 여러 도메인과 구조를 관통하는 공통된 역할을 콤포넌트화 하는 작업과도 다릅니다. 이처럼 공통 부분을 정의하는 과정을 Aspect-orient 소프트웨어에서는 공통의 관심사(cross-cutting concern; 횡단관심사라고도 함)라고 하는데 이와는 다른 의미입니다. 이벤트소싱은 데이터를 어떤 구조로 설계하고 저장하는지에 대한 내용으로 봐야 합니다.

이벤트소싱이란

이벤트소싱은 이벤트를 데이터 소스로 간주합니다. 수 십년간 개발자들은 이벤트에 대해서 그다지 중요하게 생각하지 않았습니다. 어쩌면 그런 이유로 이벤트소싱이 주목받지 못했는지도 모릅니다. 이벤트소싱이 딱히 쓸모있지 않다고 생각해도 괜찮습니다. 아직 필요하지 않을 뿐입니다.

이벤트소싱은 도메인 전문가가 이벤트를 순서대로 추적하고 싶을 때 특히 유용합니다. 소극적으로 사용한다면, 워크플로우를 표현하거나 비즈니스 로직을 일원화할 때도 유용합니다. 다만 이처럼 소극적으로 사용할 경우에는 이벤트를 보존할 필요성도 적고 이벤트를 최우선 순위로 취급하지도 않습니다. 이 정도가 요즘 흔히 사용하는 이벤트소싱 시나리오입니다. 이 글에서는 이벤트를 데이터 소스로 사용하도록 하겠습니다.  이벤트소스를 도입하려면 저장소에서 두 가지를 고려해야 합니다. 보존과 쿼리입니다. 여기서 보존이라함은 세가지 핵심 작업과 관련되어 있습니다. 바로 삽입/갱신/삭제입니다. 이벤트소싱 시나리오에서의 삽입은 현재 상태만 보존하는 통상의 시스템과 다를 바 없습니다. 요청을 받으면 새 이벤트로 저장합니다. 이벤트에는 GUID와 같은 고유식별자를 함께 기록하며, 그 외에 해당 이벤트의 타입 이름과 코드, 타임스탬프, 기타 정보도 저장합니다.

이벤트소싱에서 갱신 작업은 삽입의 다른 표현일 뿐입니다. 어떤 프로퍼티가 변경되었고 새 값은 무엇인지, 관련된 비즈니스 도메인이 무엇인지, 그 외에 변경 사유 등을 기록합니다. 갱신이 한 번 일어난 저장소의 데이터는 그림 2와 같습니다.

A New Record Indicates Update to Entity with ID #1
그림 2 Entity ID #1에 대해 갱신이 일어났음

이와 마찬가지로 삭제 작업은 해당 엔티티를 삭제했다는 정보를 ‘삽입’합니다.

갱신 작업은 쿼리할 때 새로운 고민거리를 줍니다. 갱신하기 전에 갱신할 대상이 이미 있는지, 현재 상태가 어떤지는 어떻게 알 수 있을까요? 먼저 간단한 쿼리 레이어를 하나 만들어서 ID를 조회하고, 그 다음 현재의 값에 기반하여 새 값으로 갱신하는 이벤트를 삽입해야 합니다.

예를 들면, Created 이벤트를 먼저 가져온 후 그 내용에 맞추어 새 데이터를 추가하는 방법입니다. 현재 상태의 값은 해당 ID에 대한 모든 이벤트를 조회한 후 처음부터 짚어나가면 구할 수 있습니다. 이런 방법을 ‘이벤트 리플레이’라고 합니다. 하지만 단순히 모든 이벤트를 재생해서 상태를 재구성하는 방법으로는 성능에 큰 문제가 발생합니다. 은행 계좌의 현재 잔액을 알려면 수 년 전의 계좌 개설일부터 현재까지의 모든 거래를 가져와야 할테니까요. 그리 좋은 방법은 아닙니다.

그래서 이와 같이 모든 이벤트를 가져와야 하는 문제를 해결하는 방법 중 하나로 스냅샷을 만드는게 있습니다. 스냅샷은 특정 시점의 상태를 저장한 레코드입니다. 스냅샷을 만들면 적어도 모든 이벤트를 리플레이할 필요는 없습니다.

구현에 있어서, 이벤트소싱은 특정 기술이나 제품이 아닙니다. 그러므로 관계형 데이터베이스를 사용하든 NoSQL을 사용하든 상관 없습니다. 그 대신, 이벤트소싱을 소프트웨어 콤포넌트 개념으로 본다면 ‘이벤트 저장소(event store)’를 구현한다고 말할 수 있겠습니다. 이벤트 저장소는 이벤트 로그를 구현하는 작업과 별반 다를 바 없습니다. 그러므로 최소한의 기능만 충족한다면 데이터베이스가 제공하는 API를 이용하여 직접 만들어도 무방합니다.

이벤트 저장소는 두 가지 전제를 가지고 있습니다. 먼저, 추가만 가능하고 갱신은 없습니다. 삭제 또한 삭제 표식을 추가할 뿐 이벤트를 지우지 않습니다. 둘째, 요청하는 이벤트 ID에 맞게 이벤트 스트림을 반환할 수 있어야 합니다. 이 두가지 기능만 있으면 이벤트 저장소의 기본 요건은 충족됩니다.

이벤스 저장소 구현 시 고려사항

앞서 말했듯이, 이벤트 저장소는 기본 기능만 충족하면 특정 기술과 상관없이 구현할 수 있습니다. 데이터를 보존하는 부분은 보통 관계형 데이터베이스나 NoSQL을 사용합니다. 관계형데이터베이스로 구현한다면 한 이벤트마다 하나의 레코드를 가지도록 하고 하나의 테이블은 하나의 엔티티 타입을 가지도록 구현하는 식입니다.

이벤트는 다양한 포멧이 있습니다. 예를 들어, 모든 이벤트에 공통된 속성도 있지만 그렇지 않을 수도 있습니다. 물론 이벤트가 최대한 공통된 속성을 가질 수록 구현하기에 좋습니다. 그렇게 하기 어렵다면 행 단위(row)로 레코드를 쌓지 않고 SQL Server 2014에 추가된 기능인 Column Store Index를 이용하여 열 단위, 즉 컬럼 하나씩 쌓도록 테이블을 구성할 수도 있습니다. 또 다른 방법이라면, 이벤트를 JSON 오브젝트로 만든 후 이를 문자열로 직렬화하여 하나의 문자열 컬럼에 넣을 수도 있습니다.

NoSQL에서는 다양한 속성을 담은 하나의 레코드를 “도큐먼트”라는 단위로 저장합니다. 일부 NoSQL 제품은 이러한 도큐먼트 저장에 특화되어있습니다. 개발자 입장에서는 클래스를 만들고, 값을 채우고, 그대로 저장하면 끝나므로 아주 간편합니다. 보통은 각 이벤트의 유형 별로 클래스를 만들어서 저장하도록 구현합니다.

진행 중인 프로젝트

이벤트소싱은 아직 구조적으로 성숙한 단계는 아닙니다. 무엇 하나 표준화된 규약이 없기 때문에 이벤트 저장소와 이를 이용한 개발 경험은 계속 발전해나갈 여지가 많습니다. 그러므로 이벤트소싱 솔루션은 직접 만들어도 좋습니다. 이 섹션에서는 이벤트 저장소를 좀 더 구조적으로 만들고 편리하게 다룰 수 있는 도구를 몇가지 소개하겠습니다.

이벤트소싱에 최적화된 이벤트 저장소를 사용하면 이벤트 기록과 읽기 작업에만 집중할 수 있어서 보다 효율적인 개발이 가능합니다. NEventStore (neventstore.org) 프로젝트는 이런 시도 중 하나입니다. 간단히 이벤트를 기록하고 다시 읽을 수 있고, 가장 중요한 점이라면 특정 저장소에 의존하지 않고 이를 선택할 수 있다는 점입니다. 아래 예시는 저장소로 SQL을 사용하고 있습니다.

var store = Wireup.Init()
  .UsingSqlPersistence("connection")
  .InitializeStorageEngine()
  .UsingJsonSerialization()
  .Build();
var stream = store.CreateStream(aggregateId);
stream.Add(new EventMessage { Body = eventToSave });
stream.CommitChanges(aggregateId);
위의 예시는 이벤트를 기록할 때이며, 이벤트를 읽을 때는 스트림을 열고 커밋된 이벤트 컬렉션을 한 흐름으로 볼 수 있습니다.
또 다른 프로젝트로 Event Store (geteventstore.com) 프로젝트가 있습니다. .NET과 HTTP API를 제공하며, 이 API로 이벤트를 취합하고 하나의 스트림으로 관리할 수 있습니다. 이벤트 스트림을 가지고 크게 세 가지의 작업을 할 수 있습니다. (1) 이벤트 쓰기 (2) 가장 최근의 이벤트 또는 이벤트의 특정 구간 읽기 (3) 갱신할 때 받아보기가 가능합니다.
받아보기(subscription) 기능은 기본적으로 스트림에 이벤트를 추가할 때마다 콜백 함수를 호출하지만 그 방식은 세가지로 나뉩니다.
  • Volatile: 설정 시점 이전의 이벤트는 무시합니다. 새로 입력한 이벤트부터 받습니다.
  • Catch-up: 정해진 시작 지점부터 이벤트를 받습니다. 이미 입력된 이벤트도 받을 수 있습니다.
  • Persistent: 하나의 이벤트를 여러 곳에서 받을 수 있습니다. 여러 곳에서 받을 경우에도 최소 한 번(at-least-once)을 보장하며 순서에 상관없이 여러번 받을 수도 있습니다.

(역자 주: 더 자세한 정보는 Event Store 기술문서를 참고해주세요.)

정리

이벤트소싱은 이벤트를 어플리케이션의 데이터소스로 사용합니다. 어플리케이션을 만들 때 데이터의 마지막 상태만 다루는게 아닌 비즈니스 이벤트의 흐름을 기준으로 설계하고 개발할 수 있습니다. 저장하는 이벤트 데이터는 아주 저수준의 정보이므로 현재 상태를 알기 위해서는 별도의 투사(projection) 과정을 거쳐야 합니다. 투사란, 이벤트 리플레이를 하면서 특정 시점에 대한 데이터의 상태값을 만드는 일련의 처리 과정을 말합니다. 이벤트를 이용하면 어떤 형태로든 다양한 형식의 투사 결과를 만들 수 있습니다. 그리하여, 현재의 상태값 또한 다양한 형식으로 맞춰서 구할 수 있습니다.


Dino Esposito “Microsoft .NET: Architecting Applications for the Enterprise” (Microsoft Press, 2014), “Programming ASP.NET MVC 5” (Microsoft Press, 2014)의 공동저자입니다. JetBrains에서 .NET과 안드로이드 분야의 기술 에반젤리스트이며, 세계 곳곳의 여러 행사에서 연사로 활동하고 있습니다. Esposito의 소프트웨어에 대한 비전은 다음 링크에서 볼 수 있습니다.  software2cents.wordpress.com, 트위터: @despos.

이 문서의 리뷰를 한 Microsoft 기술 전문가 Jon Arne Saeteras에게 감사드립니다.

이 문서를 번역한 김영재 교육서비스 바로풀기의 개발사 Bapul의 CTO로서 기술로 교육에 새로운 시각을 주기 위해 열심히 개발하고 있습니다.

Advertisements

[번역] 최신 기술 – CQRS 처음 도입하기

원문: https://msdn.microsoft.com/magazine/mt147237

도메인 주도 개발(이하 DDD;Domain-driven design)은 십여년 전부터 소프트웨어 개발자와 아키텍트들에게 많은 영향을 주었습니다. 분명한 장점과 단점을 모두 가진 DDD이지만, 객체지향 개발자들은 이를 통해 오래된 꿈을 실현하려고 했습니다. 그 꿈은 바로, 모든 이해관계자의 요구사항을 해소해주는 온전한 오브젝트 모델로 응용 프로그램을 만드는 것이지요.

지난 수십 년간, 많은 개발자들은 DDD 가이드라인에 따라 프로젝트를 수행했습니다. 그 중에는 성공한 프로젝트도 실패한 프로젝트도 있습니다. 결국 깨달은 진실이 있다면, 소프트웨어에서 기능적인 요소든 그 외의 요소든 모든 것을 아우른 오브젝트 모델이란 그저 환상일 뿐이라는 것입니다.  특히 요즘처럼 고차원의 UX, 급변하는 비즈니스 모델, 시도때도 없이 달라지는 요구사항이 들이닥치는 바쁜 세상에서 견고하고 안정적인 오브젝트 모델을 만들려는 것은 더 허황된 꿈처럼 느껴집니다.

최근 이에 대한 남다른 해결법으로 주목을 끌고 있는 용어가 있습니다. 바로 Command and Query Responsibility Segregation (CQRS; 명령과 쿼리의 역할구분) 입니다. CQRS가 소프트웨어 개발 방법론에서 새롭게 등장한 개념은 아닙니다. 구현도 그리 복잡하지 않습니다. CQRS는 그저 소프트웨어의 생애주기나 복잡도에 구애받지 않으면서 대부분의 소프트웨어 개발에 적용하기에 적합한 구현 패턴일 뿐입니다.

CQRS를 구현하는 방법은 취향따라 최소 세가지가 있습니다. 여러분이 뭐라고 이름 붙이든 상관없습니다. 호텔방이나 음료수를 구분할 때처럼 일반/프리미엄/디럭스로 이름붙여 봅시다. CQRS로 검색해서 나오는 대부분의 예시와 내용은 대부분 디럭스급이라고 보면 됩니다. 그런 것들은 평범한 어플리케이션에 적용하기엔 너무 복잡하고 과분합니다.

CQRS는 프로젝트의 복잡도와 상관없이 성공적으로 적용할 수 있는 소프트웨어 개발 방법론입니다. 최종적으로 CQRS는 전통적인 다층 레이어 아키텍처를 좀 더 많은 변화를 받아들이도록 재구성하는 것입니다.

명령과 쿼리

버트란드 마이어(Bertrand Meyer)가 1980년대에 Eiffel이라는 프로그래밍 언어를 개발할 때, 소프트웨어는 시스템의 상태를 바꾸는 것과 시스템의 상태를 읽는 두가지의 명령으로 나뉜다고 했습니다. 모든 소프트웨어 명령어는 명령과 쿼리 둘 중 하나라고 말입니다. 둘의 조합된 형태도 아니고 반드시 둘 중 하나에 속해야 합니다. 좀 더 세련된 표현으로 말하자면, 질문을 아무리 해도 답변은 변하지 않아야 합니다. CQRS는 이 이론을 현대적으로 복기한 것입니다. 명령과 쿼리를 구별하여 별개로 구현합니다.

명령과 쿼리를 논리적으로 나누기란 어려운 일입니다. 특히 둘 모두가 동일한 프로그래밍 스택과 동일한 모델을 사용한다면 더더욱 어렵습니다. 복잡한 비즈니스 시나리오에서는 더 어려운데, 왜냐하면 오브젝트든 함수형이든 뭐든간에 어떤 모델이라도 금세 손 쓸 수 없을 정도로 복잡해지곤 하기 때문입니다. 이렇게 모델이 급격하게 커지고 복잡해지면 시간과 예산을 잡아먹고 원래 의도했던대로 동작하지도 않을 것입니다.

CQRS는 기본적으로 쿼리 작업은 쿼리 작업끼리 한 레이어에, 명령 작업은 또 다른 레이어에 그룹핑해서 구분합니다. 각 레이어는 독립적인 데이터 모델과 서비스를 사용하고 독자적인 패턴과 기술을 조합해서 구현합니다. 중요한 점은, 두 레이어를 각자 고유의 2-tier로 구현할 수 있고 최적화도 구분해서 적용함으로써 서로에게 영향을 주지 않도록 하는 것입니다. 그림 1은 CQRS 구조의 기초적인 부분을 보여줍니다.

A Canonical and Multi-­Layered CQRS Architecture 

그림 1 기본적인 다층 레이어의 CQRS 구조

명령과 쿼리를 별개로 인식하면 소프트웨어 구조에 큰 변화를 줄 수 있습니다. 일례로, 각자의 도메인 레이어에 대해서만 모델링하고 코딩하기 때문에 훨씬 단순합니다. 명령 스택은 데이터, 비즈니스, 보안 규칙만 고려해서 개발하고, 쿼리 스택은 가장 간단하게는 DB 커넥션에 SQL 쿼리문만 작성하면 됩니다.

프레젠테이션 계층에 보안 규칙을 넣는다면, 쿼리 스택은 Entity Framework와 같은 ORM을 얇게 감싸고 데이터를 조회하는 수준일겁니다. 도메인 레이어마다 데이터를 해당 도메인의 요구사항에 최대한 맞춰서 표현하기에도 수월합니다. 데이터를 굳이 복사하거나 누더기로 만들지 않고도 말입니다.

DDD가 처음 나왔을 때는 소프트웨어 개발의 복잡함을 따져보려는 의미가 컸습니다. DDD를 시도하는 개발자들은 꾸준히 이 복잡함과 씨름해왔습니다. 대부분은 비즈니스 도메인에 대한 이야기였습니다. 하지만 대부분의 복잡한 것들은 명령과 쿼리의 곱집합 때문이었습니다. 쿼리에서 명령 부분을 떼어내면 복잡도가 한자릿수로 줄어듭니다. 단순히 수학적으로 표현하자면, 통상적인 도메인모델 기반으로 구현한 복잡도가 NxN이라면 CQRS는 N+N인 셈입니다.

CQRS 시작하기

기존의 CRUD 시스템도 얼마든지 CQRS 형태로 바꿀 수 있습니다. 예를 들어 사용자가 다양한 정보를 입력하는 폼이 있는 전통적인 ASP.NET MVC 웹 어플리케이션이 있다고 합시다. 대부분의 어플리케이션이 하는 일이기에 아키텍트들은 어떻게 해야 이런 어플리케이션을 빠르고 효율적으로 만들지 잘 알고 있습니다. 이제 여러분은 이를 CQRS로 재해석해서 만들어볼 것입니다. 아마도 바뀔 부분이 거의 없어서 놀랄 것입니다. 그에 비해 얻는 장점은 너무나 많습니다.

기존의 시스템은 대부분 여러 계층으로 이루어져 있습니다. 여러분의 시스템에 콘트롤러에서 직접 호출하는 어플리케이션 서비스가 있다고 가정해봅시다. 콘트롤러와 어플리케이션 서비스는 웹 서버 안에 있습니다. 그림 1과 같이, 어플리케이션 서비스는 어플리케이션 계층을 이룹니다. 그래서 어플리케이션 계층은 시스템에 명령과 쿼리를 실행하는 플랫폼이기도 합니다. CQRS를 적용한다는 말은 곧 역할이 둘로 구분된 중간계층을 가진다는 말이기도 합니다. 하나는 시스템 상태를 바꾸는 명령에 대한 것이고, 다른 하나는 데이터를 가져오는 것입니다. 그림 2는 이에 따라 ASP.NET MVC 프로젝트의 구조를 나타낸 다이어그램입니다.

 

The CQRS Architecture for an ASP.NET MVC Project
그림 2 ASP.NET MVC 프로젝트에서의 CQRS 구조

먼저 두 개의 클래스 라이브러리 프로젝트를 만듭니다. 쿼리 스택과 명령 스택 라이브러리입니다. 그리고 웹 서버 프로젝트에 모두 참조로 추가합니다.

쿼리 스택

쿼리 스택 클래스는 데이터를 가져오기만 합니다. 개발을 할 때 프리젠테이션 레이어에 최대한 일치하도록 데이터 모델을 만듭니다. 이 때 비즈니스 규칙은 거의 고려할 필요가 없습니다. 왜냐하면 비즈니스 규칙이란 주로 상태를 바꾸는 것이므로 명령 스택에서 구현하기 때문입니다.

DDD에서 유행한 도메인 모델 패턴은 도메인 로직을 조직하는 방법에 대한 것이었습니다. 이렇게 복잡하게 갈 필요 없이, 시스템의 프론트엔드에서 실행할 쿼리를 만들 때는 단지 어플리케이션 로직의 일부와 사용 시나리오만 신경쓰면 됩니다. 실상 ‘비즈니스 로직’이라는 말은, 변하지 않는 도메인 로직 위에 어플리케이션 별 로직을 엮은 결과물입니다. 보여주는 정보의 정해진 형식과 프레젠테이션 포멧을 알면 그저 SQL 쿼리로 나온 데이터를 매핑하는 작업만 해주면 됩니다.

어플리케이션 계층에서 실행하는 모든 코드는 시스템의 비즈니스 도메인을 반영한 결과입니다. 그러므로 시스템의 핵심 로직에 대한 API는 변하지 않아야 합니다. 이상적으로는, 핵심 로직에서 노출된 API는 그 자체로 완전무결해야 합니다. 즉, 어떠한 불일치도 없고 일관된 규칙을 가집니다. 쿼리 스택의 본질은 읽기전용이므로 아래 코드와 같이 Entity Framework 콘텍스트를 간단히 감싸고 있는 클래스를 만들 수 있습니다. 이제 감쌌다는 의미로 wrapper 클래스라고 부르겠습니다.

public class Database : IDisposable
{
  private readonly QueryDbContext _context = new QueryDbContext();
  public IQueryable<Customer> Customers
  {
    get { return _context.Customers; }
  }
  public void Dispose()
  {
   _context.Dispose();
  }
}

위 코드에서 QueryDbContext 클래스는 DbContext 클래스를 상속받았으며, DbSet<T> 콜렉션이 들어있습니다. 여기서는 QueryDbContext 클래스가 데이터베이스의 모든 테이블에 엑세스 할 수 있다고 가정합시다. 이렇게 하면 Linq to Entities 기능으로 쿼리를 수행할 수 있습니다.

쿼리 파이프라인을 만드는 첫 단계는 데이터베이스에 쿼리만 할 수 있도록 설정하는 것입니다. wrapper 클래스가 바로 이런 역할을 합니다. Database 가 IQueryable<T>만 노출하고 있기 때문입니다. 이로써 Database라는 wrapper 클래스를 사용하는 어플리케이션 계층은 쿼리를 구현해서 프리젠테이션으로 데이터를 보낼 수 있게 됩니다.

var model = new RegisterViewModel();
using (var db = new Database())
{
  var list = (from m in db.Customers select m).ToList();
  model.ExistingCustomers = list;
}

위 코드와 같이 데이터 원본과 프리젠테이션은 직접 연결되어있습니다. 이제 데이터를 표시하기 위한 용도로 데이터 읽기와 형식만 다루면 됩니다. 로그인 기능이나 UI에 제한을 둬서 데이터 접근을 제어하고 싶다면, 그저 데이터 접근까지 레이어를 더 추가하거나 IQueryable로 가져오는 데이터 콜렉션을 조절해서 구현할 수 있습니다. 데이터 모델은 데이터베이스와 동일하므로 1:1 관계입니다. 이렇게 IQueryable을 노출한 데이터 모델은 Layered Expression Trees (LET)라는 개념을 적용할 때도 유용하게 사용할 수 있습니다. (*역자주: LET는 Linq를 최대한 활용하여 데이터 모델을 풍부하게 표현하는 방법 중 하나입니다)

지금까지 논의한 내용 중 핵심 몇 가지를 정리해봅시다. 우선, 읽기전용 파이프라인에는 비즈니스 규칙이 없다는 것입니다. 인증 규칙과 필터링 외에는 특별히 고려할게 없습니다. 그리고 인증 규칙이나 필터링은 어플리케이션 계층에서 이미 잘 파악하고 있습니다. 데이터 전송 오브젝트(Data Transfer Object; DTO)를 복잡하게 다루지도 않습니다. 뷰에 표시하는데에 단 하나의 모델과 그 안에 실제 데이터만 있습니다. 그러므로, 어플리케이션 서비스는 아래와 같은 패턴일 것입니다.

var model = SpecificUseCaseViewModel();
model.SomeCollection = new Database()
     .SomeQueryableCollection
     .Where(m => SomeCondition1)
     .Where(m => SomeCondition2)
     .Where(m => SomeCondition3)
     .Select(m => new SpecificUseCaseDto
       {
         // Fill up
       })
     .ToList();
return model;

코드에 있는 데이터 전송 오브젝트는 프리젠테이션 전용으로만 사용하게 됩니다. 클래스를 만드는건 어쩔 수 없지만 그 클래스에는 사용자가 뷰에서 보고 싶어하는 정보만 있습니다. Where 절을 교체하는 IQueryable 확장메소드를 구현해서 그때그때마다 다른 것으로 볼 수 있도록 할 수도 있습니다. 이렇게 하면 특정 도메인에 대해 대화형으로 구현할 수도 있습니다.

쿼리 스택에서 고려할 점이 또 하나 있다면 데이터의 일관성(persistence)입니다. 간단한 형태의 CQRS는 명령과 쿼리 스택을 하나의 데이터베이스로 사용합니다. 이런 공용 구조는 CQRS로 구현해도 전통적인 CRUD 시스템과 유사해보입니다. 그러므로 변화에 저항감이 있는 개발자들에게는 CQRS를 도입하기에 보다 쉬워 보일 수 있습니다. 하지만 나중에는 명령과 쿼리 스택이 별개의 데이터베이스를 사용하도록 백엔드를 디자인해야 합니다. 각자의 목적에 맞도록 최적화해야 하기 때문입니다. 두 데이터베이스의 동기화는 다른 문제이므로 이 글에서 다루지는 않겠습니다.

명령 스택

CQRS 에서 명령 스택은 어플리케이션의 상태를 바꾸는 작업만 합니다. 어플리케이션 계층이 프리젠테이션에서 요청을 받으면 하나의 명령으로 구성한 후 이 명령을 파이프라인에 푸시합니다. 여기서 ‘명령을 파이프라인에 푸시한다’는 표현은 CQRS를 특징짓는 말이기도 합니다.

가장 단순하게는 트랜젝션 스크립트를 실행하는 것이 곧 명령을 푸시하는 것입니다. 트랜젝션 스크립트는 작업에 필요한 모든 과정을 처리한 워크플로우입니다. 그래서 어플리케이션 계층에서 명령을 푸시하는 작업은 아래 코드와 같이 구현할 수 있습니다.

public void Register(RegisterInputModel input)
{
  // Push a command through the stack
  using (var db = new CommandDbContext())
  {
    var c = new Customer {
      FirstName = input.FirstName,
      LastName = input.LastName };
    db.Customers.Add(c);
    db.SaveChanges();
  }
}

비즈니스 로직을 구현한 서비스 도메인 계층과 그에 관련된 도메인 모델을 좀 더 적극적으로 이용하면 보다 복잡한 구성을 만들 수도 있습니다. 하지만 CQRS를 구현하는데 반드시 DDD와 엮을 필요는 없습니다. DDD에서 언급하는 도메인 모델의 집합, 팩토리, 값 오브젝트와 같은 개념을 굳이 다루지 않아도 CQRS 구현에 문제되지 않습니다. 그저 명령과 쿼리의 구분을 명확히 해서 도메인 모델로 인해 만들어지는 복잡성을 줄일 수만 있다면, 그것이 바로 CQRS 도입의 장점일 것입니다.

CQRS 다음은 무엇일까

CQRS의 장점은 명령과 쿼리 파이프라인을 원하는대로 최적화할 수 있으면서도 다른 요소가 깨질 위험은 거의 없다는 것입니다. CQRS를 가장 기초적으로 시도하려면 단일 데이터베이스를 공유하고 어플리케이션 레이어에서 읽기와 쓰기를 별개의 라이브러리로 수행하는 방법이 있습니다.

좀 더 제대로 하려면 여러 개의 데이터베이스를 혼용하여 폴리글랏 저장소로 만들고 쿼리할 때나 이벤트 소싱에 대응하여 조합하는 것입니다. 이벤트 소싱은 명령을 백엔드에 보낼 때 더 유연하게 처리할 수 있으므로 중요합니다. 명령을 버스에 보내고 이를 이벤트로 배포하면 어떤 작업을 새로 정의하거나 수정하는 경우에 이를 플로우 차트를 다루듯이 관리할 수 있기 때문에 보다 유연합니다. 이와 동시에 버스의 성능과 기능을 추가하면 수직적으로 확장하는 효과를 얻을 수도 있습니다.

많은 개발자들이 CQRS에 찬사를 보내지만 대규모의 고차원적인 어플리케이션에만 어울린다고 생각하곤 합니다. CQRS 그 자체는 고수준의  아키텍처도 아니고 특정 기술에 종속적이지도 않습니다. 일부 디자인 패턴에 종속적일 수는 있지만, 단지 디자인 패턴일 뿐입니다. CQRS는 단순하고 강력하며 대부분의 어플리케이션에 잘 맞습니다.


Dino Esposito “Microsoft .NET: Architecting Applications for the Enterprise” (Microsoft Press, 2014), “Programming ASP.NET MVC 5” (Microsoft Press, 2014)의 공동저자입니다. JetBrains에서 .NET과 안드로이드 분야의 기술 에반젤리스트이며, 세계 곳곳의 여러 행사에서 연사로 활동하고 있습니다. Esposito의 소프트웨어에 대한 비전은 다음 링크에서 볼 수 있습니다.  software2cents.wordpress.com, 트위터: @despos.

이 문서의 리뷰를 한 Microsoft 기술 전문가 Jon Arne Saeteras에게 감사드립니다.

이 문서를 번역한 김영재 교육서비스 바로풀기의 개발사 Bapul의 CTO로서 기술로 교육에 새로운 시각을 주기 위해 열심히 개발하고 있습니다.

[번역] 최신 기술 – 이력을 기록하는 CRUD 구현하기 2부

원본: https://msdn.microsoft.com/en-us/magazine/mt707524

개념만 보면, 이력을 기록하는 CRUD는 기존 CRUD에서 하나의 파라미터가 추가된 것입니다. 바로 시간입니다. 이력을 기록하는 CRUD는 데이터베이스 레코드의 추가, 갱신, 삭제를 수행한 특정 시점과 상태를 알 수 있습니다. 이를 통해 여러분의 응용 프로그램은 보다 나은 분석과 리포팅 기능으로 비즈니스 인텔리전스를 이룰 수 있습니다.

지난번 컬럼(원문: msdn.com/magazine/mt703431, 번역: youngjaekim.wordpress.com)에서는 이력을 기록하는 CRUD에 대한 이론적 기반을 설명했습니다. 이번 글에서는 구체적인 예시를 보여주고자 합니다.

예시 시나리오

이 문서에서는 간단한 회의실 예약 시스템을 예시로 들겠습니다. 이 회의실 예약 시스템은 사내에 직원들이 이용할 수 있다고 가정합시다. 이 소프트웨어는 단순한 CRUD 기반으로 구현되어 있으며 회의실 예약을 할 때 새 레코드를 생성합니다. 사용자가 해당 예약을 변경하거나 미팅을 취소했을 때는 동일한 레코드가 갱신됩니다.

일반적인 CRUD 기반으로 예약 시스템을 만들면 시스템의 마지막 상태는 알고 있지만 삭제되거나 갱신된 정보는 잃게 됩니다. 이게 정말 문제일까요? 뭐, 경우에 따라 다르겠지요. 실제 비즈니스 환경에서는 별 문제가 되지 않기도 합니다. 하지만 사용하는 직원들의 전반적인 능률 면에서는 이력을 기록하는 CRUD를 적용하면 지나치게 많은 미팅 취소와 변경은 비효율적인 행동이라고 판단하고 사내 프로세스를 개선할 수도 있습니다.

그림 1은 빈 회의실 예약 시스템의 UI 예시입니다. 데이터베이스로는 SQL을 사용했습니다. 데이터베이스 테이블은 Rooms와 Bookings가 연결된 형태입니다.The Front-End UI for a Booking System
그림 1 예약 시스템의 프론트엔드 UI

예시로 든 응용 프로그램은 ASP.NET MVC로 만들었습니다. 사용자가 “Place request (장소 예약)” 버튼을 클릭하면, 콘트롤러 메소드가 실행되고 관련 정보를 전송합니다. 아래 코드는 요청을 받는 서버 코드를 간소화한 것입니다:

[HttpPost]
public ActionResult Add(RoomRequest room)
{
  service.AddBooking(room); 
  return RedirectToAction("index", "home");
}

이 메소드는 BookingController 클래스에 있는 것이며, 인젝션된 워커 서비스 클래스를 실행합니다. 워커 서비스는 여러 작업의 묶음으로 이해하셔도 좋습니다. 이 메소드 구현에서 주목할 점은, 예약을 생성한 후에 그림 1에서 본 첫페이지로 리디력션하는 것입니다. 예약 추가 작업을 수행한 후에 별도로 뷰를 생성하지 않습니다. 이것은 CQRS(Command Query Responsibility Segregation) 구조를 택했기에 나온 영향입니다. 예약 추가 명령이 백엔드에 전송되면, 시스템에 상태를 변경하고 끝입니다. 예시 프로그램이 AJAX를 사용하여 전송하기 때문에 새로고침할 필요도 없습니다. 명령 그 자체는 별도의 작업이 아니기 때문에 어떤 명시적인 링크도 UI에 나오지 않습니다.

기존 CRUD와 이력을 기록하는 CRUD의 가장 중요한 차이점은, 이력을 기록하는 CRUD는 시스템의 시작부터 상태를 변경하는 모든 작업을 기록으로 가지고 있다는 것입니다. 이력을 기록하는 CRUD를 만들기 위해서는 우선 비즈니스 작업은 모두 명령 단위로 만들어야 한다는 것을 명심하세요. 그렇게 해야 각 명령마다 추적 가능한 매커니즘을 만들 수 있습니다. 시스템에 전달되는 각 명령은 상태를 바꾸고 이력을 기록하는 CRUD는 그 때마다 시스템의 상태를 저장합니다.  변경된 상태는 무조건 이벤트로 저장합니다. 이벤트란 단지 어떤 일이 일어났다는 불변의 정보입니다. 이렇게 쌓인 이벤트로 목록이 만들어지면, 이를 이용해서 여러 형태의 데이터 투사체(projection)를 만들 수 있습니다. 가장 흔한 투사체라면, 단순히 엔티티의 현재 상태를 보여주는게 있겠습니다. 보통 응용 프로그램에서 이벤트는 사용자가 직접 내린 명령이거나, 다른 명령이나 외부 입력이 만들어낸 간접적인 명령입니다. 이 글의 예시 시나리오에서는 사용자가 예약 요청 버튼을 클릭하는 행위라고 할 수 있습니다.

명령을 처리하기

AddBooking 메소드는 아래와 같이 구현할 수 있을 것입니다:

public void AddBooking(RoomRequest request)
{
  var command = new RequestBookingCommand(request);
  var saga = new BookingSaga();
  var response = saga.AddBooking(command);
  // Do something based on the outcome of the command
}

RoomRequest 클래스는 전송된 데이터가 ASP.NET MVC에 의해 바인딩 된 단순한 DTO (Data-Transfer Object; 데이터 전송 오브젝트) 입니다. 그에 비해 RequestBookingCommand 클래스는 명령을 실행하는데 필요한 파라미터입니다. 이런 단순한 시나리오에서는 두 클래스는 거의 동일합니다. 이제 명령을 어떻게 처리할까요? 그림 2는 명령을 실행하는 3단계를 보여줍니다.

The Chain of Core Steps to Process a Command

그림 2 명령을 처리하는 연쇄적인 단계

핸들러는 명령을 받고 수행하는 콤포넌트입니다. 아래의 코드와 같이, 핸들러는 워커 서비스의 코드에서 호출되어 인메모리에서 직접 실행될 수 있으며, 버스를 통해서 할 수도 있습니다.

public void AddBooking(RoomRequest request)
{
  var command = new RequestBookingCommand(request);
  // Place the command on the bus for
  // registered components to pick it up
  BookingApplication.Bus.Send(command);
}

버스를 사용하면 몇가지 장점이 있습니다. 하나는, 동일한 명령에 대해 여러 개의 핸들러가 관여할 때 보다 쉽게 제어할 수 있습니다. 또 하나는, 버스로 안정적인 메시징 도구를 사용하면 메시지 전송을 신뢰할 수 있고 접속 문제도 극복할 수 있습니다. 마지막으로, 버스는 그 자체로 명령을 로깅하는 콤포넌트가 될 수 있습니다. (역자주: 버스 구현에 대하여, 온프레미스는 RabbitMQ, 클라우드 기반은 Azure Service Bus를 고려할 수 있습니다)

핸들러는 보통 일회성 콤포넌트로 하나의 요청에 한 번 시작하고 종료합니다. 하지만, 몇시간에서 몇 일간 실행하는 아주 긴 워크플로우일 수도 있고 사람이 직접 승인해줄 때까지 기다리는 형태일 수도 있습니다. 단순히 일회성 콤포넌트가 아닌 경우는 saga(긴 이야기)라고 불리기도 합니다.

통상적으로 버스나 큐를 사용하는건 확장성이나 신뢰성을 도모하고자 할 때입니다. 단지 기존의 CRUD 대신 이력을 기록하는 CRUD를 구현하기 위해 버스를 사용할 필요는 없습니다. 버스의 사용 여부와는 상관 없이, 어쨌거나 명령은 일회성 핸들러나 긴 작업 핸들러에 도달할테고 어떤 형태의 작업을 수행할 것이고, 이런 대부분의 작업은 데이터베이스의 주요 작업의 집합일 것입니다.

명령 로깅하기

전통적인 CRUD에서 데이터베이스에 정보를 기록하는 것은 입력 값을 포장한 후에 새 레코드로 추가하는 작업을 의미했습니다. 이력을 기록하는 CRUD의 관점에서 새 레코드는 새 이벤트의 생성을 의미합니다. 즉, 우리 예시에서는 새 예약 이벤트입니다. 예약 이벤트는 독립적이면서도 불변의 정보 조각으로 이벤트의 고유 ID, 타임스탬프, 이벤트 명, 이벤트 관련 변수를 포함합니다. 여기서 새 예약 이벤트 관련 변수는 전통적인 CRUD의 Bookings 테이블에 새 예약 레코드를 추가할 때 사용하는 모든 컬럼의 값입니다. 갱신 이벤트에 대한 변수는 갱신하는 필드만 해당됩니다. 그러므로, 모든 갱신 이벤트에 같은 필드가 있지는 않을 것입니다. 끝으로, 삭제 이벤트는 예약 ID 값만 있어도 충분합니다.

이력을 기록하는 CRUD는 두 단계로 동작합니다.

  1. 이벤트와 그 관련 정보를 로깅
  2. 현재 시스템 상태가 빠르게 쿼리할 수 있는지 확인

이 방법이면 시스템의 현재 상태는 언제나 최신 정보이며, 이로 인한 다음 작업도 확신을 가지고 할 수 있습니다. 참고로 기존의 전통적인 CRUD에서는 ‘시스템의 현재 상태’만 있었고 ‘시스템의 과거 상태’ 는 없었습니다. 기존 CRUD 시스템을 이력을 기록하는 CRUD로 발전시킬 때, 이벤트 로깅 단계와 시스템 상태 업데이트를 일관되게 처리하려면 하나의 트랜젝션으로 묶어야 하며, 그 결과는 그림 3과 같습니다.

using (var tx = new TransactionScope())
{
  // Create the "regular" booking in the Bookings table   
  var booking = _bookingRepository.AddBooking(
    command.RoomId, ...);
  if (booking == null)
  {
    tx.Dispose();   
    return CommandResponse.Fail;
  }
  // Track that a booking was created
  var eventToLog = command.ToEvent(booking.Id);
    eventRepository.Store(eventToLog);
  tx.Complete();
  return CommandResponse.Ok;
}
그림 3 이벤트 로깅과 시스템 업데이트

매번 예약 기록을 추가/수정/삭제할 때마다 현재 상태를 정확한 순서대로 알면서 예약 목록을 최신으로 유지할 수 있습니다. 그림 4는 예시 시나리오에 사용된 두 SQL 서버 테이블이며, 추가와 업데이트 과정을 겪은 후의 모습입니다.

Bookings and LoggedEvents Tables Side by Side
그림 4 Bookings와 LoggedEvents 테이블 비교

Bookings 테이블은 개별 예약 목록을 가지고 있으며, 각각은 현재 상태를 가지고 있습니다. LoggedEvents 테이블에는 모든 예약에서 발생한 모든 이벤트가 시간 순서대로 기록되어 있습니다. 예를 들어, 예약 #54는 예약을 만든 다음 몇 일 후에 수정했음을 알 수 있습니다. 그림의 예시에서, Cargo 열은 실행한 명령의 JSON 문자열을 그대로 직렬화해서 저장하고 있습니다.

UI로 로깅된 이벤트 보기

인증된 사용자가 예약에 대한 상세 정보를 보고 싶다고 가정해봅시다. 아마도 사용자는 달력에서 예약 정보를 불러오거나 기간 설정을 하여 쿼리할 것입니다. 두 경우 모두 기본적으로는 사용자가 언제/얼마나/누가 예약을 했는지는 알고 있으므로, 이런 상세 정보는 별로 안쓰일 것입니다. 그 대신 그림 5와 같이 예약에 대한 전체 히스토리를 보여준다면 꽤 도움이 될 것입니다.

Consuming Logged Events in the UI
그림 5 UI로 로깅된 이벤트 보기

로깅된 이벤트를 쭉 불러오면 한 엔티티(Booking 54)에 대한 상태의 목록을 표시하는 뷰 모델을 만들 수 있습니다. 예시에서, 사용자가 예약 상세 정보를 보려고 클릭하면  JSON이 백그라운드에서 다운로드되고 모달(modal) 팝업이 열립니다. 이 때 JSON을 주는 메소드는 아래와 같습니다.

public JsonResult BookingHistory(int id)
{
  var history = _service.History(id);
  var dto = history.ToJavaScriptSlotHistory();
  return Json(dto, JsonRequestBehavior.AllowGet);
}

서비스 내에 History 메소드를 실행하는 것이 전부입니다. 이 동작은 특정 예약 ID에 대한 이벤트를 쿼리해서 모두 가져옵니다.

var events = new EventRepository().All(aggregateId);
foreach (var e in events)
{
  var slot = new SlotInfo();
  switch (e.Action)
  {
    :
  }
  history.Changelist.Add(slot);
}

로깅된 이벤트를 하나씩 넘기면서, 적절한 오브젝트를 DTO에 덧붙여서 반환합니다. 그림 5에서 보이는 ToJavaScriptSlotHistory(팝업창)에는 두 상태의 차이점을 신속하게 보여주기 위한 변환 작업이 있습니다.

주목할 점은, 이벤트를 CRUD만 이용해서 로깅해도 UI가 더 나아질 수 있다는 것입니다. 여러분은 이제 시스템에서 일어나는 모든 상황을 알 수 있으며 언제든 원하는 형태, 원하는 시점으로 데이터를 투사해서 볼 수 있습니다. 예를 들어, 갱신과 삭제 작업에 대한 통계를 만들어서 애널리스트에게 회사의 회의실 예약이 얼마나 비효율적인지를 파악하게 할 수 있습니다. 또는, 특정 날짜의 예약 정보를 쿼리하고 연관된 이벤트를 산출할 수도 있습니다. 한 마디로, 이력을 기록하는 CRUD는 응용 프로그램에 완전히 새로운 가능성을 열어줍니다.

정리

이력을 기록하는 CRUD는 기존의 단순한 CRUD 응용 프로그램을 보다 똑똑하게 발전시키는 방법입니다. 이 글에서는 최근에 유행하는 CQRS, 이벤트소싱, 버스와 큐, 메시지 기반 비즈니스 로직 등 몇 가지 단어와 패턴도 언급했습니다. 이 글이 도움이 됐다면, 저의 이전 글인 2015년 7월의 글(msdn.com/magazine/mt238399)과 2015년 8월 글(msdn.com/magazine/mt185569)도 한 번 읽어보세요. 이번 예시에 이어서 더 많은 영감을 줄 것입니다!


Dino Esposito “Microsoft .NET: Architecting Applications for the Enterprise” (Microsoft Press, 2014), “Modern Web Applications with ASP.NET” (Microsoft Press, 2016)의 저자입니다. JetBrains에서 .NET과 안드로이드 분야의 기술 에반젤리스트이며, 세계 곳곳의 여러 행사에서 연사로 활동하고 있습니다. Esposito의 소프트웨어에 대한 비전은 다음 링크에서 볼 수 있습니다.  software2cents.wordpress.com, 트위터: @despos.

이 문서의 리뷰를 한 Microsoft 기술 전문가 Jon Arne Saeteras에게 감사드립니다.

이 문서를 번역한 김영재 교육서비스 바로풀기의 개발사 Bapul의 CTO로서 기술로 교육에 새로운 시각을 주기 위해 열심히 개발하고 있습니다.

MSDN Magazine 포럼에서 이 문서에 대한 토론 보기 (영문)

[번역] 최신 기술 – 이력을 기록하는 CRUD 구현하기 1부

원문: https://msdn.microsoft.com/ko-kr/magazine/mt703431
참고: 2부도 있습니다 [번역] 최신 기술 – 이력을 기록하는 CRUD 구현하기 2부

*역자주: 원문은 Historical CRUD이며, 몇몇 개발자 분들과의 논의 끝에 ‘이력을 기록하는 CRUD’로 번역했습니다. [대화 보기]