Next.js BFF 구조로 인증을 다시 정리

#BFF

나는 이번에 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에서 핵심 로직은 이거였다.

  1. 보호 API 호출

  2. 401이면 /auth/refresh 호출

  3. 성공하면 새 accessToken으로 원 요청 재시도

  4. 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 구조에서는 토큰 흐름이 조금만 어긋나도 전 구간이 꼬인다. 이번에 아예 요구사항부터 다시 정리하고 설계를 고정한 뒤 구현하니까 '로그인 상태 유지'가 비로소 일관되게 돌아가기 시작했다. 결국 인증은 코드 몇 줄이 아니라, 흐름을 설계하는 문제였다.



34

댓글