본문으로 바로가기
KIM JUNHA Playful Logo Symbol

통합재고 동기화는 버튼 하나로 끝나지 않는다: Frontend·Spring Boot·Oracle·Batch 설계

재고 동기화 버튼을 누른 뒤 브라우저 캐시, Spring Boot API, Oracle 비관적 락·DB guard, Spring Batch와 독립 스냅샷이 어떤 순서로 동작하는지 초보자도 따라가도록 정리합니다.

작성자 김준하
작성일수정일
원문 Markdown
브라우저의 TanStack Query에서 Spring Boot API와 Oracle Guard, Spring Batch, 대시보드·통계 스냅샷을 거쳐 Ready 상태로 돌아오는 통합재고 동기화 흐름

통합재고 동기화에서 가장 중요한 것은 버튼을 눌렀을 때 화면의 버튼이 잠기는 일이 아닙니다. 여러 사용자가 동시에 눌러도 같은 재고를 두 번 처리하지 않고, 서버가 중간에 재시작되어도 작업을 잃지 않으며, 대시보드와 통계가 어떤 동기화 결과를 보고 있는지 설명할 수 있어야 합니다.

이번 글에서는 한 번의 동기화 요청을 브라우저 → Spring Boot API → Oracle → Spring Batch → 대시보드·통계 스냅샷 → 브라우저 캐시 순서로 따라가 보겠습니다. 개발을 처음 접하는 분도 흐름을 이해할 수 있도록 먼저 비유를 들고, 그 뒤에 실제 기술과 선택 이유를 연결했습니다.

앞선 글에서는 서로 다른 네 원천 재고를 공통 CanonicalInventoryRecord로 번역하고 기존 업무 데이터와 매핑하는 방법을 다뤘습니다. 이번 글은 그 record가 실제 실행 버튼을 누른 뒤 어떤 보호 장치와 비동기 작업을 거치는지에 대한 후속 기록입니다.

이 글에서 먼저 알아둘 말

처음부터 테이블명과 영어 약어를 모두 외울 필요는 없습니다. 아래 네 가지를 구분하면 뒤의 흐름이 훨씬 단순해집니다.

용어이 글에서의 뜻비유하면
syncRun버튼 한 번으로 만들어진 동기화 실행 기록입니다.접수 번호가 붙은 한 장의 작업 요청서입니다.
task하나의 실행에서 따로 끝날 수 있는 하위 작업입니다.요청서 안에 들어 있는 대시보드·통계 작업 티켓입니다.
generation어떤 데이터 묶음이 어느 시점의 결과인지 나타내는 세대 번호입니다.장부의 판본 번호입니다.
leaseworker가 아직 살아 있다는 것을 나타내는 만료 시각입니다.작업자가 “아직 처리 중”이라고 갱신하는 출근 기록입니다.

syncRuntask를 분리하는 이유는 core 동기화가 끝난 뒤에도 대시보드와 통계가 각자 계산될 수 있기 때문입니다. generation은 캐시를 언제 다시 읽을지 판단하는 기준이고, lease는 서버가 중단되었을 때 고아 작업을 찾아 복구하는 기준입니다.

먼저 결론부터 말하면

현재 프로젝트에 맞춘 최종 목표는 다음과 같습니다.

사용자가 동기화 버튼 클릭
        ↓
브라우저에서 clientRequestId 생성, 버튼을 잠시 비활성화
        ↓
POST /api/v1/inventory-sync-runs
        ↓
Oracle 짧은 제출 트랜잭션
  ├─ 비관적 락으로 제출 순서 직렬화
  ├─ active_scope_key UNIQUE로 진행 중 작업 1개 보장
  └─ clientRequestId로 재전송 멱등성 보장
        ↓
202 Accepted 또는 409 Conflict
        ↓
커밋 후 Spring Batch core 동기화 실행
  ├─ 원천 4종을 페이지 단위로 읽음
  ├─ hash·version으로 변경 후보를 좁힘
  ├─ 공통 record로 검증하고 buffer에 담음
  └─ canonical 재고와 위험등급을 한 트랜잭션으로 publish
        ↓
Oracle durable task 2개 생성
  ├─ dashboard snapshot
  └─ statistics snapshot
        ↓
두 작업을 서로 독립적으로 실행·재시도
        ↓
generation 변경, snapshot-ready 알림, 실패·복구 상태 기록
        ↓
브라우저가 필요한 query만 무효화하고 다시 조회

여기서 “버튼이 잠겼다”는 것은 두 가지 의미로 나뉩니다.

의미담당 위치실패해도 안전한가
클릭 직후 같은 브라우저에서 중복 클릭을 줄이는 시각적 잠금Frontend새로고침하면 사라지므로 보안 장치가 아닙니다.
여러 브라우저·여러 WAS가 동시에 실행하지 못하게 하는 실행권Oracle과 BackendDB가 최종 판단하므로 서버가 여러 대여도 적용됩니다.

따라서 모든 사용자 화면을 실시간으로 잠그는 것이 동시성 제어의 본체는 아닙니다. 화면은 현재 상태를 보여주는 관찰자이고, 실행 가능 여부는 서버가 202, 409, 429 같은 응답으로 결정합니다.

통합 동기화 전체 흐름

화면의 버튼은 시작점일 뿐, 최종 완료는 스냅샷까지 확인해야 합니다.

TanStack Query 브라우저가 Spring Boot API, Oracle Guard, Spring Batch Core, 대시보드·통계 스냅샷을 거쳐 Ready 상태로 돌아오는 통합재고 동기화 흐름
동기화 요청과 완료 알림은 하나의 선으로 보이지만, core publish와 두 스냅샷 작업은 서로 다른 실행 단위입니다.

이 문제를 성능 문제로만 보면 놓치는 것

재고는 자주 바뀌기 때문에 최신 상태를 확인해야 합니다. 그렇다고 모든 브라우저가 3초마다 같은 API를 호출하면 사용자가 아무도 버튼을 누르지 않은 시간에도 요청과 DB 조회가 계속 발생합니다.

반대로 버튼을 누른 요청을 HTTP 요청 하나에서 끝까지 동기 처리하면, 작업이 오래 걸릴 때 브라우저 연결이 끊기거나 타임아웃이 발생합니다. 대시보드와 통계까지 같은 트랜잭션으로 묶으면 통계 계산이 느린 날에는 다음 동기화가 필요 이상으로 오래 기다려야 합니다.

그래서 해결해야 할 문제를 네 가지로 나눴습니다.

문제단순한 해결책이 실패하는 이유이번 설계의 대응
중복 실행버튼 disabled는 다른 브라우저에 전달되지 않습니다.Oracle 비관적 락과 DB unique guard를 함께 사용합니다.
긴 작업HTTP 요청을 끝까지 유지하면 타임아웃과 재시도 중복이 생깁니다.202 Accepted 후 Spring Batch가 비동기로 처리합니다.
불필요한 조회유휴 상태에서도 고정 주기 polling을 계속합니다.실행 중에만 adaptive polling을 하고, ETag로 본문 전송을 줄입니다.
서버 중단·부분 실패메모리 큐만 쓰면 재시작 시 작업 목록을 잃습니다.DB durable task와 lease·retry 상태를 영속화합니다.

이 네 문제는 서로 연결되어 있지만 같은 기술 하나로 해결되지 않습니다. 락은 실행권을, 배치는 작업 실행을, 캐시는 조회 비용을, 알림은 화면 반응성을 담당하도록 경계를 나눴습니다.

1단계. 브라우저: 버튼을 누르기 전의 준비

화면 캐시와 서버 상태를 분리했습니다

통합재고 목록은 화면에서 잠깐 만든 값이 아니라 서버가 관리하는 상태입니다. 그래서 React 컴포넌트의 useState에 목록을 넣기보다 TanStack Query가 page, size, 검색어, 위험등급 같은 입력을 query key로 관리하도록 했습니다.

const inventoryQuery = useQuery({
  queryKey: ["inventories", { page, size, keyword, riskLevel }],
  queryFn: () => fetchInventories({ page, size, keyword, riskLevel }),
  placeholderData: keepPreviousData,
  staleTime: 30_000,
  gcTime: 15 * 60_000,
});

staleTime은 “이 데이터를 얼마 동안은 다시 최신인지 묻지 않을 것인가”입니다. gcTime은 “아무 컴포넌트도 사용하지 않는 캐시를 메모리에 얼마 동안 남길 것인가”입니다. 둘을 같은 의미로 생각하면 안 됩니다.

최종 구조에서 generation을 서버가 반환하면 다음과 같이 더 정밀하게 무효화할 수 있습니다.

queryClient.invalidateQueries({
  queryKey: ["inventories"],
  predicate: (query) => query.meta?.generation !== latestGeneration,
});

staleTime: Infinity는 “절대 새로 고치지 않음”이 아니라 “자동으로 낡았다고 판단하지 않음”에 가깝습니다. 따라서 authoritative generation이 바뀔 때 명시적으로 invalidateQueries해야 합니다. 반면 gcTime: Infinity까지 사용하면 여러 페이지·필터를 조회한 결과가 브라우저 메모리에 계속 남을 수 있습니다. 현재 범위에서는 캐시 수명을 유한하게 두고, 최종 generation 무효화로 필요한 데이터만 다시 가져오는 쪽이 안전합니다.

페이지네이션은 데이터 양이 커질수록 중요합니다

수만 건의 재고를 한 번에 내려주는 대신 서버가 page, size를 받아 필요한 범위만 조회하도록 했습니다. 목록 SQL도 OFFSET에만 의존하기보다 정렬 기준과 마지막 키를 이용하는 keyset 방식으로 확장할 수 있습니다.

조회 방식장점한계현재 판단
전체 조회구현이 가장 쉽습니다.응답 크기와 메모리가 데이터 양에 비례합니다.선택하지 않았습니다.
OFFSET/LIMIT페이지 번호 UI와 잘 맞습니다.뒤쪽 페이지일수록 앞의 행을 건너뛰는 비용이 커질 수 있습니다.현재 화면의 기본 방식입니다.
keyset/cursor큰 데이터에서 다음 페이지 비용이 안정적입니다.임의의 페이지 번호 이동이 어렵고 정렬 키가 필요합니다.데이터가 커지면 목록 endpoint에 적용할 후보입니다.

고정 polling 대신 active-only adaptive polling을 사용합니다

동기화가 실행 중일 때만 상태 API를 확인합니다. QUEUED는 작업이 시작되기를 기다리는 구간이므로 3초, RUNNING은 작업 진행 중이므로 10초처럼 간격을 늘리고, SUCCEEDED·FAILED·CANCELLED에 도달하면 polling을 멈춥니다.

const POLL_INTERVAL_BY_STATUS_MS = {
  QUEUED: 3_000,
  RUNNING: 10_000,
};

const refetchInterval = (query) => {
  const status = query.state.data?.status;
  return POLL_INTERVAL_BY_STATUS_MS[status] ?? false;
};

여기서 중요한 것은 “polling이 동시성을 막는다”가 아니라는 점입니다. polling은 화면이 최신 상태를 다시 읽는 방법입니다. 동시 실행을 막는 일은 뒤에서 설명할 Oracle guard가 담당합니다. 창이 다시 활성화되거나 네트워크가 복구될 때 한 번 즉시 조회하는 것도 polling을 완전히 없애기 위한 것이 아니라, 연결이 끊겼던 동안의 상태를 보정하기 위한 동작입니다.

2단계. 브라우저에서 시작 API를 호출합니다

버튼을 누르면 브라우저는 clientRequestId를 하나 만듭니다. 이것은 “이번 클릭 요청의 영수증 번호”입니다.

{
  "clientRequestId": "8f3f0c4a-...",
  "triggerType": "MANUAL",
  "requestedScope": "INVENTORY_CANONICAL"
}

서버는 다음 결과를 구분해 응답합니다.

HTTP 응답브라우저 동작
202 Accepted새 실행을 등록했고 백그라운드에서 처리할 예정입니다.반환된 syncRunId의 상태를 관찰합니다.
200 OK같은 clientRequestId의 이전 요청을 재전송한 경우입니다.기존 실행을 새로 만들지 않고 기존 결과를 표시합니다.
409 Conflict이미 같은 scope의 실행이 진행 중입니다.“다른 동기화가 진행 중”이라고 보여줍니다.
429 Too Many Requests짧은 시간에 너무 많이 요청했습니다.잠시 뒤 다시 시도하도록 안내합니다.

네트워크가 끊겼을 때 사용자가 버튼을 다시 누르는 상황을 생각해 보겠습니다. 서버가 첫 요청을 받았지만 응답만 유실되었을 수 있습니다. 같은 clientRequestId로 재전송하면 서버는 새로운 작업을 또 만들지 않고 기존 요청을 찾아 응답합니다. 이것이 멱등성입니다. “같은 요청을 여러 번 보내도 결과가 한 번만 적용되는 성질”이라고 이해하면 됩니다.

3단계. Backend의 짧은 제출 트랜잭션

비관적 락이란 무엇인가

비관적 락(pessimistic lock)은 여러 사람이 동시에 바꿀 가능성이 있다고 보고, 먼저 자물쇠를 잡은 뒤 작업하는 방식입니다. 도서관에서 같은 좌석에 두 사람이 앉으려 할 때 좌석표를 먼저 가져간 한 사람만 자리를 확보하는 상황과 비슷합니다.

Oracle의 SELECT ... FOR UPDATE는 선택한 행을 잠그고 트랜잭션이 끝날 때까지 다른 변경을 기다리게 합니다. 이번 프로젝트에서는 재고 수만 건을 오랫동안 잠그는 것이 아니라, 동기화 제출 순서를 결정하는 아주 짧은 mutex 행만 잠급니다.

이 행 잠금은 제출 트랜잭션이 커밋되거나 롤백될 때 풀립니다. 따라서 core worker가 몇 분 동안 실행되는 동안 Oracle mutex를 계속 붙잡아 두는 설계가 아닙니다.

SELECT 1
FROM inventory_sync_mutex
WHERE mutex_key = 'INVENTORY_CANONICAL'
FOR UPDATE;

이 트랜잭션 안에서 서버는 다음 순서로 검사합니다.

  1. 같은 clientRequestId가 이미 저장되었는지 확인합니다.
  2. active_scope_key = 'INVENTORY_CANONICAL'인 실행이 있는지 확인합니다.
  3. 진행 중이면 새 실행을 만들지 않고 409를 반환합니다.
  4. 없으면 QUEUEDinventory_sync_run을 하나 삽입합니다.
  5. 트랜잭션이 커밋된 뒤에만 worker 실행을 시작합니다.

비관적 락만으로 충분하지 않은 이유

비관적 락은 “지금 이 순간 제출하는 두 요청이 부딪히지 않게” 해줍니다. 하지만 실수로 active 실행 행을 두 개 만들거나, 다른 코드 경로가 락을 우회하면 중복 실행이 생길 수 있습니다. 그래서 active_scope_key에 unique 제약을 추가해 DB 자체가 같은 scope의 활성 행 두 개를 거부하도록 합니다.

즉, active_scope_key UNIQUE는 행을 잠그는 비관적 락과 같은 개념이 아니라 “이 조건을 만족하는 행은 동시에 하나만 존재해야 한다”는 DB 무결성 규칙입니다. 두 장치를 함께 두었기 때문에 검사 순서와 최종 저장 결과를 각각 방어할 수 있습니다.

보호 장치막는 문제
비관적 락동시에 들어온 제출 요청의 검사·삽입 순서가 뒤섞이는 문제
active_scope_key UNIQUE코드 경로가 달라도 활성 실행이 두 개 저장되는 문제
clientRequestId UNIQUE네트워크 재시도로 같은 요청이 중복 등록되는 문제
fencing token오래 멈췄던 worker가 늦게 돌아와 새 실행을 덮어쓰는 문제

fencing token은 실행마다 붙는 세대 번호입니다. worker가 DB를 변경할 때 자신이 가진 token이 현재 실행의 token과 같은지 확인합니다. 오래된 worker가 다시 살아나도 token이 다르면 쓰기를 거부할 수 있습니다.

다른 락 선택지와 비교했습니다

선택지잘 맞는 상황장점주의점이번 프로젝트 판단
Oracle 비관적 락 + unique guard최종 데이터 권위가 Oracle에 있고 실행 빈도가 낮은 경우데이터와 실행권을 한 권위에서 원자적으로 판단합니다.락을 오래 잡으면 대기와 교착이 생길 수 있습니다.제출 행만 짧게 잠그므로 선택했습니다.
낙관적 락충돌이 드물고 대부분 동시에 수정하지 않는 경우락 대기가 적고 처리량이 좋습니다.충돌 시 재시도·충돌 화면이 필요합니다.단일 active run 제출 보호만으로는 부족했습니다.
Redis 분산락여러 서비스가 Redis를 이미 공통 조정 계층으로 사용하는 경우획득이 빠르고 여러 서버가 공유할 수 있습니다.lease 만료와 네트워크 단절 때 DB와 락 권위가 갈라지는 split-brain 위험이 있습니다.최종 guard로는 선택하지 않았습니다.
JVM 로컬 락WAS 한 대의 메모리 안에서만 동작하는 경우코드가 간단합니다.서버가 두 대면 서로 모릅니다.운영 guard로 사용할 수 없습니다.

Redis가 쓸모없다는 뜻은 아닙니다. 캐시, rate limit, 실시간 fan-out에는 유용합니다. 다만 재고와 실행 상태의 최종 권위가 Oracle인 구조에서 Redis lease를 유일한 실행권으로 두면, lease가 만료된 뒤 새 worker와 이전 worker가 동시에 DB를 만지는 문제가 생길 수 있습니다. 그래서 이번 범위에서는 Oracle을 실행권의 기준으로 두고 Redis는 필수 구성에서 제외했습니다.

4단계. 커밋 후 Spring Batch core worker가 실행됩니다

제출 API가 트랜잭션을 열어둔 채 worker를 직접 호출하지 않습니다. 먼저 QUEUED 실행을 커밋하고, 커밋 후에 bounded launcher가 Spring Batch job을 시작합니다. 이렇게 해야 API 트랜잭션 롤백으로 실행이 사라졌는데 worker만 시작되는 상황을 피할 수 있습니다.

worker는 브라우저의 Web Worker가 아닙니다

여기서 worker는 서버에서 백그라운드 작업을 처리하는 실행자입니다. 브라우저 화면의 JavaScript를 별도 스레드에서 실행하는 Web Worker와 다른 개념입니다.

Spring Batch는 작업의 단계와 실행 메타데이터를 관리합니다. JobRepository에는 job execution과 step execution이 남기 때문에 서버가 중단되었을 때 “어디까지 실행했는가”를 추적하고 재시작할 기반이 됩니다. 단, Spring Batch만 있다고 모든 고아 실행이 자동으로 끝나는 것은 아닙니다. lease와 recovery scanner도 함께 설계해야 합니다.

원천 4종을 한 번에 메모리에 올리지 않습니다

현재 worker는 원천 순서를 정하고 한 페이지씩 읽습니다. 기본 page size는 500이며, 원천의 source_record_keyrow map을 함께 조회합니다.

OFFLINE → ECOMMERCE → GREETING → WAREHOUSE
       각 원천을 source_record_key 기준으로 페이지 처리
                              ↓
        source projection + row map + 현재 version
                              ↓
                CanonicalInventoryRecord

원천 행은 다음 조건을 만족할 때 변경 후보가 됩니다.

src.synced_record_hash IS NULL
OR src.record_hash <> src.synced_record_hash

record_hash는 행의 현재 내용을 요약한 값이고, synced_record_hash는 마지막으로 canonical에 반영했을 때의 요약값입니다. 둘이 같으면 재처리 후보에서 제외할 수 있습니다. 다만 hash는 “변경 후보를 줄이는 기준”이지 최종 진실 그 자체는 아닙니다. publish 단계에서 target 값과 transaction을 다시 확인해야 합니다.

원천 수량을 직접 UPDATE하는 데모를 만들 때도 수량만 바꾸면 안 됩니다. row_version, record_hash, inventory_source_state.current_version, pending_record_count가 함께 갱신되어야 다음 sync가 변경을 알아챌 수 있습니다. 그래서 프로젝트의 INVENTORY_DEMO_ADJUSTMENT는 시연용 수량 변경과 멱등성·감사 이력을 담당하지만, canonical 업무 데이터는 직접 수정하지 않습니다.

버전이 실행 중 바뀌면 어떻게 하나

worker가 원천의 시작 version을 읽은 뒤 처리 중에 version이 바뀌면, 한 실행 안에서 서로 다른 시점의 데이터를 섞을 수 있습니다. 이를 막기 위해 source version을 확인하고 바뀌었으면 해당 실행을 실패·재시도 대상으로 분류합니다. 이 방법은 “무조건 최신 한 행”보다 한 실행의 일관된 기준을 우선하는 선택입니다.

5단계. canonical 재고와 위험등급을 publish합니다

페이지에서 읽은 행은 typed CanonicalInventoryRecord로 변환되고 필수 ID, 수량 범위, 원천 타입, 매핑 상태를 검증합니다. 검증에 통과한 record만 InventorySyncAttemptBuffer에 담습니다.

core publish 트랜잭션에서는 다음 결과를 함께 확정합니다.

검증된 changed record
       ↓
canonical 상품·SKU·LOT·재고 target 비교
       ↓
재고 수량/가격/정책 반영
       ↓
위험등급 재산정 결과 반영
       ↓
동일 syncRunId lineage 기록
       ↓
트랜잭션 commit

재고만 반영되고 위험등급은 이전 실행을 가리키는 상황을 줄이기 위해, 현재 범위에서는 canonical publish와 위험등급 계산을 하나의 core 트랜잭션 경계에서 처리합니다. 그렇다고 대시보드·통계 스냅샷까지 이 트랜잭션에 억지로 넣지는 않습니다. 집계 결과는 별도의 읽기 모델에 저장할 수 있고, 계산 시간이 길어지면 core 작업을 불필요하게 붙잡기 때문입니다.

6단계. 대시보드와 통계 스냅샷은 별도 작업입니다

core publish가 성공했다고 화면용 대시보드와 통계가 이미 계산된 것은 아닙니다. 두 집계는 같은 동기화 결과를 읽지만 서로 다른 작업입니다.

최종 구조에서는 core commit 직후 다음 두 durable task를 DB에 저장합니다.

inventory_sync_task
├─ (sync_run_id, DASHBOARD_SNAPSHOT)
└─ (sync_run_id, STATISTICS_SNAPSHOT)

각 task에는 PENDING, RUNNING, RETRY_WAIT, SUCCEEDED, FAILED 상태와 시도 횟수, lease_expires_at, 마지막 오류, 완료 시각을 둡니다. 두 task를 unique key로 묶으면 같은 sync run에 같은 종류의 작업이 두 번 생성되지 않습니다.

상황바람직한 처리
대시보드 성공, 통계 실패대시보드 결과는 유지하고 통계만 재시도합니다.
통계가 오래 걸림마지막 성공 통계를 보여주고 “최신 동기화 반영 대기”를 표시합니다.
worker 재시작DB에서 아직 완료되지 않은 task를 찾아 다시 claim합니다.
두 task 모두 완료snapshot-ready 상태를 만들고 sync run을 최종 완료로 전환합니다.

현재 코드의 스냅샷 coordinator는 두 개의 in-memory executor로 분리되어 있어 정상 실행 중에는 서로 영향을 덜 주지만, 프로세스가 죽으면 메모리 큐에 있던 작업이 사라질 수 있습니다. 따라서 Flyway 변경이 승인된 뒤 inventory_sync_task를 도입하고, active scope 해제 시점을 두 snapshot의 terminal 상태 이후로 옮기는 것이 다음 단계입니다.

왜 Kafka나 RabbitMQ를 바로 넣지 않았는가

이번 동기화는 초당 수천 건의 이벤트를 계속 흘리는 스트림이 아니라 사용자가 누르거나 스케줄러가 시작하는 저빈도·고신뢰성 작업입니다. 현재 규모에서는 Spring Batch와 Oracle JobRepository, durable task만으로 작업 상태·재시작·재시도를 설명할 수 있습니다.

기술강점이번 범위에서의 비용도입 판단
DB durable task + Spring Batch상태와 업무 데이터가 같은 권위에 있고 운영 구성이 작습니다.DB polling/claim 쿼리와 recovery scanner를 직접 설계해야 합니다.현재 선택입니다.
RabbitMQwork queue, ack, 재전달, consumer 확장이 쉽습니다.broker, queue, DLQ, ack·중복 처리 운영이 추가됩니다.독립 worker가 늘거나 backlog를 분리할 때 검토합니다.
Kafka이벤트 보존·재생·다수 consumer·높은 처리량에 강합니다.topic/partition/key/consumer lag 운영이 필요합니다.CDC나 다수 downstream이 필요할 때 검토합니다.

RabbitMQ의 ack는 “처리 결과가 반드시 한 번만 반영됐다”는 보장이 아닙니다. consumer가 ack 전에 죽으면 메시지가 다시 전달될 수 있으므로, DB 쪽 멱등성은 여전히 필요합니다. Kafka도 메시지를 저장하고 재생할 수 있지만, 이 프로젝트의 한 번의 sync workflow를 잠그는 역할까지 대신하지는 않습니다.

7단계. 고아 실행과 락을 복구합니다

서버가 OOM, 배포, 네트워크 장애로 죽으면 RUNNING 상태와 활성 scope가 DB에 남을 수 있습니다. 이를 고아 실행(orphan run)이라고 합니다. 만료 시간을 두지 않으면 아무도 실행하지 않는데 버튼이 계속 막히는 문제가 생깁니다.

그래서 worker는 heartbeat로 lease_expires_at을 갱신하고, recovery scanner가 오래 갱신되지 않은 run/task를 찾습니다.

RUNNING + lease_expires_at < 현재 시각
        ↓
행을 다시 잠그고 fencing token 확인
        ↓
INTERRUPTED 또는 RETRY_WAIT로 전환
        ↓
재시도 횟수 안이면 새 worker가 claim
        ↓
한도 초과면 FAILED

lease는 “작업이 살아 있는지 판단하는 시간표”이지 Oracle row lock을 며칠 동안 유지하는 장기 락이 아닙니다. recovery scanner가 같은 task를 두 worker에게 주지 않도록 claim update와 fencing token을 DB 조건에 포함해야 합니다.

또한 실행 버튼의 active guard를 core publish 직후 풀어버리면, 첫 번째 sync의 snapshot이 끝나기 전에 두 번째 sync가 시작될 수 있습니다. 최종 설계에서는 dashboard와 statistics task가 모두 terminal 상태가 된 뒤에만 active_scope_key를 비웁니다. 다만 스냅샷이 수 분 이상 걸리는 운영 규모가 되면, 특정 syncRunId의 읽기 시점을 고정한 뒤 core active lock을 먼저 해제하는 snapshot isolation/read model 구조를 별도 검토해야 합니다. 이것은 현재 단계에서 바로 추가할 기능이 아니라 확장 트레이드오프입니다.

8단계. 브라우저는 완료 상태를 다시 확인합니다

최종 기본 transport: active-only adaptive polling + ETag

상태를 읽는 최종 기본 방법은 작업이 활성일 때만 동작하는 adaptive polling입니다. 요청에 If-None-Match를 넣고 서버가 같은 version을 가지고 있으면 304 Not Modified만 반환하게 하면, 확인 요청은 남아도 같은 상태의 큰 JSON 본문을 매번 보내지 않아도 됩니다. 현재 코드에는 active-only adaptive polling이 먼저 적용되어 있고, ETag는 다음 상태 API 계약에 추가할 개선 항목입니다.

GET /api/v1/inventory-sync-runs/latest
If-None-Match: "workflow-version-42"

변경 없음 → 304 Not Modified
변경 있음 → 200 + 새 상태 + ETag

ETag는 DB 락이 아닙니다. “이 응답이 어느 버전인지”를 비교하는 HTTP 캐시 검증자입니다. 따라서 ETag만으로 동시 실행을 막을 수 없고, 서버의 active guard와 함께 사용해야 합니다.

선택적 notification transport: SSE

SSE(Server-Sent Events)는 서버가 브라우저로 한 방향 알림을 계속 보내는 연결입니다. 예를 들어 dashboard-snapshot-ready, statistics-snapshot-ready 이벤트를 보낼 수 있습니다.

브라우저 ── POST ──▶ API
브라우저 ◀─ SSE ───  snapshot-ready 알림
브라우저 ── GET ──▶ 실제 최신 데이터 재검증

SSE는 화면을 빠르게 반응시키는 알림 채널이지, 실행권·락·작업 내구성을 보장하는 저장소가 아닙니다. 연결이 끊긴 동안 이벤트를 놓칠 수 있으므로, 재연결 후 GET으로 상태를 보정하고 polling을 fallback으로 유지합니다. 여러 대의 WAS에서 SSE를 쓰려면 공통 fan-out 계층이나 각 노드의 연결 관리도 필요합니다.

현재 구현에서 SSE를 실행권이나 상태 저장소로 사용하고 있지는 않습니다. 나중에 연결된 사용자에게 빠른 snapshot-ready 알림이 필요할 때 선택적으로 붙일 수 있지만, “SSE만 사용하고 polling은 완전히 없앤다”가 이번 프로젝트의 기본안은 아닙니다. active polling은 최종 상태를 다시 확인하는 안전망이고, SSE는 있으면 좋은 빠른 알림입니다.

다른 선택지보다 이 조합을 선택한 이유

기술을 많이 붙이는 것이 곧 실무적인 설계는 아닙니다. 이번 프로젝트는 Oracle이 이미 재고의 최종 권위이고, 동기화 요청 빈도가 높지 않으며, 교육 프로젝트에서도 장애 시나리오를 설명할 수 있어야 한다는 조건을 기준으로 선택했습니다.

해결하려는 문제선택한 기술선택하지 않은 대안선택한 이유
동일 scope 중복 실행Oracle 비관적 락 + unique guardRedis 분산락만 사용최종 데이터와 실행권의 권위를 한 DB에서 판단합니다.
긴 동기화 처리202 Accepted + Spring Batch긴 HTTP 동기 요청브라우저 연결과 작업 수명을 분리합니다.
재고 원천 변경 감지hash + row/source version매번 전체 재적재같은 행의 불필요한 publish를 줄입니다.
대량 목록 조회server pagination + query cache모든 행을 한 번에 조회응답과 브라우저 메모리를 제한합니다.
화면 상태 확인active-only adaptive polling + ETag3초 고정 polling유휴 요청과 payload를 함께 줄입니다.
빠른 완료 알림선택적 SSESSE만으로 상태 보장알림 유실을 GET/polling으로 보정할 수 있습니다.
스냅샷 실패 격리DB task 2개 + 독립 retry하나의 거대한 트랜잭션대시보드 성공을 통계 실패가 롤백하지 않습니다.
메시지 브로커현재는 도입하지 않음Kafka/RabbitMQ현재 작업 빈도에 비해 운영 복잡도가 더 큽니다.

인덱스와 파티션도 같은 원칙으로 적용합니다. source_record_key, (source_type, mapping_status), 실행 상태와 만료 시각 같은 실제 필터·정렬 조건에 인덱스를 두고, Oracle 실행 계획과 p95 지표로 확인한 뒤 추가합니다. 데이터가 충분히 크고 보관 기간·날짜 범위가 명확해졌을 때만 파티션을 검토합니다. “DB 성능을 위해 인덱스와 파티션을 모두 넣는다”는 식으로 먼저 결정하지 않습니다.

처음부터 끝까지 순서대로 다시 보기

아래 순서는 구현·테스트·장애 대응에서 같은 기준으로 사용할 수 있는 최종 시나리오입니다.

  1. Frontend가 사용자의 클릭에 clientRequestId를 붙이고 버튼을 비활성화합니다.
  2. FrontendPOST /api/v1/inventory-sync-runs를 호출합니다.
  3. Backend가 짧은 트랜잭션 안에서 inventory_sync_mutex 행을 비관적 락으로 잠급니다.
  4. Oracle이 동일 요청 여부와 active_scope_key UNIQUE를 검사합니다.
  5. 진행 중인 작업이 있으면 Backend409를 반환하고, 없으면 QUEUED run을 저장합니다.
  6. 커밋 후 Spring Batch launcher가 core job을 시작합니다.
  7. Worker가 원천 4종을 500행 단위로 읽고 projection·row map·hash·version을 확인합니다.
  8. Worker가 typed record를 검증하고 buffer에 담습니다.
  9. Oracle transaction이 canonical 재고와 위험등급을 같은 syncRunId 기준으로 publish합니다.
  10. core commit 후 Backend가 dashboard/statistics durable task를 각각 하나씩 생성합니다.
  11. 두 task가 각자 실행·heartbeat·retry·lease recovery를 거칩니다.
  12. 각 task가 완료되면 Backend가 snapshot-ready 상태와 generation을 기록하고, 필요하면 SSE를 보냅니다.
  13. Frontend는 알림을 받은 뒤 해당 query만 무효화하고 최신 페이지·요약을 다시 조회합니다.
  14. 두 snapshot이 모두 terminal 상태가 되면 Backend가 active scope를 해제하고 최종 완료 상태를 표시합니다.

이 순서에서 35번은 동시성, 69번은 core 데이터 정확성, 1012번은 작업 내구성과 부분 실패 격리, 1314번은 사용자 화면과 실행 lifecycle을 담당합니다.

SLO로 무엇을 측정할 것인가

SLO(Service Level Objective)는 “서비스가 어느 정도 수준으로 동작해야 하는가”를 수치로 정한 목표입니다. 예를 들어 “목록 API p95가 500ms 이내”, “동기화 상태가 30초 안에 화면에 반영”, “완료된 core run의 99%가 task를 잃지 않음”처럼 측정 가능한 문장으로 씁니다. 단순히 “빠르게”라고 쓰면 개선 전후를 비교할 수 없습니다.

이번 기능에서 먼저 기록할 지표는 다음과 같습니다.

영역지표보는 이유
조회목록·요약 API p50/p95/p99, 오류율페이지네이션·인덱스가 실제로 효과가 있는지 확인합니다.
동기화QUEUED 대기 시간, core 처리 시간, 변경 행 수병목이 제출·원천 조회·publish 중 어디인지 구분합니다.
스냅샷task queue age, 실행 시간, retry 횟수, 실패율대시보드·통계가 core보다 늦어지는 정도를 확인합니다.
복구lease 만료 후 재시작 시간, orphan run 수서버 재시작 시 작업을 잃지 않는지 확인합니다.
DBlock 대기 시간, CPU, buffer/cache hit, 실행 계획인덱스·파티션을 추측으로 추가하지 않게 합니다.

현재 프로젝트에는 이 지표를 모두 측정한 운영 결과가 없으므로 “몇 퍼센트 빨라졌다”고 단정하지 않습니다. 먼저 기준선을 기록한 뒤 데이터 양과 동시 사용자 수를 늘려 비교해야 합니다.

현재 적용된 것과 Flyway 승인 뒤 남은 것

지금 코드와 문서에서 이미 확인한 것

  • Frontend 통합재고 조회는 TanStack Query와 서버 페이지네이션을 사용합니다.
  • 조회 query에는 캐시가 있고, 실행 상태는 활성 상태에서만 adaptive polling하도록 정리했습니다.
  • gcTime은 무한으로 두지 않고 비활성 캐시를 정리할 수 있는 유한 시간으로 설정했습니다.
  • Backend 제출 경로에는 Oracle mutex FOR UPDATE, active run 확인, client request 중복 확인, 202/409/429 응답이 있습니다.
  • Spring Batch core worker는 원천별 page 처리, source version 확인, hash 기반 변경 후보, canonical publish를 수행합니다.
  • V21~V24 Flyway에는 원천 current·row map·source state·run source·error·demo adjustment와 위험 평가 lineage가 있습니다.

Flyway를 수정하기 전에는 적용하지 않을 것

아래 항목은 현재 설계의 최종 목표이지만, Flyway 변경 승인 없이는 스키마에 반영하지 않습니다.

  • inventory_sync_task durable task 테이블과 task 상태·lease 컬럼
  • canonicalReadGeneration, workflowVersion 같은 세대 정보
  • snapshot 두 개가 끝난 뒤 active scope를 해제하는 terminal 상태 전이
  • lease 만료 run/task를 복구하는 scanner와 fencing update
  • generation을 기준으로 한 targeted cache invalidation

즉, 이 글은 “지금 모두 구현되었다”는 보고서가 아니라, 현재 적용된 부분과 다음 스키마 변경 후 도달할 목표를 분리한 설계 기록입니다. V25 마이그레이션을 임의로 만들거나 실행하지 않는 것도 중요한 운영 원칙입니다.

자주 헷갈렸던 질문

polling이 없으면 다른 사용자가 누르는 것을 막을 수 없나요?

막을 수 있습니다. 다른 사용자의 요청은 서버가 Oracle guard와 active run 검사로 409를 반환합니다. 다만 다른 사용자의 화면 버튼까지 즉시 회색으로 보이게 하려면 SSE나 짧은 상태 조회가 필요합니다. 화면 잠금과 실제 실행권은 별개의 문제입니다.

위험등급 재산정과 대시보드·통계 스냅샷은 동시에 끝나나요?

위험등급 재산정은 core publish의 일관된 결과에 포함시키고, 대시보드·통계 스냅샷은 그 결과를 읽는 별도 비동기 작업으로 둡니다. 따라서 core가 끝났다고 두 snapshot도 끝난 것은 아닙니다. 각 snapshot은 서로 실패를 전파하지 않되, 최종 workflow 완료는 두 task의 상태를 확인한 뒤 판단합니다.

비관적 락과 낙관적 락은 무엇이 다른가요?

비관적 락은 “충돌할 것”이라고 보고 먼저 잠급니다. 낙관적 락은 일단 처리한 뒤 version이 그대로인지 확인하고, 누군가 먼저 바꿨으면 충돌로 판단해 재시도합니다. 충돌이 드물고 대기 시간이 민감하면 낙관적 락이 유리할 수 있지만, 이번 제출 API는 같은 scope의 실행을 애초에 하나만 만들면 되므로 짧은 비관적 락이 설명하기 쉽고 적합했습니다.

SSE가 있으면 상태 저장 테이블이 필요 없나요?

필요합니다. SSE는 연결된 브라우저에 알림을 전달할 뿐이고, 연결이 끊긴 동안의 이벤트를 영구 보관하지 않습니다. 최종 상태와 재시작 기준은 Oracle task/run에 저장하고, SSE는 알림·재검증을 빠르게 하는 보조 채널로 사용합니다.

staleTimegcTime을 둘 다 무한으로 두면 가장 빠르지 않나요?

대부분의 화면에서 그렇지 않습니다. staleTime을 길게 두려면 generation 변경 시 명시적으로 캐시를 무효화해야 하고, gcTime까지 무한이면 사용하지 않는 페이지 캐시가 메모리에 남습니다. 현재는 stale 판정과 메모리 회수를 분리하고, 최종 generation 기반 무효화를 추가하는 방향입니다.

마무리

이번 설계에서 선택한 기술의 핵심은 “유명한 기술을 모두 추가하는 것”이 아닙니다.

  • Frontend는 TanStack Query, 서버 페이지네이션, bounded cache, active-only adaptive polling으로 불필요한 조회를 줄입니다.
  • Backend202 Accepted, idempotency key, Spring Batch, 독립 task 실행으로 요청 수명과 작업 수명을 분리합니다.
  • Oracle은 비관적 락과 unique guard로 실제 실행권의 최종 권위를 가집니다.
  • Worker는 원천별 projection, hash·version, page 처리, fencing으로 대량 데이터와 오래된 실행을 통제합니다.
  • Snapshot은 대시보드와 통계를 별도 durable task로 나눠 한쪽 실패가 다른 쪽 성공을 롤백하지 않게 합니다.
  • SSE는 있으면 빠른 알림을 제공하지만, polling·GET 재검증을 대신하는 락이나 저장소로 사용하지 않습니다.
  • Kafka와 RabbitMQ는 현재 저빈도 workflow에 필수로 넣지 않고, 독립적인 소비자·backlog·이벤트 재생 요구가 생길 때 도입합니다.

결국 사용자가 보는 것은 하나의 버튼이지만, 서버가 지켜야 하는 것은 한 번만 실행, 변경된 데이터만 반영, 실패해도 복구, 스냅샷 시점 설명 가능이라는 네 가지 약속입니다. 이 약속을 Frontend·Backend·DB·Batch의 각 책임으로 나누면 데이터가 지금보다 훨씬 많아져도 어느 지점부터 확장해야 하는지 판단할 수 있습니다.

이어서 읽기

참고 자료