Dev Log
수정·

[SNS 개발] 그룹 도메인에 대한 생각

by Dohoon Kim · 24년 12월 27일 03:42:14

그룹 도메인에 대한 생각

개발중인 SNS는 개인간의 메신저가 아닌, 그룹 단위로 메시지가 전송되는 일종의 단톡방과 유사한 서비스이다. 우선 그룹 단위의 알림 서비스로 출시를 해서 사용자를 확보한 뒤 만들고 싶었던 다른 서비스들을 추가해보고 싶은 생각에 만들기 시작했다.

그렇기 때문에 도메인을 크게 네가지로 구분 했었다.

  • 인증 도메인: 회원의 인증 정보를 생성, 관리하며 로그인 등의 비지니스 로직을 처리하는 서비스 도메인
  • 회원 도메인: 가입한 회원의 정보 및 노티피케이션을 받을 수 있는 디바이스 토큰 등을 관리하는 서비스 도메인
  • 그룹 도메인: 회원이 각각의 그룹에 가입하여 알림을 받아볼 수 있도록 하는 핵심 도메인
  • 메시지 도메인: 회원의 디바이스에 직접적으로 발송되어야 하는 Push Notification을 발송하는 역할을 수행하는 도메인

개발을 진행한지 3개월이 넘었고, 디자이너가 구해지지 않아 정체된 상태이고 기술적으로도 공부할 부분이 아직도 많이 남은 상태이다. 그 중에 DDD 패턴을 제대로 적용한 것인지 의문이 들어 이 글을 쓰게 된다.

최초의 컨텍스트 분할

최초 설계 당시 필요한 정보를 위와 같이 간단하게 표현했었다. 보통 DDD에서 컨텍스트를 구분할 때 어떤 엔티티가 있고, 그 엔티티와 수명주기가 일치하거나 비슷한 엔티티들을 묶어 컨텍스트를 구성한다고 한다. 물론 관점에 의한 분할도 있기는 하지만 우선은 그렇게 구분하는게 가장 쉽다.

수명 주기가 일치하는 경우는 생각하기가 쉬웠다. 어떤 한 엔티티가 생성될 때, 필수 불가결하게 동시에 생성되고 그 엔티티가 삭제되면 함께 삭제되어야 하는 것들이다.

예를 들어 배송이라는 도메인을 생각해보자. 배송 도메인에는 배송 물품, 배송 요청 등록일자, 배송 요청자, 배송 수취인 등이 있을 것이다. 현실에서 택배를 발송하기 위해 우체국 등을 방문하거나 택배사 요청을 하게되면 누가 보내는지, 누가 받을 것인지, 무엇을 보내는 것인지 그리고 이 모든것들을 관리할 하나의 엔트리포인트 배송 정보 자체가 형성되게 될 것이다.

이 세가지 요소들은 동시에 생성된다. 배송, 배송 수취인, 배송 요청자, 배송 물품 과 같은 정보는 수명 주기를 함께한다고 볼 수 있다. 배송을 취소하면 이 모든게 사라지고, 배송 요청을 하면 이 모든게 함께 생성된다.

수명 주기가 유사한 경우는 어떨까?

배송을 담당하는 배송 기사의 경우에는 어떨까? 시스템에 따라 다르겠지만, 배송 정보가 등록되고 배송 기사가 그걸 픽업하는 방식이라면 비동기적으로 배송 담당 기사가 선택될 것이다. 다만 배송이 완료되거나 취소되면 배송 기사 정보도 그 수명을 함께할 것이다. 현실의 예를 좀 생각해보다가, 수명 주기가 유사하다는 것은 루트 어그리거트가 삭제될 때 함께 삭제되는 정보라고 생각을 했다.

그렇다면 위 그림을 살펴보면

인증 정보는 사실 회원 정보와 수명이 일치한다. 다만 인증을 위한 정보는 회원 정보와 교집합도 충분히 존재하지만 바라보는 핵심 비지니스 관점이 다르다고 생각해서 분할의 대상이라고 판단했다. 사용자의 JWT 토큰 등은 회원가입을 한다고 반드시 생성될 필요는 없지 않은가?

반면 회원 정보에서 회원의 디바이스 토큰은 메시징이 핵심인 서비스에서 사용자가 등록되면 반드시 함께 등록되어야하는 필수적인 부분이다. 그래서 웹 기반 로그인은 고려해도 웹을 통한 회원가입은 고려조차 하지 않았다.

메시지의 경우에도 회원이 받은 알람 목록은 가입한 그룹을 통해 발생하는 메시지 외에도 서비스에 의해 발행된 메시지들이 있을 것이고 이는 회원이 생성되는 타이밍과 별개로 생성된다. 특정 회원에게 발송된 메시지가 꼭 삭제되어야하는가? 하는 부분도 의문이 있었고...

문제는 알림 그룹이다.

알림 그룹은 다른 엔티티들과 상호 작용이 굉장히 많다. 우선 알림 그룹을 생성할 때 반드시 함께 생성되는 것은 그룹 소유자이다. 그룹 소유자는 회원 도메인과 연관이 있다.

반면 그룹 소유자와 함께 그룹을 관리할 그룹 관리자들은 그룹에 우선 가입을 하고 나서야 생성될 수 있다. 그룹 알림은 그룹과 동시에 생성되는 요소는 아니고 시간이 지남에 따라 그룹 회원에 의해 생성되는 것들이다. 그룹 회원 역시 그룹이 생성될 때는 그룹 관리자를 회원으로 포함한다면 한명이 생성되지만 모든 그룹 회원이 그룹 생성시에 함께 가입되는 것은 아니다.

하지만 이 모든 정보는 그룹 회원들과 함께 삭제되어야 한다.

이를 기반으로 도메인을 분할하였고 이렇게 분할이 되었다.

깊게 생각하지 않아도 보이는 문제점들

DDD 패턴의 경우 일반적으로 커맨드성 요청에 치우친 측면이 없지않아 있다. 보통 DDD 패턴을 이야기 하면서 드는 예의 경우 어그리거트 루트에 속한 엔티티들의 갯수가 제한된 형태가 많다. 그런데 위와 같이 도메인을 분할 해두고 보니 이런저런 문제가 보이기 시작했다.

  1. 인기가 많은 그룹의 경우 그룹 사용자가 수백만명에 이를 수 있다
  2. 오래된 인기가 많은 그룹의 경우 그룹 노티피케이션이 엄청나게 많을 수 있다.

위 두 예시가 대표적이었다.

헥사고널 아키텍처를 도입하면서 도메인 엔티티와 영속성 엔티티를 나누는 방식을 선택했고, 거기에 DDD 패턴을 적용한 형태였는데 최적화 관련 문제가 보이기 시작했다. 1의 예에서 쿼리야 페이지네이션 처리를 한다고 치자. 커맨드 요청은 어떻게 처리해야하는가? 단일 레포지토리 패턴을 권장하는 DDD 패턴을 따른다면...? 모든 그룹 회원과 모든 그룹 노티피케이션을 로드하게 되면 문제가 발생한다. 더군다나 Mapper를 통해 생성자 기반 매핑을 하는 방식이었는데 JPA와도 여러 문제가 발생했따다.

그룹을 어그리거트 루트로 설정했을 때 발생하는 문제를 생각해보자

한 멤버 객체가 128 바이트를 차지한다고 가정해보자. 그런데 여기에 한 그룹에 대한 커맨드 요청을 하는데 10만명의 멤버가 로드된다고 생각하자. JPA에 저장된 1차 캐시를 포함하여 거의 250Mb 에 해당하는 메모리를 한 요청에 할당해야한다. 문제가 안생길 수가 없다. 거기에 알림 게시물까지 모두 로드한다면 당연히 문제가 발생할 것이다.

여러 고민

우선 현재 사용하고 있는 방식은 JPA의 모든 연관관계 설정을 하지 않고 정말로 영속화만 담당하게 사용하는 것이다. 한 트랜잭션에 하나의 어그리거트만을 수정하라는 DDD의 권장 사항에 따라 수정 요청 대상에 해당하는 엔티티들만 로드를 시켜 Mapper를 통해 도메인 객체로 변환하고 비지니스 로직을 수행한 뒤 다시 저장하는 과정을 거친다. 편의를 위해 도메인 엔티티 캐시 등을 만들어 사용하고 있기는 하지만... 이게 맞나? 라는 생각이 많이 든다.

처음엔 JPA의 1차 캐시와 더티 체킹을 이용해서 최대한 편하게 개발을 하고 싶었지만, 세션 문제가 발생했고
엔티티 매니저를 직접 조작하여 그룹을 DETACH 상태로 만드는 등의 작업을 수행해야했다. 이럴거면 뭐하러 JPA를 사용하나...? 싶은 생각이 들었다.

그래서 그룹 노티피케이션을 어그리거트 루트로 정한 별도의 도메인으로 분할 하는 것은 어떨까 라는 생각을 하였다. 작성자도 VO, 그룹 노티피케이션의 그룹 정보 등으로 분할을 한다면...? 커맨드 작업 시에 필요한 용량은 획기적으로 줄어들 것이다.

그런데 수명주기 측면에서 노티피케이션이 삭제되면 작성자 정보야 삭제할 수 있다지만 그룹 정보가 삭제되나? 그건 또 아니다. 그룹 회원도 동일한 전략을 선택할 수 있기는 한데 여기까지 생각해보니 이건 DDD가 아니라 Entity Driven Design 아닌가? 라는 생각이 들었다.

실제로 DDD를 적용한 예시들을 살펴보니 어그리거트 루트에 연관된 엔티티가 굉장히 많은 경우 다음과 같은 방식을 적용한 것을 보았다.

  1. 연관 엔티티를 별도로 분할
  2. 필요한 데이터만 로딩

둘 다 위에 언급했듯 고려한 사항이다. 1은 DDD에서 말하는 컨텍스트 분할의 방식과 충돌이 생기는 것 같아 2의 방식을 선택하였다.

선택한 방식에 필요한 추가 개발한 것들

현재 적용한 방식은 JPA를 영속성 레이어에서 사용하되 비지니스 로직은 도메인 엔티티로 변환하여 수행하는 방식을 선택했다. JPA는 모든 연관관계를 끊고, 도메인 엔티티(어그리거트 루트) 에 필요한 연관관계를 일일히 직접 로딩하여 설정하는 방식을 선택했다. JPA 1차 캐시와 더티 체킹을 이용하고 싶었지만 Spring Data JPA를 이용하면 생성자 매핑 방식을 이용하는 매퍼를 이용하는 현재 상태에선 도저히 깔끔한 해결 방법이 떠오르지 않았다. EntityManager 를 직접 조작해도 결국 비슷한 문제가 발생했다.

영속성 그룹 엔티티를 도메인 그룹 엔티티로 변환하는 과정에서 JPA에 그룹 멤버가 연관관계로 지정되어있다면, 이를 도메인 그룹 멤버 엔티티로 변환하는 과정에서 Lazy Loading 상태의 연관관계가 참조되며 쿼리가 발생하고 어플리케이션 측면에서도 메모리 부하, 데이터 베이스 측면에서도 대량의 데이터를 조회해야하는 부하가 발생하게 된다. 그렇다고 연관된 엔티티만 조회하는 방식을 하려면 orphanRemoval 등의 feature를 이용할 수 없고, 그룹에서 멤버를 제거하기 위해 그룹 엔티티의 멤버 필드에서 어떤 멤버를 삭제하더라도 이를 추적할 방법을 별도로 마련해야한다. JPA를 꼭 사용해야한다면 도메인 엔티티에서 영속성 엔티티로 변환하는 과정에서 우선 Entity Manager를 이용하여 1차 캐시에 저장된 JPA 엔티티를 먼저 검색하고, 존재한다면 이 객체를 가져와서 필드를 수정하는 방식을 선택할 수는 있다. 하지만 이 방식을 선택하더라도 orphanRemoval을 사용하면 문제가 발생해서 그냥 깜끔하게 포기했다.

엔티티마다 연관 객체의 삭제된 객체를 저장하는 별도의 컬렉션을 만드는 방법도 있지만, 뭔가 깔끔하지 못해서 결국 JPA의 엔티티 메니저와 비슷하게 도메인 엔티티 캐시 클래스를 만들었다. 해당 캐시는 트랜잭션 스코프에 생성되고, 스레드 로컬을 이용하여 로드된 모든 도메인 객체를 저장하고 관리하는 역할을 한다. 어떤 도메인 객체를 저장하기 전에 이 캐시를 참조하여 이미 로드되었고, 현재의 엔티티가 캐시된 내역과 다르다면 Update를 그렇지 않다면 Insert를 하는 방식을 사용하고 있다.