모아모아 서비스에는 스터디 참여 라는 기능이 있으며, 여기에는 모집 인원 제한이 함께 존재한다.

따라서 예를 들어 스터디 모집 인원이 3명이고 현재 방장을 포함하여 스터디에 참여한 인원이 2명이라고 하면 추가적으로 2명이 동시에 스터디 가입 API 요청이 보냈을 때 한 명에 대해서만 성공하고 나머지 한 명에 대해서는 실패해야 우리가 원하는 대로 서비스가 가능해지게 된다.

하지만 QA를 진행하던 도중 위 예시와 동일한 상황에서 동시에 스터디 참여 버튼을 누르면 꽤나 빈번하게 두 명 모두가 참여가 가능해지게 되었고, 결국 모집 제한 인원은 3명인데 4명이 가입되게 되는 문제가 발생하게 되었다.

당시 우리는 synchronized 키워드를 스터디 참여하는 서비스 메소드에 임시방편으로 붙이고 넘어갔었다.

    public synchronized void participateStudy(final Long memberId, final Long studyId) {
        memberRepository.findById(memberId)
                .orElseThrow(MemberNotFoundException::new);
        final Study study = studyRepository.findById(studyId)
                .orElseThrow(StudyNotFoundException::new);

        study.participate(memberId);
    }

[BE]: 스터디 참여 동시성 문제


synchronized

앞서 설명한 것처럼 synchronized 를 붙인 상태에서 우리는 문제가 해결되었다고 판단하였다. 실제로 synchronized 키워드를 붙인 이후로 앞선 QA와 달리 동시성 문제가 발생하지 않고, 정상적으로 한 명씩만 가입이 되었기 때문이다.

하지만 다음과 같이 curl 요청을 통해서 스터디 가입을 동시에 요청해보니 이전과 동일하게 문제가 발생하고 있었다. 즉, Id 10번을 가지는 스턴디가 참여 인원이 2명으로 제한되어 있고, 방장이 존재하여 현재 스터디 참여인원이 1명인 상태에서 아래와 같이 동시에 2, 3 번 member 가 참여요청을 하면 동시에 둘 모두가 가입되는 문제
(QA에서는 아무래도 사람이 직접 클릭하며 테스트했던 것이기 때문에 아래와 같은 테스트 방법보다는 동시에 요청을 보내기 힘들어 문제를 포착할 수 없었다고 생각한다.)

curl -X POST 'http://localhost:8080/api/studies/10/members/2' & curl -X POST 'http://localhost:8080/api/studies/10/members/3'

문제 원인

해당 문제 원인에 대해서 고민을 해보니 문제 원인이 @Transactional 어노테이션에 있겠다는 생각이 들었다.
우리가 Synchronized를 사용한 이유는 스터디 참여 라는 메소드를 한 쓰레드에서만 수행시키기 위해서 이다. 즉 여러 쓰레드가 있을 때 어느 한 순간에는 하나의 쓰레드만 해당 메소드를 수행시키고 싶어서 이다. 하지만 @Transactional 어노테이션과 함께 사용하게 되면 첫 번째 쓰레드가 종료되기 전에 두 번째 쓰레드가 수행될 수 있다.

왜 그런것일까?

현재 우리가 synchronized 키워드를 붙인 메소드는 @Transacitonal 로 감싸진 메소드이다. 즉, AOP 를 통해서 해당 메소드 앞 뒤로 트랜잭션 시작, 트랜잭션 커밋 또는 롤백을 수행해주고 있다. 즉 다음과 같은 형태이다.

Spring Begins Transaction -> Target(스터디 참여) -> Spring commits Transaction

우선 트랜잭션 시작은 동시에 요청이 온 두 트랜잭션 모두가 시작을 할 수 있다. 트랜잭션 프록시를 호출하게 되면 트랜잭션 프록시는 데이터 소스를 찾아서 사용하게 되면서 이 때 커넥션 풀에서 커넥션을 획득하게 된다. 따라서 동시에 온 두 요청 모두가 각각의 커넥션을 소유할 수 있게 된다. 이렇게 커넥션을 맺은 이후에 실제 타겟(Target, Advice를 적용할 대상)에 대해서는 우리가 synchronized 를 걸었기 때문에 순차적으로 진행되게 될 것이다.
하지만 커밋하는 시점, 즉 하나의 쓰레드가 Target의 메소드를 처리하고 나오는 순간 다른 트랜잭션에서 메소드에 진입이 가능하게 된다. 하지만 현재 앞선 트랜잭션에서는 커밋이 완료되지 않았고, 두번째 실행된 트랜잭션에서 스터디를 조회해오게 되면 앞선 예시로 따져보자면 여전히 스터디는 방장 혼자 있고, 최대 인원은 2명이라는 스터디 정보를 DB로 부터 조회해오게 될 것이다. 즉 처음 트랜잭션이 commit되어 DB에 반영되기 전에 두번째 트랜잭션이 DB로 부터 데이터를 조회해올 수 있는 것이다. 그림으로 살펴보면 다음과 같게 될 것이다.

T1 : B - T - C
T2 : B - - - T - C

여기서 B, 는 트랜잭션 시작(Begin)을 의미하며, T는 Target 메소드 수행, C는 커밋을 의미한다.
다시 정리해보면 T1이 Commit을 완료하지 못한 시점에 synchronized 가 붙은 Target메소드는 빠져나왔기 때문에 T2 에서 Target 메소드를 수행할 수 있고, T2 에서는 T1이 작업을 완료한 내용이 반영되지 않은 DB 정보를 읽어와 로직을 처리하기 때문에 문제가 발생한 것이다.


synchronized 이후 Transactional

앞서서는 @Transactional 어노테이션에 의해 트랜잭션을 시작하고 난 이후에 메소드가 synchronized 에 의해 하나의 쓰레드만 수행되고 이후 빠져나왔을 때 (커밋 이전에) 다른 쓰레드가 synchronized 가 걸린 target 메소드로 진입이 가능하기 때문에 발생하는 문제였다.

그럼 @Transactional 로 감싸기 이전에 먼저 synchronized 를 붙여주면 어떻게 될까?
그럼 다음과 같은 방식으로 처리가 수행되게 될 것이다.

T1 : B - T - C
T2 : - - - - - B - T - C

하지만 현재 우리의 participateStudy 메소드가 있는 StudyParticipantService 바깥 부분은 컨트롤러 였기 때문에 해당 부분에 @Transactional 을 붙일 수는 없었고 다음과 같이 문제를 해결해보았다.

@Service
@RequiredArgsConstructor
public class StudyParticipantService {

    private final AsynchronousParticipantService asynchronousParticipantService;

    public synchronized void participateStudy(final Long memberId, final Long studyId) {
        asynchronousParticipantService.participateStudy(memberId, studyId);
    }

    public synchronized void leaveStudy(final Long memberId, final Long studyId) {
        asynchronousParticipantService.leaveStudy(memberId, studyId);
    }

    public synchronized void kickOutMember(final Long ownerId, final Long studyId, final Long participantId) {
        asynchronousParticipantService.kickOutMember(ownerId, studyId, participantId);
    }
}
@Service
@Service
@Transactional
@RequiredArgsConstructor
public class AsynchronousParticipantService {

    private final MemberRepository memberRepository;
    private final StudyRepository studyRepository;
    private final DateTimeSystem dateTimeSystem;

    @Deprecated
    public void participateStudy(final Long memberId, final Long studyId) {
        memberRepository.findById(memberId)
                .orElseThrow(MemberNotFoundException::new);
        final Study study = studyRepository.findById(studyId)
                .orElseThrow(StudyNotFoundException::new);

        study.participate(memberId);
    }

    @Deprecated
    public void leaveStudy(final Long memberId, final Long studyId) {
        final Study study = studyRepository.findById(studyId)
                .orElseThrow(StudyNotFoundException::new);

        final LocalDate now = dateTimeSystem.now().toLocalDate();
        study.leave(new Participant(memberId), now);
    }

    @Deprecated
    public void kickOutMember(final Long ownerId, final Long studyId, final Long participantId) {
        final Study study = studyRepository.findById(studyId)
                .orElseThrow(StudyNotFoundException::new);

        final LocalDate now = dateTimeSystem.now().toLocalDate();
        study.kickOut(ownerId, new Participant(participantId), now);
    }
}

기존에 StudyParticipantServiceAsynchronousParticipantService 라고 네이밍을 짓고, 새로운 StudyParticipantService 를 추가해주었다.
그리고 @Transactional 이 시작되기 이전에 바깥쪽에서 synchronized 를 걸어 다음과 같이 작업이 수행되게 하였다.

T1 : B - T - C
T2 : - - - - - B - T - C

그리고 동시성 제어가 제대로 되는지 확인하기 위해 만든 테스트도 잘 통과하고 있었다.

    @DisplayName("동시에 다수의 사용자가 스터디를 가입/탈퇴한다.")
    @Test
    void concurrentlyParticipateOrLeaveStudy() {
        // arrange
        final List<GithubProfileResponse> profiles = getProfiles(0, 100);
        final GithubProfileResponse 방장 = profiles.get(0);
        final List<GithubProfileResponse> 탈퇴를_원하는_사용자 = profiles.subList(1, 50);
        final List<GithubProfileResponse> 가입을_원하는_사용자 = profiles.subList(50, 101);
        final long 스터디_ID = 사용자가(방장).로그인하고().자바_스터디를().시작일자는(LocalDate.now()).생성한다();
        final List<SlackUserResponse> users = 가입을_원하는_사용자.stream()
                .map(user -> new SlackUserResponse(user.getUsername(), new SlackUserProfile(user.getEmail())))
                .collect(Collectors.toList());

        for (SlackUserResponse profile : users) {
            when(slackUsersClient.getUserChannelByEmail(profile.getSlackUserProfile().getEmail())).thenReturn(profile.getChannel());
            doNothing().when(slackAlarmSender).requestSlackMessage(profile.getChannel());
        }

        for (GithubProfileResponse 프로필 : 탈퇴를_원하는_사용자) {
            사용자가(프로필).로그인하고().스터디에(스터디_ID).참여를_시도한다();
        }

        // act
        final ConcurrentHttpRequester requester = new ConcurrentHttpRequester(
                탈퇴를_원하는_사용자.size() + 가입을_원하는_사용자.size()
        );

        for (GithubProfileResponse 프로필: 가입을_원하는_사용자) {
            requester.submit(() -> 사용자가(프로필).로그인하고().스터디에(스터디_ID).참여를_시도한다());
        }

        for (GithubProfileResponse 프로필 : 탈퇴를_원하는_사용자) {
            requester.submit(() -> 사용자가(프로필).로그인하고().스터디에(스터디_ID).탈퇴를_시도한다());
        }
        requester.await();

        // assert
        assertThat(requester.getSuccessUser()).isEqualTo(100);
        assertThat(requester.getFailUser()).isEqualTo(0);
        assertThat(getCurrentMemberCount(스터디_ID)).isEqualTo(52);
    }

하지만 여전히 문제가 있었다. 이제는 새롭게 생성한 StudyParticipantService 의 메소드만 사용해야 한다. 하지만 @Transactional 이 동작하기 위해선 메소드의 접근 제어자가 public 이어야한다.
위에서 볼 수 있다시피 @DeprecatedAsynchronousParticipantService 의 메소드의 직접적인 사용을 제한해주었다. 해당 메소드들은 이제 StudyParticipantService 에서만 사용될 것이고, 다른 곳에서 AsynchronousParticipantService 의 메소드를 사용하려고 하면 해당 메소드는 더 이상 사용되지 않음 을 개발자는 @Deprecated 를 통해 알 수 있고, 경고해줄 수 있게 된다.

[BE] 비관적 락 없이 애플리케이션에서 동시성 제어하기
[BE] issue462: 애플리케이션에서 동시성 제어하기

낙관적 락과 비관적 락

synchronized 가 붙은 메소드를 @Transactional 바깥에 둠으로써 문제를 해결한 것 같았지만 여전히 문제가 존재하고 있었다.

  • 위에서 스터디 가입과 탈퇴에 대한 테스트 코드를 통해서 확인을 하고 있기는 하지만, 정말 문제가 없는 것일까?

    • 쓰레드는 우리가 직접 제어하기 힘든 영역이다. 우연의 케이스로 해당 테스트가 통과하고 있는 것일 수 있다. 논리적으로 생각해보자. 두 개의 쓰레드가 각각 participate, leave 를 수행한다고 하고 현재 인원은 3명이라고 하자. 각각의 메소드가 서로 병렬적으로 처리될 수 있다. 따라서 둘 모두 3이라는 숫자를 읽어왔다고 해보자. participant 는 4로 증가시키고, leave 는 2로 감소시킬 것이다. 순서에 따라서 서로 다른 결과가 나오게 될 것이다. 즉, race condition이 발생하는 것이다.
  • 여러 대의 WAS로 확장하면 여전히 동시성 문제가 존재한다.

    • 두 대의 WAS 가 있다고 가정해보자. 각가의 WAS 에서 participate 와 leave 를 호출할 수 있다. 앞서와 마찬가지로 race condition이 발생하게 된다. 따라서 두 WAS 모두에 대해서 동기화를 맞춰줄 수 있어야 한다.
  • @Deprecated 는 팀원들의 혼란을 초래한다.

    • @Deprecated 는 더 이상 사용을 하지 말라고 알려주기 위해서 혹은 위험한 메소드 이거나 대안이 있는 경우에 사용할 수 있으며 컴파일러도 경고를 해주게 된다. 나의 경우에는 대안이 있기 때문에(StudyParticipantServicesynchronized 가 붙어있는 메소드를 사용하도록) 사용한 것이지만 팀원들의 해석에 따라 다른 의미가 될 수 있다.

위와 같은 이유로 synchronized 를 이용해서 동시성을 제어할 수 없으니 우리는 낙관적 락과 비관적 락에 대해서 고민하기 시작했다.

우선 낙관적 락의 경우 내가 먼저 해당 값을 수정했다고 명시하여 다른 사람이 동일 조건으로 값을 수정할 수 없게 하는 방법으로 DB에서 제공하는 락이 아닌 애플리케이션 단에서 제공하는 방법이다.
JPA 를 사용하는 경우, @Version 어노테이션을 붙인 필드를 하나 추가하여 트랜잭션 시작시에 조회한 버전과 커밋시에 버전이 동일한지를 체크하여 중간에 누군가 먼저 수정하지 않았는지를 확인하는 방법이다.
따라서 낙관적 락을 사용하는 경우 충돌이 발생했을 때에 대한 처리를 개발자가 직접 해주어야한다.
스프링에서는 AOP를 통해서 제공하는 @Retryable 어노테이션을 이용하여 예외가 발생했을 때 해당 메소드를 재시도 할 수 있는 기능을 제공해준다.
하지만 낙관적 락을 사용하지 않은 이유는 우선 가장 큰 단점인 롤백에 대한 처리다. 아무리 스프링에서 @Retryable 기능을 제공한다고는 하지만 몇번이나 재시도를 할지에 대한 기준을 세우기 어렵다는 문제가 있었다.

다음으로는 비관적 락이다. 비관적 락은 트랜잭션 시작시에 Shared Lock(공유, 읽기 잠금) 또는 Exclusive Lock(배타, 쓰기 잠금)을 걸고 시작하는 방법이다.
(Shared Lcok의 경우에는 다른 트랜잭션에서 읽기만 가능하다. Exclusive Lock의 경우 다른 트랜잭션에서 읽기와 쓰기 모두가 불가능하다)
Shared Lock을 걸게 되면 쓰기 작업을 하기 위해서는 Exclusive Lock 을 얻어야 하는데 Shared Lock 이 다른 트랜잭션에 의해 걸려 있으면 해당 락을 얻지 못해 업데이트가 불가능하게 된다. 수정을 위해서는 해당 트랜잭션을 제외한 모든 트랜잭션이 종료되어야 한다.

우리 입장에서는 여러 대의 WAS 로 확장하게 되어도 동시성 제어가 확실하게 되어야 하며, 하나의 요청에 대해서가 아니라 participant 와 leave 에 따라 변하는 참여 인원 수 라는 데이터 자체에 대해서도 동시성을 제어해주어야 했다. 따라서 물론 성능상의 손해를 볼 수는 있겠지만 current_member_count 라고 하는 데이터 자체에 대한 락이 필요하다고 판단하였고 비관적 락 방법을 통해 동시성 제어를 수행하도록 하였다.

참고 : [database] 낙관적 락(Optimistic Lock)과 비관적 락(Pessimistic Lock)

[BE]: 동시성 제어를 위한 JPA Pessimistic Lock 적용

id 를 통해서 study 를 찾는 모든 쿼리에 대해서 락을 걸 필요는 없었기 때문에 findById 이외에 findByUpdateFor 이라는 메소드를 하나 만들어 락을 통해 조회해와야 하는 곳에 대해서 사용할 수 있도록 해주었습니다.

이 외의 방법으로 격리레벨 자체를 SERIALIZABLE 로 설정하는 것도 방법이 될 수 있겠지만, 스터디 참여와 관련된 기능에 대해서만 동시성을 제어해주면 되었기 때문에 DB 전체에 대해서 격리 수준을 높여줄 필요는 없어 해당 방법을 택하지 않았다.


여전한 문제, 데드락

하지만 앞서 비관적 락을 사용했지만 여전한 문제와 의문이 존재한다.

Q. 우선 충돌이 자주 발생하는 환경인가?

스터디 참여, 스터디 탈퇴, 스터디 강퇴 모두 하나의 데이터인 current_member_count(스터디 현재 참여 인원 수) 에 접근이 필요하다. 그리고 그렇게 때문에 동시성 문제가 발생하고 있다. 따라서 충돌이 자주 발생하는 환경인가에 대한 답은 Yes 라고 할 수 있다.

Q. 데이터 자체에 락을 걸게 되므로 동시성을 많이 떨어트려 성능상 손해를 보게 된다.

비관적 락은 레코드 단위로 락을 걸기 때문에 해당 레코드 이외에 다른 레코드에 대해서는 락을 걸지 않아 성능을 많이 떨어트리지는 않는다고 보았으나 만약 인기 있는 스터디가 개설되어 동시에 여러 요청이 오게 된다면 문제가 발생할 수 있겠다는 생각을 할 수 있었다. 하지만 인기 있는 스터디에 대해서 50명 제한인데 이를 넘어 가입되는 사태가 발생하는 것을 막기 위한 가장 확실한 방법은 비관적 락을 통해 데이터 자체에 락을 거는 방법이다.

Q. 비관적 락을 사용하여 발생가능한 데드락에 대한 대처가 되어 있나??

앞서 Spring Data JPA 를 통해서 정의한 findByUpdateFor() 을 보면 @Lock(LockModeType.PESSIMISTIC_WIRTE) 로 건 것을 확인할 수 있다. 우리는 동시성 제어한 필요한 곳에서 스터디를 조회할 때 해당 메소드를 활용해주고 있는데, @Lock(LockModeType.PESSIMISTIC_WIRTE) 의 설명을 살펴보면 "동시 업데이트 트랜잭션 중 데드락 또는 실패 가능성이 높다." 라고 데드락 발생 가능성에 대해서 경고하고 있는 것을 확인할 수 있다.

예를 들면 다음과 같은 상황이 될 수 있겠다.

쓰레드 1 : A 정보를 구하고 잠금
쓰레드 2 : B 정보를 구하고 잠금
쓰레드 1 : B 정보를 구하려고 하지만 잠겨있음
쓰레드 2 : A 정보를 구하려고 하지만 잠겨있음

이런 교착 상태를 해결하기 위한 방법으로는 락을 잡고 있는 최대 시간을 지정하는 방법이 있다. (잠금 시간 초과 설정, Setting Lock Timeout)

@QueryHints@QueryHint 어노테이션을 통해서 간단하게 지정할 수 있으며 value 속성의 단위는 밀리세컨드이다.

[BE] 비관적 락 사용으로 인한 데드락 방지하기
[BE] issue464: 비관적락 사용으로 인한 데드락 상황 방지하기

이렇게 해서 동시성 문제에 대해서 해결을 해줄 수 있게 되었고, 확장된 WAS 를 사용할 때에도 문제 없이 확장이 가능한 구조가 되었따. 또한 비관적 락을 사용함으로써 발생하는 데드락 문제와 같은 것에 대해서도 대비를 해줄 수 있었다.

profile
꾸준함에서 의미를 찾자!

3개의 댓글

synchronized에 대한 단점들 중에 race condition 문제가 궁금한데, synchronized로 동시성을 해결하고 난 뒤의 문제라서 위에서 언급해주신
T1 : B - T - C
T2 : - - - - - B - T - C
T1, T2가 순서대로 진행되어서 participant가 먼저 실행된다면 4 -> 3, leave가 먼저 실행된다면 2 -> 3이 되는것이 아닌가 생각했는데.. 이게 가능한지 모르겠습니다 ㅠ..

조금 정리하면 synchronized가 적용된 상태에서 participant와 leave가 동시에 실행될 수 있는지 궁금합니다!

1개의 답글