Post

Django 운영 코드 정리: 보안, 테스트, i18n, 계정 복구

Life Diary 프로젝트에서 통계 JSON 전달 방식, 로그인 보안, pytest fixture, 다국어 메시지 처리, 계정 복구 예외 처리를 정리한 과정을 기록

Django 운영 코드 정리: 보안, 테스트, i18n, 계정 복구

들어가며

이전 글에서는 Life Diary의 뷰에 몰린 처리 흐름을 Use Case와 Repository로 옮기고, 통계 쿼리와 캐시, 세션 설정을 정리한 과정을 다뤘다.

그 작업의 중심은 기능 처리 흐름과 비용이었다.

뷰가 너무 많은 일을 알고 있었고, 통계 계산은 점점 무거워졌으며, 반복 쿼리와 세션 write도 줄일 필요가 있었다. 그래서 시간 기록 저장 흐름을 Use Case로 옮기고, ORM 조회는 Repository에 모았고, 통계 캐시와 DB 연결 설정도 함께 정리했다.

하지만 거기서 끝나지는 않았다.

기능 흐름과 비용을 정리하고 나니, 그동안 뒤로 미뤄두었던 운영 코드가 보이기 시작했다.

통계 데이터를 템플릿에 넘기는 방식은 안전한가.
반복적인 로그인 실패를 제한하고 있는가.
테스트 코드는 계속 유지하기 쉬운가.
다국어 메시지는 한국어 하드코딩에서 벗어났는가.
계정 복구 과정에서 사용자 존재 여부를 노출하고 있지는 않은가.
메일 발송 실패가 그대로 500 에러로 이어지지는 않는가.

이번 글은 그 문제들을 정리한 기록이다.

1차 리팩토링이 기능 처리 흐름과 비용을 정리한 작업이었다면, 이번 작업은 보안, 테스트, i18n, 계정 복구처럼 운영 중 문제가 될 수 있는 부분을 정리한 작업이다.


1. 통계 JSON을 |safe로 넘기던 문제

Life Diary의 통계 화면은 JavaScript를 사용한다.

서버에서 계산한 통계 데이터를 템플릿에 넘기고, 브라우저에서는 그 데이터를 읽어 차트나 통계 UI를 그린다.

초기 구현에서는 JSON 데이터를 템플릿에 직접 넣기 위해 |safe를 사용했다.

1
{{ stats_data|safe }}

기능은 동작했다.

서버에서 만든 JSON이 브라우저에 전달되었고, JavaScript에서도 문제없이 읽을 수 있었다. 하지만 이 방식은 운영 관점에서 다시 봐야 했다.

|safe는 Django의 escaping을 우회한다.
데이터가 정말 안전하다고 확신할 수 있을 때만 사용해야 한다.

통계 데이터라고 해서 항상 안전한 것은 아니다.
태그명, 메모, 사용자 입력값이 일부 포함될 수 있다면 HTML 또는 JavaScript 문맥에서 문제가 생길 수 있다.

특히 저장된 사용자 입력이 나중에 통계 JSON에 섞여 화면에 다시 삽입된다면 저장형 XSS 위험으로 이어질 수 있다.

그래서 json_script로 변경했다.

1
{{ stats_data|json_script:"stats-data" }}

JavaScript에서는 다음처럼 읽는다.

1
2
3
const statsData = JSON.parse(
  document.getElementById("stats-data").textContent
);

이 방식은 JSON 데이터를 HTML 안에 넣되, JavaScript 코드로 직접 실행되지 않도록 다룬다. 화면에서 보이는 기능은 거의 바뀌지 않는다. 하지만 데이터 전달 방식은 더 안전해진다.

이번 수정에서 중요한 점은 기능을 새로 만든 것이 아니라는 점이다.

사용자는 차이를 느끼지 못할 수 있다.
하지만 운영하는 입장에서는 차이가 크다.

기능이 정상 동작한다고 해서 안전한 구현은 아니다.
특히 사용자 입력이 HTML이나 JavaScript 문맥에 들어가는 경우에는 더 조심해야 한다.


2. 로그인 실패를 제한하기

로그인 기능은 이미 있었다.

하지만 로그인 기능이 있다는 것과 로그인 시도를 안전하게 다룬다는 것은 다르다.

아이디와 비밀번호를 입력받는 화면이 있다면 반복적인 로그인 실패도 처리해야 한다.
그렇지 않으면 공격자가 여러 비밀번호를 계속 시도할 수 있다.

Life Diary에는 이를 보완하기 위해 django-axes를 도입했다.

django-axes는 로그인 실패를 추적하고, 설정한 기준을 넘으면 추가 시도를 제한할 수 있게 해준다.

예를 들어 같은 사용자명 또는 같은 IP에서 로그인 실패가 반복되면 일정 시간 동안 로그인을 막을 수 있다.

이 작업은 복잡한 기능은 아니다.
하지만 로그인 기능을 외부에 열어둔 서비스라면 기본적으로 확인해야 할 부분이다.

개인 프로젝트라고 해도 로그인과 사용자 데이터를 다루면 책임이 생긴다.
서비스 규모가 작다고 해서 로그인 공격 가능성이 사라지는 것은 아니다.

이번 작업에서는 로그인 실패 제한을 추가하면서 다음 기준을 확인했다.

1
2
3
4
- 로그인 실패 횟수를 추적할 수 있는가
- 일정 기준을 넘었을 때 추가 시도를 제한하는가
- 잠금 상태에서 사용자에게 과도한 정보를 주지 않는가
- 운영 로그로 실패 흐름을 확인할 수 있는가

로그인은 단순히 성공과 실패만 처리하면 끝나는 기능이 아니었다.
실패가 반복될 때 어떻게 제한할지도 함께 봐야 했다.


3. CDN 스크립트에 SRI 추가하기

Life Diary는 일부 정적 리소스를 CDN으로 불러온다.

CDN을 사용하면 빠르고 편하다.
하지만 외부에서 가져오는 스크립트라면 무결성도 확인해야 한다.

그래서 CDN 스크립트에 SRI hash를 추가했다.

1
2
3
4
5
<script
  src="..."
  integrity="..."
  crossorigin="anonymous">
</script>

SRI는 Subresource Integrity의 약자다.
브라우저가 외부 리소스를 불러올 때, 실제 파일이 예상한 해시와 일치하는지 확인한다.

해시가 맞지 않으면 브라우저는 해당 리소스를 실행하지 않는다.

CDN을 쓴다고 해서 항상 문제가 생기는 것은 아니다.
하지만 외부 스크립트가 변조되었을 때의 영향은 크다.

특히 JavaScript는 브라우저에서 직접 실행된다.
사용자 화면과 입력값에 접근할 수 있기 때문에, 외부 스크립트를 사용할 때는 최소한의 검증 장치를 두는 편이 낫다.

이번 수정도 화면 기능을 바꾸는 작업은 아니었다.
하지만 운영 기준에서는 필요한 작업이었다.


4. pytest를 쓰지만 pytest답지 않았던 테스트

Life Diary에는 테스트가 있었다.

문제는 테스트가 없는 것이 아니었다.
테스트를 계속 유지하기 불편한 상태였다는 점이다.

테스트 러너는 pytest였지만, 테스트 코드 작성 방식은 unittest에 가까웠다.
각 테스트마다 사용자 생성, 로그인 처리, 기본 데이터 생성, 언어 설정, 클라이언트 준비 코드가 반복되었다.

예를 들면 이런 준비 코드가 여러 테스트에 흩어져 있었다.

1
2
3
4
5
6
- 테스트 사용자 생성
- 로그인 처리
- 기본 태그 생성
- 날짜별 기록 생성
- 언어 설정
- 클라이언트 준비

준비 코드가 길어지면 테스트 본문에서 무엇을 검증하는지 잘 보이지 않는다.

주간 통계 화면을 확인하는 테스트라면, 테스트 본문에서는 주간 통계와 관련된 내용이 중심이 되어야 한다. 그런데 사용자 생성과 로그인 코드가 길게 들어가면 테스트의 의도가 흐려진다.

그래서 fixture 중심으로 정리했다.

1
2
3
4
factory
auth_client
en_client
ko_client

인증된 사용자가 필요한 테스트에서는 auth_client를 사용했다.

1
2
3
def test_user_can_view_weekly_stats(auth_client):
    response = auth_client.get(...)
    assert response.status_code == 200

영어 화면이 필요한 테스트에서는 en_client를 사용했다.

1
2
def test_feedback_message_is_rendered_in_english(en_client):
    ...

한국어 화면이 필요한 테스트에서는 ko_client를 사용했다.

1
2
def test_feedback_message_is_rendered_in_korean(ko_client):
    ...

이렇게 바꾸면 테스트 본문에서 준비 과정이 줄어든다.
테스트 이름과 fixture만 봐도 어떤 상황에서 무엇을 확인하는지 더 빨리 읽을 수 있다.

이번 정리로 111개의 테스트를 유지하면서 테스트 코드 LOC를 약 14% 줄였다.

LOC 감소 자체가 핵심은 아니다.
중요한 것은 테스트를 읽는 비용이 줄었다는 점이다.

테스트는 기능을 보호하기 위해 존재한다.
그런데 테스트 코드가 너무 장황하면 기능을 고칠 때마다 테스트를 고치는 일도 부담이 된다.

이번 정리의 목적은 테스트를 줄이는 것이 아니었다.
테스트가 계속 유지될 수 있게 만드는 것이었다.


5. 테스트 정리 중 발견한 locale leak

테스트를 fixture 중심으로 정리하면서 locale leak 문제도 확인했다.

다국어 기능이 있는 서비스에서는 언어 설정이 테스트 간에 섞이지 않도록 주의해야 한다.
한 테스트에서 한국어를 활성화했는데, 다음 테스트가 그 영향을 그대로 받으면 테스트 결과가 환경에 따라 달라질 수 있다.

예를 들어 영어 메시지를 기대하는 테스트가 있다고 하자.

이전 테스트에서 한국어가 활성화된 상태로 남아 있으면, 영어 메시지를 기대한 테스트에서 한국어 메시지가 나올 수 있다. 이런 문제는 테스트 순서에 따라 실패하거나 통과할 수 있어 더 위험하다.

테스트가 순서에 의존하면 신뢰하기 어렵다.

그래서 언어별 클라이언트를 분리하고, 테스트가 끝난 뒤 언어 상태가 남지 않도록 정리했다.

1
2
3
4
5
def test_feedback_message_is_rendered_in_english(en_client):
    ...

def test_feedback_message_is_rendered_in_korean(ko_client):
    ...

다국어 테스트에서 중요한 것은 번역 결과만 확인하는 것이 아니다.

각 테스트가 독립적으로 실행될 수 있어야 한다.
한국어 테스트가 영어 테스트에 영향을 주면 안 되고, 영어 테스트가 한국어 테스트에 영향을 주면 안 된다.

이번에 테스트 코드를 정리하지 않았다면 이 문제는 더 늦게 발견되었을 가능성이 크다.

테스트 정리는 단순히 코드 줄 수를 줄이는 일이 아니었다.
숨은 의존을 찾는 작업이기도 했다.


6. i18n은 문자열 치환만으로 끝나지 않았다

Life Diary는 한국어를 기본으로 만들었다.

처음에는 대부분의 문구가 한국어로 직접 작성되어 있었다.
템플릿, Python 코드, JavaScript 안에 문자열이 흩어져 있었다.

다국어 지원을 위해 먼저 기본적인 작업을 진행했다.

템플릿에는 {% trans %}를 적용했다.

1
{% trans "Today" %}

Python 코드에서는 gettext_lazy를 사용했다.

1
from django.utils.translation import gettext_lazy as _

JavaScript에서 필요한 번역은 JavaScriptCatalog를 사용했다.

여기까지는 일반적인 i18n 작업이었다.

진짜 문제는 동적 피드백 메시지였다.

Life Diary는 사용자의 기록을 분석해서 피드백을 만든다.

예를 들면 이런 메시지다.

1
2
3
운동 시간이 30분 부족합니다.
공부 시간이 어제보다 늘었습니다.
수면 시간이 목표보다 적습니다.

처음에는 Python 코드에서 바로 문장을 만들 수 있다.

1
message = f"{tag_name} 시간이 {minutes}분 부족합니다."

구현은 쉽다.
하지만 언어가 늘어나면 문제가 생긴다.

첫째, gettext가 문자열을 안정적으로 추출하기 어렵다.
둘째, 언어마다 문장 순서가 다르다.
셋째, 비즈니스 로직과 표시 문구가 결합된다.
넷째, 언어를 추가할 때 Python 코드를 계속 수정하게 된다.

그래서 메시지를 완성된 문장이 아니라 코드와 파라미터로 표현했다.

1
2
3
4
5
6
7
8
LocalizableMessage(
    code="feedback.goal.low",
    params={
        "tag_name": tag_name,
        "minutes": minutes,
    },
    severity="warning",
)

Python 코드에서는 어떤 메시지인지와 필요한 값만 만든다.
실제 문장은 번역 파일에서 관리한다.

기존 방식은 이렇다.

1
Python 코드에서 직접 문장 생성

바꾼 방식은 이렇다.

1
2
Python 코드는 메시지 코드와 파라미터만 생성
번역 파일에서 언어별 문장 관리

이렇게 바꾸면 일본어를 추가하더라도 Python 비즈니스 로직을 크게 수정하지 않아도 된다.

i18n 작업에서 중요한 것은 단순히 문구를 번역하는 것이 아니었다.
문장을 만드는 책임이 어디에 있는지 정리하는 일이었다.


7. 계정 복구 기능 추가

운영 중인 서비스라면 계정 복구 흐름도 필요하다.

사용자는 아이디를 잊을 수 있고, 비밀번호를 잊을 수 있다.
로그인에 실패했을 때 다시 서비스로 돌아올 수 있는 경로가 있어야 한다.

Life Diary에는 아이디 찾기와 비밀번호 재설정 기능을 추가했다.

처음에는 단순한 기능처럼 보인다.

이메일을 입력받는다.
계정을 찾는다.
메일을 보낸다.
완료 페이지를 보여준다.

하지만 여기에도 조심해야 할 부분이 있다.

계정 존재 여부를 사용자에게 그대로 알려주면 안 된다.

예를 들어 다음 방식은 좋지 않다.

1
2
3
4
5
가입된 이메일:
비밀번호 재설정 메일을 보냈습니다.

가입되지 않은 이메일:
해당 이메일로 가입된 계정이 없습니다.

이렇게 응답하면 공격자는 특정 이메일이 서비스에 가입되어 있는지 확인할 수 있다.

그래서 입력값과 관계없이 동일한 완료 페이지로 이동하도록 했다.

1
2
3
4
5
가입된 이메일:
메일 발송 후 완료 페이지 이동

가입되지 않은 이메일:
메일 발송 없이 완료 페이지 이동

사용자에게 보이는 응답은 같다.
내부 처리만 달라진다.

이 방식은 사용자에게 약간 덜 친절해 보일 수 있다.
하지만 계정 존재 여부를 노출하지 않는 쪽이 더 안전하다.

계정 복구는 단순 편의 기능이 아니었다.
인증 흐름의 일부였고, 보안 기준도 함께 봐야 하는 기능이었다.


8. SMTP 실패를 500으로 끝내지 않기

계정 복구 기능을 추가하면서 메일 발송 실패도 다뤄야 했다.

비밀번호 재설정 메일이나 아이디 찾기 메일은 SMTP에 의존한다.
SMTP는 외부 시스템이다. 외부 시스템은 실패할 수 있다.

초기에는 메일 발송 실패가 그대로 500 에러로 이어질 수 있었다.

이 방식은 좋지 않다.

사용자는 계정 복구를 시도했는데 서버 에러 화면을 보게 된다.
운영자는 어떤 입력에서 어떤 메일 발송이 실패했는지 로그로 확인하기 어렵다.

그래서 사용자 응답과 운영 로그를 분리했다.

1
2
3
4
5
사용자 응답:
동일한 완료 페이지 제공

운영 로그:
SMTP 실패 원인 기록

사용자에게는 안정적인 흐름을 제공한다.
운영자는 로그를 통해 실패 원인을 확인한다.

여기서 중요한 점은 실패를 숨기는 것이 아니다.

사용자에게 불필요한 내부 오류를 그대로 보여주지 않고, 운영자가 확인할 수 있는 방식으로 남기는 것이다.

외부 시스템에 의존하는 기능은 실패를 예상해야 한다.
메일 발송, 결제, 외부 API, CDN은 모두 실패할 수 있다.

따라서 이런 기능은 성공 흐름만 만들면 부족하다.
실패했을 때 사용자에게 무엇을 보여주고, 운영자는 무엇을 확인할 수 있는지까지 정해야 한다.


9. 이번 작업에서 남긴 기준

이번 작업은 새 기능을 많이 추가하는 작업이 아니었다.

이미 있던 기능을 운영 기준으로 다시 확인하는 작업에 가까웠다.

정리하면서 남긴 기준은 다음과 같다.

1
2
3
4
5
6
7
8
- 사용자 입력이 HTML/JS 문맥에 직접 들어가지 않게 한다.
- 로그인 실패는 추적하고 제한한다.
- 외부 스크립트는 무결성을 확인한다.
- 테스트 준비 코드는 fixture로 모은다.
- 언어 설정은 테스트 간에 섞이지 않게 한다.
- Python 코드에서 완성된 번역 문장을 직접 만들지 않는다.
- 계정 존재 여부를 응답으로 노출하지 않는다.
- 외부 시스템 실패는 사용자 응답과 운영 로그를 분리한다.

하지만 실제 서비스를 운영할 때는 이런 부분이 문제를 줄인다.
화면에 보이는 기능보다, 보이지 않는 처리 방식이 더 중요할 때가 있다.

통계 화면이 잘 보이는 것만으로는 충분하지 않다.
그 통계 데이터가 안전하게 전달되어야 한다.

로그인 기능이 되는 것만으로는 충분하지 않다.
실패가 반복될 때 제한할 수 있어야 한다.

테스트가 있는 것만으로는 충분하지 않다.
계속 읽고 수정할 수 있어야 한다.

다국어 문구가 출력되는 것만으로는 충분하지 않다.
언어가 추가되어도 비즈니스 로직을 계속 고치지 않아야 한다.

계정 복구 메일이 발송되는 것만으로는 충분하지 않다.
계정 존재 여부를 노출하지 않고, 메일 실패도 처리해야 한다.


마무리

1차 리팩토링에서는 Life Diary의 기능 처리 흐름과 비용을 정리했다.

이번 작업에서는 그 이후 남은 운영 코드를 정리했다.

보안, 테스트, i18n, 계정 복구는 처음 기능을 만들 때는 뒤로 밀리기 쉽다.
하지만 배포 후에는 계속 따라오는 문제다.

이번 작업을 통해 확인한 내용은 다음과 같다.

1
2
3
4
5
- 기능이 동작해도 안전하지 않은 구현이 있을 수 있다.
- 테스트는 작성하는 것보다 유지하기 쉬운 상태로 만드는 것이 중요하다.
- i18n은 문자열 번역보다 문장 생성 책임을 나누는 일이 더 중요했다.
- 계정 복구는 편의 기능이 아니라 인증 흐름의 일부다.
- 외부 시스템 실패는 사용자 응답과 운영 로그를 분리해야 한다.
This post is licensed under CC BY 4.0 by the author.