Next.js에서 인증-인가 구현하기 (2)

·5 min to read

Next.js 인증 문서 기준에 따르면, 인증/인가는 크게 세 가지 영역으로 구분할 수 있다. 사용자의 신원을 확인하는 '인증', 인증된 사용자에게 권한을 부여하고 유지하는 '세션 관리', 사용자의 권한에 걸맞는 리소스를 제공하는 '인가'. 이렇게 구분했을 때 개인적으로 설계 및 구현에 있어 제일 많은 비용이 소모되는 영역은 항상 세션 관리 쪽이었다. JWT로 운용되는 서비스의 FE 개발자라면 피해갈 수 없는 숙명이다. 따라서 이 글에서는 세션 관리를 중심으로 다룬다.

세션 관리의 두 가지 축

1. 세션 상태 (stateless vs stateful)

세션 관리는 크게 두 가지 축을 기준으로 이해가 가능하다. 하나는 Stateful 혹은 Stateless, 즉 '세션 상태'다. Stateful은 session data를 DB에 저장하는 전통적인 방식이다. 유저의 개인 정보는 감춰둔 채 매핑된 번호를 가리키는 session ID만 클라이언트와 주고 받으며 연결성을 유지한다. 필요할 땐 서버측에서 세션을 무효화하는 식으로 잠재적 보안 위협 등에 대비할 수가 있다. 반면에 Stateless는 의도적으로 연결을 끊는다. 서버에서 간단히 서명한 JWT 를 생성해서 넘겨주면 앞으로는 클라이언트의 몫이다. 서버는 세션 정보를 따로 기억하지 않는다. 클라이언트가 매 요청마다 토큰을 동봉해서 보내면 서버에서는 토큰을 디코딩해서 유효기간을 확인하고 서명값 대조를 통한(해시) 무결성 검증 절차만 밟으면 된다.

두 방식의 가장 중요한 차이점은 연결성 유무에 있다. 연결성이 유지되는 순간 다중 서버 운용 환경에 불리해진다. A서버로부터 발급받은 세션을 B서버는 모를 수 있기 때문이다. 이런식의 확장성 제약이 치명적으로 이어질 수 있기에 요즘 서비스는 stateless 방식을 선호한다. 잘 구축하면 보안적 측면에서의 디메리트도 없기 때문이다.

또 다른 중요 포인트는 '세션 관리 책임이 어느 쪽으로 귀속되는가'다. 이를테면 stateless 방식에서는 FE 개발자가 토큰을 어디에 보관할지부터 재발급 로직을 구축하는 것 까지 거의 모든 생애주기를 책임져야 한다. 반면 stateful 방식에서는 이런 고민으로부터 상대적으로 자유로워진다.

2. 브라우저에 토큰이 노출되는 정도

또 다른 하나의 축은 '브라우저에 토큰이 노출되는 정도'다. RFC 10017 에서 제시한 기준인데, 이 내용을 따라가면 세션 관리의 많은 부분을 깨우칠 수 있기에 한 쪽 기둥을 담당해도 무방하다고 판단했다. RFC 10017은 토큰이 브라우저에 노출되지 않을수록 서비스의 안정성이 올라간다는 설명을 하고 있다. 토큰 노출 정도에 따라 BFF(Backend For Frontend)/Token-Mediating Backend/Browser-Based OAuth Client 3 단계로 나뉘어 있고, Browser-based 형태일수록 토큰 노출정도가 심하고 BFF는 server to server로 토큰을 꽁꽁 숨겨둔다.

아키텍처는 언제나 trade-off고 No Silver Bullet 이기에 섣불리 'BFF로 하면 되겠네!'라고 결론내려선 안 된다. BFF는 안정성이 올라가는 만큼 비용도 상승한다. 별도로 서버를 관리해야하고 클라이언트의 모든 요청을 Proxy에 태워서 서버를 경유하도록 하는 수고를 감내해야 한다. 만약 Vercel같은 serverless 형태로 운용중이라면 요청이 많아질수록 청구되는 금액도 함께 불어난다. 아키텍처의 복잡성은 코드 복잡성으로 직결되어서 팀원에게 이해장벽으로 작용될 수 있다.

토큰 노출을 막는 방법은 JWE 혹은 stateful을 활용하는 것이다. 브라우저와 리소스 서버 사이에 FE 개발자가 관리하는 서버 (Next.js 등) 레이어를 한 겹 두고 안전장치를 마련하는 것이다. JWE는 JWS와 대비되는 개념으로, 암호화한 JWT다. JWS는 일반적으로 일컫는, 단순 서명이 들어간 JWT다. JWE의 활용방식은 후술할 예정이다.

두 가지 축 들여다보기

obsidian image|1125

세션관리 방식을 러프하게 정리해보면 위와 같다. 모든 케이스를 나열할 순 없어서 추상화의 힘을 빌릴 수 밖에 없었다. 앞으로 나올 예시들은 RFC 문서처럼 OAuth로 인증하는 사례들이다.

1. Browser-Based

obsidian image|950

브라우저와 서버가 다이렉트로 통신하는 구조에서 클라이언트는 알몸과 다름없다. 브라우저는 무언갈 안전하게 보관할 수 있는 장소가 아니다. 이럴땐 stateful 방식을 사용해서 위험한 정보를 클라이언트에 노출시키지 않는 것이 최선이다.

2. Token-Mediating Backend

obsidian image

브라우저와 리소스 서버 사이에 토큰 중개용 서버를 하나 둔다. Next.js의 API Route를 떠올리면 쉽다. 유효기간이 길게 설정되어 있는 RefreshToken만이라도 JWE나 DB를 통해 클라이언트에 노출시키지 않는 방식이다. 평소에는 브라우저와 리소스 서버가 AccessToken만을 가지고 통신하다가, accessToken이 만료되었을 때 Next 서버를 통해 새 토큰을 발급받아온다.

3. BFF

obsidian image

Token-Mediating 방식과의 차이점은 프록시 유무다. 모든 통신요청이 Next 프록시를 경유하게 함으로써 AccessToken조차 클라이언트에 노출시키지 않는 방식이다.

Auth.js로 구현한 BFF

  • Auth.js 선택한 이유 - 가뜩이나 BFF라는 무거운 구조를 택했는데 모든 짐을 짊어지고 싶지 않았다. 가능한 발명되어 있는 바퀴를 가져다 쓰고 싶었다.
  • Auth.js는 세션관리의 대부분을 해준다. JWE 기반의 세션 생성을 지원하고 세션 생애주기와 관련된 콜백이 지원된다.
  • 만약 vercel같은 Serverless 환경에 BFF를 구축하면 유저 트래픽이 많아질수록 높은 금액이 청구되니 유의해야함.
  • 대략적인 흐름

마무리

세션 관리 아키텍처의 변형은 무궁무진한 것 같다. 하지만 큰 틀에서만 숙지하고 디테일한 부분은 필요할 때 응용하고 찾아나가는 것이 현명하겠다.

처음엔 BFF라는 무거운 구조를 도입하는 게 잘못된 판단이 아닐까 하는 걱정이 컸다. YAGNI인가 싶었다. 하지만 알아갈수록 이 방식이 유효한 것 같다. 특히 요즘처럼 대형 서비스들의 개인정보 유출 사건이 빈번해지고, LLM이 활개쳐서 누구나 서비스의 보안적 취약성을 드러낼 수 있는 상황에서는.

그럼에도 유연한 판단이 중요하다. BFF를 도입할 수 없는 상황이 있을테고, stateful로 처리하는 게 나은 상황도 있을 것. 백엔드와 소통해서 보안적 위협을 간단히 해결할 수도 있고, 그게 어려울 땐 프론트쪽에서 세션 레이어를 구축하고. 유연한 판단에 두 가지 축이 요긴하게 쓰일 것이다.