[Spring]WebClient를 이용한 REST API 호출 Spring Framework

Spring에서 REST API를 호출하기 위한 라이브러리로 RestTemplate이 있는데 이 녀석은 동기 버전이기 때문에 Webflux와 같이 사용하기 어려워 문서를 찾다보니 WebClient가 있어 사용해 보았다.

참고로 JAVADOC에 보면 RestTemplate에 대해서는 다음처럼 NOTE가 있는데

NOTE: As of 5.0, the non-blocking, reactive org.springframework.web.reactive.client.WebClient offers a modern alternative to the RestTemplate with efficient support for both sync and async, as well as streaming scenarios. The RestTemplate will be deprecated in a future version and will not have major new features added going forward. See the WebClient section of the Spring Framework reference documentation for more details and example code.

요약하면 RestTemplate은 deprecated될 예정이며 더 나은 체계를 가지는 WebClient가 있으니까 그걸 써라 정도의 의미로 보인다.(비동기 버전인 AsyncRestTemplate은 이미 deprecated되어 있음) WebClient에서 동기형태로 호출할 수 있으니 RestTemplate도 대체가 가능하긴한데 그건 아래 코드에서 살펴보기로 한다.

WebClient로 비동기 호출을 했을때 RestTemplate의 동기 호출에 대해서 다르거나 좋은 점은 다음과 같다.

1. WebClient는 Netty등의 비동기 통신 라이브러리를 기반으로 한다.
2. 이벤트 루프 구조를 사용하므로 최대 스레드에 대한 관리등의 고민을 할 필요가 없다. 스택오버플로에서 WebClient에 대한 max threads에 대한 문의에 다음과 같은 코멘트가 있는데

Spring WebClient is a No-Blocking IO http client while ReactorClientHttpConnector is a Reactor-Netty based implementation. Said that I can suggest to do not warry about connection pool but focus on a complete no blocking service call. 

WebClient는 비동기 Netty 기반이니까 커넥션 풀에 대한 고민은 하지 말라는 이야기고 구조가 다르기 때문에 RestTemplate의 스레드 관련 설정 값도 없다.

3. 기본적으로 비동기 처리이므로(동기 형태로도 가능) REST 호출 후 추가적으로 기다릴 필요가 없을 때나 다른 처리와의 의존성이 없을때 다른 처리가 이 REST 호출을 기다리지 않아도 된다.

WebClient를 사용하기 위해서는 Gradle 기준으로 다음의 의존성을 요구한다. 

compile 'org.springframework.boot:spring-boot-starter-webflux'
compile 'io.projectreactor:reactor-test'
의존성의 내용처럼 WebClient는 WebFlux(Spring의 비동기 HTTP 처리)와의 연동을 전제로 구성되어있고 기본적인 동작은 비동기 처리를 전제로 한다.

WebClient는 builder를 통해 생성하며 builder는 thread-safe 하지 않으므로 각 호출 혹은 Injection Point에 대해서 별도의 인스턴스를 생성해야 한다. 때문에 RestTemplate을 Bean으로 만들어 사용하는 것과 가장 비슷하게 사용하려면 다음처럼 코딩해서 사용하면 된다. webflux에 대한 의존성을 추가하면 WebClient.Builder의 의존성은 Spring이 처리해주므로 Builder에 대한 Bean은 직접 생성하지 않아도 된다.

@Service
public class TestService {
    private final WebClient webClient;

    public TestService(WebClient.Builder webClientBuilder) {
        this.webClient = webClientBuilder.baseUrl("http://www.test.com").build();
    }

...

이렇게 생성한 webClient는 다음처럼 비동기 호출하여 사용할 수 있다.

long userId = 10;

        Mono<String> result = webClient
                .get()
                .uri(String.format("/getuser", userId))
                .retrieve()
                .bodyToMono(String.class);

        result.subscribe(response -> {
System.out.println("response : " + response);
        }, e -> {
            System.out.println("error message : " + e.getMessage());
        });
위의 호출에서는 단순히 응답 내용을 얻기 위해서 rerieve()를 사용했지만 exchange()를 사용하면 헤더 등의 부가 정보에 접근할 수 있다. 비동기 호출이므로 WebClient의 반환값은 Mono<T>로 받아 subscribe로 처리하지만 동기의 경우에는 block() 메서드를 사용하면 RestTemplate 처럼 동기식으로 처리할 수 있다.

        String result = webClient
                .get()
                .uri(String.format("/getuser", userId))
                .retrieve()
                .bodyToMono(String.class).block();
System.out.println("response : " + result);

[Spring]샤딩 구조의 데이터베이스에 대한 DataSource 사용 Spring Framework

동일한 스키마 구조를 가지는 DB 혹은 다른 데이터저장소에 대해서 특정 조건에 의해 하나를 선택하는 경우(가장 대표적인 형태는 유저정보를 여러 데이터저장소에 분산 저장하기 위해서 샤딩구조를 사용하는 경우)가 종종 발생한다. 최근 개발된 NoSQL등의 데이터저장소는 애초부터 분산 저장을 고려하고 만들어지기 때문에 이런 요구에 대응하기 쉽지만 RDBMS의 경우에는 난감한 경우가 있다.

이 내용을 살펴보기 전의 전제는 다음과 같았다.

1. Spring, MySQL, Mybatis 사용
2. 완전히 동일한 스키마를 사용하는 MySQL이 복수 존재함
3. 특정한 조건에 의해서 각각의 MySQL에 접근함
4. DB 선택에 의해서 기존 코드의 변경을 최소화함

DB갯수만큼 매퍼도 만들고 Repository 클래스도 만들면 그냥 되긴 하지만 DB가 늘어날 수록 코드가 변경되어야 하니까 당연히 좋은 방법은 아니다. 이전에 Mybatis를 안 쓰는 상태에서 유저번호 % 나누기 숫자로 샤딩할때는 DataSource를 여러개 만들어서 JdbcTemplate에 그때 그때 DataSource를 연결하기도 했는데 그건 사전 조건에 맞지 않으니까 일단 패스.

샤딩 구조를 사용하는 경우도 많으니까 Spring에서도 당연히 해당 부분에 대한 대응이 있을거라 생각해 구글링을 해보았고 다행히 관련된 구현이 있어 적용해 보니 잘 되었다. AbstractRoutingDataSource 클래스를 통해 구현이 되는데 간략한 구현은 다음과 같다

spring.datasource.primary.jdbc-url=jdbc:mysql://primary.com:3306/userdb
spring.datasource.primary.username=dbuser
spring.datasource.primary.password=password

spring.datasource.alter.jdbc-url=jdbc:mysql://secondary.com:3306/userdb
spring.datasource.alter.username=dbuser
spring.datasource.alter.password=password

위와 같은 형태로 사용할 DataSource를 properties 파일에 등록해준다. 여기서는 2개만 등록했지만 갯수는 더 많아도 상관없다.

여러 DataSource중에서 현재 사용되어야할 것을 알려주는 역할을 하는 AbstractRoutingDataSource의 상속 클래스를 구현한다.

public class RoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected  Object determineCurrentLookupKey() {
        return DbContextHolder.getDbType();
    }
}

DbContextHolder는 ThreadLocal로 사용할 DataSource의 형태를 저장한다(여러 Request가 동시 호출되면서 각 Request의 DataSource가 막 바뀌면 안되니까.)

public class DbContextHolder {
    private static final ThreadLocal<DbType> contextHolder = new ThreadLocal<DbType>();

    public static void setDbType(DbType dbType) {
        contextHolder.set(dbType);
    }

    public static DbType getDbType() {
        return (DbType)contextHolder.get();
    }

    public static void clearDbType() {
        contextHolder.remove();
    }
}

DbType은 내가 참조한 예제 코드에서는 enum을 사용했지만 어차피 Object면 다 되니까 다른 클래스나 primitive type도 될거 같다. 해보진 않았지만.

public enum DbType {
    PRIMARY,
    ALTER
}

여기까지 사전 작업이 되었으면 일반적으로 DataSource를 사용하는 것 처럼 DataSource에 대한 Bean을 만들어준다.(애노테이션이던 XML이던)

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.primary")
    public DataSource primaryDataSource() {
        return DataSourceBuilder.create().type(HikariDataSource.class).build();
    }

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.alter")
    public DataSource alterDataSource() {
        return DataSourceBuilder.create().type(HikariDataSource.class).build();
    }

다음 코드로 위에 구현한 RoutingDataSource(AbstractRoutingDataSource를 상속구현한)에 만들어둔 DataSource를 등록한다.

    @Bean
    public DataSource clientDataSource() {
        HashMap<Object, Object> targetDataSources = new HashMap<Object, Object>();

        DataSource primary = primaryDataSource();
        DataSource alter = alterDataSource();

        targetDataSources.put(DbType.PRIMARY, primary);
        targetDataSources.put(DbType.ALTER, alter);

        RoutingDataSource routingDataSource = new RoutingDataSource();
        routingDataSource.setTargetDataSources(targetDataSources);
        routingDataSource.setDefaultTargetDataSource(primary);

        return routingDataSource;
    }

RoutingDataSource에는 선택할 수 있는 DataSource가 두개 등록되었고 선택하지 않을때는 primary DataSource가 사용된다. 이렇게 만든 RoutingDataSource를 이전에 사용하던 DataSource처럼 Mybatis의 SessionFactory에 연결한다.

    @Bean
    public SqlSessionFactory sqlSessionFactory() throws Exception {
        SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
        sqlSessionFactoryBean.setDataSource(clientDataSource());
        sqlSessionFactoryBean.setConfigLocation(new ClassPathResource("mybatis-config.xml"));
        ClassLoader cl = this.getClass().getClassLoader();
        ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(cl);
        Resource[] resources = resolver.getResources("mapper/*.xml");
        sqlSessionFactoryBean.setMapperLocations(resources);
        return sqlSessionFactoryBean.getObject();
    }

여기 까지 했으면 나머지는 기존에 사용하던 Mybatis와 동일하게 코드를 구성해 주면된다. 

Controller에서는 다음과 같은 형태로 DB를 선택해 사용한다.

    @GetMapping(value = "/test/user/{dbType}/{id}")
    public ResponseEntity<Response> view(@PathVariable("dbType") DbType dbType, @PathVariable("id") long id) {
        DbContextHolder.setDbType(dbType);

        User user = userService.getUser(id);

        Response response = new Response();
        response.putContext("email", user.getEmail());

        return new ResponseEntity<>(response, HttpStatus.OK);
    }

위 코드에서는 DB를 요청의 PathVariable에 올려서 사용했는데(/test/user/PRIMARY/5 처럼) 실제 사용시에는 유저ID를 나눈 나머지를 사용하거나 유저의 서버 정보를 가져와 사용하는 형태로 사용될 것이다.

여기에서는 Mybatis에 엮어서 사용했지만 DataSource를 스위칭하는 것이니까 RDBMS 말고도 쓸 수 있을 거 같은데 생각해보면 NoSQL쪽은 분산처리 구조가 있으니까 다른 방법을 쓰지 않을까...

1 2 3 4 5 6 7 8 9 10 다음