일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 |
- 구글 OAuth login
- TiL
- 개발자부트캠프추천
- DesignPattern
- 프로그래머스 이중우선순위큐
- 단기개발자코스
- 코딩테스트 준비
- spring batch 5.0
- 커스텀 헤더
- Python
- JavaScript
- Spring multimodule
- 99클럽
- 인프콘 2024
- jwt
- 개발자 취업
- 항해99
- @FeignClient
- 1주일회고
- 취업리부트코스
- 빈 충돌
- 프로그래머스
- 디자인패턴
- jwttoken
- 디자인 패턴
- KPT회고
- 전략패턴 #StrategyPattern #디자인패턴
- infcon 2024
- 파이썬
- 빈 조회 2개 이상
- Today
- Total
m1ndy5's coding blog
Ditto 프로젝트 동시성 문제 해결하기 2편 - 낙관적 락을 사용해 동시성 해결하기 (feat. Deadlock, DirtyChecking, Retry) 본문
Ditto 프로젝트 동시성 문제 해결하기 2편 - 낙관적 락을 사용해 동시성 해결하기 (feat. Deadlock, DirtyChecking, Retry)
정민됴 2024. 2. 24. 20:39Lock이란 Lock은 다 찾아보게 만든 동시성 이슈 해결이 완료됐다...ㅋㅋㅋㅋㅋㅋ
오히려 좋아 이해가 잘됐어(?)ㅋㅋㅋㅋㅋㅋ
동시성 이슈 해결을 위한 긴 여정 레츠고.
먼저 나의 엔티티들을 소개하겠다!
package org.example.domain;
import jakarta.persistence.Version;
import org.example.global.entity.BaseEntity;
import org.example.global.entity.UsageStatus;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import lombok.*;
import org.hibernate.annotations.ColumnDefault;
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post extends BaseEntity {
@Column(name="member_id", nullable = false)
private Long memberId;
@Column(name = "member_name", nullable = false)
private String memberName;
@Column(name = "stock_id", nullable = false)
private Long stockId;
@Column(name = "title", nullable = false)
private String title;
@Column(name = "content", nullable = false)
private String content;
@Column(name = "likes", nullable = false)
@ColumnDefault("0")
private long likes;
@Column(name = "views", nullable = false)
@ColumnDefault("0")
private long views;
@Version
private Long version;
@Builder
private Post(Long memberId, String memberName, Long stockId, String title, String content){
this.memberId = memberId;
this.memberName = memberName;
this.stockId = stockId;
this.title = title;
this.content = content;
this.status = UsageStatus.ACTIVE;
}
public void updatePost(String title, String content){
this.title = title;
this.content = content;
}
public void deletePost(){
this.status = UsageStatus.DELETED;
}
public void addView(){ this.views += 1;}
public void addLike() {this.likes += 1;}
public void subLike() {this.likes -= 1;}
}
좋아요 수(likes) 컬럼을 가진 Post Entity다.
얘가 동시성 이슈를 일으키는 로직을 가지고 있기 때문에 이 엔티티에다가 낙관적 락을 걸것이다!
따라서 version 컬럼을 추가해 update할 수 있는 상태인지 아닌지를 확인해볼 것이다.
package org.example.domain;
import jakarta.persistence.*;
import org.example.global.entity.BaseEntity;
import org.example.global.entity.UsageStatus;
import lombok.*;
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PostLike extends BaseEntity {
@Column(name = "member_id", nullable = false)
private Long memberId;
@Column(name = "post_id", nullable = false)
private Long postId;
@Builder
public PostLike(Long memberId, Long postId){
this.memberId = memberId;
this.postId = postId;
this.status = UsageStatus.ACTIVE;
}
public void deletePostLike(){
this.status = UsageStatus.DELETED;
}
public void pushPostLike(){
this.status = UsageStatus.ACTIVE;
}
}
어떤 게시글에 누가 좋아요를 눌렀는지 저장하는 Entity이다.
여기서 확인해야하는 것!! (밑에서 설명할 예정)
원래는 Long postId가 아닌 @ManyToOne Post post 로 매핑해놨고 DeadLock 발생으로 인해 굳이 필요없는 연관관계는 없애주었다.
Post Entity에서 낙관적 락을 걸 것이기 때문에 PostRepository에 아래와 같은 코드를 작성했다.
@Lock(LockModeType.OPTIMISTIC)
@Query("select p from Post p where p.id = :id")
Optional<Post> findByIdWithLock(@Param("id") Long id);
@Lock(LockModeType.OPTIMISTIC) 사용해서 낙관적 락을 걸어주었다.
@Lock에는 아래와 같은 옵션이 있고 원하는 대로 골라 사용하면 된다.
OPTIMISTIC : 트랜잭션 시작 시 버전 점검 수행, 트랜잭션 종료 시에도 버전 정검이 수행
OPTIMISTIC_FORCE_INCREMENT : 커밋 직전에 추가로 버전을 강제 증가, 변경 사항이 있는 경우 버전 증가가 두번 될 수 있기 때문에 유의
PESSIMISTIC_READ : 다른 트랜잭션에게 읽기만을 허용, Shared Lock을 이용해 락을 걺
PESSIMISTIC_WRITE : 다른 트랜잭션에서 쓰지도 읽지도 못함
PESSIMISTIC_FORCE_INCREMENT : Row Exclusive Lock을 이용해 잠금을 걺과 동시에 버전 증가
이제 동시성 이슈가 나는지 안나는지 테스트 코드를 작성해 실험해 보겠다!
package org.example.service;
import org.assertj.core.api.Assertions;
import org.example.ActivityApplication;
import org.example.domain.FollowRepository;
import org.example.domain.Post;
import org.example.domain.PostLikeRepository;
import org.example.domain.PostRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ContextConfiguration;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
@ContextConfiguration(classes = ActivityApplication.class)
class PostServiceImplTest {
@Autowired
private PostService postService;
@Autowired
private PostRepository postRepository;
@Autowired
private PostLikeRepository postLikeRepository;
@Autowired
private FollowRepository followRepository;
@AfterEach
void tearDown() {
postLikeRepository.deleteAllInBatch();
postRepository.deleteAllInBatch();
}
@DisplayName("동시성 테스트 - 낙관적 락")
@Test
void concurrencyOptimisticLockTest() throws InterruptedException {
// given
Post post = Post.builder()
.title("낙관적 락 검사")
.memberName("민성")
.stockId(1L)
.content("낙관적 락이 잘 먹힐까요~?")
.memberId(1L)
.build();
Post savedPost = postRepository.save(post);
// when
int count = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(count);
for (int i = 1; i <= count; i++){
int finalI = i;
executorService.execute(() -> {
postService.pushPostLike((long) finalI, savedPost.getId());
latch.countDown();
});
}
latch.await();
// then
Post post1 = postRepository.findById(savedPost.getId()).get();
Assertions.assertThat(post1.getLikes()).isEqualTo(count);
}
}
거의 동시에 100번을 요청했을 때 과연 좋아요 개수가 100개인지를 확인하는 코드이다.
근데 웬걸 CannotAcquireLockException, LockAcquisitionException, MySQLTransactionRollbackException이 발생하면서 Deadlock found when trying to get lock; try restarting transaction 이런 오류메세지가 뜨고 Deadlock이 발생했다ㅠㅠ
첫 번째 문제 : Deadlock 발생
왜 발생하는지 이유를 몰라서 이 부분만 몇시간을 찾아서 해맨 것같다..ㅎㅎㅎ
결국 데드락이 발생했던 이유는 외래키 때문이었다.
Deadlock : 둘 이상의 프로세스가 다른 프로세스가 점유하고 있는 자원을 서로 기다릴 때 무한 대기에 빠지는 것
외래키 : 두 테이블을 서로 연결하는 데 사용되는 키로 외래키가 포함된 테이블은 자식 테이블, 외래키 값을 제공하는 테이블은 부모 테이블
외래키는 부모테이블이나 자식 테이블에 데이터가 있는지 체크하는 작업이 필요하기 때문에 잠금이 여러 테이블로 전파되고 그로 인해 데드락이 발생할 수 있어 실무에서는 잘 사용하지 않는다.
예전에 스치듯이 실무에서는 외래키를 잘 사용하지 않는다는 말을 들은 적이 있었는데 그 때는 엥?? 외래키를 사용안하면 어떻게 조인해?? 했던 기억이 있었는데 이 때문이었다,,,
그렇다면 외래키가 어떤식으로 데드락을 일으키는지 확인해보자!
If a FOREIGN KEY constraint is defined on a table, any insert, update, or delete that requires the constraint condition to be checked sets shared record-level locks on the records that it looks at to check the constraint. InnoDB also sets these locks in the case where the constraint fails.
즉 fk가 있는 테이블에서, fk를 포함한 데이터를 insert, update, delete하는 쿼리는 제약조건을 확인하기 위해 shared lock(s-Lock)을 설정한다고 한다.
s-Lock : 공유 락이라고 하며, 데이터를 읽을 때 사용. 다른 s-Lock과는 동시에 설정할 수 있지만 x-Lock과는 동시 사용 불가
-> 여러 트랜잭션에서 동시에 하나의 데이터를 읽을 순 있지만 변경중인 리소스를 동시에 읽을 수는 없다.
x-Lock : 배타적 락이라고 하며, 데이터를 변경할 때 사용. 다른 Lock들과 동시 사용 불가 -> 한 리소스에 하나의 x-Lock만 설정 가능
-> x-Lock은 동시에 여러 트랜잭션이 한 리소스에 엑세스할 수 없게 된다.(읽기도 안됨), 하나의 트랜잭션만 해당 리소스 점유 가능
낙관적 락 같은 경우 두 단계로 이루어진다.
1. 읽기 단계(Read Phase) : 데이터를 읽어올 때 충돌을 감지하기 위해 공유 락 사용
2. 업데이트 단계(Update Phase) : 충돌이 감지되지 않으면 데이터를 업데이트하기 위해 x-lock을 사용
나의 경우에 PostLike에서 Post을 외래키로 가지고 있었다.
1. poskLike를 저장할 때 Post에 s-Lock을 건다.(같은 리소스에 s-lock은 상관없기 때문에 읽기 단계에서는 데드락 발생 x)
2. Post의 좋아요 개수를 1 업하는 update 가 발생해야하고 x-Lock을 걸어야하는데 s-Lock이 걸려있기 때문에 기다린다.
3. Lock의 종료시점은 보통 트랜잭션이 커밋되거나 롤백될 때이기 때문에 계속 x-Lock을 걸지 못하게 되고 결국 데드락이 발생하게 되는 것이다.
이 외에도 다양한 경우에 데드락 발생이 되겠지만 나의 경우에는 이런 문제 때문에 데드락이 발생했다.
따라서 맨 위에 올렸던 것처럼 굳이 매핑이 되어있을 이유가 없었기 때문에 일반 컬럼으로 변경해 주었다.
두번째 문제 : 실패한 트랜잭션 재시도 했는데 OptimisticLockingFailureException 발생
낙관적 락은 버전을 확인해서 충돌이 나게 되면 OptimisticLockingFailureException을 발생시키게 되고 개발자가 수동으로 롤백을 시켜줘야하는데 나같은 경우에는 수동으로 롤백하는 코드를 작성해도 예외가 계속해서 터졌다...
package org.example.aop;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Retry {
int maxRetries() default 1000;
long retryDelay() default 100;
}
package org.example.aop;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Slf4j
@Order(Ordered.LOWEST_PRECEDENCE-1)
@Aspect
@Component
public class RetryAspect {
@Around("@annotation(retry)")
public Object doRetry(ProceedingJoinPoint joinPoint, Retry retry) throws Throwable{
Exception exceptionHolder = null;
for (int attempt = 0; attempt < retry.maxRetries(); attempt++){
try{
return joinPoint.proceed();
} catch (Exception e){
exceptionHolder = e;
Thread.sleep(retry.retryDelay());
}
}
throw exceptionHolder;
}
}
@Transactional
@Retry
public PostLikeResponse pushPostLike(Long memberId, Long postId){
맨 위와 같은 Retry라는 어노테이션을 만들고 AOP를 사용하여 @Retry 어노테이션 붙은 곳에서 예외가 발생하게 되면 maxRetries 횟수만큼 다시 시도하게 했다.
@Order(Ordered.LOWEST_PRECEDENCE-1)를 사용하여 @Transactional 직전에 실행될 수 있도록 해주었다.
처음 작성했을 때는 retryDelay를 설정하지 않았고 그 결과 가볍게 maxRetries 횟수를 넘어버린 것이었다....
뇌피셜이긴 하지만 이 이유가 거의 확실한 것같다. (틀리면 알려주세요)
이 덕분에 알게 된 것이 한 개 더 있었는데 바로 더티 체킹을 사용하지 않고 일반 update 쿼리를 날리면 동시성 문제가 발생하지 않는다는 것이었다.
세번째 문제 : 직접 Update 쿼리를 날리면 동시성 문제를 발생시키지 않는다?
처음에 retry의 문제인지 모르고 더티체킹을 쓰지 않고 직접 update쿼리를 사용해서 데이터베이스에 날린 적이 있었는데 동시성 이슈가 발생하지 않았다. 처음엔 더티체킹이 문제인줄 알았지만 직접 update 쿼리를 날리면 동시성 문제가 발생되지 않는 것이었다!
더티 체킹의 동작 방식
1. 엔티티를 로딩한 후 트랜잭션 내에서 엔티티의 필드 값을 변경
2. JPA가 이 변경 사항을 추적하고 트랜잭션이 커밋될 때 데이터베이스에 변경 사항을 자동으로 반영
-> JPA는 엔티티를 로딩할 때 스냅샷을 생성하여 변경 전 상태를 유지하고, 트랜잭션이 커밋되면 entity의 현재 상태와 스냅샷을 비교하여 변경된 필드를 감지하고, 변경된 필드에 대한 업데이트 쿼리를 생성하여 반영하는 것
여기서 궁금했던 점은 아니 결국 더티 체킹도 업데이트 쿼리를 날리는데 왜 얘는 동시성 이슈가 발생하는거야!! 였다.
이 이유는 더티 체킹은 일반적으로 낙관적 동시성 제어를 사용하기 때문에 트랜잭션 내에서 엔티티를 읽고 변경할 때는 락을 사용하지 않고 커밋 시에 충돌을 감지하여 처리하기 때문이었다.
따라서 여러 트랜잭션이 동시에 같은 엔티티를 읽고 수정해도, 실제로 데이터베이스 레벨에서는 락을 걸지 않고 허용한다는 것!
결론 : update 쿼리는 실행이 될 때부터 x-lock이 동작하니까 다른 트랜잭션이 x-lock 풀릴 때까지 기다려야해서 동시성 이슈가 생기지 않지만 더티체킹은 커밋 되기 전까지는 아무런 lock을 걸지 않아서 다른 트랜잭션이 맘대로 접근할 수 있다~ 이런 얘기였다.
많은 시간을 사용해서 조사했던 만큼(?) 많은 지식들을 알게 될 수 있었던 하루였다^_^
하지만 틀린 것이 있을 수도 있으니 혹시 틀린 것을 발견한 사람은 알려주시길!!!!
'Toy Projects > Ditto - Discuss Today's Topic' 카테고리의 다른 글
Ditto 프로젝트 N+1 문제 2편 - N+1 문제 해결과 외래키 (2) | 2024.02.27 |
---|---|
Ditto 프로젝트 N+1 문제 1편 - FetchType Lazy, Eager (0) | 2024.02.26 |
Ditto 프로젝트 동시성 문제 해결하기 1편 - 낙관적 락 VS 비관적 락 (1) | 2024.02.24 |
Ditto Project Spring Batch&Scheduling 2편 - Docker, Jenkins 사용하여 Spring Batch Job 실행하기 (1) | 2024.02.17 |
Ditto Project Spring Batch&Scheduler 1편 - 스프링 배치 5.0, 스케줄러 적용하기 (0) | 2024.02.16 |