Testcontainers 로 integration 테스트하기

오늘 이야기 할 내용은 Testcontainers라이브러리로 integration 테스트를 해보도록 하자.

Testcontainers는 java 라이브러리로 (다른 언어도 존재는 함) 데이터베이스, 메시지 큐 또는 웹 서버와 같은 의존성이 있는 모듈에서 테스트 할 수 있게 도와주는 도구이다. 기본적으로는 docker 컨테이너 기반으로 동작하기에 docker가 설치는 되어 있어야 한다.

만약 docker가 설치 되어 있지 않다면 docker를 설치 해야 된다. 내부적으로는 도커의 이미지를 땡겨와 실행하기 때문이다.

Testcontainers 다양한 테스트 프레임워크를 지원한다. junit4 부터 junit5, Spock등 java 진영에서 주로 사용하는 테스트 프레임워크를 지원하니 만약 다른 프레임워크를 사용한다면 조금은 추가적인 작업이 필요로 할 수도 있다. 하지만 걱정할 필요는 없다. 테스트 프레임워크에 종속성이 없어도 충분히 사용은 가능하다.

사실은 지원한다는 것도 Testcontainers의 라이플사이클만 지원하는 정도이다. 도커의 실행과 종료 정도만 관여하기에 사용해도 되고 원하지 않는다면 사용하지 않아도 좋다.
만약 다른 테스트 프레임워크를 사용한다면 라이플사이클 정도만 추가 하면 된다.

그럼 한번 간단하게 사용해보자. 필자는 junit5를 사용했고 테스트로는 mongodb를 사용할 예정이다.

일단 적절하게 디펜더시를 받도록 하자.

// 기타 몽고 관련 및 테스트 관련 디펜더시

testImplementation("org.testcontainers:testcontainers:1.12.0")

위의 문법은 gradle kotlin dsl 이다.

기본 문법


import com.mongodb.MongoClient; import com.mongodb.client.FindIterable; import com.mongodb.client.MongoCollection; import com.mongodb.client.MongoDatabase; import org.bson.Document; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.testcontainers.containers.GenericContainer; import java.util.function.Consumer; import static org.assertj.core.api.Assertions.assertThat; class JDefaultTestcontatinersTests { private GenericContainer mongoDbContainer = new GenericContainer("mongo:4.0.10"); @BeforeEach void setup() { mongoDbContainer.start(); } @Test void default_mongo_db_test() { int port = mongoDbContainer.getMappedPort(27017); MongoClient mongoClient = new MongoClient(mongoDbContainer.getContainerIpAddress(), port); MongoDatabase database = mongoClient.getDatabase("test"); MongoCollection<Document> collection = database.getCollection("users"); Document document = new Document("name", "wonwoo") .append("email", "test@test.com"); collection.insertOne(document); FindIterable<Document> documents = collection.find(); assertThat(documents).hasSize(1); documents.forEach((Consumer<? super Document>) it -> { assertThat(it.get("name")).isEqualTo("wonwoo"); assertThat(it.get("email")).isEqualTo("test@test.com"); }); } @BeforeEach void close() { mongoDbContainer.stop(); } }

GenericContainer(imageName) 의 생성자에는 docker image 명을 작성하면 된다. 필자는 몽고디비로 테스트하기 위해 mongo:4.0.10 라는 이미지를 사용했다.

실제로 외부 포트는 (몽고디비에 경우) 27017 로 열리지 않는다. 외부의 영향을 받지 않기 하기 위해 그런듯 싶다. 외부 포트를 가져오기 위해서는 getMappedPort(originalPort) 메서드를 사용해서 가져올 수 있다.

또한 GenericContainer 에는 docker 와 관련된 많은 메서드들이 있다.
예를들어 Environment, Command, Label, Network, dependsOn등 docker와 관련된 커멘드들을 사용할 수 있으니 필요하다면 사용해도 된다.


private GenericContainer mongoDbContainer = new GenericContainer("mongo:4.0.10") .withEnv("FOO", "BAR") .withCommand("command test") .withLabel("TEST","LABEL") .withNetwork(Network.newNetwork()) .dependsOn(new MongoDbContainer());

그 후에 해당 테스트를 진행 하면된다. 너무 간단하다. 사실 뭐 별거 없다.

junit5

junit5를 사용해서 작성도 해보자.


testImplementation("org.testcontainers:junit-jupiter:1.12.0")

testcontainers 에서 지원해주는 junit5를 디펜더시 받아야 한다. 이 또한 너무 간단하다.


/// 기타 import import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; @Testcontainers class JJunit5TestContatinersTests { @Container private GenericContainer mongoDbContainer = new GenericContainer("mongo:4.0.10"); @Test void junit5_mongo_db_test() { int port = mongoDbContainer.getMappedPort(27017); MongoClient mongoClient = new MongoClient(mongoDbContainer.getContainerIpAddress(), port); MongoDatabase database = mongoClient.getDatabase("test"); //(대충 테스트한다는 내용) } }

@Testcontainers 어노테이션은 junit5의 Extension 클래스로 해당 @Container 어노테이션이 달린 컨테이너를 실행시키는 어노테이션이다.

@BeforeEach 같이 실행전에 start() 메서드를 실행 할 필요없이 사용하면 되는 그런 어노테이션이다.
junit4경우에는 @Rule 어노테이션을 사용하면 된다.


@Rule public GenericContainer mongoDbContainer = new GenericContainer("mongo:4.0.10");

docker compose

docker compose 파일도 지원한다.

version: '2'
services:
  redis:
    image: redis:3.2
    ports:
      - 6379:6379
    volumes:
      - ~/data/redis:/data

  mongo:
    image: mongo:4.0.10

    ports:
      - 27017:27017

적절하게 docker compose 파일을 작성후에 테스트도 가능하다. volumes을 사용한다면 특정한 공간에 데이터가 저장되어 데이터가 계속 쌓이게 되므로 유의해야 한다.

@Testcontainers
class JDockerComposeTests {

    private final int REDIS_PORT = 6379;
    private final int MONGO_PORT = 27017;

    @Container
    private final DockerComposeContainer dockerCompose = new DockerComposeContainer(new File("src/test/resources/docker-compose.yaml"))
            .withExposedService("redis_1", REDIS_PORT)
            .withExposedService("mongo_1", MONGO_PORT);


    @Test
    void docker_compose_test() {

        int port = dockerCompose.getServicePort("mongo_1", MONGO_PORT);
        String host = dockerCompose.getServiceHost("mongo_1", MONGO_PORT);

        //(대충 테스트 한다는 내용)
    }

}

위와 같이 docker compose 파일을 작성후에 DockerComposeContainer 클래스를 이용해서 사용하면 된다. 사용법은 기존과 비슷하다.
참고로 꼭 파일명은 docker-compose 일 필요는 없다. 필자는 docker-compose는 docker-compose 라는 파일명이 익숙해서 그렇게 작성하였다.

spring boot data mongodb

기본적으로 사용법은 배워 봤다. 사실 그리 어렵지 않다. 적당한 디펜더시와 docker의 이미지만 설정한다면 쉽게 사용할 수 있다.

이번에는 우리가 자주 사용하는 spring boot를 사용해서 테스트를 진행 할 것이다. Spring boot 경우에는 일반적으로 자동설정에 있는 몽고 설정을 사용을 한다. 그래서 위와는 조금 다르게 설정을 해야 한다. 한번 살펴보자.

@DataMongoTest
@ContextConfiguration(initializers = MongoDbContainerInitializer.class)
class JSpringDataTestcontatinersTests {

    private final TodoRepository todoRepository;

    @Autowired
    private JSpringDataTestcontatinersTests(TodoRepository todoRepository) {

        this.todoRepository = todoRepository;
    }


    @Test
    void spring_data_mono_test() {

        todoRepository.save(new Todo(null, "wonwoo", "test@test.com"));

        List<Todo> todo = todoRepository.findAll();

        assertThat(todo).hasSize(1);

        todo.forEach(it -> {

            assertThat(it.getName()).isEqualTo("wonwoo");
            assertThat(it.getEmail()).isEqualTo("test@test.com");

        });

    }

}

필자는 @DataMongoTest 어노테이션을 이용해서 테스트를 작성했다. 물론 다른 nosql이나 rdb의 경우도 비슷하게 작성하면 된다.

MongoDbContainerInitializer 라는 클래스가 눈에 띈다. 이건 필자가 작성한 코드이다. 사실 별거 없고 위와 비슷하게 GenericContainer 실행하는 클래스이다.

public class MongoDbContainerInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {

    private GenericContainer mongoDbContainer = new GenericContainer("mongo:4.0.10");

    @Override
    public void initialize(ConfigurableApplicationContext applicationContext) {
        mongoDbContainer.start();

        TestPropertyValues.of(

                "spring.data.mongodb.uri=mongodb://" + mongoDbContainer.getContainerIpAddress() + ":" + mongoDbContainer.getMappedPort(27017) + "/test"

        ).applyTo(applicationContext);
    }
}


spring boot 의 자동설정인 spring.data.mongodb.uri이라는 프로퍼티에 해당 url을 끼워 넣기 위한 작업을 하는 클래스이다. 위에서 말했다시피 실제 외부 포트는 27017이 아니기 때문이다.

조금 더 간단하게 사용하기 위해 메타 어노테이션을 이용해도 된다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@DataMongoTest
@ContextConfiguration(initializers = MongoDbContainerInitializer.class)
@DirtiesContext //optional
public @interface JDataMongoIntegrationTest {


}

위와 같이 작성 후에 테스트를 해보자.

@JDataMongoIntegrationTest
class JSpringDataCustomizedTestcontatinersTests {

    private final TodoRepository todoRepository;

    @Autowired
    private JSpringDataCustomizedTestcontatinersTests(TodoRepository todoRepository) {
        this.todoRepository = todoRepository;
    }

    @Test
    void spring_data_mono_customized_test() {

        // (대충 테스트 한다는 내용)

    }

}

위와 같이 작성해도 문제 없이 잘 테스트가 진행 될 것이다.

오늘은 이렇게 Testcontainers의 몇가지 기능을 살펴봤다.

Testcontainers 에는 많은 기능이 있지만 기본적인 테스트를 하기 위한 정도로 작성하였다. 추후에 embedded 관련해서도 작성해보려고 한다.

필자의 경우에는 Mongo image를 직접 작성했지만 Testcontainers 에서 지원해주는 모듈들이 많다. 예를들어 MySQLContainer, PostgreSQLContainer, MSSQLServerContainer, Db2Container, CouchbaseContainer, Neo4jContainer, ElasticsearchContainer, KafkaContainer, RabbitMQContainer, MockServerContainer 등등 더 많은 컨테이너들을 기본적으로 지원해주고 있다. 적절한 디펜더시만 받으면 된다. (근데 왜 몽고는 없지..?)

Testcontainers 의 또 다른 장점(?)은 spring boot의 지원을 잘해주고 있다. 뭐 많은 이유가 있겠지만 가장 큰 이유는 해당 프로젝트에 피보탈 개발자분이 한분 계신다. 또한 Spring boot 도 Testcontainers 종종 사용해서 테스트를 진행하고 있다.

사실 원래는 코틀린으로 먼저 작성을 해서 예제의 클래스들이 모두 J~~로 시작한다. 아직 대부분의 개발자분들이 java에 익숙하기 때문에 자바로 추가적으로 작성하였다. 만약 코틀린에도 관심이 있다며 여기(github)에 자바와 코틀린 소스 모두 있으니 참고하면 되겠다.

아직 필자도 많은 부분을 알고 있지는 않다. 필자도 이제 테스트할 때 도입할 예정이라 기본적으로 부분부터 살펴봤다. 사실 이정도만 알아도 큰 문제는 되지 않아 보인다. 쓰다보면 괜찮아지겠지..

Spring 5.2 와 Spring boot 2.2 추가된 Test 기능들

오늘 알아볼 내용은 Spring 과 Spring boot의 새로운 Test 기능들을 알아볼 예정이다. 아주 많은 기능을 이야기 할건 아니지만 주로 많이 사용될 만한 것들을 살펴볼 예정이다.

일단 Spring 5.2에 추가된 2가지 내용을 살펴보도록 하자.

생성자 @Autowired

Spring5 부터는 junit5를 적극적으로 지원하기 시작했다. junit5를 사용하기전에는 다음과 같은 코드를 작성해야 했었다.

@RunWith(SpringRunner.class)
@DataJpaTest
public class UserRepositoryTests {

    @Autowired
    private AccountRepository accountRepository;

    @Test
    public void findAllTest() {

        assertThat(accountRepository.findAll())
                .isEqualTo(Collections.singletonList(new Account(1L, "wonwoo", 22)));
    }

}

위의 코드는 junit4로 작성된 코드이다. junit4에선 디폴트 생성자가 있어야 했다. 그래서 위와 같이 @Autowired을 필드에 주입했어야 했지만 junit5부턴 그럴필요 없다.

junit5에선 생성자를 통해 주입받을 수있게 되었다.

@DataJpaTest
class UserRepositoryTests {

    private final AccountRepository accountRepository;

    @Autowired
    private UserRepositoryTests(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }


    @Test
    void findAllTest() {

        // (대충 테스트한다는 내용)
    }

}

여기까지는 Spring 5.0에서도 가능했던 기능이지만 이제는 생성자에도 @Autowired 애노테이션을 제거할 수도 있게 되었다. 하지만 바로 되는건 아니고 두가지 설정 방법이 존재한다.

첫 번째로는 애노테이션을 사용하는 방법이 존재한다.

@DataJpaTest
@TestConstructor(autowireMode = AutowireMode.ALL)
class UserRepositoryTests {

    private final AccountRepository accountRepository;

    private UserRepositoryTests(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    @Test
    void findAllTest() {

       // (대충 테스트한다는 내용)

    }

}

AutowireMode.ALL 타입말고 AutowireMode.ANNOTATED 타입이 더 존재하는데 이건 그냥 안쓰는 것과 동일한 기능으로 보인다. 실제 어노테이션을 사용한다는 의미의 타입으로 보여진다.

다음은 두번째 방법인데 이건 전역으로 설정할 수 있다. 아마도 이 기능 때문에 AutowireMode.ANNOTATED 타입이 존재하는듯 싶다. 전역으로 설정한 값보다 개별로한 설정이 나중에 동작해 전역 설정을 덮어 쓰게 되어있다.

전역 설정은 아주 간단하다. spring.properties 파일을 클래스패스에 만들어서 spring.test.constructor.autowire.mode=ALL 이와 같이 작성해주면 전역설정이 된다.

spring.test.constructor.autowire.mode=ALL

설정 후에 아래와 같이 @Autowired 와 @TestConstructor 애노테이션이 없어도 테스트가 가능해진다.

@DataJpaTest
class UserRepositoryTests {

    private final AccountRepository accountRepository;

    private UserRepositoryTests(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    @Test
    void findAllTest() {

       // (대충 테스트한다는 내용)

    }

}

그럼 위와 같이 작성해도 테스트를 진행할 수 있다. 위에서 말했다시피 전역설정후 개별 테스트에서는 전역 설정을 사용하고 싶지 않다면 아래와 같이 다시 작성해주면 된다.

@DataJpaTest
@TestConstructor(autowireMode = ANNOTATED)
class UserRepositoryTests {

    private final AccountRepository accountRepository;

    private UserRepositoryTests(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    @Test
    void findAllTest() {

        // (대충 테스트한다는 내용)
    }

}

그럼 위의 코드는 테스트를 진행 할 수 없다. 만약 테스트를 진행하고 싶다면 @Autowired을 작성해줘야 한다.

kotlin dsl web test

spring 5.2 부터는 webmvc에도 dsl을 지원한다. 기존에는 (5.0에는) webflux만 지원했지만 이제는 우리가 흔히 사용하는 webmvc도 지원하게 되었다.
이렇게 webmvc를 지원하면서 webmvctest도 dsl을 지원하게 되었다.

사용법은 아주 간단하다.

위와 같이 MockMvc 에 확장함수들이 추가 되었고 MockHttpServletRequestDsl, ResultActionsDsl, MockMvcResultMatchersDsl, MockMvcResultHandlersDsl 등 몇가지 dsl 클래스들이 새로 추가가 되었다.

POST 테스트 또한 비슷한 맥략이다.

이제는 Spring boot와 관련된 기능들이다. 소소한 것들이니 참고만 하면 되겠다.

junit5

spring boot 2.2부터는 기본적으로 junit5를 디펜더시 받고 있다. 뿐만 아니라 junit vintage engine 도 추가적으로 디펜더시를 받고 있어 만약 junit5가 익숙하지 않는 분들은 그냥 junit4를 이용해도 무방하다.

import org.junit.Test;

public class Junit4Tests {

    @Test
    public void test() {

    }
}

import org.junit.jupiter.api.Test;

class Junit5Tests {

    @Test
    void test() {

    }
}

원하는 버전 아무거나 사용해도 무방하다.

ApplicationArguments

사용할일이 있을 수 있겠지만 아직은 무엇에 어떻게 사용할지 생각이 나지는 않는다. 이런기능도 있으니 만약 사용할일이 있다면 사용해도 좋다.

@SpringBootTest를 사용해 테스트를 한다면 @SpringBootTest의 args 속성을 넣어 아규먼트를 꺼내 사용할 수 있다.

@SpringBootTest(args = "--foo=bar")
class TestArgs {

    private final ApplicationArguments applicationArguments;

    @Autowired
    private TestArgs(ApplicationArguments applicationArguments) {
        this.applicationArguments = applicationArguments;
    }

    @Test
    void test() {
        List<String> optionValues = applicationArguments.getOptionValues("foo");

        //대충 argument를 사용한다는 내용
    }

}

위와 같이 ApplicationArguments 인터페이스를 주입받은 후에 원하는 테스트에 arguments를 꺼내 사용할 수 있다. ApplicationArguments API는 문서를 통해 확인 할 수 있다.

OutputCaptureExtension

마지막으로 OutputCapture와 관련된 내용이다. 예전 junit4에서 사용했던 OutputCaptureRule과 동일한 기능이지만 이것은 junit5 위한 Extension 기능이다.
간단하게 OutputCapture 의 기능을 설명하자면 log나 print를 찍은, 즉 console에 찍히는 내용을 캡쳐해서 그 내용을 검증하는 기능이다.
예전에 junit4의 경우엔 다음과 같이 작성하였다.

public class OutputCaptureRuleTests {

    @Rule
    public final OutputCaptureRule output = new OutputCaptureRule();

    @Test
    public void test() {
        System.out.println("hello world");
        assertThat(output).contains("hello world");
    }

}

콘솔에 hello world라는 내용을 찍고 그것을 검증하는 내용이다. @Rule 이라는 애노테이션은 junit4에만 존재하기에 junit5에선 사용할 수 없다. 그래서 spring boot 2.2부터 OutputCaptureExtension 클래스가 추가 되었다. 사용법은 아주 간단하다. 위의 junit4와 비슷하다.

@ExtendWith(OutputCaptureExtension.class)
class OutputCaptureExtensionTests {

    @Test
    void outputTest(CapturedOutput capturedOutput) {

        System.out.println("hello world");
        assertThat(capturedOutput).contains("hello world");

    }
}

위와 같이 작성해도 되며 다른 곳에도 사용한다면 생성자에 주입받아 사용해도 된다.

@ExtendWith(OutputCaptureExtension.class)
class OutputCaptureExtensionTests {

    private final CapturedOutput capturedOutput;

    OutputCaptureExtensionTests(CapturedOutput capturedOutput) {
        this.capturedOutput = capturedOutput;
    }

    // ...
}

오늘은 이렇게 test와 관련된 내용을 작성해봤다. 몇가지는 필자도 자주 사용할 것 같지만 ApplicationArgumentsOutputCaptureExtension 은 그리 많이 사용할 것 같진 않다. 예전에도 OutputCaptureRule 은 거의 사용하진 않았으니 말이다.

만약 webmvc dsl test나 webmvc dsl 에 관심이 있다면 은 여기에 소스가 있으니 참고하면 되겠다.

HandlerMapping 와 HandlerAdapter

오늘 이야기할 내용은 spring의 HandlerMapping, HandlerAdapter 인터페이스에 대해 알아보도록 하자.
spring web, webflux 비슷한 아키텍처로 동작하기 때문에 예제는 그냥 web으로 설명하도록 하겠다.

먼저 이글을 읽고 오는 것을 추천하지만 굳이 보지 않아도 된다.

HandlerMapping

이 인터페이스는 해당 요청 정보를 기준으로 어떤 컨트롤러를 사용할 것 인가를 결정하는 인터페이스이다. 간단히 말해서 해당 url로 해당 컨트롤러(핸들러)를 선택하는 기준이 되는 인터페이스이다.
HandlerMapping 인터페이스는 여러 구현체를 가지고 있는데 한개씩 살펴보도록 하자.

BeanNameUrlHandlerMapping

이 구현체는 클래스명 그대로 빈명을 url로 사용한다는 매핑 전략이다. 아주 심플하게 사용할 수 있는 매핑전략이므로 설정 또한 간단하다.

@Bean("/accounts")
public AccountController accountController() {
    return new AccountController();
}

빈명에 슬러쉬(“/”) 가 존재하면 매핑 전략에 대상이 된다. 실제로 저 url이 요청할 url이랑 매핑된다는 이야기이다.
위와 같이 설정하면 /accounts는 저 controller로 매핑된다는 것을 의미한다.

@Component("/accounts")
@Controller("/accounts")
public class AccountController {

}

사실 위와 같이 @Component 이나 @Controller 어노테이션 둘중 하나를 이용해도 된다.

위와 같이 BeanNameUrlHandlerMapping 을 사용하려면 설정을 해줘야 한다. 직접적으로 설정을 해줘도 무방하지만 @EnableWebMvc 어노테이션을 선언해주면 자동으로 BeanNameUrlHandlerMapping 클래스가 자동으로 빈으로 등록된다. xml 경우엔 <mvc:annotation-driven/>을 사용하면 된다. (사실 xml은 정확하지 않다.)
참고로 Spring boot는 사실 딱히 설정할 필요는 없다.

SimpleUrlHandlerMapping

이것역시 간단한 설정만으로 사용할 수 있다. 이 매핑은 url과 controller를 map에 담아 매핑할 수 있는 전략이다.

@Bean
SimpleUrlHandlerMapping urlHandlerMapping() {
    SimpleUrlHandlerMapping simpleUrlHandlerMapping = new SimpleUrlHandlerMapping();
    simpleUrlHandlerMapping.setOrder(Ordered.LOWEST_PRECEDENCE - 2);

    Map<String, Object> mapping = new HashMap<>();
    mapping.put("/accounts", accountController());
    simpleUrlHandlerMapping.setUrlMap(mapping);
//or 
//  Properties properties = new Properties();
//  properties.put("/accounts", "accountController");
//  simpleUrlHandlerMapping.setMappings(properties);

    return simpleUrlHandlerMapping;
}

Map 과 Properties 아무거나 사용해도 상관없고 value에는 인스턴스를 넣어도 되고, 빈 명을 입력해도 무방하다.

여기에서 order를 준 이유는 resourceHandlerMapping(기본 값 : Ordered.LOWEST_PRECEDENCE – 1) 보다 먼저 urlHandlerMapping이 먼저 동작해야 된다. 만약 order를 설정 하지 않았다면 resourceHandlerMapping 에는 모든 요청이 걸리게 되어 있다. 그래서 원하는 매핑으로 동작하지 않는다.

RequestMappingHandlerMapping

사실 우리는 해당 매핑 전략을 대부분 이용하고 있다. @RequestMapping 어노테이션을 사용해서 개발한다면 해당매핑전략을 사용하고 있다는 뜻이다.

@Controller
@RequestMapping("/accounts")
public class AccountController {

}

위와 같이 손쉽게 @RequestMapping (@Get, @Post…) 어노테이션을 사용하면 된다. 설정방법은 BeanNameUrlHandlerMapping와 동일하게 EnableWebMvc 어노테이션을 선언하거나 xml 경우엔 <mvc:annotation-driven/>을 선언하면 된다. 이것 역시 spring boot 경우엔 아무 설정할 필요는 없다.

여기까진 매핑 전략에 대해서 알아봤다. 사실 여기까진 어떤 url에 어떤 컨트롤러를 결정하는 것까지 만이다. 실제 어떤 메서드를 호출하는지는 HandlerMapping 에선 알 수 없다.
그래서 우린 HandlerAdapter 인터페이스를 알아야 한다.

HandlerAdapter

이 인터페이스는 HandlerMapping에서 결정된 핸들러 정보로 해당 메서드를 직접 호출해 주는 스펙이다. 이 역시 여러개의 구현체가 존재한다. 한개씩 알아보도록 하자.

SimpleControllerHandlerAdapter

Controller 인터페이스를 사용하면 위의 어뎁터를 사용한다.

public class AccountController implements Controller {

    @Override
    public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception {
        ///... 
        return null;
    }
}

위와 같이 Controller 인터페이스를 구현하면 SimpleControllerHandlerAdapter 통해 해당 메서드가 호출 된다.

HttpRequestHandlerAdapter

이것 역시 HttpRequestHandler 인터페이스를 사용하면 해당 어뎁터를 사용한다.

public class AccountController implements HttpRequestHandler {

    @Override
    public void handleRequest(HttpServletRequest request, HttpServletResponse response)  {
        ///...
    }
}

SimpleServletHandlerAdapter

클래스명 그대로 Servlet을 구현하면 된다. 아마도 기본적으로는 설정이 되어 있지 않다. 만약 사용을 원한다면 해당 어뎁터를 빈으로 등록해야 한다.

public class AccountController extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {

    }
}

RequestMappingHandlerAdapter

이것은 아마도 우리가 가장많이 쓰는 어뎁터이다. 실제로는 RequestMappingHandlerMapping 과 대응되는 클래스이다. 만약 RequestMappingHandlerMapping 매핑 전략을 사용하고 싶다면 해당 어뎁터를 이용해야 한다.

해당 어뎁터에선 어떤 메서드를 호출해야 할지 결정해야하므로 이 클래스엔 여러 정보들이 담겨서 있다. 왜냐하면 위의처럼 특정한 인터페이스를 구현한게 아니라 리플렉션을 이용해서 메서드를 호출해야 하므로 해당 파라미터 타입과 리턴타입들의 정보를 알아야 하기 때문에 해당 정보들을 파싱해줄 HandlerMethodArgumentResolver, HandlerMethodReturnValueHandler 인터페이스가 존재한다. 이외에도 더 많은 정보들을 가지고 있지만 여기선 중요한게 아니므로 생략 하겠다.

@Controller
@RequestMapping("/accounts")
public class AccountController {


    @GetMapping("/{id}")
    public String hello(@PathVariable Long id) {

       //...
    }
}

위의 클래스는 RequestMappingHandlerAdapter 통해 hello(Long) 이란 메서드가 호출된다.

HandlerMapping 와 HandlerAdapter 의 연관관계

위에서도 언급을 했지만 RequestMappingHandlerAdapterRequestMappingHandlerMapping은 연관관계가 있다. RequestMappingHandlerMapping 전략을 사용하고 싶다면 꼭 RequestMappingHandlerAdapter 어뎁터를 이용해야 한다.

하지만 다른 전략들은 연관이 있어도 되며 없어도 된다.

예를들어 다음과 같다.

@Bean("/accounts")
public AccountController accountController() {
    return new AccountController();
}

위와 같이 Mapping 전략은 BeanNameUrlHandlerMapping을 사용하면서 여러 어뎁터를 사용할 수 있다.

public class AccountController implements Controller {

    @Override
    public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception {
        //...   
        return ..;
    }
}


public class AccountController implements HttpRequestHandler {

    @Override
    public void handleRequest(HttpServletRequest request, HttpServletResponse response)  {

        //...
    }
}


public class AccountController extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        super.doGet(req, resp);
    }
}

BeanNameUrlHandlerMapping 전략을 사용했지만 어뎁터는 아무 어뎁터나 사용할 수 있다. SimpleUrlHandlerMapping 전략도 마찬가지로 가능하다.

Spring에선 다양한 Mapping 전략과 다양한 Adapter를 제공함으로써 좀 더 유연하게 개발 할 수 있게 한다.

특별한 경우가 아니라면 직접적으로는 다른 전략들은 사용하지 않을 듯 싶다.
사실 간접적으로(?)는 우린 가끔 사용하고 있다.

class WebConfig implements WebMvcConfigurer {

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {

    }

    @Override
    public void addViewControllers(ViewControllerRegistry registry) {

    }
}

ResourceHandlerRegistry, ViewControllerRegistry 는 내부적으로 SimpleUrlHandlerMapping 전략을 사용하고 있다.

오늘은 이렇게 HandlerMapping 와 HandlerAdapter 에 대해서 살펴봤다. spring web(서블릿)과 관련해서만 살펴봤지만 관심있으면 webflux도 살펴보는 것도 나쁘진 않아 보인다. 비슷한 아키텍처로 구현되어 있긴 하겠지만…

참고로 spring 5.2 부터는 RouterFunctionMapping 클래스가 추가 되었다. 리액티브에만 있던 매핑 전략이 서블릿에도 추가가 되었다.(아마 코틀린을 위한 거겠지?) 아직 릴리즈는 되지 않았지만 한번 살펴보는 것도 좋겠다.
관심있으면 여기를 스리슬쩍 참고하자.