동시성 문제 잘못된 해결 사례

이동근·2025년 7월 14일

Mini Project

목록 보기
8/8
post-thumbnail

🎀 프로젝트

🔖 프로젝트 목표

  • 목표 : 온라인 쇼핑몰에서 일어나는 동시성 문제와 캐싱 기능을 활용해보기

🎗️ 사용 기술

  • Spring Boot 3.3.3
  • MySQL (Database)
  • Docker
  • Redis, Redisson
  • QueryDSL, Native Query
  • JWT, BCrypt
  • Spring Security

🖼️ 와이어 프레임

  • 로그인 / 회원가입

  • 상품 목록 / 구매


🎨 ERD


📜 API 명세서

  • API 명세서

    • 회원,인증,인가

    • 상품 (생성,조회, 수정, 삭제 등)

    • 주문 (생성, 목록조회, 취소, 주문 물품 상세 조회)

    • 장바구니 (등록, 조회, 삭제)


🧵 동시성 제어 프로젝트 요약

🔧 필수 구현 기능

  • 트래픽 폭주 상황을 가정한 어플리케이션 기획 (ex. 티켓팅, 선착순 이벤트 등)

  • 동시성 문제 발생 테스트 코드 작성 (실패하는 테스트 포함)

  • 낙관적 락, 비관적 락, Lettuce, Redis 분산 락 4가지 중 1가지를 선택 → 사용하는 근거가 존재해야함 (ex : 성능 등)

  • 락 적용 전후 테스트로 동시성 이슈 해결 여부 검증


💫 트러블 슈팅

🧩주문 취소 시 동시성 문제

  • 문제 상황

    • 여러 스레드가 동시에 하나의 주문을 취소하려 할 때, 중복 취소 발생 우려

    • JUnit을 이용한 테스트 코드를 작성하여 동시성 테스트 시도


  • 잘못된 시도

    • cancelOrder()에서 주문 상태를 확인(if (order.getStatus() == ...))하여 중복 취소 방지 시도

    • 예외 발생을 기대했지만, 모든 스레드가 동일한 상태(ORDERED)를 보고 실행 → 실패 없음


  • 원인 분석

    • JPA는 상태 변경을 커밋 시점에 반영 → 다른 스레드가 변경 상태를 인식 못함

    • 상태 확인도 락 내부에서 수행해야 정확성 확보 가능


  • 해결 방향

    • 락을 통해 하나의 스레드만 주문을 취소하게 하고

    • 나머지 스레드는 이미 취소된 상태를 감지해 예외 처리 → 안정적인 동시성 제어


  • 결론

    • “예외가 없다고 동시성 문제가 없는 게 아니다. 재시도 수가 많다면 충돌은 이미 발생한 것.”

🧩주문 생성 vs 주문 취소 시 락 대상 차이

  • 문제 인식

    • 주문 생성에는 상품에 락,

    • 주문 취소에는 주문에 락을 거는 구조가 왜 다른가?


  • 이유 분석

    • 주문 생성 시:

      • 어떤 상품을 몇 개 담았는지 모르기 때문에 → 상품별 재고 확인 필요

      • 상품 ID 기준으로 락을 걸어야 재고 충돌 방지 가능

    • 주문 취소 시:

      • 주문 정보에 이미 어떤 상품을 몇 개 주문했는지가 명확함

      • → 주문 하나만으로 재고 복원 가능 → 주문 ID 기준으로 락


✅ 즉, 공유 자원이 다르기 때문:

생성 시 공유 자원은 상품,

취소 시 공유 자원은 주문


🔁 재시도 구조 주의사항

  • 락이 없는 경우 재시도 구조는 유효 (경쟁 상황에서 성공 확률을 높여줌)

  • 락이 있는 경우 실패 원인이 재고 부족이라면 재시도해도 동일 결과 → 의미 없음

    • 락 기반에서는 재시도 횟수보다 충돌 여부 자체가 중요

🧩 락을 주었는데 스레드가 락을 제대로 적용하지 못한 문제

  • 문제 현상

    • 락을 적용한 주문 취소 기능 테스트 중 중복 취소가 발생하여 재고 불일치 오류가 발생함

    • 락을 걸었기 때문에 안전할 것이라 생각했지만 실제로는 주문 상태(OrderStatus)가 CANCELED로 인식되지 않는 문제가 원인


  • 🧪 원인 분석

    • 주문 상태 업데이트는 JPA의 Dirty Checking(변경 감지)에 의존하고 있었음

    • JPA는 트랜잭션 커밋 시점에 일괄적으로 변경 사항을 반영하기 때문에

      • 스레드 A가 주문 상태를 CANCELED로 바꾸고 아직 커밋되지 않은 상태에서

      • 스레드 B가 락을 획득하고 같은 주문을 조회하면 여전히 ORDERED 상태로 조회됨

      • 결국 스레드 B도 중복 취소를 진행 → 재고가 이중으로 증가


  • 🔧 해결 시도 및 조치
  1. JPA → NativeQuery로 변경

    • 즉시 업데이트를 반영하기 위해 JPA의 변경 감지 대신 UPDATE 쿼리 사용

    • 하지만 트랜잭션이 커밋되지 않으면 다른 트랜잭션에서는 여전히 이전 값만 조회 가능

  2. Redisson 락 해제 시점 조정

    • 락을 트랜잭션 커밋 후에 해제되도록 TransactionSynchronizationManager를 통해 처리

    • 하나의 스레드가 트랜잭션을 커밋하고 나서야 다음 스레드가 락을 획득할 수 있도록 설정


  • ⚠️ 주의점

    • tryLock(waitTime, leaseTime) 방식에서 leaseTime`을 너무 짧게 설정하면,

      • 트랜잭션이 커밋되기 전에 락이 해제되어 다른 스레드가 작업을 수행할 수 있음

      • 이로 인해 동시성 문제가 다시 발생할 수 있음**

    • 트랜잭션 커밋 시점까지 락을 보장하려면:

      • lock()을 사용하여 watchdog 기반 자동 갱신을 활용하거나

      • leaseTime을 충분히 넉넉하게 설정해야 함


  • ✅ 결론

    • JPA의 변경 감지는 트랜잭션 커밋 이전에는 다른 트랜잭션에서 확인할 수 없음

    • 락을 걸었다고 무조건 안전한 것은 아님

    • 락 유지 시간과 커밋 시점 간의 관계를 정확히 이해하고 조절해야 동시성 문제를 방지할 수 있음


💡 아쉬운 점

  • 설계 부분에서 조금 디테일하게 정해야하는 부분이 많았다고 생각함

    • 요구사항 설정, 와이어 프레임, ERD, Entity 설정, 어디에서 캐시를 적용할지, 동시성 문제에 대해 성능 비교, 락을 어떻게 구체적으로 정할것이고 어떤 상황에 사용할 것인지

    • 어떤 락을 사용할 것인지 (비관적 락, 낙관적 락, synchronized, lettuce, Redisson 등)

    • 캐시 설정, 캐시 성능 비교 등

  • 도메인별로 개발 파트를 나누니 뒤에 부분을 맡은 사람들은 앞쪽 코드에 맞춰 코드가 밀리는 상황이 발생 → 충돌을 일으킬수 있으므로 코드를 작성할 때 dto 이름, 어떤 메서드가 필요할지 생각을 많이 해야할거 같다고 느낌..

profile
안녕하세요

0개의 댓글