내가 런던파(Mockist)에서 고전파(Detroit)로 바뀐 이유

Libienz·2025년 1월 14일

저는 이전까지 테스트 코드에 관심이 많지 않았습니다. 테스트 코드는 저에게 기능이 정상 동작함을 보장해주는 그 이상 그 이하도 아니었죠. 하지만 우아한테크코스에서 테스트 코드의 추가적인 가치들을 깨닫게 되면서 저는 테스트 코드에 관심이 아주 많아졌습니다.

내가 생각하는 테스트코드의 가치

  • 빠른 피드백 루프 제공자로서의 가치
  • 애플리케이션 설계 기법으로서의 가치
  • 기능 명세로서의 가치
  • 자동화된 품질 보증 도구로서의 가치
  • 협업 도구로서의 가치

관심이 많아지니 테스트 코드를 잘짜고 싶어졌습니다. 그래서 우아한테크코스 과정을 수행하며 크루들과 여러 이야기를 나누며 스스로의 테스트 철학을 쌓아나가기도 했습니다. 크루들과 테스트 관련 이야기를 나눈 것중에 가장 기억에 남는 주제가 있는데요, 바로 고전파와 런던파의 이야기입니다. 이야기하다가 부족해서 스터디에서 토론까지 진행했던 경험이 있는데요, 오늘의 포스팅 주제가 바로 고전파와 런던파입니다.

다만 이번 포스팅에서는 고전파와 런던파의 개념에 대해서는 다루지 않아요. (해당 개념이 궁금하시면 고전파와 런던파 키워드로 검색하여 학습하실 수 있습니다.) 이번 포스팅에서는 제가 런던파에서 고전파로 바뀐 이유를 각 분파의 장단점을 기조로 말씀드립니다.

여러분들은 고전파인가요 런던파인가요? 저는 런던파였다가 고전파로 바뀌었습니다. 토론하기를 좋아하는 저와 의견을 나눠보는 것은 어떠신가요. 우선은 제 의견을 이어지는 글로 전달드려보겠습니다.


내가 런던파였던 이유

미션을 수행하면서 단위 테스트를 작성해보기 시작했을 때 처음에는 런던파의 접근법이 매력적으로 느껴졌었습니다. 제가 런던파에 매력을 느꼈던 부분들은 다음과 같은데요 어떤 부분에서 어떠한 매력을 느꼈는지 하나씩 설명드리겠습니다.

  • 테스트 환경 구성 및 코드 작성이 쉽다
  • 테스트 실행속도가 빠르다.
  • 좁은 검증 범위로 촘촘한 단위의 검증이 가능하다.

테스트 환경 구성 및 코드 작성이 쉽다.

먼저 제가 느꼈던 런던파 방식의 첫번째 장점에 대해 설명드리겠습니다. 첫번째 장점은 바로 테스트 환경을 구성하고 테스트 코드를 작성하는 것이 쉬워진다는 것입니다.

예시를 들어보겠습니다. Persistence Layer의 Repository를 포함한 Service 클래스를 테스트한다고 가정해봅시다. 고전파의 접근법은 실제 데이터 소스 설정 파일과 테스트 데이터베이스의 가용성을 포함한 실제 배포 환경의 요소를 테스트 환경에서도 동일하게 고려해야 합니다. 이러한 요구사항은 테스트 환경을 구성하는 데 많은 시간을 소요하게 만들며, 결과적으로 테스트 코드 작성 난이도를 높이는 요인이 됩니다. 다음은 대역을 사용하지 않고 실제 데이터베이스를 사용하기 위해 설정해야 하는 설정 정보 예시입니다.

테스트 데이터베이스를 위한 데이터베이스 설정 yml 파일

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/test_db
    username: test_user
    password: test_password
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQL8Dialect
  test:
    database:
      replace: none # 테스트 환경에서 실제 데이터베이스 사용
logging:
  level:
    org.hibernate.SQL: DEBUG # Hibernate SQL 쿼리 로깅

반면 런던파는 테스트 대역(Mock 객체)을 사용합니다. 테스트를 위한 외부 의존 서비스들을 구성하기 위해 위와 같은 설정파일 및 config 클래스를 작성할 필요가 없죠. 그렇기에 일반적으로 테스트 대역은 의존성을 직접 구성하는 것보다 쉽습니다.

테스트 대역의 stubing을 활용한 sliceTest 예시

@ExtendWith(MockitoExtension.class)
class ProductServiceTest {

    @Mock
    private ProductRepository productRepository;

    @InjectMocks
    private ProductService productService;

    @Test
    void findProductById_ValidId_ReturnsProduct() {
        // Arrange
        Product product = new Product(1L, "Test Product");
        given(productRepository.findById(1L)).willReturn(Optional.of(product));

        // Act
        Product result = productService.findProductById(1L);

        // Assert
        assertThat(result.getName()).isEqualTo("Test Product");
        verify(productRepository).findById(1L); // Verify interaction
    }
}

이러한 특성으로 인해 런던파는 고전파의 방식보다 훨씬 더 간단하게 테스트 환경을 구성하고 테스트 코드를 작성할 수 있도록 도와줍니다. 어떤 복잡한 관계가 있어도 테스트 코드 작성 난이도는 굉장히 낮아지죠. 제가 느꼈던 런던파의 매력 중 하나입니다.


런던파의 슬라이스 테스트는 실행속도가 빠르다.

제가 느꼈던 런던파의 두번째 장점은 실행속도가 빠르다는 것입니다.
런던파 테스트 철학의 핵심은 테스트 대역을 적극적으로 활용하여 특정 클래스나 모듈의 동작을 독립적으로 검증하는 데 있습니다. 이러한 접근법은 테스트 실행 속도 면에서 매우 유리하기도 한데요, 왜 런던파의 슬라이스 테스트가 빠른지, 하나씩 살펴보겠습니다.

우선 선술드렸던 바와 같이 런던파의 슬라이스 테스트는 애플리케이션의 특정 계층만 테스트 대상으로 삼으며, 나머지 계층이나 외부 시스템(Database, 메시지 브로커 등)은 모두 테스트 대역으로 대체합니다. 이를 통해 테스트 환경에서 발생할 수 있는 다음과 같은 시간 소모들을 절약할 수 있습니다.

  • 데이터베이스 연결 및 쿼리 실행 시간
  • 네트워크 호출 및 응답 대기 시간
  • 애플리케이션 부팅 시 전체 컨텍스트 로드 시간

위의 작업들은 웹 애플리케이션에서 흔히 병목 지점으로 불리는 만큼 시간이 오래걸립니다. 가장 오래 걸리는 부분을 절약하니 런던파의 슬라이스 테스트가 테스트 속도를 얼마나 빠르게하는지 알 수 있는 부분입니다.

이 뿐만이 아닙니다. 런던파 방식은 또 테스트 대상 계층만 로드하기 때문에 테스트 실행 시 경량화된 컨테이너를 사용할 수 있기에 시간을 단축합니다. @WebMvcTest나 @DataJpaTest, @MockBean을 사용하여 제한적으로 로드되는 컨텍스트는 일반적인 컨텍스트 로드보다 실행속도가 훨씬 빠릅니다. 이 역시 테스트 시작부터 종료까지 소요되는 시간을 단축시킵니다.

또 고전파의 통합 테스트나 E2E 테스트에서는 테스트 전후로 데이터베이스 초기화 및 정리 작업이 필요합니다. 그러나 런던파의 슬라이스 테스트에서는 실제 데이터베이스를 사용하지 않으므로, 이러한 작업이 필요하지 않습니다. 이는 단순히 시간 절약뿐 아니라 테스트 코드 작성의 간소화에도 기여합니다.

결과적으로, 런던파의 테스트 철학은 테스트 실행 속도를 상당히 단축시킵니다. 그리고 테스트 실행속도가 빠르면 로컬 자원을 절약하고 CI/CD 파이프라인의 병목을 완화하며 개발자 경험을 향상시키죠.


좁은 검증 범위로 테스트를 핀포인트로 진행할 수 있다.

제가 느꼈던 런던파의 마지막 장점은 좁은 검증 범위로 코드 유지보수성을 높이는 것입니다.

런던파는 기본적으로 좁은 검증 범위를 설정하고 각 단위(unit)를 집중적으로 테스트할 수 있는 환경을 제공합니다. (좁은 검증 범위 외의 행동을 대역 처리 slice) 이러한 특징은 특히 복잡한 시스템에서 작은 단위의 검증을 가능하게 만들어 테스트 품질을 향상시킵니다. 좁은 검증 범위는 테스트 실패 시 문제의 원인을 빠르게 파악할 수 있도록 도와주고 독립적으로 유지보수 될 수 있게 도와줍니다.

간혹 Slice 테스트의 검증이 그 검증 범위로 인해 실제 통합테스트처럼 강력하지 않다고 주장하는 몇몇 개발자들을 볼 수 있습니다. 하지만 저는 반대로 물어봅니다. 자동차의 시운전이 잘 되었다고 엔진과 타이어를 테스트 하지 않을 것인가?

저는 촘촘히 작성된 slice 테스트가 모이면 그 자체가 하나의 통합테스트가 될 수 있다고 생각했었습니다. 좁은 범위의 테스트의 장점을 챙기며 단점까지 보완할 수 있다고 생각했고 이 역시 저에게 런던파의 큰 매력으로 다가왔습니다.


내가 고전파가 된 이유

저는 우아한테크코스 레벨 2에서 여러 미션들을 수행할 당시만 해도 열렬한 Mockist였습니다. Mockist는 죽었다고 말하는 크루들에게 앞서 말씀드린 런던파의 장점들을 계속 설득하기도 했었죠. 그런데 프로젝트를 하며 코드를 더 많이 작성해보면 해볼 수록 런던파는 허점이 많다고 결론내리게 되었습니다. 런던파의 단점들을 하나씩 체감하며 생각이 바뀌게 된 것인데요, 그래서 결국 저는 고전파로 바뀌게 되었습니다. 이어지는 글에서는 제가 고전파로 바뀐 이유들을 런던파의 단점들로 설명드리겠습니다.


우리는 기본적으로 뭘 모르는지 모르기에 슬라이스 테스트는 불안정하다

요즈음 상당히 중요하다고 생각되는 말이 있는데요, 바로 저희는 기본적으로 뭘 모르는지 모른다는 것입니다. 예전에 테스트 컨테이너 관련 포스팅에서 소개드린 적이 있는데요, 저는 프로젝트를 진행하며 MySQL InnoDB와 H2 데이터베이스의 잠금 메커니즘 차이를 몰라 팀원들과 오랜 시간 동안 문제를 해결해야 했던 적이 있습니다.

당시 상황은 다음과 같았습니다.

  • H2 기반의 테스트는 정상적으로 통과했습니다.
  • 그러나 MySQL을 사용하는 배포 환경에서 데드락(deadlock)이 발생했습니다.

이 문제는 왜 발생했을까요? 핵심 원인은 다음 두 가지입니다

1. InnoDB와 H2의 잠금 메커니즘 차이

InnoDB의 락 동작 방식과 H2의 락 동작 방식은 본질적으로 다릅니다. 그러나 이러한 차이를 사전에 인지하지 못했습니다.

2. 테스트 환경과 실제 환경의 불일치

테스트는 H2로 실행되었지만, 실제 배포 환경은 MySQL InnoDB였습니다. 이로 인해 CI에서 검증되지 못한 문제가 배포 후에야 드러나게 되었습니다.

테스트 환경의 중요성

이 경험을 통해 깨달은 점은, 실제 환경과 동일하거나 유사한 테스트 환경이 중요하다는 것입니다.
테스트 환경이 실제 환경과 다르다면, 그 차이로 인해 중요한 오류를 놓칠 수 있습니다. 특히 초보 개발자일수록 “무엇을 모르는지 모르는” 상황이 많기 때문에, 대역(Mock)으로 대체하는 대신 실제 의존성을 활용하는 테스트 환경을 구성하는 것이 도움이 됩니다.

이 경험은 테스트 코드가 자동화된 품질 보증 도구로서의 역할을 충분히 하지 못했던 사례입니다. 배포 전 오류를 완전히 검증하지 못한 것은 테스트 환경과 실제 환경의 불일치 때문이었습니다.

따라서, 저는 이제 가능하면 실제 환경과 유사한 테스트 환경을 구성하려고 노력합니다. 이를 통해 재현 가능한 모든 문제를 테스트 환경에서 발견하고, 자동화된 품질 보증 도구로서 테스트의 신뢰성을 높일 수 있다고 확신하게 되었습니다.

슬라이스 테스트는 리팩터링 내성이 낮다.

우아한테크코스 미션을 수행하며, 런던파 방식으로 작성된 테스트 코드를 유지보수해야 하는 상황이 있었습니다. 이 작업은 예상보다 훨씬 피로한 과정이었습니다. 미션 특성상 더 나은 설계를 위해 메서드를 자주 리팩터링해야 했는데, 메서드 시그니처가 바뀔 때마다 대역(Mock)의 동작도 수정해야 했기 때문입니다.

왜 이렇게 유지보수가 어려웠을까요? 그 이유는 바로 런던파 테스트 코드의 리팩터링 내성이 낮기 때문입니다.

리팩터링 내성이 낮다는 것의 의미

리팩터링 내성이 낮다는 것은 코드 구조가 변경될 때 테스트 코드가 의도치 않게 실패하거나, 수정해야 할 테스트 코드의 양이 많아지는 현상을 말합니다. 이는 런던파 테스트의 특징인 슬라이스 테스트에서 기인하는 문제입니다.

슬라이스 테스트는 대역 객체(Mock)를 활용하여 특정 단위의 동작을 검증하는 데 초점이 맞춰져 있습니다. 대역 객체의 행동은 테스트 대상의 의존 대상(예: 레포지토리, 서비스 등)의 세부 구현에 의존하기 때문에, 리팩터링 시 해당 설정을 함께 수정해야 합니다. 이는 테스트와 코드 간의 강한 결합을 초래하고, 유지보수 비용을 증가시키는 주요 원인이 됩니다.

의존하는 객체의 구현을 세부적으로 알고 있는 대역 테스트

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
    @Mock
    private OrderRepository orderRepository;

    @Mock
    private PaymentGateway paymentGateway;

    @InjectMocks
    private OrderService orderService;

    @Test
    void shouldPlaceOrderWhenPaymentSucceeds() {
        // given
        Order order = new Order("item1", 100);
        given(paymentGateway.processPayment(order.getAmount())).willReturn(true);

        // when
        String result = orderService.placeOrder(order);

        // then
        assertThat(result).isEqualTo("Order placed successfully");
        verify(paymentGateway).processPayment(order.getAmount());
        verify(orderRepository).save(order);
    }

    @Test
    void shouldThrowExceptionWhenPaymentFails() {
        // given
        Order order = new Order("item1", 100);
        given(paymentGateway.processPayment(order.getAmount())).willReturn(false);

        // when / then
        assertThatThrownBy(() -> orderService.placeOrder(order))
            .isInstanceOf(IllegalStateException.class)
            .hasMessage("Payment failed");
        verify(paymentGateway).processPayment(order.getAmount());
        verifyNoInteractions(orderRepository);
    }
}

런던파 테스트의 한계

구현 세부사항과의 강한 결합

대역 객체의 행동을 정의하기 위해 테스트 대상은 자신의 의존 대상이 어떻게 동작하는지 구체적으로 알아야 합니다. 리팩터링 과정에서 의존 대상의 메서드 시그니처나 내부 동작이 변경되면, 이를 사용하는 모든 테스트의 대역 설정도 함께 수정해야 합니다.

리팩터링 비용 증가

의존 대상의 변화는 테스트 실패로 직결됩니다. 대역 설정이 많은 테스트 코드일수록 수정해야 할 범위가 넓어지고, 테스트를 재작성하는 데 많은 시간이 소요됩니다.

강한 결합은 실제 내부 로직이 변경될 경우 테스트 코드 역시 수정하도록 만듭니다. 하나의 변경을 위한 작업이 여러 클래스로 퍼진다는 점에서 객체지향 설계 원칙이 깨지는 부분이라고 생각할 수도 있겠습니다.

저는 불필요한 관리지점을 늘리는 런던파 스타일의 테스트 코드에 지쳤고 이 경험 역시 제가 고전파로 돌아서게 하는 주요한 이유 중 하나입니다.


런던파는 수다스럽다

런던파 스타일로 작성된 테스트 코드 예시를 보겠습니다.

@ExtendWith(MockitoExtension.class)
class UserRegistrationServiceTest {

    @Mock
    private UserRepository userRepository;

    @Mock
    private EmailSender emailSender;

    @Mock
    private PasswordEncryptor passwordEncryptor;

    @InjectMocks
    private UserRegistrationService userRegistrationService;

    @Test
    void shouldRegisterUserWhenUsernameIsAvailable() {
        // given
        String username = "testuser";
        String password = "password123";
        String encryptedPassword = "encryptedPassword123";
        
        given(userRepository.existsByUsername(username)).willReturn(false);
        given(passwordEncryptor.encrypt(password)).willReturn(encryptedPassword);

        // when
        String result = userRegistrationService.registerUser(username, password);

        // then
        assertThat(result).isEqualTo("Registration successful");
        verify(userRepository).existsByUsername(username);
        verify(passwordEncryptor).encrypt(password);
        verify(userRepository).save(new User(username, encryptedPassword));
        verify(emailSender).sendWelcomeEmail(username);
    }

    @Test
    void shouldThrowExceptionWhenUsernameAlreadyExists() {
        // given
        String username = "testuser";
        String password = "password123";

        given(userRepository.existsByUsername(username)).willReturn(true);

        // when / then
        assertThatThrownBy(() -> userRegistrationService.registerUser(username, password))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage("Username already exists");

        verify(userRepository).existsByUsername(username);
        verifyNoInteractions(passwordEncryptor);
        verifyNoInteractions(emailSender);
    }
}

위의 테스트 코드는 User의 이름이 사용가능하면 회원가입이 가능하고 User이름이 이용 불가능한 경우 회원가입이 실패하는 두개의 테스트 코드입니다. 간단한 내용을 테스트하는 것 치고는 장황하지 않나요? 우아한 형제들의 기술 블로그에서 이러한 테스트 대역의 특징을 수다스럽다고 표현했는데 저 역시 수다스러움에 동의합니다.

이러한 수다스러움은 중요한 로직을 가리고 의미를 희석시키는 역할을 하게될 수 밖에 없습니다. 그리고 이는 유지보수의 불편함으로 다가갈 수 있죠.

런던파 테스트는 대역(Mock)의 사용으로 복잡한 의존성을 우회할 수 있지만, 모킹과 스터빙이 많아질수록 테스트 코드가 비즈니스 로직보다 장황하고 유지보수가 어려워질 수 있습니다. 이 수다스러움은 런던파가 가지고 있는 단점 중 하나로, 적절한 수준에서 테스트 범위를 조정하거나 고전파 스타일을 병행함으로써 해결할 수 있습니다.

이전 포스팅에서도 반복적으로 말씀드리는 부분인데 저는 코드가 해당 클래스의 중요한 명세만을 응집해야 한다고 생각합니다. 관심사가 아닌 코드가 클래스에 위치한다면 이를 읽는 개발자들은 피로해지고 어디에 집중해야 할 지 길을 잃기 쉬워집니다. 명세를 희석시키지 않기 위해서는 해당 클래스의 핵심 관심사 관련 코드만을 담아내야 합니다.

저에 철학에 크게 반하는 부분인데요 이러한 수다스러움 역시 제가 고전파로 돌아서게 만든 이유 중 하나입니다.


테스트가 실패하면 대역 설정을 살펴보게 된다.

제가 Mockist로 주변 개발자들과 열띤 토론을 할 때 한 크루가 저에게 이런 말을 해줬던 적이 있습니다.

??: 테스트 더블을 사용해서 테스트를 작성하면 그 테스트가 실패했을 때 대역 설정이 잘못된 것인지, 진짜 우리가 검증하고자 하는 부분이 잘못되었는지, 한번에 파악이 어렵다

대역 설정을 검토하는 테스트 실패 시나리오

  • 테스트는 실패
  • 하지만 실패 이유가 검증 대상의 문제인지, 아니면 대역 객체의 대역 설정 문제인지 명확하지 않음
  • 결국, 개발자는 대역 설정부터 실제 코드까지 모든 단계를 검토해야 함

저는 고전파도 마찬가지다, 의존 객체의 구현이 잘못된 것인지, 테스트 대상 객체 구현이 잘못된 것인지 파악할 수 없다라고 반박하긴 했는데요, 현재는 생각이 바뀌었습니다.

먼저 런던파 테스트가 실패하면 검증의 초점이 실제 비즈니스 로직이나 외부 의존성의 동작이 아니라 모킹된 대역의 설정으로 옮겨간다는 의견에는 동의합니다. 런던파 테스트가 실패했을 때 대역(Mock)의 설정을 먼저 살펴봐야 하는 이유는 대역 객체(Mock)가 실제 동작을 대체하는 방식 때문에 발생하는 문제로, 테스트 코드가 검증 대상의 실제 동작이 아니라 “대역의 기대 설정”에 지나치게 의존하기 때문입니다. 이는 반박할 수 없는 런던파의 단점입니다.

저는 고전파 테스트도 마찬가지다라고 이야기했지만 고전파 테스트는 궤가 다릅니다. 고전파 테스트는 애초에 검증 범위가 넓기에 의존 객체의 동작 검증도 관심사입니다. 런던파 테스트는 관심 없는 부분을 검증해야 하지만 고전파 테스트는 그렇지 않은 것이죠.

그리고 고전파 테스트는 의존 객체에 문제가 생기면 발견하기 비교적 쉽고 단일 모델로서 관리될 수 있는 반면 런던파 테스트는 발견하기 쉽지 않습니다.

true를 반환해야 하는 대역이 false를 Return하도록 잘못 작성된 상황

given(userRepository.existsByUsername("testuser")).willReturn(false); // 발견하기 어려운 잘못된 설정

대역(Mock)은 “테스트에서 기대하는 동작”을 스터빙으로 미리 정의합니다. 이 과정에서 대역 설정이 실제 비즈니스 로직과 다르거나 실수로 잘못 설정되었을 가능성은 항상 존재합니다. 위 설정이 잘못되었다면, 테스트는 잘못된 대역 설정 때문에 실패할 가능성이 높습니다. 이러한 지점을 추적하는 일은 런던파 테스트의 관심 범위 밖의 일일 수 있으며, 추적 비용이 높습니다.

크루의 의견에 동의하게 되어 고전파를 검토하게 된 일화 소개드렸습니다.

나는 고전파가 되었다.

저는 런던파의 여러 문제들을 유지보수하며 직접 느끼고 고전파로 전향하게 되었습니다. 특히 모르는게 많은 초보 개발자일수록 런던파의 테스트 전략보다는 고전파의 것을 차용하는 것이 중요하다고 생각하게 되었어요. 여러분들은 비슷한 경험이 있으신가요? 여러분은 고전파인가요 런던파인가요. 제가 제시한 의견 중에 반박하고 싶으신게 있다면 코멘트로 얘기 나누어 보고 싶습니다.

profile
추상보다 상세에 집착하는 개발자 리비(리비엔즈)입니다 🤗

0개의 댓글