💡 Projects/드로잉 게임 [눈치 코치 캐치!]

[눈치 코치 캐치!] DB 동시성 제어 문제 해결

오늘 ONEUL 2023. 2. 6. 23:39

 

시작하기 전에

[눈치 코치 캐치!]의 MVP를 완성하고 런칭의 기쁨도 잠시, 다양한 피드백을 마주하게 되었다.
그중 내가 맡아 개선한 부분은 ‘게임 진행 중 시간제한(타이머)’ 기능을 추가한 것이었다.

게임에 참여하고 있는 모든 유저가 키워드나 그림을 제출해야만
다음 라운드로 넘어갈 수 있기 때문에 시간제한을 두지 않으면
게임이 지루해진다는 의견이 있었고, 전적으로 동의했다.

빠르게 기능을 개발하면서 한 가지 문제 상황을 맞닥뜨렸다.
지금부터 이 문제를 해결하기 위해 어떤 시도를 했고,
어떤 방식으로 해결했는지 적어보려 한다.

차근차근 문제부터 뜯어보자!

 

 

 

문제 상황

제한 시간을 넘어 미처 제출하지 못한 유저의 키워드나 이미지가 일괄 자동 제출 되었을 때 DB에 제대로 데이터가 쌓이지 않아 다음 라운드로 진행되지 않는 이슈 발생

 

gameFlow 테이블

현재 데이터베이스의 구조는 위와 같다.
게임 내에서 유저가 키워드나 이미지를 제출하게 되면 gameFlow 테이블에 row가 하나씩 쌓이게 된다.

 

제한 시간을 지나 자동 제출이 되면 클라이언트에서 모든 유저의 제출 여부를 ‘제출’로 (isSubmitted를 false로) 동시에 보내준다.
해당 과정에서 일부 유저의 제출 여부가 ‘제출’로 바뀌지 않고 라운드가 넘어가버리는 현상이 발생했다.
그 이후부터 라운드가 꼬이면서 전체 제출이 완료되었는데 라운드가 넘어가지 않거나,
본인 차례의 이미지 or 키워드가 제대로 오지 않는 등 게임 진행이 어려워졌다.

차근차근 로그를 찍어본 결과, 의문점을 발견했다.
자동 제출이 되면서 요청의 순서가 뒤죽박죽이 되는 것..!

3명이 함께 게임을 한다고 가정해 보자.
다음 라운드로 넘어가는 조건은 해당 방의 GameFlowList.size()와 방의 총인원이 같아야 한다.
예를 들어, A 클라이언트의 이미지 저장 로직이 진행되던 중 B 클라이언트의 제출 로직이 시작되면서
총 제출 인원이 1 → 1 → 2 이런 식으로 조회가 된다. (정상적이라면 1 → 2 → 3)

A의 쓰기 작업이 끝나지 않은 채 B의 읽기 작업이 진행되어 실제 size는 2이나 전부 1로 조회해 오는 것을 확인했다.

 

 

 

원인

현재 로직 상 save → find의 구조를 가지고 있기 때문에
자동 제출 기능 구현 시 동시에 동일한 자원에 접근하려 하는 것을 원인으로 판단했다.

 

구조를 살펴보도록 하자.
3명은 제출했고, 2명은 제출하지 않아서 자동 제출 되는 경우 (이미지 제출 라운드)

  1. 제출 한 사람 : exist 조회 → find 조회 → update → saveAndFlush → findList 조회
  2. 제출 안 한 사람 : exist 조회 → find 조회 → insert → saveAndFlush → findList 조회

저 save에서 find로 넘어가는 부분이 문제!

동시성을 제어하기 위해 내가 시도한 방법들은 다음과 같다.

1️⃣ Java synchronized를 적용하여 동기화
2️⃣ Thread Scheduler를 통해 읽기 로직 독립
3️⃣ DB에서 제공하는 Lock 기능을 도입

 

 

시도

 

1️⃣ Java synchronized를 적용하여 동기화

가장 먼저 시도한 방법은 synchronized였다.

syncrhonized란?
여러 개의 스레드가 한 개의 자원을 사용하고자 할 때,
현재 데이터를 사용하고 있는 해당 스레드를 제외하고 나머지 스레드들은 데이터에 접근할 수 없도록 막는 개념이다.

 

제출 로직의 Controller 메서드에 synchronized 적용하여 스레드 간 데이터를 동기화하였다.
의도대로 동작은 했으나, 성능 상 속도 저하 이슈가 발생할 가능성이 크다는 피드백을 받았다.

synchronized를 적용하기 전까지도 꽤나 많은 삽질을 했기 때문에 약간 패닉이 와 있었고..
결국 멘토님께 조언을 구했다.
멘토님은 2가지 방법을 제안해 주셨다.

 

2️⃣ Thread Scheduler를 통해 읽기 로직 독립

멘토님이 제안해 주신 첫 번째 방법은,
제출 인원을 확인하는 로직을 독립시켜서 thread scheduler 등으로 주기적으로 확인하게 하는 것이다.

Thread scheduler란?
스레드의 개수가 코어의 수보다 많을 경우, 스레드를 어떤 순서에 의해 동시성으로 실행할 것인가를 정하는 작업이다.

스레드가 생성되면 가상 머신은 스레드의 실행을 위한 별도의 메모리 공간을 할당한다.
메서드의 호출을 통해 별도의 실행 흐름을 형성하는 것이다.

이제 전체의 제출 여부 확인 로직을 담은 스레드를 구현해 보자.

package com.project.trysketch.service;

import com.project.trysketch.dto.request.GameFlowRequestDto;
import com.project.trysketch.entity.GameFlow;
import com.project.trysketch.repository.GameFlowRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.simp.SimpMessageSendingOperations;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;

@Slf4j
@RequiredArgsConstructor
public class Task implements Callable<String> {
    private final int roundMaxNum;
    private final GameFlowRequestDto requestDto;
    private final GameFlowRepository gameFlowRepository;
    private final SimpMessageSendingOperations sendingOperations;

    @Override
    public String call() {
        while (!Thread.interrupted()) {
log.info(">>>>>>>>>>>>>>>>>>>>>>>> [MessageSendingThread - sendAllSubmitMessage] >>>>>>>>>>>>>>>>>>>>>>>>");
            List<GameFlow> gameFlowList = gameFlowRepository.findAllByRoomIdAndRound(
                    requestDto.getRoomId(),
                    requestDto.getRound()
            );
log.info(">>>>>>> [GameService - sendAllSubmitMessage] gameFlowCount.getGameFlowCount() if문 밖 {}", gameFlowList.size());
            if (gameFlowList.size() == roundMaxNum) {
                // 이미지 라운드 인지 키워드 라운드 인지 판별 후 destination 부여
                String destination = requestDto.getImage() == null || requestDto.getImage().length() == 0 ? "word" : "image";
                // 전체가 제출 했다면 모두에게 메시지 전송
                Map<String, Object> allSubmitMessage = new HashMap<>();
                allSubmitMessage.put("completeSubmit", true);
                sendingOperations.convertAndSend("/topic/game/submit-" + destination + "/" + requestDto.getRoomId(), allSubmitMessage);
log.info(">>>>>>> [GameService - isAllSubmit] 전체 제출 후 메시지 전송 성공! : {}", allSubmitMessage);
log.info(">>>>>>>>>>>>>>>>>>>>>>>> [MessageSendingThread - sendAllSubmitMessage] 완료 >>>>>>>>>>>>>>>>>>>>>>>>");
            }
log.info(">>>>>>>>>>>>>>>>>>>>>>>> [MessageSendingThread - sendAllSubmitMessage] 다시시작 >>>>>>>>>>>>>>>>>>>>>>>>");
        }
        return "Rdady!";
    }
}

스레드 생성하여 구현하였으나 처음에는 타임아웃을 설정해주지 않아 stackoverflow가 발생했다.
이후 타임아웃을 설정해 주었더니 바로 DB 조회 불가능해졌다..

결국 스레드 클래스 안에서 for문을 돌면서 일정 횟수 반복시키고, 조건에 맞으면 break 하는 로직으로 변경하였다.
4명이서 테스트했을 때, gameFlow row도 잘 쌓이고 gameFlowListSize도 잘 가져왔으나 어떤 이유에서인지 다음 라운드로 넘어가지지 않았다.

그리고 내가 생각해도 이 로직은 전혀 안전하지 않았다😫

 

3️⃣ DB에서 제공하는 Lock 기능을 도입

멘토님이 제안해 주신 두 번째 방법은,
제출 인원을 하나의 컬럼으로 갖는 테이블을 생성하고, row level lock을 적용하는 것이다.

지금처럼 row를 쌓는 구조는 table level lock을 걸어야 할 텐데 이러면 성능이 매우 안 좋아질 것이다.
그래서 멘토님의 조언대로 테이블 구조를 변경하고 row level lock을 적용하여 부하를 줄이면서 성능도 챙길 수 있는 방향으로 시도해 보았다.

먼저, 다음과 같이 제출 인원을 하나의 컬럼으로 갖는 테이블을 생성해 주었다.

 

DB에서 제공하는 lock의 개념은 크게 2가지가 있다.

  • 비관적 락(Pessimistic lock) : 자원 요청에 따른 동시성 문제가 발생할 것이라고 예상하고 우선 락을 거는 방법
  • 낙관적 락(Optimistic lock) : 자원에 락을 걸어서 선점하지 않고, 동시성 문제가 발생하면 그때 처리하는 방법

(제출 로직 특성상 빈번한 충돌이 예측 가능했기 때문에 롤백 비용을 고려하여 Optimistic Lock은 적용하지 않았다.)

 

update용 find 메서드를 구현하고, 해당 메서드에 @Lock 어노테이션과 모드를 설정했다.
제출 인원을 수정할 때 write lock이 걸리고 transaction이 끝나야 lock이 풀리는 것을 이용한 것이다.

그에 맞게 로직도 수정해 주었다.
방장이 무조건 gameFlowCount를 만들게 하고 나머지는 그 lock이 걸린 조회를 이용해서 update 하게 해 주었다.

 

기존

  • gameFlowCount 조회 후,
    • 있으면? gameFlowList 조회해서 해당 값으로 update
    • 없으면? 새로 생성
@Transactional
    public GameFlowCount updateGameFlowCount(GameFlowRequestDto requestDto, Boolean isSubmitted) {
log.info(">>>>>>>>>>>>>>>>>>>>>>>> [GameService - setGameFlowCount] >>>>>>>>>>>>>>>>>>>>>>>>");
        // gameFlowCount 조회
        GameFlowCount gameFlowCount = gameFlowCountRepository.findByRoomIdAndRoundForUpdate(
                requestDto.getRoomId(),
                requestDto.getRound()
        );
         // gameFlowCount 가 있으면 -> 조회 후 업데이트
        if (gameFlowCount != null) {
log.info(">>>>>>> [GameService - setGameFlowCount] gameFlowCount 있음");
//            int gameFlowListSize = gameFlowRepository.countByRoomIdAndRound(
//                    requestDto.getRoomId(),
//                    requestDto.getRound()
//            );
            List<GameFlow> gameFlow = gameFlowRepository.findAllByRoomIdAndRoundForUpdate(
                    requestDto.getRoomId(),
                    requestDto.getRound()
            ); // 여기를 제거하고 +1, -1 해줌
            log.info(">>>>>>> [GameService - setGameFlowCount] gameFlowListSize : {}", gameFlow.size());
            GameFlowCount newGameFlowCount = gameFlowCount.update(gameFlow.size());
            return gameFlowCountRepository.saveAndFlush(newGameFlowCount);
        }
        // gameFlowCount 가 없으면 -> 생성
        else {
log.info(">>>>>>> [GameService - setGameFlowCount] gameFlowCount 없음");
            GameFlowCount newGameFlowCount = buildGameFlowCount(requestDto);
log.info(">>>>>>> [GameService - setGameFlowCount] getGameFlowCount : {}", newGameFlowCount.getGameFlowCount());
log.info(">>>>>>>>>>>>>>>>>>>>>>>> [GameService - setGameFlowCount] 완료 >>>>>>>>>>>>>>>>>>>>>>>>");
            return gameFlowCountRepository.saveAndFlush(newGameFlowCount);
        }
    }

 

변경

  • gameFlowCount 조회 후, 그 조회 한 객체에 바로 update
  • 이때, isSubmitted로 제출인지 취소인지를 매개변수로 받아서
    • 제출이면? +1
    • 취소면? -1
  • 새롭게 생성하는 빌드는 시작할 때부터 생기도록 getInGameData(첫 시작)와 getPrevious(이후 라운드 시작)로 옮김
@Transactional
    public GameFlowCount updateGameFlowCount(GameFlowRequestDto requestDto, Boolean isSubmitted) {
        log.info(">>>>>>>>>>>>>>>>>>>>>>>> [GameService - setGameFlowCount] >>>>>>>>>>>>>>>>>>>>>>>>");
        // gameFlowCount 조회
        GameFlowCount gameFlowCount = gameFlowCountRepository.findByRoomIdAndRoundForUpdate(
                requestDto.getRoomId(),
                requestDto.getRound()
        );
        GameFlowCount newGameFlowCount;
        if (isSubmitted) {
            newGameFlowCount = gameFlowCount.update(1);
        } else {
            newGameFlowCount = gameFlowCount.update(-1);
        }
        return gameFlowCountRepository.saveAndFlush(newGameFlowCount);
    }

 

이렇게 적용해 주었더니 더 이상 동시성 문제가 발생하지 않았다!
만세!! 🤸‍♀️🤸‍♂️

 

 

 

글을 마치며

동시성 이슈를 런칭 이후에 해결해야 했기에 정말 테스트조차 쉽지 않았다.
이래서 테스트 서버를 두고, 테스트 코드를 짜는구나.. 깨달았다.

일주일 동안 내내 이것만 붙잡고 있었는데 딱 해결되던 그 순간의 짜릿함을 잊을 수가 없다.
지치지 않는 마음이 참 중요한 것 같다.

물론 나는 최선을 다했지만, 현재의 방법이 최선이 아닐 수도 있다.
동시성을 제어하는 다양한 기법들도 꼭 시도해보고 싶다!

 

 

 

 

📚 참고자료

 

내가 선택한 DB 동시성 해결방법

내가 선택한 DB 동시성 해결방법 서론 오랜만에 블로그 포스팅이다. 포스팅 아이템들은 넘처나는데, 시간이 없어서(핑계) 작성할 시간은 없었던 것 같다. 마침 진행하던 프로젝트에서 동시성 이

catch-me-java.tistory.com

 

JPA 비관적 잠금(Pessimistic Lock)

비관적 잠금(Pessimistic Lock) 이란? 선점 잠금이라고 불리기도 함 트랜잭션끼리의 충돌이 발생한다고 가정하고 우선 락을 거는 방법 DB에서 제공하는 락기능을 사용

isntyet.github.io