← posts/b.log()

blog92@web:~$ cat posts/backend-design-patterns-16-choosing-patterns.md

BACKEND8 min read

패턴을 고르는 법 — 교환 조건의 계산

충돌하는 힘을 숫자로 적고, 팔 권한을 확인하고, 되돌리기 비용으로 정렬하는 선택 절차. 패턴이 안티패턴이 되는 세 경로와 판 것을 남기는 ADR. 시리즈 완결.

카탈로그는 선택을 대신해주지 않는다

앞의 15편에서 마흔 개 남짓한 패턴을 지나왔다. 각각의 형태를 알고, 각각이 무엇을 내주는지도 봤다. 그런데 월요일에 자기 코드베이스를 열면 질문은 그대로 남아 있다.

이 코드에 지금 무엇을 넣어야 하는가.

패턴을 아는 것과 고르는 것은 다른 기술이다. 전자는 카탈로그를 읽으면 되고, 후자는 절차가 필요하다. 이 마지막 편은 그 절차를 쓴다. 새 패턴은 나오지 않는다.

이름이 아니라 힘에서 시작한다

패턴 선택이 취향 싸움으로 끝나는 대화는 대개 이렇게 시작한다. "여기 CQRS 쓰죠." 이름이 먼저 나오면 그다음 대화는 그 패턴의 찬반이 되고, 정작 무엇을 풀려는지는 합의된 적이 없다.

순서를 뒤집어야 한다. 먼저 충돌하는 요구를 적는다.

Alexander가 패턴을 기술할 때 해법보다 먼저 쓴 것이 그 맥락에서 서로 당기고 있는 힘들(forces)이었다. 힘을 적는 일은 어렵지 않고, 적고 나면 후보가 저절로 줄어든다.

쓰기는 불변식 때문에 애그리게이트 단위로 좁게 로드해야 하는데, 화면은 세 애그리게이트를 조인해서 보여줘야 한다. 조회가 쓰기보다 40배 많다.

여기까지 적으면 CQRS가 후보에 오르는 이유가 분명해지고, 동시에 읽기 모델 복제만으로 충분한지도 같이 검토 대상이 된다. 이름에서 출발했으면 나오지 않았을 선택지다.

힘을 적을 때 지켜야 할 것 하나. 각 힘에 숫자를 붙인다. "조회가 많다"가 아니라 "조회:쓰기 = 40:1"이다. 숫자가 없으면 힘의 크기를 비교할 수 없고, 비교할 수 없으면 결국 목소리 큰 쪽이 이긴다.

무엇을 팔 수 있는지 먼저 정한다

1편에서 백엔드 패턴이 거래하는 세 통화 — 간접성, 일관성, 중복 — 를 세웠다. 선택 절차의 두 번째 단계는 그중 무엇을 팔 권한이 있는지 확인하는 것이다.

이게 순서상 앞에 와야 하는 이유는, 팔 수 없는 것을 파는 패턴은 아무리 잘 맞아 보여도 후보가 아니기 때문이다.

파는 것누가 동의해야 하는가동의가 없으면
간접성 (추적 가능성)팀 내부팀이 코드를 읽기 힘들어할 뿐, 되돌릴 수 있다
일관성 (즉시 정합성)제품·CS·때로는 법무도입 후 "왜 바로 안 보이냐"는 문의가 CS로 간다
중복 (단일 진실 공급원)데이터·분석 조직지표가 두 곳에서 다르게 나오고 신뢰가 무너진다

일관성과 중복은 코드 밖의 동의가 필요하다. Saga(11편)를 도입하면서 "주문은 됐는데 재고에 아직 반영 안 된 5초"를 제품 담당자와 합의하지 않았다면, 그 패턴은 기술적으로 완벽해도 실패한다. 실패는 배포 후 3주쯤 뒤 CS 티켓 형태로 도착한다.

패턴 도입에서 가장 자주 빠지는 단계가 이것이다. 코드는 준비됐는데 조직이 대가를 지불할 의사가 없는 상태.

되돌리기 비용으로 정렬한다

세 번째 단계가 실무에서 가장 쓸모 있다. 후보 패턴들을 틀렸을 때 되돌리는 비용으로 줄 세운다.

되돌리기 비용패턴되돌릴 때 건드리는 것
싸다 (시간~일)Strategy·Decorator(6편), DTO(7편), Timeout·Retry(13편), Feature Toggle(14편)코드 몇 파일
중간 (주)Repository·Unit of Work(3편), Ports and Adapters(4편), BFF(7편), 메시징 도입(8편)코드 구조 전반, 팀 합의
비싸다 (분기~년)서비스 경계(10편), Database per Service(10편), Saga(11편), Event Sourcing(12편)데이터 마이그레이션, 조직 구조, 외부 계약

이 표에서 따라 나오는 규칙은 단순하다.

싼 것은 논의하지 말고 해본다. 비싼 것은 최대한 늦게 결정한다.

되돌리기 싼 패턴에 회의 두 시간을 쓰는 것은 그 자체로 손해다. 넣어보고 두 주 뒤에 판단하는 비용이 논의 비용보다 싸다. 반대로 서비스 경계는 한 번 그으면 코드 리팩터링이 아니라 데이터 이전과 팀 재편이 된다. 여기서 아낀 논의 한 시간이 나중에 분기 단위로 청구된다.

이 규칙은 YAGNI와 충돌하는 것처럼 보이지만 실은 YAGNI의 적용 범위를 정한다. YAGNI는 되돌리기 싼 영역에서만 안전한 원칙이다. 나중에 필요해지면 그때 넣으면 되는 것에는 지금 넣지 않는 게 맞다. 하지만 나중에 넣는 비용이 지금의 열 배인 결정에는 같은 논리를 적용할 수 없다. 데이터 모델과 서비스 경계가 그 영역이다.

패턴을 통째로 살 필요는 없다

선택을 이분법으로 놓는 것도 흔한 실수다. Repository를 "쓴다/안 쓴다"로 보면 중간 단계가 사라진다. 실제로는 대가를 조금만 내고 이득의 일부를 사는 지점이 거의 항상 있다.

ts
// db/queries/order.ts — 인터페이스도 구현체도 없이, 쿼리만 한곳에 모은다
export async function findOrderById(db: Db, id: string) {
  const rows = await db.select().from(orders).where(eq(orders.id, id)).limit(1)
  return rows[0] ?? null
}
 
export async function findOpenOrdersByCustomer(db: Db, customerId: string) {
  return db.select().from(orders)
    .where(and(eq(orders.customerId, customerId), eq(orders.status, 'open')))
}

인터페이스가 없으니 구현체도, 테스트 더블도, 매핑 함수도 없다. 1편에서 센 "엔티티당 파일 3~5개"가 파일 하나로 줄어든다. 얻는 것도 명확하다. 쿼리가 한곳에 모여서 중복이 보이고, 나중에 인터페이스를 뽑을 때 뽑을 자리가 이미 정해져 있다.

Repository가 파는 것 중 "DB를 모르는 도메인"과 "DB 없는 단위 테스트"는 못 산다. 하지만 그게 지금 필요하지 않다면, 이 버전이 정확히 필요한 만큼만 지불한 상태다.

같은 눈금이 다른 패턴에도 있다. CQRS는 같은 DB에서 코드 경로만 가르는 1단계가 있고(12편), 계층 분리는 DI 컨테이너 없이 함수 인자로 의존을 주입하는 최소형이 있고(4편), 메시징은 브로커 없이 DB 테이블과 폴링으로 시작하는 형태가 있다. 패턴을 도입할 때 던져야 할 질문은 "쓸 것인가"가 아니라 "몇 퍼센트를 살 것인가"다.

패턴이 안티패턴이 되는 세 가지 경로

앞 시리즈 16편과 이 시리즈 16편이 만나는 자리다. 잘 알려진 패턴이 나중에 문제로 불리게 되는 경로는 세 가지뿐이다.

첫째, 가격표를 안 보고 샀다. 이득만 듣고 도입한 뒤 비용이 도착한다. 1편에서 다룬 전파 과정의 문제다.

둘째, 맥락이 바뀌었는데 그대로 남았다. 도입 당시에는 옳았고 지금은 아니다. 앞 시리즈 전체가 이 경로를 추적했다. 이건 도입자의 잘못이 아니라 기록의 부재다.

셋째, 이름만 사고 구조는 반만 넣었다. 실무에서 가장 흔하고, 가장 진단하기 어렵다.

반만 산 것빠진 것결과
RepositoryUnit of Work(3편)유스케이스 하나가 부분 커밋된다
서비스 분리Database per Service(10편)배포만 쪼개진 시스템
계층 구조의존 방향 규칙(4편)아무것도 안 하는 계층이 쌓인다
이벤트 발행Outbox·멱등 소비자(11편)조용히 유실되고 중복 처리된다
Circuit Breaker설정값 튜닝·관측(13편)정상 트래픽에서 회로가 열린다

세 경로 모두 코드 리뷰로는 잡히지 않는다. 각 줄은 멀쩡하고, 빠진 것은 그 자리에 없기 때문에 보이지 않는다. 잡으려면 패턴을 도입할 때 그 패턴이 요구하는 동반 구조를 목록으로 적어두는 수밖에 없다.

결정에 가격표를 남긴다

앞 시리즈 종장에서 ADR을 권했다. 여기서는 형식을 한 칸 바꾼다. 결정문보다 판 것과 되돌리는 조건이 중요하다.

md
<!-- docs/adr/0022-read-model-for-order-list.md -->
# 22. 주문 목록에 읽기 모델을 둔다 (CQRS — 같은 DB 안 투영 테이블)
 
- 상태: 승인 (2026-09-15)
- 힘: 조회:쓰기 = 40:1. 목록 1건에 애그리게이트 3개 조인.
  p99 1.8초, 목표 300ms.
- 산 것: 조회 경로가 쓰기 모델과 독립적으로 최적화된다.
- **판 것**: 같은 DB 안 투영이라 즉시 정합성은 유지되지만,
  투영 코드가 도메인 규칙의 두 번째 사본이 된다.
  주문 상태 규칙이 바뀌면 두 곳을 고쳐야 한다.
- 기각: 별도 읽기 저장소(CQRS 3단계) — 쓰기 직후 읽기 불일치를
  제품이 받아들이지 않음. 이 제약이 풀리면 재검토.
- **되돌리는 조건**: 투영 불일치 버그가 분기 2건을 넘으면
  조인 쿼리 최적화로 회귀를 검토한다.

"판 것"과 "되돌리는 조건" 두 줄이 이 문서의 값 전부다. 결정문은 코드를 보면 알 수 있지만, 무엇을 포기했는지와 언제 다시 볼 것인지는 코드에 남지 않는다. 세 번째 경로(반만 산 것)를 막는 것도 이 형식이다. "판 것"을 쓰려고 하면 그 패턴이 무엇을 요구하는지 다시 보게 된다.

이 절차를 쓰지 말아야 할 때

전 편에 둔 절을 종장에서도 유지한다. 이 방법론 자체에 적용하면 이렇다.

되돌리기 싼 결정에 이 절차를 쓰는 것은 과잉이다. 함수 하나를 Strategy로 뺄지 말지에 힘을 적고 통화를 식별하고 ADR을 쓰는 것은, 그 결정을 그냥 해보고 마음에 안 들면 되돌리는 것보다 비싸다. 앞의 되돌리기 비용 표에서 "싸다" 줄에 있는 것들은 절차 없이 판단하는 게 맞다.

절차가 값을 하는 구간은 좁다. 되돌리기 비싸고, 코드 밖의 동의가 필요하고, 한번 정하면 여러 팀이 그 위에 쌓아 올리는 결정. 한 해에 몇 번뿐이고, 그 몇 번이 다음 몇 년을 결정한다.

요약

항목내용
1단계패턴 이름이 아니라 충돌하는 힘을 먼저 적는다. 각 힘에 숫자를 붙인다
2단계무엇을 팔 권한이 있는지 확인한다. 일관성과 중복은 코드 밖의 동의가 필요하다
3단계되돌리기 비용으로 정렬한다. 싼 것은 해보고, 비싼 것은 늦게 결정한다
YAGNI의 범위되돌리기 싼 영역에서만 안전한 원칙
부분 구매"쓸 것인가"가 아니라 "몇 퍼센트를 살 것인가". 거의 모든 패턴에 중간 눈금이 있다
실패 경로 1가격표를 안 보고 샀다
실패 경로 2맥락이 바뀌었는데 그대로 남았다
실패 경로 3이름만 사고 동반 구조를 빠뜨렸다 — 가장 흔하고 진단이 어렵다
기록ADR에 "판 것"과 "되돌리는 조건"을 남긴다. 결정문은 코드에 이미 있다

두 시리즈를 닫으며

안티패턴 16편과 패턴 16편, 서른두 편이 같은 문장의 앞뒤였다.

안티패턴은 맥락이 사라진 뒤에도 남아 있는 해법이고, 패턴은 대가를 알고 사는 해법이다.

두 정의 사이의 거리는 코드가 아니라 기록이다. 같은 Repository, 같은 Saga, 같은 서비스 분리가 한쪽에서는 패턴이고 다른 쪽에서는 안티패턴이 되는 차이는 구현의 품질이 아니라 왜 그것을 샀고 무엇을 내줬는지가 남아 있는가에 있다.

그래서 이 두 시리즈가 남기려는 것은 패턴 마흔 개의 목록이 아니라 질문 두 개다.

우리는 이 해법으로 무엇을 내주고 있는가. 그 대가를 지불할 이유는 아직 유효한가.


시리즈 완결

백엔드 안티패턴 16편과 백엔드 디자인 패턴 16편으로 두 시리즈를 마친다. 한쪽만 읽어도 완결되지만, 같은 주제를 양쪽에서 보면 — 예를 들어 Distributed Monolith(안티패턴 9편)와 서비스 경계 설계(10편)를 이어 읽으면 — 같은 결정의 비용과 값이 함께 보인다.

COMMENTS (…)

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

NEW COMMENT0 / 1000
⌘↵ 전송

blog92@web:~$ cd ..