이번 게시글에서는 synchnonized, 비관적 락, 낙관적 락, 분산락(named lock)을 사용하여 데드락을 처리해 보도록 하겠습니다.
우선 제가 redis나 zookeeper 등을 사용하여 데드락을 처리하지 않은 이유는 새로 인프라를 추가하게 된다면 개발 환경, 배포 환경 등에 변화가 생기게 되어 이로 인한 side effect가 발생할 수 있기 때문입니다.
제가 진행했던 프로젝트에서는 redis를 사용하지 않았기 때문에 redis를 이용하여 분산락을 처리하지 않고 mysql에서 제공하는 분산락(named lock)을 이용하도록 하겠습니다.
데드락 발생 상황
우선 데드락이 발생하는 상황에 대해서 먼저 설명해 드리도록 하겠습니다.
요구 사항은 유저가 ImageQuiz 문제를 풀고 제출하면 서버에서 이를 채점하여 유저에게는 맞춘 문제만큼의 점수를 지급하고 서버에서는 이 문제를 푼 정보를 등록하여 이후 분석에 활용합니다.
이 요구 사항을 충족하기 위해서 작성한 코드는 아래와 같습니다.
ImageQuizController
@Operation(summary = "ImageQuiz 문제 풀기", description = "푼 ImageQuiz 문제를 제출")
@PostMapping("/solve")
public ResponseEntity<ResponseTemplate<Object>> solveImageQuizzes(
@AuthenticationPrincipal AuthUser user,
@Valid @RequestBody QuizSolvedListRequest request) {
ImageQuizSolveScoreResponse score = imageQuizService.solveImageQuizzes(
request.quizSolvedRequestList(), user.memberId());
return ResponseEntity
.status(HttpStatus.OK)
.body(ResponseTemplate.from(score));
}
ImageQuizService
@Transactional
public ImageQuizSolveScoreResponse solveImageQuizzes(List<QuizSolvedRequest> quizSolvedRequestList,
Long memberId) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new MemberNotFoundException(MEMBER_NOT_FOUND));
return ImageQuizSolveScoreResponse.scoreFrom(
quizSolvedService.solveQuizzes(quizSolvedRequestList, member, IMAGE_QUIZ));
}
QuizSolvedService
public Long solveQuizzes(List<QuizSolvedRequest> quizSolvedRequestList, Member member, QuizType type) {
QuizSolver solver = quizSolverFactory.getSolver(type);
QuizScorer scorer = quizScorerFactory.getScorer(type);
Long score = quizSolvedRequestList.stream()
.map(quizSolvedRequest -> solver.solveQuiz(quizSolvedRequest, member))
.map(solvedQuizInfo -> scorer.addPerQuizScore(solvedQuizInfo, member))
.reduce(0L, Long::sum);
member.updateScore(member.getScore() + score);
return score;
}
ImageQuizSolver, ImageQuizScorer
@Service
@Transactional
@RequiredArgsConstructor
public class ImageQuizSolver implements QuizSolver {
private final ImageQuizRepository imageQuizRepository;
private final QuizSolvedRepository quizSolvedRepository;
/**
* 기존에 풀었던 문제인지에 따라 다르게 처리
*
* @param solvedRequest ImageQuizSolved DTO
* @param member 푼 멤버
* @return Long 점수
*/
@Override
public SolvedQuizInfo solveQuiz(QuizSolvedRequest solvedRequest, Member member) {
ImageQuiz imageQuiz = imageQuizRepository.findById(solvedRequest.quizId())
.orElseThrow(() -> new ImageQuizNotFoundException(IMAGE_QUIZ_NOT_FOUND));
QuizSolved imageQuizSolved = solvedRequest.toQuizSolved(imageQuiz, member);
return solveNewImageQuiz(imageQuizSolved, imageQuiz);
}
private SolvedQuizInfo solveNewImageQuiz(QuizSolved quizSolved, ImageQuiz imageQuiz) {
QuizSolved newQuizSolved = quizSolvedRepository.save(quizSolved);
return !newQuizSolved.getIsCorrect() ? new SolvedQuizInfo(imageQuiz.getQuizCategory(), 0L)
: new SolvedQuizInfo(imageQuiz.getQuizCategory(), Level.getPoint(imageQuiz.getLevel()));
}
}
@Service
@Transactional
@RequiredArgsConstructor
public class ImageQuizScorer implements QuizScorer {
private final QuizScoreRepository quizScoreRepository;
@Override
public Long addPerQuizScore(SolvedQuizInfo solvedQuizInfo, Member member) {
QuizScore quizScore = quizScoreRepository.findByMemberAndQuizCategory(member, solvedQuizInfo.category())
.orElseGet(() -> quizScoreRepository.save(
QuizScore.makeInitQuizScore(member, solvedQuizInfo.category())));
QuizScore updateQuizScore = quizScore.addScore(solvedQuizInfo.score()).addCount();
quizScoreRepository.save(updateQuizScore);
return solvedQuizInfo.score();
}
}
위와 같은 코드에서 동일한 유저 id로 문제 풀이를 동시에 실행할 시에 데드락이 발생합니다.
위 코드가 사용된 서비스는 앱 환경으로 JWT를 활용하고 로그인을 관리합니다. 이때 2개 이상의 핸드폰을 사용하여 문제 풀이를 진행하는 상황을 가정했을 때 데드락이 발생 가능합니다.
우선 데드락이 발생하는 이유를 알기 위해 아래의 선행 지식이 필요합니다.
아래의 mysql 공식 문서에서는 아래와 같은 설명이 있습니다. FK를 가지고 있는 테이블에서 insert, update, delete 연산을 할 때는 제약 조건을 확인하기 위해서 S lock이 record level에 걸린다는 내용입니다.
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.
https://dev.mysql.com/doc/refman/8.0/en/innodb-locks-set.html
데드락이 발생하는 이유는 ImageQuizSolver에서 QuizSolved 엔티티 객체를 테이블에 저장하기 위해서 해당하는 Member row에 FK 검사를 위한 S lock이 걸립니다.
또한 ImageQuizScorer에서 QuizScore 객체를 등록하기 위해서 FK를 확인해야 하기 때문에 Member row에 S lock이 또 발생되게 됩니다.
이러한 상황에서 QuizSolvedService에서 문제를 풀고 난 다음 Member 테이블에 있는 score column에 얻은 점수를 더해주기 위해서 해당 유저의 row에 대한 X lock을 얻어야 합니다.
이때 싱글 쓰레드에서는 문제가 발생하지 않지만 멀티 쓰레드 환경에서 먼저 온 요청을 처리하는 트랜잭션이 끝나기 전에 또 다른 요청이 와서 트랜잭션을 시작하면 문제가 발생합니다.
위 상황에서 어떠한 문제가 발생하는지 그림으로 설명드리도록 하겠습니다.
위 그림은 member1이라는 record에 대한 lock을 얻으려고 하는 상황을 나타낸 그림입니다.
Transaction1이 먼저 실행된 트랜잭션이라고 했을 때 ImageQuizSolver와 ImageQuizScorer에서 각각 member1에 대한 S lock을 가집니다.
이때 조금 더 늦게 요청이 온 Transaction2가 위 과정과 동일하게 member1에 대한 S lock을 얻게 됩니다.
이후 Transaction1이 member1에 대한 X lock을 얻고자 하는데 Transaction2가 해당 record에 대한 S lock을 가지고 있기 때문에 X lock을 얻지 못하고 대기합니다.
이후 Transaction2도 member1에 대한 X lock을 얻고자 하는데 Transaction1에서 이미 해당 record에 대한 S lock을 가지고 있기 때문에 X lock을 얻지 못하고 대기하게 됩니다.
이때 Transaction1과 Transaction2가 둘 다 member1에 대한 작업을 하지 못하고 멈춰 있는 상황이 발생합니다. 이 상황을 데드락이라고 합니다.
데드락 처리 방식
우선 데드락이 발생하는 조건은 4가지가 존재합니다.
- Mutual Exlusion
- No Preemption
- Hold and Wait
- Circular Wait
위의 조건들이 모두 만족할 때 데드락이 발생합니다.
이렇게 데드락이 발생하면 이에 대한 처리를 하지 않는 deadlock ignore 방식을 제외하면 4가지의 처리 방식이 존재합니다.
- Deadlock Prevention
- Deadlock Avoidance
- Deadlock Detection
- Deadlock Recovery
이 방식들에 대해서 간단히 설명드리도록 하겠습니다.
Deadlock Prevention은 위에서 언급한 데드락이 발생한 4가지 조건 중 하나를 막아서 데드락이 아예 발생하지 않게 하는 방식입니다. 이에 대한 예시로 Hold and Wait이라는 조건을 막기 위해서 timeout을 두어 일정 기간 동안 아무 동작이 없으면 가지고 있는 lock을 모두 풀 수 있습니다.
Deadlock Avoidance는 해당 자원을 점유하면 데드락이 발생하는지 계속 검사하여 데드락이 발생하지 않게 합니다.
Deadlock Detection과 Deadlock Recovery는 보통 함께 사용하는데 Deadlock Detection을 사용하여 데드락이 발생했는지 확인한 후 데드락이 발생했으면 Deadlock Recovery를 이용하여 복구하는 방식을 선택합니다.
아래에서 설명드릴 데드락 처리 방식 중 Synchronized, 비관적 락, 분산 락은데드락이 아예 터지지 않게 막는다는 관점에서 deadlock prevention에 속한다고 생각하시면 되고 낙관적 락은 Deadlock Detection & Deadlock Prevention에 해당하여 데드락이 터지면 이를 회복한다고 생각하시면 됩니다.
동시성을 확인하기 위해서 사용한 테스트 코드는 아래와 같습니다.
@Test
@DisplayName("ImageQuiz 풀기 테스트 - 동시성(데드락)")
void solveImageQuizConcurrent() throws Exception {
//given
int loopCnt = 2;
ExecutorService executorService = Executors.newFixedThreadPool(loopCnt);
CountDownLatch latch = new CountDownLatch(loopCnt);
StopWatch stopWatch = new StopWatch();
//when
stopWatch.start();
for (long i = 1; i <= loopCnt; i++) {
long quizId = i;
executorService.execute(() -> {
try {
String accessToken =
jwtTokenProvider.createAccessToken(memberRepository.findById(1L).orElseThrow());
QuizSolvedListRequest quizSolvedListRequest =
new QuizSolvedListRequest(List.of(
QuizSolvedRequest.builder()
.quizId(quizId)
.answer("answer")
.build(),
QuizSolvedRequest.builder()
.quizId(quizId + 1)
.answer("answer")
.build()));
mockMvc.perform(post("/quiz/image/solve")
.header("Authorization", "Bearer " + accessToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(quizSolvedListRequest)))
.andExpect(MockMvcResultMatchers.status().isOk());
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
latch.countDown();
}
});
}
latch.await(); // 모든 스레드가 작업을 완료할 때까지 기다림
stopWatch.stop();
executorService.shutdown();
//then
log.info("===================결과 출력부===================");
long dataCount = quizSolvedRepository.count();
assertThat(dataCount).isEqualTo(loopCnt * 2);
log.info("데이터 수: {} 개", dataCount);
log.info("반복 횟수: {} 회", loopCnt);
log.info("총 소요 시간: {} ms", stopWatch.getTotalTimeMillis());
}
위 테스트 코드를 실행시키면 아래와 같은 로그가 발생합니다.
2024-07-22T22:20:24.685+09:00 WARN 35620 --- [pool-2-thread-2] o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 1213, SQLState: 40001
2024-07-22T22:20:24.685+09:00 ERROR 35620 --- [pool-2-thread-2] o.h.engine.jdbc.spi.SqlExceptionHelper : Deadlock found when trying to get lock; try restarting transaction
2024-07-22T22:20:24.703+09:00 WARN 35620 --- [pool-2-thread-2] s.k.s.g.e.h.GlobalExceptionHandler : handleAllException
org.springframework.dao.CannotAcquireLockException: could not execute statement [Deadlock found when trying to get lock; try restarting transaction] [update member set birth_date=?,email=?,gender=?,last_modified_date=?,name=?,oauth_type=?,role=?,score=?,social_id=? where member_id=?]; SQL [update member set birth_date=?,email=?,gender=?,last_modified_date=?,name=?,oauth_type=?,role=?,score=?,social_id=? where member_id=?]
at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:283) ~[spring-orm-6.1.5.jar:6.1.5]
at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:244) ~[spring-orm-6.1.5.jar:6.1.5]
2024-07-22T22:20:24.734+09:00 INFO 35620 --- [ Test worker] s.k.s.tuning.ImageQuizConcurrentTest : ===================결과 출력부===================
Expected :4L
Actual :2L
데드락이 터져서 lock을 얻을 수 없는 상황이 발생했습니다.
데드락이 실제로 어떻게 터졌는지 확인하기 위해서 아래와 같은 명령어로 DBMS에 어떠한 로그가 발생했는지 확인해 보도록 하겠습니다.
show engine innodb status;
=====================================
2024-07-22 22:23:10 0x212c INNODB MONITOR OUTPUT
------------------------
LATEST DETECTED DEADLOCK
------------------------
2024-07-22 22:20:24 0x240c
*** (1) TRANSACTION:
TRANSACTION 528831, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 9 lock struct(s), heap size 1128, 5 row lock(s), undo log entries 4
MySQL thread id 1265, OS thread handle 36916, query id 563353 localhost 127.0.0.1 root updating
update member set birth_date=null,email='email1',gender='MAN',last_modified_date='2024-07-22 22:20:24.661849',name='name1',oauth_type=null,role='MEMBER',score=27,social_id='socialId1' where member_id=1
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 18139 page no 4 n bits 168 index PRIMARY of table `kidbeantestdb`.`member` trx id 528831 lock mode S locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 13; compact format; info bits 0
0: len 8; hex 8000000000000001; asc ;;
1: len 6; hex 0000000810eb; asc ;;
2: len 7; hex 82000000860110; asc ;;
3: SQL NULL;
4: len 8; hex 99b3ed651603cf96; asc e ;;
5: len 8; hex 99b3ed651603cf96; asc e ;;
6: len 8; hex 8000000000000019; asc ;;
7: len 5; hex 6e616d6531; asc name1;;
8: len 6; hex 656d61696c31; asc email1;;
9: len 9; hex 736f6369616c496431; asc socialId1;;
10: len 1; hex 01; asc ;;
11: SQL NULL;
12: len 1; hex 02; asc ;;
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 18139 page no 4 n bits 168 index PRIMARY of table `kidbeantestdb`.`member` trx id 528831 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 13; compact format; info bits 0
0: len 8; hex 8000000000000001; asc ;;
1: len 6; hex 0000000810eb; asc ;;
2: len 7; hex 82000000860110; asc ;;
3: SQL NULL;
4: len 8; hex 99b3ed651603cf96; asc e ;;
5: len 8; hex 99b3ed651603cf96; asc e ;;
6: len 8; hex 8000000000000019; asc ;;
7: len 5; hex 6e616d6531; asc name1;;
8: len 6; hex 656d61696c31; asc email1;;
9: len 9; hex 736f6369616c496431; asc socialId1;;
10: len 1; hex 01; asc ;;
11: SQL NULL;
12: len 1; hex 02; asc ;;
*** (2) TRANSACTION:
TRANSACTION 528832, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 9 lock struct(s), heap size 1128, 5 row lock(s), undo log entries 4
MySQL thread id 1264, OS thread handle 8492, query id 563352 localhost 127.0.0.1 root updating
update member set birth_date=null,email='email1',gender='MAN',last_modified_date='2024-07-22 22:20:24.661849',name='name1',oauth_type=null,role='MEMBER',score=27,social_id='socialId1' where member_id=1
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 18139 page no 4 n bits 168 index PRIMARY of table `kidbeantestdb`.`member` trx id 528832 lock mode S locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 13; compact format; info bits 0
0: len 8; hex 8000000000000001; asc ;;
1: len 6; hex 0000000810eb; asc ;;
2: len 7; hex 82000000860110; asc ;;
3: SQL NULL;
4: len 8; hex 99b3ed651603cf96; asc e ;;
5: len 8; hex 99b3ed651603cf96; asc e ;;
6: len 8; hex 8000000000000019; asc ;;
7: len 5; hex 6e616d6531; asc name1;;
8: len 6; hex 656d61696c31; asc email1;;
9: len 9; hex 736f6369616c496431; asc socialId1;;
10: len 1; hex 01; asc ;;
11: SQL NULL;
12: len 1; hex 02; asc ;;
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 18139 page no 4 n bits 168 index PRIMARY of table `kidbeantestdb`.`member` trx id 528832 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 13; compact format; info bits 0
0: len 8; hex 8000000000000001; asc ;;
1: len 6; hex 0000000810eb; asc ;;
2: len 7; hex 82000000860110; asc ;;
3: SQL NULL;
4: len 8; hex 99b3ed651603cf96; asc e ;;
5: len 8; hex 99b3ed651603cf96; asc e ;;
6: len 8; hex 8000000000000019; asc ;;
7: len 5; hex 6e616d6531; asc name1;;
8: len 6; hex 656d61696c31; asc email1;;
9: len 9; hex 736f6369616c496431; asc socialId1;;
10: len 1; hex 01; asc ;;
11: SQL NULL;
12: len 1; hex 02; asc ;;
*** WE ROLL BACK TRANSACTION (2)
----------------------------
END OF INNODB MONITOR OUTPUT
============================
예상대로 Member에 대한 Lock 때문에 데드락이 터진 것을 확인할 수 있었습니다.
이제 이 데드락을 해결해 보도록 하겠습니다.
Synchronized를 이용한 데드락 해결
synchronized는 자바에서 지원하는 동시성을 제어하기 위한 키워드로 메서드에 사용될 수 있고, 블록을 지정해서 사용할 수 있습니다.
저는 이 중에서 메서드에 사용하는 방식으로 사용하였고, non-static으로 인스턴스에만 synchronized의 유효 범위가 지정되도록 하였습니다.
우선 제가 synchronized를 지정한 부분은 Controller에 있는 method입니다.
@Operation(summary = "ImageQuiz 문제 풀기", description = "푼 ImageQuiz 문제를 제출")
@PostMapping("/solve")
public synchronized ResponseEntity<ResponseTemplate<Object>> solveImageQuizzes(
@AuthenticationPrincipal AuthUser user,
@Valid @RequestBody QuizSolvedListRequest request) {
ImageQuizSolveScoreResponse score = imageQuizService.solveImageQuizzes(
request.quizSolvedRequestList(), user.memberId());
return ResponseEntity
.status(HttpStatus.OK)
.body(ResponseTemplate.from(score));
}
우선 이렇게만 코드를 바꾸고 테스트 코드를 실행시키도록 하겠습니다.
이때 각 해결 방식에서 성능의 차이가 어떻게 되는지 확인하기 위해서 API 요청의 수를 동시에 100번이 오는 것으로 하여 처리 시간이 얼마나 걸리는지 확인하겠습니다.
2024-07-22T22:25:48.068+09:00 INFO 29676 --- [ Test worker] s.k.s.tuning.ImageQuizConcurrentTest : ===================결과 출력부===================
2024-07-22T22:25:48.170+09:00 INFO 29676 --- [ Test worker] s.k.s.tuning.ImageQuizConcurrentTest : 데이터 수: 200 개
2024-07-22T22:25:48.170+09:00 INFO 29676 --- [ Test worker] s.k.s.tuning.ImageQuizConcurrentTest : 반복 횟수: 100 회
2024-07-22T22:25:48.170+09:00 INFO 29676 --- [ Test worker] s.k.s.tuning.ImageQuizConcurrentTest : 총 소요 시간: 3532 ms
총 3532ms의 시간이 걸리는 것을 확인하실 수 있습니다.
이때 Service 코드가 실행되는 메서드에 synchronized를 붙이지 않은 이유는 Sevice가 호출될 때 @Transactional이 있기 때문입니다.
@Transactional이 붙어 있는 class는 Spring AOP의 적용 대상이 되어 빈 후처리기에서 처리를 한 다음 Proxy 객체가 스프링 빈으로 등록이 되게 됩니다.
이 상황에서 Service에 synchronized를 붙이더라도 이 키워드는 상속받은 입장에서 신경 쓰지 않기 때문에 동시성이 제대로 지켜지지 않을 수 있습니다.
이 상황을 그림으로 나타내도록 하겠습니다.
위의 그림은 Spring AOP에 의해서 생성된 싱글톤 빈에 대한 멀티 쓰레드 접근을 나타낸 그림입니다.
현재 가정한 상황은 Transaction 1이 먼저 ImageQuizService에 대한 proxy 객체의 점유를 얻고 ImageQuizService에서 synchronized가 붙은 메서드를 실행한 상황입니다.
이때 Transaction 2에서 해당 빈에 대해서 접근을 하면 getTransaction() 까지는 실행이 가능하지만 synchronized가 붙은 영역에는 들어갈 수 없어 대기합니다.
이후 Transaction 1에서 해당 영역에서 나온 후 commit 또는 rollback 하기 전에 Transaction 2에서 해당 영역에 접근할 수 있고 Transaction 1에서 update 한 내용을 Transaction 2에서 읽어오지 않고 commit 이전의 값을 가져와서 lost update의 문제가 발생할 수 있습니다.
따라서 Controller 단에서 트랜잭션이 시작하기 전에 synchronized를 붙여 데이터 부정합 문제를 방지하였습니다.
비관적 락을 이용한 데드락 해결
다음으로 시도해 볼 방식은 비관적 락입니다.
이 방식은 충돌이 자주 일어나는 상황을 가정한 방식이기 때문에 DB에서 원하는 record에 X Lock을 걸어서 다른 쓰레드에서 내가 이후 수정하고자 하는 record에 대해서 접근을 할 수 없게 합니다.
비관적 락을 적용하기 위해서 수정한 부분은 아래와 같습니다.
MemberRepository
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select m from Member m where m.memberId = :memberId")
Optional<Member> findByIdPessimisticLock(Long memberId);
ImageQuizService
@Transactional
public ImageQuizSolveScoreResponse solveImageQuizzes(List<QuizSolvedRequest> quizSolvedRequestList,
Long memberId) {
Member member = memberRepository.findByIdPessimisticLock(memberId)
.orElseThrow(() -> new MemberNotFoundException(MEMBER_NOT_FOUND));
return ImageQuizSolveScoreResponse.scoreFrom(
quizSolvedService.solveQuizzes(quizSolvedRequestList, member, IMAGE_QUIZ));
}
비관적 락으로 테스트 코드를 작동시켰을 때의 로그는 아래와 같습니다.
2024-07-23T11:10:02.034+09:00 INFO 10912 --- [ Test worker] s.k.s.tuning.ImageQuizConcurrentTest : ===================결과 출력부===================
2024-07-23T11:10:02.139+09:00 INFO 10912 --- [ Test worker] s.k.s.tuning.ImageQuizConcurrentTest : 데이터 수: 200 개
2024-07-23T11:10:02.140+09:00 INFO 10912 --- [ Test worker] s.k.s.tuning.ImageQuizConcurrentTest : 반복 횟수: 100 회
2024-07-23T11:10:02.140+09:00 INFO 10912 --- [ Test worker] s.k.s.tuning.ImageQuizConcurrentTest : 총 소요 시간: 2966 ms
2966ms의 시간이 걸려 synchronized를 사용했을 때보다 성능이 조금 더 좋아졌지만 큰 차이는 없습니다.
낙관적 락을 이용한 데드락 해결
낙관적 락은 데드락이 자주 발생하지 않을 것이라 생각하고 생각하여 데드락이 발생하는 경우 이를 감지하고 회복하는 방식입니다.
낙관적 락은 DB에 실제로 락을 걸진 않기 때문에 어플리케이션 락이라고도 합니다.
낙관적 락을 적용하기 위해서 수정한 코드는 아래와 같습니다.
Member 엔티티
@Table(name = "member")
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long memberId;
@Column(name = "email", length = 25)
private String email;
@Column(name = "name", length = 20)
private String name;
@Enumerated(EnumType.STRING)
@Column(name = "oauth_type", length = 20)
private OAuthType oAuthType;
@Column(name = "social_id", length = 100)
private String socialId;
@Column(name = "gender")
@Enumerated(EnumType.STRING)
private Gender gender;
@Column(name = "birth_date")
private LocalDate birthDate;
@Column(name = "role")
@Enumerated(EnumType.STRING)
private Role role;
@Column(name = "score")
private Long score;
@Version
private Long version;
@Builder
public Member(String email, String name, OAuthType oAuthType, String socialId, Gender gender, LocalDate birthDate,
Role role, Long score) {
this.email = email;
this.name = name;
this.oAuthType = oAuthType;
this.socialId = socialId;
this.gender = gender;
this.birthDate = birthDate;
this.role = role;
this.score = score;
}
public void updateScore(Long score) {
this.score = score;
}
}
ImageQuizController
@Operation(summary = "ImageQuiz 문제 풀기", description = "푼 ImageQuiz 문제를 제출")
@PostMapping("/solve")
public ResponseEntity<ResponseTemplate<Object>> solveImageQuizzes(
@AuthenticationPrincipal AuthUser user,
@Valid @RequestBody QuizSolvedListRequest request) {
ImageQuizSolveScoreResponse score =
imageQuizServiceFacade.solveImageQuizzes(request.quizSolvedRequestList(), user.memberId());
return ResponseEntity
.status(HttpStatus.OK)
.body(ResponseTemplate.from(score));
}
ImageQuizServiceFacade
@RequiredArgsConstructor
@Component
public class ImageQuizServiceFacade {
private final ImageQuizService imageQuizService;
private final MemberScoreUpdateStrategy memberScoreUpdateStrategy;
public ImageQuizSolveScoreResponse solveImageQuizzes(
List<QuizSolvedRequest> quizSolvedRequestList, Long memberId
) {
ImageQuizSolveScoreResponse score =
imageQuizService.solveImageQuizzes(quizSolvedRequestList, memberId);
if (score.score() != 0) {
memberScoreUpdateStrategy.updateUserScore(score.score(), memberId);
}
return score;
}
}
ImageQuizService
@Transactional
public ImageQuizSolveScoreResponse solveImageQuizzes(
List<QuizSolvedRequest> quizSolvedRequestList, Long memberId
) {
Member member = memberRepository.findByIdOptimisticLock(memberId)
.orElseThrow(() -> new MemberNotFoundException(MEMBER_NOT_FOUND));
return ImageQuizSolveScoreResponse.scoreFrom(
quizSolvedService.solveQuizzes(quizSolvedRequestList, member, IMAGE_QUIZ));
}
MemberScoreUpdateStrategy
@Service
@Transactional
@RequiredArgsConstructor
public class MemberScoreUpdateStrategy {
private final MemberRepository memberRepository;
@SneakyThrows(InterruptedException.class)
@Transactional
public void updateUserScore(Long score, Long memberId) {
Member member = memberRepository.findByIdOptimisticLock(memberId)
.orElseThrow(() -> new MemberNotFoundException(MEMBER_NOT_FOUND));
while (true) {
try {
member.updateScore(member.getScore() + score);
break;
} catch (ObjectOptimisticLockingFailureException e) {
Thread.sleep(30);
}
}
}
}
MemberRepository
@Lock(LockModeType.OPTIMISTIC)
@Query("select m from Member m where m.memberId = :memberId")
Optional<Member> findByIdOptimisticLock(Long memberId);
낙관적 락을 적용하기 위해서는 꽤나 많은 수정이 필요했습니다.
이 중에서 가장 중요한 변화는 Member 엔티티에 @Version 필드가 추가된 부분과 ImageQuizServiceFacade에서 solveImageQuizzes와 updateUserScore를 따로 호출한 부분입니다.
우선 낙관적 락을 사용하기 위해서는 @Version이 붙어 있는 필드가 필요합니다. 이 필드의 타입은 Long, Integer, Short, Timestamp가 될 수 있습니다.
사실 낙관적 락을 사용하기 위해서 아래와 같이 repository에서 @Lock(LockModeType.OPTISMISTIC)을 붙이지 않아도 됩니다.
하지만 위의 @Lock이 없으면 기본 설정이 그대로 낙관적 락에 적용이 되는데 이는 엔티티를 수정할 때만 version을 확인합니다. 이러한 경우 Lost update와 같은 데이터 부정합 문제가 발생할 수 있는데 @Lock을 사용하면 트랜잭션을 커밋할 때 version을 확인해 이러한 문제를 방지할 수 있습니다.
ImageQuizServiceFacade에서 solveImageQuizzes와 updateUserScore를 따로 호출한 이유는 데드락이 발생했을 때 recover 하는 영역을 최대한 줄이기 때문입니다.
트랜잭션은 원자성을 가지고 있기 때문에 문제가 발생하면 해당 트랜잭션은 모두 롤백하고 다시 실행해야 합니다.
이 경우 엄청난 비용이 들기 때문에 트랜잭션을 분리하기 위해서 메서드를 2개로 나누었고 이러한 복잡성을 외부에 드러내지 않기 위해서 Facade 패턴을 적용하였습니다.
이렇게 메서드를 분리한 후 아래와 같은 코드를 이용하여 데드락이 발생한 경우 낙관적 락을 사용하면 발생하는 Exception인 ObjectOptimisticLockingFailureException을 이용하여 데드락을 회복하였습니다.
@Service
@Transactional
@RequiredArgsConstructor
public class MemberScoreUpdateStrategy {
private final MemberRepository memberRepository;
@SneakyThrows(InterruptedException.class)
@Transactional
public void updateUserScore(Long score, Long memberId) {
Member member = memberRepository.findByIdOptimisticLock(memberId)
.orElseThrow(() -> new MemberNotFoundException(MEMBER_NOT_FOUND));
while (true) {
try {
member.updateScore(member.getScore() + score);
break;
} catch (ObjectOptimisticLockingFailureException e) {
Thread.sleep(30);
}
}
}
}
데드락이 발생하여 회복을 하는 로그는 아래와 같습니다.
org.springframework.orm.ObjectOptimisticLockingFailureException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : [soongsil.kidbean.server.member.domain.Member#1]
at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:325) ~[spring-orm-6.1.5.jar:6.1.5]
at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:244) ~[spring-orm-6.1.5.jar:6.1.5]
at org.springframework.orm.jpa.JpaTransactionManager.doCommit(JpaTransactionManager.java:565) ~[spring-orm-6.1.5.jar:6.1.5]
at org.springframework.transaction.support.AbstractPlatformTransactionManager.processCommit(AbstractPlatformTransactionManager.java:794) ~[spring-tx-6.1.5.jar:6.1.5]
at org.springframework.transaction.support.AbstractPlatformTransactionManager.commit(AbstractPlatformTransactionManager.java:757) ~[spring-tx-6.1.5.jar:6.1.5]
at org.springframework.transaction.interceptor.TransactionAspectSupport.commitTransactionAfterReturning(TransactionAspectSupport.java:676) ~[spring-tx-6.1.5.jar:6.1.5]
at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:426) ~[spring-tx-6.1.5.jar:6.1.5]
at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) ~[spring-tx-6.1.5.jar:6.1.5]
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) ~[spring-aop-6.1.5.jar:6.1.5]
at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:765) ~[spring-aop-6.1.5.jar:6.1.5]
at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:717) ~[spring-aop-6.1.5.jar:6.1.5]
at soongsil.kidbean.server.imagequiz.application.MemberScoreUpdateStrategy$$SpringCGLIB$$0.updateUserScore(<generated>) ~[main/:na]
at soongsil.kidbean.server.imagequiz.presentation.ImageQuizController.solveImageQuizzes(ImageQuizController.java:96) ~[main/:na]
2024-07-24T18:49:15.704+09:00 INFO 38476 --- [ Test worker] s.k.s.tuning.ImageQuizConcurrentTest : ===================결과 출력부===================
2024-07-24T18:49:15.904+09:00 INFO 38476 --- [ Test worker] s.k.s.tuning.ImageQuizConcurrentTest : 데이터 수: 200 개
2024-07-24T18:49:15.905+09:00 INFO 38476 --- [ Test worker] s.k.s.tuning.ImageQuizConcurrentTest : 반복 횟수: 100 회
2024-07-24T18:49:15.905+09:00 INFO 38476 --- [ Test worker] s.k.s.tuning.ImageQuizConcurrentTest : 총 소요 시간: 3230 ms
총 실행 시간은 3230ms로 비관적 락과 큰 차이가 없어 보입니다.
하지만 다른 API 요청이 함께 들어올 때 성능의 차이가 크게 나게 됩니다.
비관적 락은 해당 락이 점유하고 있는 동안 그 record에 대한 접근 자체를 막기 때문에 다른 API 요청에도 영향을 주지만 낙관적 락은 그러한 영향을 주지 않습니다.
또한 현재는 일부러 엄청난 충돌을 일으켰기 때문에 부하가 심하게 보이는 것으로 현재 프로젝트에서는 유저들이 동일한 계정, 다른 기기로 동시에 엄청나게 보내는 상황이 자주 있지 않기 때문에 이 방식이 적합하다고 생각합니다.
분산 락(Named Lock)을 이용한 데드락 해결
마지막으로 분산 락을 이용하여 데드락을 해결해 보도록 하겠습니다.
분산 락이란 분산되어 있는 DB, 서비스를 이용할 때 동시성을 관리하기 위해서 사용됩니다.
보통 redis의 싱글 쓰레드를 사용하는 특성을 이용한 분산 락을 많이 사용하나 현재 추가적인 인프라를 적용하지 않고자 하기 때문에 MySQL에서 named lock(user level lock)을 이용하도록 하겠습니다.
현재 프로젝트에서 MySQL로 분산락을 제어할 수 있는 이유는 1개의 DB만 사용하고 있기 때문입니다.
Named Lock은 특정 String에 대한 Lock을 걸어서 해당 락을 얻은 세션이 락을 해제하거나 timeout 될 때까지 해당 String에 대한 락을 얻을 수 없게 만듭니다.
Named Lock을 얻기 위해서는 GET_LOCK을 하면 되고 락을 풀기 위해서는 RELEASE_LOCK을 하면 됩니다.
이때 트랜잭션이 끝나더라도 락이 자동으로 풀리지 않기 때문에 명시적으로 락을 풀어주어야 합니다.
Named Lock을 수정한 코드는 아래와 같습니다.
ImageQuizController
@Operation(summary = "ImageQuiz 문제 풀기", description = "푼 ImageQuiz 문제를 제출")
@PostMapping("/solve")
public ResponseEntity<ResponseTemplate<Object>> solveImageQuizzes(
@AuthenticationPrincipal AuthUser user,
@Valid @RequestBody QuizSolvedListRequest request) {
ImageQuizSolveScoreResponse score =
imageQuizService.solveImageQuizzes(request.quizSolvedRequestList(), user.memberId());
imageQuizServiceFacade.updateUserScore(score.score(), user.memberId());
return ResponseEntity
.status(HttpStatus.OK)
.body(ResponseTemplate.from(score));
}
ImageQuizService
@Transactional
public ImageQuizSolveScoreResponse solveImageQuizzes(
List<QuizSolvedRequest> quizSolvedRequestList, Long memberId
) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new MemberNotFoundException(MEMBER_NOT_FOUND));
return ImageQuizSolveScoreResponse.scoreFrom(
quizSolvedService.solveQuizzes(quizSolvedRequestList, member, IMAGE_QUIZ));
}
ImageQuizServiceFacade
@Slf4j
@RequiredArgsConstructor
@Component
public class ImageQuizServiceFacade {
private final MemberRepository memberRepository;
private final MemberScoreUpdateStrategy memberScoreUpdateStrategy;
@Transactional
public void updateUserScore(Long score, Long memberId) {
if (score != 0) {
try {
memberRepository.getLock(memberId.toString());
memberScoreUpdateStrategy.updateUserScore(memberId, score);
} finally {
memberRepository.releaseLock(memberId.toString());
}
}
}
}
MemberScoreUpdateStrategy
@Slf4j
@Service
@RequiredArgsConstructor
public class MemberScoreUpdateStrategy {
private final MemberRepository memberRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void updateUserScore(Long score, Long memberId) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new MemberNotFoundException(MEMBER_NOT_FOUND));
member.updateScore(member.getScore() + score);
}
}
MemberRepository
@Query(value = "SELECT GET_LOCK(:key, 3000)", nativeQuery = true)
void getLock(String key);
@Query(value = "SELECT RELEASE_LOCK(:key)", nativeQuery = true)
void releaseLock(String key);
QuizSolvedService
public Long solveQuizzes(List<QuizSolvedRequest> quizSolvedRequestList, Member member, QuizType type) {
QuizSolver solver = quizSolverFactory.getSolver(type);
QuizScorer scorer = quizScorerFactory.getScorer(type);
Long score;
try {
quizScoreRepository.getLock(member.getMemberId().toString());
score = quizSolvedRequestList.stream()
.map(quizSolvedRequest -> solver.solveQuiz(quizSolvedRequest, member))
.map(solvedQuizInfo -> scorer.addPerQuizScore(solvedQuizInfo, member))
.reduce(0L, Long::sum);
} finally {
quizScoreRepository.releaseLock(member.getMemberId().toString());
}
return score;
}
QuizScoreRepository
@Query(value = "SELECT GET_LOCK(:key, 3000)", nativeQuery = true)
void getLock(String key);
@Query(value = "SELECT RELEASE_LOCK(:key)", nativeQuery = true)
void releaseLock(String key);
ImageQuizScorer
@Transactional(propagation = Propagation.REQUIRES_NEW)
@Override
public Long addPerQuizScore(SolvedQuizInfo solvedQuizInfo, Member member) {
QuizScore quizScore = quizScoreRepository.findByMemberAndQuizCategory(member, solvedQuizInfo.category())
.orElseThrow(() -> new IllegalArgumentException("해당 멤버의 퀴즈 카테고리가 존재하지 않습니다."));
quizScore.addScore(solvedQuizInfo.score());
return solvedQuizInfo.score();
}
분산락을 적용하기 위해서는 많은 수정이 필요했습니다.
분산락을 적용하는 핵심은 락이 필요한 지점마다 getLock을 해주고 해당 부분이 끝나면 releaseLock을 해주어야 한다는 것입니다.
현재 문제가 생길 수 있는 부분은 Member에 점수를 추가해야 하는 부분과 QuizScore에 해당 유저가 푼 카테고리에 점수를 추가해야 하는 부분이어서 이 두 부분에 분산 락을 적용해 주었습니다.
이때 분산락을 얻고 실행하는 메서드에 아래와 같은 속성이 있는 것을 보실 수 있습니다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
@Transactional에서 propagation의 기본값은 REQUIRED로 기존에 트랜잭션 이 존재하면 거기에 참여하고, 존재하지 않다면 트랜잭션을 생성한다는 의미입니다.
만약 위의 REQUIRED 속성 그대로 트랜잭션을 전파한다면 데이터 정합성에 큰 문제가 생길 수 있습니다.
트랜잭션 전파를 REQUIRED로 그대로 두면 위의 그림과 같은 상황입니다.
위의 그림에서 Business Logic에서 데이터를 수정하고 Lock을 풀면 해당 트랜잭션을 commit 하기 전에 다른 쓰레드에서 Lock이 있던 부분에 접근할 수 있게 하면 변경되지 않은 값을 DB에서 가져올 수 있습니다.
그럼 이후 TX1이 commit 하고 TX2가 할 일을 하고 commit을 하면 lost update의 문제가 발생할 수 있습니다.
따라서 트랜잭션 전파를 REQUIRES_NEW로 설정해서 새로운 트랜잭션을 만들어주어 아래와 같이 처리하여 줍니다.
이렇게 처리하면 데이터를 변경하는 Business Logic은 이미 commit이 되어 다른 트랜잭션에서 이 Lock을 얻은 후 DB에서 값을 읽으면 이미 변경된 값을 읽으므로 데이터 정합성에 문제가 생기지 않습니다.
이제 테스트 코드를 실행해 보도록 하겠습니다.
실행되는 SQL을 보면 아래와 같이 Lock을 잘 설정하고 해제하는 것을 확인하실 수 있습니다.
Hibernate:
SELECT
GET_LOCK(?, 3000)
Hibernate:
select
m1_0.member_id,
m1_0.birth_date,
m1_0.created_date,
m1_0.email,
m1_0.gender,
m1_0.last_modified_date,
m1_0.name,
m1_0.oauth_type,
m1_0.role,
m1_0.score,
m1_0.social_id
from
member m1_0
where
m1_0.member_id=?
Hibernate:
update
member
set
birth_date=?,
email=?,
gender=?,
last_modified_date=?,
name=?,
oauth_type=?,
role=?,
score=?,
social_id=?
where
member_id=?
Hibernate:
SELECT
RELEASE_LOCK(?)
하지만 해당 코드에는 큰 문제가 있습니다.
아래의 글을 참고하면 MySQL 5.7 이상의 버전에서는 동시에 여러 개의 잠금을 획득이 가능하다는 말이 있습니다.
처음에는 이게 무슨 소리지?? 하고 이해가 안 되었으나 위 테스트 코드에서 한 번에 오는 요청의 수를 많이 늘리자 이와 관련된 문제가 발생하였습니다.
https://techblog.woowahan.com/2631/
Hibernate:
SELECT
GET_LOCK(?, 3000)
Hibernate:
SELECT
GET_LOCK(?, 3000)
Hibernate:
SELECT
GET_LOCK(?, 3000)
Hibernate:
SELECT
GET_LOCK(?, 3000)
Hibernate:
SELECT
GET_LOCK(?, 3000)
Hibernate:
SELECT
GET_LOCK(?, 3000)
Hibernate:
SELECT
GET_LOCK(?, 3000)
Hibernate:
SELECT
GET_LOCK(?, 3000)
Hibernate:
SELECT
GET_LOCK(?, 3000)
Hibernate:
SELECT
GET_LOCK(?, 3000)
위의 상황은 똑같은 memberId를 이용하여 named lock을 얻었을 때의 코드입니다.
제가 예상한 것과는 달리 GET_LOCK을 한 다음 RELEASE_LOCK을 하지 않고 또 다른 Lock을 계속하여 잡는 문제가 있었습니다.
테스트는 종료되지 않고 계속 돌고 있었고 이 상황이 위의 글에 있던 동시에 여러 잠금이 가능한 상황이라는 것을 알게 되었습니다.
이 상황을 분석하기 위해서 아래의 코드로 DB의 상태를 확인하였습니다.
select * from performance_schema.metadata_locks;
위의 사진을 보시면 똑같은 OBJECT_NAME에 대해서 USER LEVEL LOCK이 걸리고 1개의 쓰레드만 Lock을 할당받고 나머지 쓰레드는 대기하고 있는 것을 확인할 수 있었습니다.
이 상황은 현재 가용한 모든 커넥션을 USER LEVEL LOCK을 대기하는 데 사용하기 때문에 문제가 있습니다.
MySQL을 이용한 분산락을 구현하기 위해서 Business Logic을 실행하는 부분은 트랜잭션 전파를 REQUIRES_NEW로 하였습니다.
이 옵션이 정상적으로 실행되기 위해서는 커넥션이 필요한데 커넥션 풀에 남은 커넥션이 없어 다른 쓰레드가 작업을 종료하고 커넥션을 반납하기를 기다립니다.
하지만 다른 쓰레드들은 USER LEVEL LOCK을 얻기 위해서 대기하고 있기 때문에 작업을 종료하지 않게 됩니다.
USER LEVEL LOCK을 얻은 쓰레드도 작업을 완료할 수 없게 되고 시스템에 큰 문제가 발생합니다.
따라서 커넥션이 없어서 이와 같이 문제가 생기지 않게 분산락을 위한 커넥션 풀을 분리해 주어 이러한 문제를 해결해 주어야 합니다.
이와 같은 문제를 확인한 후 커넥션 풀을 나누어줄까 고민하였지만 그렇게 해주면 다른 서비스에서 사용하는 커넥션의 수도 줄어들게 됩니다.
또한 추후 시스템이 확장하는 것을 고려해 보더라도 redis를 사용한 분산락이 구현과 성능 면에서 더 좋다고 판단하여 굳이 이렇게 많은 작업을 하기보다 많은 충돌이 기대되지 않기 때문에 낙관적 락을 시스템에 구현하기로 결정하였습니다.
이렇게 데드락이 터지는 상황에 대해서 고민해 보며 락을 어느 상황에 걸어 문제를 해결해야 하는지에 대해 이해를 가지게 되었고 인프라 확장이 제한되어 있을 때 문제를 해결하는 능력을 기를 수 있었습니다.
'CS > 스프링' 카테고리의 다른 글
Offset을 사용하지 않는 무한 페이징 기능 구현 (1) | 2024.09.30 |
---|---|
AOP를 이용한 분산락(Named Lock) 처리 (0) | 2024.07.28 |
QueryDSL을 활용한 동적 쿼리 처리 (0) | 2024.07.17 |
join 제거, dto projection, covering index를 활용한 성능 최적화 (1) | 2024.07.04 |
spring 프로젝트에서 더미 데이터 사용 & 테스트용 fixture 사용하기 (0) | 2024.06.16 |
댓글