이번 글에서는 프로젝트에 결제 기능을 추가할 때 겪었던 동시성 문제를 겪었던 과정을 소개하려고 합니다.
저희가 진행했던 프로젝트에서는
상품을 계좌이체로 판매할게 아니라면 대부분 결제 시스템은 포트원이라는 서비스를 통해서 구현하니 저희도 포트원을 통한 결제를 진행하기로 결정하고 그에 따른 구조를 설계하였죠.
저희가 설계한 구조는 아래와 같았습니다.

유저의 결제 요청이 들어오면 클라이언트에서는 포트원에서 제공하는 SDK를 호출합니다. 그러면 유저의 화면에서는 결제창이 나와서 저희가 흔히 하는 결제를 진행할 수 있죠. 결제 완료 후 포트원 서버에서는 클라이언트로 paymentId라는 결제 식별값을 응답합니다. 동시에 서버로도 결제가 완료되었다는 웹훅을 (알림) 전달하죠.
그후 서버는 받아온 결제 식별값 paymentId를 통해 포트원 서버에 신뢰 가능한 결제 정보를 달라는 요청을 하고, 응답을 받아 DB에 저장 후 결제 확정 응답을 합니다.
결제 프로세스를 간단하게 보자면 이와 같은데, 특이한 점은 서버가 신뢰할 수 있는 정보는 paymentId를 통해 포트원 서버로부터 받은 정보라는 점입니다. 다시말해 결제 프로세스를 처리하기 위해서 필요한것은 오직 paymentId 뿐이죠. 결제는 민감한 정보이고, 원칙적으로 서버는 클라이언트로부터 받은 정보를 신뢰해선 안되거든요. 조작이 간단하니까요.
따라서 클라이언트의 책임은 서버로 paymentId를 전달하는 것 뿐이고, 웹훅은 이를 보조하는 역할입니다. 둘 다 결제가 진행되었으니 확인해보라고 서버를 활성화 하는 역할이고 실제 검증에 대한 책임은 서버가 가지고 있죠. 클라이언트가 보내는 요청은 유저의 잘못된 조작으로 (갑작스러운 브라우저 창의 종료 등의) 유실될 수 있고, 웹훅도 100% 도착을 보장하지 않고 포트원의 사정에 따라 도착이 지연될 수 있습니다. 그래서 결제의 안정성을 위해서 양쪽에서 paymentId로 결제가 진행되었으니 확인해 보라는 알림을 보내는거죠.

하지만 이 안정성을 위한 조치에서 문제가 발생합니다. 클라이언트의 요청과 웹훅은 둘 중 뭐가 먼저 올거라는 보장이 없습니다. 둘 중 하나가 먼저 도착했다고 다른 하나가 오지 않는것도 아니구요. 그래서 둘 중 먼저 오는걸로 결제 정보 처리를 하고, 그 이후에 온거는 무시하는 설계를 구상했죠.
public License processOrRetryLicense(LicensePurchaseRequestDto dto, Long memberId, PaymentStatus initialStatus) {
Optional<License> existingLicense = licenseRepository.findByPaymentIdForUpdate(dto.paymentId());
if (existingLicense.isPresent()) {
// 기존 paymentId가 존재하는 경우
return processExistingLicense(dto, existingLicense.get());
} else {
// 새로운 paymentId 처리
return processNewLicense(dto, memberId, initialStatus);
}
}
위 코드와 같이 결제 정보가 있는지 확인하고, 만약 존재한다면 이미 확정되었음 처리하고, 존재하지 않는다면 새로운 결제가 발생한 것이므로 그에 맞는 처리를 하도록 설계하였습니다.

하지만 위와 같이 해놓았더니 간혹 동일한 paymentId를 가진 결제정보가 2개씩 저장되는 현상이 발생하였습니다.
결제가 복사가 된다구요??
클라이언트의 요청과 웹훅이 차례로 줄서서 들어왔으면 이런 문제가 발생하진 않았겠지만, 이 둘은 둘중 뭐가 먼저 올거라는 보장도 없고, 동시에 오지 않을거라는 보장도 없었습니다. 그래서 둘이 동시에 들어오면, 두 요청 모두 자신이 가지고 있는 paymentId가 새로 들어온 상태라고 판단하여 둘 다 DB에 저장된거죠. 참 골치가 아픈 상황이 아닐 수 없습니다.

큰맘먹고 붙인 결제 시스템에서 이런 ㅈ버그가 발생하다니... 너무 속상한 나머지 반드시 이 문제를 해결하겠다라는 의지가 차올랐습니다. 바로 테스트 환경부터 구성하기 시작했죠.
// when: 두 스레드가 동시에 동일 paymentId로 진입 (POST 요청과 웹훅)
var pool = Executors.newFixedThreadPool(2);
var barrier = new CyclicBarrier(2);
var latch = new CountDownLatch(2);
var startTime1 = new AtomicLong(0);
var startTime2 = new AtomicLong(0);
Runnable postTask = () -> {
try {
barrier.await();
startTime1.set(System.nanoTime());
licenseProcessingCore.processLicense(dto, saved.getId(), PaymentStatus.PENDING);
} catch (Exception ignored) {
} finally {
latch.countDown();
}
};
Runnable webhookTask = () -> {
try {
barrier.await();
startTime2.set(System.nanoTime());
licenseProcessingCore.processLicense(dto, saved.getId(), PaymentStatus.WEBHOOK_RECEIVED);
} catch (Exception ignored) {
} finally {
latch.countDown();
}
};
pool.submit(postTask);
pool.submit(webhookTask);
위와 같이 2개의 작업을 준비했습니다. POST 요청과 웹훅 요청이 동시에 들어오는 상황을 가정하여 각 스레드에 요청을 담아 동시에 진행되도록 하는 시나리오를 준비했죠. 그리고 매 테스트는 10번씩 반복 실행하여 혹시나 "운이 좋아" 성공하거나 실패하는 상황이 없도록 설계하였습니다.
코드 수정 없이, 현재 상황에서 10번의 테스트를 돌려본 결과

이렇게 똑같은 paymentId가 2개씩, 총 20개의 결제 데이터가 저장된걸 확인할 수 있었습니다.
그래서 이 현상의 해결 방법으로 생각했던 것이, 데이터베이스 조회 쿼리에 락을 거는 것입니다. 쉽게 설명하면 데이터베이스에 데이터를 삽입할 때, 하나가 삽입중이면 해당 테이블을 조회하거나 수정 하지 마라, 즉 요청이 동시에 오더라도 하나씩 줄지어서 들어와야 한다고 강제하는 겁니다. 그럼 같은 paymentId로 저장하는 요청이 동시에 들어오더라도, 하나의 트랜젝션만 수정 요청을 받아주고, 다른 트랜젝션은 첫번째 트랜젝션이 끝날때까지 접근하지 못하고 기다리도록 설계하는거죠.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT l FROM LicenseEntity l WHERE l.paymentId = :paymentId")
Optional<LicenseEntity> findByPaymentIdWithLock(@Param("paymentId") String paymentId);
이런식으로 레포지터리 코드, 즉 쿼리에 락을 걸어서 특정 paymentId가 존재하는지 확인하고 작성하는 트랜젝션이 수행될 때 다른 트랜젝션이 침범하지 못하도록 구현할 수 있으리라 생각했습니다. 그래서 이렇게 락을 걸어주고 테스트를 진행해보았습니다.
2025-10-03 04:18:47.133 [pool-6-thread-1] WARN o.h.e.jdbc.spi.SqlExceptionHelper - SQL Error: 1213, SQLState: 40001
2025-10-03 04:18:47.133 [pool-6-thread-1] ERROR o.h.e.jdbc.spi.SqlExceptionHelper - Deadlock found when trying to get lock; try restarting transaction
그랬더니 이번엔 저희가 원하는대로 하나의 주문정보만 DB에 저장되었습니다만, 위와 같은 에러가 발생하였습니다. 결제가 진행될때마다 위와 같은 에러가 발생하는걸 두고만 볼 수는 없었기에 로그를 살펴보았죠. Deadlock이 발생했다는 에러였습니다.
간단하게 요약하면, 동시에 요청이 들어왔는데 서로 락을 선점하려는 순환 오류가 생겨서 위 에러가 발생해서 트랜젝션 하나를 롤백했다는 뜻인데 이때는 이것을 그렇게 심각하게 생각하지 않았습니다. 그래서 그냥 별 생각 없이 "구글링 해보니까 락 경합 오류 생겼을 때는 재시도 로직 추가하면 된다는데?" 라고 생각하고 스프링부트의 Retry 옵션을 추가했죠.
@Retryable(
retryFor = {CannotAcquireLockException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 300, multiplier = 2)
)
@Transactional
public void processLicense(LicensePurchaseRequestDto dto, Long memberId, PaymentStatus initialStatus) {
log.info("이용권 처리 시작 - paymentId: {}, memberId: {}, initialStatus: {}",
dto.paymentId(), memberId, initialStatus);
// paymentId 처리
// ...
}
위 코드와 같이 Retryable 옵션을 추가해 주었습니다. 메서드 실행 중 CannotAcquireLockException 에러가 발생하면 (락 경합 에러가 발생하면) 300ms 의 간격을 두고 최대 3번까지 재시도 하라는 옵션입니다. 이 옵션을 적용하고 테스트를 실행하니 에러를 잘 잡아내면서 의도대로 동작하는 것처럼 보였죠.


그렇게 동시성 문제가 해결되는 듯 보였으나 테스트에서 이상한 점을 발견하였습니다.
저희 프로젝트에서는 결제 가능한 품목이 2가지 있었습니다. 프로젝트 내의 서비스를 체험하기 위한 이용권과, 서비스 내에서 제작된 상품을 실물로 배송받아볼 수 있는 주문 결제 이렇게 2가지가 있었죠. 그런데 이용권을 결제할 때는 평군 30ms 로 종료되던 요청이 배송 주문에서는 350ms 이상이 소요되었습니다.
이 상황이 이해가 되지 않았던게, 두 결제 프로세스는 함수 이름과 들어가는 필드명만 다르다 할 수 있을정도로 유사한 구조로 작성되어 있었습니다. 300ms의 차이는 아마 위에 설정했던 재시도 옵션이 원인일 것으로 생각되었습니다만, 그걸 감안해도 왜 배송 주문에서만 락 경합 에러가 발생하고 이용권 결제에서는 발생하지 않는건지 이해가 되지 않았죠.
심지어 성능이 약 10배 가까이 차이가 나는것도 심각한 문제였습니다. 0.3초가 사용자 입장에서는 정말 짧을 시간일 수 있지만, 서비스 운영 관점에서는 무시할 수 없는 차이입니다. 스프링의 스레드는 블록킹 방식으로 처리되기 때문에 스레드는 하나의 요청을 처리하는 동안 꼼짝도 할 수 없고, 10번의 요청을 처리할 수 있는 시간을 하나의 요청에 붙잡혀 있다는건 커다란 병목이죠. 그래서 두가지 결제 기능이 대체 무슨 차이가 있는건지 찾아보았습니다.
@Table(name = "license")
public class LicenseEntity extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "license_id")
private Long id;
private String paymentId;
// 기타 필드들
// ...
}
@Table(name = "orders")
public class OrderEntity extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String paymentId;
// 기타 필드들
// ...
}
그렇게 하나의 차이점을 발견할 수 있었는데요. License 테이블, 그러니까 이용권 결제 정보를 저장하는 테이블의 paymentId 필드에는 unique 제약조건이 존재하지 않았습니다. 물론 제약조건이 있어야 정상이고 제가 이용권 테이블에 제약조건 추가하는걸 깜빡 했던거 같습니다. 그런데 차이점을 찾아서 기분이 좋았던것도 잠시, 동시에 의문이 생겼죠.
paymentId에 걸려있는 unique 제약조건은 같은 값이 존재하지 않도록 하는건데 이게 왜 데드락을 유발하는거죠? 저희는 기존에 존재하지 않는 새로운 paymentId를 추가하려다가 데드락 상황이 발생했는데 이게 어떻게 unique 제약조건과 연결이 되는거죠? 심지어 unique 제약 조건이 원인이라면 데드락 에러가 아니라 데이터 중복 에러가 발생하는게 맞지 않을까요?
그리고 이용권 테이블에서는 paymentId에 걸린 unique 제약조건 없이 락만 걸었는데 동시성 문제가 해결된것처럼 보이네요? 그럼 order 테이블에서도 (배송 주문 테이블에서도) unique 제약 조건을 빼면 성능 병목이 없어지는 걸까요? 그런데 그게 제대로 된 설계가 맞을까요? 저희 프로젝트에서는 절대로 paymentId가 겹치는 결제가 있어서는 안되는데 말이죠.

오랜 시간이 걸릴것이 예상되었지만, 이걸 이대로 두고볼수도 없는 노릇이어서 gemini와 정말 긴 대화를 시작했죠. 현재 프로젝트의 맥락을 이해시키고 지금 이런 상황인데 이런 알 수 없는 결과가 보이고 있다. 대체 왜 이런거냐 라고 물어보고, gemini가 가설을 세우면 테스트를 통해 검증해보는 시간을 가졌습니다. 그리고 답을 찾았죠.

우선 위 상황을 이해하기 위해서는 unique 제약조건을 자세하게 알아볼 필요가 있습니다. unique 제약조건은 단순히 "데이터베이스의 특정 컬럼이 중복된 값을 갖지 않도록 하는 제약조건" 이라고 이해하면 안되고, "데이터베이스의 특정 컬럼이 중복된 값을 갖지 않도록 하며, 그걸 위해 인덱싱을 적용하는 제약조건" 이라고 이해를 확장해야 합니다.
만약 어떤 값을 추가할 때, 그 값이 이미 데이터베이스의 컬럼에 존재하는지를 확인하고 싶다면 어떻게 해야 할까요? 단순하게 생각하면 해당 컬럼의 값을 모두 조회하여 하나하나 대조해 봐야겠죠. 하지만 이는 성능상 커다란 병목이라고 볼 수 있습니다. 그래서 데이터베이스는 unique 제약조건을 만들때, 자동으로 해당 컬럼에 인덱스를 설정합니다. 이는 컬럼 내부의 값끼리 대소 비교를 할 수 있게 만들어주고 순차적인 구조로 값을 정렬할 수 있게 만들어줍니다. 정렬된 구조에서는 B-tree와 같은 알고리즘을 활용해 빠르게 값을 검색할 수 있죠.
따라서 "어떤 값을 삽입하고 싶다" 라는 요청이 들어오면, 데이터베이스는 이 값에 적절한 인덱스를 부여하고, 해당 인덱스가 들어가야 할 곳에 값이 없는지를 확인한 후, 값이 없다면 삽입을 하는 과정을 거칩니다.
이제 저희의 문제로 돌아와 봅시다. 저희는 JPA의 데이터 조회 쿼리에 PESSIMISTIC_WRITE 라는 락을 걸었습니다. 이 락은 해당 컬럼을 수정하는 명령이 포함되어 있는 트랜젝션이 실행되면, 다른 트랜젝션이 해당 컬럼에 접근하지 못하도록 하는 역할을 합니다. 이 락 때문에, unique 제약 조건이 있는 컬럼에 데이터를 삽입할 때는 2가지의 단계를 거칩니다.
1. paymentId에 인덱스 부여 후, 해당 값이 들어갈 위치에 아무런 값이 없는 걸 확인 한 후 그 범위에 Gap Lock을 겁니다.
2. 그 후, 해당 위치에 값을 삽입하기 위해 Gap Lock이 걸린 부분과 동일한 범위로 쓰기 락을 걸어 데이터를 삽입합니다.
이 단계를 거치다가 저희가 발견한 DeadLock 상황이 발생하였는데요. Thread-A와 Tread-B라는 요청이 동시에 들어왔다고 가정해봅시다. Thread-A는 paymentId가 들어갈 위치를 찾아 Gap Lock을 걸어줍니다. 동시에 Thread-B가 들어오지만, 해당 위치에 이미 Gap Lock이 걸린것을 확인합니다. Gap Lock은 공유될 수 없기에 Thread-B는 해당 락이 풀릴때까지 대기하게 되죠. 그리고 Thread-A는 값이 없는것을 확인했으므로 같은 위치에 쓰기 락을 시도합니다. 하지만 Thread-B의 대기 요청과 충돌이 납니다.
여기서 순환 대기 오류, 즉 Dead Lock 상황이 발생한거죠. Thread-A와 Thread-B 모두 같은 위치에 락을 걸기 위해서 서로가 포기할것을 기대하고 대기하고 있으니, 끝나지 않는 대기 상태가 발생합니다. 그래서 데이터베이스에서는 이 비정상적인 상황을 감지하고 둘중 하나의 트랜젝션을 롤백시킨다음에, 제가 설정한대로 300ms 후에 다시 시도합니다.
위 과정을 거쳤기 때문에, unique 제약조건이 동시에 들어온 두 쓰레드에 대해 Dead Lock 상황을 유발했던거죠.

그럼 이번엔 unique 제약조건이 없는 상황을 가정해 보죠. (위에서 license 테이블에 해당하는 시나리오입니다.) 이 경우에는 상황이 더 간단합니다. Thread-A와 Thread-B가 동시에 들어왔다고 가정했을 때, 이 2가지 쓰레드에는 paymentId를 삽입하는 요청이 포함되어 있습니다. 그리고 PESSIMISTIC_WRITE 라는 쓰기 락을 걸어야 하죠.
그런데, 지금의 paymentId에는 unique라는 제약조건도 없고, 인덱스도 없습니다. 이는 락을 걸어야 할 범위를 특정하지 못한다는 것을 의미합니다. 따라서 PESSIMISTIC_WRITE 락을 걸기 위해선 license라는 테이블 전체를 잠가야 합니다. Thread-A가 먼저 실행되었다고 가정했을 때, 그 외의 모든 요청들은 Thread-A의 작업이 종료된 이후에야 license 테이블에 접근이 가능한거죠.
그래서 Thread-A와 Thread-B의 시나리오만 생각해봤을 때는 상당히 이상적으로 보입니다. 언듯 생각해보았을 때 Thread-A가 작업하는 동안 license 테이블은 안전히 보호받고 있으며, Thread-B는 Thread-A의 작업이 끝날때까지 기다리다가 그 이후에 처리를 시작하니까, 저희가 걱정했던 동시성 문제가 완전히 해결된것처럼 보이죠. 실행 시간도 평균 30ms 정도로 많이 단축되었구요.
하지만 이는 시스템 설계에서 그리 좋지 못한 구조입니다. 전체적인 시스템 구조를 보았을 때, paymentId에는 unique 제약조건이 있는것이 압도적으로 유리한 trade-off를 갖습니다. 저희 프로젝트에는 paymentId를 통해 주문 정보를 조회하는 기능이 있는데 이 경우 paymentId에 인덱싱이 되어 있는것이 압도적으로 빠른 속도를 낼 수 있죠. 또한, 데이터를 저장 할 때 데이터베이스는 데이터의 무결성을 보장하는 최후의 방어선입니다. paymentId는 민감한 결제정보이고, 절대로 중복되는 값이 저장되어선 안됩니다. 따라서 paymentId에서 unique 제약조건을 빼는것은 설계 원칙에 어긋난다고 보는것이 맞죠.
사실 성능상으로도 크게 이점이 있다고 보기 힘듭니다. Thread-A가 작업 중 license 테이블 전체에 락을 건다는 것은, license 테이블을 필요로 하는 다른 모든 요청들의 병목을 감수해야 하거든요. 예를 들어서, 누군가 결제를 진행하고 Thread-A가 실행되고 있다면 그 시점에서는 license 테이블 조회, 수정 삭제가 불가능해집니다. Thread-A가 끝날때까지 기다려야 하죠. 이정도면 trade-off에 의한 설계가 아니라 그냥 명백하게 잘못된 설계로 보는게 맞습니다.

여기까지 오니까, 동시성 문제를 해결하기 위해서 "JPA에 락을 걸어야겠다" 라는 생각이 잘못되었다는 것을 인정할 수밖에 없었습니다. 동시성 문제를 해결하기 위해서는 크게 비관적 락 방식과 낙관적 락 방식이 있습니다. 비관적 락 방식은 동시성 문제가 생길것을 가정하고 테이블에 락을 거는 방식이고, 낙관적 락 방식은 동시성 문제가 없을것이라 가정하고 일단 해보고, 안되는건 그때 가서 처리하자 라는 방식입니다.
이제까지 비관적 방식을 통해 해결하고자 했으나, 저희의 시나리오에서는 틀려먹은 방식이었습니다. 처음부터 정신 차리고 있었으면 이렇게 길게 돌아올 필요도 없었을 텐데요 하하...
그래서 다음으로 생각한 방식은 Upsert라고 불리는 방법입니다. Upsert 는 insert와 update가 합쳐진 방식이라고 할 수 있는데, 아래와 같은 철학으로 동작합니다.
1. paymentId가 들어오면 일단 DB에 삽입 시도 (insert)
2. 만약 중복 데이터 오류가 감지되면 catch하여 처리 (update)
public License processOrRetryLicense(LicensePurchaseRequestDto dto, Long memberId, PaymentStatus initialStatus) {
try {
// 1. (Happy Path) 새로운 이용권이라고 가정하고 먼저 생성을 시도합니다.
// 이 과정에서 DB에 INSERT 쿼리가 실행됩니다.
License newLicense = processNewLicense(dto, memberId, initialStatus);
log.info("새로운 이용권 생성 성공. paymentId: {}", dto.paymentId());
return newLicense;
} catch (DataIntegrityViolationException e) {
// 2. DataIntegrityViolationException 예외가 발생한 경우
// (paymentId UNIQUE 제약조건 위반, 즉 이미 다른 스레드가 INSERT에 성공함)
log.warn("이용권 중복 생성 시도 감지. 기존 데이터를 조회합니다. paymentId: {}", dto.paymentId());
// 3. 이미 존재하는 이용권을 DB에서 조회합니다.
// 여기서는 비관적 락이 없는 일반 findByPaymentId 메서드를 사용합니다.
License existingLicense = licenseRepository.findByPaymentId(dto.paymentId())
.orElseThrow(() -> new IllegalStateException("Duplicate entry detected but cannot find the license."));
// 4. 기존 이용권에 대한 후속 처리를 진행합니다.
return processExistingLicense(dto, existingLicense);
}
}
처음의 메서드를 upsert 방식으로 수정하면 위 코드와 같이 수정할 수 있습니다. 동시성 문제를 예방하는 것이 아닌, 에러가 생겼을 때 동시성 문제가 발생하였다 판단하여 후속 처리를 하는거죠. 데이터베이스에 unique 제약조건이 있기 때문에 정상적으로, 깔끔하게 진행됩니다. 만약 이 코드를 처음 본 개발자들이 있더라도 어렵지 않게 이해할 수 있구요. 훨씬 깔끔해지죠. 락으로 인해 유발되는 병목 현상도 없구요. 애초에 락이 필요하질 않으니까요.
위와 같이 코드를 수정하고 정말 심란했습니다. 그냥 처음부터 paymentId에 unique 제약 조건을 까먹지 않고 걸어놨으면 이렇게 먼 길을 돌아올 필요가 없었을 거 같더라구요. 그냥 데이터 중복 에러가 간혹 발생하니까 catch 해서 처리해야겠다 라고 생각이 이어졌을 거 같은데, 냅다 결제가 복사되는 ㅈ버그부터 발견하니까 생각이 이상한 곳으로 흘러갔던거 같았습니다.

정말 길고 복잡하고 어려운 내용을 공부하고 파해쳤지만 그에 비해 결론은 좀 허무한 에피소드였습니다. 데이터베이스에 대한 지식이 더 깊어졌다는 것에 위안을 삼고 마무리 해보겠습니다. 모두들 중간에 포기하지 않고 끝까지 읽어주셨으리라 믿고 있겠습니다.
다들 읽어주셔서 감사합니다 d^^b
'[프로젝트 일지]' 카테고리의 다른 글
| 보안을 위한 DB 테이블 설계 (0) | 2025.11.24 |
|---|---|
| 트랜잭션과 병렬처리에 대한 고찰 (3) | 2025.10.23 |
| pdf 관련 기능 도입기 (2) | 2025.10.13 |
| 대용량 이미지 데이터의 (base64) 요청과 응답 처리 (7) | 2025.09.27 |