백엔드를 숨기지 않기로 했다
Article
퍼블리셔가 프론트를 어려워하는 진짜 이유
디자이너와 웹퍼블리셔를 대상으로 그룹스터디를 열었다. 주제는 "Next.js로 실무 프로젝트 만들기"였는데, 준비하면서 전제를 하나 뒤집었다.
퍼블리셔가 프론트 개발을 어려워하는 이유는 Next가 어려워서가 아니다. 컴포넌트를 나누고 Tailwind로 화면을 짜는 일은 이미 하던 일의 연장이다. 진짜로 막히는 건 여기다.
- 왜
fetch가 CORS 에러를 뱉지? - 토큰을 어디에, 어떻게 붙이지?
- 로컬과 배포의 API 주소를 어떻게 분리하지?
- 매번 서버를 부르지 않으려면 뭘 어떻게 캐싱하지?
전부 백엔드와 대화하는 방법에 관한 질문이다. 화면을 그리는 법이 아니라. 그래서 이 프로젝트의 목표를 "Next를 가르친다"가 아니라 **"백엔드가 실무에서 어떤 흐름으로 도는지 눈에 보이게 만든다"**로 잡았다.
만든 건 DevTI다. MBTI 기반 개발자 캐릭터 카드를 만들고, 남의 카드를 둘러보고, 댓글과 좋아요를 남기는 서비스. 나는 백엔드와 기획·설계를 맡았다.
첫 결정은 기능이 아니라 구조였다
풀스택 웹에는 정통 패턴이 둘 있다.
A. 분리형
SPA ↔ 별도 백엔드 REST API
- 백엔드는 Spring · Node · Django 등 별도 서버
- 백엔드 팀이 따로 있는 회사의 기본형
- 브라우저가 REST 를 직접 때린다
- 백엔드가 눈에 보인다
B. 풀스택형
Next 자체가 백엔드
- 서버액션 · API Route 로 DB 를 직접 만진다
- 프론트 1명이 풀스택까지 하는 소규모의 기본형
- 서버 호출이 함수 호출처럼 보인다
- 백엔드가 숨는다
그런데 B를 고르면 백엔드가 함수 호출처럼 보인다. 서버액션을 부르면 서버 코드가 도는데, 코드만 봐서는 그게 브라우저를 떠난 건지 아닌지 알 수 없다. 경계가 지워진다. 편하기 때문에 지워지는 거다.
이 스터디의 목적은 정확히 그 반대였다. 그래서 A를 기반으로 뒀다. Spring Boot가 비즈니스 로직과 인증의 단일 소유자이고, Next는 "REST를 호출하는 렌더링·캐싱 레이어"다.
이렇게 두면 백엔드가 별도 서버로 눈앞에 있다. 브라우저가 /api/v1/cards를 때리고 → JWT를 헤더에 붙이고 → CORS를 넘고 → 공통 ApiResponse를 받아 풀고 → 401이면 로그아웃시키고. 이 전 과정이 코드에 그대로 노출된다. 숨기고 싶은 걸 일부러 드러낸 셈이다.
"서버액션을 못 쓴다"는 말은 틀렸다
여기서 흔한 오해를 하나 짚고 갔다. "Spring + MySQL이라서 서버액션을 못 쓴다"는 말은 틀렸다. 서버액션은 DB를 직접 건드리는 기능이 아니고, DB 벤더와도 무관하다.
진짜 결정 기준은 셋이다.
- 데이터의 주인이 누구인가. Next가 DB를 직접 소유(Prisma 등)하면 서버액션이 홈그라운드다. 별도 백엔드가 소유하면 서버액션은 결국 Spring을 부르는 중계 한 홉이 된다.
- 인증 토큰이 어디 있는가. ← 가장 결정적이다.
- BFF(보안 가림막)가 필요한가.
DevTI는 토큰을 클라이언트가 보관해서 Authorization: Bearer로 보낸다. 서버액션은 Next 서버에서 도는데, 그 서버는 브라우저의 스토리지를 읽을 수 없다. 즉 토큰이 없다. 인증이 필요한 작업을 서버액션으로 옮기려면 토큰을 httpOnly 쿠키로 먼저 옮겨야 한다.
그래서 결론은 "못 쓴다"가 아니라 **"이 인증 구조에서는 이득보다 마찰이 커서 안 쓴다"**다. 이 차이가 중요하다. 전제(인증 위치)가 바뀌면 결론도 바뀌기 때문이다.
토큰은 sessionStorage에 있다
처음엔 localStorage에 뒀다가 sessionStorage로 옮겼다. 이유는 보안이 아니라 어색함이었다 — localStorage는 브라우저를 닫고 컴퓨터를 꺼도 로그인이 유지된다. 공용 PC를 생각하면 그게 더 이상하다. 탭을 닫으면 토큰이 사라지는 쪽을 택했다.
XSS가 나면 토큰이 노출된다는 약점은 그대로 있다. 흠이라기보다 분리형 + 헤더 인증의 가장 흔한 짝이고, 상용 SPA 다수가 이렇게 한다. 다만 알고 쓰는 것과 모르고 쓰는 건 다르다. 그래서 토큰을 붙이는 지점을 apiClient 한 곳으로 격리해 뒀다. 나중에 쿠키로 전환한다면 갈아끼울 곳이 여기 하나다.
// lib/api/apiClient.ts — 토큰 부착·에러 변환·언랩을 한 곳에 모은다
if (auth) {
const token = getToken();
if (token) finalHeaders["Authorization"] = `Bearer ${token}`;
}
// ...
if (!res.ok || isErrorResponse(parsed)) {
// 토큰 만료/무효 → 자동 로그아웃. clearToken 이 쏘는 이벤트로 UI 가 갱신된다.
if (res.status === 401) clearToken();
throw new ApiError(res.status, code, message);
}
return (parsed as ApiResponse<T>).data; // 공통 봉투를 풀어 data 만 돌려준다
마지막 줄이 이 파일의 존재 이유다. 서버는 모든 응답을 { success, message, data }로 감싸는데, 그 봉투를 푸는 일을 화면이 하게 두면 안 된다. 여기서 한 번 풀어두면 컴포넌트는 data만 본다.
백엔드 도메인 = 프론트 feature
구조를 정했으니 다음은 협업이다. 규칙은 하나였다. 백엔드의 도메인 패키지와 프론트의 feature 폴더를 같은 이름, 같은 경계로 자른다.
백엔드 (Spring Boot)
backend/.../card/
controller/— REST 입구dto/— 주고받는 모양service/— 비즈니스 로직repository/— DB 접근entity/— 테이블
프론트 (Next.js)
frontend/src/features/card/
api/— 그 REST 를 호출types/card.ts— 같은 모양을 TS 로hooks/— 캐싱 · 상태 (react-query)components/— 화면 · Tailwindutils/
auth · member · card · mbti · skill · comment · like · file · aptitude. 백엔드에 이 패키지가 있으면 프론트에도 같은 이름의 feature가 있다.
같은 경계로 잘렸기 때문에 "이 기능 누가 맡아?"가 폴더 단위로 나뉜다. 한 사람이 백엔드 card를 맡고 다른 사람이 프론트 features/card를 맡으면, 그게 곧 한 기능의 협업 단위다.
endpoints.ts가 계약서다
두 영역이 만나는 지점은 파일 하나로 몰아뒀다.
// lib/api/endpoints.ts — 백엔드가 경로를 정하고, 프론트는 이 한 곳만 본다
export const endpoints = {
cards: {
list: "/cards",
detail: (cardId: number) => `/cards/${cardId}`,
views: (cardId: number) => `/cards/${cardId}/views`, // 조회수 +1 (GET 에서 분리한 부수효과)
share: (shareSlug: string) => `/cards/share/${shareSlug}`,
},
likes: {
toggle: (cardId: number) => `/cards/${cardId}/likes`, // POST=추가 / DELETE=취소
status: (cardId: number) => `/cards/${cardId}/likes/me`, // 내 좋아요 상태
},
} as const;
경로 문자열이 컴포넌트 안에 흩어지면, 백엔드가 경로를 바꿀 때 프론트를 전수 검색해야 한다. 한 곳에 모아두면 바뀐 곳이 한 줄이다.
여기에 더해 서버 DTO와 프론트 TS 타입을 1:1로 맞췄다. DevCardSummaryResponse의 필드와 types/card.ts의 DevCardSummary 필드가 같다. 한쪽이 필드를 바꾸면 타입 에러로 즉시 드러난다. 문서로 관리하는 계약은 반드시 낡지만, 타입으로 관리하는 계약은 낡으면 빌드가 깨진다.
요청 한 번이 흐르는 길
스터디에서 제일 먼저 한 실습이 이거였다. 카드 목록을 여는 클릭 한 번이 DB까지 갔다 오는 전 경로를 손으로 따라가기.
- 화면
CardSwiperList.tsx— 퍼블리셔가 주로 만지는 곳 - 상태
useCards()— react-query 캐싱 - 전송
cardApi.getCards()— 어떤 API 를 어떻게 부를지 - 계약
endpoints.ts— 백엔드와 프론트가 만나는 약속. 여기서 브라우저를 떠난다 - 입구
DevCardController—GET /api/v1/cards. 여기부터 백엔드다 - 로직
DevCardServiceImpl— 공개 + 정상 카드만. 검색 · 정렬 · 페이징 - 검색
DevCardSpecs— 동적 조건 (Specification) - DB
DevCardRepository→ MySQL - 응답
DevCardSummaryResponse가 JSON 으로 나가types/card.ts의 같은 모양이 된다
이 그림을 그리고 나면 질문이 바뀐다. "데이터가 어디서 오는지 모르겠다"가 "이 화면은 서비스에서 필터를 걸고 있으니 비공개 카드가 안 보이는 게 맞다"로 바뀐다.
퍼블리셔는 마지막 단계만 맡는다
역할은 네 단계로 잘랐다.
- 01
API 설계 — 백엔드
경로 · 메서드 · DTO 를 합의하고
endpoints.ts에 등록한다. - 02
서버 구현 — 백엔드
Controller → Service → Repository. 검증과 권한도 여기서 끝낸다.
- 03
전송 · 상태 — 프론트
api/와hooks/로 호출과 캐싱을 잇는다. - 04
화면 · 디자인 — 퍼블리셔
components/에서 Tailwind 로 화면을 구성한다. 여기만 책임지면 된다.
퍼블리셔는 마지막 단계만 책임지면 된다. 데이터가 어디서 오는지는 훅 한 줄로 추적된다.
const { data } = useCards({ keyword, mbtiType, page });
// data.content = 카드 배열 → map 돌려 그리면 끝
스터디에서 이렇게 설명했다. 백엔드가 DB를 어떻게 뒤졌는지는 몰라도 된다. "무엇을, 어떤 모양으로 주는가"만 알면 화면을 짤 수 있다. 그리고 그 "모양"은 추상적인 개념이 아니라 types/card.ts라는 파일 하나다. 그 파일을 열면 화면에 그릴 수 있는 것의 전부가 나열돼 있다.
합격 기준도 그렇게 잡았다. *"이 화면의 이 데이터는 백엔드 어느 메서드가, 어떤 DTO 모양으로 줬다"*를 말로 설명할 수 있으면 통과.
한 가지 더 강조한 게 있다. 화면의 보임/숨김은 디자인 변덕이 아니라 백엔드 권한 규칙의 거울이다. 수정 버튼이 가끔 안 보이는 건 "내 카드일 때만" 이라는 서버 규칙이 화면에 반사된 결과다. 이걸 알면 "이 버튼 왜 안 떠요?"가 질문이 아니라 확인이 된다.
이 구조는 공짜가 아니다
정직하게 적어 두면, 분리형은 비용을 치른다. CORS를 넘어야 하고, 토큰을 직접 붙여야 하고, 401을 직접 처리해야 하고, 로컬과 배포의 API 주소를 분리해야 한다. 서버액션이었으면 대부분 없었을 일이다.
다만 이 프로젝트에서 그건 비용이 아니라 커리큘럼이었다. 어차피 실무에서 만날 것들이고, 만나면 반드시 막히는 것들이다. 숨겨진 채로 배우면 나중에 혼자 뚫어야 한다.
그리고 이 비용들은 흩어져 있지 않다. CORS는 백엔드 설정 한 곳, 토큰과 401은 apiClient 한 곳, 경로는 endpoints.ts 한 곳. 드러내되 한 곳에 모아둔다 — 그게 이 구조에서 지키려 한 규칙이었다.
배운 것
구조를 고르는 일은 성능이나 유행의 문제가 아니었다. 무엇을 보이게 하고 무엇을 숨길지를 고르는 일이었다.
서버액션은 백엔드를 숨겨서 편하게 만든다. 그게 장점인 상황이 분명히 있다. 다만 "백엔드가 어떻게 도는지 몰라서 막힌 사람들"을 가르치는 자리에서는, 편하게 만드는 것이 곧 배울 것을 없애는 것이었다.
다음 편에서는 그 위에 얹은 데이터 패칭 이야기를 쓴다. RSC와 react-query 중 하나를 고르지 않고 셋을 공존시킨 이유, 그리고 원칙을 정해놓고 카드 목록 화면 하나만 의도적으로 예외로 둔 이유. 그리고 그 화면에서 로딩바 하나 때문에 원인을 세 번 연속 헛짚은 이야기.