개발 생활 - 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용 코드로 전환된다. 불필요한 사양이 좀 있긴 하지만, 어쨌든 확장성과 유연성이 있는 설계와 구조로 동작한다.

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

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

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