올해의 삽질들
끝이 없다
2020 한해의 회고… 라기엔 너무 거창하고, 그냥 올해 했던 기억에 남는 삽질들을 몇 개 기록해보려고 한다.
장고와 셀러리
연구개발로 만든 엔진을 사내에 서비스하려니 자연스럽게 백엔드를 개발 하게 되었다. 뭘로 백엔드를 짜야 가성비가 쩔게 나와줄지, 즉 노력 대비 생산성이 최대를 찍을지 꽤 고민한 결과, 파이썬의 Django (이하 장고) 프레임워크 위에 올라타기로 했다. 워낙 유명한 프레임워크라서 이미 많은 사람들이 여러가지 장단점을 정리해뒀으니, 여기서는 내가 선택한 이유만 기록해 보자면:
- (그나마) 파이썬이다. 웹, 특히 백엔드를 하려면 크게 자바(스프링)와 자바스크립트(노드)가 두 축인데, 개인적인 취향으로는 두 언어 모두 마음에 들지 않아서 이걸로 개발했다간 코딩이 즐겁지 않을 것 같다. 최애 언어로 개발하고 싶지만 그랬다간 이 레거시를 누가 감당하게 될지 모르고 무엇보다 아직까지 OCaml의 웹 백엔드는 성숙하지 못하니, 그나마 파이썬이 괜찮은 선택인 것 같다.
- 장고의 철학인 “Batteries included”, 즉 “니가 필요한 웬만한 건 우리가 다 지원해줄게” 라는 말이 내 마음을 편하게 해준다. 반면 비슷한 파이썬 웹 프레임워크인 Flask 는 미니멈을 추구하는 것이 철학이라, 예를 들면 ORM 같은 걸 디폴트로 제공해주지 않고 외부 라이브러리에 의존하는 것 같다. 이런걸 고민하는 재미도 있지만, 이번에는 뭘 쓸지 고민하는 것보다는 프레임워크가 제공해주는 걸 편하게 쓰고 싶다.
- 관리자 페이지가 기본으로 만들어진다. 이게 정말 강력한 기능이다. 따로 UI 고민할 필요 없이 모델에 대한 어드민 뷰 페이지를 알아서 뽑아주고 계정 별 권한 컨트롤까지 제공해주는데, 이것 덕분에 엄청난 시간 절약을 할 수 있었다.
- 파이썬 웹 프레임워크 중 가장 메이저라고 할 수 있다. 덕분에 프로젝트가 갑자기 사라지거나 (…) 혹은 에러를 겪었을 때 혼자 고립될 걱정을 덜 해도 된다. 장고 소프트웨어 파운데이션은 비영리 재단의 지원을 받긴 하지만, 장고로 만들어진 무수한 메이저 서비스들이 장고의 단단함을 대신 말해준다1: 디스커스, 인스타그램, 스포티파이, …
순수 장고 프레임워크만으로 여기까지는 다 좋았다. 하지만 GIL로 인해 태생적으로 Sequential 한 파이썬과, 웹을 위한 프레임워크인 장고의 한계로 인해 내가 원하는 만큼의 성능을 뽑아내긴 힘들어보였다.
- 일단 프론트엔드가 아니라 API를 제공해야 한다. 이건 가볍게 장고 REST API 프레임워크로 해결했다.
- 엔진의 수행 시간이 짧지 않다. 따라서 비동기로 API 콜을 다뤄야 한다.
그래서 처음에는 요청마다 직접 multiprocessing
으로 fork해서 엔진을
돌리려고 했다. 그런데 나도 생각한 걸 다른 사람들이 생각하지 않을리가
없다고 생각해서 좀 검색해보니, 과연
Celery (이하 셀러리)
라는 좋은 게 있더라. Redis 같은 인메모리 캐시를 미들웨어로 활용하는
분산 작업 큐이다. 그리고 무엇보다 장고와의 통합을 나이스하게
지원한다.
덕분에 아주 가성비 좋은 서비스 백엔드를 만들 수 있었다. 뼈대는 장고+셀러리로, 물론 지금은 여기에 여러가지가 추가되어서 좀더 커지긴 했지만, 기본적으로 내가 원했던 “최소한의 노력으로 최대한의 생산성을 잡아낼 수 있는 분산 백엔드”를 달성할 수 있었다.
이제 다음에는 그 유명한 리액트나 Next.js 같은 물건을 건드릴 날이 오려나. 온다면 최대한 천천히 찾아 오길.
스크립트: OCaml, 파이썬, 쉘
반복적인 실험을 위해서는 스크립팅이 필수적이다. 스크립트 짤 때만이라도 최애 언어 OCaml을 굴려보려고 다방면의 시도를 해보았지만, 결국 파이썬과 순수 쉘 스크립트의 조합이 가장 생산성이 좋은 것 같다. JaneStreet 쯤은 가야 모든 스크립트가 OCaml로 짜여져 있는 그런 환경을 겪어볼 수 있을까? 아무래도 이번 생에는 무리인 것 같다.
OCaml
OCaml은 바이트코드 인터프리터도 딸려 오기 때문에 사용법은 여타 쉘 스크립트와 크게 다르지 않다. 파일의 시작 부분에 Shebang으로 OCaml 인터프리터로 해석하라고 알려주고 평소처럼 짜면 된다.
좀 사용해본 결과, OCaml로 짠 스크립트의 성능 자체는 굉장히 만족스러웠다. 인터프리터 자체도 파이썬보다 훨씬 빠르고, 무엇보다 타입 안전해서 파이썬으로 짤 때 발생하는 익셉션의 대부분을 사전에 예방할 수 있었다. 대신 코드를 짤 때 조금 더 심사숙고 해야 하는 부분이 있어서 파이썬이나 쉘처럼 후다닥 실행해보고 디버깅하진 못하지만, 타입 덕분에 최종 생산 속도는 비슷하거나 더 빠른 느낌이다.
스크립트의 성능은 마음에 들지만, 문제는 Portability (이식성?)
이다. 로컬에서 쓰는 건 아무 문제 없는데, 사실 대부분 스크립트가
필요한 곳은 컨테이너 속이었다. 그런데 이런 경우, 일단 200MB 정도 되는
용량의 OCaml 런타임을 새로 깔아야 한다는 것, 그래서 인터넷이 안되거나
속도가 느린 환경에서는 설치부터 느리다는 것, 그리고 이렇게 깔린
버전이 OS(이미지)마다 조금씩 차이나서 라이브러리 사용에 주의해야
한다는 것(이건 파이썬도 일정 부분 공유하는 문제이긴 하다), 그리고
외부 패키지를 쓰려면 opam을 깔아야 하는데, opam을 깔고, switch로
컴파일러를 컴파일하고, 버전에 맞는 패키지를 설치하고… 이 과정이
제법 비싸다는 것 등, 여러가지가 이식성의 발목을 잡더라. python3
랑
python3-pip
설치한 후에 pip install -r requirements.txt
한줄이면
런타임부터 패키지까지 다 설치 가능한 파이썬과는 사뭇 달랐다.
결국 OCaml 스크립트는 쓸만한 물건이지만, 들고 다니기 힘들기 때문에 로컬에서만 적당히 사용하기로 했다. 나만 쓰는 물건 하나 정도는 있어도 괜찮겠지.
파이썬
파이썬은 대부분의 리눅스 배포판에 기본으로 깔려있다는 장점이
있다. 그리고 패키지 설치 과정이 꽤 가볍고 pyenv
같은걸로 opam
switch
를 흉내낼 수도 있다. 물론 이것도 훨씬 가볍다.
그리고 웬만한 작업은 표준 라이브러리를 이용해서 몇 줄 안되는 코드로 가능한 점이 가장 만족스러웠다. 파일 입출력, 정규식, 파일 시스템 탐색, HTTP 요청, fork 기반 병렬처리까지. 그리고 OCaml의 utop에 해당하는 ipython과, 그 위에서 실행 가능한 장고 쉘 덕분에 DB에 직접 쿼리를 날리지 않고 장고 ORM 으로 간단한 내용은 확인 해볼 수 있다는 점도 좋았다. 다만 타입이 없어서, 코너 케이스를 잘 생각하지 않으면 생각보다 자주 예외가 발생한다는 점은 아쉬웠다.
예전에 트위터의 한 개발자 분이 했던 비유 중에 “파이썬은 프로그래밍 언어 계의 WD-40 이다” 라는 게 아직까지 기억에 남아있는데, 정말 그런 것 같다. 물론 요즘이야 하드웨어 성능이 좋아져서 파이썬으로도 일정 수준의 프로덕션이 가능하긴 하지만, 파이썬의 진가는 말 그대로 WD-40처럼 여러가지 복잡한 작업을 매끄럽게 이어주는 것이라고 생각한다.
결론적으로 파이썬은 여기저기 아무렇게나 갖다 쓸 수 있는 점이 너무 강력해서, 항상 고맙게 잘 사용하고 있다. 아마도 당분간은 이게 주된 밥벌이 수단이지 않을까.
쉘
튜닝의 끝은 역시 순정이다. 파이썬이 아무리 가볍고 좋다고 해도, 파이썬
파일을 만들고, 스크립트를 짜고, 인터프리터를 호출하는 과정마저도
번거로울 때가 있다. 특히 몇 가지 커맨드를 파이프라이닝하거나 특정
커맨드의 결과를 리다이렉션하거나 하는 경우에는 파이썬의 subprocess
를 사용하는 게 오히려 번거롭다.
이럴 때는 그냥 순정 쉘 스크립트를 쓰는 것이 직빵이다. 특히 내 삶을 윤택하게 만들어줬던 몇 가지 원라이너를 소개한다.
for f in path/*.ext; do ...; read var; done
path
안의*.ext
파일에다가 ‘뭔 짓’을 하는 원라이너다. 키포인트는read var
다. ‘뭔 짓’의 속도가 너무 빠르면 한 스텝 실행하고 곧바로 다음 스텝으로 넘어가버리기 때문에 이전 스텝의 결과를 확인할 수 없다. 이럴 때 interactive하게 입력을 받아서 처리하도록 하여 이전 결과를 눈으로 확인하고 다음 스텝으로 진행하도록 한다.
[ -f path ] && ....
path
가 있을 때 뭔가 실행하도록 하는 원라이너다.if ...
로 길게 해도 되는데 저렇게&&
를 이용해서 앞 커맨드의 리턴 코드를 이용하는 방법을 활용하니 좀더 빨리 타이핑할 수 있어서 좋더라.
find . -name "*.ext" -print0 | xargs -0 ...
- 이건 우연히 알게 된 건데, 특정 파일을 찾아서 커맨드를 실행할 때
(
map
이나iter
?) 원래는find
의-exec
옵션을 사용했었다. 그런데find
로 찾은 결과가 너무 많으면-exec
도중에 죽어버리는 (실행도 못하고 죽었었나, 정확한 기억은 안난다) 이슈를 겪었었다. 그렇다고 일일이 파일 목록을 쪼갤 수도 없고 해서 검색해보니, 저렇게xargs
로 파이프라이닝 결과를 받아 실행하면 파일 제한도 없(진 않겠지만-exec
보다는 훨씬 크)고 무엇보다-exec
보다 훨씬 빨랐다!
rm -rf * && git checkout -- .
- 이건 별건 아니고 어떤 프로젝트를 클린 빌드하고 싶을 때 자주 썼던
원라이너다. 대부분의 프로젝트가 빌드 스크립트에는 관심이 없어서
(…)
make clean
이 엉망이거나 없는 경우가 꽤 많기 때문에, 그냥 저렇게 클린하는게 더 깔끔하더라.
무의식적으로 사용하고 있는 것들은 이것보다 더 많긴 하다. 그리고 사실 이런 원라이너보다 중요한건 무조건 fzf를 써야한다는 것이다. 중요하니 한번 더 말해야지. 무조건 fzf를 써야한다. fzf는 쉘 스크립팅의 빛이요 지혜의 광명이다.
도커 길들이기
전에도 말한 적 있는 것 같은데, 이제 도커 없이는 살 수 없는 몸이 되었다. 그런데 이 도커를 길들이기가 꽤 까다로웠다. 삽질했던 두 가지를 기록해둔다.
용량 길들이기
실험을 위해서 이미지를 받고 컨테이너를 띄우다 보면 용량 잡아먹는
속도가 어마무시하다. 도커는 기본적으로 /var/lib/docker
에서
이미지나 볼륨, 컨테이너 파일을 관리하기 때문에 애초에 /var
를
용량이 큰 하드에 마운트한 경우가 아니라면 금새 다 차버린다.
이럴 때는 용량 큰 볼륨을 /var
에다가 마운트하는 것도 괜찮은
방법이지만, 아예 도커 전용 볼륨을 따로 관리하는 것도
괜찮더라. 방법은,
- 일단 서비스를 멈춘다:
service docker stop
- 용량 큰 볼륨 마운트한 곳에다가 도커 디렉토리를 통째로 옮긴다:
mv /var/lib/docker /huge-volume
-
데몬 설정을 열어서
"data-root": "/huge-volume"
을 추가해준다. 없으면 새로 만들어준다:vi /etc/docker/daemon.json
- 재시작 한다:
service docker start
덕분에 /var
에 볼륨 마운트를 위해 새로 포맷하거나2 하지 않고
도커 디렉토리만 큰 곳으로 옮겨도 되고, 무엇보다 기존 도커 설정이
그대로 남아있어서 떠있던 도커 컨테이너나 풀 받아둔 이미지가 그대로
있다. 덕분에 실험 서버를 포맷하거나 도커를 재설치하는 삽질을 피할 수
있었다.
도커 안에서 도커 사용하기
한 컨테이너에서 모든 작업이 끝나면 좋겠지만, 그렇지 않은 경우도 있다. 예를 들면 어떤 프로그램은 계정마다3 단 하나의 프로세스만 허용하기도 한다 (이런 프로그램을 싱글톤 프로그램이라고 부르자). 이런 프로그램을 사용하는 경우, 들어온 요청끼리 프로그램 사용을 위해서 서로 경쟁하도록 락을 잡아도 되지만, 애초에 외부 프로그램이라 한 요청의 실행 시간이 꽤 길어서 생각없이 락 걸었다가는 온통 타임아웃 나기 십상이다.
이럴 때는 도커 안에서 도커를 활용하는게 가장 좋다. 그렇다고 도커
컨테이너 안에서 도커를 설치해서 사용하는 일은 불가능하기도 하고
미련한 짓이다. 도커 데몬을 띄울려면 1번 프로세스 init
이 필요한데
컨테이너에서는 보통 첫 시작인 /bin/bash
가 1번 프로세스가 되기
때문에 불가능하고, 이렇게 새로 도커 환경을 꾸려버리면 기존 호스트에
남아있던 각종 설정들, 예를 들면 캐싱해둔 도커 이미지, 도커 로그인
세션들, 등을 재활용할 수 없어 엄청난 낭비를 하기 때문에 미련한
짓이다.
그래서 내가 찾은 방법은 가장 처음 뜨는 컨테이너에서 호스트의 도커 설정을 마운트해서 먹고 들어가는 것이다. 아래 세 가지를 필수적으로 마운트 해주면 된다:
/var/bin/docker
: 호스트 도커 바이너리/var/lib/docker
: 호스트 도커 라이브러리/var/run/docker.sock
: 호스트 도커 데몬에 접속하기 위한 소켓
여기까지 들고 들어가면 컨테이너 안에서 별도의 설치나 설정 없이
docker ps
나 docker run
같은 도커 커맨드를 마치 호스트에서
실행하는 것처럼 사용할 수 있다.
여기서 추가로, 컨테이너들끼리 통신을 원활하게 하려면 경험 상 /tmp
디렉토리까지 먹고 들어가는게 좋더라. 특히 커맨드라인 옵션 말고 파일을
입력으로 주고 싶다거나, 혹은 콘솔 몇 줄로 간단하게 결과를 뿌리는 게
아니라 수십/수백메가의 파일로 그 결과물을 떨어뜨리는 프로그램을
컨테이너로 실행하고 싶을 때는 /tmp
에서 약속 장소를 잡고 서로
만나면 쉽게 얘기를 나눌 수 있다.
단, 주의사항은 호스트의 경로와 완전히 똑같은 경로를 마운트해줘야 한다는 것이다. 그리고 권한 오류가 발생할 수 있기 때문에 컨테이너를 priviledged로 실행하는 것이 편하다.
빌드 재현하기
아무리 좋은 기술을 개발했어도 리얼월드에 적용하지 못하면 빛 좋은 개살구일 뿐이다. 특히 C/C++ 프로젝트에서 뭔가를 분석하려면, “빌드” 라는 큰 산을 반드시 넘어야 한다. 그런데 이 산은 정말 넘기 힘들다. 왜냐하면 프로젝트마다 컴파일 환경도 제각각이고4 , 빌드 시스템도 제각각이고, 심지어 브랜치마다 빌드 정책이 다르기도 하다.
특히 지금 구상하고 있는 서비스는 PR의 head 정보를 가지고 분석을 시도하는데, 이게 험준한 빌드의 산과 섞이면서 별 희한한 이유로 빌드 자체를 재현하기가 힘든 경우가 있었다. 예를 들면,
- PR이 빠르게 merge 되었고 GitHub 브랜치 정책으로 인해 PR이 merge 되자마자 head가 자동 삭제되어 사라진 경우 -> 분석 시점에서 PR의 merge 여부를 체크해서 있는게 보장된 브랜치를 활용하도록 하고,
- PR head가 private fork 라서 권한이 없는 경우 -> 일단 PR의 base에는 권한이 있기 때문에 여기 정보를 활용하도록 하고,
- 분석한 브랜치에 커밋이 추가되면서 실제로 그 브랜치의 빌드가 깨지는 경우 -> 브랜치가 아니라 커밋 아이디를 기준으로 빌드 시도하도록 하고,
- 빌드 정보만으로는 알 수 없는 각종 권한 이슈로 빌드가 안되는 경우 -> 로그를 뜯고 디버깅을 해서 개발팀에 직접 물어봐야 하고,
- 서브 모듈 클론해와야 하는데 방화벽에 막혀있는 경우 -> 방화벽 설정해주고,
- 서브 모듈 클론하는데 ssh known_hosts에 등록이 안되어 있어서 interactive 에 막힌 채로 멈춰버리는 경우 -> 호스트의 ssh 설정 물고 들어가고,
… 등 그 외에도 온갖 다양한 이슈로 분석은 커녕 빌드도 못하고 주저 앉는 경우가 많았다. 이런 경우 하나하나를 땜질해가면서 어떻게든 fully-automated 분석 시스템을 만들고 있는데, 문득 예전에 교수님께서 말씀하셨던 “양 손에 피를 묻히는 작업”이 과연 이런 것인가, 생각하게 된다.
적고 보니 결국 모든 삽질의 목표는 “최소한의 노력으로 최대한의 생산성 달성하기” 였던 것 같다. 어떻게든 일을 덜 하려고 했던 노력이 아이러니 하게도 적당한 수준의 생산성을 가져다주는 것 같다.
내년엔 또 어떤 삽질을 하게 될까? 적어도 여기서 했던 삽질들은 하지 않겠다는 심정으로 글을 마친다.