마지막 한 장, 동시에 저장하면 무슨 일이 생길까?
한도 검사와 저장 사이의 경쟁 상태를 살펴보고, PostgreSQL 행 잠금으로 정합성 경계를 만듭니다.
사이트 초기 콘텐츠로 작성한 샘플 글입니다. 프로젝트 수치는 작성자 제공 자료이며, Java 코드는 원리를 보여주는 예제입니다.
이 글의 목차
1. 트랜잭션만 붙이면 안전할까?2. 실행 순서를 바꾸어 보기3. 항상 존재하는 행을 먼저 잠그기4. NEMO의 실제 동시 저장 테스트5. 잠금이 해결하지 않는 것더 읽기사진이 19장 있고 한도는 20장입니다. 동시에 들어온 두 요청이 모두 19장을 읽으면 각 요청은 “한 장 더 저장해도 되겠네”라고 판단합니다. 확인하는 순간과 실제로 바꾸는 순간 사이에 다른 요청이 끼어드는 것이 문제입니다.
1. 트랜잭션만 붙이면 안전할까?
@Transactional은 한 작업의 원자성을 구성하지만, 여러 트랜잭션의 검사와 저장을 자동으로 한 줄에 세워 주지는 않습니다. 실제 결과는 실행 순서와 격리 수준, 사용하는 SQL에 달려 있습니다.
아래 실험은 사진 삽입을 그대로 복제하지 않고, 더 단순한 재고 카운터를 사용합니다. 여러 요청이 같은 재고를 읽고 절대값으로 덮어쓰는 lost update를 보여줍니다. 사진 한도 초과와는 다른 데이터 변경 방식이지만, 검사와 변경 사이의 경쟁이라는 원리를 공유합니다.
2. 실행 순서를 바꾸어 보기
CONCURRENCY EXPERIMENT
SIMULATION선택한 실행 순서의 최종 결과 · 재생 중간값과 구분됩니다.
L 잠금 · W 대기 · R 읽기 · U 쓰기 · C 커밋 · X 거부 · ↗ 잠금 해제. 가로로 움직이면 모든 단계를 볼 수 있습니다.
전체 트랜잭션 로그 (24단계)
- 01 · TX 1 · 읽기 — 동일한 이전 재고 1 읽기
- 02 · TX 2 · 읽기 — 동일한 이전 재고 1 읽기
- 03 · TX 3 · 읽기 — 동일한 이전 재고 1 읽기
- 04 · TX 4 · 읽기 — 동일한 이전 재고 1 읽기
- 05 · TX 5 · 읽기 — 동일한 이전 재고 1 읽기
- 06 · TX 6 · 읽기 — 동일한 이전 재고 1 읽기
- 07 · TX 7 · 읽기 — 동일한 이전 재고 1 읽기
- 08 · TX 8 · 읽기 — 동일한 이전 재고 1 읽기
- 09 · TX 1 · 쓰기 — 읽어 둔 1 - 1 = 0 기록 (절대값 UPDATE)
- 10 · TX 1 · 커밋 — 예약 성공 · 트랜잭션 커밋
- 11 · TX 2 · 쓰기 — 읽어 둔 1 - 1 = 0 기록 (절대값 UPDATE)
- 12 · TX 2 · 커밋 — 예약 성공 · 트랜잭션 커밋
- 13 · TX 3 · 쓰기 — 읽어 둔 1 - 1 = 0 기록 (절대값 UPDATE)
- 14 · TX 3 · 커밋 — 예약 성공 · 트랜잭션 커밋
- 15 · TX 4 · 쓰기 — 읽어 둔 1 - 1 = 0 기록 (절대값 UPDATE)
- 16 · TX 4 · 커밋 — 예약 성공 · 트랜잭션 커밋
- 17 · TX 5 · 쓰기 — 읽어 둔 1 - 1 = 0 기록 (절대값 UPDATE)
- 18 · TX 5 · 커밋 — 예약 성공 · 트랜잭션 커밋
- 19 · TX 6 · 쓰기 — 읽어 둔 1 - 1 = 0 기록 (절대값 UPDATE)
- 20 · TX 6 · 커밋 — 예약 성공 · 트랜잭션 커밋
- 21 · TX 7 · 쓰기 — 읽어 둔 1 - 1 = 0 기록 (절대값 UPDATE)
- 22 · TX 7 · 커밋 — 예약 성공 · 트랜잭션 커밋
- 23 · TX 8 · 쓰기 — 읽어 둔 1 - 1 = 0 기록 (절대값 UPDATE)
- 24 · TX 8 · 커밋 — 예약 성공 · 트랜잭션 커밋
READ COMMITTED를 가정한 결정적 교육 모델입니다. 락 없음은 SELECT 후 애플리케이션이 계산한 절대값으로 UPDATE하는 경우의 lost update를 보여줍니다. 실제 DB의 원자적 조건부 UPDATE, 낙관적 락, REPEATABLE READ/SERIALIZABLE에서는 대기·충돌·재시도 결과가 달라질 수 있습니다. 잠금이 있는 경우 도착한 요청은 차례로 읽습니다.
“겹침”은 모든 요청이 먼저 읽도록 선택한 실행 순서입니다. “순차”에서는 락 없이도 앞선 요청의 결과를 다음 요청이 읽습니다. 실패가 항상 나타나는 것은 아닙니다. 나타날 수 있다는 것이 정합성 문제입니다.
3. 항상 존재하는 행을 먼저 잠그기
사진 개수 집계 결과 자체를 잠그는 대신, 해당 사용자의 앨범 행을 잠금 대상으로 정할 수 있습니다. 모든 사진 저장 경로가 같은 행을 먼저 잠근다는 규칙이 필요합니다.
public interface AlbumRepository extends JpaRepository<Album, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select a from Album a where a.id = :id")
Optional<Album> findByIdForUpdate(@Param("id") Long id);
}@Transactional
public void savePhoto(Long albumId, PhotoInput input) {
Album album = albumRepository.findByIdForUpdate(albumId)
.orElseThrow(AlbumNotFoundException::new);
if (photoRepository.countByAlbumId(albumId) >= 20) {
throw new PhotoLimitExceededException();
}
photoRepository.save(Photo.of(album, input));
}4. NEMO의 실제 동시 저장 테스트
작성자가 제공한 프로젝트 사례에서는 한도 20장, 기존 19장, 동시 요청 8건에 PostgreSQL 행 잠금을 적용했습니다. 결과는 최종 20장 유지, 7건 거부였습니다.
이 결과가 모든 환경에서 똑같이 나오지는 않습니다. DB 버전, 격리 수준, 잠금 대상, 인덱스, 타임아웃과 재시도 정책, 테스트가 동시 시작을 보장하는 방식까지 기록해야 합니다. 이 사이트에는 아직 원본 테스트 코드와 전체 환경이 등록되지 않았습니다.
5. 잠금이 해결하지 않는 것
잠금은 같은 자원에 접근하는 요청을 기다리게 만듭니다. 경합이 심하면 지연이 늘고, 여러 행을 서로 다른 순서로 잡으면 교착 상태가 생길 수 있습니다.
비관적 락
충돌이 예상되는 경우 검사와 변경을 차례로 수행하기 쉽습니다. 대신 연결·잠금 대기와 타임아웃 비용이 생깁니다.
다른 선택지
낙관적 락은 충돌 시 재시도가 필요합니다. 재고 감소는 조건부 원자 UPDATE로 해결할 수도 있습니다. 사진 수 집계처럼 여러 행에 걸친 조건은 보호할 경계를 따로 설계해야 합니다.
REPEATABLE READ에서는 오래된 스냅샷을 기준으로 판단하거나 직렬화 오류로 중단될 수 있습니다. PostgreSQL에서 FOR UPDATE가 모든 조회를 막는 것도 아닙니다. 보통의 읽기와 잠금을 요청하는 읽기·쓰기를 구분해야 합니다.