나는 이번에 Next.js BFF 구조로 인증을 다시 정리하면서, '로그인 상태 유지'라는 게 생각보다 훨씬 디테일 싸움이라는 걸 제대로 체감했다. 그냥 accessToken 하나 들고 다니면 끝날 줄 알았는데, 실제로 서비스에 붙여보니 401, 재발급, 쿠키 옵션, 에러 포맷, 로그 보안까지 전부 연결되어 있었다. 그래서 아예 요구사항부터 다시 정의하고, 설계를 고정한 다음 구현을 맞춰갔다.
내가 처음에 마주한 문제
구조는 이랬다.
프론트: Next.js
중간 계층: BFF(/api/bff)
백엔드: 인증 API 서버
목표는 단순했다.
사용자는 로그인 한 뒤,
토큰 만료가 와도 끊김 없이
계속 로그인 상태가 유지되어야 한다.
출처 입력
그런데 막상 구현을 시작하니 이런 문제들이 튀어나왔다.
401이 나면 어떻게 복구하지?
refresh는 어디서 호출하지?
쿠키로 할까? Authorization 헤더로 할까?
토큰 이름이 서로 다르면?
refresh 실패하면 클라이언트 상태는 어떻게 정리하지?
이걸 제대로 정리하지 않으면, BFF와 백엔드가 서로 다른 말을 하게 된다.
그래서 나는 요구사항을 먼저 고정했다.
1. 토큰은 쿠키로, 이름은 절대 통일
제일 먼저 한 건 쿠키 이름 통일이었다.
accessToken
refreshToken
대소문자까지 동일하게 고정했다. 환경변수로 바꿀 수는 있게 했지만 기본값은 무조건 이 두 개. 이걸 통일하지 않으면, 어디선 access_token, 어디선 AccessToken, 어디선 refresh_token 디버깅하다가 시간 다 날린다. 이건 경험에서 나온 결정이었다.
2. 로그인은 Set-Cookie + body 둘 다 준다
로그인 설계하면서 제일 많이 고민한 부분이 여기였다.
refreshToken은 반드시 Set-Cookie refresh는 쿠키만으로 동작하게 설계했기 때문에, refreshToken은 무조건 Set-Cookie로 내려줘야 했다. 그래야 /auth/refresh에서 헤더나 body 없이도 브라우저가 자동으로 쿠키를 실어 보낼 수 있다.
accessToken은?
여기서 선택지가 생겼다.
쿠키만?
body만?
둘 다?
결론은 둘 다.
쿠키(HttpOnly)로 내려서 XSS 노출 최소화
동시에 body에도 포함시켜서 BFF가 401 재시도할 때 바로 사용
이렇게 하니까 BFF가 재요청을 만들 때 훨씬 깔끔해졌다.
3. refresh는 오직 "쿠키만" 본다
refresh는 일부러 단순하게 설계했다.
입력: refreshToken 쿠키 하나
성공:
새 accessToken 발급
refreshToken rotation
두 쿠키 모두 Set-Cookie 갱신
body에 accessToken 포함
실패:
HTTP 401
accessToken / refreshToken 쿠키 삭제용 Set-Cookie 반환
여기서 중요한 건 실패 시 쿠키 삭제였다.
이걸 안 해두면, 클라이언트는 "로그인 된 줄 알고" 계속 요청을 날린다. 401이 오면 그냥 끝이 아니라, 쿠키 상태까지 '로그아웃 상태'로 정리해줘야 흐름이 깨끗해진다.
4. 보호 API는 두 가지 인증 방식 허용
나는 보호 API에서 이렇게 열어뒀다.
Authorization: Bearer {accessToken}
또는 accessToken 쿠키
모바일이나 외부 클라이언트는 Bearer 방식, 브라우저 + BFF는 쿠키 기반. 덕분에 매 요청마다 헤더에 토큰을 붙이지 않아도 되고,
BFF도 훨씬 단순해졌다.
5. 401이 나면 어떻게 복구하나?
BFF에서 핵심 로직은 이거였다.
보호 API 호출
401이면 /auth/refresh 호출
성공하면 새 accessToken으로 원 요청 재시도
refresh도 401이면 로그인 상태 종료
이 흐름을 미리 설계해두니까
예외 케이스가 줄었다.
6. 에러 포맷을 통일하지 않으면 지옥이 열린다
나는 에러를 무조건 이 구조로 통일했다.
{ok: false,error: {
status,
code,
message,
details?}}이걸 안 맞추면 BFF에서 에러 파싱하다가 분기 지옥이 열린다.
response.error.code만 보면 되게 만들어두니까 클라이언트 로직이 깔끔해졌다.
7. 로그에는 절대 토큰 남기지 않는다
처음엔 디버깅한다고 토큰을 찍고 싶어졌다.
그런데 이건 절대 하면 안 된다.
로그인 실패 시 이메일 로그 남기지 않음
refresh 실패 시 토큰/예외 상세 남기지 않음
로그인 실패, refresh 실패 수준만 남김
보안은 기능이 아니라 기본값이어야 한다고 생각한다.
정리하면서 느낀 점
이번 작업에서 깨달은 건 이거다.
'로그인 유지'는 프론트 문제도, 백엔드 문제도 아니다.
계약(Contract)을 맞추는 문제다.
쿠키 이름 통일
로그인 시 Set-Cookie + body 제공
refresh는 쿠키만 사용
실패 시 쿠키 삭제
401 refresh 재시도 흐름 확정
에러 포맷 통일
로그 보안 준수
이걸 초기에 합의하고 들어가니까 나중에 디버깅 시간이 눈에 띄게 줄었다. 특히 BFF 구조에서는 토큰 흐름이 조금만 어긋나도 전 구간이 꼬인다. 이번에 아예 요구사항부터 다시 정리하고 설계를 고정한 뒤 구현하니까 '로그인 상태 유지'가 비로소 일관되게 돌아가기 시작했다. 결국 인증은 코드 몇 줄이 아니라, 흐름을 설계하는 문제였다.