Home 일어나야해! 넌 조선의 자존심이야! (2/2)
Post
Cancel

일어나야해! 넌 조선의 자존심이야! (2/2)

전편에서는 클라이언트와 서비스 방향이 중점이였다면, 이번 편은 조금 더 백엔드 중심적인 이야기이자 기술적인 내용을 담고 있다.

서버 이슈


Web Mediapipe

전편에서 말한 결과로 사용자 상태 분석은 백엔드에서 이뤄지게 됐는데, 사용자 상태 분석에 사용되는 프레임워크인 mediapipe는 Web과 모바일, Python을 지원했다. 백엔드가 node 환경이니 손쉽게 Web용 mediapipe를 사용하면 된다. 자연스럽게 패키지 설치 후 기본 코드를 실행시켰지만 document 관련 코드가 없는 오류가 발생했다. Web용 mediapipe는 브라우저용 javascript다. 따라서 node.js에서는 document 관련된 구현체가 제공되지 않아 실행되지 않았다.

node 환경에서 mediapipe를 실행시키려면 실제 mediapipe의 c++ 라이브러리를 래핑해서 사용할 수 있다. 또는 Python을 지원해주니 파이썬 프로세스를 생성하여 요청을 처리할 수도 있다. c++ 라이브러리를 래핑해서 사용하는 것보다는 파이썬 프로세스를 이용하는 게 훨씬 간단하고 관리가 쉬워보였다.

파이썬 프로세스

상태 분석을 위해 익스텐션에서 사용자 웹캠으로부터 초당 한장씩 이미지를 캡처하여 백엔드로 전송한다. nest 서버가 전송받은 이미지를 파이썬 프로세스에 전달하여 눈감음 정도나 사람 존재 유무와 같은 분석된 결과를 응답한다. 익스텐션에서는 이 결과값들을 기반으로 사용자 상태를 확정 짓고 알맞는 알림을 제공하게 된다.

이럴수가! 서버가 죽었다. 혼자 테스트할 때 정상적인 것을 확인하고, 배포 후 다른 팀원과 같이 테스트를 진행했다. 이 과정에서 30분 정도가 지나니 서버로 요청이 가지 않는 문제가 발생했다. 확인해보니 서버가 죽어있던 것이였다.

Postman으로 API를 호출해서 확인해보니 응답 시간이 1초대가 넘어갔다. 1초마다 사용자 이미지를 보내는데 처리하는 시간이 1초를 넘기니 계속 작업이 쌓이게 되는 것이였다. AWS에서 제공하는 Cloud watch를 사용해서 CPU 사용량을 모니터링 해봤는데, 실제로 한 명이 사용할 때 몇분 지나지 않아서 곧장 20%에 도달했고 계속 상승했다.

이 수치가 심각하게 느껴졌던 게 mediapipe는 클라이언트용 모델이다. 그렇다보니 굉장히 가볍고 로컬(브라우저)에서 동작할 때도 빨랐으며 CPU 부하가 적었다. 아무리 t3.micro 인스턴스라도 이것은 아니라고 생각했다.

매번 프로세스를 생성하는 게 문제다. 이 과정에는 프로그램 파일을 읽는 파일 I/O 작업부터해서 mediapipe 모델을 로드해서 이미지 분석, 프로세스 종료 후 메모리 회수 등 많은 작업들이 중복되게 된다. 이 불필요한 중복 과정을 제거하기 위해 파이썬 프로세스를 계속 띄워놓을 방법이 필요했다. 이 띄워진 프로세스에 nest 서버로 요청받은 이미지를 전달하고, 이미지를 처리한 결과값을 다시 전달 받아서 응답해주면 될 것이다.

이제와서 보니 CGI를 FastCGI로 만드는 과정처럼 보이는..?

이미지 처리 서버 구축

파이썬 프로세스는 어떻게든 종료되지 않게 한다치고, 이미지는 어떻게 전달해야할까

  1. 이미지를 파일로 저장해두고 파이썬 프로세스에서 이미지 저장 경로를 전달한다.
  2. 이미지를 적재하고 전달해줄 메세지 브로커를 도입한다.
  3. 운영체제에서 제공하는 공유 메모리에 이미지를 저장시켜 전달한다.
  4. 이미지만을 처리하는 파이썬 서버를 구축하여 이미지를 전달하지 않고 직접 받아 처리한다.

이미지는 사용자 상태 분석을 위한 일회성 데이터이기에 파일로 저장하기에는 비용이 너무 컸다. 또한 이미지만 전달하면 되는데 메세지 브로커를 도입하는 것도 부담스러웠다. 공유 메모리를 사용하자니 메모리에 추가적인 read/write 연산이 생기고 여러 요청이 들어오면 관리하기가 곤란했다.

파이썬 프로세스를 계속 띄워두고 이미지 분석 요청이 들어올 때만 처리해줄 방법이 필요했는데, 그러한 애플리케이션을 우리는 이미 클라이언트-서버 모델에서 서버라고 하지 않았던가! 앞선 말한 문제들을 모두 해결하고 가장 간단한 해결방법은 4번이였다. 이미지 분석 결과만 얻을 수 있으면 됐기에 꼭 nest 서버내에서 처리할 필요도 없다.

백엔드를 담당하고 있으면서도, 서버를 마지막으로 떠올렸다는게 참 아이러니하다. 이제서야 서버의 의미를 제대로 깨달은 듯하다.

이미지 처리에 대한 응답속도가 10배(1200~1500ms -> 90~100ms)나 빨라졌다. 이미지 처리 서버도 비동기 프레임워크인 FastAPI로 선택했고 직접 이미지를 처리하면서 이미지 전달과 프로세스 기반 처리 오버헤드를 없앨 수 있었다. 드디어 서버가 버티기 시작했다. 나와 다른 분들이 같이 이용해도 CPU 사용률이 1~2%로 안정적인 사용이 가능해졌다.

응답속도가 빠를 수록 이미지 처리 정체가 없어지기에 많은 요청을 처리할 수 있다. 응답속도를 추가로 2배(90~100ms -> 40~60ms) 빠르게 만들었다. 이미지 처리 서버를 구축하면서 이미지 처리 관련 코드를 들여다보니 매 처리마다 모델을 불러오고 분석을 하는 것이였다.

사실상 모든 이미지 처리에 대해 동일한 분석을 하기 때문에, 모델 로드는 처음 한번만 진행하면 될 것으로 생각했다. 따라서 이미지 처리 객체를 전역으로 두고 이미지 처리 함수를 처음 호출하는 시점만 모델을 로드해주고 이후 호출에는 객체를 재사용했다.

빨라진 건 좋지만, 그러면.. 이제 서버가 사용자 몇 명을 버틸 수 있지…?

성능 측정


부하 테스트

서버의 한계를 측정해볼 필요가 있다. 부하 테스트 도구를 찾아보니 여러 개가 존재했다. 자세한 내용보단 간략한 정보와 간단한 사용법을 제공하는 locust를 선택했다. locust는 파이썬으로 내가 원하는 동작을 정의하여 사용할 수 있었고 결과를 직관적인 UI로 제공해줬다.

실제 웹캠에서 이미지를 20장 정도 찍어서 1초에 한장씩 전송해봤다. 제일 처음 50명 정도로 진행했는데 RPS(Requests Per Second)는 약 40 rps, 평균 응답시간은 270ms가 나왔다.

50명이 초당 하나씩 요청하면 rps가 50이 나와야 하는 게 아닌가 싶었다. 이 부분은 멘토님께 물었는데 요청을 보내고 응답 받는데에서 네트워크 지연이 있다고 하셨다. 그러고보니 네트워크를 타니깐 이러한 지연은 당연한 것이였다. 또한, 이미지를 보내다보니 이미지 파일을 읽고, 전송할 때 쓰기 작업이 일어나면서 추가로 지연이 생긴 것 같다.

동시 요청수를 늘리면서 이상함을 감지했다. 동시 사용자가 50명일 때 40 rps가 나왔는데 100명으로 늘렸을 때 겨우 50 rps가 나왔다. 150명으로 늘렸을 때도 rps가 비슷했다.

CPU 사용률이 확인했다. 동시 사용자를 50명으로 두고했을 때 CPU 사용률이 20~30%에 맴돌았고, 100명으로 증가시킨 뒤에는 CPU 사용률이 40%에 도달했다. 그런데 150명으로 늘렸을 때에도 CPU 사용률이 40%대가 유지됐다.

CPU를 온전히 사용하지 못하고 있는 것으로 판단했다. ec2 인스턴스가 t3.mirco로 코어(vCpu)가 2개였는데 마치 한 개만 사용하는 느낌이였다. 단순하게 생각했을 때, CPU 사용을 최대로 만들기 위해서 하나의 서버를 더 만들면 되지 않을까 싶었다.

멀티 프로세싱

코어를 최대한 활용하기위해 두 가지 방법을 시도했다.

  1. 도커 컨테이너를 사용해서 애플리케이션을 두 개 생성하고, nginx로 로드 밸런싱을 해준다.
  2. gunicorn을 통해 프로세스를 여러개 생성하고 uvicorn으로 처리한다.

두 가지 방법 모두 클라이언트에서 요청 호스트 변경없이 이뤄질 수 있었다. nginx을 이용한 방법을 먼저 시도했는데, 아래 이미지와 같이 최저 10에서 최대 80까지 rps가 굉장히 들쑥날쑥했다. 이 원인을 정확하게는 모르겠으나, 로드 밸런싱이 제대로 되지 않았거나 도커 네트워크 설정 문제였던 것 같다.

nginx+docker

gunicorn과 uvicorn을 같이 사용하면 코어를 최대한 활용할 수 있다. 하지만 실제로 시도해보니 이전에 프로세스를 생성하고 제거할 때에 성능과 같았다. 실패다. 다른 멀티 프로세싱 방법이 필요하다.

gunicorn과 uvicorn은 미들웨어로써 웹서버와 파이썬 애플리케이션 중간에 위치하여 파이썬 애플리케이션을 실행한 결과를 웹 서버로 전달한다. gunicorn은 WSGI(Web Server Gateway Interface)이며 쓰레드 기반으로 요청을 처리하고, uvicorn은 ASGI(Asynchronous Server Gateway Interface)이며 이벤트 루프로 요청을 처리한다.

uvicorn의 worker라는 파라미터가 존재했다. 서버를 기동할 때 이 파라미터를 전달해주면 uvicorn에 쓰일 프로세스의 갯수를 정할 수가 있었다. 우리의 인스턴스는 코어가 2개 였으니 worker를 그에 맞게 설정해주고 다시 기동시켰더니 아래와 같은 결과 나왔다.
uvicorn+2worker

cpu 사용량은 80~90%로 드디어 코어를 최대한 활용하고 있다. 사용자를 150명으로도 늘려봤으나 rps는 그다지 늘지 않았고 응답 시간만 늘어나는 것으로 보아, 한 인스턴스당 100명까지는 안정적으로 서비스가 가능할 것으로 판단했다.

갑자기 사용자가 늘어나는 상황에 스케일 아웃이 가능하게끔 리더님과 같이 오토 스케일링 그룹에 넣어봤다. 인스턴스가 생성되고 환경이 구축되는 시간도 있으니, 바로 적용되려면 스케일 아웃되는 시점을 잘 잡아야겠다는 생각이 들었다.

LLM 서비스


LLM 사용

로아 서비스에는 강의 내용 기반으로 수강생의 집중도를 확인하기 위해 문제를 출제해주는 기능이 있다. 이 문제를 생성할 때 우리가 직접 하지 않고 LLM을 활용하여 생성했다. LLM을 사용하는데 있어서 Langchain 같은 프레임워크를 사용하거나 상용 서비스의 API를 바로 호출해서 사용할 수 있었다.

Langchain을 사용해서 개발하면 여러 작업들을 연결시킬 수 있어 보였는데, 우리의 서비스는 단일 작업에다가 컨텍스트를 쌓아가는 것보단 일회성 호출이여서 바로 상용 API를 호출하는 방식을 선택했다.

상용 API로는 Claude와 ChatGPT, Gemini 등이 있었는데, 이 당시 Claude가 떠오르는 LLM이였고 크레딧도 제공하여 빠르게 실험해보기 위해 Claude를 선택했다.

문제 생성

Claude에서 제공하는 모델은 이미 잘 학습된 모델이기에 프롬프트 작성만으로도 원하는 답변을 낼 수 있었다. 처음에는 프롬프트를 추상적이고 짧게 작성했는데, 그렇다보니 생성되는 문제가 거의 말장난 수준의 문제가 만들어져 버렸다. 프롬프트를 수정하여 입력되는 내용의 컨텍스트를 제공하고 생성 과정을 차례대로 지시했다. 원하는 결과물에 대한 예시를 제공하니 조금 더 그럴듯한 문제가 만들어졌다.

이러한 데이터를 모델에 재학습시키는 것을 파인 튜닝이라고 하던데, 파인 튜닝을 진행하면 훨씬 퀄리티 높은 문제를 만들 수 있어 보였다.

JSON

LLM이 원하는 형식으로 답변을 하지 않는다. LLM 통해 생성한 결과를 DB에 간단하게 저장하기 위해서 답변을 JSON으로 받았다. 그런데 한번씩 JSON 형태가 아닌 일반 텍스트로 오거나 “네 알겠습니다!”와 같은 문구가 추가되는 바람에 DB 저장에 실패하는 문제가 발생했다. 이 부분은 팀원이 해결줬는데, LLM 서비스의 답변을 확인하여 JSON 형태를 찾아 뽑아내는 작업 추가함으로써 해결됐다.

Claude API의 크레딧을 다 사용할 때 쯤 계속 Claude를 사용할 지와 ChatGPT 같은 다른 서비스로 갈아탈 지를 결정해야했다. 결국 ChatGPT로 바꾸기로 결정했는데, 가장 큰 이유로는 JSON 형식으로 답변을 하게끔 강제할 수 있다는 점이였다. GPT-3.5 Turbo를 사용해도 나름 괜찮은 문제가 출제됐고 비용도 매우 저렴했다.

최근에는 ChatGPT가 구조화된 출력 형식을 아예 지원해준다는 소식을 들었다. 타입만 알려주면 그것대로 답변을 해주는 것 같았다.

인프라


초반에는 배포를 직접 ssh 접속하여 배포했다. 자바 애플리케이션을 배포할 땐 jar 파일 하나만 필요했었는데, nest 서버를 배포할 때는 소스들이 빌드된 dist 폴더와 패키지들이 담긴 node_modules 폴더가 필요했다. 또한 pm2 같은 도구를 사용해서 서버를 기동시킬 수 있었으며 이걸로 클러스터 모드, 모니터링 등이 가능했다.

프로젝트 후반에는 배포가 잦아 배포를 자동화시킬 필요가 있었다. 쉘 스크립트를 통해 애플리케이션을 배포하고 배포가 끝나면 헬스 체크까지 하는 것으로 구성했다. 손으로 작업했을 때 2분 정도 걸렸다면 스크립트 실행으로 15초내로 배포가 가능했다.

이전 프로젝트에서는 CI/CD를 구성하고 진행한 적이 있다. 반면에 이번 프로젝트는 필요성에 대한 의문이 들어 아예 없이 진행했다. 우선 테스트 코드를 작성해본 경험은 나밖에 없었고, 이런 상황에서 테스트 코드를 작성해가며 일정에 맞춰서 기능을 구현하기에는 빠듯해보였다.

이 상황에서 CI를 도입하면 정적 분석이나 빌드 테스트 정도를 할 수 있게 되는데 그게 지금 우리 프로젝트에 도움이 될 것 같진 않았다. 물론 그러다가 병합한 뒤 패키지 오류나 자잘한 오류를 몇번 겪긴 했으나 일정에 지장을 줄 정도는 아니였다.

진정한 CI(Continous Integration)는 하나의 메인 브랜치에 자주 병합시키는 것이라는 글을 본 적이 있다. 이 방법을 적용해도 괜찬을 것 같은 판단이 든 게, 개발하는 시간에는 항상 5명이 같이 있기에(책상도 ㄷ자로 붙어있었다) 충돌시 바로 붙어서(뒤로 돌면) 해결해줄 수 있는 상황이였다. 몇몇 팀원은 main에 바로 작업하는 분도 있었으나, 나는 아직 거기까진 무서워서 기능 브랜치 정도는 만들고 병합하는 식으로 개발했다.

또한 CD(Continous Deployment)를 도입하게 되면 민감정보를 또 별도로 처리해줘야하는데 이 과정이 은근 오버헤드가 발생하는 것을 경험했다. 서버 또는 배포하는 사람이 늘어날수록 더 그러하다. 물론 문서화 같은 것으로 극복할 수 있겠지만서도 우린 항상 같이 있기에 문서화도 오버헤드다. 따라서 다른 분들은 개발에 집중할 수 있게끔 배포는 그냥 내가 맡아 진행했다.

그래도 API 문서화와 꼭 필요한 정보들은 문서화를 진행하긴 했다

백엔드 팀원과는 코드 리뷰도 대면으로 진행했는데, 팀원이 백엔드 코드를 작성하면 내가 검수하는(휴먼 정적 분석기) 방식으로 진행했었다. 이 과정에서 서로의 로직을 공유하면서 내가 놓치는 부분도 발견할 수 있었다.

마무리


한 달이라는 개발시간으로 인해 최소한의 요구사항으로 빠르게 기능들을 구현하려고 노력했다. 문제를 겪고 필요성이 생기면 개발하는 식으로 오버 엔지니어링을 피하려고했다. 5명이 정말 한 팀으로써 자기 분야를 가리지 않고 오로지 문제 해결에만 집중했다.

프로젝트 시작날부터 발표 전까지 매일 아침부터 새벽까지 프로젝트에 몰입했다. 굉장히 많은 대화를 나눴고 심지어 나는 리더님과 룸메이트였기에 자는 시간 빼고는 모든 대화가 프로젝트였다. 아침에 일어나자말자 아침 인사보다 먼저 어제 고민하던 문제의 해결방법을 공유하기도 했다.

초반에 이 서비스가 리더님의 좌절로 무산될 뻔했지만 겨우 설득시켜 다시 진행하게 된 일도 있었고, 팀 이름이 각자 원하는 게 하나씩 있어 투표가 무용지물이였던 일, 중간 발표 전날에 기능이 작동하지 않아 새벽 3시에도 방에 모여 테스트하던 일, 발표 3일 전 인프런 웹페이지 요소가 바껴 문제집 생성이 불가능해질뻔 했던 일 등 한달이 길게 느껴질만큼 많은 일들이 있었다.

“굉장히 재밌었다”

좋은 해결 방법을 찾기 위해 토론하고 나름의 해결 방안을 도출해서 적용하는 과정이 매우 즐거웠다. 처음 사용하는 기술들이 대부분이였으나 구글과 LLM 서비스, 정글 과정에서의 CS 지식이 많은 도움을 줬다. 구글과 LLM 서비스는 프레임워크 사용에 두려움을 없앴으며, CS 지식은 문제 해결에 좋은 참조가 됐다.

This post is licensed under CC BY 4.0 by the author.

일어나야해! 넌 조선의 자존심이야! (1/2)

[WRITING] 버블 정렬로 해보는 점진적 개선: 버블몬 3단 진화