외부 체결 내역을 장부에 자동 반영하는 백그라운드 작업을 안전하게 켜는 설계

외부 시스템의 처리 결과를 주기적으로 읽어 내 DB 장부에 반영하는 백그라운드 작업은 잘못 동작하면 장부를 조용히 망가뜨린다. 개인용 모의투자 대시보드에서 외부 체결 내역을 주문 장부에 자동 반영하는 기능을 만들면서, 처음부터 "꺼져 있어도 안전하고, 켜도 바로 쓰지 않는" 구조로 설계했다. 투자 이야기가 아니라 백그라운드 동기화 작업을 안전하게 켜는 설계 기록이다.
배경
대시보드는 주문을 넣으면 외부 증권 API 중계 서비스의 접수 응답을 받아 장부에 기록했다. 문제는 접수 응답만으로 행 상태를 "체결"로 기록하고 있었다는 점이다. 실제 체결 수량과 가격은 별도 체결 조회 API에서 확인해야 했고, 응답이 시간 초과로 끝난 주문은 실제로 체결됐는지조차 다시 확인되지 않았다. 재확인 도구는 있었지만 사람이 CLI로 직접 돌려야 했다.
목표는 체결 조회 결과를 주기적으로 읽어 기존 주문 행에 실제 수량·가격을 반영하고, 시간 초과 주문도 재확인하는 것이었다. 동시에 이미 수동으로 보정해 둔 장부를 건드리지 않아야 했다.
선택지와 결정
가장 단순한 방법은 서버가 뜨면 바로 타이머를 돌려 모든 미확정 주문을 고치는 것이다. 하지만 이 방식은 배포하는 순간 과거 데이터 전체에 쓰기가 일어난다. 그래서 다섯 가지 안전 장치를 겹쳐 두기로 했다.
1. 기본 꺼짐: 활성화 환경 변수가 정확히 "1"일 때만 켠다. "true"나 빈 값은 모두 꺼짐이다. 꺼져 있으면 타이머·DB 연결·외부 조회를 하나도 만들지 않는다.
2. 시작 시각 하한: 시간대가 포함된 ISO 8601 배포 시각을 필수로 받는다. 값이 없거나 형식이 틀리면 켜지지 않는다. 조회(SELECT)와 갱신(UPDATE) 모두 이 시각 이후 생성된 주문만 다룬다.
3. 제외 목록: 장부 밖에서 따로 관리하거나 수동으로 처리하는 대상은 코드에 고정한 목록으로 자동 처리에서 뺀다.
4. 외부 주문번호 단독 중복 판정: 처음에는 생성일과 주문번호를 묶어 중복을 봤다. 그런데 수동 보정 행은 생성일이 달라 같은 체결을 두 번 반영할 수 있었다. 전체 체결 장부에서 외부 주문번호만으로 중복을 판정하도록 바꿨다.
5. 첫 주기는 미리 보기: 서버가 시작된 뒤 첫 정상 주기는 무조건 dry-run으로 "무엇을 바꿀지"만 로그에 남긴다. 쓰기 허용 환경 변수도 정확히 "1"이어야 그다음 주기부터 쓴다.
설정이 잘못되면 열리는 쪽이 아니라 닫히는 쪽으로 동작하게 만드는 것이 공통 원칙이다.
구현 요점
시작점은 Next.js의 instrumentation 파일이다. 공식 문서에 따르면 register 함수는 서버 인스턴스가 시작될 때 한 번 호출되고, 모든 런타임에서 불리므로 NEXT_RUNTIME으로 Node.js 런타임인지 확인해야 한다. 여기에 빌드 단계도 걸렀다.
```ts
export async function register() {
if (process.env.NEXT_RUNTIME !== 'nodejs') return
if (process.env.NEXT_PHASE === 'phase-production-build') return
const { resolvePolicy } = await import('./lib/sync-policy')
if (!resolvePolicy().active) return
const { startSync } = await import('./lib/sync')
globalThis.fillSync ??= startSync({ /* DB 연결, 보고 콜백 */ })
}
```
정책 모듈은 동적 import로 늦게 불러 꺼진 상태에서는 무거운 모듈을 아예 읽지 않게 했다. globalThis에 한 번만 등록해 register가 다시 불려도 타이머가 중복 생성되지 않게 했다.
주기 실행기는 setInterval 대신 "끝나면 다음 타이머를 거는" setTimeout 연쇄로 만들었다. 한 주기가 길어져도 겹치지 않고, 실행 중인 주기가 있으면 같은 Promise를 돌려준다. 타이머에는 unref를 걸어 프로세스 종료를 막지 않게 했다. 외부 오류 본문에는 토큰이나 계좌 정보가 섞일 수 있어 실패 로그에는 성공 여부만 남긴다.
장부 쓰기는 즉시 응답 저장 경로와 자동 동기화 경로가 같은 잠금을 쓴다. PostgreSQL의 LOCK TABLE ... IN SHARE ROW EXCLUSIVE MODE는 공식 문서상 동시 데이터 변경을 막고 자기 자신과도 충돌해 한 세션만 잡을 수 있다. 여기에 트랜잭션 범위로 lock_timeout을 3초로 걸어, 잠금을 오래 기다리지 않고 실패한 뒤 다음 주기에 다시 시도하게 했다. SQL로 대상을 거른 뒤에도 갱신 직전에 같은 하한·제외 조건을 코드에서 한 번 더 확인한다.
운영하면서 알게 된 것
- 안전 기준을 먼저 테스트로 쓰고 기존 코드에 돌리자, 꺼진 상태에서도 타이머가 두 개 생기는 문제가 바로 드러났다. 시작점·주기 실행기·재확인기 세 곳 모두에 꺼짐과 하한 검사를 넣어야 했다.
- 부분 체결 행에서 오래된 접수 응답 근거와 최신 체결 조회 근거를 함께 집계하자 누적 수량이 틀어졌다. 부분 체결은 최신 체결 조회 결과로만 판정하도록 바꿨다.
- 중계 서비스와 대시보드가 쓰는 조회 인자 이름과 시간 형식이 달랐다. 계약을 소스로 대조해 시간 창을 중계 서비스 형식으로 바꾸고 자정 경계를 막는 회귀 테스트를 추가했다.
체크리스트
- 백그라운드 쓰기 작업은 기본 꺼짐, 정확한 값으로만 켜지게 한다.
- 처리 범위를 배포 시각 같은 명시적 하한으로 묶는다.
- 중복 판정 키는 외부 시스템이 보장하는 고유 식별자 하나로 둔다.
- 첫 주기는 dry-run으로 계획만 기록한다.
- 쓰기 경로끼리 같은 잠금과 짧은 lock_timeout을 공유한다.
- 꺼진 상태에서 타이머·DB·외부 호출이 0건인지 테스트로 확인한다.
참고 자료
- Instrumentation — Next.js Docs
- Explicit Locking — PostgreSQL Documentation
- Client Connection Defaults (lock_timeout) — PostgreSQL Documentation