발행일

6년 전 방탈출 사이트를 다시 열었더니, 글을 쓸 수 없는 게시판이었다 — escape-plus 리팩터

6년 전 방탈출 사이트를 다시 열었더니, 글을 쓸 수 없는 게시판이었다

2020년 코멘토 직무부트캠프에서 4주 동안 방탈출 리뷰 사이트를 만들었습니다. Django를 처음 만난 프로젝트였고, 4주차에 REST API 연동을 실패한 채로 끝났어요.

2026년 4월, 이걸 포트폴리오에 올릴 수 있는 상태로 다시 짰습니다. 이 글은 그 전후 비교입니다.

항목2020 (원본)2026 (escape-plus)
Django3.0.76.0.3
앱board, tag, escapeuseraccounts, board, cafe, maps, note
인증직접 만든 사용자 모델 + 세션에 user 저장AbstractUser 확장 + Django Auth, 카카오 로그인
게시판카테고리 없음, 카테고리별 정적 HTML 8장카테고리 필드 하나 + URL 하나
테스트0개15개 (전부 통과, PR #3 머지 후)
저장소hyo814/escape-plus같은 저장소

1. 원본 코드는 zip 안에 있었다

저장소의 2021년 커밋에는 소스가 파일로 없고 escape-master.zip 하나만 올라가 있었습니다. 리팩터를 시작하면서 이 zip을 지우고 소스를 풀어 올렸어요(ae6ffd5, 38e5ea3). 그래서 이 글의 "원본"은 그 zip에서 꺼낸 코드입니다.

다시 읽기 전까지는 "기능은 다 되는데 코드가 낡았다" 정도로 기억하고 있었습니다. 읽어 보니 아니었어요.


2. 원본에서 발견한 것 — 글을 쓸 수 없는 게시판

로그인이 항상 실패한다

# escapeuser/forms.py (2020)
def clean(self):
    ...
    if username and password:
        escapeuser = Escapeuser.object.get(username=username)

objects가 아니라 object예요. Django 모델 매니저 이름은 objects라서, 아이디와 비밀번호를 둘 다 입력하는 순간 AttributeError가 납니다. 없는 아이디를 넣었을 때 생기는 DoesNotExist도 처리하지 않았고요. 로그인 폼이 성공할 수 있는 경로가 없었습니다.

글쓰기와 상세 화면이 URL에 없다

# board/urls.py (2020)
urlpatterns = [
    path('list/', views.board_list)
]

views.py에는 board_write, board_detail이 다 있는데 URL에는 목록만 연결돼 있었습니다. 로그인이 됐더라도 글을 쓸 주소가 없었던 거예요.

카테고리는 HTML 파일 개수였다

리뷰·구직·양도·창작·질문·꿀팁·대외활동·온라인, 게시판이 8개처럼 보였지만 Board 모델에는 카테고리 필드가 없었습니다. 대신 front-end/ 폴더에 138줄짜리 HTML이 8장 있었어요. 두 장을 비교하면 다른 곳은 <title>과 들여쓰기뿐입니다.

-    <title>구직 게시판</title>
+    <title>방탈출 꿀팁 게시판</title>

화면은 8개인데 데이터는 하나라서, 어느 게시판에 글을 써도 같은 목록에 쌓이는 구조였습니다.

비밀 값이 코드에 있었다

settings.py의 SECRET_KEY와 지도 화면 HTML의 카카오 지도 앱 키가 그대로 커밋돼 있었습니다.

부트캠프 회고에서는 "REST API 연동에 실패했다"만 적었어요. 그 아래에 로그인과 글쓰기도 안 됐다는 사실이 있었는데, 테스트가 0개라 이걸 알려 줄 장치가 없었습니다.


3. 다시 짠 것

사용자 모델은 Django에 맡긴다

직접 만든 Escapeuser는 비밀번호 해시와 세션 처리를 손으로 하고 있었습니다. AbstractUser를 확장한 accounts.User로 바꾸고, 로그인·로그아웃·권한 확인은 Django Auth에 넘겼어요.

# accounts/models.py (2026)
class User(AbstractUser):
    kakao_id = models.BigIntegerField(unique=True, null=True, blank=True)
    nickname = models.CharField(max_length=64, blank=True)
    profile_image = models.URLField(blank=True)

request.session.get('user')로 직접 막던 글쓰기는 @login_required 한 줄이 됐습니다. 카카오 로그인은 같은 사용자 모델에 kakao_id로 붙였고, 과정은 Django 커스텀 User에 카카오 OAuth 붙이기에 따로 적었어요.

카테고리는 필드 하나, URL 하나

HTML 8장을 TextChoices 하나로 바꿨습니다.

# board/models.py (2026)
class Board(models.Model):
    class Category(models.TextChoices):
        REVIEW = 'review', '리뷰'
        JOB = 'job', '구직'
        # ... 8개
    category = models.CharField(max_length=20, choices=Category.choices)

    class Meta:
        ordering = ['-created_at']
        indexes = [models.Index(fields=['category', '-created_at'])]
# board/urls.py (2026)
path('<str:category>/', views.board_list, name='board_list'),
path('<str:category>/write/', views.board_write, name='board_write'),
path('<str:category>/<int:pk>/', views.board_detail, name='board_detail'),

목록은 늘 "카테고리로 거르고 최신순 정렬"이라 그 조합으로 색인을 걸었습니다. 없는 카테고리는 뷰에서 Board.Category.values로 확인해 404를 냅니다. 게시판을 하나 더 만들려면 이제 HTML을 복사하지 않고 Category에 한 줄을 추가하면 돼요.

원본에 없던 것

  • 삭제 권한 확인: 작성자가 아니면 PermissionDenied
  • 댓글과 대댓글: 대댓글에 다시 답하면 원래 댓글 아래로 붙여서 깊이를 한 단계로 묶었습니다
  • 쪽지(note): 안 읽은 쪽지 수를 context processor로 모든 화면에 넘깁니다
  • 카페·테마 데이터(cafe): 시드 데이터를 management command로 넣습니다. data migration 대신 커맨드를 고른 이유는 management command로 시드 데이터 다루기에 적었어요
  • 비밀 값 분리 일부: 카카오 키와 콜백 주소 4개는 환경변수로 뺐습니다

게시판이 실제 커뮤니티처럼 보이게 더미 글을 채운 과정은 더미 데이터로 커뮤니티 체감 검증하기에 있습니다.


4. 다시 짠 코드에서 찾은 것 — 재현하고 고쳤다

이 글을 쓰면서 저장소를 새로 받아 테스트를 돌렸습니다. 12개 모두 통과해요. 그런데 테스트가 다루지 않는 입력을 넣어 보니 둘이 걸렸습니다. 둘 다 먼저 실패하는 테스트를 쓰고 고쳐서 PR #3로 머지했어요(2026-10-09).

페이지 번호에 글자를 넣으면 500

# board/views.py (2026)
page = int(request.GET.get('p', 1))
paginator = Paginator(boards, 10)
page_obj = paginator.get_page(page)
GET /board/review/?p=abc  →  500
ValueError: invalid literal for int() with base 10: 'abc'

Paginator.get_page()는 원래 숫자가 아니거나 범위를 벗어난 값을 받으면 알아서 첫 페이지나 마지막 페이지를 돌려줍니다. 그 앞에 int()를 씌운 탓에 그 보호를 스스로 꺼 버린 거예요. 그래서 int()를 지웠습니다.

# board/views.py (PR #3 이후)
page_obj = paginator.get_page(request.GET.get('p'))

로그인 후 next로 아무 사이트에나 보낸다

# accounts/views.py (2026)
next_url = request.GET.get('next', '/')
return redirect(next_url)
POST /accounts/login/?next=https://evil.example/  →  302 Location: https://evil.example/

로그인 링크에 외부 주소를 next로 붙여 보내면, 사용자는 진짜 사이트에서 로그인한 직후 그 주소로 넘어갑니다. 피싱에 쓰기 좋은 열린 리다이렉트예요. Django가 이걸 확인하는 함수를 이미 갖고 있어서, 같은 사이트 주소가 아니면 /로 보내게 했습니다.

# accounts/views.py (PR #3 이후)
next_url = request.GET.get('next', '/')
if not url_has_allowed_host_and_scheme(
    next_url,
    allowed_hosts={request.get_host()},
    require_https=request.is_secure(),
):
    next_url = '/'
return redirect(next_url)

테스트로 묶어 두기

?p=abc가 200을 돌려주는지, 외부 next는 /로 가고 내부 next는 그대로 가는지를 테스트 3개로 붙였습니다. 고치기 전에는 새 테스트 중 둘이 실패했고(ERROR 1, FAIL 1), 고친 뒤에는 전체 15개가 통과합니다.

아직 남은 것

  • SECRET_KEY는 2026년 코드에도 settings.py에 그대로 있습니다. 카카오 키만 환경변수로 뺐어요. README의 "환경변수 분리 미완료"가 이 뜻입니다.
  • 조회수는 board.view_count += 1 후 저장합니다. 두 요청이 동시에 오면 하나가 사라질 수 있어서 F('view_count') + 1로 DB에서 더하는 편이 맞습니다.
  • 카카오 로그인 요청에 state 값이 없고, 카카오 API 호출에 timeout이 없습니다.

돌아보면

  • 6년 전 코드는 기억보다 덜 동작했습니다. "REST 연동만 실패했다"고 적어 둔 회고 아래에 로그인 오타와 빠진 URL이 있었어요.
  • 다시 짠 코드도 테스트가 통과한다고 끝난 게 아니었습니다. 12개가 초록색인데, ?p=abc 한 번과 next 하나로 문제가 나왔어요. 2020년과 같은 실수를 크기만 줄여서 반복한 셈이라, 이번에는 고치면서 그 입력을 테스트로 남겼습니다.