HTTP HEAD 메소드는 특정 자원을 GET 메소드로 요청하는 경우에 어떤 헤더들이 반환되는지를 요청한다. Amazon S3 API의 경우 객체에 대해 HEAD 요청을 전송하면 응답에 Content-Length가 함께 전달될텐데, 이를 통해 굳이 GET 요청으로 객체를 읽지 않고도 객체의 용량을 알아낼 수 있다. boto3에서는 head_object라는 이름의 메소드로 bucket에서 특정 key에 해당하는 객체에 HEAD 요청을 보낼 수 있다.

head_object 메소드는 객체의 메타데이터들로 이루어진 dict를 반환하고, 'ContentLength'라는 key에 객체의 size가 값으로 담겨 있다. 단위는 byte다. 아쉽게도 Bucket 객체에는 head_object 메소드가 없다.

SQL에서 AS 절은 SELECT 절의 expression에 별명을 붙여주는 역할을 한다. SQLAlchemy에서는 컬럼 객체의 label 메소드를 통해 labeled column을 얻어내는 방식으로 AS 절을 표현할 수 있다.

TblUsers.pw 컬럼을 password라는 이름으로 aliasing하여 SELECT했으므로 row 객체에서의 속성 이름도 pw가 아니라 password가 된다. 이렇게 aliasing된 expression을 다른 곳에서 사용하려면, filter 등에 string expression으로 표현하면 된다.

무조건 string expression을 써야 하는 것은 아니다. aliasing되었더라도 filter(func.length(TblUsers.pw) > 16)같은 표현식을 쓸 수 있다. 그러나 aliasing이라는 것 자체가 어떠한 expression에 별명을 지어주는 것이기 때문에, 위처럼 단지 filter에 문자열을 전달하는 것으로 alias된 이름을 통한 쿼리를 표현할 수 있다는 것이다.

함수

aliasing은 SELECT 절에서 함수를 사용할 때 유용하다. SQLAlchemy에서는 SQL의 함수들이 sqlalchemy.sql.expression.func 모듈에 준비되어 있다.


'Python 계열 > SQLAlchemy' 카테고리의 다른 글

Column.like, Column.ilike, not_, ~expr  (0) 2019.02.12
특정 컬럼만 SELECT  (0) 2019.02.12
limit  (0) 2019.02.12
query 객체가 실제로 쿼리를 실행하는 시기  (0) 2019.02.12
Multiple Primary Key  (0) 2018.10.18

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

결정된 어플리케이션 스택(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를 권고하고 있다.

쿼리의 결과 row 수를 제한하기 위해 LIMIT 쿼리를 사용한다. 사용자가 리소스를 만들어가는 형태의 서비스(SNS, 게시판 블로그 등)는 데이터들이 수직적으로 늘어나는데, 이런 곳에서는 일반적으로 목록 데이터를 넘겨줄 때 pagination 개념을 적용한다. 페이스북 뉴스피드에서 포스트가 가장 처음에는 10개 남짓 보이다가, 스크롤이 끝나면 포스트가 새롭게 로드되는 것이 그 예다.

사용자에겐 자체적인 rank 알고리즘이나, 특별한 기준(가장 최신 글부터 등)에 따라 먼저 몇 개 정도만 보여주고, 원하면 더 불러오게 만드는 것이다. 수만 개가 넘는 포스트 목록이 있어봤자 사용자는 그들 중 일부만 볼텐데, 그 데이터를 다 넘겨줘 봐야 불필요하게 화면에 렌더링시키는 시간만 늘어나고, 네트워크 비용이 낭비될테니 말이다.

이런저런 이야기들로 LIMIT 쿼리의 의도를 설명했는데, 아무튼 결론적으로는 자원 낭비를 줄이기 위해 LIMIT 쿼리를 사용한다. 목록 데이터를 다 내려주기엔 양이 너무 많아서던, 표본 추출(Sampling) 방식으로 통계를 내기 위해서던 말이다.

SQLAlchemy에서 limit은 그냥 query 객체에 슬라이싱을 씌우면 된다. Query 클래스에는 슬라이싱을 처리할 수 있는 __getitem__ 매직 메소드가 구현되어 있고, 이는 n:m으로 슬라이싱했을 때 쿼리에 LIMIT n OFFSET m을 추가하고 all의 호출 결과를 반환하는 역할을 한다. 주저리주저리 열심히 설명했지만 참 간단하게 된다.

query 객체의 slice 메소드를 사용하는 것도 한가지 방법이다.


'Python 계열 > SQLAlchemy' 카테고리의 다른 글

Column.like, Column.ilike, not_, ~expr  (0) 2019.02.12
특정 컬럼만 SELECT  (0) 2019.02.12
aliasing과 함수  (0) 2019.02.12
query 객체가 실제로 쿼리를 실행하는 시기  (0) 2019.02.12
Multiple Primary Key  (0) 2018.10.18

어플리케이션 레벨의 입장에서, raw string 형태의 SQL은 여간 귀찮은 일이 아닐 수가 없다. 수많은 객체 간의 작업, 또는 함수와 콜백 간의 인터랙션이 주가 되는 소스 코드에서, raw string으로 되어 있는 쿼리 문자열은 가독성에 문제를 주기도 하고, 테이블이 alter되거나 데이터베이스 엔진을 마이그레이션했다면 쿼리 문자열들을 하나하나 찾아 수정해 주어야 하는 문제점도 있다.

한가지 예를 들어보자. tbl_users라는 테이블에 사용자에 관련된 몇가지 컬럼들이 있어서, 특정 나이에 해당하는 사용자들의 리스트를 반환하는 함수를 작성한다고 가정하면, 아래와 같은 코드가 나올 수 있다.

위 코드는 그리 문제가 없어 보이지만, tbl_users 테이블에 대한 쿼리를 다루는 곳이 프로젝트 전체에 걸쳐 10군데가 넘는다고 생각해 보자.

  1. 테이블이 alter되어 email 컬럼이 user_email이라는 이름으로 변경되었다면, '이름 변경'이라는 단순한 작업임에도 불구하고 해당 테이블을 사용하는 쿼리의 갯수만큼 작업 공수가 높아진다.
  2. 데이터베이스 서버를 마이그레이션한다면, 예를 들어 PostgreSQL을 사용하던 데이터베이스를 MySQL로 옮긴다면 쿼리를 수정해야 할 필요가 있다.

ORM

ORM은 Object Relation Mapping의 약자다. ORM은 RDB의 테이블 구조객체지향 프로그래밍 방법론에서의 객체가 꽤 많이 닮아 있다는 것에서 출발한다. 테이블에서 하나의 레코드는 하나의 객체와 매핑되고, 하나의 열과 타입은 객체의 필드와 타입으로 매핑할 수 있다. 결론적으로 ORM은 쿼리와 쿼리의 결과를 각각 string과 Map으로 관리하지 않고, 메소드와 객체로 이루어진 list로 관리하자는 것이다. 이것도 코드로서 예시를 들어 보겠다.

객체지향 표현 방식 중 하나인 클래스로 스키마를 매핑하고, 특별한 메소드를 이용해 쿼리하여 객체로 이루어진 리스트를 얻어온다. 문자열 형태의 SQL을 사용할 때에 비하면, 어플리케이션과 데이터베이스 간의 이질감이 줄어들었다. 그림으로 표현하면 아래와 같다.

객체 단위로 쿼리를 생성하고, ORM 라이브러리는 이를 raw string SQL로 변환하여 실제로 쿼리를 수행한다. ORM 라이브러리는 데이터베이스를 제공하는 vendor마다 어떤 방식으로 SQL을 작성해야할 지를 알고 있기 때문에, ORM은 데이터베이스 액세스에 대한 추상화 레벨을 높일 수 있는 가장 쉬운 방법이다. 그러나 ORM은 매우 많은 논쟁을 끌고 다니는 기술이다. 이는 Should I or should I not use ORM?이나 마틴 파울러의 ORMHate, Why we don't use ORM등의 글을 읽어보면 도움이 될 것 같다.

'배경지식' 카테고리의 다른 글

ProxySQL  (1) 2019.02.12
직렬화와 JSON  (0) 2019.02.12
HTTP 메소드  (0) 2019.02.12
StatsD  (0) 2019.02.11
HTTP 헤더  (0) 2018.11.02

HTTP라는 프로토콜은 그 이름(Hypertext Transfer Protocol)에 맞게 하이퍼미디어 문서 전송을 위해 웹 브라우저와 웹 서버 사이의 통신을 위해 설계되었지만, 연결을 열고 - 요청을 보낸 뒤 - 응답을 대기하는 고전적인 클라이언트-서버 모델이 필요한 곳이라면 웹 문서 전송이 아닌 다른 목적을 위해서도 사용할 수 있다. 이런 곳에서는 백엔드 레이어를 구현할 때 메소드(method) 개념을 생각하게 된다. 클라이언트에게 동일한 URL에 대해 생성/조회/삭제 등의 동작을 제공하는 게 명료하기 때문이다. 메소드는 HTTP 요청에서 사용하며, 클라이언트가 웹서버에게 요청의 목적이나 종류를 알리는 수단으로서 사용한다. HTTP 메소드에는 종류가 참 많은데도 불구하고 나는 GET/POST/PATCH/PUT/DELETE 정도만 사용하고, 알고 있었던 것 같아 한 번 정리해보고자 한다.

HTTP 메소드

HTTP의 요청에서 URL, 헤더, body와 함께 중요한 구성 요소다. 위에서 말했듯 자원(URL)에 대한 행위(verb)를 명시하기 위해 사용된다. 조회, 전송, 삭제 등의 행위에 따라 여러 종류의 메소드가 존재한다.

GET

GET /news HTTP/1.1

리소스의 표시를 요청한다. URL에 웹 브라우저로 접속하는 것이 대표적인 GET 요청의 예다. 게시글 목록, 주소록 등을 불러오는 것처럼, 데이터를 받아오는 맥락의 HTTP 요청은 통상적으로 GET을 사용한다. 대부분의 경우 GET 요청에는 body가 없다.

POST

POST /post HTTP/1.1

리소스에 데이터를 전송한다. 새로운 게시글을 작성하거나, 회원가입을 하는 등 새로운 데이터를 서버에 등록하기 위해 일반적으로 POST를 사용한다.

PUT

PUT /meal/2018/12/25 HTTP/1.1

POST와 동일하게, 리소스에 데이터를 전송한다. '생성하되, 이미 존재한다면 변경한다'라는 논리가 깔려 있는 것이 한 가지 다른 점이다. 사용자의 설정 데이터 적용이나, 날짜별 급식 정보 업로드처럼 POST와 PATCH를 구분짓기 애매하거나 클라이언트 레벨에서 생성과 수정을 따로 구현하기 귀찮아하는 기능에서 종종 사용했던 것 같다.

PATCH

PATCH /post/9e15c13aed1c48b5 HTTP/1.1

리소스의 특정 부분을 수정하는 데에 쓰인다. 게시글 수정이나 닉네임, 자기소개 변경과 같이 update 개념의 기능에서 자주 쓰인다.

DELETE

DELETE /me/profile-picture HTTP/1.1

리소스를 삭제한다. 친구 삭제나 프로필 사진 제거와 같은 기능에서 쓰인다.

HEAD

HEAD /dumps/log/1540993830.csv HTTP/1.1

리소스를 GET 메소드로 요청하는 경우에 어떤 헤더들이 반환되는지를 요청한다. 큰 용량의 리소스를 다운로드 받을지 말지 결정하기 위해서 Content-Length를 알아내거나, 응답에 영향을 줄 수 있는 헤더 목록을 조회하기 위해 Vary 헤더를 얻어오는 용도 등으로 사용할 수 있다. HEAD 메소드의 요청에 대한 응답은 body를 가질 수 없고, 만약 가지더라도 무시된다.

OPTIONS

OPTIONS * HTTP/1.1

자원에 대한 통신 옵션을 요청하기 위해 사용된다. 웹 클라이언트의 Axios 모듈에서 Access-Control-Allow-Origin 등과 같은 CORS 정보를 얻어내는 것이 한가지 예다. 서버 전체를 나타내기 위해 asterisk(*)를 사용하기도 한다.

클라이언트 사이드 렌더링HTTP API 기반의 웹 서비스가 활발히 만들어지고 있는 요즘에는 HEAD, OPTIONS와 같은 특별한 것들을 제외한 메소드들은 어떤 용도로 사용되어야 하는지에 대한 정리가 사실상 proposal 수준으로 받아들여지고 있는 것 같다. 따라서 '조회에는 무조건 GET을 써야 한다'라기보단 '조회와 비슷한 맥락의 기능에는 GET을 쓰는 게 좋다' 정도로 생각하면 될 것 같다. 내 경우에도 '목록 조회' 동작임에도 불구하고 요청 데이터의 양이 많아서 JSON body를 사용하도록 하기 위해 POST로 API를 구성할 때가 종종 있었다. ElasticSearchDruidGET에도 body를 포함시킬 수 있는데, 이게 웬만한 웹 프레임워크들에서도 지원된다면 참 좋겠다.

'배경지식' 카테고리의 다른 글

ProxySQL  (1) 2019.02.12
직렬화와 JSON  (0) 2019.02.12
ORM  (0) 2019.02.12
StatsD  (0) 2019.02.11
HTTP 헤더  (0) 2018.11.02

SQLAlchemy를 쓰면서 가장 혼란스러운 것 중 하나가 'Query 객체는 도대체 언제 SELECT 쿼리를 수행하는가?'였다. 마주치게 되는 상황은 이렇다.

  • query.all()로 SELECT의 결과를 모두 가져올 수 있다고 한다. 이는 전형적인 반복자(iterator)로, for row in query.all()처럼 for문에서 사용할 수 있다.
  • 그런데 그냥 query 자체를 for문에 넣는 예제도 보인다. for row in query를 해도 문제가 없다.
  • LIMIT 쿼리를 어떻게 표현하는지 검색했더니, 그냥 슬라이싱하라고 한다. 그래서 for row in query[:10].all()로 했더니 에러가 발생하고, for row in query[:10]처럼 표현하니 제대로 동작한다.

그냥 SQLAlchemy의 소스 코드를 GitHub에서 살펴보면, 그 비밀을 알 수 있다. sqlalchemy.orm.query.py 모듈을 보면 되고, 아래는 그들 중 일부를 가져온 snippet이다.

__iter__

Query 객체는 __iter__ 메소드에서 실제로 SELECT를 수행한다고 요약할 수 있다. __iter__ 메소드가 호출되는 타이밍 중 몇개를 예로 들면,

  • iter 함수에 전달되었을 때
  • for문에 사용되었을 때(for문은 in 우측에 전달된 객체를 iter 함수에 전달하므로)
  • list 함수에 전달되었을 때

그럼 의문이 조금 풀린다.

  • for row in query : for문에 의해 간접적으로 __iter__가 호출되므로 잘 동작한다.
  • for row in query.all() : 위 snippet을 보면 알 수 있듯, list(self)의 반환을 iteration하므로 잘 동작한다.

__getitem__

__getitem__ 메소드는 인덱싱과 슬라이싱 연산을 오버라이딩한다. Query.__getitem__ 메소드는

  • 슬라이스를 받았다면 현재 Query 객체에 LIMIT 쿼리를 추가하는 self.slice 메소드를 호출한 후, 이를 통해 LIMIT이 반영된 Query 객체의 SELECT 결과를 반환하거나
  • 인덱스를 받았고 그 인덱스가 -1이라면 list(self)[-1]을 반환하고, 아니라면 list(self[item:item + 1])[0]으로 __getitem__을 재귀호출하여 해당 인덱스에 대한 row 하나만을 가져오도록 LIMIT 쿼리를 수행해서 경제적으로 row를 반환한다. 예를 들어 query[15]에 대해서는, LIMIT 15, 1 쿼리가 추가된다.

따라서,

  • for row in query[5:8] : __getitem__에 슬라이스가 전달되고, LIMIT 5, 3 쿼리가 추가된 Query 객체의 iterator가 반환된다.
  • row = query[10] : __getitem__에 인덱스가 전달되고, LIMIT 10, 1 쿼리가 추가된 Query 객체의 결과 중 0번째 인덱스가 반환된다.

'Python 계열 > SQLAlchemy' 카테고리의 다른 글

Column.like, Column.ilike, not_, ~expr  (0) 2019.02.12
특정 컬럼만 SELECT  (0) 2019.02.12
aliasing과 함수  (0) 2019.02.12
limit  (0) 2019.02.12
Multiple Primary Key  (0) 2018.10.18

어플리케이션 레벨 의사결정은 이제 반 정도 한 것 같다. 뭐 이렇게 자잘한 것까지 다 결정하냐 싶겠지만, 내가 맘대로 정하고 통보하는 것보단 나을 것 같았기 때문에, 그리고 꽤 재밌는 이야깃거리일 것 같아서 이렇게 하고 있다. 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 인스턴스를 만들며 입력해 두었던 마스터 사용자 이름과 마스터 암호를 사용하자.

StatsD는 로그 메트릭을 수집하는 영역에서 ProxySQL과 비슷한 용도로 쓰인다. UDPTCP를 통해 countertime과 같은 통계 데이터를 받아, Graphite같은 backend에 전송하는 Proxy다. 따라서 ProxySQL처럼 connection 중계가 아니라 통계적인 데이터 집계 처리 용도의 proxy로서 사용된다.

조금 더 쉽게 설명하면, StatsD는 메소드 호출 빈도와 소요 시간, 서비스 내에서 사용자가 일으키는 conversion(로그인, 장바구니 담기, 결제 등) 수 등을 MongoDB, MySQL, Datadog, zabbix, influxdb와 같은 backend에 적절히 기록한다. Graphite와 가장 케미가 좋다. (StatsD가 지원하는 backend 목록)

StatsD가 주는 메리트

ProxySQL처럼 auto failover와 같은 high availability 지원도 안 되는데 왜 굳이 쓰나? 싶겠지만, 애초에 StatsD를 비롯한 proxy 구조 자체의 이점이 있다. 바로 client-side 지원이다.

만약 InfluxDB로 로그 메트릭을 수집하고자 하더라도, 사용하는 언어에 안정적인 InfluxDB client 라이브러리가 없다면 번거로울 수밖에 없다. 이 경우 StatsD같이 InfluxDB를 backend로 지원하는 프록시 라이브러리를 사용하면 되고, 없더라도 구현하기 비교적 더 쉽다. TCP/UDP 소켓 기반으로 간결하게 통신할 수 있도록 설계되어 있기 때문이다. 그리고 StatsD는 TCP/UDP 프로토콜과 꽤 밀접하게 붙어 있기에, HAProxy같은 소프트웨어 로드 밸런서를 통해 high availability도 보장해줄 수 있다.

추가적으로 StatsD는 통계 데이터 집계 면에서 우수하다. StatsD는 데이터를 모아 두다가 일정 시간마다 집계 처리를 하고, 이들을 한번에 backend로 전송한 후 StatsD의 데이터를 모두 비운다. 이 때문에 로그를 visualize하기 이전에 통계 데이터를 미리 집계해둘 수 있다. 들어오는 데이터를 모두 실시간으로 전송하는 것보다, 잠시동안 모아 뒀다가 집계 작업 후 한번에 전송하는 게 추후의 집계 작업을 위한 컴퓨팅 비용이나 네트워크 비용 면에서 우수하다.

Metric의 형태

조금 추상적인 단어긴 한데, 어떤 데이터가 오가는 형태를 metric이라고 부를 수 있을 것 같다. StatsD도 데이터를 표현하기 위한 포맷이 존재하고, 아래와 같다.

<bucket>:<value>|<type>

예를 들면, gorets:1|c가 될 수 있다. 'gorets'로 명시된 bucket은 namespace 개념이다. 그냥 key라고 생각해도 된다. bucket은 StatsD에서 이야기하는 이름이고, metric name이라고 말할 수도 있다. 구분자(|) 뒤에 'c'로 명시된 type은 StatsD에서 제공하는 타입들 중 하나다.

StatsD는 데이터를 잠시 보관해 뒀다가 일정 시간마다 이들을 backend에 전송한다고 했다. 이와 같은 pipelining을 StatsD에선 flush interval이라 부르며, 기본값은 10초다.

Metric Type

StatsD에는 4개의 metric type(Counting, Timing, Gauges, Sets)이 있다.

Counting(:|c)

'c'로 표현한다. method_check_id_duplicated:1|c처럼 사용할 수 있다. 이는 간단한 카운터로, bucket(metric name)에 value를 더하는 작업이다. 메소드나 API 호출 수 등에 사용할 수 있다. StatsD에선 bucket마다 카운트 값을 누적하고 있다가, flush interval마다 backend에 카운트의 합을 전송하고 StatsD에선 count를 0으로 재설정한다.

Timing(:|ms)

'ms'로 표현한다. api_user_auth:82|ms처럼 사용할 수 있다. 이 경우, metric name인 api_user_auth를 위해 value인 82ms가 소요되었다는 의미이다. API별 소요 시간 등에 사용할 수 있다. StatsD에 이 데이터를 쌓으면, flush interval마다 평균, 표준 편차, 합계, 상한, 하한을 계산하여 backend에 이들을 전송한다.

Gauges(:|g)

'g'로 표현한다. goroutines_a:-8|g처럼 사용할 수 있다. 말 그대로 '게이지'를 표현하기 위해 사용하며, counting에 비해 값을 감소시킬 수 있어서 하드웨어 사용량같은 측정 값이나 스레드 갯수처럼 위아래로 이동할 수 있는 counter 등에 사용할 수 있다.

Sets(:|s)

's'로 표현한다. user:planb|s처럼 사용할 수 있다. flush interval마다 bucket에 전달된 고유한 value의 수를 계산하여 backend로 전송한다. SQL로 따지면 distinct count라고 생각하면 된다. MAU나 DAU 등을 뽑아낼 때 사용할 수 있을 것 같다.

'배경지식' 카테고리의 다른 글

ProxySQL  (1) 2019.02.12
직렬화와 JSON  (0) 2019.02.12
ORM  (0) 2019.02.12
HTTP 메소드  (0) 2019.02.12
HTTP 헤더  (0) 2018.11.02

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를 문서화했다.


+ Recent posts