✍️ 개발 기록

synchronized 키워드를 사용하여 임계 영역 지정하기

ming412 2024. 5. 17. 11:37

synchronized 키워드 사용

java에서 한 자원에 synchronized 키워드를 붙이면, 멀티 스레드 환경에서 단일 스레드만이 해당 자원에 접근 가능하도록 보장한다.

그렇다면 publishCoupon() 메서드에 synchronized 키워드를 붙임으로써, 동시에 들어오는 요청들을 순차적으로 처리할 수 있지 않을까?

CouponService.java

@Transactional
    public synchronized void publishCoupon(long couponId) {
        Coupon coupon = couponRepository.findById(couponId)
                .orElseThrow(() -> new RuntimeException("존재하지 않는 쿠폰입니다."));

        coupon.increaseReservedAmount();
        couponRepository.saveAndFlush(coupon); // 명시적으로 save를 나타내려고 사용

        int couponNumber = coupon.getReservedAmount();
        reservationRepository.save(new Reservation(coupon, couponNumber)); // 쿠폰 예매 내역 기록
    }

쿠폰 발급 로직에 synchronized 키워드를 붙였다. 이제 coupon.reservedAmount 는 하나의 스레드만 점유할 수 있으므로, 갱신 손실 문제를 해결할 수 있지 않을까?

실행 결과

분명 쿠폰의 수량은 10개였는데, 19명의 사용자가 예매에 성공하는 결과가 발생했다.

어떻게 티켓의 수량보다 많은 사용자가 티켓을 예매할 수 있었을까?

준비한 수량보다 더 많은 수량의 쿠폰이 발급된 원인

데이터베이스의 Reservation 테이블을 확인하면 그 이유를 알 수 있다.

 

 

1번부터 10번 쿠폰이 각각 한 장씩 예매된게 아니라 여러 장씩 예매되었다. 즉, 하나의 티켓이 여러 사용자에게 발급되는 문제가 발생했다.

→ 요청이 순차적으로 진행되지 않고, 병렬적으로 진행되어 갱신 손실 문제가 발생한 것이다.

갱실 손실 문제

 

  1. Thread 1이 데이터를 읽는다 (reservedAmount=0)
  2. Thread 2가 데이터를 읽는다 (reservedAmount=0)
  3. Thread 1이 데이터를 갱신한다 (reservedAmount=1)
  4. Thread 2가 데이터를 갱신한다 (reservedAmount=1)
    1. 스레드 2가 데이터를 갱신할 때, 스레드 1이 갱신했던 내역(reservedAmount가 0에서 1로 증가)이 사라진다.

→ 스레드 1의 ‘갱신’이 ‘손실’ 되었으므로 갱신 손실 문제 발생

그렇다면 synchronized 키워드를 사용했음에도 왜 갱신 손실 문제가 발생한걸까?

synchronized를 사용했음에도 갱신 손실 문제를 해결하지 못한 이유

결론부터 말하자면 Spring AOP 때문이다. @Transactional 을 사용하면 Spring AOP는 해당 빈을 프록시 객체로 감싼다.

프록시 객체는 원래 객체인 couponService 를 상속해서 만들어진다.

// Proxy class
class CouponServiceProxy extends CouponService{

    private CouponService couponService;

    @Override
    public void publishCoupon(long couponId) {
         try{
             tx.start();
             couponService.publishCoupon();
         } catch (Exception e) {
             // ...
         } finally {
             tx.commit();
         }
    }
}

// Origin Class
class CouponService {
	
    public synchronized void publishCoupon(long couponId) {
        // ...
    }
}

프록시 객체는 트랜잭션을 시작하고 종료하는 로직을 추가한다. 하지만 synchronized 는 메서드 시그니처(=메서드 이름 + 파라미터 타입과 개수)가 아니기 때문에 프록시 객체의 메서드에는 적용되지 않고, 원본 객체의 메서드에만 적용된다.

따라서 트랜잭션 경계와 동기화 경계가 일치하지 않게 된다.

따라서 프록시 객체의 publishCoupon() 은 여러 스레드가 사용할 수 있게 된다.

트랜잭션 경계와 동기화 경계의 불일치

Client
                            |
                            | publishCoupon() 호출
                            v
+--------------------------------------------+
|                Spring AOP                  |
|    +--------------------------------+      |
|    |           Proxy Object         |      |
|    |   1. Transaction Start         |      |
|    |   2. realObject.publishCoupon()|      |
|    +--------------------------------+      |
|                    |                       |
|                    v                       |
|    +--------------------------------+      |
|    |           Real Object          |      |
|    |                                |      |
|    |      synchronized block        |      |
|    |      3. publishCoupon() 실행   |      |
|    |                                |      |
|    +--------------------------------+      |
|                    |                       |
|                    v                       |
+--------------------------------------------+
             4. Transaction Commit/Rollback
  1. 클라이언트가 publishCoupon() 메서드를 호출한다.
  2. Spring AOP는 클라이언트 호출을 가로채어 프록시 객체의 메서드를 실행한다. 프록시 객체는 트랜잭션을 시작하고, 원본 객체의 publishCoupon() 메서드를 호출한다.
  3. 원본 객체의 publishCoupon() 메서드에는 synchronized 블록이 적용되어 있다. 이 블록은 원본 객체의 메서드에만 적용되므로, 프록시 객체의 메서드에서는 synchronized 가 적용되지 않는다.
  4. 원본 객체의 publishCoupon() 실행이 끝나면, 프록시 객체에서 트랜잭션을 커밋하거나 롤백한다.

이처럼 트랜잭션 경계는 프록시 객체의 메서드 시작과 끝에 있지만, synchronized 블록은 원본 객체의 메서드에만 적용되므로 트랜잭션 경계와 동기화 경계가 불일치하게 된다.

이로 인해 다음과 같은 문제가 발생한다.

  • 원래 객체인 **couponService**의 publishCoupon() 메서드가 synchronized 키워드로 동기화되지만, 프록시 객체는 이 동기화를 인지하지 못하고, 트랜잭션 경계 내에서 동시성 제어가 이루어지지 않는다.
  • 결과적으로, 하나의 트랜잭션이 커밋되기 전에 다른 스레드가 데이터를 읽거나 수정할 수 있어, 갱신 손실 문제를 해결할 수 없게 된다.

그럼 synchronized 는 ‘절대로’ 갱신 손실 문제를 해결할 수 없냐? 라고 묻는다면 그건 아니다.

@Transactional 을 사용하지 않는 방법

프록시 객체가 문제의 원인이었으니, 프록시 객체를 사용하지 않도록 @Transactional 을 사용하지 않으면 된다.

//    @Transactional
    public synchronized void publishCoupon(long couponId) {
        Coupon coupon = couponRepository.findById(couponId)
                .orElseThrow(() -> new RuntimeException("존재하지 않는 쿠폰입니다."));

        coupon.increaseReservedAmount();
        couponRepository.saveAndFlush(coupon); // 명시적으로 save를 나타내려고 사용

        int couponNumber = coupon.getReservedAmount();
        reservationRepository.save(new Reservation(coupon, couponNumber)); // 쿠폰 예매 내역 기록
    }

 

실행 결과

 

 

드디어 갱신 손실 문제를 해결하고 테스트에 성공했다.

하지만 여기에는 아래와 같은 트랜잭션 관련 문제들이 발생할 수 있다.

  • 원자성 보장 실패
    • 트랜잭션은 데이터베이스 작업이 부분적으로 실행되는 것을 방지하고, 모든 작업이 성공하거나 실패하도록 보장한다.
  • 격리 수준 관리 어려움
    • 트랜잭션은 격리 수준을 통해 다른 트랜잭션이 진행되는 동안 데이터가 어떻게 보이는지를 제어한다.
  • 트랜잭션 전파 관리 실패
    • 트랜잭션 전파는 하나의 트랜잭션이 다른 트랜잭션을 호출할 때 어떻게 도착해야 하는지를 정의한다.

결론

@Transactional 을 살리기 위해서는, synchronized 대신 다른 동시성 제어 기법을 사용하는 것이 좋아보인다.

Ref.

@Transactional과 synchronized를 같이 사용할 때의 문제점

synchronized 키워드 사용

java에서 한 자원에 synchronized 키워드를 붙이면, 멀티 스레드 환경에서 단일 스레드만이 해당 자원에 접근 가능하도록 보장한다.

그렇다면 publishCoupon() 메서드에 synchronized 키워드를 붙임으로써, 동시에 들어오는 요청들을 순차적으로 처리할 수 있지 않을까?

CouponService.java

@Transactional
    public synchronized void publishCoupon(long couponId) {
        Coupon coupon = couponRepository.findById(couponId)
                .orElseThrow(() -> new RuntimeException("존재하지 않는 쿠폰입니다."));

        coupon.increaseReservedAmount();
        couponRepository.saveAndFlush(coupon); // 명시적으로 save를 나타내려고 사용

        int couponNumber = coupon.getReservedAmount();
        reservationRepository.save(new Reservation(coupon, couponNumber)); // 쿠폰 예매 내역 기록
    }

쿠폰 발급 로직에 synchronized 키워드를 붙였다. 이제 coupon.reservedAmount 는 하나의 스레드만 점유할 수 있으므로, 갱신 손실 문제를 해결할 수 있지 않을까?

실행 결과

분명 쿠폰의 수량은 10개였는데, 19명의 사용자가 예매에 성공하는 결과가 발생했다.

어떻게 티켓의 수량보다 많은 사용자가 티켓을 예매할 수 있었을까?

준비한 수량보다 더 많은 수량의 쿠폰이 발급된 원인

데이터베이스의 Reservation 테이블을 확인하면 그 이유를 알 수 있다.

1번부터 10번 쿠폰이 각각 한 장씩 예매된게 아니라 여러 장씩 예매되었다. 즉, 하나의 티켓이 여러 사용자에게 발급되는 문제가 발생했다.

→ 요청이 순차적으로 진행되지 않고, 병렬적으로 진행되어 갱신 손실 문제가 발생한 것이다.

갱실 손실 문제

  1. Thread 1이 데이터를 읽는다 (reservedAmount=0)
  2. Thread 2가 데이터를 읽는다 (reservedAmount=0)
  3. Thread 1이 데이터를 갱신한다 (reservedAmount=1)
  4. Thread 2가 데이터를 갱신한다 (reservedAmount=1)
    1. 스레드 2가 데이터를 갱신할 때, 스레드 1이 갱신했던 내역(reservedAmount가 0에서 1로 증가)이 사라진다.

→ 스레드 1의 ‘갱신’이 ‘손실’ 되었으므로 갱신 손실 문제 발생

그렇다면 synchronized 키워드를 사용했음에도 왜 갱신 손실 문제가 발생한걸까?

synchronized를 사용했음에도 갱신 손실 문제를 해결하지 못한 이유

결론부터 말하자면 Spring AOP 때문이다. @Transactional 을 사용하면 Spring AOP는 해당 빈을 프록시 객체로 감싼다.

프록시 객체는 원래 객체인 couponService 를 상속해서 만들어진다.

// Proxy class
class CouponServiceProxy extends CouponService{

    private CouponService couponService;

    @Override
    public void publishCoupon(long couponId) {
         try{
             tx.start();
             couponService.publishCoupon();
         } catch (Exception e) {
             // ...
         } finally {
             tx.commit();
         }
    }
}

// Origin Class
class CouponService {
	
    public synchronized void publishCoupon(long couponId) {
        // ...
    }
}

프록시 객체는 트랜잭션을 시작하고 종료하는 로직을 추가한다. 하지만 synchronized 는 메서드 시그니처(=메서드 이름 + 파라미터 타입과 개수)가 아니기 때문에 프록시 객체의 메서드에는 적용되지 않고, 원본 객체의 메서드에만 적용된다.

따라서 트랜잭션 경계와 동기화 경계가 일치하지 않게 된다.

따라서 프록시 객체의 publishCoupon() 은 여러 스레드가 사용할 수 있게 된다.

트랜잭션 경계와 동기화 경계의 불일치

Client
                            |
                            | publishCoupon() 호출
                            v
+--------------------------------------------+
|                Spring AOP                  |
|    +--------------------------------+      |
|    |           Proxy Object         |      |
|    |   1. Transaction Start         |      |
|    |   2. realObject.publishCoupon()|      |
|    +--------------------------------+      |
|                    |                       |
|                    v                       |
|    +--------------------------------+      |
|    |           Real Object          |      |
|    |                                |      |
|    |      synchronized block        |      |
|    |      3. publishCoupon() 실행   |      |
|    |                                |      |
|    +--------------------------------+      |
|                    |                       |
|                    v                       |
+--------------------------------------------+
             4. Transaction Commit/Rollback
  1. 클라이언트가 publishCoupon() 메서드를 호출한다.
  2. Spring AOP는 클라이언트 호출을 가로채어 프록시 객체의 메서드를 실행한다. 프록시 객체는 트랜잭션을 시작하고, 원본 객체의 publishCoupon() 메서드를 호출한다.
  3. 원본 객체의 publishCoupon() 메서드에는 synchronized 블록이 적용되어 있다. 이 블록은 원본 객체의 메서드에만 적용되므로, 프록시 객체의 메서드에서는 synchronized 가 적용되지 않는다.
  4. 원본 객체의 publishCoupon() 실행이 끝나면, 프록시 객체에서 트랜잭션을 커밋하거나 롤백한다.

이처럼 트랜잭션 경계는 프록시 객체의 메서드 시작과 끝에 있지만, synchronized 블록은 원본 객체의 메서드에만 적용되므로 트랜잭션 경계와 동기화 경계가 불일치하게 된다.

이로 인해 다음과 같은 문제가 발생한다.

  • 원래 객체인 **couponService**의 publishCoupon() 메서드가 synchronized 키워드로 동기화되지만, 프록시 객체는 이 동기화를 인지하지 못하고, 트랜잭션 경계 내에서 동시성 제어가 이루어지지 않는다.
  • 결과적으로, 하나의 트랜잭션이 커밋되기 전에 다른 스레드가 데이터를 읽거나 수정할 수 있어, 갱신 손실 문제를 해결할 수 없게 된다.

그럼 synchronized 는 ‘절대로’ 갱신 손실 문제를 해결할 수 없냐? 라고 묻는다면 그건 아니다.

@Transactional 을 사용하지 않는 방법

프록시 객체가 문제의 원인이었으니, 프록시 객체를 사용하지 않도록 @Transactional 을 사용하지 않으면 된다.

//    @Transactional
    public synchronized void publishCoupon(long couponId) {
        Coupon coupon = couponRepository.findById(couponId)
                .orElseThrow(() -> new RuntimeException("존재하지 않는 쿠폰입니다."));

        coupon.increaseReservedAmount();
        couponRepository.saveAndFlush(coupon); // 명시적으로 save를 나타내려고 사용

        int couponNumber = coupon.getReservedAmount();
        reservationRepository.save(new Reservation(coupon, couponNumber)); // 쿠폰 예매 내역 기록
    }

실행 결과

드디어 갱신 손실 문제를 해결하고 테스트에 성공했다.

하지만 여기에는 아래와 같은 트랜잭션 관련 문제들이 발생할 수 있다.

  • 원자성 보장 실패
    • 트랜잭션은 데이터베이스 작업이 부분적으로 실행되는 것을 방지하고, 모든 작업이 성공하거나 실패하도록 보장한다.
  • 격리 수준 관리 어려움
    • 트랜잭션은 격리 수준을 통해 다른 트랜잭션이 진행되는 동안 데이터가 어떻게 보이는지를 제어한다.
  • 트랜잭션 전파 관리 실패
    • 트랜잭션 전파는 하나의 트랜잭션이 다른 트랜잭션을 호출할 때 어떻게 도착해야 하는지를 정의한다.

결론

@Transactional 을 살리기 위해서는, synchronized 대신 다른 동시성 제어 기법을 사용하는 것이 좋아보인다.

Ref.

@Transactional과 synchronized를 같이 사용할 때의 문제점