← posts/b.log()

blog92@web:~$ cat posts/backend-antipatterns-5-smart-db-as-architecture.md

BACKEND9 min read

데이터베이스가 아키텍처가 될 때 — Smart DB와 DB-as-IPC

스토어드 프로시저에 로직이 숨은 Smart DB의 청구서, Oracle에서 PostgreSQL로 갈 때 돌아오는 비용, 무엇을 DB에 남길지의 기준.

로직이 어디 있는지 찾는 데 이틀이 걸린다

"주문 취소 시 적립금이 두 번 복구된다"는 제보를 받고 원인을 찾는다. 애플리케이션 코드에는 SP_ORDER_CANCEL을 호출하는 줄 하나밖에 없다.

DB로 들어가면 구조가 이렇다.

text
SP_ORDER_CANCEL           주문 상태 변경, 재고 복구, SP_POINT_RESTORE 호출
  └ SP_POINT_RESTORE      적립금 복구 + POINT_LOG insert
      └ TRG_POINT_LOG_AI  POINT_LOG insert 시 USER_SUMMARY 갱신 (AFTER INSERT 트리거)
TRG_ORDER_AU              ORDERS update 시 상태가 'C'면 적립금 복구 (AFTER UPDATE 트리거)
V_USER_DASHBOARD          V_USER_POINT 위의 뷰, 그건 다시 V_POINT_BASE 위의 뷰

적립금 복구가 두 번 일어나는 이유는 SP_POINT_RESTORE 호출과 TRG_ORDER_AU 트리거가 같은 일을 하기 때문이다. 트리거는 3년 전에, 프로시저 호출은 작년에 추가됐다. 둘 다 개별적으로는 정당한 커밋이었다.

이 구조의 진짜 비용은 버그가 아니라 버그를 찾는 데 걸린 시간이다. 코드 검색으로는 호출 그래프가 보이지 않는다. POINT_LOG에 insert가 일어나면 무슨 일이 벌어지는지 알려면 ALL_TRIGGERS를 조회해야 하고, 그 트리거가 또 무엇을 건드리는지는 본문을 읽어야 안다. Smart DB(비즈니스 로직의 소재지가 데이터베이스인 구조)에서는 제어 흐름이 코드가 아니라 카탈로그 뷰에 들어 있다.

이 선택이 옳았던 세 가지 전제

저장 프로시저 중심 설계는 무지의 산물이 아니다. 세 가지 전제 위에서 그것은 명백히 더 나은 선택이었다.

전제 1 — 네트워크 왕복이 비쌌다. 주문 처리에 쿼리 12회가 필요하고 왕복 지연이 10ms면 애플리케이션에서 처리할 때 120ms가 네트워크에만 든다. 프로시저 하나로 묶으면 10ms다. 12배 차이다. 이 조건에서 로직을 DB에 두는 건 최적화였다.

전제 2 — DB 서버가 가장 좋은 하드웨어였다. 애플리케이션 서버는 여러 대의 저사양 장비였고, DB는 한 대의 고사양 장비였다. 계산을 어디서 할지 물으면 답이 정해져 있었다.

전제 3 — 애플리케이션 언어가 불안정했다. 프런트 기술과 애플리케이션 프레임워크가 5년 주기로 바뀌는 동안 SQL과 DB 스키마는 그대로였다. 오래 살아남을 곳에 로직을 두자는 판단에는 근거가 있었다.

세 전제는 각각 다른 시점에 무너졌다. 왕복 지연은 같은 가용 영역 안에서 1ms 아래로 내려갔고(12회 × 0.5ms = 6ms, 프로시저 대비 차이가 무의미해진다), 애플리케이션 서버는 수평 확장이 쉬운 쪽이 되었고 DB는 수직 확장만 가능한 가장 비싼 병목이 되었으며, TypeScript와 Node.js는 5년 주기로 갈아엎을 대상이 아니게 되었다. 전제가 무너진 뒤에도 남은 해법 — 1편의 정의 그대로다.

청구서의 항목들

버전 관리 부재. 프로시저는 기본적으로 DB 안에 있고, 배포는 DDL 실행이다. 누가 언제 왜 바꿨는지가 Git에 없다. 프로시저 200개짜리 시스템에서 "이 분기가 언제 추가됐는가"에 답할 방법이 없으면, 그 분기를 지울 근거도 없다. 지울 수 없는 코드는 계속 쌓인다.

단위 테스트 불가능. 프로시저 하나를 테스트하려면 DB 인스턴스, 스키마, 참조 무결성을 만족하는 시드 데이터가 전부 필요하다. 케이스 하나당 셋업이 2초면 200케이스에 400초다. 같은 로직이 순수 함수라면 200케이스에 0.2초다. 2000배 차이이고, 이 차이는 "테스트를 안 쓴다"로 귀결된다.

트리거 체인의 디버깅 비용. 트리거는 호출되는 게 아니라 발동된다. 호출자가 없으므로 코드에서 역추적할 수 없고, 깊이 3단계 체인이면 하나의 UPDATE가 일으키는 쓰기가 몇 개인지 실행 전에 알 수 없다.

로직 소재지 분산. 하나의 규칙을 확인하려면 애플리케이션 코드, 프로시저, 트리거, 뷰, 체크 제약조건 다섯 곳을 봐야 한다. 규칙 하나를 이해하는 비용이 5배가 아니라, 어느 곳을 안 봤는지 모른다는 점이 비용이다.

벤더 락인. PL/SQL과 T-SQL과 PL/pgSQL은 서로 호환되지 않는다. 이 비용은 이관을 결정하는 날 전액 청구된다.

DB-as-IPC

같은 뿌리에서 나오는 별도 안티패턴이 DB-as-IPC(데이터베이스를 프로세스 간 통신 수단으로 쓰는 구조)다. 테이블을 메시지 큐로 쓰는 형태가 가장 흔하다.

sql
-- 워커가 1초마다 도는 폴링 쿼리 (Before)
select * from job_queue where status = 'pending' order by id limit 10;
-- ... 처리 후
update job_queue set status = 'done' where id = $1;

세 가지 비용이 붙는다. 첫째, 폴링이다. 워커 8대가 1초 간격으로 돌면 하루 8 × 86,400 = 69만 쿼리이고, 큐가 비어 있는 시간이 90%라면 그중 62만 개가 빈 조회다. 둘째, 락 경합이다. 위 쿼리에는 워커 간 배타 처리가 없어 같은 행을 여러 워커가 집는다. 이를 FOR UPDATE로 막으면 워커들이 같은 행에서 직렬화되어 처리량이 워커 수에 비례하지 않는다. PostgreSQL 9.5(2016)의 FOR UPDATE SKIP LOCKED는 이 문제를 상당 부분 해결하므로, 소규모에서는 이 구조가 실제로 합리적이다. 셋째, 순서 보장이다. order by id는 커밋 순서가 아니라 시퀀스 발급 순서다. 시퀀스를 먼저 받고 늦게 커밋한 행은 건너뛰어진 뒤에 나타난다.

한 가지를 분명히 해 둔다. Transactional Outbox는 DB-as-IPC가 아니다. 아웃박스는 DB를 최종 전달 수단으로 쓰는 게 아니라, 상태 변경과 발행 의도를 한 트랜잭션에 묶기 위한 중간 저장소로 쓴다. 이 구분과 Dual Write 문제는 11편에서 다룬다.

Oracle에서 PostgreSQL로 갈 때 돌아오는 청구서

로직의 소재지가 DB라는 선택의 총비용은 이관 견적서에서 처음으로 한꺼번에 보인다. 스키마와 데이터 이전은 도구로 되지만, 아래는 사람이 한 줄씩 읽어야 한다.

  • PL/SQL 패키지. PostgreSQL에는 패키지가 없다. 패키지 사양·본문·패키지 전역 변수(세션 유지 상태)를 스키마와 함수, 임시 테이블 조합으로 다시 설계해야 한다. 기계적 변환이 되지 않는 부분이 여기다.
  • 빈 문자열과 NULL. Oracle은 빈 문자열을 NULL로 취급한다. PostgreSQL은 빈 문자열과 NULL이 다른 값이다. where name = ''가 Oracle에서는 항상 거짓이었는데 PostgreSQL에서는 참인 행이 생긴다. 이 차이는 예외를 던지지 않고 조용히 다른 결과를 낸다.
  • 암묵적 형변환. Oracle은 where id = '123'처럼 문자열과 숫자를 비교할 때 변환을 넓게 허용한다. PostgreSQL은 더 엄격해서 오류가 나거나 인덱스를 쓰지 못한다. 날짜도 NLS_DATE_FORMAT에 의존하던 문자열 변환이 명시적 캐스팅으로 바뀌어야 한다.
  • 시퀀스와 트리거 시맨틱. Oracle의 BEFORE INSERT 트리거로 시퀀스를 채우던 관례는 PostgreSQL의 identity 컬럼이나 default nextval()로 바뀐다. 트리거 실행 순서, 행 단위/문장 단위 구분, WHEN 절 동작이 조금씩 달라서 체인 전체를 재검증해야 한다.

프로시저 300개짜리 시스템에서 한 개를 옮기고 검증하는 데 평균 4시간이면 1,200시간, 1인 기준 7개월이다. 그 7개월 동안 원본 시스템도 계속 바뀐다.

무엇을 DB에 남기고 무엇을 옮길 것인가

전부 애플리케이션으로 옮기는 건 반대 방향의 실수다. 판정 기준을 표로 고정한다.

DB에 남긴다애플리케이션으로 옮긴다
제약조건 (PK, FK, unique, check, not null)분기가 많은 비즈니스 규칙
인덱스와 실행 계획에 영향을 주는 것외부 시스템 호출이 섞인 절차
집합 연산 (집계, 조인, 윈도 함수)정책·요금·등급 같은 자주 바뀌는 규칙
트랜잭션 경계와 격리 수준표현 형식 변환, 응답 조립
데이터 무결성의 마지막 방어선부수효과의 순서 제어 (트리거 체인 대체)

경계선은 단순하다. 집합에 대해 한 번에 답하는 것은 DB, 하나에 대해 여러 번 분기하는 것은 애플리케이션. 그리고 이관한 스키마와 함수는 전부 마이그레이션 도구로 코드화한다(Drizzle Kit, node-pg-migrate 등). 이렇게 해야 "언제 왜 바뀌었는가"가 Git으로 돌아온다.

Before는 분기와 집합 연산이 한 프로시저에 섞여 있다.

sql
-- Before: SP_SETTLE_MERCHANT — 정산 규칙(분기)과 집계(집합)가 같은 곳에
create procedure SP_SETTLE_MERCHANT(p_merchant_id in number) as
begin
  select sum(amount) into v_gross from orders where merchant_id = p_merchant_id and ...;
  if v_grade = 'A' then v_fee := v_gross * 0.02;                  -- 등급별 수수료
  elsif v_gross > 10000000 then v_fee := v_gross * 0.025;         -- 구간별 할인
  else v_fee := v_gross * 0.03; end if;
  if v_is_promo = 1 then v_fee := v_fee - 50000; end if;          -- 프로모션
  insert into settlements(...) values(...);
end;

After는 집합 연산만 SQL에 남기고, 분기는 순수 함수로 뺀다.

ts
// adapters/pg/settlement-repo.ts — 집합 연산은 SQL이 가장 잘한다
export async function grossByMerchant(merchantId: MerchantId, period: Period) {
  const { rows } = await db.execute(sql`
    select coalesce(sum(amount), 0)::bigint as gross, count(*)::int as cnt
    from orders
    where merchant_id = ${merchantId} and paid_at >= ${period.from} and paid_at < ${period.to}
      and status = 'paid'`)
  return { gross: BigInt(rows[0].gross), count: rows[0].cnt }
}
 
// domain/fee.ts — 분기는 순수 함수. DB 없이 200케이스를 0.2초에 돌린다
export function calcFee(gross: bigint, grade: Grade, promo: boolean): bigint {
  const rate = grade === 'A' ? 20n : gross > 10_000_000n ? 25n : 30n   // 1/1000 단위
  const fee = (gross * rate) / 1000n
  return promo ? (fee > 50_000n ? fee - 50_000n : 0n) : fee
}

calcFee는 DB도 네트워크도 모르는 함수이므로 규칙이 바뀌어도 배포로 끝나고, 테스트는 표 하나로 전부 덮인다. 반면 sum과 count를 애플리케이션으로 끌고 오지 않은 점이 중요하다.

대가는 왕복 횟수와 성능이다. 프로시저 한 번이 쿼리 세 번이 되면 왕복이 3배가 된다. 지연이 0.5ms면 1.5ms로 무시할 만하지만, 이걸 반복문 안에서 하면 N+1이 된다. 더 큰 위험은 집합 연산을 코드로 끌고 오는 것이다. 100만 행을 애플리케이션으로 가져와 reduce로 합산하면 DB에서 sum으로 끝날 일이 네트워크 전송과 GC 비용으로 바뀐다. DB에서 1초 걸릴 집계가 코드에서 60초가 되는 건 흔한 결과다. 이관의 목표는 DB를 얇게 만드는 게 아니라 분기를 DB 밖으로 빼는 것이다.

이게 오히려 정답인 경우

  • 대량 집합 연산. 수천만 행의 집계, 배치 정산, 대규모 UPDATE는 데이터 옆에서 하는 게 옳다. 데이터를 옮기는 비용이 계산 비용을 압도한다.
  • 여러 애플리케이션이 같은 DB를 공유하는 환경. 애플리케이션이 넷이고 그중 둘은 소스 코드가 없는 레거시라면, 규칙을 DB에 두는 것이 그 규칙이 모든 경로에 적용되는 유일한 방법이다. 이건 Shared Database(10편)의 문제와 맞물리는데, 그 상황에서의 최선은 여전히 DB에 규칙을 두는 것이다.
  • 데이터 무결성이 절대적인 규칙. "잔액은 음수가 될 수 없다"는 애플리케이션 검증만으로 보장되지 않는다. 배치 스크립트, 수동 SQL, 다른 서비스가 전부 우회 경로다. check 제약조건은 우회 불가능한 마지막 방어선이고, 애플리케이션 검증과 중복해서 두는 것이 맞다.
  • DB 접근만 허용된 조직 환경. 애플리케이션 배포 주기가 분기 1회이고 DB 변경은 주 1회 가능한 조직이라면, 로직 소재지는 기술이 아니라 배포 권한이 결정한다.

요약

항목내용
증상로직은 프로시저, 부수효과는 트리거 체인, 조회는 뷰 위의 뷰. 제어 흐름이 코드에 없음
당시의 합리성왕복 10ms, DB가 최고 사양 하드웨어, 애플리케이션 언어가 5년 주기로 교체
전제 붕괴왕복 1ms 미만, DB가 수직 확장만 되는 병목, 런타임 안정화
비용버전 관리 부재, 테스트 셋업 2000배, 트리거 체인 역추적 불가, 소재지 5곳 분산, 락인
DB-as-IPC빈 폴링 62만/일, 락 경합, 커밋 순서 미보장. SKIP LOCKED(PG 9.5)로 완화 가능
이관 청구서PL/SQL 패키지 재설계, 빈 문자열=NULL 차이, 암묵적 형변환, 시퀀스·트리거 시맨틱
탈출집합 연산은 SQL, 분기는 순수 함수. 스키마는 마이그레이션 도구로 코드화
탈출의 대가왕복 증가, 집합 연산을 코드로 옮기면 성능 붕괴(1초 → 60초)
정답인 경우대량 집계, 다중 애플리케이션 공유 DB, 절대적 무결성 규칙, 배포 권한 제약

다음 편 — 6편. Smart Pipes, Dumb Endpoints — ESB 안티패턴

1막이 여기서 끝난다. 2~5편은 하나의 프로세스와 하나의 DB 안에서 경계를 긋지 않았을 때 벌어지는 일들이었다. 6편부터는 시스템을 쪼갠 다음의 이야기다. 조각들을 다시 하나의 중앙 장치로 묶으면서 그 장치에 라우팅·변환·오케스트레이션·비즈니스 규칙까지 전부 올린 시대, 그리고 5편의 Smart DB가 통신 계층에서 똑같은 모양으로 반복되는 구조를 다룬다.

COMMENTS (…)

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

NEW COMMENT0 / 1000
⌘↵ 전송

blog92@web:~$ cd ..