go to post list

인증은…힘들다

2023. 12. 23 • Module Federation을 사용한 환경에서 oAuth 인증을 구현하기 위해 한 고민

왜 인증에 물렸는가

처음부터 인증/인가 처리를 밑바닥부터 구현할 생각은 아니었습니다. Next.js 문서에서 권장하는 NextAuth를 이용해 이 부분을 빠르게 구현하고 다른 부분 구현에 집중하려고 했었는데요, NextAuth는 저희 상황과는 잘 들어맞지 않는 라이브러리여서 직접 구현하는 것으로 방향을 틀게 되었습니다. 이번 글에서는 왜 프론트엔드에서 인증/인가를 공들여 구현하게 되었는지에 대해 써 보려고 합니다.

가설 수립의 흔적

가설 수립의 흔적

1. 라이브러리는 사용할 수 없었고

NextAuth는 NextAuth 자체 토큰 또는 oAuth 인증 기관의 토큰을 그대로 사용합니다. 또, Next App을 db와 직접 연결했을 때 적절하게 쓸 수 있는 라이브러리입니다. 저희는 Next App의 백엔드를 단순히 데이터 운반과 가공만 하는 역할로만 사용하고, 데이터 처리와 비즈니스 로직은 Spring 서버에 있었던 구조였습니다. 카카오에서 발급한 토큰을 사용하는 것이 아닌 Spring 단에서 발급한 토큰을 사용하고, Spring Security를 통해 검증하는 구조를 가진 저희 상황에서는 NextAuth가 제공하는 메서드 안에서 토큰을 Next App 바깥으로 빼는 작업이 상당히 제한적이고 부자연스럽다고 판단했습니다. 그렇다고 Next App의 전체 구조를 무시하고 브라우저에서 Spring 서버로 바로 HTTP 요청을 보내는 것은 아키텍처상 부적절하다고 생각했습니다. 클라이언트의 영역을 브라우저와 Next App 전체라고 생각했기 때문에, 기본적으로 브라우저와 Spring 서버와의 통신은 Next App을 반드시 거쳐야 한다고 생각했습니다.

2. 서로 다른 Next App이 어떻게 인증 상태를 공유하도록 할지 결정해야 했다

또, 저희가 구현해야 하는 인증 로직은 그 요구조건이 조금 까다로웠습니다. 사실 이게 진짜 이유인데요, 프론트엔드가 4개의 Next App과 여러 다른 패키지들로 이루어진 상태에서 module federation을 이용해 빌드되고 실행되는 환경이었기 때문입니다. 프로젝트가 어쩌다 이렇게 되었는지는 바로 이전 글에서 보실 수 있습니다.

shell 이라고 하는 첫 번째 Next App은 페이지 라우팅과 컴포넌트의 조립을 하고, 나머지 세 개의 Next app은 user, schedule, notice로 각각의 서비스 도메인 서버의 역할을 하고 있습니다. 각각의 Next app에는 도메인에 대한 기능들과 페이지, api 라우트가 구현되어 있었고요. 사용자가 보는 화면에 있는 알림 컴포넌트는 notice에서, 달력 컴포넌트는 schedule에서, 친구와 사용자에 관한 컴포넌트는 user에서 원격 import를 통해 받아오게 되고요, 최종적으로 프레임을 제공하는 shell에서 조립되어 브라우저에 렌더링된 것입니다. 길게 설명했는데 제가 사용한 방식을 정말 거칠게 요약하면 “런타임에 다른 App의 빌드 파일에 들어가 원격으로 코드를 import한다”가 핵심이라고 할 수 있습니다.

이렇게 shell에서 조립된 컴포넌트들은 각자 자신의 고향으로 API 요청을 하게 됩니다. 만약 shell이 3000번, user가 3001번 포트에서 실행됐다면 user에서 import된 컴포넌트는 3000번에서 3001번 포트로 HTTP 요청을 하게 됩니다. 이때 문제가 하나 발생하는데, 3002번 포트 schedule에서 shell의 상태를 참조할 방법이 필요할 수 있다는 점입니다. 단적인 예로 인증 문제가 그렇습니다. schedule에서 유저의 일정 정보를 조회하기 위해서는 지금 로그인된 유저가 누구인지를 알아야 하는데, 이 정보는 shell에 있기 때문입니다.

검색을 하다 보니 서로 다른 Next App들이 상태를 공유할 수 있도록 하는 상태관리 라이브러리들이 있다는 걸 알게 됐는데요, 클라이언트 단에서 도메인간에 공유할 상태가 없다고 판단했기 때문에 JWT를 쿠키에 첨부해 구현하는 것으로 결정했습니다. 더 구체적인 판단 근거는 다음 세 가지였는데요,

  1. 브라우저에서 서버로 통신을 시도할 때 쿠키를 이용하면 쿠키가 자동으로 전송되기 때문에 이 성질을 이용하면 인증 로직의 구현량을 줄일 수 있다는 장점이 있습니다.
  2. 각기 다른 Next App의 도메인을 리버스 프록시를 통해 통일시켰기 때문에 각 Next App에서 발급한 쿠키가 shell 모듈에서 서드파티 쿠키로 인식되지 않는 상태였습니다. 즉 쿠키는 4개의 모듈에서 모두 발급하지만 컴포넌트의 런타임은 Shell로 고정되어 있도록 했기 때문에 각 Next App들의 도메인이 모두 같고 하위 경로만 달라 쿠키 송수신에 문제가 없었습니다.
  3. 클라이언트와 서버 양 쪽에서 쿠키를 컨트롤하며 사용해 보는 경험을 해 보고 싶었습니다.

직접 컨트롤할 수 있는 서버가 있으니 프론트엔드 파트에서 더 많은 해결 가능성과 더 능동적인 대안을 취할 수 있었습니다.

최종적으로 설계한 통신 흐름

사용자가 정상적으로 로그인했을 때의 흐름, 클릭하면 크게 보실 수 있습니다.

사용자가 정상적으로 로그인했을 때의 흐름, 클릭하면 크게 보실 수 있습니다.

사용자가 로그인을 시도하면 아래와 같은 흐름으로 통신이 이루어집니다.

  • oAuth 로그인 버튼을 누르면 카카오 로그인 페이지로 이동시킵니다. (위 그림의 1)
  • 카카오 로그인에 성공하면 카카오 로그인 페이지에서 서비스의 로그인 요청 처리 페이지로 리다이렉트해 주면서, 카카오 서비스에 API 요청을 할 수 있는 토큰을 함께 알려줍니다.
  • 페이지가 서버 사이드에서 렌더링되면서 카카오에 부가 정보를 요청하고, 이 정보로 Spring 서버에 토큰을 요청한 후, 브라우저에 전송하면서 리다이렉트시켜 로그인이 완료됩니다. (위 그림의 2 ~ 7)
  • 6 에서 Redis에 저장하는 토큰은 Refresh Token이고, 7 에서 브라우저로 전송하는 토큰은 Access Token입니다.
  • 4 를 요청하는 도중 회원 정보가 없다면 회원 가입 페이지로 이동하고,
  • 모든 과정이 정상적으로 처리되었다면 로그인 처리가 되면서, Access Token을 담은 쿠키와 함께 서비스 메인 페이지로 리다이렉트시킵니다.

이렇게 SSR로 oAuth를 구현했을 때 프론트엔드 영역에서 oAuth 인증이 끝나니, 백엔드 서버가 외부 서비스와 차단되어 격리되는 효과가 생겼습니다. 비즈니스 로직을 처리하는 메인 서버의 API 주소가 직접 노출되지 않는 등 외부의 영향을 덜 받게 되는 것이 장점이라고 생각합니다.

이 구조가 완성되기까지는 질곡의 시간이 있었는데요, 대체로는 Next.js에 대한 학습이 부족해서 벌어진 일들이었습니다. 가장 크게 발목을 잡았던 문제는 Edge Runtime 이었는데요, 쿠키 인증 로직을 미들웨어에서 처리하는 도중 해당 문제를 만났습니다. 미들웨어 기능이 강력하다고 생각했던 만큼 꼭 사용해보고 싶었지만, 미들웨어가 실행되는 환경인 Edge Runtime 특성상 Redis 연결이 불가능하거나 Axios가 런타임을 알지 못해 적절한 어댑터를 찾지 못하는 등의 문제가 있었습니다. 대체 스택(Vercel KV)을 찾아 마이그레이션하는 과정을 거쳤습니다만 결국 외부 서비스에 의존하는 것이고, Redis를 EC2에 배포해 사용할 수 있는 상황이었기 때문에 미들웨어에서 인증 로직을 분리해 인증이 필요한 API Route와 SSR 로직에 각각 적용시키는 방법으로 구현했습니다. 이 부분에 대한 구현은 바로 아래에 있습니다.

또 spring 서버에 4 를 요청할 때에는 쿠키를 그대로 보내는 것이 아니라 Bearer 인증을 이용하는 식으로 변환했습니다. 서버 사이드에서는 쿠키가 자동으로 전송되는 것도 아니거니와 sameSite 같은 쿠키 설정들이 동작하는 것도 아니기에 브라우저로부터 받은 쿠키를 수동으로 첨부해 다시 보내야 했는데요, 해당 이슈를 해결하다 보니 변환을 해서 보내주는 것이 프로토콜상 더 의도한 바에 부합하다고 생각했습니다. 쿠키의 생명 주기를 브라우저와 Next App 선에서 끊어주는 것이 설계상 깔끔하다고도 판단했습니다.

일반적인 API 요청을 할 때의 흐름

인증이 필요한 API 요청을 할 때에는 다음과 같은 순서로 요청/응답이 발생합니다. Access Token과 함께 무거운 Request Body를 계속해서 전송하는 것보다는 토큰을 먼저 검증받고 정상 응답이 오면 본 요청을 보내는 방식으로 구현했는데요, 이 방식은 HTTP Preflight Request를 참고해 구현했습니다.

  1. Next.js 서버로 인증이 필요한 요청 도착
  2. Next.js에서 Spring 서버로 Access Token만 분리해 (이하 AT) 검증 요청
  3. AT 검증 성공 시 Request Body를 포함한 본 요청 전송, 쿠키 갱신 후 브라우저로 응답 전달
  4. AT 검증 실패 시 Refresh Token(RT) 검증 요청, 3 재시도
  5. RT 검증 실패 시 쿠키 삭제 후 로그아웃 페이지로 리다이렉트

아래는 구현 코드입니다. 고차 함수를 이용해, withAuth(API_HANDLER) 로 감싸면 인증이 필요한 API 핸들러를 간편하게 만들 수 있도록 했습니다. SSR 상황에서 사용할 수 있는 버전도 구현되어 있습니다.

Typescript

export function withAuth(fn: API_HANDLER): API_HANDLER {
  return async function (req: NextApiRequest, res: NextApiResponse) {
    let ACCESS_TOKEN = req.cookies.Auth || '';
    if (!req.cookies.Auth) {
      return res.redirect(`로그아웃 페이지`);
    }
    try {
			//토큰을 먼저 검증합니다. 리프레시 토큰에 대한 검증 요청까지 포함되어 있으며,
			//새 액세스 토큰이 발급된 경우 새 토큰이 들어옵니다.
      ACCESS_TOKEN = await VERIFIED_TOKEN(ACCESS_TOKEN);
      req.headers.authorization = `Bearer ${ACCESS_TOKEN}`;
			//검증된 토큰을 끼워 본 요청을 Spring 서버로 보냅니다.
      await fn(req, res);
			//검증된 토큰을 포함한 응답을 브라우저 측으로 전달합니다.
      return res.setHeader('Set-Cookie', `Auth=${ACCESS_TOKEN}; Max-Age=900; HttpOnly; SameSite=Lax;`);
    } catch (err) {
      Sentry.captureException(err);
      res.setHeader('Set-Cookie', `Auth=; Max-Age=0;`);
      return res.redirect(`로그인 페이지`);
    }
  };
}

//.../api/.../anyApiHandler.ts
...
export default withAuth(handler); //이렇게 인증 처리가 필요한 핸들러를 작성할 수 있습니다.

마무리: 아쉬움과 한계

사실 쿠키로 일반적인 로그인 인증을 구현한 것과 다를 것 없는 결과이긴 한데, 고민하는 과정 속에서 그래도 성장을 할 수 있었다고 생각합니다. 생각보다 간단히 해결됐지만 처음에는 정말 깜깜했는데, 알고 있는 지식들을 하나하나 조합해 가설을 세우고 실험하면서 답을 찾아낼 수 있었습니다. 지금부터는 이렇게 처리했을 때의 문제점과 한계에 대해 이야기해 보겠습니다.

1. 정적으로 생성된 페이지에 대한 인증 처리 문제

Edge Runtime에서 Redis 연결이 불가능하기 때문에 미들웨어를 사용할 수 없어, 정적으로 생성된 페이지에 대한 GET 요청 인증 처리를 하기 곤란하다는 점이 있습니다. 다만 이 프로젝트만 놓고 보면 일정을 다루는 특성상 메인 컨텐츠의 수정이 매우 잦고 실시간성도 있기 때문에 UI 템플릿 정도를 제외한다면 정적으로 보안이 필요한 페이지를 생성할 여지는 없을 것 같아 괜찮다고 판단했습니다.

2. Redis에 문제가 생긴 경우에 대한 대비 필요

토큰 정보를 저장하는 Redis가 하나만 올라가 있기 때문에 문제가 생기면 액세스 토큰이 만료되었을 때 토큰이 갱신되지 못하고 무조건 로그인이 풀립니다. 이 부분은 로직상의 문제라기보다는 아키텍처상의 보완이 필요한 부분입니다. 본격적인 라이브 서비스를 개시하면 여러 대의 Redis와 함께 어느 Redis에 토큰을 저장할지 결정하는 라우팅 로직 또는 라우팅 서버의 구현도 필요할 것입니다.

3. Next.js 서버와 Spring 서버 사이의 물리적 거리가 먼 경우

저희는 두 서버의 물리적 거리가 가깝다는 것을 상정하고 이런 식의 구현을 했는데요, 그렇지 않은 경우에는 서비스 속도 저하가 일어나게 됩니다. 분산 배치를 하거나 여러 인스턴스를 사용하게 된다면 다른 방식의 구현을 고려해야 한다고 생각합니다.

여담

프론트엔드 개발자 준비를 하면서 컴포넌트나 상태 관리에 대한 고민만 하다가 서버가 주어지니, 아키텍처나 통신 흐름에 대한 고민들도 하게 되었습니다. 모호하게만 알고 있던 영역에 대한 고민들을 해 보고 실제로 구현을 해 보면서, 제가 알고 있던 프론트엔드에 대한 시야가 많이 확장되었다고 생각합니다. 또 서버 개발자의 영역도 아주 조금이지만 엿볼 수 있었어요. ‘아, 이런 상황에 대해서는 어떻게 대비해놓아야 하지?’ 하는 생각을 UI가 아닌 다른 방면에서 좀더 많이 해 볼 수 있었던 경험이었습니다.

사족을 붙여보자면, 어떤 분들이 “이제 백엔드는 죽었다” 라든가 “프론트엔드는 죽었다” 같은 말씀을 하시곤 합니다. 각자 다른 이유이긴 하지만 Next.js를 보고는 이제 백엔드 직무는 어떡하냐고 한 사람이 기억이 나네요. 저는 아직까지는 저 두 말에 모두 반대하는데, 프론트엔드에 서버가 주어지니 프론트엔드에 특화된 쪽(UX)으로 더 발전해 나가는 느낌이지, 전문성 측면에서는 한 쪽이 다른 쪽을 흡수하는 방향은 아닌 것 같습니다. 하나의 견고한 서버로 기능한다기에는 이런저런 제한도 있었고요. 저는 프론트엔드를 지망하는 취준생인 입장에서 이런 식의 기술 발전이 신기합니다. 새로 기술을 배우는 것도 재미있고요. 어떤 문제를 해결하기 위해 계속해서 기술이 발전하는 과정 안에 제가 있다는 건 참 설레고 가슴 뛰는 일이라고 생각합니다.

다른 글