【Django】DB 연결처를 주석으로 전환하다 사고가 나서, 되돌리면 실패하는 테스트를 작성한 이야기
요약
Django 프로젝트에서 주석 처리된 방식으로 DB 연결처를 전환하던 과정에서 데이터 삭제 사고가 발생했습니다. 재발 방지를 위해 환경 변수 사용으로 설정을 개선하고, 과거의 실수로 되돌리는 것을 막는 테스트 코드를 작성한 경험을 공유합니다.
핵심 포인트
- 주석 기반 설정은 연결처 착각 등 치명적인 실수를 유발할 수 있습니다.
- DB 연결 관리는 환경 변수(environment variable)를 통해 명시적으로 분리해야 합니다.
- 데이터 무결성 확보를 위해 Soft Delete와 PROTECT 설정을 적용하는 것이 중요합니다.
- '원래 방식'으로 되돌아가는 것을 막는 테스트 케이스 작성의 필요성을 강조했습니다.
조직 내에서 시험적으로 운영하고 있는 Django 프로젝트의 테스트 체계를 확립하는 작업을 진행하고 있습니다. 주요 목적은 나중에 제가 손을 떼고 간 후에도 블랙박스화되지 않도록 하는 것입니다. 또한, QA 엔지니어 직무로 이직을 앞두고 있어 그 연습도 겸하고 있습니다.
이 글에서는 로컬(local)에서 DB 연결처를 주석 처리하여 전환하던 구성을 버리고, 환경 변수(environment variable)를 통해 명시적으로 선택하도록 수정한 과정과, 이때 추가한 “누군가 원래 방식으로 되돌리면 실패하는 테스트”에 대해 정리했습니다.
AI(Claude)에게 구현을 맡기면서 진행했는데, 그 과정에서 AI는 특별히 지시하지 않는 한 “인간은 깜빡하고 원래대로 돌린다”라는 전제 하에 테스트를 작성해주지 않더군요. 이 부분에 대한 소감도 마지막에 조금 적겠습니다.
참고로 고찰 부분은 잡담입니다. 정량화한 것도 아니고 샘플을 취한 것도 아니므로, 다소 무책임할 수 있는 가능성까지 포함하여 읽어주시면 감사하겠습니다.
개발 초기부터 settings.py에서 다음과 같은 주석 처리 형식의 전환 방식을 사용하고 있었습니다.
if ENV == "local":
# 로컬에서 진행하는 테스트용 데이터베이스
# """
...
# """
여기서 #를 한 글자 지우면, 테스트용 설정이 문자열 리터럴(string literal)로 무효화되고 대신 운영 환경용 설정이 활성화되는 방식입니다.
처음에는 매우 편리했습니다. 마음대로 쓰고 지우고 싶을 때는 테스트 쪽을, 실제 운영 데이터 확인이나 운영 환경이 아닌 곳에서는 시험할 수 없는 API 체크를 하고 싶을 때는 운영 쪽으로 원터치 전환이 가능했기 때문입니다.
하지만 예상대로, 엄청난 실수를 저질렀습니다. Django 셸(shell)에서 학생에게 연결된 교과서 설정을 제거하려고 하다가, 교과서 자체를 삭제해 버린 것입니다.
# 하고 싶었던 것: 학생과 교과서의 연결 해제
student.textbook = None
student.save()
...
다행히 오래된 데이터를 가지고 실험하고 있었기 때문에 이용자는 제로였습니다. 간신히 원본 데이터를 다시 읽어와 데이터 상태를 되돌릴 수 있었습니다.
재발 방지 차원에서 먼저 다음과 같은 조치를 취했습니다.
- 교과서를 물리적으로 삭제하는 대신, 비활성화(soft delete) 처리하도록 변경했습니다.
- 교과서와 장(chapter)의 연결을, 부모를 지우면 자식도 지워지는
CASCADE방식에서, 자식이 있으면 부모를 지울 수 없는PROTECT방식으로 변경했습니다.
class Textbook(models.Model):
is_active = models.BooleanField(default=True)
def deactivate(self):
...
여기서 하나, 이 글을 쓰기 위해 당시의 커밋 기록을 되돌아보며 놀란 것이 있습니다.
제 기억으로는, 이번 사고는 “테스트 DB에 연결되어 있다고 착각하고 운영 DB를 조작했던 것”이었습니다. CASCADE로 설정된 것도 아마 의식조차 하지 못했고, “어떻게든 보호되어 있을 거라고 착각했기 때문”이라고 생각했습니다. 그리고 임시방편으로 on_delete를 수정했지만, 근본적인 원인은 주석 처리 한 글자로 연결처가 바뀌는 settings.py에 있다고 생각하여 이번 대책을 세우게 되었다…라는 흐름으로 기억하고 있었습니다.
그런데 기록은 달랐습니다.
- 당시 커밋 메시지에는 “셸 조작 중에, 학생의 교과서 설정을 제거하려다 교과서 본체를 삭제해 버렸다”라고만 적혀 있습니다.
연결처를 착각한 이야기는 어디에도 없습니다. - 이번 대책에 대한 Issue는 사고가 발생한 약 1개월 후에 제기되었고, 계기가 된 것은
별건(운영 DB로 마이그레이션 확인 시, “LOCAL”이라는 표시와 실제 연결처의 관계가 파악하기 어려웠던 점)
커밋의 시간 순서로 보면, 당시에는 “교과서가 미등록인 학생에게는, 학습할 수 없는 이유를 표시한다”라는 변경 사항을 운영 환경에 반영한 직후였습니다. 따라서, 학생의 교과서를 제거한 상태의 표시를 확인하려고 하고 있었던 시점이 가장 유력합니다(이것은 추측입니다).
연결처를 착각했는지, delete()하면 연결만 끊어질 거라고 오해했는지, 그 둘 중 하나일까요. 솔직히 말하자면, 지금 와서는 그것조차 모호합니다. 이 점은 마지막에 조금 더 다루겠습니다.
그렇다고 해도, 어떤 착각이었든 주석 처리 한 글자로 운영 환경으로 향하는 구조는 위험하다는 사실에는 변함이 없습니다. 피로 누적이나 멀티태스킹 등으로 머리가 엉망일 때는 평소라면 하지 않을 실수를 저지릅니다.
우선 가장 빠르고 간단한 대책으로, 연결처를 명확하게 보여주는 print 문을 삽입했습니다.
print("Now Connecting to test DB")
...
print("[CAUTION] Now Connecting to PRODUCT DB")
다만, 이것은 '사람이 화면을 보고 인지하는 것'에 의존한 대책입니다. '아, 지금은 본(本) 서버의 DB에 접근하고 있으니 조심해야겠다' 같은 생각은 머리가 복잡할 때는 하지 못합니다. 아마도 화면 자체를 보지도 않았을 겁니다.
그래서 시스템적으로 막는 방향으로 수정했습니다.
- 주석 처리로 전환하는 것을 멈추고, 환경 변수
DB_TARGET으로 연결 대상을 선택한다 - 지정하지 않으면 테스트 DB가 기본값이다. 본 서버 DB는DB_TARGET=production을 명시했을 때만 작동한다 - 예상치 못한 값(prod나Production등)은 에러를 내며 실행을 멈춘다 - 본 서버에 연결할 때는 눈에 띄는 경고 메시지를 출력한다 DB_TARGET은.env파일에 적지 않는다
from django.core.exceptions import ImproperlyConfigured
DB_TARGET_TEST = "test"
DB_TARGET_PRODUCTION = "production"
...
if ENV == "local":
DB_TARGET = resolve_db_target(os.getenv("DB_TARGET"))
print(connection_banner(DB_TARGET)) # 본 서버일 경우 화려하게 경고 메시지 출력
...
본 서버에 연결하고 싶을 때는 작업하는 셸에서만 환경 변수를 설정합니다.
$env:DB_TARGET = "production" # 이 터미널에서만 본서버
# ...작업...
Remove-Item Env:DB_TARGET # 끝나면 즉시 해제
DB 연결 대상 처리 방식에 대해서는 표준적인 방법들이 여러 가지 있습니다.
- 설정은 환경 변수에 둔다. dev / staging / production과 같이 이름 붙여진 '환경'에 설정을 묶지 않고, 배포(deploy)마다 독립적으로 관리한다 (The Twelve-Factor App의 Config)
settings/base.py
・dev.py
・prod.py
처럼 설정 모듈을 나누고,DJANGO_SETTINGS_MODULE로 전환한다 - 연결 정보는DATABASE_URL하나에 모아두고, django-environ의env.db()로 읽는다
다만, 이 프로젝트는 settings.py의 맨 앞에서 .env 파일을 자동으로 로드하고 있습니다. 표준대로 본 서버의 DATABASE_URL을 .env 파일에 적으면, 그 순간 모든 터미널에서 본 서버가 기본값이 되어 오히려 지금보다 위험합니다.
그렇기 때문에 '연결 대상 이름과 DB 매핑은 코드에 고정하고, 본 서버는 작업하는 셸에서 환경 변수를 명시했을 때만'이라는, 어떤 의미에서는 표준에서 벗어난 형태를 선택했습니다. 대응 관계가 코드에 있으면 리뷰와 테스트로 지킬 수 있다는 이유도 있습니다.
작업 과정 자체는 아마 흔한 것이었습니다.
- ChatGPT + Claude와 상담
- 막히는 부분이 생기면 Issue를 생성하여 범위를 확정한다
- Claude에게 구현을 맡기고, 모든 것을 설명받으면서 확인한다
- Codex에게 교차 검토(cross review)를 부탁한다
같은 형태였습니다.
예시처럼 제가 수정할 부분은 거의 없었지만, QA로서 수련 중인 이상 '테스트는 됐나요?'라고 통과시키는 것도 영 별로라는 생각이 들었습니다.
처음에 추가된 테스트는 resolve_db_target() 같은 **구성 요소(component)**를 단독으로 확인하는 것이었습니다. 기본값이 테스트 DB인지, production일 때 본 서버가 되는지, 잘못된 값에서 멈추는지 등을 검사했습니다.
이 자체로는 맞지만, 생각해보면 누군가가 settings.py에 예전 방식처럼 직접 코드를 적어 넣어도, 구성 요소는 올바르기 때문에 전부 통과해 버립니다. 이번 안전장치는 'settings.py가 구성 요소를 거쳐야만' 의미를 갖는데, 그 부분이 지켜지지 않았습니다.
그래서 제안한 것이, 만약 나 자신이나, 나의 엉성한 지시를 듣고 작업한 AI가 실수로 원래의 연결 방식으로 되돌아갔을 때 감지할 수 있는 테스트입니다.
import io
import os
import runpy
...
실제로는 여기에 '`production``까지 이어집니다.
또한 '실제 DB와 경고', '잘못된 값일 경우 ImproperlyConfigured로 중단'의 두 가지 케이스도 같은 방식으로 확인했습니다.
핵심 포인트는 세 가지입니다.
- 호출 여부가 아니라 결과를 본다: settings.py는 시작 시 단 한 번만 로드되기 때문에,
mock으로assert_called을 확인하는 것은 어렵고 게다가 호출된 후에DATABASES가 덮어쓰여지면 우회할 수 있습니다.runpy.run_path()로 settings.py를 다시 실행하여 생성된DATABASES를 보면, 덮어쓰기 여부도 감지할 수 있습니다. - DB에 연결하지 않는다: 단순히 settings만 로드하기 때문에 실제 운영 DB에는 접근하지 않습니다.
- 환경 변수를 오염시키지 않는다:
patch.dict(os.environ, ...)을 벗어나면 원래대로 돌아가기 때문에 다른 테스트에 영향을 주지 않습니다.DB_TARGET이 미설정인 경우 명시적으로pop하기 때문에, 테스트를 실행하는 셸에서DB_TARGET이 설정되어 있어도 결과는 변하지 않습니다.
실제로 settings.py를 일시적으로 예전의 직접 작성 방식(hardcoding)으로 되돌려 보았더니, 제대로 'prod_db' != 'test_db'로 실패하는 것을 확인했습니다. 테스트는 '수정 전(깨진 상태)에서 실패함'을 확실히 한 후에 신뢰할 수 있다는 것이죠.
덧붙여 AI용 지침 파일(CLAUDE.md)에도 다음과 같이 내용을 추가했습니다.
DB_TARGET=production은 사람이 해당 명령어에 대해 명시적으로 지시했을 때만 사용한다..env에는 기재하지 않는다 - 이 가드레일을 되돌리거나 완화하지 않도록 한다(DATABASES의 직접 작성, 주석 처리된 전환 복구, prod 같은 별칭 허용, 기본값 변경 등). 테스트를 통과시키기 위한 목적이라 할지라도 테스트가 실패하면, 가드레일이나 테스트를 수정하지 않고 보고한다.
'되돌리지 않는다'라고 추상적으로 쓰는 것보다, 흔히 발생하는 되돌림 방식을 구체적으로 나열하고 대신 취할 행동(보고)까지 적어두는 것이 AI가 지키기 더 쉬운 것 같습니다.
이 작업 자체는 문제없이 완료되었고 이미 머지(merge)도 끝났습니다. 다만, 매번 작업을 할 때마다 배움을 추출하다 보니, 이 같은 종류의 지적, 즉 '사람이 실수할 수 있음'을 전제로 한 지적이 제가 내보내면 비교적 통하는 경우가 많다고 느꼈습니다. 과거 Issue나 문서를 되돌아봐도,
- 사람 측의 오독과 AI의 환각(hallucination)이 겹치는 케이스가 무섭다.
- 향후 개인 정보를 실수로 로그에 전달했을 경우, 시스템 측(테스트나 필터)에서 감지할 수 있어야 한다.
- 메서드 이름이 복수형으로 남아있으면, 이름만 본 미래의 독자(인간이든 AI든)가 오해할 수 있다.
와 같은 관점들을 제가 추가했던 기억이 있습니다.
이번에도 마찬가지로 Claude는 처음에는 Issue에서 결정된 범위에 맞는 컴포넌트 테스트만 작성했습니다. 그때 '나중에 누군가가 되돌려 놓으면 감지할 수 있을까요?'라고 묻자, 즉시 빠져 있는 부분을 구체적으로 설명하며 가치가 높다고 답해왔습니다.
즉, 지식이 없었던 것이 아닙니다.'이 사람(혹은 후임)은 나중에 실수로 되돌릴 수도 있다'라는 전제가 아무 말도 없는 상태에서는 우선순위가 아니었던 것이라고 생각합니다. 물론 AI의 내부에서 이유를 설명할 수는 없기 때문에, 어디까지나 행동 패턴으로 그렇게 보였다는 이야기입니다. 샘플도 이 한 건뿐입니다.
포카요케(Poka-Yoke)나 스위스 치즈 모델처럼 '사람은 실수를 할 수 있다고 가정하고 설계한다'는 생각은 공학에서 예전부터 있었습니다. AI 역시 지식으로는 당연히 가지고 있어야 하지만, 문맥이 그것을 불러일으키지 않는 한 기본적으로 사용하지 않으려는 경향이 있는 것 같습니다. 대화형 서비스로서 '당신은 언젠가 실수할 것입니다'라는 전제의 제안은, 요청받지 않은 일에 대해서는 개입하기 어렵다는 점도 있을 수 있습니다.
반면에, 저희 쪽에서도 '저는 바보니까 멈춰주세요'라고 지시하지 않습니다. 설령 그렇게 한다고 해도, 무엇을 잊어버리고 어떻게 망가뜨릴지까지 지정할 수는 없습니다. 모르는 사람은 무엇을 모르는지 설명할 수 없기 때문입니다.
그래서 실무적으로 효과적이었던 것은 다음 두 가지였습니다.
미래의 오용을 질문 형태로 던지기: "장래에 사정을 모르는 누군가(나 자신이나 AI)가 부실한 지시로 되돌린다면, 감지할 수 있나요?". 이것은 어디서든 쓸 수 있는 질문 방식인 것 같습니다. 물어보면 AI는 즉시 대답해 줍니다. 역시 AI다. 똑똑하다.
실패는 사실과 해석을 분리하여 기록하기: 앞서 말했듯이, 저는 제 사고의 원인을 정확히 기억하지 못했습니다. 언제・어떤 커맨드를・어떤 DB에 대해 실행했는지라는 '사실'과 왜 그렇게 했는지라는 '해석'을 분리해서 기록해 두면, 나중에 기억이 바뀌더라도 사실은 남아 있습니다. 이번에는 당시 커밋(commit)에 사실이 남아있었기 때문에, 기억과의 불일치에 알아차릴 수 있었습니다.
그리고, 그렇게 쌓인 실패 기록은 AI에게는 그 사람 고유의 전제가 됩니다. 이번에는 그것을 CLAUDE.md 규칙으로 남겨두었습니다. AI는 일반적인 훌륭한 결과물은 내주지만, 제가 어리석다는 사실은 말해주지 않는 한 전제로 삼지 않습니다. 그렇다면, 저의 어리석음을 기록하고 전제로서 전달해 두는 것이 좋겠습니다. 그 정도 거리가 적당할 것 같습니다.
이 '실패담은 왜 이야기되지 않는가', '자신의 실수를 스스로 설명할 수 없는 것은 아닌가'와 같은 이야기는 좀 더 깊이 파서 다른 글로 쓸 예정입니다.
이번에 배운 것은, 목록으로 정리하자면 다음과 같은 느낌입니다.
- 주석 처리만으로 프로덕션(Production) DB를 바라보게 하는 구성은 편리하지만, 정신이 맑지 않을 때 사고가 난다.
- 출력(
print)을 통한 경고는 사람이 알아차리는 것에 의존하기 때문에, 피곤할 때는 효과가 없다. 시스템적으로 막아야 한다. - 연결 대상은 환경 변수
DB_TARGET로 명시하고, 미지정 시에는 테스트 DB를 사용하며, 잘못된 값일 경우 실행을 중단한다..env파일에는 적지 않는다. - 부품의 단위 테스트(Unit Test)만으로는, settings.py에 직접 코드를 다시 작성해도 알아차리기 어렵다.
runpy를 사용하여 settings.py를 실행한 결과를 보는 배선 테스트(Wiring Test)를 추가한다.
다만, 더 상위의, 모호한 레이어 단위로 본다면, 스스로 자신의 실수를 예측하고 질문하는 것의 중요성을 배웠다고 생각합니다. AI는 정말 엄청나게 지식이 풍부합니다. 인류인 우리 입장에서 보면 거의 무한대에 가깝습니다.
하지만, 모든 것을 가정할 수는 없고, 아마도 불가능합니다. "인류는 어리석기 때문에, 여기도 예상해 두는 것이 좋습니다"라고 일반론으로 말할 수 없습니다. 인간의 어리석음은 천차만별이며 다양성으로 가득 차 있기 때문입니다.
그렇기 때문에, 모호하고 정성적이라 죄송하지만, '나의 멘탈 모델의 구멍(hole)'을 스스로 신고해야 한다고 최근에는 특히 생각하는 것이 많아졌습니다. 나는 이것을 모르겠다. 당신이 제안한 이것은 의미가 불분명하다. 이렇게 크게 주장하는 것이 중요한 걸까 하고요.
자, 이번에는 여기까지입니다. 읽어주셔서 감사합니다. 다음에도 잘 부탁드립니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기