안녕하세요! 회사와 함께 성장하고 싶은 KOSE입니다.

이번 포스팅은 프로젝트를 진행하며 발생했던 멀티스레드 환경의 동시성 문제를 극복하는 과정을 작성하고자 합니다.

 

1. 멀티 스레딩이란?

 

 

Thread란 프로세스 내에서 작업을 수행하는 단위로 프로세스의 자원을 이용해 작업을 수행합니다.

Multi Threading은 하나의 프로세스 내에서 여러 스레드가 동시에 작업을 수행하는 것입니다. CPU코어는 한 번에 하나의 작업만 수행할 수 있으므로, 실제로 동시에 처리되는 작업의 개수와 일치합니다.

하지만, 코어가 매우 빠른 시간 동안 여러 작업을 번갈아 가며 수행하므로 우리가 느끼기에 동시에 수행되는 것처럼 보이도록 합니다. 멀티 스레드를 이용하면 공유하는 영역이 많아 프로세싱 방식보다 Context Swithcing의 오버헤드가 적어, 메모리 리소스가 소비가 상대적으로 적다는 장점이 있습니다.

 

 

2. 멀티스레딩과 동시성 문제

 

멀티 스레딩은 프로세스 내부에 있는 리소스를 공유해서 사용합니다. 프로세스 내에서 여러 스레드가 공유 리소스에 동시에 액세스를 할 경우, 경합 조건, 교착 상태 및 기타 동기화 문제가 발생합니다.

여기서 말하는 공유 리소스는 스레드간 공유가 가능한 힙이나 파일 혹은 데이터베이스 등이 해당할 수 있습니다. 
(스레드 내부에 있는 스택은 스레드 별로 개별적으로 관리되기 때문에 동시성 문제에 영향을 주지 않습니다)

각 스레드가 공유 리소스에 접근하여 값을 변경하는 요청을 수행한다면, 개발자가 의도한 방식대로 변수의 값이 바뀌지 않고 예기치 않은 오류를 발생시킬 수 있습니다.

이번에는 공유 리소스 중에서도 Redis를 데이터베이스로 활용하여 값을 처리할 때 생기는 동시성 문제를 중점적으로 다루겠습니다.

 

 

3, 공통 코드 작성

 

먼저 Redis 객체로 저장할 Game 클래스와 리포지토리 역할을 수행할 GameRepository입니다.

 

@Getter
@RedisHash("Game")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Game {

    @Id
    private String id;
    private String roomId;
    private String hostId;
    private String gameName;
    private List<String> playerIds;
    private Topic topic;

    protected Game (SetUpGameDto setUpGameDto) {
        this.id = UUID.randomUUID().toString();
        this.roomId = setUpGameDto.getRoomId();
        this.hostId = setUpGameDto.getHostId();
        this.gameName = setUpGameDto.getRoomName();
        this.playerIds = setUpGameDto.getUserIds();
    }

    public static Game of(SetUpGameDto setUpGameDto) {
        return new Game(setUpGameDto);
    }
}
public interface GameRepository extends CrudRepository<Game, String> {
}

 

 

4. 멀티 스레드에 취약한 테스트 코드 

 

먼저 멀티 스레드를 활용하지 않고 단일 스레드 내에서 처리하는 순차적으로 값을 처리하는 테스트 코드입니다.

 

@SpringBootTest
class GameRepositoryTest {

    @Autowired
    GameRepository gameRepository;

    private int duplicatedTotalCnt = 0;

    @AfterEach
    public void tearDown() {

        gameRepository.deleteAll();
        duplicatedTotalCnt = 0;
    }

    @Test
    @DisplayName("Game을 저장하면, redis에 저장되어야 한다.")
    public void save_game_success() throws Exception {
        //given
        SetUpGameDto setUpGameDto = new SetUpGameDto("1", "1", "1", Arrays.asList("2", "3", "4"));
        Game game = Game.of(setUpGameDto);

        //when
        Game savedGame = gameRepository.save(game);

        //then
        assertThat(savedGame.getId()).isEqualTo(game.getId());
        assertThat(savedGame.getGameName()).isEqualTo(game.getGameName());
        assertThat(savedGame.getHostId()).isEqualTo(game.getHostId());
        assertThat(savedGame.getRoomId()).isEqualTo(game.getRoomId());
        assertThat(savedGame.getPlayerIds()).isEqualTo(game.getPlayerIds());
    }
}

 

일반적으로 문제없이 처리되는 것을 볼 수 있습니다. 

하지만, 멀티 스레드 환경에서는 어떨까요?

아래는 멀티 스레딩 환경을 구축하여, gameRepository에 gameId의 키 값으로 저장된 game이 없다면 game을 redis에 저장하는 코드를 테스트하는 코드입니다.

(자바에서 스레드 환경을 구축하기 위해서는 Thread 객체를 생성한 후, start() 메소드를 호출하여 사용할 수 있습니다. )

 

@Test
@DisplayName("스레드 safe 하지 않은 상태에서 game이 있다면 값을 저장하지 않고, 없다면 값을 저장한다.")
public void saveIfNotExistsGame_ThreadNotSafe() throws Exception {
    //given
    Thread[] threads = new Thread[100];

    SetUpGameDto setUpGameDto = new SetUpGameDto("1", "1", "1", Arrays.asList("2", "3", "4"));
    Game game = Game.of(setUpGameDto);

    //when

    for (int i = 0; i < 100; i++) {
        threads[i] = new Thread(() -> {

            if (gameRepository.findById(game.getId()).isEmpty()) {
                System.out.println("repository에 저장합니다.");
                gameRepository.save(game);
                duplicatedTotalCnt++;
            }
        });
    }

    for (int i = 0; i < 100; i++) threads[i].start();
    for (int i = 0; i < 100; i++) threads[i].join();

    //then
    System.out.println("duplicatedTotalCnt = " + duplicatedTotalCnt);
    assertThat(duplicatedTotalCnt).isEqualTo(1);
}

 

결과는 다음과 같습니다. 하나의 game만 저장되어야 하지만 다수의 game이 저장되어 duplicatedTotalCnt의 값이 100이 처리된 것을 볼 수 있습니다.

 

redis를 활용할 때 동시성을 확보하기 위해서는 어떠한 절차를 거쳐야 할까요?

 

 

5. 멀티 스레드를 극복하기 위한 RedissonClient

 

RedissonClient는 데이터베이스, 캐시, 메시지 브로커 등 일반적으로 사용되는 오픈 소스 인메모리 데이터 구조인 Redis에 사용할 수 있는 자바 클라이언트 라이브러리입니다. RedisClient는 Redis와 함께 사용하는데 도움을 주는 고급 인터페이스를 제공하는데, 분산 잠금, 스케줄링 등 기능을 제공합니다.

저는 RedisClient의 분산락 기능을 활용하여 해당 문제를 해결하였습니다.

 

먼저, Spring에서 RedisClient를 사용하기 위해서는 해당 라이브러리 의존성을 주입하고 config 설정을 해야 합니다.

 

implementation 'org.redisson:redisson-spring-boot-starter:3.17.7'
@Bean
public RedissonClient redissonClient() {
    Config config = new Config();
    config.useSingleServer().setAddress("redis://localhost:6381");
    return Redisson.create(config);
}

 

RedissonClient에서 분산락 설정을 위해 사용한 기능은 아래와 같습니다.

 

RLock: RLock은 여러 스레드 또는 프로세스 간에 공유 리소스에 대한 액세스를 동기화하는 방법을 제공하는 Redisson 분산락 기능입니다. 

 

lock.tryLock(): RLock의 tryLock() 메서드는 즉시 잠금을 획득하려고 시도하여 잠금을 획득한 경우, true를 반환하고, 잠금을 획득하지 않은 경우 false를 반환합니다.

 

lock.unlock(): 해당 메소드는 이전에 획득한 잠금을 해제합니다. 보통 try-catch-finally의 finally에 작성하여 해당 락이 종료되도록 하여 데드락이 발생하는 것을 방지할 수 있습니다.

 

lock.isHoldByCurrentThread(): 잠금이 현재 스레드에 의해 유지되고 있는지 확인하여, 맞다면 true, 아니라면 false를 반환합니다.

 

 

6. RedissonClient를 이용한 테스트 코드 수정

private <T> String getLockKey(T arg) {

    if (arg instanceof String) {
        return (String) arg;
    }

    else if (arg instanceof Game) {
        return ((Game) arg).getId();
    }

    throw new IllegalArgumentException();
}

 

먼저 getLockKey()라는 key를 반환하는 제네릭 메소드를 선언하였습니다.

getLockKey()는 인스턴스를 입력받으면 인스턴스의 타입에 따라 getId() (여기서는 key)를 반환하여 key값에 대한 lockKey를 반환하는 역할을 수행합니다.

보통 redis에 값을 저장하는 방법은 RedisTemplate을 사용하거나 CrudRepository를 이용합니다. 저는 인덱스 키워드를 사용하여 간편하게 값을 저장할 수 있는 후자의 방식을 사용했으므로 클래스 인스턴스에서 getId()를 받아오는 방식을 사용했습니다.

 

이 코드는, AOP를 도입할 때 중요한 역할을 수행할 수 있습니다. aop는 횡단 관심사를 묶어서 한 번에 처리할 수 있도록 돕는 기술인데, repository 클래스 혹은 save의 메서드 명을 사용하는 redis repository에 해당 메소드를 사용한다면 타입 변환에 이점을 얻을 수 있습니다.

String lockKey = getLockKey(game.getId());
RLock lock = redissonClient.getLock(lockKey);

try {
    boolean isLocked = lock.tryLock(2, 3, TimeUnit.SECONDS);
    if (!isLocked) {
        System.out.println("락을 획득할 수 없습니다.");
        throw new RedisLockException();
    }

    if (gameRepository.findById(game.getId()).isEmpty()) {
        System.out.println("game = " + game);
        gameRepository.save(game);
        duplicatedTotalCnt++;
    }

} catch (InterruptedException e) {
    e.printStackTrace();
    System.out.println("런타임 에러");
    throw new RuntimeException(e);
} finally {
    if (lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

락을 생성하고 try - catch - finally 내부에서 락을 획득에 대한 boolean 값을 리턴 받습니다.

현재 락을 획득한 상태라면 game을 저장하는 메소드가 실행됩니다.

락을 획득하지 못한 스레드가 해당 요청을 수행한다면, 예외가 발생합니다. 
최종적으로 finally에 최종적으로 해당 스레드가 락을 가지고 있다면 unlock() 메소드를 호출하여 락을 해제합니다.

따라서, 특정한 값을 저장해야 하는 redis의 save() 메소드는 이러한 방법으로 분산락을 적용한다면, 동시성을 제어하여 

원하지 않는 값의 변경을 예방할 수 있습니다.

 

<최종코드>

@Test
@DisplayName("스레드 safe 한 상태로 game 단건 저장하기")
public void saveIfNotExistsGame_ThreadSafe() throws Exception {
    //given
    Thread[] threads = new Thread[100];

    SetUpGameDto setUpGameDto = new SetUpGameDto("1", "1", "1", Arrays.asList("2", "3", "4"));
    Game game = Game.of(setUpGameDto);

    //when
    for (int i = 0; i < 100; i++) {
        threads[i] = new Thread(() -> {

            String lockKey = getLockKey(game.getId());
            RLock lock = redissonClient.getLock(lockKey);

            try {
                boolean isLocked = lock.tryLock(2, 3, TimeUnit.SECONDS);
                if (!isLocked) {
                    System.out.println("락을 획득할 수 없습니다.");
                    throw new RedisLockException();
                }

                if (gameRepository.findById(game.getId()).isEmpty()) {
                    System.out.println("game = " + game);
                    gameRepository.save(game);
                    duplicatedTotalCnt++;
                }

            } catch (InterruptedException e) {
                e.printStackTrace();
                System.out.println("런타임 에러");
                throw new RuntimeException(e);
            } finally {
                if (lock.isHeldByCurrentThread()) {
                    lock.unlock();
                }
            }

        });
    }

    for (int i = 0; i < 100; i++) threads[i].start();
    for (int i = 0; i < 100; i++) threads[i].join();

    //then
    System.out.println("duplicatedTotalCnt = " + duplicatedTotalCnt);
    assertThat(duplicatedTotalCnt).isEqualTo(1);

}

private <T> String getLockKey(T arg) {

    if (arg instanceof String) {
        return (String) arg;
    }

    else if (arg instanceof Game) {
        return ((Game) arg).getId();
    }

    throw new IllegalArgumentException();
}

 

7. 결론

이번 포스팅은 멀티스레딩 환경에서 발생하는 동시성 문제를 제어하기 위해 RedisClient를 사용하는 것을 정리하였습니다.

다음 포스팅은 분산락은 횡단 관심사로 분류할 수 있으므로 AOP를 적용하여 다른 Repository에도 적용하는 과정을 정리하도록 하겠습니다.

 

읽어주셔서 감사드립니다.!

 

 

참고:

https://devwithpug.github.io/java/java-thread-safe/

+ Recent posts