개발 생활 - 3

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

2. 개발 프로젝트

2-3. 전자우편 알림 서버

개요

전자우편 알림 서버는 특정 사건(event)이 발생하면 관련된 사람에게 그 사건에 대해 알리는 역할을 한다. 가령, K와 C영업인이 한날이라는 고객을 담당하고 있는데, C담당자가 고객과 영업 관련 일정을 잡을 경우, K담당자에게도 이에 대한 내용을 전자우편으로 안내하는 것이다.

  • 요구사항
    1. 사건이나 상황(event)이 발생하면, 이와 관련된 담당자나 팀에 즉시 전자우편으로 그 내용을 보낸다.
    2. 비밀번호 찾기 등 고객 홈페이지에서 발생하는 안내나 통지 행위도 처리한다.
    3. 여러 상황에 처한 고객에게 상황에 맞는 전자우편을 자동으로 보낸다.
      • 예) 특정 기간 이상 접속하지 않은 고객에게 서비스 이용 안내와 매물을 추천
  • 내가 정한 추가 목표치
    • Python 3로 작성한다.
    • 프로그래밍 언어나 라이브러리, 프레임워크를 이전(migration)할 가능성을 염두에 두고 구조를 짠다.
    • PEP 8을 최대한 지킨다.
    • 병렬로 작업(알림 처리)을 수행한다.
    • Unittest를 작성한다.
    • 오류 내역을 효율성 있게 관리한다.
개발 환경
개발 과정

먼저 이름부터 붙였다. Mailer라는 비공식 이름이 통용되었지만 좀 더 일상과 대중에 친숙한 단어인 postman이라 프로젝트 이름을 붙였다. 보이지 않는 곳에서 나대지 않고 조용히 일하는 느낌이 들도록 머릿글자도 소문자로 표기했다.

작동 흐름은 간단했다.

  • 알림 전자우편
    1. 전자우편 발송 요청(request)을 API Server가 받으면 각 요청을 발송 대기함에 쌓음.
    2. 발송 작업자(worker)가 대기함에 있는 발송 대상을 가지고 와서 전자우편 발송 서비스에 전달.
  • 개인 또는 그룹을 대상으로 하는 소식지(newsletter)
    1. 일정 주기 마다 소식지 주제 별 수신자를 수집(build).
      • 예) 가입 후 일정 기간 동안 로그인을 안 한 이용자들에게 서비스 안내 전자우편 발송.
    2. 수신자 그룹을 전자우편 발송 서비스에 생성.
    3. 생성한 수신자 그룹으로 전자우편 발송.

SMTP 서버를 직접 구축하진 않기로 했다. 개발팀에서 프로그래머는 개발과 운영, 관리를 수행하고 있는데다 여러 개 제품이 이미 운영되고 있었기 때문에 되도록 관리할 대상을 줄여야 했기 때문이다. 그래서 전자우편 발송 서비스를 이용하기로 하고, 몇 개 업체를 검토한 끝에 Mailjet을 이용하기로 결정했다. API도 잘 만들어져 있고, SMTP를 제공하며, 이미 쓰고 있는 지인이 소개해주어 무료 서비스를 사용해봤는데 기대만큼 만족스러웠다.

또한, 고객 지원도 아주 빠르고 친절했다. 예를 들어, 무료 서비스는 한 달에 발송 가능한 전자우편 개수가 무척 적게 제한되어 있는데, 이 개수를 모르던 나는 몇 백 개째부터는 전자우편이 발송이 안 되고 대기 상태에 머물러있자 이것 저것 설정을 변경하며 방황하고 있었다. 그러자 놀랍게도 몇 십 분 후에 대시보드 페이지에 도움이 필요하느냐는 안내 버튼이 출력되었고, 이 버튼을 눌러 실시간 대화를 나누어 문제를 파악하고 처리할 수 있었다. 그때가 한국 시간으로 14~15시 경이었기에 꽤 놀랐다. 비용도 한 달에 30,000건 발송하는 정도는 한 달에 약 9 USD이면 되는 수준이라 바로 유료 전환했다.

postman 프로젝트는 여러 차례 구조를 세웠다 무너뜨리며 설계를 되풀이했다. 그렇다고 해서 처음부터 크게 구조를 잡은 건 아니고, 1차 버전은 특정 상황(event)이 발생하면 담당자에게 이에 대해 개별 전자우편을 보내어 알리는 기능만 개발하는 범위여서 확장하거나 변경될 여지를 두고 구조를 잡았다.

  • 발송부(sender)
    • 발송 서버에 연결하는 API
    • 일정 시간 마다 발송 대기함에서 대기 작업물 처리자(periodic worker)
    • 각 전자우편에 지정된 템플릿으로 전자우편 제목과 본문을 만드는 서식부
  • 발송 대기함에 쌓는 기능부
  • 수신자 구성부(builder)

Mailjet에서 REST API로 개별 전자우편을 발송하는 API를 제공했지만, 발송 서버에 연결하는 부분은 SMTP로 처리하였다. 이 방식이 HTTP로 연결하여 요청하는 것보다 좀 더 빨랐다. 쌓인 전자우편을 여러 개 보내야 하는 경우가 생기는데, HTTP/1.x는 매 요청마다 Mailjet 서버에 연결하고 끊기를 되풀이한다. 그에 반해 SMTP는 연결을 유지한다.

Python으로 SMTP는 smtplib 모듈을 이용하여 간단하게 다룬다. 다만, 웹에 있는 대부분 예제는 Python 2용이어서 Python 3.4에서는 문제가 발생하는데, 원인은 패키지나 모듈 배치가 바뀐 탓이다. 다행히 Python 공식 문서에 email: Examples라는 문서에 아주 자세한 예제가 나와있다.

편의점 프로젝트과는 달리 postman 프로젝트는 Celery를 이용하여 일정 시간마다 지정한 작업이 진행되도록 하였다. Periodic Tasks를 이용했는데, periodic_task라는 장식자(decorator)를 사용했다.

from datetime import timedelta

from celery.decorators import periodic_task

notify_staff_settings = {
    'notify_staff': {
        'run_every': timedelta(minutes=1),
    },
}

@periodic_task(**notify_staff_settings)
def notify_staff():
    pass

템플릿 엔진인 Jinja2Django의 템플릿 문법과 유사하고, 제안서를 PDF로 만드는 문서 서버를 만들면서 다뤄서 친숙했다. 다만, 템플릿 전체가 아니라 블록 단위로 내용을 가져오려고 하는 부분은 문서에 예제가 자세히 나오지 않아서 조금 애먹었다.

{% block subject %}
[공실] {{ building.name }}에 새로운 공실
{% endblock %}

{% block body %}
  {{ building.id }} - {{ product.id }}
  Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
{% endblock %}

이런 템플릿이 있는 경우, 전자우편 제목은 subject 블록에 있는 내용을, 본문은 body 블로에 있는 내용을 사용하는 것이다. 템플릿에서 전자우편 제목과 본문을 만들면 알림 전자우편 제목이나 본문 형식(format)이 바뀌어도 Python 애플리케이션 코드는 변동되지 않으므로 애플리케이션 서버를 재가동하지 않아도 되고, 템플릿이 읽히는 다음 작업 시기에 곧바로 변동 내역이 반영된다는 장점이 있다.

Jinja에서 템플릿을 렌더링하면 템플릿 컨텍스트(변수나 필터 등)가 반영된 최종 결과물 문자열이 반환된다. 나는 이걸 subject 블록 따로, body 블록 따로 다루고 싶었고, 당연히(?) Jinja로 이런 처리가 가능하다.

from jinja2 import (
    Environment,
    FileSystemLoader,
)

_template_engine = Environment(
    loader=FileSystemLoader(settings.TEMPLATE_PATH),
    trim_blocks=True,
)

_template = _template_engine.get_template('test.html')
_context = _template.new_context({
    'title': 'Hello world',
})

email_subject = ''.join(_template.blocks['subject'](_context))
email_body = ''.join(_template.blocks['body'](_context))

오류 내역은 벼르고 별렀던 Sentry를 도입하여 관리했다. 그동안은 로그를 쌓거나 출력하여 문제를 추적했는데, 로그가 쌓이면 문제를 추적하기 불편했고, traceback 정보가 제대로 나오지 않아 문제 파악도 힘들었다. Sentry 역시 전자우편 발송 서버와 마찬가지로 실 서버는 Sentry 회사가 제공하는 서비스를 이용하고, 개발 중에는 내 작업 PC에 직접 설치하여 관리했다. 사용법도 간단하다.

from raven import Client as RavenClient

raven_client = RavenClient(SENTRY_APIKEY)

@raven_client.capture_exceptions
def notify_staff():
    pass
우여곡절

Mailjet에서 제공하는 REST API로 개별 전자우편을 발송하는 과정에서 적지않은 시행착오를 겪었다. REST API를 다루려고 Requests 라이브러리를 사용했는데 자꾸 요청이 Mailjet 서버로부터 거절되었다. Curl을 이용하면 잘 작동하였다.

curl -X POST --user "$MJ_APIKEY_PUBLIC:$MJ_APIKEY_PRIVATE" \
    https://api.mailjet.com/v3/send/message \
    -F from='Miss Mailjet <[email protected]>' \
    -F [email protected] \
    -F subject='Hello World!' \
    -F text='Greetings from Mailjet.'

이는 HTTP에서 Post 방식으로 데이터를 보낼 때 컨텐트 타입을 multipart/form-data로 보내는 RFC 2388를 따르는 것이며, 이를 Requests 라이브러리를 이용하여 다음과 같이 처리한다.

_res = requests.post(
    'https://api.mailjet.com/v3/send/message',
    auth=(
        '$MJ_APIKEY_PUBLIC',
        '$MJ_APIKEY_PRIVATE'
    ),
    data={
        'from': 'Miss Mailjet <[email protected]>',
        'to': '[email protected]',
        'subject': 'Hello World!',
        'text': 'Greetings from Mailjet.',
    },
)

하지만, 무슨 이유에서인지 Mailjet쪽에서 발송을 거절했다. Postman이라는 HTTP 클라이언트로 보내도 잘 작동했는데, 유독 Requests로는 실패했다. 그래서 HTTP Header를 하나씩 까보니 Requests는 파일을 첨부하면 Curl 등 다른 소프트웨어와는 미묘하게 다른 HTTP Header를 만든다는 걸 발견했고, Mailjet은 이런 요청은 거부하는 민감한 동작을 했다. 어차피 SMTP를 이용해 보낼 계획이어서 SMTP로 직접 발송하여 문제는 해결했지만, 찝찝한 마음이 남았다. 현재는(2015년 3월 기준) 문제없이 발송된다.

Celery를 사용하는 인터페이스 부분을 추상화하는 과정도 뜻대로 되지 않은 부분이 많았다. 언제든지 Celery를 걷어내고 다른 라이브러리를 사용해도 문제가 없도록 패키지와 모듈 구성을 구성하였는데, 문제가 발생했을 때 문제를 추적하기 불편하였고 추상화 한 것에 비해 실제 Celery를 사용하는 부분 인터페이스가 많지 않았다. 결국은 Celery 인터페이스 이름만 바꾼 것에 가까운 반쪽짜리 추상화가 되고 말았다. 어설픈 추상화는 안 하느니만 못 하다.

Python 3를 사용하는 데엔 별다른 시행착오를 겪지 않았다. Python 3 기능을 그다지 깊게 사용하지 않기도 했지만, 지난 프로젝트부터 Python 3를 대비하고 준비한 게 도움이 됐다. 그리고, postman 프로젝트에 사용한 외부 라이브러리도 모두 Python 3를 지원했다.

PEP 8을 도입하는 것도 무난했다. PEP 8 검사에 사용한 Flake8의 Sublime Text용 부가기능(plugin)이 Sublime Text 2에서 잘 작동하지 않아 엉겁결에 Sublime Text 3로 이전한 것 빼고는 별달리 어렵거나 힘든 상황은 맞이하지 않았다. 한 줄을 80열 미만으로 코드를 작성하는 것을 제외하면.

Unittest는 만족스럽게 사용하진 못 했다. 딸 출산이 임박한 시기에 이르자 마음이 급해져 다시 원래 개발하던 방식으로 돌아가고 말았던 것이다. 결국 Unittest를 쓰지 않아 겪는 불편을 개발 막바지에 그대로 다시 겪었다.

Linux에서 프로세스를 관리하는 upstart용 프로세스 구동 스크립트를 작성하는 데 꽤 고생했다. 그동안 Linux의 init을 쓰거나 Python용 프로세스 관리 도구인 Supervisor를 써왔는데, 서버 프로세스를 관리하는 올바른 방법에 대한 글을 읽고 upstart를 사용하기로 했다. 그런데 AWS AMI에서 돌아가는 upstart는 상당히 오래된 버전이어서 웹에서 참고한 자료가 별 도움이 되지 않았다. 가령, uidgid, chdir 같은 명령어가 동작하지 않았다. 워낙 간단한 스크립트여서 무작정 시도했는데, 동작하지 않아 결국 로그를 찍으며 문제를 해결했다.

script
  exec >/tmp/postman_sender.log 2>&1
  exec sudo -u ec2-user /bin/sh -c "/.../postman_run.sh"
end script
정리

postman 프로젝트는 코딩보다는 설계와 추상화, 그리고 외부 도구 연계에 시간을 많이 썼다. 그동안 Celery나 SQLAlchemy 등 도구의 단편만 다뤘는데, 이번 프로젝트를 진행하면서 좀 더 깊게 들여다보고 시험해 보았다.

이번 프로젝트는 여러 모로 무척 바빠서 공부를 많이 하지 못 했다. 이 프로젝트에 사용한 기술의 하부 영역을 더 이해하려고 리눅스 시스템 프로그래밍(C언어)과 Python twisted를 공부하였지만, 다소 지지부진하게 진도가 나갔다.

Go 언어로 작성한 코드는 Go 언어에 좀 더 친숙해진 정도 성과를 거두었다. 워낙 간단한 코드이기 때문이다. 나중에 시간을 내어 발송할 전자우편을 구성(build)하는 부분과 발송 부분을 분리하여 발송 부분을 Go 언어로 재작성하면 좋을 것 같다.