문서화도 다 끝났으니 로직을 코드에 옮기기만 하면 된다. 그러나 아직 어떤 언어를 쓸지/의존성을 어떻게 관리할지/어떤 데이터베이스를 어디서 운영할지와 같은 것들이 정해지지 않아서 바로 개발에 착수하기는 어렵다. 이번에는 어플리케이션 기술스택(프로그래밍 언어와 프레임워크)을 결정하고, /GET 요청 시 'Hello World'text/plain으로 반환하는 서버를 작성해 보자.

도입 이유

웹 어플리케이션 서버를 작성할 수 있는 언어는 수없이 많고, 프레임워크도 그렇다. 그만큼 조직의 현재 상황과 미래, 서비스의 형태, 요구사항, 일정 등에 맞춰서 매우 신중하게 결정해야 한다. '요즘 다 파이썬 쓰니까 파이썬', 'Node.js는 한글 문서가 잘 돼있으니까', '장고가 쓰는 사람도 많고 쉬우니까'같은 트렌드 차원의 이유로 어플리케이션 기술스택을 가볍게 결정하고 나면, 조직에게 매우 큰 기술 부채가 되어 돌아올 수도 있다. 기술 선택은 팀과 비즈니스를 고려해야 한다. 오늘은 두 의사결정 모두 하나의 요구사항 집합으로 정리된다.

배경과 요구사항

  • 조직에게 익숙하거나, 러닝커브가 감당할 수 있는 수준인가? - 생산성이 보장되며 유지보수에 큰 문제가 없는가?
  • 조직이 납득할 수 있는가?
  • 너무 마이너한 기술스택은 아닌가? - 라이브러리나 예제는 결국 그 언어/프레임워크의 사용자가 만들기 마련인데, 인기가 없으면 커뮤니티의 사이즈가 작고, 직접 만들어야 하는 도구의 범위가 넓어질 수 있다.(memcached 클라이언트, StatsD 클라이언트, PostgreSQL ORM 라이브러리 등등) 그리고 커뮤니티 파워가 약하면 먼 미래를 보장하기 어려울 수 있다.
  • 안정적인 응답 속도에 rps(requests per second)를 잘 뽑아주거나, TTL/LRU Cache, 경량 스레드, 비동기 프로그래밍 지원처럼 성능 최적화를 위한 도구가 잘 준비되어 있는가? - 비용 최적화를 위해 매우 중요하다.

쭉 써놓고 나니, 대충 인기 많은 스택이라면 이 조건에 대부분 부합하지 않을까 싶다. 데이터 티어나 모니터링 티어의 인프라를 결정할 땐 조금 더 자유도 높게 선택지를 둘 수 있을텐데, 이번 의사결정은 어쩔 수 없이 인기를 좀 따르게 될 것 같으니 양해 바란다.

의사결정 - 프로그래밍 언어

선택지

WAS 개발에 있어서 주류 언어로 꼽히고 + 필자(조직)가 조금이나마 경험해본 언어들을 선택지로 두자.

  • Python
  • Scala
  • Java
  • Go
  • Node.js

의사결정

Python을 선택하겠다. 그 이유는,

  • 조직(필자)에게 가장 익숙하다.
  • 커뮤니티 파워가 강하다.
  • 언어가 강력하다. 대부분 언어들이 어디 하나씩은 맘에 안드는 부분이 있는데, Python은 이런저런 면에서 평타 이상 쳐준다.
  • unpacking, comprehension, 비교 연산자 chaining과 같은 syntactic sugar나 arrow같은 drop-in replacement 라이브러리들이 생산성을 잘 커버해 준다.
  • 라이브러리 풀이 꽤 크고, 의존성 관리와 가상 환경 지원도 꽤 쓸만하다.
  • 공식 코드 컨벤션 가이드라인PEP8 덕분에 코드 퀄리티 유지도 괜찮게 할 수 있다.
  • Scala액터 모델 기반의 짱 좋은 동시성을 가지고 있고, 동시성 모델 자체가 Immutability에 기반되어 있어서 동기화에 관한 삽질이 비교적 적다. 그러나 조직 내에 Scala를 잘 다루는 개발자가 없어서 생산성을 보장할 수 없다.
  • Java는 비즈니스 관점에서 인력풀이 풍부하고 레퍼런스가 많지만, 필자의 기준으론 JSON 리터럴이 없어서 꽤 불편하다. Go의 [string]interface 타입 map이나, Scala의 List/Map 타입, Kotlin의 hashMapOf 함수처럼 JSON 리터럴을 우회할만한 방법도 딱히 없다. Map 객체를 만들어 put문을 발라두거나 별도로 VO(Value Object)를 정의하고 JSON string을 VO 객체로 역직렬화/VO로 만든 객체를 JSON string으로 직렬화하는 식으로 사용한다. VO를 사용하는 방식은 꽤 직관적이지만, WAS의 로직과 데이터 모델이 복잡한 게 아니기 때문에 JSON 리터럴 표현식이 언어 차원에서 지원되는 게 좋을 듯 싶었다. 게다가 나중에 VO를 쓰는 방식으로 스타일이 바뀌더라도, 파이썬이라고 못하는 것도 아니고 말이다.
  • Go는 웬만하면 성능 정말 잘 뽑아주고, 사용자 층도 세계적으로 꽤 많아지는 추세지만, 이게 생각보다 생산성이 좋지 않고 의존성 관리가 마음아프다. 그래도 트래픽 처리 비용을 낮추려고 시도하게 된다면, 고성능 파이썬 웹 프레임워크들과 Go를 벤치마킹해 볼 것 같다. 내가 Go를 많이 안 해봐서 삽질하는 게 아닐까 하는 믿음이 있다(아직은).
  • Node.js는 JavaScript 런타임인데, 자바스크립트라는 언어가 생각보다 잘 쓰기 정말 어렵고, 굳이 러닝커브를 감수하고 Node.js를 선택하는 것에 당장의 메리트가 별로 없다.

결론은 조직에게 익숙한 Python을 선택하게 됐다. 물론 조직에게 아무리 익숙한 기술이더라도, 그 기술이 의사결정의 기준에 부합하지 못한다면 보류시키는 게 맞다.

만약 백엔드 개발자가 여럿 있고 각각에게 익숙한 기술스택끼리의 교집합이 없거나 적은 상황이라면, 서비스와 개발 조직의 요구사항(예산, 처리량, 개발 일정 등)을 기반으로 서로를 설득하는 시간을 갖자.

의사결정 - 웹 프레임워크

선택지

써볼만한 프레임워크는 이정도가 있다.

의사결정

Sanic을 사용하겠다. 그 이유는,

  • Flask마이크로 프레임워크이기에, 자유도가 매우 높다. 원하는 설계 방향대로 프레임워크를 유연하게 써먹을 수 있다. 그러나 멀티스레드 모델의 원천적인 한계(컨텍스트 스위칭) + Python의 GIL 구조로 인해 여러 스레드가 시분할해서 동작하기 때문인지, 성능이 조금 아쉽기도 하고 요청 객체가 의존성 주입 형태가 아니라 global 객체로 제공되어서 자칫하면 안티패턴 코드를 작성하게 된다. Flask를 가장 많이 써봤지만, 얘를 열심히 튜닝하기 전에 대체제를 찾아보고 싶었다.
  • Sanicuvloop: Blazing fast Python networking이라는 글에서 영감을 받아, Flask-like한 APIasync 개념을 넣고 uvloop로 asyncio 이벤트 루프를 튜닝한 비동기 웹 프레임워크다. 벤치마크 상황에 따라 다르지만, 동일 조건에서 웬만하면 Sanic이 Flask보다 2~4배정도 처리량이 많다. 그리고 요청/응답 객체가 의존성 주입 형태로 제공되고, 미들웨어 개념이 잘 잡혀 있다.
  • Vibora도 Flask-like API에 뭔가 이것저것 붙여서 퍼포먼스를 높이는, Sanic과 비슷한 컨셉을 가지고 있다. 그리고 실제로 정말 빠르다(Flask를 기준으로, Sanic이 2~4배, Vibora가 5~6배 정도). 그러나 레퍼런스가 너무 없다. Sanic은 공식 문서라도 잘 작성되어 있는데, Vibora의 문서는 '이게 다인가?' 싶다.
  • Japronto는 내가 아는 파이썬 웹 프레임워크 중 rps가 가장 높다(웹 프레임워크 전체에 대한 벤치마크에서도 거의 선두권). 그런데 뭐 프레임워크가 딱히 제공해주는 게 별로 없다. 라우팅 메소드 하나랑 요청 객체, 응답 헬퍼 몇 개 정도. 2017년 2월에 미들웨어 관련 이슈가 올라왔지만 '계획이 있다'고만 하고 아직도 open되어 있다. 그리고 Vibora보다 크게 빠르지도 않고, 사용 사례가 많지 않다.
  • Falcon은 사용 사례도 꽤 있고 + 성능도 Japronto, Vibora와 함께 파이썬 웹 프레임워크 중에서 선두권 + 문서도 잘 만들어져 있지만 프레임워크 디자인 자체가 꽤 새로운 느낌이라 러닝커브를 감당하기 힘들 수 있을 듯 싶었다.
  • aiohttp는 생각보다 퍼포먼스가 안나온다. Sanic이나 aiohttp나 async/await 기반인데, 굳이 aiohttp를 선택할 이유가 없다.
  • Tornado는 이름만 보면 엄청 빨라 보이는데, 파이썬의 주류 웹 프레임워크들 중에 퍼포먼스가 가장 안나온다. 퍼포먼스를 포기하고서라도 쓸만한 이유도 없다. 생각보다 커뮤니티 파워도 약하다.
  • DjangoDjangoRestFramework가 있긴 하지만, Django에 익숙한 조직이 아니므로 제외했다. DjangoRestFramework를 쓰는 후배한테 물어봤는데, 이것저것 챙겨주는 게 많아서 익숙해지기 시작하면 생산성이 아주 괜찮다고 하니 구미가 당긴다면 배워봐도 좋을 것 같다.

작업 - Hello World 서버 작성

이슈를 생성하고, /GET 요청 시 'Hello World'text/plain으로 반환하는 서버를 작성해 보자. 서버 작성 직후의 스냅샷

지금까지

  • 버전 관리 시스템으로 Git을 사용하기 시작했다.
  • Git 웹호스팅 서비스로 GitHub를 사용하기 시작했다.
  • GitHub Issues와 Projects로 이슈 트래킹을 시작했다.
  • 개발 프로세스와 브랜칭 모델을 정립했다.
  • HTTP API 아키텍처 기반으로 API 스펙을 디자인하기로 했다.
  • JSON을 직렬화 포맷으로 결정했다.
  • Authorization 헤더로 인증 정보를 명시하기로 했다.
  • 인증 스키마에 JWT 기반의 Bearer를 사용하기로 했다.
  • API 스펙을 정의했다.
  • GitBook으로 API를 문서화했다.
  • Python + Sanic 조합으로 WAS를 개발하기로 했다.


이번엔 의존성 관리 도구를 결정하자. pip, npm, yarn, gem, maven, gradle 등과 같은 의존성 관리/빌드 도구를 써본 적 없다면 이해하기 어려울 수 있으니 의존성 관리 도구(Dependency Manager)라는 글을 읽어보자. 프로젝트 한두번 하면서 의존성 관리를 해 본 경험이 있다면 더욱 좋다.

도입 이유

소프트웨어 어플리케이션 개발에는 대부분 라이브러리가 필요하다. 언어 차원에서 지원되는(built-in) 것일 수도 있고, 우리가 사용하기로 한 Sanic처럼 외부 라이브러리 저장소에 의존하는 것일 수도 있다. 프로젝트가 어떤 외부 라이브러리를 사용하고 있는지를 별도로 관리하는 것의존성 관리라고 한다. 프로젝트가 사용하고 있는 외부 라이브러리들을 남들이 알도록 하는 것이 기본적인 목표인데, 이런 의존성 관리가 중요한 이유는 다음과 같다.

  • 먼저, 언어 차원에서 지원되는 라이브러리는 이미 다들 설치되어 있으니 따로 관리할 필요가 없다.
  • 백엔드 팀에서 함께하는 타 개발자의 입장에서 생각해 보자. 의존성이 제대로 설치되어 있지 않으면 어플리케이션을 실행해볼 수 없다. 혼자 개발하는 입장이라면 그냥 필요한 라이브러리들을 컴퓨터에 미리 설치해 두기만 하면 되겠지만, 협업이 힘들어지는 것이 문제다.
  • 오픈 소스로 관리되는 라이브러리의 소스 코드를 그냥 그대로 붙여넣거나, 따로 무슨무슨 라이브러리 쓴다고 문서화해둬도 되기야 하겠지만, 의존성의 의존성과 같은 재귀 의존 현상라이브러리 각각의 버전 업데이트 대응을 별도의 도구 없이 해결하기엔 너무 낭비다. 그 예로 Sanic은 httptools, websockets, multidict 등의 외부 의존성을 가지고 있다. 그리고 라이브러리 한 20개 쓰고 있는 상황에서, 버전 맞춰서 하나하나 설치하는 것도 귀찮은 일이다.
  • 배포 과정에서 필요하다. 예를 들어 테스트와 같은 배포 전처리를 위해 어플리케이션의 의존성들을 모두 다운로드해야 하고, 빌드/패키징 등과 같이 컴파일이 들어가는 과정에서 항상 필요하다.

이름은 거창하지만 의존성 관리 도구들이 자체적으로 많은 부분을 도와주기에, 우리에게 보여지는 겉모습 자체는 별 거 없다. 대부분 어떤 라이브러리의 무슨 버전을 쓸 지 목록화시킨 파일로서 의존성을 관리한다. 아래는 차례대로 Pythonrequirements.txt, JavaScriptyarn, RubyGemfile 예제다.

의사결정

선택지

  • pip와 requirements.txt
  • pipenv

의사결정

pipenv를 선택하겠다. 그 이유는,

  • Python은 일반적으로 가상 환경(virtual environment)을 통해 독립된 개발 환경을 구성하게 되는데, pipenv는 가상 환경 구성의존성 관리한번에 해결해주기 때문이다. JavaScript를 해봤다면, npm을 떠올리면 된다.

준비

pipenv란 무엇인가Pipenv으로 Python 프로젝트 관리하기라는 글로 pipenv에 입문하자.

작업

pipenv를 통해 프로젝트 디렉토리에 pipenv를 초기화하고, 가상 환경에 들어가 Sanic을 설치한 후 lock하자.

lock 직후의 스냅샷

이번엔 Compute Engine을 결정하자. 우리가 구현한 서버를 실행할 위치를 결정하는 것이다. 아래 3가지에 대해 의사결정이 필요하다.

  • 어떤 컴퓨팅 파워를 이용할 것인지(출처)
  • 만약 외부 서비스를 이용하기로 했다면, 어떤 서비스를 사용할 것인지
  • 해당 서비스에서 제공하는 컴퓨팅 엔진들 중 어떤 것을 사용할 것인지 등

오늘 하게 될 이야기는 AWS와 배경지식이 부족하면 조금 어려울 수도 있다. 읽다가 어렵다면 이고잉님의 AWS 강의로 흐름을 선회해도 좋을 것 같다. 컨테이너, dockerize, nginx, IAM 등등 이해되지 않는 부분이 있을 수 있을텐데, 큼지막한 흐름이 대충 이해가 된다면 머리아프게 다 이해하려 하지 말고 그냥 다음 내용으로 넘어가도 괜찮다. 이 컨텐츠는 텀을 길게 두고 두세번 정도 읽는 게 좋다고 생각한다. 일단 쭉 읽어보기 바란다.

도입 이유

필자는 초등학교 고학년(2012년) 때 마인크래프트 서버를 운영했었다. 내가 쓰던 데스크탑의 IPv4 주소에 oa.to 도메인을 묶어 두고, craftbukkit으로 서버를 직접 돌리는 방식이었다. 서버를 돌리기 위해 24시간동안 계속 컴퓨터를 켜두는 건 컴퓨터의 수명에 문제가 있지 않을까 걱정도 되고, 부모님 눈치도 보였기에 서버를 켜고 끄는 시간을 정해 공지해 두었었다. 3년만에 당시 운영하던 카페에 들어가보니, 켜지는 시간은 오전 8시 10분이었고 꺼지는 시간은 오후 11시였다. 등교하기 전에 켜두고, 잠들기 전에 껐나보다.

아무튼 결국 서비스 사용자의 트래픽을 처리하기 위해선 24시간 쉬지 않고 돌아가는 컴퓨팅 파워가 필요하다. 그게 PC던, 서버용 하드웨어를 구입해 구축한 홈서버던, 임대료를 지불해 호스팅받은 서버던, 클라우드 서비스던 말이다. 뭘 쓰던 '당장 되게 만드는 것'에는 문제가 없을 테지만, 여러 질문을 던져보며 가장 좋은 방향으로 의사결정 해보자. 백엔드 조직에 휴먼 리소스가 그리 많지 않은 상황이니, 되도록이면 서버의 컴퓨팅 자원같은 잡다한 부분을 직접 운영하는 일을 최대한 적게 만드려고 한다. 개발자는 어플리케이션 코드를 제공하기만 하면 되도록 말이다.

의사결정 - 컴퓨팅 파워의 출처

배경과 요구사항

  • 우리는 돈이 없다. 비용이 최소한으로 발생하는 방법을 선택해야 한다.
  • 그렇다고 불안정한 컴퓨팅 파워를 쓰진 말아야 한다.
  • 인프라 엔지니어링에 익숙하지 않기에, 인프라 관리에 있어서 휴먼 리소스가 되도록 적게 소비되어야 한다.

선택지

  • PC
  • 서버용 하드웨어를 구입해 홈서버 운영
  • 서버호스팅
  • 클라우드 플랫폼

의사결정

클라우드 플랫폼을 선택하겠다. 그 이유는,

  • PC나 별도의 하드웨어를 구입해 돌리는 것은 초기 투자 비용이 너무 비싸고, 인프라 엔지니어링을 잘할 수 있는 조직이 아니라면 운영도 어렵다. 클라우드 플랫폼은 쓰기 좋게 준비된 컴퓨팅 자원원하는 리소스 수준에 맞게, 원하는 만큼만 사용할 수 있다.
  • 클라우드 형태로 제공되는 인프라는 일반적으로 'pay as you go' 컨셉을 가지고 있다. 사용하는 만큼만 비용을 지불하는 것이다. 만약 하드웨어 스케일이 너무 크다거나 머신이 필요없어 졌다면, 그냥 버튼 클릭 몇 번으로 더 작은 하드웨어 사이즈를 가진 인스턴스 타입으로 변경하거나, 없애버릴 수 있다. 물리적인 서버가 남아서 '이걸 또 어디다 쓰지'하는 고민을 하지 않아도 된다.
  • 인프라 관리에 대해 수많은 부분이 이미 준비되어 있다. 오버워치같이 꽤 큰 조직들마저 클라우드 플랫폼을 사용하는 이유가 이건데, 서버 시스템 엔지니어 수 명이 비싼 서버실을 대신 운영해주는 수준이기 때문이다. 수 분만에 리눅스 서버를 띄우고, 서버를 다중화하고, 트래픽을 분산시키고, 로그 메트릭을 적재적소에 전송하고, 도메인을 달고, 스토리지를 자동으로 백업하고, 인바운드/아웃바운드 접근 제어를 수정하고, 트래픽 상태에 따라 서버를 늘리고 줄이는 등의 설정이 클릭 몇 번으로 가능하다. 물론 아무것도 모르는 사람이 마음껏 설정할 수 있을 정돈 아니지만, 뭐 하나 세팅하려고 라우터에 접속해서 커맨드 치고있는 것보다 훨씬 쉽고 빠르며 정확하다.
  • SLA(Service-Level Aggrement, 서비스 수준 협약서)를 통해 인프라의 가동 시간을 보장받는다. 부득이하게 SLA에 명시된 만큼의 서비스 수준을 보장받지 못한다면, 페이백이나 크레딧 등을 통해 돈으로 보상받을 수 있다. 백엔드 조직이 서버를 직접 운영한다고 치면, 클라우드 플랫폼 만큼의 높은 가동 시간을 보장할 수 없을 것이라고 판단했다.
  • 인프라의 신속한 배치, 쉬운 자동화, 유연한 용량(확장성), 실패에 대한 대비(신뢰성), 규모의 경제가 주는 비용적 혜택, 전 세계에 어플리케이션을 쉽게 배포하고, 높은 수준의 보안과 품질 표준이 준수되어 있는 등 클라우드를 사용함으로써 얻을 수 있는 것이 수없이 많다.

클라우드 플랫폼이 얼마나 편하며 비용 걱정이 줄어드는지는 한 번 써보면 알 것이다. 몇몇은 '클라우드에 너무 의존하지 말라'고들 하지만, 필자는 라우터 세팅같이 짜치는 일에 돈과 인생을 낭비하고 싶지 않다. 클라우드 컴퓨팅에 대해 조금 더 알고 싶다면 AWS의 '클라우드 컴퓨팅이란?' 문서를 읽어보자.

의사결정 - 클라우드 플랫폼 결정

배경과 요구사항

  • 우리는 돈이 없다. 무료로 제공되는 범위가 클수록 좋다.
  • 조직은 현재 AWS(Amazon Web Service)에 익숙하다.

선택지

  • AWS(Amazon Web Service)
  • GCP(Google Cloud Platform)
  • Microsoft Azure

의사결정

AWS(Amazon Web Service)를 선택하겠다. 그 이유는,

  • 조직이 AWS에 가장 익숙하다.
  • AWS는 이미 클라우드 플랫폼 시장의 50% 이상을 점유하고 있으며, 수많은 대형 클라이언트들의 신뢰를 받고 있다.
  • AWS에 새로 회원가입하고 나면, 12개월간 주요 서비스를 일정 한도 내에서 무료로 이용할 수 있는 free tier를 제공해주는 건 덤이다. Google Cloud Platform과 Azure도 비슷하지만, AWS가 가장 후하게 무료 플랜을 제공해준다.

의사결정 - 컴퓨팅 엔진 결정

배경과 요구사항

  • 프로젝트 시작 단계이므로 트래픽이 적고, 얼마나 늘어날지 예측 불가능하다.
  • 인프라를 직접 관리하는 범위가 적을수록 좋다.(개발자들은 코드를 제공하기만 되게)
  • Python으로 개발된 어플리케이션을 배포하는 데에 문제가 없어야 한다.

선택지

  • EC2(Elastic Compute Cloud)
  • Beanstalk
  • ECS(Elastic Container Service)
  • ECS+Fargate
  • Lightsail
  • Lambda

의사결정

Lambda를 선택하고, 추후에 ECS+Fargate로 마이그레이션을 진행하겠다. 그 이유는,

  • Lambda는 월마다 100만 개의 요청까지를 무료로 제공해준다. 정확히는 100만 초만큼의 함수 실행 시간을 무료로 제공해주는 것인데, 서비스가 인기몰이를 하기 전까진 이걸로 버텨볼만 하다. API 사용량이 많아지면 ECS+Fargate로 옮기는 것을 고려해보려 한다.
  • Lambda엔 서버가 존재하긴 하지만, 서버 관리의 주체는 우리가 아니고 Amazon이다(serverless). 이는 Amazon에서 관리하는 뛰어난 가용성을 가진 인프라에서 동작하기 때문에, 코드에 문제가 있는 것이 아니라면 API가 죽을 일이 거의 없다는 것이다. 알아서 여러 가용 영역(데이터 센터)에 걸쳐서 컴퓨팅 파워를 유지시키고, 우리가 따로 설정하지 않더라도 트래픽에 따라 서버를 축소/확장시키는 auto scaling도 기본으로 제공받는다. 우리는 컴퓨팅 파워를 직접 운영할 필요 없이, 코드를 제공하기만 하면 된다. 트래픽이 적고, 얼마나 늘어날지 예측 불가능한 현재 배경에 가장 알맞다.
  • 서비스가 커지면 과금 문제 때문에 lambda를 계속 유지하기가 어려울 것이다. lambda는 코드(함수)의 실행 시간에 비례하여 가격을 책정하는데, 일정 수준이 넘어가면 해당 트래픽의 처리기로 컴퓨팅 인프라를 직/간접적으로 따로 운영하는 것이 웬만하면 훨씬 싸다. 그 때를 생각해 추후 마이그레이션 대상으로 ECS+Fargate를 선택했다. 그 이유는 아래에서 설명한다.
  • EC2는 모던한 컴퓨팅 파워 서비스인데, 이게 EC2만 달랑 쓰면 배포 파이프라인을 수립하기가 번거롭다. 예를 들어 2대 이상의 인스턴스에 동일한 웹 어플리케이션을 배포하려면, 하나 잡아서 배포해 두고 이미지를 그대로 떠서 복사시켜줘야 하는데, 구성도 귀찮고 오류에 대한 리스크도 크며 느리다. 차라리 EC2를 쓸거라면 Beanstalk이나 ECS같이 EC2를 기반으로 돌아가는 추상화 계층을 두어 이들의 도움을 받는 것이 좋을 것이라는 판단이다.
  • Beanstalk은 웹 어플리케이션을 간편하게 배포하고 조정할 수 있는 서비스다. heroku같은 PaaS를 생각하면 되는데, 세팅 조금만 하면 GitHub같은 데에 hook 걸어서 배포 자동으로 알아서 해주고 트래픽 변동이 생기면 서버 사이즈 scaling도 자동으로 해준다. 서버의 관리를 클라우드 제공자에게 위임하는 서버리스 형태를 유지하면서, lambda의 실행 시간에 비례한 과금 정책을 회피할 수 있게 된다. 위에서도 말했듯 EC2를 기반으로 돌아가는 추상적인 layer라고 볼 수 있는데, 추후 웹 어플리케이션의 런타임을 옮기는 입장에서 생각해 보면, 어차피 EC2 위에 뭔가 올려서 쓴다고 쳤을 때 그냥 조금 더 공부해서 ECS 쓰는 게 더 낫다는 판단이다.
  • ECS는 docker 컨테이너 orchestration 서비스다. 컨테이너식 어플리케이션은, 경량 가상화된 OS 위에서 어플리케이션을 실행하게 되므로 환경에 구애받지 않고 어플리케이션을 배포, 확장하며 빌드 과정을 파일로 표준화시킬 수 있는 여지가 생긴다. 배포 자동화던 scaling 자동화던 Beanstalk이나 ECS나 고만고만 하니까, 컨테이너식 어플리케이션의 메리트를 가져오자는 생각이다. 컨테이너식 어플리케이션은 관리의 입장에서 orchestration이 필요한데, kubernetes나 Docker swarm을 직접 운영하지 않더라도 ECS가 대신 해준다. 컨테이너와 docker가 어렵다면 초보를 위한 도커 안내서 시리즈나 AWS의 Docker란 무엇입니까?, 도커를 이용한 웹서비스 무중단 배포하기의 초반부 정도를 읽어 보자. 왜 백엔드 엔지니어들이 다 도커도커 거리는지의 이유를 대충 알게 될 것이다.
  • Fargate는 옵션에서 제거할 수 있는 부분이라 따로 선택지를 두었다. ECS는 EC2 인스턴스 위에 올릴 수도 있는데, 결국 이것도 컴퓨팅 자원을 따로 운영해야 하기에 관리 포인트가 될 수밖에 없다. Fargate는 컨테이너에 최적화된 별도의 컴퓨팅 엔진을 제공하고, 이를 직접 관리해 주므로 서버리스 컨테이너 엔진이라고 이야기할 수 있다. ECS와 Fargate를 묶어서 쓰게 되면, 서버리스 형태를 유지하며 컨테이너 기반 어플리케이션의 장점을 함께 가져가기에 좋을 것이라고 생각했다. 원래 Fargate가 EC2보다 한 2배 정도 비쌌어서 고민하고 있었는데, 최근에 요금 인하가 되어서 걱정이 좀 사라졌다.
  • Lightsail은 어플리케이션을 손쉽게 배포하고 관리하는 데에 필요한 필수적인 인프라(컴퓨팅, 스토리지, 데이터베이스, DNS 등)들이 자체적으로 포함되어 있는 플랫폼이다. 간단한 어플리케이션을 빠르게 셋업하는 데에 초점이 맞추어져 있는데, Lambda를 운영하는 것과 비교했을 때 그리 큰 메리트가 있어 보이지 않는다.

여기에 더해 terraform같은 Infrastructure as Code(IaC) 도구나, EKS(Elastic Kubernetes Service)를 통해 kubernetes 인프라를 직접 운영하는 간지나는 모습도 욕심은 나지만 조직 상황을 감안해서 보류했다. kubernetes 등으로 컨테이너 클러스터의 orchestration을 직접 수행하는 것은 비즈니스적 요구사항에 매우 유연하게 대응할 수 있겠지만, 조직이 크지 않은 상황에서 클러스터 자체 운영은 자칫하면 IaC와 kubernetes에 익숙하지 않은 현재 조직에게 휴먼 리소스의 낭비가 될 수 있으므로 ECS의 도움을 받으려 한다. 

작업 - Hello World 서버 배포하기

Amazon Lambda는 '호출 기반으로 코드를 실행해주는 서비스'라고 요약할 수 있다. 호출을 수행하는 주체, 즉 event source는 대표적으로 CloudWatch, API Gateway가 있는데, 그 예를 들면,

  1. CloudWatch의 호출 : 특정 EC2 인스턴스에서 CPU 사용량이 80%가 넘어가면, 설정해 둔 lambda 함수를 호출해 조직 내 슬랙의 특정 채널에 경고 메시지를 보낸다. 이러한 설정을 'CloudWatch 이벤트 트리거'라고 부른다.
  2. API Gateway의 호출 : 외부의 요청이 들어오면, 설정해 둔 lambda 함수를 호출해 결과를 얻고 응답을 반환한다. 마치 웹 어플리케이션 서버를 호출하는 웹 서버(nginx 등)처럼 말이다.

우리는 API Gateway를 사용하는 후자의 방식을 사용할테고, AWS Lambda를 이용해서 HTTP API 만들기라는 outsider님의 포스트를 천천히 읽어보면 대충 무슨 말인지 알 것이다. 그리고 이 글을 읽어 보았다면, 우리는 Lambda에 코드를 배포하기 위해 아래의 과정을 거쳐야 한다는 것도 깨달았을 것이다.

  1. 소스 코드를 zip으로 패키징S3라는 object storage 서비스에 업로드한다.
  2. 소스 코드를 Lambda에 올려 새로운 버전의 코드가 lambda를 구성하도록 만든다.
  3. 만약 이게 최초 배포라면, API Gateway를 세팅해 준다. 특정 엔드포인트에 반응하여 lambda 함수를 호출하도록 말이다. CloudFormation같이 Amazon 자체에서 제공하는 Infrastructure as Code 서비스를 써야할 수도 있을 것이다.

물론 이걸 손으로 직접 클릭해가며 하진 않을테고 CLI에서 커맨드를 입력하는 방식을 사용하게 될텐데, 그렇게 생각하더라도 여간 귀찮은 일이 아닐 수 없다. update같은 커맨드를 입력하면 알아서 패키징하고, S3에 올리고, lambda를 세팅하고, API Gateway를 세팅해주는 도구가 있다면 매우 편할 것이다. Python 판에서는 zappa라는 라이브러리가 이런 문제를 해결하는 데에 압도적인 인지도를 가지고 있고, 사실상 다른 선택지가 없기도 하다.

aws configure

먼저 zappa는 AWS 리소스에 대한 접근 권한이 필요하므로, 권한을 부여해 주어야 한다. AWS 콘솔에 로그인하고(계정이 없으면 만들자.), IAM이라는 서비스의 콘솔에 들어가 zappa를 위한 IAM 사용자를 만들자. 본인 계정의 AWS 콘솔에 접근할 수 있는 권한을 가진 또 다른 하위 사용자를 만드는 것이다. AWS 계정의 IAM 사용자 생성 가이드를 보며 따라하면 된다. 주의해야 할 것은,

  1. 액세스 권한 유형은 프로그래밍 방식 액세스(또는 Programmatic access)를 선택하자.
  2. 권한 설정은 '기존 정책 직접 연결'을 선택해 AdministratorAccess를 부여하도록 하자. 필요한 권한만 연결하고, 그룹을 만드는 것이 여러모로 좋지만, 이건 따로 챕터를 진행할 것이다.

사용자를 생성하고 나면 Access Key IDSecret Access Key를 제공받을 수 있을텐데, CLI 터미널(cmd, terminal 등)을 켜고 아래의 순서에 따라 설정을 진행하자. AWS CLI 구성 가이드와 동일한 내용이다.

  1. pip install awscli 커맨드를 실행해 AWS CLI 툴을 설치하자. 일부러 pipenv 대신 pip를 사용했다.
  2. aws configure 커맨드를 실행한 후 Access Key ID와 Secret Access Key를 붙여넣자. Default region name에는 원하는 지역을 AWS region 규칙에 따라 입력하자. 나는 서울 region을 사용할 것이므로, ap-northeast-2를 입력했다.

zappa init

pipenv install zappa 커맨드로 의존성 목록에 zappa를 추가함과 함께 패키지를 본인의 가상 환경에 설치하고 프로젝트 루트 디렉토리에서 zappa init 커맨드를 입력해 zappa 설정을 초기화하자. environment, bucket name같은 것들은 적당히 잘 입력하거나 default로 설정되도록 그냥 엔터만 누르면 되고, 'Where is your app's function?'에 대한 답만 잘 입력하자. app 객체가 있는 경로를 입력하면 되는데, 현재 Hello World 서버 기준으론 run.py 모듈에 객체 이름이 app이므로 run.app이라 입력할 것이다. 필자의 경우 pipenv install 시 Python 3.7.1 버전으로 초기화되었는데, zappa가 Python 2.7과 3.6만을 지원해서 pipenv install --python 3.6 커맨드로 pipenv를 다시 초기화하고 진행했다. 성공하면 zappa_settings.json이라는 파일이 새로 생길 것이다. pipenv install zappa, zappa init 직후의 스냅샷

이제 배포 준비가 완료되었지만, 문제는 Sanic 어플리케이션을 zappa로 업로드하는 과정에 오류가 많다는 것이다. zappa에서는 마이크로 프레임워크로 Flask를 공식 지원하고 있고, Sanic은 Flask-like하게 만들어져 있으므로 Hello World 서버를 Flask 기반으로 변경하자. Pipfile에도 반영하고, 코드에도 반영해야 한다. 변경 직후의 스냅샷

이제 정말 배포만 남았다. zappa deploy를 입력하고 조금 기다리면 https://abc.execute-api.us-east-1.amazonaws.com/...같은 형태의 URL이 콘솔에 보여질 것이다.

그대로 복사해서 브라우저로 접속해 봤을 때, 'Hello World'가 뜨면 성공이다. 사람들이 그렇게나 말하던 서버리스 어플리케이션을 배포한 것이다.


이번엔 서비스 운영을 위한 메인 데이터베이스를 결정하고, AWS 클라우드 위에서 해당 데이터베이스 엔진을 사용하는 인스턴스를 하나 띄워보자. 가상 컴퓨팅 환경 하나 단위를 AWS에서는 인스턴스라고 부른다.

도입 이유

데이터베이스는 엑셀을 떠올리면 된다. 데이터베이스의 가장 중요한 특성은 구조화된 데이터를 관리한다는 점이다. 'ID', '비밀번호'처럼 컬럼에 따라 데이터를 정리하고, 어떤 기준을 통해 데이터를 정렬하고, 필터링할 수 있다. 굳이 엑셀 안 쓰고 데이터베이스를 쓰는 이유는, 데이터베이스라는 것 자체가 데이터를 추가/변경/삭제/조회하는 작업이 더 구조화되어 있고 빠르기 때문이다. 잘 모르겠다면 이고잉님의 강의인 데이터베이스란?을 보고 오자.

이 세상엔 수많은 형태와 종류의 데이터베이스가 있다. 형태로 치자면 RDB(Relational Database)NoSQL(Not only SQL)로 나눌 수 있고, 종류로 치자면 빅데이터 쿼리를 분산 처리하기 위해 개발된 PrestoDB, key-value 형태의 구조로 캐싱 용도로 자주 쓰이는 redis나 memcached, 시계열성 데이터를 저장하는 데에 특화되어 있는 InfluxDB같은 특별한 제품들이나 MySQL, PostgreSQL 등 전형적인 RDBMS가 있을 수 있다.

위에서 이야기했듯 이번 의사결정은 서비스 운영을 위한 메인 데이터베이스를 결정하는 것이라, PrestoDB, druid, InfluxDB같이 특별한 목적을 위해 만들어진 데이터베이스를 사용하진 않을 것이다.

의사결정 - RDB vs NoSQL

배경과 요구사항

  • 모든 스키마가 고정되어 있다.
  • 엄청나게 빠른 속도를 요하진 않는다.
  • 되도록이면 AWS가 관리형으로 제공하는 인프라에서 사용할 수 있어야 한다. EC2에서 데이터베이스 서버를 직접 관리해야 하는 일이 없도록 만들고 싶다.

선택지

  • RDB
  • NoSQL

의사결정

RDB를 선택하겠다. 그 이유는,

  • 'NoSQL은 schemaless여서 더욱 유연한 형태로 데이터를 저장할 수 있다'고 하지만, 우리가 개발하려는 것을 포함해 대부분의 서비스는 스키마가 유동적인 경우가 거의 없다.
  • NoSQL은 'scale out(접속된 서버의 대수를 늘려 처리 능력을 향상시키는 것)이 가능해서 scale up만 가능한 RDB에 비해 단일 장애 지점도 없고 비용도 적고 클러스터에서 잘 동작하도록 만들어져 있다'라며 퍼포먼스와 장애 대응에 관한 메리트를 어필하곤 하는데, RDB에서도 master-slave 모델을 사용하면 이런 문제들을 충분히 커버할 수 있다. 이는 읽기 전용 인스턴스(slave)를 만들어 데이터를 복제시키는 것인데, 이정도만 해도 웬만한 수준의 서비스 트래픽을 다 감당할 수 있다고 판단했다. master가 죽은 경우 slave가 master로 승격하는 구조를 만들면 장애 복구도 잘 되고 말이다.
  • 평균적으로 NoSQL이 RDB보다 빠른 것이 맞고, 동일한 비용을 사용했을 때 퍼포먼스로 치면 웬만하면 NoSQL이 가성비가 좋다. 그러나 이건 우리가 어떤 RDB 제품을 사용하느냐에 따라 다르다.
  • NoSQL 데이터베이스 서버를 AWS에서 운영하려면, 대부분의 경우 EC2 위에서 직접 돌려줘야 한다. MongoDB의 경우 최근에 출시된 DocumentDB를 사용해볼 수 있는데, 아시아 쪽에선 아직 서비스가 오픈되지 않았다. 결국은 관리 포인트가 늘어날 것이며 이를 감당하기 어려운 상태다.
  • 나도 NoSQL을 좋아하지만, '많이 써봤으니 쓰기 편해서'라는 이유밖에 얘기하지 못 하겠다.

의사결정 - 데이터베이스 호스팅 위치

배경과 요구사항

  • 우리는 RDB를 사용하기로 했다.
  • EC2에서 직접 데이터베이스 서버를 관리하는 것은 설정, 패치, 백업과 같은 시간 소모적인 관리 작업이 많아지며 안정성을 보장하기 어렵다. 따라서 AWS에 인프라 관리를 양도하는 관리형 서비스가 있다면 이를 사용하고자 한다.

선택지

데이터베이스 서버 관리를 직접 하느냐, AWS에게 관리를 양도하느냐의 차이다.

  • EC2
  • RDS(Relational Database Service)

의사결정

RDS를 선택하겠다. 그 이유는,

  • 동일한 워크로드(하드웨어 요구량)를 기준으로 했을 때 EC2의 비용이 더 적을 수는 있겠으나, 데이터베이스 서버를 직접 운영하는 것은 정말 큰 관리 포인트다. 백업과 복구 정도만 생각해도 아득하다. 이런 건 그냥 돈 좀 더 내는 게 낫다고 생각한다(물론 여기선 어차피 free tier를 쓸테니 이런게 딱히 상관은 없겠지만, 의사결정의 이유니까 알아두고 넘어가자).
  • 관리형 서비스가 없는 데이터베이스를 선택한 경우 어쩔수 없이 EC2에 올려야겠지만, RDS는 대부분의 메이저한 RDB 엔진을 지원하며 그 외의 RDB 중에서는 굳이 EC2에서 직접 프로비저닝할 정도의 메리트가 있는 것도 딱히 없다.

의사결정 - 데이터베이스 엔진

배경과 요구사항

  • 우리는 Amazon RDS를 사용하기로 했으므로, RDS에서 사용 가능한 데이터베이스 엔진을 선택지로 두어야 한다.
  • 조직이 MySQL에 익숙한 상태다.
  • Python에서 주로 쓰는 ORM 라이브러리인 SQLAlchemy나 Peewee에서 지원하는 데이터베이스여야 한다.
  • 서비스 초기이므로, 한동안이라도 비용이 발생하지 않는 방향이면 더 좋다.

선택지

  • Aurora
  • MySQL
  • MariaDB
  • PostgreSQL
  • Oracle
  • SQL Server

의사결정

MySQL을 선택하고, 추후 Aurora로 마이그레이션하겠다. 그 이유는,

  • Aurora는 AWS가 개발한 RDBMS인데, 클라우드에서 잘 구동되도록 설계되어 비교적 빠르고 비용도 싸다. 하지만 다시 역으로 비용 문제가 있는데, Aurora를 RDS에 띄우는 경우 free tier가 지원되지 않는다.
  • 조직이 MySQL에 익숙하며, 메인 데이터베이스로 MySQL을 사용하는 것에 딱히 걸리는 점이 없다.
  • 어차피 AWS 가입 후 1년이 지나면 free tier가 지원되지 않으므로 비용 효율이 좋은 엔진으로 마이그레이션을 고려하게 될텐데, Aurora는 MySQL과 호환되며 MySQL보다 3~4배 정도 빠르므로 그 대상이 된다. 사실 Amazon 인프라에선 Aurora만한 RDB가 없는데 free tier 때문에 잠시 동안만 MySQL에 머물러 있는 것이라고 생각하면 된다.

작업 - 인스턴스 시작

Amazon RDS 자습서를 보고 따라하자.

이번 주제는 배포 자동화다. 원래 CI(Continuous Integration)CD(Continuous Deployment)같은 것들을 이야기해보려 했으나, 배포 자동화라는 용어가 덜 추상적이고 더 명시적이라 용어 선택을 선회했다.

도입 이유

배포라는 과정은 불필요한 반복 작업이다. 우리가 만들고 있는 웹 어플리케이션의 경우, 기능 추가나 버그 패치 등 어떤 단일 작업 하나를 완료한 후 → 테스트를 돌리고 → 문제가 없다면 배포한다. 우리의 상황에 맞추자면,

  1. 게시글 작성 API를 작업하고
  2. 이에 대응되는 테스트를 작성한 후
  3. 테스트를 돌리고
  4. 테스트가 모두 성공하면 zappa update 커맨드를 실행해 새로운 버전의 코드를 배포한다.

1, 2번은 분명히 개발자의 작업이 필요한 게 확실한데, 3, 4번에 해당하는 배포 과정은 자동화시킬 여지가 있다. 그 이유는,

  • '작업의 완료''master 브랜치에 대한 push'로 판단할 수 있다. 어떤 방법으로든 push 이벤트를 다른 곳에 알려주는 기능을 GitHub이 지원하기만 한다면, 코딩 후의 작업을 시스템적으로 처리할 수 있게 된다.
  • 테스트를 돌리고, 문제가 없는지를 판단하고, 배포하는 것은 결국 명령어의 집합일 뿐이다. 명령어를 대신 실행해주는 뭔가가 있다면, 필요한 명령어를 정리해 두기만 하면 된다.

그럼 명령어를 대신 실행해주는 뭔가가 있다고 치고 push가 일어나면 알아서 명령어를 실행하도록 설정해 두었다고 하면, 작업-배포 흐름이 아래처럼 바뀔 것이다.

  1. 게시글 작성 API를 작업하고
  2. 이에 대응되는 테스트를 작성한 후
  3. master 브랜치에 push하면 테스트랑 배포를 누군가가 대신 해준다.

이에 대한 흐름을 시스템적으로 조금 더 이야기하자면,

  • master 브랜치에 대한 push같이, GitHub 내에서 특정 이벤트가 일어났을 때 특정 URL로 HTTP 요청을 하게 만드는 webhook이라는 기능이 있다. 예를 들면, push가 일어났을 때 GitHub가 자동으로 http://mingyu.io/hook에 POST 요청을 하도록 설정할 수 있다.
  • 배포 자동화 서비스에게 이런 hook URL을 받아서 GitHub 저장소에 push에 대한 webhook을 추가하면, push가 발생할 때마다 대상 hook URL로 HTTP 요청을 보낼 것이다.(서비스에 따라 webhook을 자동으로 추가해주는 경우도 있다.)
  • webhook에 의해 HTTP 요청을 받은 배포 자동화 서비스는 'push가 일어났구나' 하며 저장소를 clone받는다.
  • 배포 자동화 서비스는 우리가 준비해 둔 빌드 스크립트(.travis.yml, circle.yml, buildspec.yml 등)에 따라 명령어들을 실행한다. 빌드 스크립트는 대부분 yml 파일로 작성하고, 동일한 동작을 명시하더라도 그 내용은 서비스마다 조금씩 다르다. 아래는 Travis-CI를 기준으로 작성한 빌드 스크립트인 .travis.yml의 예다.

따라서 이번엔 배포 자동화를 위한 서비스를 선택해 세팅하고, zappa update배포 자동화 시스템에 의해 실행되도록 만들어 보자.

의사결정 - 배포 자동화 서비스

배경과 요구사항

  • private repository에 대해서 사용하는 데에 비용적인 문제가 크게 없어야 한다.
  • Python 런타임이 제공되어야 한다.
  • GitHub과 잘 연동되어야 한다.
  • AWS와 잘 접합되면 좋다.
  • 개발 조직이 Travis-CI를 써본 경험이 많이 있다.

선택지

  • Travis-CI
  • CircleCI
  • Amazon CodeBuild와 CodeDeploy를 함께 사용
  • Amazon CodeBuild만 사용

의사결정

Amazon CodeBuild만 사용하는 것을 선택하겠다. 그 이유는,

  • 개발 조직이 Travis-CI를 많이 써 봤지만, CircleCI나 Travis-CI나 private repository에 붙이려면 유료 플랜을 결제해야 하며 가격이 만만치 않다.
  • 관리 포인트가 여기저기에 분산되어 있으면 관리하기 불편하다. 되도록 AWS 내에서 모두 해결할 수 있도록 하기 위해서다.
  • CodeBuild는 private repository도 무료로 붙일 수 있게 되어 있다. 단지 빌드를 실행한 시간만큼만 지불하면 되는데, 월 100분까지는 무료로 제공되며 이를 넘더라도 과금이 그렇게 많지 않다. 메모리 3GB, 2개의 vCPU를 지원하는 build.general1.small 인스턴스 기준으로 하면 빌드 분당 0.005달러만 지불하면 된다.
  • CodeDeploy를 함께 사용하지 않는 이유는 바로 아래에서 더 설명한다.

CodeDeploy를 사용하지 않는 이유

zappa update라는 건 사실 우리의 웹 어플리케이션을 정말로 lambda에 새로 업데이트하는 배포의 과정이라, '코드 배포 자동화'라는 주제를 가진 CodeDeploy라는 서비스가 더 어울릴 것이라 생각할 수도 있다. 필자도 그렇게 생각했어서 검토를 위해 조금 찾아봤더니, CodeDeploy는 정말 '배포 자체'에 대해 많은 지원을 하고 있었다. 무슨 뜻이냐면,

  • 우리가 그냥 zappa update 커맨드 하나로 패키징부터 업데이트까지 다 끝내니까 배포라는 게 정말 쉬워 보일 수 있겠지만, 배포 과정을 손수 정의하게 된다면 단일 작업이 많이 쪼개져 있어서 그리 쉬운 일이 아니다. 패키징도 해야 하고, 배포가 끝나면 어플리케이션 reload(서버 껐다 켜기)도 해줘야 하고, 서버가 다중화되어 있다면 그들도 함께 배포를 진행해야 한다. CodeDeploy는 일차적으로 '배포를 정의'하는 일을 많이 도와준다.
  • 어플리케이션을 배포하는 동안 가동 중지 시간을 최소화시키는 것도 배포에 있어서 매우 중요한 일이다. 그래서 구 버전과 새로운 버전의 코드를 함께 올려둔 채 트래픽을 나눠 전송하고, 새 버전의 어플리케이션으로 보내는 트래픽의 비율을 점진적으로 증가시키는 Canary 배포나, 구 버전과 새로운 버전의 코드를 함께 올려둔 채 트래픽을 이동시키고 구 버전의 코드를 제거하는 Blue/Green 배포같은 걸 쓰곤 한다. 에러가 생기면 롤백도 하고. 이러한 배포 프로세스들도 손수 정의하기 어려운 일이라 CodeDeploy가 도와준다.

엄청 좋아 보이지만 우리가 CodeDeploy를 사용하지 않는 이유는, CodeDeploy를 써서 얻을 수 있는 메리트가 그리 많지 않을 것이라고 생각했기 때문이다. 위에서도 말했듯 그냥 zappa update 커맨드가 패키징부터 배포까지 알아서 다 해주니 배포 정의에 대해 도움을 받을 이유가 딱히 없다. 게다가 가동 중지 시간 최소화는 lambda 자체에서 알아서 잘 챙겨준다. 현재로선 그냥 명령어를 대신 실행해 줄 주체가 필요할 뿐이다.

작업 - CodeBuild 세팅

새로운 소스 코드로 배포가 이루어지기 전엔 빌드라는 과정을 거친다. 그런데 사실 파이썬은 컴파일 언어도 아니고, 뭐 따로 패키징하고 그런 작업도 없기 때문에 빌드라는 단어는 별로 어울리지 않는 것 같다고 생각했는데, 그냥 '배포 전처리 과정'을 빌드라고 관례적으로 이야기하는 것 같다.

빌드 프로젝트 생성

AWS 콘솔에서 CodeBuild에 들어가, 우측 상단의 '빌드 프로젝트 생성' 버튼을 눌러 내용을 채우자. 총 5개의 메뉴 - 프로젝트 구성 / 소스 / 환경 / Buildspec / 아티팩트에 대해 뭔가 설정하게 되어 있는데, 아래 것들만 채워주면 된다.

  • 프로젝트 구성 - 프로젝트 이름
  • 소스 - '소스 공급자'를 'GitHub'으로
  • 소스 - '리포지토리'를 '내 GitHub 계정의 리포지토리'로 선택하고, GitHub 계정을 연결
  • 소스 - 'GitHub 리포지토리'에서 만들어 두었던 저장소를 선택
  • 환경 - '운영 체제'를 'Ubuntu'로
  • 환경 - '런타임'을 'Python'으로
  • 환경 - '실행 시간 버전'을 'aws/codebuild/python:3.6.5'로

모두 완료했다면, '빌드 프로젝트 생성' 버튼을 눌러 생성을 완료하자.

buildspec.yml 추가

프로젝트 화면에서 '빌드 시작'을 누르고 다른거 건들 거 없이 아래로 내려가 '빌드 시작'을 한 번 더 눌러 보자. 빌드 화면에서 '단계 세부정보'를 보면, DOWNLOAD_SOURCE에서 실패가 일어난 것을 확인할 수 있다.

CodeBuild가 우리 대신 명령어를 실행해 줄텐데, 이게 정리된 파일인 buildspec.yml 파일이 없어서 생긴 문제다. 이 파일을 추가한 뒤, 다시 빌드를 시작하면 에러가 생기지 않을 것이다. 'Build Started'를 콘솔에 찍는 커맨드 하나만 들어 있는 buildspec.yml을 추가했다.

buildspec.yml 추가 직후 스냅샷

빌드를 다시 시작하면, 문제 없이 잘 실행되는 것을 볼 수 있다.

webhook 추가

빌드가 필요할 때마다 손으로 '빌드 시작' 버튼을 클릭하는 것보단, push가 일어났을 때 CodeBuild가 자동으로 가져가서 buildspec.yml에 따라 빌드를 실행하게 만드는 게 더 이상적이다. 그러려면 webhook을 추가해야 하는데, 필자는 CLI를 정말 싫어해서 AWS Console에서 webhook을 설정하는 곳을 찾아보려 했으나 아무리 찾아도 보이지 않았다. 따라서 어쩔 수 없이 CLI를 써야 할텐데, 이건 AWS CodeBuild의 사용 설명서 중 AWS CodeBuild에서 빌드 실행 - 빌드 실행 자동 시작(AWS CLI) 부분을 따라하면 된다. 여기서도 간단히 설명하겠다.

Hello World 서버를 배포했던 9챕터에서, 액세스 권한 유형이 Programmatic access이며 AdministratorAccess 권한을 가진 IAM 사용자를 만들어서 이미 configure를 완료했을 것이므로 바로 본론으로 들어가자. webhook을 추가하려면 aws codebuild create-webhook 명령을 사용하면 된다. 내 CodeBuild 프로젝트의 이름은 Sampleapp_for_blog이므로, aws codebuild create-webhook --project-name Sampleapp_for_blog를 입력했다. url, payloadUrl 같은 데이터가 반환되면 성공이다.

GitHub 저장소에 들어가, Settings - Webhooks 메뉴에 들어가면 webhook이 자동으로 추가된 것을 볼 수 있다.

작업 - zappa update 커맨드 추가하기

이제 push가 일어나면 CodeBuild가 코드를 가져가buildspec.yml에 따라 빌드를 실행하게 만들었다. 이제 그 buildspec.yml에 아래 3개의 커맨드를 추가하면 된다.

  • pipenv install
  • pipenv shell
  • zappa update

이들은 yml 파일의 build → commands 하위에 밀어넣는 것보다, 각각을 적절한 phase에 나누어 정의해두는 게 좋다.

phase

위에서 빌드를 실행한 후 '단계 세부정보'에서 보았듯, CodeBuild는 빌드를 여러 단계로 나누어서 실행한다. submitted - queued - provisioning - ... - completed 순서로 이루어지는데, 이는 빌드 커맨드를 '설치 단계', '빌드 직전 단계', '빌드 단계', '빌드 후 단계' 등으로 체계화시키기 위함도 있고, 빌드 단계별로 접근 가능한 데이터들을 몇가지 붙여주기 위함도 있다. post_build에서 '빌드 성공 여부'(정확히는 build phase의 exit code)를 판단하기 위해 접근할 수 있는 환경변수 CODEBUILD_BUILD_SUCCEEDING을 예로 들 수 있다.

적절한 phase에 나누어 buildspec.yml에 명령어 추가하기

pipenv installinstall phase에, pipenv shellpre_build phase에, zappa updatebuild phase에 추가했다.

또한 배포가 잘 이루어지는지 알아보기 위해 GET /에서 내려주던 'Hello World'라는 문자열을 'This is version 2'로 변경했다. 코드 스냅샷

push 후 삽질

hook을 걸어 두었으니, push만 하고 기다렸을 때 CodeBuild가 hook에 의해 코드를 알아서 가져가서 잘 빌드하는지 지켜보자.

첫번째 실패

pipenv shell 명령어를 실행하다가 'Inappropriate ioctl for device'라는 에러 메시지와 함께 빌드에 실패했다. 구글링 해보니 Trouble running pipenv in CI라는 제목의 이슈를 찾을 수 있었고, 애초에 그냥 pipenv shell을 실행하지 말고, pipenv가 필요한 커맨드의 앞에 pipenv run을 붙이면 될 것 같았다. buildspec.yml을 아래와 같이 수정했고, 다시 push했다.

pipenv shell을 제거한 직후의 스냅샷

두번째 실패

pipenv 관련된 이슈가 해결되어 zappa update 명령어도 실행 시작까지 잘 됐지만, CodeBuild 머신에선 aws configure에 의해 'default' profile이 설정된 적 없어서 또 빌드에 실패했다.

zappa가 AWS 관련 작업을 할 때 boto3라는 라이브러리를 사용하는데, 이 친구는 자격 증명을 위해 환경 변수도 확인한다. 더 많은 정보는 boto3 User Guide - Credentials를 확인하자. 아무튼 우린 환경 변수에 AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY를 설정해 주면 되고, 이는 CodeBuild에서 빌드 프로젝트를 선택한 후 '편집' 드롭다운을 열고 '환경'에 들어가 '추가 구성'을 열면 설정할 수 있다.

'환경 업데이트' 버튼을 누르고, zappa가 config를 무시하고 바로 환경 변수를 참조할 수 있도록, 아래와 같이 zappa_settings.json에서 profile_name 부분을 지운 후 push하여 빌드를 유발하자.

zappa_settings.json에서 profile_name을 제거한 직후의 스냅샷

세번째 실패

그러나 이번에도 빌드는 실패한다.

virtual environment가 활성화되어 있지 않다고 한다. 근데 사실 zappa가 virtual environment의 활성화 상태를 확인하는 건 환경 변수라서(코드), pre_build에서 VIRTUAL_ENV 환경변수를 설정해주면 될 것이다. 그 값은 virtual environment의 경로여야 하는데, 이는 pipenv --venv로 알아낼 수 있다.

pre_build에 export 커맨드를 추가한 직후의 스냅샷

다섯번째 실패

빌드는 또 실패한다.

export 명령어가 안 먹히는 것 같아서 구글링을 좀 해봤더니, Environment variables not being set on AWS Codebuild라는 스택오버플로우 질문이 있었고, buildspec 버전을 0.2로 변경하면 잘 될 거라는 답변이 있어서 buildspec.yml을 한 번 더 변경해 봤다.

buildspec 버전을 0.2로 변경한 직후의 스냅샷

성공!

드디어 성공했다. URL에 접속해 보면, 'This is version 2'라는 문자열이 반환되는 걸 볼 수 있다.

수많은 실패 끝에 드디어 배포 자동화 세팅을 성공시켰다. 사실 독자 입장에서는 여기까지 오는 데 별로 긴 시간이 걸리지 않았겠지만, 필자는 CodeBuild를 처음 써보는 입장이었어서 빌드 실패만 16번 만들고 4시간동안 계속 구글링하며 삽질했다. 그러나 배포 자동화는 세팅 삽질로 하루를 보내더라도 아깝지 않을 정도로 우리의 생산성에 많은 도움을 줄테니, 좋은 경험이라고 생각하고 넘어가고자 한다.

이제부턴 push가 일어날 때마다 빌드가 실행될 것이고, 빌드 한 번에 1분 30초 정도가 소요된다. 우리가 build.general1.small 인스턴스 타입을 쓰고 있으니, free tier로 제공되는 100분을 넘긴 후부턴 AWS CodeBuild 요금에 따라 push 한 번당 0.0075$(약 8~9원) 정도의 요금이 발생할 것이다.

여태까지 많은 의사결정과 작업을 섞어가며 우리가 개발에만 집중할 수 있는 환경을 열심히 만들어 봤다. 아직 꽤 부족한 상황이지만, 우리의 프로토타입 어플리케이션을 개발하는 데에는 이 정도면 충분하다. 이번엔 코드를 작성하는 데에 있어서 이런저런 판단의 기반이 될 의사결정을 진행하고자 한다. 깊게 고민할 건 딱히 아니고 체크리스트 정도라 각각의 의사결정은 (아마도)그리 길지 않을테지만, 갯수 자체가 많아서 여러 편으로 나눈다.

결정된 어플리케이션 스택(Python + Flask + Zappa)에 의해 선택지는 한정되더라도, 의사결정의 주제는 한정되지 않으므로 독자 여러분이 나중에 프로젝트를 하게될 때 이걸로 체크리스트를 만들어서 정리해 봐도 좋을 것 같다.

의사결정 - 어플리케이션 구조

배경과 요구사항

  • Flask는 마이크로 웹 프레임워크기에, Spring이나 Django와 대조되게 정해져 있는 구조가 없다. 모듈 하나에 이것저것 다 밀어넣어도 동작은 하겠지만, 코드에도 퀄리티라는 게 있으며 생산성과 유지보수를 미리 염두에 두면 좋으니 우리가 써먹어 볼만한 좋은 구조를 한 번 찾아보도록 하자.
  • 조직 내에 Flask 어플리케이션 구조를 직접 만들어 본 개발자가 있고, 서비스 레벨에서 사용해본 경험이 있다. Flask-Large-Application-Example
  • API 추가, 설정 데이터 증가, 스키마 추가 등과 같은 확장이 많이 이루어지더라도, 어플리케이션 구조로 인해 생산성이 제약되는 일이 없어야 한다.
  • 구조가 불필요하게 복잡하지 않아야 한다.

선택지

의사결정

Flask-Large-Application-Example으로 구조를 잡겠다. 그 이유는,

  • HermeticaFlask 어플리케이션의 뼈대를 만들어주는 CLI 툴이다. 근데 이게 굳이 이정도까지 필요한가 싶기도 하고, 이전에 한 번 써봤을 땐 Hermetica가 제공하는 뼈대에 100% 종속되지 않으면 차라리 더 불편했던 경험이 있다.
  • Flasky는 O'Reilly의 'Flask Web Development 2nd edition'가 챕터의 진행에 따라 Flask 어플리케이션을 발전시키는 히스토리를 커밋 단위로 기록한 저장소다. Flasky는 필자가 Flask 어플리케이션의 구조에 대해 고민할 때 많은 깨달음을 주었는데, relative import가 너무 많고, 우리에게 불필요한 것들(migration, Dockerfile, Procfile, boot.sh 등)이 조금 있어서 그대로 가져다 쓰려면 좀 많이 다듬어야할 것 같았다.
  • Flask-Foundation처럼 모듈마다 blueprint가 있는 방식확장에 오버헤드가 크다. extensions.py로 확장 패키지의 객체들을 따로 관리하는 것이나 config를 다루는 컨셉은 배울만한 부분이다.
  • cookiecutter-flask-skeleton은 나쁘지 않은 구조같은데, 이것도 우리한테 필요없는 게 너무 많다. 우린 JWT로 사용자를 인증하기로 했으나, 여기는 session을 통해 사용자 인증을 구현flask-login을 사용하고 있어서 이 부분을 걷어내야 하고, form 부분은 JSON 위주로 데이터를 관리하는 우리에게 아예 필요가 없다.
  • flask-boilerplate구조가 과하게 복잡하고, Flask에서 써먹으면 좋을 패턴들은 정작 별로 안 들어가있다. 나중에 참고할만한 코드 블럭이 중간중간에 조금 있긴 하지만 가져다 쓰고 싶지는 않다.
  • Flask-Large-Application-Example은 필자가 Best Practice들을 여기저기서 많이 찾아보고, 2년 가까이 Flask로 크고 작은 WAS를 몇번씩 만들면서 지속적으로 개선해온 구조다. 사실 익숙하다는 게 큰 이유긴 한데, 서비스 레벨에서 직접 써보기도 많이 써봤고 GitHub에 돌아다니는 이런저런 구조들은 솔직히 실속이 별로 없고 겉치레인 경향이 꽤 있다. 남들이 좋다는 거 하나 가져와서 커스텀하는 것보다 낫다.

의사결정 - request data validation 룰

배경과 요구사항

  • 비정상적인(예상 범위를 벗어나는) 데이터가 포함된 요청이 들어와 500 Internal Server Error를 일으키는 일을 줄이려면 검증이 필요하다. 예상 범위를 명시해서 요청 데이터를 미리 검증하고, 문제가 있다면 API 로직이 실행되지 않게 400 Bad Request406 Not Acceptable같은 status code를 내려주기 위함이다.
  • Content Type에 대한 validation이 가능해야 한다. 예를 들어, application/json만 허용하도록 정의해둘 수 있어야 한다.
  • key check가 가능해야 한다. 예를 들어, 게시글 작성 API에서 'title''content'가 요청의 JSON payload에 포함되어야 함을 명시할 수 있어야 한다.
  • nested keynon-required 제약 명시가 가능해야 한다. 예를 들어, 회원가입 API에서 'contact' 내에 'phone', 'email'이 요청의 JSON payload에 포함되어야 함을 명시하거나, 게시글 목록 API에서 'size'가 요청의 query string에 선택적으로 포함됨을 명시할 수 있어야 한다.
  • 값에 대해 타입 검증, 문자열이라면 길이의 범위, 숫자라면 값의 범위, enum 등에 대한 명시가 가능해야 한다.
  • view decorator 형식으로 사용하는 것을 기능으로 제공하고 있거나, view decorator화 시키는 데에 큰 문제가 없어야 한다.

선택지

의사결정

schematics를 선택하겠다. 그 이유는,

  • 선택지에서 schematics 외의 것들처럼 딕셔너리 형태로 정의하는 스키마자동완성의 도움을 받기 어렵다. schematics를 보면 데이터 각각의 제약조건을 StringType, URLType처럼 클래스를 통해 정의하는데, required같은 제약들을 생성자에 인자로 전달하는 형태이므로 IDE에게 자동완성 지원을 잘 받을 수 있다. 어떤 목적을 이루기 위해 코드를 뒤져보는 게 문서를 보는 것보다 낫다는 생각이다. 예를 들어, 'enum을 표현하려면 어떻게 해야 하지?'에 대해 StringType 클래스로 들어가 생성자 메소드의 인자를 보고 'choice'임을 인지하는 게, 공식 문서를 찾아보는 것보다 빠르고 정확하다.
  • 딕셔너리 형태로 정의하는 스키마는 다들 그들만의 세계가 있어서, 익숙해지기 전까진 생산성이 잘 안 나온다. 필자 기준으로 선택지들 중 2순위라고 생각이 드는 voluptuousschematics와 비교하면 되는데, '"size"는 int 타입이며 optional하고, 1부터 100 사이의 값을 가지며 기본값은 30이다'는 voluptuous의 경우 Optional('size', default=30): All(int, Range(min=1, max=100)), schematics의 경우 size = IntType(min=1, max=100, default=30)로 표현할 수 있다. schematics가 voluptuous에 비해 의식의 흐름 자체가 비교적 더 깔끔하다.
  • jsonschema와 cerberus는 문자열과 딕셔너리로만 이루어진 스키마를 정의한다는 점에서 비슷하게 생겼는데, 개인적으로 JSONSchema는 API 문서 작성할 때만 썼으면 좋겠다. 데이터 validation 용도로 쓰이기엔 포맷 자체가 과한 느낌이다.

의사결정 - SQL 쿼리 처리 방식

배경과 요구사항

  • 문자열 결합으로 쿼리를 만드는 것은 지양하려고 한다. 띄어쓰기나 개행, 따옴표, 포맷 문자열의 순서 등을 코드 레벨에서 계산하느라, 좋은 코드를 작성하기가 어렵고 생산성도 보장하기 어렵다.
  • 객체지향적으로 쿼리를 만드는 방식을 선호한다. 객체지향이란 단어만 나오면 이 악물고 달려드는 사람들이 있어서 패턴이고 뭐고 그런 얘기는 안 할거고, 아래와 같은 코드의 흐름을 생각하면 된다.

쿼리 만들어주는 겸 쿼리 실행이랑 트랜잭션 관리도 해주면 더 좋다.

선택지

  • 문자열 결합으로 쿼리를 빌드하고 별도의 클라이언트로 직접 쿼리 실행
  • PyPika로 쿼리를 빌드하고 별도의 클라이언트로 직접 쿼리 실행
  • SQLAlchemy로 쿼리 빌드부터 실행까지 도움을 받기
  • Peewee로 쿼리 빌드부터 실행까지 도움을 받기

의사결정

SQLAlchemy를 선택하겠다. 그 이유는,

  • 문자열을 결합하는 방식으로 쿼리를 빌드하다 보면 정말 한숨밖에 안 나온다.
  • PyPika쿼리 빌더 라이브러리다. 객체지향 디자인 패턴 중 하나인 빌더 패턴을 통해 쿼리 객체를 만들도록 하고, 이를 쿼리 문자열로 변환해준다. 필자도 처음 보고 되게 쓸만하다 싶었는데, 라이브러리 특성 상 쿼리 실행 단계부터는 따로 관여하지 않는다는 게 마음에 좀 걸린다. 별도의 MySQL 클라이언트 라이브러리를 통해 쿼리를 직접 실행해줘야 하는데, 쿼리 결과에서 각 row들이 dictionary 타입이어서 너무 날것 그대로 쓰는 느낌이다. 그렇다고 따로 객체로 wrapping해서 쓸 바에 SQLAlchemy나 Peewee같은 ORM을 쓰는 게 더 낫다고 생각했다.
  • SQLAlchemyPeeweeORM + 쿼리 빌더 + 쿼리 실행 헬퍼가 합쳐진 라이브러리다. 테이블의 스키마를 클래스 형태로 정의하고, 그 클래스를 통해 객체지향적으로 쿼리를 빌드하고, 이를 실행하면 해당 클래스의 객체가 결과로 반환된다. 이 둘 중에 하나를 선택하는 건 취향 차이인 것 같은데, 필자의 관점에서 Peewee와 SQLAlchemy를 비교하면, Peewee는 비교적 코드가 깔끔해 보인다는 것 정도가 메리트고 SQLAlchemy만큼 고급 기능을 잘 지원하진 않는다고 생각한다. 이는 나중에 데이터베이스 서버에 읽기 전용 복제본을 추가하는 챕터에서 더 설명하도록 하겠다. 그리고 독자 여러분의 이해를 돕기 위해 ORM 라이브러리의 컨셉을 반영한 코드를 첨부한다.

의사결정 - MySQL Driver(클라이언트 라이브러리)

배경과 요구사항

  • 어차피 실제로 쿼리를 실행하는 건 SQLAlchemy 단에서 처리해 주므로, API가 어떻게 생겼던 SQLAlchemy가 지원하기만 한다면 상관 없다.
  • Python 3를 지원해야 한다.
  • 개발이 몇 년째 진행되지 않다거나 하는 문제만 없다면, CPython 3.6을 기준으로 퍼포먼스가 가장 잘 나오는 드라이버를 선택하자. 이 쪽은 우리가 의사결정에 신경쓸만한 부분이 '쓸 수 있는가(SQLAlchemy와 Python 3를 지원하는지 등등..)'와 속도 정도밖에 없다.

선택지

의사결정

mysqlclient-python를 선택하겠다. 그 이유는,

  • MySQLDB1'legacy version'이라고 명시되어 있다. 버전 1.3.0부터 Python 3를 지원하기로 되어 있었으나, 2014년 1월 3일에 1.2.5 버전이 릴리즈된 후 더이상 개발이 진행되고 있지 않다.
  • 중간에 몇가지 버그 수정과 Python 3을 지원하자는 명목으로 MySQLDB1을 fork하여 MySQLDB2mysqlclient 프로젝트가 시작되었는데, MySQLDB2는 2012년 8월 25일 이후로 더 이상의 개발이 진행되고 있지 않다. 그에 비해 mysqlclientPython 커뮤니티가 개발을 잘 이끌어나가고 있다.
  • 두 벤치마크(methane/bench.py, Benoss/PythonMysqlDriversTest)를 보면 각각 mysqlclient와 MySQLdb1이 가장 준수한 성능을 보여준다. 애초에 mysqlclient가 MySQLdb1의 fork라는 걸 생각하면, 성능 상의 이점을 챙기기 좋을 것이라 판단했다.
  • mysql-connector-pythonOracle의 MySQL group에 의해 개발된 드라이버인데, 퍼포먼스가 그리 좋지도 않고, 라이선스 문제 때문에 PyPI를 통해서 설치할 수도 없다.
  • pymysql은 사실 mysqlclient와 maintainer(프로젝트 관리자)가 동일인물인데(pymysql과 mysqlclient를 한 조직에서 같이 개발하고 있음), 특별한 이유가 없다면 mysqlclient를 권고하고 있다.

어플리케이션 레벨 의사결정은 이제 반 정도 한 것 같다. 뭐 이렇게 자잘한 것까지 다 결정하냐 싶겠지만, 내가 맘대로 정하고 통보하는 것보단 나을 것 같았기 때문에, 그리고 꽤 재밌는 이야깃거리일 것 같아서 이렇게 하고 있다. 12챕터의 내용들은 딱히 몰라도 상관 없기 때문에, 맘에 안 들면 그냥 13챕터로 넘어가도록 하자.

의사결정 - 시각 데이터 저장 방식

배경과 요구사항

  • 데이터의 생성일과 최근 수정일을 기록하는 것은 비즈니스적 지표를 뽑아내고(ex - 1월에 가입한 사용자 수, 날짜별 신규 게시글 수 등), 서비스에서 사용하고(ex - 게시글 작성일자 표기, 마이페이지에서 사용자 가입일 표시 등), 관리의 관점에서 데이터의 변화를 추적할 수 있는 기준이 될 수 있기에 여러모로 의미가 크다. 이렇게 데이터와 관련된 시각 정보를 기록하는 것을 timestamping이라 하는데, 이것도 데이터 저장에 대해 정해진 룰이 없으면 나중에 큰 기술부채가 될 수 있다.
  • 글로벌 서비스 시 유연하게 대응할 수 있는 방식이어야 한다.
  • millisecond 단위까지 표현할 수 있어야 한다. 사실 이렇게 단위를 결정할 때는 기술 조직 전체의 협의를 진행하는 것이 좋지만, 기술 조직 전체가 나 혼자니까 내 맘대로 정했다.
  • MySQL에서 시각 데이터를 관리하기 위한 DATETIME 타입이 잘 인식할 수 있어야 한다.
  • human friendly한 포맷이면 좋다. 예를 들면 Unix time(1547580999)보다 시각 문자열(2019-01-15 19:36:39)이 낫다.
  • Unix time처럼 특정 시각에 문제를 발생시키는 포맷이 아니어야 한다. 구체적인 내용은 2038년 문제를 참고하자.

선택지

  • Unix time
  • Asia/Seoul 기준의 시각 문자열
  • UTC 기준의 시각 문자열
  • ISO 8601 format 문자열

의사결정

UTC 기준의 시각 문자열로 저장하도록 하자. 그 이유는,

  • Unix time은 2038년 문제를 일으키고(2038년까지 서비스가 살아있을지 모르겠지만), 사람이 읽기 힘들다.
  • 어느 timezone 기준으로 시각 데이터를 저장하는지에 대해 일관성만 보장되면 상관 없으니 그냥 KST를 써도 당장 문제는 없겠지만, UTC가 '국제 표준시'로서 기준점이 되기도 하고, local timezone에 의존하며 생길 수 있는 실수를 방어하기가 좋다. 시각을 다루는 대부분의 라이브러리가 제공하는 now()utcnow()의 차이인건데, 예를 들어 한국 region에서의 now()미국 버지니아 region에서의 now()는 각각 Asia/Seoul(UTC+9), America/Virgin(UTC-4) 기준의 현재 시각을 반환할 것이다. 코드가 실행되는 운영체제의 timezone 설정에 의존한다는 것이다. 그냥 애초에 시각 정보를 UTC 기준으로 생성하면 timezone이 어떻게 생겨먹었던 어디서든 동일한 시차(UTC+0)의 시각 데이터를 만들 것이므로 실수를 방지할 수 있을 것이다. 물론 반대로 now(timezone='Asia/Seoul')같은 코드를 통해 '애초에 KST 기준으로 생성하면 되지 않나'라고 생각할 수도 있겠지만, 위에서도 말했듯 UTC는 '국제 표준시'로서의 기준점이 되니 굳이 KST를 고집할 필요가 없다.
  • ISO 86012019-01-15T19:36:39+09:00같은 포맷인데, '시각은 이거고, 이 시각은 UTC에 얼마 만큼의 시간을 더하거나 빼서 만들어진건지'를 명시한다. 사실 이건 시각 데이터에 대한 표현 단에서 쓸모있는 포맷이라, 저장 단에서는 투머치다. 바로 아래 의사결정에서 한 번 더 얘기할 것이다.

의사결정 - 시각 데이터 표현 방식

배경과 요구사항

  • 필요에 따라, UTC 기준으로 저장된 시각 문자열을 API에서 내려줘야 할 일이 생길 것이다. 예를 들면 '게시글 목록' API에서 각 게시글의 생성일을 포함시켜 주는 것이다. 이에 따라 표현(representation)에 대한 결정이 필요하다.
  • timezone에 대해 자유로울 수록 좋다. 바로 위 의사결정에 있었던 '글로벌 서비스 시 유연하게 대응할 수 있는 방식이어야 한다.'라는 요구사항의 연장선이다. 프론트엔드 관점에서, API가 반환한 시각 문자열이 어느 timezone을 기준으로 했는지에 대해 신경쓸 필요가 없어야 한다.
  • 시간을 다루는 라이브러리들이 잘 파싱할 수 있는 포맷이어야 한다. 환경에 따라 케바케겠지만 190115-193639보단 2019-01-15 19:36:39가 더 안전할 것이라고 생각한다.
  • 현재의 어플리케이션 기술스택에 무리 없이 적용할 수 있는 포맷이어야 한다.

선택지

  • YYYY-MM-DD HH:mm:ss.SSS
  • Unix time
  • YY-MM-DD HH:mm:ss.SSS
  • ISO 8601 format(YYYY-MM-DDTHH:mm:ss.SSS±hh:mm)
  • RFC 3339 format

의사결정

ISO 8601 format으로 표현하도록 하자. 그 이유는,

  • ISO 8601날짜와 시간에 관련된 데이터 표현을 다루는 국제 표준이다. 애초에 ISO 8601의 ISO국제 표준화 기구를 의미한다. ISO 8601 format의 시각 문자열을 파싱 못하는 정신나간 환경은 없을 것이라 생각한다.
  • ISO 8601이 표준화한대로 시각을 표현하려면, 그레고리력 날짜24시간제에 기반하는 시간, 그리고 UTC와의 시간 간격(time interval)을 조합해 만든다. 글로벌(다중 timezone 위에서) 서비스를 한다고 했을 때, 시각 데이터에 대한 국제화를 위해 ISO 8601을 사용한다는 것은 충분히 납득 가능하다고 생각한다.
  • 어차피 UTC로 저장했으니 그냥 그대로 YYYY-MM-DD HH:mm:ss.SSS 포맷을 써도 될 것 같기도 하다. 그러나 '시각 문자열이 UTC다'라는 것은 단지 내부적으로 정한 룰일 뿐이고, 데이터에 이를 명시할 수 있는 방법이 뻔히 존재하므로 충분히 써먹도록 하자.
  • RFC 3339ISO 8601과 비슷하게 생긴 시각 데이터 표현 표준이다. ISO 8601의 부분집합이라고 보면 되는게, 두 글자로 이루어진 연도 표현(2018을 18로 표현)이 불가능하고 + 마침표 문자를 millisecond 하위 단위에서만 사용할 수 있고 + date와 time을 구분하는 시간 지정자인 'T'를 대신해 공백을 허용하는 것 정도가 핵심적인 차이점이며 나머지는 모두 ISO 8601과 동일한 스펙이다. 조금 더 완전한 표현(complete representation)을 사용하도록 제약한다는 것인데, 이는 의사결정의 요구사항을 만족하는 데에 추가적인 기여를 딱히 하지 않는다. 게다가 Python에서 자주 쓰이는 시간 관련 라이브러리인 datetime과 arrow를 따져 보면, 둘 다 RFC 3339 format을 '제대로 지원'하고 있지 않다. isoformat()은 지원하더라도 rfcformat()은 지원하지 않는다는 것이다. 어차피 두 글자로 이루어진 연도 표현을 피하는 것 정도만 지켜주면 되는데, datetime이나 arrow나 isoformat()은 이미 이게 포함되어 있는 상태다. Golang은 RFC 3339를 자주 써서, 우리가 결정한 어플리케이션 기술스택이 그 쪽이었다면 RFC를 썼을텐데 모든 상황이 우리가 ISO 8601을 쓰도록 만들고 있다.
  • ISO 8601과 RFC 3339를 제외한 나머지 선택지들은, '이 시각 데이터가 어느 timezone을 기준으로 했는지'를 신경써야 할 수밖에 없다. 이렇게 되면 결국은 프론트엔드 레벨에서 시각 데이터에 timezone 정보, 혹은 특정 timezone과의 시간 간격 정보UTC+9와 같이 별도로 추가해서 쓰게 될텐데, timezone에 대한 명시를 백엔드가 해주면 'API가 내려주는 모든 시각 데이터는 UTC 기준이다.'처럼 내부적인 룰을 귀찮게 따로 얘기하지 않아도 되니 더 낫고, 이를 위해 ISO 8601이 좋은 해결책이 되어줄 것이라고 생각했다. 이미 '시간 간격은 UTC를 기준으로 한다'는 게 표준으로서 정의되었으니 말이다.
  • 부가적인 옵션이긴 하지만, 사람이 읽기 좋다는 것도 이유 중 하나다.

의사결정 - JSON key 네이밍 룰

배경과 요구사항

  • 우리가 사용하기로 한 직렬화 포맷인 JSON의 핵심 타입 중엔 Object라는 타입이 있는데, 이는 key-value 매핑으로 데이터를 나열하도록 되어 있다. Python의 DictionaryJava의 Map을 떠올리면 되는데, '사용자'의 데이터를 표현하기 위해 'id', 'password', 'nickname', 'email'같은 key에 값들을 달아주는 것을 예로 들 수 있다. 이러한 key들은 'signin_date'처럼 여러 단어가 섞이는 경우도 생긴다. 이러한 '이름'들에 대한 표기 룰을 정해둬야 한다. '이름을 짓는 방법'이라기보단 'key에서 단어와 단어 사이를 어떻게 구분할 것인지'를 결정한다고 생각하면 될 것 같다.
  • 코딩을 조금 해 봤다면 '이름을 잘 짓는 것'과 '이름 짓기에 일관성을 지키는 것'은 생각보다 꽤 중요하다는 것을 이미 깨달았을 것이다.
  • 프론트엔드가 사용하는 네이밍 룰과 섞였을 때, 위화감이 적거나 없어야 하며 관례를 고려하자. Python이 어떤 네이밍 룰을 쓰는지는 상관 없다.

선택지

  • 신경쓰지 않는다. ex) 'signindate'
  • 공백으로 분리한다. ex) 'signin date'
  • 카멜 케이스(Camel Case)를 사용한다. ex) 'signinDate'
  • 파스칼 케이스(Pascal Case)를 사용한다. ex) 'SigninDate'
  • 스네이크 케이스(Snake Case)를 사용한다. ex) 'signin_date'

의사결정

카멜 케이스를 사용하자. 그 이유는,

  • 프론트엔드에선 이러한 데이터 처리 로직들을 JavaScriptTypeScript로 처리하게 될텐데, 대부분의 인지도 있는 코드 가이드라인들은 Object의 key를 네이밍할 때 camel case를 사용하도록 권고하고 있다.
  • 모바일 어플리케이션에서도 변수 네이밍에 camel case를 사용하는 가이드라인이 지배적이다. 모바일 어플리케이션 개발에 일반적으로 사용하는 Java, Kotlin, Objective-C, Swift가 그렇다. 이 쪽에도 변수 네이밍 룰을 통일해 주면 좋을 것 같았다. val signinDate = payload['signinDate']val signinDate = payload['signin_date']보다 나을 것이다.
  • Python은 변수 네이밍에 snake case를 사용하지만, 프론트엔드를 배려하고 관례를 지키도록 하자는 입장이다.

일러두기 - JWT 관리 방식

JWT 관리 방식에 대해서는 필자도 정확히 아는 입장이 아닌지라 쉽게 얘기할 수 있을 것 같지 않고, 내용 자체도 길어질 것 같아서 JWT에 관한 의사결정과 코드 업데이트는 나중으로 미뤄서 별도의 챕터로 작성할 예정이다. 당장은 'JWT를 제대로 못 쓰는' 상태'일단 동작하게 만들기'를 먼저 해보자.

일러두기 - 사용할 라이브러리

WAS를 개발하기 위해 사용할 라이브러리는 다음과 같다. Pipfile에 리스팅될 라이브러리의 목록이라고 이해하면 되며, 의사결정을 거하게 할 필요가 없는 것 같아서 한 곳에 정리한다.

  • Flask : 9. Compute Engine 결정과 Hello World 서버 배포에서 이야기했다.
  • sqlalchemy, mysqlclient : 12. 어플리케이션 레벨 의사결정 - (1)에서 이야기했다.
  • arrow : 시간에 관한 로직을 잘 처리하기 위해 사용한다. 딱히 길게 얘기할 것 없이, Python에 내장되어 있는 시간 라이브러리인 datetime보다 훨씬 낫다.
  • flask-jwt-extended : JWT를 관리하기 위해 사용한다. pyjwt를 써서 비교적 low-level로 JWT를 관리하면 조금 더 많은 일들을 할 수 있지만, 굳이 그렇게까지 할 필요가 없다. flask-jwt라는 라이브러리가 한동안 star 수가 더 많았는데, 개발이 제대로 진행되고 있지 않고 있는데다 라이브러리의 컨셉 자체가 그렇게 좋은 편이 아니었어서 오래 전에 flask-jwt-extended에게 역전당했다.
  • flask-restful : API 로직을 class 단위로 작성할 수 있게 해주는 flask의 MethodView를 조금 더 확장한 라이브러리다. 더 탄탄한 어플리케이션 구조를 만드는 데에 큰 도움을 준다.

일러두기 - 민감한 데이터 관리 방식

데이터베이스 접속 비밀번호같은 민감한 데이터들은, 아무리 private repository라고 하더라도 소스코드 바깥에서 관리하는 게 좋다. 이전에 AWS well architecture에 관한 글을 읽었을 때, '깃허브가 해킹당하면 private도 의미가 없다'라는 내용이 있었다. 이러한 데이터들을 어디에 관리할지는 사람마다 다른데, 이 내용은 별도의 챕터로 분리하는 것으로 하고, 당장은 환경 변수에서 관리하도록 하자. AWS Lambda Management Console에 들어가서 이전에 배포해 두었던 함수를 선택한 후 '환경 변수' 섹션을 통해 데이터를 넣어 두겠다. RDS MySQL의 엔드포인트사용자 이름, 비밀번호를 각각 DB_ENDPOINT, DB_USER, DB_PASSWORD라는 이름으로 추가했다.

값들은 그대로 따라하지 말고, 독자 여러분의 상황에 따라 입력해 두자. 엔드포인트는 RDS Management Console에 들어가서 DB 인스턴스를 클릭하면 볼 수 있고, 사용자 이름과 비밀번호는 이전에 RDS 인스턴스를 만들며 입력해 두었던 마스터 사용자 이름과 마스터 암호를 사용하자.

API 스펙 설계가 끝났으니 이제 프론트엔드 팀에게 전해줄 문서를 작성해야 한다. 이거야 뭐 대충 마크다운같은 걸로 열심히 시간 쏟아서 정리해도 되는 부분이지만, 더 나은 방법이 없을지부터 고민해 보자. 이번 챕터에서는 API 문서화 방식을 결정한다.

의사결정 - API 문서화 방식

난 처음에 엑셀로 API를 문서화했다. 메소드 URI, 요청 파라미터, 응답 status code별 설명, 응답 body 등등을 컬럼으로 두고 내용을 채웠었다.

이게 어떤 문제가 있냐면,

  • 변경을 추적하기 어렵다.
  • 변경이 생길 때마다 프론트엔드에게 새로운 파일을 전달해줘야 한다. 대안으로 Google Sheets같은 중앙화된 문서화 도구를 쓰는 방법이 있다.
  • 엑셀과 같은 모던한 문서화 도구들은 중복을 추상화하기 어렵다. 예를 들어, '게시글 작성'과 '댓글 작성' API의 response body 포맷이 동일하다고 치면, API 문서화에 특화된 도구들은 이걸 따로 분리해서 참조할 수 있게 만들어져 있다. 중복 제거는 통일성 있는 확장을 위해 중요한데, 이걸 포기하는 셈이다.
  • 가독성이 나쁘다. 비슷한 endpoint끼리 묶어서 카테고리화 시키고, 설명에 테이블이나 리스트와 같은 HTML 기반의 컨텐츠를 추가하는 등의 일이 힘들다(적어도 엑셀 많이 안 다뤄본 나로서는).

그냥 API 문서화 도구의 결과물을 사진으로 만나보자.

HTTP API의 문서화 방식을 표준화시키기 위해, yaml 파일로 작성하는 형태의 OpenAPI라는 스펙이 존재하며 거의 모든 API 문서화 도구들은 이런 OpenAPI 스펙에 대응되어 있다. 따라서, OpenAPI 스펙에 따라 작성한 API 문서를 시각화해줄 도구를 선택해야 한다. 간소화된 문법으로 작성한 문서를 OpenAPI 포맷으로 convert해주는 도구라면 더 좋고. 또는 OpenAPI 신경 안쓰고 GUI 방식으로 문서를 작성하는 자체적인 문서화 서비스일 수도 있다.

배경과 요구사항

  • 변경을 추적하기 쉬워야(버전 관리가 가능해야) 한다. Git으로 관리할 수 있는 것이라면 더 좋다.
  • 문서를 작성하는 일이 고통스럽지 않아야 한다. raw한 OpenAPI 3.0 spec은 유지보수하기 정말 쉽지 않다.
  • UI가 예뻐야 한다.
  • Private로 관리할 수 있어야 한다.

선택지

의사결정

GitBook을 사용하겠다. 그 이유는,

  • Excel을 쓰는 방식은 너무 구리다.(위에서 말했던 것처럼)
  • SwaggerHub는 OpenAPI 스펙 그대로 문서를 정의해야 한다. 따라서 모든 API를 한 페이지(파일)에서 관리해야 하는데, API 한 대여섯개만 넣어도 가볍게 1000줄이 넘어가서 관리가 힘들다.
  • 소스코드에 임베딩하는 방식은, 라이브러리 단에서 HTML+CSS 리소스 + 소스코드에서 추출한 문서 정보를 가지고 있다가 /docs 같은 uri에서 문서를 웹으로 서빙하는 형태다. 유명한 것으로는 Flask 기반의 flasgger가 있다. 소스코드에 문서가 포함되어 있으니, 리뷰 과정에서 API 스펙이 변경되었을 때 이를 문서에도 반영했는지 볼 수 있어서 좋고 대부분 API 각각에 문서가 주입되는 형태라서 가독성도 괜찮다. 그러나 API 문서의 수정이 필요할 때마다 어플리케이션 전체를 다시 빌드하고 배포해야 해서 리스크가 크다. 따로 분리하는 것이 좋겠다고 생각했다.
  • ReDoc과 Slate는 OpenAPI 스펙에 맞춰진 문서를 조금 더 간소화된 문법으로 작성할 수 있어서 좋은데, 직접 관리하는 경우 조금 귀찮다. Amazon S3 website같이 스토리지 서비스에서 정적 웹사이트를 호스팅하는 형태로 관리하게 될텐데, 변경을 자동으로 배포하기 위해 배포 자동화를 설정해야 할테고, API가 외부로 공개되어야 한다면 status check도 붙어야 하고, 상황에 따라 ReDoc이나 Slate에서 제공하는 React App을 커스텀하기도 해야 한다. 관리 포인트가 늘어나기 때문에 보류.
  • 관리 포인트가 감당 가능하다 하더라도, ReDoc과 Slate가 간소화한 문법을 쓰는 것이 사실 경험 상 문서를 작성하는 데에 생산성이 그렇게 비교될 정도로 좋지는 않았고 썩 즐겁지도 않았다.
  • 관리 포인트를 줄인답시고 ReDoc과 Slate를 GitHub Pages에 올리는 경우, 문서를 private로 관리할 수 없다.
  • GitBook은 문서 작성에 대한 스트레스가 비교적 적다. GUI로 작성하기 때문. 내부적으로 OpenAPI 스펙으로 관리하고 있지는 않고 그냥 자체적인 API 문서화 툴이다. 과거에도 여기저기서 GUI 기반의 API 문서화 플랫폼을 개발하고자 하는 시도가 많았지만 조잡했어서 안 썼는데, 최근에 들어가 본 GitBook은 괜찮았던 것 같다.

OpenAPI에는 $ref라는 문법으로 중복을 관리할 수 있어서 좋은데, GitBook은 그렇지 않다. 하지만 편히 문서를 작성할 수 있으니 트레이드오프 한다고 생각하자.

준비

GitBook에 로그인하고, workspace를 만들어서 API를 문서화하자.

지금까지

  • 버전 관리 시스템으로 Git을 사용하기 시작했다.
  • Git 웹호스팅 서비스로 GitHub를 사용하기 시작했다.
  • GitHub Issues와 Projects로 이슈 트래킹을 시작했다.
  • 개발 프로세스와 브랜칭 모델을 정립했다.
  • HTTP API 아키텍처 기반으로 API 스펙을 디자인하기로 했다.
  • JSON을 직렬화 포맷으로 결정했다.
  • Authorization 헤더로 인증 정보를 명시하기로 했다.
  • 인증 스키마에 JWT 기반의 Bearer를 사용하기로 했다.
  • API 스펙을 정의했다.
  • GitBook으로 API를 문서화했다.


맨날 뭐 결정만 하느라 지쳤으니, 이제 드디어 조금이라도 생산적인 작업을 해보자. API 스펙 설계와 문서화 방식 결정인데, 우리가 여태까지 의사결정한 결과물들이 이 작업의 기반이 되어 도움을 줄 것이다. 여기로 다시 끌어와 보면,

  • HTTP API 설계 원칙을 기반으로 API 스펙을 디자인하기로 했다.
  • JSON을 직렬화 포맷으로 결정했다.
  • Authorization 헤더로 인증 정보를 명시하기로 했다.
  • 인증 스키마에 JWT 기반의 Bearer를 사용하기로 했다.

이번 챕터는 글이 꽤 길어질 것 같아서, 두 편으로 나눠 진행한다.

도입 이유

'뭔가 하기 전에는 설계부터 해야지!'라는 의미 없는 이유라면 시도조차 안 했을텐데, 이 과정이 필요한 이유가 좀 있다.

  • 개발에 착수하기 전에 구조에 대해 고민할 시간이 생긴다.
  • 어차피 API 스펙을 프론트엔드에게 전달할 때는 문서로 정리해야 한다.
  • 실제로 로직을 작성하기 전에 스펙을 리뷰하는 단계가 생기고, 아직 코드를 작성하지 않았으니 변경 사항을 빠르게 반영할 수 있다.
  • 문서를 착실하게 작성해 두면, 커뮤니케이션으로 낭비하는 시간이 줄어든다.

작업 - API 스펙 설계

일반적인 프로젝트라면 기획서, 기본적인 와이어프레임, UI처럼 기능 명세를 살펴볼 수 있을만한 산출물이 나오고 난 상태여야 한다. 하지만 우리 프로젝트 팀 멤버는 가상 인물이므로 산출물같은 게 없으니 게시판 서비스에 필요할만한 기능들을 대충 떠올려서 설계해보도록 하자. HTTP API 설계 원칙에 따라 진행할텐데, 사실 이게 표준이 따로 있는 것이 아니고 조직마다 설계 원칙이 다르다. 우리는 Heroku 플랫폼 API를 개발한 조직이 정리한 디자인 가이드를 참고하겠다. 한국어 버전도 있으니 한 번쯤 읽어보도록 하자. 어떤 디자인 가이드를 사용할지에 대해서도 의사결정을 따로 했으면 어떨까 싶기도 했는데, 다들 뭐 비슷한 소리만 해서 깔끔히 정리된 거 하나 선택해서 참고해도 큰 문제 없을 것이라는 판단이다. 표준이 없다면 규칙은 조직이 납득 가능한 선에서 정하기 나름이니 말이다.

설계를 얼마나 구체적으로 할 지는 사람마다 다르지만, 나는 그냥 기능마다 엔드포인트와 함께 간단히 한두줄 정도로 정리해보려 한다. 필자는 원래 이런 것들을 notion이나 bear같은 메모 도구로 정리하곤 하는데, 글을 써야 하니 여기에 정리하도록 하겠다. 참고로 엔드포인트는 HTTP method + URI를 의미한다.

작업

정리한 API 스펙은 다음과 같다. {...}과 같은 URI는 path parameter를 뜻한다.

  • GET /check-duplicate/email/{email} : 이메일 중복체크 API
  • POST /signup : 회원가입 API. ID는 이메일을 사용하며, 이메일로 확인 링크가 전송된다. 이메일/비밀번호/닉네임을 받는다.
  • GET /verify : 회원가입 확인 링크 클릭 시 GET으로 호출하게 될 API. 이메일 전송 시 query string에 확인 링크의 ID를 담아 GET /verify?verify_id=am38gjbkeo같은 링크를 전달할 것이다. /verify/{verify_id}처럼 path parameter를 사용할 수도 있으나, GET 요청의 명시성을 늘리고 나중에 확인 링크에 더 많은 정보를 담게 될수도 있을테니 이렇게 했다.
  • GET /auth : 로그인 API. JWT 포맷으로 인코딩된 access token과 refresh token을 발급한다.
  • GET /refresh : access token refresh API. refresh token의 expire가 얼마 남지 않았다면 refresh token도 새로 만들어서 발급해 준다.
  • POST /board/categories : 카테고리 생성 API
  • GET /board/categories : 카테고리 목록 API
  • GET /board/categories/{category_id}/posts : 특정 카테고리의 게시글 목록 API
  • POST /board/categories/{category_id}/posts : 게시글 작성 API
  • PATCH /board/posts/{post_id} : 게시글 수정 API
  • DELETE /board/posts/{post_id} : 게시글 삭제 API
  • GET /board/posts/{post_id}/content : 게시글 내용 API
  • GET /board/posts/{post_id}/comments : 게시글의 댓글 목록 API
  • POST /board/posts/{post_id}/comments : 댓글 작성 API
  • DELETE /board/comments/{comment_id} : 댓글 삭제 API

모호함이 많다. 예를 들어, 게시글 목록 API에서는 페이지 개념이 들어갈 것인지, 페이지 당 게시글 갯수는 몇 개로 할 것인지, 회원가입 시 닉네임의 중복을 체크할 것인지, 비밀번호의 최소/최대 자릿수는 몇으로 할 것인지 등이다. 이런 건 기획 단에서 의사결정해 주어야 하는 부분이지만, 상황이 그렇지 않으므로 문서화 단계에서 적절히 이런 제약 사항들을 끼워넣을 것이다. API 처리 결과의 구분(이메일 중복 시 409, 게시글 삭제 권한이 없는 경우 403 등)도 문서화 단계에서 명시하겠다.

이번에는 사용자 인증 방식을 결정하자. SNS나 우리가 만드려는 서비스처럼 계정 개념이 들어가고, 리소스가 특정 사용자에게 귀속되는 서비스라면 인증이 꼭 필요하다.

도입 이유

HTTP는 연결 지향 프로토콜인 TCP 기반임에도 불구하고, 대표적인 비연결 지향 프로토콜이다. 따라서 한 번의 요청-응답 사이클이 완료되면 연결을 종료하기 때문에, 동일한 클라이언트가 요청을 아무리 많이 하더라도 프로토콜은 이를 모두 독립적인 요청으로 인지한다. 이 때문에 클라이언트는 매 HTTP 요청마다 본인이 누구인지를 인지시킬 수 있는 인증 정보(credential)를 요청의 어딘가에 포함시켜야 하며, 서버 또한 클라이언트의 자원 접근을 허용하기 전에 이러한 인증 정보를 기반으로 인증 과정을 일차적으로 거쳐야 한다. 사용자 A가 작성한 게시글을, 다른 사용자가 마음대로 수정/삭제할 수 없게 만들어야 하기 때문이다.

의사결정 - 인증 정보의 위치

가장 먼저, 인증 정보를 HTTP 요청의 어디에서 관리할지를 결정하자.

배경과 요구사항

  • 모든 형태의 HTTP 요청에 다 사용 가능해야 한다. 예를 들어, GET 요청에서 사용할 수 없으면 안된다.
  • HTTP 표준에 맞춰지면 더 좋다.
  • 클라이언트 사이드에서, 쉽게 저장하고 HTTP 요청 단에서 쉽게 데이터를 실어줄 수 있어야 한다.

선택지

  • request body
  • 요청의 query parameter
  • Cookie 헤더
  • Authorization 헤더

의사결정

Authorizaton 헤더를 선택하겠다. 그 이유는,

  • 인증 데이터는 메타데이터 성격이 강하다. request body와 어울리지 않는다.
  • request body를 사용할 수 없는 메소드가 있다. GET, HEAD, DELETE, TRACE가 그렇다.
  • url의 ? 뒤에 붙는 query parameter는 고려해볼 만 하지만, 사용자 인증 하라고 Authorization 헤더가 표준화되어 있는데 굳이 query string을 써서 얻을 메리트가 없다.
  • Cookie는 헤더를 사용한다는 점에서 Authorization과 비교해볼 만 하다. 하지만 이것도 위에서 query parameter를 걸렀던 이유와 비슷하게, 인증이라는 맥락은 Authorization이 더 어울린다.
  • 내 주변만 그런 건지는 모르겠는데, 모바일 클라이언트들이 쿠키 기반의 인증을 싫어한다. 아마도 cookie store를 별도로 구현해야 되기 때문인 듯.

옛날에는 쿠키랑 세션을 이래저래 섞어서 썼던 기억이 있다. '자동 로그인'이라는 기능이 있어서, 이게 활성화되어 있으면 쿠키를 주고, 활성화되어 있지 않으면 세션을 줬다. 무슨 생각으로 그랬나 싶다. 코드

준비

MDN의 Authorization 헤더 문서를 읽어 보자.

의사결정 - 인증 스키마

이제 사용자가 로그인을 했을 때, 서버는 그 사용자를 나타내는 특별한 값을 만들어서 전달해 권한을 부여하고, 사용자는 나중에 Authorization 헤더로 그 인증 데이터를 보내준다는 것까지 결정이 되었다. '사용자를 나타내는 값'을 어떻게 만들어낼 지는 표준이 결정해 줄 것이다. Authorization 헤더에는 값에 대한 표준도 있으니까.

Authorization 헤더의 value는 <type> <credentials>처럼 생겨먹도록 하는 것이 표준이다. Bearer xmp98-cb35.potn6jz.zorj15gmb-이 한가지 예다. 인증 타입에 따라 credential을 만들어내는 방식이 정해져 있기 때문에 맘대로 할 수 있는 부분이 아니다. 표준을 따르지 않더라도 이유는 있어야 한다. 그러니 인증 스키마에 대한 의사결정을 진행하자.

배경과 요구사항

  • 표준을 따르지 않아도 괜찮지만, 충분한 이유와 대안이 있어야 한다.
  • 추후 확장 가능성을 위해 토큰 기반 인증 시스템이면 좋다. 모르는 단어라면 Velopert님의 토큰 기반 인증에 대한 소개를 읽어보자.
  • 충분히 암호화된 상태로 주고받을 수 있거나, 비밀번호와 같이 critical한 데이터를 값 내부에 포함시키지 않는 방식이어야 한다.

선택지

표준 상 Authorization 헤더의 값에는 RFC에 의해 표준화된 인증 스키마를 사용할 수 있게 되어 있다.

  • Basic
  • [비표준] OAuth 1.0a를 사용하는 Bearer
  • OAuth 2.0을 사용하는 Bearer
  • [비표준] JWT, 또는 JWT를 사용하는 Bearer
  • Digest
  • HOBA

의사결정

JWT을 사용하는 Bearer를 선택하겠다. 그 이유는,

  • Basic은 ID와 비밀번호를 base64 인코딩하는 방식이다. base64는 별도의 key 없이도 복호화가 가능한 인코딩이므로, 안전하지 않다.
  • OAuth 1.0a는 Bearer 인증 표준이 아니다. Bearer 스펙을 명시한 RFC 6750에는 큰 글씨로 'The OAuth 2.0 Authorization Framework'라고 되어 있기까지 하다.
  • Bearer에서 사용하는 OAuth 2.0 방식의 인증은 확장성이 매우 높다. 'Facebook 계정으로 로그인'과 같은 기능이 OAuth로 구현되었다. 되도록 이런 흐름에 낄 수 있다면 좋겠지만, OAuth 2.0은 자체 암호화를 지원하지 않기 때문에 HTTPS를 쓰는 것을 권고하고 있고, 돈이 들어가야 하는 부분이다. 인증 정책은 나중에 HTTPS 관련 비용 문제를 해결하고 나서 변경해도 괜찮을 것 같다는 판단이다. + 스펙 자체에서 명확하게 정의하지 않은 부분이 꽤 있어서 그만큼 고민이 깊어진다고 한다.
  • Bearer에 JWT를 사용하거나, JWT라는 타입을 쓰는 것도 표준이 아니다. 그러나 HTTPS 문제로 OAuth 2.0을 보류하게 되니, 대신 쓸 토큰 기반 인증 시스템으로 JWT가 가장 쓸만 하다.
  • JWT는 사용 사례가 많고, 거인의 어깨(잘 만들어진 라이브러리, 예제 등)가 잘 준비되어 있다.

그냥 아무 type이나 붙여서 값을 전달하거나, type 그거 명시해 봤자 딱히 쓸모 없는 것 같으니 type 안 붙여도 된다. DB 단에서 사용자와 매핑한 랜덤 문자열이나, 사용자 ID 자체가 인코딩된 문자열을 쓰는 등의 방식이다. 이번에 결정한 JWT도 그 연장선이다. 사실 경험 상 조직 내의 정책적으로 충분한 대안이 있다는 가정 하에, 인증에 관해서는 표준을 어겨도 그리 큰 문제는 없었던 것 같다.

준비

JSON Web Token 소개 및 구조라는 글을 읽고 구글링 이리저리 하면서 JWT를 조금 알아두자.

결론은 표준을 어기는 것으로 결정이 났다. JWT가 충분한 대안이 되어서 의사결정의 후회가 없거나 적었으면 좋겠다. 물론 HTTPS 문제를 극복하고 나서 OAuth 기반으로 인증 시스템을 변경하는 것도 이 컨텐츠에 포함시킬 계획이다.

지금까지

  • 버전 관리 시스템으로 Git을 사용하기 시작했다.
  • Git 웹호스팅 서비스로 GitHub를 사용하기 시작했다.
  • GitHub Issues와 Projects로 이슈 트래킹을 시작했다.
  • 개발 프로세스와 브랜칭 모델을 정립했다.
  • HTTP API 아키텍처 기반으로 API 스펙을 디자인하기로 했다.
  • JSON을 직렬화 포맷으로 결정했다.
  • Authorization 헤더로 인증 정보를 명시하기로 했다.
  • 인증 스키마에 JWT 기반의 Bearer를 사용하기로 했다.


+ Recent posts