일기 slecs

정산 SQL 롤백 대비 패치와 운영 DB 설계 점검

목차

정산 관련 유틸 코드를 손보고 운영 DB에 올릴 SQL 패치를 정리한 세션이었다. 특별히 새 기능을 붙인 건 아니고, 쌓여 있던 부채를 털어내는 작업이었는데 - 막상 들여다보니 생각보다 신경 쓸 게 많았다.

롤백 SQL을 왜 먼저 쓰는가

운영에 SQL 패치를 올릴 때 자주 보이는 실수가 있다. 패치 파일만 만들고, "문제 생기면 그때 되돌리면 되지" 하고 넘어가는 것. 근데 실제로 장애 상황에서 롤백 쿼리를 그 자리에서 작성하면 거의 반드시 실수가 난다. 시간 압박에 빠진 상태에서 손으로 쓰는 SQL은 검토가 제대로 안 된다.

이번에는 패치 파일을 작성할 때 롤백 SQL을 함께 작성해두었다. 구조는 단순하다.

migrations/
  20260417_patch_settlement.sql        -- 정방향
  20260417_patch_settlement_rollback.sql  -- 역방향

롤백 파일은 정방향과 같은 시점에 리뷰를 받아야 한다. 정방향은 꼼꼼히 검토하고 롤백은 나중에 쓰겠다는 건 사실상 롤백이 없는 거랑 같다. 리뷰어 입장에서도 두 파일을 같이 보면 "이 패치가 정확히 무엇을 바꾸는지"가 훨씬 명확하게 보인다 - 역방향을 읽으면 변경 의도가 역으로 드러나기 때문이다.

실제로 정산 도메인은 데이터 정합성 민감도가 높다. 금액이나 상태값이 잘못 변경되면 downstream 집계가 틀어지고, 그걸 뒤늦게 발견하면 수습 범위가 넓어진다. 그래서 패치 하나를 올릴 때도 "이게 틀렸을 때 원복까지 몇 초 만에 할 수 있냐"를 기준으로 접근하는 게 맞다.

정리 커밋 분리 기준

이번 작업에서 정산 유틸 코드 정리도 같이 진행했는데, 커밋 분리를 신경 썼다.

chore: 미사용 정산 유틸 함수 제거
fix: 운영 SQL 패치  롤백 파일 추가

두 가지를 하나의 커밋에 묶으면 나중에 이 커밋을 단위로 리버트할 때 의도치 않은 범위까지 같이 되돌아간다. 코드 정리는 기능에 영향 없는 변경이고, SQL 패치는 데이터에 영향 있는 변경이다. 성격이 다른 것들을 같이 묶지 않는다.

정리 기준으로 삼은 것들:

  • 실제로 호출되는 코드인지 정적 분석으로 먼저 확인하고, IDE의 "Find Usages"만 믿지 않고 grep으로 한 번 더 체크
  • 제거 후 빌드와 주요 시나리오 실행이 정상인지 확인
  • 기능 동작에 영향 없는 변경인지 로직 흐름을 따라가며 검토

정리 커밋에 chore: 접두사를 쓰는 건 단순히 컨벤션 때문이 아니다. CI에서 chore: 커밋은 특정 알림을 건너뛰게 설정하는 경우도 있고, 릴리즈 노트 생성 시 필터링 기준이 되기도 한다. 커밋 타입이 명확하면 자동화 파이프라인이 그 타입에 맞게 동작할 수 있다.

DB 설계 점검 - 인덱스, 소프트 삭제, 페이징

SQL 패치를 작성하면서 관련 테이블 설계도 같이 들여다봤다. 세 가지가 주로 걸렸다.

인덱스 설계

정산 특성상 statuscreated_at 기반 조회가 많다. 단일 컬럼 인덱스가 각각 있는 것과 복합 인덱스가 있는 건 실행 계획이 다르다.

-- 복합 인덱스 - status 필터 후 최신순 정렬이 잦을 때
CREATE INDEX idx_status_created ON settlements (status, created_at DESC);

-- 단순 EXPLAIN으로 실행 계획 확인
EXPLAIN SELECT * FROM settlements
WHERE status = 'PENDING'
ORDER BY created_at DESC
LIMIT 50;

복합 인덱스는 선두 컬럼이 조건에 반드시 포함돼야 효과가 있다. status 없이 created_at만 쓰는 쿼리는 이 인덱스를 타지 않는다. 그래서 인덱스를 추가할 때는 "이 인덱스가 어떤 쿼리 패턴을 위한 것인지"를 주석이나 마이그레이션 파일 내 코멘트로 남겨두는 게 나중에 유지보수할 때 훨씬 편하다.

소프트 삭제 패턴

정산 이력 데이터는 물리 삭제가 아니라 deleted_at 컬럼으로 논리 삭제하는 패턴을 유지했다. 감사 추적이나 분쟁 대응이 필요한 데이터는 지우면 안 된다.

방식 장점 단점
물리 삭제 스토리지 절약, 쿼리 단순 복구 불가, 이력 없음
소프트 삭제 이력 추적, 복구 가능 모든 쿼리에 WHERE deleted_at IS NULL 필요, 인덱스 설계 복잡

소프트 삭제를 쓰면 deleted_at IS NULL 조건이 모든 쿼리에 붙어야 하는데, 이걸 빠뜨리면 삭제된 데이터가 슬며시 섞여 들어온다. ORM 레벨에서 기본 스코프로 처리하거나, 뷰를 만들어두는 방법이 있다.

페이징 전략

현재 데이터 규모에서는 LIMIT / OFFSET 방식으로 충분하다. 다만 페이지 번호가 커질수록 DB가 앞의 행을 전부 읽고 버리는 구조라 데이터가 늘어나면 느려진다.

-- OFFSET 방식 - 단순하지만 깊은 페이지에서 느림
SELECT * FROM settlements ORDER BY created_at DESC LIMIT 50 OFFSET 10000;

-- 커서 방식 - 마지막으로 읽은 행의 값을 기준으로
SELECT * FROM settlements
WHERE created_at < :last_cursor
ORDER BY created_at DESC
LIMIT 50;

커서 기반은 중간 삽입/삭제가 있어도 누락이나 중복 없이 페이지를 넘길 수 있어서 정합성 측면에서도 유리하다. 지금 당장 바꿀 이유는 없지만, 데이터가 일정 규모를 넘어가는 시점에 전환을 검토해야 한다는 걸 이번에 확인해뒀다.


정산 도메인 작업은 항상 "내가 틀렸을 때의 시나리오"를 먼저 그려보게 된다. 롤백 SQL 먼저 쓰는 것도, 커밋 분리도, DB 설계 이중 점검도 결국 그 맥락에서 나온 습관이다.

댓글 0

첫 댓글 달아줘.