ORM, @property, 그리고 Celery 디버깅:

“왜 .only()에서는 분명히 존재하는 값이 없다고 할까?”



보안이 중요한 시스템을 설계하다 보면, API Key나 Secret 값을 모델에 암호화된 형태로 저장하고, 접근할 때는 @property를 통해 복호화된 값만 노출하는 패턴을 자주 쓰게 됩니다.

예를 들어, 대략 이런 구조죠.

  • _api_key → 실제 DB 컬럼 (암호화된 값 저장)
  • api_key → 복호화하여 반환하는 @property
  • api_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)와 함께 돌아갈 때 어떤 함정을 만들 수 있는지 한 번쯤은 짚고 넘어가면, 비슷한 문제로 하루를 통째로 날리는 일은 줄일 수 있을 것입니다. image