## 1) 문제 제기: “아니, 왜 이렇게 쓸데없는 결과도 섞여나와?” {#sec-b345eb02ca5e} 최근에 운영하던 블로그에 글들이 늘어나면서 체계적인 관리가 언젠가부터 필요하기 시작했습니다. 제 블로그는 다국어로 발행이 되기 때문에 같은 슬러그라도 해도 다양한 인스턴스가 사용하고 있기도 해서, 글들이 많아 질 수록 원하는 포스트를 콕 찝어 찾아내는데 한번에 찾기가 살짝 어려운 정도가 되었습니다. 블로그가 풍부해지고 있다는 좋은 신호이기도 하지만요. 그런데 이런 원하는 인스턴스를 한방에 찾기 어려운 문제는 Django Admin의 기본 검색(`search_fields`)이 검색 로직이 좀 **두리뭉실 하기 때문**이라고 생각했습니다. ![마법의 돋보기를 들고 책을 찾는 도서관 사서](/media/editor_temp/6/12d4b1c2-0844-485d-b9ef-0e1f766b18ca.png) `search_fields`를 많이 잡아두면(그리고 운영 기능이 늘면 늘수록 보통 그렇게 되죠), 검색창에 단어 하나 넣는 순간: * 제목에도 걸리고 * 설명에도 걸리고 * 사용자 이름에도 걸리고 * 엉뚱한 메모 필드에도 걸리고 결과가 “많이 나오는” 수준을 넘어 “내가 찾는 게 묻히는” 상태가 됩니다. 운영하다가 급하게 특정 도메인 하나 찾고 싶은데, 결과가 수십 개로 펼쳐지면… 솔직히 짜증이 날때도 있습니다. 그래서 “필드 하나만 딱 집어서 검색할 수 있으면 좋겠다”가 시작이었습니다. --- ## 2) 해결 아이디어: `field:value` 문법 + `shlex`로 따옴표 처리 {#sec-5381e00c4696} 아이디어는 단순합니다. * `name:diff` 처럼 **필드를 지정해서** 검색하기 * `title:"system design"` 처럼 **공백 포함 검색어**도 지원하기 여기서 `shlex.split()`이 꽤 유용합니다. Admin 검색창은 보통 “그냥 공백으로 나누기”를 해버리는데, 그러면 `"system design"` 같은 입력이 깨져요. `shlex`를 쓰면 쉘처럼 따옴표를 존중해서 토큰을 나눠줍니다. 예를 들면: * `title:"system design"` → `["title:system design"]` 같은 식으로 깔끔하게 처리 가능 * `name:diff python` → `["name:diff", "python"]` 즉, **필드 지정 검색**과 **일반 검색어**를 섞어도 예쁘게 파싱할 수 있습니다. --- ## 3) 코드 설명 {#sec-8edf0b65ccd2} 전체 코드는 아래 저장소에 올려뒀습니다. 복붙해서 쓰기 편하게 해놨어요. [깃허브 레포지토리 바로가기](https://github.com/mikihands/djangomixins/blob/main/admin_fieldscopedsearch.py) 블로그에는 핵심만 뽑아서 설명해볼게요. ### `_build_lookup_for`: `search_fields` 접두사를 그대로 존중하기 {#sec-25f0fe79cd1c} Django Admin의 `search_fields`에는 접두사 규칙이 있죠. * `=field` → 정확히 일치(대소문자 무시) 쪽으로 * `^field` → prefix 매칭 * 기본은 `icontains` 저는 이 규칙을 “새 문법을 도입하더라도” 그대로 유지하고 싶었습니다. 그래서 접두사를 보고 lookup을 결정하는 작은 헬퍼가 들어갑니다. ```python def _build_lookup_for(self, raw_field): if raw_field.startswith('='): return raw_field.lstrip('=^@'), '__iexact' if raw_field.startswith('^'): return raw_field.lstrip('=^@'), '__istartswith' return raw_field.lstrip('=^@'), '__icontains' ``` 예를들어 : `admin.py`에서 Admin class들의 설정을 하실때, `search_fields = ['=name', 'title']`처럼 써두면 * `name`은 `iexact` * `title`은 `icontains` * 그리고 `field:value`에서도 **똑같은 lookup 정책**이 적용됩니다. --- ### `get_search_results`: 필드 지정은 AND, 일반 검색어는 기존처럼 {#sec-2432c8a5949d} 핵심 로직은 `get_search_results()`에서 처리합니다. 1. 먼저 입력을 `shlex`로 토큰화 2. 토큰을 두 부류로 나눔 * `field:value` 형태이면서 field가 `search_fields`에 있는 경우 → **field_specific_terms** * 그 외 → **normal_terms** 3. 필드 지정 검색은 **AND로 누적** 4. 일반 검색어는 Django Admin 기본 규칙을 따라가되(개념적으로) * 각 term은 여러 필드에 대해 OR * term들끼리는 AND 중요한 부분만 보면 이런 흐름입니다. ```python terms = shlex.split(search_term or "") field_specs = [self._build_lookup_for(f) for f in getattr(self, 'search_fields', [])] field_lookup_map = {} for name, lookup in field_specs: field_lookup_map.setdefault(name, lookup) field_specific_terms = [] normal_terms = [] for t in terms: if ':' in t: field, value = t.split(':', 1) if field in field_lookup_map and value != '': field_specific_terms.append((field, value)) else: normal_terms.append(t) else: normal_terms.append(t) ``` 여기서 “잘못된 필드명”이나 “빈 값”은 그냥 일반 검색어로 보내버립니다. 그리고 실제 쿼리 적용은 이렇게 나뉩니다. #### (1) `field:value`는 AND로 누적 ```python for field, value in field_specific_terms: lookup = field_lookup_map[field] qs = qs.filter(Q(**{f"{field}{lookup}": value})) ``` `name:diff status:active` 같은 입력은 자연스럽게 `name == diff AND status == active`처럼 쌓입니다. #### (2) 일반 검색어는 term마다 (필드 OR) → term끼리 AND ```python if normal_terms and field_lookup_map: for term in normal_terms: term_q = Q() for field, lookup in field_lookup_map.items(): term_q |= Q(**{f"{field}{lookup}": term}) qs = qs.filter(term_q) ``` 그래서 이런 입력이 됩니다: * `name:diff python` * `name=diff` AND * `(name OR title OR description 중 하나에 python)` 이 방식이 좋은 이유는 “기존 Admin 검색 감각”을 크게 안 깨기 때문이에요. `field:value`는 내가 의도를 명확히 한 거니까 정확히 좁혀주고, 나머지는 예전처럼 넓게 찾게 두는 거죠. --- ## 4) 사용 방법: Admin에 믹스인만 끼우면 끝 {#sec-0eb3a92e04b6} 사용은 간단합니다. ```python @admin.register(MyModel) class MyAdmin(FieldScopedSearchMixin, admin.ModelAdmin): search_fields = ['=name', 'title', 'description'] ``` 이제 Admin 검색창에서 이런 입력이 가능합니다. * `name:diff` * `title:"system design"` * `name:diff python` 그리고 사용시 도움말을 자신에 맞게 변경하려면 `get_changelist_instance()`를 고쳐서 쓰시면 됩니다. --- ## 5) 회고 및 한계: “편하긴 한데, 더 욕심이 난다” {#sec-4fc456da292d} 이 믹스인을 만들고 제일 좋았던 건, 운영할 때 **내가 원하는 범위로 검색을 ‘조절’할 수 있게 된 것**이었습니다. 생각보다 체감상 편리함에 오는 만족감은 꽤 컸습니다. 다만 한계도 분명합니다. * **Full-text search(@ 접두사)** 는 여기서 제대로 다루지 않았습니다. PostgreSQL의 full-text를 제대로 연결하면 더 똑똑해질 수 있는데, 그건 또 세팅/가중치/tsvector 관리로 이야기가 커지더라고요. 지금은 일단 “운영 스트레스 줄이기”에 집중했습니다. * `icontains` 중심이라 데이터가 커지면 **성능 이슈**가 생길 수 있습니다. 특히 여러 필드에 OR를 걸고 term을 여러 개 쓰면 쿼리가 무거워질 수 있어요. (운영 환경에서는 검색어가 짧게 끝나는 경우가 많지만, 최악의 입력은 언제나 존재하죠.) * 다음으로 해보고 싶은 건 이런 것들입니다. * `created_at:2026-01-01..2026-01-31` 같은 **날짜 범위 검색** * `status:active,-status:deleted`처럼 **부정 조건** * `field>10` 같은 **간단한 비교 연산자** 근데 일단은… 지금 형태가 딱 “작고 확실하게 편해지는” 지점이라 마음에 듭니다. 운영 중에 내가 직접 짜증을 느낀 포인트를 해결한 도구라, 손이 자주 가고요. **고민과 아이디어에 약간의 수고를 더한 것만으로 업무의 효율이 영구적으로 높아지는 것은 정말 기분 좋은 일입니다.** 이것이 바로 코딩의 매력이 아닌가 싶네요. --- **관련글** - [admin을 지금 당장 숨겨야 하는 이유](/ko/whitedec/2025/11/10/admin-now-need-to-hide/)