이 글을 쓰는 이유

블로그 글을 쭉 다시 읽어보다가, OnPyRunner 시리즈에서 당시의 사고 과정이 충분히 담기지 못한 부분이 있다는 걸 느꼈다. 그래서 총평을 써보기로 했다.

OnPyRunner 시리즈는 1편부터 10편까지, 2026년 1월 8일부터 2월 22일까지 약 45일간의 개발 과정을 기록한 글이다. 당시에는 그 순간의 사고 과정을 최대한 솔직하게 남기려 했고, 그 목적은 달성했다고 생각한다.

하지만 시리즈를 다시 읽어보니 빠져 있는 것이 있었다. 각 편에는 “이렇게 했다, 안 됐다, 바꿨다"는 사실의 나열은 있지만, 왜 그런 판단을 했고, 그 판단의 어디가 틀렸으며, 그래서 나의 사고방식이 어떻게 바뀌었는지는 충분히 담기지 못했다.

시리즈 원문은 당시의 날것 그대로 보존하기로 했다. 이 총평은 그 위에 현재의 시선을 얹는 글이다.


출발점: 나는 뭘 알고 있었나

이 프로젝트를 시작했을 때, 내가 할 줄 아는 건 Python으로 알고리즘 문제를 해결하는 코드를 작성하는 것이 전부였다. 웹 서버가 요청을 어떻게 받는지, API가 뭔지, Express가 뭔지, Redis가 뭔지, 설계라는 행위가 무엇을 의미하는지조차 몰랐다. 알고리즘 문제 풀이가 나의 개발 경험 전부였다.

이 맥락을 먼저 밝히는 이유는, 시리즈에서 등장하는 수많은 시행착오가 알면서도 한 실수가 아니라 몰랐기 때문에 할 수밖에 없었던 탐색이었음을 분명히 하기 위해서다.

시리즈를 읽으면 2편까지는 Express(JavaScript)로 구현하다가, 이후 FastAPI(Python)로 바뀌어 있는 것을 알 수 있다. 처음에는 새로운 언어를 배우는 계기로 삼겠다는 생각에 JavaScript와 Express를 선택했다. 하지만 실제로 진행해보니, 모르는 언어와 모르는 도메인을 동시에 다루는 것은 두 개의 미지수를 한꺼번에 푸는 것과 같았다. 문제가 생겼을 때 그것이 JavaScript 문법의 문제인지, 설계의 문제인지, 도구의 문제인지 구분할 수가 없었다. 그래서 내가 잘 아는 언어인 Python으로 된 FastAPI로 전환했다. 변수를 하나 줄여야 나머지 하나에 집중할 수 있다는 판단이었고, 지금 돌아봐도 이 선택은 맞았다고 생각한다.


핵심 의사결정 복기

1. 비동기 큐 도입과 철회 (1편 -> 3편)

  • 당시 판단: 1편에서 “동시 접속자가 늘면 코드 실행 끝날 때까지 블로킹"이라는 문제를 정의했고, 해결책으로 “메시지 큐 기반 비동기 처리"를 선택했다. BullMQ라는 것을 찾아서 2편에서 Express + BullMQ로 첫 구현을 했고, API 호출이 Worker까지 도달하는 것을 확인했다.

  • 실제로 일어난 일: 3편에서 이 구조를 폐기했다. 구현하면서 “사용자가 jobId로 결과를 조회해야 하는 구조가 내가 원하던 실행기의 모습인가?“라는 의문이 들었다. 내가 원한 건 tio.run처럼 실행 버튼을 누르면 결과가 바로 나오는 간단한 실행기였는데, 비동기 큐를 도입하면서 사용자 경험의 본질이 바뀌어 버린 것이다. 게다가 비동기 큐는 병목 자체를 해결해주지 않았다. 실행 컨테이너가 N개면, N개를 넘는 요청의 병목은 컨테이너 수를 늘리는 것 외에는 방법이 없었다.

  • 지금의 해석: 이 실수의 근본 원인은 비동기를 도입한 근거가 틀렸다는 것이다. “동시 접속 → 블로킹 → 비동기"라는 연상으로 비동기를 선택했지만, 3편에서 스스로 확인했듯이 비동기는 그 병목을 해결해주지 않았다. 이후 6편에서 비동기 구조를 다시 도입했을 때는 “Request와 Job의 생명주기 분리"라는 전혀 다른 근거에서였다. 같은 비동기라도 왜 도입하는지에 따라 결과가 달라진다는 걸, 한 바퀴 돌고 나서야 알게 되었다.
    또한 지금 풀어야 할 문제와 나중에 풀어도 될 문제를 구분하지 못했다인 것도 있다. 동시 접속이 문제가 된다는 판단 자체는 틀리지 않았다. 하지만 “코드를 넣으면 결과가 나온다"는 기본 동작조차 없는 MVP 단계에서, 그것이 지금 해결해야 할 1순위는 아니었다.

2. 동기/비동기와 블로킹/논블로킹의 혼동 (3편 -> 6편)

  • 당시 판단: 3편에서 비동기를 걷어내고 동기로 전환했지만, 1편에서 제기한 블로킹 문제에 대한 해결책은 제시하지 않은 채 넘어갔다. “간단한 실행기니까 동기가 맞다"는 결론만 내렸다.

  • 실제로 일어난 일: 6편에서 이 혼동을 스스로 발견했다. 동기/비동기와 블로킹/논블로킹은 독립적인 개념이고, “nsjail 내부 실행이 동기"라고 해서 “전체 서버가 동기여야 한다"는 것은 아니었다. 전체를 하나의 동기/비동기로 통일할 필요 없이, 각 계층의 상호작용에 따라 별도로 판단해야 했다.

  • 지금의 해석: 1편에서 3편까지의 사고 흐름은 동기 <-> 비동기라는 하나의 축 위에서 왔다갔다한 것이었다. 개념이 정립되지 않은 상태에서 기술적 판단을 하다 보니, 한쪽이 틀리면 반대쪽으로 뒤집는 것 외에는 선택지가 보이지 않았다. 6편에서 개념을 정리하고 나서야 “nsjail 실행은 동기, 서버 I/O는 비동기/논블로킹이 가능하다"는 구분이 생겼고, 부분적으로 비동기를 적용할 수 있다는 사실을 알게 되었다. 결국 10편의 최종 아키텍처가 그 답이었다. 내부적으로는 Redis 큐를 통해 비동기로 처리하되, 클라이언트는 Polling으로 결과를 기다리는 — UX적으로는 동기처럼 보이는 구조. 1편에서 “비동기면 사용자가 jobId로 조회해야 하잖아"라고 느꼈던 불편함은, 비동기 자체의 문제가 아니라 비동기를 UX로 포장하는 방법을 몰랐기 때문이었다.

3. Redis 역할의 재정의 (1편 -> 3편 -> 6편)

  • 당시 판단: 1편에서 Redis를 “비동기 처리를 위한 메시지 큐 백엔드"로 도입했다. 3편에서 비동기를 걷어내면서 Redis도 함께 제거했다.

  • 실제로 일어난 일: 6편에서 Redis를 다시 도입했다. 하지만 이번에는 이유가 달랐다. 문제의 본질은 코드 실행이라는 긴 작업의 생명주기가 HTTP 요청의 생명주기에 종속되어 있다는 것이었다. 사용자가 탭을 닫으면 실행 결과를 수집할 주체가 사라진다. 실행은 되었지만 시스템 입장에서는 해당 실행이 존재한 적 없는 것과 다름없는 Orphaned 상태가 된다. Redis는 이 두 생명주기를 분리하기 위한 장치였다.

  • 지금의 해석: 1편의 Redis 도입은 “비동기를 하려면 큐가 필요하고, 큐를 하려면 Redis가 필요하다"는 도구 중심의 사고였다. 6편의 Redis 도입은 “Job의 상태를 HTTP 요청으로부터 독립시키기 위해 외부 저장소가 필요하다"는 문제 중심의 사고였다. 같은 기술을 도입했지만, 사고의 방향이 정반대였다. 이 차이를 깨달은 것이 프로젝트 전체에서 가장 중요한 전환점이었다고 생각한다.

4. Docker 도입 — 도구가 아니라 환경의 문제 (4편 -> 5편)

  • 당시 판단: 4편에서 nsjail을 선택하고 로컬 우분투에서 직접 테스트하려 했다.

  • 실제로 일어난 일: 로컬 우분투의 파일 시스템 위에서 nsjail의 chroot + mount를 설정하다 보니, nsjail이 돌아가는지조차 확인할 수 없는 디버깅 지옥에 빠졌다. 개발 환경과 배포 환경의 괴리도 커졌다. 5편에서 Docker를 도입하여 일관된 환경을 만들었고, 이후 clone_newns 설정 문제도 Docker 안에서 깔끔하게 해결했다.

  • 지금의 해석: 이 판단은 실수라기보다 경험하지 않으면 알 수 없는 것에 가깝다. “환경을 먼저 고정하고 그 위에서 개발한다"는 원칙은 말로는 쉽지만, 직접 삽질해보지 않으면 체감하기 어렵다. 이 경험 이후로는, 시스템 레벨 도구를 다룰 때 “로컬에서 직접 세팅"이 아니라 “컨테이너로 환경을 격리한 뒤 그 안에서 작업"하는 순서로 접근하게 되었다.

5. cgroup v2 삽질 — 문서를 읽는 법을 배운 순간 (9편)

  • 당시 판단: fork bomb을 막기 위해 cgroup으로 프로세스 수를 제한하려 했다.

  • 실제로 일어난 일: Docker 컨테이너 내부에서 nsjail이 cgroup을 설정하려 하면 계속 실패했다. 원인은 cgroup v2의 no internal processes rule이었다. 루트 cgroup에 이미 프로세스가 존재하는 상태에서는 하위 cgroup에 컨트롤러를 위임할 수 없다. 루트의 프로세스를 init 하위 그룹으로 옮기고, 루트를 비운 뒤 subtree_control에 컨트롤러를 활성화하는 절차를 거쳐 해결했다.

  • 지금의 해석: 이건 구글링으로는 안 되는 문제였다. 스택오버플로우에도 같은 상황의 답이 없었고, 결국 cgroup v2 공식 문서를 직접 읽고 no internal processes rule이라는 규칙을 이해하고 나서야 풀렸다. 이때 느낀 건, 에러 메시지를 검색하는 것과 시스템의 규칙을 이해하는 것은 완전히 다른 종류의 디버깅이라는 것이였다. 이후로는 문제가 생겼을 때 검색으로 안되면 공식 문서를 먼저 찾는 습관이 생겼다.


이 프로젝트에서 얻은 것

10편의 시리즈를 작성하면서 프로젝트를 회고하면서 나는 과연 뭘 배웠나?를 고민해보았다. 기술적으로도 배운게 있었고, 사고방식의 변화도 있었다.

사고 방식의 변화

첫째, 궁극의 결과물이 아니라 MVP를 지향점으로. 1편에서 tio.run을 보고 프로젝트를 시작했지만, tio.run은 이미 완성된 서비스였다. 그걸 지향점으로 삼다 보니, 첫 설계부터 비동기 큐, 워커 스케일링, 상태 관리까지 한꺼번에 고려하게 되었다. 하지만 MVP 단계에서 필요한 건 “코드를 넣으면 결과가 나온다"는 최소한의 동작이었고, 나머지는 그 위에서 고도화할 문제였다. 완성된 서비스를 처음부터 만들려 하면 과잉 설계가 되고, 최소 동작부터 만들면 다음으로 어떤걸 만들어야할지 고민하게 되면서 그 단계에서 진짜 필요한 것이 무엇인지가 보인다.

둘째, 도구가 아니라 문제를 먼저 정의하라. BullMQ, Redis, Docker, Nsjail 이 프로젝트에서 사용한 모든 기술은 결국 올바른 자리를 찾았다. BullMQ는 사라짐. 하지만 처음부터 올바른 자리에 놓인 것은 하나도 없었다. 매번 “이 기술이 좋아 보인다 -> 도입 -> 문제에 안 맞는다 -> 제거하거나 재정의"라는 사이클을 거쳤다. 6편에서 Redis의 역할을 재정의한 것이 이 사이클을 깨는 전환점이었다. 문제를 정의하고 그것을 해결할 수 있는 도구를 찾자.

셋째, 대비와 과잉 설계의 경계는 근거의 유무다. 1편에서 동시 접속 문제를 예상하고 비동기 큐를 도입한 것도 “대비"였고, 6편에서 요청과 실행의 생명주기가 구조적으로 다르다는 것을 분석하고 Redis를 도입한 것도 “대비"였다. 하지만 전자는 사용자가 나 혼자인 MVP에서 존재하지 않는 문제를 막으려 한 과잉 설계였고, 후자는 nsjail 실행이 수 초가 걸린다는 구조적 사실에 기반한 설계였다. 같은 “미리 준비한다"라는 행위도, 그 아래에 분석이 있느냐 예감이 있느냐에 따라 결과가 완전히 달라졌다.

기술적 역량

이 프로젝트를 시작할 때는 알고리즘 문제 해결을 위한 단일 Python 파일을 만드는게 전부였다. 이 프로젝트를 거치면서 Docker로 일관된 개발 환경을 구축하는 법, nsjail로 신뢰할 수 없는 코드를 격리 실행하는 법, cgroup v2로 리소스를 제한하는 법, Redis 기반의 웹-큐-워커 아키텍처를 설계하는 법, pytest와 TDD로 API를 자동 검증하는 법, github action으로 자동화 배포하는 법, Nginx로 reverse proxy하는 법 등의 웹 개발에서 배포까지의 전체적인 과정을 직접 부딪혀가며 익혔다.


지금이라면

같은 프로젝트를 지금 다시 시작한다면, 아마 Docker부터 만들고, 동기 MVP로 시작하고, 구조적 분석이 끝난 시점에서 Redis를 도입할 것이다.
이렇게 하면 45일이 아니라 2~3주 안에 같은 결과물이 나왔을 것이다. 하지만 위의 판단은 전부 직접 만들어보고 문제를 겪었기 때문에 나온 것이다. 그 과정을 거치지 않았으면 왜 그게 나은지도 몰랐을 것이고, 애초에 그런 선택지가 보이지도 않았을 것이다.


마치며

OnPyRunner는 나의 첫 프로젝트였다. 처음부터 모든 걸 알 수는 없다. 이 글은 “처음부터 이렇게 했으면 됐는데"가 아니라, 프로젝트를 진행하면서 수많은 문제를 겪고, 개념을 정립한 지금의 내가 돌이켜봤을 때 “어떤 걸 얻을 수 있었나?“를 회고하는 글이다. 나는 Python 파일 하나 만들 줄 아는 상태에서 시작해서, 웹 서버, 메시지 큐, 샌드박스 격리, 컨테이너, cgroup, 배포까지 건드려보며 run.ljweel.dev 라는 실제로 동작하는 서비스를 만들어냈다.

시리즈 10편은 “당시의 내가 어떻게 생각했는지"의 기록이고, 이 총평은 “지금의 내가 그것을 어떻게 바라보는지, 무엇을 얻어갔는지"의 기록이다.