← posts/b.log()

blog92@web:~$ cat posts/backend-antipatterns-10-shared-database-entity-service.md

BACKEND8 min read

Shared Database와 Entity Service — 경계를 데이터로 그을 때

컬럼 하나를 못 바꾸는 공유 DB와 명사로 쪼갠 Entity Service의 함정. 경계를 변경 이유로 긋고 데이터 중복을 결합도의 값으로 치르는 법.

컬럼 하나를 못 바꾼다

orders.memo 컬럼을 VARCHAR(200)에서 TEXT로 바꾸려 한다. 마이그레이션은 한 줄이다. 그런데 배포가 막힌다.

정산 서비스가 그 테이블을 직접 읽고, 관리자 도구가 거기에 UPDATE를 하고, 야간 배치가 SELECT *로 전량을 긁어간다. 누가 무엇을 읽는지 아는 사람이 없어서 확인에 사흘이 걸린다.

스키마가 공용 API가 됐는데, 아무도 그것을 API로 설계하지 않았다.

이게 Shared Database다. 같은 실수의 다른 얼굴이 하나 더 있다. 서비스 목록이 UserService, OrderService, ProductService, CouponService이고, "쿠폰을 적용해 주문을 생성한다"는 유스케이스 하나가 네 서비스를 모두 거친다. Entity Service다.

둘을 한 편에 묶는 이유는 원인이 같기 때문이다. 둘 다 서비스 경계를 데이터 구조로 그었다. 하나는 테이블을 공유하는 방식으로, 하나는 테이블을 서비스 이름으로 승격하는 방식으로.

원칙을 어기는 데는 이유가 있다

"서비스마다 자기 DB를 가져야 한다"는 규칙은 널리 알려져 있다. 그런데도 Shared Database가 계속 나타나는 것은 규칙을 몰라서가 아니라 규칙을 지키는 비용이 눈에 보이기 때문이다.

조인이 필요하다. 주문 목록에 구매자 이름을 붙여야 한다. 같은 DB라면 INNER JOIN 한 줄이다. 나누면 주문 100건에 사용자 조회 100번이거나(8편의 Chatty Interface), 배치 조회 API를 새로 만들거나, 이름을 복제해야 한다.

일관성이 필요하다. 같은 트랜잭션에서 재고를 차감하고 주문을 쓰면 원자성이 공짜다. 나누면 분산 트랜잭션 문제가 생기고, 그건 11편에서 보듯 공짜가 아니다.

정규화 교육이 반대 방향을 가리킨다. 관계형 데이터베이스 훈련은 중복을 제거하라고 가르친다. 사용자 이름이 두 테이블에 있으면 설계 오류다. 이 감각이 서비스 경계에도 그대로 적용된다.

Entity Service도 합리적인 출발을 갖는다. 도메인 모델에 User, Order, Product가 있으니 서비스도 그렇게 나누면 깔끔해 보인다. 소유 주체가 헷갈리지 않고 이름 짓기도 쉽다. 명사는 세기 쉽고 유스케이스는 세기 어렵다.

경계를 데이터로 그으면 무엇이 무너지는가

Shared Database에서 무너지는 것은 배포 독립성이고, 그것도 DDL 시점에 한 번에 무너진다.

애플리케이션 코드는 서로 다른 리포지토리에 있고 파이프라인도 따로 있다. 그런데 스키마 변경은 모든 읽기·쓰기 주체에 동시에 영향을 준다. 컬럼 이름 하나를 바꾸는 데 N개 서비스의 코드 수정과 배포 순서 조율이 따라온다. 9편에서 측정한 동시 배포 빈도가 여기서 올라간다.

Entity Service에서 무너지는 것은 다른 쪽이다. 유스케이스 하나가 서비스 K개를 거치면 두 가지가 K에 비례한다.

  1. 배포 조율 대상이 K개. 쿠폰 정책이 바뀌면 네 서비스가 함께 나간다. Distributed Monolith다(9편).
  2. 가용성이 곱셈. 각 가용성이 99.9%일 때 K=4면 0.999⁴ = 0.99601, 즉 99.6%다. 연간 다운타임은 8,760시간 × (1 − 0.99601) = 35.0시간으로, 단일 서비스의 8.76시간 대비 4배다.

여기에 꼬리 지연이 더해진다(7편, 12편). 네 번의 동기 호출이 직렬이면 지연은 더해지고 가장 느린 구간이 전체를 지배한다.

더 근본적인 문제는 어떤 기능도 한 서비스 안에서 끝나지 않는다는 것이다. 서비스는 네 개인데 기능 하나를 이해하려면 네 개를 다 읽어야 한다. 경계가 있지만 아무것도 감싸고 있지 않다.

Shared DatabaseEntity Service
경계 기준테이블을 공유테이블 이름을 서비스 이름으로
무너지는 것DDL 시점의 배포 독립성유스케이스의 국소성
비용 형태스키마 변경 한 번에 N개 조율기능 하나에 K개 호출·K개 배포
겉보기중복 없는 데이터대칭적인 서비스 목록

경계는 명사가 아니라 변경 이유로 긋는다

대안의 축은 세 가지이고, 셋은 같은 말의 다른 표현이다.

비즈니스 능력(business capability). 조직이 고객에게 제공하는 단위로 나눈다. "주문 이행", "가격 책정", "정산". 명사가 아니라 하는 일이다.

Bounded Context. Eric Evans가 Domain-Driven Design(2003)에서 제시한, 하나의 모델이 일관된 의미를 갖는 범위다. 핵심은 같은 단어가 맥락마다 다른 것을 가리켜도 된다는 허용이다. 배송 맥락의 "주문"은 주소와 무게를 갖고, 정산 맥락의 "주문"은 금액과 세금을 갖는다. 이 둘을 하나의 Order 테이블로 합치려는 압력이 Shared Database를 만든다.

변경 이유의 동일성. 가장 실용적인 기준이다. 함께 바뀌는 것을 함께 둔다. 9편의 자카드 계수가 사후 측정 도구였다면, 사전에 쓸 방법은 유스케이스를 나열하고 각각이 몇 개 경계를 넘는지 세는 것이다.

유스케이스 20개를 적고 각각에 "이 기능을 구현하려면 몇 개 서비스를 수정해야 하는가"를 적는다. 평균이 1.5 이하면 경계가 맞는 편이고, 3을 넘으면 경계가 유스케이스를 가로지르고 있다.

데이터 중복은 결합도를 사는 값이다

이 편의 전환점은 여기다.

정규화는 하나의 데이터베이스 안에서 성립하는 원칙이지, 서비스 경계를 넘는 원칙이 아니다.

정규화가 해결하는 문제는 갱신 이상(update anomaly)이다. 같은 사실이 여러 곳에 있으면 한쪽만 바뀌어 불일치가 생긴다. 단일 트랜잭션 경계 안에서는 심각한 문제지만, 경계를 넘으면 다르다. 애초에 원자적으로 갱신할 수 없고, 무엇보다 각 서비스가 필요로 하는 것은 같은 사실이 아니다.

정산이 필요한 건 "결제 시점의 구매자 이름"이고, 사용자 서비스가 가진 건 "현재의 이름"이다. 사용자가 개명해도 과거 주문의 이름은 바뀌면 안 된다. 이때 복제는 중복이 아니라 서로 다른 데이터다.

정산 서비스가 주문·사용자 테이블을 직접 조인하던 코드를 보자.

ts
// Before — services/settlement/src/report.ts
// 주문 서비스와 사용자 서비스의 테이블을 같은 DB에서 직접 조인한다
import { orders, users } from '@acme/common/schema'   // 남의 테이블 스키마를 import
 
export async function monthlyReport(from: Date, to: Date) {
  return db
    .select({ orderId: orders.id, amount: orders.amount, buyer: users.name })
    .from(orders)
    .innerJoin(users, eq(orders.userId, users.id))
    .where(and(gte(orders.paidAt, from), lt(orders.paidAt, to)))
    // 주문 팀이 컬럼명을 바꾸면 깨진다. 그런데 그들은 이 쿼리의 존재를 모른다.
}

탈출은 정산 서비스가 자기 DB에 읽기 모델을 갖는 것이다. 주문 이벤트를 받아 자기 스키마에 필요한 필드만 저장한다.

ts
// After — services/settlement/src/read-model.ts
export const settledOrders = pgTable('settled_orders', {
  orderId: text('order_id').primaryKey(),
  amount: numeric('amount', { precision: 12, scale: 2 }).notNull(),
  buyerName: text('buyer_name').notNull(),   // 결제 시점의 이름을 그대로 고정한다
  paidAt: timestamp('paid_at').notNull(),
})
 
// 주문 서비스가 발행한 이벤트를 자기 모델로 번역해 저장 (Anticorruption Layer)
export async function onOrderPaid(e: OrderPaidEvent) {
  await db.insert(settledOrders).values({
    orderId: e.orderId, amount: e.amount, buyerName: e.buyer.name, paidAt: e.paidAt,
  }).onConflictDoNothing()   // 재전달에 대비한 멱등 삽입 (11편)
}
 
// 자기 테이블만 읽는다. 주문 팀의 DDL이 이 쿼리를 깨지 않는다.
export const monthlyReport = (from: Date, to: Date) =>
  db.select().from(settledOrders)
    .where(and(gte(settledOrders.paidAt, from), lt(settledOrders.paidAt, to)))

onConflictDoNothing()이 붙은 이유는 이벤트가 중복 전달되기 때문이고, 왜 반드시 그런지는 11편에서 다룬다.

Evans가 같은 책에서 정리한 Context Mapping은 이 관계를 유형으로 나눈다. 일부 모델을 공유하는 Shared Kernel, 상류가 하류의 요구를 반영하기로 합의한 Customer-Supplier, 상류의 모델을 그대로 받지 않고 자기 언어로 번역하는 Anticorruption Layer. 위 코드의 onOrderPaid가 마지막 것이다. 어떤 관계를 맺을지 정하지 않으면 기본값은 언제나 Shared Database다.

탈출의 대가: "잠깐 다를 수 있다"를 제품 요구사항으로 만들기

읽기 모델 복제의 대가는 명확하다. 최종 일관성을 받아들여야 한다.

이벤트 전달에 200ms가 걸린다면 그 200ms 동안 정산 리포트는 방금 결제된 주문을 보지 못한다. 릴레이가 5초 지연되면 5초 동안 다르고, 장애 중이라면 더 길다.

기술적으로는 해결된 문제지만 조직적으로는 아니다. 누군가 "실시간으로 맞아야 한다"고 말하는 순간 복제 설계는 무너지고 다시 조인으로 돌아간다. 이 탈출의 진짜 작업은 코드가 아니라 협상이다. 허용 지연을 숫자로 합의하는 것 — "정산 화면은 최대 30초", "재고 표시는 최대 2초" — 이게 안 되면 복제는 실패한다.

두 번째 대가는 운영이다. 이벤트 핸들러가 서비스마다 늘고, 복제가 어긋났을 때 맞추는 재동기화가 필요하다. 원본에서 전량을 다시 읽어 읽기 모델을 재구축하는 경로를 미리 만들어두지 않으면, 한 번 어긋난 뒤에 손으로 고치게 된다.

판단 기준 — 같은 DB를 봐도 되는 경우

읽기 전용 리포팅과 분석. 데이터 웨어하우스나 리포팅 레플리카가 여러 서비스의 데이터를 한곳에서 조인하는 것은 정상이다. 쓰기가 없으므로 스키마가 양방향 계약이 되지 않고, 깨져도 리포트만 멈춘다. 다만 레플리카는 운영 DB와 분리하고, 스키마 변경 시 리포트가 깨질 수 있음을 리포트 소유자가 알아야 한다.

서비스가 2~3개인 초기. 인원이 한 자리 수이고 스키마 변경을 슬랙 한 줄로 합의할 수 있다면, 읽기 모델 복제의 설계·운영 비용이 얻는 것보다 크다. 이 단계에서는 같은 DB를 쓰되 스키마를 모듈별로 분리하고(PostgreSQL의 스키마 네임스페이스), 서비스별 DB 계정에 자기 테이블 권한만 주는 편이 실용적이다. 경계를 권한으로 먼저 긋는 것이다.

소유자가 하나인 공유. 한 팀이 두 서비스를 모두 소유하고 함께 배포한다면 조율 비용이 거의 없다. 다만 이건 "서비스가 두 개"가 아니라 "모듈이 두 개"라고 부르는 게 정확하고, 9편의 Modular Monolith가 그 이야기다.

요약

항목내용
공통 원인서비스 경계를 데이터 구조(테이블·엔티티 명사)로 그음
Shared Database스키마가 설계되지 않은 공용 API가 됨. DDL 한 번에 N개 서비스 조율
Entity Service유스케이스가 K개를 횡단. K=4·각 99.9%면 99.6%, 연 35시간 다운
초기 매력조인이 공짜, 원자성이 공짜, 중복 없음, 서비스 이름이 명확
대안 축비즈니스 능력, Bounded Context(Evans, 2003), 변경 이유의 동일성
진단유스케이스 20개가 각각 몇 개 경계를 넘는지 세기. 평균 3 초과면 재설계
탈출Context Mapping으로 관계 결정 → 읽기 모델 복제 → 이벤트 전파(11편)
탈출의 대가최종 일관성. 허용 지연을 숫자로 합의 못 하면 실패. 재동기화 경로 필수
정답인 경우읽기 전용 리포팅 레플리카, 서비스 2~3개, 소유자가 하나인 공유

다음 편 — 11편. Dual Write와 2PC의 유혹 — 분산 트랜잭션 안티패턴

이번 편에서 "이벤트로 전파한다"고 쉽게 적었지만, 그 이벤트를 어떻게 발행하는지가 다음 문제다. DB에 저장하고 메시지를 발행하는 다섯 줄짜리 코드가 왜 어떤 순서로도 원자적일 수 없는지 단계별로 따져보고, 2PC가 후퇴한 이유와 Saga가 성립하지 않는 연산이 무엇인지 본다. 탈출 경로는 Transactional Outbox이고, 그것이 5편의 DB-as-IPC와 어떻게 다른지도 분명히 해둔다.

COMMENTS (…)

댓글을 불러오는 중이에요.

NEW COMMENT0 / 1000
⌘↵ 전송

blog92@web:~$ cd ..