운영 중인 핵심 도메인 무중단 분리하기

행사와 셔틀 상품이 한 테이블에 혼재되어 있던 핵심 도메인을 무중단으로 분리한 과정을 소개합니다.

목차 · 8

저는 스타트업에서 행사 - 일자 - 노선 구조를 바탕으로 한 셔틀 서비스를 운영해왔습니다.

이 구조는 1년 간 문제 없이 동작했으나, 셔틀 서비스에서 팬덤 특화 서비스로 확장을 하게 되며 한계에 맞닥뜨리게 되었습니다. 이 글에서는 서비스의 핵심 도메인을 무중단으로 분리한 과정을 다룹니다.


기존 구조의 문제점과 한계

가장 먼저 문제가 된 것은 아티스트 상세 페이지였습니다. 유저들에게 최애의 공연 정보를 모아 보여주기 위해 페이지를 신설하게 되었고, 자연스럽게 기존 행사 테이블을 그대로 활용하는 방식으로 구현하였습니다.

그러나 기존 행사 테이블의 행사명은 저작권 이슈로 공식 명칭이 아닌 임의로 간소화한 이름이 들어가 있었습니다. 셔틀 상품 카드에서는 그대로 노출되어도 무방했지만 아티스트 페이지에서는 공식 명칭이 필요했습니다. 호환성을 위해 행사 테이블의 이름과 이미지 칼럼을 표시용과 공식용으로 나누었고, 셔틀을 판매하지 않는 행사들도 다루기 위해 관련 플래그 칼럼까지 추가하게 되었습니다.

여기에 한 행사에 두 종류의 패키지 상품을 분리하여 판매하고자 한 운영측의 요구사항이 추가로 들어왔습니다. 즉, 동일한 행사에 대해 행사 목록에서 두 개의 카드로 보여지고 관리되어야 했습니다.

특정 행사에 한해 예외 처리를 하면 단기적으로는 해결이 가능했으나, 앞으로 비슷한 요구사항이 반복될 것이라 판단하여 장기적인 해결책을 고민하게 되었습니다.

이 두 문제의 근본적인 원인은 결국 셔틀 상품 정보와 행사 정보가 하나의 테이블에 혼재되어 있다는 점이었습니다. 기존에는 행사와 셔틀 상품이 1대1로 대응되었기에 문제가 되지 않았으나, 한 행사에 여러 패키지를 판매하거나 셔틀이 없는 단순 행사가 등장하며 사실상 저희가 행사 목록이라고 부르던 페이지는 셔틀 상품 목록 페이지였다는 것을 깨달았습니다.

서비스 확장을 위해서는 더 이상 임시 처리로 감당할 수 없다고 판단하여 행사와 셔틀 상품을 1대N 관계로 분리하기로 했습니다.


aggregate 분리 구조 결정

가장 먼저 고려한 것은 셔틀 상품을 어느 위치에 둘지였습니다. 기존 구조는 아래와 같았습니다.

기존 구조 ERD
기존 구조 ERD

이상적인 구조는 행사와 셔틀 상품을 완전히 분리하여 셔틀 상품이 자기 자신의 일자 단위까지 가지는 형태였습니다. 행사는 셔틀 상품을 아예 모르고 셔틀 상품이 일방향으로 행사를 참조하는 구조이며, 이를 통해 행사를 기반으로 다양한 서비스들을 자유롭게 확장할 수 있게 됩니다.

이상적인 구조 ERD
이상적인 구조 ERD

그러나 이 구조는 마이그레이션 비용이 컸습니다. 일일 행사를 옮기게 되면 노선, 예약, 알림 등 일일 행사를 참조하던 거의 모든 핵심 로직을 동시에 갈아엎어야 했습니다. 또한 한 행사 하위의 셔틀 상품들은 동일한 일자를 가질 것이기에 일자 정보가 불필요하게 중복된다는 점도 문제였습니다.

최종적으로는 일일 행사를 행사 aggregate에 그대로 두고, 노선만 셔틀 상품을 추가로 참조하는 형태로 타협했습니다. 노선 입장에서 어느 행사의 어느 일자에 운행하는지는 daily_event로, 어떤 셔틀 상품에 속하는지는 shuttle_product로 각각 표현하게 됩니다. 이를 통해 마이그레이션 범위를 노선만으로 좁히면서도 위 두 문제를 모두 해결할 수 있었습니다.

최종 구조 ERD
최종 구조 ERD

데이터 마이그레이션과 ID 재사용

셔틀 상품 테이블에는 셔틀에 해당하던 기존 행사들을 그대로 셔틀 상품으로 복제한 뒤 노선의 새 외래키를 일괄로 채우는 단일 SQL로 백필을 진행했습니다.

이때 기존 행사의 ID를 셔틀 상품의 ID로 그대로 재사용했습니다.

ID를 새로 발급하지 않은 가장 큰 이유는 외부 호환성 때문이었습니다. 서비스 특성상 상품 상세 URL이 X나 커뮤니티를 통해 많이 퍼져있는 상태였습니다. ID를 재사용함으로써 어드민의 행사 상세 URL을 셔틀 상품 상세 URL로 그대로 재사용할 수 있었고, 클라이언트가 캐싱하던 식별자, 외부 연동 시 주고받던 키, 사용자가 공유한 링크가 별도의 매핑 테이블 없이도 자연스럽게 새 도메인을 가리킬 수 있었습니다.

물론 ID를 재사용할 경우 셔틀 상품 ID 자리에 행사 ID를 넣고 조회해도 row가 반환되기에 잘못된 테이블 조회가 통과해버릴 수 있다는 단점도 존재했으나, 저희 서비스 내부에서는 branded type을 바탕으로 두 도메인의 ID를 타입 시스템으로 분리해 섞이지 않도록 하여 문제가 되지 않았습니다.


점진적 분리

셔틀 상품 도입은 매출과 직결되는 핵심 도메인이기에 기존 조회와 결제 흐름이 멈추지 않도록 세 단계로 나누어 진행했습니다.

도입

먼저 셔틀 상품 테이블과 엔티티, 서비스, API를 새로 만들고 백필을 적용했습니다. 이때 기존 행사 API와 갱신 로직은 그대로 살려둔 상태에서, 노선 등록과 수정 use case에서 dual-write를 적용해 application service 레이어에서 두 갱신을 하나의 DB 트랜잭션으로 묶어 행사와 셔틀 상품을 같은 시점에 갱신했습니다.

분리

다음으로 클라이언트가 읽는 쪽을 행사에서 셔틀 상품으로 옮겼습니다. ID를 그대로 재사용했기 때문에 클라이언트 전환은 호출 엔드포인트와 응답 필드명을 바꾸는 수준에 가까웠고, 사용자 입장에서는 깨지는 링크 없이 자연스럽게 새 도메인으로 이동할 수 있었습니다.

클라이언트가 모두 새 도메인으로 옮겨간 뒤에는 행사 테이블에서 셔틀 상품 성격의 칼럼들과 관련 인덱스를 일괄로 드랍했고 dual-write 코드도 함께 제거했습니다. 노선과 통계, 알림 등에서 행사를 참조하던 코드들도 모두 셔틀 상품 기반으로 교체했습니다.

정리

마지막으로 분리 과정에서 임시로 두었던 속성들을 정리했습니다. 백필을 위해 nullable로 두었던 노선의 셔틀 상품 외래키를 NOT NULL로 변경하고 분리 도중 양쪽에 남아 있던 임시 플래그도 제거했습니다.

또한 행사에는 분리 직후에도 운영 준비 상태를 의미하던 STAND_BY가 남아 있었는데, 해당 상태도 사실 셔틀 상품 성격이기에 이를 셔틀 상품 상태로만 유지하고 행사 상태는 축소시켰습니다.

마지막으로 셔틀 상품을 행사/일자/노선이 가지고 있던 라이프사이클 안에 추가시켰습니다. 다행히 기존에 해당 라이프사이클을 도메인 인과 방향으로 리팩토링 해두어, 셔틀 상품은 단순하게 기존 스케줄러에 한 줄을 추가하고 종료 조건을 평가하는 use case를 새 파일로 더하는 정도로 확장할 수 있었습니다.


마무리

이 작업을 통해 앞으로 서비스가 다양한 기능들로 확장할 수 있는 기반을 다질 수 있었습니다.

운영팀에서도 이번 변경사항을 매우 직관적으로 받아들이고 빠르게 적응하는 모습을 보며 더욱 필요했던 개선이라고 느꼈습니다.

이전 글
상위 도메인이 하위를 제어하던 cascading 뒤집기