들어가기 전에

 

우아한테크톡의 선착순 이벤트 서버 생존기를 보고 아이디어를 얻었고, 그 과정을 처음부터 부딪히면서 배우기 위해 바닥부터 만들어보면서 느낀점과 배운것들을 정리하기 위해 쓰는 글입니다.

 

 

개요

초보 개발자로써 높은 트래픽을 경험하는 것은 거의 불가능합니다. 그래서 그런지 저도 동시성 불감증이 있나 별로 크게 생각하지 않았었는데, 우아한테크톡에서 다룬 영상을 보고 직접 만들어보고 싶은 생각이 들었습니다. 개발 과정은 처음부터 완성된 결과물을 만들어내는게 아니고 바닥부터 단계별로 적용해보면서 조금씩 나아지는 결과물을 만들어 보는 방향으로 해보았습니다. 그게 더 공부가 될 것 같거든요.

 

 

Entity

@Entity
public class Coupon {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "COUPON_ID")
    private Long id;

    private String name;
    private int amount; // 최대 수량

}
@Entity
public class EventCoupon {

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "COUPON_ID")
    private Coupon coupon;

    @Column(unique = true, nullable = false)
    private Long userId;

    private LocalDateTime createdAt;

}

 

Entity는 테스트 환경에 맞게 간단하게 구성했습니다.

Coupon의 amount는 한정수량을 나타냅니다. 500 이면 500장만 발급할 수 있습니다.

EventCoupon의 Long userId 는 원래 User user 로 join 되어야 하지만 테스트 환경 상 수 많은 User 데이터를 넣는것이 번거롭기 때문에 User user의 id 값인 Long userId 로 넣었습니다. 저 자리에 실제로는 User user 가 들어가야 한다고 이해하면 됩니다.

이벤트 쿠폰은 중복수령할 수 없기 때문에 userId에 unique 속성을 주었습니다.

 

Config

@Configuration
@RequiredArgsConstructor
public class CouponConfig {

    private final JpaCouponRepository jpaCouponRepository;

    @PostConstruct
    public void init() {
        Coupon saveCoupon = Coupon.builder()
            .name("한정수량 쿠폰")
            .amount(200)
            .build();
        jpaCouponRepository.save(saveCoupon);
    }
}

쿠폰을 서버 띄울 때 하나 만들어줍니다. 수량은 200개로 고정하고 시작하겠습니다.

 

 

동시성 문제 해결하기

먼저 내가 작성한 로직이 정상적으로 동작해야 다음 파트로 넘어갈 수 있습니다. 제가 생각한 로직의 순서는 다음과 같습니다.

 

  1. Coupon에서 현재 amount를 확인 - amount가 0이면 예외
  2. EventCoupon에서 이전에 발급받은 이력 확인 - 발급이력이 존재하면 예외
  3. Coupon 의 amount 1 감소
  4. 이벤트 쿠폰 발급

간단하게 이렇게 구성했습니다.

 

synchronized

@Service
@RequiredArgsConstructor
public class CouponServiceV1 {

    private final JpaEventCouponRepository jpaEventCouponRepository;
    private final JpaCouponRepository jpaCouponRepository;
    private final CouponRepository couponRepository;

    @Transactional
    public synchronized void issueEventCoupon(long userId, LocalDateTime now) {

        Coupon coupon = jpaCouponRepository.findById(1L)
            .orElseThrow(() -> new CouponException("쿠폰을 찾을 수 없습니다."));
        if (!coupon.isAvailable()) {
            throw new CouponException("마감되었습니다.");
        }

        if (jpaEventCouponRepository.existsByUserId(userId)) {
            throw new DuplicateEventCouponException("중복해서 쿠폰을 받을 수 없습니다.");
        }

        coupon.decreaseAmount();
        couponRepository.issueEventCoupon(userId, now, coupon);
    }

}
@Repository
@RequiredArgsConstructor
public class CouponRepository {

    private final JpaEventCouponRepository jpaEventCouponRepository;

    public void issueEventCoupon(long userId, LocalDateTime now, Coupon coupon) {
        EventCoupon saveEventCoupon = EventCoupon.builder()
            .userId(userId)
            .coupon(coupon)
            .createdAt(now)
            .build();
        jpaEventCouponRepository.save(saveEventCoupon);
    }
    
}

그렇게 만들어진 CouponServiceV1과 CouponRepository입니다. Coupon Id는 1L로 그냥 고정하고 만들었습니다.

 

V1의 특징으로는 @Transactional 에 synchronized 키워드가 붙어있습니다.

'synchronized가 Thread-Safe 하니까 알아서 잘 해주겠지?' 하고 테스트코드를 작성해봅니다.

 

 

 

Test

@SpringBootTest
public class CouponServiceV1Test {

    @Autowired
    private CouponServiceV1 couponService;
    @Autowired
    private JpaCouponRepository jpaCouponRepository;
    @Autowired
    private JpaEventCouponRepository jpaEventCouponRepository;


    @Test
    @DisplayName("1000개의 동시 요청에도 200개의 이벤트쿠폰이 정상적으로 발급된다.")
    void issueEventCoupon() throws InterruptedException {
        // given
        final int threadPoolSize = 32;
        final int threadCount = 1000;

        final ExecutorService executorService = Executors.newFixedThreadPool(threadPoolSize);
        final CountDownLatch countDownLatch = new CountDownLatch(threadCount);

        // when
        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
                try {
                    long userId = new Random().nextLong(100000) + 1;
                    couponService.issueEventCoupon(userId, LocalDateTime.now());
                } finally {
                    countDownLatch.countDown();
                }
            });
        }
        countDownLatch.await();
        executorService.shutdown();

        // then
        Optional<Coupon> findCoupon = jpaCouponRepository.findById(1L);
        assertThat(findCoupon).isPresent();
        assertThat(findCoupon.get().getAmount()).isEqualTo(0);

        List<EventCoupon> findEventCoupon = jpaEventCouponRepository.findAll();
        assertThat(findEventCoupon).hasSize(200);

    }

}

 

ExecutorService로 여러 Thread로 해당 메소드를 실행하는 코드를 작성했습니다. 근데 조금 보기가 불편해서 ThreadExecutor 클래스를 새로 만들었습니다.

 

public class ThreadExecutor {

    private final ExecutorService executorService;
    private CountDownLatch countDownLatch;

    private ThreadExecutor(int nThreads) {
        this.executorService = Executors.newFixedThreadPool(nThreads);
    }

    public static ThreadExecutor newThreadPool(int threadPoolSize) {
        return new ThreadExecutor(threadPoolSize);
    }

    public ThreadExecutor threadCount(int threadCount) {
        this.countDownLatch = new CountDownLatch(threadCount);
        return this;
    }

    public void run(Runnable task) throws InterruptedException {
        assert (countDownLatch != null) : "Require countDownLatch before run()";
        for (int i = 0; i < countDownLatch.getCount(); i++) {
            executorService.submit(() -> {
                try {
                    task.run();
                } finally {
                    countDownLatch.countDown();
                }
            });
        }

        countDownLatch.await();
        executorService.shutdown();
    }

}
    @Test
    @DisplayName("1000개의 동시 요청에도 200개의 이벤트쿠폰이 정상적으로 발급된다.")
    void issueEventCoupon2() throws InterruptedException {
        // given
        final int threadPoolSize = 32;
        final int threadCount = 1000;

        // when
        ThreadExecutor.newThreadPool(threadPoolSize)
            .threadCount(threadCount)
            .run(() -> {
                long userId = new Random().nextLong(100000) + 1;
                couponService.issueEventCoupon(userId, LocalDateTime.now());
            });

        // then
        Optional<Coupon> findCoupon = jpaCouponRepository.findById(1L);
        assertThat(findCoupon).isPresent();
        assertThat(findCoupon.get().getAmount()).isEqualTo(0);

        List<EventCoupon> findEventCoupon = jpaEventCouponRepository.findAll();
        assertThat(findEventCoupon).hasSize(200);

    }

앞으로도 몇 번 더 써야하니까 미리 분리했고 조금더 깔끔해 보이네요 이제 진짜 돌려보겠습니다.

 

결과

[pool-3-thread-2] o.h.engine.jdbc.spi.SqlExceptionHelper   : Deadlock found when trying to get lock; try restarting transaction

 

Deadlock이 감지되었습니다.

Deadlock 감지 기능이 ON 되어있어서 이렇게 뜨는겁니다.
Deadlock 감지 기능 ON(default) -> 이전 요청을 롤백해서 선행요청에 Blocking이 해제되어 작업수행
Deadlock 감지 기능 OFF -> 양쪽에서 Timeout 되어 롤백됩니다.

MySQL에서 상태변경
SET GLOBAL innodb_deadlock_detect = OFF; -- Deadlock 감지 비활성화
SET GLOBAL innodb_deadlock_detect = ON; -- Deadlock 감지 활성화 (기본값)

쿠폰은 200개로 한정했지만 총 380개가 발행되었습니다. 대참사입니다. 처음에는 synchronized 키워드를 사용했는데 동시요청이 왜 발생한건지 이해가 되지 않았습니다.

 

원인

결론부터 말하자면 synchronized 는 정상적으로 동작했으나 트랜잭션의 프록시 객체에 의해서 실행되는 것이 문제였습니다.

public class CouponServiceV1Proxy {

    private CouponServiceV1 couponServiceV1;

    public void issueEventCoupon(long userId, LocalDateTime now) {
        // 트랜잭션 시작
        
        couponServiceV1.issueEventCoupon(userId, now);
        
        // 트랜잭션 종료
    }

}

대충 만들어보자면 이런 구조인거죠.

  1. 프록시객체에서 트랜잭션을 시작한다
  2. synchronized 메소드는 트랜잭션 내부에 있다.
  3. 트랜잭션이 종료되고 최종 커밋된다.
  4. synchronized 메소드내에서 변경이 일어나고 메소드가 종료되어도 아직 프록시 객체의 트랜잭션은 종료되지 않았다.
  5. 따라서 의도되지 않은 동시성 이슈가 발생했다.

 

프록시 객체가 만들어지는건 뻔히 알고있었는데도 synchronized 키워드에 대해서는 인지하지 못한 실수입니다.

그렇다면 트랜잭션 밖에 synchronized 를 붙히면 되지 않을까?

 

    private synchronized void issueEventCouponSynchronized(Long userId, LocalDateTime now) {
        couponService.issueEventCoupon(userId, now);
    }

대충 이렇게 만들어보았다.

성공

제대로 테스트가 성공했습니다. 그럼 이제 해피엔딩일까? 저도 처음에는 그렇게 생각했습니다. 잘 생각해보면 synchronized 는 하나의 서버에서만 Thread-Safe 하다는걸 느끼실겁니다. 서버가 2개 이상일때는 무방비해진다는거죠. 따라서 DB 자체에서 Lock을 걸어 데이터의 일관성을 유지하도록 해야합니다. 

 

서버의 확장이 전혀 없을 만한 환경에서는 사용해도 무방할것같습니다. 하지만 저는 synchronized 는 사용하지 않도록 하겠습니다.

 

 

 

Lock

 

[DB] Lock 이해하기

Lock 이란?하나의 자원에 여러 요청이 들어올 때 데이터의 일관성과 무결성을 보장하기 위해 하나의 커넥션에게만 변경할 수 있는 권한을 주는 기능으로 동시성을 제어하기 위한 목적으로 사용

tmd8633.tistory.com

MySQL에서 사용하는 비관적 락, 낙관적 락 과 같은 내용은 따로 글을 다루고 여기에 추가하겠습니다. 아직 글을 안썼습니다.

 

위의 글을 참고하시면 도움이 될겁니다.

 

 

@Service
@RequiredArgsConstructor
public class CouponServiceV2 {

    private final JpaEventCouponRepository jpaEventCouponRepository;
    private final JpaCouponRepository jpaCouponRepository;
    private final CouponRepository couponRepository;

    @Transactional(isolation = Isolation.REPEATABLE_READ) // MySQL 기본 격리 수준, Oracle 은 READ_COMMITTED
    public void issueEventCoupon(long userId, LocalDateTime now) {

        Coupon coupon = jpaCouponRepository.findByIdWithLock(1L)
            .orElseThrow(() -> new CouponException("쿠폰을 찾을 수 없습니다."));
        if (!coupon.isAvailable()) {
            throw new CouponException("수량 마감");
        }

        if (jpaEventCouponRepository.existsByUserId(userId)) {
            throw new DuplicateEventCouponException("중복해서 쿠폰을 받을 수 없습니다.");
        }

        coupon.decreaseAmount();
        couponRepository.issueEventCoupon(userId, now, coupon);
    }

}

그렇게 만들어진 CouponServiceV2 입니다.

트랜잭션이 시작될 때 읽은 데이터를 트랜잭션이 종료될 때까지 다른 트랜잭션이 변경할 수 없는 격리수준 (REPEATABLE_READ)을 사용했습니다. 저는 MySQL을 사용하고있고, 기본격리수준이 REPEATABLE_READ 라 명시하지 않아도 되지만 공부하기 위해서 작성해놓았습니다.

 

public interface JpaCouponRepository extends JpaRepository<Coupon, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE) // 배타적 락 획득
    @Query("SELECT c FROM Coupon c WHERE c.id = :id")
    Optional<Coupon> findByIdWithLock(@Param("id") Long id);
}

@Lock이나 @Transactional isolation 에 대해서는 따로 글을 작성하겠습니다.

 

Coupon을 가져올 때, 배타적 락을 획득하도록 만들었습니다. UPDATE가 이루어지기 때문에 WRITE로 했습니다.

 

배타적 락을 획득함으로 다른 트랜잭션에서는 락을 반환하기전까지 Blocking 되도록 했습니다.

 

 

Test

@SpringBootTest
public class CouponServiceV2Test {

    @Autowired
    private CouponServiceV2 couponService;
    @Autowired
    private JpaCouponRepository jpaCouponRepository;
    @Autowired
    private JpaEventCouponRepository jpaEventCouponRepository;

    @Test
    @DisplayName("1000개의 동시 요청에도 200개의 이벤트쿠폰이 정상적으로 발급된다.")
    void issueEventCoupon2() throws InterruptedException {
        // given
        final int threadPoolSize = 32;
        final int threadCount = 1000;

        // when
        ThreadExecutor.newThreadPool(threadPoolSize)
            .threadCount(threadCount)
            .run(() -> {
                long userId = new Random().nextLong(2000) + 1;
                couponService.issueEventCoupon(userId, LocalDateTime.now());
            });

        // then
        Optional<Coupon> findCoupon = jpaCouponRepository.findById(1L);
        assertThat(findCoupon).isPresent();
        assertThat(findCoupon.get().getAmount()).isEqualTo(0);

        List<EventCoupon> findEventCoupon = jpaEventCouponRepository.findAll();
        assertThat(findEventCoupon).hasSize(200);

    }

}

달라진거 하나도 없습니다. CouponServiceV1 -> CouponServiceV2로만 변경되었습니다.

 

 

결과

성공

 

이번에는 JMeter로도 조금더 강하게 테스트 해보겠습니다.

 

400 Thread, 10초간 요청하도록 설정

 

10초간 21124번의 요청이 있었고, 에러 0% 입니다. 정상 작동합니다.

 

 

 

결론

위의 코드는 최대한 한 화면에 보이도록 작성한 코드입니다. 쓸만한 코드가 아닙니다. 그냥 내용만 봐주세요.

생각보다 오랫동안 고민하면서 작성했습니다. 특히 synchronized 부분에서 애먹었던것 같습니다. 'synchronized 는 thread-safe하다' 와 '트랜잭션은 프록시를 통해 실행된다.', '프록시객체를 사용하기 때문에 내무 메소드를 호출하면 안된다' 를 통합하는게 이상하게 너무 어려웠네요.. 그래서 Lock 파트는 내용 자체가 어렵지 않아서 금방 이해할 수 있었습니다. 이번 동시성 이슈를 다루면서 전에 생각하지 못했던 부분들 (락 획득 자원의 순서의 중요성, Deadlock 감지 기능 등)에 대해서 배울 수 있었던 시간이었습니다.

 

다음 글은 Redis를 활용해서 줄세우기를 만들어보도록 하겠습니다. 만들어보고 테스트해보고 글을 작성하는거라 좀 걸립니다.

Lock 이란?

하나의 자원에 여러 요청이 들어올 때 데이터의 일관성과 무결성을 보장하기 위해 하나의 커넥션에게만 변경할 수 있는 권한을 주는 기능으로 동시성을 제어하기 위한 목적으로 사용됩니다.  간단한 예시를 보면서 이해해 보도록 하겠습니다.

사용자에게 송금하는 메소드 transfer 가 있습니다. 순서는 다음과 같습니다. 잔고조회 -> 잔고 + amount

 

사용자A사용자B가 동시에 사용자C에게 송금한다고 했을 때, 사용자A, 사용자B는 1000 씩 송금을 했으니 사용자C의 잔고가 4000이 될 것으로 예상했으나 최종잔고는 3000이 되었습니다. 이는 동시성 문제로 데이터의 일관성과 무결성이 보장되지 않아서 생기는 문제입니다. 오늘은 이 문제를 해결하는 Lock 에 대해서 알아보도록 하겠습니다.

 

 

Lock의 종류

1. Shared Lock (공유 락)

  • 읽기 작업을 위한 Lock
  • 여러 트랜잭션이 동시에 데이터를 읽을 수 있음
  • 다른 Shared Lock과는 호환되지만 Exclusive Lock과는 호환되지 않음
    • 읽기 작업 중일 때, 새로운 읽기 작업이 들어온 경우 -> 가능
    • 읽기 작업 중일 때, 새로운 쓰기 작업이 들어온 경우 -> 불가능 (쓰기 작업으로 멱등성 보장x)

2. Exclusive Lock (배타적 락)

  • 쓰기 작업을 위한 Lock
  • 한 번에 하나의 트랜잭션만 데이터를 수정할 수 있음
  • 다른 어떤 Lock과도 호환되지 않음
    • 쓰기 작업 중일 때, 새로운 읽기 작업이 들어온 경우 -> 불가능
    • 쓰기 작업 중일 때, 새로운 쓰기 작업이 들어온 경우 -> 불가능

 

그렇다면 호환되지 않은 Lock의 요청이 들어올 때 불가능하다고 했는데 어떤 상황이 벌어질까?

 

 

Blocking

호환되지 않은 Lock 이 들어올 경우 블로킹(Blocking)이 발생합니다. 새로운 Lock의 요청이 들어올 때, 이미 Lock이 선행되고있으면 선행되고있는 Lock이 종료될떄까지 대기하고있다가 종료가 되면 새로운 Lock을 획득해 진행하게됩니다.

 

위의 예시에 Lock을 적용한다면 순서는 다음과 같습니다.

사용자A -> transfer(사용자A, 사용자C, 1000) -> Exclusive Lock 획득 -> 사용자B -> transfer(사용자B, 사용자C, 1000) -> Blocking (사용자A의 Lock이 해제될 때까지 대기) -> 송금 -> Blocking 해제, Exclusive Lock 획득 -> 송금 

 

위에서 Shared Lock은 여러 Shared Lock과 호환된다고했습니다. 만약 100개의 트랜잭션에서 Shared Lock을 획득하고 있을때 1개의 Exclusive Lock 요청이 들어온다면 100개의 Shared Lock이 모두 해제될때까지 대기하게 됩니다. 또는 끊기지 않고 Shared Lock이 들어온다면 Exclusive Lock은 계속 획득하지 못하게 됩니다. 따라서 Lock을 설정하는 범위를 알맞게 설정하는 것이 중요합니다.

 

 

그 다음으로 두개의 자원에 대한 Lock을 다룰때는 어떤 문제가 생길 수 있을까요?

 

 

Dead Lock

두 개 이상의 자원에서 서로 Blocking 되어 영구적으로 접근할 수 없는 상태(교착상태)입니다.

 

위의 transfer 메소드에서 사용자의 잔고를 줄이는 로직추가하여 예시로 들어보겠습니다. 로직은 다음과 같습니다. 그림그리는 것보다 코드가 더 읽기 쉽고 이해하기 편하니 코드로 작성하겠습니다.

    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        // 잔액 확인 및 업데이트를 위해 Exclusive Lock
        User fromUser = userRepository.findByIdWithLock(fromId);

        // 수신자의 계좌에 Exclusive Lock
        User toUser = userRepository.findByIdWithLock(toId);

        // 송금 처리
        fromUser.withdraw(amount);
        toUser.deposit(amount);
    }

 

시간 사용자A
(사용자A -> 사용자B에게 100원 송금)
사용자B
(사용자B -> 사용자A에게 100원 송금)
1 사용자A 락 획득 사용자B 락 획득
2 사용자B 락 획득시도 (Blocking) 사용자A 락 획득시도 (Blocking)
3 대기 대기
4 Dead Lock

 

그러면 위의 코드에서 Dead Lock이 발생하지 않게 하기 위해서는 어떻게 해아할까요?

락 획득의 순서에서 일과성을 유지하는 것입니다.

    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        User fromUser, toUser;
        if (fromId < toId) {
            fromUser = userRepository.findByIdWithLock(fromId);
            toUser = userRepository.findByIdWithLock(toId);
        } else {
            toUser = userRepository.findByIdWithLock(toId);
            fromUser = userRepository.findByIdWithLock(fromId);
        }
        fromUser.withdraw(amount);
        toUser.deposit(amount);
    }

 

사용자A ID = 1

사용자B ID = 2 라고 했을 때,

시간 사용자A
(사용자A -> 사용자B에게 100원 송금)
사용자B
(사용자B -> 사용자A에게 100원 송금)
1 사용자A 락 획득 사용자A 락 획득시도 Blocking
2 사용자B 락 획득 대기
3 송금 (락 반환) 사용자A 락 획득
4 - 사용자B 락 획득
5 - 송금 (락 반환)

 

 

결론

동시성 문제와 대용량 트래픽을 공부하기 위해 한정 수량 쿠폰과 대기열을 만들어보는 와중에 DeadLock와 데이터 무결성에 문제가 생겼고, 이를 해결하는 과정에서 Lock에 대해서 공부하게되었습니다. 다음 글은 JPA Lock 전략과 JAVA synchrozied, 그리고 JMeter 에 대해서 작성하려고합니다.

+ Recent posts