
이제는 Spring, Fastapi와 같은 라이브러리 없이 서버를 구현하는 일은 상상도 할 수 없을 것이다. 대부분의 프로그램에서는 블랙박스인 라이브러리를 피상적인 인터페이스와 문서를 믿고 가져다 쓰는 경우가 많을 것이다. 많은 사람들이 사용하고 있는 라이브러리라면, 충분히 밀도 있는 예제와 Q&A 자료가 인터넷에 널려있고 활발한 커뮤니티가 형성되어 있다. 필자는 Spring을 그렇게 좋아하지는 않지만, 그럼에도 불구하고 Spring으로 서버를 짜는 것을 나쁘지 않게 보는 이유가 이 때문이다. 그런데, 그런 거대한 라이브러리를 가져다 써서 원하는 기능을 구현했는데 안 된다면 대체 문제를 어떻게 해결해야 할까? 이번에 겪은 문제 해결 사례를 바탕으로 자그마한 필자의 경험을 소개해보려고 한다. 문제 인식을 한 후, IntelliJ IDEA의 디버거 기능으로 의심되는 코드 위치를 찾아 가설을 세우고, 실제로 코드를 고친 후 오픈소스에 기여하는 과정을 담았다.
우리 팀에서는 Scala라는 JVM 위에서 돌아가는 함수형 언어를 사용하고 있다. Scala는 꽤나 현실적인 함수형 언어(Haskell 같은 너무 학문적인 언어가 아니다)의 특징을 띄고 있어서, 함수형으로 서버를 구현하기에는 좋지만 그 커뮤니티 자체는 꽤나 작은 편인 듯하다. 어째 제대로된 문서도(generated된 것만 겨우 볼 수 있었다) 거의 없고, 하다 못해 뭔가를 찾으려고 하면 결국 라이브러리 PR까지 볼 정도였다. 여기에 Spring 같은 잘 알려지고 경험도 풍부한 프레임워크가 아니라, grpc-java 바탕의 zio-grpc라는 것을 사용하여 서버를 구현하였다. 이 zio-grpc는 ZIO라는 비동기, 동시성 라이브러리 생태계에서 구현하고 있는 gRPC 서버 프레임워크이다. 굳이 왜 ZIO를 썼냐고 묻는다면, JDK 19 이전(Project Loom 이전)까지는 JVM에 green thread(virtual thread라고 부르기도 하고, 한국어로는 경량 쓰레드라고도 한다) 없기 때문에 이를 따로 구현한 것으로 알고 있다. 그래서인지 ZIO 홈페이지에 Build scalable applications with 100x the performance of Scala’s Future라고 적혀 있기도 하다. 아무튼 Scala 위에서 ZIO, zio-grpc 등을 활용해서 MSA 서버를 작성하고 있던 와중이었다.
gRPC/protobuf(이하 gRPC)로 서버를 구현해보신 분들이라면 한 번쯤은 겪고 가는 문제에서 시작한다. gRPC는 OK일 때 정해진 protobuf message를 보내주고, not OK일 때 error code(HTTP response code랑 비슷한데 조금씩 달라서 이것도 참 헷갈린다)와 description을 보내준다. 문제는 이 description은 protobuf message처럼 정규화할 수 있는 에러 메시지가 아닌 단순히 human-readable text에 불과하다는 사실이다. MSA끼리 서로 통신할 때 에러를 정규화하여 전달하고자 하는 니즈가 있었기 때문에 여러 방안을 생각했었다.
description에 JSON을 담아서 보내자.
어차피 이래봤자 엄밀한 정규화가 잘 안 되는 것은 마찬가지이다. 동적으로 파싱하니 단순히 text를 보낼 때보다야 낫긴 하겠지만.
error response의 metadata에 protobuf message를 binary로 담아서 보내자.
이러면 파싱하기가 좀 귀찮아지긴 하지만, 무엇보다 protobuf로 작성된 인터페이스만 공유된다면 정규화하여 써먹기 좋기 때문인지 이 방안이 받아들여졌다.
우리 서버의 요청을 처리하는 타 서버에서 먼저 error response에 error message binary를 metadata를 담아서 처리하는 것을 해주었기에, 우리는 이를 테스트하고 에러를 적절히 처리해주기만 하면 되었다. 그때는 그렇게 생각했다.
여기서 잠깐 zio-grpc가 생성해주는 서버 및 클라이언트 인터페이스를 살펴보고 가는 것이 좋을 거 같다. 실제로는 DI도 따로 해줘야 하고, 이외에 부가적인 코드들을 작성해야 하지만 서버를 구현하는데 있어서 단순히 generated된 trait을 구현하는 부분은 다음과 같다.
override def issue(
request: IssueRequest
): ZIO[Has[RequestContext], Status, IssueResponse] = ...
이 method를 클라이언트가 호출하는 코드는 다음처럼 생성이 된다.
def issueCashReceipt(request: IssueRequest): ZIO[R with Context, Status, IssueResponse]
ZIO 타입도 간단하게 설명을 하자면, ZIO[-R, +E, +A](+, -는 공변성에 대한 것이다)라고 정의한 타입은 R이라는 환경(DI된다)에서 성공하면 A를, 실패하면 E를 반환하는 타입이라는 것이다. 즉, 서버와 클라이언트 모두 성공하면 IssueResponse를, 실패하면 Status를 반환한다.
타입으로만 유추해봐도 일반적인 서버 및 클라이언트를 구성해야 할 것은 다 가지고 있다. 성공하면 원하는 protobuf message를 잘 파싱한 scala 객체를 얻을 수 있고, 실패하면 grpc status code와 description이 담긴 객체를 얻을 수 있다. 그렇다면, 요청과 반환에 있어서 metadata는 어떻게 처리되는 것일까? zio-grpc도 문서화가 잘 되어 있는 편은 아니라서, 검색하면서 찾으니 결국 기능을 구현한 PR이 나왔다.
서버쪽 지원: https://github.com/scalapb/zio-grpc/pull/418
클라이언트쪽 지원: https://github.com/scalapb/zio-grpc/pull/428
당장 우리가 서버의 입장으로서 metadata를 넘겨줄 일은 없었으니 신경을 쓰지 않았고, 클라이언트도 뭔가 기능을 구현해뒀으니 잘 될 것이라 생각을 했다. 이제 에러타입에서 어떻게 metadata를 꺼내올 수 있는지 찾아보았다. 에러 타입은 Status(io.grpc의 구현체)인데, 이 타입은 개략적으로 다음과 같이 값을 담고 있도록 구성되어 있다.
Status(Code code, @Nullable String description, @Nullable Throwable cause)
그리고 이 class의 method를 잘 보면 Metadata trailersFromThrowable(Throwable t)가 있어서, 이것으로 가져올 수 있을 것이라 생각했다. 그런데 아무리 에러가 발생해도 cause가 null로 떨어져서 뭔 수를 써도 metadata를 가져올 수가 없었다.
크게 두 가지를 생각할 수 있었다.
타 서버에서 구현한 것이 제대로 동작하지 않는가?
gRPC를 테스트할 때 요긴하게 쓰고 있는 grpcui에서 요청을 보내면 binary가 담겨 있다는 것은 확인할 수 있었다.
그러면 우리 서버 문제네?
진짜로 멘붕이 왔다. 라이브러리가 제대로 해주지 않는 기능을 또 뜯어 고쳐야 한다니 말이다. 나중에 소개할 기회가 있으면 좋겠지만 예전에는 spring R2DBC DB driver에 성능 이슈가 있는 것을 찾아서 패치하고 따로 빌드해서 갖다 쓴 기억이 있었기에, 또 삽질을 해야 하는 구나 싶었다.
일단 코드를 쭉 깊이 들어가봤다. 다른 언어들도 잘 되어 있을 거 같지만, Java가 참 좋은게 이미 컴파일된 라이브러리의 원본 코드를 레퍼런스로 제공하고, 거기에다 브레이크포인트까지 찍어볼 수 있어서 이곳저곳을 헤집어 볼 수 있다. depth 순서대로 살펴볼 부분만 정리해보았다. 참고로 지금까지 보시면서 뭔가 이상한 점을 눈치채셨을 수도 있는데, velog code block highlighter는 scala를 지원하지 않는다(...) 일단 전체 코드는 gist로 남겨두었으니 좀 더 편하게 보실 수 있고, 글에서는 아주 짧은 코드들만 들고 올 것이니 미리 양해의 말씀을 전달해드리고 싶다.
모든 depth 코드: https://gist.github.com/codingskynet/c115de12973cf6afacca055d9d601d22
처음 분석할 때에는 GIO.scala를 의심했었다. 코드를 다시 보면,
def fromTask[A](task: Task[A]) =
task.mapError(e => Status.INTERNAL.withDescription(e.getMessage).withCause(e))
def effect[A](effect: => A) = fromTask(Task.effect(effect))
ZClientCall에서 모든 요청을 다 GIO를 통해서 처리하는데, fromTask를 살펴보면 에러가 나면 INTERNAL로 따로 생성해서 처리하고 있다. 사실 지금 생각해보면 Status.INTERNAL로 고정하고 있기 때문에 의심할 필요는 없었지만, 일단 가장 간단한 부분이었기에 여기에 브레이크 포인트를 물고 테스트를 진행했었다. 예상하셨다시피 저 .mapError 부분을 아예 타지를 않았다. 아니 그러면 어디에서 빼놓고 처리하고 있던 것일까? 혹시 grpc-java를 조금이라도 맛 보신 분들이라면, 이 zio-grpc는 다음과 같은 구조로 되어 있다는 것을 쉽게 이해하실 수 있을 것이다.
my server <-> zio-grpc <-> grpc-java(실제 gRPC 구현체)
grpc-java는 많은 곳에서 잘 사용되고 있기 때문에 여기를 의심하는 것은 적절하지 않다고 생각했다. 그렇다면 나올 수 있는 가설은 'grpc-java에서 client call을 처리하고 response를 보내줄 때 zio-grpc가 어디서 누락하고 있는 것이 아닐까?' 였다. 이것이 맞았다. grpc-java의 ClientCall.Listener를 구현하고 있는 UnaryClientCallListener를 살펴보면 다음과 같은 코드가 있다.
override def onClose(status: Status, trailers: Metadata): Unit =
runtime.unsafeRun {
for {
s <- state.get
_ <- if (!status.isOk) promise.fail(status)
else
s match {
case ResponseReceived(headers, message) =>
promise.succeed(ResponseContext(headers, message, trailers))
case Failure(errorMessage) =>
promise.fail(Status.INTERNAL.withDescription(errorMessage))
case _ =>
promise.fail(
Status.INTERNAL.withDescription("No data received")
)
}
} yield ()
}
여기서 for 구문은 우리가 아는 일반적인 for문은 아니고, scala에서 flatMap을 for 구문 안에서 <- 연산자를 사용하여 나타낸 것이다. 따라서 잘 읽어보면 onClose일 때(응답을 받고 종료하는 단계) state를 가져와서 뭔가 핸들링을 하고 있는데, 그 도중에 if (!status.isOk) promise.fail(status)로 not Ok response를 처리하고 있다는 것을 알 수 있다. 실제로 ok response가 오면 promise.succeed로 타고 들어가서 header, message, trailers를 잘 전달해주고 있는데, not Ok일 경우엔 아닌 것이다. 여기가 무조건 문제일 것이라 생각해서 브레이크 포인트 찍어서 status와 trailers의 값이 어떤 게 들어있는지 확인했다. 예상대로 저 시점의 status는 cause를 null로 들고 있었고, 잘 들어오던 trailers를 여기서 안 넣어주고 있던 것이었다. grpc-java쪽 ClientCall.Listener도 혹시 몰라 살펴봤는데 확실히 이 시점에서 넣어주는 것이 맞는 구현인 듯 했다.
@Override
public void onClose(Status status, Metadata trailers) {
if (status.isOk()) {
if (!isValueReceived) {
// No value received so mark the future as an error
responseFuture.setException(
Status.INTERNAL.withDescription("No value received for unary call")
.asRuntimeException(trailers));
}
responseFuture.set(value);
} else {
responseFuture.setException(status.asRuntimeException(trailers));
}
}
이 유실되고 있는 trailers를 어떻게 잘 넣어줄 수 있을까? 사실 grpc-java만 보더라도 원래 에러 타입은 Status가 아니라 StatusRuntimeException(or StatusException)의 꼴을 가져야 한다는 것을 알 수 있다. 그런데 에러 타입을 바꾸는 것은 breaking change이고 많은 곳을 뜯어 고쳐야 하기 때문에 거부감이 들었다. 따라서 조금 이상하지만 Status(cause = StatusException(status = self, trailers))의 구조를 가지도록 다음과 같이 수정하였다.
_ <- if (!status.isOk) promise.fail(status.withCause(status.asException(trailers)))
이러면 그토록 원하던 trailers를 위에 제시한 trailersFromThrowable로 가져올 수가 있었다. 당장 이 기능이 필요했기에 먼저 우리 회사 레포에 클론 떠서 빌드 및 배포를 해서 현재까지 사용하고 있다.
이렇게 라이브러리에서 미지원하는 기능이 어떤 부분에서 구현되어야 하는 지 분석해서 적절한 구현을 하였다. 마침 이 기능은 PR에 계류되고 있는 그런 것도 아니었기에 추가적인 테스트와 streaming server쪽 기능도 구현하여 PR을 올렸다.
https://github.com/scalapb/zio-grpc/pull/459
처음으로 오픈소스에 기여하는 것이라 PR 규칙이나 기여 정책을 알아봤는데, 뭔가 정형화되어 있는 그런 건 없는 듯해서 잘 정리해서 올렸더니 다행히 잘 머지되었다. 살짝 아쉬운 부분이라면, 현재 우리는 0.5.x 레거시 버전을 사용하고 있어서 아직 main branch에는 반영이 안 되어서 기여자에 내가 안 뜨긴 했다.
위에 말한 것처럼 근본적으로 에러 타입이 Status가 아니라 StatusException와 같은 것이었으면 좀 더 깔끔할 거 같기 때문에, minor version 업데이트 시에 그렇게 고쳐보는 것에 대한 이야기도 계속 하고 있다.
지난 번에도 기여할 기회가 있었지만 아쉽게 놓쳤어서, 이처럼 원하는 기능에 대한 문제를 찾아서 적절히 구현하여 오픈소스에 기여한 것은 나름 값진 경험인 거 같다. 너무 시간을 잡아 먹지도 않고 적절하게 빠르게 한 일 같아서 돌아볼 겸 이렇게 글을 남겨 본다. 개인적으로 회사에 다니기 전까지는 회사에서 어떻게 일을 하는지 너무 몰랐기 때문에, 이런 글들이 그런 사람에게 조금이나마 도움이 된다면 그저 기쁠 것 같다.
멋있어요 응원합니다