
아무도 알려주지 않았던 프로그래밍 의식 (Programming Rituals)
요약
Bash, Django, Python 등 주요 개발 도구들이 가진 기본 동작의 함정과 주의사항을 다룹니다. 공식 문서에는 잘 드러나지 않지만, 실무에서 운영 장애를 일으킬 수 있는 도구별 특이 동작(Rituals)을 설명합니다.
핵심 포인트
- Bash는 명령 실패 시에도 에러 핸들링 없이는 스크립트를 계속 실행함
- Bash 파이프라인의 종료 코드는 마지막 명령어의 결과에 의존함
- 정의되지 않은 Bash 변수는 빈 문자열로 처리되어 위험한 명령을 유발할 수 있음
- Django의 Secret Key 관리 및 Python의 Async Task 가비지 컬렉션 주의 필요
Bash는 당신에게 조용히 거짓말을 합니다. Django는 당신의 Secret Key를 유출합니다. Python은 당신의 Async Task를 유령처럼 사라지게 합니다. 온보딩 문서에는 아무도 이런 내용을 적어두지 않았습니다.
당신은 면접을 통과했습니다. 기능을 배포했습니다. 어쩌면 시니어 타이틀을 얻었을지도 모릅니다. 그러다 세 번째 운영 장애(Production Incident)와 다섯 번째 "잠깐, 원래 이렇게 작동하는 건가?" 하는 순간 사이의 어딘가에서 당신은 깨닫게 됩니다. 이 업무의 거대한 부분은 공식적으로 아무도 알려주지 않은 함정 목록을 암기하는 것이라는 사실을 말이죠.
엣지 케이스(Edge cases)가 아닙니다. 모호한 버그도 아닙니다. 도구 자체에 내장되어, 당신이 그 의식(Ritual)을 잊어버릴 단 한 번의 순간을 인내심 있게 기다리고 있는 함정들입니다.
저는 수년 동안 저만의 목록을 수집해 왔습니다. 어떤 것들은 제가 일으킨 장애에서 왔습니다. 어떤 것들은 팀원이 몇 시간 동안 디버깅(Debugging)을 하다가 결국 파일 상단에 입력해야 할 네 글자 때문에 고생하는 것을 지켜보며 얻었습니다. 몇몇은 저보다 훨씬 큰 회사의 엔지니어들이 작성한 사후 분석 보고서(Post-mortems)를 읽으며 얻었습니다. 그들도 정확히 똑같은 이유로 똑같은 실수를 저질렀습니다. 기본 동작(Default behavior)이 잘못되어 있었고, 아무도 그들에게 경고해주지 않았던 것입니다.
좋은 소식은 이렇습니다. 일단 그 의식을 알게 되면, 다시는 그 특정 불길에 데이지 않을 것입니다. 나쁜 소식은 이렇습니다. 항상 또 다른 불길이 있다는 것입니다. 목록은 결코 멈추지 않고 계속 늘어납니다.
요약 (TL;DR): 이것은 모든 현업 개발자가 결국 수집하게 되는 다섯 가지 의식에 대한 투어입니다. Bash의 조용한 실패 모드, 다양한 형태의 어리석은 치환(Substitution), Secret Key를 하드코딩하는 Django의 매력적인 습관, 그리고 실행 도중 당신의 Async Task를 조용히 가비지 컬렉션(Garbage-collecting) 해버리는 Python에 대해 다룹니다. 이 중 그 어느 것도 당신의 잘못이 아닙니다. 하지만 잊어버린다면 모두 당신에게 대가를 치르게 할 것입니다.
Bash의 "낙관적 허무주의" 모드
재미있는 게임을 하나 해봅시다. Bash 스크립트를 작성하세요. 중간에 명령 하나가 실패하도록 만드세요. 에러 핸들링(Error handling)은 추가하지 마세요. 그리고 실행해 보세요.
Bash는 스크립트를 끝까지 실행할 것입니다. 실패한 이후의 모든 줄을 말이죠. 아주 유쾌하게 말입니다. 마치 아무 일도 없었다는 듯이요.
이것은 제가 제출할 버그 리포트가 아니라, 그것이 기본 동작(Default behavior)입니다. Bash는 설계상 실패한 명령을 하나의 제안으로 취급합니다. 당신은 이것들을 실행하라고 말했고, Bash는 그것들을 실행했습니다. 그것들이 성공했는지 여부는 일종의 당신 문제입니다.
상황은 더 악화됩니다. 파이프라인 (pipeline) 내의 명령어가 실패하더라도, 파이프라인의 종료 코드 (exit code)는 실제로 실패한 명령어가 아니라 마지막 명령어에 의해 결정됩니다. 즉, 세 개의 파이프를 통해 쓰레기 데이터가 흐르고 전체 과정이 엉터리 결과를 만들어내더라도, Bash는 당신을 똑바로 쳐다보며 종료 코드 0을 반환할 것입니다. 성공입니다. 모두 수고하셨습니다.
그리고 정의하는 것을 잊어버린 변수를 참조하면 어떻게 될까요? Bash는 그것을 빈 문자열 (empty string)로 간주합니다. 경고도 없습니다. 에러도 없습니다. 그저 조용히 아무것도 없는 상태로 대체하고 계속 진행할 뿐입니다. 이것은 그 변수가 경로 (path)의 일부가 되기 전까지는 괜찮습니다. 하지만 그 순간 당신은 rm -rf /some/actual/directory 대신 rm -rf /를 실행하게 됩니다.
마지막 사례는 가설이 아닙니다. 2021년, 교토 대학교 (Kyoto University)의 백업 스크립트는 정의되지 않은 변수가 빈 값으로 확장되면서, 특정 대상을 삭제하려던 명령이 루트 레벨 (root-level) 삭제로 변질되어 77TB의 연구 데이터를 삭제했습니다. 연구자들은 수년간의 작업물을 잃었습니다. 스크립트는 아무런 불평 없이 끝까지 실행되었습니다.
Cloudflare에서도 이와 유사한 사례가 있었습니다. 배포 스크립트 (deployment script)가 실패한 명령어의 출력을 다음 단계로 파이프 (pipe) 했는데, 다음 단계가 그 쓰레기 데이터를 받아들이면서 전 세계적인 서비스 중단 (global outage)으로 이어졌습니다. 이런 종류의 사건은 Hacker News의 메인 페이지에 올라가 800개의 댓글이 달리게 되며, 그중 절반은 “그냥 set -euo pipefail을 써라”라고 말하게 됩니다.
이것이 바로 우리를 '의식 (ritual)'으로 인도합니다.
set -euo pipefail
네 가지 옵션. 한 줄. 예외 없이 모든 Bash 스크립트의 최상단에 작성하십시오:
-e: 어떤 명령어가 0이 아닌 종료 코드를 반환하면 스크립트 중단-u: 설정되지 않은 변수를 빈 문자열이 아닌 에러로 취급-o pipefail: 파이프라인 내의 어떤 명령어가 실패하더라도 (마지막 명령어뿐만 아니라) 파이프라인 전체를 실패 처리
그게 전부입니다. 이것이 의식의 전부입니다. 30년의 Bash 역사 속에서도 “무언가 잘못되면 중단하라”는 설정은 여전히 선택 사항 (opt-in)입니다.
이것이 기본값 (default)이 아닌 이유는 주로 역사적인 이유 때문입니다. 오래된 스크립트들이 Bash의 관대한 동작에 의존하고 있었고, 기본값을 바꾸면 그것들이 망가졌을 것이기 때문입니다. 이는 1980년대에 내려진 매우 합리적인 공학적 결정이었지만, 우리 나머지 사람들은 그 이후로 줄곧 그 대가를 치르고 있습니다.
진짜 실체는 새로운 코드베이스(codebase)에 합류하여 쉘 스크립트(shell scripts)를 grep으로 검색해 볼 때 드러납니다. 만약 그중 절반이 set -euo pipefail을 누락했다면, 당신은 그 팀이 이 설정을 어떻게 배웠는지, 혹은 왜 배우지 못했는지를 정확히 알게 됩니다. 이는 "이 프로젝트에 불행한 날이 찾아온 적이 있는가"를 보여주는 꽤 신뢰할 만한 지표입니다.

그것을 추가하세요. 매번 말입니다. 다른 무엇보다도 먼저 말이죠. 당신이 이 설정을 잊어버리는 단 하루가, 운영 서버(prod server)에서 정의되지 않은 변수가 나타나 그것이 무엇을 삭제했는지 알아내느라 다음 4시간을 허비하게 되는 날이 될 것입니다.
Django가 "그냥 시크릿 키(secret key)를 커밋하세요, 괜찮아요"라고 말했다
당신이 실제로 좋아하는 프레임워크로부터 받는 배신감은 그 결이 다릅니다.
Django는 진심으로 훌륭합니다. 필요한 기능이 모두 포함되어 있고(Batteries included), 합리적인 기본값(sensible defaults)을 제공하며, 성숙한 생태계와 탁월한 문서화를 갖추고 있습니다. 빠르게 생산성을 느낄 수 있게 해주는 종류의 프레임워크이며, 바로 그 점 때문에 새로운 프로젝트를 시작할 때 가장 먼저 하는 행동이 이 프레임워크가 지향하는 모든 가치와 너무나도 훌륭하게 어긋나곤 합니다.
django-admin startproject myproject
cat myproject/settings.py | grep -i secret
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-m&!$#k(56_this_is_literally_in_your_repo_now'
거기 있습니다. 당신의 시크릿 키(secret key)가 소스 파일 안에 있습니다. 바로 윗줄에는 그것을 비밀로 유지하라는 주석이 달려 있는데, 정작 그 주석 바로 아래 줄에서는 비밀이 지켜지지 않고 있습니다.
django-insecure- 접두사는 이 키가 자동 생성되었으며 운영 환경(production)으로 넘어가서는 안 된다는 것을 알리는 Django만의 방식입니다. 합리적인 아이디어입니다. 문제는 이것이 설정 파일(settings file)에 있는 단순한 문자열 접두사일 뿐, 실제로 아무것도 막지 못한다는 점입니다. 배포(deploy) 시점에 경고를 주지도 않습니다. GitHub에 푸시(push)하는 것을 차단하지도 않습니다. 교체하는 것을 잊더라도 런타임 에러(runtime error)를 발생시키지 않습니다. 그것은 야심만 찬 주석일 뿐입니다.
한편, 업계의 나머지 부분은 바로 이 순간을 피하기 위해 특별히 인프라를 구축해 왔습니다. GitHub에는 푸시된 커밋(pushed commits) 내에 노출된 자격 증명(credentials)을 표시해 주는 비밀 스캐닝(secret scanning) 기능이 있습니다. AWS에는 Secrets Manager가 있습니다. 모든 CI 플랫폼에는 암호화된 환경 변수(environment variables)가 있습니다. HashiCorp는 비밀 정보(secrets)가 소스 코드에 절대 닿아서는 안 된다는 아이디어를 중심으로 제품 전체를 구축했습니다. 그런데 당신이 django-admin startproject를 실행하면, settings.py에 당신의 키가 놓여 있고, 단 한 번의 git push만으로 영원히 공개될 위기에 처하게 됩니다.
커밋된 settings.py에 django-insecure-를 포함하고 있는 공개 GitHub 저장소(repositories)의 수는 결코 적지 않습니다. 이는 5초간의 검색만으로도 찾아낼 수 있는 종류의 일이며, 실제로 사람들은 그렇게 하고 있습니다.
이 의식(ritual)은 간단하며, 솔직히 기본값(default)이 되어야 합니다:
import os
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY')
또는 조금 더 편리한(ergonomic) 방식을 원한다면, python-decouple과 django-environ 모두 이를 깔끔하게 처리합니다. 로컬에서는 .env 파일에서 키를 가져오고, 운영(prod) 환경에서는 환경 변수에서 가져오며, 버전 관리 시스템(version control)에 절대 노출되지 않도록 합니다.
.env 파일은 즉시 .gitignore에 추가됩니다. 운영 환경의 비밀 정보는 코드베이스(codebase)가 아닌 배포 환경(deployment environment)에 존재합니다. 이것은 고급 보안 관행(advanced security practice)이 아니라 기본(baseline)이며, 수년 동안 기본값이었습니다.
Django 상황을 좌절스럽게 만드는 것은 프레임워크 자체가 보안에 취약해서가 아닙니다. 전체적으로 보면 그렇지 않습니다. 문제는 이 특정한 하나의 기본 설정이, 생태계의 나머지 부분이 지난 10년 동안 구축하려고 노력해 온 보안 습관(secure habits)과 정확히 정반대로 작동한다는 점입니다. 처음으로 startproject를 실행하는 주니어 개발자는 생성된 파일이 함정이라는 것을 알 특별한 이유가 없습니다. 경고 주석은 쉽게 지나치기 마련입니다. 키는 자격 증명(credential)이 아니라 설정(configuration)처럼 보이기 때문입니다.

첫 번째 커밋에서 이를 수정하세요. 프로젝트 템플릿에 추가하세요. 팀의 Django 온보딩(onboarding) 문서의 가장 첫 번째 항목으로 만드세요. 그렇지 않으면 대부분의 사람들이 겪는 방식, 즉 푸시(push)를 하고, 스캔(scan)을 하고, 아주 부적절한 시간에 GitHub로부터 "저장소에 자격 증명이 노출되었을 수 있습니다"라는 이메일을 받은 뒤에야 이 사실을 알게 될 것이기 때문입니다.
Python의 가비지 컬렉터(garbage collector)가 당신의 비동기 태스크를 조용히 해제할 것입니다
Python 커리어를 쌓다 보면 어느 시점에 다음과 같은 코드를 작성하게 될 것입니다:
async def notify_user(user_id):
await send_email(user_id)
async def handle_request(user_id):
asyncio.create_task(notify_user(user_id))
return {"status": "ok"}
깔끔합니다. 비동기(Async) 방식이며, 논블로킹(Non-blocking)입니다. 요청은 즉시 반환되고, 알림은 백그라운드에서 실행됩니다. 정확히 당신이 원하던 모습입니다.
하지만 때로는 실행되지 않을 때가 있습니다. 항상 그런 것은 아니지만, 가끔씩, 예측 불가능하게, 그리고 어떠한 에러(error)나 경고(warning), 로그(log) 기록도 남기지 않는 방식으로 말이죠. 태스크(task)가 그냥 실행되지 않습니다. 이메일은 발송되지 않습니다. 사용자는 아무런 응답을 받지 못합니다. 그리고 당신의 모니터링 시스템에는 예외(exception)가 전혀 나타나지 않습니다. 왜냐하면 실제로 실패한 것이 아니라, 태스크가 시작될 기회를 갖기도 전에 그냥 존재 자체가 사라져 버렸기 때문입니다.
발생한 일은 이렇습니다. Python의 가비지 컬렉터(garbage collector, GC)가 당신의 태스크를 살펴본 뒤, 프로그램 내의 그 어떤 것도 해당 태스크에 대한 참조(reference)를 유지하고 있지 않다는 것을 감지하고 이를 수거(collect)해 버린 것입니다. GC 로직에서 "참조가 없다"는 것은 "필요하지 않다"는 의미이며, GC 로직은 당신의 태스크가 실행 중(mid-flight)이었다는 사실을 알지도 못하고 신경 쓰지도 않습니다. 런타임(runtime)의 관점에서 보면, 당신은 객체(object)를 생성하자마자 즉시 버렸고, 정리 팀(cleanup crew)이 제 할 일을 수행한 것뿐입니다.
이것은 asyncio의 버그가 아닙니다. Python 공식 문서에도 실제로 명시되어 있으며, 처음 읽을 때는 그냥 지나치기 쉬운 노트(note)에 적혀 있습니다:
"중요: 태스크가 실행 도중 사라지는 것을 방지하려면, 이 함수의 결과에 대한 참조(reference)를 저장하십시오."
이것이 경고의 전부입니다. 이탤릭체로 작성된 단 한 단락뿐입니다. 운영 환경(production)에서 작업을 조용히 누락시켜 버릴 동작에 대한 경고가 말이죠.
이 의식은 작업이 실행되는 동안 참조(reference)를 살아있는 상태로 유지하는 것입니다:
tasks = set()
async def handle_request(user_id):
task = asyncio.create_task(notify_user(user_id))
tasks.add(task)
task.add_done_callback(tasks.discard)
return {"status": "ok"}
add_done_callback은 작업이 완료되었을 때 스스로를 세트(set)에서 자동으로 제거해 주는 깔끔한 부분입니다. 메모리 누수(memory leak)를 일으키는 것이 아니라, 작업이 완료될 때까지 작업이 머물 수 있는 장소를 제공하는 것뿐입니다. 그것이 작업에 필요했던 전부였습니다.
Python 3.11 이상 버전을 사용 중이라면, asyncio.TaskGroup이 더 구조적인 해결책입니다:
async def handle_request(user_id):
async with asyncio.TaskGroup() as tg:
tg.create_task(notify_user(user_id))
return {"status": "ok"}
TaskGroup은 수명 주기(lifetimes)를 명시적으로 관리하고, 예외(exceptions)를 적절히 처리하며, 일반적으로 백그라운드 작업 관리(background task management)를 언어가 우회해서 구현해야 하는 무언가가 아니라, 실제로 지원하는 기능처럼 느껴지게 만듭니다.
이 문제가 디버깅하기 특히 잔혹한 이유는 실패 모드(failure mode) 때문입니다. SQL 파라미터화(parameterization)가 누락되면 요란하게 터집니다. 하드코딩된 비밀 키(secret key)는 GitHub 스캔에서 드러납니다. set -e가 없는 Bash는 적어도 어디를 찾아야 할지만 안다면 로그에 흔적을 남깁니다. 하지만 가비지 컬렉션(GC) 대상이 된 비동기 작업(async task)은 아무것도 생성하지 않습니다. 스택 트레이스(stack trace)도, 에러 코드도, 관측성 플랫폼(observability platform)의 엔트리도 없습니다. 작업이 그냥 조용히 일어나지 않을 뿐이며, 사용자가 불평하거나 메트릭(metric)이 원인을 파악하기까지 시간이 걸리는 방향으로 드리프트(drift)할 때서야 비로소 알게 됩니다.
저는 백그라운드 작업 프로세서(background job processor)를 운영하는 팀이 완료율(completion rate)이 떨어졌다는 것을 아무도 알아차리기 전까지 3일 동안 이 문제로 고생하는 것을 보았습니다. 개별 요청은 모두 정상적으로 보였습니다. 서비스는 모든 대시보드 메트릭상 건강했습니다. 작업들은 실행되기도 전에 가비지 컬렉션(GC) 타이밍과 부하(load)에 따라 결정되는 속도로 조용히 증발하고 있었으며, 이는 처음에는 데이터 문제처럼 보일 정도로 충분히 불규칙했습니다.
문제를 찾은 후 해결하는 데는 20분이 걸렸습니다. 하지만 문제를 찾는 데는 3일이 걸렸습니다.

참조(reference)를 추가하세요. 가능하다면 TaskGroup을 사용하세요. 그리고 실제로 중요한 모든 백그라운드 작업(background task)에는 완료 카운터(completion counter)를 추가하여, 숫자가 더 이상 올라가지 않는 시점을 알 수 있도록 하세요.
의식의 비용(ritual tax)은 복리로 쌓인다
당신이 초보자일 때 아무도 말해주지 않는 사실이 하나 있습니다. 경력이 쌓인다고 해서 일이 쉬워지지는 않는다는 것입니다. 대신 다른 종류로 어려워질 뿐입니다. 초기에는 사물이 어떻게 작동하는지 이해하는 것이 어려운 부분입니다. 나중에는, 비밀스럽게 제대로 작동하지 않는 모든 것들을 기억해내는 것이 어려운 부분이 됩니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기