안녕하세요. 오늘의집 Platform팀 리드 호성, DevOps팀 엔지니어 제제입니다.
MSA 전환을 위한 첫 번째 작업이었던 <프론트엔드 분리 작업 (클릭)>이 성공적으로 끝났습니다. 프론트엔드와 백엔드의 분리로 하나의 컴포넌트가 두 개가 된 것처럼 보이지만, 사실 프론트엔드와 백엔드 사이에 API Gateway라는 컴포넌트가 추가로 들어가게 되었습니다.
API Gateway는 잘 알려진 OOP의 디자인 패턴 중 Facade 패턴과 같이 뒤에 있는 복잡한 서비스를 심플한 인터페이스로 제공하는 Edge Server로 모든 API의 Endpoint를 받아서 내부의 마이크로서비스로 라우팅해주는 역할을 합니다.
API Gateway를 적용할 때 얻는 이점과 문제점은 다음과 같이 있을 수 있습니다.
API Gateway를 적용할 때 얻는 이점
- 클라이언트에서 어느 마이크로서비스 인스턴스로 요청을 보낼지 고민하지 않아도 된다.
- 백엔드 마이크로서비스의 복잡도를 숨길 수 있다.
- 클라이언트 입장에서 여러 개의 서비스를 하나의 서비스로 취급하거나, 반대로 한 개의 서비스를 여러 서비스로 취급하여 처리할 수 있다.
- 요청에 대한 공통 관심사를 중앙에서 처리할 수 있다. (Security, Routing, Rate Limiting, Monitoring etc)
API Gateway를 적용함으로써 생길 수 있는 문제점
- 모든 요청이 게이트웨이를 거치기 때문에 응답시간이 늘어날 수 있다.
- API Gateway 장애 시 모든 서비스가 작동하지 않는 SPOF(Single Point Of Failure) 문제가 발생할 수 있다.
저희는 앞으로 더 많은 백엔드 컴포넌트를 마이크로서비스로 떼어내기 위해서 API Gateway 추가는 필수라고 판단하고, 도입 당시 크게 3가지 기능을 중점적으로 여러 API Gateway 오픈소스를 검토했습니다.
- Path, Header 기반 백엔드 서비스 라우팅
- Weight에 따른 트래픽 라우팅
- 서비스 및 사용자 인증
이 외에도 기존 시스템과 앞으로 도입할 오픈소스 시스템과의 연동성, 확장성, 안정성 등을 고려해 결국 Ambassador(현 Emissary-ingress)를 API Gateway로 선정했는데요.
특히 ‘인증’ 부분이 MSA 전환 기간 중 제일 먼저 해결해야 하는 문제였습니다. 오늘 포스팅에서는 어떻게 이 문제를 해결해서 API Gateway를 프론트엔드와 백엔드 사이에 넣을 수 있었는지 그 과정을 설명하려 합니다. 🙌
바라는 이상향과 현실, 그리고 문제점
우리가 바라는 이상향
‘아침에 눈을 뜨고 일어났더니 프론트엔드와 백엔드가 분리되어 있고, 백엔드도 DB까지 도메인별로 분리되어 배포되어 있으면 좋겠다!'
백엔드
- 도메인별로 서비스가 분리돼 운영되면 좋겠다.
- 서로 다른 서비스의 통신은 gRPC를 이용해 통신 오버헤드를 줄이고 싶다.
- 이벤트(메시지)를 적극 이용해 서로 다른 도메인 간의 결합을 느슨하게 유지하면서 확장성을 가져가고 싶다.
프론트엔드
- 마이크로 프론트엔드를 도입해 도메인별 페이지도 따로 개발하고 싶다.
- BFF(Backend for Frontend)를 두어 프론트엔드를 그리기에 최적화된 API만 내려주고 싶다.
But 우리의 현실
- 웹 클라이언트, 모바일 클라이언트 모두 ‘ohou.se’라는 단일 도메인으로 API를 호출하고 있음
- ohou.se 도메인에서 API도 제공하고 웹 페이지도 제공함
- ohou.se 도메인은 ohs-web이라는 Rails로 구현된 단 하나의 서비스를 바라보고 있음
- 즉, ohou.se 은 모든 도메인의 API도 제공하고 SSR(Server Side Rendering)을 적용한 웹페이지도 제공함
새로운 기능을 개발할 때 ohs-web 코드베이스에서 벗어난 새로운 프로젝트를 만들고 배포하여 작은 Microservice를 만들긴 했지만 서비스의 주요 비즈니스 로직이 모두 ohs-web에 있기 때문에 해당 서버들은 아주 특정한 동작만을 담당해 주는 API Server로서 동작했고, 해당 API를 ohs-web에서 사용하는 형태로 구현했습니다.
MSA Phase 1
어떻게 해야 우리가 원하는 이상향으로 점진적으로 나아갈 수 있을까?
Phase 1.1 프론트엔드 백엔드 분리
Phase 1.2 백엔드 도메인 분리
인증에 대한 필요성
위와 같은 단계로 서비스 분리를 시작한다고 했을 때 API Gateway에서 수행해 줘야 하는 가장 중요한 역할은 ‘인증’입니다.
클라이언트로부터 들어온 요청을 서로 다른 언어로 만들어진 서비스로 넘겨준다고 했을 때 해당 요청이 인증된 유저의 요청이라는 것을 API Gateway에서 판단할 수 있다면 뒤단에서는 반복된 인증작업을 거치지 않아도 될 것입니다.
마주한 도전과제
- 현재 Rails에 구현되어 있는 인증방식을 동일하게 지원할 수 있어야 한다.
- 앞으로 새롭게 도입할 인증방식도 동시에 지원할 수 있어야 한다.
중단 및 모바일 애플리케이션의 강제 업데이트 없이 기존 서비스의 구조를 변경하기로 결정하였기 때문에 반드시 기존 Rails에 구현되어 있던 인증방식을 지원해야만 합니다. 또한, 앞으로는 MSA에 보다 적합한 인증방식을 도입할 수 있기 때문에 새로운 인증방식에도 유연하게 지원할 수 있어야 합니다. 따라서 API Gateway에 우리가 원하는 커스텀한 인증방식을 추가할 필요가 있었습니다.
처음에는 Ambassador에서 제공하는 인증기능을 활용해 구현하려 했으나 해당 기능은 유료 사용자에게만 제공하는 기능이었기 때문에 Envoy에서 제공하는 ext-authz 인터페이스를 활용해 API Gateway의 인증 레이어를 직접 구현하기로 했습니다.
커스텀 인증 레이어의 구현 과정
Ext Authz 란 무엇인가요?
한마디로 정의하자면 Envoy Proxy에서 제공하는 외부 인증 인터페이스입니다.
위 시퀀스 다이어그램에서 확인할 수 있듯이, ext-authz 서버를 정의해두면 Envoy Proxy로 들어오는 모든 요청이 Upstream으로 넘어가기 전에 ext-authz 서버로 질의를 하고 해당 요청이 인증된 요청인지 아닌지 판단합니다.
Filter와 FilterPolicy
본격적으로 ext-authz 구현체 서버를 개발하기에 앞서, 해당 서버에서 관리할 Object를 먼저 정의하기로 했습니다.
Filter
독립된 인증 방법을 제공하는 단위입니다. 여러 타입이 존재하며 동일한 타입의 Filter를 여러 개 정의할 수도 있습니다. 현재 제공하고 있는 Filter Type은 아래와 같습니다.
- S2T: Session To Token Filter로써 인증세션과 Auth Token으로부터 User ID를 추출 할 수 있게 합니다.
- JWT: JWT Token 인증방식을 제공합니다.
- IP: 요청 IP를 기반으로 필터링합니다.
추후 해당 Filter의 타입만 추가로 정의한다면 더 다양한 인증방식을 제공할 수 있게 됩니다.
FilterPolicy
특정 Host, Path로 요청이 들어왔을 때 어떤 Filter를 적용시킬지에 대한 정의를 담고 있습니다. 순서를 가진 Array로 Rule을 정의할 수 있으며 하나의 Policy에 다수의 Filter를 적용시킬 수 있습니다. Filter는 순서에 맞게 처리되며 해당 Filter의 성공, 실패 여부에 따라 다음 Filter를 실행시킬지 말지도 정의할 수 있습니다.
Custom Resource로 Object를 관리하자!
Kubernetes는 관리하는 모든 데이터를 Object로 표현합니다. 그리고 해당 Object에 정의된 내용을 기반으로 주어진 작업을 수행하는 여러 개의 Controller가 존재합니다.
Kubernetes의 이러한 디자인 덕분에 모든 인프라스트럭처를 YAML파일로 표현할 수 있으며 나아가 Kubernetes는 개발자에게 이런 Object를 직접 정의하고 관리할 수 있는 인터페이스를 제공하기도 하는데요. 이 인터페이스 중 하나가 바로 Custom Resource Definition입니다. Ambassador도 API Gateway의 라우팅 규칙을 Mapping이라는 Custom Resource를 통해 정의하고 관리하고 있습니다.
우리는 특정 서비스의 모든 인프라스트럭처 정의와 규칙을 해당 서비스의 배포 Manifest가 존재하는 곳에서 모두 확인하고 싶었습니다. (그것을 도와주는 툴이 Helm이 됐든, Kustomize가 됐든) 즉, 해당 서비스가 어떠한 규칙을 통해서 API Gateway로 라우팅되고 있고(Mapping), 어떤 Filter를 통해 인증을 하고 있는지 모두 Kubernetes의 Object 형태로 정의하길 원했습니다.
따라서 앞서 정의한 Filter와 FilterPolicy라는 Object를 Custom Resource로 정의해 Kubernetes 위에서 관리하기로 하였습니다.
마주한 문제들과 해결 방법
다양한 Filter와 FilterPolicy를 개발하면서 마주한 문제를 어떻게 해결했는지에 대해 소개하겠습니다.
Custom Resource Object를 모니터링하고 관리하는 Kubernetes Controller를 개발하면서 많은 난관에 봉착했고, 이미 해결해 나간 것들도 많은데요. 해당 내용은 다음 기회에 다뤄보도록 하겠습니다. ☺️
Session-to-Token Filter(a.k.a S2T Filter):
Rails Session Key로 어떻게 UserId를 Parsing 할 것인가
Ruby Object Marshal
기존 레거시의 인증 방식은 쿠키에 저장되어 있는 Session 정보들을 서버에서 받아 Ruby Object로 Marshalling된 상태로 저장하고 있기 때문에, 해당 데이터를 다른 언어를 사용하는 환경에서 인식할 수 있어야 합니다.
기존 환경에서는 JSON이나 YAML처럼 범용적인 포맷이 아닌 Ruby에서 정의한 포맷으로 Marshal, Unmarshal이 되므로 Ruby의 객체나 Data Type이 별도의 변환 없이 저장되고 다시 불러올 수 있다는 장점이 있었습니다.
문제는 Go로 만들어진 ohouse-ext-authz서버에서 Ruby Object로 저장된 데이터를 읽어서 사용자를 특정할 수 있어야 한다는 것이었습니다.
해결 방법으로 떠오른 것은 다음과 같습니다.
- Go에서 Ruby를 Child Process로 Execution시켜서 응답값을 받아온다.
- ohs-web에 Unmarshal해주는 Internal API를 제공하고 매번 통신한다.
- Go에서 Ruby Marshal Format으로 직접 Unmarshalling한다.
앞선 두 가지 방법은 모두 커다란 통신 오버헤드를 가지고 있었습니다. 따라서 API Gateway로 들어오는 모든 요청의 앞단에서 인증을 판단해야 하는 ohouse-ext-authz 서버의 특성상 선택할 수 없는 옵션이었습니다. 그래서 결국 Ruby Marshal Format을 Go 코드로 직접 구현하기로 하였습니다. 😭
다행히도 github에 비슷한 구현체들이 많이 있었기 때문에 Ruby 공식 문서와 해당 구현체들을 참고하여 비교적 쉽게 문제를 해결할 수 있을 줄 알았습니다.
그러나 당연하게도 Go에서는 Ruby의 Primitive Data Type 정도의 Unmarshal만이 가능할 테고, Rails 위에서 돌아가는 특정 Object를 Load 할 순 없었습니다. 또한 ohs-web에서 Session에 담는 정보에는 Rails Object도 많이 들어있었습니다.
따라서 전체 Session Object를 Unmarshal 하지는 못하고 UserId가 들어있는 특정 Key Value만을 Ruby Int Format으로 Unmarshal해서 UserId를 Parsing 하였습니다. (즉, 세션에서 다른 정보를 더 추출하고 싶다면 추가 구현을 해야 합니다.)
JWT Filter: OAuth 서버와 JWKS를 어떻게 공유할 것인가
MSA로 시스템 구조를 변경하면서 새로운 인증 방식으로 선택한 방법은 JWT Token을 이용한 OAuth2입니다.
따라서 인증 토큰을 발급해 줄 OAuth 서버가 생기게 되었고, ohouse-ext-authz는 헤더에 들어있는 JWT Token이 OAuth 서버로부터 발급된 Token인지 확인해 줘야 하는 책임이 생겼습니다.
JWKS란 무엇인가요?
JSON Web Key Set의 줄임말로 JSON 형태로 암호 Key Set를 제공하는 특정 포맷을 의미합니다. (rfc 문서 참조)
해당 Key Set을 통해서 JWT Token을 서명한 암호키를 전달받을 수 있습니다.
k8s secret(Kubernetes Secret)을 통해서 공유하자
JWKS를 공유하는 대표적인 방법은 OAuth 서버에서 현재 사용하고 있는 JWKS를 받을 수 있도록 URL을 제공하는 것입니다. 그러나 이렇게 OAuth 서버에 요청을 보내서 JWKS를 받아오는 것은 OAuth 서버에 부하를 줄 수도 있고, OAuth 서버의 배포가 잘못되거나 서버의 장애로 인해 서비스를 제대로 제공하지 못하면 해당 JWKS에 종속성이 존재하는 일련의 서비스 장애가 발생하게 됩니다.
따라서 JWKS를 Kubernetes Secret Object로 관리하여 관리 책임을 Kubernetes에게 넘기고, OAuth 서버와 ohouse-ext-authz 서버는 해당 Secret을 서로 읽음으로써 Secret Key를 확인하는 방법으로 JWKS를 공유하기로 하였습니다.
그렇다면 Secret 주입은 누가 해주지?
Token을 서명한 Private Key는 매우 신중히 관리되어야 하는 정보입니다. Kubernetes Secret을 통해 배포하고 서로 공유하기로 한 것은 좋았지만, 해당 Secret을 어떻게 주입하느냐에 대한 문제점이 남아있었습니다.
Secret을 관리하기 위한 별도의 Secret 백엔드를 가지는 것이 필요했습니다. 하지만 우리는 특정 Secret 백엔드에 종속성이 생기는 것을 최대한 막고 싶었습니다.
그래서 External-Secrets-Controller라는 Controller를 Kubernetes 클러스터 안에 추가하고, Secret 백엔드를 한 번 더 추상화해 특정 Secret 백엔드에서 Secret 정보를 가져와서 Kubernetes Secret Object로 주입할 수 있게 하는 방법을 선택했습니다. 이렇게 Secret 백엔드에서 가져온 데이터를 Kubernetes Secret Object로 변환해 줌으로써 특정 Secret 백엔드에 종속성이 생기지 않게 되었고, Secret 백엔드가 다운되는 경우에도 이미 변환되어 있는 Kubernetes Secret Object 덕분에 서비스에는 영향을 주지 않게 됩니다.
마치며
사실 API Gateway, 커스텀 인증레이어의 필요성은 굉장히 오래전부터 팀 내부에서 논의되었습니다. 미래의 시스템 구조를 위해 미리 프로젝트를 시작하고 개발을 진행하고 있는 상황이었기 때문에 개발 이후에도 한참 동안 사용되지 않았고, 개발 당시에는 임팩트가 적었는데요.
그럼에도 불구하고 뛰어난 동료 개발자분들과 함께 더 확장성 있는 구조, 다음 단계인 Phase 2를 생각할 수 있는 시스템을 만들기 위해 전투적으로 논의하고 공부하며 개발에 임했습니다. 또한 최악의 상황을 가정하고 대처법을 논의하며 로드 테스트, 스트레스 테스트를 거쳐 지속적으로 발전시켜 나갔습니다.
이런 일련의 과정들이 있었기 때문에 실제 MSA 기간에 돌입했을 때 API Gateway 컴포넌트를 믿고 사용할 수 있었고, 다른 새로운 컴포넌트에 더 집중해서 테스트 할 수 있었습니다.
이처럼 오늘의집 플랫폼팀과 DevOps팀은 현재의 시스템에 만족하지 않고 더 나은 시스템을 구축하기 위해 서로 긴밀하게 협업하며 다음을 위한 스텝을 계속해서 밟아 나가고 있습니다.