앱 푸시 알림 서버 개발 (2)

Race Condition

앱 푸시 알림을 등록 API에 요청이 들어오면 알림 데이터를 생성하는데 이 때는 상태값이 대기중으로 된다. 그 다음에는 앱 푸시 payload와 대상자 정보를 퍼블리싱하게 된다. 컨슈머에서 처음 메세지를 가져오면 푸시 발송이 시작되므로 상태값을 진행중으로 바꾸고 마지막 큐 작업이 끝나면 상태값을 완료로 바꾸도록 해야한다.

시작과 끝을 알아야 하는데 이 부분에 대해 고민을 하다가 레디스를 이용해보기로 했다. 레디스의 키를 push#1 같은 포맷으로 알림 ID가 들어가도록 하고 퍼블리싱 할 때마다 INCR 명령어로 값을 하나씩 증가시키고, 컨슈머에서는 레디스에서 값을 가져와서 1일 때는 상태값을 진행중으로 바꾸고 푸시 알림을 보낸 후에는 DECR 명령어로 하나씩 줄인 후에 값이 0일 때는 마지막 작업이라 생각할 수 있으므로 상태값을 완료로 바꾸도록 했다. 처음 레디스 값을 가져올 때와 푸시를 보낸 후 값을 줄인 후의 값을 모두 로그에 추가하고 테스트를 해보았다.

내 의도와는 다르게 레디스 값이 1일 때 상태값을 바꾸는 동작이 여러 번 일어났고, 레디스 값이 0일 때도 마찬가지였다. 운영체제를 공부하면서 처음 들었던 경쟁 조건이 발생한 것이다. 경쟁 조건은 공유 자원에 대해 여러 프로세스가 동시에 접근을 시도할 때, 타이밍이나 순서 등이 결과값에 영향을 줄 수 있는 상태를 말한다. 경쟁 조건에 의해서 의도대로 되지 않는 상황을 시니어 개발자 분과 얘기를 하고 sleep()을 주는 방법을 테스트 해보았다. 적절한 위치를 찾기가 쉽지 않았고 테스트 데이터로는 의도대로 되는 듯 했지만, 실제로 사용할 때는 더 많은 데이터가 있을텐데 그 때도 확실하게 보장해줄 수 있을거라는 생각이 들지 않았다.

이 문제는 결국 레디스를 사용하지 않고 RDB만 이용하는 방법으로 해결했다. DB schema도 조금 수정해서 컬럼이 몇 개 추가되었다. 상태를 대기에서 실행중으로 바꾸기 위해 레디스 값이 1일 때를 확인하던 부분은 퍼블리셔에서 하도록 수정했다. 컨슈머에서 큐를 가져와서 푸시를 보낸 후에야 상태값이 진행중으로 바뀌는 게 정확하겠지만 퍼블리셔에서 컨슈머까지 짧은 시간 내에 진행되기 때문에 이렇게 수정하도록 했다. 푸시는 최대 1000건까지 보낼 수 있으므로, 푸시 대상자가 만명일 경우에는 10번에 나눠서 퍼블리싱하게 되어 있다. 컨슈머에서는 큐를 가져와서 바로 푸시 API를 호출하고 응답값에 포함된 success(푸시 발송된 수), failure(푸시 실패 수)를 DB에 업데이트 하고 그 수를 모두 대상자 수와 비교해서 같을 경우에 상태값을 완료로 바꾸도록 수정했다. DB 업데이트는 아래의 쿼리와 비슷하게 사용했다.

UPDATE sample_tb SET success_cnt + success, fail_cnt + failure WHERE id = 1;

Refactoring

앱 푸시가 보내지는 과정은 아래와 같다.

  1. 앱 푸시 등록(제목, 내용, 대상자 등의 정보 포함)
  2. API 단에서 푸시 데이터 DB 저장 & 푸시 데이터 퍼블리싱
  3. 컨슈머가 큐를 가져와 대상자에 대한 작업 & 대상자와 푸시 데이터 퍼블리싱
  4. 마지막 컨슈머가 푸시를 보내고 푸시 결과 로그 DB에 저장

앱 푸시 대상자는 csv로 올리거나 DB에서 전체 사용자, 전체 고수, 전체 고객을 선택할 수 있다. 푸시 대상자는 휴면회원, 탈퇴회원, 앱 푸시 동의 여부 등을 확인하고 광고성일 때도 광고 수신 동의 등을 하게 되는데, 해당하는 조건의 데이터를 한 번에 가져오게 되어서 시간이 정말 오래 걸렸다. 그래서 이 부분을 수정하기로 했다. 간단하게 대상자를 한 번에 가져오지 말고 페이지네이션 하듯이 가져오도록 수정했다. 사용자 ID의 최소값, 최대값을 가져온 후에, limit에 해당하는 숫자를 100부터 시작해서 느려가면서 최종적으로는 10,000개씩 가져오도록 수정했다.

ThreadPoolExecutor

테스트를 할 때는 대상자의 수가 적지만, 실제 앱 푸시를 보낼 때의 대상자는 많기 때문에 동시 처리를 고려하게 되었다. 동시성(concurrency)과 병렬성(parallelism)은 종종 함께 등장하곤 하지만, 세상에 같은 단어는 없기 때문에 온전히 같은 것을 의미하진 않는다. 동시 처리는 멀티 스레드를 이용하는 방법으로 동시에 여러가지 일을 하고 있는 것처럼 보이지만, 사실은 짧은 시간에 한 가지 일을 하고 빠르게 다른 일을 하는 작업이 반복되어 동시에 하는 것처럼 보인다. 병렬성이야말로 실제로 동시에 여러 일을 하는 작업으로서 이는 멀티 프로세싱을 이용해 이루어진다.

동시 처리 방법을 결정할 때는 CPU bound 처리인지 혹은 I/O bound 처리인지도 고려해야 한다. 암호화나 계산 등의 CPU 자원을 사용하는 처리를 하는 CPU bound 처리는 멀티 프로세싱을 이용한 병렬 처리를 하는 것이 더 좋다. DB에 접속하거나 네트워크에 의해 대기 시간이 발생하는 I/O bound 처리는 멀티 프로세싱, 멀티 스레딩 모두 유효하다. 파이썬은 비동기 처리를 위해 concurrent.futures 모듈을 제공한다. 그 중에 멀티스레딩으로 처리하기 위해서 ThreadPoolExecutor를 사용하기로 했다. 아래 예제를 통해 사용법을 간단하게 살펴볼 수 있다. 단순하게 어떻게 사용하는지만 살펴보기 위해서 func 함수에서는 계산한 값을 반환하도록 했다.

from concurrent.futures import ThreadPoolExecutor, as_completed


def func(a: int, b: int):
    return a + b


with ThreadPoolExecutor() as executor:
    result = [executor.submit(func, i, i * 2) for i in range(3)]
    for future in as_completed(result):
        print(future.result())
# 0
# 3
# 6

실제 앱 푸시 서버를 개발할 때는 csv로 전달된 사용자가 광고 수신, 휴면 회원, 탈퇴 여부 등을 DB에서 확인할 때와 아예 대상자 자체를 DB에서 가져와야 하는 작업을 할 때 비동기로 처리할 수 있도록 사용했다. 책으로 공부하고 예제로만 익혀보다가 실제로 업무에서 해볼 수 있어서 정말 좋은 경험이었다.

배포

처음 앱 푸시 서버를 개발하게 될 때 막막함이 앞섰는데 스스로 고민도 많이 하고 리드분 그리고 다른 팀원들과도 논의하고 써보고 싶던 기술도 써보고 여러가지로 내게 좋은 경험이 되었다. 이후에 사용하면서 나오는 이슈도 있을 수 있겠지만, 이 또한 해결하면서 많이 배울 수 있는 기회가 될 것이다.

[참조]

효율적 개발로 이끄는 파이썬 실천 기술