실무에서 '배관' 정보를 등록하는 API를 구현하던 중
PK를 문자열 형식(P + 연도 + 일련번호)으로 생성해야 하는 요구사항이 있었다.
얘를들면 이런식이다
P20260001
P20260002
생성연도에서 뒤에 4자리 일련번호는 등록 순서대로 증가하는 방식이다.
처음에는 단순하게 java단에서 현재년도의 일련번호 최대값을 조회한 뒤
거기서 +1 을 해서 다음 번호를 만드는 방식(MAX+1)으로 구현했다.
이 코드는 신규배관 등록을 하는 다른 담당자의 페이지에서 사용중인 로직이었다.
하지만 이 코드는 동시성문제가 있었다.
이 로직은 일단 조회 후 일련번호를 계산해서 insert하는 구조라서
A 트랜잭션 시작
B 트랜잭션 시작
A: maxId = P20260005 조회
B: maxId = P20260005 조회
A: P20260006 생성 -> 저장
B: P20260006 생성 -> 저장 -> X PK 충돌
예를들면 이런방식의 충돌이 생긴다
결론적으로 이 문제를 해결한 방식은
PK 생성 로직을 Java단이 아니라 DB에 맡기는 방식으로 변경했다.
일반적인 웹 서버 구조에서는 자바단에서 저런 동시성문제를 잡기 어렵기 때문이다.
모든 관계형 DB에서는 트랜잭션과 락 기능을 기본적으로 제공하지만
트랜잭션만으로는 동시성 문제가 해결되지 않기 때문에 별도의 락을 명시적으로 사용해야 한다.
이를 위해 "FOR UPDATE" 문법을 사용하여 특정 row에 대한 쓰기 락(row-level lock)을 획득하도록 했다.
이 "FOR UPDATE"는 JPA에서 직접 SQL로 작성하지 않고 다음과 같이 어노테이션으로 사용할 수 있다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
이 어노테이션을 Repository 메서드에 붙이면, 내부적으로 해당 조회 쿼리가 SELECT ... FOR UPDATE로 변환되어 실행된다.
*락이란? 해당 데이터를 다른 트랜잭션이 동시에 수정하거나 동일한 방식으로 접근하지 못하도록 막고, 먼저 접근한 트랜잭션이 작업을 끝낼 때까지 대기시키는 방식(예시: FOR UPDATE )
그리고 값 증가(+1)를 시킨뒤 결과를 반환하는데
A: UPDATE 실행 → 락 획득
B: UPDATE 실행 → 대기
A: 값 증가 (6 → 7) → 완료 → 락 해제
B: 실행 시작 → 값 증가 (7 → 8)
이런방식으로 진행된다.
create table SF_ID_SEQ_M
(
SF_DIV_CD VARCHAR2(2) not null,
YEAR VARCHAR2(4) not null,
LAST_SEQ NUMBER(20) not null,
INPT_DTM DATE default SYSDATE,
INPT_ID VARCHAR2(10) default 'SYSTEM',
UPDT_DTM DATE,
UPDT_ID VARCHAR2(10),
constraint PK_SF_ID_SEQ_M
primary key (SF_DIV_CD, YEAR)
)
/
comment on table SF_ID_SEQ_M is 'ID일련번호관리'
/
comment on column SF_ID_SEQ_M.SF_DIV_CD is '시설구분코드(159)'
/
comment on column SF_ID_SEQ_M.YEAR is '연도 (YYYY)'
/
comment on column SF_ID_SEQ_M.LAST_SEQ is '최종일련번호'
/
comment on column SF_ID_SEQ_M.INPT_DTM is '입력일시'
/
comment on column SF_ID_SEQ_M.INPT_ID is '입력자'
/
comment on column SF_ID_SEQ_M.UPDT_DTM is '수정일시'
/
comment on column SF_ID_SEQ_M.UPDT_ID is '수정자'
/
이 테이블의 용도는 동시성 제어를 하려면 “모든 요청이 경쟁하는 단일 row”가 필요하기 때문에 락을 걸기위해 생성했다.
시설테이블에서 직접 "FOR UPDATE" 문법을 사용하여 락을 걸지 못하는 이유는
기본적으로 MAX같은 집계함수와는 같이 쓰지 않는 문법이기 때문이다.(MAX 계산할때는 락이 걸리지 않음)
백단에서는 '현재년도'와 공통코드로 시설구분 속성3에 넣어둔 알파벳 식별자를 가져와서 조합을 하는 방식으로 설계 했다.
처음 등록시 데이터가 없으면 insert를 하고 데이터가 있는경우 update를 하는 방식이다.
여기서 또다시 동시성 문제가 발생하는데
시간이 지나 연도가 바뀌면 해당 연도 데이터가 존재하지 않기 때문에
여러 요청이 동시에 들어올 경우 아래와 같은 상황이 발생할 수 있다.
A 트랜잭션: SELECT → 없음
B 트랜잭션: SELECT → 없음
A: INSERT → 성공
B: INSERT → PK 충돌 발생
이를 해결하기위해
for (int attempt = 0; attempt < MAX_BOOTSTRAP_ATTEMPTS; attempt++) {
Optional<SfIdSeqM> locked = sfIdSeqMRepository.findBySfDivCdAndYearForUpdate(
SF_DIV_CD_SUPPLY_MAIN_PIPE,
year
);
if (locked.isPresent()) {
long seq = locked.get().bumpAndGetSeq();
return idPrefix + String.format("%04d", seq);
}
long initialLastSeq = maxIssuedSequenceForPrefix(idPrefix, mainPipeRepository::findMaxPipeIdByPrefix);
try {
sfIdSeqMRepository.saveAndFlush(
new SfIdSeqM(new SfIdSeqMId(SF_DIV_CD_SUPPLY_MAIN_PIPE, year), initialLastSeq)
);
} catch (DataIntegrityViolationException ignored) {
}
}
throw new IllegalStateException(
"SF_ID_SEQ_M 초기화에 실패했습니다. SF_DIV_CD=" + SF_DIV_CD_SUPPLY_MAIN_PIPE + ", YEAR=" + year
);
INSERT 충돌이 발생하더라도 예외를 그대로 처리하지 않고 무시하도록 했다. 이는 충돌이 실제 오류가 아니라, 다른 트랜잭션이 먼저 데이터를 생성했다는 신호이기 때문이다.
예외가 발생한 이후에는 반복문을 통해 다시 조회를 수행하게 되며
이 시점에는 이미 해당 연도의 데이터가 생성되어 있기 때문에 정상적으로 조회가 가능하다.
이후 SELECT ... FOR UPDATE를 통해 락을 획득하고 일련번호를 증가시키는 로직으로 자연스럽게 이어지게 된다.
결과적으로 초기 생성 구간에서는 충돌을 완전히 제거하는 것이 아니라, 충돌 발생을 허용한 뒤 재시도를 통해 정상적인 흐름으로 복구하는 방식으로 동시성 문제를 해결했다.
추가적으로 추후 운영시 시설테이블에 직접 데이터를 추가하는 이례적인 경우까지 고려하여
public void alignLastSeqToAtLeast(long minLastSeq) {
if (minLastSeq > this.lastSeq) {
this.lastSeq = minLastSeq;
}
}
메서드를 추가하여 매번 시설테이블을 조회하여 최종일련번호가
SF_ID_SEQ_M 테이블에 저장된 일련번호보다 더 큰경우
먼저 일련번호를 최신화 한 뒤 이후 로직이 실행되도록 했다.
(시설테이블에서 MAX를 읽는 쿼리에는 FOR UPDATE이 없으므로 동시성을 해치지 않음)
나는 java단에 트랜잭션이 걸려있어서 문제가 없을 줄 알았는데
Java: 작업 단위 관리 (트랜잭션)
DB: 동시성 제어 (락 + 원자성)
이 둘은 역할이 달랐다.
동시성 문제가 발생하는 영역, 특히 PK 생성, 재고 처리, 금액 처리 같은 “공통 자원”은
반드시 DB에서 처리해야 한다.
'자바-백엔드' 카테고리의 다른 글
| [JPA] JPA환경에서 프로시저를 호출하려면? (StoredProcedureQuery) (0) | 2026.03.24 |
|---|---|
| [스프링] 어떤 상황에서 빈(Bean)을 사용하고 new를 사용해야 할까? (0) | 2026.03.22 |
| [DBeaver] 디비버로 엑셀에 있는 데이터 DB테이블에 넣기 (0) | 2026.02.27 |
| QueryDSL은 왜 쓰는걸까? (0) | 2026.02.06 |
| (HTTP 메서드) GET / POST / PUT / DELETE 는 select / insert / update / delete에 대응되는가? (0) | 2026.02.03 |