- 발행일
6년 전 방탈출 사이트를 다시 열었더니, 글을 쓸 수 없는 게시판이었다 — escape-plus 리팩터
6년 전 방탈출 사이트를 다시 열었더니, 글을 쓸 수 없는 게시판이었다
2020년 코멘토 직무부트캠프2020-07-08코멘토 직무부트캠프 (2020) — Django를 처음 만난 4주현업 웹 개발자의 피드백을 받으며 4주간 방탈출 리뷰 사이트를 기획하고 만들어본 부트캠프 기록입니다. Django를 여기서 처음 배웠고, REST API 연동은 4주차에 실패한 채로 끝났습니다.에서 4주 동안 방탈출 리뷰 사이트를 만들었습니다. Django를 처음 만난 프로젝트였고, 4주차에 REST API 연동을 실패한 채로 끝났어요.
2026년 4월, 이걸 포트폴리오에 올릴 수 있는 상태로 다시 짰습니다. 이 글은 그 전후 비교입니다.
| 항목 | 2020 (원본) | 2026 (escape-plus) |
|---|---|---|
| Django | 3.0.7 | 6.0.3 |
| 앱 | board, tag, escapeuser | accounts, 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 붙이기2026-04-05Django 커스텀 User + 카카오 OAuth 2.0 — 일반 회원가입과 공존시키기AbstractUser를 확장한 커스텀 User 모델에 카카오 OAuth 2.0을 얹어, 일반 이메일 가입과 소셜 로그인을 한 테이블에서 공존시키는 과정을 정리합니다.에 따로 적었어요.
카테고리는 필드 하나, 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로 시드 데이터 다루기2026-04-06Django management command로 시드 데이터 다루기 — data migration과의 분기점시드 데이터를 data migration에 넣을지, 커스텀 management command로 뺄지. 두 방법의 차이와 실제로 언제 무엇을 택해야 하는지 정리합니다.에 적었어요 - 비밀 값 분리 일부: 카카오 키와 콜백 주소 4개는 환경변수로 뺐습니다
게시판이 실제 커뮤니티처럼 보이게 더미 글을 채운 과정은 더미 데이터로 커뮤니티 체감 검증하기2026-04-09더미 데이터로 커뮤니티 체감 검증하기 — 카테고리·작성자 분산 회고포트폴리오용 커뮤니티 서비스에서 "비어 보이지 않게, 가짜 같지 않게" 더미 데이터를 만든 과정을 정리합니다. 페이지네이션 검증과 작성자 분산이 핵심이었습니다.에 있습니다.
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년과 같은 실수를 크기만 줄여서 반복한 셈이라, 이번에는 고치면서 그 입력을 테스트로 남겼습니다.