Monolithic 아키텍처는 오랫동안 많은 개발자들이 좋아하며 사용한 아키텍처입니다. 하나의 커다란 서비스와 저장소로 구성된 이 아키텍처는 하나의 모듈이 변경되어도 전체 서버를 배포해야 했고 작은 기능의 문제점도 곧 서버 전체의 문제점이 되었습니다. 또한 많은 코드가 엮여 있기 때문에 변화하는 비지니스의 요구 사항에 유연하게 대처하지 못했습니다.
이러한 단점을 대체하기 위한 대안으로 MSA(Microservice Architecture)가 떠올랐습니다. Netflix에서 전격적으로 적용한 이 아키텍처에서 커다란 서비스는 작은 단위의 독립된 모듈로 쪼개지고 추가 기능이나 확장이 필요하면 해당 모듈만 변경하면 되었습니다. 많은 개발자들은 MSA에 열광하면서 기존 Monolithic 아키텍처를 MSA로 바꾸어가기 시작했습니다.
하지만 이들에게 황금빛 미래만 있는 것은 아니었습니다. 단일 애플리케이션 내부의 메서드 콜을 통한 정보 교환이 네트워크를 타고 모듈 간의 정보 교환으로 바뀌었기 때문입니다. 스레드 풀을 이용한 동기식 호출 방식은 코드가 간단하고 순차적으로 동작하기 때문에 개발자가 코드를 직관적이고 빠르게 작성할 수 있습니다. 하지만 이렇게 작성한 코드로 만든 서버도 빠르게 동작하고 많은 요청을 처리할 수 있을까요?
동기식 호출 방식에서는 상대편의 응답이 올 때까지 스레드는 기다려야(blocking) 합니다. 응답이 빨리 오면 그 기다림은 길지 않겠지만 만약 응답이 늦게 오면 서버가 요청에 대한 응답을 기다리는 데 스레드를 모두 소진해서 추가 요청을 처리할 수 없는 상태가 될 수 있습니다. 이는 요청을 한 클라이언트와 요청을 처리하는 서버 둘만의 문제는 아닙니다. 특히 MSA에서는 타임아웃이 발생할 정도의 지연이 발생하면 순식간에 다른 모듈로 전파되어 전체 시스템이 마비되는 등의 악영향을 끼칠 수 있습니다.
그렇다면 의문점이 생깁니다. 스레드가 서버로 요청을 하고 나서 꼭 그 응답을 기다리면서 아무 것도 하지 않고 대기해야 할까요? 스레드가 응답을 기다리지 않고 다른 일을 처리하다가 응답이 왔을 때 해당 일을 처리한다면 응답만 기다리면서 불필요하게 리소스를 점유하는 일은 없을 것입니다. 이러한 요구 사항에서 나온 것이 이벤트 루프를 이용한 비동기 프로그래밍입니다.
이벤트 루프를 활용하면 요청을 보내고 응답이 올 때까지 무작정 기다리는 대신 자신에게 할당된 다른 여러 소켓의 요청을 순차적으로 빠르게 처리합니다. 이제 여러분의 서버와 클라이언트의 스레드는 더이상 blocking되지 않습니다. Spring 생태계에서도 버전 5부터 도입된 WebFlux를 통해 비동기 프로그래밍을 본격적으로 도입하고 있습니다.
이 글에서는 기존 Spring MVC에서는 부족했던 비동기 프로그래밍 및 RPC를 대폭 보강하여 MSA에 어울리는 애플리케이션 개발의 첫 발을 내딛는 것을 목표로, WebFlux를 간단히 소개하고 LINE에서 개발 중인 오픈소스 Armeria에 대해서 알아봅니다.
Spring WebFlux 소개
기존의 Spring Framework(Spring MVC)
Spring MVC의 기본 형태는 XML 기반의 환경 설정과 DispatcherServlet 기반의 코드를 WAR로 패키징하고 이를 Tomcat 등 WAS의 docRoot에 배포한 뒤 WAS를 별도로 기동시켜야 애플리케이션이 동작하는 구조였다. 이런 구조는 단일 애플리케이션 구조에서는 나쁘지 않은 방법이었으나 필연적으로 아래와 같은 불편함을 야기했다.
- 관리의 이원화
- 특히 애플리케이션의 설정과 WAS의 설정을 별도로 관리해야 했다.
- 배포 및 설정의 불편
- WAS 중지 -> WAR 패키지 배포 -> WAS 기동 등의 단계가 필요했다.
- XML 기반의 설정은 애플리케이션을 기동해보기 전까지는 설정이 맞는지 틀렸는지 알 수 없었다.
- WAS 의존적인 구조
- Servlet 기반이기 때문에 WAS를 벗어나 단독으로 기동할 수 없었다.
위와 같은 문제점은 특히 MSA 시대에 와서는 치명적인 단점으로 자주 언급되었다. 이러한 어려움을 극복하기 위해 버전 3에서는 JavaConfig, 애너테이션 기반 설정이 도입되어 기존 XML 기반 설정의 불편함이 대폭 개선되었고 버전 4에서는 Spring Boot의 AutoConfig에 힘입어 설정의 대폭 간소화 및 WAS 엔진의 내장으로 단일 JAR 파일로 패키징하여 배포하는 것이 가능해졌다.
하지만 이러한 노력에도 Async Non-Blocking에 대한 미비한 지원이 발목을 잡고 있었다. JDK에도 java.nio나 람다 표현식과 같은 Modern-Language의 장점이 추가되고 Servlet 3.1 스펙에도 Async Servlet이 추가되는 등의 움직임이 있었지만 아직도 많은 애플리케이션들이 기존 방식대로 쓰이고 있었다.
Spring WebFlux 개발 배경
Servlet 3.1 스펙에서는 Non-Blocking I/O를 지원하지만 기존 Servlet API에 동기식(Filter, Servlet), Blocking(getParameter, getPart) 등 레거시가 많이 남아 있었다. 그래서 Spring WebFlux는 Spring MVC와 달리 Servlet과는 전혀 관계없이 만들어졌으며, 그렇기 때문에 더이상 WAS가 필요하지 않다. 기본 설정은 Netty(reactor-netty)를 기반으로 하지만 별도 설정을 통해 다른 Servlet 3.1 스펙을 준수하는 WAS 엔진(Tomcat, Jetty 등)도 사용할 수는 있다. 하지만 특정 WAS에 대한 의존성이 있는 코드 등의 특수한 경우를 제외하면 Netty로 사용하는 것을 권장한다. 또한 Project Reactor를 통해서 Reactive Programming을 지원한다.
Project Reactor란
Reactive Streams의 구현체이다. Reactive Streams는 단순히 JVM 기반에서 Async Non-Blocking 처리를 위한 스펙을 명세한 것이며 다른 구현체로는 RxJava, Akka Streams 등이 있다. 뒤에서 다룰 Armeria에서도 해당 스펙을 직접 구현했다. 같은 스펙 명세를 구현했으므로 구현체끼리는 모양새와 사용법이 거의 유사하다. Reactive Streams 및 Backpressure 관련 내용이 궁금하다면 LINE Engineering 블로그의 Armeria로 Reactive Streams와 놀자! – 1 포스팅을 참고한다.
기본적으로 Project Reactor를 사용하도록 되어 있고 이 글에서도 기본값인 Project Reactor를 기준으로 설명한다. 그러나 RxJava, JDK Flow API 등을 사용할 수도 있다(ReactiveAdapterRegistry.java 참고).
MVC에서 넘어오기
WebFlux와 MVC는 생각보다 많은 부분에서 상호 호환이 이루어진다. MessageConverter나 Exception 처리 같은 많은 부분에서 별도의 코드 수정 없이 바로 예전 코드를 가져다 쓸 수 있다. Spring MVC 공식 문서와 Spring WebFlux 공식 문서를 보면 많은 챕터에서 "Same as in Spring MVC", "Same as in Spring WebFlux"라고 적혀 있는 것을 볼 수 있다
맨 처음 와닿는 차이점은 바로 진입점 설정인데, 기존 MVC와 완벽히 동일하게 애너테이션 기반으로도 설정할 수 있다. @RequestMapping, @ResponseBody, @PathVariable 등 많은 애너테이션이 완벽하게 동일하게 동작한다. 아래 코드를 보자.
// 애너테이션 기반 라우팅
@GetMapping("/hello")
@ResponseBody
public Mono<String> getHello() {
return demoService.getHello();
}
// 함수 기반 라우팅
@Bean
public RouterFunction<ServerResponse> routes(DemoHandler demoHandler) {
return RouterFunctions.route(RequestPredicates.GET("/hello"), demoHandler::getHello);
}
이런 식으로 기존에 익숙했던 애너테이션 기반 설정으로도 WebFlux의 진입점을 설정할 수 있다. 애너테이션과 함수 중 개인이 편한 방법을 선택하여 개발해도 무방하다.
WebFlux 간단 예
"/demo"로 POST 요청을 받아 객체를 검증한 뒤 받은 객체에 true를 세팅하여 넘겨주는 예를 살펴보자. MVC와 코드 생김새는 똑같지만 한 가지가 다르다. 바로 반환형이다. MVC에서 일반적으로 사용하던 Plain Object를 사용할 수 없고 반드시 Publisher Object로 감싸서 반환해야 한다.
Plain Object는 기존에 우리가 사용하던 String, List 등의 일반적인 객체를 말한다
- DemoRouter.java
@Configuration
public class DemoRouter {
@Bean
public RouterFunction<ServerResponse> routes(DemoHandler demoHandler) {
return RouterFunctions.route(RequestPredicates.POST("/demo").and(contentType(APPLICATION_JSON)), demoHandler::post);
}
}
- DemoHandler.java
@Slf4j
@Component
@RequiredArgsConstructor
public class DemoHandler {
private final Validator validator;
private final DemoService demoService;
public Mono<ServerResponse> post(ServerRequest serverRequest) {
Flux<DemoResponse> ret = demoService.post(serverRequest.bodyToFlux(DemoModel.class).filter(demoModel -> {
Set<ConstraintViolation<DemoModel>> validationResult = validator.validate(demoModel);
if (validationResult.isEmpty()) {
return true;
} else {
return false;
}
}));
return ServerResponse.ok().body(ret, DemoResponse.class);
}
}
- DemoService.java
@Slf4j
@Service
public class DemoService {
public Flux<DemoResponse> post(Flux<DemoModel> demoModelFlux) {
return demoModelFlux.flatMap(demoModel -> {
log.debug("demoModel : {}", demoModel);
return Flux.just(new DemoResponse(demoModel, true));
});
}
}
- DemoModel.java
@Getter
@Setter
@ToString
@Validated
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DemoModel {
@NotEmpty
private String id;
private Map<String, String> data;
private LocalDateTime createDateTime = LocalDateTime.now();
private String source;
private double version = 1.0;
}
- DemoResponse.java
@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
public class DemoResponse {
private DemoModel content;
private boolean result;
}
Publisher Object는 위 예에 등장한 Mono 외에 Flux가 있으며 이 둘의 쓰임새가 다르다. Mono와 Flux는 모두 Project Reactor의 Publisher Object이다.
-
Mono는 0 또는 1개의 아이템이 나올 수 있음을 의미한다.
출처: https://projectreactor.io/docs/core/release/reference/#mono
-
Flux는 0 또는 n개의 아이템이 나올 수 있음을 의미한다.
출처: https://projectreactor.io/docs/core/release/reference/#flux
Mono<List<String>>과 Flux<String> 비교
둘 다 여러 개의 요소가 포함될 수 있음을 의미하지만 용도가 조금 다르다. 전자는 여러 개의 요소가 "한번에" 반환됨을 의미한다(Mono가 0 또는 1개이므로 List<String> 1개가 반환된다). 후자는 여러 개의 요소가 Stream 형태로 반환됨을 의미한다. 그래서 후자는 MediaType.TEXT_EVENT_STREAM_VALUE와 조합하면 SSE의 반환형으로 쓸 수 있다. 또한, Flux<String>에서 .collectList 메서드로 Mono<List<String>>으로 형 변환도 가능하다
Armeria 소개
Java 8, Netty, HTTP/2, Thrift, gRPC 기반의 비동기 RPC/REST 라이브러리
Armeria는 LINE에서 만들어서 오픈소스로 공개 중인 라이브러리이다. 고성능 비동기 Microservice를 손쉽게 제작할 수 있게 하는 것이 목표이며 이미 openzipkin, Slack, 카카오페이, SOCAR 등 많은 곳에서 활용되고 있다. Netty를 기반으로 하고 있으며 이희승 님이 메인 컨트리뷰터로 활동 중이다.
선택 계기
애플리케이션의 성능 향상을 위해 HTTP 대신 RPC를 도입하려다 보니 Spring Framework에서는 RPC(gRPC, Thrift)를 사용하는 것이 상당히 제한적이었다.
- gRPC
- Protocol Buffer의 encode/decode를 지원하기는 하지만 제한적이다(참고).
- 'Provide gRPC support [SPR-16358]' 이슈에서는 아래 두 artifact 중 하나의 사용을 권장한다.
- reactive-grpc는 RxJava와 Project Reactor를 지원하지만 Spring과 함께 쓰기 어렵다.
- thrift
- ServletRegistrationBean을 이용하여 TServlet을 등록하거나 별도의 TServer를 선언해야 한다.
- WebFlux는 Servlet 기반이 아니므로 ServletRegistrationBean으로 등록할 수 없다.
- TServer는 별도의 서버 구현체를 만들어야 하기 때문에 별도의 포트를 점유한다.
- ServletRegistrationBean을 이용하여 TServlet을 등록하거나 별도의 TServer를 선언해야 한다.
Armeria의 장점
단일 포트로 HTTP, RPC(gRPC, Thrift)를 동시에 서비스할 수 있다
HTTP 요청이 들어오면 Spring WebFlux로, RPC 요청이 들어오면 별도로 만든 RPC 핸들링 클래스로 요청을 분배한다. Armeria에서도 자체적으로 애너테이션을 이용하여 HTTP 요청을 처리할 수 있는 Annotated services를 지원한다.
RPC를 브라우저에서 테스트할 수 있다(Swagger-like)
Spring 생태계에서 API 문서화를 할 때는 보통 Swagger를 많이 사용하지만 RPC는 Swagger를 사용할 수 없었다. 이러한 한계를 극복하기 위해 Armeria에서는 DocService라는 것을 지원하는데 이를 활용하면 RPC를 Swagger처럼 브라우저에서 테스트할 수 있다.
DocService의 초기 화면
Endpoint 테스트
인입된 로그
HTTP 요청/응답의 내용을 쉽게 볼 수 있다
Spring WebFlux에서는 HTTP 요청/응답의 내용을 보려면 ExchangeFunction이나 기타 방법을 사용해야 했다(reactor-netty의 로그 레벨을 조정해서 보는 방법이 있었는데 없어졌다). Armeria에서는 설정 한 줄로 요청/응답의 내용을 사람이 읽을 수 있는(human-readable) 형식으로 볼 수 있다.
@Bean
public ArmeriaClientConfigurator armeriaClientConfigurator(ClientFactory clientFactory) {
return clientBuilder -> {
clientBuilder.decorator(LoggingClient.newDecorator());
clientBuilder.decorator(ContentPreviewingClient.newDecorator(500, StandardCharsets.UTF_8));
clientBuilder.factory(clientFactory);
};
}
@Bean
public ArmeriaServerConfigurator armeriaServerConfigurator() {
return serverBuilder -> {
serverBuilder.decorator(LoggingService.newDecorator());
serverBuilder.accessLogWriter(AccessLogWriter.combined(), false);
serverBuilder.decorator(ContentPreviewingService.newDecorator(500, StandardCharsets.UTF_8));
};
}
서버/클라이언트에 공통적으로 contentPreview를 적용하면 설정한 글자 수(여기서는 500자)까지 내용을 보여준다
Spring WebFlux와 손쉽게 결합할 수 있다
armeria-spring-boot-webflux-starter 패키지를 제공하며 이 패키지를 사용하면 Spring WebFlux와 간단하게 결합할 수 있다. Armeria는 원래 단독으로도 Microservice 구축이 가능하도록 만들어져 있지만 Spring WebFlux와 결합하여 사용하면 Bean 관리, Transaction 관리 같은 것은 Spring이 담당하게 하고 Spring에서 지원하지 않는 RPC 서빙을 Armeria가 담당하게 할 수 있다. 해당 패키지를 적용하면 Spring WebFlux의 기본 서버 구현체인 reactor-netty가 Armeria로 대체된다.
한국어로 질의응답이 가능하다
Github Repository에서는 불가능하지만 Armeria Slack의 #general-ko 채널에서 Armeria의 한국인 컨트리뷰터들과 소통할 수 있다. 공식 홈페이지 왼쪽의 Chat on slack 버튼을 눌러 참여해 보자.
Spring WebFlux + Armeria 결합
위 부분은 지난 LINE Developer Day 2019 Hands-on 세션에서 필자가 발표한 What a BOOTiful microservice with Armeria + WebFlux의 발표 자료 공유로 갈음한다. 아래와 같은 내용이 소개되어 있다.
- IntelliJ Community Edition의 기본적인 설정법
- Spring WebFlux와 Armeria의 결합 방법
- Armeria Decorator의 사용 방법
Step-by-Step으로 진행할 수 있도록 Github에 코드 예가 모두 공개되어 있으며 코드 예는 챕터별 브랜치로 분리되어 있다. chapter-0 브랜치는 최초 코드이며 chapter-5 브랜치는 발표 자료의 모든 것이 적용된 최종 코드이다.
- 발표 자료
- IntelliJ Community Edition 설정
- 코드 예
- https://github.com/joonhaeng/line-devday-2019-hands-on-src
- chapter-5 브랜치를 선택하면 최종 코드를 볼 수 있다
- https://github.com/joonhaeng/naver-helloworld-2020-armeria-webflux-src
- 발표 이후 Armeria의 버전 업에 따른 의존성 갱신 및 breaking-change를 반영한 저장소이다. 현재 최신 버전인 0.98.2가 적용되어 있다.
- https://github.com/joonhaeng/line-devday-2019-hands-on-src
유의점
무작정 도입하는 것은 독이다
If you have a Spring MVC application that works fine, there is no need to change. Imperative programming is the easiest way to write, understand, and debug code. You have maximum choice of libraries, since, historically, most are blocking.
기존의 MVC 애플리케이션이 잘 동작하고 있다면 무리해서 WebFlux로 재작성할 필요는 없다. Reactive Programming을 도입하기 위해서는 본인뿐만 아니라 같이 협업하는 팀원 모두의 사고방식이 절차형에서 Reactive Programming으로 바뀌어야 한다. 또한 절차형보다 디버깅이 힘들고 예상치 못한 곳에서 버그가 나올 가능성이 높다.
데이터베이스(RDBMS vs NoSQL)
현재 Spring Data에서 공식적으로 Reactive 드라이버를 지원하는 데이터베이스는 Redis, MongoDB, Cassandra, Couchbase 정도가 있다. Reactive 드라이버가 지원되지 않는 데이터베이스의 경우는 드라이버에서의 입출력에서 Blocking으로 동작하므로 우리가 아무리 WebFlux를 이용한 Reactive로 구현해도 소용이 없어진다. Reactive 드라이버를 지원하는 것들이 모두 NoSQL이므로 RDBMS를 지원하기 위해서는 별도로 Spring Data R2DBC라는 라이브러리를 사용할 수 있다. Spring Data R2DBC는 R2DBC를 Spring Data와 접목시킨 것으로, RDBMS에 대한 Reactive 드라이버를 제공한다. 지원하는 RDBMS는 아래와 같다.
- Google Cloud Spanner
- H2
- PostgreSQL
- MSSQL
- MySQL(R2DBC 직접 지원이 아님)
Oracle Database가 현재 지원되지 않는데 Oracle에서는 차기 Driver에서 Reactive 드라이버를 직접 지원할 예정이라고 한다(참고). Spring Data R2DBC가 아직 정식 버전이 아니므로 본격적인 운영 환경보다는 서비스에 영향이 적은 내부 어드민 툴 정도에 조금씩 적용해 보는 것을 추천한다.
디버깅
Reactive 환경에서의 디버깅은 절차형보다 어렵다. Project Reactor에서는 Hooks를 이용하여 글로벌 후킹을 통해 디버깅에 필요한 정보를 얻을 수 있도록 도와준다.
Hooks.onOperatorDebug();
onOperatorDebug를 사용하여 오류 발생 시 추가로 정보를 제공받을 수 있다.
onOperatorDebug를 사용했을 때 추가로 제공되는 메시지
onOperatorDebug를 사용하면 디버깅할 때는 편리하지만 실제 운영 환경에서는 오류가 발생하지 않는 상황에서도 불필요한 오버헤드가 생긴다. 이러한 오버헤드를 제거하여 운영에서도 사용할 수 있도록 Project Reactor에서는 운영 환경에 onOperatorDebug 대신 reactor-tools를 사용하는 것을 추천한다. 의존성을 추가한 뒤 애플리케이션의 main 함수에 아래와 같이 선언한다.
@SpringBootApplication public class DemoApplication { public static void main(String[] args) { ReactorDebugAgent.init(); // run보다 먼저 실행되어야함 SpringApplication.run(DemoApplication.class, args); } }
자세한 내용은 아래 사이트를 참고한다.
reactor-tools는 추후 reactor-core에 통합될 예정이다
Blocking Call
WebFlux 애플리케이션에서 Blocking Call을 수행할 경우 애플리케이션 전체가 심각한 기동 불능에 빠질 수 있다. 기본적으로 Mono, Flux의 Block 계열 메서드(Mono의 block(), Flux의 blockFirst(), blockLast() 등)에는 해당 메서드를 호출할 때 non-blocking 스레드 여부를 검사해서 오류를 발생시키는 로직이 적용되어 있다.
- BlockingSingleSubscriber.java의 blockingGet()
@Nullable
final T blockingGet() {
if (Schedulers.isInNonBlockingThread()) {
throw new IllegalStateException("block()/blockFirst()/blockLast() are blocking, which is not supported in thread " + Thread.currentThread().getName());
}
if (getCount() != 0) {
try {
await();
} catch (InterruptedException ex) {
dispose();
throw Exceptions.propagate(ex);
}
}
Throwable e = error;
if (e != null) {
RuntimeException re = Exceptions.propagate(e);
//this is ok, as re is always a new non-singleton instance
re.addSuppressed(new Exception("#block terminated with an error"));
throw re;
}
return value;
}
이 로직으로 Mono, Flux에서 명시적으로 Blocking 동작을 수행하는 것은 막을 수 있지만 다른 Blocking 동작을 수행하는 것(예를 들어 Thread.sleep)을 막을 수는 없다. 이런 것들을 찾아내는 것을 돕기 위해 Project Reactor에서는 BlockHound를 제공한다. BlockHound는 개발자를 대신하여 코드가 Blocking Call을 수행하는지 감시하고 감지될 경우 강제로 오류를 발생시킨다. 감시하는 동작에서 오버헤드가 발생하므로 운영 환경보다는 테스트 환경에서 수행하는 것이 권장된다.
자세한 내용은 아래 사이트를 참고한다.
마치며
최근 Reactive Programming 같은 새로운 패러다임과 node.js 같은 새로운 경쟁자들이 등장하면서 Java의 입지가 예전보다는 많이 좁아진 것이 사실이다. Java에서의 웹 개발 프레임워크의 사실상 표준인 Spring Framework에서 야심차게 내놓은 Spring WebFlux로 새로운 시대에도 Java가 예전처럼 주류 언어의 위치를 유지할 수 있을지 귀추가 주목된다.
'IT > Java' 카테고리의 다른 글
(Lombok) @Slf4j 사용하기 (0) | 2020.11.10 |
---|---|
Spring R2DBC + MySQL (0) | 2020.11.09 |