[Java & Spring] 낙관적 락, 비관적 락 (with. JPA와 AOP)

2025. 8. 2. 23:55·Java & Spring
반응형

1️⃣ 들어가며 – ReentrantLock의 한계

기존에 ReentrantLock과 ConcurrentHashMap을 활용해 인메모리 환경에서 동시성 제어를 시도해봤습니다.
아래 글들을 참고해주세요.

 

👉 ReentrantLock, 쿠폰 발급으로 실습하고 테스트 코드로 증명해보기 | ReentrantLock 정리

 

[Java] ReentrantLock 정리 (with. synchronized)

1️⃣ ReentrantLock 개요🔧 등장 배경멀티스레드 환경에서 공유 자원을 안전하게 다루기 위해 자바는 오래전부터 synchronized 키워드를 제공해왔다. 하지만 단순하고 자동화된 방식 뒤에는 유연성

20s-hoon.tistory.com

 

하지만 단일 애플리케이션 서버에서의 락 제어는 수평 확장 환경에서는 무의미해집니다.

두 가지 락 전략  ✅ 낙관적 락 (Optimistic Lock) / ✅ 비관적 락 (Pessimistic Lock) 을 실습 및 AOP 로깅과 함께 다뤄보겠습니다.

 


 

2️⃣ 낙관적 락 vs 비관적 락 비교

구분 낙관적 락 비관적 락
락 획득 시점 커밋 시까지 별도 락 없음 조회 시점에 행 즉시 락 (FOR UPDATE)
충돌 감지 방식 @Version 필드로 버전 불일치 확인 DB 레벨의 Row Lock으로 충돌 방지
장점 락 대기 없음, 읽기 위주에 적합 재시도 로직 불필요, 충돌 자체 차단
단점 충돌 시 재시도 필요 대기/데드락 가능성

3️⃣ 원리 및 적용 방법

3.1 낙관적 락 (Optimistic Lock)

  1. 원리
    • 엔티티에 @Version 필드를 두어 버전을 관리
    • 트랜잭션 시작 시 조회한 버전 값을 기억
    • 커밋 단계에서 
    • UPDATE coupons SET issued = ?, version = version + 1 WHERE id = ? AND version = <조회 시 기억한 버전>
    • DB의 현재 버전과 조회 시 기억한 버전이 일치하면 1행이 업데이트되고 커밋 성공 → 버전이 1 증가
    • 불일치하면 업데이트 대상이 없어져(영향받은 행 = 0) JPA가 OptimisticLockException을 던져 롤백
  2. 흐름 예시
    • 초기 상태
      id=100, issued=false, version=1
    • 스레드 A, B 동시 조회 → 둘 다 version=1 읽음
    • 스레드 A 커밋
      UPDATE coupons
      SET issued=true, version=2
      WHERE id=100 AND version=1;  -- 성공, version 1 에서 2로
    • 스레드 B 커밋 시도
      UPDATE coupons
      
      SET issued=true, version=2
      
      WHERE id=100 AND version=1;  -- 실패, 업데이트 대상 없음 / 예외 발생
  3. 장단점
    • 장점: 락 대기 없음, 읽기 위주·분산 환경에 적합
    • 단점: 충돌 시 재시도 로직 필요, 충돌 빈도 높으면 성능 저하

3.2 비관적 락 (Pessimistic Lock)

  1. 원리
    • JPA 리포지토리 메서드에 @Lock(LockModeType.PESSIMISTIC_WRITE) 지정
    • 실행 시점에 SQL SELECT … FOR UPDATE 발행 → 해당 행을 즉시 잠금
    • 다른 트랜잭션은 락 해제 전까지 대기하거나 예외
    • 트랜잭션 커밋/롤백 시점에 잠금 해제
  2. 흐름 예시
    • 초기 상태
      id=100, issued=false, version=1
    • 스레드 A findByCodeForUpdate() → DB가 row 잠금, version=1 읽음
    • 스레드 B findByCodeForUpdate() → A가 락 해제될 때까지 대기
    • 스레드 A 완료
      UPDATE coupons
      
      SET issued=true, version=1  -- 버전 필드 변경은 없거나 자동 증가 없이 그대로
      
      WHERE id=100;
      
      COMMIT;  -- 락 해제
    • 스레드 B 잠금 획득 후 조회 → issued=true 검증 실패 → 예외
  3. 장단점
    • 장점: 충돌 자체를 사전 차단, 재시도 로직 불필요
    • 단점: 대기 시간·데드락 위험, 높은 동시성 환경에서 비효율

3.3 선택 가이드

상황 추천 전략
읽기 위주, 충돌 드문 환경 ✅ 낙관적 락
쓰기 많고 충돌 빈번 ✅ 비관적 락

 


4️⃣ 코드 소개

💡 Coupon 엔티티

@Entity
@Table(name = "coupons")
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Coupon {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String code;

    @Column
    private Long ownerId;

    @Column(nullable = false)
    @Builder.Default
    private boolean issued = false;

    @Version
    private Long version;

    public void issueTo(Long ownerId) {
        this.ownerId = ownerId;
        this.issued = true;
    }
}

📦 Repository

@Repository
public interface CouponRepository extends JpaRepository<Coupon, Long> {
    Optional<Coupon> findByCode(String code);

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select c from Coupon c where c.code = :code")
    Optional<Coupon> findByCodeForUpdate(@Param("code") String code);
}

🧠 Service

@Service
@RequiredArgsConstructor
public class CouponService {
    private final CouponRepository couponRepository;

    // 낙관적 락
    @Transactional
    public void issueCouponWithOptimisticLock(CouponRequest request) {
        Coupon coupon = couponRepository.findByCode(request.code())
            .orElseThrow(() -> new IllegalArgumentException("해당 코드의 쿠폰이 존재하지 않습니다."));
        if (coupon.isIssued()) {
            throw new IllegalStateException("이미 발급된 쿠폰입니다.");
        }
        coupon.issueTo(request.ownerId());
        try {
            couponRepository.save(coupon);
        } catch (ObjectOptimisticLockingFailureException e) {
            throw new RuntimeException("동시성 충돌 발생: 낙관적 락", e);
        }
    }

    // 비관적 락
    @Transactional
    public void issueCouponWithPessimisticLock(CouponRequest request) {
        Coupon coupon = couponRepository.findByCodeForUpdate(request.code())
            .orElseThrow(() -> new IllegalArgumentException("해당 코드의 쿠폰이 존재하지 않습니다."));
        if (coupon.isIssued()) {
            throw new IllegalStateException("이미 발급된 쿠폰입니다.");
        }
        coupon.issueTo(request.ownerId());
        couponRepository.save(coupon);
    }
}

5️⃣ 테스트 코드를 통한 검증

CountDownLatch를 이용해 5개 스레드가 동시에 쿠폰 발급을 시도했을 때

  • 낙관적 락(Optimistic Lock) 과
  • 비관적 락(Pessimistic Lock)

각각 성공은 단 1회만 일어나는지 확인합니다.

🔍 테스트 시나리오

  1. 데이터 초기화
    • 매 테스트마다 쿠폰 테이블을 비우고 issued=false 상태의 쿠폰 1개(TEST_CODE)를 삽입합니다.
  2. 다중 스레드 준비
    • readyLatch(5) → 모든 스레드 준비 완료 대기
    • startLatch(1) → 동시에 발급 로직 진입
  3. 발급 로직 실행
    • 스레드별로 낙관적 락 / 비관적 락 메서드를 호출
    • 성공 여부를 results 리스트에 기록
  4. 검증
    • doneLatch 로 모든 스레드 종료 대기
    • successCount 가 1 인지 assertEquals 로 단언

💻 JUnit 코드

@Test
@SpringBootTest
class CouponConcurrencyTest {

    private static final Logger log = LoggerFactory.getLogger(CouponConcurrencyTest.class);

    @Autowired
    private CouponService couponService;

    @Autowired
    private CouponRepository couponRepository;

    private static final String TEST_CODE = "TEST_CODE";

    @BeforeEach
    void setUp() {
        couponRepository.deleteAll();
        couponRepository.save(Coupon.builder()
                .code(TEST_CODE)
                .issued(false)
                .build());
        log.info("테스트용 쿠폰 초기화 완료: code={}", TEST_CODE);
    }

    //  낙관적 락 검증
    @Test
    void 동시에_여러_스레드가_쿠폰발급을_요청하면_낙관적락에_성공은_한번만_발생한다() throws InterruptedException {
        int threadCount = 5;
        CountDownLatch ready = new CountDownLatch(threadCount);
        CountDownLatch start = new CountDownLatch(1);
        CountDownLatch done  = new CountDownLatch(threadCount);
        List<Boolean> results = Collections.synchronizedList(new ArrayList<>());

        for (int i = 0; i < threadCount; i++) {
            new Thread(() -> {
                long tid = Thread.currentThread().getId();
                ready.countDown();
                try {
                    start.await();
                    log.info("Thread-{}: 낙관적 락 발급 시도 시작", tid);
                    couponService.issueCouponWithOptimisticLock(new CouponRequest(TEST_CODE, tid));
                    results.add(true);
                } catch (Exception e) {
                    results.add(false);
                    log.info("Thread-{}: 실패 - {}", tid, e.getMessage());
                } finally {
                    done.countDown();
                }
            }).start();
        }

        ready.await();     // 모든 스레드 준비
        start.countDown(); // 동시 시작
        done.await();      // 종료 대기

        long success = results.stream().filter(r -> r).count();
        log.info("낙관적 락 전체 성공 횟수={}", success);
        assertEquals(1, success);
    }

    //  비관적 락 검증
    @Test
    void 동시에_여러_스레드가_쿠폰발급을_요청하면_비관적락에_성공은_한번만_발생한다() throws InterruptedException {
        int threadCount = 5;
        CountDownLatch ready = new CountDownLatch(threadCount);
        CountDownLatch start = new CountDownLatch(1);
        CountDownLatch done  = new CountDownLatch(threadCount);
        List<Boolean> results = Collections.synchronizedList(new ArrayList<>());

        for (int i = 0; i < threadCount; i++) {
            new Thread(() -> {
                long tid = Thread.currentThread().getId();
                ready.countDown();
                try {
                    start.await();
                    log.info("Thread-{}: 비관적 락 발급 시도 시작", tid);
                    couponService.issueCouponWithPessimisticLock(new CouponRequest(TEST_CODE, tid));
                    results.add(true);
                } catch (Exception e) {
                    results.add(false);
                    log.info("Thread-{}: 실패 - {}", tid, e.getMessage());
                } finally {
                    done.countDown();
                }
            }).start();
        }

        ready.await();
        start.countDown();
        done.await();

        long success = results.stream().filter(r -> r).count();
        log.info("비관적 락 전체 성공 횟수={}", success);
        assertEquals(1, success);
    }
}

✅ 검증 포인트 요약

검증 항목 기대 결과
issued=true 행 갯수 최종 1개
successCount 로그 1
JUnit 단언 assertEquals(1, successCount) 통과

 


6️⃣ AOP를 활용한 로그 구체화

동시성 테스트를 통과했으니, AOP(Aspect‑Oriented Programming) 로 쿼리, 버전, 락 흐름을 세밀하게 기록해 보겠습니다. 실행 흐름마다 Before / AfterReturning 지점을 잡아 두면 “어느 시점에, 어떤 버전·락이 걸렸는지” 를 한눈에 추적할 수 있습니다.

✨ 설계 포인트

포인트 설명
Pointcut execution(* ..findByCode(..)) / findByCodeForUpdate(..) / save(..) 세 그룹으로 분리
파라미터 바인딩 args(code) · args(coupon) 을 통해 파라미터 값을 로그에 직접 출력
락 종류 태그 [OPTIMISTIC], [PESSIMISTIC] prefix 로 락 타입 표시
Before vs AfterReturning Before : 메서드 진입 전 조회/세이브 시도AfterReturning : 정상 반환 직후 — DB 결과·버전 확인
버전 추적 Coupon.version 값을 항상 함께 출력해 충돌 원인을 빠르게 파악
save() 두 번 로깅 버전 증가 전·후 상태를 비교해 변화를 한 줄로 확인

💻 AOP 코드

@Aspect
@Component
public class CouponLockLoggingAspect {
    private static final Logger log = LoggerFactory.getLogger(CouponLockLoggingAspect.class);

    // 1) 낙관적 락: 조회 전/후 버전 로깅
    @Before("execution(* com.example.demo.repository.CouponRepository.findByCode(..)) && args(code)")
    public void beforeFindByCode(String code) {
        log.info("[OPTIMISTIC] findByCode 호출 – code={}", code);
    }

    @AfterReturning(pointcut = "execution(* com.example.demo.repository.CouponRepository.findByCode(..))",
                    returning = "opt")
    public void afterFindByCode(JoinPoint jp, Optional<Coupon> opt) {
        opt.ifPresent(c -> log.info("[OPTIMISTIC] 조회된 Coupon id={}, version={}", 
                                    c.getId(), c.getVersion()));
    }

    // 2) 비관적 락: FOR UPDATE 전/후 로깅
    @Before("execution(* com.example.demo.repository.CouponRepository.findByCodeForUpdate(..)) && args(code)")
    public void beforeFindByCodeForUpdate(String code) {
        log.info("[PESSIMISTIC] findByCodeForUpdate 호출 (SELECT FOR UPDATE) – code={}", code);
    }

    @AfterReturning(pointcut = "execution(* com.example.demo.repository.CouponRepository.findByCodeForUpdate(..))",
                    returning = "opt")
    public void afterFindByCodeForUpdate(JoinPoint jp, Optional<Coupon> opt) {
        opt.ifPresent(c -> log.info("[PESSIMISTIC] 잠금 획득된 Coupon id={}, version={}", 
                                    c.getId(), c.getVersion()));
    }

    // 3) save() 전/후 버전 로그
    @Before("execution(* com.example.demo.repository.CouponRepository.save(..)) && args(coupon)")
    public void beforeSave(Coupon coupon) {
        log.info("→ save 호출 전: id={} code={} version={}", 
                 coupon.getId(), coupon.getCode(), coupon.getVersion());
    }

    @AfterReturning(pointcut = "execution(* com.example.demo.repository.CouponRepository.save(..))",
                    returning = "saved")
    public void afterSave(JoinPoint jp, Coupon saved) {
        log.info("← save 완료 후: id={} code={} version={}", 
                 saved.getId(), saved.getCode(), saved.getVersion());
    }
}

7️⃣ AOP를 통한 낙관적 락과 비관적 락 분석

🔍 낙관적 락 로그

2025-08-02T05:14:25.025+09:00  INFO 3336 --- [demo] [           main] c.e.demo.aop.CouponLockLoggingAspect     : → save 호출 전: id=null code=TEST_CODE version=null
2025-08-02T05:14:25.028+09:00  INFO 3336 --- [demo] [           main] c.e.demo.aop.CouponLockLoggingAspect     : ← save 완료 후: id=2 code=TEST_CODE version=0
2025-08-02T05:14:25.029+09:00  INFO 3336 --- [demo] [           main] c.e.demo.service.CouponConcurrencyTest   : 테스트용 쿠폰 초기화 완료: code=TEST_CODE
2025-08-02T05:14:25.030+09:00  INFO 3336 --- [demo] [           main] c.e.demo.service.CouponConcurrencyTest   : 모든 스레드 준비 완료, 발급 시작
2025-08-02T05:14:25.030+09:00  INFO 3336 --- [demo] [       Thread-7] c.e.demo.service.CouponConcurrencyTest   : Thread-34: 낙관적 락 발급 시도 시작
2025-08-02T05:14:25.030+09:00  INFO 3336 --- [demo] [       Thread-8] c.e.demo.service.CouponConcurrencyTest   : Thread-35: 낙관적 락 발급 시도 시작
2025-08-02T05:14:25.030+09:00  INFO 3336 --- [demo] [      Thread-10] c.e.demo.service.CouponConcurrencyTest   : Thread-37: 낙관적 락 발급 시도 시작
2025-08-02T05:14:25.030+09:00  INFO 3336 --- [demo] [       Thread-6] c.e.demo.service.CouponConcurrencyTest   : Thread-33: 낙관적 락 발급 시도 시작
2025-08-02T05:14:25.030+09:00  INFO 3336 --- [demo] [       Thread-9] c.e.demo.service.CouponConcurrencyTest   : Thread-36: 낙관적 락 발급 시도 시작
2025-08-02T05:14:25.032+09:00  INFO 3336 --- [demo] [       Thread-7] c.e.demo.aop.CouponLockLoggingAspect     : [OPTIMISTIC] findByCode 호출 – code=TEST_CODE
2025-08-02T05:14:25.032+09:00  INFO 3336 --- [demo] [       Thread-6] c.e.demo.aop.CouponLockLoggingAspect     : [OPTIMISTIC] findByCode 호출 – code=TEST_CODE
2025-08-02T05:14:25.032+09:00  INFO 3336 --- [demo] [       Thread-8] c.e.demo.aop.CouponLockLoggingAspect     : [OPTIMISTIC] findByCode 호출 – code=TEST_CODE
2025-08-02T05:14:25.032+09:00  INFO 3336 --- [demo] [      Thread-10] c.e.demo.aop.CouponLockLoggingAspect     : [OPTIMISTIC] findByCode 호출 – code=TEST_CODE
2025-08-02T05:14:25.032+09:00  INFO 3336 --- [demo] [       Thread-9] c.e.demo.aop.CouponLockLoggingAspect     : [OPTIMISTIC] findByCode 호출 – code=TEST_CODE
2025-08-02T05:14:25.039+09:00  INFO 3336 --- [demo] [      Thread-10] c.e.demo.aop.CouponLockLoggingAspect     : [OPTIMISTIC] 조회된 Coupon id=2, version=0
2025-08-02T05:14:25.039+09:00  INFO 3336 --- [demo] [       Thread-8] c.e.demo.aop.CouponLockLoggingAspect     : [OPTIMISTIC] 조회된 Coupon id=2, version=0
2025-08-02T05:14:25.040+09:00  INFO 3336 --- [demo] [       Thread-7] c.e.demo.aop.CouponLockLoggingAspect     : [OPTIMISTIC] 조회된 Coupon id=2, version=0
2025-08-02T05:14:25.040+09:00  INFO 3336 --- [demo] [       Thread-9] c.e.demo.aop.CouponLockLoggingAspect     : [OPTIMISTIC] 조회된 Coupon id=2, version=0
2025-08-02T05:14:25.040+09:00  INFO 3336 --- [demo] [       Thread-6] c.e.demo.aop.CouponLockLoggingAspect     : [OPTIMISTIC] 조회된 Coupon id=2, version=0
2025-08-02T05:14:25.040+09:00  INFO 3336 --- [demo] [       Thread-7] c.e.demo.aop.CouponLockLoggingAspect     : → save 호출 전: id=2 code=TEST_CODE version=0
2025-08-02T05:14:25.040+09:00  INFO 3336 --- [demo] [       Thread-6] c.e.demo.aop.CouponLockLoggingAspect     : → save 호출 전: id=2 code=TEST_CODE version=0
2025-08-02T05:14:25.040+09:00  INFO 3336 --- [demo] [      Thread-10] c.e.demo.aop.CouponLockLoggingAspect     : → save 호출 전: id=2 code=TEST_CODE version=0
2025-08-02T05:14:25.040+09:00  INFO 3336 --- [demo] [       Thread-9] c.e.demo.aop.CouponLockLoggingAspect     : → save 호출 전: id=2 code=TEST_CODE version=0
2025-08-02T05:14:25.040+09:00  INFO 3336 --- [demo] [       Thread-8] c.e.demo.aop.CouponLockLoggingAspect     : → save 호출 전: id=2 code=TEST_CODE version=0
2025-08-02T05:14:25.040+09:00  INFO 3336 --- [demo] [      Thread-10] c.e.demo.aop.CouponLockLoggingAspect     : ← save 완료 후: id=2 code=TEST_CODE version=0
2025-08-02T05:14:25.040+09:00  INFO 3336 --- [demo] [       Thread-9] c.e.demo.aop.CouponLockLoggingAspect     : ← save 완료 후: id=2 code=TEST_CODE version=0
2025-08-02T05:14:25.040+09:00  INFO 3336 --- [demo] [       Thread-8] c.e.demo.aop.CouponLockLoggingAspect     : ← save 완료 후: id=2 code=TEST_CODE version=0
2025-08-02T05:14:25.040+09:00  INFO 3336 --- [demo] [       Thread-7] c.e.demo.aop.CouponLockLoggingAspect     : ← save 완료 후: id=2 code=TEST_CODE version=0
2025-08-02T05:14:25.040+09:00  INFO 3336 --- [demo] [       Thread-6] c.e.demo.aop.CouponLockLoggingAspect     : ← save 완료 후: id=2 code=TEST_CODE version=0
2025-08-02T05:14:25.043+09:00  INFO 3336 --- [demo] [       Thread-8] c.e.demo.service.CouponConcurrencyTest   : Thread-35: 낙관적 락 발급 시도 결과=성공
2025-08-02T05:14:25.058+09:00  INFO 3336 --- [demo] [       Thread-9] c.e.demo.service.CouponConcurrencyTest   : Thread-36: 낙관적 락 발급 시도 결과=실패 - Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect): [com.example.demo.coupon.Coupon#2]
2025-08-02T05:14:25.058+09:00  INFO 3336 --- [demo] [       Thread-6] c.e.demo.service.CouponConcurrencyTest   : Thread-33: 낙관적 락 발급 시도 결과=실패 - Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect): [com.example.demo.coupon.Coupon#2]
2025-08-02T05:14:25.058+09:00  INFO 3336 --- [demo] [      Thread-10] c.e.demo.service.CouponConcurrencyTest   : Thread-37: 낙관적 락 발급 시도 결과=실패 - Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect): [com.example.demo.coupon.Coupon#2]
2025-08-02T05:14:25.058+09:00  INFO 3336 --- [demo] [       Thread-7] c.e.demo.service.CouponConcurrencyTest   : Thread-34: 낙관적 락 발급 시도 결과=실패 - Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect): [com.example.demo.coupon.Coupon#2]
2025-08-02T05:14:25.059+09:00  INFO 3336 --- [demo] [           main] c.e.demo.service.CouponConcurrencyTest   : 낙관적 락 전체 성공 횟수=1

✅ 낙관적 락 흐름 분석

1️⃣ 테스트용 쿠폰 초기화 단계

05:14:25.025  → save 호출 전: id=null code=TEST_CODE version=null  
05:14:25.028  ← save 완료 후: id=2 code=TEST_CODE version=0  
05:14:25.029  테스트용 쿠폰 초기화 완료: code=TEST_CODE
  • id=null, version=null: JPA save() 호출 직전에는 엔티티가 아직 DB에 없어서 식별자(id)와 버전(version) 모두 null입니다.
  • id=2, version=0: save() 이후, DB에 INSERT 되고 id가 할당되면서 version 은 0으로 초기화되었습니다.
  • 이 시점부터 code=TEST_CODE, id=2, version=0 인 쿠폰이 DB에 단 하나 존재합니다.

2️⃣ 동시 스레드 발급 시도 시작

05:14:25.030  모든 스레드 준비 완료, 발급 시작  
05:14:25.030  Thread-34: 낙관적 락 발급 시도 시작  
05:14:25.030  Thread-35: 낙관적 락 발급 시도 시작  
… (총 5개 스레드)
  • 모든 워커 스레드가 startLatch.countDown() 후 거의 동시에 issueCouponWithOptimisticLock() 호출을 시작합니다.

3️⃣ 조회 단계 (findByCode)

05:14:25.032  [OPTIMISTIC] findByCode 호출 – code=TEST_CODE  (스레드 34,35,36,37,33 전부)  
05:14:25.039  [OPTIMISTIC] 조회된 Coupon id=2, version=0      (각 스레드별로 모두)
  • 각 스레드 모두 같은 시점의 DB 상태(id=2, version=0)를 읽어옵니다.
  • 낙관적 락은 이 “조회 시점의 버전”을 기억해 두었다가 커밋 시 비교합니다.

4️⃣ 상태 변경 후 저장 직전

05:14:25.040  → save 호출 전: id=2 code=TEST_CODE version=0  (모든 스레드)
  • coupon.issueTo(...) 로 issued=true 및 ownerId 변경 후, repository.save() 직전의 로그입니다.
  • 이 시점에도 각 스레드가 가지고 있는 version 필드는 여전히 0입니다.

5️⃣ 첫 번째 커밋(성공)

05:14:25.040  ← save 완료 후: id=2 code=TEST_CODE version=0  (Thread-35)  
05:14:25.043  Thread-35: 낙관적 락 발급 시도 결과=성공
  • Thread-35가 가장 먼저 save()를 호출해 UPDATE ... WHERE version = 0을 실행, 행이 업데이트(version → 1)되고 커밋 성공합니다.
  • AOP AfterReturning 시점에는 아직 version이 0으로 보이지만, 실제 DB에는 1로 반영된 상태입니다.

6️⃣ 두 번째 이후 커밋(실패)

05:14:25.058  Thread-36: 낙관적 락 발급 시도 결과=실패 - Row was updated or deleted by another transaction …  
05:14:25.058  Thread-33: 낙관적 락 발급 시도 결과=실패 - …  
… (나머지 스레드)  
05:14:25.059  낙관적 락 전체 성공 횟수=1
  • 뒤늦게 save()를 시도한 스레드들은 UPDATE ... WHERE version = 0 조건에 맞지 않아(version=1) 예외를 던지고 롤백됩니다.
  • 최종 성공: ✅ 1개, 실패 ❌ 4개

7️⃣ 요약

  • version=0 상태로 동시에 조회
  • 첫 커밋 스레드만 version 0 → 1 업데이트 성공
  • 나머지 스레드는 DB 버전 불일치로 OptimisticLockException 발생
  • AOP 로그에는 save 직후(플러시 전) 구버전(0)이 찍히지만, 실제 DB에는 1이 반영되어 이후 실패 원인이 됩니다.

🔍 비관적 락 로그

2025-08-02T05:24:21.753+09:00  INFO 20304 --- [demo] [           main] c.e.demo.aop.CouponLockLoggingAspect     : → save 호출 전: id=null code=TEST_CODE version=null
2025-08-02T05:24:21.821+09:00  INFO 20304 --- [demo] [           main] c.e.demo.aop.CouponLockLoggingAspect     : ← save 완료 후: id=1 code=TEST_CODE version=0
2025-08-02T05:24:21.822+09:00  INFO 20304 --- [demo] [           main] c.e.demo.service.CouponConcurrencyTest   : 테스트용 쿠폰 초기화 완료: code=TEST_CODE
2025-08-02T05:24:21.828+09:00  INFO 20304 --- [demo] [           main] c.e.demo.service.CouponConcurrencyTest   : 모든 스레드 준비 완료, 발급 시작
2025-08-02T05:24:21.829+09:00  INFO 20304 --- [demo] [       Thread-3] c.e.demo.service.CouponConcurrencyTest   : Thread-30: 비관적 락 발급 시도 시작
2025-08-02T05:24:21.829+09:00  INFO 20304 --- [demo] [       Thread-1] c.e.demo.service.CouponConcurrencyTest   : Thread-28: 비관적 락 발급 시도 시작
2025-08-02T05:24:21.829+09:00  INFO 20304 --- [demo] [       Thread-4] c.e.demo.service.CouponConcurrencyTest   : Thread-31: 비관적 락 발급 시도 시작
2025-08-02T05:24:21.829+09:00  INFO 20304 --- [demo] [       Thread-5] c.e.demo.service.CouponConcurrencyTest   : Thread-32: 비관적 락 발급 시도 시작
2025-08-02T05:24:21.829+09:00  INFO 20304 --- [demo] [       Thread-2] c.e.demo.service.CouponConcurrencyTest   : Thread-29: 비관적 락 발급 시도 시작
2025-08-02T05:24:21.832+09:00  INFO 20304 --- [demo] [       Thread-5] c.e.demo.aop.CouponLockLoggingAspect     : [PESSIMISTIC] findByCodeForUpdate 호출 (SELECT FOR UPDATE) – code=TEST_CODE
2025-08-02T05:24:21.832+09:00  INFO 20304 --- [demo] [       Thread-3] c.e.demo.aop.CouponLockLoggingAspect     : [PESSIMISTIC] findByCodeForUpdate 호출 (SELECT FOR UPDATE) – code=TEST_CODE
2025-08-02T05:24:21.832+09:00  INFO 20304 --- [demo] [       Thread-4] c.e.demo.aop.CouponLockLoggingAspect     : [PESSIMISTIC] findByCodeForUpdate 호출 (SELECT FOR UPDATE) – code=TEST_CODE
2025-08-02T05:24:21.832+09:00  INFO 20304 --- [demo] [       Thread-2] c.e.demo.aop.CouponLockLoggingAspect     : [PESSIMISTIC] findByCodeForUpdate 호출 (SELECT FOR UPDATE) – code=TEST_CODE
2025-08-02T05:24:21.832+09:00  INFO 20304 --- [demo] [       Thread-1] c.e.demo.aop.CouponLockLoggingAspect     : [PESSIMISTIC] findByCodeForUpdate 호출 (SELECT FOR UPDATE) – code=TEST_CODE
2025-08-02T05:24:21.869+09:00  INFO 20304 --- [demo] [       Thread-5] c.e.demo.aop.CouponLockLoggingAspect     : [PESSIMISTIC] 잠금 획득된 Coupon id=1, version=0
2025-08-02T05:24:21.869+09:00  INFO 20304 --- [demo] [       Thread-5] c.e.demo.aop.CouponLockLoggingAspect     : → save 호출 전: id=1 code=TEST_CODE version=0
2025-08-02T05:24:21.871+09:00  INFO 20304 --- [demo] [       Thread-5] c.e.demo.aop.CouponLockLoggingAspect     : ← save 완료 후: id=1 code=TEST_CODE version=0
2025-08-02T05:24:21.881+09:00  INFO 20304 --- [demo] [       Thread-5] c.e.demo.service.CouponConcurrencyTest   : Thread-32: 비관적 락 발급 시도 결과=성공
2025-08-02T05:24:21.881+09:00  INFO 20304 --- [demo] [       Thread-3] c.e.demo.aop.CouponLockLoggingAspect     : [PESSIMISTIC] 잠금 획득된 Coupon id=1, version=1
2025-08-02T05:24:21.882+09:00  INFO 20304 --- [demo] [       Thread-4] c.e.demo.aop.CouponLockLoggingAspect     : [PESSIMISTIC] 잠금 획득된 Coupon id=1, version=1
2025-08-02T05:24:21.883+09:00  INFO 20304 --- [demo] [       Thread-3] c.e.demo.service.CouponConcurrencyTest   : Thread-30: 비관적 락 발급 시도 결과=실패 - 이미 발급된 쿠폰입니다.
2025-08-02T05:24:21.883+09:00  INFO 20304 --- [demo] [       Thread-4] c.e.demo.service.CouponConcurrencyTest   : Thread-31: 비관적 락 발급 시도 결과=실패 - 이미 발급된 쿠폰입니다.
2025-08-02T05:24:21.883+09:00  INFO 20304 --- [demo] [       Thread-1] c.e.demo.aop.CouponLockLoggingAspect     : [PESSIMISTIC] 잠금 획득된 Coupon id=1, version=1
2025-08-02T05:24:21.885+09:00  INFO 20304 --- [demo] [       Thread-1] c.e.demo.service.CouponConcurrencyTest   : Thread-28: 비관적 락 발급 시도 결과=실패 - 이미 발급된 쿠폰입니다.
2025-08-02T05:24:21.886+09:00  INFO 20304 --- [demo] [       Thread-2] c.e.demo.aop.CouponLockLoggingAspect     : [PESSIMISTIC] 잠금 획득된 Coupon id=1, version=1
2025-08-02T05:24:21.886+09:00  INFO 20304 --- [demo] [       Thread-2] c.e.demo.service.CouponConcurrencyTest   : Thread-29: 비관적 락 발급 시도 결과=실패 - 이미 발급된 쿠폰입니다.
2025-08-02T05:24:21.887+09:00  INFO 20304 --- [demo] [           main] c.e.demo.service.CouponConcurrencyTest   : 비관적 락 전체 성공 횟수=1

✅ 비관적 락 로그 흐름 분석

1️⃣ 테스트용 쿠폰 초기화

05:24:21.753  → save 호출 전: id=null code=TEST_CODE version=null  
05:24:21.821  ← save 완료 후: id=1 code=TEST_CODE version=0  
05:24:21.822  테스트용 쿠폰 초기화 완료: code=TEST_CODE
  • id=null, version=null 상태로 save() 호출 → INSERT
  • id=1, version=0 로 초기 세팅
  • 이제 DB에는 (id=1, code=TEST_CODE, issued=false, version=0) 하나가 존재

2️⃣ 스레드 준비 완료 & 발급 시도

05:24:21.828  모든 스레드 준비 완료, 발급 시작  
05:24:21.829  Thread-30: 비관적 락 발급 시도 시작  
... (총 5개 스레드 거의 동시에)
  • 카운트다운 래치 해제 후, 5개 워커 스레드 모두 issueCouponWithPessimisticLock() 진입

3️⃣ SELECT … FOR UPDATE 호출

05:24:21.832  [PESSIMISTIC] findByCodeForUpdate 호출 (SELECT FOR UPDATE) – code=TEST_CODE  
... (모든 스레드)
  • AOP @Before가 찍은 로그
  • 실제로는 첫 호출한 스레드 한 개만 DB 레벨에서 행 잠금(행 락)을 획득하고, 나머지는 잠금 해제될 때까지 대기

4️⃣ 첫 번째 잠금 획득 & 처리 (Thread-5)

05:24:21.869  [PESSIMISTIC] 잠금 획득된 Coupon id=1, version=0  
05:24:21.869  → save 호출 전: id=1 code=TEST_CODE version=0  
05:24:21.871  ← save 완료 후: id=1 code=TEST_CODE version=0  
05:24:21.881  Thread-32: 비관적 락 발급 시도 결과=성공
  • 잠금 획득: DB가 해당 행을 FOR UPDATE으로 잠금 → 그 시점의 version=0
  • 비즈니스 로직: issued=true로 변경 후 save()
  • 커밋 시점: 락이 해제되며 다른 스레드가 대기에서 깨어남
  • AOP 로그에는 AfterReturning 결과에 여전히 0이 보이지만, 실제 DB에는 버전이 업데이트 되었을 수 있음

5️⃣ 대기 중이던 나머지 스레드 처리

05:24:21.881  [PESSIMISTIC] 잠금 획득된 Coupon id=1, version=1  (Thread-3)  
05:24:21.882  Thread-30: 비관적 락 발급 시도 결과=실패 - 이미 발급된 쿠폰입니다.  
...  
05:24:21.886  [PESSIMISTIC] 잠금 획득된 Coupon id=1, version=1  (Thread-1, Thread-2, Thread-4)  
05:24:21.886~887  비관적 락 발급 시도 결과=실패 - 이미 발급된 쿠폰입니다.  
05:24:21.887  비관적 락 전체 성공 횟수=1
  • 첫 스레드 커밋 후 잠금 해제 → 다음 스레드가 잠금 획득
  • 이때 issued=true이므로 서비스 검증 단계에서 IllegalStateException 발생 → 롤백

6️⃣ 요약

  • SELECT … FOR UPDATE 로 한 번에 하나의 스레드만 잠금
  • 첫 스레드는 비즈니스 로직 수행 후 커밋 → 락 해제
  • 나머지는 락 해제 후 조회하나, 이미 issued=true 이므로 검증 실패
  • 최종 성공: ✅ 1개, 실패 ❌ 4개 로 테스트 통과

이렇게 비관적 락은 충돌 자체를 예방하고, 잠금 대기 후 검증 실패 구조로 동작합니다.

8️⃣ 정리 – 낙관적 락 vs 비관적 락

📌 요약 비교표

항목 낙관적 락 (Optimistic) 비관적 락 (Pessimistic)
철학 “충돌이 가끔 있을 뿐” → 일단 작업 후 검증 “충돌이 분명 있다” → 먼저 잠금
획득 방법 @Version 필드 + JPA 내부 UPDATE … WHERE version=? SELECT … FOR UPDATE or @Lock(PESSIMISTIC_*)
락 보유 시간 없음 (커밋 시 단일 update) 트랜잭션 전체 기간
장점 ✔ 락 대기 0 → 읽기 위주 적합
✔ 멀티 인스턴스 간 네트워크 지연 無
✔ 충돌 자체 차단
✔ 재시도 로직 불필요
단점 ❌ 충돌 시 재시도·롤백 비용
❌ 충돌 빈번하면 오히려 느림
❌ 대기/데드락 가능
❌ 장기 트랜잭션 시 성능 급락
대표 사용처 📧 알림 발송 카운터
🏷️ API 호출 쿼터
📊 분석 로그 적재
💸 계좌 이체 잔액 차감
🎫 재고·티켓 한정 판매

🔍 내부 동작 비교

🔸 낙관적 락

  1. 조회 시 version=V 값 메모
  2. 수정 후 커밋 단계에서
UPDATE tbl SET col=?, version=version+1
WHERE id=? AND version=V;
  1. 성공 → 1행 갱신 & 버전++
  2. 실패 → 0행 갱신 → OptimisticLockException

🔸 비관적 락

  1. 조회 즉시 아래 쿼리 실행
SELECT * FROM tbl WHERE id=? FOR UPDATE;
  1. 행 레벨 X-Lock 획득 → 타 트랜잭션 대기
  2. 수정 & 커밋 → 락 해제
  3. 대기 중인 트랜잭션이 이어서 실행 (또는 타임아웃/데드락 발생)

⚠️ 적용 전 체크리스트

질문 낙관적 락 비관적 락
쓰기 충돌 빈도는? “드물다” “자주 발생”
재시도 허용? 가능 (Idempotent 로직) 불가·복잡

💡 Tip: 두 전략을 혼합 사용해도 좋다.
예) 재고 감소 → 먼저 낙관적 시도, OptimisticLockException ≥ N회면 비관적으로 폴백.

반응형

'Java & Spring' 카테고리의 다른 글

[Java & Spring] ReentrantLock, 쿠폰 발급으로 실습하고 테스트 코드로 증명해보기  (0) 2025.07.02
[Java] ReentrantLock 정리 (with. synchronized)  (0) 2025.06.27
[Java] Map의 computeIfAbsent()과 computeIfPresent() 메서드  (0) 2025.06.22
[Java & Spring] HashMap vs ConcurrentHashMap, 실습으로 알아보는 차이  (2) 2025.06.13
[Java] Java의 ConcurrentHashMap 정리  (1) 2025.06.12
'Java & Spring' 카테고리의 다른 글
  • [Java & Spring] ReentrantLock, 쿠폰 발급으로 실습하고 테스트 코드로 증명해보기
  • [Java] ReentrantLock 정리 (with. synchronized)
  • [Java] Map의 computeIfAbsent()과 computeIfPresent() 메서드
  • [Java & Spring] HashMap vs ConcurrentHashMap, 실습으로 알아보는 차이
Penguin Dev
Penguin Dev
What does the Penguin say?
    글쓰기 관리
  • Penguin Dev
    Pengha!
    Penguin Dev
  • 전체
    오늘
    어제
    • 분류 전체보기 (153)
      • Java & Spring (6)
      • System Hacking (4)
      • Algorithm (8)
        • Sorting algorithm (3)
      • Python (6)
      • Web (2)
        • Web Hacking & Security (2)
      • Write-Up (108)
        • pwnable.kr (17)
        • HackCTF (16)
        • 해커스쿨 FTZ (21)
        • LOB(lord of bufferoverflow) (19)
        • LOS (lord of sql injection) (28)
        • XSS-game (6)
        • Webhacking.kr (1)
      • SUA (19)
        • 오픈소스 보안 (19)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    sqlinjection
    Java
    Lock
    hashmap vs concurrenthashmap
    putval()
    lord of bufferoverflow
    tabat
    thread-safe
    ConcurrentHashMap
    computeifpresent()
    DB정리
    동시성
    enumerate #list comprehension
    spring boot
    nop sled
    concurrenthashmap vs hashmap
    computeifabsent
    낙관적 락
    computeifpresent
    ReentrantLock
    동시성처리
    코드트리
    SpringBoot
    쿠폰발급
    spring
    computeifabsent()
    reentrantlock실습
    CountDownLatch
    비관적 락
    코드트리조별과제
  • 최근 댓글

  • 반응형
  • hELLO· Designed By정상우.v4.10.3
Penguin Dev
[Java & Spring] 낙관적 락, 비관적 락 (with. JPA와 AOP)
상단으로

티스토리툴바