← Home

아이즈원 프라이빗 메일 백업의 계보 - 3편

#비동기 #동시성 #병렬성 #차단 #이진 탐색
By 탐정토끼(Taehee Kim)
↑ 맨 위로

아이즈원 프라이빗 메일 백업의 계보 - 1편

아이즈원 프라이빗 메일 백업의 계보 - 2편

지난 이야기

아이즈원이 해체를 발표하고, 팬덤인 위즈원들은 아이즈원 팬 전용 SNS인 프라이빗 메일을 보존하고 백업하려 했습니다. 여러 개발자 분들은 프록시와 이어받기 방식을 이용해 Access Token을 확보하고 백업을 시도했습니다. 백업은 점점 편하고 단순해져갔지만, 아직 속도 문제가 남아 있었습니다. Python Requests 라이브러리로 만들어진 초기 백업 툴은... 8000여개가 넘는 메일을 다운 받는데 1시간이 넘는 시간이 걸렸습니다.

음. 이게 파이썬이라서 느린 걸까요? 옆집 자바 개발자님도 네이티브 자바 코드로 다운로드를 받으셨지만, 여전히 오랜 시간이 걸렸습니다. 그 이유는 언어가 느려서가 아니었습니다. 문제의 원인은 이미지를 다운 받는 동안의 네트워크 지연... 차단(Blocking)에 있었으니까요.

오늘은 비동기를 이용해 백업 시간을 Python에서도 8분, Go에서 1~3분으로 단축했던 이야기를 해보려 해요.

동기(Sync)와 차단(Blocking) 문제

만약 당신이 웹 서버와 같은 네트워크 앱을 만든다면 CPU가 병목을 일으킬 확률은 매우 적습니다. 당신이 만든 웹 서버가 요청을 처리할 때, 웹 서버는 데이터베이스 쿼리, Redis와 같은 캐시 서버로 보내는 네트워크 요청을 만들어냅니다. 데이터베이스, Redis와 같은 서비스는 빨라도 이 서비스들로 보내는 네트워크 요청은 느립니다. 특정 작업에 따른 속도 변화에 대한 매우 좋은 글이 있습니다. 이 글에서 저자는 CPU 사이클 시간을 더 쉽게 이해할 수 있게 사람들이 사용하는 시간으로 변환해서 보여줍니다. 한 번의 CPU 사이클이 1초와 같았다면, 캘리포니아에서 뉴욕으로 보내는 네트워크 요청은 거의 4년과 맞먹습니다.

[번역] 네 Python은 느립니다, 하지만 저는 신경쓰지 않습니다

!성능!은 생각보다 복잡한 문제입니다. 단순히 계산이 느리거나, 인터프리터 언어라서 느린 게 아니거든요. 일단 동기(sync)식 코드가 뭔지 알아보고, 차단(Blocking)이 왜 성능을 저하시키는지 이야기를 해보겠습니다.

동기는 요청을 하면 결과가 나올 때까지 기다리는(wait) 코드를 이야기합니다. 더 정확히는 호출한 함수가 제어권을 돌려줄 때까지 기다립니다. 우리가 프로그래밍을 처음 배우는 방식이기도 합니다. 계산을 하면 즉시 결과가 나오고요. 좀 시간이 걸리는 계산도 기다리면 끝입니다. 동기식 코드는 실행되는 순서를 이해하기 쉽습니다. Python이나 Java에서는 이런 동기식 코드가 주류입니다.

# 서버에서 데이터를 가져옵니다
const total_visitors_n = requests.get('https://my.server.com/api/total-visitors-number/', auth=('user', 'pass')).json()
# 데이터를 가공하고
const message = f"오늘 방문자 수는 총 {total_visitors_n}명 입니다."
# 사용자에게 보여줍니다
print(message)

문제는 보통 외부와 통신하는 IO, 특히 네트워크 통신에서 생깁니다. 프런트엔드라면 API에 HTTP 요청을 날리거나, 백엔드라면 DB에 데이터를 질의(query)하는 경우가 있겠습니다.

네트워크 응답을 기다리는 동안 내 코드는 뭘 할 수 있나요? 아무 것도 할 수가 없습니다. 이렇게 실행이 차단(Blocking)되서 아무 것도 못하는 게 프메 백업의 병목(Bottle Neck)이었습니다. 프메 백업은 API 서버와 CDN에 메일 목록과 파일을 요청하고 저장하는 게 대부분입니다. HTML에서 주소를 로컬 파일 주소로 바꾸는 약간의 작업을 하긴 하죠.

프메 백업의 실행 과정을 적어보면 이런 식이었습니다.

  1. 이미지 한 개를 요청합니다.
  2. 기다립니다.
  3. 기다립니다.
  4. 이미지를 받으면 파일로 저장합니다.
  5. 다음 이미지를 요청합니다.
  6. 기다립니다.
  7. 기다립니다.
  8. ...

이미지 한 개를 다운 받는데 0.1초가 걸린다고 해도, 8000개의 메일과 1만 6천 장의 이미지를 다운 받으려면 2400초... 40분이 넘게 걸립니다. 실제로는 1시간 넘게 걸렸으니 하나에 0.2초 정도 걸린 모양입니다.

Python이던 Java던 간에 기다려야하는 건 마찬가지니까. 자바라고 더 빠를 수가 없었던 것이죠.

병렬(parallelism) 처리

한 가지 해법은 멀티 코어를 이용한 병렬처리였습니다. 쓰레드 같은 걸 이용하면 코어가 4개라면 4배 빠르게 백업할 수 있겠죠. 한 번에 4개씩 하니까요.

하지만 병렬 처리에도 문제가 있습니다. 일단 코드가 복잡해집니다. 여러 개의 코어에게 일을 나눠줘야 하고, 일이 겹치거나 꼬이면 안 됩니다.

API 서버는 매우 짧은 시간에 요청이 한 번에 들어오면 크롤링이나 악성 코드를 의심해서 자동으로 차단하기도 합니다.그래서 자바 개발자님은 멀티 쓰레드를 시도했지만... 잘 되다가도 중간에 계속 차단되거나 에러가 나는 사람들이 있어서 어려움을 겪으셨습니다.

무엇보다 병렬처리 만으로는 차단 문제가 해결되지 않습니다. 4개의 쓰레드를 쓰더라도, 4개의 쓰레드가 동시에 차단되고 기다릴 뿐이죠.

게다가 파이썬이나 자바스크립트처럼 기본적으로 싱글 쓰레드인 언어에서는 이런 솔루션을 적용하기 어렵습니다.

비동기(Async)와 동시성(Concurrency)

"Is concurrency better than parallelism?¶
Nope! That's not the moral of the story.
Concurrency is different than parallelism. And it is better on specific scenarios that involve a lot of waiting."

"그러면 동시성이 병렬성보다 좋은 건가요?
아뇨! 그건 이 이야기의 교훈이 아닙니다.
동시성은 병렬성과 다릅니다. 많이 기다려야하는 특수한 상황에서 동시성이 더 좋습니다. 그렇기 때문에 동시성은 보통 웹 앱 개발에서 더 좋습니다. 하지만 항상 그런 건 아닙니다."
FastAPI 공식 문서 : Concurrency and async / await

요즘 JS로 개발을 하시거나, 자바의 Spring WebFlux 혹은 파이썬의 FastAPI를 들어보신 분들은 '비동기'에 대해 들어보신 적이 있으실 겁니다. 도대체 비동기가 뭐길래 그렇게 비동기, 비동기하는 걸까요? 바로 앞에서 이야기한 차단 문제를 해결해주는 게 비동기입니다.

비동기는 작업의 결과를 기다리지 않는 방식입니다. 함수는 별도로 실행되게 하고 제어권은 그대로 쥐고 있습니다. 서버에게 데이터를 요청한다면, 데이터가 도착할 때까지 다른 일을 하고 있을 수 있습니다. 다음 요청을 미리 보낼 수도 있죠.

여기서 '동시성'이 등장합니다. 동시성은 병렬성과 비슷하지만 좀 다릅니다. 내가 한 번에 처리할 수 있는 일보다 많은 일을 '동시에'처리하는 게 동시성입니다. 병렬 쓰레드를 쓰더라도 한 번에 8개 코어 밖에 일을 못하지만. 서버에 1만 6천 개 이미지를 모두 보내달라고 '동시에' 요청해놓을 수는 있습니다. 그러면 이미지 데이터가 도착하면 하나씩 파일로 저장하면 되는 거죠.

이런 동시성은 멀티 쓰레드를 이용해서 구현할 수도 있습니다. 운영체제는 쓰레드가 IO 때문에 차단되서 놀고 있으면 다른 쓰레드로 변경합니다. 이런 걸 컨텍스트 전환(Context Switching)이라 하는데요. 운영체제 교재를 보신 분들은 프로세스를 스위칭하는 것보다는 쓰레드를 스위칭하는 게 빠르다고 알고 계실 겁니다.

하지만 쓰레드를 이용한 동시성은 한계가 있습니다. 쓰레드를 만들고 없애는 건 비용이 큰 일입니다. 쓰레드 하나는 메모리 1MB 를 점유한다고 하는데, 이러면 쓰레드가 5천 개만 되어도 5기가를 차지하게 됩니다. (저희는 메일 8천개 이미지 1만 6천 개를 처리해야합니다) 컨텍스트 스위칭 비용도 생각만큼 저렴하지 않습니다. 몇 천 개나 되는 쓰레드를 전환하는 건 아까운 일이고요. 그래서 보통 서버 프레임워크는 쓰레드 풀을 만들고, 쓰레드 개수에 제한을 둡니다.

음... 꼭 그래야 할까요?

코루틴과 Promise를 이용한 비동기 동시성

요즘은 비동기 전성시대라 할 수 있습니다. 대부분의 언어들은 더 가벼운 친구를 쓰고 있습니다. 경량 쓰레드라고도 부르는 코루틴(coroutine)이나, 프로미스(Promise), Future, Task 같은 친구들 말이죠.

이 친구들은 자체 이벤트 루프나 스케쥴러가 관리하는 가벼운 자료구조 같은 친구입니다. Golang에서 쓰는 고루틴은 2kb 정도의 스택 밖에 차지하지 않습니다. JS의 Promise는 이벤트 루프에 올라가는 자료구조일 뿐이고요.

이런 스케쥴러는 보통 Task Queue로 관리됩니다. 할일목록처럼 생각하시면 편합니다. 할일목록에 일을 동시에 잔뜩 올려놓고 하나씩 처리해나가는 것이죠.

Python 뷰어 내장 비동기 백업 툴

[백업이 빠른 남자] 비동기는 혹시
[백업이 빠른 남자] 오늘내로 되려나
...(다음 날)
[ㅇㅇ] 진짜 빠르더라구요ㅋㅋ

저는 당시에 FastAPI를 이용해서 프메 백업 뷰어의 서버를 만들었습니다. FastAPI는 파이썬 쪽 최신 서버 프레임워크로, 비동기 지원이 특징이었죠. 저는 Python의 AsyncIO와 httpx라는 라이브러리를 이용해, 백업툴의 동기식 코드를 비동기로 바꿨습니다. 다음은 동기식 코드와 비동기 코드 예시입니다.

# Old : 동기식
def fetch(self):
    if self.id == "":
        raise Exception("PrivateMail ID cannot be null")
    url = "https://app-web.izone-mail.com/mail/%s" % self.id
    res = pmGet(url).text
    # 이하 생략

def getPMList():
    pm_list = []
    skipped = 0
    idx = 1
    target = "https://app-api.izone-mail.com/v1/inbox?is_star=0&is_unread=0&page=%d"
    while True:
    whole_data = json.loads(pmGet(target % idx).text)
        print("[+] Fetching page %d" % idx)
        # 이하 생략


# New : 비동기식
# 함수 정의에 async 가 붙었습니다
async def fetch_mail(self, mail: Mail):
    if mail.id == "":
        raise Exception("PrivateMail ID cannot be null")

    url = "https://app-web.izone-mail.com/mail/%s" % mail.id
    # 호출할 때 await을 붙여줍니다
    res = await self.pm_get_text(url)

# 페이지에 있는 메일을 모두 요청합니다.
coroutine_list = [self.fetch_mail(pm) for pm in new_page if pm.id not in mail_to_body_dict]
# 모든 메일을 가져와서, HTML을 하나의 리스트로 모읍니다.
html_list = await asyncio.gather(*coroutine_list)

생각보다 간단한 작업이었습니다. 이렇게 간단한 변경만으로 1시간이 넘게 걸리던 백업이, 8분만에 끝나게 되었죠.

뒤에서 또 이야기하겠지만, 아직 한계는 있었습니다. 저는 한 페이지에 있는 메일 20장과 이미지 40여 개를 다 불러오고, 다음 페이지를 불러오는 식으로 만들었습니다. 한 번에 60개를 처리한다고 해도 await을 할 때마다 차단이 되는 건 다름이 없었습니다. 저는 이럴 수 밖에 없다고 생각했었는데, 마지막 페이지가 되면 멈춰야했기 때문에. 매번 has_next 다음 페이지가 있는지 확인하면서 진행했습니다.

Go 백업 툴 : 이진 탐색 + 동시 + 병렬

[Gopher] 1 2 4 8 16 32 24 28 26 27
[Gopher] 요런식으로 찾거든요
[Gopher] 27페이지가 끝이라면
[Gopher] 금방 끝장부터 찾고
[Gopher] 1부터 끝페이지까지 나머진 싹다 병렬처리라

21년 6월 3일 Go개발자님과 개인적 대화

Go 백업은 공식적으로는 3분. 좋은 인터넷 환경에서는 1분 만에 1.8기가에 가까운 프라이빗 메일을 모두 다운 받을 수 있었습니다. 인터넷 속도 한계에 가까운 속도를 낸 것이죠.

Go는 확실히 파이썬보다 빠른 언어입니다. 비동기 지원도 더 성숙하고 강력하고요. 하지만 이야기를 나눠보니 Go 개발자님은 더 신기한 알고리즘으로 이 문제를 해결하셨습니다. 바로 이진 탐색과 유사한 아이디어였습니다.

up down 게임을 아시나요? 어떤 사람이 생각하는 숫자를 맞추는 게임입니다. 저희도 마지막 페이지가 몇인지 알아야했습니다. 팬들마다 구독한 기간이 달랐기 때문에. 메일을 처음부터 다 받은 사람도 있고, 구독한지 몇 달 밖에 되지 않아서 메일이 조금 밖에 없는 사람도 있었습니다.

이진탐색은 생각보다 간단합니다. 내가 예측한 숫자가 실제보다 작으면 (= 마지막 페이지에 도달하지 않았으면) 2배를 곱합니다. 1 2 4 8 16 같은 식으로요. 그러다 넘어버리면 그 중간으로 갑니다. 예를 들어

이제 남은 건 Go의 모든 능력을 활용하면 됩니다. 고루틴은 가볍기 때문에 수 만 개의 고루틴을 띄울 수 있습니다. 27 페이지의 메일 목록을 한 번에 요청합니다. 그 다음에는 8000여개의 모든 메일 HTML 파일을 한 번에 요청합니다. 마지막으로 1만 6천여개의 모든 이미지 파일을 한 번에 요청하고 인터넷에서 다운받는 족족 저장해버리면...

백업이 끝납니다. 이렇게 1~3분이라는 최고 기록을 경신한 것이죠.

프메 서버는 어떻게 무사했을까? CDN

Amazon Simple Storage Service(Amazon S3)는 업계 최고의 확장성, 데이터 가용성 및 보안과 성능을 제공하는 객체 스토리지 서비스입니다. 즉, 규모와 업종에 상관없이 고객이 이 서비스를 이용하여 데이터 레이크, 웹사이트, 모바일 애플리케이션, 백업 및 복원, 아카이브, 엔터프라이즈 애플리케이션, IoT 디바이스, 빅 데이터 분석과 같은 다양한 사용 사례에서 원하는 만큼의 데이터를 저장하고 보호할 수 있습니다.

음, 대기록을 세운 건 축하할 일이지만. 걱정이 되기도 합니다. 크롤링은 백엔드 개발자에게 재앙입니다. 수 많은 사용자가 이렇게 대규모 크롤링을 시도한다면 서버가 죽지 않을까요? 한 개발자님은 너무 빠른 속도에 우려를 표하기도 하셨습니다. "한 번에 1만 개씩 요청을 보내면 누가 좋아하겠냐."고요.

다행히?도 프메 서버는 무사했습니다. 그 당시에는 저도 소송을 당하는 건 아닐까 걱정했는데. 생각해보니 별 거 아닌 이야기였습니다.

아이즈원 프라이빗 메일은 여러 번 말씀드린 것처럼 꽤 간단한 앱입니다. API 서버는 메일 목록만 보내주고요. 메일 본문인 HTML과 이미지 파일은 별도 파일 서버를 통해 전송됩니다. 즉 API 서버에 가해지는 부하는 몇 십 페이지의 메일 목록 요청 뿐입니다.

8000개의 HTML이니 16000개의 이미지는 CDN에 부하가 걸리는데요. 영상도 아니고 텍스트와 이미지인 데이터들의 총 용량은 1.6기가 밖에 되지 않습니다. 제 라즈베리파이 메모리에도 모두 올려버리고 캐시해둘 수 있는 용량입니다.

프메 앱은 AWS를 사용하는 걸로 알고 있는데요. AWS의 Cloudfront나 S3는 이런 일을 잘 합니다. 아마존은 더 많은 사용자들이 사용하고, 더 많은 부하를 겪는 게 일상입니다.

프메 서비스는 종료한 후에도 1달 동안 백업할 수 있는 유예기간을 줬습니다. 혹시 부하가 심하게 걸렸다고 하더라도, 서비스 종료 직전이니 서버를 증설하시지 않았을까 싶네요.

어쨌든 프메 서버는 종료되었고. 백업 툴을 만들기 위한 힘든 여정도 끝이 났습니다.

다음 회 예고

원래 매 주 한 편씩 쓰려했는데요. 좀 늘어졌습니다. 하지만 한 번 시작했고, 약속한만큼 끝을 내보려합니다.

아직 제가 만든 뷰어 이야기는 하나도 안 했습니다. 프메 뷰어를 만든 사람도 여럿 있었습니다. 제이쿼리, 안드로이드, 플러터, Go 서버... 심지어 오리지널 프메 앱을 그대로 복제한 복제 프메앱까지. 여러 종류가 있었습니다. 저도 Svelte라는 특이한 기술 스택을 썼는데요. 제 이야기를 중심으로, 프메 백업 뷰어가 겪은 이슈와 고민들을 더 풀어나가려 합니다.

다음 주에 월요일에 만나요~