티스토리 뷰
코드를 살펴보면서 좋아요 기능에 멀티 스레드로인한 동시성이나 성능을 개선할 수 있는 방법이 떠올라 개선해보았습니다.
제가 계속해서 개선해나간 기록들을 남겨두며 누군가에게 도움이 됐음 좋겠습니다.
문제 상황
@Transactional
public Long likeBoard(final Long boardId, final Long memberId) {
Board board = boardReadService.getBoardOne(boardId);
board.getBoardCount().plusLike();
return likeRepository.save(Like.createLike(memberId, boardId)).getId();
}
여러 스레드가 Board 객체를 조회하고 plusLike를 한 후 마지막으로 Like 엔티티를 저장해주고 있습니다. 만약 여러 스레드가 해당 API에 접근한다면 무슨 문제가 생길까요?
여러 스레드가 하나의 공유 자원을 놓고 경쟁하는 걸 Race Condition이라고 하는데요 Race Condition이 발생할 때 적절한 동기화 또는 잠금을 통해 데이터 정합성을 지켜줘야 합니다.
하지만 현재 코드에선 지켜주지 않으므로 다음과 같은 문제 상황이 생깁니다.
case | T1 | T2 | Total Result |
T1 Board 조회 | LikeCount : 0, Like Entity : 0 | LikeCount : 0, Like Entity : 0 | |
T2 Board 조회 | LikeCount : 0, Like Entity : 0 | LikeCount : 0, Like Entity : 0 | |
T1 LikeCount 증가 | LikeCount : 1, Like Entity : 0 | LikeCount : 1, Like Entity : 0 | |
T2 LikeCount 증가 | LikeCount : 1, Like Entity : 0 | LikeCount : 1, Like Entity : 0 | |
T1 Like 엔티티 저장 | LikeCount : 1, Like Entity : 1 | LikeCount : 1, Like Entity : 1 | |
T1 Like 엔티티 저장 | LikeCount : 1, Like Entity : 2 | LikeCount : 1, Like Entity : 2 |
실제 200개의 동시 요청 속에 Like Count 개수는 다음과 같이 저장됐습니다.
데이터 정합성이 심하게 깨져있습니다. 그렇다면 해결하는 과정을 Step. 1, 2, 3 으로 보여드리겠습니다.
Step1. Update Execute로 직접 Update 쿼리 날려서 해결하기.
우선적으로 JPA Dirty Checking에 대해서 알면 좋습니다. Dirty Checking은 JPA에서 영속 상태의 Entity가 DB에 반영 되기 전 변경 사항이 있으면 Update Query를 실행하여 변경사항을 저장해줍니다.
하지만 이 작업은 영속화 된 Entity가 DB에 반영될 때 Update Query가 실행되기 때문에 commit 전 다른 스레드가 SELECT쿼리로 엔티티를 조회하게 되면 충분히 변경된 데이터가 DB에 반영되기 전에 데이터를 가져올 수 있습니다.
그렇다면 코드를 개선해보겠습니다. 먼저 QueryDSL을 통해 업데이트 쿼리를 작성하고 직접 execute하게 해줍니다.
public void increaseLikeCount(final Long boardId) {
query.update(board)
.set(board.boardCount.likeCount, board.boardCount.likeCount.add(1))
.where(board.id.eq(boardId))
.execute();
}
그리고 수정해줍니다.
public Long likeBoard(final Long boardId, final Long memberId) {
boardRepository.increaseLikeCount(boardId);
return likeRepository.save(Like.createLike(memberId, boardId)).getId();
}
테스트 결과 일관성이 맞는 것을 확인할 수 있습니다.
하지만 이 코드는 문제의 여지가 있습니다.
무엇이 문제인가?
보통 UPDATE 전 해당 요청으로 들어온 ID로 SELECT를 하여 ID에 대한 객체가 실제로 DB에 존재하는지 확인합니다. 하지만 위 코드는 update를 바로 해주기 위해 과정을 생략하고 있습니다. 이 때문에 엉뚱한 ID로 요청 보내더라도 내부적으로 해당 객체가 있는지 알 수 없습니다. 또한 영속화 하지 않기 때문에 후에 추가적으로 뭔가 요구사항으로 인해 객체 내부를 더 바꿔주기 위해서 Repository와 Service 영역 모두 변경이 필요합니다. 이는 Repository와 Service 사이에 강력한 결합을 해주는 형태로 객체지향적이지 못합니다.
그렇다면 어떻게 해결할 수 있을까요?
Step2. 비관적락을 통해 해결하기
MySQL에는 SELECT FOR UPDATE라는 문법이 존재합니다. SELECT 하고 UPDATE 하는 하나의 연산동안 다른 트랜잭션에서 READ WRITE를 모두 막습니다. JPA에서는 이를 비관적락 베타락을 통해 제공해주고 있습니다.
비관적락이란 ?
자원에 대한 동시 요청이 발생하여 일관성에 문제가 생길 것이라고 비관적으로 생각하고 이를 방지하기 위해 트랜잭션이 시작될 때 락을 먼저 거는 방식입니다.
비관적 락은 배타 락(exclusive lock)과 공유 락(shared lock)이라는 두 가지 옵션이 있는데요.
공유 락을 걸면 다른 트랜잭션에서 읽기는 가능하지만 쓰기가 불가능합니다. 반면 베타 락을 걸면 다른 트랜잭션에서 읽기와 쓰기가 모두 불가능합니다. 배타 락은 쿼리로 보면 'SELECT ~ FOR UPDATE'로 나타낼 수 있습니다.
출처 : https://wildeveloperetrain.tistory.com/128
그러면 구현해봅시다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Board> findById(final Long id);
먼저 베타락을 사용하기위해 PESSMISTIC_WRITE 방식을 사용하는 걸로 선언합니다.
그 후 해당 메서드를 이용하여 SELECT 후 UPDATE를 합니다.
@Transactional
public Long likeBoard(final Long boardId) {
Board board = boardReadService.getBoardOne(boardId);
board.getBoardCount().plusLike();
return likeRepository.save(Like.createLike(memberId, boardId)).getId();
}
테스트 결과 정합성이 잘 지켜지는걸 확인할 수 있습니다.
하지만 이러한 방식은 한 가지 아쉬운 점이 있습니다. 트래픽이 몰린다면 해당 서비스는 어떨까요? 실제로 Tomcat 200개 Thread Pool 기반으로 500개 요청을 보냈을 때 15s 정도의 성능이 나왔습니다. Lock으로 인한 병목으로 좋아요 하나에 너무 긴 Latency가 걸리는 상황이죠 이러한 상황에서 사용자가 안눌려서 계속 좋아요를 누른다면 서버 장애로도 이어질 수 있고 사용성이 굉장히 떨어질 수 있습니다.
Step3. 비동기로 성능 개선하기
가만히 생각해보면 좋아요 개수는 실시간이 엄청 중요한 비즈니스는 아닐 수 있습니다. 또한 필요하면 Front에서 Count하여 우선적으로 눌리는 만큼의 개수를 보여줄 수 있습니다. 그리고 facebook이나 instagram에서도 좋아요를 누르고 바로 밑의 게시글을 구경하는 경우가 흔합니다.
그렇다면 이러한 특성을 이용하여 좋아요 기능을 어떻게 개선해볼 수 있을까요? 바로 비동기입니다. 비동기는 스레드가 독립적으로 작업을 처리하고 순차적으로 처리하지 않습니다. 그렇기 때문에 비동기로 처리하면 count자체는 독립적으로 처리하게 하여 좋아요를 눌렀을 때 Latency를 줄일 수 있을겁니다.
코드를 봅시다.
@Async("likeCountExecutor")
public void increaseLikeCount(final Long boardId, final Long memberId, final Long likeId) {
Board boardResult = transactionTemplate.execute(s -> {
Board board = boardReadService.getBoardOne(boardId);
try {
board.getBoardCount().plusLike();
} catch (Exception e) {
log.error("좋아요 count가 이상해요 : {}", e.getMessage());
s.setRollbackOnly();
throw new RuntimeException(e);
}
return board;
});
if (boardResult == null) {
likeRepository.deleteById(likeId);
}
}
핵심 로직은 위 코드입니다. @Async 어노테이션으로 비동기로 처리하고 있습니다. 비동기처리로 인해 Lock에 대한 병목이 해결되어 기존 500개 요청을 15s -> 1s 까지 개선할 수 있었습니다.
여기서 transactionalTemplate과 boardResult가 null인지 아닌지에 대해 확인하여 null이면 like 테이블에서 id가 일치하는 Entity를 지워주고 있습니다.
왜 그런걸까요?
비동기의 단점
비동기로 동작하게 되면 다른 스레드가 독립적으로 서로 각자 할일을 하기 때문에 요청하는 부모 메서드와 자식 메서드의 결합이 없어지기 때문에 서로 영향을 주지 않습니다. 이러한 특성을 이용해서 Latency에 대한 병목을 해결했지만 반대로 트랜잭션이 불리되면서 가장 중요한 연산의 원자성이 지켜지지 않습니다. 연산이 중간에 실패하더라도 전부 실패하지 않고 일부는 성공하여 일관성도 깨지는 것이죠.
예를 들어 위 코드에서 롤백을 수동으로라도 하지 않으면 Like 테이블에 게시판에 좋아요 누른 사람은 100명이 있는데 그 중에 카운팅에 40개가 실패해서 60개로 표현될 수 있습니다. 이러한 장애는 실제 운영중인 서버에서는 꽤나 치명적인 버그입니다.
그렇기 때문에 TransactionTemplate으로 수동으로 범위를 정해줘 Transaction 결과에 따라 수동적으로 rollback을 해주는 것입니다. 물론 분산 코디네이터를 이용해서 가능하겠지만 현재 프로젝트에서 좋아요 기능을 위해 그렇게 사용하는건 오버 스펙으로 판단되어 사용하진 않았습니다.
왜 낙관적락은 안썼는가?
낙관적락은 데이터 변경이 많지 않을 것이다 라는 가정하에 동시 접근 허용하는 방식입니다. 트랜잭션이 데이터를 읽을 때 해당 데이터의 버전을 유지하고 업데이트가 되면 버전이 올라갑니다. 그 후 이전 버전으로 스레드가 변경 요청을 보내면 버전이 다르므로 실패처리하여 Race Condition을 관리합니다.
하지만, 이 방법은 실패에 대한 재시도가 없기 때문에 일정 시간마다 재시도를 계속해서 해줘야합니다. 그럴 경우 DB에 계속 부하를 줄 수 있기 때문에 실시간으로 계속 변경 요청이 오는 좋아요 기능에선 DB에 많은 부하를 준다 생각하였습니다. 그래서 저는 Lock을 점유하는 동안 대기 했다가 잠금이 해제되면 다음 스레드가 작업하는 방식의 비관적락 (shared Lock) 방식을 사용했습니다.
회고
SELECT FOR UPDATE와 비동기를 이용해서 트래픽이 몰리는 상황에서 데이터 정합성과 성능을 개선해봤습니다. 성능 개선을 시도하면서 가장 어려움을 겪은 부분은 트랜잭션인데요.
비동기로 좋아요를 저장하는 flow와 게시판 좋아요 개수를 올리는 flow를 분리하여 동작시키다 보니 Transaction도 분리되어 트랜잭션의 원자성과 일관성을 지키기가 힘들었습니다.
대표적으로 이러한 경우 동기로 처리하기, 분산 트랜잭션 도구 사용 등도 고민해볼 수 있었지만 환경과 풀어나가야하는 문제를 생각하면 적절한 솔루션이 아니었습니다. 그래서 최종적으로 수동으로 롤백 로직을 구현함으로 해결했습니다.
또한 더 개선할 부분이 있다면 SELECT FOR UPDATE는 lock timeout 설정을 JPA로 설정할 수 없습니다.
@QueryHints({@QueryHint(name="javax.persistence.lock.timeout", value = "3000")})
다음 코드는 쿼리 힌트로 쿼리에 대한 lock timeout을 설정하여 너무 Latency가 길어질 때 좋은 솔루션이 될 수 있습니다.
하지만 MySQL은 해당 쿼리힌트를 사용할 수 없기 때문에 제한됩니다.
그럴 땐 다음을 고려해볼 수 있습니다.
1. NamedLock
2. Redisson Lock
이 중 Redisson Lock은 제가 따로 포스팅한 글이 있으니 읽어보는걸 추천드립니다.
https://golf-dev.tistory.com/65
마침.
'블로그 프로젝트' 카테고리의 다른 글
[Spring Boot + JPA] 게시판 프로젝트 JWT 구현 (2) | 2024.05.24 |
---|---|
동시 접속자가 많은 서비스에서 조회 기능 TPS 개선하기 (1) | 2023.06.07 |
[Spring boot + JPA] 게시판 프로젝트 - 회원 기능(레포지토리 개발) (0) | 2022.09.15 |
[Spring boot + JPA] 게시판 프로젝트 - 회원기능(엔티티 개발) (1) | 2022.09.12 |
[Spring boot + JPA] 게시판 프로젝트 - 회원기능(서비스 로직 개발 및엔티티 개발) (2) | 2022.08.29 |
- Total
- Today
- Yesterday
- swarm
- 코드
- 면접 준비
- 코딩
- 개발
- 인터뷰
- 자바
- CS
- 프로그래밍
- thread
- Kotlin
- 게시판
- 동시성
- IT
- DB
- 취업준비
- 프로젝트
- docker
- Spring
- JPA
- 면접준비
- MySQL
- 개발자
- DevOps
- 면접
- 백엔드
- Redis
- 취업
- 취준
- java
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 | 29 | 30 | 31 |