본문 바로가기
팀 프로젝트/cheerha.project

RedisTemplate과의 결합도를 낮춰봅시다 (DIP)

by pon9 2025. 3. 14.

DIP란?

DIP는 SOLID원칙 중 하나로, 고수준 모듈이 저수준 모듈에 의존하는 것이 아니라 "추상화"된 클래스를 의존하도록 만드는 원칙입니다.

스프링에서는 IoC와 DI로 이를 구현할 수 있습니다.

DIP를 적용하게 되면, 일반적인 어플리케이션 아키텍처에서 정의하고 있는 패키지 의존성 흐름대로 바꿀 수 있습니다. 예를 들어,

현재 우리 서비스의 문제점입니다. Redis를 사용하는 수많은 Application계층의 서비스들이 모두 Infrastructure계층을 바라보며, RedisTemplate에 의존하고 있습니다. 이는 고수준 모듈이 저수준 모듈에 의존하게 되는 것이며 DIP를 위반합니다.

이는 유연성을 감소시키고, 테스트를 불편하게 만들며, 모듈 간 결합도가 증가하는 원인이고, 확장성도 떨어지는 설계입니다.

이 때, 추상화된 클래스를 이용하여 패키지 의존성 흐름을 반대로 바꿀 수 있습니다.

인터페이스 KeyValueRepository를 두고, 인터페이스의 구현체가 RedisTemplate으로 이를 구현하게 만들어

Infrastructure계층이 Application계층을 바라보도록 만들 수 있습니다.

해당 개념을 프로젝트에 적용시켜봅시다!

 

 

1. RedisTemplate 의존성 리팩토링

‘common/redis’ 안에 RedisTemplate에 의존하는 서비스가 너무 많이 존재합니다.

 

 

현재 코드의 문제점: DIP 위반!

RedisTemplate을 직접 호출하다 보니 다른 Key-Value 저장소, 또는 Redisson과 Lettuce로 이전할 때 코드를 전부 바꿔야 합니다.

테스트 코드 작성 시 RedisTemplate Mocking 과정이 상당히 불편합니다.

 

 

리팩토링 과정

우선, 추상화합시다! RedisTemplate 호출을 추상화된 인터페이스의 구현체에 맡겨볼까요?

public interface KeyValueRepository {

    //Key-Value
    String getValue(String key);

    void setValue(String key, String number, Duration duration);
    void setValue(String key, String value, long ttl, TimeUnit timeUnit);

    void removeValue(String key);
}

이제 Redis 결합도가 느슨해지고, Mocking이 훨씬 쉬워졌습니다. 더는 @BeforeEach로 RedisTemplate 행동을 미리 정의하지 않아도 돼요!

@BeforeEach
void setUp() {
    when(redisTemplate.opsForValue()).thenReturn(valueOperations);
}

@Test
void incrementDailyLimit_첫번째요청_카운트_1증가_만료시간설정() {
    when(valueOperations.get(REDIS_KEY)).thenReturn(null);

    assertDoesNotThrow(() -> checkDailyEmailCount.incrementDailyLimit(TEST_EMAIL, OPERATION_KEY));

    verify(valueOperations, times(1)).increment(REDIS_KEY);
    verify(redisTemplate, times(1)).expire(eq(REDIS_KEY), anyLong(), eq(TimeUnit.SECONDS));
}

(전) @BeforeEach로 valueOperations를 반환해주어야했습니다.

@Test
void incrementDailyLimit_첫번째요청_카운트_1증가_만료시간설정() {
    when(keyValueRepository.getValue(REDIS_KEY)).thenReturn(null);

    assertDoesNotThrow(() -> checkDailyEmailCount.incrementDailyLimit(TEST_EMAIL, OPERATION_KEY));

    verify(keyValueRepository, times(1)).incrementValue(REDIS_KEY);
    verify(keyValueRepository, times(1)).expireValue(eq(REDIS_KEY), anyLong(), eq(TimeUnit.SECONDS));
}

(후) 결합도가 낮아져서 테스트코드 작성이 훨씬 쉬워졌어요!

 

리팩토링 결과

RedisTemplate에서 Redisson과 Lettuce를 사용할 때, Repository의 구현체만 수정하면 돼요!

Redis에서 다른 Key-Value 저장소로 이전하기 쉬워졌어요!

단위 테스트를 수행하기 편해졌어요!

 


 

 

2. KeyValueRepository 책임 과다

public interface KeyValueRepository {

    //Key-Value
    String getValue(String key);
    Set<String> getKeys(String key);

    void setValue(String key, String value);
    void setValue(String key, String number, Duration duration);
    void setValue(String key, String value, long ttl, TimeUnit timeUnit);

    void removeValue(String key);

    long incrementValue(String key);

    void expireValue(String key, long ttl, TimeUnit timeUnit);

    Boolean hasKey(String key);

    List<String> opsForListRange(String key, long start, long end);
    void opsForListLeftPush(String key, String value);

    Set<String> opsForZSet(String key, long start, long end);
    void opsForZSetAdd(String key, String value, long score);
    Long opsForZSetCard(String key);
    Set<String> opsForZSetReverseRange(String key, long start, long end);
    void opsForZSetRemoveRange(String key, long start, long end);
}

redis를 사용중인 서비스가 생각보다 많았군요..

 

 

현재 코드의 문제점: SRP 위반!

하나의 Repository에 너무 많은 메서드가 있고, 이들의 공통점은 Key-value 저장소를 사용한다는 점뿐입니다.

새로운 팀원이 합류한다면, 어떤 메서드를 어디서 찾아서 사용해야 할지 헷갈립니다.

 

 

리팩토링 과정

CQRS 아이디어를 차용해 볼까요? 읽기 작업과 쓰기 작업으로 분리합시다!

public interface KeyValueQueryRepository {

    //읽기 전용
    String getValue(String key);
    Set<String> getKeys(String key);
    Boolean hasKey(String key);
    List<String> getListRange(String key, long start, long end);
    Set<String> getZSetRange(String key, long start, long end);
    Long getZSetCard(String key);
    Set<String> getZSetReverseRange(String key, long start, long end);
}

읽기(Query) 작업을 담는 KeyValueQueryRepository

public interface KeyValueCommandRepository {

    //쓰기 전용
    void setValue(String key, String value);
    void setValue(String key, String value, Duration duration);
    void setValue(String key, String value, long ttl, TimeUnit timeUnit);
    void removeValue(String key);
    long incrementValue(String key);
    void expireValue(String key, long ttl, TimeUnit timeUnit);
    void pushToListLeft(String key, String value);
    void addToZSet(String key, String value, long score);
    void removeFromZSetRange(String key, long start, long end);
}

쓰기(Command) 작업을 담는 KeyValueCommandRepository

 

 

리팩토링 결과

로직을 나누어 협업하기 쉬워졌어요!

유지 보수하기 편해졌어요!

패턴이 명확하여 다른 Key-Value 저장소로 이전하기 쉬워졌어요!