발행일

게시판 카테고리 설계 — 단일 모델 + category 필드 vs 앱 분리

게시판 카테고리 설계 — 단일 모델 + category 필드 vs 앱 분리

카페 커뮤니티를 만들면서 게시판을 8개 카테고리로 쪼개야 했습니다. 후기, 구인, 양도, 창작, 건의, 팁, 모집, 온라인. 이걸 어떻게 모델링할지 꽤 오래 고민했고, 결국 단일 Post 모델 + category 필드 로 갔습니다. 그 과정의 트레이드오프를 정리합니다.


1. 세 가지 선택지

게시판 카테고리를 설계할 때 흔히 나오는 선택지는 세 가지입니다.

선택지 A — 카테고리별로 앱 분리

review/    models.py -> ReviewPost
job/       models.py -> JobPost
ticket/    models.py -> TicketPost
...

선택지 B — 단일 앱 + 카테고리별 서브클래스 (multi-table inheritance 또는 proxy)

class Post(models.Model):  # 공통
    ...

class JobPost(Post):  # multi-table inheritance
    salary = models.IntegerField()

선택지 C — 단일 앱 + 단일 모델 + category 필드

class Post(models.Model):
    CATEGORY_CHOICES = [
        ('review', '후기'),
        ('job', '구인'),
        ...
    ]
    category = models.CharField(max_length=20, choices=CATEGORY_CHOICES)
    title = models.CharField(max_length=200)
    content = models.TextField()
    ...

2. 평가 기준 — 뭘 중요하게 볼 것인가

단순히 "어느 게 깔끔하냐" 가 아니라, 이번 프로젝트에 맞는 기준을 먼저 잡았습니다.

  1. 카테고리별 필드 차이가 얼마나 큰가?
  2. 전체 검색(카테고리 무관)이 필요한가?
  3. 카테고리 추가/삭제 빈도가 높은가?
  4. URL 구조가 카테고리별로 달라야 하는가?
  5. 권한이나 정책이 카테고리별로 크게 다른가?

이 다섯 개 기준으로 선택지를 하나씩 때려봤습니다.


3. 이번 프로젝트의 답

기준이번 프로젝트유리한 선택지
카테고리별 필드 차이거의 없음 (전부 제목/본문/작성자)C
전체 검색 필요있음 (헤더 검색창)C
카테고리 추가/삭제 빈도낮음 (8개 고정 예정)A, B, C 모두 가능
URL 구조/board/<category>/ 로 통일C
권한/정책 차이미미 (관리자 공지 정도)C

다섯 중 네 개가 C로 몰렸습니다. 그래서 단일 모델 + category 필드로 갔습니다.


4. A(앱 분리)의 함정 — 중복의 비용

선택지 A는 처음에 가장 "깔끔해 보이는" 선택입니다. 앱 구조가 카테고리와 1:1로 맞으니 직관적이죠. 그런데 실제로 써보면 이런 일들이 생깁니다.

  • 공통 템플릿(글 목록, 상세, 작성)을 8번 복붙
  • 공통 뷰 로직(페이지네이션, 권한 체크)도 8번 복붙
  • 전체 검색을 구현하려면 8개 모델을 UNION ALL 로 합쳐야 함
  • 공통 기능 추가(예: 좋아요, 북마크)가 모든 앱을 건드리는 작업이 됨

카테고리별 필드가 정말 크게 다르지 않다면, 이 중복은 빠르게 기술 부채가 됩니다. 반대로 "구인" 카테고리에만 급여, 근무지, 연락처 같은 고유 필드가 한가득 있다면 A가 정답입니다. 그런데 이 프로젝트는 그런 상황이 아니었습니다.


5. B(상속)의 함정 — ORM 복잡도

선택지 B는 "공통은 Post에 두고, 카테고리별 필드는 서브클래스에" 라는 깔끔한 OOP 접근입니다. Django에서는 이걸 multi-table inheritance로 지원합니다.

class Post(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()

class JobPost(Post):
    salary = models.IntegerField()

단점:

  • JOIN이 기본입니다. JobPost.objects.all()PostJobPost 테이블을 조인합니다. 작을 때는 괜찮지만 테이블이 커지면 성능에 영향.
  • 전체 글 목록을 얻으려면 Post.objects.all() 을 돌리는데, 이때 서브클래스 필드는 접근할 수 없음. 필요하면 select_related 로 끌어와야 합니다.
  • 마이그레이션이 번잡. 서브클래스 모델 추가마다 FK가 새로 생성.

OOP적으로는 예쁘지만, Django ORM이 실제로 돌아가는 방식과 정합성이 떨어집니다. "이론적으로 맞는" 설계와 "현실적으로 편한" 설계가 엇갈리는 지점이죠.


6. C(단일 모델)의 트레이드오프 — 인정하고 가져간 것

단일 모델을 택했을 때 내가 감수하겠다고 결정한 단점도 분명히 있었습니다.

6.1 필드 분기가 생기면 지저분해진다

나중에 "구인 카테고리만 급여 필드를 받고 싶다" 는 요구가 생기면, 단일 모델에서는 extra_data(JSON 필드)나 별도 테이블로 도망가야 합니다. 이때가 바로 B나 A로 마이그레이션해야 하는 시점 입니다.

6.2 카테고리 목록이 코드에 박혀 있다

CATEGORY_CHOICES 가 상수로 박혀 있어서, 새 카테고리 추가하려면 코드 수정 + 마이그레이션이 필요합니다. 카테고리를 런타임에 바꿀 수 있으려면 별도 Category 테이블로 빼야 합니다.

이번에는 카테고리를 자주 안 바꿀 거라 상수로 두는 게 편했습니다. 관리자가 직접 카테고리를 추가하는 서비스였다면 테이블로 뺐을 겁니다.

6.3 URL 라우팅 한 곳에서 전부 분기

# board/urls.py
urlpatterns = [
    path('<str:category>/', views.board_list, name='list'),
    path('<str:category>/write/', views.board_write, name='write'),
    path('<str:category>/<int:pk>/', views.board_detail, name='detail'),
]

URL 한 곳에 카테고리가 들어가고, 뷰에서 category를 받아 필터링합니다. 유효하지 않은 카테고리는 404로 보냅니다.

VALID_CATEGORIES = dict(Post.CATEGORY_CHOICES)

def board_list(request, category):
    if category not in VALID_CATEGORIES:
        raise Http404()
    posts = Post.objects.filter(category=category)
    ...

7. "확장성" 의 유혹에 지지 않기

설계할 때 가장 흔한 실수가 "나중에 바뀔 수도 있으니 지금 유연하게 만들자" 입니다. 8개 카테고리가 80개가 될 수도 있고, 각 카테고리가 완전히 다른 필드를 가질 수도 있고… 이런 "혹시" 를 전부 대비하면 YAGNI 원칙을 위반한 오버엔지니어링이 됩니다.

이번 프로젝트의 기준:

  • 카테고리가 정말로 런타임에 추가돼야 하는가? → 아니요
  • 카테고리별 필드가 정말로 크게 다른가? → 아니요
  • 운영 중에 스키마가 정말로 자주 바뀔 것 같은가? → 아니요

세 개 다 "아니요" 면 단일 모델이 맞습니다. 나중에 정말로 필요해지면 그때 마이그레이션하면 됩니다. "나중의 가능성"을 위해 지금의 복잡도를 쓰지 않는다.


8. 체크리스트 — 단일 모델이 적합한지 판정

다음 프로젝트에서도 쓸 수 있도록 체크리스트로 정리했습니다.

  • 카테고리별 공통 필드 비율이 80% 이상인가?
  • 전체 검색(카테고리 무관)이 자주 필요한가?
  • 카테고리 추가 빈도가 낮은가 (연 1~2회 이하)?
  • URL 구조가 카테고리 파라미터 하나로 충분한가?
  • 카테고리별 권한 정책이 동일하거나 간단한 분기로 처리 가능한가?

4개 이상 예스면 단일 모델이 편합니다. 2개 이하면 앱 분리를 검토할 타이밍입니다.


9. 나중에 이 판단 기준을 다시 써봤다

이 글의 기준을 다른 프로젝트에서 그대로 적용해볼 기회가 있었습니다. 표준데이터 시스템에 FAQ 게시판을 추가해야 했는데, 이미 공지사항용 Post 모델이 있었어요.

기준을 대보니 결론이 같았습니다 — 새 모델을 안 만들고 기존 Post + 카테고리로. 필드 차이가 없고(제목·본문·노출 상태), CRUD 화면을 그대로 재사용할 수 있었으니까요. 그 과정은 새 모델 없이 Post + 카테고리로 FAQ 게시판 만들기에 적었습니다.

그런데 한 가지는 반대로 갔습니다. 6.2절에서 이렇게 썼었죠.

CATEGORY_CHOICES가 상수로 박혀 있어서 (…) 관리자가 직접 카테고리를 추가하는 서비스였다면 테이블로 뺐을 겁니다.

FAQ 쪽은 실제로 Category 테이블을 썼습니다. 게시판 종류를 운영 중에 늘릴 여지가 있었거든요. 이 글이 예측한 분기점이 실제로 나타난 셈이라, 기준 자체는 잘 작동했습니다.

다만 테이블로 빼니 이 글에는 없던 위험이 생겼어요.

# 상수 방식 — 오타는 choices 검증에서 걸린다
category = models.CharField(max_length=20, choices=CATEGORY_CHOICES)

# 테이블 방식 — 쿼리에서 이름 문자열로 참조하게 된다
Post.objects.filter(category__name__exact="FAQ")

이름을 바꾸면 에러 없이 목록이 빈 채로 뜹니다. 상수 방식이었다면 Post.CATEGORY_CHOICES를 통해 한곳에서 관리되니 오타가 나기 어려운데, 테이블로 빼는 순간 참조가 문자열이 돼요. 유연성을 얻는 대가로 조용히 깨질 자리가 생긴 겁니다.

이 글에 이 트레이드오프를 안 적었던 게 아쉽습니다. 6.2절은 "상수는 유연하지 않다" 까지만 말하는데, "테이블은 참조가 느슨해진다" 가 짝으로 있어야 했어요.


10. 지금 다시 보면 보강할 것

choices는 DB 제약이 아닙니다.

category = models.CharField(max_length=20, choices=CATEGORY_CHOICES)

Django의 choices폼과 full_clean() 단계의 검증입니다. 벌크 적재나 update()로 넣으면 아무 문자열이나 들어가요. 단일 모델 방식의 핵심 안전장치가 앱 레벨에만 있는 셈입니다.

class Meta:
    constraints = [
        models.CheckConstraint(
            check=models.Q(category__in=[c[0] for c in CATEGORY_CHOICES]),
            name="post_category_valid",
        )
    ]

카테고리가 상수라면 이렇게 DB에도 못 박을 수 있습니다. 앱 레벨 제약은 우회 경로가 있으면 제약이 아니라는 것을 이후에 여러 번 배웠어요.

② 404 검사가 뷰마다 반복됩니다.

def board_list(request, category):
    if category not in VALID_CATEGORIES:
        raise Http404()

목록·작성·상세 세 뷰에 같은 줄이 들어가고, 뷰가 늘면 계속 늘어납니다. 커스텀 path converter를 만들면 라우팅 단계에서 걸러져서 뷰가 이 검사를 몰라도 됩니다.

class CategoryConverter:
    regex = "|".join(dict(Post.CATEGORY_CHOICES))
    def to_python(self, value): return value
    def to_url(self, value): return value

register_converter(CategoryConverter, "category")
# path('<category:category>/', ...)  → 잘못된 값은 URL 매칭 자체가 안 됨

③ 체크리스트에 구멍이 있습니다.

"4개 이상 예스면 단일 모델, 2개 이하면 앱 분리" 라고 적었는데 3개일 때가 빠져 있습니다. 항목이 5개니 3개는 충분히 나올 수 있는 값이에요. 애매한 구간이 있다는 걸 인정하고 "3개면 단일 모델로 시작하되 분리 시점을 문서로 정해둔다" 정도로 채웠어야 했습니다.


정리

  • 필드 차이가 작고 공통 화면이 많은 게시판은 단일 모델 + category 필드가 제일 싸고 단순
  • 상속(B) 은 OOP적으로 예쁘지만 ORM 복잡도가 늘어 실익이 적음
  • 앱 분리(A) 는 카테고리별 필드 차이가 클 때만
  • 카테고리를 상수로 둘지 테이블로 뺄지는 별개의 결정입니다. 상수는 참조가 단단하고, 테이블은 유연한 대신 문자열 참조가 조용히 깨질 수 있어요
  • choices는 앱 레벨 검증이라, 상수로 둔다면 CheckConstraint로 DB에도 걸어두는 게 안전합니다
  • "나중의 확장성" 을 핑계로 지금 복잡도를 키우지 않는다
  • 단일 모델의 한계(필드 분기, 런타임 카테고리)가 실제로 닥치면 그때 마이그레이션