본문 바로가기
JPA

[ JPA ] 외래키 데드락 / 낙관적 락

by 희디비 2025. 8. 11.
📌 주의: 해당 글은 잘못된 내용이 포함 되어 있을 수 있습니다.
혹시 잘못된 내용을 발견 하신다면 댓글로 알려주시면 감사 합니다!

 

🌱 [ 배경 ]

이전 프로젝트인 모임 어플리케이션에 대한 트러블 슈팅 내용 입니다.

모임은 최대 참여 회원수가 있고 회원은 모임에 참여할 수 있습니다.

만약에 여러 회원들이 하나의 모임에 동시에 참여 하게 된다면 어떻게 될까요?

 

이를 테스트 하기 위해 유사한 프로젝트를 만들고 실험해 보겠습니다. 


🌱 [ 엔티티 ]

[ 모임 ] 

  • 연관관계 : 모임 참여와 양방향
  • 상태 : 이름, 최대 참여 회원 수
@Getter
@Entity
public class Meeting {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

    @OneToMany(mappedBy = "meeting", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<MeetingParticipation> meetings = new ArrayList<>();

    private String name;

    private int capacity;

    protected Meeting() {}

    @Builder
    private Meeting(String name, int capacity) {
        this.name = name;
        this.capacity = capacity;
    }

    public void participate(long userId) {
        this.meetings.add(MeetingParticipation.participatedByUser(this, userId));
    }

    public boolean isNotJoinAble(int joinPersonCount) {
        return this.capacity <= joinPersonCount;
    }
}

 

[ 모임 참여 ]

  • 상태: 모임ID 외래키 사용
@Getter
@Entity
public class MeetingParticipation {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "meeting_id")
    private Meeting meeting;

    private long userId;

    protected MeetingParticipation() {}

    private MeetingParticipation(Meeting meeting, long userId) {
        this.meeting = meeting;
        this.userId = userId;
    }

    public static MeetingParticipation participatedByUser(Meeting meeting,
                                                          long userId) {
        return new MeetingParticipation(meeting, userId);
    }
}

🌱 [ 비지니스 로직 ]

@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class MeetingService {

    private final MeetingRepository meetingRepository;

    @Transactional
    public void participate(long meetingId, long userId){
        Meeting meeting = meetingRepository.findById(meetingId)
           .orElseThrow(() -> new IllegalArgumentException("미팅을 찾지 못했습니다"));

       if(meeting.isNotJoinAble(getJoinPersonCount(meeting))){
         throw new IllegalArgumentException("모임 참여 가능 정원수를 초과 하였습니다.");
       }

        meeting.participate(userId);
        meetingRepository.save(meeting);
    }

    private int getJoinPersonCount(Meeting meeting) {
        return meeting.getMeetings().size();
    }
}

 

코드의 흐름에 대하여 간략하게 소개 하겠습니다!

  1. 모임을 조회 합니다.
  2. 모임의 최대 참여 가능 회원 수와 현재 참여한 인원수를 비교 하여 모임에 참여 가능한지 확인 합니다.
  3. 모임에 참여한 후 모임을 저장 합니다

로직을 작성했으니 이제 테스트를 해봐야겠죠?


🌱 [ 테스트 ]

작성한 비지니스 로직이 잘 동작 하는지 확인해 보겠습니다.

@Transactional
@DisplayName("회원이 모임에 참여 합니다")
@Test
void participate() {
    //given
    User user = new User("테스터");
    userRepository.save(user);

    Meeting meeting = createMeeting("모임", 3);
    meetingRepository.save(meeting);

    //when
    meetingService.participate(meeting.getId(), user.getId());

    //then
    Meeting findMeeting = meetingRepository.findById(meeting.getId()).orElseThrow();

    assertThat(findMeeting)
            .extracting("name", "capacity")
            .containsExactly("모임", 3);

    assertThat(findMeeting.getMeetings()).hasSize(1);
}

 

모임에 잘 참여 되는것을 볼 수 있어요!

이제 여러 회원이 같은 모임에 모임 참가 하면 어떻게 되는지 테스트 해보겠습니다!


🌱 [ 모임 동시 참여 테스트 ]

  • 모임 최대 인원수 : 2명
  • 동시 참여 회원수 : 3명

결과는 어떻게 될까요? 원하는 결과는 2명만 참여가 가능 하기를 원합니다.

@DisplayName("여러 회원이 모임에 동시에 참가 한다.")
@Test
void participate_WhenConCurrency() throws InterruptedException {
    //given
    int taskCount = 3;

    List<User> users = Stream
            .generate(this::createAndSaveUser)
            .limit(taskCount)
            .toList();

    Meeting meeting = meetingRepository.save(createMeeting("모임", 2));

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

    //when
    for (User user : users) {
        executorService.submit(() -> {
            try {
                meetingService.participate(meeting.getId(), user.getId());
            } finally {
                countDownLatch.countDown();
            }
        });
    }
    countDownLatch.await();
    executorService.shutdown();
    em.clear();

    // then
    long participantCount = participationRepository
                            .countByMeetingId(meeting.getId());
    assertThat(participantCount).isEqualTo(2);
 }

 

하지만 실제 결과에서는 3명 모두 참여한 것을 볼 수 있습니다.

왜 이런 문제가 발생 하는걸까요?


🌱 [ 동시성 문제 ] 

  • 모임 최대 인원수 : 1명
  • 동시 참여 회원수 : 2명

발생 하는 원인은 트랜잭션 격리 수준이 REPEATABLE READ 일 때 두 트랜잭션이 동시에

현재 모임 참여 인원수를 조회 한다면 참여 인원수가 모두 0명으로 조회 하기 때문입니다.

 

 


🌱 [ 동시성 문제 해결 방법 ]

 [ 낙관적 락 ]

  • Application에서 동시성을 제어 하는 방법 입니다.
  • 엔티티에 version 필드를 통해 트랜잭션 커밋시 version을 체크 합니다.

 

[ LockMode ]

  • OPTIMISTIC : 엔티티를 수정 하여 트랜잭션 커밋시 version 업데이트
  • OPTIMISTIC_FORCE_INCREMENT: 엔티티 수정 없이 강제로 version 업데이트

[ 비관적 락 ]

  • 데이터베이스 락을 활용 하여 동시성 제어 하는 방법

[ 낙관적 락 적용 ]

  • 모임 참가할 때 엔티티의 수정이 없기 때문에 낙관적 락 강제 증가 사용

@Transactional
public void participate(long meetingId, long userId){
    Meeting meeting = meetingRepository.findByIdWithOptimisticLock(meetingId)
           .orElseThrow(() -> new IllegalArgumentException("미팅을 찾지 못했습니다"));
        
    (중략)...
}

🌱 [ 낙관적 락 테스트 ]

이제는 성공 하겠다! 하고 자신있게 테스트를 돌렸으나 테스트가 종료 되지 않습니다. ㅠㅠ..

로그를 보면 트랜잭션들이 롤백이 되었습니다 관련된 내용을 찾아보니 외래키 데드락 문제 였습니다.

 

[ 외래키 데드락 ]

show engine innodb status 명령어를 통해 데드락을 확인할 수 있었습니다.

 

[ 내용 요약 ]

  • 두 트랜잭션이 모임에 대한 S-Lock ( 공유락 ) 을 획득
  • 두 트랜잭션 모두 X-Lock 획득을 원하지만 다른 트랜잭션이 가진 S-Lock 으로 인해 데드락이 발생

[ 공유락은 왜 획득 하나요? ]

참고 : RealMySql 281p
  • 외래키를 생성하면 자식 테이블에 레코드가 추가되는 경우 해당 참조키가 부모테이블에 있는지 확인
  • 이러한 체크를 위해 테이블에 읽기 잠금을 걸어야 한다.

모임 참가 ( 자식 ) 테이블에 모임 ID가 외래키로 걸려 있어 모임 참가 테이블에 데이터를 넣을려면

부모 ( 모임 ) 테이블에 해당 모임 ID가 존재 하는지 읽기락이 걸리게 됩니다.

그 후 모임 업데이트를 위해 쓰기 락을 획득 하려니 다른 트랜잭션의 읽기 락으로 인해 데드락이 발생합니다.

 

[ 데드락은 어떻게 발생 하나요? ]

자세히 알아 보기 위해 두 트랜잭션을 준비 합니다.

  1. 트랜잭션 A가 모임 참가 시도 합니다.
  2. 동시에 트랜잭션 B가 모임 참가 시도 합니다.
  3. 그 후에 트랜잭션 A가 버전을 업데이트 시도 합니다.

쿼리 실행 할때 마다 " select * from performance_schema.data_locks " 락 획득 현황을 보겠습니다.

 

[ 트랜잭션A 모임 참가 ]

  • 모임에 대한 공유락을 획득

 

[ 트랜잭션B 모임 참가 ]

  • 마찬 가지로 모임에 대한 공유락 획득

 

[ 트랜잭션A 모임 버전 업데이트 ]

  • 모임에 대한 쓰기락이 추가 되어 데드락이 발생

 

[ 어떻게 해야 할까? ]

  1. 모임을 조회할 때 비관적 락을 걸어 데드락을 방지
  2. 외래키를 제거 하여 데드락 방지

먼저 비관적 락을 통해서 문제가 해결 되는지 확인해 보겠습니다.


🌱 [ 비관적 락 테스트 ] 

  • 모임을 조회할 때 비관적 락을 통해 조회 하도록 수정

 

[ 테스트 결과 ] 

 

드디어 ㅠㅠ... 테스트가 성공 했습니다.

두 트랜잭션은 커밋 되었고 하나는 모임 최대 인원수를 넘어 롤백 되었습니다.

 

[ 낙관적 락과 비관적 락 ] 

낙관적 락을 사용 하면 재시도를 하거나 사용자에게 잠시 후 다시 요청 해달라고 메시지를 남길 수 있고,

비관적 락을 사용 하면 조금 시간이 더 걸리더라도 모든 요청을 처리할 수 있습니다.

결론적으론 모임 참가 동시성 문제가 자주 발생할 것 같지 않기 때문에

낙관적 락과 외래키를 제거를 하여 동시성 문제 발생시 한 회원만 성공 하도록 수정 하였습니다.  


🌱 [ 엔티티 수정 ]

[ 모임 엔티티 ] 

  • 외래키가 아닌 모임 ID로 수정
@Getter
@Entity
public class Meeting {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

    private String name;

    private int capacity;

    @Version
    private long version;

    protected Meeting() {
    }

    @Builder
    private Meeting(String name, int capacity) {
        this.name = name;
        this.capacity = capacity;
    }

    public boolean isNotJoinAble(long joinPersonCount) {
        return this.capacity <= joinPersonCount;
    }
}

 

[ 모임 참가 ] 

@Getter
@Entity
public class MeetingParticipation {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

    private long meetingId;

    private long userId;

    protected MeetingParticipation() {
    }

    private MeetingParticipation(long meetingId, long userId) {
        this.meetingId = meetingId;
        this.userId = userId;
    }

    public static MeetingParticipation of(long meetingId, long userId) {
        return new MeetingParticipation(meetingId, userId);
    }
}

 

[ 모임 참가 비지니스 로직 ]

@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class MeetingService {

    private final MeetingRepository meetingRepository;
    private final MeetingParticipationRepository participationRepository;

    @Transactional
    public void participate(long meetingId, long userId){
      Meeting meeting = meetingRepository.findByIdWithOptimisticLock(meetingId)
            .orElseThrow(() -> new IllegalArgumentException("미팅을 찾지 못했습니다"));

      if(meeting.isNotJoinAble(getJoinPersonCount(meeting))){
         throw new IllegalArgumentException("모임 참여 가능 정원수를 초과 하였습니다.");
      }

      MeetingParticipation participation = MeetingParticipation.of(
      			meeting.getId(), userId
      );
      participationRepository.save(participation);
    }

    private long getJoinPersonCount(Meeting meeting) {
        return participationRepository.countByMeetingId(meeting.getId());
    }
}

 

[ 테스트 ]

@DisplayName("여러 회원이 모임에 동시에 참가 할때 한 회원만 참여 한다.")
@Test
void participate_WhenConCurrency_ThenOneSuccess() throws InterruptedException {
    //given
    int taskCount = 3;

    List<User> users = Stream
            .generate(this::createAndSaveUser)
            .limit(taskCount)
            .toList();

    Meeting meeting = meetingRepository.save(createMeeting("모임", 2));

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

    //when
    for (User user : users) {
        executorService.submit(() -> {
            try {
                meetingService.participate(meeting.getId(), user.getId());
            } finally {
                countDownLatch.countDown();
            }
        });
    }
    countDownLatch.await();
    executorService.shutdown();
    em.clear();

    // then
    long participantCount = participationRepository.countByMeetingId(meeting.getId());
    assertThat(participantCount).isEqualTo(1);
}

 

3명의 회원이 동시에 참가를 시도 할 때 8번 트랜잭션은 모임 참가에 성공 합니다.

7,9번 트랜잭션은 이후 version이 수정 되었기 때문에 모임 참가에 실패 하게 됩니다.

'JPA' 카테고리의 다른 글

[JPA] 영속성 전이 충돌 문제  (1) 2025.07.19
[JPA] 영속성 컨텍스트(entityManager)  (0) 2024.05.14