배경
Gasip 프로젝트를 진행하던 도중 게시글 조회수를 올릴 때 동시성 문제를 해결하고자 조사하고 고민한 방법을 공유하고자 합니다.
조사하면서 정말 다양한 방법이 있었고, 저는 왜 이런 방법을 선택했는지도 함께 풀어서 설명해보겠습니다.
해결하고자 하는 문제는 당연히 조회수 동시성 문제입니다. 로컬 환경에서 JMeter를 활용하여 500개의 멀티쓰레드 환경에서 동시에 게시글을 조회한 결과,조회수가 500만큼 오르는 것이 아닌 200~250내외로 조회수가 오르는 것을 확인할 수 있었습니다.
당시 조회수를 올리는 로직은 아래와 같습니다.
서비스 레이어에서 게시글을 조회하는 메서드 내에서 위 increaseView를 호출했습니다.
@Transactional
public BoardReadResponse findBoardId(Long postId,MemberDetails memberDetails) {
Member member = memberRepository.findById(memberDetails.getId())
.orElseThrow(() -> new MemberNotFoundException(ErrorCode.NOT_FOUND_MEMBER));
Board board = boardRepository.findById(postId)
.orElseThrow(() -> new BoardNotFoundException(ErrorCode.NOT_FOUND_BOARD));
board.increaseview();
return BoardReadResponse.fromEntity(board);
}
즉, 특정 게시글을 조회하게 된다면 board의 기존 clickCount(조회수에 +1)을 해주는 방식으로 구현해뒀습니다.
다만 이런 방식은 아래와 같은 문제가 발생하는데,
DB에서 기존 조회수 조회 -> 기존 조회수에 +1 -> 커밋
위와 같은 로직으로 구현되다보니 조회 - 조회수 증가처리 - 커밋하는 과정에서 어쩔 수 없이 시간이 소요 되고, 만약 커밋되기 전 다른 쓰레드가 똑같은 로직을 수행하게 된다면 데이터 정합성이 깨지는 결과가 발생합니다.
결국 500개의 쓰레드는 모두 기존 조회수에서 +1을 해서 조회수를 업데이트하는 로직을 수행했지만, Queue 처럼 줄 세워서 선입선출의 방식이 보장된 것이 아니기 때문에 조회수 증가 로직은 동시성을 보장하지 못하는 한계가 있었습니다.
해결방법
1. update 쿼리 활용
위와 같은 동시성 문제를 해결하기 위해 적용할 수 있는 가장 간단한 방법입니다.
저는 일단 쿼리DSL로 update쿼리를 실행했습니다. (JPQL로 하셔도 무방합니다.)
이 방법은 update쿼리를 통해 먼저 현재 게시글(board)에 저장되어 있는 조회수를 조회하고 조회한 값에 +1을 원자적으로 처리하는 방식입니다. 500개 쓰레드로 해당 방법을 적용해본 결과 조회수가 기존 대비 500만큼 증가하는 것을 확인할 수 있었습니다.
다만 update 쿼리는 본질적으로 DB에 LOCK(배타 락)을 거는 기능이기 때문에 동시다발적으로 많은 트래픽이 몰린다면 서버 부하에 부담이 되는 단점이 있어 채택하지 않았습니다.
현재 해결하고자 하는 문제는 조회수의 동시성 처리이므로 정말 트래픽이 몰리기 쉬운 환경이기 때문입니다.
(만약 티켓 예매 등 조회수 대비 트래픽이 비교적 덜 몰릴 수 있는 환경이라면 고려했겠지만...)
2. 비관적 락 활용
DB에서 제공하는 비관적 락을 활용해 해결하는 방법도 있습니다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT b FROM Board b WHERE b.id = :boardId")
Optional<Board> findById(@Param("boardId")Long boardId);
비관적 락이란, 자원 요청에 따른 동시성 문제가 발생할것이라고 미리 예상하고 락을 걸어버리는 방법론입니다.
1. Transaction 1에서 table의 id 2번을 읽음 (name = Karol) - 공유락 or 배타락을 table에 걸어 둠
2. Transaction 2에서 table의 id 2번을 읽음 (name = Karol)
3. Transaction 2에서 table의 id 2번의 name을 Karol2로 변경 요청(name = Karol)
3-1. 하지만 Transaction 1에서 이미 shared Lock을 잡고 있기 때문에 대기
5. Transaction 1에서 트랜잭션 해제 (commit)
6. Blocking 되어 있었던 Transaction 2의 update 요청 정상 처리
다만 비관적 락을 거는 경우, 1번의 update 쿼리를 이용하는 방법보다도 처리 시간이 더 긴 것을 확인할 수 있었습니다.
성능을 고려해야하는 백엔드 개발자에게는 fit하지 않은 방법이라고 판단했기 때문에 이 또한 채택하지 않았습니다.
3. Synchronized 키워드 활용
결론부터 말하면, 이 방법은 쓸 수 없는 방법입니다.
Synchronized는 해당 키워드가 붙은 메서드는 1개의 쓰레드만 접근할 수 있도록 하는 방법인데, 현재 발생하는 문제는 트랜잭션과 연관된 문제이기 때문입니다.
즉, 멀티쓰레드 환경에서 Synchronized를 사용하면 동시성 문제가 해결되지 않습니다.
아래 코드는 서비스 레이어 내 특정 게시글 조회 메서드입니다.
@Transactional
public synchronized BoardReadResponse findBoardId(Long postId,MemberDetails memberDetails) {
Member member = memberRepository.findById(memberDetails.getId())
.orElseThrow(() -> new MemberNotFoundException(ErrorCode.NOT_FOUND_MEMBER));
Board board = boardRepository.findById(postId)
.orElseThrow(() -> new BoardNotFoundException(ErrorCode.NOT_FOUND_BOARD));
board.increaseview();
return BoardReadResponse.fromEntity(board);
}
잠깐 트랜잭션과 동기화(Synchronized)의 차이에 대해 설명해보자면 아래와 같습니다.
트랜잭션 처리 과정
트랜잭션 시작 -> 로직 실행(조회수 +1) -> 커밋 순으로 진행되며, 하나의 트랜잭션이 커밋되기 전 다른 트랜잭션이 실행될 수 있다.
이런 특징 때문에 동시성 이슈가 발생하는 것
Synchronized 처리 과정
메서드가 한번에 하나의 쓰레드만 실행할 수 있도록 Locking
즉, 서로 다른 개념이기 때문에 올바른 해결 방법이 아니라고 판단했습니다.
3-1. 트랜잭션이 적용되기 전 Synchronized를 적용
서비스 레이어에서 트랜잭션이 걸려있으니, 아예 트랜잭션 자체를 감싸면 어떨까 생각했습니다.
한 번 컨트롤러 레이어에서 synchronized 키워드를 적용해봤습니다.
@GetMapping("/details/{postId}")
public synchronized ResponseEntity<?> getBoardDetail(
@PathVariable Long postId,
@AuthenticationPrincipal MemberDetails memberDetails) {
return ResponseEntity
.ok()
.body(
ApiUtils.success(boardService.findBoardIdWithOutMember(postId))
);
}
동시성 문제는 일부 해결됐지만, 성능 상 문제와 멀티 쓰레드 환경에서는 동시성 문제가 해결되지 않는다는 태생적 단점이 있었습니다.
4. Redisson 분산락 활용
가장 고민을 많이한 방법이기도 하고, 실제로도 많이 채택되는 방법입니다.
먼저 코드는 아래와 같습니다.
@Service
@RequiredArgsConstructor
public class BoardLockFacade {
private final BoardService boardService;
private final RedissonClient redissonClient;
public BoardReadResponse insertView(Long postId) {
// 락 획득
RLock lock = redissonClient.getLock(String.format("click:%d", postId));
BoardReadResponse boardReadResponse;
try {
// 락 획득에 성공하면
boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);
if (!available) {
throw new IllegalArgumentException();
}
// 게시글 조회 & 조회수 +1
boardReadResponse = boardService.addView(postId);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
lock.unlock();
}
return boardReadResponse;
}
}
- 락(lock)을 획득하기 위해 redissonClient.getLock()를 호출합니다.
- 여기서 "click:%d"는 Redis 키의 형식을 나타내며, 해당 게시물의 조회 락을 구분짓는 역할을 합니다.
- lock.tryLock(10, 1, TimeUnit.SECONDS)를 호출하여 락을 획득합니다. 이 메서드는 10초 동안 락을 획득할 수 있도록 시도합니다. 락을 획득하지 못할 경우 pub/sub을 이용해 락 획득 가능 메세지가 올 때까지 대기하다가 락 획득을 시도합니다.
- 락을 획득한 후에는 boardService.addView(postId)를 호출하여 해당 게시물을 조회하고 조회수를 증가시킵니다.
- 마지막으로 finally 블록에서 lock.unlock()을 호출하여 락을 해제합니다.
@Transactional
public BoardReadResponse addView(Long postId) {
Board board = boardRepository.findById(postId)
.orElseThrow(() -> new BoardNotFoundException(ErrorCode.NOT_FOUND_BOARD));
board.increaseView(); // 포스트 맨 위에 있는 로직
return BoardReadResponse.fromEntity(board);
}
Redisson은 Java에서 사용되는 Redis의 클라이언트 중 하나입니다.
Redis에서 기본적으로 제공하는 pub/sub 방식을 기반으로 동작하는 분산락이 내부적으로 이미 구현되어 있기 때문에 개발자가 직접 락을 구현하지 않아도 된다는 장점이 있습니다. 또한 Lettuce(Redis 클라이언트)와 비교해봤을 때 훨씬 간편하며 성능 상 이점도 있습니다.
Lettuce의 경우 일단 공식적으로 분산락을 제공하지 않기 때문에 필요한 경우 개발자가 직접 구현해야합니다.
또한 Lettuce의 락 획득 방식은 락을 획득하지 못한 경우 락을 획득하기 위해 redis에 계속해서 요청을 보내는 스핀락(spin lock)으로 구성되어 있기 때문에 redis에 부하가 생길 수 있다는 단점이 있습니다.
위의 코드로 보면 Redisson이 제공하는 RLock을 통해 lock을 획득한 이후, service 레이어의 게시글 조회 로직을 실행하는 것을 확인할 수 있습니다. synchronized와는 다르게 분산 서버 환경에서도 동시성을 보장할 수 있는 방법이며, redis를 활용할 환경만 갖춰진다면 MySQL의 부하를 분산시킬 수 있는 장점이 있어 굉장히 휼륭한 방법 중 하나라고 생각합니다.
다만,
어쩔 수 없이 Lock이라는 특성 상 성능 상 문제가 발생할 수 밖에 없었습니다.
앞서 말씀드렸다시피 티켓 예매, 제품 구매 등 의 로직에는 매우 적합한 방법이지만, 조회수 증가 로직에는 성능 상 단점이 너무 두드러질 것이라는 생각이 들었습니다.
실제로 JMeter로 500개 쓰레드를 두어 테스트해본 결과, 24초가 소요되었습니다.
5. Redis 기반 Sync schedule 적용
대망의 마지막 방법이자, 제가 실제로 적용한 방법입니다.
먼저 어떤 방법인지 간단하게 설명드리자면, 1분 주기로 기존 조회수와 1분간 추가된 조회수를 합치는 방법입니다.
- 사용자가 특정 게시글을 조회한다.
- 조회할 때마다 Redis에 게시글 조회수를 +1 한다.
- cron(스케줄링)을 활용하여 1분에 한번씩 redis에 저장된 조회수와 기존 MySQL에 저장된 조회수를 합친다.
- Redis에 저장된 조회수는 초기화 한다.
이 방법을 채택한 이유는,
- 빠르다.
똑같이 500개 멀티 쓰레드 환경에서 테스트한 결과 Reddison을 사용할 당시 26초 걸리던 것이 4~5초로 감소했음을 확인할 수 있었습니다. LOCK을 활용하지 않는 방법이기 때문에 속도를 보장할 수 있기 때문입니다.
또한 빠르다는 점은 트래픽이 몰린다 하더라도 처리 속도가 빠르기 때문에 보다 안정적인 서비스를 구축할 수 있다는 장점이 됩니다. - 동시성 제어 성공.
Redis 는 특징 상 싱글 쓰레드로 운영됩니다. 그렇기에 신규 조회수를 MySQL에 바로 저장하는 것이 아니라 Redis에 저장할 경우, 자연스레 동시성이 보장되어 저장되는 것을 확인할 수 있었습니다. - MySQL 부하 감소.
기존에는 조회수가 1씩 증가할 때마다 MySQL에 업데이트해야하는 번거로움이 존재했습니다.
즉, 게시글 1번 조회할 때마다 조회수를 올리는 쿼리도 1번 대응되서 나가야한다는 의미입니다.
그러나 위와 같은 방식은 1분 동안 증가된 조회수를 Redis에 모아뒀다가 한번에 MySQL로 업데이트하기 때문에 조회수를 증가하기 위한 쿼리 개수가 기하급수적으로 줄어듭니다. 그렇기에 특정 게시글 기준으로 1분에 조회수 올리는 쿼리를 1번 실행함으로써 MySQL의 부하를 줄일 수 있었습니다.
단점을 뽑자면 Redis를 쓰지 않으면 추가 인프라 비용이 든다는 점과, 업데이트 주기가 오기 전까진 조회수가 특정 시기에 정확하지 않을 수 있다는 점입니다.
하지만 저는 이미 조회수 중복 방지를 위해 Redis를 사용하고 있었고, AWS 엘라스틱 캐시가 프리티어로 지원되기 때문에 Redis에 대한 추가 비용은 없었습니다. 또한 조회수가 잠시 정확하지 않더라도 서비스 자체가 정확한 조회수 개수를 요구하는 서비스가 아니기 때문에 크리티컬한 이슈가 발생하지 않는다고 판단했기 때문에 위 방법이 가장 적합한 방법이라고 판단했습니다.
해당 아이디어는 유튜브, 페이스북, 인스타그램 등 SNS 서비스를 보고 아이디어를 얻었습니다. 유튜브를 보면 특정 개수 이상으로 넘어가면 조회수나 좋아요 수가 정확하게 표시 되지 않고 올림해서 처리하는 것을 확인할 수 있습니다.
또한 영상에 들어가서 조회수를 본다해도 조회수가 바로바로 오르는 것이 아니라 특정 주기별로 조회수가 한번에 오르거나, 새로고침하면 조회수가 올라가 있는 것을 확인할 수 있었습니다.
이를 기반으로 조회수에 대한 최적의 로직을 찾아낼 수 있었습니다.
다음으론 코드 레벨에서 자세히 설명해보겠습니다.
일단 redis에 기본적으로 크게 3가지 key가 구분되어 저장됩니다.
- key : 게시글ID , value : 조회수 형식으로 저장해 각 게시글 별 조회수를 각각 저장합니다. (게시글별 추가된 조회수 개수 모으기)
- key : 사용자들이 조회한 게시글ID 리스트 , value : 게시글ID (어떤 게시글이 업데이트해야하는 게시글인지 확인하기 위함)
- key : memberId , value : 해당 멤버가 한번 이상 조회한 postID를 저장합니다 (조회수 중복 방지)
위 사진을 보면 5라는 키는 현재 로그인한 MemberID입니다.(5번 유저) 5번 유저가 방문한 게시글의 ID를 5분간 value로 저장하고 있어 이후 같은 게시글을 방문할 때 조회수가 중복되어 오르지 않도록 하는 역할을 합니다. 각 게시글별 id를 구분하기 위해 _(언더바)를 이용해 서로 구분하고 있습니다. (ex. 5_7_1112_123 -> 5,7,1112,123번 게시글을 방문했다는 의미)
게시글 중복 조회 방지 로직에 관련한 자세한 내용은 아래 포스팅을 참고해주세요.
[Gasip] 게시글 조회수 중복 방지 처리 로직 구현
자세한 코드는 깃허브 주소를 참고 부탁드립니다. (BoardService.java) https://github.com/GASIP-PROJECT/gasip-service/tree/develop/src/main/java/com/example/gasip/board gasip-service/src/main/java/com/example/gasip/board at develop · GASIP
hyem5019.tistory.com
5board라는 키는 게시글ID 5번을 의미합니다. 1분 간 5번 게시글을 방문한 조회수가 지속적으로 더해져 value를 이루고 있습니다.
마지막으로 keylist는 1분간 서비스를 사용하는 모든 유저가 1번이라도 방문한 게시글 ID 들을 저장합니다.
현재 redis에 저장된 게시글ID가 어떤 것들이 있는지 알 수 없기 때문에 keylist를 따로 만들어 keylist에 담긴 게시글ID 리스트를 불러와 1분마다 조회수를 기존 조회수와 합쳐주고 있습니다.
컨트롤러 로직은 아래와 같습니다.
@GetMapping("/details/{postId}")
@Operation(summary = "게시글 상세 정보 요청", description = "교수의 게시글 중 특정 게시글 상세 정보를 불러옵니다.", tags = { "Board Controller" })
@Parameter(name = "postId", description = "postId를 URL을 통해 입력받아 특정 게시글을 조회합니다.")
public ResponseEntity<?> getBoardDetail(
@Parameter(name = "postId", description = "조회할 postId를 입력받아 해당 게시글을 조회합니다.", in = ParameterIn.PATH)
@PathVariable Long postId,
@AuthenticationPrincipal MemberDetails memberDetails) {
return ResponseEntity
.ok()
.body(
ApiUtils.success(boardService.findBoardId(postId,memberDetails))
);
}
서비스 로직은 아래와 같습니다.
findBoardId는 특정 게시글을 조회하는 서비스 메서드입니다.
로직 내 insertView라는 메서드를 호출시켜 조회수 동시성 처리를 진행합니다.
@Transactional
public BoardReadResponse findBoardId(Long postId,MemberDetails memberDetails) {
Member member = memberRepository.findById(memberDetails.getId()).orElseThrow(
() -> new MemberNotFoundException(ErrorCode.NOT_FOUND_MEMBER));
Board board = insertView(postId, member);
return BoardReadResponse.fromEntity(board);
}
@Transactional
public Board insertView(Long postId,Member member) {
String viewCount = redisViewCountService.getData(String.valueOf(member.getMemberId()));
Board board = boardRepository.findById(postId)
.orElseThrow(() -> new BoardNotFoundException(ErrorCode.NOT_FOUND_BOARD));
// 현재 로그인한 member가 최근 5분 동안 게시글 자체를 처음 방문한 경우, redis에 멤버ID(key) : value(방문한 게시글ID)틀 생성
if (viewCount == null) {
redisViewCountService.setDateExpire(String.valueOf(member.getMemberId()), postId + "_", calculateTimeOut(5));
redisViewCountService.addViewCountInRedis(postId);
} else {
// 5분 동안 이미 방문한 게시글이 있는 경우, 현재 방문한 게시글이 5분 이내 방문했던 게시글인지 체크
String[] strArray = viewCount.split("_");
List<String> redisBoardList = Arrays.asList(strArray);
// 5분 이내 조회했던 게시글이라면 break
boolean isview = false;
if (!redisBoardList.isEmpty()) {
for (String redisPostId : redisBoardList) {
if (String.valueOf(postId).equals(redisPostId)) {
isview = true;
break;
}
}
// 5분 이내 처음 조회하는 게시글이라면,
// redis에 사용자ID(key) : 조회한 게시글ID(value) 추가
// redis에 게시글ID(key) : 조회수(value)를 입력할 수 있는 틀 생성
if (!isview) {
String alreadyView = postId + "_";
redisViewCountService.addBoardId(String.valueOf(member.getMemberId()),alreadyView);
redisViewCountService.addViewCountInRedis(postId);
}
}
}
return board;
}
// 1분에 1번씩 redis에 저장된 게시글 별 조회수 합과 keylist를 조회해서 MySQL에 업데이트 후 삭제
@Scheduled(cron = "0 * * * * *",zone = "Asia/Seoul")
@Transactional
public void combineViewCount() {
List<String> viewCountList = redisViewCountService.deleteViewCountInRedis();
for (String key : viewCountList) {
Board board = boardRepository.getReferenceById(Long.valueOf(key));
board.increaseView(Long.valueOf(redisViewCountService.getAndDeleteData(key)));
}
}
그리고 Redis를 다루기 위해 RedisViewCountService 클래스를 별도로 생성했습니다.
@Service
@RequiredArgsConstructor
public class RedisViewCountService {
private final StringRedisTemplate stringRedisTemplate;
public String getData(String key) {
ValueOperations<String, String> valueOperations = stringRedisTemplate.opsForValue();
return valueOperations.get(key);
}
public void setDateExpire(String key, String value, long duration) {
ValueOperations<String,String> valueOperations = stringRedisTemplate.opsForValue();
Duration expireDuration = Duration.ofSeconds(duration);
valueOperations.set(key, value, expireDuration);
}
public void addBoardId(String key, String value) {
ValueOperations<String,String> valueOperations = stringRedisTemplate.opsForValue();
valueOperations.append(key, value);
}
public void addViewCountInRedis(Long boardId) {
String key = String.valueOf(boardId)+"board";
ValueOperations<String, String> valueOperations = stringRedisTemplate.opsForValue();
SetOperations<String, String> setOperations = stringRedisTemplate.opsForSet();
if (getData(key) == null) {
valueOperations.set(key,"0");
setOperations.add("keyList", key.replace("board",""));
}
if (!setOperations.isMember("keyList",getData(key))) {
setOperations.add("keyList",key.replace("board",""));
}
valueOperations.increment(key);
}
public List<String> deleteViewCountInRedis() {
SetOperations<String, String> setOperations = stringRedisTemplate.opsForSet();
List<String> value = setOperations.pop("keyList",setOperations.size("keyList"));
return value;
}
public String getAndDeleteData(String key) {
ValueOperations<String, String> valueOperations = stringRedisTemplate.opsForValue();
return valueOperations.getAndDelete(key+"board");
}
}
결론
위와 같이 구성한 결과, 성능과 조회수 동시성 제어를 모두 챙길 수 있었습니다. JMeter로 500개 멀티 쓰레드 환경에서 동시성 테스트를 한 결과, 총 소요시간은 6초가 걸렸습니다.
만약 Redis를 쓰지 못하는 경우, update 쿼리를 활용하거나 MySQL 내 조회수 관련 테이블을 만들어 Sync Schedule을 적용하는 방식으로 진행할 것 같습니다.
감사합니다.
'Development > Spring&Springboot' 카테고리의 다른 글
[Gasip] queryDSL을 활용하여 교수 평균 평점 구하기 (2) | 2024.05.29 |
---|---|
[Gasip] 게시글 조회수 중복 방지 처리 로직 구현 (2) | 2024.04.01 |
스프링 빈이란?? (3) | 2024.02.28 |
스프링 컨테이너 DI와 IoC (2) | 2024.02.28 |
[채팅] 채팅 메세지 전송 속도 개선 (3) | 2024.02.14 |