Activities/Focussu 개발일지

[Focussu] Kafka & Redis 통합/단위 테스트

oxdjww 2025. 4. 15. 16:16
728x90
반응형

1. 들어가며

최근 집중도 분석 독서실 플랫폼(또는 스터디룸 플랫폼) 백엔드(Spring Boot)에서, 사용자의 WebRTC 연결 상태를 Kafka 메시지로 수신하고 이를 Redis에 반영하는 기능을 개발했다. 요약하자면,

  • Kafka Topic
    • mediasoup.user.connected (사용자 입장)
    • mediasoup.user.disconnected (사용자 퇴장)
  • 동작 흐름
    1. Kafka 메시지를 @KafkaListener가 수신
    2. JSON 파싱 후, roomId, userId 추출
    3. Redis에 roomId를 키로 한 Set 구조에 userId 추가/삭제
  • 테스트 목표
    • 이 흐름이 실제로 잘 동작하는지, 즉 Kafka 메시지를 소비했을 때 Redis가 제대로 갱신되는지를 테스트하고 싶었다.

그런데 이게 말처럼 간단하지 않았다. 아래에서 어떤 과정을 거쳐 해결했는지, 그리고 왜 여러 가지 대안을 시도해야만 했는지 트러블슈팅 과정을 공유해 본다.


2. 통합 테스트를 시도하다: Kafka Embedded & Redis 임베디드

2.1 Kafka Embedded

Kafka의 경우, spring-kafka-test에서 제공하는 @EmbeddedKafka 애노테이션을 사용하면 손쉽게 임베디드 브로커를 띄울 수 있다. 예를 들어 아래와 같이 작성한다.


@SpringBootTest
@EmbeddedKafka(partitions = 1, topics = {
    "mediasoup.user.connected",
    "mediasoup.user.disconnected"
})
@ActiveProfiles("test")
class StudyRoomParticipantKafkaIntegrationTest {

    @Autowired
    private KafkaTemplate<String, String> kafkaTemplate;

    @Autowired
    private StudyRoomParticipantQueryService queryService;

    @Test
    void testKafkaConnected_addsUserToRedis() throws Exception {
        // given
        String message = "{\"roomId\":1, \"userId\":\"user123\"}";

        // when
        kafkaTemplate.send("mediasoup.user.connected", message);

        // then
        await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> {
            Set<String> participants = queryService.getParticipants(1L);
            assertThat(participants).contains("user123");
        });
    }
}
  • @EmbeddedKafka 설정 후, kafkaTemplate을 통해 메시지를 전송한다.
  • awaitility 라이브러리를 사용해 메시지 처리 지연이 있어도 일정 시간 안에 Redis 데이터가 준비되었는지 체크한다.

Kafka는 이 방식이 꽤 안정적으로 동작한다. 문제는 Redis 쪽이었다.

2.2 Redis 임베디드 도입 시도

Redis도 비슷하게 “embedded-redis” 라이브러리를 사용해 메모리 상에서 Redis를 띄워 테스트 환경을 구성하려고 했다. 그러나 macOS(M1 칩 등 ARM 기반) + JDK 17 이상의 환경에서 호환성 이슈가 발생했다.

@Bean
public RedisServer redisServer() {
    return new RedisServer(6379); // 여기서 에러 발생
}
  • Caused by: java.net.UnknownHostException: dummy
  • Can't start redis server. Check logs for details.

등등, 여러 오류가 발생했다. 특히 “dummy” 호스트를 사용해 테스트 컨테이너에서 오버라이드하려 했는데도, 로컬 환경이 잡히지 않거나 애초에 embedded-redis가 ARM 환경을 제대로 지원하지 않는 문제가 컸다.


3. 대안 1: Mock Redis(lettuce-mock) 활용

3.1 lettuce-mock을 이용한 Redis 독립성 확보

Redis를 실제로 띄우지 않고, Mock 라이브러리를 사용하는 방법이다. spring-data-redis-mock과 lettuce-mock 조합을 사용하면, 테스트 환경에서 RedisTemplate이 동작하는 것처럼 흉내 낼 수 있다.

의존성 예시

testImplementation 'io.github.vanroy:spring-data-redis-mock:2.0.0'

설정 파일

 
@Configuration
public class MockRedisConfig {

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceMockConnectionFactory();
    }

    @Bean
    public RedisTemplate<String, String> redisTemplate(
        RedisConnectionFactory factory
    ) {
        RedisTemplate<String, String> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        return template;
    }
}

이렇게 설정하면, 애플리케이션의 RedisTemplate은 실제 Redis가 아닌 mock 환경에서 동작하게 된다. 덕분에 macOS M1 등 특정 OS나 JDK 버전에 구애받지 않고 테스트를 안정적으로 진행할 수 있다.

3.2 통합 테스트 예시

이제 Kafka 메시지를 실제로 임베디드 브로커를 통해 보내고, Redis는 mock 환경으로 처리하는 식의 하이브리드 통합 테스트가 가능해진다.

@SpringBootTest
@EmbeddedKafka(partitions = 1, topics = {
    "mediasoup.user.connected",
    "mediasoup.user.disconnected"
})
@ActiveProfiles("test")
@Import(MockRedisConfig.class)
class StudyRoomParticipantKafkaIntegrationTest {

    @Autowired
    private KafkaTemplate<String, String> kafkaTemplate;

    @Autowired
    private StudyRoomParticipantQueryService queryService;

    @Test
    void testKafkaConnected_addsUserToRedis() throws Exception {
        // given
        String message = "{\"roomId\":1, \"userId\":\"user123\"}";

        // when
        kafkaTemplate.send("mediasoup.user.connected", message);

        // then
        await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> {
            Set<String> participants = queryService.getParticipants(1L);
            assertThat(participants).contains("user123");
        });
    }
}
  • Kafka 브로커는 실제로 구동(@EmbeddedKafka)되지만, Redis는 mock으로 대체된 상태다.
  • “통합 테스트”에 가깝게 진행하면서도, Redis 띄우기 실패 문제에서 자유로워진다.

4. 대안 2: 완전한 단위 테스트(RedisTemplate Mocking)

또 다른 방법은 KafkaRedis 양쪽 모두를 실제로 띄우지 않고, 완전히 “단위 테스트”로 전환하는 것이다. 특히 Kafka 리스너가 실제로 메시지를 받고 Redis를 갱신하는 로직을 검증할 때, “메시지 처리 로직” 자체만 집중하고 싶다면 RedisTemplate을 직접 mock할 수도 있다.

아래는 최종적으로 선택한 테스트 코드 예시다. 여기서는 RedisTemplate과 SetOperations를 mock 처리하여, Redis 연결 없이도 유효한 단위 테스트가 가능하다.

package com.focussu.backend.studyroomparticipant;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;

import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;

import com.focussu.backend.studyroomparticipant.service.StudyRoomParticipantQueryService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.SetOperations;

@ExtendWith(MockitoExtension.class)
public class StudyRoomParticipantKafkaIntegrationTest {

    @Mock
    private RedisTemplate<String, String> redisTemplate;

    @Mock
    private SetOperations<String, String> setOperations;

    private StudyRoomParticipantQueryService queryService;

    @BeforeEach
    public void setup() {
        // redisTemplate.opsForSet() 호출 시 setOperations 목 객체 반환하도록 설정
        when(redisTemplate.opsForSet()).thenReturn(setOperations);
        queryService = new StudyRoomParticipantQueryService(redisTemplate);
    }

    @Test
    public void testGetParticipants() {
        Long roomId = 1L;
        String key = "studyroom:participants:" + roomId;
        Set<String> expectedParticipants = new HashSet<>(Arrays.asList("user123"));

        // setOperations.members(key) 호출 시 expectedParticipants 반환하도록 설정
        when(setOperations.members(key)).thenReturn(expectedParticipants);

        Set<String> actualParticipants = queryService.getParticipants(roomId);
        assertEquals(expectedParticipants, actualParticipants);
    }
}
  • 단위 테스트가 되므로, OS, 포트 충돌, CI 환경 등 외부 요인에서 완벽히 자유로워진다.
  • 대신 “Kafka → Redis”라는 실제 메시지 플로우가 재현되는지는 확인하기 어렵다.
  • 따라서 정말로 전체 흐름을 검증하고 싶다면 MockRedisConfig(또는 Testcontainers 활용) 같은 부분 통합 테스트가 낫고, 핵심 로직만 빠르게 검증하고 싶다면 단위 테스트가 효율적이다.

5. 그 외 대안: Testcontainers Redis

만약 CI 환경에서 Docker 구동이 가능하고, 진짜 Redis를 쓰면서도 독립적 테스트 환경을 원한다면, Testcontainers로 Redis를 띄우는 방법도 있다. 이 경우,

  1. Docker가 설치된 환경이면, Redis 컨테이너를 테스트 시점에 실행
  2. 테스트 종료 후 컨테이너 정리
  3. OS나 포트 문제를 자동으로 해결

이라는 장점이 있다. 하지만 설정이 약간 더 복잡해지고, 매 테스트마다 Docker 컨테이너를 띄우므로 테스트 속도가 줄어들 수 있다. 상황에 따라 Mock vs Testcontainers vs Local Embedded를 선택하면 된다.


6. 마무리하며

Kafka와 Redis를 연동해 메시지를 처리하는 시스템에서, 테스트를 어떻게 구성할지 고민하는 과정은 생각보다 쉽지 않다. 이번 트러블슈팅 사례를 요약해보면,

  1. Kafka
    • @EmbeddedKafka를 통한 임베디드 테스트가 비교적 간단하고 안정적이다.
    • 메시지 테스트는 awaitility 같은 라이브러리로 처리 지연에 유연하게 대응.
  2. Redis
    • embedded-redis는 OS, JDK 버전 호환성이 까다롭다(특히 M1 Mac).
    • lettuce-mock(또는 spring-data-redis-mock)을 쓰면 독립적인 테스트가 가능하다.
    • 조금 더 실제 환경에 가깝게 하려면 Testcontainers 활용.
    • 단위 테스트로 돌리려면 RedisTemplate 자체를 mock 처리해 빠른 테스트 가능.
  3. 통합 vs 단위 테스트
    • 모든 것을 한 번에 검증하고 싶다면 통합 테스트(+mock Redis)
    • 빠르고 환경 독립적인 검증을 원한다면 단위 테스트(MockBean/Mockito 활용)

결국 운영 환경, CI 파이프라인, 개발 속도 등을 고려해서 적절히 타협점을 찾는 것이 핵심이다.
이번 프로젝트에서는 Kafka Embedded를 쓰는 대신, Redis는 ‘목(mock) 처리’로 빠르고 안정적인 테스트를 할 수 있었다.

 

물론 실제 운영 환경과 거의 동일한 흐름을 검증해야 한다면, Testcontainers Redis를 통해 더욱 현실에 가까운 통합 테스트를 구현할 수도 있다.

결론적으로, ‘Kafka 메시지 → Redis 업데이트’라는 로직을 검증하는 방법은 여러 갈래가 있다. 운영 환경, CI 설정, 개발자의 편의 등을 종합해 가장 실용적인 방법을 선택하면 된다.

728x90
반응형

'Activities > Focussu 개발일지' 카테고리의 다른 글

[Focussu] 아키텍쳐 설계  (0) 2025.04.07