TL;DR
Django 멀티프로세스 환경에서 캐싱 데코레이터가 제대로 작동하지 않았다. 원인은 Python 내장 hash() 함수가 프로세스마다 다른 값을 반환하는 해시 랜더마이제이션 때문이었다. hashlib.sha1()으로 해싱을 구현해 해결했다.
로컬에선 잘 되는데요?
평화로운 인턴생활을 보내던 중, 복잡한 계산이 포함된 API 성능 최적화 작업을 맡았다.
계산 로직이 100ms 이상 걸려서 캐싱 데코레이터를 직접 구현했다. 로컬 개발 환경에서 테스트해보니 완벽했다.
첫 번째 호출에서는 실제 계산을 수행하고, 두 번째 호출부터는 캐시에서 즉시 결과를 반환했다.
실제 계산 없이 캐시에서 바로 가져오니까 체감상 거의 즉시 응답이 왔다.
동일한 입력에는 동일한 해시값이 나오고, 다른 입력에는 다른 해시값이 나오니까 완벽한 캐시 키가 되니 이제 성능 문제 해결됐다며 자신만만하게 PR을 올렸다.
그런데 코드 리뷰에서 예상치 못한 피드백을 받았다.
파이썬 내장 hash() 함수는 프로세스마다 다른 값을 제공합니다. Django의 멀티프로세스 환경에서는 여러 워커가 서비스를 동시에 처리하기 때문에, 같은 API를 여러 번 호출해도 다른 워커가 처리하면 해시값이 달라져서 캐싱이 무의미해질 수 있어요.
피드백 한 문단에 정말 많은 생각이 들었다.
hash()가 프로세스마다 다른 값을 준다고?
"동일한 입력 → 동일한 출력" 즉, 멱등성이 해시 함수의 기본 원리 아니었나?
딕셔너리도 내부적으로 이걸 쓰고, 세트(set)도 이걸 쓰는데 그게 불안정하다는 소리인가
그럼 딕셔너리는 어떻게 작동하는 거지?
프로세스마다 해시값이 다르면 딕셔너리도 망가지는 거 아닌가
로컬에서는 왜 잘 됐을까?
내 개발 환경에서는 분명히 일관된 캐시 동작을 보였는데
그럼 다른 사람들은 다 어떻게 하고 있지?
캐싱 라이브러리들은 내부적으로 어떤 해시 함수를 쓰는 거지
범인의 정체: PYTHONHASHSEED
직접 실험해봤다.
터미널 두 개를 띄워서 동일한 문자열에 hash()를 적용해보니...
터미널 1: -2045365527980573896
터미널 2: 7123456789028234561
똑같은 입력인데 완전히 다른 결과!
알고보니 Python 3.3부터 해시 랜더마이제이션(Hash Randomization)이라는 해시 충돌 공격(Hash DoS Attack)방지를 위해 프로세스마다 랜덤 시드를 사용한다고 한다. 해시 충돌 공격은 해커가 의도적으로 동일한 해시값을 갖는 여러 입력을 서버에 보내는 공격이다.
악의적인 입력들이 모두 같은 해시값을 가진다면?
malicious_inputs = [
"Aa", # hash = 12345
"BB", # hash = 12345 (충돌!)
"CC", # hash = 12345 (충돌!)
# ... 수백 개의 충돌 입력
]
정상적인 해시 테이블 (O(1))
hash("user1") = 1001 → bucket[1]
hash("user2") = 2002 → bucket[2]
hash("user3") = 3003 → bucket[3]
해시 충돌 공격 당한 해시 테이블 (O(n) - 연결리스트 순차탐색)
hash("Aa") = 12345 → bucket[12345] → ["Aa", "BB", "CC", ...]
hash("BB") = 12345 → bucket[12345] (같은 버킷!)
hash("CC") = 12345 → bucket[12345] (같은 버킷!)
이러한 공격은 딕셔너리 조회가 100배~1000배 느려지게 만들고 결국엔 CPU 사용률 100% 도달해 서버 응답 불가 상태 (DoS)까지 간 사례도 있다고 한다.
결국 이걸 막기 위해 PYTHONHASHSEED 환경변수가 프로세스 시작 시점에 랜덤하게 설정되어, 동일한 문자열도 프로세스마다 다른 해시값을 갖는다.
운영 환경과의 차이
뿐만 아니라 실제 운영 서버에서는 성능을 위해 여러 워커 프로세스가 동시에 실행된다.
로드밸런서가 들어온 요청을 각각 다른 워커에게 분산시킨다. 같은 작업일지라도 그때 자원 현황에 맞춰 로드밸런서가 분배를 하기 때문에, 완전히 동일한 요청인데도 다른 워커에서 작업을 하게 되면 매번 새로 계산하는 결과가 나타나게 된다.
이제 동일한 계산 요청이 연속으로 들어온다고 해보자.
첫 번째 요청이 Worker 1에서 처리되어 calc_-2045562798057383659라는 캐시 키로 저장된다. 그런데 두 번째 동일한 요청이 들어왔을 때 로드밸런서가 Worker 2로 분배하면, Worker 2는 calc_8234567123456789012라는 완전히 다른 캐시 키를 생성한다.
결국 완전히 동일한 계산 요청임에도 불구하고 매번 새로운 워커에서 처리될 때마다 캐시 미스가 발생하고, 시간이 소요되는 계산을 반복하게 된다.
혼란스러운 질문들의 답
🤔 그럼 딕셔너리는 어떻게 작동하는 거지?
딕셔너리는 같은 프로세스 내에서만 사용되기 때문에 문제없다.
프로세스가 시작될 때 PYTHONHASHSEED가 한 번 설정되면, 그 프로세스 내에서는 계속 동일한 시드를 사용한다.
d = {}
d["user123"] = "value" # hash("user123") = -1234567890
print(d["user123"]) # hash("user123") = -1234567890
문제는 프로세스 간 데이터 공유에서 발생한다.
Worker 1에서 생성한 캐시 키를 Worker 2에서 찾으려고 할 때 서로 다른 해시값 때문에 찾을 수 없는 것이 내가 마주했던 상황이었다.
🤔 로컬에서는 왜 잘 됐을까?
로컬 개발 환경에서는 보통 단일 프로세스로 Django를 실행하기 때문이다.
로컬 개발 서버 (단일 프로세스)
python manage.py runserver
운영 서버 (멀티 프로세스)
gunicorn myapp.wsgi:application --workers 4
단일 프로세스에서는 모든 요청이 같은 해시 시드를 사용하므로 캐싱이 정상 작동한다.
이것이 "로컬에서는 잘 되는데 운영에서는 안 되는" 전형적인 케이스였다.
🤔 그럼 다른 사람들은 다 어떻게 하고 있지?
대부분의 캐싱 라이브러리들은 이 문제를 이미 해결했다.
1. Redis, Memcached 등 외부 캐시
프로세스 외부에 캐시를 두어 모든 워커가 공유한다.
2. 결정적 해싱 사용
Django의 캐시 프레임워크는 내부적으로 hashlib.md5()나 hashlib.sha1()을 사용한다.
3. 구조화된 키 생성
cache_key = f"loan_calc:{user_id}:{amount}:{rate}:{term}"
4. 입력값 검증과 정규화
해시 충돌 공격을 방지하기 위해 입력값에 제한을 두고 검증한다.
결국 이 문제는 Python의 보안 강화 조치와 멀티프로세스 환경의 만남에서 생긴 예상치 못한 부작용이었다.
SHA-1으로
문제의 원인을 파악 한 이후엔 hash() 대신 hashlib.sha1()을 사용해 해결했다.
SHA-1은 동일한 입력에 대해 프로세스와 관계없이 항상 동일한 출력을 보장하는 결정적 해싱 알고리즘이다.
이제 어떤 워커에서 처리하든 같은 캐시 키가 생성된다.
import hashlib
# Before
cache_key = f"{func.__name__}_{hash(input_string)}"
# After
cache_key = f"{func.__name__}_{hashlib.sha1(input_string.encode()).hexdigest()}"
물론 성능 트레이드오프는 존재한다.
실제 벤치마크 결과 SHA-1이 내장 hash()보다 3.8배 느렸지만,
요청당 0.66μs의 오버헤드는 100ms 걸리는 계산에 비하면 낮은 수준으로 무시할 정도다.
해싱 오버헤드를 감수하고 99%의 성능 향상을 얻는 것은 충분히 가치 있는 거래였다.
캐싱이 제대로 동작하게 되면서 동일한 요청에 대해서는 첫 번째만 계산하고 나머지는 즉시 반환할 수 있게 되었다.
SHA-1을 선택한 이유는 이 상황에서 보안보다 기능성이 더 중요했기 때문이다.
내부 캐싱 시스템이므로 외부 공격에 직접 노출되지 않고,
입력값도 통제된 계산 파라미터로 제한되어 있어 해시 충돌 공격의 위험이 상대적으로 낮았다.
또한 빠른 문제 해결이 필요한 상황에서 Redis 같은 외부 캐시 도입보다는 간단한 해시 알고리즘 변경이 더 현실적인 선택이었다.
이번 경험을 통해 가장 큰 깨달음은 분산 시스템에서는 결정성이 생명이라는 점이었다.
로컬 단일 프로세스 환경에서 완벽하게 작동하던 코드가 멀티프로세스 환경에서는 완전히 무용지물이 될 수 있다.
"동일한 입력에 대해 항상 동일한 출력"이라는 해시 함수의 기본 원칙이 Python의 보안 강화 조치로 인해 깨지면서,
예측 불가능한 캐싱 실패가 발생했다.
또한 플랫폼의 구현 세부사항에 의존하는 코드의 위험성도 깨달았다.
PYTHONHASHSEED같은 내부 동작에 의존하지 말고,
명시적이고 예측 가능한 방법을 사용해야 한다는 교훈을 얻었다.
단 한 줄의 수정이었지만 로컬과 운영 환경의 차이점,
성능과 정확성의 트레이드오프에 대해 깊이 생각해볼 수 있는 값진 경험을 얻었다.