지난 주 Redis 에서 Transaction 을 주제로 포스팅 했는데, 오늘은 해당 트랜잭션이 잘 동작하고 있는지 테스트를 작성해 보았다. 트랜잭션에서 가장 중요하게 다뤄져야 할 부분은 원자성이므로, 테스트 대상은 트랜잭션 중간에 예외가 발생했을 시 롤백의 여부가 되겠다.
강제로 예외를 발생시키는 부분을 코드 중간에 넣어서 테스트를 해봐도 되지만 그렇게 되면 테스트 시에는 기존의 멀쩡한 비지니스 코드를 건드렸다가 배포시에는 다시 해당 예외 코드를 지워야 하는 번거로움이 생기며, 테스트의 자동화라는 목적도 무색해진다. 어떻게 기존의 코드를 건드리지 않고 예외를 던지게 할 수 있을까? 를 고민하다가, Decorator 패턴 / Proxy 패턴에서 그 답을 찾았다.
Decorator 패턴 / Proxy 패턴
Decorator 은 "장식하다" 라는 의미의 단어 Decorate 에서 -or 접미사를 붙여서 "장식하는 주체" 를 지칭하는 말이 된다. 어떤 것을 장식해주는 것이라는 뜻이다. Proxy 는 "대리자" 라는 뜻인데 컴퓨터, 네트워크 쪽에서 자주 등장하는 단어지만 Proxy 패턴이라는 단어와는 다른 문맥에서 쓰이는 단어라고 한다. 두 이름의 사전적 의미만 봐서는 거리가 멀어보이지만 두 패턴은 사실 상당히 유사한 맥락에서 쓰인다.
우선 Proxy 패턴은 단어 그대로 어떤 대상의 "대리자" 를 이용하는 패턴이다. 예컨대 A 라는 클래스를 생성한다고 했을 때, A 대신 A 를 대리할 수 있는 A' 를 반환해주는 식이다. A' 는 A 객체 행세를 하면서 A 객체로의 요청을 대신 받아 처리한다. (A' 가 A 와 동일한 인터페이스라면 사용하는 사람은 해당 클래스가 A' 인지 A 인지 알 수 없다) 이렇게 되면 A 의 메서드나 변수에 접근 하기 위해서는 A' 를 거쳐가야 한다. 이렇게 특정 대상에 접근 방식에 변화를 주는 것이 Proxy 패턴이다.
사실 이렇게만 설명하게 되면 '그게 대체 왜 필요한데...?' 라는 의문이 자연히 따라붙는다. 여기서 Decorator 패턴이 등장한다. Decorator 패턴은 특정 대상을 꾸며주는 식의 패턴이다. 앞서 말한 A 라는 객체의 내부에 a 라는 메서드가 있다고 해보자. a 메서드는 DB 에 String 형태의 자료를 저장하는 역할을 하는데, 저장되는 모든 문자열을 대문자로 변환하여 저장하고 싶다. 이를 위해서는 자료를 저장하는 코드 바로 이전에 모든 문자열을 대문자로 변경하는 코드를 추가해 줄 수 있다. 그러나 a 의 관심사는 "저장" 에 있기 때문에, 이러한 코드의 추가는 단일 책임 원칙에 위배되며 관심사를 분리하라는 소프트웨어 설계 원칙에도 맞지 않는다. 때문에 다른 식의 접근법이 필요한데 이때 Proxy 패턴을 이용하여 이를 해결할 수 있다.
A 에 Proxy 패턴을 적용하여 A 객체대신 A' 객체를 대리자로 반환한다. 이 때 A 는 온전히 저장 기능에 집중하고 있으며 A' 의 존재를 알지 못한다. A' 는 A 로의 요청을 대신하여 받는데, "문자열을 모두 대문자로 변경한다" 라는 요구사항을 수행한다. A' 는 해당 요구 사항을 수행한 후 A 의 메서드 a 를 실행 시켜 모든 문자열이 대문자인 자료를 DB 에 저장한다.
이러한 흐름은 마치 A' 가 A 의 기능을 꾸며주는 듯한 역할을 하는 것 처럼 보이는데, 이를 Decorator 패턴이라고 하는 것이다.
현재 프로젝트에 적용
디자인 패턴을 적용하지 않은 코드
public class SubscribeService {
private final SubscribeRepository subscribeRepository;
private final ChatRepository chatRepository;
@Transactional
public void disconnect(String sessionId) {
// 유저 로드
Long userId = subscribeRepository.findUserIdBySessionId(sessionId)
.orElseThrow(() -> new WebSocketException(ErrorCode.SESSION_NOT_FOUND));
// 유저가 구독하고 있는 채널Id 로드
Set<Long> channelIds = subscribeRepository.findSubscribeChannelIdBySessionId(sessionId);
// 유저가 구독한 채널에서 유저를 삭제합니다.
channelIds.forEach(channelId -> subscribeRepository.deleteSubscriberFromChannel(userId, channelId));
// 구독 ID 의 채널 정보를 삭제합니다.
subscribeRepository.findSubscriptionIdBySessionId(sessionId)
.forEach(subscribeRepository::deleteSubscriptionId);
// 세션의 구독 정보를 삭제합니다.
subscribeRepository.deleteAllSubscriptionsBySessionId(sessionId);
// 롤백 테스트를 위한 코드
// rollback(sessionId, "disconnect");
// 세션의 유저 정보를 삭제합니다.
subscribeRepository.deleteSessionId(sessionId);
}
@Transactional
public void unsubscribe(String sessionId, String subscriptionId) {
// 유저 로드
Long userId = subscribeRepository.findUserIdBySessionId(sessionId)
.orElseThrow(() -> new WebSocketException(ErrorCode.USER_NOT_FOUND));
// 구독 채널 로드
Long channelId = subscribeRepository.findChannelIdBySubscriptionId(subscriptionId)
.orElseThrow(() -> new WebSocketException(ErrorCode.SUBSCRIPTION_NOT_FOUND));
// 구독 ID 삭제
subscribeRepository.deleteSubscriptionId(subscriptionId);
// 롤백 테스트를 위한 코드
// rollback(sessionId, "unsubscribe");
// 채널에서 구독자 삭제
subscribeRepository.deleteSubscriberFromChannel(userId, channelId);
// 세션에서 구독 Id 삭제
subscribeRepository.deleteSubscriptionIdBySessionId(subscriptionId, sessionId);
}
@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);
// 롤백 테스트를 위한 코드
// rollback(commandDto.getSessionId(), "subscribe");
// 세션에 구독 id 를 등록합니다.
String sessionId = commandDto.getSessionId();
subscribeRepository.saveSubscriptionIdBySessionId(subscriptionId, sessionId);
}
// private void rollback(String sessionId, String condition){
// if(sessionId.equals(condition)) throw new TestException();
// }
public boolean isSubscriber(Long userId, Long channelId) {
Set<Long> subscribers = subscribeRepository.findSubscribersByChannelId(channelId);
return subscribers.contains(userId);
}
}
Subscribe 클래스는 subscribe, unsubscribe, discoect 세 개의 메서드가 있고 각각 롤백 테스트를 위한 코드를 포함하고 있다. (주석 처리 된 부분) 테스트 시에 적절한 sessionId 를 설정하여 예외를 던지게 하면 트랜잭션이 롤백이 되는지 테스트가 가능하다. 하지만 위 코드는 기존 비지니스 로직을 건드리고 있기 때문에 테스트 시에 주석을 해제하고 테스트가 끝나면 다시 주석처리를 해야하는 번거로움이 있다.
Proxy 패턴, Decorator 패턴을 적용한 TestSubscribeRepository (SubscibeService 의 주석처리 된 부분은 지움)
// Transaction 테스트를 위한 테스트 stub 입니다.
@Repository
@Profile("TestSubscribeRepository")
@Primary
@RequiredArgsConstructor
public class TestSubscribeRepository implements SubscribeRepository {
private final RedisSubscribeRepository redisSubscribeRepository;
@Override
public void saveUserIdBySessionId(Long userId, String sessionId) {
redisSubscribeRepository.saveUserIdBySessionId(userId, sessionId);
}
@Override
public Optional<Long> findUserIdBySessionId(String sessionId) {
return redisSubscribeRepository.findUserIdBySessionId(sessionId);
}
@Override
public Set<Long> findSubscribersByChannelId(Long channelId) {
return redisSubscribeRepository.findSubscribersByChannelId(channelId);
}
@Override
public void setSubscriberToChannel(Long userId, Long channelId) {
redisSubscribeRepository.setSubscriberToChannel(userId, channelId);
}
@Override
public void deleteSubscriberFromChannel(Long userId, Long channelId) {
redisSubscribeRepository.deleteSubscriberFromChannel(userId, channelId);
}
@Override
public Set<Long> findSubscribeChannelIdBySessionId(String sessionId) {
return redisSubscribeRepository.findSubscribeChannelIdBySessionId(sessionId);
}
@Override
public void deleteAllSubscriptionsBySessionId(String sessionId) {
redisSubscribeRepository.deleteAllSubscriptionsBySessionId(sessionId);
}
@Override
public Optional<Long> findChannelIdBySubscriptionId(String subscriptionId) {
return redisSubscribeRepository.findChannelIdBySubscriptionId(subscriptionId);
}
@Override
public void setSubscriptionIdToChannel(String subscriptionId, Long channelId) {
redisSubscribeRepository.setSubscriptionIdToChannel(subscriptionId, channelId);
}
@Override
public void deleteSubscriptionId(String subscriptionId) {
redisSubscribeRepository.deleteSubscriptionId(subscriptionId);
}
@Override
public Set<String> findSubscriptionIdBySessionId(String sessionId) {
return redisSubscribeRepository.findSubscriptionIdBySessionId(sessionId);
}
// 여기서 부터 데코레이트가 적용됨
@Override
public void saveSubscriptionIdBySessionId(String subscriptionId, String sessionId) {
if(sessionId.equals("subscribe")) throw new TestException();
redisSubscribeRepository.saveSubscriptionIdBySessionId(subscriptionId, sessionId);
}
@Override
public void deleteSubscriptionIdBySessionId(String subscriptionId, String sessionId) {
if(sessionId.equals("unsubscribe")) throw new TestException();
redisSubscribeRepository.deleteSubscriptionIdBySessionId(subscriptionId, sessionId);
}
@Override
public void deleteSessionId(String sessionId) {
if(sessionId.equals("disconnect")) throw new TestException();
redisSubscribeRepository.deleteSessionId(sessionId);
}
}
이미 빈으로 등록 되어있는 RedisSubscribeRepository 을 DI 받아서 프록시 패턴을 적용하고, 예외를 던져야 할 메서드에 코드를 추가하여 Decorator 패턴으로서 작용하게끔 만들어 주었다. 또한 Profile 을 설정하여 테스트 환경에서만 로드되도록 하여 배포환경에서는 동작하지 않는다.
너무 중복코드가 많아서 아래와 같이 리팩토링 하였다. 아래도 동일한 기능을 수행한다.
리팩토링한 코드
// Transaction 테스트를 위한 테스트 stub 입니다.
@Repository
@Profile("TestSubscribeRepository")
@Primary
public class TestSubscribeRepository extends RedisSubscribeRepository {
public TestSubscribeRepository(RedisTemplate<String, Object> redisTemplate) {
super(redisTemplate);
}
@Override
public void saveSubscriptionIdBySessionId(String subscriptionId, String sessionId) {
if(sessionId.equals("subscribe")) throw new TestException();
super.saveSubscriptionIdBySessionId(subscriptionId, sessionId);
}
@Override
public void deleteSubscriptionIdBySessionId(String subscriptionId, String sessionId) {
if(sessionId.equals("unsubscribe")) throw new TestException();
super.deleteSubscriptionIdBySessionId(subscriptionId, sessionId);
}
@Override
public void deleteSessionId(String sessionId) {
if(sessionId.equals("disconnect")) throw new TestException();
super.deleteSessionId(sessionId);
}
}
사실 위와 같이 테스트 스텁을 작성하는 일은 상당히 번거롭다. (미리 인터페이스로 선언되어있지 않은 경우는 훨씬) 또한, 지금은 테스트 스텁을 하나 작성하였지만 트랜잭션 테스트가 다른 레포지토리에서도 필요하다면, 또다시 위 과정을 반복해야한다. 이러한 번거로운 작업을 반복하지 않는 방법이 없을까?
다음 포스팅에서는 자동으로 프록시 객체를 생성해주는 ProxyFactoryBean 을 활용하여 자동으로 프록시를 생성해주고 마지막으로 Spring AOP 로 완성 시키려고 한다.