개발 생활 - 2

  1. 개발 생활 - 1 : PDF 문서 생성 서버
  2. 개발 생활 - 2 : 연산된 데이터 수집 작업자
  3. 개발 생활 - 3 : 전자우편 알림 서버
  4. 개발 생활 - 4 : Python과 Django 강의
  5. 개발 생활 - 5 : 공부 자료
  6. 개발 생활 - 6 : 앞으로 계획

2. 개발 프로젝트

2-2. 연산된 데이터 수집 작업자

개요

부동산 다이렉트의 데이터베이스엔 사무실이나 건물 정보가 많이 등록되어 있다. 각 사무실과 건물을 구성하는 속성도 세세하게 등록되고 관리되어, 많은 검색 조건을 조합해 고객이 원할만한 매물을 검색한다.

그런데 검색 조건 중에는 별도 연산이 필요한 조건이 있다. 예를 들어, 300평짜리 사무실을 찾는다고 했을 때, 면적만 놓고 보면 다음과 같은 경우가 가능하다.

  • 단일층 : 단일층이 300평인 경우.
  • 연층 또는 연속층 : 층이 분할되어도 되지만 층은 연속되어야 하는 경우.
  • 복수층 : 여러 층이어도 되고 층이 꼭 연속되지 않아도 되는 경우.
  • 분할층 : 한 층을 여러 사무실로 분할하였고, 이 중 1개 이상 사무실이 다른 층과 연속되는 경우.

복수층이나 연층, 분할층인 경우, 몇 개 층까지 분할이어도 되는지, 즉 2~3개인데 연속층으로 총 300평인 사무실을 찾는 검색 조합도 가능하다.

내가 두 번째로 맡은 프로젝트는 바로 연층이나 복수층을 빠르고 정확하게 검색하는 기능을 구현하는 것이었다. 이런 검색 기능은 SQL Query만으로 처리하기에는 연산 비용이 크다. 나는 SQL Query만으로 이런 검색을 아주 빠르게 처리하는 방법을 모른다.

개발 환경

내가 이 프로젝트를 맡겠다고 한 이유는 특정 검색을 위해 미리 계산된 데이터를 구축하고 이 구축한 데이터 안에서 검색하면 쉽고 빠르겠다는 생각이 떠올랐고, 이를 직접 실현하고 싶었기 때문이다. 결자해지.

사무실이나 빌딩은 분야 특성상 데이터가 생성되거나 수정되는 빈도가 높진 않다. 더구나 부동산 다이렉트는 사람이 일일이 사무실이나 건물을 확인하여 허위 매물을 걸러내기 때문에 데이터 변화 빈도가 아주 높진 않다. 빈도가 높지 않다라는 말은 초 단위로 데이터가 수 백 수 천 개가 쌓이거나 변경되진 않는다는 뜻이다.

이런 이유로 애초에 개발 언어는 Python으로 결정했다. DBMS 외 부분에서 연산을 Python이 하더라도 충분히 빠르고, 개발 생산성이 좋고, 무엇보다 내게 익숙한 언어이기 때문이다.

  • 언어 : Python 2.7
  • 사용 라이브러리, 프레임워크

schedule은 Linux나 Unix의 crontab 역할을 하는 라이브러리이다. CeleryPeriodic Tasks 기능을 채택할 지 고민하기도 했지만 일정 주기로 작업자를(worker) 호출해 정해진 작업(task)을 수행하는 정도에 쓰기엔 거창하다 생각했다. 직접 구현하기에도 간단한 기능이지만, 많은 사람이 이미 사용하여 검증된 schedule 라이브러리를 도입했다.

개발 과정

이 프로젝트는 시작부터 확장성과 이전성(migration)을 고려했다. 이전 가능성은 Python 3로 이전하는 걸 염두에 두는 것이고, 확장성은 연층 검색 뿐만 아니라 다른 검색 조건에 대응하는 작업(task) 처리 구조로 만드는 것이다.

프로젝트는 검색 편의를 돕는 역할이어서 편의점(convenient store)이라고 이름지었다. 그리고 각 구성물도 편의점과 연관된 용어를 썼다. 로직을 상상할 때 실제 사람이 매장에서 일하는 모습이 떠올라 재밌어서 지루하지도 않고 개념을 다루기에도 좋았다.

  • 매장 : 검색 조건과 관련된 작업을 정의한 모듈(module).
    • 점원 : 검색 데이터를 수집하고 연산하는 작업자(controller).
    • 창고 : DB 관련 작업을 처리하는 모델(model).
    • 본사 직원 : 검색용 데이터를 쌓은 테이블에서 데이터를 검색하는 SQL Query 인터페이스. API Server용이며 Go 언어로 작성.
  • 매장 관리자 : 신규 매장이 등록되면 가동하고, 기존 매장이 변동되면 그 내용을 반영하여 재가동하는 프로세스 관리자.

난 연층 검색을 위한 데이터를 다루는 첫 번째 매장을 만드는 셈인데, 구조도 정해놓은 규칙으로 클래스와 설정 파일 등을 만들어 창고, 본사 직원으로 구성하고, 이 Python 패키지를 매장(store) 디렉터리에 넣는다. 매장 관리자는 곧 이를 감지하여 메모리에 적재하여 매장을 개장하고 영업을 개시한다. 그래서 코드가 변경되더라도 편의점 프로세서는 중단되지 않고 계속 작업을 처리한다. 즉, 검색봇을 쉽게 추가하고 변경하면서도 무중단 서비스가 가능한 설계를 구상했다.

이는 반만 구현해냈는데, 병렬 처리를 위한 구조로 만들지 않았기 때문이다. 게다가 신규 매장을 만드는 일 자체가 그 이후 없었기 때문에 결국 필요없는 작업을 하느라 일주일을 써버린 셈이 되었다. 소득이라면 연결과 분리(pluggable) 가능한 모듈을 어떻게 작성하고 구조를 잡아야 하는 지 고민하고 경험하여 다음 프로젝트에 도움이 됐다는 점이다.

여러 층을 조합하여 면적을 산출하여 DB 테이블에 데이터를 넣는다면, 어떤 조합까지 연산하여 DB에 넣을 것인지 고민해야 한다. 연층은 기본이다. A건물에 3, 4, 6, 8, 9, 10층 사무실이 공실인 경우,

  • 3,4층
  • 8, 9층
  • 9, 10층
  • 8, 9, 10층

이 조합을 뽑아내어 면적 등을 합해야 한다. 하지만, 연속되지 않은 복수층은 난감했다.

  • 3, 6층, 3, 8층, 3, 9층, 3, 10층
  • 3, 4, 6층, 3, 4, 8층, 3, 4, 9층, 3, 4, 10층, …

연속되지 않은 복수층은 이와 같이 조합 가능한 경우의 수가 무척 많다. 건물에 사무실이 많은 큰 건물인 경우 수 억 가지 조합이 발생했다. 회사에 등록된 전체 건물을 대상으로 계산해보니 수십 억 건이 넘는 조합이 나왔다. 각 조합이 DB의 항목(row)이므로 “오, 나도 좀 큰 데이터 좀 다루는 건가!?”하는 시덥잖은 생각이 들기도 했다.

실제로 갓 완공되어 20여 개 층이 임대 가능한 건물이 있었는데, 이 건물의 20개 사무실에 대해 조합 가능한 경우의 수는 263,644,104가지이다. 그리고, 공실이 30개 이상인 건물도 몇 개 있었다. 이런 경우(case)는 양이 너무 많아 시간이 오래 걸렸다. 느려서 Go언어나 C언어로 짜서 시간을 몇 십배 줄여봤지만, 수에는 어쩔 도리가 없었다. 결국, 연속되지 않은 복수층은 사전에 연산하여(pre-operated) 데이터 테이블에 넣는 방법 대신 매 질의(query) 때마다 연산하기로 했다.

건물이나 사무실에 변동이 생기면 이를 감지하는 건 DBMS의 Trigger 기능으로 구현하려 했다. 지정한 이벤트가 일어나면 관련 내용을 트럭에 쌓아두고, 점원이 트럭에 쌓인 작업물을 꺼내어 연산하는 식이다. 여기서 트럭이란 Task queue를 의미한다. 하지만, AWS RDS에서 운영 중인 DB에서는 Trigger가 동작하지 않았다. MySQL, Triggers and Amazon RDS 글에 따르면 가능하긴 한데, 내키진 않았다. 마침 그 시기에 데이터베이스와 관련된 문제, 다분히 사람의 실수로 큰 문제가 생길 뻔한 상황을 겪어서 운영 중인 데이터베이스에 안전한 설정이라도 변화를 주기 부담스러웠다.

그래서 일정 시간마다 정보가 변경된 사무실을 검색하고, 있으면 그 사무실이 속한 건물의 검색 대상인 사무실들을 모두 가져와 연층을 연산하는 방식으로 구현 방법을 바꿨다. 건물에 속한 검색 대상 사무실을 모두 가져오는 이유는, 기존엔 연층 조합인 사무실인데 이 사무실이 계약되어 더이상 공실이 아닌 경우, 이 사무실과 연층으로 연결된 조합을 끊어야 하기 때문이다. 만약, 3, 4, 5층, 4, 5층 조합이 있는데 4층이 빠지면, 이 두 조합은 더이상 유효하지 않다.

조합을 찾는 알고리즘은 Python에 이미 내장되어 있다. itertools 모듈에 여러 함수가 있는데, 그 중에서 combinations를 사용하면 건물에 속한 사무실을 조합 가능한 모든 경우를 한 줄로 산출해낸다.

from itertools import combinations

_combinated = combinations(('a', 'b', 'c', 'd',), 2)

이 코드를 수행하면 _combinated[('a', 'b'), ('a', 'c'), ('a', 'd'), ('b', 'c'), ('b', 'd'), ('c', 'd')]이 이터레이션 객체로 할당된다. 편하긴 했는데, 같은 기능을 하는 코드를 Go언어와 C언어로 짤 때엔 알고리즘을 직접 구현해야 했다. 재밌긴 했지만, 알고리즘 문제(issue)는 어딘가 알고리즘스러운(?) 코드로 짜고 싶은 욕심이 생겨서 스트레스를 받게 된다. 왜냐하면 알고리즘 자체가 모든 걸 해결해주는 은총알이 아니기 때문이다.

이 조합(combinations)은 연속 여부와는 관계없이 조합하는 경우이다. 즉, 연층과 연속하지 않은 복수층 모두를 포함한다. 여기에서 연속층 조합은 따로 연산해야 한다. 그리고, 사무실이 언제나 한 층에 하나만 있진 않아서 다음과 같은 조합도 염두에 둬야 한다.

  • 3층 전체
  • 401호, 402호, 403호, 405호
  • 501호
  • 6층 전체
  • 8층 전체

이 경우, 단순히 4, 5, 6층이 아니라 401호, 5, 6층이나 401, 402호, 5층과 같은 조합도 연층으로 연산해야 한다. 그리고, 지하는 층 숫자가 -1, -2와 같이 음수로 입력이 되어 있어서, 지하 1층, 1층, 2층과 같은 연속층은 층 숫자만으로는 연속되지 않는다. -1, 1, 2가 되기 때문이다.

이런 저런 예외 상황을 고려하니 기존에 알려진 순열(順列, permutation)과 조합(組合, combination) 알고리즘으로는 내게 필요한 조합을 도출할 수 없었다. 촌철살인 같은 알고리즘으로 멋지게 문제를 해결하고 싶었지만 결국 2중 for문으로 일일이 조합을 연산하여 해결했다.

Python으로 검색용 사전 연산된 데이터를 탐색하고 구축하는 기능을 구현한 후, Go 언어로 작성된 API Server가 해당 DB 테이블에서 데이터를 질의(query)하는, 실제 검색하는 코드를 작성하는 단계로 넘어갔다. 수행은 다음 단계로 진행한다.

  1. 연층 데이터 테이블에서 요청받은 연층 조건에 해당하는 건물을 찾는다.
  2. 이 건물 목록을 대상으로 다른 검색 조건으로 검색한다. (예 : 보증금, 임대료, 24시간 개방 여부 등)
  3. 클라이언트에게 반환할 건물 항목마다 해당 건물의 연층 데이터를 추가한다.
  4. JSON으로 반환한다.

연층, 즉 사무실 연속 연결 관계를 미리 연산하여 각 조합을 DB 테이블에 넣어두었으니 당연히 1번 과정은 빠르게 처리돼서(평균 0.01 이하) 기존 검색 수행 속도에 별 영향을 주지 않았다. 만족스러웠다.

약 한 달 동안 개발했다. 1주일은 분석하고 조사하는 데, 1주일은 Python으로 개발하는 데, 10여일은 테이블 구조를 변경하고 재구축하는 삽질하는 데, 나머지 며칠은 Go 코드 작성하는 데 썼다. 이후 한 차례 정도 고생하고, 자잘하게 손 보는 일은 있었지만, 대체로 사고없이 동작하고 있다.

우여곡절

미리 연산한 데이터를 구축하는 기능을 만들다보니 데이터 집합을 재구성하는 데 시간이 많이 소요되는 문제가 몇 번 있었다. 조합 하나를 처리하는 데 0.5초가 소요되는데, 순수 연산 시간 자체는 얼마 안 걸리고 대부분 DB I/O에 시간이 소요된다. 사무실이나 건물에 변동이 생겨서 해당 건에 대해서만 연층 정보를 DB에 반영하는 건 양이 많지 않아서 몇 초에서 몇 분 안에 처리가 끝나지만, 전체 연층 조합을 처음 구성하는 초기화 또는 전체 재구성을 하는 경우엔 시간이 너무 오래 걸렸다. 실제로 사소한 산술 오류가 생기거나 테이블 구조가 변경되어 전체 데이터를 재구성하는 경우가 몇 번 있었는데, 그때마다 몇 시간씩 재구성이 끝날 때까지 기다려야 했다.

DB Commit 시기를 조정해보며 어떡해서든 DB I/O 시간을 줄이려 했지만, 병렬 처리를 하지 않는 이상 어쩔 수 없었다. 더도 말고 CPU Core 수만큼만 병렬 처리해도 시간은 크게 단축됐다. 하지만, 이번에도 나는 코드를 병렬 수행을 염두에 두지 않고 작성했다. 정확한 계산을 하려고 각 연산 과정을 잠가서(locked) 수행했고, SQLAlchemy도 처음 사용하다보니 세션이 꼬여서 DB 연결에 문제가 발생하기도 했다. 이 일을 계기로 동시성과 병렬성 문제를 직접 체험하였고, 이론으로 접하던 상황이나 해결책을 좀 더 이해하게 되었다. 역시 게임 규칙에 걸려서 얻어 맞으면 곧바로 게임 규칙을, 적어도 내게 고통을 야기하는 게임 규칙만큼은 빠르게 이해하는 법이다.

연속되지 않은 복수층을 검색하는 기능은 예상보다 힘들고 어려웠다. DB에 모든 조합을 넣기엔 양이 너무 많아서 그때 그때 연산을 하려 했는데,

  1. 산술 연산이 생각보다 까다롭고
  2. 이 산술 연산을 SQL Query문으로 표현하는 것이 어려웠다.

예를 들어, 두 개에서 여덟 개 층으로 250~400평에 해당하는 건물을 찾는다고 가정해보자. 32평씩 여덟 개 층 조합도 조건을 만족하고, 200평 두 개 층도 조건을 만족한다. 즉, 최소, 최대 층 개수와 최소, 최대 면적을 이용하면 된다.

문제는 여기에 다른 조건이 추가되는 것이다. 이 조건에서 월 고정비가 2,000~3,500만원을 추가하면 연산이 복잡해진다. 실제로는 여기에 그치지 않고 보증금 조건도 추가된다. 조합이 다(many) 대 다(many) 대 다(many)로 연결되는데, 각 요소가 교집합(AND)이 아니라 차집합 연산도 필요하다. 교집합으로만 연산하면 검색 결과가 아주 적어지는데, 최대면적/최소층개수, 최소면적/최대층개수 등 그룹 중 어느 하나라도 조건을 만족하지 않으면 연관된 조건 전체가 성립되지 않기 때문이다. 즉, A건물이 32평씩 여덟 개 층이 있고 200평 두 개는 없으면 이 건물은 검색 대상이 돼야 하는데, 모든 조건을 교집합으로 연산하면 A건물은 검색 대상에서 빠지는 것이다. 그렇다고 이 둘을 합집합(OR)으로 연산해서도 안 된다. 보증금이나 월 고정비와 같은 다른 조건식과 조합할 때 조합 경우의 수가 너무 많아져서 연산하는 데 시간이 오래(몇 배에서 몇 십 배) 걸린다.

이를 의사(pseudo) 코드로 작성했을 땐 비교적 간단하게 답을 찾았다. 문제는 SQL Query로 표현하는 것이었다. 몇 시간을 끙끙댔지만 결국 제대로 동작하는 Query문을 작성하지 못했다. 다행히 동료 개발자가 한 시간도 안 되어 문제를 해결해 주었다. Union을 사용했는데, 생각보다 빠르게 수행되었다.

여러 검색 조건이 조합된 복수층 검색을 구현할 때 겪은 어려움 중 하나는 검색 결과가 유효한 것인지를 판단하기 어렵다는 점이었다. SQL Query문을 조금 고치자 검색된 데이터가 달라졌는데, 매번 일일이 제대로 검색된 것인지 데이터를 확인할 수는 없었다. 하지만, 제대로 된 테스트 케이스를 만들지 않아서 “할 수는 없는” 그 일을 실제로 해야만 했다. 면적 조건은 만족하고 보증금 조건도 만족하고 월 고정비도 만족하는 것 같은데, 자세히 살펴보니 월 고정비에서 몇 만원 차이로 조건을 만족시키지 않는 경우가 있다. 이는 암산으로 유효한 결과인지 검증해서는 안 된다는 걸 뜻한다. 그렇다고 하나 하나 계산기를 두드리기엔 사람은 너무 느리다.

검색된 결과에서 조건에 해당하지 않는 데이터를 찾는 건 그래도 낫다. 검색 결과에 포함되지 않는 수많은 데이터는 막막하다. 검색 결과에 포함돼야 하는데, 포함되지 않은 경우 대체 어떻게 이 사실을 알아내야 할까? 테스트 시나리오를 만들어서 검색될 수 밖에 없는 데이터를 대상으로 검색 조건을 돌리는 것이다.

테스트 케이스를 작성하지 않아 고통이 증폭되고, 도저히 견딜 수 없겠다 싶은 시점 직전에 모든 문제를 해결했다. 아마 느리디 느리고 부정확한 사람의 연산 능력으로 검색 결과 데이터를 검증하는 일을 몇 번 더 했더라면 진행하던 일을 중단하고 테스트 케이스부터 작성했을 것이다.

정리

목표치는 모두 달성했다. 이 프로젝트 이후 나는 Python 3에 정착하였고, Python 2.7용으로 작성한 소스도 비교적 간단히 Python 3용 코드로 전환된다. 불필요한 사양이 좀 있긴 하지만, 어쨌든 확장성과 유연성이 있는 설계와 구조로 동작한다.

이번 프로젝트를 진행하면서 전업 프로그래머가 된 이래 비로소 내가 하고 싶은 일과 만들고 싶은 일을 어떻게 해야할 지 방향을 잡았다. 이 프로젝트를 마치고 나는 곧바로 수학 공부를 중등 과정부터 다시 시작했으며, 알고리즘과 데이터 구조, 프로그램 구조 공부를 시작했다. 비동기와 병렬성, 그리고 데이터 처리도 물론 내가 공부할 분야이지만, 일단은 좀 더 여유를 두고 파기로 했다.

기능에 대한 유닛 테스트가 필요하다는 건 생각만 했지 실제로는 잘 실천하지 않았는데, 데이터를 다루면서 유닛 테스트가 반드시 필요하다는 걸 절실히 느꼈다. 데이터 연산에 사소한 연산 변화를 주더라도 예측을 크게 벗어나는 경우가 많았고, 이를 사람이 직관으로 검증하는 건 효율이 대단히 떨어진다.

기계가 할 일을 사람이 해서는 안 된다. 기계가 할 일은 기계가 잘 하도록 맡겨 두고, 사람은 사람이 잘하는 일에 집중해야 한다.


개발 생활 - 1

지난 8월 중순부터 취미로 하던 프로그래밍을 전업으로 하고 있다. 그동안 세 개 제품을 투입(release)했고, 하나는 비공개 시험 운영 중이며, 약 두 달 동안 강의를 하기도 했다. 지난 6개월을 돌이켜 본다.

  1. 개발 생활 - 1 : PDF 문서 생성 서버
  2. 개발 생활 - 2 : 연산된 데이터 수집 작업자
  3. 개발 생활 - 3 : 전자우편 알림 서버
  4. 개발 생활 - 4 : Python과 Django 강의
  5. 개발 생활 - 5 : 공부 자료
  6. 개발 생활 - 6 : 앞으로 계획

1. 전업 결심

2014년 7월에 프로그래머로 전직을 결심했지만 막막하기만 했다. 여러 언어와 도구, 분야를 검토한 끝에 Python을 주 프로그래밍 언어로, 관심을 두고 꾸준히 다룰 언어로는 Go 언어를 정했다. 기준은 내가 하고 싶고 만들고 싶은 것에 필요하고 내 취향이나 성향에 맞는 지 여부이며, 급여 등은 판단 기준에 넣지 않았다.

그러다 8월 중순에 부동산 다이렉트라는 회사에 서버 프로그래머로 합류했다.

2. 개발 프로젝트

2-1. PDF 문서 생성 서버

개요

이 프로젝트는 서버에 저장된 데이터를 제안서로 만들고, 이 제안서를 PDF 문서로 생성하는 기능을 만드는 것이었다. 기존에 Go 언어로 만들어 사용하는 서버가 있었는데 다음 요구사항을 구현해야 했고, 이 기능 구현을 내가 맡은 것이다.

  • 요구사항
    • 여러 제안서를(PDF 파일) 하나로 묶어서 PDF 파일 하나로 만든다.
    • DB에서 직접 데이터를 가져오던 기존 방식 대신 API Server에서 REST API로 데이터를 가져온다.
  • 내가 정한 추가 목표치
    • 웹 페이지에서 PDF 생성 버튼을 누르면 응답 화면을 먼저 보여주고, PDF 생성이 완료되면 따로 알려주어 사용성을 높인다.
    • 유연하고 빠르게 제안서 양식에 변화를 주어 생산성을 높인다.
    • 3주 안에 개발을 끝낸다.
개발 환경

제안서는 꽤 복잡한 구조(layout)였고 Google Map API도 사용해서 Javascript를 처리해야 했다. wkhtmltopdf는 이런 복잡한 HTML를 PDF로 변환하는 데 가장 적합한 도구이다. Webkit 렌더러를 이용하여 웹브라우저가 HTML를 렌더링하는 방식으로 HTML 문서를 출력하고 이 문서를 PDF로 변환하기 때문에 우리에게 필요한 기능을 모두 제공했고, 이미 개발팀에서 사용하고 있기도 했다.

하지만, wkhtmltopdf엔 PDF 파일들을 하나로 합치는 기능이 없다. 여러 도구를 살펴봤는데 Python용 라이브러리인 PyPDF 2가 적합했다. 그리고 내가 정한 추가 목표치를 달성하는 데에도 Python이 가장 적합했다. 그래서 PDF 문서 생성 서버는 Python을 이용해 새로 만들기로 결정했다.

  • 언어 : Python 2.7
  • 사용 라이브러리, 프레임워크
개발 과정

막상 개발에 착수하자 시작부터 문제에 봉착했다. PyPDF 2는 wkhtmltopdf로 만든 PDF를 두 개 이상 병합하려 하면 __WKANCHOR__ 관련 오류가 발생하였다. wkhtmltopdf로 만든 PDF에 형성한 앵커의 ID가 PDF 마다 동일하게 만들어져서 이를 합칠 때 중복되어서(고유하지 않아서) 발생하는 문제 같다. wkhtmltopdf나 PyPDF 2 소스를 고쳐서 문제를 해결하려 했으나 시간이 여의치 않았다. 나는 여러 제안서를 HTML 문서 하나로 합치고, 이 합친 HTML 문서를 PDF로 변환하기로 했다. wkhtmltopdf에 여러 HTML 문서를 PDF 문서 하나로 병합해 생성하는 기능이 있긴 했으나, 결과물인 PDF 외양(layout)이 깨지는 문제가 발생하였다. 그리고, 백 개가 넘는 제안서를 하나로 합치는 상황을 터미널 콘솔에서 테스트하기 번거로웠다. 그래서 아예 하나로 합쳐진 HTML을 만드는 방법을 골랐다.

API Server에서 제공하는 REST API로 데이터를 가져오는 건 httplib2를 이용했다. 대세인 HTTP 라이브러리인 Requests를 사용하려 했으나 HTTPS 통신이 잘 되질 않았다. 구현을 마친 나중에 안 사실이지만 이 문제는 HTTPS 인증서를 내가 잘못 사용해서 발생한 문제였다.

API Server에서 가져온 데이터를 어떻게 구조화 할까 고민하다 Django의 ORM을 흉내 내기로 했다. Django의 Model은 다음과 같이 데이터를 데이터베이스에서 가져온다.

building = Building.objects.get(id=3, status='open')

print building.name

내가 만든 건 이런 모양이다.

building = Building.filter(id=3, {'status': 'open'})

print building.name

데이터베이스를 사용하지 않고 오직 REST API로 데이터를 가져오기 때문에 굳이 ORM처럼 만들 필요는 없었다. 데이터 모델 스키마는 API Server의 문서에 잘 나와 있기 때문에 문서 서버에서 모델을 정의하지 않아도 무방했다. Django ORM을 분석하면서 많이 배우긴 했지만 프로젝트 관리 관점에서 보면 초과 사양으로 보였다. 하지만, 자잘한 수정 사항이 매우 빈번하게 발생하자 그 나름대로 기준과 일관성이 있는 인터페이스 덕에 유연하게 대응할 수 있었다.

1차 요구사항을 구현하고 나서 사용성을 높이는 단계로 넘어갔다. PDF 생성은 적지 않은 시간이 소요되기 때문에 이용자가 웹 페이지에서 PDF 생성 버튼을 누르면 한참 기다려야 했다. 만약 100장짜리 제안서를 PDF로 생성하는 경우 30~40분을 기다려야 하며, HTTP/1.x 특성상 연결이 끊기거나(Timeout) 연결에 문제가 생길 가능성이 높아진다. 그래서 PDF 생성은 백그라운드에서 별도 작업자가 처리하고, 생성이 완료되면 푸시로 이용자에게 알려주는 기능을 구현하기로 했다.

Celery를 이용해 백그라운드로 처리하는 기능은 구현하긴 쉬웠다. 하지만, PyPDF 2의 PDF 병합 문제로 병합할 개별 PDF 문서를 분산해서 처리하지 않았기 때문에 비동기로 백그라운드에서 PDF 를 생성하는 역할로만 활용했다.

웹 브라우저가 웹 소켓을 열어 웹 소켓 서버와 연결을 유지하고, PDF 생성이 완료되면 서버가 클라이언트인 웹 브라우저로 푸시 알림으 보내는 기능도 간단히 구현했다. gevent 라이브러리를 사용하는 gevent-websocket 라이브러리를 이용하여 웹 소켓 서버를 만들었다. 처음엔 직접 구현하고 있었는데 때마침 열린 PYCON Korea 2014에서 정민영님이 발표한 제약을 넘어 : Gevent를 듣고 gevent와 gevent-websocket을 도입했다. 무척 간단했다.

from gevent import monkey
monkey.patch_all()

from geventwebsocket import (
    WebSocketServer,
    WebSocketApplication,
    Resource,
)

class TaskChecker(WebSocketApplication):
    # 작업 class
    pass

notifier_host = 'localhost'
notifier_port = 9999

WebSocketServer(
    (notifier_host, notifier_port),
    Resource({'/': TaskChecker})
).serve_forever()

서버가 백그라운드에서 PDF를 생성하는 긴긴 시간 동안 이용자는 자신이 받을 제안서 PDF를 웹 화면으로 먼저 접하게 된다. 미리보기(preview) 역할을 하는 셈이다. 이용자에겐 별다른 기능이 없지만, 실제로는 제안서 HTML을 만드는 중요 역할을 한다. 이 웹 서버는 Flask를 이용해 만들었다. 내겐 Django가 더 익숙했지만, 경량 프레임워크이면서도 문서 서버의 웹 서버에 필요한 기능은 충분히 포함하고 있었고, Flask에 기본 탑재된 템플릿 엔진인 jinja2의 문법이 Django의 템플릿 문법과 거의 동일해서 금방 적응했다.

Flask는 참 좋은 프레임워크이다. PDF 생성과 병합하는 기능을 분석하고 구현하는 데 예상보다 많은 시간을 써서 시간에 쫓기는 상황이다보니 Flask 문서를 거의 읽지 않고 Stackoverflow를 전전하며 개발했는데, 대부분 코드에서 기대하는 바가 의도하는 대로, 그리고 예상하는 대로 동작하였다. 구조도 명료해서 Flask 내부를 들여다 보기에도 좋았다.

우여곡절

이용자가 웹 브라우저에서 웹 소켓으로 푸시 알림 서버에 연결하면 이 알림 서버는 Redis 서버에 접속하여 Celery가 진행하고 있는 작업 상태를 추적한다. 작업이 끝나면(celery.AsyncResult(id=task_id).status == 'SUCCESS') 푸시 알림 서버는 해당 작업(PDF 생성)에 대한 알림을 요청한 클라이언트들에게 생성된 PDF 파일을 내려 받는 URL을 반환한다. 웹 브라우저는 이 URL을 받으면 화면에 PDF 다운로드 버튼을 출력하고, 이용자는 이 버튼을 눌러 제안서 PDF를 받는 것이다.

그래서 백그라운드 작업 처리 부분과 푸시 알림 서버를 연계해야 했는데, 테스트가 까다로웠다. 동시성이나 병렬성에 익숙하지 않다보니 지극히 직렬성 사고를 하여 코드나 로직을 직렬성을 전제로 작성했는데, 동기식 직렬성을 전제로 작성한 코드로 비동기 상황을 테스트 하려다 보니 상황을 재현하고 발생한 문제를 추적하기 용이하지 않았다. 작은 코드 변화만으로 작동 순서가 달라져 문제가 발생하기도 했다.

wkhtmltopdf가 만든 PDF가 HTML 문서와 다르게 만들어지는 문제도 해결하기 참 까다로웠다. wkhtmltopdf이 Webkit을 사용하긴 하지만, Webkit을 사용하는 구글 크롬이나 애플 사파리과는 다르게 작동하는 부분이 꽤 많았다. 렌더러 버전 차이도 있고, 웹 브라우저 벤더마다 따로 맞춘 설정(customized)이 문제였다.

글자 간격이나 틀(layout)이 틀어지는 건 그나마 간단했다. HTML DOM 요소들이 미쳐 날뛰듯이 뒤엉키는 문제는 해결하기 무척 힘들고 어려웠다. 이 문제가 오직 wkhtmltopdf로 생성한 PDF에서만 발생했고, HTML 문서와는 달리 PDF 문서는 요소를 검사(inspect)할 마땅한 방법이 없었기 때문이다. HTML과 CSS의 여러 속성과 항목을 하나하나 고치고 wkhtmltopdf로 PDF를 생성해 결과 화면을 보며 문제 원인을 추적해야 했다. 디버깅 할 때 중단점(breaking point)을 찍고선 진행 과정을 한 단계씩 추적하듯이, wkhtmltopdf가 PDF 문서를 생성하는 과정을 하나 하나 추적할 수 있었다면 얼마나 좋았을까. 또 다른 어려운 점은 이런 문제는 구글링을 해도 딱히 자료가 없다는 점이다.

몇 몇 문제는 도저히 재현되지도 파악되지도 않아서 wkhtmltopdf 컴파일 설정을 바꿔서 wkhtmltopdf를 다르게 빌드해보기도 했다. 몇 가지 문제는 wkhtmltopdf에 QT 패치를 해서 해결하기도 했다. wkhtmltopdf는 상당히 덩치가 큰 소프트웨어여서 컴파일하고 빌드하는 데 시간이 많이 든다. 인텔 i5 (2.2GHz quad core), 램 8기가, SSD 사양인 맥북 프로에서도 Clean build를 마치는 데 40여 분이 소요됐고, 이보다 훨씬 사양이 낮은 실 서비스 서버에서는 네 시간에서 다섯 시간이 소요됐다. 개발 중이어서 서비스 서버 사양을 AWS t2.micro로 낮게 설정했기 때문이다. 힘들게 개발 환경인 Mac OS X에서 wkhtmltopdf 컴파일 설정을 맞췄는데, Redhat 계열 Linux인 AMI에서는 다르게 동작해서 다시 컴파일을 하기도 했다.

마지막으로 고생했던 점은 Go로 작성된 기존 문서 서버 코드를 분석하며 새 버전을 만든 점이다. 당시 개발팀은 중요 일정을 맞추려고 밤낮으로 고생하고 있었다. 그에 비해 나는 비교적 일정과 구현에 대해 배려받고 있었고, 기능만 놓고 보면 내가 맡은 부분은 중요도가 높진 않았다. 그래서, 가능한 한 다른 개발자를 방해하지 않고 기존 코드를 보며 알아서 해결하려 노력했다. 이미 운영 중인 문서 서버의 Go 소스 코드를 몇 번이고 읽으며 의도를 이해하려 했지만, 기존 문서 서버는 DB에 직접 연결하여 데이터를 다루는 등 동작 방식이 전혀 다른데다 객체 지향 언어가 아닌 Go로 작성된 코드여서 Python으로 코드를 작성하는 데 혼란을 야기했다. 즉, 맥락 전환(context switching)에 비용이 많이 든 것인데, 당시엔 전혀 염두에 두지 않았던 상황이다.

코드로는 의도나 목적을 이해하여 초기 구현은 빨리 마쳤다. 기존 것과 동일하게 동작하면 됐기 때문이다. 하지만, 나는 새로 만드는 버전을 맡은 것이고, 새 버전의 제안서는 기존 제안서와 꽤 달랐다. 이런 문제들은 출시(release)하고나서 운영할 때 유지보수에 혼란을 일으키는 요소가 된다. 정확한 기능의 의도와 목적을 이해하지 않은 상태에서 구조와 설계를 잡고 구현했기 때문이다. 다행히 초과 사양으로 구현하느라 일정 초기에 고생스러웠던 추상화 부분들, 그리고 기획자의 꼼꼼한 지원 덕에 서비스 적용(release) 후 유지보수에 큰 난관이 일어나진 않았다.

정리

목표로 했던 것보다 2주 더 걸린 5주 만에 개발을 마쳤다. Python 2.7을 사용한 걸 제외하면, 사용한 라이브러리나 프레임워크 등 대부분을 처음 사용하는 경우였다. 도구 뿐만 아니라 개발 메카니즘이나 구성도 생소했다. 이용자가 실제로 접하는 기능과 동작은 단순하기 그지 없는데, 실제 구현은 까다로운 부분도 많았다.

당시엔 밤엔 공부하고 실험하고 출근하면 밤에 쌓은 자산을 적용하여 구현하는, 말 그대로 주경야독 시기를 혹독하게 보냈다. 과의욕 상태에서 불필요한 설계를 하거나 구현하여 고생을 자초하기도 했다.

많이 학습하고 부족한 부분을 찾은 과정이었다.