본문으로 건너뛰기

안쓰는 노트북으로 개인 서버 만들기💻-네트워크편

· 약 9분
eunsung shin
Software Engineer

얼마전 의도치 않은 AWS 요금 폭탄을 맞고(미리 요금 모니터링을 안 한 내 잘못이 맞ㄷㅏ…), 개인용 pc로 배포 서버를 구성하기로 마음 먹었다. 마침 중고로 팔려고 언젠가부터 창고 한켠에 쳐박아두었던 맥북 프로가 생각났다. 오랫동안 쓰지 않은 터라, 부팅 속도가 느리고, 프로그램 하나만 실행해도 팬 돌아가는 소리가 시끄럽긴 했지만, 꽤 잘 동작한다.

macbook.png

이 맥북을 서버로 사용하기 위해 필요한 작업을 크게 세가지로 정리했다.

  1. 서버 셋팅
  2. 외부에서 접속 가능하도록 네트워크 설정
  3. 앱 배포

서버 셋팅과 앱 배포 부분은 우선 네트워크를 구성해놓은 후 천천히 진행해도 될 것 같아서, 이번 글에서는 2번 네트워크 설정한 후기에 대해서 적어보려고 한다.


진행 방식은 다음과 같다.

  1. 홈 네트워크 구성 파악하기
  2. 외부 IP와 내부 IP 파악하기
  3. DHCP 서버에서 고정 IP 할당받기
  4. 포트 포워딩하기
  5. dDNS 등록하기

홈 네트워크 구성 파악하기

우선 맥북이 연결된 네트워크의 구성을 파악할 필요가 있었다.

네트워크 구성을 살펴보면 이렇게 그림으로 정리될 수 있다.

network_composition.png

네트워크 구성 트러블 슈팅

우리집 네트워크는 회선이 두 개다. 하나는 내 방에 랜선으로 데스크탑에 연결되어있고, 다른 하나는 거실에 WiFi를 연결할 수 있는 공유기로 연결되어 있다. 회선을 2개를 사용(아니 애시당초 왜 2개 회선인 거지…?)하고 있기 때문에 당연히 나는 구성이 데스크탑과 WIFI가 서로 별개의 네트워크 구성이라고 생각했다. 그림으로 그려보자면,

wrong_network_composition.png

이런 식으로 말이다.

결론적으로 알게 된 구성의 형태는

network_composition.png

이런 식이였다.

35.1 공유기의 포트 포워딩 설정 후에도 외부에서 접속이 불가한 이슈가 있었는데, 한참을 헤매다가 traceroute 8.8.8.8 커맨드로 35.1 공유기를 지나친 패킷이 55.1을 거쳐간다는 걸 확인하고 35.1이 55.1에 속해있다는 걸 파악할 수 있었다.

traceroute.png

그래서 이 35.1 공유기를 bridge모드로 설정하여 서브넷팅 없이 패킷만 전송하도록 설정하니 외부에서 접속이 가능해졌다.

아무튼 내 소소한 삽질이였고, 본인의 네트워크 구성도 skbroad밴드에서 회선을 두 개 사용하는 분이라면, 위에 저 그림만 참고하면 좋을 것 같다.

그런데 인터넷 회선은 결국 랜선에 연결된 55.1 하나인 것 같은데, 도대체 왜 회선 2개만큼의 비용을 지불해야하는지 의문이다. 아시는 분…?


외부 IP와 맥북 IP 파악하기

외부에서 바라보는 내 네트워크(맥북이 연결된)의 공인 IP를 우선 파악한다.

이렇게 되면

ip.pe.kr에 접속하면 내 공인 IP를 확인할 수 있다.

public_ip.png

그리고 이제 맥북의 IP를 파악해야한다. 공유기 관리자 페이지(?)에서 확인할 수도 있고, ifconfig 를 통해 확인할 수도 있다.

ifconfig.png


DHCP 서버에서 고정 IP 할당받기

지금 맥북 주소는 공유기의 DHCP 서버로부터 할당받은 동적 IP이기 때문에, 공유기 관리자 페이지에서 DHCP 서버가 이 맥북에게 고정 IP를 제공할 수 있도록 설정해줘야 한다.

dhcp_static_ip.png


포트 포워딩하기

port_forwarding.png

맥북에서 실행되는 앱을 외부에서 접근할 수 있도록 하려면, 포트 포워딩이 필요하다.

실제로 앱이 접속가능한지 테스트해보기 위해 간단한 fastapi 앱을 작성해준다.

simple_app.png

그리고, 공유기 관리자 페이지에서 외부에서 해당 포트로 접속했을 경우 맥북 IP의 로컬 포트로 연결받을 수 있도록 설정을 추가해준다.

port_forwarding_page.png

그럼 이제 외부 IP의 8000번 포트로 접속이 성사되는 걸 확인할 수 있다.

ip_sample_success.png


dDNS 등록하기

마지막으로 한가지 문제가 남았다.

맥북은 와이파이를 통해 연결이 되고, 가정용 인터넷 서비스는 대부분의 경우 동적 IP를 할당한다.(고정 IP를 할당받기 위해서는 추가적인 비용이 필요하다.) 왜 동적 IP를 할당하냐면, 인터넷 서비스 업체들(ISP)이 수익을 최대화하기 위해서라고 할 수 있다. 접속을 끊은 사용자들의 IP는 거둬들이고, 접속한 사용자들에게 다시 할당하는 방식으로 한정된 IP주소들을 최대한 활용하는 방법이다.

외부에서 계속해서 바뀌는 동적 IP 주소를 접근하도록 하기 위해서는 동적 DNS가 필요하다. DNS(Domain Name System)는 서버의 이름과 IP를 매칭해서 관리하는 시스템이라고 보면된다. 서버의 이름을 설정해놓고, 서버에 DNS가 제공하는 소프트웨어를 설치해놓으면 지속적으로 변경되는 IP를 DNS에서 추적해서 업데이트해준다. (무료로 DNS를 활용할 수 있는 사이트들이 있다.)

나는 noip.com이라는 사이트를 이용했다. 내 공인 IP와 hostname을 매칭해서 설정해준다.

noip_ddns.png

이제 dDNS에서 할당받은 hostname으로 맥북에 실행시켜둔 앱이 정상적으로 동작하는 걸 확인할 수 있다.

ddns_sample_success.png


마무리

이렇게 외부에서 접근가능한 개인서버의 네트워크 설정이 완료되었다. 이 맥북 프로를 팔았을 때 중고가격(서버로 운용한다면 사용가능한 수명이 확 떨어지지 않을까…), 24시간 운용했을 때 발생하는 전기비 등 클라우드 서비스 비용과 비교해봤을 때 개인 서버 운용이 과연 경제적인가 하는 질문에 대해서는 좀 더 지켜봐야겠지만, 서버를 구성하면서 나름 네트워크 공부도 하고 나만의 서버를 구성한다는 것 자체가 즐거웠다.

정상적으로 동작하긴 하지만, 아직 추가할 부분들이 많다. 도메인 구매, SSL설정, 도커 레지스트리 구성, CI/CD 설정 그리고 쿠버네티스 환경 구성, 모니터링, 백업 설정 등 앱 올려보면서 진행해보려고 한다.

메이플랜드의 시세는 어떻게 결정되는 걸까🍁

· 약 9분
eunsung shin
Software Engineer

이번 글은 호기심을 데이터로 구현하는 월간 데이터노트 1회차를 참여하면서 작성한 내용을 담았다.


내가 월간 데이터노트에 참여하게 된 계기

참여중인 개발자 글쓰기 모임(글또)에서 월간 데이터노트 홍보글을 봤다.

월간 데이터노트는 호기심을 데이터로 구현하여 1-2장의 결과물을 기록하는 모임이다. 예를 들어, 계절별 과일 가격은 추석이나 설날 같은 명절에 어떻게 변동하는지 추적하거나, 한강의 노벨문학상 수상을 통해 종이책 구매량은 어떻게 변했는지와 같은 일상 주제들을 데이터로써 분석해볼 수 있다.

예전부터 자주 이런 일상 주제들에 대해서 머릿속으로 나름의 가설을 세우고, 증명하기 위해서는 어떤 데이터들이 필요할지 상상해보곤 했다. 예를 들어, 퇴근시간과 같이 교통량이 몰리는 시기에 신호 패턴이 어떤 식으로 바뀌는지, 그에 따른 영향은 어떤 식으로 수치화할 수 있는지와 같은 상상.. 그래서 이런 상상을 직접 데이터를 수집해보고 결과물을 뽑아보는 실천으로 옮겨보면 재밌을 거라는 생각에 참여하게 되었다.

분석 주제 및 목적

  • 초등학교 시절 자주 하던 메이플스토리. 그 시절 메이플스토리가 최근에 다시 ‘메이플랜드’ 라는 이름으로 런칭되었다. 반가운 마음에 유튜브에 뜬 메이플스토리 플레이 영상을 보다보면 아련한 옛 추억에 잠기곤 한다.

  • 찾아보니 게임머니를 현금처럼 사용하고, 아이템을 구매하기 위해 적지 않은 금액을 기꺼이 지불하는 걸 알게 되었다.

  • 아이템 매니아(25.01.09) 기준 현재 100만메소당 5000-9000원 정도의 가격에 거래되는 것을 확인할 수 있다. 정말 비싼 아이템은 몇백만원에 거래되기도 한다.

  • mapleland.gg같은 사이트에 팝니다, 삽니다 정보를 알아보기 쉽게 정리되어있다.

    레드크리븐.png

분석 결과

“아이템 시세에 영향을 미치는 요소에는 뭐가 있을까?”

  • 시세를 어떻게 측정할 수 있을까?

    매수희망 가격을 내림차순, 매도희망 가격을 오름차순으로 정렬함.

    파는 사람은 최대한 비싸게, 사는 사람은 최대한 싸게

    순서대로 매수/매도 금액을 매칭하여 거래가 성사될 수 있는 건들에 대한 평균값을 시세로 측정.

    노작기준_매수_매도가격_비교.png

    업그레이드 없는 순정 레드 크리븐을 기준으로 시세는 2억 4889만 메소로 형성 되어있음.

    *발표 후 질문 중에 ‘실제 성사된 거래 내역을 확인해볼 수도 있을까요?’라는 질문에 깨닫게 되었는데, 시세를 확인하기 위해 먼저 고려해볼 부분은 실제 거래가 성사된 내역이라는 걸 간과하고 있었다.

  • 어떤 아이템의 시세를 살펴보면 좋을까?

    현시점(2025-01-25) 직업군 중 가장 쎈 도적의 엔드 아이템(가장 좋은 아이템) 레드 크리븐

    옵션이 좋은 매물을 기준으로 5억 메소에 매수가가 형성되어 있음. 5억 메소는 자그마치 현금 450만원(100만 메소=9000원 가정)

    레드크리븐.png

  • 아이템 스탯별 시세의 상승률을 시각화해보자

    주문서_종류.png

    • 메이플랜드에는 “주문서” 시스템이 있음. 주문서를 사용해서 아이템의 스탯을 업그레이드할 수 있는 시스템임

    • 아이템에는 보통 업그레이드횟수가 7번 정도 주어짐.

    • 주문서는 성공 확률에 따라 10%, 60%, 100%로 나뉘는데, 성공확률이 낮을수록 업그레이드할 수 있는 스탯 비중이 높아짐.

    • 공격력별 성공확률 시각화

      주문서_성공확률.png

    • 공격력별로 매수/매도 데이터를 그룹화한 후, 시세를 구한다.

    공격력별_시세비교.png

    === 시세 분석 ===
    공격력과 시세의 상관계수: 0.776
    매도 있는 최대 공격력: 12
    매수 있는 최대 공격력: 18

    === 공격력 구간별 가격 상승률 ===
    공격력 3 → 4: 0.0%
    공격력 4 → 5: 122.2%
    공격력 5 → 6: 80.0%
    공격력 6 → 7: 144.4%
    공격력 7 → 8: 11.4%
    공격력 8 → 90: 69.4%
    공격력 9 → 10: 137.3%
    공격력 10 → 11: 39.1%
    공격력 11 → 12: 9.5%
    공격력 12 → 13: 216.7%
    공격력 13 → 14: 47.4%
    공격력 14 → 15: 42.9%
    공격력 15 → 16: 6.1%
    공격력 16 → 17: 88.6%
    공격력 17 → 18: 75.0%

진행하면서 배우게 된 것/ 더 보충할 수 있을 것 같은 부분

  • 분석면에 있어서 한 문장으로 정의내릴 수 있는 명확한 결론은 나지 않은 것 같다.
  • 시세를 어떻게 정의내릴지에 대해 알아본 것은 유용했다.
  • 업그레이드 수준에 따른 시세 변화 같은 경우에 결론은 결국 ‘업그레이드가 잘 될 수록 시세는 비싸진다’ 정도에서 그친 것 같아서 좀 아쉽다. 업그레이드 횟수와 성공확률에 따라 ‘기회비용’이 얼마정도 되는지(과연 추가로 주문서 작을 하는 게 나을지에 대한 판단을 위한 수치)와 같은 분석을 추가로 진행하면 좋을 것 같다는 생각이 들었다.
  • 한 아이템에 대해서만 분석을 진행했는데, 크롤링 범위를 전체 아이템 또는 API 형식으로 확장하고 범용적으로 분석이 가능한 대쉬보드를 작성한다면 좀 더 유용한 결과물이 될 수 있을 것 같다는 생각이 들었다.

데이터출처 & 코드

  • mapleland.gg - 셀레니움으로 크롤링(코드는 chatGPT로 작성)

Django Signal 쉽게 이해하기⚡️

· 약 14분
eunsung shin
Software Engineer

Intro

django를 학습 중이다. django에서는 웹 서비스 개발에 필요한 편리한 도구들을 제공한다. 그 중 Signal은 이벤트 기반 아키텍쳐에서 강력한 도구로 활용될 수 있다. 이번 글에서는 Django Signal이란 무엇인지, 어떻게 동작하는지, 그리고 어떤 상황에서 Signal을 사용하는 것이 적절한지에 대해 심층적으로 알아보려고 한다.


Signal이 뭔가요?

signal은 django 시스템 실행 중 이벤트가 발생했을 때 실행되는 콜백 메커니즘이다. 이 시그널은 이벤트를 알리는 publisher와 알림을 받는 subscriber로 이루어진 pub/sub 구조를 통해 동작한다. publisher가 이벤트의 특정 동작 시점을 알리면, 대기하고 있던 subscriber는 그에 맞춰서 필요한 동작들을 수행한다. 이 pub/sub 구조의 장점은 느슨한 결합을 유지할 수 있다는 점인데, 예를 통해 어떤 의미인지 살펴보자.


Pub/Sub 구조

police

신호등이 없는 나라에 새로 부임받은 교통경찰이 있다. 이 교통경찰은 차량들이 지체없이 통행할 수 있도록 해야한다. 이 교통경찰은 지나가는 차량들을 일일이 붙잡고 멈춰야할 시점과 출발해야하는 시점을 알려준다. 열심히 뛰어다녔지만 혼자서는 버겁다. 그리고 간혹 말이 통하지 않는 외국인이 있는 경우에는 정보 전달을 위해 더 많은 시간이 걸렸다. 차량 퇴근시간이 되어 차량이 많아지자, 교통경찰 혼자서 차량들을 통제하는 일은 점점 더 버거워졌고, 결국은 3중 추돌 사고가 나고 말았다.

이렇게는 안 되겠다 싶었던 경찰은 신호등을 설치하기로 마음먹는다. 빨강, 노랑, 초록 싸인에 대한 설명 글귀를 큼지막하게 적어 운전자들이 확인할 수 있도록 하고, 차량들이 신호등의 신호에 따를 수 있도록 통제했다. 색깔 사인은 언어가 달라도 이해할 수 있었기 때문에 외국인도 별 무리 없이 운전이 가능했다. 만약 신호등 통제를 따르지 않는다면, 벌금을 부과하도록 단속했다. 이제 모든 차량들이 신호등의 신호에 집중해서 악셀을 밟을지 브레이크를 밟을지 결정했고, 교통 경찰은 더 이상 차량들을 하나하나 통제하지 않아도 됐다.

pubsub

이 스토리에서 교통경찰이 일일이 차량을 통제하는 건 강한 결합, 그리고 신호등을 통해 통제하는 건 느슨한 결합이라고 볼 수 있다. SRP 원칙에 따라 한 앱(모델 또는 앱)은 자신이 맡은 역할만 수행하는 게 기본적이지만, 로직상 다른 앱의 처리과정과 연관되어 있을 경우도 자주 발생한다. (예를 들어 온라인 쇼핑몰에서 상품 결제 처리 시 재고 처리, 결제 처리, 알림 처리 등이 동작해야한다.)

pub/sub 구조는 로직을 같은 컨텍스트 안에 작성하여 강하게 결합된 코드를 만드는 대신, ‘분리되어야하는 동작을 발동시키는 로직’을 ‘이벤트’로 정의하고 큐에 넣어서 처리한다. 이렇게 하면, 모델은 본 컨텍스트에 관련된 로직만 처리하고, 신호등과 같은 역할인 큐에서 컨슈머들(차량)이 알아서 가져가서 처리할 수 있도록 할 수 있다. 퍼블리셔는 자신의 행동 외에 컨슈머가 어떤 행동을 할지에 대해서 모르고, 컨슈머도 발생한 이벤트에 맞춰 자신이 어떤 행동을 해야할지만 알 뿐이다.


정리하자면, signal을 사용함으로써 얻을 수 있는 이점은

“독립적인 컴포넌트들이 독립적으로 구분되어 서로를 모르고 동작할 수 있도록 한다.”


한 가지 주의할 점은 일반적인 pub/sub 구조는 publisher와 subscriber 가운데에 queue 또는 broker를 두어 느슨한 결합을 이루지만, django signal 같은 경우 따로 queue없이 publisher, 즉 signal 자체가 브로커 역할을 한다는 점이다.

django에서 signal을 사용하는 대표적인 예는 유저 정보를 처리할 때다. django에서 제공하는 User 모델로는 충분한 정보를 담지 못 하기 때문에 일대일 대응되는 UserProfile 같은 모델로 추가 정보를 담는 경우가 일반적인데, User 생성시에 UserProfile도 함께 생성될 수 있도록, User 생성 이벤트에 UserProfile이 생성되는 시그널을 만들어 처리한다.

@receiver(post_save, sender=User) 
def create_user_profile(sender, instance, created, **kwargs):
if created:
UserProfile.objects.create(user=instance)

receiver 데코레이터는 아래 함수가 이벤트 콜백 함수임을 명시하고, 어떤 이벤트에 동작하는지 정의한다. create_user_profile이 User 모델의 post_save 시에 동작하는 콜백함수인 것을 확인할 수 있다.


signal 종류

이벤트 종류에 따라 시그널 종류가 나뉜다.

크게 ORM관련, Request와 Response 사이클 관련, Authentication 관련, 그리고 데이터베이스 연결, 테스트, 매니지먼트(manage.py 실행 관련), Admin관련, 미들웨어 관련 등으로 나눌 수 있다. 다양한 종류들이 존재하지만, ORM, Request/Response, 그리고 Authentication 관련 시그널이 많이 쓰인다고 볼 수 있다. pre, post와 같이 세부적으로 이벤트 시점 조정이 가능하고, 필요하다면 Custom Signal을 만들어 특정 이벤트에 동작할 수 있도록 구성할 수도 있다.

아래 표를 통해 어떤 signal 종류가 있는지 알아보자.


ORM 관련

Signal설명
pre_init모델 인스턴스가 초기화되기 전에 발생
post_init모델 인스턴스가 초기화된 후에 발생
pre_save모델 인스턴스가 저장되기 전에 발생
post_save모델 인스턴스가 저장된 후에 발생
pre_delete모델 인스턴스가 삭제되기 전에 발생
post_delete모델 인스턴스가 삭제된 후에 발생
m2m_changedManyToManyField관계가 변경될 때 발생
pre_migrate마이그레이션이 실행되기 전에 발생
post_migrate마이그레이션이 실행된 후에 발생

Request/Response 관련

Signal설명
request_startedHTTP 요청이 시작될 때 발생
request_finishedHTTP 요청이 끝날 때 발생합
got_request_exception예외가 발생했을 때 발생
setting_changedDjango 설정(settings.py)이 변경될 때 발생

Authentication 관련

Signal설명
user_logged_in사용자가 로그인할 때 발생합니다.
user_logged_out사용자가 로그아웃할 때 발생합니다.
user_login_failed로그인에 실패했을 때 발생합니다.
password_changed사용자의 비밀번호가 변경될 때 발생합니다.
password_reset사용자의 비밀번호가 재설정될 때 발생합니다.

어떤 식으로 사용할 수 있나요?

  1. Decorator 방식 (@receiver 사용)

가장 일반적이고 권장되는 방식이다.

# signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.contrib.auth.models import User

@receiver(post_save, sender=User)
def user_created_signal(sender, instance, created, **kwargs):
if created:
print(f'New user created: {instance.username}')

  1. connect 메서드 사용하기
python
# signals.py
from django.db.models.signals import post_save
from django.contrib.auth.models import User

def user_created_signal(sender, instance, created, **kwargs):
if created:
print(f'New user created: {instance.username}')

post_save.connect(user_created_signal, sender=User)

차이점:

  • connect는 동적으로 Signal을 연결할 수 있다.

c. Custom Signal 만들기

python
코드 복사
# signals.py
from django.dispatch import Signal

# Signal 정의
order_completed = Signal()

# Signal 수신기
def notify_order_completed(sender, **kwargs):
print(f"Order completed for: {kwargs['user']}")

# Signal 연결
order_completed.connect(notify_order_completed)

# Signal 발행
order_completed.send(sender=None, user='John Doe')

그럼 signal은 디렉터리 구조 중 어디에 위치하는 게 좋을까?

일반적으로 다음과 같은 구조를 사용한다.

myapp/
├── __init__.py
├── models.py
├── views.py
├── signals.py <-- Signal 정의
├── apps.py <-- Signal 등록
└── admin.py

(apps.py에 시그널을 등록해야만 활성화된다는 점도 잊지말자)

# apps.py
from django.apps import AppConfig

class MyAppConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'myapp'

def ready(self):
import myapp.signals # Signal import


어떻게 동작하나요?

signal의 동작 방식은 생각보다 단순하다. django.dispatch.dispatcher.py의 Signal의 connect 함수를 통해 확인 할 수 있다.

with self.lock:
self._clear_dead_receivers()
if not any(r_key == lookup_key for r_key, _, _ in self.receivers):
self.receivers.append((lookup_key, receiver, is_async))
self.sender_receivers_cache.clear()

lock이 걸린 상태에서 connect함수가 호출될 경우, Signal 인스턴스에 정의된 receivers를 순회하며 해당 signal에 대해 어떻게 반응해야할지 찾는다. 그리고 기본값으로 설정되어 있는 weak=True는 receiver가 호출 후, garbage collector에 의해 수거되어 메모리를 낭비하지 않아도 될 수 있도록 약한 참조를 할 수 있게 돕는다.

if weak:
ref = weakref.ref
receiver_object = receiver
# Check for bound methods
if hasattr(receiver, "__self__") and hasattr(receiver, "__func__"):
ref = weakref.WeakMethod
receiver_object = receiver.__self__
receiver = ref(receiver)
weakref.finalize(receiver_object, self._remove_receiver)

어떨 때 사용하면 좋을까요?

장점과 단점을 알고 상황에 맞게 사용하는 것이 중요하다. 위에서 언급한 것처럼 signal을 사용하면서 얻을 수 있는 장점은 느슨한 결합이다. 책임이 분리되어있기 때문에 코드가 깔끔해지고, 유지보수가 쉬워진다는 장점이 있다. 하지만 물론 단점도 있다. 코드 흐름을 알기 어렵다. two scoops of django 책에는 “그러므로 시그널을 받는 리시버가 어떤 것인지조차 알 필요가 없는 경우라면 그 때 시그널을 활용하라”라는 조언이 있다. 그리고, signal은 “동기화되고 블로킹을 일으키는 무거운 프로세스를 호출한다. 확장과 성능 면에서 어떤 장점도 찾아볼 수 없다”라는 단점이 있기도 하다. 그러므로 signal을 과도하게 사용하기보다는 정말 필요한 경우에만 사용하도록 하고, 무거운 작업일 경우, celery를 사용하는 등 비동기 처리로 옮기는 게 더 바람직하다.


Outro

이번 글에서는 django Signal에 대해 알아보았다. signal이 어떤 장점을 가지고 있는지, 어를 통해 어떤 기능을 구현할 수 있는지, 그리고 어떻게 동작하는지와 언제 사용하면 적합한지에 대해 살펴보았다. 모쪼록 django를 공부하는 분들께 도움이 되었으면 좋겠다.

오픈소스 첫 발자국 떼기🌱

· 약 12분
eunsung shin
Software Engineer

Intro

2024년이 끝나간다. 연초에 이루고 싶은 여러 목표들을 적어두었었는데, 그 중 하나는 '오픈소스 기여하기'였다. 미루고 미루다가 24년이 얼마 안 남은 시점에 그 목표를 시도해보려고 한다. 전에 해본 적이 없으니 어디서부터 시작해야할지 막연한 마음이 컸고, 이런 오픈소스 기여같은 작업은 뭔가 대단한 작업이라 내가 할 수 있을까 하는 생각 때문에 미루게 된 것 같은데, 우선 해보는 게 중요하지 않을까. 이번 글은 그래서 PR을 제출하는 과정에 있어서 어떤 생각의 흐름을 가지고 진행했는지에 대해서 써보았다.



오픈소스란?

소스 코드가 공개되어 있어 누구나 자유롭게 사용, 복사, 수정, 배포할 수 있는 소프트웨어를 말한다. 오픈소스의 장점은 모두에게 수정할 기회가 열려있다보니, 코드에 대해서 활발하게 의견 교류가 이뤄지고, 더 좋은 코드로 발전할 가능성이 높아진다는 점이다. 오픈소스 코드 작성에 기여하면서 얻을 수 있는 점은 다른 개발자들의 코드를 읽고 배우면서 개발 실력을 기를 수 있다는 점이다.



오픈소스 기여 팁

본격적으로 시작하기 전, 오픈소스 기여 관련 다양한 자료를 얻을 수 있었다. 다음과 같은 조언을 얻었다.

  • 어떤 오픈소스를 고를 것인가? => 대단한 게 아니여도 된다. 비교적 코드가 복잡하지 않은 리포지토리부터 시작해보자
  • 시작을 어떻게 하면 좋을까? => 자주 사용하는 라이브러리 또는 프레임워크
    => 리포지토리의 이슈 항목을 살펴보자. 보통 오픈소스는 컨트리뷰트를 어디서부터 시작하면 좋을지 안내하는 good first issue 라벨이 있다.
    => 전체 코드를 한 번에 이해하려 하지 말고, 관심있는 컴포넌트를 하나 선택해서 거기서부터 시작하는 게 좋다.
  • 어떻게 코드를 읽어야할지? => 우선 README부터 읽기
    => 시작 포인트를 찾아서 거기서부터 타고들어갈 것
    => 한줄 한줄 다 읽어볼 것
    => 리포지토리의 TODO 부분을 확인하고 기여할 수 있는 부분을 확인해볼 것
    => 테스트 코드 활용하기
    => 코드를 읽다가 이해가 안되는 부분이 있다면, 관련 PR을 찾아보는 것도 방법
    (배두식님의 PyCon KR 2023 오픈소스와 함께 성장하기를 참고했다.)


오픈소스 선정하기

위 팁들을 기반으로 요새 사용중인 웹 프레임워크인 django의 라이브러리 중 django-faker라는 라이브러리에 기여해보기로 마음먹었다.
django-faker는 django model의 값들을 랜덤으로 생성해주는 역할을 한다.
로직이 자료형에 알맞는 랜덤값만 생성하면 된다는 점에서 코드 파악을 위한 피로감이 다른 리포지토리보다 덜 하지 않을까하는 생각이 들었다. 한 가지 문제는 마지막 update가 2016년... 무려 8년전... 거의 업데이트가 더 이상 없는 라이브러리라는 점이였다. 하지만, 목표가 '오픈소스 기여하기'인만큼 이 또한 좋은 경험이 될 거라는 생각이 들었다. PR에 대한 피드백이 늦을 수 있다는 점만 감안하면 될 듯 했다.
django-faker의 커밋 히스토리



코드 파악하기

README.md를 우선 한 번 훑었다. README에는 어떻게 모듈을 사용할지에 대한 예제가 있어서 이해가 조금 더 수월해졌다. 그리고 테스트 코드를 보면서 어떻게 동작하는지 파악할 수 있었다. django-faker는 django Model의 field 타입을 예측하는 Guessor, 타입에 맞춰 값을 랜덤하게 생성하는 Generator, 생성한 값의 포맷팅을 담당하는 Formatter, 모델에 생성한 랜덤값을 주입하는 Populator로 이루어져 있다. 이에 맞춰서 테스트 코드도 각 부분들을 테스트하는 것을 확인할 수 있었다. django-faker의 동작방식



기여 목표 선정하기

django-faker는 업데이트된지 꽤 오래되었다. 하지만 해당 모듈이 의존하는 djangofaker는 계속 업데이트가 이뤄지고 있었다. 그래서 django-faker가 현재 버젼이 커버하지 못하는 새로운 django의 model field들이 있을 거라고 가정을 세우고, 이 부분에 코드를 추가해보기로 마음먹었다. 그리고 다음과 같이 목표를 세웠다.

  • django-faker에서 정해놓은 PR checks를 우선적으로 통과할 것.
  • 메인테이너 또는 오너가 내가 올린 PR을 merge시켜주는 것. (워낙 오래된 라이브러리라 리액션이 늦게 돌아올 수도 있지만, 우선은 해보자)


기여할 부분 찾기

우선 보통 기여할 부분을 찾는 방법은 이슈를 살펴보거나, 모듈 사용중에 불편함을 겪은 부분에 대해 개선방법을 제시하는 식으로 진행한다는 점을 기억하자. (우리는 전략적으로 django-faker가 업데이트가 오랫동안 이뤄지지 않았다는 점을 이용하는 거다.) django-faker의 마지막 업데이트 일자인 2015년을 기점으로 django에는 어떠한 모델 관련 업데이트가 있었는지 확인해보았다.(https://docs.djangoproject.com/en/5.1/releases/) 여러 변경사항들중에 2.0을 기준으로 시간 간격을 저장하는 DurationField 필드가 추가된 것을 확인할 수 있었다! 우선 내 계정으로 리포지토리를 fork하고 로컬에 clone했다.
repo_fork
실제로 django-fakerDurationField를 처리하지 못하는지 확인할 필요가 있었기 때문에, DurationField를 사용하는 모델을 하나 만들고, 테스트 코드를 작성해준다.

class GamePlayTime(models.Model):
game = models.ForeignKey(Game, on_delete=models.CASCADE)
player = models.ForeignKey(Player, on_delete=models.CASCADE)
duration = models.DurationField()
class TestDurationField(unittest.TestCase):
def testGamePlayTime(self):
generator = fake
populator = Populator(generator)
populator.addEntity(Game, 10)
populator.addEntity(Player, 10, {
'game': lambda x: Game.objects.order_by('?').first()
})
populator.addEntity(GamePlayTime, 10, {
'game': lambda x: Game.objects.order_by('?').first(),
'player': lambda x: Player.objects.order_by('?').first()
})
insertedPks = populator.execute()
self.assertEqual(len(insertedPks[GamePlayTime]), 10)

테스트가 django-faker에서 발생하는 AttributeError로 테스트를 통과하지 못 하는 걸 확인했다.


DurationField에러

새로운 코드를 반영하기 위한 브랜치를 생성한다. 브랜치 이름은 나름 비장하게 eature/enhance-django-support 그리고, 테스트 코드를 통과하기 위해 FieldTypeGuesserDurationField를 처리할 수 있도록 코드를 추가해주었다. (django와 faker의 버젼에 맞춰 코드를 업데이트해줄 필요가 있었는데, 이 부분에서 의외로 시간을 많이 사용했다.)


DurationHandling코드추가 이렇게 작성한 코드를 바탕으로 테스트를 다시 실행시켜준다.
테스트를 잘 통과한다..!
테스트를 내가 임의로 바꾸는 건 좋지 않은 방법이지만, 오랫동안 관리되지 않은 부분에 대한 업데이트를 적용하기 위한 테스트이기 때문에 우선 적용하기로 했다.



Pull Request 작성하기

테스트도 무사히 잘 통과하니, 이제 마지막으로 메인 리포지토리에 풀리퀘스트를 날려준다!! PR에는 어떤 문제를 찾았고, 그 문제를 해결하기 위해 어떤 변경사항을 적용했는지 코드를 보지 않더라도 의도와 변경사항이 명확히 전달될 수 있도록 작성하는 게 중요하다. 작성한 PR은 여기에서 확인 가능하다. PR작성



Outro

이렇게 첫 오픈소스 기여하기를 진행해보았다. 나름 단순한 코드 리포지토리였지만, 소스코드를 살펴보는 과정, 업데이트 릴리즈 노트를 확인하는 과정, 라이브러리 호환성을 맞추는 과정, PR을 잘 작성하는 과정 등 생각보다 신경쓸 것이 많았다. 하지만, 오픈소스 기여라는 것이 내가 엄두조차 못 낼 정도로 대단한 건 아니라는 걸 알게 되었다. 다음에는 버그를 찾고, 픽스해보는 PR을 날려보자! 2024년도 며칠 안 남았다. 연초에 다짐했던 목표 중 하나를 이렇게 글을 쓰면서 달성할 수 있어서 다행이다!

AI해커톤 2024 후기

· 약 9분
eunsung shin
Software Engineer

[해커톤 리포지토리 link]
항해 AI 해커톤에 참여했다. 몇 팀 참여하지 않긴 했지만, 기분 좋게 우승도 했다. 이번 글에서는 회고겸, 어떤 기능들을 구현했는지, 만약 시간이 더 주어진다면 어떤 부분들을 더 손볼 수 있을지 적어보려고 한다.

주제는 'AI를 통해 삶과 일의 균형을 찾아라'였다. 금요일 저녁 7시부터 다음날 오후 2시까지 진행되었다. 우리팀은 AI 개발자 1명, 백엔드 3명, 그리고 프론트 2명으로 구성되었다. 다들 의견 제시와 역할 분담에 적극적이여서 초반부터 끝날 때까지 신나게 진행할 수 있었다.

우리 팀이 정한 서비스 주제는 '채용담당관 입장에서 수많은 이력서를 검토하면서 느낄 수 있는 피로감을 줄이기 위해 이력서 정보를 LLM으로 요약정리해주는 서비스'였다. 시연영상.gif

메인 기능은 요약하자면 다음과 같다.

  1. 이력서 정보 추출 및 벡터DB에 저장(input: PDF file, output: 벡터DB저장 정보)
  2. 엠베딩 기반 질문 생성 기능(input: 이력서 ID, output: 이력서 관련 생성 질문)
  3. 자연어 기반 벡터DB 쿼리 기능(input: 프롬프트, output: list[이력서ID])

좀 더 자세히 설명하자면,

  1. 이력서 정보 추출 및 벡터DB에 저장
  • PDF 이력서 업로드 시, 텍스트를 추출하여 요약정리 후 벡터DB에 저장한다. 이력서에서 뽑을 수 있는 공통된 정보들을 정형화하여(지원자 이름, 직군[백엔드, AI, 프론트, 풀스택], 연차, 사용 언어) 벡터DB의 메타정보에 저장해두었다. Pydantic으로 만든 스키마가 있다면, 이에 맞춰 정보를 뽑아주는 langchain의 with_structured_output 함수를 사용했다.
  1. 엠베딩 기반 질문 생성 기능
  • 미리 저장해둔 이력서의 엠베딩 정보를 기반으로 질문을 생성한다. 질문의 종류는 직군별, 컬쳐핏, 경험, 프로젝트 질문으로 나뉜다. 예를 들자면, 이력서에 OCR 프로젝트에 대한 내용이 있다면, 해당 프로젝트에 대해 어떤 기여를 했는지, 어떤 어려움이 있었는지에 대한 질문을 생성해준다.
  1. 자연어 기반 벡터DB 쿼리 기능
  • 채용담당관이 적은 요구사항(프롬프트)을 기반으로 매칭되는 이력서ID들을 반환한다. 요구사항에 있는 메타정보로 우선적으로 필터링한 후, similarity search를 진행한다. (langchain에서는 이 과정을 query-construction chain이라고 명칭되어있다.)

생각보다 밤을 새면서 개발하는 과정은 쉽지 않았지만, 팀원들이 다 열심히 해준 덕분에 해가 뜰 무렵, 가시적인 결과물인 나오기 시작했다. 미리 MockAPI를 작성해둔 덕분에 각 도메인(프론트, 백엔드, AI)에서 따로 작업을 하긴 했지만, API 연동도 나름 순탄하게 진행되었다. 11월달에 들어 개발하면서 가장 뿌듯한 순간이였다. 그리고 해커톤 일주일이 지난 시점, 만약 시간이 더 주어진다면 어떤 부분을 더 개선해볼 수 있을까 고민했다. 구현한 기능은 LLM을 활용한다는 점에서 특수할 수는 있으나, 결국 데이터를 저장하고, 잘 읽어오는 과정이다. Store와 Retriever 객체로 추상화하여 틀을 만들어 변경사항에 유연하게 대처할 수 있도록 한 다음에 LLM을 활용해서 할 수 있는 여러 가지 시도들을 해볼 수 있을 거라는 생각이 들었다.

여러 가지 시도들을 하기 전에 가장 먼저 해야할 일들은 평가 지표를 설정하는 것이다. 시도들에 대한 객관적인 지표가 있어야만 서비스에 알맞는 선택을 할 수 있기 때문이다.

평가 프로세스 구축

현재 시스템의 성능을 객관적으로 측정할 수 있는 평가 체계가 필요하다. 현재 프로세스는 이력서를 요약하고 저장하고 쿼리하여 읽어오는 방식이다. 요약, 저장, 쿼리 각각의 과정에 대해 평가 지표를 구성할 수 있다. 그리고, 이 과정에서 사용자의 피드백을 반영할 수 있도록 한다면 더욱 좋을 거라고 생각한다.

  • 이력서 요약의 정확성 평가
  • 생성된 질문의 품질 평가
  • 검색 결과의 정확도 및 관련성 평가
  • 사용자 피드백 시스템 구축

이 밖에도 찾아보니 LLM관련 개선할 부분들이 굉장히 많았다. 시간은 좀 걸리겠지만, 천천히 이 부분들에 대해서도 알아보려고 한다.

임베딩 최적화

현재는 이력서 내용을 요약한 후 임베딩을 수행하고 있는데, 이는 중요한 정보의 손실을 초래할 수 있다.

  • 전체 이력서 내용에 대한 섹션별 임베딩 수행
  • 주요 키워드와 문맥 정보를 보존하는 청크 단위 임베딩
  • 다중 임베딩 모델의 앙상블 적용

검색 성능 향상

프롬프트 기반 검색의 정확도를 높이기 위한 방안:

  • 하이브리드 검색 구현 (키워드 + 시맨틱 검색)
  • 검색 결과 재순위화(Reranking) 도입
  • 사용자 피드백을 반영한 검색 결과 개선

데이터 저장소 최적화

메타데이터 저장 및 관리 전략:

  • RDB와 벡터DB의 하이브리드 구조 검토(어디까지 메타정보로 저장해도 될까? 어디서부터 RDB를 활용하는 게 좋을까?)
  • 메타데이터의 인덱싱 전략
  • 캐싱 레이어 도입 검토

데이터 검증 및 예외처리

안정적인 서비스 운영을 위한 개선사항:

  • 입력 데이터 유효성 검사 강화
  • PDF 파싱 예외상황 대응
  • 정형화된 데이터 스키마 검증 로직 추가
  • 에러 로깅 및 모니터링 시스템 구축

이번 글에서는 11월 15일부터 16일까지 무박 2일로 참여한 항해 AI 해커톤 회고 글을 작성해보았다. LLM을 활용한 실제 서비스를 구현해보면서, LLM의 활용 가능성에 대해 다시 한 번 감탄하지 않을 수 없었다. 그리고, 실제 사용자들이 사용하는 서비스를 만들기 위해서 많은 부분들을 개선할 수 있을 거라는 생각이 들었다.

외부 API 다루는 방법(feat. Rate Limit)

· 약 11분
eunsung shin
Software Engineer

TLDR

  • Muze라는 음악 SNS를 개발하고 있다.
  • 노래 정보 검색 기능이 필요한데, 데이터가 부족한 상황이라 검색 결과가 좋지 않다.
  • 필요한 데이터를 바로 검색 후 수집할 수 있도록 외부 API를 활용한다.
  • 이 외부 API에 문제가 생기면, 앱에도 문제가 생긴다. 그리고, API에는 Rate Limit이 걸려있어서 요청 수가 제한된다.
  • 외부 API의 의존성을 최소화하기 위해 fallback으로 사용할 API를 여러 개 두고, Token Bucket 알고리즘을 사용하여 요청을 분산시킨다.

서비스를 개발하면서 A-Z까지 전부 다 구현하기란 어렵다. 구현의 난이도 뿐만 아니라 효율면에서 부적절하다. 그래서 우리는 외부 API를 호출한다. 원하는 기능을 편리하게 사용이 가능하다. 한 가지 문제점이라면, 외부의 의존성이 생김으로써 내부의 문제가 아닌 외부의 문제로 서비스의 결함이 발생할 수도 있다는 점이다. 예를 들어, 쇼핑몰 서비스에서 외부 결제 시스템의 문제로 상품 결제가 이루어지지 않는다면, 실질적인 손해로 이어질 수 있다.

진행중인 사이드 프로젝트 Muze는 음악 취향을 공유하는 웹서비스이다. 자신이 좋아하는 노래를 게시하고 공유하는 기능이 메인 서비스다. 이 서비스를 위해서는 노래 정보들을 데이터베이스에 저장하고 검색하는 기능이 필요한데, 프로젝트의 규모상 '검색이 가능한 노래'의 범위를 정하기가 애매했다. 물론 적당히 구할 수 있는 노래들을 읽어와서 저장하는 방법도 있겠지만, 실제 사용자들이 사용하기에 매끄러운 서비스를 만들고 싶었다.

그래서 생각한 방법은 사용자가 검색 시에 음악 플랫폼(Spotify, Shazam 등)의 검색 API를 활용해서 결과를 리턴해준다. 음악 정보가 서버 내에 당장 없더라도, 정보를 반환해줄 수 있게 되는 것이다. 하지만 이런 처리는 외부 API에 대한 서버의 의존성이 생기게 된다. 우리 서버가 정상적으로 동작한다고 해도, Spotify의 서버에 문제가 생기면 고스란히 그 문제가 우리 서버의 문제로 돌아온다.

외부 API를 활용하는 것은 이미 결정된 사항이기 때문에 이 상황에서 내릴 수 있는 차선의 선택을 하는 것이 중요하다. Spotify 서버에 문제가 생긴다면, Shazam 서버를 활용하는 건 어떨까? 두 플랫폼 모두 문제가 생길 일은 극히 드물 것이다. 인풋 형식과 아웃풋 형식을 맞춰서 추상화된 RequestHandler 클래스를 작성한다.

from abc import ABC, abstractmethod

class RequestHandler(ABC):
def __init__(self, api_client):
self.api_client = api_client

@abstractmethod
def search(self, keyword, category):
return 노래정보

class SpotifyAPIRequestHandler(RequestHandler):
...

class ShazamAPIRequestHandler(RequestHandler):
...

class YoutubeAPIRequestHandler(RequestHandler):
...

노래 검색을 위해 활용할 API가 세 개나 생겼다. 노래 검색을 위해 활용할 API가 세 개나 생겼다. 그럼 이제 들어오는 요청들을 이 API들에게 어떻게 분배해줄지 설정해주어야한다. 요구사항은

  1. 결과가 나오지 않을 경우(response.status_code !=200), 다른 API로 요청을 넘긴다.
  2. 각각의 API의 Rate Limit을 초과하지 않는다.
  3. 사용자 경험을 고려하여 최대한 빠른 속도로 결과를 리턴해준다. 로 정리가 가능하다.

이 요구사항을 충족시키는 방법은 Token Bucket알고리즘을 활용하여 Rate Limiting을 적용하는 방법이다. TokenBucketAlgorithm.png

토큰 버킷 알고리즘은 토큰을 기록하여 사용량을 조절한다. 토큰은 시간에 따라 일정량씩 채워지고, 등록된 함수의 호출 시점에 토큰을 사용한다. 만약 시간당 채워지는 토큰보다 더 많은 호출이 이뤄지면 잠시동안 함수 호출이 멈추는 원리이다.

TokenBucket 클래스와 이를 관리하는 APIManager 클래스를 만들어 토큰 버킷 알고리즘을 구현해준다. 각 API당 할당된 Rate Limit 정보를 기반으로 rate(초당 버킷에 채워지는 토큰 수)과 capacity(버킷이 담을 수 있는 토큰의 최대수)을 설정한다.

그리고, 서버 접속 사용자 수에 따라 프로세스가 추가된다는 점을 상기해야한다. 프로세스 간의 APIManager 인스턴스는 독립적인데, API 호출은 공유하기 때문에 이를 인스턴스 간 정보들을 공유할 수 있도록 설정해주어야한다. 인메모리의 Redis를 사용해서 rate, capacity, token 정보를 공유하도록 설정한다.

rate, capacity 설정을 위해서 API 문서에서 Rate Limit에 대한 정보들을 살펴보았다. Spotify같은 경우에는 Rate Limit을 30초동안 쌓인 요청수를 기준으로 잡는다는 이야기만 있고 최대 요청량은 적혀있지 않았다(내가 못 찾았을수도...). 그래서 concurrent.future 모듈을 활용하여 30초동안 보낼 요청 수를 조정하여 실험한 후 최대 요청량을 가늠하였다.

# num_worker=1,2,3,4,5, ...,9,10
[{'num_requests': 158, 'num_success': 158, 'num_failures': 0}, {'num_requests': 302, 'num_success': 301, 'num_failures': 1}, {'num_requests': 533, 'num_success': 307, 'num_failures': 226}, {'num_requests': 774, 'num_success': 311, 'num_failures': 463}, {'num_requests': 992, 'num_success': 342, 'num_failures': 650}, {'num_requests': 1208, 'num_success': 356, 'num_failures': 852}, {'num_requests': 1474, 'num_success': 414, 'num_failures': 1060}, {'num_requests': 1659, 'num_success': 390, 'num_failures': 1269}, {'num_requests': 1973, 'num_success': 405, 'num_failures': 1568}, {'num_requests': 2243, 'num_success': 402, 'num_failures': 1841}]

스포티파이 API는 30초동안 대략 300 - 400개 정도의 요청을 받아준다. 대체 API인 Shazam과 Youtube도 비슷한 수량의 요청을 받아준다고 할 때, 대략 우리 서버는 30초동안 1000개의 검색 요청을 처리할 수 있다는 이야기이다. 이 얘기는 Muze 앱 운영 중에 1001명의 유저가 검색을 한 번씩 하는 경우가 30초의 시간동안 한 번이라도 발생할 경우, 다운타임이 발생할 수도 있다는 이야기이다. (물론 300명의 유저가 생길지 안 생길지조차 모르는 상황이긴 하지만, 30초동안 검색 한 번은 너무 짜다. 보통 한 유저당 한번 이상의 검색을 한다.)

이런 문제는 검색이 호출될 때 백그라운드로 해당 노래 정보를 데이터베이스에 저장하는 식으로 구성한다면, 시간이 지남에 따라 이런 문제들은 해결될 수 있을 거라고 생각했다. 한 번 이상 검색된 정보는 데이터베이스에서 호출되는 방식이기 때문에, 시간이 지날수록 외부 API에 대한 의존도가 낮아질 것이라고 기대했다. 여기에다가 추가적으로 백그라운드 스케쥴러로 최신곡에 대한 크롤링을 주기적으로 수행한다면, 외부 검색 API의 의존성을 최소화하고, 괜찮은 사용자 경험을 제공해줄 수 있게 된다.

이번 글에서는 Rate Limit이 설정되어있는 API를 다루기 위해 고려해야할 부분들과 안정적으로 처리할 수 있는 Rate Limit 기법에 대해서 알아보았다. 검색 기능은 Muze에서 꽤 중요한 기능이다. 이런 중요한 기능을 외부 API에 의존해야한다는 점이 고민되긴 했지만, 부족한 데이터를 다 커버할 수 없는 초기 개발 상황에서 차선의 선택이 필요했다. 외부 API에 의존하되, 운영 시 생길 수 있는 의존성을 최소한으로 줄이고, 데이터가 쌓임에 따라 그 의존성을 아예 없앨 수 있다면, 꽤 괜찮은 시나리오라고 생각한다.

GIL이 뭐길래

· 약 12분
eunsung shin
Software Engineer

Introduction

지난 10월 7일, 파이썬 3.13이 릴리즈되었습니다. 변경사항 중 가장 주목할만한 내용은 GIL을 키고 끌 수 있도록 변경한 부분이였습니다. 정식으로 GIL이 완전 제거 결정을 내리기까지는(제거하지 않기로 결정할 수도 있음) 약 5년정도의 시간이 걸린다고 하지만, 멀티쓰레딩이 어려웠던 기존의 파이썬을 생각하면 엄청난 변화라고 할 수 있을 것 같습니다. 이번 글에서는 GIL이 무엇인지, 왜 등장했고 왜 제거하기로 한 건지에 대해 알아보겠습니다.

GIL이 무엇일까요?

GIL이 무엇인지 이해하기 위해서 우선 우리가 작성한 파이썬 코드가 어떤 식으로 돌아가는지 알아야합니다. 컴퓨터의 기본 동작 방식은 다음과 같습니다. 보조기억장치(하드 드라이브)에 저장된 코드를 읽어 메모리 공간(RAM)에 프로세스로써 올리고, CPU는 레지스터의 도움을 받아 RAM에 있는 명령어들을 처리합니다. RAM에 적재된 코드는 컴퓨터가 이해할 수 있는 언어로 번역(소스코드 ->바이트코드->기계어)되어야합니다. 언어마다 다르지만, 파이썬은 작성한 코드를 메모리에 올릴 때마다 인터프리터가 코드 한줄 한줄 이 번역을 수행해주는 인터프리터 언어입니다. 인터프리터는 사람이 작성한 코드를 CPU에서 수행할 수 있게 번역해주는 역할을 한다고 보면 됩니다. 그리고, GIL(Global Interpreter Lock)은 프로세스당 하나의 쓰레드만이 이 인터프리터의 제어권을 갖고 명령을 수행할 수 있게 하는 락(Mutex)입니다. 동일한 프로세스라면, 여러 쓰레드가 존재해도, 이 락을 소유하기 전까지는 명령을 수행할 수 없습니다. GIL은 멀티코어로 구성되어있는 최근 CPU들을(2024년 기준 인텔 i7은 최대 20개의 코어, 애플 M3는 최대 40코어 보유) 멀티 쓰레딩으로 활용할 수 있는 이점을 제약합니다.

GIL은 왜 등장한 것일까요?

그렇다면 왜 파이썬에 GIL이 등장한 걸까요? 정답은 다수의 쓰레드를 사용하면서 생길 수 있는 Race Condition을 예방하기 위해서입니다. 더 자세히는 파이썬의 메모리 관리 방법과 연관되어있습니다. 파이썬에서는 Garbage Collection에 (Heap영역에서 더이상 사용되지 않는 객체들을 삭제) Reference Counting을 사용합니다. 어떤 객체의 Reference Count가 0이상이라면, Garbage Collection 대상에서 제외하고, 0에 도달한다면, 더 이상 사용하지 않는다고 판단하고 제거하는 식입니다. 그런데 이런 Reference Counting 방식은 같은 메모리 공간을 공유하는 쓰레드들끼리 같은 자원에 접근하는 Race Condition이 발생했을 때 문제가 생길 수 있습니다. Reference Counting에 대한 동시 접근으로 인해 삭제되어야할 객체가 삭제되지 않거나 유지되어야할 객체가 삭제되는 경우가 발생할 수 있습니다. 이런 Race Condition을 방지하기 위해 GIL이 탄생한 것입니다. (Java같은 경우, 특정 시점을 기준으로 garbage collection을 수행합니다.)

파이썬이 멀티코어를 활용하는 방법

GIL이 있다고 해서 파이썬이 멀티코어를 아예 활용하지 못 하는 건 아닙니다. 파이썬은 웹 프레임워크나 머신러닝 등 다양한 분야에서 활용되는 언어입니다. 파이썬이 멀티코어를 활용하는 방법은 크게 세 가지로 나뉠 수 있습니다.

  1. 멀티 프로세싱
  2. C나 C++로 작성한 코드
  3. CPython이 아닌 다른 인터프리터(PyPy, Jython과 같은) 사용 (추가로 GIL때문에 멀티쓰레드가 아예 의미가 없는 건 아닙니다. I/O작업시에 GIL은 자동으로 해제되기 때문에 I/O bounded 작업에서는 멀티 쓰레드가 동작한다고 볼 수 있습니다.)

파이썬 멀티코어 활용법1. 멀티프로세스

멀티프로세싱을 활용하면 멀티 코어를 온전히 사용할 수 있습니다. 그림으로 그리면 이런 식입니다. GIL에 의해 제약받지 않을 수 있도록 각 프로세스마다 별도의 인터프리터를 둡니다. 웹 게이트웨이 인터페이스인 gunicorn은 일반적으로 위에 보는 것과 같이 멀티 프로세싱 방식으로 동작합니다. 여기서 한 가지 알아야할 점은 프로세스는 개별적인 메모리 공간을 요구하기 때문에 멀티쓰레드에 비해서 메모리 공간을 더 차지합니다. 그리고 이 메모리 공간 때문에 메모리를 공유하는 쓰레드에 비해 컨텍스트 스위칭 비용이 발생합니다.

파이썬 멀티코어 활용법2. C,C++ 작성 코드

파이썬에서는 C나 C++로 작성된 코드를 수행할 수 있도록 C API를 제공합니다. 파이썬 인터프리터가 C 코드를 확인하면 제어권을 넘기는 방식입니다. 이렇게 되면 GIL 상관없이 C나 C++ 코드에서 사용하는 것처럼 멀티쓰레딩을 사용할 수 있습니다. 대표적으로 numpy나 pandas와 같은 데이터과학 모듈이 이 방법을 활용합니다. C 작성 코드의 빠름과 유연함을 활용할 수 있다는 점이 장점이지만, C, C++ 데이터 구조에서 Python 구조체로 변경하기 위해 어느 정도의 오버헤드는 발생할 것입니다.

파이썬 멀티코어 활용법3. CPython이 아닌 다른 인터프리터 사용하기

CPython이 표준 인터프리터이긴 하지만, PyPy나 Jython, IronPython 등 다른 인터프리터들도 존재합니다. GIL은 CPython 안에 존재하는 락으로, 만약 다른 인터프리터를 사용한다면 쓰레드 제약에 대해 더 이상 신경쓰지 않아도 됩니다. (물론, 다중 쓰레드 사용에 따른 공유 자원의 Race Condition을 직접 신경써줘야하겠죠) CPython이 아닌 다른 인터프리터를 직접 사용해본 적은 없지만, 필요하다면 인터프리터의 장단점을 확인하여 상황에 맞게 적절히 사용해볼 수 있을 것 같습니다.

3.13은 GIL 없이 어떻게 동작하는 걸까?

그럼 여기서 떠오르는 궁금증은 이미 잘 돌아가고 있는데, 왜 이제서야 바꾸는 걸까 입니다. 2007년에 파이썬의 창시자 귀도 반 로썸이 게시한 글에는 멀티 코어가 등장하면서, GIL을 제거하는 방안을 고민해보았지만, 딱히 성능적으로 이점이 없었다는 얘기가 있습니다. GIL을 제거하면 CPU위주의 태스크를 수행하는 멀티 쓰레딩 프로그램에서는 확실히 이점이 있겠지만, 기존의 싱글쓰레드 프로그램에서 성능적으로 손실이 있었기 때문에 정식으로 반영되지 않았다고 합니다. 실제로 GIL을 제거하려는 여러 프로젝트(대표적으로 Giletomy)들이 있었지만, 3.13까지는 정식적으로 반영될 정도로 효과적이진 않았던 것 같습니다. 하지만 GIL 없이 멀티 쓰레딩을 사용가능하다면, 확실히 개선할 수 있는 포인트들이 있을 것입니다. 쓰레드 간 메모리를 공유한다는 점을 활용하면 메모리 공간을 더 효율적으로 사용할 수 있고, 멀티프로세스 사용시 프로세스 간에 자원을 공유해야하는 부담도 덜 수 있습니다. 이 GIL 제거는 기존의 reference counting을 biased referencing, immortialization을 통해 가능해졌다고 합니다. 자세한 사항은 글이 길어져 생략하지만, 락을 걸지 않고, 어떻게 쓰레드 간의 Race Condition을 해결할지에 초점을 두고 키워드들을 살펴보시면 좋을 것 같습니다.

Outro

지금까지 GIL이 무엇인지, 왜 파이썬에 GIL이 생긴 건지, 그리고 파이썬에서는 이런 제약을 어떻게 해결해왔는지에 대하여 알아보았습니다. 기존의 파이썬 생태계에서 어느정도의 최적화가 이루어진 상황이지만, no gil 도입을 통해 어떤 식으로 더 개선될 수 있을지 기대됩니다.

DEVIEW 2023 "ML/AI 개발자를 위한 단계별 Python 최적화 가이드라인"을 보고

· 약 15분
eunsung shin
Software Engineer

영상 링크 : ML/AI 개발자를 위한 단계별 Python 최적화 가이드라인

유튜브 추천 동영상에 흥미로운 영상이 올라왔다.


"ML/AI 개발자를 위한 단계별 Python 최적화 가이드라인"

네이버 파파고 OCR팀 문주혁 님의 DEVIEW 2023 영상이다.

OCR, 이미지에서 텍스트를 탐지하는 단계에서, 텍스트 박스 갯수가 많아지면 속도가 느려지는 이슈를 어떻게 해결했는지에 대해 이야기한다. 전에 일했던 회사에서 똑같이 겪었던 이슈였기 때문에 집중해서 봤다. 영상을 보고 내 나름대로 문제 해결을 위해 발표자분이 어떤 식으로 접근했는지 정리해보았다. 기술적인 부분보다는 문제를 해결하기 위한 자세에 초점을 두고 글을 작성했다.

https://velog.velcdn.com/images/silvercity/post/5146f5e8-fe68-461a-ab63-8fd7025d6c64/image.png

  1. 문제 배경
  2. 문제 해결 방법
  3. 내가 시도해볼 것(Takeaway)

문제 배경


파이썬은 딥러닝 모델을 개발하기 위한 생태계가 잘 구성되어있다. GPU를 활용하여 딥러닝 모델을 학습/평가/서빙할 수 있는 pytorch, tensorflow 등 여러 프레임워크들과 고차원의 행렬 데이터들을 처리할 수 있는 pandas나 numpy와 같은 라이브러리들이 있다.

문제는 파이썬이 다른 언어들보다 느리다는 점이다. 모델 개발이 끝난 후, 딥러닝 모델의 앞단과 끝단에서 데이터를 처리하는 전처리나 후처리 또는 다른 부분들에서 속도가 느린 걸 발견할 수도 있다.

데이터 사이언티스트들은 보통 CS 백그라운드가 다른 개발자들보다 강한 편은 아니다. (물론 케바케겠지만) 내 첫번째 컴퓨터 언어는 파이썬이고, 비교적 최근에야 C나 C++ 같은 언어에 관심을 갖게 되었지만, 개인적으로 생각하기에 코드를 보고 '이런 부분을 고치면 조금 더 속도가 개선되겠는걸? 아 여기서 병목현상이 발생하는구나' 같은 컴퓨터 내부 동작 원리를 건드리는(또는 컴퓨터 내부 동작 원리를 알아야만 가능하다고 생각한) 생각은 많이 해보지 못한 것 같다.

그래도 완성된 딥러닝 알고리즘이 작동하는 방식을 제일 잘 이해하는 개발자는 모델을 담당한 데이터 사이언티스트 본인일 것이고, 속도가 개선되어야만 한다면 그건 데이터 사이언티스트의 임무일 것이다.

영상에서는 요새 핫한 모델 최적화 기법, 상당히 높은 지식 수준을 요구하는 고난이도 기술 대신, 우리가 시도해볼 수 있는 것 그리고 실질적인 결과물을 낼 수 있는 방법에 대해서 이야기한다.


문제 해결방법


측정 => {문제 개선방법 시도 => 측정 => 결과 분석} x repeat => 문제 개선

측정

문제를 해결하기 위해서는 우선 문제가 있다는 사실부터 인정해야한다는 말이 있다.

'속도가 느리다'라는 이슈를 해결하기 위해, 우선 속도 프로파일링을 진행한다. 문제 해결을 위한 가장 첫번째 단계는 문제 개선 여부를 측정할 수 있는 지표 설정이다.

파이썬에서 제공하는 timeit함수도 있지만, 코드 곳곳에 일일히 입력해야한다는 단점이 있다. timeit 대신 line_profiler라는 라이브러리를 사용했다고 한다. 전체 코드에 대해서 줄줄이 얼마만큼의 시간이 걸렸고, 몇 퍼센트의 비중을 차지하는지 알 수 있다고 한다.

문제 개선방법 시도

해결해야하는 문제가 '속도가 느리다'에서 '우리 코드 중 어느 부분이 특히 느리다'로 문제의 범위가 좁아졌다. 이제는 문제를 개선할 수 있는 방법들을 나열한다. 무작정 이 방법, 저 방법 해보자 보다는 여러 측면/레벨에서 시도해볼 수 있는 것들을 체계화하는 것이 중요하다. 영상에서는 속도 개선을 위해 python level, semi-c level, c/c++ level 세 가지로 나누어 접근했다.

https://velog.velcdn.com/images/silvercity/post/51584687-ccf1-401b-80a3-e973c2ac4d33/image.png

문제 개선방법 - python Level

문제 해결 방법들을 나열했다면, 가장 시도해보기 쉬운 것부터 시도해본다. 실패했을 때 비용이 적고, 가볍게 직접 시도해보면서 해결하고자 하는 문제가 조금 더 구체화된다는 장점이 있다. 문제 정의 시에 알지 못했던 부분에 대해 새롭게 알게 된다거나, 새로운 해결방법들을 추가해볼 수도 있다.

numpy나 opencv는 c와 c++로 작성된 라이브러리로 파이썬에서 작동할 수 있는 최적 속도를 어느정도 보장받는다. 그렇다고 해서 해당 라이브러리로 작성된 코드가 작성할 수 있는 최적의 속도라는 의미는 아니다. 개선할 수 있는 부분이 있을지도 모른다.

다시 한번, 문제 해결 방법은 여러가지가 있겠지만, 가장 시도해보기 쉬운 것부터 시도하는 것이 중요하다. numpy나 opencv에서 개선할 수 있는 부분을 찾으라고 해서, 안에 구동되는 동작원리를 파악하고, c++ 코드를 재작성하라는 의미가 아니다. 가장 쉽게 시도해볼 수 있는 방법은 '같은 라이브러리 내에서 코드 문맥에 조금 더 적합한 함수가 있을 수 있다. 더 알맞은 함수로 변경해보자'이다.

문제 개선방법 - Semi-c Level

파이썬 속도 개선을 위해 검색을 하다보면 항상 나오는 키워드가 있다. cython이나 numba. 결론만 말하자면, "pure python, 즉 라이브러리를 사용하지 않은 파이썬 자체 코드일 경우 눈에 띄는 성능 개선을 확인할 수 있지만, 다른 언어로 작성되어 이미 빠른 라이브러리를 사용할 경우, 그다지 좋은 성능 개선은 기대하기 힘들다"고 한다. 여기서 내가 생각하기에 중요한 부분은 '좋다고 해서 써봤는데?"라는 생각으로 시도한 방법에 대한 대응 방식이다.

https://velog.velcdn.com/images/silvercity/post/ee84945e-32af-4c3e-b1ae-e3230ec5e4ba/image.png

시도한 방법은 생각보다 결과가 좋을 수도 있고, 좋지 않을 수도 있다. 하지만, 결과의 퀄리티를 떠나, 결과를 분석하는 단계는 항상 있어야한다. cython이나 numba를 시도하는 방법에 있어서, '파이썬 자체 코드 => 성능 개선 우수, 라이브러리 코드 => 성능 개선 미미'라는 결론에 다다르기까지는 여러번의 실험과 분석이 있었을 것이다(추측이긴 하지만). 한 번에 이런 결론에 다다르면 정말 좋겠지만, 배경지식이 없는 상태에서 실험 후에 명료한 결론을 내리는 것은 생각보다 쉬운 일이 아니라고 생각한다. 여러번 걸릴 수도 있겠지만, 각 시도마다 결과를 분석하고, 나름의 결론과 경험치를 쌓아서 실용적인 결론에 다다르는 것이 중요하다.

('시도해봤는데 결과가 잘 안나왔다. 이 방법은 구리다' 같은 결론이 아니라, 영상에서처럼 '이 방법은 이럴 때는 잘 작동하지만, 저럴 때는 잘 작동하지 않을 수도 있으니 참고하세요.' 같은 실용적인 결론이다.)


문제 개선방법 - C/C++ Level

https://velog.velcdn.com/images/silvercity/post/89a1d1bb-c2e1-4118-ba69-20834eb4f066/image.png

https://velog.velcdn.com/images/silvercity/post/ef040d8e-7c41-40f9-a410-d42e1c5bf283/image.png

만약 C/C++ implementation을 적용하기로 결정했다면, '시도해보기 쉬운 것부터 시도' 원칙이 다시 한번 적용된다. 전체 코드를 바꾸는 대신, 시간이 오래 걸리는 병목 함수를 찾고 해당 부분만 C/C++ 구현을 적용한다. 여기서 내가 생각하기에 중요한 부분은 두 가지였다.

첫째는 새로운 코드 적용이 항상 공식 문서에 제공된 example과 같이 딱 들어맞지는 않을 것이기 때문에, 내 상황에 맞게 활용하는 유연성이 필요하다는 점이다. 영상에서는 numpy의 ndarray를 C에서 지원하는 cv::Mat 형식으로 변환하기 위해 Numpy C api에서 제공하는 PyArray_FromAny와 c++에서 제공하는 PyArrayObject를 거친다. '어? 내가 원하는 기능을 지원 안 하네?'하고 바로 포기해버리기보다는 조금 돌아가더라도 어떻게 해결할 수 있을지 고민해볼 필요가 있다.

두번째는 직접 해봐야 안다는 점이다. 위에서 언급했다시피, numpy와 opencv 같은 라이브러리들은 C/C++로 구현되어 있기 때문에 어느정도 최적의 속도를 보장한다. 그렇다면, 해당 라이브러리로 작성한 코드 속도가 무조건 C/C++로 구현한 속도와 동일할까? 아니다. 영상에서는 라이브러리로 작성한 파이썬 코드를 동일한 로직의 C/C++로 변환했을 때, 50%에 가까운 속도 향상을 얻었다고 한다. 개발을 하다보면 이론과는 다른 결과가 발생하기도 한다. 이론을 맹신하지말고, 직접 시도해보는 과정이 필요하다.

(문제 해결을 위해 돌아서 풀어가는 과정(workaround)에 길을 잃지 않기 위해서는 기초지식과 다루고 있는 문제의 본질에 대한 이해가 필요하다. 또, 이론이 전부가 아니다. 직접 시도해보는 과정이 필요하다.)


내가 시도해볼 것들(Takeaway)

영상을 보고 배운 점을 정리하자면 다음과 같다.

  • 최적화를 위해 컴퓨터 내부 동작 원리를 알아야만 가능한 것은 아니다.
  • 문제 해결은 측정 -> 해결 방법 시도 -> 측정 -> 결과 분석 의 반복으로 이루어진다.
  • 문제 개선 여부를 측정할 수 있는 지표 설정하기
  • 문제 범위를 좁히기
  • 여러 측면/레벨에서 시도해볼 수 있는 것들을 체계화하기
  • 각 시도마다 결과를 분석하고, 나름의 결론과 경험치를 쌓아서 실용적인 결론 내리기
  • 새로운 방법을 내 상황에 맞게 활용하는 유연성 기르기
    • 기초지식 갈고닦기
    • 해결하고자하는 문제를 이해하기
  • 이론을 맹신하지 말고, 직접 시도해보기

LLMSummarizer 프로젝트(2) CI/CD 구성

· 약 6분
eunsung shin
Software Engineer

만족스럽진 않지만, 우선은 동작하는 어플리케이션을 배포했다.

(어플리케이션은 http://3.39.105.35:8090/에서 확인할 수 있다.)

아직 추가해야할 내용도 많고, 개선해야할 내용도 많지만, 한 번에 하기보다는 하나씩 작업하는 게 중요하다. 개선과정에 있어서 중요한 것은 자동화할 수 있는 부분은 최대한 자동화해서 개발 싸이클을 개선하는 것이다. 작업하는 내용에만 집중하고, 코드를 관리하기 위해서 Github Issue에 작업할 내용을 기록하고, 브랜치를 파서 작업했다.

현재 AWS LightSail에다가 어플리케이션을 배포하였는데, 이 배포 과정을 Github Action을 사용해서 자동화하려고 한다.

서칭해보니 AWS의 다른 서비스들에서는 배포를 위해 CodeDeploy라는 배포 서비스를 제공하는데, AWS-LightSail은 해당사항이 없다고 한다. 그럼 LightSail은 배포를 자동화를 할 수 없는 걸까?

AWS-LightSail Instance가 EC2와 비슷하다면, AWS-LightSail Container Service는 ECS(container service)와 비슷하다.

aws-cli에서 lightsail로 이미지를 푸쉬하고 배포하는 기능을 제공한다.

EC2를 사용하면 S3에 빌드한 소스를 넘기고, S3에서 CodeDeploy가 변경내용을 가져와서 인스턴스에 적용하는 방식이였는데, LightSail Container Service는 바로 이미지를 푸쉬하고 배포하면 되니 더 간편한 것 같다. (왜 CodeDeploy가 S3를 통해야만 하는지는 아직 잘 모르겠다.)

다시 본론으로 돌아와서,

LightSail Container Service에 배포하기 위해서는 Github Action이 다음과 같은 과정을 거쳐야한다. 여기서 action runner는 깃헙 액션에서 워크플로를 실행하기 위해 제공하는 임시 서버이다.

  1. 깃헙 체크아웃 - 마스터 브랜치의 변경사항을 action runner에 반영한다.
  2. aws-cli 중 lightsailctl을 사용할 것이므로, action runner에 aws-cli lightsail 플러그인을 설치한다.
  3. 도커 이미지를 빌드한다.
  4. 빌드한 이미지를 컨테이너 서비스로 푸쉬한다.
  5. 푸쉬한 서버를 배포한다.
name: lightsail-deploy
on:
push:
branches: ['master']
pull_request:
branches: ['master']
permissions:
contents: read

env:
LIGHTSAIL_SSH_KEY: ${{ secrets.LIGHTSAIL_SSH_KEY }}
LIGHTSAIL_HOST: ${{ secrets.LIGHTSAIL_HOST }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY}}
LIGHTSAIL_USERNAME: ubuntu
LIGHTSAIL_SERVICE_NAME: llmsummarizer-container
AWS_REGION: ap-northeast-2

jobs:
buildfastapi:
name: Building FastAPI app
runs-on: ubuntu-latest

steps:
# 1. 깃헙 체크아웃 - 마스터 브랜치의 변경사항을 action runner에 반영한다.
- name: Getting Code from Github
uses: actions/checkout@v4
- name: Updating to the latest versions
run: |
sudo apt-get update
sudo apt-get install -y jq unzip
# 2. aws-cli 중 lightsailctl을 사용할 것이므로, action runner에 aws-cli lightsail 플러그인을 설치한다.
- name: Install Amazon Client
run: |
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip awscliv2.zip
sudo ./aws/install || true
aws --version
curl "https://s3.us-west-2.amazonaws.com/lightsailctl/latest/linux-amd64/lightsailctl" -o "lightsailctl"
sudo mv "lightsailctl" "/usr/local/bin/lightsailctl"
sudo chmod +x /usr/local/bin/lightsailctl

# 3. 도커 이미지를 빌드한다.
- name: Build a Docker Container
run: docker build -t llmsummarizer:latest .

#4.빌드한 이미지를 컨테이너 서비스로 푸쉬한다.
- name: upload image to Lightsail container service
run: |
service_name=${{ env.LIGHTSAIL_SERVICE_NAME }}
aws lightsail push-container-image \
--region ${{ env.AWS_REGION }} \
--service-name ${{ env.LIGHTSAIL_SERVICE_NAME }} \
--label llmsummarizer \
--image llmsummarizer:latest

- name: AWS authentication
uses: aws-actions/configure-aws-credentials@v1
with:
aws-region: ${{ env.AWS_REGION }}
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{secrets.AWS_SECRET_ACCESS_KEY }}

- name: AWS Lightsail 연결 확인
run: aws configure list

#5.푸쉬한 서버를 배포한다.
- name: Launching the Containers
run: |
aws lightsail create-container-service-deployment --service-name ${{ env.LIGHTSAIL_SERVICE_NAME }} \
--containers file://aws-lightsail/deploymentconfig.json \
--public-endpoint file://aws-lightsail/publicendpoint.json1

물론 액션이 한 번에 성공하지는 못했다. 예기치 못한 실패를 몇십번 거치고 나서야 액션이 잘 실행된 걸 확인할 수 있었다.

Untitled

그렇다면 이번엔 라이트세일 페이지에서 배포가 성공적으로 되는 걸 확인한다.

![Untitled](Untitled 1.png)

서버도 무사히 잘 동작하는 걸 확인했다.

배포를 자동화하는 개발환경을 셋팅했으니, 개발속도와 피드백 주기가 조금 더 빨라질 것을 기대해본다. 그럼 이제 추가 기능과 성능을 개선해보자.

LLMSummarizer 프로젝트(1) - 왜 나는 같은 프로젝트를 다시 진행하게 되었을까?

· 약 7분
eunsung shin
Software Engineer

LLMSummarizer는 이미 한 번 진행한 적이 있는 프로젝트이다. langchain-youtube-video-summarizer라는 이름으로 작년 말에 진행하다가 그만두었다. 프로젝트를 제대로 완성시키는 경험을 하고 싶기도 하고, 도대체 뭐가 문제였길래 하는 생각에 같은 주제로 다시 프로젝트를 진행하기로 마음먹었다.

같은 프로젝트를 다시 진행하기 전에 우선 왜 그만두었는지 이유를 곰곰히 고민해보고, 똑같은 실수를 반복하지 않도록 하고싶다.

이전 프로젝트를 그만두게 된 원인을 몇가지 꼽아보자면 다음과 같다.

  1. 코드 구조를 미리 설계하고 시작했다.

    프로젝트를 진행하기 위해서 미리 설계하고 시작하는 것은 중요하다. 코드 구조가 잡혔는데 새로운 기능들을 추가하다보면, 코드가 금방 지저분해질 것 같다는 생각이 들었다. 그래서 디렉토리 구조를 미리 나눠놓고, 거기에 코드들을 끼워맞췄다. 틀을 정해놨으니, 그 안에 맞추면 된다는 생각이였다. 하지만, 이상하게도 코드 라인이 많아지면 많아질수록, 관리하기 힘들어졌고, 뭐 하나 쉽사리 변경하기 어려워졌다.

    코드 구조는 정해져있는 절대적인 답이 있는 것이 아니라, 그 쓰임새에 맞게 합리적으로 조금씩 변화해가는 과정을 통해 결정된다. 구조에 대한 고민은 프로젝트 초기에 한 번 하고 마는 것이 아니라, 코드를 작성하면서 끊임없이 고민해야된다는 것이였다. 코드가 돌아간다고 괜찮다 생각하지말고, 나중에 다시 봤을 때, 혹은 전체 구조에서 알맞는 코드인지 가독성이 좋은지 주기적으로 검토하는 것이 중요하다. 마틴 파울러의 ‘클린 아키텍쳐’는 확장성이 좋고, 유지 보수성이 좋은 코드를 작성하기 위한 해법들을 잘 알려준다. 그리고 테스트 코드를 작성하면, 변경사항에도 안전성을 확보할 수 있다고 한다. 이번 프로젝트에서는 테스트 코드를 작성하고, 클린 아키텍쳐에서 말하는 내용을 잘 숙지하면서 진행해보려고 한다.

  2. 프로젝트에서 샛길로…

    진행하면서 초기단계에서는 미처 생각하지 못 했던 사항들이 발생했다. 예를 들면, 음성파일을 텍스트파일로 변환하는 whisper 모델 같은 경우에는 직접 서빙을 목표로 했었는데, 서빙을 위한 클라우드 비용을 알아보니, 운영이 도저히 힘든 가격이였다. 이럴 때는 조금 찝찝하더라도, 애초에 계획한 목표에 도달하기 위해 우선 가장 빠르고 쉬운 방법을 택하는 게 맞지 않을까 라는 생각을 지금에서라도 해본다. 그 당시에는 어떻게든 이 문제를 해결해야만 다음 단계로 넘어갈 수 있을 거라는 생각이였다. 결과적으로 GPU이던 CPU이던 whisper를 로컬로 서빙할 수 있는 방법은 찾지 못하였고, 계획한 목표에도 달성하지 못했다. 결과만 봤을 때는 시간만 낭비한 셈이다. 우선은 openai에서 제공하는 api로 목표한 요약 기능을 구현하고, 추가 개선을 하는 상황에서 다시 접했다면 어땠을까 하는 생각이 든다. 심리적으로도 결과물이 이미 있으니, 조금 덜 부담을 가지고 문제를 해결할 수 있지 않았을까 아쉬운 마음이다. 기술부채가 나쁜 것만은 아니다. 상황에 맞게 현명한 판단을 하는 게 중요하다. 이번 프로젝트에서는 미흡하더라도 우선은 완성을 하고, 조금씩 개선해나가는 것이 목표이다.

그래서 우선 돌아가는 어플리케이션을 작성했다. 이미 이전 프로젝트에서 작성해놓은 코드들을 가지고와서 확장성을 고려하여 작성했다. 인풋에 대한 전처리와 요약 과정이 혹시 변경될 수 있을지 모르니, Inputhandler와 mapreducer를 추상화하여 변경 사항에 대처할 수 있도록 했다. 어플리케이션 배포는 AWS의 lightsail 인스턴스에다가 했다. 3개월 무료라는 점과 lighsail이 제공하는 간편성 그리고 서버 규모가 커지면 EC2로 손쉽게 옮길 수 있다는 점에서 채택했다.

어플리케이션은 http://3.39.105.35:8090/ 여기에서 확인할 수 있다.

물론 고칠 점이 많다. 하나하나씩 개선해나갈 예정이다.