여느 때처럼 평화롭게 개발을 하던 어느날, 프로덕트의 dev 환경에서 502가 계속해서 발생했습니다. 지표도 정상이고 헬스 체크도 정상이고, 도대체 무슨 문제인지 긴가민가 했습니다. 로그를 확인 해 본 결과 처음보는 낯선 에러가 발생하더군요
요약하자면 Python WSGI 애플리케이션 서버인 uWSGI의 buffer-size 설정 값을 초과했기 때문이었습니다. 찾아보니 이 buffer-size는 요청 헤더의 사이즈의 설정 값이라고 하더군요.
Express를 사용했을 때를 떠올려보면 발생하지 않았던 문제였습니다. 요청 헤더가 커서 요청이 Drop된 적이 없었기 때문입니다.
이에 관련된 CS적인 지식은 기초 수준의 간단한 것입니다만, Django 진영의 웹 서버는 왜 이런 방식을 선택했는지 궁금해서, 기록 겸 포스팅을 남깁니다.
원인
위에서 정리했듯이, 원인은 HTTP 요청 헤더의 사이즈 초과입니다.
uWSGI 관련 공식 문서를 보면, 버퍼 사이즈는 매우 작게 설정되어있으며, 로그에서 이상 현상이 감지된다면 더 큰 사이즈로 변경이 필요할 수도 있다고 언급되어 있습니다. 말은 쉽지만 실제 환경에서는 충분히 문제가 발생할 수 있고 저 또한 겪었습니다. 핫픽스로 배포를 나가도 물려있는 CICD 때문에 10여분 넘게 다운타임이 발생할 수 있는 것이죠 (실제로는 서버 다운은 아니고 UX적인 다운타임이겠습니다.)
정확한 원인을 추적하기 위해 좀 더 깊게 파악해보겠습니다.
일반적인 Python 웹 서비스는 요청이 리버스 프록시(nginx)를 통해 uWSGI를 거쳐 Django로 들어오는 흐름입니다. 리버스 프록시는 클라이언트로부터 요청을 받아 uWSGI로 패킷을 전달 할 때 uwsgi 바이너리 프로토콜로 변환해서 보내게 됩니다.
# nginx 설정
location / {
uwsgi_pass unix:///tmp/uwsgi.sock;
include uwsgi_params;
}
uwsgi_pass를 쓰면 nginx가 HTTP 헤더들을 uwsgi 프로토콜의 key-value 쌍으로 변환하고, 이걸 하나의 uwsgi 패킷으로 직렬화해서 전송합니다. uwsgi 프로토콜 스펙 자체가 하나의 패킷을 전달 받아야만 하는 스펙으로 설계가 되어있기 때문입니다. uwsgi 프로토콜은 패킷 헤더에 datasize라는 변수를 16비트 정수로 담습니다.
패킷 헤더의 datasize가 뒤따르는 전체 데이터 크기를 나타내는 구조라서, 요청 메타데이터를 여러 패킷으로 나눠 보내는 개념이 없습니다. 그래서 수신 측인 uWSGI도 이 패킷을 고정 버퍼에 통째로 받는 구조가 됩니다.
즉, 버퍼 사이즈를 초과하는 요청이 들어오면 uWSGI는 Django에 요청을 넘기지 않고 Drop하게 됩니다. 서버가 죽은 것이 아니라 요청이 앱에 도달하기 전에 버려지게 되어 헬스 체크는 200인데 UX는 다운된 것처럼 보였던 것입니다.
Express
이전에 썻던 Node의 Express는 파싱 방식과 기본값에서 차이가 있었습니다.
Node의 HTTP 파서인 llhttp는 헤더를 고정 버퍼에 담지 않습니다. 들어오는 데이터를 바이트 단위로 스트리밍하면서, 누적 크기만 카운터로 추적합니다.
nodejs의 http parser
고정 버퍼에 통째로 담는 uWSGI와 달리 파싱하면서 카운터만 올리는 방식입니다. 그리고 결정적으로 기본 제한값이 16KB로 크기 때문에 대부분의 상황에서 발견하지 못했습니다. 더불어 uwsgi처럼 바이너리 프로토콜 변환 없이 raw HTTP를 직접 파싱하기 때문에 프로토콜 크기의 제약도 없습니다.
즉, Express를 사용했을 때는 같은 요청이여도 서버가 받아들이는 구조가 다르기 때문에 한 번도 겪어본 적이 없었습니다.
해결
원인을 알았으니 해결책은 명확하겠죠..?
근본 원인인 헤더가 커진 이유를 조사하기 시작했습니다. 짐작하시겠지만, 프로덕트가 커져가면서 구워지는(?) 쿠키도 많아졌습니다. 구체적으로는 A/B 테스트나 지표 추적을 위해 이벤트를 심는 양이 늘어남에 따라 쿠키가 같이 늘어났습니다. 디버깅 결과 전체 쿠키의 70% 이상이 해당 지표 추적을 위한 쿠키였고, 계속해서 늘어나고 있었습니다. 브라우저가 요청마다 이 쿠키를 전부 실어 보내는 구조였기 때문에 점점 헤더 사이즈가 커지면서 결국 터져버렸던 것입니다.
저희는 일단 버퍼 사이즈를 늘리는 것으로 선조치를 했습니다. 하지만 쿠키가 계속 늘어난다면 또 터질테니 근본 해결책은 아니겠죠.
CloudFront Origin Request Policy에서 쿠키를 필터링하거나 로컬 스토리지로 쿠키를 마이그레이션, 쿠키 도메인을 분리하는 등의 방안을 고려중에 있습니다. 각각 하나씩 간단히 살펴보면서 마무리하겠습니다.
CloudFront가 오리진(nginx/uWSGI)에 요청을 전달할 때 필요한 쿠키만 화이트리스트로 전달하도록 설정이 가능합니다.
브라우저는 쿠키를 다 보내지만 CF가 걸러줄 수 있도록 설정이 가능하다고 합니다. 프론트 코드 변경 없이 인프라 설정만으로 적용이 가능하지만, CF를 통하지 않고 직접 접근할 때는 효과가 없습니다.
서버에 보낼 필요가 없는 데이터를 쿠키에서 로컬 스토리지로 마이그레이션하는 방법도 있습니다.
이 방법은 서버가 읽어야 하는 쿠키는 이동이 불가능하고, 프론트 코드 변경이 필요할 수도 있습니다.
마지막으로 쿠키 도메인을 분리하는 방법입니다.
.example.com에 쿠키가 설정되면 모든 서브도메인에 전송됩니다. 이걸 api.example.com으로 분리하고 쿠키 도메인을 서브도메인 별로 격리하면, API 요청에는 해당 서브도메인 쿠키만 실리게 되겠죠. 가장 구조적인 해결이 될 것이라 생각하는데, 서드파티 스크립트인 GA나 이벤트 도구 등이 최상위 도메인에 쿠키를 설정해야한다면, 통제가 어려울 수도 있겠습니다. (아직 확인해보진 않았습니다.)
마무리
오랜만에, 정말 오랜만에 AI에서 벗어나 실제 문제를 좀 깊게 파볼 수 있는 시간이었습니다.
내가 어떤 조직에 속하게 되었을 때, 조직에서 관리하는 애플리케이션을 한 번씩 사용자 관점에서 돌아보고, 개발자 관점에서 돌아보고 문제점을 리스트업하는 습관이 있다. 이를 통해 당장의 애플리케이션에 대한 이해를 넘어서, 어느 정도의 주인의식과 우선적으로 해결해야하는 과제는 무엇인지 선정하는 연습(?)을 같이 하고 있다.
이 포스팅은, 속했던 조직에서 가장 먼저 개선해야한다고 판단했던 실시간 채팅 기능의 개선기이며, 2년차인 현재 시점에서 더 개선할 부분은 없었는지가 첨가된 포스팅이다.
모자란 내용에 혹여 더 좋은 의견 남겨주시면 성장에 큰 도움이 됩니다. 감사합니다!
문제 파악하기
속했던 조직은, 커머스 비스무리한(?) 서비스를 운영하고 있었지만, 도메인 특성상 결제는 곧 예약이었다.
결제 후 오프라인으로 상품을 직접 소비(?)하는 특징과 더불어 상품들이 우리가 자주 소비하는 필수 소비재들의 성격이 아닌, 특정 니즈에 따라 대부분 일회성으로 구매하는 상품들이기 때문에 결제 전/후로 채팅을 통한 상담이 서비스의 코어였다.
이런 핵심 기능인 채팅에서 응답 속도가 평균 3초정도로 매우 느리게 동작했고, 이는 시간을 갈아넣어서라도 반드시 해결해야하는 최우선 과제라고 판단했다.
최초에 파악했던, 채팅 전반의 플로우를 그림으로 나타내보았다. 메시지를 전송하면, 기본적인 메시지 관련 데이터베이스 I/O 작업과 더불어 메시지 전송에 처리되어야 할 모든 기능들이 함께 동기적으로 처리 되고 있었다. 근본적으로 응답 속도가 느릴 수 밖에 없는 구조였다.
더불어 아이러니하게도 이 실시간 채팅을 포함해 애플리케이션 내부에서 MyISAM 엔진을 사용하고 있었다. 동시성 제어를 위해 테이블 락 매커니즘을 사용하는 MyISAM의 특성상 쓰기 작업이 느릴 수 밖에 없었다. 여기저기 쓰기 작업을 하게 되는데, 메시지가 많아지면 많아질 수록 여러 테이블에서 서로 쓰기 작업을 위해 기다리는 현상이 기하급수적으로 늘어날 수 밖에 없다.
효과적인 테스트와 구현을 위해, 당시 최대 TPS를 산정해서 예상 최대 지점까지 고려했다면 어땟을까?
여기까지 생각이 미치지 않았다보니, 워커의 처리량보다 큐에 작업 유입량이 많을 때 어떻게 대처할지 등의 비동기 작업의 안전성을 보장하지 못했다고 생각한다.
비동기 처리를 위해
스토리지 엔진의 한계 외에도 단순 하나의 로직에 이리저리 얽혀있는 여러 비즈니스 로직들을 살펴보고, 메시지 전송 과정에서 반드시 수행되어야 할 로직과 아닌 로직들을 분리 했다. 메시지 전송이 성공했다. 라는 의미는 메시지를 저장하는 chat_message 테이블에만 입력을 보장하면 된다고 판단했고, 나머지 로직들을 전부 분리했다.
이 분리한 로직들을 다시 네 개의 구간들로 나눴고, 원자성을 보장해야하는 구간을 추가 DB 입력 구간인 추가 I/O와 업무 알림으로, 실패해도 괜찮다고 판단되는 부분들을 푸시알림과 SMS전송으로 구분했다.
메시지 전송 로직과 분리하여 비동기 처리를 수행하기 위해 BullMQ라는 메시지 큐를 사용했다. Kafka나 RabbitMQ 등도 Nest의 공식 문서에 UseCase등을 문서화해뒀는데 사용하지 않았다. 이들을 사용하기에는 발톱의 때 만큼의(?) TPS였다. 또 Nest에서 기본적인 Queue 사용문서 에 친절하게 언급되어있는 BullMQ의 실패 시 재시도와 스케줄링과의 연동, 이벤트 기반 처리와 이벤트 리스너를 통한 통합 로깅 등을 구현하기에 용이했기 때문에 BullMQ를 사용했다.
TPS를 산정했을 때, 최대 TPS는 1.55였다. 단일 워커에서 DB I/O와 외부 API의 연동 작업들의 평균 latency가 1초대라고 가정하더라도 큐에는 작업이 계속 쌓이게된다. 위에서 언급한 것 처럼, 이러한 상황들을 먼저 가정하고 접근했더라면 워커의 concurrency를 늘리는 등의 동시 처리 방법까지 자연스레 고려할 수 있었을 것 같다.
원자성 보장을 위해
메시지의 추가 I/O는 단일 테이블의 insert라고 하더라도, 업무 알림은 여러개의 테이블을 insert/update하는 연속적인 과정이다.
MyISAM은 항상 auto-commit한 쓰기 작업을 보장한다. 롤백을 무시하며 트랜잭션을 지원하는 엔진이 아니기 때문에, 이런 연속적인 과정에서 트랜잭션을 보장하기 위해서는 소위 transaction-like한 무언가를 직접 구현해야했다.
type InsertRecord = {
table: string;
id: number;
deleteFn: (id: number) => Promise<void>;
};
class JobTransactionContext {
private inserts: InsertRecord[] = [];
recordInsert(record: InsertRecord) {
this.inserts.push(record);
}
async rollback() {
for (const record of this.inserts.reverse()) {
await record.deleteFn(record.id);
}
}
}
이를 해결하기 위해 위처럼 트랜잭션 컨텍스트 객체를 사용해서, 트랜잭션을 보장해야하는 로직에 활용하게 되었다.
실패 후속 처리
워커에서 작업을 실패할 경우 원자성을 보장해야하는 경우는 실패로 간주되지만, 위에서 말했던 것 처럼 실패했을 경우에도 운영 상에 지장이 없다고 판단했던 SMS 발송과 푸시 알림은 실패로 간주되지 않는다.
위 정책에 따라 분리하여 트랜잭션이 롤백되는 상황에서만 실패로 간주하여 작업을 종료시키고, 실패 시 재시도 전략을 수립했다.
재시도는 BullMQ에서 기본으로 구현되어있는 지수 백오프(Exponential Backoff)를 사용했다.
모든 재시도에 지수 백오프만 사용할 경우 모든 실패한 작업들이 동시에 백오프 될 경우도 고려해야한다. 실패한 작업에 대해 재시도를 분산하기 위해 사용하는 전략임에도 재시도 과정에서 다시 요청이 몰리는 것은 똑같다. 이를 해결하기 위해 AWS에서는 지연 변이(Jitter)라는 개념의 추가 전략을 통해 일정의 랜덤 시간을 추가로 부여하여 재시도의 동시성을 분산했다고 한다. (자세한 내용은 AWS의 공식 포스팅1 / 포스팅2 를 참조)
추가 개선
최근에 이 내용들을 복기하면서 추가로 고려하지 못했던 사항들이 무엇이었는지, 내가 1년 반 전과 비교해서 어디까지 고려하는 개발자가 되어있었는지 확인해보고 싶었다. 위에 잠깐 언급했던 트러블 슈팅을 위한 TPS 산정을 포함해서 정리한 피드백 내용은 다음과 같다.
위에서 언급한 TPS를 조기에 산정했더라면
큐에 메시지가 계속 쌓인다면? (처리량보다 유입량이 많은 경우)
BullMQ의 심장(?)인 Redis에 장애가 발생한다면?
위 세 가지 상황이 모두 연관이 있는 것 같다. 1번을 조기에 고려하지 못해서 자연스레 2번 문제를 캐치하지 못했고, 2번 문제를 계속 방치하다보면 결국 최종에는 Redis에도 문제가 생기지 않을까? 추가 개선을 위해, BullMQ는 어떻게 Redis를 활용해서 Job을 입력하는지 알아보는 게 좋겠다.
여기서 생각해보아야할 부분은, Redis의 SET은 중복된 키값이 있다면 내부 데이터를 덮어 쓰는 방식으로 동작 한다는 점이다. 이해를 돕기 위해, 실제 Job 등록에 사용되는 여러 자료구조 중 Hashes를 직접 CLI를 통해 입력해본 결과를 아래에 서술해두었다. 결과를 보면 중복 방지를 디폴트로 수행하지 않는다는 것을 알 수 있다.
127.0.0.1:6379> HSET user-1 name test
(integer) 0
127.0.0.1:6379> HGETALL user-1
1) "name"
2) "test"
127.0.0.1:6379> HSET user-1 name test2
(integer) 0
127.0.0.1:6379> HGETALL user-1
1) "name"
2) "test2"
그렇다면 BullMQ를 사용하는 우리 개발자들은 중복 처리를 사전에 확인하는 모듈을 따로 구성해야할까? 그렇지 않다. BullMQ에서는 편의를 위해 중복된 Job은 등록이 되지 않도록 처리해두었다. 우선 BullMQ의 소스 코드를 실제로 분석해 본 후 동작 과정에 대한 간략한 플로우를 그려봤다.
Redis에 데이터를 등록하기 위해 실행되는 add....Job-*.lua 스크립트에서 입력 전 중복 확인에 대한 로직이 같이 수행된다.
else
jobId = args[2]
jobIdKey = args[1] .. jobId
if rcall("EXISTS", jobIdKey) == 1 then
return handleDuplicatedJob(jobIdKey, jobId, parentKey, parent,
parentData, parentDependenciesKey, KEYS[5], eventsKey,
maxEvents, timestamp)
end
end
추가적으로 priority나 delayed 작업을 위한 ZSET 활용, 작업 로그를 위한 Streams등에 추가로 입력하지만, 이 부분은 현재 주안점에 벗어나니 생략하겠다. 관심 있으신 분들은 BullMQ 소스코드를 참고하면 될 것 같다.
이제 이러한 이해들을 바탕으로, 추가 개선을 어떻게 해야하는지 한 번 생각해보았다.
처리량 < 유입량
우선 처리량보다 유입량이 많은 경우부터 따져보자.
워커의 처리 속도가 생산 속도를 따라가지 못할 경우 큐에는 자연스레 Job이 쌓이게 된다.
이 상황이 지속되면 메모리 부담은 물론이고(Redis) 뒤에 들어온 Job의 처리 시간은 기하급수적으로 증가하게 된다.
위의 비즈니스 흐름을 예시로 절망적인 상황을 들어보자면 고객님이 어제 채팅을 보냈는데, 담당자는 오늘 업무 알림을 받아볼 수도 있다.
큐에 작업이 원활하게 처리되지 못하는 상황을 해결하기 위해 워커에서 동시 처리량을 제어할 수 있다. TPS가 최대 1.55였기 때문에 645ms당 1개의 요청이 발생한다고 가정하고, 워커의 처리 속도는 외부 API에 의해 최대 2초가 걸린다고 가정한다면 concurrency는 3~4정도가 적당할 것이다. 이처럼 적절한 동시 처리나 워커 자체를 늘리는 방향도 고려해볼 수 있다.
하지만 동시성을 제어할 경우에는 현재 사용중인 리소스, 여기서는 데이터베이스의 총 Connection과 평균 활성 Connection, 현재 서버의 Connection Pool과 할당된 메모리 자원 등을 고려하는 것이 필수이다. 이 모든 리소스간의 밸런스를 고려하는 엔지니어링이 개발자의 필수 덕목인 것 같다.
유입량이 많은 경우 중 또 고려해야할 부분은, 처리해야할 메시지가 중복해서 들어오는 경우이다.
하지만 위의 BullMQ의 기본 Job 적재 방식에 대한 이해를 바탕으로 중복 방지에 선 조회 후 early-return하는 코드는 오히려 추가 I/O가 발생할 것이라는 것을 짐작할 수 있다.
Redis 장애 대응을 위해
근본적으로 Redis 장애 발생 시 당연히 BullMQ는 더이상 메시지를 받을 수 없다. 또한 이미 enqueued된 작업조차 Redis의 휘발성이라는 특성 때문에 손실될 수 있다.
개발자로서 이런 현상을 미리 대응할 수 있도록 설계하여 이미 enqueued된 작업을 복구할 수 있도록 구성할 수 있어야한다.
아직까지 서비스에서 사용중인 Redis에 장애가 발생한 적은 없지만, 혹시 모를 Fail Over에 대비한 전략이 하나도 구성되어있지 않다는 것을 인지했다. 서비스 도메인 특성상 트래픽이 엄청나게 성장할 일은 없다고 판단해서 Sentinel로 FailOver 시 노드 승격 전략과 장애 발생 알림 처리를 구성했다. 서비스 레벨에서 Redis 연결 재시도를 허용하여 마스터 노드 전환 시에도 워커가 자동으로 재연결되도록 처리했다.
이와 더불어 꾸준히 큐들의 작업 개수를 주기적으로 수집하여 모니터링하고 대기열이 일정 수치를 초과하면 알림을 받아볼 수 있도록 구성하여 장애 징후를 빠르게 감지할 수 있도록 했다.
마무리
당시의 개선 방향과 현재 시점에서 생각나는 추가 개선 사항들을 정리하여 쭉 정리해보았다.
점진적으로 이런저런 시도를 해보면서 현재 트래픽을 감당하기 여유로운 상황이다보니 엣지 케이스들을 또 고려하지 못했나 싶기도 하다.
조금씩 알면 알수록 더 어려운 빌어먹을 엔지니어링의 세계 ㅡㅡ.. 외부 레퍼런스들을 많이 찾아보면서 실제 개선 사례들을 대입해보면서 무엇을 놓쳤는지, 지금 방식이 최적이었는지 계속 생각해보고있다. 언젠가 이 글을 다시 꺼내먹는 날 예전의 내가 한심해질지도..?
docker run -d --name sonarqube -p 9000:9000 sonarqube
위 명령어를 통해 바로 설치와 실행을 해준다. docker run 명령을 실행할 때 로컬에 이미지가 없으면 도커 허브에서 자동으로 이미지를 다운로드하고 실행시킨다. (특정 버전을 사용해야한다면 이미지를 특정 버전으로 설치한 후 실행한다.)
소나 큐브의 웹 콘솔은 기본적으로 9000 포트를 사용하는 것 같다. 현재 해당 포트를 사용하지 않기 때문에, 포트포워딩을 통해 9000번 포트로 접근할 수 있도록 설정하였다.
세팅
admin/admin으로 접속할 수 있었고, 로컬 프로젝트를 바로 생성해주었다. 간단하게 과정들을 캡처했는데, 간단하게 세팅할 수 있었고 안내를 너무 잘해줘서 쉽게 세팅할 수 있었다.
설정을 마치고나면, 분석 방법에 대한 설정을 하게 된다. 현재 배포 파이프라인이 Actions을 통해 구성되어있으므로 Actions를 선택했다.
기존 배포 프로세스와 겹치지 않게, 별도의 Actions 스크립트로 구성해서 셋팅을 완료했다.
분석받기
아무 설정도 하지 않았을 때, 현재 코드가 Passed된 모습이고 보안, 신뢰성, 유지보수성, 코드 중복, 커버리지 등 상세하게 문제지점을 볼 수 있다. Measures 탭이 있는 걸 보니, 뭔가 새로운 코드가 반영됐을 때 이전 상태와 비교해서 개선이 되었는지도 측정해주는 것 같다.
누르고 싶게 생긴 빨간 Security Hotspots를 살펴봤다.
위 코드는 테스트나 로컬 환경에서의 여러 스키마를 synchronized하는 설정 코드이다. 로컬 / 테스트 환경에서만 동작하는 코드이기 때문에 딱히 수정할 필요가 없을 수 있다.
아래 코드는 이미지를 랜덤으로 가져오는 코드이며, 단순 10장 중 랜덤 한장을 보여주기만 하기 때문에 Math.random을 사용해도 PRNG 사용 등의 가능성을 고려하지 않아도 된다.
// sonar-ignore-start
connection.query(`CREATE DATABASE IF NOT EXISTS \`${databaseName}\``);
// sonar-ignore-end
(분석기가 경고하지 않게 만들 수도 있다고....)
간단하게 도커 컨테이너로 소나큐브를 구성하고, 현재 서비스의 메인 브랜치 코드를 관리할 수 있게 되었다. 과하게 의존하진 않더라도, 소나큐브가 제공하는 코드 분석의 결과가 긍정적이라면, 적극적으로 수용하여 계속해서 나은 코드, 설계에 대한 방향성을 고민하고 적용해나갈 수 있게 되었다. 소나큐브의 분석 스타일을 커스터마이징하여 프로젝트 내의 일관적인 컨벤션을 유지할 수 있게 구성해서 사용해보아야겠다.
커버리지 세팅
11월 14일 현재, 어느정도 커버리지에 대한 세팅을 하여 사용중이다.
테스트 커버리지와 동일한 커버리지를 받아볼 수 있으면서, 테스트 코드 자체는 분석이 되게 구성하였다.
# 프로젝트 기본 정보
sonar.projectKey=iwedding-api-v2
sonar.projectName=IWedding API v2
sonar.projectVersion=1.0
# 분석 경로 설정
sonar.sources=src
sonar.tests=test
# 커버리지 경로 설정
sonar.javascript.lcov.reportPaths=coverage/lcov.info
# 커버리지에서 제외할 파일
sonar.coverage.exclusions=**/*.config.ts, **/*.module.ts, \
**/*.interface.ts, **/*.dto.ts, **/*.constant.ts, **/*.enum.ts \
**/*.error.ts, **/*.exception.ts \
src/**/*.error.ts, src/**/*.exception.ts, \
src/infra/database/**, \
src/main.ts, \
src/**/entity/**/*.ts, \
src/**/enum/**/*.ts, \
src/**/interface/**/*.ts, \
src/**/type/**/*.ts, \
src/**/*.config.ts, \
src/**/*.const.ts, \
src/common/error/**/*.ts, \
test/**/*.ts # 테스트 파일 커버리지 제외
# 소스 인코딩
sonar.sourceEncoding=UTF-8
# TypeScript 설정
sonar.typescript.tsconfigPath=tsconfig.json
실제 서비스의 백엔드 프로젝트의 루트 경로에 위와 같은 프로퍼티 파일을 생성해서 소나큐브가 읽을 수 있게 구성하여 사용중이다. 소나큐브의 커버리지는 jacoco와 연동해서 많이들 사용하던데, node이기 때문에 Jest를 사용하였다.
소나큐브 Actions 스크립트에서 test를 돌리기 위해서는 DB와 기타 서드파티를 도커로 구성하여 띄워야 소나큐브에서도 테스트를 돌려 커버리지 파일을 동적으로 활용할 수 있을텐데, 그 작업은 뒤로 미루고 메인 브렌치에서 pre-commit 단계에서 실행되는 테스트에 커버리지 파일을 생성하여 coverage/lcov.info는 ignore하지 않고 레포에 같이 올려서 사용중이다.
(더 추가적인 세팅이 완료되면, 1차적으로 해당 포스팅에 이어 작성하고, 분량이 많아지면 정리하여 새로운 포스팅으로 정리하자.)
1편의 연장선으로, 실제 기술 면접의 코드 리뷰에서 mysql2에 의존적인 코드를 어떻게 변화에 유연하게 만들것이냐는 질문을 받았다. 레이어간 역할을 명확하게 하는데 그치지 않고 데이터베이스에 엑세스하는 레이어를 두 단계로 나눠서, 레포지토리 레이어는 단순 쿼리 실행 레이어로 인프라 레이어를 데이터베이스와 연결하는 레이어로 두어 인프라 레이어만 교체하게끔 할 수 있다고 생각했다.
ORM, DB 라이브러리와 비슷하게 실무에서 많이 쓰는 외부 의존성이 무엇이 있을까 생각해보다가, 실제로 운영중인 서비스에서 많이 활용중인 Nest의 HttpModule을 바탕으로 의존성을 제어해보고자 했다. 현재 운영중인 서비스의 HTTP 클라이언트 라이브러리 사용 구간은 전환이 이루어지지 않은 일부를 비롯하여 샌드버드, 웹 훅, SNS 회원가입 시 약관 정보를 가져오기 위해 사용하는 등 여러 구간에서 애플리케이션의 핵심적인 기능을 담당하고 있다.
기존 모듈의 한계
위 코드는 공식 문서의 예제, 아래 코드는 실 사용중인 샌드버드 API의 일부이다.
공식 문서에 따르면 AxiosResponse는 axios에서 내보낸 인터페이스이며, 모든 HttpService 메서드들은 Observable로 래핑된 AxiosResponse를 반환한다고 한다.
실제로 소스를 들여다보면, get 메서드는 Axios Promise -> Observable을 반환하게 되어있다. 현 상황에서 Observable의 이점을 활용할 수 없다는 판단이 섰다. 유연한 처리(요청 취소, 체이닝 등)를 할 필요가 없는 상황. 단순 응답 데이터를 받아 그대로 활용하는 상황에서 굳이? 라는 생각을 했다.
하고 개발을 완료했었는데, 최근 인프랩 면접을 기점으로 코드를 보는 시각이 조금 달라졌다. 항상 구현에서 그치지 않고 유연한 설계를 지향하기 시작했다.
axiosRef는 axios 그 자체이다. AxiosInstance는 axios를 상속받은 구현체이다. 그럼 나는 불필요하게 axios를 사용하기 위해서 특정 프레임워크에 종속되어 있는건가? 왜 굳이 HttpService를 사용해야하지?
라는 생각들이 마구 들어 직접 HTTP 클라이언트 라이브러리를 활용하여 HttpService를 구현해보고자 했다. 추가로 axios에서 fetch로, got, undici로 HTTP 클라이언트 라이브러리가 교체되더라도 유연한 구조를 설계해보고 싶었다.
독립적인 HTTP 모듈 구성
우선 생각한 구조는 다음과 같다. HTTP 메서드들을 추상화한 인터페이스를 활용해서, HttpService에서 이를 상속받아 인터페이스의 메서드들을 구현하고 이 서비스에서 직접적으로 HTTP 클라이언트 라이브러리에 의존하기 때문에, 라이브러리가 교체되더라도 HttpService만 교체하면 되는 상황을 설계하고자 했다.
이제 Nest의 주입 포맷에 맞게, 주입해서 사용해주면 된다. 원하는 대로 설계는 잘 이루어졌고 제대로 동작했다.
관련해서 레퍼런스들을 찾다가, 올해 코딩적 영감을 가장 많이 받은 인프랩의 기술 블로그에 같은 주제의 포스팅이 있었다. 포스팅을 서너번 읽어봤는데 코드의 가독성이 훨씬 더 좋고, 내부 서버에서 사용할 클래스들로 유연하게 변경시켜주는 차이점이 있었다.
나는 위 설계를 바탕으로 코드를 작성한지 2시간 반 만에 리팩토링을 해야만 했다. 아니 반드시 하고 싶었다.
리팩토링
응답 데이터로 유연하게 객체 생성하기
우선, 응답받은 데이터를 바로 내부에서 사용하는 객체로 변환시켜주고자 했다. 처음엔 단순 class-transformer의 plainToInstance만을 사용하려고 했으나, 응답받는 데이터가 Snake Case이거나, 불규칙적인 형태일 경우 모두 고려해야했고, 현 상황에서 모두를 아우를 수 있는 형태로 발전하게 되었다.
(class-transformer는 class-validator와 더불어 시너지를 내기도 좋고, nest라는 프레임워크를 사용하는 이유 중 하나라고 생각을 하기 때문에, 적극 사용중이다.)
기존에는 실제 axios와 똑같이, get메서드 하나에 모든 파라미터를 다 집어넣어서 데이터를 뽑아내는 방식이었다. 취향 차이일지 모르겠으나 내 기준에서는 위 기술 블로그의 메서드 체이닝 방식이 훨씬 읽기 편하고, 원하는 메서드만 골라서 사용할 수 있어 훨씬 유연할 것 처럼 보였다.
최종 형태의 SendbirdService가 완성되었다. 위 코드에서, 어디에도 axios에 의존적인 구간은 없는 것으로 보인다. 설계한 의도대로 잘 흘러간 것 같다.
정리
HTTP 클라이언트 라이브러리를 사용하기 위해 추가적인 프레임워크의 특정 모듈에 의존적이어야 하는 상황. 이 상황을 너무 종속적이고 유연하지 못하다고 판단했고, 실제 구현한 HttpService 구현체를 통해 다른 라이브러리로의 교체가 이루어지더라도 해당 서비스만 교체하면 될 것 처럼 설계했다.
라이브러리를 교체해도 모듈 내부만 교체하면 된다. 외부에서는 교체됐는지 알 필요도, 알 수도(???) 없다. 추가로, 인터페이스 자체를 모킹해서 테스트에도 용이할 것이다.
레퍼런스들을 찾아보면, 대부분이 PM2를 통해 간편하게 인스턴스를 띄워 사용하다가, 컨테이너 단위로 전환하고 노드 환경에서는 추가로 pm2-runtime을 도커 컨테이너 내부에서 적용하는 경우를 많이 봤던 것 같다.
나는 반대로, 현재 운영중인 서비스를 계속해서 개발하면서 겪은 문제들을 개선하기 위해 배포 파이프라인을 Git Actions - Docker에서 Git Actions - PM2로 전환하였다.
1. 긴 배포 시간 (길면 3~5분, 평균 1분 후반대)
2. 간헐적인 서비스 장애 탐지의 위치 이동
서버와 백엔드 코드를 혼자 관리하다 보니 빈번하게 코드를 업데이트해야 한다. 테스트 같은 안전 장치의 통과를 위해 소요되는 시간은 언제나 수긍하지만, Docker 프로세싱의 긴 소요 시간은 점차 불쾌하게 다가왔다. 얕은 지식과 레퍼런스들을 바탕으로 이미지 최적화를 수행해도 1~2분은 기본적으로 걸렸다. 메인 브렌치에 코드를 하루에도 많게는 십 수번 씩 반영해야하는 환경이라 배포 속도가 중요하게 다가왔다.
또한 처음 입사 후에 간헐적으로 배포 후에 발생되는 서비스 장애를 방지하기 위해 코드 통합 이후 health check를 추가했다. 테스트 코드를 조금씩 적용해나가면서, 현재의 환경에서 굳이 배포 사이클에 테스트를 돌릴 필요가 없다고 판단이 되었다. 그래서 lint를 포함한 테스트와 관련된 사이클은 pre-commit 단계에서 수행할 수 있도록 husky를 도입했다.
이러한 문제들을 개선하기 위해 기존 사용하던 Docker Compose를 통한 BLUE / GREEN 배포 대신 PM2와 심볼릭 링크를 활용해 새로운 배포 방식으로 변경하게 되었다.
BLUE / GREEN 배포
새 배포 프로세스 구조
BLUE / GREEN 배포
아래의 스크립트는, 같은 BLUE / GREEN 방식으로 current 심볼릭 링크가 가리키는 폴더를 기준으로 반대편 폴더에 새로운 코드를 배포한 후 심볼릭 링크를 전환하는 구조로 작성했다. PM2 인스턴스는 current 심볼릭 링크를 통해 해당 디렉토리의 코드만을 실행하게 된다.
name: iwedding_api_v2_deploy
on:
push:
branches:
- 'main'
jobs:
SSH_DEPLOY:
if: contains(github.event.head_commit.message, 'skip') != true
runs-on: [self-hosted, api_v2_action]
steps:
- name: SSH scripts
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.IWD_HOST }}
username: ${{ secrets.IWD_USERNAME }}
password: ${{ secrets.IWD_PASSWORD }}
script: |
echo "=========================================================="
echo "🤝 Integration: Pull latest code and install dependencies"
echo "=========================================================="
source /home/svc/.nvm/nvm.sh
cd /home/svc/api/v2
nvm use # .nvmrc
echo "============================================="
echo "🔎 Determine Target for Blue/Green Deployment"
echo "============================================="
if ls -l /home/svc/api/v2/current | grep -q 'blue'; then
TARGET_FOLDER="green"
else
TARGET_FOLDER="blue"
fi
echo "========================================="
echo "📁 Pulling latest code for $TARGET_FOLDER"
echo "========================================="
cd /home/nodesvc/api/v2/$TARGET_FOLDER
git pull origin main
yarn install
echo "========================================"
echo "🛠️ Compile application in $TARGET_FOLDER"
echo "========================================"
yarn build
# 테스트를 main branch에서도 한 번 더 실행해야 할지 고민중
# echo "=================="
# echo "✅ Test: Run tests"
# echo "=================="
# yarn test
echo "============================================"
echo "🚀 Switching symbolic link to $TARGET_FOLDER"
echo "============================================"
ln -sfn /home/svc/api/v2/$TARGET_FOLDER /home/nodesvc/iwedding-api/v2/current
echo "==================================="
echo "🚀 Restarting application with PM2"
echo "==================================="
pm2 restart /home/svc/api/v2/current/ecosystem.config.js
기존의 도커 방식은 블루 인스턴스와 그린 인스턴스에 포트를 서로 다르게 부여한 후 리버스 프록시에서 proxy_pass로 최신 버전의 인스턴스를 읽는 방식이다. 오래된 인스턴스는 백업 컨테이너로 copy된 후 삭제된다.
이러한 일련의 과정들을 모두 간소화하여 배포 시간은 30초 대로 단축되었고, 기존의 BLUE / GREEN에 따른 관련 설정들을 따로 독립적으로 구성하고 관리할 필요가 없게 되었다.
Docker 환경에서는, 볼륨 마운트로 로그를 수집했지만, PM2로 전환하면서 심볼릭 링크가 변동이 되기 때문에 인스턴스에서 current 디렉토리가 아닌 특정 경로에 고정된 방식으로 로그를 내보내게 되었다. Promtail에서 해당 경로만 지정해준다면, 문제 없이 기존처럼 로그를 용이하게 수집하고 관리할 수 있다.
급하게 반영된 프로덕션의 코드가 잘못되어 롤백해야할 때, restore을 사용해서 도커 컨테이너의 포트를 바꿔버리는 스크립트를 사용했다.
기존 액션 스크립트의 심볼릭 링크를 확인하는 명령어를 기반으로, 자바스크립트 코드로 심볼릭 링크를 교체해주면 기존의 restore와 동일하게 사용할 수 있었다.
당연한 말이지만, 내부 IP 혹은 내 노트북과 같은 특정 IP에서만 접근할 수 있도록 방화벽 설정을 호스트 서버 구성해두었다.
exec(`ln -sfn /home/svc/api/v2/${newDir} /home/svc/api/v2/current`, (switchError, switchStdout, switchStderr) => {
if (switchError) {
console.error(`스위치 오류: ${switchError.message}`);
res.end('스위칭 중 오류가 발생했습니다.');
return;
}
res.end(`스위칭 성공: ${newDir} 디렉토리로 전환되었습니다.`);
});
정리
기존 배포 프로세스(위)에서, 현재의 방향(아래)으로 교체한 뒤, 가장 큰 문제였던 배포 시간을 2분 내외에서 30초 내외로 크게 단축시킬 수 있었다. 메인 브렌치에 마구잡이로 핫픽스를 하면 안되겠지만, 현재의 조직 특성상 맞는 방향으로 배포 파이프라인을 교체하였고, 바꾼지 이틀 되었는데 이미 크게 체감이 되는 모양이다. 본인을 포함하여, 다른 구성원들까지 빨리 반영된다고 좋아하는 것 같다.
정답은 없지만 오답은 있다는 말처럼, 오답이 아닐까 노심초사 전환기를 남겨둔다. 현재 상황에 맞는 여러 방법 중 배포 시간을 줄이자!!! 라는 방향만 본 극단적인 선택일 수 있지만, 나중에 내가 다시 봤을 때 최적의 선택은 무엇이었을까? 하고 돌아볼 수 있지 않을까..
글 마무리가 조금 이상한데,,, 여튼 배포 시간을 3~4배 단축시킨 것에 현재는 만족한다.
@Injectable()
export class WeddingUserService {
constructor(
private readonly wUserRepository: IWeddingUserRepository,
) {}
async me(userId: string): Promise<UserInfoDto> {
const wUser: WeddingUser | null = await this.wUserRepository.findOneByWebId(userId);
//custom exception
if (!wUser) throw new NotFoundException(ErrorCode.User, ErrorMessage.User);
return new UserInfoDto.create(wUser);
}
}
최근 면접에서 심도있게 답변을 하지 못했던,
단순히 서비스 레이어는 레포지토리의 인터페이스에만 의존하고 있으므로 레포지토리 자체의 구현의 변경에 영향을 받지 않도록 설계했습니다. 를 넘어선 무언가에 대해 생각을 확장해보아야 했다.
데이터를 Fetch하는 라이브러리의 구현 메서드 자체는 변할지 모르지만, 서비스에서 호출되는 레포지토리의 함수명 자체가 변하지 않는 이상 추상화 하는 것이 정말 의미가 있을까? 또한 해당 인터페이스를 여러 저장소에서 가져온다거나 하는 상황이 실제로 발생할까? > 현재 서비스는 실제로 서비스 내부의 고객 데이터를 외부에서 사용하는 경우는 있을 수 있지만, 외부의 데이터를 고객 데이터와 합쳐서 처리해야 하는 경우는 없을 것이다.
레포지토리가 의존하고 있는 무언가가 변경되더라도 테스트 작성에 더 용이할까? > 인터페이스에 의존한 Mock 객체를 사용하게 되면, 실제 데이터베이스에 접근하지 않고 단위 테스트를 수행할 수 있지만 데이터 I/O가 없는 단위 테스트에 의미가 있을 뿐, 실제 저장소 연결을 필요로 하는 통합 테스트 등에는 이점이 없는 것 같다. 단위 테스트는 현재 Fixture를 통해 충분히 관리되고 있다.
좀 더 클린한 코드를 작성하기 위한 리팩토링 중에 레이어의 인터페이스 사용은 위와 같은 판단의 근거들을 통해 사용하지 않기로 결정했다.
(관련해서 피드백을 해주신다면 소정의 성의를 돌려드리겠습니다!!! 많관부)
정답은 없어도 오답은 있다는 말처럼, 면접 간에 내가 선택하고 작성한 코드에 대해 명확하고 자신감 있게 판단의 근거를 제시하지 못해 아쉬었다. 물론 기술 면접은 통과했었지만, 내면에 정의된 기술적 지식이 조금 더 있었다면 보다 나은 코드 리뷰를 할 수 있지 않았을까. 하는 아쉬움이 남는다.
최근 조영호 님의 오브젝트 책을 구매했다. 객체 지향 쪽에서의 조금 더 나은 구조와 코드 설계를 위해 학습하고 있다.
이런 학습들을 하다보면, 기술 선택 뿐 아니라 내가 작성해나가는 코드 한 줄 한 줄에 어떤 판단의 근거를 가지고 작성했는지 조금 더 명확하게 말할 수 있지 않을까. 이런 것들이 쌓이다보면 개발자로서의 중간 목표인 오프라인 세션에서 나의 지식과 경험을 공유하는 사람. 으로 한 발 더 다가갈 수 있으리라 믿는다.
분명 일정한 기준이 있을 텐데 왜 얘기들이 조금씩 다른 것일까? DB의 버전 때문일까 옵티마이저가 무조건적으로 100% 맞다는 보장이 없어서일까? 잘 모르겠다. 그래서 직접 쿼리 튜닝의 경험들을 복기하며 복합 인덱스를 생성할 때에는 어떤 순서로 인덱스를 구성해야 하는지 알아보았다.
서비스 내부에는 모든 유저의 장바구니(앞으로 견적함이라 부름)를 볼 수 있는 기능이 존재하는데 업종으로 필터링할 경우의 집계를 위한 쿼리의 일부이다. (사진과 드레스를 선택했을 경우를 예시로 platform_A 테이블에 대한 인덱스 생성만 예시로 든다.)
SELECT cart_group_no
FROM platform_A.cart c
INNER JOIN service.product p
ON p.no = c.product_no
WHERE p.category IN ("사진", "드레스")
AND c.cart_group_no != 0
AND c.option != 1;
COUNT(DISTINCT) 쿼리로 간단하게 카디널리티를 확인해 본 결과는 위와 같았고, 당연히 나는 아래와 같이 인덱스를 생성했다.
CREATE INDEX IDX_REALTIME_QUOTATION
ON platform_A (cart_group_no, product_no, option_product);
쿼리 실행 시간이 어느 정도 눈에 보이는 수치로 감소했다. 하지만 여전히 Fetch는 비슷했다.
인덱스 생성 전에는, 해당 테이블에서 풀스캔(ALL)을 했다. 전체 테이블 row를 스캔한 것으로 보인다. 인덱스 생성 후에는 INDEX RANGE SCAN을 통해 조회하였고 테이블 row의 스캔 개수도 20%가량 줄어들었다. 추측건대 fetch time이 개선되지 않은 이유는 스캔하는 row가 여전히 많기 때문이라고 판단했다.
쿼리에서는 조인을 먼저 수행하지만 쿼리 실행 계획을 보면 조인 시 인덱스를 효율적으로, 아니 전혀 사용하지 못하는 것처럼 보였다.
왜 이런 결과가 나올까 생각해 보다가 쿼리 실행 순서를 바탕으로 인덱스 순서를 변경해 보았다.
CREATE INDEX IDX_REALTIME_QUOTATION
ON platform_A (product_no, cart_group_no, option_product);
p의 인덱스 길이는 category가 varchar(150)이기 때문인 것 같다
변경 후에도 결과가 다소 개선되었는데 Duration도 개선되었지만 Fetch Time이 대폭 개선되었음을 볼 수 있다. 실제로 스캔하는 row의 수가 100배 가까이 줄었기 때문이라고 유추해 볼 수 있었다. 추가로 c 테이블의 KEY LENGTH가 1이 줄었는데, 이 쿼리의 결과에서 option_product을 참조하지 않았다고 판단할 수 있다. (option_product는 tinyint이다)
카디널리티는 cart_group_no가 두 배 높았지만, 실제로 product_no를 앞에 사용해 줘야 올바르게 튜닝이 된 것을 확인할 수 있었다.
위 쿼리에 대해 모든 인덱스를 참조하게 되는데, 이런 인덱스를 커버링 인덱스라고 한다.
실제 쿼리를 튜닝하는 과정을 통해 카디널리티가 낮더라도 첫 번째 조건 절에서 사용된 컬럼을 인덱스 컬럼으로 사용하지 않는다면 올바르게 동작하지 않는다는 것을 알 수 있었다.
정리하자면 선행 조건 컬럼은 반드시 인덱스 선행 컬럼이 되어야 하고, 다음과 같은 조건절이 있을 때
WHERE col1 = ?
AND col2 = ?
AND col3 BETWEEN ? AND ?
AND col4 = ?
AND col5 = ?
CREATE INDEX ON IDX ON TB1 (col1, col2, col3, col4, col5);
인덱스를 위와 같이 생성했다면 col4, col5는 인덱스를 타지 않고 조건 필터링만 수행하게 되며 쿼리 실행 순서와 마찬가지로 WHERE 조건 절은 ORDER BY 컬럼보다 우선한다.
모든 인덱스를 참조하게 하고 싶다면 col1, col2, col4, col5, col3 순으로 생성해야 한다.
향로님 포스팅 일부 발췌
이왕이면 카디널리티도 위의 규칙을 지키면서 고려하면 좋다고 생각했지만, 카디널리티는 복합 인덱스에서 고려할 사항이 아니라는 자료도 있고 아예 선행 조건절이 일치한다면 후행 조건절에서는 순서가 유의미하지 않다는 글도 있다.
최근엔 이전과 같이 꼭 인덱스 순서와 조회 순서를 지킬 필요는 없는 것 같다. 인덱스 컬럼들이 조회 조건에 포함되어 있는지가 중요하고, 내가 사용하고 있는 데이터베이스 엔진에 맞게 인덱스를 활용하고 튜닝할 줄 아는 게 중요한 것 같다.
ALB에서 Nginx에 대한 트래픽 분산 처리를 하지 않기 때문에 ALB는 사용하지 않았다
기존 ACM을 통한 SSL인증 처리를 Nginx로 이전하고, 로드밸런싱 또한 차후에 필요 시 컨테이너의 대수를 늘려 사용할 계획이다.
3.
백엔드 컨테이너는 무중단 배포를 Git Action의 Workflow를 통해 구축했으며, 헬스 체크를 통한 백업 컨테이너로의 restore가 적용되어 있다.
4.
필요한 로그들은 Discord Webhook을 통해 개인 채널에 메세지 형태로 받아볼 수 있게 구성하였다.
진행상황
나를 위한 기록 어플을 타겟으로 삼았기 때문에, 우선 내가 중점적으로 필요한 체중과 운동기록 부분만 우선적으로 구현했다.
간단하게 소셜 로그인을 거치고나면, 운동과 체중을 기록할 수 있게 구성했다.
앱 아이콘은 미리캔버스에서 사각형 크기와 색상을 변경해가며 덤벨 아이콘으로 제작했고
디자인 부분은 관련 분야에서 이미 많은 사용자를 보유하고 있는 PLANFIT, FLEEK을 보고 영감을 얻어 제작했다.
(자세하게 보고 싶다면 위의 앱 이미지 보기를 클릭해보길)
비용 문제
작업을 완료하여 베타버전을 릴리즈하여, 사용자를 조금이라도 더 다양하게 모아 테스트를 받아보고 싶었으나 과금 요소들이 많이 부담이 되었다.
첫 번째로, 애플리케이션 릴리즈를 위한 개발자 계정의 과금 문제다.
찾아본 바로는 앱을 릴리즈하기 위해서, 안드로이드, IOS 진영 각각의 개발자 계정을 등록해야하는데
안드로이드는 25달러(최초), 애플은 99달러(연간)가 발생한다고 했다.
두 번째로, 또, 앱에서 사용하는 운동 이미지에도 과금적인 요소가 추가될 수 있었다.
(결론적으로 계산을 해보니,300달러정도가 필요했다.)
운동 동작별로 운동 수행동작과 타겟부위를 보여줄 수 있는 이미지를 반드시 추가하고 싶었고, 찾아보니 LYFTA라는 앱의 이미지를 사용해보고 싶었다.
귀사의 운동 이미지를 비상업적인 단순 운동 기록을 위해 사용할 것인데 사용 가능한지? 아니라면 얼마의 금액을 부과해야하는지, 혹시 귀사의 이미지가 아니라면 출처가 어디인지? 와 같은 질문을 메일로 보냈고, 감사하게도 출처를 받아볼 수 있었으며 직접 해당 이미지의 판매처와 연결도 해주었다.
대량 구매 시 할인을 제공해준다고 했지만, 개발자 계정과 이미지 가격을 합쳐 4~50만원이 넘는 돈이 필요했다.
현재로서는 많이 부담이 되었다.
과금적인 문제들로 인해 현재는 지인들에게만 직접 apk, ipa파일을 제공하여 10명 남짓한 베타 테스터를 보유한(?) 서비스를 운영중이다.