ORM, @property, 그리고 Celery 디버깅:
“왜 .only()에서는 분명히 존재하는 값이 없다고 할까?”
보안이 중요한 시스템을 설계하다 보면, API Key나 Secret 값을 모델에 암호화된 형태로 저장하고, 접근할 때는 @property를 통해 복호화된 값만 노출하는 패턴을 자주 쓰게 됩니다.
예를 들어, 대략 이런 구조죠.
_api_key→ 실제 DB 컬럼 (암호화된 값 저장)api_key→ 복호화하여 반환하는@propertyapi_key.setter→ 값 저장 시 자동 암호화
이 구조는 코드도 깔끔하고 보안적으로도 자연스러운 패턴입니다. 그런데 ORM(Django ORM 기준)과 함께 사용할 때, 아주 쉽게 빠지는 함정이 하나 있습니다.
“분명 모델에서 잘 쓰고 있던
api_key인데, ORM 쿼리에서만 ‘필드가 없다’고 한다?”
이 글은 그 함정이 어떻게 발생했고, 왜 디버깅이 특히 힘들었는지, 그리고 어떻게 피해야 하는지를 정리한 기록입니다.
1. 문제 상황: “분명히 값이 있는데 ORM이 없다고 한다?”
암호화 필드를 @property로 감싼 모델을 운영하던 중, 어느 날 ORM에서 다음과 같은 코드를 실행했습니다.
Model.objects.only("api_key").first()
혹은:
Model.objects.values("api_key")
또는:
Model.objects.filter(api_key__isnull=False)
결과는 항상 동일했습니다.
FieldDoesNotExist: Model has no field named 'api_key'
개발자 입장에서는 이렇게 말하고 싶습니다.
“아니, 있어. 내가 매일
obj.api_key로 잘 쓰고 있는데?”
실제로 모델은 이런 형태였습니다(축약 예시).
class Model(models.Model):
_api_key = models.CharField(db_column="api_key", max_length=255, null=True)
@property
def api_key(self):
if not self._api_key:
return None
return decrypt(self._api_key)
@api_key.setter
def api_key(self, value):
self._api_key = encrypt(value)
- 인스턴스에서는
obj.api_key로 잘 동작 - 저장도
obj.api_key = "plain-text"형식으로 잘 동작
그런데 ORM 레벨에서만 “필드가 없다”고 주장합니다. 이상하지만, 사실 ORM 입장에서 보면 전혀 이상하지 않은 반응입니다.
2. 원인: ORM이 인식하는 “필드 이름”은 프로퍼티 이름이 아니다
핵심은 아주 단순합니다.
@property로 만든api_key는 진짜 필드가 아니다.
Django ORM이 “필드”라고 부르는 건, 모델의 models.Field 서브클래스들입니다. 즉, Model._meta.fields에 들어 있는 그 목록이 전부입니다.
위 예시에서 ORM이 아는 정보는 이 정도입니다.
_api_key = models.CharField(db_column="api_key", max_length=255, null=True)
여기서:
- 모델 필드 이름(field name) →
_api_key - DB 컬럼 이름(column name) →
"api_key" @property api_key→ ORM은 아무것도 모름
정리하면 이렇게 됩니다.
우리가 매일 쓰는 이름 (obj.api_key) |
ORM이 아는 이름 (Model._meta) |
|---|---|
api_key (프로퍼티) |
_api_key (필드 이름) |
그래서 ORM에서:
Model.objects.only("api_key")
Model.objects.values("api_key")
Model.objects.filter(api_key__isnull=False)
이렇게 쓰면, ORM 입장에서는 정말로 “그런 필드는 없다”고 보게 됩니다. ORM은 프로퍼티를 필드로 취급하지 않기 때문입니다.
ORM이 이해하는 건 항상 “필드 이름” 기준입니다.
- ✅
only("_api_key") - ✅
values("_api_key") - ✅
filter(_api_key__isnull=False) - ❌
only("api_key") - ❌
filter(api_key=...)
즉,
Python 레벨에서 쓰는 프로퍼티 이름과 ORM이 이해하는 필드 이름을 혼동해서 생긴 문제였습니다.
3. Celery 안으로 들어가면 이게 왜 더 치명적일까?
여기까지는 “조금 귀찮지만 금방 해결할 수 있는” 오류처럼 보입니다. 문제는 이 코드가 Django view가 아니라 Celery 태스크 내부에 있었다는 점입니다.
특히 다음과 같은 형태였다고 가정해 봅시다.
@shared_task(bind=True, max_retries=3)
def send_something(self, model_id):
try:
obj = Model.objects.only("api_key").get(id=model_id)
call_external_api(obj.api_key)
except SomeRecoverableError as exc:
raise self.retry(exc=exc)
여기서 진짜 문제는:
only("api_key")에서FieldDoesNotExist예외가 발생- 이 예외가 특정 예외 범주에 포함되거나, 상위에서 catch 되면서
- Celery가 이걸 “일시적인 오류”로 오인하고
self.retry()로 재시도
즉, 실제로는 코드 버그인데, 겉으로는 “계속 실패해서 재시도되는 네트워크 문제”처럼 보이는 상태가 됩니다.
증상이 어떻게 보이냐면:
- Celery 태스크는 계속 retry 중
- 외부 API 서버에는 요청이 전혀 안 찍힘
- 로그를 자세히 안 보면, “외부 서버가 응답을 안 주나?” 쪽으로 의심이 가기 쉽다
디버깅 난이도가 괜히 폭발하는 게 아닙니다. 예외가 바로바로 위로 올라와 터져줘야 찾기 쉬운데, Celery의 retry 메커니즘 안에 숨어버리면 “조용한 버그”가 돼 버립니다.
4. 해결: ORM에는 “항상 필드 이름”만 넘긴다
결론은 간단합니다.
프로퍼티는 인스턴스에서만 쓰고, ORM에는 항상 “진짜 필드 이름”을 사용한다.
즉, 아래처럼 써야 합니다.
# 잘못된 예 (FieldDoesNotExist)
obj = Model.objects.only("api_key").get(...)
# 올바른 예
obj = Model.objects.only("_api_key").get(...)
real_key = obj.api_key # 여기서부터는 프로퍼티로 접근
필터링할 때도 마찬가지입니다.
# 잘못된 예
Model.objects.filter(api_key__isnull=False)
Model.objects.filter(api_key="plain-text")
# 올바른 예
Model.objects.filter(_api_key__isnull=False)
Model.objects.filter(_api_key=encrypt("plain-text"))
좀 더 안전하게 가고 싶다면, 아예 프로퍼티 이름으로 ORM을 호출하지 않도록 팀/프로젝트 차원에서 규칙을 정하는 것도 방법입니다.
예를 들어:
- “
_로 시작하는 필드는 ORM에서만 쓴다” - “외부에 노출하는 프로퍼티 이름은 ORM 쿼리에서 절대 쓰지 않는다”
- 리뷰할 때
.values("..."),.only("..."),.filter(...)에서 프로퍼티 이름이 들어가 있지는 않은지 체크
같은 식으로요.
5. 추가 팁: 아예 커스텀 필드로 감추는 방법도 있다
규모가 큰 프로젝트에서는, @property 대신 커스텀 모델 필드를 만드는 방법도 고려해볼 수 있습니다.
예를 들어, EncryptedCharField 같은 걸 만들어서:
to_python()/get_prep_value()에서 암·복호화를 처리하고- 필드 자체는 ORM이 인식하는 “진짜 필드”로 두되
- 값은 항상 평문처럼 보이게 사용할 수 있습니다.
대략 이런 느낌입니다 (개념 예시):
class EncryptedCharField(models.CharField):
def get_prep_value(self, value):
if value is None:
return None
return encrypt(value)
def from_db_value(self, value, expression, connection):
if value is None:
return None
return decrypt(value)
class Model(models.Model):
api_key = EncryptedCharField(max_length=255, db_column="api_key", null=True)
이렇게 하면:
- ORM 입장에서는
api_key가 진짜 필드 - 코드에서는 그냥
obj.api_key를 평문처럼 사용 .only("api_key"),.values("api_key"),.filter(api_key__isnull=False)모두 정상 동작
대신:
- 커스텀 필드 구현 + 테스트 비용
- 마이그레이션/호환성 등을 고려해야 합니다.
규모, 팀 상황에 따라 “간단히 @property + _api_key 필드를 쓰고 규칙만 잘 지키자” vs “아예 커스텀 필드로 추상화하자” 중 하나를 선택하면 됩니다.
6. 배운 점: 단순해 보이지만, 쉽게 놓치는 부분
이번 경험에서 얻은 교훈은 생각보다 단순합니다.
하지만 실제 코드에서는 정말 쉽게 놓치게 되어 있습니다.
- 평소에는
obj.api_key만 보니까 “이게 진짜 필드가 아니다”라는 감각이 서서히 흐려지고 - 길게 작성한 ORM 쿼리 한 줄에
.only("api_key")같은 실수가 끼어들기 딱 좋습니다. - 특히 Celery 같은 비동기 환경에서는 예외가 바로 눈앞에서 터지지 않고 조용히 retry로 흡수되기 때문에 디버깅이 몇 배 더 어려워집니다.
결국, 한동안은 이렇게 착각하게 됩니다.
“외부 API가 느리네? 네트워크 문제인가?” → 알고 보니 ORM이
FieldDoesNotExist를 던지고 있었다…
이런 시간을 줄이려면, 아래 네 가지만 기억하면 됩니다.
7. 정리
1) ORM은 “모델 필드 이름”만 이해한다
Model._meta.fields기준으로만 동작한다.@property는 여기에 포함되지 않는다.
2) @property는 ORM 쿼리에서 사용할 수 없다
.only("api_key"),.filter(api_key=...)는 전부 잘못된 사용.- ORM에는
_api_key처럼 실제 필드 이름만 넘겨야 한다.
3) 암호화/복호화 패턴을 쓸수록 더 조심해야 한다
_api_key/api_key처럼 이름이 비슷하면 더 헷갈린다.- 리뷰 단계에서 이런 부분은 꼭 한 번씩 짚고 넘어가는 게 좋다.
4) Celery 내부에서는 이런 버그가 특히 더 위험하다
- 예외가 retry 로직에 섞여 조용히 숨을 수 있다.
- 네트워크 장애처럼 위장되기도 한다.
- 로그 + 예외 핸들링 정책을 같이 점검하는 것이 중요하다.
보안을 위해 필드를 감추는 설계는 좋은 방향입니다. 다만 그 구조가 ORM, 비동기 워커(Celery)와 함께 돌아갈 때 어떤 함정을 만들 수 있는지 한 번쯤은 짚고 넘어가면, 비슷한 문제로 하루를 통째로 날리는 일은 줄일 수 있을 것입니다. 
댓글이 없습니다.