발행일

./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에서 실제로 쓰며 배운 것 · Django 템플릿에서 공용 매크로와 스크롤 스파이를 외부화한 회고 · Django Formset과 Inline Formset 이해하기 · 배포 첫날 커밋 열 개 · 파일 33개를 한 커밋에 넣었다