트랜잭션·데이터 정합성 · Data Integrity

MAX+1 충돌이 무관 주문 데이터 혼입으로 번진 이유: 주문 단위로 정합성 복구하기

거의 동시에 등록된 주문이 같은 하위 식별자를 얻은 뒤, 식별자 하나만 신뢰한 후속 조회에서 서로 무관한 주문 데이터가 섞인 원인과 보수적인 복구 방식을 정리합니다.

JavaOracleMyBatisConcurrencyData IntegrityLegacy System

문제의 출발점

MAX+1 방식의 위험은 중복 키 예외만이 아니다. 생성된 번호가 데이터베이스의 전역 유일 키로 강제되지 않거나, 후속 조회가 그 번호 하나만 유일하다고 믿으면 더 조용한 형태의 정합성 문제가 생길 수 있다.

이번 사례에서는 거의 동시에 등록된 서로 무관한 주문이 같은 샘플 식별자를 얻었다. 이후 조회가 샘플 식별자만 조건으로 사용하면서 현재 주문에 속하지 않은 데이터까지 함께 반환했다.

증상은 저장보다 후속 조회에서 드러났다

사용자에게 보인 문제는 다른 주문의 샘플 정보가 현재 주문 화면에 섞이는 현상이었다. 처음에는 화면 상태나 주문접수 로직의 잘못된 객체 재사용을 의심할 수 있었다.

하지만 데이터를 주문 단위로 나누어 비교하자 다음 조건이 연결됐다.

  1. 두 주문이 거의 같은 시점에 등록됐다.
  2. 두 처리 흐름이 MAX+1 방식으로 같은 다음 샘플 식별자를 계산했다.
  3. 서로 다른 주문에 같은 하위 식별자가 존재하게 됐다.
  4. 후속 조회가 상위 주문 범위 없이 하위 식별자만 사용했다.
  5. 같은 하위 식별자를 가진 무관 주문 데이터가 함께 조회됐다.

즉, 채번 충돌과 조회 범위 누락이 결합한 문제였다. 둘 중 하나만 보고는 실제 혼입 경로를 설명할 수 없었다.

MAX+1이 경쟁 조건을 만드는 방식

아래는 실제 SQL이 아니라 경쟁 조건을 설명하기 위한 pseudocode다.

요청 A: 현재 최대값 120을 읽음
요청 B: 현재 최대값 120을 읽음

요청 A: 다음 값 121을 사용
요청 B: 다음 값 121을 사용

개념적인 Query는 다음과 같다.

SELECT MAX(child_id) + 1
FROM child_data;

SELECT와 저장이 하나의 원자적 채번 연산이 아니라면 두 요청이 같은 최대값을 읽을 수 있다. 다만 이번 작업에서 데이터베이스 Lock, 격리 수준 또는 전체 채번 체계를 변경한 것으로 확인되지는 않는다.

식별자 하나를 유일하다고 믿은 조회가 문제를 키웠다

충돌한 하위 식별자가 주문 범위 안에서만 사용됐다면 서로 다른 주문의 데이터가 한 결과에 섞이는 현상은 제한할 수 있었다. 하지만 기존 조회는 하위 식별자 하나를 사실상 전역 유일값처럼 사용했다.

아래 SQL은 실제 테이블·컬럼명을 제거한 개념 예시다.

-- 취약한 조회
SELECT ...
FROM sample_data
WHERE sample_id = :sampleId;

같은 sampleId가 다른 주문에도 있으면 두 주문의 행이 함께 반환될 수 있다. 개선된 조회는 업무상 상위 식별자인 주문 범위를 함께 사용했다.

-- 개선된 조회 범위
SELECT ...
FROM sample_data
WHERE order_id = :orderId
  AND sample_id = :sampleId;

하위 식별자의 유일성 가정이 깨져도 “현재 주문에 속한 데이터만 조회한다”는 업무 경계는 유지할 수 있다.

가능한 근본 개선과 당시의 보수적 수정

이 문제에 대해 구조적으로 생각할 수 있는 방향은 여러 가지다.

가능한 근본 개선과 당시의 보수적 수정 데이터 표
방향해결하려는 문제이번 적용 여부
Database Sequence동시 요청의 식별자 중복 방지적용 확인되지 않음
공통 ID 관리 체계시스템 전반의 채번 책임 통합적용 확인되지 않음
Lock·격리 수준 조정MAX 조회와 저장 사이 경쟁 통제적용 확인되지 않음
주문 범위를 포함한 조회충돌 시 무관 주문 데이터 혼입 제한적용

운영 중인 레거시 시스템에서 채번 체계를 전면 교체하면 생성·저장·조회·연계·화면의 가정을 모두 다시 확인해야 한다. 당시에는 변경 영향과 운영 위험을 제한하기 위해 현재 주문에 등록된 샘플만 조회하도록 범위를 보완했다.

이 선택은 MAX+1을 제거한 근본 해결이 아니다. 이미 확인된 혼입 경로를 주문이라는 상위 업무 경계 안으로 제한한 단기·보수적 개선이다.

한 Query만 바꾸지 않은 이유

조회 범위는 SQL에 표현되지만, 주문 식별자를 그 Query까지 전달하는 책임은 호출 경로 전체에 걸쳐 있다.

  • 화면은 현재 사용자가 보고 있는 주문 맥락을 유지해야 한다.
  • Controller는 주문과 하위 식별자를 함께 받아야 한다.
  • Service는 두 값의 관계를 보존해 조회·접수 흐름에 전달해야 한다.
  • SQL은 상위 주문 범위와 하위 식별자를 함께 조건으로 사용해야 한다.

실제 변경도 Controller·Service·SQL·JSP 경로에 걸쳐 이뤄졌다. 공개 글에서는 내부 클래스·메서드·화면 이름 대신 이 책임 연결만 남긴다.

검증은 주문 경계를 기준으로 했다

품질 검증 환경에서 수정 경로를 확인하고 최종 완료 상태를 확인했다. 이후 실제 운영 반영 범위도 별도로 확인됐다.

중요한 기준은 “조회가 성공하는가”만이 아니었다.

  • 현재 주문의 샘플은 기존처럼 조회되는가
  • 같은 하위 식별자가 다른 주문에 있어도 현재 주문 데이터만 반환되는가
  • 주문접수 흐름에서 상위 주문 맥락이 중간에 사라지지 않는가
  • 화면과 서버가 같은 주문 범위를 사용하고 있는가

검증 기록이 확인하는 범위는 주문 단위 조회 개선과 운영 반영이다. 전체 시스템의 동시성 문제가 사라졌다고 확대할 수는 없다.

남는 위험

이번 수정 이후에도 MAX+1 방식 자체가 존재한다면 동일한 다음 값을 계산하는 경쟁 조건은 남을 수 있다. 다른 조회나 외부 연계가 하위 식별자를 전역 유일값으로 가정한다면 별도의 영향 분석이 필요하다.

따라서 장기적으로는 다음 두 축을 분리해 관리해야 한다.

  1. 생성 축: 하위 식별자가 동시 요청에서도 유일하게 발급되는가
  2. 사용 축: 식별자를 조회할 때 올바른 상위 업무 범위를 함께 확인하는가

Sequence를 도입해도 잘못된 주문 범위를 조회하면 정합성 문제는 생길 수 있고, 주문 범위를 추가해도 중복 식별자 생성 자체가 사라지는 것은 아니다.

정리

식별자 충돌은 생성 시점의 오류로 끝나지 않는다. 그 값을 유일하다고 믿는 조회·수정·연계 경로를 따라가며 영향이 확대된다.

이번 개선은 채번 체계를 전면 재설계한 사례가 아니다. 현재 운영 구조의 제약을 유지하면서도 하위 식별자를 주문 범위 안에서 해석하도록 바꿔, 무관 주문 데이터가 섞이는 경로를 차단한 사례다.