(동시성 문제) 프로젝트 작업 중 동시성 문제 해결

안녕하세요. 이 프로젝트를 진행하면서 겪었던 동시성 문제를 어떻게 해결했는지 설명하는 글을 쓰겠습니다.

우선 동시성을 기본적으로 정리한 글을 보면 동시성 문제가 무엇인지 알 수 있다.

동시성 문제는 무엇입니까? – (하나)

내가 이 글을 쓰는 이유는? CS와 Java를 공부하면서 동시성에 대한 많은 언급이 있었습니다. 하지만 이 부분은 개념적으로 이해했지만 실제로는 코드를 통해 배웠고

pos04167.tistory.com

1. 드디어 만났다 동시성 문제


먼저 동시성 문제의 원인을 설명하기 위해 프로젝트 ERD에 대해 설명하겠습니다.

일반 회원은 연구를 만들 수 있습니다. 이 경우 1개의 스터디룸이 생성되며, 생성된 구성원은 ROLE_STUDY_LEADER 권한을 가집니다. 설명을 위해 멤버 A 및 연구 A라고 합니다.

A멤버가 생성한 스터디 A는 B멤버가 참여를 원할 때 Study_Member 테이블에 추가하여 지원하도록 설계되어 있습니다.

이 경우 Study의 칼럼을 보시면 참여 가능 인원을 알 수 있습니다. 일관성중요한 문제로 인식하고 있습니다.

왜냐하면 참가 가능한 인원수는 정해져있는데 멀티 스레드 환경에서 동시에 여려 명의 인원이 추가되면
데이터의 정합성에 문제가 생길 수 있다고 생각을 하였습니다.

2. 문제를 해결하는 방법?

1. 자바 공부하기 동기화됨다음을 사용하여 여러 스레드의 동시 액세스를 방지합시다.

동기화된 영역은 동기화된 키워드를 통해 표시됩니다. 그러면 쓰레드에 안전한 환경이 만들어지고 이것이 가능한 이유는 현재 데이터에 접근하고 있는 한 쓰레드를 제외한 나머지 쓰레드에서 데이터 접근을 불가능하게 만들기 때문이다.

그러나이 문제를 해결하기 위해 sync 키워드를 사용하지 않았습니다.

Synchronized 키워드를 사용하면 내부적으로 Block과 Non-Block이 발생하는데 이러한 프로세스가 많으면 성능 문제가 발생하기 때문입니다. 하나의 서버에서 Synchronized 키워드를 사용하면 동시성 문제를 해결할 수 있지만 n개의 서버가 증가하면 여러 곳에서 데이터 접근이 가능해지면서 다시 동시성 문제가 발생한다.

2. MySQL에서 잠금을 사용하여 해결하기 전에 잠금을 해제합시다.

비관적 자물쇠

낙관적 자물쇠

3. 문제 해결

1. 단일 스레드에서 테스트 코드를 작성하여 기존 로직을 확인했습니다.

시험

@SpringBootTest
public class StudyMemberServiceTest {

    @Autowired
    private StudyMemberService studyMemberService;
    @Autowired
    private StudyRepository studyRepository;
    @Autowired
    private MemberRepositry memberRepositry;
    @Autowired
    private RoleRepository roleRepository;

    @BeforeEach
    void setUp() {
        Member member = Member.builder()
                .email("[email protected]")
                .name("김무건")
                .password("1234")
                .build();

        memberRepositry.save(member);

        Study study = Study.builder()
                .title("제목")
                .content("내용")
                .membersCount(100)
                .build();

        study.addMemberWithStudy(member);

        Optional<Role> leaderRole = roleRepository.findByName(RoleEnum.ROLE_STUDY_LEADER.getRoleName());
        leaderRole.ifPresent(member::addRole);

        studyRepository.save(study);
    }

    @Test
    @DisplayName("싱글 스레드 환경에서 참여 스터디 조회")
    public void studyMemberCreateJoinMemberWithSingleThread
            () throws Exception {
        //given
        LoginUserDto userDto = LoginUserDto.builder()
                .memberId(1L)
                .build();
        //when
        studyMemberService.create(1L, userDto);

        //Then
        Study study = studyRepository.findByIdAndDeleteStudyFalse(1L)
                .orElseThrow(() -> new NotFoundStudyWithId(1L));

        Assertions.assertThat(study.getMembersCount()).isEqualTo(99);
    }
  }

이 테스트 코드는 멤버 및 스터디 생성 프로세스를 테스트하기 전에 실행하기 위해 @BeforeEach로 처리되었습니다. 그런 다음 단일 스레드 환경에서 논리를 처리하도록 코드를 작성했습니다. setUp()에서 memberCount를 100으로 설정하고 한 명의 멤버가 참여하면 100-1 = 99명의 멤버가 나와야 정상적인 동작을 합니다.


단일 쓰레드에서 정상적으로 처리된 것을 확인할 수 있다. 그럼 DB에서 값을 확인해보자.

위의 그림을 보면 STUDY_MEMBER가 추가되고 MEMBER_COUNT가 감소한 것을 확인할 수 있습니다.

2. 다중 스레드 환경에서 테스트

   @Test
    @Transactional
    @Rollback(value = false)
    @DisplayName("멀티쓰레드 환경에서 테스트")
    public void test2() throws Exception {

        LoginUserDto userDto = LoginUserDto.builder()
                .memberId(1L)
                .build();

        int threadCount = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(32);
        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
                try {
                    studyMemberService.create(1L, userDto);
                } finally {
                    latch.countDown();
                }
            });
        }
        latch.await();
        Study study = studyRepository.findById(1L)
                .orElseThrow();
        assertThat(study.getMembersCount()).isEqualTo(0);
    }

이번에는 멀티스레드 환경에서 테스트할 테스트 코드를 작성하는 방법이 궁금해서 구현해봤습니다.

이 코드를 설명하기 위해 ExecutorService를 사용하여 스레드 풀을 만듭니다. 이때 쓰레드 풀은 최대 32개의 쓰레드로 설정했고 CountDownLatch를 이용하여 100개의 쓰레드가 모두 실행될 때까지 기다렸다.

이후 StudyMemberService에서 로직이 실행되면 CountDown 메소드를 호출하여 카운트를 줄이고 카운트가 0이 될 때까지 대기한다.

이후 검증 로직을 통해 결과를 확인합니다.

지금 코드를 보면 참여인원을 100명으로 설정하고 100명이 참여한다고 가정한 테스트 코드입니다.

이때 내가 기대한 값은 100 – 100 = 0명이었다.

하지만 값이 다르게 나왔기 때문에 MySQL에서 잠금을 사용하여 동시성 문제를 해결했습니다.

기존 서비스 코드 살펴보기

 @Transactional
    public void create(Long studyId, LoginUserDto userDto) {

        preventionDuplicateStudyParticipation(studyId, userDto);

        Study study = studyRepository.findByIdAndDeleteStudyFalse(studyId)
                .orElseThrow(() -> new NotFoundStudyWithId(studyId));

        Member member = memberRepositry.findById(userDto.getMemberId())
                .orElseThrow(() -> new NotFoundMemberid(userDto.getMemberId()));


        if (study.getMembersCount() != 0) {
            study.decreaseMembersCount();
        } else {
            throw new MembersWhoInvalidJoinCount();
        }


        // StudyMember 객체를 생성하고 연관 관계를 설정합니다.
        StudyMember studyMemberS = StudyMember.builder()
                .study(study)
                .member(member)
                .build();

        studyMemberRepository.save(studyMemberS);
    }

    private void preventionDuplicateStudyParticipation(Long studyId, LoginUserDto userDto) {
        List<StudyMember> studyMember = studyMemberRepository.findAllWithStudyAndMemberByStudyId(studyId);


        List<Long> memberIds = studyMember.stream()
                .map(sm -> sm.getMember().getId())
                .collect(Collectors.toList());

        if (memberIds.contains(userDto.getMemberId())) {
            throw new RuntimeException("Duplicate member id detected: " + userDto.getMemberId()); // 특정 값과 중복된 경우 오류 발생
        }
    }

여기서 나는 두 곳에서 잠그는 것을 생각했다. 처음으로 PreventionDuplicationStudyParticipation에서 findAllWithStudyAndMembersByStudyId에 잠금을 설정합니다. 이는 동일한 구성원이 동일한 연구에 중복 참여하지 않도록 하는 방법입니다.

FindByIdAndDeleteStudyFalse가 잠겨 두 곳에서 설정되었습니다.

JPA의 낙관적 잠금을 사용하여 동시성 문제 해결

우아한 테크코스 4기 달록팀 기술블로그에 올라온 글입니다. 오랜만에 올리는 포스팅입니다. 이 기사에서는 JPA 낙관적 잠금을 사용하여 Dallock의 동시성 문제를 해결한 경험에 대해 이야기할 것입니다.

hudi.blog

MySQL InnoDB 잠금 및 교착 상태 이해하기 — Steemit

MySQL InnoDB 잠금 및 교착 상태 동시에 많은 요청을 받는 데이터베이스(DB) 응용 프로그램의 경우 데이터 일관성을 유지하면서 동시성을 최대한 높이는 것이 매우 중요합니다.

steemit.com