프로젝트를 진행하며, 세미나에 인원 수에 따라서 선착 순으로 신청 하는 기능을 만들고 있습니다.
이떄, 멀티스레드를 통해 여러 사람이 거의 동시에 신청할경우의 환경에서 테스트를 진행할시 동시성 문제가 발견하게 되었고, 해당사항을 비관적 락( Pessimistic lock )으로 해결한 과정을 기록해보려고 합니다.
동시성 오류가 발생했을떄 해결방안으로 알아본 키워드입니다.
- Syncrhonized 키워드
- 낙관적 락 ( Optimistic Lock )
- 비관적 락( Pessimistic lock )
- 번외. 분산락
- 1. Syncrhonized
- Synchronize는 다른 작업이 끝날때까지 대기하다가 해당 작업이 끝난뒤 바로 실행하도록하는 Java의 키워드입니다.
- 이 경우, 하나의 프로세스 내에서의 쓰레드의 작업의 순서를 보장할 수 있기에 여러개의 프로세스가 존재할경우 작업의 순서를 보장하지 못합니다. --> 즉, 분산서버환경에서 활용이 불가합니다.
- 2. 낙관적 락 ( Optimistic Lock )
- 낙관적 락이라는 이름처럼, 락이 발생할 것을 낙관적으로 생각하여 락이 기본적으로 발생하지 않는다고 가정합니다.
- 낙관적 락은 동시성 충돌을 예방하는것이 아닌 충돌이 발생했을때 해결하는 방안입니다.
- 낙관적 락은 기본적으로 Version 이라는 변수를 활용합니다.
- 트랜잭션1이 시작하고, 쓰레드A가 엔티티 1개를 Version=1으로 조회했다.
- 동시에 트랜잭션2가 시작하고, 쓰레드B가 동일한 엔티티 1개를 Version=1으로 조회하고 먼저 수정했다. 즉, Commit 완료했다. 그러면서 Versio=2 로 증가한다. (동시에 했기에 쓰레드B가 우연히 먼저 수정했다.)
- 트랜잭션1을 실행하고 있는 쓰레드 A에서 해당 엔티티를 Commit해서 처리할려고 한다. 하지만 Version=2 다. 트랜잭션1은 처음에 Entity를 수정하려고하였을때 Version1을 가지고서 시작하였는데 Version2로 바뀌었다는 의미는 이미 해당 엔티티를 다른 트랜잭션이 수정한것이다. 즉, Commit할 수 없다.
- 위의 예시를 통해 낙관적 락은 동시성 충돌을 예방하는것이 아닌, 충돌이 발생했을때 중단시킨다.
- 일반적으로 운영체에에서 Semaphore를 활용하여 Race Condition을 예방하는 것처럼 하는것이 아니다.
- 락을 사용할 Entity에 @Version을 선언해줍니다.
- 분산서버이면서 단일DB인 경우에만 사용할 수 있습니다. DB의 본연의 Lock 기능을 활용하기에 그렇습니다.
- 충돌이 발생했을때 중단시키므로 재시도 처리를 따로 처리해야합니다. 물론, 예외처리도 같이 진행합니다.
- 3. 비관적 락( Pessimistic lock )
- 비관적 락은, 락이 발생할 것을 비관적으로 생각하여 락이 반드시 발생한다고 가정합니다.
- 이 부분이 가장 안전성이 높다고 느껴져서 도입하고 싶었습니다.
- 동시성 충돌을 예방합니다. 데이터에 액세스 하기전에 먼저 락을 걸어 충돌을 예방합니다.
- 비관적 락은 JPA가 아닌 DB에서 제공하는 기능으로써, SELECT...FOR UPDATE, 혹은 SELECT...FOR SHARE 쿼리를 사용하여 구현됩니다. 이를 통해 특정 레코드나 특정 범위의 레코드에 대한 락을 설정할 수 있습니다.
- 단일, 분산서버이면서 단일DB인 경우에만 사용할 수 있습니다. DB의 본연의 Lock 기능을 활용하기에 그렇습니다.
- 비관적 락은, 락이 발생할 것을 비관적으로 생각하여 락이 반드시 발생한다고 가정합니다.
- 번외. 4. 분산락 ( Example : Redis, Redisson)
- 단일, 분산 서버이면서 분산DB 인경우에도 사용할 수 있습니다.
구현과정을 담아보겠습니다.
1. DB테이블 구조입니다.
1-1. Member.class : 회원 Entity 입니다.
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString(exclude = {"member_role_set", "member_seminar_list"})
public class Member extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_no")
private Long member_no;
@Column(length = 100, nullable = false, unique = true, name = "member_id")
private String member_id;
@Column(length = 100, nullable = false)
private String member_password;
@Column(length = 100, nullable = true)
private String member_nickname;
@Column(nullable = false)
private boolean member_from_social;
@Column()
private LocalDateTime del_dt;
@OneToMany(mappedBy = "member")
private List<Member_Seminar> member_seminar_list;
public Member(long member_no) {
this.member_no = member_no;
}
}
1-2. Seminar.class : 세미나 Entity입니다.
이번 테스트에서 선착 순 인원으로 사용될 변수는 seminar_maxParticipants 입니다.
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString(exclude = {"member_seminar_list"})
public class Seminar extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long seminar_no;
@Column(length = 100, nullable = false, unique = true)
private String seminar_name;
@Column(length = 500)
private String seminar_explanation;
@Column
private Long seminar_price;
@Column
private Long seminar_maxParticipants;
@Column(nullable=true)
private LocalDateTime del_dt;
@OneToMany(mappedBy = "seminar", fetch = FetchType.LAZY)
private List<Member_Seminar> member_seminar_list;
@Version //만약 낙관적 락 사용할시
private Integer version; //만약 낙관적 락 사용할시
}
1-3. Member_Seminar.class : 회원이 세미나에 등록한 정보입니다.
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString(exclude = {"member", "seminar"})
public class Member_Seminar extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long member_seminar_no;
@ManyToOne(
targetEntity = Member.class,
fetch = FetchType.LAZY
)
@JoinColumn(name = "member_no")
private Member member;
@ManyToOne(
targetEntity = Seminar.class,
fetch = FetchType.LAZY
)
@JoinColumn(name = "seminar_no")
private Seminar seminar;
@OneToOne
private Payment payment;
@Column(nullable=true)
private LocalDateTime del_dt;
}
2. 처음에 Lock없이 구현해서 동시성을 제어하지 못한 코드입니다.
- 기본적인 로직은 다음과 같습니다.
- 1. seminar_participant_cnt : 신청하려는 세미나의 신청 인원 수를 구합니다.
- 2. maxParticipant_cnt : 신청하려는 세미나의 최대 인원제한 수를 구합니다.
- 3. seminar_participant_cnt < maxParticipant_cnt : 현재 신청하려는 세미나의 인원 수가 아직 최대 인원제한 수보다 작아서 신청가능하다면, 신청합니다.
SeminarQuerydslRepository.class
@Transactional
public void participateOnSeminar(Long member_no, Long seminar_no){
QSeminar seminar = QSeminar.seminar;
//신청시 인원이 몇명인지 확인하고 각 세미나의 maxParticipant가 넘는지 안넘는지 확인한다.
QMember_Seminar member_seminar = QMember_Seminar.member_Seminar;
Long seminar_participnat_cnt = queryFactory.select(member_seminar.count())
.from(member_seminar)
.where(member_seminar.seminar.seminar_no.eq(seminar_no)
.and(member_seminar.del_dt.isNull()))
.fetchOne();
System.out.println("seminar_participant_cnt:"+seminar_participnat_cnt);
Long maxParticipant_cnt = (long) 0;
maxParticipant_cnt = queryFactory.select(seminar.seminar_maxParticipants)
.from(seminar)
.where(seminar.seminar_no.eq(seminar_no)
.and(seminar.del_dt.isNull()))
.fetchOne();
System.out.println("maxParticipant_cnt:"+maxParticipant_cnt);
if(seminar_participnat_cnt < maxParticipant_cnt){ //인원이 더적다면 member_seminar에 넣는다.
Optional<Member> memberEntity = memberRepository.findByMember_no(member_no);
Optional<Seminar> seminarEntity = seminarRepository.findBySeminar_no(seminar_no);
Member_Seminar member_seminarEntity = Member_Seminar.builder()
.member(memberEntity.get())
.seminar(seminarEntity.get())
.build();
member_SeminarRepository.save(member_seminarEntity);
}else{
System.out.println("Max_Participant Over");
}
}
위의 코드를 테스트해보기 위한 테스트코드를 작성해보겠습니다.
SeminarRepositoryTests.class
@DisplayName("testParticipateOnSeminarWithoutLock")
@Test
public void testParticipateOnSeminarWithoutLock() throws InterruptedException {
Long member_no = 1L;
Long seminar_no = 2000028L;
final int executeNumber = 100;
final ExecutorService executorService = Executors.newFixedThreadPool(40);
final CountDownLatch countDownLatch = new CountDownLatch(executeNumber);
for(int i=0;i<executeNumber;i++){
executorService.execute( () -> {
try{
seminarQuerydslRepository.participateOnSeminarWithPESSIMISTIC_WRITE(member_no, seminar_no);
} catch(Exception e){
System.out.println(e.getMessage());
}finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
}
- 100번을 Thread 40개로 동시 실행시킬 것 입니다.
아래의 SQL 코드로 먼저 해당 값이 0 개인지 확인합니다.
이제 위의 테스트코드를 실행시켜봅니다.
40개의 Thread로 총 100번을 실행합니다.
우리가 설정한 Seminar의 최대 값(seminar_maxParticipants) = 40 으로 설정했습니다.
아래의 결과값은 46이 나옵니다. 40명이 최대인원수인데 46명이 신청함으로써 6명이 더 신청하였습니다.
추가로 로그도 함꼐 확인해보겠습니다.
로그를 확인해보면 각 Thread number가 seminar_participant_cnt, 즉 현재 20000028 번 Seminar에 신청한 수가 20, 20, 21 이 있을떄 똑같이 20 20 인경우가 있습니다.
이유는, Member_Seminar 값이 업데이트 되기 이전에 Count 쿼리가 실행되었기 떄문입니다.
이 사항을 비관적 Pessimistic Lock 을 통해 Seminar에 대한 Lock이 종료되기 이전까지는 Seminar_cnt에 접근할 수 없도록 수정해보겠습니다.
실행시간은 687 ms입니다. 이 시간은 이후에 비관적 락의 시간과 비교해볼 것 입니다.
3. 비관적 락을 통해 동시성을 제어합니다.
- 기본적인 로직은 다음과 같습니다.
- 1. seminarEntityLock : Seminar Entity를 가져오고 해당 Entity에 Lock을 걸어줍니다. 이 Entity의 Lock이 해제되는 시점은 @Transactional로 묶여준 하나의 작업이 끝나는 시점입니다.
- 2. seminar_participant_cnt : 신청하려는 세미나의 신청 인원 수를 구합니다.
- 3. maxParticipant_cnt : 신청하려는 세미나의 최대 인원제한 수를 구합니다.
- 4. seminar_participant_cnt < maxParticipant_cnt : 현재 신청하려는 세미나의 인원 수가 아직 최대 인원제한 수보다 작아서 신청가능하다면, 신청합니다.
- 조금더 Thread적인 개념에서 설명해보겠습니다.
- 1. 스레드 A가 participateOnSeminarWithPESSIMISTICLock 메서드 호출합니다.
- 2. 스레드 B는 동일한 seminar_no에 대한 PESSIMISTIC_WRITE 락을 요청하려고 시도합니다.
- 3. 스레드 B는 스레드 A가 락을 해제할 때까지 대기하며, 다른 스레드도 동일한 방식으로 락을 요청하고 대기합니다.
- 4. 스레드 A가 작업을 완료하고 락을 해제합니다. 이때 @Transactional로 묶여진 작업이 끝나는 시점이 스레드 A가 작업을 완료하는 시점입니다.
- 5. 다른 스레드들이 락을 얻고 동시에 작업을 진행합니다/
추가로, LockModeType이 PESSIMISTIC_WRITE 를 설정합니다. 이를 통해 특정 엔티티에 대해 쓰기/수정 작업을 하려는 다른 쓰레드를 차단시키는 역할입니다.
SeminarQuerydslRepository.class
@Transactional
public void participateOnSeminarWithPESSIMISTICLock(Long member_no, Long seminar_no){
QSeminar seminar = QSeminar.seminar;
//신청시 인원이 몇명인지 확인하고 각 세미나의 maxParticipant가 넘는지 안넘는지 확인한다.
QMember_Seminar member_seminar = QMember_Seminar.member_Seminar;
// 세미나 레코드를 PESSIMISTIC_WRITE 락으로 가져옵니다.
Seminar seminarEntityLock = queryFactory.selectFrom(seminar)
.where(seminar.seminar_no.eq(seminar_no)
.and(seminar.del_dt.isNull()))
.setLockMode(LockModeType.PESSIMISTIC_WRITE) // 비관적 락 설정
.fetchOne();
if (seminarEntityLock != null) {
Long seminar_participant_cnt = queryFactory.select(member_seminar.count())
.from(member_seminar)
.where(member_seminar.seminar.seminar_no.eq(seminar_no)
.and(member_seminar.del_dt.isNull()))
.setLockMode(LockModeType.PESSIMISTIC_WRITE)
.fetchOne();
Long maxParticipant_cnt = seminarEntityLock.getSeminar_maxParticipants();
System.out.println("seminar_participant_cnt: " + seminar_participant_cnt);
System.out.println("maxParticipant_cnt: " + maxParticipant_cnt);
if (seminar_participant_cnt < maxParticipant_cnt) {
Optional<Member> memberEntity = memberRepository.findByMember_no(member_no);
Optional<Seminar> seminarEntity = seminarRepository.findBySeminar_no(seminar_no);
if (memberEntity.isPresent() && seminarEntity.isPresent()) {
Member_Seminar member_seminarEntity = Member_Seminar.builder()
.member(memberEntity.get())
.seminar(seminarEntity.get())
.build();
member_SeminarRepository.save(member_seminarEntity);
}
} else{
System.out.println("bigger than MAX_Participant ");
}
}
}
SeminarQuerydslRepository.class
@DisplayName("testParticipateOnSeminarWithPessimisticLock")
@Test
public void testParticipateOnSeminarWithMultiThread() throws InterruptedException {
Long member_no = 1L;
Long seminar_no = 2000028L;
final int executeNumber = 200;
final ExecutorService executorService = Executors.newFixedThreadPool(40);
final CountDownLatch countDownLatch = new CountDownLatch(executeNumber);
for(int i=0;i<executeNumber;i++){
executorService.execute( () -> {
try{
seminarQuerydslRepository.participateOnSeminarWithPESSIMISTICLock(member_no, seminar_no);
} catch(Exception e){
System.out.println(e.getMessage());
}finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
}
똑같이 테스트를 진행합니다.
아래의 SQL 코드로 먼저 해당 값이 0 개인지 확인합니다.
이제 위의 테스트코드를 실행시켜봅니다.
40개의 Thread로 총 100번을 실행합니다.
우리가 설정한 Seminar의 최대 값(seminar_maxParticipants) = 40 으로 설정했습니다.
아래의 결과값은 40이 나옵니다. 40명이 최대인원수인데 40명이 신청함으로써 동시성이 제어되었습니다.
이번에도 로그를 확인해보겠습니다.
아까 비관적 락을 사용하기 전에는 seminar_participant_cnt가 같은값인 경우가 있어서, 올바르게 동시성이 제어되지 않았습니다.
하지만, 이번 로그를 살펴보면 MultiThread로 다량의 요청을 동시에 한다고해도 Lock을 지켰기에 각 Transaction이 끝나는 시점, 즉 Lock이 풀리는 지점에만 Thread가 Lock을 다시 얻어 시작함으로써 동시성을 제어할 수 있었습니다.
하지만 이와 같은 방식에는 시간이 더 걸린다는 단점이 있습니다.
실행시간을 확인해보면, 1 sec 284 ms가 걸립니다.
위의 실행시간으로도 확인할 수 있듯이
비관적 락을 사용 안할시에는 약 700ms,
비관적 락을 사용할시에는 약 1280 ms 가 걸립니다.
동시성을 제어하는 대신에 시간은 더 걸립니다.
낙관적락과 비관적락을 언제 적용하는게 좋을까 개인적 생각
낙관적 락을 사용하는 경우가 성능이 훨씬 빠를 것이라고 생각이 듭니다. 하지만 낙관적 락에는 재시도 처리와 예외처리를 해야하는것과 같은 코드 복잡도를 높일 수 있는 작업이 필요합니다.
비관적 락을 사용하면 멀티스레드를 활용하는것의 장점이 사라지기 때문입니다.
위의 예시에서 40개의 멀티스레드가 동시에 요청했을떄 1sec 280이 걸렸기에 상당히 많은 시간이 걸렸습니다.
어떤 상황에서 낙관적 락을 사용하는게 좋을까 생각해보면,
쇼핑몰이 존재한다고 해보겠습니다.
낙관적 락을 사용하면 좋은경우는,
- 소량이 아닌 약 1,000,000개의 수량이 주어진 물품을 팔면서 많은 사람들이 경쟁하듯 살려고 하지 않는다면 낙관적 락을 사용합니다.
비관적 락을 사용하면 좋은 경우는,
- 대량이 아닌 약 1,000 개의 수량이 주어진 물품을 매우 많은 사람들이 경쟁하면서 산다면 비관적 락을 사용합니다.
만약, 제가 개발자라면, 수량이 많을때는 낙관적 락, 일정 수준 이하로 수량이 줄어든다면 비관적락을 선별하여 적용해볼것 같습니다.
만약 1,000,000 개의 수량이 주어졌다고 가정합니다. 1,000,000개부터 5,000개까지는 낙관적 락을 사용하고, 약 5,000개 정도의 수량이 남았을떄부터는 비관적 락을 사용하도록 설계하지 않을까 싶습니다.