0. 이 글을 쓰게 된 이유
팀 프로젝트에서 "모임"과 관련된 기능들을 구현하는 역할을 맡아서 개발을 진행 중이었다. 구현해 둔 로직들을 다시 살펴보다가 생각지도 못했던 동시성 문제가 숨어있었다. 동시성 문제를 해결하면서 공부한 내용들과 과정을 포스팅해보고자 한다.
1. 요구사항과 해결방법
1-1. "모임 가입 요청"의 요구사항
- 모임이 존재하고, 모임은 제한된 인원만 참여할 수 있다.
- 이미 다 찬 모임에 가입하려는 경우 사용자에게 가입할 수 없음을 알린다.
- 이미 가입되어 있는 경우 가입 요청을 거부한다.
- 남은 자리에 여러 명이 동시에 가입 요청하는 경우, 가능한 요청만큼만 받아들일 수 있어야 한다.
1-2. 문제점
현재 로직으로는 동시에 여러 가입 요청이 오는 경우에 동시성 이슈가 발생한다.
1-3. 원인
동시에 여러 요청이 오는 경우, 여러 개의 트랜잭션이 겹쳐서 경쟁상태가 발생하는 것으로 보인다.
1-4. 해결방법
JPA를 이용하고 있으므로 JPA의 Lock 기능을 통해 해결해보자.
2. JPA의 Lock의 종류와 선택
2-1 낙관적 잠금 (Optimisstic Lock)
데이터 갱신 시 충돌이 발생하지 않을 것이라고 낙관적으로 가정하는 방식이다. 따라서 동시에 데이터 갱신이 이루어질 경우 최초 커밋을 제외하고 예외가 발생한다. 즉 일종의 충돌 감지라고 볼 수 있다. JPA는 Version 관리를 통해서 낙관적 잠금을 구현한다.
2-2 비관적 잠금 (Pessimistic Lock)
데이터 갱신 시 충돌이 발생할 것이라고 비관적으로 가정하는 방식이다. 동시에 데이터에 접근할 수 없도록 잠금을 사용하기 때문에 다른 트랜잭션은 잠금이 풀릴 때까지 대기해야 한다. 따라서 성능적인 이슈가 있을 수 있다. 잠금은 데이터베이스에서 제공하는 배타적 잠금(Exclusive Lock)을 사용한다.
공유 락 (Shared Lock)
다른 공유 락과 호환이 가능하다. 한 데이터에 두 개 이상의 공유 락이 가능하다는 의미이다. 즉 여러 트랜잭션에서 동시에 한 데이터를 읽을 수 있다.
배타적 잠금(Exclusive Lock)
다른 락과 호환되지 않는다. 한 데이터에 하나의 배타적 락만 가능하다는 의미이다. 즉 여러 트랜잭션에서 동시에 한 데이터에 접근(읽기, 쓰기)할 수 없다.
2-3 암시적 잠금 (Implicit Lock)
코드상에 잠금을 명시하지 않아도 잠금이 발생하는 것을 의미한다. JPA에서는 @Version이 붙은 필드가 존재하면 자동적으로 낙관적 잠금이 수행된다. 그리고 대부분의 DB의 경우 업데이트, 삭제 쿼리 시에 암시적으로 해당 Row에 대해 배타적 잠금을 사용한다. JPA의 충돌 감지가 역할을 할 수 있는 것도 DB의 암시적 잠금이 있기 때문이다. 암시적 잠금 과정이 없다면 충돌 감지를 통과한 후 커밋이 실행되는 사이에 틈이 생겨 충돌 감지의 정합성을 보장할 수 없을 것이기 때문이다.
2-4 명시적 잠금 (Explicit Lock)
의도적으로 잠금을 실행하는 것이 명시적 잠금이다. JPA를 통해 엔티티를 지정할 때 LockMode를 지정하거나 select for update 쿼리를 통해서 해당 Row 데이터에 배타적 작금을 수행할 수 있다.
JPA 비관적 잠금 모드를 사용하면 실제로 select for update 쿼리가 발생하는 것을 확인할 수 있다
2-5 낙관적 잠금의 Version 관리
모든 문제 상황은 말했듯이 트랜잭션의 충돌에서 시작한다. 낙관적 잠금은 트랜잭션이 충돌하지 않을 것이라고 가정하고 설계된 방식이기 때문에 최초의 트랜잭션을 제외하고 나머지 트랜잭션은 모두 실패하는 방식으로 동작한다. 이렇게 동작할 수 있는 이유는 Version을 관리하기 때문인데, 각 트랜잭션은 조회 시 확인한 엔티티의 Version과 커밋 시 확인한 Version이 동일하다면 Version을 1 증가시키고 커밋을 수행한다. 만약 Version이 다르다면 다른 트랜잭션이 이미 엔티티를 업데이트했다고 판단하여 OptimisticLockException을 발생시킨다.
2-6 낙관적 잠금이 문제를 해결해 줄 수 있을까?
사실 여기까지 읽어보았다면 낙관적 잠금은 내 모든 요구사항을 충족시켜 줄 수 없음이 예상된다. 왜냐하면 한 자리가 남았을 경우 중복된 요청들 중에서 하나의 요청만 허용하여 원하는 결과가 나올 것으로 보이는 반면, 두 자리 이상 남았을 경우 한 자리만 채워진 채 남은 요청들은 무시되어 클라이언트들은 요청을 다시 보내야 하는 불편함을 야기할 것으로 보인다.
2-7 그럼 비관적 잠금을 고려해보자
내가 설계한 모임 엔티티의 경우 fullFlag라는 필드는 모집 인원이 모두 가득 찬 경우 true로 설정되는 필드이다. 만약 한 자리가 남았을 때 5명이 가입 요청을 한다면 5명이 동시에 데이터를 읽고 동시에 fullFlag를 갱신하려고 할 것이다. 이 fullFlag 데이터 갱신에 충돌이 발생할 것이라고 비관적인 예상이 가능해진다.
그럼 비관적 잠금을 적용하면 문제가 해결될까? 아마도 그럴 것이다. 한 자리 또는 두 자리 이상 남은 경우에도 트랜잭션 하나씩 데이터에 대한 접근이 가능하기 때문에 남은 자리 수만큼만 트랜잭션이 순차적으로 반영되고 이후 트랜잭션은 fullFlag에 의해서 커밋에 실패할 것이다.
3. 코드를 보자
Meeting Entity는 단순하게 fullFlag가 있다는 것 정도만 알면 될 듯하다.
@Builder.default
private Boolean fullFlag = false;
가장 중요한 가입 요청을 처리하는 로직이다. 로직의 순서는 다음과 같다.
- 가입을 요청한 모임의 ID가 유효한지 확인
- 모임의 모집 인원이 이미 가득 찼는지 fullFlag를 통해서 확인
- 모임에 이미 참여하고 있는지 확인
- 위 세 개의 유효성 검사를 통과하면 MemberMeeting을 저장하여 가입을 승인
- 이번 요청으로 모집 인원이 가득 찬 경우 fullFlag를 true로 설정
위 모든 과정은 하나의 트랜잭션에서 이루어져야 한다. 만약 과정이 나뉘어 있다면 중간에 트랜잭션이 끝나고 배타적 잠금이 풀려 다른 트랜잭션이 모임 데이터에 접근하게 되고 그로 인해 예상치 못한 결과가 발생할 수도 있기 때문이다.
@Override
@Transactional
public void checkJoinRequestValid(Long meetingId, Long memberId) {
Optional<Meeting> optionalMeeting = meetingRepository.findMeetingByIdWithLock(meetingId);
if(optionalMeeting.isEmpty()) {
throw new BusinessException(ErrorCode.MEETING_NOT_FOUND);
}
Meeting meeting = optionalMeeting.get();
if(meeting.isFull()) {
throw new BusinessException(ErrorCode.MAX_PARTICIPANTS);
}
Set<MemberMeeting> participants = meeting.getParticipants();
boolean alreadyJoin = participants.stream().anyMatch(M -> Objects.equals(M.getMemberId(), memberId));
if(alreadyJoin) {
throw new BusinessException(ErrorCode.ALREADY_JOIN);
}
memberMeetingRepository.save(MemberMeeting.of(memberId, meetingId));
if(participants.size() + 1 == meeting.getMemberLimit()) {
meeting.setFullFlag(true);
}
}
위 코드에서 findMeetingByIdWithLock 메서드는 다음과 같다. 이곳에서 비관적 잠금을 명시해준다.
(LockModeType에 대한 설명은 따로 정리할 예정이다)
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select m from Meeting as m where m.id = :id")
Optional<Meeting> findMeetingByIdWithLock(@Param("id") Long id);
그리고 지금까지 작성한 코드들의 로직을 검사하는 ConcurrencyTest는 다음과 같다.
@DisplayName("모임 가입 동시성 이슈 테스트")
@Test
void concurrencyTest() throws InterruptedException {
//given
final int PARTICIPANT_PEOPLE = 5;
final int MAXIMUM_PEOPLE = 2;
// 가입 요청을 받을 모임을 미리 저장한다.
Meeting meeting = meetingRepository.save(Meeting.builder()
.ownerId(0L)
.title("ConcurrencyTest")
.content("ConcurrencyTest")
.memberLimit(MAXIMUM_PEOPLE)
.category(Category.HEALTH)
.build());
for(int i = 1; i < 11; i++) {
memberRepository.save(Member.builder()
.id((long) i)
.build()
);
}
log.info("[모임 및 멤버 저장 완료]");
// 병렬 스레드가 모두 완료될 때 까지 기다리도록 만들기 위해 사용한다.
CountDownLatch countDownLatch = new CountDownLatch(PARTICIPANT_PEOPLE);
AtomicInteger memberCount = new AtomicInteger(1);
List<ParticipateWorker> workers = Stream
.generate(() -> new ParticipateWorker(meeting.getId(), (long) memberCount.getAndIncrement(), countDownLatch))
.limit(PARTICIPANT_PEOPLE)
.collect(Collectors.toList());
//when
log.info("[스레드 시작]");
workers.forEach(W -> new Thread(W).start());
countDownLatch.await();
//then
List<MemberMeeting> memberMeetings = memberMeetingRepository.findALlByMeetingId(meeting.getId());
long memberMeetingCount = memberMeetings.size();
assertThat(memberMeetingCount).isEqualTo(MAXIMUM_PEOPLE);
}
private class ParticipateWorker implements Runnable {
private final Long meetingId;
private final Long memberId;
private final CountDownLatch countDownLatch;
public ParticipateWorker(Long meetingId, Long memberId, CountDownLatch countDownLatch) {
this.meetingId = meetingId;
this.memberId = memberId;
this.countDownLatch = countDownLatch;
}
@Override
public void run() {
try {
// 검증하고자 하는 비즈니스 로직
meetingInquiryService.checkJoinRequestValid(meetingId, memberId);
}
catch (Exception e) {
// Exception을 logging
log.warn("*** [" + e.getClass().getSimpleName() + "]: " + e.getMessage());
}
finally {
// Exception에 의해서 CountDown되지 않으면 무한 대기 상태에 빠질 수 있음
// countDownLatch.await(); 에 Timeout을 적용해도 된다.
countDownLatch.countDown();
}
}
}
4. 테스트 결과
비관적 잠금이 적용되어 meeting을 조회할 때 select for update 쿼리가 나가는 모습이다.
빈자리가 두 자리였기 때문에 5개의 요청 중 3개의 요청은 다음과 같이 커스텀하게 정의해둔 BusinessException이 발생했다.
테스트 결과도 성공
'JPA' 카테고리의 다른 글
OneToOne 정리 (0) | 2022.08.29 |
---|---|
JPA fetch join 대상의 별칭 사용과 주의사항 (0) | 2022.08.16 |
fetch join과 paging 시 OOM 문제 (OneToOne은 괜찮지) (0) | 2022.08.10 |
JPA에서 발생할 수 있는 문제들과 해결방법 정리 (N + 1, 무작위 JOIN..) (0) | 2022.08.09 |
JOIN vs Fetch JOIN with JPA (0) | 2022.06.24 |