- 발행일
./img가 Nginx에서 깨졌다 — 입사 한 달째 첫 배포 환경 차이, 그리고 이름이 같은 함수 둘
./img가 Nginx에서 깨졌다
이 글은 2026년 9월에 당시 커밋 이력을 다시 보며 정리한 것입니다. 날짜는 작업한 날 기준입니다.
2025년 5월 12일, 입사 한 달째였습니다. 그 전 주에 관리자 워크플로 편집 화면을 아코디언 형태로 다시 짜고 있었어요. 항목 묶음마다 접었다 펴는 헤더가 있고, 헤더 왼쪽에 위·아래 화살표 아이콘이 붙는 구조입니다.
로컬 runserver에서는 다 보였습니다. 운영 서버에 올라간 뒤에 그 아이콘이 안 떴어요. 그날 오전 커밋 제목이 이렇습니다.
- 1. Fixed image path escaping for Nginx web server
- 2. Added test build for additional context support in accordion body
무엇을 고쳤나
고친 건 경로 문자열의 ./ 두 글자였습니다.
- src="{{ './img/icn_expand_up_gray_16px.svg' | static }}"
+ src="{{ 'img/icn_expand_up_gray_16px.svg' | static }}"
같은 파일의 JS 쪽 세 곳, 그리고 한 시간 반쯤 뒤 두 번째 커밋(f193153b)에서 프로세스 상세 템플릿의 다섯 곳까지. 제가 만든 아코디언 파일 두 개만 손댔습니다.
그런데 그 시점 저장소를 다시 열어 보면 './img/…' 꼴 경로가 템플릿 전체에 40곳 넘게 있었습니다. 2024년 12월에 관리자 기본 페이지가 만들어질 때부터 그 꼴이었고, 홈·푸터·페이지네이션·사이드바가 전부 같은 방식이었어요. 제 아코디언 템플릿도 그걸 보고 따라 쓴 겁니다.
그러니까 저는 "저장소 관행이 틀렸다"고 판단한 게 아니라, 내 화면이 안 뜨니 내 파일만 고친 거였습니다. 나머지 40곳이 운영에서 뜨는지 안 뜨는지는 그날 확인하지 않았습니다.
지금 다시 보면 설명이 안 된다
이 글을 쓰면서 처음으로 static 필터가 뭘 하는지 따라가 봤습니다. Jinja 환경에 이렇게 등록돼 있었어요.
from django.templatetags.static import static
...
env.filters["static"] = static
Django의 static()은 결국 urljoin(STATIC_URL, 경로)를 돌려줍니다. 그리고 파이썬의 urljoin은 ./를 접어 버립니다.
>>> from urllib.parse import urljoin
>>> urljoin('/static/', './img/a.svg')
'/static/img/a.svg'
./가 있든 없든 브라우저가 받는 주소는 같습니다. 그러면 운영에서 왜 안 떴고, 왜 이 수정으로 떴는지가 설명이 안 돼요. 그날 설정 파일에는 STATIC_URL = "static/" 한 줄뿐이고, 해시 붙은 정적 파일 저장소 같은 건 없었습니다. 커밋 메시지에 "Nginx"라고 적은 근거도 남아 있지 않습니다.
가능성은 몇 가지 떠오르지만 전부 추측이라 여기 적지 않겠습니다. "고쳤더니 됐다"까지만 확인하고 "왜 됐는지"는 안 남겼습니다. 이 글에서 말할 수 있는 건 거기까지입니다.
./img는 그 뒤로도 저장소에 한동안 살아 있었습니다. 마지막으로 걷어낸 커밋이 8월 12일이고 지금 저장소에는 한 곳도 없습니다.
같은 날 오후 — 이름이 같은 함수 둘
오후 세 시 커밋은 다른 문제였습니다. 제목이 "Resolve function conflict issue preventing script file transmission"인데, 지금 읽으면 무슨 말인지 저도 잘 모르겠어요. diff는 분명합니다.
이 페이지에는 스크립트가 두 군데서 옵니다. 공용 상세 템플릿 utils/item.html.j2에 이런 함수가 있고,
function deleteItem(element) {
if (window.confirm(element.dataset.message))
document.getElementById("utils-item-form").submit();
}
제가 만든 item_set.html.j2에도 같은 이름이 있었습니다.
function deleteItem(button) {
...
const formRow = button.closest(".form-row");
const deleteField = formRow.querySelector("input[id$='-DELETE']");
하나는 개체를 통째로 삭제하는 폼을 제출하고, 다른 하나는 폼셋의 행 하나에 삭제 표시를 합니다. 둘 다 전역 함수라 한 페이지에 같이 실리면 나중에 실행된 쪽이 이깁니다. 행 하나 지우려고 누른 버튼이 개체 삭제 폼을 제출할 수 있는 구조였어요.
이름을 바꾸고, 행 삭제는 이미 넣어 둔 jquery-formset 플러그인의 버튼을 대신 눌러 주는 방식으로 바꿨습니다.
- function deleteItem(button) {
+ function deleteIndividualItem(button) {
...
- const formRow = button.closest(".form-row");
- const deleteField = formRow.querySelector("input[id$='-DELETE']");
-
- if (deleteField) {
- deleteField.value = "on";
- formRow.style.display = "none";
- alert("삭제 완료되었습니다.");
- }
+ $(button).closest(".form-row").find(".js-formset-delete").click();
DELETE 필드를 직접 on으로 만들고 행을 숨기던 코드를 지운 건, 플러그인이 같은 일을 이미 하고 있었기 때문입니다. 사흘 전에 그 플러그인을 넣어 놓고 제 손으로 또 짜고 있었던 거예요.
검증
- 경로 수정은 운영 서버에 올려 아이콘이 뜨는 것까지 봤을 겁니다. 그 확인을 어디에도 적어 두지 않아서 지금은 커밋 메시지의 "Fixed"가 유일한 기록입니다.
- 함수 충돌은 로컬에서 행 삭제 버튼과 개체 삭제 버튼을 각각 눌러 봤습니다. 테스트는 없습니다.
- 이 글을 쓰며 확인한 것:
urljoin이./를 접는다는 것, 그날'./img/'경로가 40곳 넘게 있었다는 것, 현재 저장소에는 0곳이라는 것.
남은 것 · 한계
- 경로 수정이 왜 효과가 있었는지 모릅니다. 그날은 알았을 수도 있는데 남기지 않았습니다. 재현할 환경도 이제 없습니다.
- 40곳 중 2곳만 고쳤습니다. 나머지는 석 달에 걸쳐 다른 커밋들에 묻어서 사라졌고 일괄로 정리한 커밋은 없습니다.
- 전역 함수 이름 충돌은 이름을 바꿔 피한 것이지 구조를 바꾼 게 아닙니다. 템플릿 안 인라인 스크립트에 전역 함수를 두는 방식은 두 달 뒤 JS 파일로 분리할 때까지 그대로였습니다.
- 같은 날 커밋에
TODO주석을 네 줄 남겼습니다. "개별 삭제가 잘 동작이 안되는 케이스 확인", "추가나 취소를 할때 실제 백엔드에 전달이 되기 때문에 그 부분 확인" 같은 것들인데, 이 TODO가 언제 닫혔는지 추적하지 못했습니다.
관련 글: Jinja 템플릿 정리 — 문법 레퍼런스와 Django에서 실제로 쓰며 배운 것2025-04-21Jinja 템플릿 정리 — 문법 레퍼런스와 Django에서 실제로 쓰며 배운 것Jinja 템플릿의 문법·필터·상속·매크로를 표로 정리한 레퍼런스입니다. Django 프로젝트에서 Jinja2를 실제로 쓰며 배운 것들을 뒤에 붙였습니다 — url·static이 공짜가 아니라는 것, safe 필터가 사고의 지름길이라는 것, 그리고 원문 필터 표의 예시가 문법부터 틀려 있었다는 고백까지. · Django 템플릿에서 공용 매크로와 스크롤 스파이를 외부화한 회고2026-05-12Django 템플릿에서 공용 매크로와 스크롤 스파이를 외부화한 회고약관/방침 페이지와 소개·가이드 페이지에 흩어져 있던 마크업 중복을 base 템플릿 + 공용 매크로 + 외부 스크립트로 정리한 회고입니다. · Django Formset과 Inline Formset 이해하기2025-08-21Django Formset과 Inline Formset 이해하기Django에서 여러 폼을 한 페이지에서 처리하는 Formset과 부모-자식 관계를 함께 처리하는 Inline Formset의 차이와 사용법을 정리합니다. · 배포 첫날 커밋 열 개2024-11-28배포 첫날 커밋 열 개 — www 누락, 리라이트 삭제, 그리고 읽히지도 않던 middlewareTripTune을 처음 Netlify에 올린 날, 커밋 열 개가 전부 next.config.js와 middleware.ts를 만지는 것이었습니다. 백엔드 주소에 www가 빠졌고, 리라이트를 지웠다가 되살렸고, 다음날에는 middleware의 matcher를 통째로 뒤집었어요. 지금 커밋을 다시 보니 그 middleware는 위치 때문에 애초에 실행된 적이 없었을 가능성이 큽니다. · 파일 33개를 한 커밋에 넣었다2025-07-17파일 33개를 한 커밋에 넣었다 — 제목은 한 가지 일이라고 말하고, 안에는 다섯 가지가 있었다입사 석 달째, 파일 33개에 1,228줄을 더하고 823줄을 지운 커밋을 올렸습니다. 제목은 스크립트 하나를 바꿨다는 한 줄이었어요. 지금 세어 보니 그 안에 이름 바꾸기, 새 모듈 다섯, 인라인 스크립트 분리, 설정 변경, 뷰 정리가 같이 들어 있었습니다. 그중 하나는 두 달 뒤 팀원이 지웠고, 하나는 1년 뒤 버그로 돌아왔습니다.