안녕하세요. 아이들나라 회원/구독 백엔드 팀 소속 김윤지입니다.
해당 포스팅에서는 아이들나라 구독 도메인에서 Aggregate 저장과 관련한 경험을 공유하고자 합니다.
? 을 저장한다.
Aggregate 는 개념적으로 함께 묶여 있을 수 있는 그룹을 칭합니다. 개발 분야에서는 도메인 및 엔티티처럼 응집력있는 그룹을 뜻합니다. 한 도메인을 중심 도메인과 서브 도메인으로 나눌 수 있을 때, 구독 도메인에서 중심은 ‘구독’이고 서브는 ‘빌링’입니다. 이 때, 중심 도메인과 서브 도메인을 모두 포괄하는 개념이 Aggregate 라고 저는 이해하고 있습니다. ‘구독’과 ‘빌링’은 일관성있게 움직이는 하나의 집합이어야하기 때문입니다.
지난 3개월 간, 회원/구독 팀에서는 아이들나라 구독은 웹에서도 가능하다. 라는 하나의 AC 를 진행해왔습니다. 이 과정에서 Aggregate 가 어떻게 저장되었는지 초점을 맞춰 다시 정리해보려고 합니다.
먼저 구독 정보가 저정된다. 를 생각해보았을 때, 테이블을 간단히 상상해보면 이렇습니다.
하지만 저희가 구독 정보를 저장한 결과는 다음과 같습니다.
구독 도메인의 aggregate인 subscription이 통째로 저장되어있습니다.
왜 이런 결과가 나왔을까요?
aggregate 를 저장한다는 것은 해당 aggregate 에 영향을 가한 사건들이 저장된다는 것과 같다고 생각했기 때문입니다. 아주 간단하게 다음과 같은 문장으로 이해할 수 있습니다.
구독 요청이라는 사건 발생에 따라 구독이 저장된다.
aggregate에 어떤 사건이 가해지고 그 결과에 도메인이 어떤 영향을 받았는지, 그 캡쳐본을 저장하는 방식입니다.
사실 해당 시점의 도메인 로직을 저장하는 방식이라고 볼 수도 있을 것 같습니다. 모든 변경사항에 대해 히스토리로 적재되는 것은 아니지만, 개체 상태에 대한 변경 사항을 일련의 이벤트로 캡쳐해 저장한다는 개념을 생각해보면 이벤트 소싱 레포지토리도 떠올랐습니다.
문서 지향 DB 를 사용하지 않은 이유?
한편, aggregate가 저장되는 환경은 Postgres와 JdbcTemplate 이었습니다. MongoDB나 CouchDB 같은 스키마가 없는 형식의 문서 지향 데이터 베이스를 이용하는 것이 어쩌면 더 훌륭한 대안일 수도 있습니다. 해당 aggregate(구독) 의 구조가 다양하고 복잡했다는 점을 고려하면 더더욱 채택되어야할 기술같다는 생각이 듭니다.
하지만, 빠른 시일내에 AC 를 마치기 위해서는 새로운 기술을 사용하는 것에는 부담이 있었습니다. 팀내 기술 숙련도와 기한을 고려한 방법이었습니다. 또한 테이블 스키마 설계나 저장 방식보다는 도메인을 우선적으로 개발하는 팀 내 방식도 이런 결정에 한 몫 했던 것 같습니다.
구체적인 구현 방법은?
먼저 제일 상위의 I.Aggregate 는 중심 도메인 객체인 Subscription이 구현하고 있습니다. Subscription 객체는 실제로 더 많은 인터페이스를 구현하고 있지만, persist 에 초점을 맞춰 간소화한 구조입니다. 중간에 I.SubscriptionState 와 I.SubscriptionSnapshot 가 위치하고 있습니다. 위 그림이 persist 와 어떤 관련이 있는가는 아래와 같은 문장으로 이해해 볼 수 있습니다.
구독(Subscription)의 상태(SubscriptionState)를 스냅샷(SubscriptionShapshot)으로 저장한다.
Subscription 영속에 대해 협력하는 객체들의 단면입니다. 하나씩 따라가보면서 흐름을 짚어보겠습니다. I.Aggregate 를 기준으로 중간에 I.AggregateRepository<ID, AGGREGATE> 와 오른편에는 I.IspSupport<ID, AGGREGATE> 가 있습니다. 두 인터페이스는 Aggregate<ID> 구현하고 있는 객체만 수용합니다.
먼저 I.AggregateRepository 는 Aggregate 를 저장하고, id 로 조회하는 두 가지 기능이 추상되어있습니다. I.IspSupport 는 스냅샷 개념과 연결된 인터페이스입니다. 사실 Subscription 은 아래와 같이 다양한 인터페이스를 구현하고 있습니다.
위 그림은 아래와 같은 문장으로 이해해 볼 수 있습니다.
Subscription은 해지(Unsubscriber)될 수 있다.
Subscription은 확정(Confirmable)될 수 있다.
Subscription은 깨질(Breakable) 수 있다.
Subscription은 회복(Recoverable) 될 수 있다.
…
스냅샷으로 저장된 Subscription을 특정 상태로 조회할 수 있도록 지원하는 역할이 바로 I.IspSupport의 책임입니다. I.IspSupport 가 I.SubscriptionRepository 를 상속하고 있으므로 다음과 같이 조회가 가능해집니다.
•
Confirmable confirmable = repository.findBy(subID, Confirmable.class);
•
Subscribable subscribable = repository.findBy(subID, Subscribable.class);
간단히 I.AggregateRepository 와 I.IspSupport 를 짚어보았습니다. 두 인터페이스는 <ID> 와 <AGGREGATE>으로 제네릭 프로그래밍되어있습니다. 이 제네릭이 구체화되는 곳이 바로 I.SubscriptionRepository 입니다.
그리고 두 인터페이스의 기능이 구체화되는 곳이 JdbcSubscriptionRepository 입니다.
하지만 해당 객체를 살펴보면 특별이 구체화된 메소드가 보이지 않습니다. 그 기능을 구체화한 GeneralAggregateReposiroy를 JdbcSubscriptionRepository가 상속하고 있는 형태이기 때문입니다. GeneralAggregateRepository 를 살펴볼 의미가 있습니다.
Subscription 이 영속화되기 위해서는 두 가지 단계가 필요합니다.
•
도메인의 Aggregate 를 AggregateRecord(DTO) 로 매핑 시킨다. 이 때 serializer 와 협력한다.
•
AggregateRecord 를 테이블에 저장한다. 이 때 recordRepository 와 협력한다.
간단히 save 메소드만 살펴보겠습니다.
Aggregate 는 serializer 를 거쳐 Record로써 저장되고, 조회 후 realizer 를 거쳐 다시 Aggregate 로 매핑됩니다. 이 과정을 관장하는 역할이 GeneralAggregateRepository입니다.
여기부터 Record 라는 키워드가 등장합니다. Aggregate 가 실질적으로 Record 화 되는 과정이 시작되었기 때문입니다.
I.AggregateRecordRepository 와 구현체 구조를 보면, 막단에는 JdbcAggregateRecordRepository가 Record 로서의 Aggregate 를 저장합니다. 그리고 이 JdbcAggregateRecordRepository 는 Bean이 아닙니다. 해당 객체는 GeneralAggregateRepository 를 구현하는 JdbcSubscriptionRepository 에서 인스턴스화됩니다.
구조가 복잡해서 단편적인 사진만으로는 이해하기 어려우실 수 있습니다.
지금까지 살펴보았던 Aggregate 가 저장되는 흐름은 다음과 같습니다.
•
Aggregate 와 관련된 가장 상위 인터페이스는 I.AggregateRepository 와 I.IspSupport다.
•
I.AggregateRepository 는 persist 기능이 추상화되어있다.
•
Aggregate 를 특정 상태로 조회할 수 있도록 지원하는 것이 I.IspSupport 다.
•
두 인터페이스의 제네릭 <ID, AGGREGATE>이 구체화되는 곳은 I.SubscriptionRepository다.
•
두 인터페이스의 기능이 구체화되는 곳은 JdbcSubscriptionRepository다.
•
기능에 대한 구현은 JdbcSubscriptionRepository이 상속하는 GeneralAggregateRepository 에서 하고있고, 그 구현에 필요한 객체 협력JdbcAggregateRecordRepository은 JdbcSubscriptionRepository 생성자를 통해 생성하고 있다.
•
GeneralAggregateRepository 은 다음과 같은 두 가지 기능을 관장한다.
◦
mapping
▪
serializer(Aggregate → Record)
▪
realizer(Record → Aggregate)
◦
Aggregate persist
userId 로 subscription 조회하고 싶다면?
aggregate는 ID를 통해서만 조회할 수 있습니다. userId 로 조회할 수 있다면 더 편의성이 높아질 것 같다는 생각이 들었습니다. 구독이 유저가 이용가능한 서비스라는 점을 감안하면 더욱 그렇습니다. userId 로 해당 aggregate(구독) 를 조회하고 싶다면 어떻게 해야할까요?
저희는 CQS 패턴을 차용해 command 와 query 를 분리했습니다. user-subscriptions-view 라는 이름으로 유저의 구독 정보를 확인할 수 있는 조회용 테이블을 따로 생성했고 해당 테이블에서는 유저별, 구독 상태별, 해지 예정일 별로 정보를 조회할 수 있습니다. 구독에 대한 command 에 의해 변경된 데이터는 subscriptions 에 저장되고, 구독에 대한 query 는 view 테이블에서 조회하는 식입니다.
하지만 마찬가지로 여기서도 views 라는 컬럼 안에 위에서 저장한 aggregate 에 들어간 값이 그대로 존재합니다. 구독 중심 도메인에서 발생한 이벤트를 구독 view 가 소비하는 형태인데, 이벤트에 view가 필요한 정보만 필터링하여 serialize/deserialize(이하 S/D) 하지않고, aggregate 에 저장 시에 활용한 S/D 모듈을 그대로 사용했습니다. 이것은 view 에서 필요한 정보만을 S/D 할 시간적 여력이 충분치 않았기 때문입니다. 해당 views 는 materialized view 의 정체성을 가지고 있다는 점을 감안하면 추후 추가 정보 확인을 위해 나쁘지 않은 선택이었다는 생각도 듭니다.
serialize 와 deserialize, ObjectMapper 의 대안은?
Aggregate 를 S/D 할 때 사용했던 라이브러리는 ObjectMapper 입니다. CustomObject 를 직접 StdSerializer 와 StdDeSerializer 를 구현하여 objectMapper 의 registerModule 에 세팅해주는 방식으로 구현했습니다.
Mapstruct 를 시도해보기도 했습니다. 간단한 애노테이션으로 매핑되기를 기대했으나 생각보다 설정해야할 부분이 많고, 러닝 커브가 존재했습니다. 하지만 해당 도메인 구현 방식처럼 객체간 매핑이 많이 필요하다면 따로 공부를 해도 괜찮은 대안인 것 같습니다. ObjectMapper 보다 Mapstruct 를 사용하는 것이 코드 양을 줄이는 데에도 도움될 것 같습니다.
Aggregate 를 저장하는 데 있어 구현 방식의 한계점?
해당 방식대로 구현하면서 가장 불편했던 점은 s/d 시 에러 발생 포인트가 많다는 사실입니다. 저희는 NPE 발생을 고려해 위 메소드를 통해 null이 존재할 수 있는 모든 필드를 체크했습니다.
그리고 코드 양이 많아지면서 모든 필드에 대한 S/D 를 구현하기가 귀찮았습니다. 귀찮은 게 대수냐 할 수도 있지만, 유지보수 측면에서 기능대비 추가해야할 포인트가 많을 시 효율이 낮아진다는 점을 고려하면 대수인 것 같습니다..
또한, 해당 아키텍처를 이해하는 데 시간이 다소 걸릴 수도 있을 것 같습니다. 여러 개의 인터페이스와 상속 그리고 구현체의 빅 픽처를 뉴 페이스가 학습해야할 때, 추가적인 유지 관리 비용이 발생할 수 있을 것 같습니다. 데이터가 많아질 때를 대비한 DB 성능 및 유지 관리에 대한 고려가 충분치 않았다는 점도 아쉬운 지점입니다.
마지막, 꼬꼬무
저는 해당 포스팅을 통해 다음과 같은 질문에 답을 얻을 수 있었습니다.
•
aggregate 는 저장한다는 것은 어떤 의미를 내포하는가?
•
aggregate 가 persist 되는 구체적 과정은 어떻게 진행되는가?
•
이와 같은 구현 방식, 기술을 채택하게 된 이유는 무엇인가?
•
더 적합한 기술로 개선할 수 있다면 어떤 것들이 있는가? 해당 기술에 대한 팀 내 숙련도가 어느정도인가?
•
위 과정에서 느꼈던 아쉬운 점, 부족한 점은 무엇인가?
그리고 꼬리에 꼬리를 무는 질문도 얻게되었습니다.
•
처음 개발 할 때, 더 적합해보였던 MongoDB 와 같은 문서 지향 데이터 베이스를 채택했다면 어떻게 됐을까? 팀 내 컨벤션과 기술 적합도 중에서 더 우선시 되는 것은 무엇일까?
•
구독 도메인은 aggregate 로써 좀 더 도메인다워진 것 같다. 이 밖에도 도메인을 도메인답게 만드는 것은 무엇이 있을까? 통일된 언어의 사용? 캡슐화? 우리 팀원들은 도메인을 어떤 방식으로 만들고 싶어할까?
•
해당 코드들은 아이들나라 기술 표준에 맞춰 코프링+JPA 로 변경될 예정이다. 점진적 개선을 한다고 했을 때, JPA 와 JDBC 를 동시에 사용하는 애플리케이션에서 트랜잭션은 어떻게 동작할까?
•
코드를 살펴보다보니 @Transaction 은 서비스 계층에만 붙여져 있는데, 왜 그래야할까? 더하여, 데이터 영속 범위가 넓어지면 어떤 점이 이슈일까? JPA 는 OSIV 를 왜 허용하고 있을까?
긴 글 읽어주셔서 감사합니다.