테스트 대역을 어떻게 활용하고있나요?

Jeongkyun AnJeongkyun An
8 min read

시작하며

필자는 현재 B2B SaaS 제품(이하 KOS)을 만들고있다. 제품을 만들면서 테스트에선 분명 성공했지만 운영에서 장애를 발생시킨 경험을 하고 난 이후 테스트 대역을 어떻게 바라보고 사용하는지에 대해 소개해보려한다.

테스트 대역이 습관화 되는것이 해롭다는것은 많은 개발자들이 인지하고있는 사실이다. 그러나 안타깝게도 테스트를 작성하다보면 테스트 대역이 필요한 순간은 있기마련이다. 이에 대해 필자는 어떤 기준을 갖고 테스트 대역을 활용하고있는지에 대해 소개한다.

배경

KOS의 아키텍처는 Mircroservices 환경으로 이루어져있고, 여러 컴포넌트 간 동기 방식의 HTTP 통신 또는 메세지를 발행하여 Message Broker 등을 활용하는 여러 통신 방식이 존재한다. 그리고 메인 데이터베이스로는 MongoDB를 사용하고있다.

시스템에서 복잡한 비지니스 요구사항을 만족하기위해서 하나의 명령(Command)이 실행 됐을 때 여러 서비스 컴포넌트 간 데이터 일관성을 위해 다양한 방식의 통신이 발생하고 이를 제어하기 위해선 하나의 유즈케이스에 많은 의존성이 필요해질 때가 있다.

다음과 같이 복잡한 비즈니스 요구사항과 시스템 제약사항을 만족하기위해 다양한 의존 관계가 형성되었을 때, 어떻게 시스템을 설계하여 테스트 대역을 설정하는지에 대한 경험을 KOS 예약 시스템의 예제 코드 기반으로 공유하고자한다.

시스템 요구사항

  • 동일한 내원객(Visitor)은 동 시간대 중복 예약을 할 수 없다.

  • 예약 완료 시 해당 내원객에게 예약 확정 SMS 문자가 발송되어야한다.

  • 예약 완료 시 내원객의 스케줄 내역의 읽기 모델이 만들어져야한다 (ref. CQRS)

    • 위 조건을 만족하기 위해, KOS에서는 여러 도메인 서비스 에서 변경이 발생한 사건들(Domain Events)을 발행하여 Downstream 서비스 에서 요구사항에 맞추어 처리하고있다.

예약 요구사항을 만족하기 위한 아키텍처 구성도

(위 이미지는 요구사항을 이해하기 위한 보충 설명일 뿐이니, 깊게 이해를 필요로하지않는다.)

스케줄 B.C 에서 예약 성공 후 발행되는 Reserved 도메인 이벤트를 Notification B.C 와 스케줄의 리드모델 이벤트 처리기 등에서 이벤트를 소비하여 요구사항을 만족하는 과정을 추상적으로 도식화해보면 위와 같다.

이제 위 요구사항을 만족하는 예제 코드를 구현해보면 아래와 같이 작성할 수 있다.

예약 유즈케이스 구현 (As-Is)

public class ReserveUseCase {
    private final ScheduleEventStore eventStore;
    private final VisitorClient visitorClient;
    private final ReservationOverlapValidator reservationOverlapValidator;

    public ReserveUseCase(
        ScheduleEventStore eventStore,
        VisitorClient visitorClient,
        ReservationOverlapValidator reservationOverlapValidator
    ) {
        this.eventStore = eventStore;
        this.visitorClient = visitorClient;
        this.reservationOverlapValidator = reservationOverlapValidator;
    }

    public void execute(Reserve command) {
        Visitor visitor = visitorClient.read(command.getVisitorId());

        if (
            !reservationOverlapValidator.validate(
                visitor.getId(),
            command.getStartDateTime(),
            command.getEndDateTime()
        )) {
            throw new RuntimeException("overlapped reservation");
        }

        eventStore.collectEvents(
              List.of(new Reserved(...))
        );
    }
}

public class ScheduleEventStore {
    private final IScheduleEventRepository mongoRepository;
    private final IMessageBus kinesisClient;

    public void collectEvents(String streamId, Iterable<Object> events) {
          mongoRepository.save(...)
          kinesisClient.send(...)
          // ...
    }

    public List<Object> queryEvents(String streamId) {
          return mongoRepository.findAll(...)
          // ...
    }
}

public class VisitorClient {
    private final WebClient webClient;

    Visitor read(String visitorId) {
        return webClient.get(...)
        // ...
    }
}

public class ReservationOverlapValidator {
    private final JedisPool redis;

    Boolean validate(
        String visitorId,
        OffsetDateTime requestedStartDateTime,
        OffsetDateTime requestedEndDateTime
    ) {
          redis.exists(key)
          // ...
    }
}

VisitorClient

내원객의 식별값(VisitorId)으로 내원객 정보를 읽어오는 Reader이다

Spring Webflux의 WebClient를 이용하여 Visitor Component를 호출한다

ReservationOverlapValidator

중복 예약 방지 요구사항을 만족하기 위해 필요한 내원객의 식별값(VisitorId)과 예약 시간을 파라미터로 받는 중복 예약 검증기이다

⇒ 예약 시 Redis에 캐싱하여 내원객 + 예약 시간 기반으로 중복 예약인지 검증한다

EventStore

KOS는 Event Sourcing을 사용하고있어 명령이 발생하면 관련된 도메인 이벤트들을 스토어에 적재하고있는데, EventStore는 도메인 이벤트들을 관리하는 스토어이다

MongoDBAWS Kinesis Data Stream을 이용하여 Message의 Store And Forward 역할을 한다

Test (As-Is)

@SpringBootTest
public class ReserveUseCaseTest() {

    @Autowired
    private final ScheduleEventStore eventStore;

    @Autowired
    private final VisitorClient visitorClient;

    @Autowired
    private final ReservationOverlapValidator reservationOverlapValidator;

      @Test
      void `Sut는 내원객은 동 시간대 중복 예약을 할 수 없다`() {
            //Arrange
            var sut = new ReserveUseCase(
                eventStore,
                visitorClient,
                reservationOverlapValidator
            );

            var command = new Reserve(...);

            //Act & Assert
            assertThrows(RuntimeException.class, () -> sut.execute(command));
      }

  //...
}

현재의 구조에서는 테스트를 실행하기 위해서 Redis, WebClient, AWS Kinesis Data Stream, Mongo 등 많은 의존성이 필요한 상황이다. 즉, 모든 인프라 계층의 자원들에게 강결합이 되어있는 지금은 Docker와 같은 컨테이너 서비스를 이용하여 Redis, MongoDB, LocalStack 등의 컨테이너를 띄워서 테스팅을 진행할 수 밖에없는 상황인것이다.

게다가 Docker를 사용하기 힘든 환경이라면, 개발자들은 로컬 장비에 MongoDB, Redis를 설치받아야하고 AWS와 같은 자원은 테스트를 위한 서비스 계정을 추가로 관리해야할 수 있다.

Q. 그렇다면 테스팅을 할 때 실제 인프라 자원들을 실행시키는것이 좋지않은것인가?

아니다. (강력) 몇가지 필요 조건에 부합하기만 한다면 실제 인프라 자원과 객체를 사용하는것이 우선되어야한다고 생각한다.

필자가 고려하는 테스트 대역을 사용해야하는 조건은 크게 아래와 같다.

  • 실제 구성 요소 제어가 불가능한 의존성인가?

  • 실제 구성 요소의 출력이 비일관적인가?

  • 실제 구성 요소가 테스트 실행 시 속도가 느린가?

위 조건에 N개라도 부합한다면, 필자는 고민없이 필요 인프라 자원을 실행시키고, 실제 운영 객체(depended-on component, 이하 DOC)를 사용할 것이다. 그렇게 운영이 가능하면 당연하게도 실제 운영에서도 동일한 동작을 할것이기때문에 실패하는 거짓 음성(False-Nagative)의 상황을 제거할 수 있다.

테스트를 작성하는 이유에는 몇 가지 이유들이 있지만 필자는 안정감을 유지하기 위해 꼭 필요한 도구라고 생각한다. 만약 가짜 객체를 주입시키고, 가짜 인프라를 실행시키면 분명 자동화 테스트에서 다 성공해서 운영에 배포했는데, 시스템 장애를 맞이할 수 있는것이다.

(실제로 필자는 Redis TTL 제어가 필요한 유즈케이스 논리에서 대역을 사용했다가 운영에서 TTL에 음수가 들어갈 수 있는 상황이 발생해서 장애를 맞이한적이 존재한다 🫨 만약 실제 Redis 의존성을 주입해서 사용했다면, 테스트에서 잡혔을것이다.)

이제 위 대역 조건을 통해, 선정해보면 AWS Kinesis는 실행 속도가 느리고, 자원 세팅이 번거롭다. (협업자들의 자원 세팅의 비용도 중요하다 생각한다.) 그리고 Visitor API를 제어하는 WebClient는 제어가 불가능하다고 생각했다. Visitor API에 대한 논리를 요청자가 제어하는것은 불가능하다.

반면, Redis와 MongoDB는 실행 속도가 빠르고 입력에 따른 출력이 일관적이다. 제어도 주어진 인터페이스 내에선 가능하다고 생각했다. 이제 위의 결정대로 의존성을 주입할 수 있는 설계가 되도록 리팩터해보자.

예약 유즈케이스 구현 (To-Be)

public class ReserveUseCase {
    private final EventStore eventStore;
    private final VisitorReader visitorReader;
    private final ReservationOverlapValidator reservationOverlapValidator;

    public ReserveUseCase(
        EventStore eventStore,
        VisitorReader visitorReader,
        ReservationOverlapValidator reservationOverlapValidator
    ) {
        this.eventStore = eventStore;
        this.visitorReader = visitorReader;
        this.reservationOverlapValidator = reservationOverlapValidator;
    }

    public void execute(Reserve command) {
        Visitor visitor = visitorReader.read(command.getVisitorId());

        if (
            !reservationOverlapValidator.validate(
                  visitor.getId(),
              command.getStartDateTime(),
              command.getEndDateTime()
          )
        ) {
            throw new RuntimeException("overlapped reservation");
        }

        eventStore.collectEvents(
            List.of(new Reserved(...))
        );
    }

public interface EventStore {
    void store(String streamId, Iterable<Object> events);
    List<Object> queryEvents(String streamId);
}

public interface VisitorReader {
    Visitor read(String visitorId);
}

public interface ReservationOverlapValidator {
    Boolean validate(
        String visitorId,
        OffsetDateTime requestedStartDateTime,
        OffsetDateTime requestedEndDateTime
    );
}

예약 유즈케이스를 기존 인프라스트럭쳐 레벨의 의존성들을 의존하던것들을 제거하고 순수한 인터페이스만을 의존하고있는 구조로 변경하였다. 이제 리팩터링 한 내용을 기반으로 테스트를 작성해보면 다음과 같다. (도메인 모델 입장에서 순수한 인터페이스를 의존하고 다양한 의존성을 주입할 수 있는 환경을 만드는게 주 목적이다)

Test (To-Be)

@SpringBootTest
public class ReserveUseCaseTest() {  
    @Autowired
    private final JedisPool redis;

    @Autowired
    private final ScheduleMongoRepository mongoRepository;

  @Test
  void `Sut는 동일한 내원객은 동 시간대 중복 예약을 할 수 없다`() {
        //Arrange
        var sut = new ReserveUseCase(
            new ScheduleEventStore(
                mongoRepository,
                new MessageBusStub()
            ),
            VisitorReaderStub(),
            new ReservationOverlapValidator(redis)
        );

        var command = new Reserve(...)

        //Act & Assert
        assertThrows(RuntimeException.class, () -> sut.execute(command));
  }

  //...
}

public class VisitorReaderStub implements VisitorReader {
    @Override
    public Visitor read(...) { }
}

public class MessageBusStub implements IMessageBus {
    @Override
    public void send(...) { }
}

현재 테스트 대상(System Under Test, 이하 SUT)의 의존성을 조립하는 부분을 보면, 인터페이스를 구현한 테스트 대역들을 주입하고있는 것을 확인할 수 있다. 이 때, Redis와 Mongo와 관련된 의존성인 실제 운영 객체를 주입하고있는것도 볼 수 있다.

테스트 대역에는 Stub, Fake, Spy, Mock, Dummy 와 같은 많은 방법들이 있는데, 이번엔 예시 코드에서 사용한 개념들에 대해서만 예시 코드로 알아본다.

테스트 대역(Test Double)

테스트 환경에서 실제 객체를 대신하여 사용되는 객체들을 의미한다. 이는 테스트의 독립성을 유지하고, 외부 의존성에 대한 제어를 통해 다양한 시나리오를 검증하는데 유용하게 사용된다

Fake

SUT가 의존하는 실제 구성 요소(DOC)를 대체하여 동일한 기능을 간단하게 구현한 객체이다. 검증 목적으로 사용되지는 않으며, 제어점이나 관찰점으로 사용되지 않는다

e.g.

public class InMemoryEventStoreFake implements EventStore {
    private final Map<String, List<Object>> store;

    public InMemorySaasEventStore() {
        this.store = new HashMap<>();
    }

    @Overrride
    public void collectEvents(String streamId, Iterable<Object> events) {
        store.computeIfAbsent(
            streamId,
            x -> new ArrayList<>()
        )
        .addAll(events);
    }

    @Override
  public List<Object> queryEvents(String streamId) {
      return store.getOrDefault(streamId, Collections.emptyList());
  }
}

위 시나리오에서는 실제 구성 요소(DOC)를 사용하고있지만, 만약 메인으로 검증하고자 하는 DB가 아니라면, 위와 같이 구현하여 사용할 수 있다.

Stub

SUT가 의존하는 실제 구성 요소(DOC)를 대체하여, 미리 정의된 응답이나 동작을 제공하는 객체이다. SUT가 특정 조건이나 상황에서 어떻게 동작하는지를 테스트하기 위해 사용되며, 내부 동작이나 호출된 메서드를 기록하지는 않는다

e.g.

public class VisitorReaderStub implements VisitorReader {
        private Visitor visitor;

        public VisitorReaderStub(Visitor visitor) {
            this.visitor = visitor;
        }

    @Override
    public Visitor read(String visitorId) {
        return new Visitor(
            visitor.getId(),
            visitor.getName()
        ); 
    }
}
  • 내부 간접 입력을 통해 미리 정해진 결과를 반환하는 VisitorReaderStub의 구현체이다.

  • 만약 실제 구성 요소 객체(DOC)를 SUT에 주입했다면, WebClient를 이용하여 Visitor Component로 HTTP 요청을 보내야한다. 그러나 Stub 객체를 읽어 간접 출력한 값으로 테스트를 진행하도록 했다.

Spy

실제 구성 요소(DOC)를 대체하면서, 호출된 메서드나 전달된 인자를 기록하는 객체이다. SUT의 상호작용을 검증하는 데 사용되며, Stub처럼 미리 정의된 동작을 제공할 수도 있다.

public class EventStoreSpy implements EventStore {
    private final List<Object> eventLogs = new ArrayList<>();

    @Override
    public void collectEvents(Iterable<Object> events) {
        this.eventLogs.addAll(events);
    }

    public List<Object> getEvents() {
        return this.eventLogs;
    }
}
  • 위와 같이 Spy는 실제 해당 메서드가 호출(출력)되었는지 관찰하고 싶을 때 사용한다.

  • 만약 유즈케이스가 실행되었을 때, EventStore의 collectEvents가 호출되었는지, 그리고 예상한 형태의 이벤트가 수집되었는지 검증하고자 할 때 사용한다.

    • 대게 Spy는 명령이 몇 번 실행되었는지, 또는 메세지가 정상적으로 발행을 했는지 등을 검증할 때 사용한다.

Classists vs Mockists

테스트 주도 개발(Test-Driven Development, TDD)이 널리 확산되면서, 테스트 작성 방법에 대한 다양한 철학과 접근 방식이 등장했다. 이 중에서도 Classists와 Mockists는 테스트의 초점과 목적에 따라 나뉘는 두 가지 대표적인 테스트 철학이다.

두 학파 중 어떤 방식을 선택하느냐는 테스트하려는 시스템의 복잡성과 팀의 철학에 따라 달라질 수 있다고 생각하나.. 최근엔 전체적인 시장에서도 Classists쪽으로 많이 기운것같다. (기울게 된 이유도 명확하다 생각한다)

Classists

실제 객체를 사용하여 검증하고, 통합된 시스템의 동작을 확인하며 상태 검증(State Verification)에 중점을 둔다. 시스템이 의도한 대로 동작하는지를 검증하기 위해 실제 객체(DOC)를 사용하거나 ‘간혹 상황에 따라’ 이를 대체하는 테스트 대역을 활용한다.

Mockists

특정 메서드 호출이나 객체 간 상호작용을 검증한다. 행위 검증(Behaviour Verification)에 주로 중점을 두고, 객체 간의 상호작용이 올바르게 이루어졌는지 확인한다.

어떤 방식을 더 선호하나요?

필자는 Classists 방식을 선호한다.

Mockists의 접근 방식은 행위 검증을 통해 간단한 반환값을 특정하여 메서드 호출의 정확성을 확인할 수 있는 장점이 있지만, 상태 검증이 부족하여 거짓 음성(False Nagative)이 발생할 수 있기때문이다.

이로 인해, 테스트는 통과하나 실제 구성 요소(DOC) 동작은 실패하는 경험을 할 수 있다. 그래서 가능하다면 실제 객체를 사용하고있으며, 외부 시스템과 협력이 필요한 경우에는 테스트 대역(Test Double)을 사용하여 Classists 방식의 테스팅을 진행하고있다.

마치며

테스트를 작성하는 가장 큰 이유가 안정감이라고 생각한다.

그 안정감을 유지하기 위해선 거짓 음성, 거짓 양성을 최소화해야한다. 그러기위해서 우리는 테스트 대역을 지양해야하고, 실제 객체를 사용하는것이 좋다. 이번 글에선 대역에 대한 기준을 정하고, 코드를 재설계하여 필요한 의존성을 주입해서 사용하는 과정을 소개해보았다.

0
Subscribe to my newsletter

Read articles from Jeongkyun An directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Jeongkyun An
Jeongkyun An