[Java & Spring] ReentrantLock, 쿠폰 발급으로 실습하고 테스트 코드로 증명해보기

2025. 7. 2. 21:17·Java & Spring
반응형

1️⃣ 들어가며

"한 명에게만 줘야 하는 쿠폰인데, 어떻게 두 명이 받았지?"

 

이런 상황, 개발을 하며 정말 자주 접하게 됩니다. 특히 선착순 쿠폰, 이벤트 응모, 재고 1개 남은 상품 구매와 같은 케이스에서는 "빠르게 두 번 클릭했더니 쿠폰이 두 장 발급됐다(따닥 이슈)", "100명 이벤트인데 101명이 응모 완료했다" 같은 문제가 벌어지곤 하죠.

 

이번 글에서는 이 문제를 실험으로 명확하게 확인하고, Java에서 제공하는 ReentrantLock을 통해 동시성 문제를 어떻게 해결할 수 있는지 단계적으로 살펴보려 합니다.

 

이전에 작성한 글 🔗ReentrantLock 정리와 🔗HashMap vs ConcurrentHashMap을 먼저 읽어보면 해당 글을 이해하는데 도움이 됩니다!!

🧩 실습 목표

실험을 통한 가설 검증

  • ✅ 가설 1: 락 없이 동시에 쿠폰 발급 요청 시 중복 발급이 발생한다
  • ✅ 가설 2: 전역 락을 사용하면 단일 발급이 보장된다
  • ✅ 가설 3: 한명의 사용자가 빠르게 클릭을 했을때(따닥 이슈), 쿠폰이 하나만 발급된다.
  • ✅ 가설 4: 서로 다른 쿠폰 코드를 동시에 발급 요청하면, 서로 다른 쿠폰은 독립적으로 발급 처리 되어야한다.
  • ✅ 추가적으로.... : 락 타임아웃을 설정하면, 일정 시간 대기 후 락을 획득하지 못한 요청은 실패해야 한다

"그냥 synchronized 걸면 되지 않나요?" 같은 접근으로는 동시성 처리에 부족한 경우가 많습니다.
이 글을 통해 락을 왜 써야 하는지, 어떤 방식으로 써야 서비스 병목 없이 설계할 수 있을지를 직접 코드로 작성하고 테스트로 검증합니다.

2️⃣ 기본 구조와 코드 소개

📌 1. CouponController

HTTP 요청을 받아 쿠폰 발급 서비스를 호출하는 컨트롤러입니다.
두 가지 API가 존재합니다.

  • /coupon/without-lock: 락 없이 쿠폰을 발급
  • /coupon/with-lock: ReentrantLock으로 보호된 쿠폰 발급
@RestController
@RequestMapping("/coupon")
@RequiredArgsConstructor
public class CouponController {

    private final CouponService couponService;

    @PostMapping("/without-lock")
    public void issueWithoutLock(@RequestBody CouponRequest request) {
        couponService.issuedCouponWithoutLock(request);
    }

    @PostMapping("/with-lock")
    public void issueWithLock(@RequestBody CouponRequest request) {
        couponService.issuedCouponWithLock(request);
    }
}

 


📌 2. CouponRequest

쿠폰 발급 요청을 나타내는 DTO입니다.
이 요청 객체를 기반으로 Coupon 엔티티를 조립할 수 있도록 toEntity(Long id) 메서드를 제공합니다.

public record CouponRequest(
        String code,
        Long ownerId
) {
    public Coupon toEntity(Long id) {
        return Coupon.builder()
                .id(id)
                .code(code)
                .ownerId(ownerId)
                .issued(false)
                .build();
    }
}

 


📌 3. Coupon

쿠폰 도메인 객체입니다. 이미 발급된 상태인지, 누구에게 발급되었는지를 나타냅니다. 또한 issueTo(Long ownerId) 메서드를 통해 발급 상태의 복사 객체를 만들 수 있습니다.

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Coupon {

    private Long id;
    private String code;
    private Long ownerId;
    private boolean issued;

    public Coupon issueTo(Long ownerId) {
        return Coupon.builder()
                .id(this.id)
                .code(this.code)
                .ownerId(ownerId)
                .issued(true)
                .build();
    }
}

 


📌 4. CouponRepository

쿠폰을 저장하고 조회하는 역할을 담당하는 인메모리 저장소입니다.

@Repository
public class CouponRepository {
    private final Map<Long, Coupon> store = new ConcurrentHashMap<>();
    private final AtomicLong sequence = new AtomicLong(0);

    public Coupon save(Coupon coupon) {
        Long id = (coupon.getId() == null) ? sequence.incrementAndGet() : coupon.getId();

        Coupon saved = Coupon.builder()
                .id(id)
                .code(coupon.getCode())
                .ownerId(coupon.getOwnerId())
                .issued(coupon.isIssued())
                .build();

        store.put(id, saved);
        return saved;
    }

    public Optional<Coupon> findByCode(String code) {
        return store.values().stream()
                .filter(coupon -> coupon.getCode().equals(code))
                .findFirst();
    }

    public List<Coupon> findAll() {
        return new ArrayList<>(store.values());
    }
}

📌 5. CouponService

비즈니스 로직을 수행하는 핵심 클래스입니다.
여기서 실제 쿠폰 발급 시 중복을 막기 위한 동기화 처리를 수행합니다.

@Service
@RequiredArgsConstructor
public class CouponService {

    private final CouponRepository couponRepository;
    private final ReentrantLock lock = new ReentrantLock();

    // 락 없이 쿠폰 발급
    public void issuedCouponWithoutLock(CouponRequest request) {
        Coupon coupon = couponRepository.findByCode(request.code())
                .orElseThrow(() -> new IllegalArgumentException("해당 코드의 쿠폰이 존재하지 않습니다."));

        if (coupon.isIssued()) {
            throw new IllegalStateException("이미 발급된 쿠폰입니다.");
        }

        Coupon issuedCoupon = coupon.issueTo(request.ownerId());
        couponRepository.save(issuedCoupon);
    }

    // 전체 락 걸고 발급
    public void issuedCouponWithLock(CouponRequest request) {
        lock.lock();
        try {
            Coupon coupon = couponRepository.findByCode(request.code())
                    .orElseThrow(() -> new IllegalArgumentException("해당 코드의 쿠폰이 존재하지 않습니다."));

            if (coupon.isIssued()) {
                throw new IllegalStateException("이미 발급된 쿠폰입니다.");
            }

            Coupon issuedCoupon = coupon.issueTo(request.ownerId());
            couponRepository.save(issuedCoupon);
        } finally {
            lock.unlock();
        }
    }

    // 코드별 락 적용 (쿠폰이 각각 다를때, 극 각 쿠폰마다 락을 건다)
    private final ConcurrentHashMap<String, ReentrantLock> locks = new ConcurrentHashMap<>();

    public void issuedPerCouponWithLock(CouponRequest request) {
        ReentrantLock perLock = locks.computeIfAbsent(request.code(), k -> new ReentrantLock());
        perLock.lock();
        try {
            Coupon coupon = couponRepository.findByCode(request.code())
                    .orElseThrow(() -> new IllegalArgumentException("해당 코드의 쿠폰이 존재하지 않습니다."));

            if (coupon.isIssued()) {
                throw new IllegalStateException("이미 발급된 쿠폰입니다.");
            }

            Coupon issuedCoupon = coupon.issueTo(request.ownerId());
            couponRepository.save(issuedCoupon);
        } finally {
            perLock.unlock();
        }
    }
}

이 구조를 바탕으로 이제 동시성 실험과 ReentrantLock의 효과를 직접 확인할 준비가 끝났습니다.

3️⃣ 테스트코드를 통한 가설 검증

초기 세팅

@BeforeEach
void setUp() {
    couponRepository = new CouponRepository();
    couponService = new CouponService(couponRepository);
    Coupon initial = Coupon.builder()
            .id(1L)
            .code("TEST_CODE")
            .ownerId(null)
            .issued(false)
            .build();
    couponRepository.save(initial);
}

✅ 가설 1 : 락 없이 동시에 발급 요청하면 중복 발급이 일어날 수 있다.

해당 가설은 issuedCouponWithoutLock() 메서드를 대상으로 진행된다. 락을 적용하지 않은 상태에서 다수의 스레드가 동시에 쿠폰을 발급 요청할 때 어떤 현상이 발생하는지 확인한다. 즉, 락 없이 1000명이 발급을 요청할 때 몇 개의 쿠폰이 발급되는지를 검증한다.

🔍 실험 조건

  • 총 1000개의 스레드가 동시에 POST /coupon/without-lock 요청 수행
  • 쿠폰 코드는 단 하나만 존재 (TEST_CODE)
  • 모든 스레드가 같은 쿠폰을 발급받으려 시도
  • 각 스레드는 고유한 ownerId를 가지고 요청

🧪 테스트 코드

@Test
void 락없이_동시에_쿠폰을_발급_요청하면_쿠폰이_다수가_발행된다_withoutLock() throws InterruptedException {
    int threadCount = 1000;
    ExecutorService executor = Executors.newFixedThreadPool(threadCount);
    CountDownLatch ready = new CountDownLatch(threadCount);
    CountDownLatch start = new CountDownLatch(1);
    AtomicInteger successCount = new AtomicInteger();

    for (int i = 0; i < threadCount; i++) {
        executor.submit(() -> {
            ready.countDown();
            try {
                start.await();
                couponService.issuedCouponWithoutLock(
                    new CouponRequest("TEST_CODE", Thread.currentThread().getId()));
                successCount.incrementAndGet();
                System.out.println("접근 Thread - : " + Thread.currentThread().getId() + " 쿠폰 발급 성공");
            } catch (Exception e) {
                System.out.println("접근 Thread - : " + Thread.currentThread().getId() + " 쿠폰 발급 실패: " + e.getMessage());
            }
        });
    }

    ready.await();
    start.countDown();
    executor.shutdown();
    executor.awaitTermination(1, TimeUnit.MINUTES);

    System.out.println("총 쿠폰 발급 개수: " + successCount.get());
    assertTrue(successCount.get() > 1, "락 없이 동시 접근 시 1개 초과 발급되어야 합니다.");
}

✅ 테스트 결과

접근 Thread - : 25 쿠폰 발급 성공
접근 Thread - : 130 쿠폰 발급 성공
접근 Thread - : 86 쿠폰 발급 성공
...(생략)
접근 Thread - : 615 쿠폰 발급 실패: 이미 발급된 쿠폰입니다.
접근 Thread - : 807 쿠폰 발급 실패: 이미 발급된 쿠폰입니다.
접근 Thread - : 758 쿠폰 발급 실패: 이미 발급된 쿠폰입니다.
접근 Thread - : 666 쿠폰 발급 실패: 이미 발급된 쿠폰입니다.
총 쿠폰 발급 개수: 691

예상과 다르게 발급 성공 개수가 1보다 크다는 것은 동시에 여러 스레드가 발급 로직을 통과했다는 의미이다.
즉, 발급 체크 조건을 여러 스레드가 동시에 통과해 중복 발급이 발생한 것이다.

❗ 왜 이런 일이 발생했을까?

  • issued 필드 확인 후 true로 바꾸는 작업이 원자적이지 않다
  • 두 스레드가 거의 동시에 아래 조건문을 통과할 수 있다:
if (coupon.isIssued()) {
    throw new IllegalStateException("이미 발급된 쿠폰입니다.");
}
  • 첫 번째 스레드가 아직 issued=true를 저장하지 않은 상태에서 두 번째 스레드가 같은 조건을 통과하면 Race Condition이 발생하게 된다.

✅ 가설 2: 전역 락을 사용하면 단일 발급이 보장된다

이전 테스트에서는 락을 사용하지 않을 경우, 동시에 여러 스레드가 발급 조건을 통과해 중복 발급이 발생하는 현상을 확인했다. 이번에는 ReentrantLock을 이용해 발급 로직 전체에 락을 적용함으로써 단일 발급이 보장되는지를 검증한다.

🔍 실험 조건

  • 총 1000개의 스레드가 동시에 /coupon/with-lock 요청을 수행
  • 쿠폰 코드는 "TEST_CODE" 하나만 존재
  • 모든 스레드는 서로 다른 ownerId를 가지고 발급 요청

🧪 테스트 코드

@Test
void 락_사용_시_단일_쿠폰만_발급_되어야_한다_withLock() throws InterruptedException {
    int threadCount = 1000;
    ExecutorService executor = Executors.newFixedThreadPool(threadCount);
    CountDownLatch ready = new CountDownLatch(threadCount);
    CountDownLatch start = new CountDownLatch(1);
    AtomicInteger successCount = new AtomicInteger();

    for (int i = 0; i < threadCount; i++) {
        executor.submit(() -> {
            ready.countDown();
            try {
                start.await();
                couponService.issuedCouponWithLock(new CouponRequest("TEST_CODE", Thread.currentThread().getId()));
                successCount.incrementAndGet();
                System.out.println("접근 Thread : " + Thread.currentThread().getId() + " 쿠폰 발급 성공");
            } catch (Exception e) {
                System.out.println("접근 Thread : " + Thread.currentThread().getId() + " 쿠폰 발급 실패: " + e.getMessage());
            }
        });
    }

    ready.await();
    start.countDown();
    executor.shutdown();
    executor.awaitTermination(1, TimeUnit.MINUTES);

    System.out.println("총 발급된 쿠폰 개수: " + successCount.get());
    assertEquals(1, successCount.get(), "락 사용 시 단일 발급만 되어야 합니다.");
}

✅ 테스트 결과

접근 Thread : 22 쿠폰 발급 성공
접근 Thread : 580 쿠폰 발급 실패: 이미 발급된 쿠폰입니다.
접근 Thread : 263 쿠폰 발급 실패: 이미 발급된 쿠폰입니다.
...(생략)
접근 Thread : 903 쿠폰 발급 실패: 이미 발급된 쿠폰입니다.
접근 Thread : 615 쿠폰 발급 실패: 이미 발급된 쿠폰입니다.
접근 Thread : 127 쿠폰 발급 실패: 이미 발급된 쿠폰입니다.
총 발급된 쿠폰 개수: 1

📌 결론

  • ReentrantLock을 통해 발급 로직 전체를 감싸면, 동시에 여러 스레드가 접근해도 단 하나의 발급만 허용된다.
  • 다른 모든 스레드는 쿠폰이 이미 발급된 상태이기 때문에 예외 처리되어 실패하게 된다.
  • 락이 동작하고 있다는 증거이며, 전역 락은 단일 자원에 대해 철저한 동시성 제어를 가능하게 해준다.

✅ 가설 3: 한 명의 사용자가 빠르게 두 번 클릭해도 쿠폰은 한 번만 발급된다

이번 가설은 실 서비스에서 자주 발생할 수 있는 "빠른 중복 클릭 이슈"를 검증하기 위한 것이다. 실제로 사용자가 버튼을 빠르게 두 번 클릭하면 같은 요청이 짧은 시간 내에 연달아 서버로 전송되는데, 이 상황에서도 동일한 쿠폰이 두 번 발급되지 않아야 한다는 것이 이번 테스트의 핵심이다.

🔍 실험 조건

  • 단 2개의 스레드만 사용한다.
  • 두 스레드는 동시에 issuedCouponWithLock() 메서드를 호출한다.
  • 같은 쿠폰 코드 (TEST_CODE)를 가지고 요청하며, 이 코드는 테스트 시작 전 미리 한 개 등록해둔 상태다.
  • 각 스레드는 고유한 ownerId를 가지고 있지만, 실험 목적상 동시에 발급되는지를 보기 위함이다.

🧪 테스트 코드

@Test
void 빠른_중복_클릭_테스트_withLock() throws InterruptedException {
    ExecutorService executor = Executors.newFixedThreadPool(2);
    CountDownLatch ready = new CountDownLatch(2);
    CountDownLatch start = new CountDownLatch(1);
    AtomicInteger successCount = new AtomicInteger();

    for (int i = 0; i < 2; i++) {
        executor.submit(() -> {
            ready.countDown();
            try {
                start.await();
                couponService.issuedCouponWithLock(new CouponRequest("TEST_CODE", Thread.currentThread().getId()));
                successCount.incrementAndGet();
                System.out.println("접근 Thread :  " + Thread.currentThread().getId() + " 쿠폰발급 성공");
            } catch (Exception e) {
                System.out.println("접근 Thread : " + Thread.currentThread().getId() + " 쿠폰발급 실패 " + e.getMessage());
            }
        });
    }

    ready.await();
    start.countDown();
    executor.shutdown();
    executor.awaitTermination(1, TimeUnit.MINUTES);

    System.out.println("쿠폰 발급 개수: " + successCount.get());
    assertEquals(1, successCount.get(), "빠른 중복 클릭 시에도 한 번만 발급되어야 합니다.");
}

 

✅ 테스트 결과

접근 Thread :  22 쿠폰발급 성공
접근 Thread : 23 쿠폰발급 실패 이미 발급된 쿠폰입니다.
쿠폰 발급 개수: 1

🧠 분석

  • ReentrantLock을 통해 해당 코드 블록이 동시에 하나의 스레드만 진입할 수 있도록 제어되었기 때문에, 한 스레드가 발급을 완료한 순간 issued = true가 되어 두 번째 스레드는 조건문을 통과하지 못하고 예외를 던진다.
  • 따라서 실제로 쿠폰이 하나만 발급되는 현상이 발생했고, 이는 우리가 예상한 Race Condition 방지 효과와 정확히 일치한다.

✅ 결론

  • 단시간 내 빠르게 연속 클릭이 발생해도, ReentrantLock으로 임계영역을 안전하게 보호하고 있다면 단일 쿠폰만 발급된다.
  • 실서비스에서 "따닥 이슈"를 방지하기 위해 락 처리는 매우 유효한 방어책이 될 수 있음을 확인했다.

✅ 가설 4: 서로 다른 쿠폰 코드를 동시에 발급 요청하면, 서로 다른 쿠폰은 독립적으로 발급 처리 되어야 한다.

이번 실험은 서로 다른 코드의 쿠폰이 동시에 요청될 때, 각 코드에 대해 하나씩만 발급이 되는지를 확인한다. issuedPerCouponWithLock() 메서드는 쿠폰 코드별로 ReentrantLock을 분리하여 관리하기 때문에, 각 코드별로 동시성을 독립적으로 제어할 수 있어야 한다.

🔍 실험 조건

  • 총 1000개의 스레드가 동시에 실행됨
  • "CODE_A", "CODE_B" 두 종류의 쿠폰만 존재
  • 스레드가 번갈아 가며 각 쿠폰 코드를 요청
  • 각 코드마다 오직 하나의 쿠폰만 발급되어야 한다

🧪 테스트 코드

@Test
void 서로_다른_코드_동시_발급_테스트() throws InterruptedException {
    int threads = 1000;
    ExecutorService exec = Executors.newFixedThreadPool(threads);
    CountDownLatch ready = new CountDownLatch(threads);
    CountDownLatch go = new CountDownLatch(1);
    AtomicInteger successA = new AtomicInteger();
    AtomicInteger successB = new AtomicInteger();

    for (int i = 0; i < threads; i++) {
        String code = (i % 2 == 0) ? "CODE_A" : "CODE_B";
        exec.submit(() -> {
            ready.countDown();
            try {
                go.await();
                couponService.issuedPerCouponWithLock(new CouponRequest(code, Thread.currentThread().getId()));
                if (code.equals("CODE_A")) successA.incrementAndGet();
                else successB.incrementAndGet();
            } catch (Exception ignored) {}
        });
    }

    ready.await();
    go.countDown();
    exec.shutdown();
    exec.awaitTermination(1, TimeUnit.MINUTES);

    System.out.println("CODE_A 쿠폰 발급개수: " + successA.get());
    System.out.println("CODE_B 쿠폰 발급개수: " + successB.get());
    assertEquals(1, successA.get(), "CODE_A는 하나만 발급");
    assertEquals(1, successB.get(), "CODE_B는 하나만 발급");
}

 

✅ 테스트 결과

CODE_B succeeded by 437
CODE_B failed: 이미 발급된 쿠폰입니다.
CODE_A succeeded by 438
CODE_A failed: 이미 발급된 쿠폰입니다.
...(생략)
CODE_A 쿠폰 발급개수: 1
CODE_B 쿠폰 발급개수: 1

 

🧠 분석

  • 서로 다른 쿠폰 코드에 대해 별도의 락을 부여함으로써 동시 요청 시에도 각 쿠폰은 정확히 한 번씩만 발급되었다.
  • 전역 락을 사용하는 방식보다 병목을 줄이면서 높은 동시성 처리가 가능하다는 점에서 실용적인 방식이다.

📌 결론

쿠폰 코드별로 락을 분리하면, 서로 다른 쿠폰을 동시에 요청해도 충돌 없이 안전하게 처리 가능하다. 이는 고성능 시스템이나 실시간 서비스에 특히 유용한 방식이다.


✅ 가설 5: 락 타임아웃을 설정하면, 일정 시간 대기 후 락을 획득하지 못한 요청은 실패해야 한다

🧩 실험 목적

스레드 간 락 경쟁이 치열한 상황에서 tryLock(timeout)을 사용하면, 일정 시간 안에 락을 획득하지 못한 스레드는 더 이상 기다리지 않고 적절히 종료될 수 있는지 확인한다.

💡 기대 결과

  • 첫 번째 스레드는 락을 획득하고 발급 로직을 수행 (2초 대기 포함)
  • 두 번째 스레드는 1초 동안 락을 기다리다가 실패하고 예외를 반환
  • 테스트는 예외 발생 여부 및 예외 메시지를 통해 타임아웃 동작을 확인

🔐 쿠폰 서비스 코드 (with timeout)

public void issuedCouponWithTimeout(CouponRequest request) {
    try {
        if (!lock.tryLock(1, TimeUnit.SECONDS)) {
            throw new RuntimeException("쿠폰 발급 대기 시간 초과");
        }
        // 락 획득 후 인위적 대기
        Thread.sleep(2000);
        // 기존 발급 로직
        Coupon coupon = couponRepository.findByCode(request.code())
                .orElseThrow(() -> new IllegalArgumentException("쿠폰 없음"));
        if (coupon.isIssued()) throw new IllegalStateException("이미 발급됨");
        couponRepository.save(coupon.issueTo(request.ownerId()));
    } catch (InterruptedException ie) {
        Thread.currentThread().interrupt();
        throw new RuntimeException("스레드 인터럽트", ie);
    } finally {
        if (lock.isHeldByCurrentThread()) lock.unlock();
    }
}

🧪 테스트 코드

@Test
void 락_타임아웃_검증() throws InterruptedException, ExecutionException {
    ExecutorService exec = Executors.newFixedThreadPool(2);
    CountDownLatch ready = new CountDownLatch(2);
    CountDownLatch go = new CountDownLatch(1);

    Future<?> first = exec.submit(() -> {
        ready.countDown();
        try {
            go.await();
            service.issuedCouponWithTimeout(new CouponRequest("TIMEOUT", 1L));
        } catch (Exception e) {
            fail("첫 스레드 예외: " + e.getMessage());
        }
    });

    Future<Exception> second = exec.submit(() -> {
        ready.countDown();
        try {
            go.await();
            service.issuedCouponWithTimeout(new CouponRequest("TIMEOUT", 2L));
            return null;
        } catch (Exception e) {
            System.out.println("두 번째 타임아웃 예외: " + e.getMessage());
            return e;
        }
    });

    ready.await();
    go.countDown();
    exec.shutdown();
    exec.awaitTermination(5, TimeUnit.SECONDS);

    Exception ex = second.get();
    assertNotNull(ex, "두 번째 스레드는 예외가 발생해야 합니다.");
    assertTrue(ex.getMessage().contains("대기 시간 초과"), "타임아웃 예외 메시지 확인");
}

 

✅ 테스트 결과

두 번째 타임아웃 예외: 쿠폰 발급 대기 시간 초과

🧾 결론

  • ReentrantLock.tryLock(timeout)을 이용하면, 무한 대기를 방지하고 일정 시간 안에 처리되지 못하는 요청을 적절히 실패시킬 수 있다.
  • 락 경쟁이 심한 환경에서 타임아웃 설정은 서비스 응답성과 안정성을 유지하는 데 큰 도움이 된다.

4️⃣ 마무리 및 정리

이번 실습을 통해 동시성 환경에서 안전한 쿠폰 발급 로직을 구현하기 위한 다양한 방법들을 실험하고, 각 방식의 안정성과 위험성을 비교해보았다.

✅ 실험을 통해 검증한 가설 요약

가설 번호 내용 결과
가설 1 락 없이 동시에 발급 요청 시 중복 발급 가능성 존재 ✅ 중복 발급 발생함
가설 2 전역 락 사용 시 단일 발급 보장 ✅ 성공적으로 단일 발급 유지됨
가설 3 빠른 중복 클릭(따닥 클릭) 시에도 락으로 중복 방지 가능 ✅ 중복 방지 성공
가설 4 서로 다른 코드 동시 발급 시 각각 독립적으로 처리되어야 한다 ✅ 각 코드당 1개만 발급됨
가설 5 락 획득 실패 시 타임아웃 처리 필요 ✅ 타임아웃으로 예외 처리됨

 


💡 실험을 통해 얻은 교훈

  1. 멀티스레드 환경에서 동시성 제어는 필수적이다.
    • 단순한 조건 체크 (if (coupon.isIssued())) 만으로는 중복 발급을 막을 수 없다.
    • 락을 사용하지 않으면 수십, 수백 개의 쿠폰이 동시에 발급되는 일이 실제로 발생한다.
  2. ReentrantLock을 이용한 락 제어는 효과적인 동시성 제어 방법이다.
    • 전역 락은 간단하지만 경쟁이 치열할수록 병목이 될 수 있고,
    • code별로 락을 분리하는 방식은 병렬성을 높이면서도 안전성을 유지할 수 있는 절충안이 된다.
  3. 락은 무조건 좋은 것만은 아니다.
    • 잘못된 사용은 데드락의 원인이 되고,
    • 락을 무기한 기다리는 구조는 사용자 경험을 망칠 수 있다.
    • 이를 방지하기 위해 tryLock(timeout)과 같은 타임아웃 기법이 중요하다.

📌 정리 포인트

  • ConcurrentHashMap을 활용해 락 객체를 쿠폰 코드 별로 분리하여 병렬 처리와 안정성의 균형을 맞출 수 있다.
  • 락을 사용할 땐 tryLock()을 적절히 활용해 시스템 전체가 멈추는 상황을 방지해야 한다.
  • 테스트 코드를 통해 실전 환경에서 발생할 수 있는 race condition, 빠른 중복 클릭, 데드락 등을 미리 검증해보는 과정이 매우 중요하다.

🧠 결론

동시성 문제는 코드의 양보다 훨씬 복잡한 버그를 유발할 수 있는 영역이다.
이번 실험을 통해 동시성 제어의 필요성과 락의 사용 방법, 그리고 락을 안전하게 사용하는 실전 기법까지 함께 다뤘다.
단순히 "락을 쓰자"가 아닌, 어떻게, 어디서, 얼마나 정교하게 쓰는지가 시스템의 안정성과 직결된다.

 

 

반응형

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

[Java] ReentrantLock 정리 (feat. 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] ReentrantLock 정리 (feat. synchronized)
  • [Java] Map의 computeIfAbsent()과 computeIfPresent() 메서드
  • [Java & Spring] HashMap vs ConcurrentHashMap, 실습으로 알아보는 차이
  • [Java] Java의 ConcurrentHashMap 정리
Penguin Dev
Penguin Dev
What does the Penguin say?
    글쓰기 관리
  • Penguin Dev
    Pengha!
    Penguin Dev
  • 전체
    오늘
    어제
    • 분류 전체보기 (152)
      • Java & Spring (5)
      • 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)
  • 블로그 메뉴

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

  • 공지사항

  • 인기 글

  • 태그

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

  • 반응형
  • hELLO· Designed By정상우.v4.10.3
Penguin Dev
[Java & Spring] ReentrantLock, 쿠폰 발급으로 실습하고 테스트 코드로 증명해보기
상단으로

티스토리툴바