어제 토비의 스프링 5장 <서비스 추상화> 챕터를 읽으면서 Transaction 의 원자성 에 대해서 처음 알게 되었다. 사실 Transaction 을 선언함에 있어서 가장 중요하고 핵심적인 이야기로 보이는데, 이걸 이제서야 개념적인 지식으로 습득했다는 데서 다소 충격적이었다. 가끔씩 Service 단에 Transaction 이 진행되는 메서드를 작성하면서 뚜렷한 이유는 없지만 왠지 모를 불안감, 다소 완성도가 떨어지는 듯한 느낌이 들곤 했는데, 바로 원자성에 대한 보장이 없다는 점이 그 원인임을 알게되었다. 이걸 깨닫고 TIL 에 Transaction 의 특성 4가지를 정리해서 아주 그럴듯하게 정리해놓고 싶었지만 솔직히 원자성 이외의 특성들에 대해서 잘 알지도 못하면서 떠드는 것은 기만같다. 그래서 간단하게 원자성에 대한 설명을 적어두고 현재 프로젝트에서 어떤 부분을 개선하고, 테스트 했는지를 정리하려고 한다.
1. Transaction 의 원자성과 동기화
원자성(Atomicity)
원자성이란 "더이상 쪼개지지 않는 물질" 을 의미하는 원자(Atom) 에서 따온 것으로, Transaction 이 원자성을 가진다는 말은 해당 Transaction 은 "쪼개지지 않는다" 는 뜻이다. 약간 은유적이다. 풀어서 설명하자면 Transaction 내부에서 어떤 일련의 코드가 실행될 때, Transaction 을 반으로 뚝 잘라서 중간 부분까지만 실행이 된다거나 아니면 특정 코드 하나가 누락된다거나 하는 일은 절대 일어나지 않으며 온전히 전부 실행되든지, 아니면 아예 실행되지 않는(AllorNothing) 성질을 가짐을 의미한다. (솔직히 원자성이라는 단어가 별로 직관적이지 않다고 생각한다. 나에게 원자라는 말은 공학적인 느낌보다 좀 자연철학, 자연과학적으로 다가온다.)
예컨대, 송금이라는 행위는 1) 송금하는 사람의 계좌 잔고를 차감하고 2) 입금받는 사람의 계좌 잔고를 증가하는 두 개의 행위로 이루어지는다. 만약 이 두 행위가 쪼개져서 둘 중 하나의 행위만 일어나게 된다면 돈이 사라지거나, 돈이 증가해버리는 어처구니없는 일이 일어나게 된다. 때문에 송금은 반드시 하나의 Transaction 안에서 일어나야 한다.
Transaction 동기화
Transaction 의 원자성을 구현하기 위해 스프링은 Transaction 동기화(Syncorization) 라는 것을 한다고 한다. 이를 대략적으로 설명해보자면 이렇다. 1) Tranaction 을 시작하면서 데이터베이스와의 Connection 을 생성한 후 이를 특정 동기화 관리 저장소에 저장한다. 2) 저장된 Connection 은 Commit 이라는 행위를 하기 전까지 실행해야하는 일련의 코드를 전부 보관하고 있는다. 3) 정상적으로 작업을 마치면 Commit 을 해주고, 중간에 예외가 발생하면 Rollback 을 시켜 작업을 종료하여 Transaction 을 마친다.
동기화 관리 저장소는 스레드마다 딱 하나가 생성되고, 동기화된 Connection 이 존재하는 한 DB 와의 Connection 을 새로 생성하지 않고 저장소에 있는 Connection 을 사용하고 반환하기 때문에 동기적으로 동작한다는 데서 Transaction 동기화 라는 표현이 쓰이는 것 같다.
@Transactional
Transaction 동기화는 TransactionSyncronizationManager 클래스를 통해 동기화된 Connection 을 얻고, 이를 Commit 하고 동기화를 닫아주는 방식으로 진행될 수 있는데, 사실 이건 하나의 예시이다. 동기화 방식은 DB 마다 전부 다르고, 또 하나의 트랜잭션에 여러 DB에 접근할 필요가 있다면 로컬 트랜잭션(하나의 DB Connection 에서 이뤄지는 트랜잭션)이 아닌 글로벌 트랜잭션(또는 분산 트랜잭션) 방법이 필요하다고 한다.
그런데 이런 복잡한 것들은 내가 지금 생각하기 버겁고, 지금 나의 단계에서는 가장 간단하게 메서드에 @Transaction 을 붙여 주면 해당 메서드의 호출에서 부터 리턴까지 하나의 Transaction 으로 묶이게 된다.
2. Redis 에서 Transaction 다루기
기존 코드 Test
기존 코드에서는 RedisRepository 를 이용하는 Service 에서 @Transactional 을 붙이지 않았었다. 위에서 말한 것 처럼 종종 까먹어서 누락하기도 하지만 왠지 전혀다른 성격의 DB 이기 때문에 어노테이션이 제대로 동작할까? 하는 생각도 있었다.
일단 @Transactional 을 붙이고 특정 메서드의 중간에서 일부러 예외를 터트리고 난 후 모두 롤백이 되는지를 Test 해보았다.
테스트 대상 메서드
@Transactional
public void subscribe(SubScribeCommandDto commandDto) {
// 구독 권한 확인
Long channelId = commandDto.getChannelId();
Long userId = commandDto.getUserId();
if(chatRepository.findChatByChannelIdAndOwnerId(channelId, userId).isEmpty()){
throw new WebSocketException(ErrorCode.UNAUTHORIZED_SUBSCRIBE_REQUEST);
}
// destination 확인
String destination = commandDto.getDestination();
if (destination == null || !destination.startsWith("/sub/channel/")) {
throw new WebSocketException(ErrorCode.UNIDENTIFIED_DESTINATION);
}
// 채널에 유저를 등록합니다.
subscribeRepository.setSubscriberToChannel(userId, channelId);
// 구독 id 가 어떤 채널을 가르키는지 저장합니다.
String subscriptionId = commandDto.getSubscriptionId();
subscribeRepository.setSubscriptionIdToChannel(subscriptionId, channelId);
// Transaction 테스트를 위한 예외 설정
if(subscriptionId == "error") {
throw new WebSocketException(ErrorCode.BAD_REQUEST);
}
// 구독 id 를 등록합니다.
String sessionId = commandDto.getSessionId();
subscribeRepository.saveSubscriptionIdBySessionId(subscriptionId, sessionId);
}
사실 테스트를 위해서 원래 코드를 저렇게 고치는 것이 별로 보기 좋진 않고 나도 내키지도 않는데, 중간에 예외를 터트릴 방법을 도저히 모르겠다... ㅠㅠ 토비는 내부에 static 클래스를 만들고 상속을 통해서 코드를 조금만 바꿔가지고 테스트를 위한 클래스를 만들던데 나는 그 방법으로 해도 영 이상해서 그냥 일단 이렇게 했다.
롤백 테스트 코드
@Test
@DisplayName("예외 발생시 Transaction 롤백 테스트")
void test2(){
// when
Assertions.assertThrows(WebSocketException.class, () ->
subscribeService.subscribe(errorCommand)
);
// then
Map<Object, Object> entries = redisTemplate.opsForHash().entries("Subscription_Channel:");
Assertions.assertFalse(
entries.keySet().stream().map(k -> String.valueOf(k))
.collect(Collectors.toSet()).contains("error")
);
}
테스트의 의도를 설명하자면, 예외는 setSubscriberToChannel 이라는 구독Id 의 채널 정보를 저장하는 메서드 호출 이후 발생하는데, 만약 @Transactional 이 잘 적용된다면 "error" 라는 key 가 저장되어 있으면 안된다.(롤백 되어야 하므로)
그러나 위 테스트 코드는 실패하며, 해결해야할 다른 문제도 있다.
문제 해결
트랜잭션 제어
테스트가 실패한 것으로 보아 Redis 에는 일반적으로 트랜잭션의 시작과 끝을 정해주는 @Transactional 이 적용되지 않는 모양이다. 위에서 이야기 했듯 DB 는 제각기 동기화 프로세스가 다르기 때문인 것으로 추측 된다. 다른 방법을 생각해야할 필요가 있다.
TranactionManager 로서 동기화를 제어하는 것처럼 Redis 또한 여러가지 명령어를 통해 트랜잭션의 동기화를 직업 제어할 수 있다. commit, rollback 과 대응하는 muti, exec, discard 같은 메서드가 있다. 이런 메서드를 이용해서 직접 트랜잭션을 제어하게 되면 더 정확하고 섬세한 제어가 가능하겠지만, 번거롭게 콜백 함수를 구현 해야한다. Redis 도 단순하게 어노테이션으로 트랜젝션을 선언할 수 없을까?
@Transactional 적용
Redis 도 사실 요 애너테이션으로 Transaction 처리가 가능하다 (ㅎㅎ) 아래처럼 redisTemplate 설정에 setEnableTransactionSupport(true); 를 붙여주면 활성화 된다.
@Bean
public RedisTemplate<String, Object> defaultRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setEnableTransactionSupport(true);
// 시리얼라이저 설정
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class));
return redisTemplate;
}
3. 주의 사항
테스트 롤백
위 방법을 통해 Redis 에도 Transaction 을 적용할 수 있었으며, 롤백 테스트 코드를 통과할 수 있었다. 그런데 다른 문제가 있었다. 테스트는 독립적으로 시행가능 해야하며, 또 DB 에 흔적을 남기지 않아야 한다. 때문에 DB 에 저장된 데이터를 다시 복구해주어야 하는데, 기존의 테스트에서는 테스트 코드에 @Transactional 애너테이션을 붙임으로서 흔적을 지울 수 있었다.
Redis 에서도 해당 어노테이션을 붙여서 테스트를 시행하였고, 테스트 이후 DB 에 흔적이 없었기 때문에 Transaction 을 롤백할 수 있는 것처럼 보였다. 그러나 문제는 아래 성공 테스트 코드가 제대로 동작하지 않는 것이었다.
성공 테스트 코드
@Test
@Transcational
@DisplayName("성공 테스트")
void test1(){
// when
subscribeService.subscribe(normalCommand);
// then
// 채널에 유저 등록 여부
Set<Long> userIds = longRedisTemplate.opsForSet().members(CHANNEL_USER + normalCommand.getChannelId());
Assertions.assertTrue(userIds.contains(user.getId()));
// 구독Id 에 채널Id 등록 확인
Object channelId = longRedisTemplate.opsForHash().get(SUBSCRIPTION_CHANNEL, normalCommand.getSubscriptionId());
Assertions.assertEquals(String.valueOf(channelId), String.valueOf(normalCommand.getChannelId()));
}
이 또한 Redis 의 Transaction 동기화 특성 때문인데, Redis 는 Transaction 에서는 Get 이 항상 null 을 반환한다고 한다.
(Reference : https://gompangs.tistory.com/entry/Spring-Redis-Template-Transaction)
이런 경우에는 직접 DB의 흔적을 제거해준다거나, 트랜잭션 동기화를 직접 제어해줄 필요가 있을 것이다.
사실 트랜잭션 이라는 키워드가 눈에 많이 띄었음에도 불구하고 상당히 무지했던 것에 많이 반성하는 계기가 되었다. 여러 DB에 걸쳐서 트랜잭션을 걸어준다든지 경계를 섬세하게 설정해야하는 경우 상당한 실력이 필요할 것같다. 백엔드 영역에서 가장 큰 축 중 하나가 DB 를 제어하는 기술이라고 하는데, 앞으로 배워야 할 것이 많다는 것을 또 느낀다...