서비스에는 언제나 동시성 문제가 발생할 수 있다.
동시성이 발생 할 수 있는 포인트를 찾아서 동시성 문제를 사전에 해결하고자 한다.
그린위닛 프로젝트에서 Point 관리에 대해 동시성 이슈가 발생할 것으로 보였다.
@Service
@RequiredArgsConstructor
public class PointTransactionService {
private final PointTransactionRepository pointTransactionRepository;
public void spendPoints(Long memberId, PointAmount spendAmount, PointSource pointSource) {
PointAmount currentAmount = getPointAmount(memberId);
if (!currentAmount.canSpend(spendAmount)) {
throw new PointException(PointExceptionMessage.NOT_ENOUGH_POINT);
}
PointTransaction spend = PointTransaction.spend(memberId, pointSource, spendAmount, currentAmount);
pointTransactionRepository.save(spend);
}
public void earnPoints(Long memberId, PointAmount earnAmount, PointSource pointSource) {
PointAmount currentAmount = getPointAmount(memberId);
PointTransaction earn = PointTransaction.earn(memberId, pointSource, earnAmount, currentAmount);
pointTransactionRepository.save(earn);
}
public PointAmount getPointAmount(Long memberId) {
return pointTransactionRepository.findLatestBalance(memberId)
.orElseGet(PointAmount::ofZero);
}
}
포인트 내역 관련 서비스는 아래와 같다.
1. 관리자가 챌린지 인증을 확인하고 포인트를 지급한다.
2. 사용자는 포인트 상점에서 포인트 상품 교환시 포인트를 사용한다.
여기서 동시성 문제가 식별 될 수 있다.
1. 여러 관리자가 동시에 한 사용자에게 포인트 지급
얼핏 보면 PointTransaction Table을 따로 분리해서 데이터를 삽입하기 때문에, 문제가 발생하지 않을 것처럼 보일 수 있다.
하지만 해당 방식은 심각한 문제가 발생한다.
문제 상황 가정: 사용자의 포인트 내역이 없는 상황
동시에 두 관리자가 한 사용자의 포인트를 1000p 지급
Thread1: 처음 사용자 조회 시 0p
Thread2: 처음 사용자 조회 시 0p
Thread1: current: 0p + earn: 1000p의 balance
Thread2: current: 0p + earn: 1000p의 balance
결과적으로 DB에는 두 건의 데이터가 저장됐지만
| id | memberId | type | amount | balance | createdAt | createdBy |
|---|---|---|---|---|---|---|
| 1 | 1 | EARN | 1000 | 1000 | .. | .. |
| 2 | 1 | EARN | 1000 | 1000 | .. | .. |
과 같이 저장 될 것이다.
사용자 입장에서 보자.
현재 포인트 조회시: 1000p (어? 나 2000p 지급되어야하는데?)
전체 포인트 내역 요약시: 지급=2000p, 차감=0p, 현재=1000p
이런 심각한 시나리오가 발생할 수 있다.
2. 사용자가 상품 구매를 동시에 진행한다면?
이것은 더 문제다.
맥락 자체는 1번과 동일하지만 1번 시나리오는 사용자가 문의 시 복구하면 된다.
2번 시나리오는 상품을 다 배송하고 나서 보니 이런 문제를 식별했다면 자산에 손해가 생긴다. 끔찍한 시나리오다.
사실 상 해당 문제들은 테스트 없이, 해결할 수 있다.
하지만, 미리 테스트 코드를 만들어 식별하고 코드 리팩토링 혹은 로직 변경 시에도 안정성을 체크할 수 있도록 미리 테스트 코드를 만들 수 있다.
그럼 이제 실제 동시성 문제를 식별하기 위해 발생할 수 있는 포인트들을 찾고, 테스트 코드를 만들어 안전성 높은 코드로 만들어보자.
CountDownLatch와 ExecutorService를 활용하면 된다.
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger failureCount = new AtomicInteger(0);
CountDownLatch latch = new CountDownLatch(config.getThreadCount());
int threadCount = 2;
try (ExecutorService executor = Executors.newFixedThreadPool(threadCount) {
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
// task
successCount.incrementAndGet();
} catch (Exception e) {
failureCount.incrementAndGet();
} finally {
latch.countDown();
});
}
}
}
assertThat(successCount.get()).isOne();
assertThat(failureCount.get()).isOne();
만약, 동시성 문제가 잘 해결된 코드라면 2개의 스레드 중 1개는 실패하는 시나리오가 발생하는 것을 활용하는 방법이다.
동시성 문제는 CPU의 스케줄링 때문에 어느 도메인이든 발생할 수 있는 당연한 시나리오다. 그래서 팀원도 쉽게 사용할 수 있도록 템플릿 메서드 패턴을 활용해 테스트 도구를 제작해보자.
public class ConcurrencyTestTemplate {
private final ConcurrencyTestExecutor executor;
public ConcurrencyTestTemplate() {
this.executor = new ConcurrencyTestExecutor();
}
public ConcurrencyTestResult executeInParallel(
Supplier<Boolean> task,
ConcurrencyTestConfig config
) throws InterruptedException {
return executor.executeInParallel(task, config);
}
public static ConcurrencyTestBuilder build() {
return new ConcurrencyTestBuilder(new ConcurrencyTestTemplate());
}
//... 그외
}
@RequiredArgsConstructor
public class ConcurrencyTestBuilder {
private final ConcurrencyTestTemplate template;
private final ConcurrencyTestConfigBuilder configBuilder = ConcurrencyTestConfig.builder();
// ... 빌드 메서드
}
@Getter
@Builder
public class ConcurrencyTestConfig {
@Builder.Default
private final int threadCount = 2;
@Builder.Default
private final int timeoutSeconds = 10;
@Builder.Default
private final String testName = "동시성 테스트";
}
@Slf4j
public class ConcurrencyTestExecutor {
public ConcurrencyTestResult executeInParallel(
Supplier<Boolean> task,
ConcurrencyTestConfig config
) throws InterruptedException {
// ... 앞선 실행 코드와 동일
}
// 그 외 실행 메서드..
}
이제 예상한 시나리오가 정말 예상한대로 동작하는지 확인해보자. 시나리오 설명이 조금 부족했던 포인트 차감에 대한 시나리오를 확인해본다. (기본적으로 지급했던 시나리오와 동일합니다.)
@Test
void 잔액이_1000p_일_때_동시에_같은_사용자의_600p가_차감되면_안된다() throws InterruptedException {
// given - 1000포인트 적립
Long memberId = 1L;
service.earnPoints(memberId, PointAmount.of(1000), PointSource.ofEvent("초기적립"));
AtomicInteger exceptionCount = new AtomicInteger(0);
// when - 2개 스레드가 동시에 600포인트씩 차감 시도 (총 1200포인트 필요)
ConcurrencyTestResult result = ConcurrencyTestTemplate.build()
.threadCount(2)
.timeout(5)
.execute(() -> {
try {
log.info("스레드 {} - 시작 시 잔액: {}", Thread.currentThread().getName(),
service.getPointAmount(memberId).getAmount());
service.spendPoints(memberId, PointAmount.of(600),
PointSource.ofTarget(1L, "상품구매", TargetType.EXCHANGE));
log.info("스레드 {} - 성공", Thread.currentThread().getName());
return true;
} catch (PointException e) {
exceptionCount.incrementAndGet();
log.error("스레드 {} - 실패: {}", Thread.currentThread().getName(), e.getMessage());
return false;
}
});
// then - 현재 상황 출력
log.info(result.toString());
log.info("총 예외 발생 횟수: {}", exceptionCount.get());
log.info("최종 잔액: {}", service.getPointAmount(memberId).getAmount());
List<PointTransaction> all = repository.findAll();
for (PointTransaction pointTransaction : all) {
log.info(
"{}: memberId: {{}} targetId: {{}} description {{}} targetType {{}} pointAmount {{}} balance {{}}",
pointTransaction.getId(), pointTransaction.getMemberId(),
pointTransaction.getPointSource().getTargetId(), pointTransaction.getPointSource().getDescription(),
pointTransaction.getPointSource().getTargetType(), pointTransaction.getPointAmount().getAmount(),
pointTransaction.getBalanceAfter().getAmount());
}
}
이 코드로 먼저 결과를 확인해보면 아래와 같다.

처음 예상한 시나리오대로 다른 Id로 600원이 2번 적립되었고, 현재 최종 잔액은 400원이다. -> 심각한 버그
의도적으로 실행한거지만, 실제로 이런 일이 발생할까?
이런 고민도 한 번 해봐야한다고 생각한다. 지금 내가 너무 먼 미래를 생각한 것일까? 하지만, 백엔드 개발자라면 CS 지식을 기본으로 당연히 예상하고 있어야한다.
크게 대표적으로 2가지의 상황이 있을 것 같다.
무엇이 됐든 결국 서비스에 손해가 발생할 일이다. 그러므로 언제든 동시성 문제는 대비하는게 좋다. 여러 데이터에서 장애가 발생하고 뒤늦게 대비하면 추적하기도 힘들다.
사실상 관리자가 포인트 지급 + 사용자가 상품 구매하는 시나리오가 동시성이 발생할 확률은 더 높다!
이번에는 시나리오 확인도 다 끝났고 테스트를 통해 문제를 해결해보자.
@Test
void 잔액이_1000p_일_때_600p_차감이_두번_요청되면_한_건만_처리된다() throws InterruptedException {
// given
Long memberId = 1L;
service.earnPoints(memberId, PointAmount.of(1000), PointSource.ofEvent("초기적립"));
// when
ConcurrencyTestResult result = ConcurrencyTestTemplate.build()
.threadCount(2)
.timeout(1)
.execute(() -> service.spendPoints(
memberId,
PointAmount.of(600),
PointSource.ofTarget(1L, "상품구매", TargetType.EXCHANGE)
));
// then
assertThat(result.successCount()).isOne();
assertThat(result.failureCount()).isOne();
}
/* result
expected: 1
but was: 2
필요:1
실제 :2
*/
현재는 동시성 문제 때문에 당연히 실패할 것이다.
해당 문제는 row 간 삽입할 때, 문제이므로 Transaction으로 처리 할 수 없다!
그래서 비관적 락(Pessimistic Lock) 설정을 해줘야 한다!
public interface PointTransactionRepository extends JpaRepository<PointTransaction, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""...""")
Optional<PointAmount> findLatestBalance(@Param("memberId") Long memberId);
}
@Lock(LockModeType.PESSIMISTIC_WRITE) 어노테이션으로 Lock을 설정할 수 있다.
org.springframework.dao.CannotAcquireLockException: could not execute statement [Deadlock found when trying to get lock; try restarting transaction] [insert into POINT_TRANSACTIONS (BALANCE_AFTER,CREATED_DATE,MEMBER_ID,MODIFIED_DATE,POINT_AMOUNT,DESCRIPTION,TARGET_ID,TARGET_TYPE,TYPE) values (?,?,?,?,?,?,?,?,?)]; SQL [insert into POINT_TRANSACTIONS (BALANCE_AFTER,CREATED_DATE,MEMBER_ID,MODIFIED_DATE,POINT_AMOUNT,DESCRIPTION,TARGET_ID,TARGET_TYPE,TYPE) values (?,?,?,?,?,?,?,?,?)]
하지만 이렇게 실행하면, 예외 메시지에 친절하게 락 획득 중 데드락이 발생했다고 한다.
왜 데드락이 발생할까?
MySQL InnoDB의 특별한 처리 방법 때문이다.
여기서 다룰 내용은 아니므로 간략한 예시를 보자.
T1: SELECT ... FOR UPDATE
→ 레코드 락 획득 (member_id=1 레코드 점령)
→ 비즈니스 로직 처리 중...
→ INSERT 시도 → "AI 락 주세요!"
→ InnoDB: "어? T2가 이미 AI 락 예약중이네... 대기하세요"
T2: SELECT ... FOR UPDATE 시도
→ "레코드에 접근하고 싶은데 T1이 점령중이네..."
→ InnoDB: "그럼 레코드 대기하면서 AI 락이라도 미리 예약해둘게!"
→ 레코드 락 대기 중...
이 과정에서 InnoDB는 데드락을 인지하고 예외를 발생해준다.
데드락이 발생하면 어떻게 처리할 것인가?
운영체제 학습을 해봤다면, 한 번은 본 내용이 있다.
orstrich algorithm
실제로 데드락 발생 확률도 매우 낮고, 현재 예상 시나리오는 동시 접근은 많아봤자 2건 정도이기 때문에 그냥 무시하고 재시도하면 된다.
@Retryable을 활용하기 전에, 적당한 시간 값도 계산해보자.

로그를 보면 T1이 먼저 실행했지만, AI락을 획득 하는 과정에 실패한 것을 확인할 수 있다. 이어서, T2가 작업을 끝내고 T1이 재시도를 시작한다.
이 때, 로그 사진에서 볼 수 있듯 T2가 작업을 시간하고 끝나는 시간은 6.48ms 이다. 보통 50ms를 설정하는데 50ms는 페이커 반응속도보다 빠르기 때문에 전혀 문제 없을 것으로 보인다.
@Retryable(
retryFor = CannotAcquireLockException.class,
backoff = @Backoff(delay = 50)
)
public void spendPoints(Long memberId, PointAmount spendAmount, PointSource pointSource) {...}
이미 로그에서 테스트 결과를 봤지만, 다시 테스트를 해보자!

많은 동시성이 발생한다면 ?
나같은 취준생은 경험해보지도 못하겠지만, application lock을 활용해야된다. 안그럼 DB CPU 경합이 너무 잦아진다.
현재 우리 프로젝트는 오버 엔지니어링이므로 고려만 해두자!
동시성 문제는 "언젠가 발생할 수 있는" 문제가 아니라 "반드시 발생할" 문제입니다. 특히 금융/포인트 관련 도메인에서는 한 번의 실수가 큰 손실로 이어질 수 있다고 생각합니다.
1. 테스트 주도 해결
막연한 걱정보다는 실제 테스트 코드로 문제를 재현하고 해결하는 것이 효과적이었고 실제 변화를 관측하니 재밌었습니다. ConcurrencyTestTemplate같은 도구를 만들어두면 팀 전체가 쉽게 동시성 테스트를 할 수 있겠네요.
2. 적절한 기술 선택
복잡한 분산락보다는 DB 수준의 비관적 락 + 재시도로 충분히 해결할 수 있었습니다. 현재 규모에 맞는 적절한 해결책을 선택하는 것이 중요하다는 걸 깨달았습니다.
3. 데드락은 친구
처음엔 데드락을 보고 당황했지만, 결국 MySQL이 동시성 문제를 감지하고 알려준 것이었습니다. Ostrich Algorithm으로 재시도하면 깔끔하게 해결되더군요.
현재는 단순한 포인트 차감/적립만 다뤘지만, 실제 서비스에서는 더 복잡한 시나리오들이 있을 것으로 예상됩니다.
이런 상황에서는 분산락이나 더 정교한 동시성 제어가 필요할 수도 있겠네요. 하지만 지금처럼 테스트로 문제를 재현하고, 단계적으로 해결해나가는 접근법은 계속 유효할 것 같습니다.
동시성 문제, 생각보다 어렵지 않았습니다! 😊