aster®

게시판 API 서버: 설계부터 배포까지

2026-09 · API 서버

github.com

1. 작업 소개

도메인 정의부터 배포·운영까지 아홉 단계를 순서대로 밟아 게시판 API 를 만들었다. 서버는 Express 5 와 Prisma, PostgreSQL 18.6 이고 클라이언트는 Next.js 16 이다.

교육용이라 코드 파일마다 해설 문서가 하나씩 있고 서로를 가리킨다. 소스 첫 줄에 짝이 되는 문서 경로가, 문서 첫 줄에 소스 경로가 적혀 있다. 단계 문서 15편과 코드 문서 40여 편이 그렇게 붙어 있다.

어두운 배경의 게시판 첫 화면. 위쪽에 '게시판' 제목과 로그인 링크가 있고, 그 아래 '글' 제목 옆에 주황색 '글 쓰기' 버튼이 있다. 제목 검색창과 '최신순' 표시 아래로 글 다섯 개가 줄지어 있고, 줄마다 작성자·날짜와 오른쪽의 댓글 수가 보인다. 맨 아래에 '마지막 글입니다.' 가 적혀 있다.

배포된 게시판 첫 화면이다. 글 목록은 로그인하지 않아도 보이고, 글을 쓰거나 댓글을 달 때만 로그인을 요구한다.

2. 아키텍처

게시판 API 배포 구조도. 왼쪽의 브라우저가 Railway 프로젝트 안의 Next.js web 서비스로 세 갈래 요청을 보낸다. 가운데 /api/graphql 로 GraphQL, 위쪽 /api/auth/* 로 httpOnly 쿠키가 실린 인증 요청, 아래쪽 /docs 로 명세 화면 요청이 간다. 세 경로는 모두 공개 도메인이 없는 Express 5 로 이어지고, Express 5 는 PostgreSQL 18.6 에 SQL 로 접근한다. 아래 카드 셋에는 공개 범위, 규칙이 있는 곳, 토큰 수명이 적혀 있다.

밖으로 열린 주소는 Next.js 로 만든 web 서비스 하나다. 브라우저는 이 서비스의 세 경로만 부른다.

Express 와 PostgreSQL 은 Railway 내부 주소로만 통신하고 공개 도메인이 없다.

3. 비즈니스 로직 구성

게시판의 규칙은 Express 의 라우터 핸들러에 모여 있다. 서비스 계층을 따로 두지 않았다. 핸들러가 하는 일은 저장소 호출, 소유권 판단, 성공 응답 세 가지로 줄였고, 형식 검사와 에러 응답은 앞뒤의 미들웨어가 맡는다.

요청 하나는 server/src/app.ts 에 등록한 미들웨어와 각 라우트에 끼운 미들웨어를 이 순서로 지나간다.

순서파일하는 일
1middleware/logger.ts요청 id 를 붙이고 로그를 남긴다
2middleware/currentUser.tsAuthorization: Bearer 토큰을 검증해 req.user 를 채운다. 토큰이 없거나 망가져도 막지 않고 신원만 비워 둔다
3middleware/validate.tszod 스키마로 경로·쿼리·본문을 검사하고 400 또는 422 를 던진다
4routes/*.ts존재 확인 → 권한 확인 → 저장소 호출 → 응답
5middleware/errorHandler.ts던져진 에러를 { error: { code, message, details } } 로 바꾼다. 4xx 본문을 만드는 곳은 여기 하나뿐이다

라우터 밑으로는 역할별로 파일을 나눴다.

규칙

규칙결과
글 목록·상세·댓글 목록은 로그인 없이 본다쓰기 요청에만 requireAuth 를 끼운다. 없으면 401
수정·삭제는 존재를 먼저, 권한을 나중에 확인한다없는 글은 404, 남의 글은 403. 같은 DELETE 를 두 번 보내면 두 번째는 404
글을 지우면 딸린 댓글도 지워진다코드가 아니라 DB 의 ON DELETE CASCADE 가 한 문장 안에서 처리한다
댓글은 댓글 작성자만 지운다글 작성자라도 남의 댓글은 지울 수 없다. 권한 등급은 소유자인지 아닌지 둘뿐이다
없는 글의 댓글 목록을 부르면빈 배열이 아니라 404
글 상세는 댓글 첫 20개를 함께 준다상세 화면을 호출 한 번으로 그린다
같은 이메일로 다시 가입하면409 EMAIL_ALREADY_EXISTS. 비밀번호는 argon2id 해시로만 저장한다
로그인 실패없는 이메일과 틀린 비밀번호를 구분하지 않고 같은 401 을 준다. 없는 이메일에도 더미 해시를 한 번 검증해 응답 시간 차이로 가입 여부가 드러나지 않게 했다
토큰 재발급리프레시 토큰을 쓸 때마다 새것으로 바꾼다. 서명이 맞아도 계정이 있는지 한 번 더 확인한다
로그아웃로그인을 요구하지 않는다. 액세스 토큰이 만료된 사람도 쿠키를 지울 수 있어야 하기 때문이다

클라이언트 쪽 규칙

Next.js 쪽은 domain · application · infrastructure · presentation 네 층으로 나누고, 층끼리 잇는 일은 composition/container.ts 가 한다.

BFF 리졸버에는 규칙이 없다. GraphQL 과 REST 사이의 모양만 맞춘다. 정렬값 createdAt_desc 를 createdAt:desc 로 되돌리고, 본문 없는 204 응답을 true 로 바꾸고, 서버의 error.code 를 GraphQL 에러의 extensions 에 실어 화면이 코드로 분기하게 한다.

4. 인증 아키텍처와 구현

로그인은 회원가입, 로그인, 토큰 재발급, 로그아웃 네 가지 요청으로 이뤄진다. 서버가 직접 토큰을 발급하고 검증하며, 외부 인증 서비스는 쓰지 않는다.

설계: 토큰을 두 개로 나눴다

액세스 토큰리프레시 토큰
형식JWT, typ: "access"JWT, typ: "refresh"
수명15분14일
보관 위치브라우저 localStoragehttpOnly 쿠키
쓰는 곳모든 API 요청의 Authorization: Bearer 헤더재발급 요청(POST /auth/refresh)만

둘로 나눈 이유는 토큰이 새어 나갔을 때의 피해를 줄이기 위해서다. 액세스 토큰은 자바스크립트가 읽을 수 있는 곳에 있어서, 페이지에 악성 스크립트가 끼어들면(XSS) 가져갈 수 있다. 대신 15분이 지나면 쓸모가 없다. 오래 사는 리프레시 토큰은 httpOnly 쿠키에 둔다. 자바스크립트가 아예 읽지 못하는 쿠키라서 같은 방법으로는 가져갈 수 없다.

쿠키는 브라우저가 요청에 자동으로 실어 보낸다. 그래서 다른 사이트가 사용자 브라우저를 시켜 요청을 보내게 하는 공격(CSRF)을 따로 막아야 하고, SameSite=Strict 가 그 일을 한다. 쿠키 경로도 Path=/auth 로 좁혀서 재발급·로그아웃 요청에만 실린다.

로그인과 토큰 재발급 순서도. 브라우저, /api/auth/, /api/graphql, Express 5, PostgreSQL 다섯 칸이 가로로 놓여 있다. 로그인 구간에서는 브라우저의 로그인 요청이 /api/auth/ 를 거쳐 Express 로 가고, Express 가 이메일로 사용자를 조회한 뒤 토큰과 Path=/auth 쿠키를 돌려준다. /api/auth/* 는 쿠키 경로를 /api/auth 로 고쳐 브라우저에 전달한다. 다음 구간에서는 15분 뒤 GraphQL 요청이 Bearer 토큰을 싣고 Express 까지 갔다가 401 토큰 만료로 돌아온다. 마지막 구간에서는 브라우저가 쿠키를 실어 재발급을 요청하고, Express 가 계정이 있는지 확인한 뒤 새 토큰과 새 쿠키를 돌려주며, 브라우저는 같은 요청을 한 번만 다시 보낸다.

서버 구현

비밀번호는 argon2id 해시로만 저장한다. SHA-256 같은 빠른 해시는 GPU 로 초당 수십억 번을 돌릴 수 있어서, 사람이 고른 짧은 비밀번호는 금방 맞힌다. argon2id 는 일부러 느리고 메모리를 많이 쓰게 만든 함수다. 라이브러리 기본값(메모리 64MiB, 반복 3, 병렬 4)을 그대로 쓰고, salt 도 라이브러리가 넣는다. 그래서 같은 비밀번호라도 저장되는 값이 매번 다르다. 컬럼 이름도 password 에서 passwordHash 로 바꿨다. 이름만 보고 평문인 줄 알고 비교하는 실수를 막으려는 것이다.

로그인 실패는 이유를 알려 주지 않는다. 없는 이메일과 틀린 비밀번호 모두 같은 401 INVALID_CREDENTIALS 를 돌려준다. 다른 응답을 주면 그 이메일이 가입돼 있다는 사실이 드러나기 때문이다. 응답 시간도 맞췄다. 없는 이메일일 때 해시 검증을 건너뛰면 응답이 눈에 띄게 빨라지므로, 사용자가 없어도 더미 해시로 검증을 한 번 돌린다.

토큰에는 최소한만 담는다. server/src/auth/tokens.ts 가 jose 로 HS256 서명을 한다. 페이로드에는 sub(사용자 id), typ, iat, exp 만 넣는다. JWT 는 서명된 것이지 암호화된 것이 아니어서 누구나 내용을 읽을 수 있다. 그래서 이메일이나 닉네임은 넣지 않는다. 검증할 때는 알고리즘을 HS256 으로 못 박는다. 토큰이 스스로 밝힌 알고리즘을 믿으면 alg: "none" 같은 바꿔치기가 통하기 때문이다. typ 을 확인해서 리프레시 토큰으로 API 를 부르는 것도 막는다. 서명 키 JWT_SECRET 은 서버가 뜰 때 env.ts 가 32자 이상인지 확인하고, 기본값은 두지 않는다.

라우터는 사용자 id 하나만 안다. middleware/currentUser.ts 가 Authorization 헤더의 토큰을 검증해 req.user 를 채운다. 토큰이 없거나 망가져도 여기서는 막지 않는다. 글 목록처럼 로그인 없이 보는 요청이 있기 때문이다. 로그인이 필요한 라우트에만 requireAuth 를 끼워 401 을 낸다. 라우터는 req.user.id 가 어디서 왔는지 모른다. 그 덕에 사용자 id 를 헤더에 그대로 싣던 임시 방식에서 JWT 로 바꿀 때 고친 파일은 currentUser.ts 하나였고, 글·댓글 라우터는 한 줄도 바뀌지 않았다.

401 과 403 을 구분한다. 401 은 누군지 모른다는 뜻이고, 403 은 누군지는 알지만 권한이 없다는 뜻이다. 화면은 401 이면 로그인 화면으로 보내고 403 이면 보내지 않는다. 다시 로그인해도 남의 글을 고칠 수는 없기 때문이다.

재발급할 때마다 리프레시 토큰을 바꾼다. POST /auth/refresh 는 쿠키가 없으면 401 이다. 쿠키가 있으면 typ: "refresh" 인지 검증한다. 서명이 맞더라도 그사이 계정이 지워졌을 수 있어서 DB 에서 한 번 더 확인한다. 통과하면 새 액세스 토큰을 돌려주고, 쿠키도 새 리프레시 토큰으로 바꿔 심는다.

쿠키를 심는 옵션과 지우는 옵션을 한 곳에서 만든다. 브라우저는 이름이 같아도 path·sameSite·secure 까지 같아야 같은 쿠키로 본다. 두 옵션을 따로 적어 두면 한쪽만 고치는 날 로그아웃이 204 를 주면서도 쿠키가 남는다. 그래서 cookieScope 하나(httpOnly, sameSite: "strict", 운영에서만 secure, path: "/auth")를 두고, 심을 때는 수명(maxAge)만 더한다.

로그아웃은 로그인을 요구하지 않는다. 액세스 토큰이 만료된 사람에게 401 을 주면 쿠키를 지울 방법이 없다. 그래서 쿠키가 있든 없든 쿠키를 지우고 204 를 돌려준다.

BFF 를 지나는 쿠키

브라우저는 Next.js 와만 통신하고, 쿠키를 심는 것은 Express 다. 그래서 중간에서 쿠키를 옮겨 줘야 한다. GraphQL 리졸버가 응답 헤더를 다루게 하면 스키마만 보고 계약을 알 수 없게 된다. 그래서 로그인·재발급·로그아웃만 /api/auth/* 라는 REST 경로로 뺐다.

web/src/app/api/auth/proxy.ts 는 세 가지 일을 한다.

  1. 브라우저가 보낸 cookie 헤더를 Express 로 그대로 넘긴다. 이 줄이 없으면 재발급이 "리프레시 토큰이 없습니다" 로 끝난다.
  2. Express 가 보낸 Set-Cookie 의 Path=/auth 를 Path=/api/auth 로 고쳐 브라우저에 전한다. 브라우저 입장에서 재발급 주소는 /api/auth/refresh 이기 때문이다.
  3. 로그아웃의 204 는 본문 없이 돌려준다. 빈 문자열이라도 본문을 넣으면 Response 가 거부한다.

나머지 요청은 GraphQL BFF 가 받는다. BFF 는 브라우저가 보낸 Authorization 헤더에서 토큰을 꺼내 Express 호출에 그대로 싣는다.

클라이언트 구현

검증과 한계

인증을 붙인 단계의 검사 24가지가 모두 통과했다. 토큰 없음, 만료, 남의 키로 서명, 서명 잘라냄 네 경우 모두 401 이 나왔다. DB 의 passwordHash 는 모두 $argon2id$ 로 시작했고, 같은 비밀번호로 만든 두 계정의 해시는 서로 달랐다. 리프레시 쿠키 없이 재발급하거나 액세스 토큰을 쿠키 자리에 넣어도 401 이었다.

남은 한계는 이렇다.

토큰이 이어지는 원리는 액세스 토큰과 리프레시 토큰은 어떻게 이어지나에 더 자세히 적었다.

5. 구현 과정과 선택 기준

REST 서버를 두고 GraphQL 은 Next 안에 BFF 로 넣었다. 서버를 GraphQL 로 바꾸면 명세를 프론트엔드에 보여주기 어려워지고, 화면이 필요로 하는 모양은 화면 쪽에서 정하는 편이 맞다고 봤다. 다만 로그인·재발급·로그아웃만 REST 한 겹으로 남겼다 — 리프레시 토큰이 httpOnly 쿠키인데 쿠키는 HTTP 의 것이고 GraphQL 은 그것을 모른다.

목록은 커서 페이지네이션이다. 글 1만 건에서 오프셋은 뒤로 갈수록 느려지고(0.053 → 1.504 ms) 커서는 위치와 무관하게 일정했다. (createdAt desc, id desc) 복합 인덱스를 붙이니 Seq Scan + Sort 가 Index Only Scan 으로 바뀌면서 1.922 ms 가 0.049 ms 가 됐다.

400 과 422 를 가르는 기준을 값으로 정했다. 쿼리·경로는 무조건 400, 본문은 zod 의 issue code 로 나눈다. 형식이 틀린 것과 값이 범위를 벗어난 것은 고쳐야 할 곳이 다르다.

배포에서 밖으로 나가는 것은 클라이언트 하나뿐이다. API 서버와 DB 는 공개 도메인이 없다. 그래서 명세 화면(/docs)도 클라이언트가 대신 받아 내부 주소로 넘긴다.

6. 현재 상태와 남은 작업

배포되어 있고 API 명세는 Swagger UI 로 열려 있다.

Swagger UI 로 연 게시판 API 명세 화면. 위쪽에 '게시판 API' 제목과 버전 0.6.0, OAS 3.1 표시가 있고, 응답 형태 세 가지와 프론트엔드가 알아야 할 것이 목록으로 적혀 있다. 그 아래 auth 묶음에 POST /auth/signup, /auth/login, /auth/refresh, /auth/logout 과 GET /me 가, posts 묶음에 GET·POST /posts 와 GET·PATCH /posts/id 가 보인다.

main 에 푸시하면 검사 43가지가 돌고, 통과해야만 배포로 넘어간다. 올라간 뒤에는 공개 주소를 5가지로 불러 전 구간이 이어졌는지 확인한다 — 플랫폼 헬스체크는 컨테이너가 떴는지만 보기 때문이다.

만들면서 정한 것들은 아홉 단계로 만들면서 정한 것들에, 토큰이 이어지는 방식은 액세스 토큰과 리프레시 토큰은 어떻게 이어지나에 따로 적었다.

되돌리기는 아직 자동이 아니다. 스테이징 환경도 없어서 main 에 들어오면 바로 운영이다. 검사는 모두 HTTP 를 통과하는 것이라 단위 테스트는 없다.