Netty로 HTTP/2 서버도 만들고 gRPC도 붙여봤다. 그런데 실무에서는 하나의 서버에 gRPC, Thrift, REST API를 모두 올려야 할 때가 있다. 매번 Netty 파이프라인을 직접 구성하는 건 너무 저수준이지 않을까? Netty의 강력함을 유지하면서도 더 편하게 마이크로서비스를 만들 수 있는 방법은 없을까?

Armeria란 — LINE이 만든 비동기 마이크로서비스 프레임워크

Armeria는 LINE 에서 만든 오픈소스 비동기 RPC/REST 프레임워크입니다. 내부적으로 Netty 위에 구축되어 있고, 비동기 논블로킹을 기본으로 합니다.

공부하다 보니 Armeria의 정체성을 한 문장으로 정리하면 이렇습니다.

Netty의 저수준 네트워킹 위에 **마이크로서비스에 필요한 것들 **(서비스 라우팅, 직렬화, 로깅, 메트릭, 서비스 디스커버리)을 얹은 프레임워크

일반적인 HTTP 프레임워크(Spring MVC, Express.js)와 다른 점이 있습니다.

  • ** 프로토콜 독립적 **: gRPC, Thrift, REST를 하나의 서버에서 동시에 지원합니다
  • **HTTP/2 기본 **: HTTP/1.1이 아니라 HTTP/2를 기본 프로토콜로 사용합니다
  • ** 비동기 우선 **: CompletableFuture 기반의 비동기 API가 기본입니다
  • **Netty 네이티브 **: 내부적으로 Netty의 EventLoop, Channel, ByteBuf를 그대로 활용합니다
JAVA
import com.linecorp.armeria.server.Server;
import com.linecorp.armeria.server.ServerBuilder;

// Armeria 서버 기본 구성 — 놀라울 정도로 간결하다
Server server = Server.builder()
    .http(8080)
    // REST 서비스
    .annotatedService("/api", new MyRestService())
    // gRPC 서비스
    .service(GrpcService.builder()
        .addService(new MyGrpcServiceImpl())
        .build())
    // Thrift 서비스
    .service("/thrift", THttpService.of(new MyThriftHandler()))
    .build();

// 서버 시작
server.start().join();

이 코드 한 장으로 REST, gRPC, Thrift가 ** 같은 포트 **에서 동시에 동작합니다. Netty로 직접 이걸 구현하려면 파이프라인 분기, 코덱 설정, 프로토콜 감지 로직을 전부 직접 짜야 합니다.


왜 Armeria인가 — 프로토콜 독립적 통합 서버

마이크로서비스 환경에서 흔히 겪는 문제가 있습니다.

  • 레거시 서비스는 Thrift 를 쓰고 있다
  • 새 서비스는 gRPC 로 만들고 싶다
  • 프론트엔드에는 REST API 를 제공해야 한다
  • 이걸 각각 다른 서버로 띄우면 관리 포인트가 3배로 늘어난다

Armeria는 이 문제를 하나의 서버, 하나의 포트 로 해결합니다.

프로토콜 라우팅의 원리

Armeria가 여러 프로토콜을 동시에 지원할 수 있는 비밀은 HTTP/2 위에서의 라우팅 입니다.

PLAINTEXT
클라이언트 요청


[ Armeria Server (Netty 기반) ]

    ├── Content-Type: application/grpc  ──→  gRPC 서비스
    ├── Content-Type: application/x-thrift ──→  Thrift 서비스
    └── 경로 패턴 매칭 (/api/**)  ──→  REST 서비스

gRPC도 HTTP/2 위에서 동작하고, Thrift도 THttpService를 통해 HTTP 위에서 동작하므로, HTTP/2라는 공통 기반 위에서 Content-Type과 경로만으로 분기할 수 있습니다.

REST 서비스 — 어노테이션 기반

JAVA
import com.linecorp.armeria.server.annotation.*;

public class MyRestService {

    // GET /api/users/{id}
    @Get("/users/{id}")
    public HttpResponse getUser(@Param("id") long id) {
        User user = userRepository.findById(id);
        return HttpResponse.ofJson(user);
    }

    // POST /api/users
    @Post("/users")
    public HttpResponse createUser(CreateUserRequest request) {
        // 비동기 처리도 자연스럽다
        return HttpResponse.from(
            userService.create(request)
                .thenApply(user -> HttpResponse.ofJson(HttpStatus.CREATED, user))
        );
    }
}

Spring MVC와 비슷한 어노테이션 스타일이지만, 반환 타입이 CompletableFutureHttpResponse로 비동기를 자연스럽게 지원합니다.


Netty를 어떻게 활용하는가 — ServerBuilder 내부

Armeria를 쓰면서 Netty를 직접 만질 일이 거의 없습니다. 하지만 내부적으로는 Netty가 모든 것을 처리하고 있습니다.

ServerBuilder → Netty ServerBootstrap

Armeria의 Server.builder()가 내부적으로 하는 일을 풀어보면 이렇습니다.

PLAINTEXT
Armeria ServerBuilder


Netty ServerBootstrap 구성
    ├── bossGroup: NioEventLoopGroup (acceptor)
    ├── workerGroup: NioEventLoopGroup (I/O 처리)
    ├── channel: NioServerSocketChannel (또는 EpollServerSocketChannel)
    └── childHandler (ChannelPipeline):
        ├── SslHandler (TLS가 설정된 경우)
        ├── Http2FrameCodec (HTTP/2 프레이밍)
        ├── Http2MultiplexHandler (스트림 멀티플렉싱)
        └── Armeria 내부 핸들러 (라우팅, 서비스 디스패치)

직접 Netty 파이프라인을 구성할 때와 비교하면, 개발자가 해야 할 일이 크게 줄어듭니다.

직접 Netty 구성Armeria 사용
ServerBootstrap 생성Server.builder()
EventLoopGroup 직접 생성·관리자동 생성 (CPU 코어 기반)
ChannelInitializer 작성서비스 등록만 하면 자동 구성
HTTP/2 코덱 직접 파이프라인에 추가기본 내장
Graceful Shutdown 직접 구현server.stop() 호출 한 번

Netty 설정 커스터마이징

그래도 Netty 수준의 튜닝이 필요할 때가 있습니다. Armeria는 이를 위한 탈출구를 제공합니다.

JAVA
Server server = Server.builder()
    .http(8080)
    // Netty ChannelOption 직접 설정
    .channelOption(ChannelOption.SO_BACKLOG, 1024)
    .childChannelOption(ChannelOption.SO_KEEPALIVE, true)
    .childChannelOption(ChannelOption.TCP_NODELAY, true)
    // worker EventLoop 스레드 수 지정
    .workerGroup(16)
    // 최대 연결 수 제한
    .maxNumConnections(10000)
    .annotatedService("/api", new MyRestService())
    .build();

Netty의 ServerBootstrap.option()이나 childOption()과 같은 역할을 합니다. 추상화의 이점을 누리면서도 저수준 튜닝을 포기하지 않는 설계입니다.


데코레이터 패턴 — Netty Pipeline의 서비스 레벨 추상화

Netty의 핵심 패턴이 ChannelPipeline에 핸들러를 체이닝 하는 것이었다면, Armeria의 핵심 패턴은 데코레이터 입니다.

ChannelPipeline vs 데코레이터

PLAINTEXT
[ Netty ChannelPipeline ]
바이트 → SslHandler → Http2FrameCodec → Http2MultiplexHandler → 비즈니스 핸들러

[ Armeria 데코레이터 체인 ]
HttpRequest → LoggingService → AuthService → MetricCollecting → 비즈니스 서비스

두 개념은 같은 원리(체이닝)를 다른 추상화 레벨에서 적용한 것입니다.

  • Netty Pipeline: 바이트 레벨에서 인코딩/디코딩/프레이밍을 처리합니다
  • **Armeria 데코레이터 **: HTTP 요청/응답 레벨에서 로깅, 인증, 메트릭을 처리합니다

데코레이터 사용법

JAVA
Server server = Server.builder()
    .http(8080)
    .annotatedService("/api", new MyRestService())
    // 데코레이터 체이닝 — 안쪽에서 바깥쪽 순서로 감싼다
    .decorator(LoggingService.newDecorator())        // 요청/응답 로깅
    .decorator(MetricCollectingService.newDecorator(  // 메트릭 수집
        MeterIdPrefixFunction.ofDefault("my.service")))
    .decorator(AuthService.newDecorator(              // 인증
        new MyAuthHandler()))
    .build();

요청이 들어오면 데코레이터가 순서대로 실행됩니다. decorator() 호출 순서에 따라 ** 바깥쪽부터 안쪽으로** 감싸지므로, 위 코드에서 실행 순서는 다음과 같습니다.

PLAINTEXT
요청 → Auth → Metric → Logging → 비즈니스 로직
응답 ← Auth ← Metric ← Logging ← 비즈니스 로직

커스텀 데코레이터 만들기

데코레이터를 직접 만드는 것도 간단합니다. Netty에서 ChannelDuplexHandler를 상속하는 것보다 훨씬 직관적입니다.

JAVA
// 요청 처리 시간을 측정하는 커스텀 데코레이터
public class TimingDecorator implements DecoratingHttpServiceFunction {

    @Override
    public HttpResponse serve(HttpService delegate, ServiceRequestContext ctx,
                              HttpRequest req) throws Exception {
        long start = System.nanoTime();

        // 원본 서비스에 위임
        HttpResponse response = delegate.serve(ctx, req);

        // 응답이 완료되면 시간 측정
        response.whenComplete().thenRun(() -> {
            long elapsed = System.nanoTime() - start;
            logger.info("{} {} took {}ms",
                req.method(), req.path(),
                TimeUnit.NANOSECONDS.toMillis(elapsed));
        });

        return response;
    }
}

// 사용
Server.builder()
    .annotatedService("/api", new MyRestService())
    .decorator(TimingDecorator::new)
    .build();

Netty에서는 channelReadwrite를 오버라이드하고 바이트 버퍼를 다뤄야 했지만, Armeria에서는 HttpRequestHttpResponse라는 높은 추상화에서 작업합니다.


HTTP/2 기본 — 왜 HTTP/2를 기본 프로토콜로 선택했는가

대부분의 프레임워크(Spring MVC, Express.js)는 HTTP/1.1이 기본이고 HTTP/2는 선택 사항입니다. Armeria는 반대입니다. HTTP/2가 기본 이고 HTTP/1.1은 호환성을 위해 지원합니다.

마이크로서비스에서 HTTP/2의 이점

마이크로서비스 환경에서 HTTP/2가 특히 유리한 이유가 있습니다.

  • **멀티플렉싱 **: 서비스 간 통신에서 하나의 연결로 여러 요청을 동시에 보낼 수 있습니다. 연결 수가 줄어들어 리소스 사용이 효율적입니다
  • ** 헤더 압축 **: 마이크로서비스 간 호출은 비슷한 헤더가 반복되므로 HPACK 압축 효과가 큽니다
  • **gRPC 호환 **: gRPC가 HTTP/2 위에서 동작하므로 자연스럽게 통합됩니다

Armeria의 HTTP/2 처리 내부

Armeria 내부에서 HTTP/2는 Netty의 Http2FrameCodecHttp2MultiplexHandler를 통해 처리됩니다.

PLAINTEXT
클라이언트 연결


[ TLS + ALPN 협상 (h2 선택) ]


[ Netty Http2FrameCodec ]  ← 바이너리 프레임 파싱


[ Netty Http2MultiplexHandler ]  ← 스트림별 자식 Channel 생성


[ Armeria HttpServerHandler ]  ← 서비스 라우팅 및 디스패치

TLS가 없는 환경(개발·테스트)에서는 h2c(HTTP/2 over cleartext) 도 지원합니다. Armeria는 연결 첫 바이트를 보고 HTTP/2 프리페이스(PRI * HTTP/2.0)인지 HTTP/1.1인지 자동 감지합니다.

JAVA
Server server = Server.builder()
    .http(8080)          // h2c + HTTP/1.1 자동 감지
    .https(8443)         // h2 (TLS + ALPN)
    .tlsSelfSigned()     // 개발용 자체 서명 인증서
    .annotatedService("/api", new MyRestService())
    .build();

서비스 디스커버리 — 클라이언트 사이드 로드밸런싱

마이크로서비스에서 서비스 디스커버리는 필수입니다. Armeria는 클라이언트 사이드 로드밸런싱 을 기본으로 제공합니다.

EndpointGroup

EndpointGroup은 여러 서버 엔드포인트를 하나의 그룹으로 묶는 개념입니다.

JAVA
// 정적 엔드포인트 그룹
EndpointGroup staticGroup = EndpointGroup.of(
    Endpoint.of("server1.example.com", 8080),
    Endpoint.of("server2.example.com", 8080),
    Endpoint.of("server3.example.com", 8080)
);

// DNS 기반 서비스 디스커버리
EndpointGroup dnsGroup = DnsEndpointGroup.builder("my-service.internal")
    .port(8080)
    .ttl(10, 60) // 최소 10초, 최대 60초 TTL
    .build();

// ZooKeeper 기반 (별도 의존성)
EndpointGroup zkGroup = ZooKeeperEndpointGroup.builder(
        curatorFramework, "/services/my-service")
    .build();

로드밸런싱 전략

JAVA
// 가중 라운드로빈 (기본)
WebClient client = WebClient.builder("http", staticGroup)
    .endpointRemapper(endpoint ->
        endpoint.withWeight(endpoint.host().equals("server1") ? 3 : 1))
    .build();

// 엔드포인트 그룹으로 클라이언트 생성
WebClient client = WebClient.of(SessionProtocol.HTTP, staticGroup);

// 요청 — 자동으로 로드밸런싱된다
HttpResponse response = client.get("/api/users/1");

별도의 로드밸런서(L4/L7) 없이 클라이언트가 직접 여러 서버에 요청을 분배합니다. 서비스 메시(Istio, Linkerd) 없이도 기본적인 서비스 디스커버리와 로드밸런싱이 가능합니다.

헬스 체크 연동

JAVA
// 엔드포인트 헬스 체크 — 죽은 서버는 자동으로 제외
EndpointGroup healthChecked = HealthCheckedEndpointGroup.builder(
        staticGroup, "/internal/health")
    .protocol(SessionProtocol.HTTP)
    .retryInterval(Duration.ofSeconds(5))  // 5초마다 헬스 체크
    .build();

// 헬스 체크 통과한 서버에만 요청이 간다
WebClient client = WebClient.of(SessionProtocol.HTTP, healthChecked);

Spring Boot 통합 — 기존 생태계와 공존

Armeria는 독립적으로도 사용할 수 있지만, 대부분의 자바 프로젝트가 Spring Boot를 쓰고 있는 현실을 무시하지 않습니다. armeria-spring-boot-starter를 통해 기존 Spring Boot 애플리케이션에 Armeria를 통합할 수 있습니다.

의존성 추가

XML
<!-- build.gradle 또는 pom.xml -->
<dependency>
    <groupId>com.linecorp.armeria</groupId>
    <artifactId>armeria-spring-boot3-starter</artifactId>
    <version>1.31.3</version>
</dependency>

ArmeriaServerConfigurator

Spring Bean으로 ArmeriaServerConfigurator를 등록하면 Armeria 서버 설정을 추가할 수 있습니다.

JAVA
@Configuration
public class ArmeriaConfig {

    @Bean
    public ArmeriaServerConfigurator armeriaServerConfigurator() {
        return builder -> {
            // gRPC 서비스 등록
            builder.service(GrpcService.builder()
                .addService(new MyGrpcServiceImpl())
                .build());

            // 데코레이터 추가
            builder.decorator(LoggingService.newDecorator());

            // DocService — Armeria의 내장 API 문서 & 디버그 도구
            builder.serviceUnder("/docs", DocService.builder().build());
        };
    }
}

Spring MVC와 공존

기존 Spring MVC 컨트롤러를 유지하면서 Armeria 서비스를 추가하는 것이 가능합니다.

PLAINTEXT
[ Armeria Server (Netty 기반) ]

    ├── /grpc/**  ──→  Armeria gRPC 서비스
    ├── /docs/**  ──→  Armeria DocService
    └── 나머지   ──→  내장 Tomcat (Spring MVC)

Armeria가 프론트에서 모든 요청을 받고, 경로에 따라 Armeria 네이티브 서비스 또는 Tomcat으로 라우팅합니다. 기존 @RestController는 그대로 동작하면서, gRPC 서비스를 같은 서버에 추가할 수 있습니다.

JAVA
// 기존 Spring MVC 컨트롤러 — 그대로 동작한다
@RestController
@RequestMapping("/api/v1")
public class UserController {

    @GetMapping("/users/{id}")
    public User getUser(@PathVariable long id) {
        return userService.findById(id);
    }
}

// 새로운 gRPC 서비스 — 같은 서버에서 함께 동작한다
public class UserGrpcService extends UserServiceGrpc.UserServiceImplBase {

    @Override
    public void getUser(GetUserRequest request,
                        StreamObserver<GetUserResponse> observer) {
        // gRPC 구현
    }
}

DocService — 내장 API 문서

Armeria의 숨은 보석은 DocService입니다. Swagger UI처럼 API 문서를 자동으로 생성하는데, gRPC, Thrift, REST 모든 프로토콜의 API를 한곳에서 보여줍니다.

JAVA
builder.serviceUnder("/docs", DocService.builder()
    // 예제 요청 추가
    .exampleRequests(MyGrpcServiceGrpc.SERVICE_NAME,
        "GetUser",
        GetUserRequest.newBuilder().setId(1).build())
    .build());

/docs 경로로 접속하면 웹 UI에서 모든 서비스의 메서드 목록, 요청/응답 스키마, 예제 요청 실행까지 가능합니다. 개발과 디버깅에서 큰 도움이 됩니다.


정리 — Armeria의 위치

Armeria를 한마디로 정리하면, Netty의 성능과 유연성 위에 마이크로서비스 프레임워크의 편의성을 올린 것 입니다.

계층역할예시
비즈니스 로직서비스 구현@Get, @Post, gRPC impl
Armeria라우팅, 데코레이터, 직렬화ServerBuilder, LoggingService
Netty비동기 I/O, 프로토콜 코덱EventLoop, Http2FrameCodec
OS소켓, epoll/kqueue커널

개발자는 Armeria 레벨에서 작업하면서, 필요할 때만 Netty 레벨로 내려가면 됩니다.

순수 Netty와 비교했을 때의 트레이드오프도 있습니다.

  • ** 장점 **: 프로토콜 통합, 데코레이터, 서비스 디스커버리, DocService 등이 기본 제공
  • ** 장점 **: HTTP/2, gRPC, Thrift 설정을 직접 하지 않아도 됨
  • ** 단점 **: Armeria 자체의 추상화 레이어가 추가되므로, 극한의 저수준 최적화가 필요한 경우 순수 Netty가 더 적합
  • ** 단점 **: Armeria 특유의 API와 개념을 별도로 학습해야 함

면접에서 "Netty 기반 프레임워크를 써본 적 있나요?"라는 질문이 나오면, Armeria가 어떻게 Netty의 EventLoop, Pipeline, HTTP/2 코덱을 활용하는지 설명할 수 있으면 좋습니다. 단순히 "Armeria 써봤습니다"보다는 "내부적으로 Netty ServerBootstrap을 구성하고, 데코레이터 패턴으로 ChannelPipeline의 관심사를 서비스 레벨로 끌어올린 구조"라고 말할 수 있으면 훨씬 강한 인상을 줍니다.

댓글 로딩 중...