개발놀이터

스프링에서 동시성 문제 해결하기 본문

Spring/Spring

스프링에서 동시성 문제 해결하기

마늘냄새폴폴 2023. 5. 21. 13:34

프로젝트를 고도화하는 과정에서 동시성 문제에 대해 고민하게 되었습니다. 

 

동시성 문제... 참 쉽지 않더군요... 우선 정말 추상적이고 해결 방법도 정말 많습니다. 

 

이번 포스팅에선 동시성 문제에 대해 짧게 서술하고 해결방안 그리고 문제점까지 확인해보도록 하겠습니다. 

 

 

동시성 문제

어떤 것을 동시성 문제라고 할까요? 

 

사실 동시성 문제는 제 포스팅에 자세히 나와있습니다. 동시성 문제에 대한 내용도 많이 기술했습니다. 아래의 링크에서 확인해주세요!

 

https://coding-review.tistory.com/362

 

세마포어와 뮤텍스

프로세스 동기화에 대한 면접질문을 외우다가 문득 생각이 들었습니다. "Critical Section (이하 임계구역) 에 접근하는 것을 제어하기 위해 세마포어나 뮤텍스를 사용합니다." 라는 문장에서 세마포

coding-review.tistory.com

https://coding-review.tistory.com/359

 

교착상태 (DeadLock) 와 기아상태 (Starvation)

잘 기억은 안나지만 2022년 여름 주말이었는데 지인들끼리 다같이 모여서 쿠팡이츠로 점심을 시켜먹으려고 했는데 서버가 내려가 주문이 안된 사태가 있었습니다. 여자친구가 당시 쿠팡이츠에

coding-review.tistory.com

https://coding-review.tistory.com/354

 

프로세스 동기화

이번 시간에는 프로세스 동기화에 대해서 알아보도록 하겠습니다. 프로세스 동기화란 OS에서 같은 메모리 공간을 공유하고 있는 프로세스들을 관리하기위한 방법입니다. 프로세스 동기화를 진

coding-review.tistory.com

https://coding-review.tistory.com/92

 

동시성문제와 스레드 로컬

이 포스팅은 인프런 김영한 님의 스프링 핵심 원리 고급 편을 보고 각색한 포스팅입니다. 자세한 내용은 강의를 확인해주세요 이번 시간에는 스프링을 사용할 때 주의할 점과 해결방법인 스레

coding-review.tistory.com

https://coding-review.tistory.com/351

 

동시성문제와 Thread-safe

동시성 문제와 Thread-safe를 고민하는 것은 개발자로서 정말 중요한 과제라고 생각됩니다. 서비스가 커지면 커질수록 멀티스레딩이 절실히 필요해질텐데 성능을 위해 멀티스레딩을 강요받았지만

coding-review.tistory.com

 

이렇게 나열하고 보니까 정말 많이 썼네요... 

 

아무튼 정말 많이 입이 닳도록 얘기한 주제이기 때문에 간단하게 짚고 넘어가겠습니다. 

 

동시성 문제는 여러 스레드가 하나의 자원을 사용하면서 값이 꼬여버리거나 개발자가 원하는대로 동작하지 않는 현상을 말합니다. 

 

근데 이 동시성 문제 한번쯤 고민해보신 분들은 아실겁니다. 어떤 상황에서 동시성 문제가 발생하는지 전혀 감이 안잡힙니다. 

 

몇가지 패턴이 있는데 

 

  1. 멤버변수 (전역변수) 를 선언한 경우
  2. Thread-safe 하지 않은 클래스를 사용한 경우
  3. ++, -- 와 같은 증감연산자를 사용한 경우
  4. 동시에 여러개의 스레드가 하나의 메서드에 접근하는 경우

대충 이정도가 있습니다. 하지만 이런 경우가 동시성 문제의 전부는 아닙니다. 

 

저의 경우가 그러했는데요. 만약 이런 경우를 생각해봅시다. 이 예시는 제가 처한 상황이랑 100퍼센트 일치합니다. 

 

  1. 온라인 쇼핑몰에서 상품 A는 재고가 1개입니다. 
  2. 유저 A가 상품 A를 1개 결제합니다. 동시에 유저 B가 상품 A를 1개 결제합니다. 
  3. 에이 그럴줄 알고 재고가 0이 되면 결제가 안되게 예외처리를 했죠
  4. 하지만 멀티 스레드라면요? 각각의 스레드가 메서드를 타면서 값이 지역적으로 바뀌게됩니다. 이는 데이터베이스 격리수준과 연관있습니다. 
  5. 스레드 1에서의 상품 A의 재고는 유저 A에 의해 0이 되었습니다. 스레드 2에서 상품 A의 재고는 유저 B에 의해 0이 되었습니다. 
  6. 동시에 두개가 결제됐네요? 하지만 재고는 한개인데... 
  7. 이게 바로 중복 저장입니다. 

이런 상황 충분히 가능하지 않을까요? 글로만 보면 이해가 안되니까 그림으로 이해해봅시다. 

 

 

이런 상황도 동시성 문제에 들어가는 상황입니다. 

 

이제 해결방안을 찾아봐야겠죠? 

 

동시성 문제 해결 방안

동시성 문제를 해결하기 위한 방법은 여러가지가 있습니다. 제가 말한 상황 이외에도 다양한 상황에서 동시성 문제가 발생하고 해결할 수 있습니다. 

 

제가 포스팅에서 소개한 내용을 간단하게 다시 소개해드리고 위의 예제에서 어떻게 해결하는지에 대해서 알아보죠!

 

  1. 불변 객체를 사용한다.
  2. final 변수를 사용한다.
  3. java.util.concurrent 클래스를 사용한다.
  4. Thread Local을 사용한다.
  5. 전역 변수의 사용을 지양한다. 
  6. synchronized 키워드를 사용한다. 

 

제가 알고있는 동시성 문제는 값이 꼬여버리는 상황입니다. 대부분 증감연산자를 사용해서 생기는 문제들이 그러하죠. 대표적으로 조회수 증가 같은 것도 동시성 문제의 대상입니다. 

 

동시에 두 사람이 같은 게시글을 클릭한다면? 같은 문제죠. 이제 아주 직관적이면서 이해하기 쉬운 예시입니다. 

 

때문에 제 예시에서 생기는 동시성 문제를 아주 단순하게 생각했습니다. 그래서 상품 재고에 대한 테스트코드를 작성했습니다. 재고가 1이고 3번의 동시 결제가 일어났다. 그럼 재고가 -2가 되겠구나!

 

재고에 대한 테스트 코드를 계속 작성해봤지만 결과는 대참패였습니다. 

 

테스트 코드에서 재고가 항상 0이었습니다. 물론 이 결과에 대한 이해 전에 수많은 삽질이 있었습니다... (JPA 전파 단계라던가...)

 

그러다가 동시성 문제를 10시간정도 고민했을 때 문득 생각이 들었습니다. 재고가 0이면 결제가 안되어야하잖아..? 근데 결제가 세번 일어나네? 데이터베이스에 저장이 세번되잖아 그럼 내가 생각하던 동시성 문제가 아니잖아!!!

 

맞습니다. 각각의 스레드가 제가 만든 메서드를 동시에 통과하는게 아니고 한 스레드당 하나만 통과해야 했습니다. 

 

위의 예제 정확히는 제 상황의 해결 방안은 크게 세가지입니다. (이 세가지를 떠올리는 것만 해도 엄청난 삽질이...)

 

  • synchronized 키워드 사용
  • 격리수준을 Serializable로 격상
  • 낙관적 락, 비관적 락 사용

 

어떻게 제가 구현했나 한번 보시죠!

 

synchronized 키워드

우선 저는 synchronized 키워드를 되도록 사용하고 싶지 않았습니다. 왜냐하면 기존 프로젝트에서 이미 synchronized로 동시성 문제를 해결했거든요. (사실 해결한건 아닙니다. 해결한 척 한거죠)

 

synchronized 키워드를 사용하는건 제 프로젝트와 맞지 않았습니다. 왜냐하면 매번 결제가 일어날 때마다 이 결제 메서드를 통과할건데 모든 결제에 대해서 synchronized 를 건다고? 

 

제 프로젝트는 이용가자 100만명 있다고 가정하기 때문에 이런 경우는 절대 용납할 수 없었습니다. synchronized 키워드 때문에 발생하는 성능 이슈는 도저히 참고 넘어갈 수 없었죠. 

 

synchronized 키워드 정말 쉽습니다. 구글링 잠깐 하면 바로 나오니까요 한번 도전해보세요!

 

isolation level 을 Serializable로 격상

MySQL이 격리 수준을 Repeatable Read를 채택하고 있다는 것은 다들 아실겁니다. 때문에 Oracle이나 PostgreSQL에서 발생하는 Non-Repeatable Read 부정합 문제가 발생하지 않죠. (물론 이 둘은 다른 방법으로 부정합 문제를 해결했습니다.)

 

구현은 정말 간단합니다. 기존 Service 계층에서 사용하던 @Transactional의 격리수준을 올려버리면 됩니다. 

 

    @Transactional(isolation = Isolation.SERIALIZABLE)
    public Map<String, String> saveUsingTemporaryOrder(List<TemporaryOrder> tOrder, Order order) {
        // 결제 로직
    }

 

하지만 이 방법에는 두가지 문제가 발생합니다. 

 

격리 수준을 Serializable로 격상하면 쓰기작업은 물론이고 읽기 작업까지 락이 걸려버립니다. 

 

이는 synchronized 키워드를 작성한 것과 마찬가지의 효과가 생깁니다. 

 

아니 성능 때문에 synchronized 키워드 안쓰는건데 격리수준을 격상하면 뭐가 달라지나?

 

달라지지 않습니다. 오히려 더 안좋아집니다. 왜냐하면 격리수준을 격상시키면 synchronized 키워드였다면 생기지도 않았을 데드락 (교착상태)에 걸리게 됩니다. 

 

Caused by: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction
	at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:123)
	at com.mysql.cj.jdbc.exceptions.SQLExceptionsMapping.translateException(SQLExceptionsMapping.java:122)
	at com.mysql.cj.jdbc.ClientPreparedStatement.executeInternal(ClientPreparedStatement.java:953)
	at com.mysql.cj.jdbc.ClientPreparedStatement.executeUpdateInternal(ClientPreparedStatement.java:1098)
	at com.mysql.cj.jdbc.ClientPreparedStatement.executeUpdateInternal(ClientPreparedStatement.java:1046)
	at com.mysql.cj.jdbc.ClientPreparedStatement.executeLargeUpdate(ClientPreparedStatement.java:1371)
	at com.mysql.cj.jdbc.ClientPreparedStatement.executeUpdate(ClientPreparedStatement.java:1031)
	at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeUpdate(ProxyPreparedStatement.java:61)
	at com.zaxxer.hikari.pool.HikariProxyPreparedStatement.executeUpdate(HikariProxyPreparedStatement.java)
	at org.hibernate.engine.jdbc.internal.ResultSetReturnImpl.executeUpdate(ResultSetReturnImpl.java:197)
	... 35 more

 

물론 이걸 국지적으로 해결하는 방법이 있습니다. 바로 데드락이 발생할 것 같은 객체를 정렬하는 방법입니다. 

 

엥? 데드락이랑 정렬이랑 뭔상관? 

 

저도 처음엔 그렇게 생각했는데요. 

 

데드락은 스레드 A가 자원 B의 락을 가지고 있고 자원 A를 요청하고, 스레드 B가 자원 A의 락을 가지고 있고 자원 B를 요청하는 상황에서 발생합니다. 

 

즉 자원의 락을 순서를 가지지 않고 막 가지게 되면 데드락이 발생합니다. 

 

자원의 순서를 sort() 메서드로 정렬해서 자원에게 걸릴 락의 우선순위를 부여하는 방법입니다. 

 

그럼 데드락이 '국지적으로' 안걸립니다. 

 

국지적으로 안걸린다는 의미는 걸릴수도? 안걸릴수도? 있다는 의미이죠. 

 

이는 좋은 해결책은 아닙니다. 그래서 제가 선택한 마지막 방법 바로 낙관적 락과 비관적 락입니다. 

 

Optimistic Lock, Pessimistic Lock

낙관적 락과 비관적 락은 데이터 행에 락을 거는겁니다. 이 둘은 비슷한듯 다른데요. 

 

낙관적 락은 말 그대로 락을 거는 것에 대해 낙관적입니다. 비관적 락은 락을 거는 것에 비관적입니다. 때문에 낙관적 락은 락을 느슨하게 겁니다. 비관적 락은 일단 동시성 문제가 발생한다고 가정하고 락을 걸기 때문에 굉장히 빡빡합니다. 

 

낙관적 락과 비관적 락은 각각 장단점이 있습니다. 

 

  • 낙관적 락 (Optimistic Lock) : 충돌이 빈번하게 일어나지 않을 때 성능상 유리한 락입니다. 보통 @Version을 통해 Versioning 즉 버전 관리를 하는 것이죠. 낙관적 락은 충돌이 일어났을 경우 개발자가 다시 요청하는 로직을 구현해야합니다. 이는 try / catch 문에서 OptimisticLockingFailureException을 구현하면서 이뤄냅니다. 
  • 비관적 락 (Pessimistic Lock) : 비관적 락은 충돌이 빈번하게 일어날 때 조금 더 효과적입니다. 아무래도 락을 걸고 시작하니까요. 비관적 락은 락을 통해 업데이트를 제어하기 때문에 데이터의 정합성이 어느정도 보장됩니다. 하지만 그에 따르는 성능 감소는 감수해야합니다. 

 

저는 결제 로직이라면 충분히 충돌이 빈번할 것이라고 생각했습니다. (아무래도 100만명이 이용한다고 가정하니까요)

 

그래서 비관적 락을 이용해서 동시성 문제를 해결했습니다. 

 

public interface ItemDetailRepository extends JpaRepository<ItemDetail, Long> {

    @EntityGraph(attributePaths = "item")
    @Query("select i from ItemDetail i where i.item.id = :itemId")
    @Lock(value = LockModeType.PESSIMISTIC_WRITE)
    List<ItemDetail> findByItemId(@Param("itemId") Long itemId);
}

 

하지만 주의하셔야 할 것이 있는데요. 바로 낙관적, 비관적 락은 행에 락을 거는 것이기 때문에 쓰기 작업에는 락을 걸 수 없습니다. 

 

이렇게 락을 걸면? 테스트가 성공합니다. 

 

서비스 계층은 아무런 코드 변경도 없었구요, 그냥 저기에 락하나 걸어주면 끝입니다. 정말 쉽습니다. 

 

근데 저는 쓰기 작업에 락을 걸어야한다고 아까 해결 방법에서 말했습니다. 그런데 읽기 작업에 락을 거는 것이 어떻게 위와 같은 동시성 문제를 해결할 수 있을까요? 

 

바로 읽기 작업에 락을 거는 것이 그 이후에 있을 쓰기 작업까지 영향을 주기 때문입니다. 

 

그림으로 설명하면 다음과 같은데요. 

 

 

그림과 같이 조회로 가는 락이 걸려있기 때문에 스레드 B가 처리해야할 결제 로직을 더이상 들어갈 수 없는 것입니다. 

 

saveAndFlush + synchronized 키워드?

블로그 포스팅을 보다보니 saveAndFlush를 이용해서 커밋을 시켜버린 다음에 synchronized 키워드로 락을 걸어버리는 방법에 대해서 알게되었습니다. 

 

물론 커밋을 시키면 좋겠는데 한가지 걸린다면 커밋을 해버리면 그 다음부터 영속성 컨택스트의 1차 캐시를 사용하지 못하는 것은 아닐까 하는 생각이 듭니다. 

 

하지만 원래 synchronized 키워드는 사용하지 않을 목적이었기 때문에 상관은 없지만요. 

 

 

마치며

여기까지 동시성 문제를 스프링에서 어떻게 해결하는지에 대해서 알아봤습니다. 

 

동시성 문제, 트랜잭션 처리 정말 어렵네요... 아직 하나의 서비스 로직만 동시성 문제를 해결했습니다. 이후에 천천히 다른 로직까지 넓혀갈 생각입니다. 

 

프로젝트를 고도화 한다는 것은 정말 놀랍네요. 평소라면 생각하지도 않았을 내용에 대해서 공부하고 삽질도 미친듯이 해보고 좋은 경험이었습니다. 

 

거기다 동시성 문제는 현업에서도 정말 조심해야할 문제이기 때문에 더욱 값진 경험이 된 것 같아서 시간은 오래 걸렸지만 굉장히 뿌듯합니다. 

 

그럼 이만 긴 글 줄여보도록 하겠습니다. 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

 

 

출처

https://crazy-horse.tistory.com/entry/%EC%9E%AC%EA%B3%A0-%EC%8B%9C%EC%8A%A4%ED%85%9C%EC%9C%BC%EB%A1%9C-%EC%95%8C%EC%95%84%EB%B3%B4%EB%8A%94-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95

 

[스프링] 재고 시스템으로 알아보는 동시성 문제 해결 방법

아래 코드를 보면 재고 100개의 요청에서 100개의 재고를 하나씩 감소시켰기 때문에 일반적으로 0이 나오는 것을 예상한다. 그러나 실제로는 race condition이 발생했기 때문에 예상한 결과를 받을 수

crazy-horse.tistory.com

https://parkjeongwoong.github.io/articles/Failure/5

 

Java (Spring Boot) 동시성 테스트

# Java (Spring Boot) 동시성 테스트 ``` 이 글은 한옥 스테이의 예약 시스템을 만들며 마주한 동시성 문제를 해결한 과정을 다룹니다. ``` ## 서론 최근 한옥 스테이에 사용할 예약 시스템을 만들고 있

parkjeongwoong.github.io

 

+

GPT 야 정말 고맙다 ㅜㅜ