JavaScript 적응기 01 - Vue.js

최근 새 팀에 합류했다.

합류한 팀에서 풀 스택 개발 추구하고 있었고, 그 과정에서 웹 프론트엔드 개발에 vue.js를 사용하고 있었다.

Vue.js

자연스레 자바 스크립트를 사용해야 했는데, 2009~2012년경 윈도우 배치 스크립트 짜기 괴로워, Jscript (윈도우 내장 자바 스크립트 엔진을 이용한 스크립팅)을 했던 뒤로 오랜만에 실무에 사용하게 됐다.

종종 Node.js를 이용한 REST API 서버를 가볍게 써오긴 했으나, 나의 경우엔 업무 외적인 습작에 써왔고, 백엔드 서버로써 사용한 거라 굉장히 다르다고 할 수 있다.

주로 백엔드 내지는 서버 개발자 포지션에 있었으나, 클라이언트 개발에 관심도 많고 종종 해왔던지라 거부감이 없다는 점은 매우 다행이지 않나 싶다. 팀의 방향성과 나의 가치관에 충돌이 있을 땐 스트레스 요소가 될 수 있는데, 나는 아주 좋은 기회라고 생각이 들었고, 재미도 있었다.


대략 한달 간 가량 진행한 Vue.js의 감흥은 생각보다 쉽다 였다. 웹 프론트엔드 문외한이나 다름없었지만, 동료들이 작성한 코드를 보고 구조적인 이해나, 인프런 vue.js 강좌와 동일한 강사분이 써두신 입문서를 보고 금새 무언가를 만들 수 있을 만큼 직관적이고, 동작하는 무언가를 만드는 시간이 적게 소요됐다.

즉 학습도, 실습도 빠른 시간내에 가능했다는 의미다.

가장 도움이 된 것은 vue.js의 컴포넌트 라이프 사이클 이해하기 였는데, 유니티때와 마찬가지로 컴포넌트간 상호 작용이 크게 중요한 만큼, 이 그림은 계속 참고하면서 코딩하게 되는 유용한 자료 였다.

Vue.js Lifecycle

참고: https://medium.com/witinweb/vue-js-%EB%9D%BC%EC%9D%B4%ED%94%84%EC%82%AC%EC%9D%B4%ED%81%B4-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0-7780cdd97dd4


반면 힘들었던 점은 역시나 언어의 태생적인 단점이었다. 나는 강타입 언어를 선호하는데, java script 자체는 typeless 언어라서, 이에 대한 아쉬움이 있었다.

Vue.js에서도 TypeScript를 지원 Vue.js TypeScript 지원 하고 있으나, 현재 팀에선 사용하고 있지 않은 상태였고, 리액트에 비해 상대적으로 미약하다고 한다. 이 부분은 아무래도 좀 이해도가 높아진 후에야 명확히 말할 수 있을 것 같다.

Vue.js 자체의 규칙은 간결하고 직관적이라 쉬운편이라고 할 수 있는 반면, 내가 HTML5, css, bootstrap등에 대한 이해도나 경험이 부족해서 이 부분에 대해서 막히는 부분이 많았다.

html을 직접 작성해야 하고, vue와 결합도가 높게 동작하고 연관하여 분석, 작성 해야 하다 보니 부족한 이해도가 아쉬웠다.

그런 부분을 감안했을 때에도 생각보다 허들이 낮은 편에 속했고, 프레임워크 자체에 대한 룰이나 제약이 단순하고, 해결 방법도 제시되어 있어 큰 어려움이 없이 작업 할 수 있었다.

Vue.js에서 주장하는 쉽고 빠른 간결한 프레임워크라는 주장을 신뢰할 수 있는 경험이었다고 할 수 있겠다.


애초에 Web Frontend라 할 수 있는 작업도 처음이지만, SPA로 작업한 것도 처음이고, 컴포넌트 단위로 코딩한 것도 처음이지만, 게임 UI랑 비교 했을 때에 아주 색다른 개념들은 크진 않았다.

컴포넌트간 통신 및 계층에 따른 의미나 제약을 집중해서 살펴보면서 작업을 진행했다.

컴포넌트 간의 순환 참조, 생성 순서 문제, 상호 통신, 순환 참조 문제 등 다양한 이슈가 얽혀 있었다. 선행 조건들이 늘어 날 수록 컴포넌트 간의 관계도 복잡해지고, 결합도가 높아지는 걸 느낄 수 있었는데, 이에 대한 몇가지 우회 방법들이나 팁이 존재하더라.

또 다른 측면의 걱정은 느리지 않을까?

처음 맞닥뜨린 vue.js는 각 컴포넌트를 감시하게 동작 할 것 같다는 생각이 강하게 들었었다.

앵귤러의 특징이면서도 단점으로 여겨지는 양방향 바인딩도 존재하고, v-bind나 v-if 등의 키워드로써 걸어놓은 트리거들이 동작하는 것들이 많고, 쉽게 사용할 수 있게 직관성을 강조하다보니 그만큼 느리지 않을까 하는 우려가 있었다. (물론 내부적으로 최대한 폴링보다는 이벤트 드리븐으로 잘 짰겠지만, 미지의 영역인 프론트 엔드 프레임워크에 대한 막연한 의구심 같은 거라고 할 수 있겠다.)

아주 의외인 것은 리액트보다 빠르다는 점이었다. 측정한 곳이 vue.js 공식 페이지다보니 수치나 정말 복잡하게 사용하는 상황에서 마저 우위에 있을지를 100% 믿기엔 어렵겠지만 말이다.

막연한 의구심을 해소하기 위해 이런 저런 글들과, 지인들에게 자문을 구했는데, 결론은 프론트엔드 프레임워크 대부분이 사용성과의 밸런스를 맞추기 위한 코스트를 감안하고 구현되어 있고, 돔을 직접 다루는 번거로움과 어지간히 잘 관리하지 못하면 돔을 찾고 수정하는 과정에 오버헤드가 걸리곤 하는데, 이를 프레임워크가 관리하면서 적정 수치 이상으로 유지해주기에 신경 쓸 거리가 적다고 봐야 한다는 것이었다.

이런 고민들이나 의구심들 대다수가 게임 UI 에서도 공통되게 고민되는 것 들이고, 비슷하게 접근하고 나면 납득 할 수 있는 것들이 많았다.

다만 현재까지의 생각과 감상이 든 과정 모두 내가 적응기로써 짧은 기간 학습+적응을 목표로 작업한 admin web에 한정된 이야기였고, 브라우저 호환성 및 메모리 이슈, 훨씬 더 복잡한 UI간 상호 작용을 요구하는 작업에선 훨씬 더 많은 감상이 있지 않을까 싶다.

Mongodb 서버 구축 및 아키텍쳐

Mongodb를 실 운용해본 후기 및 상황에 따른 권장 구성에 대해서 설명해보고자 한다.


최종 데이터는 1.3TB였고, read & write node 역할을 함께 하게끔 구성해서 3대로 운용하다가, read node, write node 각각 3개씩으로 나누어 운용하며 겪었던 경험에 대한 이야기다.

1.3TB데이터는 모두 단일 콜렉션에 담았으며, 인덱스만으로 aggregation을 시도했고, 이 과정에서의 성능 차이를 주는 요소를 확인했으며, 일일 API 호출 통계(정상 API 호출까지 모두 집계)를 집계했으며, 일 데이터는 400MB~2GB 사이였다.

분산 콜렉션에 map reduce하는 방법도 고려해봄직 했으나, 이 역시 연산 코스트는 작지 않으며, 큰 단일 콜렉션에서의 성능과 이슈를 확인해보고 싶었다. (인덱스 효율, 인덱스를 타는 케이스, 타지 않는 케이스가 방대해진 인덱스에서도 동일하게 동작하는지 확인 등)

대다수의 상황에서는 hot data개념이라거나, read node, write node 분리 정도만 염두에 두어도 충분히 효과를 볼 수 있다.


mongodb는 기본적으로 샤딩과 레플리카를 모두 기본 옵션으로 지원한다. 샤딩은 오토 샤딩을 지원하며, 이는 리밸런싱을 배제한 옵션이다.

mongodb의 구성에서는 몇가지 이해하고 넘어가야 할 조건들이 존재한다. 아무리 RDB보다 빠르다지만 1억건 이상의 데이터에서는 결국 성능 문제를 맞닥뜨린다. 이 중 가장 큰 이슈가 되는 것은 당연하게도 agreegate 동작이다.

mongodb는 기본적으로 memory mapped file을 기반으로 한다.

이는 메모리 + 가상 메모리를 이용한다는 의미로써, 물리 메모리가 적재할 데이터 보다 작다면 디스크 IO가 크게 발생하고 이는 곧 성능 저하로 이어진다는 의미다.

이 부분이 긴가 민가 했는데, 실제 운용해보니 인덱스에 있는 데이터는 메모리에 올리고 인덱스에 없는 데이터까지 연관 조회 혹은 aggregate시에 인덱스에 없는 데이터를 조건에 포함시켰을 때, 메모리에 없는 데이터를 읽기 위해 계속 메모리에 적재를 시도한다.

이 과정에서 가상 메모리를 결국 사용하게 되고 성능이 급격히 저하되는 것을 확인할 수 있었다.

또한 메모리에 올라와있는 데이터를 hot data라 부르는데, 이를 적절한 크기로 유지해주면 성능상 큰 이득을 볼 수 있다. hot data에 대한 자세한 이야기는 밑에서 좀 더 설명하겠다.


샤딩과 레플리카 구성 으로 넘어오자면, mongodb의 경우 여타 db들과 비교했을 때 샤딩, 레플리카 구성 모두 쉽다.

mongodb에는 3가지 종류의 서버가 존재한다.

MongoDB Sharding 구조

하나의 config 서버와 여러개의 MongoDB 서버로 구성되어 있다.

  • Config 서버
    • 중개자 계층, 샤딩을 위한 메타 데이터를 저장한다. (데이터들의 위치 정보를 저장)
  • Mongos 서버
    • MongoDB의 중개자 역할, Config 서버의 메타 데이터를 이용해 각 MongoDB에 데이터 접근을 도와준다.(라우터와 같은 역할)
  • Mongod 서버
    • MongoDB의 데이터 서버
    • 서버 장애에 대비해 MongoDB 서버 안에 여러 개의 리플리카 셋 구조로 구성되어 있다.

Client(응용 계층) → Mongos(중계 계층) → Config(중계 계층) → Mongod(데이터 계층)

mongodb sharding internals

필요한 구성요소는 이렇고, 배치와 연관 관계 설정에 따라 여러가지 모델이 있을 수 있는데, 핵심은 read node와 write node(+read지만 write만 하는 것을 권장한다)를 잘 선정 해야 하는 데 있다.

read node와 write node를 같이 두지 않는 것은 RDB에서도 중요하나 mongodb에서는 더욱 더 크게 중요하다.

위에서 언급한 hot data를 node마다 따로 보유하기 때문이다.


document를 참고해서 시스템을 구축하고 운용하면서 몇가지 궁금증이 생겼었다.

  1. config 서버는 왜 3대를 구축해야 하는가?
    • config 서버는 registry+router라고 봐야 함.
    • 결국 데이터를 쌓을 곳 혹은 읽을 곳을 전달해주는 서버가 되므로, 서버 수에 따라 안정성 및 반응 속도, 데이터 유실율, Read/Write 정합성을 맞추는 데에 중요한 역할을 한다.
    • 3개의 서버중 하나가 죽어도 나머지 서버가 설정 정보를 보유,전달 하면서 지탱해주는 3중화라고 봐야 한다고 한다.
  2. 자동으로 해주는게 별로 없는데 auto sharding이라 부르는 이유는 무엇인가?

둘다 mongodb 공식 페이지 및 각종 글을 읽어보고 내린 결론인데, 이견이나 보강하실 부분이 있으시다면 좀 더 자세히 설명해주시면 감사하겠다.


또 다른 팁은 무엇이 있을까?

aggregate처럼 집계연산은 실시간이면 좋긴하지만 또 일정 시간의 소요를 감안하고 운용되곤 한다. 실제로 hadoop 과 같은 빅데이터 솔루션들 대다수는 실시간이라기보다는 배치 잡으로 동작하여 긴 시간이 소요되는 걸 감내하고 운용되는 것을 감안하면 감내할 수 있는 부분이다.

  1. aggregate와 같은 메모리 사용량이 큰 동작을 처리하는 node를 분리하고, 응답성이 중요한 데이터를 다루는 read node를 분리하는 것도 하나의 전략이 될 수 있다.
    • 메모리 사용량이 큰 동작은 hot data가 잦은 갱신이 이뤄지는 원흉이지만 또 한편으로는 통계나 집계의 핵심 역할이다. 이를 여타 솔루션으로 보내기보단, 직접 처리할 수 있으면 빅데이터의 중간 처리자나, 직접적인 통계 처리자의 역할을 충분히 수행 할 수 있기에, 여타 read node에 영향을 주지 않게끔 분리하는 것은 충분히 합리적인 선택지 중 하나가 될 것이다.
  2. document 단위로 최대한 read node를 분리해서, document 단위의 hot data를 의도하고 최대한 hot data 연산이 이루어 질 수 있게끔 구성하는 것도 방법이다.
    • document가 유사하면, 유사한 쿼리를 요청할 확률이 높고 이는 같은 인덱스를 사용할 확률이 높다.
    • 그럼에도 인덱스들이 메모리 사용량을 상회할 것이라고 생각된다면, 이 마저 감안해서 분산하는 것이 옳다.
    • 특히 main db로써 사용중이라면 응답성의 핵심이 mongodb의 응답 속도 일 것이므로, 인덱스를 얼마나 잘 활용할 수 있는지에 맞게끔 read node를 분산해야 한다.
  3. 여타 DB도 마찬가지이지만, mongo db도 항상 read & write node를 분리해야만 하는 것은 아니다.
    • 내가 주로 사용한 agreegation 자체가 최근에 사용된 데이터 기반이 아닌,  전날 데이터 전체를 가져와야 했으므로, read node 자체가 write node가 바라보는 working set이 무의미한 구성이기도 했고, 서로 영향을 주고 받지 않게 하기 위해서였다.
    • 대다수 상황에서는 read & write node는 일치 시키고, write node와 replica set 구축하는 것이 더 일반적이며, 효율적인 구성이 된다는 점을 염두에 두자.

너무나 중요해서 다시 한번 강조하자면, mongodb를 구축한 서버의 메모리와 주로 다루게 되는 인덱스의 크기를 안넘어서게 하면 최대한 메모리 내에서의 연산을 이끌어내서 성능 저하를 피할 수 있다는 점이다.

node 분배의 최적화는 hot data를 RAM을 넘어서지 않게끔 관리해주고 hit rate를 높이는 데에 있으나, 이렇게 강조했음에도 현실적으론 용도에 맞게끔 node를 매번 구성하기 어렵다. auto-sharding이 된다고해도 리밸런싱이 지원되는 것은 아니며, 직접적으로 hot data (=working set) 를 관리하는 기능은 없기 때문.

node의 hot data(=working set)을 완벽히 최적화하긴 어렵기 때문에, 현실적인 가이드 라인은 active한 데이터가 mongodb node의 물리 메모리 크기를 넘어서지 않게끔 어플리케이션을 구성하는 것 정도가 아닐까 싶다.

mongodb도 rdb와 유사하게 쿼리 실행 계획을 볼 수 있다.

explain 명령을 통해 볼 수 있는데, 여기선 winningPlan, rejectPlan등의 성공과 실패시 어떻게 동작하냐만 보여줄 뿐 실제 어떤 쿼리가 인덱스를 잘 이용해서 Full Scan을 안할 것인지, hot data로 인해 가상 메모리를 크게 쓸 건지에 대한 설명까진 이어지지 않는다.

실은 나 역시 구축하고 두번의 구성 변경을 해야 했다. 이유는 예측한 데이터보다 훨씬 컸고, 용량이 크면 클수록 인덱스를 타지 않은 aggregate 동작은 시스템 부하가 컸으며, 물리 램 사용량이 매우 중요했고, 메모리에 존재하지 않는 데이터 억세스 코스트가 컸다.

node마다의 부하 분산도 첫 계획대로 되지 않았기 때문이다.

그럼에도 몇가지 팁은 최초 설계가 실제 운용시의 갭을 줄이는 데에 용이하지 않을까 생각한다.

내 시행 착오와 경험담이 도움이 되길 바란다.


참고

Mongodb 장단점, 활용시 고민할 사항들

장점

  1. 비동기 드라이버를 사용할 수 있다.
    • 현재 JDBC의 경우 동기 드라이버만 존재해 블러킹 포인트가 된다.
  2. RDB와 개념이 유사해, 쿼리 변환기가 있을 만큼 개념적으로 어색하지 않다.
    • 사용법도 마찬가지로 이질감이 없다.
  3. RDB에 비해 성능이 100배 이상 빠르다.
    • 별도의 캐시 솔루션이 필요하지 않을 만큼 성능 문제에서 우월하다.
  4. 스키마 관리가 필요 없다.
  5. 이미 성숙기에 접어들어 운용, 개발, 유틸리티에 부족함이 없다.
    • Cassandra, Couchbase, Mongodb의 경우 대규모 트래픽, 데이터 저장, fail-over, fault-tolerance에 다양한 대안이 마련 되어있는 상황이다.
    • redis과 비교해봐도 충분히 안정권에 들어온 상태다.
  6. 샤드 추가가 간편하다.
    • 다른 NOSQL처럼 리밸런싱은 불가능하지만, 그럼에도 적정 수치때 샤드를 추가해준다면 장점은 충분히 누릴 수 있다.

단점

  1. 복잡한 쿼리를 사용할 수 없다.
    • join을 사용할 수 없다.
  2. 메모리 사용량이 큰 편이다.
    • 메모리 부족 시 퍼포먼스가 급락한다.
  3. 데이터 일관성이 보장되지 않는다.

적절한 사용 사례

  1. 로그성 데이터나 빅데이터 처리의 중간 저장소
    • RDB보다는 성능이 우월하고, 파일보다는 다양한 유틸리티성 기능과 검색에 유연하다.
  2. 설정 데이터의 보관소
    • 이는 Redis같은 Key-Value DB가 유용하다고 여겨질 수 있으나, 검색 조건의 다양화가 필요할 경우 MongoDB가 훨씬 유용하다.
  3. null 필드가 많이 존재할 때
    • 데이터에 null 필드가 가변으로 다양하게 존재할 경우, rdb보다 스토리지 사용량, 처리 속도등에서 효율이 좋다.
  4. 압도적인 퍼포먼스가 필요할 떄
    • RDB 대비 100배 이상의 차이를 내는 압도적인 퍼포먼스 차이.
    • memory mapped file 기반 구조에서의 장점
  5. nosql 계열에서 압도적인 index 활용도
    • Single Field Indexes : 기본적인 인덱스 타입
    • Compound Indexes : RDBMS의 복합인덱스 같은 거
    • Multikey Indexes : Array에 미챙되는 값이 하나라도 있으면 인덱스에 추가하는 멀티키 인덱스
    • Geospatial Indexes and Queries : 위치기반 인덱스와 쿼리
    • Text Indexes : String에도 인덱싱이 가능
    • Hashed Index : Btree 인덱스가 아닌 Hash 타입의 인덱스도 사용 가능
  6. 집계 연산, paging, 복잡한 쿼리 (단일 document 한정)가 필요할 때
    • key-value db (redis, aerospike 등)와 달리 집계 연산, paging이 가능함.
      • RDB와 동일한 접근의 데이터 스토어로서 사용 가능함.
        • 실제로 쿼리 변환기가 존재함.
  7. 스키마 관리가 불필요함.
    • json 기반 저장 구조로, 유연한 동적 데이터 저장이 가능.
    • 정규화할 데이터보다는, 단일 스키마 기반의 참조 데이터 저장에 장점이 많음.
    • 로그성 데이터도 매우 적합 함.

부적절한 사용 사례

  1. 데이터 무결성이 가장 중요한 가치 일 때
    • 단일 document 무결성은 유지되지만, 멀티 document 무결성은 유지 되지 않음.
      • 애초에 join이 되지 않는지라, 일말이 오차는 존재할 수 있음.
  2. 데이터 처리량보다 일관성 있는 데이터 구조가 중요할 때
    • 데이터 처리량이 빠른 이유는 ACID를 수행하지 않기 때문이다.
    • 특히 샤딩+레플리카를 조합해서 사용 할 때 무결성이 깨진 상태의 데이터가 조회 될 수 있다. (document 자체가 corrupt 된다는 의미는 아니고, 조회한 시점 이전의 데이터가 조회 될 수 있거나, 이미 삭제된 데이터가 조회될 수 있다는 의미)
  3. 운용 이슈에 대한 우려 사항들이 해소가 덜 됐을 때
    • NoSQL 계열도 여타 DB와 마찬가지로 운용 이슈가 중요하고, 이에 대한 경험과 노하우가 부족하다면, 이 부분이 리스크가 될 수 있다.
    • 임계치가 높을 뿐 RDB와 마찬가지로 데이터가 많아지면 성능이 저하되는 부분은 마찬가지고, 이를 해결하기 위한 방안과, 풀 스캔이나, 메모리 사용량이 커질 수 있는 작업을 production 레벨에선 사용할 수 없게 만들어야 한다.
  4. 데이터 무결성이 무엇보다 중요할 때
    • 결제, 아이템 등을 담기엔 여전히 RDB보단 불안한 면이 존재한다.
  5. 엄격한 데이터 타입 검사나 연관 관계가 중요할 때
    • 데이터 타입이 동적 결정되는 스키마리스 DB이고, 컬럼도 동적 컬럼이다보니 마이그레이션이 어려운 편이고, 제약 검사를 추가할 만큼 스키마가 중요하다면 선택해선 안된다.
      • 코드 레벨에서 제어할 방법도 있지만, 접근 권한을 준 다른 서버 혹은 툴 등에서 쿼리를 날렸을 때 이를 막을 수 없다.
    • 또한 테이블 간 연관 관계가 중요하다면, 이 또한 선택지에서 배제해야 한다.
    • 위에서 언급한 대로 여러 document (테이블)을 동시에 조회할 수 없기에, 동일한 시점에 무결성이 깨지지 않은 데이터를 조회할 수 없다.

참고 자료

JWT 사용기

JWT란 Json Web Token으로써, JSON으로 이루어진 데이터를 token화 한 것을 말한다.

JWT의 특징은 여럿이 있는데, 정보를 token 내에 보유하고 있는 (self contained) 독특한 토큰이다.

만료 시간은 부여할 수 있으나 강제 만료는 불가능하다. JWT를 이용한 강제 만료를 구현하기 위해선 이를 검증하는 저장소를 한번 거쳐야 되는 이슈가 있는데, 매번 키 검사를 해야 하는 구조라면 신경 쓰일 수 밖에 없는 요소라고 할 수 있다. (결합도도 증가 하는 단점도 덤이다.)

즉 탈취되도 문제가 적은 AccessToken으로써 기간 만큼만 유효성을 유지시키고, 서명을 통한 발급 정보 크로스 체크, 해쉬 검사에 쓸 값도 같이 담아서 받아 재확인 함으로써 유효하다는 것을 검증하는 구조로 사용 할 만 하다.

부가정보를 다양하게 담을 수 있으므로, 확실히 검증 된 결과를 바탕으로 여러가지 정보 값을 체크 하는 용도로 쓸 수 있으므로, DB 조회량을 감소 시키는 효과를 볼 수 있다.

또한 토큰 발급과, 정보 값을 검증하는 로직을 서버에만 둔다면 (secret 키를 사용자에게 감췄다면), 결합도를 낮추고 서버의 접근 권한을 검사/허용하는 용도로 쓸 수도 있다.

json_web_tokens

참고 : https://auth0.com/learn/json-web-tokens/

너무 중요해서 다시 한번 강조하자면, 보안적 핵심은 secret은 Server만 안다는 점이다.

아래와 같이 Authorization Server에서 발급한 정보를 바탕으로 특정 Resource Server로 질의할 수 있다는 점인데, 이 과정에서의 결합도도 낮아지며, 보안 취약점도 없다는 점이 매우 중요하다.

jwt_client-credentials-grant

참고 : http://jwt.io

JWT가 아니었다면 아래와 같았을 것이다. JWT이기에 위와 같은 과정만으로 접근 허가를 확인 받을 수 있다.

jwt_before

많이 강조되는 점은 아니지만, 성능상으로도 충분히 합리적이다. 생성 자체는 가볍다지만, 정보를 다시 조회하기 위해서 DB 종속성을 갖게 되는 일반 토큰과 달리 정해진 규칙대로 풀고 확인하면 되기 때문이다.


참고 링크

Java-JWT

Angular-JWT

JWT

Java 적응기 06 - Netflix Zuul

이번에는 Netflix Zuul 도입기에 대해서 이야기 해보고자 한다.

지난 MSA 그리고 API Gateway 글을 읽고 오면 더 이해가 쉽게 될 것이다.


내가 합류했던 팀은 MSA로 구성되어 있었다.

반면 API Gateway가 존재하지 않았다 보니, frontend에 바로 물려 있는 서버에서 proxy 처리를 해주고 있었고, 그 서버에 interceptor를 통한 인증 로직도 함께 포함되어 있었다.

zuul 도입 이전 아키텍쳐

우선 frontend에서 proxy를 처리하는 코드가 범용적이지 않다보니, 이를 우회하기 위해서 javascript단에서 서비스를 직접 호출하는 상황이 일부 존재했다. 직접 호출시의 규격에 제약도 없다보니, 어떤 사용자가 호출한 것인지 분류해내기 매우 까다로울 수 밖에 없었다.

서비스별로 로드밸런싱/endpoint 변경 처리/부하 제어/호출 권한 관리 어느 하나 쉽지 않았다. 각 서비스가 어디에 떠있는지 관리하기 어렵기 때문에 호출 권한 검사, 부하 체크도 어려웠다.

각 서비스별 endpoint를 들고 있었어야 되는데, 내가 아닌 다른 서비스가 재시작 될 때에도 내가 들고 있는 값을 갱신해 주기 위한 여러가지 처리가 필요했다. (이는 동적 설정 파일 혹은 db에 관리하지 않은 이슈도 있긴하지만 endpoint 정보의 중복이란 관점만 생각해도 문제가 있다)

또한 frontend와 동일한 백엔드 서버를 거치자니 사용자 요청과 서비스에서의 호출인지 구분도 어렵고 모든 예외처리, 인증, 인가, 부하 제어, 로드밸런싱 로직이 서비스 갯수만큼 필요하다. 이를 sdk를 제공함으로써 조금은 편해질 수 있어도 기능의 역할의 중복은 피해갈 수 없다.

마찬가지로 Open API 제공 하기에도 어려웠는데, API 사용자별 ratelimt, 권한 검사, proxy (with router)를 구현해주고 관리해주어야 했기에 때문이다. 외부 사용 권한 제어는 API Umbrella를 통해서 대행하고 있었으나, frontend, 서비스간 호출까지 포함해서 일관성 있는 아키텍쳐를 구성하고 관리하고 싶은 니즈가 있었기에, 요구사항에 맞는 기술 선정을 위한 검토와 개발을 진행하게 되었다.


API Gateway가 도입 되는 과정에서 여러가지 도입 검토 후보군이 존재했다.

  • ruby on rails : API Umbrella
  • node.js : express gateway, proxy를 통한 자체 구현
  • go : tyk
  • lua : kong
  • java : netflix zuul

최초에는 API Umbrella가 먼저 도입됐다. 다만 인증 인가 로직을 붙이기 위해서 rails 코드를 추가/관리해주어야 된다는 점과, 패키징 되서 배포되는 모델이다보니 코드 수정 및 연계가 불편한점으로 인해, 타 부서에 배포하는 용도로만 사용됐다.

이후 node.js로 proxy를 이용한 도입과 netflix zuul을 비교하게 되었는데, 이 중 zuul이 자바로 이루어진 점과 spring-cloud에 포함된 스프링 공식이되었고 spring 서버군에 적용될 공용 코드를 다수 이용할 수 있다는 점, 유틸리티 기능이 풍부하다는 점 등에서 선택하게됐다.


최근 여러 컨퍼런스나 블로그에서 언급된 내용이지만 나도 조금 설명을 하자면 zuul은 아래 4가지 핵심 기능으로 분류된다.

netflix zuul에 대해 간략히 살펴보자면 크게 4가지 기능으로 분류 된다.

  • Zuul : Router and Filter
  • Ribbon : Load Balancer
  • Eureka : Registry Service
  • Hystrix : Latency and fault tolerance

이렇게 네가지 기능을 통한 API Gateway 구현을 하게 된다.

이 중 zuul은 router를 통한 endpoint 관리와 filter를 통한 인증/인가 처리, 접근 제어, 부하 제어등을 구현하게 된다.

https://cloud.spring.io/spring-cloud-netflix/multi/multi__router_and_filter_zuul.html

Zuul Filters

Filter의 종류와 각 Filter에서의 적절한 처리

  • Pre
    • Request Header 및 파라미터 검사. 일관성이 어긋난 요청을 하진 않는지. Ratelimit 처리. 요청자 정보를 얻어오기. 혹은 요청자 정보를 각 컴포넌트에 신뢰성 있게 전달. (헤더에 추가하는게 가장 좋다)
  • Post
    • Response Header 및 파라미터, Body 검사. Body 로깅도 처리하면 좋다. 처리 결과에 대한 로깅, Ratelimit을 위한 요청 기록 처리
  • Error
    • 오류 발생시 핸들링. 여기서 Exception을 catch해서 핸들링했다.
  • Custom
    • 무언가 부가 작업을 해주어야 할 때. (요청에 따라 다른 코드를 넣으면 좋다. 인증/인가라거나 기타 등등)
  • Routing
    • 어디로 라우팅할지를 결정 지어 줌. 이 시점에서 ribbon 사용시에는 ribbon에서 전달된 정보를 바탕으로 origin server를 결정짓는다.
    • 만약 서버 목록 중 특정 서버로 보내고 싶은 로직을 구현하고 싶을 경우 이 곳에서 처리하면 된다.

zuul을 통한 service 관리를 간략히 살펴보면 다음과 같다.

zuul routes 예제 (application.yml)

 zuul:
  routes:
    user_service: # serviceId
      path: /users/** # users path 이하의 요청은 모두 아래 url로 route한다.
      url: http://127.0.0.100
    registry_service: # serviceId
	path: /registry/** # registry path 이하의 요청은 모두 아래 url로 route한다.
	url: http://127.0.0.101

해당 설정으로 구동 시킨 서버가 http://127.0.0.1이었다면,

  1. http://127.0.0.1/users/ 이하로 들어온 모든 request
    • http://127.0.0.100 로 전달
    • serviceId는 user_service
  2. http://127.0.0.1/registry/ 이하로 들어온 모든 request
    • http://127.0.0.101
    • serviceId는 registry_service

serviceId는 ribbon, eureka, hystrix등에서 설정 때 쓰이는 핵심 키 값이므로 유념하자.

위 예제는 zuul만 사용했기 때문에 정해진 경로로만 보낸다. 만약 load balancing을 하고 싶다면 아래와 같이 구성하면 된다.

https://spring.io/guides/gs/client-side-load-balancing/

users: # serviceId
  ribbon:
    eureka:
      enabled: false
    listOfServers: localhost:8090,localhost:9092,localhost:9999
    ServerListRefreshInterval: 15000

serviceId로 들어온 요청을 listOfServers중 하나로 보낸다.

이 예시는 eureka를 사용하지 않는 케이스고 로드밸런싱 기능만 수행한다.

eureka 사용시 동작 구조는 다음과 같다.

Zuul Eureka

출처: https://exampledriven.wordpress.com/2016/07/06/spring-cloud-zuul-example/

아래 링크에도 잘 설명되어있으니 참고바란다. https://supawer0728.github.io/2018/03/11/Spring-Cloud-Ribbon%EA%B3%BC-Eureka/


hystrix의 경우 서비스 장애의 전파를 방지한다.

장애가 발생한 노드를 서비스 노드에서 제거하고, 요청에 대한 예외를 격리하여, 지연이 발생한 노드의 성능 저하가 다른 노드로 전파되지 않게끔 처리해준다.

Zuul Hystrix

출처: https://thepracticaldeveloper.com/2017/06/27/hystrix-fallback-with-zuul-and-spring-boot/

fallback 처리를 서비스 구조에 맞게 처리해줌으로써, 장애 대응을 한다. 이에 대한 다양한 팁이 공유 되는데, 서비스 자체가 아니라 특정 node만 제거하게끔 연동한다거나, 미리 캐싱해둔 html을 내려줌으로써 최대한 유사하게 처리한다거나 하는 처리를 대비해두고, fallback 처리를 통해 번 시간을 실제 장애 대응에 맞게 대응하도록 가이딩한다.

자세한 내용은 아래 링크에서 자세히 설명해주셨다. https://supawer0728.github.io/2018/03/11/Spring-Cloud-Hystrix/


다시 돌아와 팀에서 구현한 내용은 zuul 위주로 사용하게 됐다. router를 통한 endpoint 집중 관리, filter를 통한 인증/인가/접근 제어/부하 제어가 가장 중요한 이슈였기 때문이었다.

이 과정에서 router를 서비스 별로 구분하려 했는데, 애초에 서비스를 기능 단위로 쪼개 놓은 상태여서 이부분은 endpoint 취합 및 API Gateway를 사용하게끔 가이딩 하는걸로 충분했다.

다만 API 사용자별로 과도한 API 호출이 이뤄지지 않게끔 제어하는 기능이 필요했는데, 이는 https://github.com/marcosbarbero/spring-cloud-zuul-ratelimit 를 사용자 단위로 처리할 수 있게 커스터마이징 해서 사용했다. 해당 모듈이 serviceId별로 ratelimit만을 제공해 커스터마이징이 불가피했다.

이어서 filter를 이용한 각종 처리를 검토했는데, 사용자를 3부류로 분류했다.

  1. Frontend
    • 말 그대로 Web Frontend 호출.
    • Frontend 사용자의 추가 인증을 처리한다.
  2. Service
    • 기능별로 쪼개진 각 서비스.
    • 다른 서비스를 호출하기 위해 분류한다.
  3. External (Open API Requester))
    • 온전히 Service를 가져다가 사용하기만 하는 외부 사용자.
    • 부하제어/인증/인가가 Service보다 조금 더 복잡해야 해서 따로 분류했다.

세가지 분류의 사용자는 각기 서버에서 미리 발급해둔 API-KEY를 사용하게 된다. API-KEY 자체는 JWT로 발급된 값으로써, 어떠한 사용자이며, 어떤 인증을 거쳐야하며, 어떤 ServiceId를 이용할 수 있는지를 담고 있다.

JWT를 이용한 이유는 JWT는 자체에 정보 값을 부여할 수 있어서 이를 이용하면 DB를 사용하지 않고도 각종 정보를 담을 수 있어서 였다. 단점은 기존에 발급된 토큰을 invalidate 시키기 어려운 점이다. 좀 더 자세한 내용은 다음 글에서 좀 더 자세히 다룰 예정이다.

이를 바탕으로 사용자 타입에 따른 인증 로직을 수행한다.

접근 제어의 경우에도 발급된 API-KEY에 따라 갈리는데, API-KEY별로 Ratelimit을 걸어서 일정 수준이상의 웹 콜을 요청 하지 못하도록 처리했다.

zuul 도입 이후 아키텍쳐

도입 이후 아키텍쳐는 다음과 같다.


웹 호출을 API Gateway로 모두 취합하고 나니 API 통계를 남길 수 있었다. 이전에는 통계를 남기기 위해 모든 API 서버마다 기록해야 했으며, 일부 에러는 취합되지 못하곤 했다. (서버가 꺼져서 응답이 없는 상황이나, interceptor 자체의 오동작 상황 등등)

이를 한 곳에 모을 수 있게 되자, 통계가 좀 더 확실해졌고 이 통계를 바탕으로 API 품질 개선을 이뤄 낼 수 있었다.

남기게 된 API 로그

  • Request
    • RequestPrefix
    • RequestFullURI
    • RequestParameters
    • ipAddress
  • Response
    • ResponseActionLog
    • ResponseStatusCode
    • rtCode
  • 식별 값
    • tenant 단위 정보, 요청자 정보
  • Error 발생시
    • Error cause & message
  • 기본 정보
    • 시간

로그 서버 저장 및 통계는 Mongodb로 했는데, 이에 대한 이야기는 추후에 좀 더 자세히 다뤄보겠다.


결과적으로 netflix zuul이 대다수 작업을 해주었기 때문에, 일부 개선을 위한 동작에 대한 작업만으로 목표를 달성 할 수 있었다.

이와 동시에 Netflix zuul에 대해서 공부하면서, Filter, Interceptor, AOP의 차이도 알 수 있었다.

바퀴를 재발명 많이 해본 입장에서 (어쩔 수 없는 재발명도 있었다고 본다. 오픈소스로 잘 공개되지 않는 인하우스 라이브러리 기반 개발이 주였던 C++ 게임 개발의 전례 때문이기도 했으니 말이다.) 자바의 가져다 쓰는 환경. 그리고 잘 가져다 쓰는 것이 중요한 환경이란 것을 깨닳았기에 그 적절한 사례를 또 하나 찾고 적용한 것 같아 기뻤다.

물론 잘 만들어 공개해준 netflix에 매우 큰 감사를 표한다.