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)
- 원리
- 엔티티에
@Version필드를 두어 버전을 관리 - 트랜잭션 시작 시 조회한 버전 값을 기억
- 커밋 단계에서
UPDATE coupons SET issued = ?, version = version + 1 WHERE id = ? AND version = <조회 시 기억한 버전>- DB의 현재 버전과 조회 시 기억한 버전이 일치하면 1행이 업데이트되고 커밋 성공 → 버전이 1 증가
- 불일치하면 업데이트 대상이 없어져(
영향받은 행 = 0) JPA가OptimisticLockException을 던져 롤백
- 엔티티에
- 흐름 예시
- 초기 상태
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.2 비관적 락 (Pessimistic Lock)
- 원리
- JPA 리포지토리 메서드에
@Lock(LockModeType.PESSIMISTIC_WRITE)지정 - 실행 시점에 SQL
SELECT … FOR UPDATE발행 → 해당 행을 즉시 잠금 - 다른 트랜잭션은 락 해제 전까지 대기하거나 예외
- 트랜잭션 커밋/롤백 시점에 잠금 해제
- JPA 리포지토리 메서드에
- 흐름 예시
- 초기 상태
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 선택 가이드
| 상황 | 추천 전략 |
|---|---|
| 읽기 위주, 충돌 드문 환경 | ✅ 낙관적 락 |
| 쓰기 많고 충돌 빈번 | ✅ 비관적 락 |
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회만 일어나는지 확인합니다.
🔍 테스트 시나리오
- 데이터 초기화
- 매 테스트마다 쿠폰 테이블을 비우고
issued=false상태의 쿠폰 1개(TEST_CODE)를 삽입합니다.
- 매 테스트마다 쿠폰 테이블을 비우고
- 다중 스레드 준비
readyLatch(5) → 모든 스레드 준비 완료 대기startLatch(1) → 동시에 발급 로직 진입
- 발급 로직 실행
- 스레드별로 낙관적 락 / 비관적 락 메서드를 호출
- 성공 여부를
results리스트에 기록
- 검증
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 호출 쿼터 📊 분석 로그 적재 |
💸 계좌 이체 잔액 차감 🎫 재고·티켓 한정 판매 |
🔍 내부 동작 비교
🔸 낙관적 락
- 조회 시 version=V 값 메모
- 수정 후 커밋 단계에서
UPDATE tbl SET col=?, version=version+1
WHERE id=? AND version=V;
- 성공 → 1행 갱신 & 버전++
- 실패 → 0행 갱신 →
OptimisticLockException
🔸 비관적 락
- 조회 즉시 아래 쿼리 실행
SELECT * FROM tbl WHERE id=? FOR UPDATE;
- 행 레벨 X-Lock 획득 → 타 트랜잭션 대기
- 수정 & 커밋 → 락 해제
- 대기 중인 트랜잭션이 이어서 실행 (또는 타임아웃/데드락 발생)
⚠️ 적용 전 체크리스트
| 질문 | 낙관적 락 | 비관적 락 |
|---|---|---|
| 쓰기 충돌 빈도는? | “드물다” | “자주 발생” |
| 재시도 허용? | 가능 (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 |
