Spring

동시성 문제 해결하기

시롱시롱 2023. 8. 2. 17:48

프로젝트 테스트를 진행하다 데이터가 중복되어 저장되거나 데이터에 결함이 발생하는 문제가 확인되었다.

 

이를 위해 우선 멀티 쓰레드 환경에서 동시성 문제에 대해 알아보고 이를 해결하는 방법을 순차적으로 알아보려 한다.

 

동시성 문제란 ?

어떤 두 사건이 같은 시간에 일어나는 것

 

우리의 웹 서버엔 동시에 여러 요청이 들어올 수 있으며 이로 인해 우리의 코드가 동시에 실행될 수 있다.

따라서, a가 자원X를 X+1로 바꾸고 확인하는 사이 b가 X를 또 1증가시키는 경우 a입장에선 X+1이 아닌 X+2가 조회될 수 있다는 뜻이다.

 

개념은 간단하니 넘어가고 실제로 문제가 발생하는 지 확인해보자.

우선, 현 프로젝트에서 그룹에 가입하는 경우 가입 전 현재 그룹에 속한 인원을 세어 해당 인원이 최대치 보다 작은 경우에만 가입을 진행하고 그렇지 않으면 에러를 던져주는 로직으로 구성되어 있다.

 

만약, 3명이 제한인 그룹에 10명의 가입 신청에 대해 그룹장이 동시에 수락하게 되면 어떻게 될까? (사실 그룹장이 직접 수락 api를 하나하나씩 다 보내야 해서 동시에 10명의 사용자를 요청 승인해주는 경우는 발생하지 않을 것이다)

이상적인 상황이라면, 요청 하나씩 확인하며 3명을 승인하게 되면 4번째 사용자부턴 승인 거절이 될 것이다.

 

실제로 그렇게 되는 지 확인해보자.

@Test
void joinError() throws InterruptedException {
    Member member = makeMember();
    Team team = makeTeam(member);
    log.info("made member and team");
    CountDownLatch countDownLatch = new CountDownLatch(10);
    ExecutorService executorService = Executors.newFixedThreadPool(10);
    for (int i = 0; i < 10; i++) {
        executorService.execute(()->{
            log.info("execute Service Logic..");
            Member target = makeMember();
            MemberTeam mt = new MemberTeam();
            mt.initMemberTeam(target,team,target);
            memberTeamRepository.save(mt);
            groupService.acceptMember(target.getId(),team.getId());
            countDownLatch.countDown();
        });
    }
    countDownLatch.await();
    log.info("logic is finished");
    List<MemberTeam> byTeam = memberTeamRepository.findByTeam(team);
    int size = byTeam.stream().filter(mt -> mt.isBelong()).collect(Collectors.toList()).size();
    log.info("now team size:"+size);
}

(makeMember는 랜덤한 id로 회원을 만드는 메소드, makeTeam은 랜덤하게 team을 만들어주는 메소드이다.)

멀티 스레드 환경을 테스트하기 위한 ExecutorService, CountDonwLatch에 대해선 여기여기를 참고하는 것을 추천한다.

 

테스트를 수행한 결과는 다음과 같다.

이상적인 경우처럼 로직이 모두 끝나고 팀의 크기가 3이 되지 않았고, 10명 모두 가입이 되었다.

 

왜 그런걸까?

팀에 현재 가입된 유저의 수를 확인하는 로직 -> 기입된 정보를 바탕으로 회원을 팀에 가입시키는 로직 (isBelong을 true로 변경 및 fcm으로 알림을 보냄)

이 두 순서로 가입이 진행되는데, 만약 10명이 동시에 가입된 유저를 확인하게 된다면 그 수는 0이 된다. 즉, 모두 가입가능한 상태가 되고 이를 바탕으로 가입이 진행된다.

 

그렇다면 어떻게 해결할까?

1. Synchronized 키워드 사용

트랜잭션이 적용되기 전 or 트랜잭션 어노테이션을 제거 후 키워드를 붙여 메소드를 사용하면 동시성 문제를 해결할 수 있다.

하지만, 동일 프로세스 내의 스레드 끼리의 동시성을 보장하기에 여러 서버를 사용하는 경우 동시성을 보장할 수 없으며, 무엇보다 성능이 매우 좋지 않다.

 

2. DB의 고립 수준을 변경하기

serializable로 레벨을 올려버리면 해결되지 않을까? 란 생각을 할 수 있지만 그렇지만은 않다.

만약 여러 트랜잭션이 동시에 S-Lock을 걸고 그 후 X-Lock을 걸려 하는데 2단계 로킹 프로토콜에 의해 lock을 다 걸어야 그 후 해제하는 단계로 넘어가지만, X-lock은 S-lock과 양립할 수 없으므로 트랜잭션들이 모두 서로의 s-lock이 해제되기를 기다리는 데드락이 발생한다.

 

3. JPA가 제공하는 낙관적 락과 비관적 락 사용하기

두 번의 갱신 분실 문제는 트랜잭션의 격리 수준으로 해결할 수 없다.

해당 문제를 처리하는 방법으로는

1. 마지막 커밋만 인정하기

2.최초 커밋만 인정하기

3. 충돌 내용 병합하기

이 있다.

물론, 마지막 커밋만 인정하기는 가능하다. (그냥 덮어쓰면 되니..)

 

하지만, 순서가 중요한 기능에 1번 정책을 적용하기엔 무리가 있다.

예를 들어, 트랜잭션 a가 기존 잔고 만원에 2만원을 더하고 트랜잭션 b가 기존 잔고에 만원을 빼는 상황을 가정해보자.

a가 2만원을 더한 후 커밋하기 전 b가 잔고를 조회하게 되면, b는 잔고가 만원이라 생각하게 되고 그 후 a가 커밋되어 잔고가 3만원으로 갱신되어도 b가 커밋되며 잔고가 0원으로 다시 갱신되어 a의 커밋 내용이 사라지는 것이다.

 

이 경우, 1번 정책을 적용하는 것은 바람직 하지 않기에 2번 혹은 3번 정책을 사용해야 한다.

 

그를 위해 JPA에서 제공하는 낙관적 락과 비관적 락에 대해 살펴보자

 

낙관적 락

트랜잭션들이 충돌하지 않을 것이라 가정하는 방법

DB가 제공하는 락을 사용하지 않고 엔티티에 버전을 사용해 동시성을 제어한다.

@Entity
public class MemberTeam {
    @Id@GeneratedValue
    private Long id;
    @Version
    private Integer version;

위와 같이 @Version을 통해 엔티티에 버전을 명시한다. (Long,Integer,Shrot, Timestamp 타입에 적용이 가능하다)

 

그렇게 되면, 엔티티가 변경될 때 마다 version값이 자동으로 증가한다.

또, 엔티티를 수정할 때 조회한 시점과 수정한 시점의 버전이 다르면 예외를 발생시킨다.

 

앞선 예시에 낙관적 락을 적용한 후의 시나리오는 다음과 같다.

a가 엔티티를 접근하고 잔고를 조회한다. (엔티티 버전 v1) b가 엔티티를 접근하고 잔고를 조회한다 (엔티티 버전 v1)

a가 커밋되어 잔고가 업데이트 된다 (v1->v2) b가 커밋을 시도하지만 실패한다 (조회시 v1이였지만 커밋하는 시점엔 v2이므로 예외가 발생한다)

 

LockModeType은 다음과 같다.

위에서부터 간단히 소개하면

OPTIMISTIC : 엔티티를 조회하는 경우에도 버전을 체크하여 엔티티의 조회 시점부터 트랜잭션의 종료시점까지 다른 트랜잭션에 의해 변경되지 않는 것을 보장한다.

 

NONE: @Version만 필드에 넣어준 상태에서 기본적으로 적용되는 락 옵션으로, 엔티티를 수정하는 시점에 다른 트랜잭션으로부터 변경되지 않음을 보장한다

 

OPTIMISTIC_FORCE_INCREMENT: 낙관적 락을 사용하면서 버전 정보를 강제로 증가시킨다. 물리적으로는 변경되지 않았지만 논리적으로 변경되었을 경우 버전을 증가하고 싶을 때 사용한다.

 

READ: 하위 호환을 위한 것으로 OPTIMISTIC과 같다

 

PESSIMISTIC_READ: 잘 사용되지 않는 옵션으로, DB에 select for share쿼리를 날려 S-lock을 건다. (대부분의 DB의 방언에 의해 PESSIMISTIC_WRITE로 동작한다고 한다..)

 

PESSIMISTIC_WRITE : 주로 사용하는 비관적 락 옵션으로, DB에 select for update쿼리를 날려 X-lock을 건다

PESSIMISTIC_FORCE_INCREMENT : 버전정보를 사용하는 비관적 락으로, 버전 정보를 강제로 증가시키는 PESSIMISTIC_WRITE이다. 또, 하이버네이트의 경우 nowait을 지원하는 DB에 대해 for update nowait을 사용하고 그렇지 않다면 for update를 사용한다고 한다.

 

WRITE: 하위 호환을 위한 것으로 OPTIMISTIC_FORCE_INCREMENT와 같다.

 

그럼 이제 낙관적 락을 적용하였고, LockModeType들에 대해서도 알아보았으니 테스트를 진행해보자.

 

동일한 테스트 코드를 다시 돌려보면

 

버전 업데이트 쿼리는 잘 날라갔지만 여전히 10명이 모두 가입되는 문제가 발생한다.

 

생각해보면 당연한 현상이였다.

 

팀에 가입된 회원의 수는 MemberTeam이란 관계 테이블에서 team을 기준으로 엔티티를 모두 조회하고 belong여부에 따라 필터링하여 그 수를 측정하는 방식으로 세고있다.

 

여기에 버저닝을 추가한들 새로운 팀원이 추가되면 버전은 항상 1이 되고 다른 트랜잭션은 해당 MeberTeam의 버전과 전혀 상관이 없기에 낙관적 락을 사용해도 이 문제를 해결할 수 없다.

 

이 문제를 해결할 방법은 두가지라 생각된다.

 

1. team테이블에 버전을 추가하고 LockModeType을 force_increment로 바꿔주고, team에 새로운 회원이 들어올 때마다 버전을 올려준다

2. team 테이블에 현재 회원의 수를 저장하는 필드를 추가한다.

 

우선, 1의 방법을 시도해보자.

@Version
private Long version;

Team 테이블에도 버전 정보를 추가해주고

 

@Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
@Query("select t from MemberTeam t where t.team=:team")
List<MemberTeam> findByTeamForceIncrement(Team team);

MemberTeam을 조회하는 쿼리에서 Force_increment로 강제로 버전 정보를 업데이트 해주자

 

그 후,동일 테스트 코드를 돌려보면

데드락이 발생한다..

 

낙관적 락을 사용함에도 데드락이 발생하는 이유는 무엇일까?

 

우선, mysql에

show engine innodb status

를 입력해서 데드락의 history를 확인해보자

S-lock을 보유한 상태로 X-lock 획득을 기다리는 상황에서 데드락이 발생한 것을 확인할 수 있다.

 

낙관적 락을 쓰는데 실제 lock이 걸리는 이유를 찾아보니 fk가 있는 테이블에서 fk를 포함한 데이터를 CUD하는 쿼리는 제약조건 확인을 위해 S-lock을 설정하는데, 현재 memberTeam 테이블에 fk로 team이 있고 memberTeam을 삽입하는 과정에서 team정보도 같이 넣어주기에 S-lock이 걸리게 되었다.

 

또, update쿼리에 사용되는 모든 레코드엔 x-lock이 자동으로 걸리게 되고 force_increment로 인해 update쿼리가 나가게 되니 X-lock이 요구된다.

 

즉, 트랜잭션 a가 meberTeam을 넣는 과정에서 s-lock을 걸고 트랜잭션 b 또한 s-lock을 걸게된다.

여기서, a가 version update를 위해 x-lock을 획득해야 하는데, s-lock이 모두 해제되어야 하므로 (s-lock과 x-lock은 양립 불가능하다) b가 걸고있는 s-lock이 해제될 때 까지 기다리게 되고, b역시 a가 가진 s-lock이 해제될 때까지 기다리게 되고 데드락이 발생하게 된다.

 

따라서, 1번 방법으로 문제를 해결하기엔 무리가 있다.

 

2번 방법을 시도해보자.

 

Team엔티티에 nowMember란 필드를 만들고 Builder에서(엔티티 생성을 모두 빌더로 했다) nowMember를 0으로 초기화 해주고, nowMember의 수를 늘리고 줄이는 addMember, removeMember 메소드를 만들어준다.

 

그리고, 회원을 팀에 추가할 때, 회원을 팀에서 삭제시킬 때 메소드를 각각 마지막에 사용하여 nowMember의 무결성을 지켜준다.

 

그 후, 테스트 코드에 낙관적 락이 던지는 ObjectOptimisticLockingFailureException을 잡고 테스트를 실행해보면

try {
    groupService.acceptMember(target.getId(),team.getId());
}catch (ObjectOptimisticLockingFailureException e){
    log.error("Detected multiple access");
}

회원들이 팀의 최대 허용 인원을 넘기며 저장되지 않게끔 변경된 것을 볼 수 있다.

 

근데..

낙관적 락을 사용하는 경우 처음 시도한 쓰레드만 트랜잭션이 커밋될 때 team의 버전이 같기에 해당 테스트를 통과할 수 있다.

다른 쓰레드의 경우 처음 통과되는 쓰레드가 커밋되기 전에 team을 조회했다면 team의 버전이 달라지기에 예외가 터지게 된다.

따라서, 처음 시도한 쓰레드를 제외한 나머지 쓰레드들은 재시도를 하든 오류를 던져 프론트에서 오류 메세지를 보여주는 로직을 추가하든 여타 거추장스러운 작업이 필요하게 된다.

 

따라서, 비관적 락을 이용하여 다른 방법을 시도해보자.

 

비관적 락

모든 트랜잭션은 충돌이 발생한다고 가정하고 lock을 실제로 거는 것.

엔티티가 아닌 스칼라 타입을 조회할 때에도 사용할 수 있다.

또, 트랜잭션 커밋 전 데이터 수정 시에 충돌을 미리 감지할 수 있고 timeout시간도 설정할 수 있다.

 

그리고, select for update쿼리가 나갈 때 where절에 고유한 검색 조건이 없는 경우 갭락을 사용한다고 한다.

즉, 비관적 락이 걸린 테이블은 해당 락이 해제되지 않는다면 (트랜잭션이 끝나지 않는다면) 다른 데이터를 insert할 수 없게 된다. 물론, 트랜잭션이 데드락이 걸리지 않는 이상 블락 되는 시간이 길지는 않을 것이다.

 

그럼 이제 비관적 락을 적용해보자.

 

우선, 기존에 낙관적 락을 사용하기 위해 만들었던 version필드를 모두 지워준 후

//        List<MemberTeam> byTeam = memberTeamRepository.findByTeam(team);
        List<MemberTeam> byTeam = memberTeamRepository.findByTeamPessimistic(team);
        int size = byTeam.stream().filter(mt -> mt.isBelong()).collect(Collectors.toList()).size();
//        Long size=team.getNowMember();

다시 memberTeam에서 belong으로 현재 회원수를 판단하는 로직을 살려준다.

 

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select t from MemberTeam t where t.team=:team")
List<MemberTeam> findByTeamPessimistic(Team team);

그리고, 기존 낙관적락에서 PESSMISTIC_WRITE으로 LockModeType을 변경해준다.

 

if(size>=team.getMaxMember()){
    throw new BaseException(BaseResponseStatus.MAX_MEMBER_ERROR);
}

정원을 초과했다면 이 부분에서 오류가 던져질테니 테스트 코드에서 오류를 잡아 출력해주자.

try {
    groupService.acceptMember(target.getId(),team.getId());
}catch (Exception e){
    log.error("Detected multiple access");
    log.error("msg: {}",e.getMessage());
}

간단하게 Exception으로 잡고 출력해준다. (실제로는 controller advice로 잡아 리턴하지만 테스트코드에선 잡히지 않기에 직접 잡아준다)

 

이제 meberTeam을 조회하는 시점에 memberTeam 테이블의 Row중 현재 groupId와 같은 값을 가진 row에 모두 X-lock이 걸리게 된다.

 

즉, a트랜잭션이 memberTeam을 제일 먼저 조회했다면 b트랜잭션은 a트랜잭션이 커밋될 때 까지 기다리게 된다.

 

따라서, 순차적으로 memberteam 내 등록된 회원의 수를 조회하게 되고 지정한 최대 인원인 3명까지는 가입이 이루어지지만, 그 후의 트랜잭션들은 모두 3이상의 값이 조회되기에 (사실 정확히 3이 조회된다) 설정한 if문에서 걸려 RuntimeError를 던지게 된다.

 

즉, 원했던 요구사항을 모두 만족하게 된다.

테스트 결과 역시 의도대로 수행된 것을 확인할 수 있다.

 

그렇다면 비관적 락의 단점은 무엇일까?

우선, 데드락이 발생 가능하다.

트랜잭션 a가 x테이블의 i번째 row에 x lock을 걸고, 트랜잭션 b는 y테이블의 j번째 row에 x lock을 건 상황에서

트랜잭션 a가 y테이블의 j번째 row에 접근하게되는 경우 트랜잭션 b가 완료될 때 까지 기다리게 된다.

반대로, 트랜잭션 b 또한 x테이블의 i번째 row에 접근하게 되는 경우, 트랜잭션 a가 완료될 때 까지 기다리게 된다.

 

결과적으로, 여러테이블에 접근하고 비관적락을 한 트랜잭션 내에서 중첩해서 사용하는 경우 혹은 서로 다른 메소드간에 비관적락을 교차적으로 여러개 사용하는 경우 데드락이 발생가능하게 된다.

 

또, lock을 걸게되니 당연히 성능상 문제가 발생하게 된다 ( 갭락, 동시 접근 시 lock wait ..)

이러한 상황에서 트래픽이 급증하게 되면 timeout되는 요청들이 많아지게 될 수 있다.

 

이러한 문제는

Redis의 Sorted Set,Lua Script

Kafka같은 메세징 큐

API Gateway단에서 처리율을 조절하는 로직  구현 등등 으로 해결해볼 수 있다..고 한다.

 

이 분의 글을 보고 정말 많은 이해를 한 것 같다 ..!

 


이제 동시성 제어를 하는 방법도 알게 되었고 코드도 수정했으니 동시성을 고려하여 코드를 좀 수정하고 다시 부하테스트를 해보자 

 

 

 


참고

https://dev-monkey-dugi.tistory.com/152

 

CountDownLatch로 동시성 테스트 하기

CountDownLatch란? 쓰레드가 2개 이상일 경우 일정 개수의 쓰레드가 끝난 후 다음 쓰레드가 실행될 수 있도록 대기시키고, 끝나면, 다음 쓰레드가 실행될 수 있도록 하는 것이다. 언제 사용할 수 있을

dev-monkey-dugi.tistory.com

https://simyeju.tistory.com/119

 

Java] ExecutorService란?

❓ ExecutorService란? 병렬 작업 시 여러 개의 작업을 효율적으로 처리하기 위해 제공되는 JAVA 라이브러리이다. ❔ ExecutorService가 없었다면? 각기 다른 Thread를 생성해서 작업을 처리하고, 처리가 완

simyeju.tistory.com

https://zzang9ha.tistory.com/443

 

 

좋아요 기능을 통해 살펴보는 동시성 이슈 (synchronized)

안녕하세요, 이번 포스팅에서는 동시성(Concurrency)에 대해 살펴보겠습니다. (예제 코드는 깃허브에서 확인하실 수 있습니다.) 동시성(Concurrency) 개념 네이버 사전에 검색해본 동시성은 다음과 같

zzang9ha.tistory.com

https://velog.io/@znftm97/%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0-V1-%EB%82%99%EA%B4%80%EC%A0%81-%EB%9D%BDOptimisitc-Lock-feat.%EB%8D%B0%EB%93%9C%EB%9D%BD-%EC%B2%AB-%EB%A7%8C%EB%82%A8

 

동시성 문제 해결하기 V1 - 낙관적 락(Optimistic Lock) feat.데드락 첫 만남

낙관적락, 동시성 문제, Optimistic Lock

velog.io

https://velog.io/@znftm97/%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0-V2-%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BDPessimistic-Lock

 

동시성 문제 해결하기 V2 - 비관적 락(Pessimistic Lock)

동시성 문제, 비관적 락, Pessimistic Lock

velog.io

https://hudi.blog/jpa-concurrency-control-optimistic-lock-and-pessimistic-lock/

 

JPA의 낙관적 락과 비관적 락을 통해 엔티티에 대한 동시성 제어하기

설명된 트랜잭션 격리 수준과 락에 대한 대부분의 내용은 MySQL 8.0을 기준으로 하고 있다. 트랜잭션 격리 수준 트랜잭션은 ACID(원자성, 일관성, 격리성, 지속성)을 보장해야한다. 트랜잭션은 원자

hudi.blog