📌 주의: 해당 글은 잘못된 내용이 포함 되어 있을 수 있습니다.
혹시 잘못된 내용을 발견 하신다면 댓글로 알려주시면 감사 합니다!
🌱 [ 배경 ]
이전 프로젝트인 모임 어플리케이션에 대한 트러블 슈팅 내용 입니다.
모임은 최대 참여 회원수가 있고 회원은 모임에 참여할 수 있습니다.
만약에 여러 회원들이 하나의 모임에 동시에 참여 하게 된다면 어떻게 될까요?
이를 테스트 하기 위해 유사한 프로젝트를 만들고 실험해 보겠습니다.
🌱 [ 엔티티 ]
[ 모임 ]
- 연관관계 : 모임 참여와 양방향
- 상태 : 이름, 최대 참여 회원 수
@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();
}
}
코드의 흐름에 대하여 간략하게 소개 하겠습니다!
- 모임을 조회 합니다.
- 모임의 최대 참여 가능 회원 수와 현재 참여한 인원수를 비교 하여 모임에 참여 가능한지 확인 합니다.
- 모임에 참여한 후 모임을 저장 합니다
로직을 작성했으니 이제 테스트를 해봐야겠죠?
🌱 [ 테스트 ]
작성한 비지니스 로직이 잘 동작 하는지 확인해 보겠습니다.
@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가 존재 하는지 읽기락이 걸리게 됩니다.
그 후 모임 업데이트를 위해 쓰기 락을 획득 하려니 다른 트랜잭션의 읽기 락으로 인해 데드락이 발생합니다.
[ 데드락은 어떻게 발생 하나요? ]
자세히 알아 보기 위해 두 트랜잭션을 준비 합니다.
- 트랜잭션 A가 모임 참가 시도 합니다.
- 동시에 트랜잭션 B가 모임 참가 시도 합니다.
- 그 후에 트랜잭션 A가 버전을 업데이트 시도 합니다.


쿼리 실행 할때 마다 " select * from performance_schema.data_locks " 락 획득 현황을 보겠습니다.
[ 트랜잭션A 모임 참가 ]
- 모임에 대한 공유락을 획득

[ 트랜잭션B 모임 참가 ]
- 마찬 가지로 모임에 대한 공유락 획득

[ 트랜잭션A 모임 버전 업데이트 ]
- 모임에 대한 쓰기락이 추가 되어 데드락이 발생

[ 어떻게 해야 할까? ]
- 모임을 조회할 때 비관적 락을 걸어 데드락을 방지
- 외래키를 제거 하여 데드락 방지
먼저 비관적 락을 통해서 문제가 해결 되는지 확인해 보겠습니다.
🌱 [ 비관적 락 테스트 ]
- 모임을 조회할 때 비관적 락을 통해 조회 하도록 수정

[ 테스트 결과 ]




드디어 ㅠㅠ... 테스트가 성공 했습니다.
두 트랜잭션은 커밋 되었고 하나는 모임 최대 인원수를 넘어 롤백 되었습니다.
[ 낙관적 락과 비관적 락 ]
낙관적 락을 사용 하면 재시도를 하거나 사용자에게 잠시 후 다시 요청 해달라고 메시지를 남길 수 있고,
비관적 락을 사용 하면 조금 시간이 더 걸리더라도 모든 요청을 처리할 수 있습니다.
결론적으론 모임 참가 동시성 문제가 자주 발생할 것 같지 않기 때문에
낙관적 락과 외래키를 제거를 하여 동시성 문제 발생시 한 회원만 성공 하도록 수정 하였습니다.
🌱 [ 엔티티 수정 ]
[ 모임 엔티티 ]
- 외래키가 아닌 모임 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 |