[1]. 동시성 이슈(synchronized)

어?개발자·2022년 12월 17일
0

회사 이슈

목록 보기
1/1

회사에서 개발 중 발생 했던.. 동시성 문제

발생 했던 부분은 게시글 조회수 증가 부분과 좋아요 개수 증감에서 동시성 문제가 발생 했었다.
그때 당시에 돌려막기로 어찌어찌 해결 했지만 공부가 필요하다 생각하여 블로그를 찾아봤고
아주 좋은 블로그를 발견 했다.

해당 블로그에서 진행 했던 것을 그대로 일부 타겟만 변경해서 공부 해보기로 했다.
자세한 내용은 해당 블로그에 가서 보는 것을 추천한다.

이번 글에서는 synchronized에 대해서만 테스트 하겠다.


특정 코인은 100개의 수량이 있으며 사용자가 구매시 1개씩 감소 된다.

엔티티

@Entity
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
public class CoinStock {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(columnDefinition = "int(11)")
    private Long coinStockId; // 코인 재고 아이디
    @Column(columnDefinition = "int(11)")
    private Long coinId; // 코인 아이디
    @Column(columnDefinition = "int(11)")
    private Long quantity; // 수량


    /**
     * 코인 수량을 1개 감소하는 메서드
     */
    public void decrease() {
        if (this.quantity - 1L < 0) {
            throw new RuntimeException("코인 수량 감소 에러 - 수량이 0이하 입니다.");
        }
        this.quantity -= 1L;
    }
}

서비스

@Service
@RequiredArgsConstructor
public class CoinStockService {
    private final CoinStockRepository coinStockRepository;
    private final Logger LOG = LoggerFactory.getLogger(this.getClass().getSimpleName());

    /**
     * 코인 재고 감소 서비스
     *
     * @param coinStockId Long
     */
    public void decrease(Long coinStockId) {
        CoinStock coinStock = coinStockRepository.findById(coinStockId).orElseGet(CoinStock::new);
        LOG.info("[감소 전]코인 재고 아이디: {} / 코인 재고 개수: {}", coinStock.getCoinStockId(), coinStock.getQuantity());
        coinStock.decrease();
        LOG.info("[감소 후]코인 재고 아이디: {} / 코인 재고 개수: {}", coinStock.getCoinStockId(), coinStock.getQuantity());
        coinStockRepository.save(coinStock);
    }
}

동작 코드

@DisplayName(value = "100개의 요청")
    @Test
    void decrease() throws InterruptedException {
        int threadCount = 100;

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

        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
                try {
                    stockService.decrease(1L);
                } finally {
                    countDownLatch.countDown();
                }
            });
        }

        countDownLatch.await();

        CoinStock coinStock = coinStockRepository.findById(1L).orElseGet(CoinStock::new);
        LOG.info("coinStock -> quantity : {}", coinStock.getQuantity());
    }

결과

[pool-1-thread-10] INFO  CoinStockService.decrease.25 라인 - [감소 후]코인 재고 아이디: 1 / 코인 재고 개수: 86
[pool-1-thread-8] INFO  CoinStockService.decrease.23 라인 - [감소 전]코인 재고 아이디: 1 / 코인 재고 개수: 87
[pool-1-thread-8] INFO  CoinStockService.decrease.25 라인 - [감소 후]코인 재고 아이디: 1 / 코인 재고 개수: 86
[pool-1-thread-6] INFO  CoinStockService.decrease.23 라인 - [감소 전]코인 재고 아이디: 1 / 코인 재고 개수: 87
[pool-1-thread-1] INFO  CoinStockService.decrease.23 라인 - [감소 전]코인 재고 아이디: 1 / 코인 재고 개수: 87
[pool-1-thread-6] INFO  CoinStockService.decrease.25 라인 - [감소 후]코인 재고 아이디: 1 / 코인 재고 개수: 86
[pool-1-thread-1] INFO  CoinStockService.decrease.25 라인 - [감소 후]코인 재고 아이디: 1 / 코인 재고 개수: 86
[pool-1-thread-9] INFO  CoinStockService.decrease.23 라인 - [감소 전]코인 재고 아이디: 1 / 코인 재고 개수: 86
[pool-1-thread-9] INFO  CoinStockService.decrease.25 라인 - [감소 후]코인 재고 아이디: 1 / 코인 재고 개수: 85
[Test worker] INFO  CoinStockServiceTest.decrease.44 라인 - coinStock -> quantity : 85

코인 재고는 시작할 때 100개가 있었으며 100개의 감소 요청이 있었으니 재고는 0이 맞지만 85이다.

생각했던 것은 쓰레드 8의 작업이 끝나고 쓰레드1이 작업을 하는 방식이었다.
하지만 결과는 쓰레드 8과 쓰레드 1이 동시에 작업을 한 것 이다.
[쓰레드8] : 재고 조회 -> 재고 감소 -> 저장
[쓰레드1] : 재고 조회 -> 재고 감소 -> 저장

쓰레드 8이 감소하고 저장하기 전에 쓰레드 1이 조회를 해서 같은 값을 1을 뺀 것 이다.

하나의 예시를 보자면...
[쓰레드8] DB 재고 조회(수량 100)
[쓰레드8] 재고 감소(내부 로직에서 1 뺌 -> 수량 99)
[쓰레드1] DB 재고 조회(수량 100)
[쓰레드1] 재고 감소(내부 로직에서 1 뺌 -> 수량 99)
[쓰레드8] DB 저장
[쓰레드1] DB 저장

결론적으로는 수량이 99가 저장 된다.

이 문제를 해결 하기 위해서는 쓰레드 동기화를 진행 해줄 수 있다.


쓰레드 동기화 synchronized 키워드

자바 멀티 쓰레드 환경에서는 쓰레드 끼리 static 영역과 heap 영역을 공유하기 때문에 synchronized 키워드를 사용해서 동기화를 해줘야 한다.

이번 문제는 static 영역과 heap 영역을 공유해서..는 아닌거 같고 쓰레드 두개 이상 동시에 진입하여 조회 하면서 발생하는 타이밍 문제로 보이기 때문에 좀 다르긴 하다.. 하지만 synchronized을 추가 하면 메서드에 하나의 쓰레드만 진입할 수 있기 때문에 synchronized로 문제를 해결하는 것 같다.

synchronized 키워드가 붙은 메서드에 쓰레드가 진입을 할 때 lock을 가지고 진입을 하며 해당 메서드에 lock이 걸려 해당 쓰레드가 메서드에서 나오지 않는 이상 다른 쓰레드에서 진입할 수 없으며 이러한 방식으로 쓰레드 동기화 처리를 한다고 한다. synchronized을 가지고 lock을 하는 여러 방식이 있으니 별로도 synchronized에 대해서 찾아보는게 좋을 듯 하다.
(synchronized에 대해서도 나중에 포스팅 해야 겠다.)

메서드에 synchronized 추가

    /**
     * 코인 재고 감소 서비스
     *
     * @param coinStockId Long
     */
    public synchronized void decrease(Long coinStockId) {
        CoinStock coinStock = coinStockRepository.findById(coinStockId).orElseGet(CoinStock::new);
        LOG.info("[감소 전]코인 재고 아이디: {} / 코인 재고 개수: {}", coinStock.getCoinStockId(), coinStock.getQuantity());
        coinStock.decrease();
        LOG.info("[감소 후]코인 재고 아이디: {} / 코인 재고 개수: {}", coinStock.getCoinStockId(), coinStock.getQuantity());
        coinStockRepository.save(coinStock);
    }

메서드에 synchronized를 추가하고 테스트를 동작 해보면 아래와 같은 결과가 나옵니다.

[pool-1-thread-10] INFO  CoinStockService.decrease.22 라인 - [감소 전]코인 재고 아이디: 1 / 코인 재고 개수: 4
[pool-1-thread-10] INFO  CoinStockService.decrease.24 라인 - [감소 후]코인 재고 아이디: 1 / 코인 재고 개수: 3
[pool-1-thread-1] INFO  CoinStockService.decrease.22 라인 - [감소 전]코인 재고 아이디: 1 / 코인 재고 개수: 3
[pool-1-thread-1] INFO  CoinStockService.decrease.24 라인 - [감소 후]코인 재고 아이디: 1 / 코인 재고 개수: 2
[pool-1-thread-3] INFO  CoinStockService.decrease.22 라인 - [감소 전]코인 재고 아이디: 1 / 코인 재고 개수: 2
[pool-1-thread-3] INFO  CoinStockService.decrease.24 라인 - [감소 후]코인 재고 아이디: 1 / 코인 재고 개수: 1
[pool-1-thread-2] INFO  CoinStockService.decrease.22 라인 - [감소 전]코인 재고 아이디: 1 / 코인 재고 개수: 1
[pool-1-thread-2] INFO  CoinStockService.decrease.24 라인 - [감소 후]코인 재고 아이디: 1 / 코인 재고 개수: 0
[Test worker] INFO  CoinStockServiceTest.decrease.44 라인 - coinStock -> quantity : 0

정상적으로 동작하는 것을 확인 했고 동시성 문제가 해결 되었다.


참고 하고 있는 블로그에서 의문이 발생하는 문장 발견

참고하고 있는 블로그에서 눈에 잡히는 라인이 있었다.

설마~하고 synchronize이 추가된 상태인 decrease에 @Transactional을 추가 해서 결과를 확인 해봤다.

    /**
     * 코인 재고 감소 서비스
     *
     * @param coinStockId Long
     */
    @Transactional
    public synchronized void decrease(Long coinStockId) {
        CoinStock coinStock = coinStockRepository.findById(coinStockId).orElseGet(CoinStock::new);
        LOG.info("[감소 전]코인 재고 아이디: {} / 코인 재고 개수: {}", coinStock.getCoinStockId(), coinStock.getQuantity());
        coinStock.decrease();
        LOG.info("[감소 후]코인 재고 아이디: {} / 코인 재고 개수: {}", coinStock.getCoinStockId(), coinStock.getQuantity());
        coinStockRepository.save(coinStock);ㄴ
    }

결과

[pool-1-thread-1] INFO  CoinStockService.decrease.23 라인 - [감소 전]코인 재고 아이디: 1 / 코인 재고 개수: 72
[pool-1-thread-1] INFO  CoinStockService.decrease.25 라인 - [감소 후]코인 재고 아이디: 1 / 코인 재고 개수: 71
[pool-1-thread-4] INFO  CoinStockService.decrease.23 라인 - [감소 전]코인 재고 아이디: 1 / 코인 재고 개수: 72
[pool-1-thread-4] INFO  CoinStockService.decrease.25 라인 - [감소 후]코인 재고 아이디: 1 / 코인 재고 개수: 71
[pool-1-thread-6] INFO  CoinStockService.decrease.23 라인 - [감소 전]코인 재고 아이디: 1 / 코인 재고 개수: 71
[pool-1-thread-6] INFO  CoinStockService.decrease.25 라인 - [감소 후]코인 재고 아이디: 1 / 코인 재고 개수: 70
[pool-1-thread-10] INFO  CoinStockService.decrease.23 라인 - [감소 전]코인 재고 아이디: 1 / 코인 재고 개수: 71
[pool-1-thread-10] INFO  CoinStockService.decrease.25 라인 - [감소 후]코인 재고 아이디: 1 / 코인 재고 개수: 70
[pool-1-thread-3] INFO  CoinStockService.decrease.23 라인 - [감소 전]코인 재고 아이디: 1 / 코인 재고 개수: 70
[pool-1-thread-3] INFO  CoinStockService.decrease.25 라인 - [감소 후]코인 재고 아이디: 1 / 코인 재고 개수: 69
[Test worker] INFO  CoinStockServiceTest.decrease.44 라인 - coinStock -> quantity : 78

허허.. 다시 동시성 문제가 발생 했으며 해당 내용에 대해서 찾아봤다.

synchronized과 @Transactional이 한 메서드에 있으면 동시성 문제가 발생?

@Transactional의 특성 때문에 발생하는 문제였다.

스프링의 @Transactional은 AOP이며 해당 메서드 앞, 뒤로 별도의 작업이 진행된다.
(커넥션 획득, 커밋)

synchronized 범위에 @Transactional AOP가 포함되지 않기 때문에 발생하는 것이다.

일단..두개의 쓰레드가 동시에 메서드를 호출 했을 때 @Transactional이 있기 때문에 커넥션 획득은 동시에 하게 되며 synchronized가 걸린 메서드니까 하나의 쓰레드만 진입을 하는 것은 맞지만 해당 쓰레드가 메서드에서 나오면 두번째 쓰레드가 메서드에 진입할 수 있다. 여기서 문제가 될 수 있는 예는.. 첫번째 쓰레드가 나오기는 했지만 결과가 커밋이 되지 않았다면?. 커밋을 하지 않았기 때문에 DB에 적용이 되지 않았고 두번째 쓰레드가 진입해서 조회 했을 때 변경 전 데이터를 가져올 수 있다는 문제가 발생 한다.

이 문제를 해결 하기 위해서는 감소 메서드에는 @Transactional을 붙이고 감소 메서드를 호출하는 외부 메서드에서 synchronized를 걸면 문제는 해결이 된다.

  /**
     * "synchronized"가 있고 코인 재고 감소 메서드 호출하기 위한 sync 메서드
     * @param coinStockId Long
     */
    public synchronized void syncDecrease(Long coinStockId) {
        decrease(coinStockId);
    }

    /**
     * "@Transactional"이 추가 된 코인 재고 감소 서비스
     *
     * @param coinStockId Long
     */
    @Transactional
    public void decrease(Long coinStockId) {
        CoinStock coinStock = coinStockRepository.findById(coinStockId).orElseGet(CoinStock::new);
        LOG.info("[감소 전]코인 재고 아이디: {} / 코인 재고 개수: {}", coinStock.getCoinStockId(), coinStock.getQuantity());
        coinStock.decrease();
        LOG.info("[감소 후]코인 재고 아이디: {} / 코인 재고 개수: {}", coinStock.getCoinStockId(), coinStock.getQuantity());
        coinStockRepository.save(coinStock);
    }

쓰레드 두개가 요청이 되어도 syncDecrease()에는 쓰레드 하나만 진입을 하여 감소 메서드를 호출 하게 될 것 이고 감소 메서드에 붙어 있는 @Transactional도 동작을 하며 동시성 문제가 없게 된다.

결과

[pool-1-thread-1] INFO  CoinStockService.decrease.31 라인 - [감소 전]코인 재고 아이디: 1 / 코인 재고 개수: 4
[pool-1-thread-1] INFO  CoinStockService.decrease.33 라인 - [감소 후]코인 재고 아이디: 1 / 코인 재고 개수: 3
[pool-1-thread-8] INFO  CoinStockService.decrease.31 라인 - [감소 전]코인 재고 아이디: 1 / 코인 재고 개수: 3
[[pool-1-thread-8] INFO  CoinStockService.decrease.33 라인 - [감소 후]코인 재고 아이디: 1 / 코인 재고 개수: 2
[pool-1-thread-7] INFO  CoinStockService.decrease.31 라인 - [감소 전]코인 재고 아이디: 1 / 코인 재고 개수: 2
[pool-1-thread-7] INFO  CoinStockService.decrease.33 라인 - [감소 후]코인 재고 아이디: 1 / 코인 재고 개수: 1
[pool-1-thread-6] INFO  CoinStockService.decrease.31 라인 - [감소 전]코인 재고 아이디: 1 / 코인 재고 개수: 1
[pool-1-thread-6] INFO  CoinStockService.decrease.33 라인 - [감소 후]코인 재고 아이디: 1 / 코인 재고 개수: 0
[Test worker] INFO  CoinStockServiceTest.decrease.44 라인 - coinStock -> quantity : 0

이렇게 동시성 문제는 해결 되었고
@Transactional + synchronized 두개를 사용하면서 해결하는 방법을 알아보았다.

느낀점

synchronized가 간단하지만 만능은 아니며 하나의 메서드에 하나만 진입을 하기 때문에 성능 문제가 있어 보인다. synchronized에 대한 깊은 공부가 필요할 것으로 보인다.

또한 두개의 서버에서 만약 발생하는 것이라면 synchronized로 동시성 문제를 해결하는 것은 문제가 생긴다. synchronized는 프로세스에 대하여 쓰레드를 제어하는 것이기 때문에
해당 작업을 하는 서버가 두개라면 프로세스도 두개 즉.. 서로 다른 프로세스이기 때문에 동시성 문제가 다시 발생한다.

profile
새로운 것을 알아가는 것에 즐거움을 느끼는 개발자 입니다.

0개의 댓글