시스템 파트너 롤백 SQL 추가로 운영 원복 즉시 대응
목차
운영 배포를 앞두고 롤백 SQL을 미리 작성해두는 건, 해보기 전엔 "굳이?"싶다가 한 번 쓰고 나면 다음 배포 전에 습관적으로 챙기게 된다. 이번에도 시스템 파트너 관련 스키마 패치를 운영에 올리기 전, 롤백 SQL을 함께 정리해뒀다.
왜 롤백 SQL을 미리 써야 하는가
배포 직후 문제가 터지는 타이밍은 대개 최악이다. 트래픽이 몰리는 시간대거나, 다른 작업이 맞물려 있거나. 그 상황에서 "어, 롤백 어떻게 하지?" 하고 SELECT 문 뒤져가며 역산하면 이미 늦다.
롤백 SQL을 미리 작성해두는 데는 두 가지 효과가 있다. 하나는 당연히 즉시 원복 가능하다는 것. 다른 하나는 덜 알려진 효과인데, 롤백을 먼저 써보는 과정에서 forward SQL의 허점이 보인다는 것이다. "이걸 되돌리려면 어떻게 해야 하지?" 를 고민하다 보면 "아, 이 컬럼 DEFAULT 값을 이렇게 주면 안 되겠다"든지, "이 데이터 마이그레이션은 단방향이라 rollback 시 데이터 손실이 생기네" 같은 걸 사전에 잡을 수 있다.
운영 SQL 패치 파일은 보통 이런 구조로 관리하고 있다.
db/patches/
20260418_system_partner_add_status.sql ← forward
20260418_system_partner_add_status_rollback.sql ← rollback
파일명에 날짜 prefix를 붙이는 건 나중에 히스토리 뒤질 때 결정적으로 편하다. 어떤 팀은 Flyway나 Liquibase 같은 마이그레이션 툴을 쓰지만, 운영 DB에 직접 패치를 올려야 하는 상황이면 SQL 파일 자체를 버전관리하는 게 더 직관적이고 DBA나 인프라 팀과 커뮤니케이션하기도 쉽다.
롤백 SQL의 기본 원칙은 단순하다. forward에서 ADD COLUMN을 했으면 rollback에서는 DROP COLUMN, INSERT INTO를 했으면 DELETE FROM (WHERE 조건 명확히), UPDATE를 했으면 원래 값으로 되돌리는 UPDATE. 다만 데이터 마이그레이션이 섞이면 얘기가 달라진다. 이미 변환된 데이터를 원복할 방법이 없다면, 그냥 rollback SQL에 주석으로 "데이터 원복 불가, 백업 복구 필요" 라고 명시해두는 게 낫다. 없는 척 빈 파일 두는 것보다 훨씬 낫다.
이번 SQL 작성하면서 점검한 것들
인덱스는 항상 고민이다. WHERE 조건에 자주 쓰이는 컬럼인데 인덱스가 없으면 배포 이후 슬슬 느려지기 시작하고, 그게 이번 배포 탓인지 다른 탓인지 추적하기 어려워진다. 이번에는 status와 created_at 조합으로 필터링하는 쿼리가 있어서 복합 인덱스를 같이 추가했다.
-- forward
ALTER TABLE system_partner ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE';
CREATE INDEX idx_system_partner_status_created
ON system_partner (status, created_at DESC);
-- rollback
DROP INDEX IF EXISTS idx_system_partner_status_created;
ALTER TABLE system_partner DROP COLUMN status;
복합 인덱스 컬럼 순서는 선택도(cardinality)가 높은 쪽을 앞에 두는 게 원칙이지만, 실제 쿼리 패턴도 같이 봐야 한다. status = 'ACTIVE' 같은 등치 조건이 먼저 들어오고 그 다음 날짜 정렬을 한다면 (status, created_at) 순서가 맞다. 반대로 날짜 범위를 먼저 자르고 status로 필터링한다면 순서를 바꾸는 게 낫다. EXPLAIN 한 번 더 돌려보는 수고가 나중에 쿼리 튜닝 티켓 줄여준다.
소프트 삭제 패턴은 이번에도 그대로 유지했다. deleted_at TIMESTAMP NULL 컬럼 두고, 삭제는 UPDATE SET deleted_at = NOW(), 조회는 WHERE deleted_at IS NULL. 이력 추적이 필요한 테이블에서 물리 삭제는 진짜 조심해야 한다. 삭제된 줄 알았는데 다른 테이블에서 FK 참조하거나, 감사 로그 맞춰야 하는 상황이 생기면 이미 지운 거 복구할 방법이 없다.
소프트 삭제의 단점도 있다. 시간이 쌓이면 테이블이 커지고, 인덱스에 deleted_at IS NULL 조건이 항상 붙어야 해서 partial index를 고려해야 하는 시점이 온다.
-- 소프트 삭제 전용 partial index (PostgreSQL 기준)
CREATE INDEX idx_system_partner_active
ON system_partner (created_at DESC)
WHERE deleted_at IS NULL;
MySQL이면 partial index가 없어서 deleted_at 컬럼을 복합 인덱스에 넣고 deleted_at = '0000-00-00' 같은 sentinel 값을 쓰는 편법을 쓰기도 하는데, 그건 취향 탄다. 어느 쪽이든 쿼리 패턴이랑 같이 봐야 한다.
페이징은 현재 규모에서 LIMIT/OFFSET이 충분하다. 다만 OFFSET 값이 커지면 DB가 그만큼 앞쪽 row를 스캔하고 버려야 하기 때문에, 데이터가 많아지면 커서 기반으로 전환을 생각해둬야 한다. 커서 페이징은 WHERE id > :lastId ORDER BY id 형태라 매번 인덱스 스캔이 효율적이고, 중간에 데이터가 삽입/삭제돼도 중복/누락이 없다. 지금 당장 바꿀 건 아니지만, 인터페이스 설계할 때 cursor 파라미터 자리는 미리 잡아두는 게 나중에 변경 비용을 줄인다.
정리 커밋 분리 원칙
이번 작업을 하면서 다시 확인한 건데, SQL 패치 추가나 유지보수성 작업은 기능 커밋과 반드시 분리해야 한다. 같은 PR에 기능 변경이랑 스키마 정리랑 인덱스 추가가 섞여 있으면 리뷰어가 무엇이 사이드이펙트고 무엇이 의도된 변경인지 판단하기가 어렵다. 롤백 단위도 불명확해진다. "이 커밋 되돌리면 어떻게 되나요?" 질문에 명확히 답하려면 커밋 하나가 하나의 의도를 담고 있어야 한다.
chore: prefix를 따로 쓰는 이유가 거기 있다. 기능 추가나 버그픽스랑 체리픽, 롤백 단위가 다르기 때문이다. 특히 운영 장애 대응 중에 git log 뒤지는 상황이면 커밋 메시지가 명확하지 않은 게 얼마나 스트레스인지 실감한다.
| 커밋 타입 | 설명 | 롤백 대상 여부 |
|---|---|---|
feat: |
기능 추가 | 가능 |
fix: |
버그 수정 | 가능 |
chore: |
정리, 인덱스 추가, 패치 파일 관리 | 경우에 따라 다름 |
db-patch: |
운영 SQL 패치 | rollback SQL 쌍으로 관리 |
db-patch: 는 팀마다 컨벤션이 다를 수 있는데, 운영 DB 변경을 코드 변경과 구분해서 추적하고 싶으면 prefix를 따로 가져가는 게 이력 관리에 편하다. 어쨌든 핵심은 하나의 커밋이 하나의 맥락을 담는 것. 리뷰할 때도, 배포 이후 추적할 때도, 장애 대응할 때도 그게 결국 시간을 아껴준다.
댓글 0
첫 댓글 달아줘.