LINE Corporation이 2023년 10월 1일부로 LY Corporation이 되었습니다. LY Corporation의 새로운 기술 블로그를 소개합니다. LY Corporation Tech Blog

Blog


Armeria로 서버 간 인증 마이크로서비스 개발하기

LINE DEV Meetup #11 'LINE 서버 개발자들이 말한다! Armeria 아직도 안 써요?'에서 김도한 님이 발표하신 'Building an Authentication Microservice at LINE세션 내용을 옮긴 글입니다.

안녕하세요. 김도한입니다. 이번 글에서는 LINE에서 Armeria를 이용해 개발한 인증 마이크로서비스인 Doorkeeper를 소개하겠습니다. 앞서 Armeria를 소개합니다라는 글에서 이희승 님께서 LINE이 Armeria를 낳았다고 표현하셨는데요. 이번 글에서 소개할 내용은 Armeria를 낳은 LINE에서의 Armeria 사용 예시라고 할 수 있습니다.

본론으로 들어가기에 앞서 간략히 제 소개를 하자면, 저는 LINE에서 뛰어난 개발자 및 동료들과 함께 '톡서버'라고 부르는 메시징 서버를 개발하고 있습니다(GitHub ID: amosdoan). LINE에서는 이번 글에서 소개할 인증 마이크로서비스는 물론 LINE 메시징 서버에서도 Armeria를 정말 잘 사용하고 있는데요. 이렇게 잘 사용하고 있으면서도 Armeria에 기여를 많이 하지 못한 것 같아서 항상 송구한 마음을 가지고 있습니다. 앞으로 좀 더 기여할 수 있도록 열심히 노력해 보겠습니다.

이번 글은 서버 간 인증 마이크로서비스인 Doorkeeper가 무엇인지 간략히 소개한 뒤 Doorkeeper에서 Armeria를 어떻게 사용하고 있는지 각 모듈(Doorkeeper 서버, Doorkeeper 클라이언트)별로 자세히 설명하는 순서로 진행하겠습니다.  

Doorkeeper 소개

Doorkeeper에 대해 설명하기 전에 먼저 Doorkeeper가 등장한 배경을 말씀드리겠습니다. 

Doorkeeper의 등장 배경

LINE에는 정말 많은 서버 컴포넌트가 존재합니다. 그중에서 LINE 메시징 서버는 사용자 정보와 연락처 정보 등 다양한 정보를 보관하고 있습니다. 이와 같은 메시징 서버의 정보가 필요한 다른 컴포넌트는 LINE 메시징 서버의 여러 API를 이용하게 되는데요. 이때 다른 서버 컴포넌트에서 요청한 데이터가 민감한 정보일 수 있으며, 혹은 메시징 서버에서 특정 컴포넌트에만 허용하고자 하는 API가 있을 수도 있습니다.

따라서 메시징 서버 입장에서는 API를 요청하는 여러 서버 컴포넌트의 요청을 추적(tracking)할 필요가 생기고 이에 따라 API 요청에 대한 접근 제어가 필요합니다.

또한 LINE 메시징 서버뿐 아니라 앞서 말씀드린 대로 LINE에는 정말 많은 서버 컴포넌트가 있고 이 서버들은 서로 요청을 주고받고 있습니다. 아마 가까운 미래에는 LINE 메시징 서버 역시 MSA(microservice architechture) 관점에서 조금 더 작은 마이크로서비스로 분리하게 될 텐데요. 그렇게 되면 각 서버 입장에서 자신의 API에 대한 접근 제어가 필요합니다. 이때 서버별로 접근 제어를 구현하는 것보다는 공통으로 사용할 수 있는 접근 제어 방법이 필요합니다.

이런 요구 사항을 충족하기 위해 마이크로서비스로 개발한 것이 바로 서버 간 인증 마이크로서비스인 Doorkeeper입니다.

Doorkeeper의 구조

Doorkeeper는 이름 그대로 요청의 접근을 허용하거나 거절하는 문지기 역할을 합니다. Doorkeeper는 특정 API에 접근할 수 있는 권한이 있는 토큰을 발급하고 이 토큰을 검증해 인증합니다. 요청 헤더에 포함된 토큰의 권한에 따라 요청이 처리될 수도 있고 거부될 수도 있습니다.

Doorkeeper의 컴포넌트를 살펴보겠습니다. Doorkeeper는 크게 Doorkeeper 서버와 Doorkeeper 클라이언트, 두 가지 요소로 구성됩니다. 먼저 Doorkeeper 서버는 토큰을 관리하는 토큰 서버로 이해하시면 됩니다. Doorkeeper 클라이언트는 Doorkeeper 서버와 통신을 하면서 Doorkeeper 토큰 인증을 수행합니다. 이 두 가지 구성 요소는 아래 그림과 같은 흐름으로 작동합니다.

예를 들어 LINE Openchat 서버가 Doorkeeper 토큰을 헤더에 넣고 메시징 서버 API를 호출한다고 생각해 보겠습니다. 이때 Doorkeeper 클라이언트는 Doorkeeper 서버에 토큰에 관한 정보를 요청해서 그 정보에 기반해 현재 요청이 호출한 API에 접근할 수 있는지를 판단합니다. 만약 접근할 수 있는 요청이라면 API 처리로 넘기고, 권한이 없거나 유효하지 않은 토큰이라면 요청을 거부합니다.

그럼 Doorkeeper에는 어떤 기술 스택을 적용했을까요? 먼저 Doorkeeper 서버와 클라이언트는 gRPC로 통신합니다. 또한 Doorkeeper 서버는 리액티브 프로그래밍을 이용하기 위해서 Reactor를 사용하고 있고, DI(dependency injection)를 위해서 Spring Boot를 사용하고 있습니다. 

그리고 이렇게 다양한 기능들을 모두 아우르며 쉽게 비동기 서버를 구축할 수 있는 Armeria를 적용했습니다.

Doorkeeper에 Armeria 적용하기 

오늘의 주인공은 Armeria입니다. 그럼 Doorkeeper에서 Armeria를 어떻게 사용하고 있는지 자세히 살펴보겠습니다.

왜 Armeria인가

먼저 Doorkeeper에서 왜 Armeria를 사용했는지 이유를 말씀드리겠습니다. 첫 번째는 손쉽게 비동기 서버를 만들 수 있다는 점입니다. Armeria의 강점이죠. 두 번째는 Doorkeeper 클라이언트와 Doorkeeper 서버가 통신할 때 클라이언트 측 로드 밸런싱(client side load balancing, CLSB)을 사용할 수 있다는 점입니다. 기존의 서버 측 로드 밸런싱과는 다르게 클라이언트 측 로드 밸런싱은 어떤 전용(dedicate) 로드 밸런서 없이 서비스 디스커버리에 등록하는 것만으로 손쉽게 로드 밸런싱을 할 수 있습니다. 세 번째 역시 Armeria의 강점이라고 할 수 있는데요. 데코레이터로 정말 다양한 기능을 제공하고 있어서 손쉽게 적용할 수 있다는 점입니다. 이 장점은 또한 역으로 적용할 수도 있는데요. Doorkeeper 클라이언트가 라이브러리이기 때문에 다른 서버 컴포넌트에서 손쉽게 Doorkeeper 인증을 적용할 수 있도록 커스터마이징한 데코레이터를 만들어서 라이브러리로 공유할 수도 있습니다.

Armeria에는 그 외에도 정말 다양한 기능들이 많은데요. 자세한 내용은 Armeria 공식 사이트를 참고하시기 바랍니다. 다양하고 유용한 기능을 마이크로서비스를 만들 때 손쉽게 사용할 수 있도록 제공하고 있어서 생산성이 많이 올라갑니다. 덕분에 개발자들이 조금 더 일찍 퇴근할 수 있게 됐다고 생각하고 있습니다. :)

Doorkeeper에 적용한 Armeria 기능

그럼 구체적으로 어떤 Armeria 기능을 적용했는지 Doorkeeper 서버부터 살펴보겠습니다.

Doorkeeper 서버

아래 코드는 Armeria를 이용해서 Doorkeeper 서버를 빌드하는 코드입니다.

return sb -> sb.accessLogWriter(new DKAccessLogWriter(settings.getHealthCheckPath()), true)
               .service(GrpcService.builder()
                                   .addService(doorkeeperServiceHandler)
                                   .build(),
                        MetricCollectingService.newDecorator(MeterIdPrefixFunction.ofDefault("doorkeeper-server")),
                        BraveService.newDecorator(tracing),
                        LoggingService.newDecorator(),
                        AuthService.newDecorator(new DKAuthorizer(tokenService)));

앞서 Doorkeeper 서버는 Doorkeeper 클라이언트와 gRPC로 통신한다고 말씀드렸습니다. 두 번째 줄 서비스 부분을 보면 GrpcService를 사용하고 있는 것을 확인할 수 있습니다. 이와 같이 gRPC로 정의한 API를 Armeria의 비동기 서버를 이용해 서빙할 수 있었습니다. 또한 그 뒤로 이어지는 코드를 살펴보면 여러 유용한 데코레이터를 이용해 다양한 기능들을 정말 손쉽게 적용할 수 있다는 것을 알 수 있습니다. 먼저 MetricCollectingService를 이용해 기본적인 서버 지표를 쉽게 추출할 수 있었고, BraveServiceLoggingService, 맨 윗줄에 있는 AccessLogWriter 등을 이용해 각 요청별로 필요한 정보를 원하는 형태로 쉽게 로깅할 수 있었습니다. 마지막 줄에는 AuthService가 있는데요. Doorkeeper 서버로 인입되는 Doorkeeper 클라이언트의 요청 역시 Doorkeeper 토큰 인증을 해야 하는데 AuthService를 이용해 인증 관련 로직을 간단하게 추가해서 인증할 수 있었습니다.

앞서 설명드린 기능들을 하나씩 살펴보겠습니다. Doorkeeper는 아래와 같이 gRPC와 Reactive Stream을 위한 Reactor를 사용하고 있습니다. 이와 같이 gRPC를 사용하고 있는 상태에서도 투명(transparent)하고 매끄럽게 Armeria와 통합할 수 있는 것이 가장 큰 장점이라고 생각합니다.

service DoorkeeperService {
    rpc verifyToken (VerifyTokenRequest) returns (VerifyTokenResponse) {};
}
@Component
public class DoorkeeperServiceHandler extends ReactorDoorkeeperServiceGrpc.DoorkeeperServiceImplBase {
    @Override
    public Mono<VerifyTokenResponse> verifyToken(Mono<VerifyTokenRequest> request) {
      ...
    } 
}

아래와 같이 Armeria에서 기본적으로 제공하는 DocService를 이용하면 트러블 슈팅과 같은 작업을 진행할 때 웹에서 gRPC API를 손쉽게 호출해 테스트할 수 있습니다.

앞서 MetricCollectingServic를 이용해서 데코레이팅하는 것만으로 서버 지표를 추출할 수 있다고 말씀드렸습니다. 아래는 Grafana에서 해당 지표들을 확인한 모습입니다.

또한 Armeria와 Logback의 통합 덕분에 요청이 처리되고 있을 때 요청과 관련된 정보를 MDC(Mapped Diagnostic Context)로 내보내서 처리되는 스레드가 변경되더라도 다양한 프로퍼티를 로깅할 수 있습니다. 아래 코드를 보면 IP 주소나 Zipkin의 traceidID, request ID 등 요청과 관련된 정보를 내보내고 있는데요. 이를 통해 해당 요청을 처리하면서 기록되는 정보들을 안전하게 로그로 남길 수 있습니다.

<appender name="RCEA_WEBAPP" class="com.linecorp.armeria.common.logback.RequestContextExportingAppender">
  <springProfile name="local">
    <appender-ref ref="STDOUT" />
  </springProfile>
  <springProfile name="!local">
    <appender-ref ref="WEBAPP_ASYNC_APPENDER" />
  </springProfile>
  <export>remote.ip</export>
  <export>req.path</export>
  <export>req.headers.x-b3-traceid</export>
  <export>res.status_code</export>
  <export>req.id</export>
</appender>
[traceIdTest] [18aa997fb1d3e88f] 2022-04-13 12:05:57.956  WARN 87333 --- [pool-3-thread-8] c.l.d.server.context.DKAuthorizer        : Failed to authentication

다양한 컴포넌트에서 Doorkeeper 인증을 사용하기 때문에 Doorkeeper 서버는 트러블 슈팅할 때 어느 컴포넌트에서 들어온 요청인지 파악할 필요가 있습니다. 이때 BraveService 데코레이터를 사용해서 아래와 같이 Zipkin의 traceId를 헤더의 x-b3-traceid에 넣어서 요청하면 Doorkeeper 서버 로그에 해당 traceId가 남기 때문에 요청을 좀 더 쉽고 빠르게 특정할 수 있습니다.

curl --location --request POST 'http://doorkeeper-server:20080/verifyToken' \
--header 'Authorization: DK doorkeeper token' \
--header 'content-type: application/json; charset=utf-8; protocol=gRPC' \
--header 'x-b3-traceid: traceId-test-0000' \
--data-raw '{
  "token": "targetToken"
}'
traceId-test-0000 2020-02-10 18:44:09.674 DEBUG [-worker-nio-2-2] TokenService : Token<eyJ0e> is not found
traceId-test-0000 2020-02-10 18:44:09.675 INFO [-worker-nio-2-2] TokenService : Failed to verifyToken;Token<eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ>

또한 앞서 말씀드렸던 것처럼 Doorkeeper 클라이언트가 Doorkeeper 서버에 요청할 때도 Doorkeeper 토큰으로 인증해야 합니다. 이에 Armeria의 AuthService를 이용하기 위해서 Authorizer라는 클래스를 구현해서 아래와 같이 간단하게 인증 로직을 넣는 방식으로 오버라이드해서 요청 인증을 처리했습니다.

public class DKAuthorizer implements Authorizer<HttpRequest> {
    @Override
    public CompletionStage<Boolean> authorize(ServiceRequestContext ctx, HttpRequest req) {
        final DkContext dkContext = ctx.attr(DkContext.ATTR_KEY_DK_CONTEXT);
        final Optional<String> token = dkContext.getToken();
        return tokenService.verifyToken(token.get()) 
                           .toFuture().handle((decryptedToken, throwable) -> {
                try (SafeCloseable ignored = ctx.push()) { 
                    if (throwable != null) { 
                        return false; 
                    } 
                    final Principal principal = decryptedToken.toPrincipal();
                    dkContext.setPrincipal(principal); 
                    dkContext.setServiceCode(serviceCode); 
                    return true; 
                }
        }); 
    } 
}

Doorkeeper 클라이언트

다음으로 Doorkeeper 클라이언트에서 Armeria를 어떻게 사용했는지 살펴보겠습니다. 앞서 보여드린 그림인데요. 아래와 같이 Doorkeeper 클라이언트는 Doorkeeper 서버와 통신하면서 Doorkeeper 토큰에 기반해 인증하는 라이브러리입니다.

Doorkeeper 클라이언트는 클라이언트 측 로드 밸런싱을 사용하고 있어서 Doorkeeper 서버에 따로 L4나 L7 로드 밸런서를 세팅할 필요가 없습니다. DynamicEndpointGroup과 서비스 디스커버리를 이용해 로드 밸런싱하므로 Doorkeeper가 기동할 때 서비스 디스커버리에 등록만 잘 하면 클라이언트는 자연스럽게 잘 작동하고 있는 Doorkeeper 서버로 요청할 수 있습니다.

Endpoint endpointGroup = HealthCheckedEndpointGroup.builder(endpointGroup, HEALTH_CHECK_PATH)
                                                    .protocol(SessionProtocol.HTTP)
                                                    .retryInterval(healthCheckInterval)
                                                    .build();

다음은 Doorkeeper 클라이언트의 핵심인데요. Doorkeeper 인증을 사용하려는 서버에서 쉽게 Doorkeeper 인증을 적용할 수 있도록 라이브러리를 데코레이터 형태로 제공하고 있습니다. 이 데코레이터를 Armeria 서버를 빌드할 때 잘 달아주면 바로 Doorkeeper 인증을 사용할 수 있습니다. 데코레이터는 특정 API에 필요한 권한을 파싱해서 요청의 토큰에 포함된 권한과 비교해 인증을 진행합니다. Doorkeeper 클라이언트 라이브러리에 아래와 같은 Doorkeeper 인증을 위한 데코레이터가 포함돼 있습니다. 

public final class DoorkeeperRpcDecorator extends SimpleDecoratingRpcService {
    @Override
    public RpcResponse serve(ServiceRequestContext ctx, RpcRequest req) throws Exception {
        ...
        final Optional<RequiredPermissionsHolder> requiredPermissions = doorkeeperAsyncService.findRequiredPermissions(targetClass, targetMethod);
        final CompletableFuture<RpcResponse> rpcResponseCompletableFuture =
                doorkeeperAsyncService.isPermitted(accessToken, requiredPermissions.get())
                                      .handle((result, cause) -> {
                                          if (cause != null) {
                                              return RpcResponse.ofFailure(
                                                      new DoorkeeperException(ErrorCode.INTERNAL_ERROR, cause));
                                          }
                                          if (result) {
                                              return unwrap().serve(ctx, req);
                                          } else {
                                              return RpcResponse.ofFailure(
                                              new DoorkeeperException(ErrorCode.NOT_AUTHENTICATED));
                                          }
                                      });
        return RpcResponse.from(rpcResponseCompletableFuture);
    } 
}

아래 코드를 보시겠습니다. CoffeeServiceHandler에 정의된 API를 대상으로 Armeria 서버를 빌드하면서 Doorkeeper 데코레이터를 달아주는 코드입니다.

final HttpService service = ThriftCallService.of(handler)
                                             .decorate(delegate -> new DoorkeeperRpcDecorator(
                                                     delegate, CoffeeServiceHandler.class,
                                                     doorkeeperAsyncService));

위와 같은 방식으로 CoffeeServiceHandler 서비스에 정의된 API에 Doorkeeper 인증을 적용할 수 있습니다. 가령 getAmericano API에 접근하기 위해서는 아래 코드의 RequiredPermission에 명시된 "coffee:americano:read"라는 권한을 가진 토큰이 필요합니다. 

@RequiredPermissions("coffee:americano:read")
@Override
public void getAmericano(GetAmericanoRequest request, AsyncMethodCallback resultHandler) throws TException {
    ... 
}

@RequiredPermissions("coffee:latte:read")
@Override
public void getLatte(GetLatteRequest request, AsyncMethodCallback resultHandler)
            throws TException {
                ...
}

만약 권한이 다르거나 아예 토큰이 포함되어 있지 않다면 Doorkeeper 데코레이터에서 요청을 거부합니다.

Q&A

Q: Doorkeeper는 API 게이트웨이 역할인가요? 

A: 서버에서 API를 트래킹하고 권한에 따라 접근을 제어하기 위한 라이브러리라고 생각하시면 될 것 같은데요. 관점에 따라 API 게이트웨이의 역할도 포함한다고 볼 수 있겠습니다. 

Q: 잘 활용하면 SaaS(Software as a Service)로도 가능할까요?

A: Doorkeeper가 아직 개발 초기 단계라서 계속 로드맵을 고민하고 있는데요. 말씀해 주신 것과 같은 좋은 의견들을 많이 주시면 퓨처 워크로 열심히 고려해 보겠습니다.

Q: Armeria를 사용한 뒤 개발 생산성이 많이 올라갔나요? 어느 정도 체감하셨나요? 

A: 일단 Armeria에서 다양한 기능을 데코레이터라는 일관된 형태로 제공하기 때문에 필요한 기능을 찾는 데 많은 도움이 됐습니다. 덕분에 여러 기능을 적용하면서 크게 어려웠던 적이 없었던 것 같네요. 또한 현재 제공되지 않는 기능이 필요할 때 Armeria 커뮤니티에 문의하면 이슈가 정말 빠르게 올라가서 거의 다음 버전에서 바로 사용할 수 있을 정도로 일이 진행되는데요. 이와 같은 신속한 대응도 Armeria를 사용하면서 개발 생산성 측면에서 크게 체감한 장점이었습니다. 

Q: Doorkeeper에 API 리미터 기능도 있으면 좋을 것 같습니다.

A: 저도 API 리미터, API 커터 기능을 넣고 싶다고 생각하고 있어서 현재 퓨처 워크로 고려하고 있습니다.  

마치며

이번 글에서는 Armeria로 서버 간 인증 마이크로서비스인 Doorkeeper를 개발한 내용을 말씀드렸습니다. 앞으로 Armeria에 많은 관심을 가져주시길 부탁드리며, 더 나아가 기여에도 도전해 보시길 바라겠습니다. '기여는 리포지터리의 스타를 눌러주는 것으로 시작한다'고 들었는데요. :) Armeria에 많은 스타를 부탁드리면서 이번 글을 마치겠습니다. 긴 글 읽어주셔서 감사합니다.