티스토리 뷰

안녕하세요 Post-tx & Accounting팀 권우석입니다.

최근 온보딩 프로젝트를 함께했던 Shopping Service API팀의 김도훈님의 제안으로 회원가입/로그인 API를 간단하게 구현하는 토이 프로젝트를 진행하고 있는데요. 이번 글에서는 토이 프로젝트를 진행하며 고민했던 아래 주제에 대해 다뤄보려고 합니다.

 

1. 인증/인가를 어떻게 구현할 것인가?

2. 인증/인가 로직을 어디에 구현할 것인가?

 

인증과 인가란?

인증이란 사용자의 신원을 검증하는 프로세스를 뜻합니다. 가장 간단한 예시로는 ID와 PW를 통해 로그인하는 행위를 인증이라 할 수 있죠. 인가는 인증 이후의 프로세스입니다. 인증된 사용자가 어떠한 자원에 접근할 수 있는지를 확인하는 절차가 바로 인가이죠. 로그인을 예시로 들었듯이 거의 모든 웹 사이트에서는 인증/인가를 필요로 합니다.

 

HTTP의 stateless

웹 사이트는 HTTP 통신 위에서 동작합니다. 때문에 웹 사이트 내의 모든 요청과 응답은 stateless한 특성을 가지죠. 즉, 서버에서 Client의 이전 상태를 기억하고 있지 않습니다.

 

 

HTTP의 stateless라는 특성을 인증과 함께 생각해보면 로그인을 통해 인증을 거쳐도 이후 요청에서는 이전의 인증된 상태를 유지하지 않게 됩니다. 이러한 상황에서 웹 사이트를 이용하려면 인증/인가가 필요한 모든 상황에서 사용자는 반복적으로 ID/PW를 입력해야 하는 불상사가 생기게 되겠죠.

 

인증/인가를 어떻게 구현할 것인가?

그렇다면 stateless한 HTTP 위에서 인증/인가는 어떠한 방식으로 이루어져야 할까요?

 

해결책 1: Cookie

HTTP 쿠키란 서버에서 사용자 브라우저로 전송하는 작은 데이터를 뜻합니다. 브라우저는 서버에서 받은 데이터(Cookie)를 저장해 놓았다가 동일한 서버로 재요청 시 제공받았던 데이터를 함께 전송하죠. 이를 통해 HTTP의 stateless를 보완해 HTTP 통신에서도 상태 정보를 보존할 수 있게 됩니다. (ssul로는,, Cookie라는 단어가 헨젤과 그레텔 이야기 중 과자 조각으로 길을 표시한 것에서 비롯되었다는,,)

 

 

인증/인가에 Cookie를 이용한다면 어떨까요? 사용자가 로그인할 때 서버는 ID와 PW를 Cookie에 담아 응답하고, 이후 요청부터는 브라우저가 ID/PW를 Cookie에 담아 함께 보낸다면 사용자는 인증/인가를 위해 매번 ID와 PW를 입력할 필요가 없어지겠죠.

 

1. 로그인 로직

@PostMapping("/login")
public String login(@ModelAttribute @Validated LoginForm loginForm,
                     BindingResult bindingResult,
                     @RequestParam(defaultValue = "/home") String redirectURL,
                     HttpServletResponse response) {
​
     // 입력된 loginForm(ID/PW)으로 회원 여부 검증 로직 (생략...)
        
        
     // 회원일 경우 ID/PW로 Cookie 생성
     Cookie cookie = new Cookie("IDPW", loginMember.getLoginId()+"."+loginMember.getPassword());
     // 경로 설정 ("/" 설정 시 브라우저는 같은 도메인의 모든 경로로 요청할 때 이 Cookie를 함께 보냄)
     // Default가 "/"
     cookie.setPath("/");
     // cookie 유효기간 1시간 설정 (기준은 Sec)
     cookie.setMaxAge(60*60);
     // response header에 cookie 설정
     response.addCookie(cookie);
​
        
     return "redirect:" + redirectURL;
}

Cookie는 기본적으로 key/value형태로 데이터가 저장되고, 경로와 유효기간과 같은 설정 정보들을 가지고 있습니다. 경로는 브라우저가 서버로 요청을 보낼 때 cookie를 포함할 경로를 뜻하며 "/"를 Default 값으로 가집니다. setMaxAge의 경우 Cookie의 유효기간을 뜻하는데요. Javax.servlet의 Cookie 기준으로 단위는 Sec이고, response Date에 설정한 시간이 추가되어 Cookie의 Expire / Max-Age 값으로 설정됩니다.

 

 

코드 상에서 설정하지는 않았지만 Cookie에는 HttpOnly라는 설정값이 있습니다. servlet Cookie의 경우 setHttpOnly(true) 메서드로 설정할 수 있는 속성인데요. HttpOnly는 JavaScript로 Cookie에 접근할 수 없도록 막아 XSS와 같은 공격으로부터 보호해줍니다. 때문에 Cookie를 생성할 때는 HttpOnly true를 Default로 두는 것이 좋죠.

 

2. 로그인 request 헤더

POST /login HTTP/1.1
Host: localhost:8080
Cache-Control: max-age=0
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36
Accept:text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate, br
Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7

 

3. 로그인 response 헤더

HTTP/1.1 302
Date: Sun, 11 Sep 2022 06:34:37 GMT
Set-Cookie: IDPW=test.test!; Max-Age=3600; Expires=Sun, 11-Sep-2022 07:34:37 GMT; Path=/
Location: http://localhost:8080/home
Content-Language: ko-KR
Content-Length: 0
Keep-Alive: timeout=60
Connection: keep-alive

response헤더 Date와 Set-Cookie의 Expires를 비교해보면 setMaxAge 메서드로 지정한 시간인 1시간 차이가 나는 걸 확인할 수 있다.

 

4. 브라우저 Cookie 저장소

브라우저에 저장된 Cookie를 보면 아래와 같은 설정 값들을 확인할 수 있습니다.

 

Key: IDPW

Value: test.test! (ID+"."+PW)

Domain: localhost (자동 설정)

Path: '/'

Expire / Max-Age: response header Date + 1시간

 

위 정보들을 고려했을 때 앞으로 1시간 동안 localhost/* 경로로 보내지는 request에는 header에 위 Cookie가 자동으로 설정되어 서버에 요청됩니다.

 

5. Cookie 설정 이후 request

GET /home HTTP/1.1
Host: localhost:8080
Cache-Control: max-age=0
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36
Accept:text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate, br
Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
Cookie: IDPW=test.test!

localhost/home은 localhost/* 경로에 포함되기에 Cookie가 header에 포함되어 요청이 가는 것을 확인할 수 있습니다.

 

 

정리해보자면 인증/인가에 Cookie만을 사용한다는 것은 첫 인증 때 기존 로그인 정보를 Cookie 형태로 브라우저에 심고 매 요청마다 Cookie를 통해 로그인 정보를 함께 서버로 보내는 것입니다. 즉, 매번 자동으로 로그인을 하는 것과 다름없죠.

 

 

Cookie의 장점은 기존 로그인을 위한 정보를 사용하기 때문에 인증/인가를 위한 추가적인 데이터 저장이 필요 없습니다. 또한 서버 대수를 늘리는 Scale out에도 크게 이슈가 없죠. 하지만 사용자의 주요 정보를 매번 요청에 담아야 하기에 보안상의 문제를 가지고 있습니다.

 

해결책 2: Session

매번 로그인 정보를 요청에 담아 보내는 것은 보안상 문제가 생길 수 있습니다. 이러한 문제점에서 벗어나고자 나온 것이 Session입니다. Session은 고객의 주요 정보가 아닌, 단지 고객을 식별할 수 있는 값 생성해 Cookie로 주고받습니다. 예를 들어 A 사용자가 ID/PW를 통해 로그인을 했다면 A사용자를 식별할 수 있는 값를 생성해 Cookie로 브라우저에 심고 매번 요청 때마다 생성한 값을 통해 인증/인가를 진행하는 것이죠.

 

 

이러한 방식은 고객의 로그인 정보를 주고받지 않아 이전 방식보다 보안상 이점이 있지만, 사용자를 식별할 수 있는 값을 생성하고 서버에 저장해두어야 하는 작업이 생기게 됩니다.

 

 

이때 생성되는 사용자 식별 값을 Session ID라 하고 어디에 저장하는가에 따라 2가지 방식이 존재합니다. 우선 첫 번째는 서버 내부에 Session 저장소가 있는 경우입니다. 예를 들어 Tomcat의 ManageBase 클래스를 보면 아래와 같은 세션 저장소를 가지고 있는 것을 확인할 수 있습니다.

 

// The set of currently active Sessions for this Manager, keyed by session identifier.
protected Map<String, Session> sessions = new ConcurrentHashMap<>();

사용자가 서버로 요청을 할 경우 Tomcat은 경우 HTTP request를 처리하는 과정에서 session ID 존재 여부를 확인하고 있을 경우 위 저장소에서 Session을 조회하여 사용할 수 있게 됩니다. Tomcat의 Session 관리에 대해 조금 더 자세히 알고 싶다면 Kaden님의 <서블릿의 세션 관리 (Servlet Session Management)> 글을 추천드립니다.

 

 

하지만 위 방식은 다중 서버일 경우에는 문제가 있습니다. 매번 같은 서버로 요청이 가는 것을 보장하지 않는 다중 서버 환경에서 각 서버가 개별적인 Session 저장소를 가질 경우 Session ID가 저장되지 않은 곳으로 요청이 가면 사용자를 식별할 수 없죠. 이러한 문제점을 보완하는 방법에는 2가지가 있습니다.

 

 

1. Load Balancer를 통해 사용자를 식별하여 이전에 통신한 서버로 요청이 가도록 경로를 지정해주는 방법과

2. 다중 서버의 모든 Session 저장소를 동일하게 유지하는 방법(Clustering/Session Replication How-To)이 있죠.

 

 

반면 두 번째 방식의 경우 단일 Session 저장소를 두고 서버에서 저장소에 접근해 Session ID를 삽입하고 조회하기 때문에 다중 서버 환경에서도 단일 저장소가 감당해야 할 부하에 대한 고민만 남게 됩니다.

 

 

Session을 통한 인증/인가는 사용자가 로그인을 할 때 기존 로그인 정보와는 별도로 사용자를 식별할 수 있는 Session ID를 생성하고 이를 Session ID 저장소(서버 내부 or 외부)와 브라우저에 저장한 후, Session ID를 바탕으로 사용자를 인증하고 인가를 진행합니다. Session은 로그인 정보를 직접 사용하지 않는다는 점에서 Cookie만을 사용한 인증/인가보다 안전합니다. 하지만 Session ID 또한 탈취의 가능성이 있고, 무엇보다 Client의 상태를 서버에서 관리한다는 점이 HTTP 통신의 stateless 특성과는 거리가 있어 보이죠.

 

해결책 3: Token

그렇다면 기존 로그인 정보를 그대로 사용하지 않으면서도 서버에서 사용자 식별 값을 저장/관리하지 않아도 되는 방법은 없을까요? Token은 사용자를 인증할 수 있는 정보가 숨겨진 암호화된 Access Token을 발행하고, 인증이 필요할 때마다 서버에 Token과 함께 요청을 보내게 됩니다. 서버는 저장된 데이터가 아닌 Token 해독을 통해 Token 속에서 사용자를 식별할 수 있는 정보들을 알아내고 이를 바탕으로 인증/인가가 진행되죠.

 

 

Token에도 다양한 유형과 종류가 존재하지만 이번 글에서는 개발자들에게 가장 친숙한 JWT(JSON Web Token)에 대해 다뤄보려고 합니다. JWT는 웹 서비스에서 자주 사용되는 Token으로 아래와 같은 요소들로 구성되어 있습니다.

  • Header: Token 유형, 서명 알고리즘(HS256 or RSA 등)이 담긴다.
  • Payload: Claim이 포함되는 영역으로 전송하고자 하는 여러 데이터가 담긴다.(Claim 유형: Registered / Public / Private)
  • Signature: Base64로 인코딩 된 Header, Payload와 서버만이 가지고 있는 비밀 키를 설정한 알고리즘으로 암호화한 값이 담긴다.
// Header, Payload, Signature는 "."로 구분된다.
// 예시
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9  // Header 영역
.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWQiOiJlZ2c1MjgifQ // Payload 영역
.dxx7fHi3twlN_GnfG3kZHxkLElTi9y99n5DEby-a_TE  // Signature 영역

Header, Payload, Signature로 구성된 정보들은 "."을 구분자로 위 예시와 같이 읽기 어려운 JWT로 생성됩니다.

 

JWT 생성 방식

출처: jwt.io

JWT 공식 홈페이지 Debugger를 확인해보며 JWT가 만들어지는 방식을 확인해보겠습니다. 우선 Header 데이터를 Base64로 인코딩한 결과가 첫 번째 영역(Encoded의 빨간 영역)입니다. 마찬가지로 Payload 데이터를 Base64로 인코딩하여 두 번째 영역(Encoded 보라색 영역)이 채워지죠. 마지막으로 파란색 Signature 영역은 Base64로 인코딩된 Header, Payload 값을 설정한 알고리즘으로 암호화한 값으로 채워집니다. (HS256 방식은 서버만 알고 있는 SecretKey를 활용, RSA 방식은 서버만 가지고 있는 개인키/공개키를 활용)

 

jjwt를 사용한 JWT 생성 코드

dependencies {
    implementation 'io.jsonwebtoken:jjwt:0.9.1'
}

우선 jjwt의존성을 추가해줍니다. (gradle 기준)

 

String jwt = Jwts.builder()
                 .setHeaderParam("alg", "HS256") 
                 .setHeaderParam("typ", "JWT")
                 .setSubject("1234567890")
                 .claim("id", "egg528")
                 .signWith(SignatureAlgorithm.HS256, "SecretKey")
                 .compact();
​
/*
생성된 jwt 문자열은 아래와 같다. (사진 속 jwt와도 일치)
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWQiOiJlZ2c1MjgifQ
.dxx7fHi3twlN_GnfG3kZHxkLElTi9y99n5DEby-a_TE
*/

이후 Jwts 클래스의 static 메서드를 활용해 JWT를 간단하게 구현할 수 있습니다. 보다 자세한 builder 사용법은 아래 사이트에 잘 정리되어 있습니다.

 

JWT 검증 방식

JWT가 어떻게 생성되는지 알아봤다면, 요청과 함께 JWT가 서버에 도착했을 때는 어떤 과정이 필요할까요? 우선 서버는 요청과 함께 들어온 JWT가 자신(서버)이 발행한 것이 맞는지 확인합니다. 이 과정은 암호화 방식에 따라 조금 차이가 있는데요 HS256의 경우 Header와 Payload 그리고 서버가 가지고 있는 SecretKey로 암호화를 진행해 Signature 영역과 동일한지를 확인합니다. 반면 RSA의 경우 공개키로 Signature를 복호화해 JWT Header, Payload와 비교하게 되죠.

 

// HS256 방식
Jwts.parserBuilder()
  .setSigningKey(secretKey) 
  .build()
  .parseClaimsJws(요청과 함께 온 JWT);
​
// RSA 방식
Jwts.parserBuilder()
  .setSigningKey(publicKey) // <---- publicKey, not privateKey
  .build()
  .parseClaimsJws(요청과 함께 온 JWT);

jjwt를 사용하면 위 코드와 같이 간단하게 JWT를 검증할 수 있습니다. 위 코드로는 해당 서버에서 만들어진 JWT인지만 검증했지만 추가적으로 Claim 정보들을 통해 만료 기한이 지난 JWT는 아닌지 혹은 JWT가 적절한 IP에서 요청된 것인지 등을 확인할 수 있죠.

 

JWT 저장 장소

Cookie와 Session의 경우 Set-Cookie를 통해 고민 없이 브라우저 Cookie 저장소에 저장했는데요. JWT의 경우 저장 장소에 대해서도 고민하는 글을 많이 접할 수 있었습니다. JWT의 저장 장소로는 Local Storage와 Cookie가 선택지인데요. 두 선택지 모두 장단점이 있고 정답이 있는 문제가 아니기에 JWT를 사용하기 전에 한 번쯤 고민해보면 좋을 것 같습니다.

 

인증/인가를 어디에 구현할 것인가?

지금까지 인증/인가 구현 방식으로 Cookie, Session, Token에 대해 알아봤습니다. 구현 방식 중 Cookie를 설명하는 부분의 코드를 보면 Cookie를 생성하는 부분은 Controller 메서드 내에 존재합니다.

 

 

예시에서는 나오지 않았지만 Cookie / Session / Token을 검증하는 로직 또한 Controller 메서드 내부에 존재할 수 있죠. 하지만 인증/인가 로직을 Controller 내부에 구현할 경우 대부분의 Controller 메서드에 중복된 코드가 존재하게 되고,, 수정이 필요하다면,, 번거로운 반복 작업으로 이어질 수 있는데요.

 

 

그렇다면 인증/인가 로직은 어디에 구현하는 것이 적절할까요?

 

Interceptor / Filter(Spring Security)

Interceptor와 Filter는 모두 Controllor에 request가 도착하기 이전에 로직을 수행하거나 사용자에게 response가 전달되기 이전에 로직을 처리할 수 있도록 돕는 비슷한 역할을 합니다. 거의 비슷한 용도로 사용되는 둘에 미세한 차이가 있다면 Interceptor는 Spring Context에 존재하고 Filter는 Web Context에 존재해 실행되는 시점이 다르다는 점입니다.

 

 

조금 더 자세하게는 Interceptor의 경우 Controller 처리 이전(preHandle), Controller 처리 이후(postHandle), View 렌더링 이후(afterCompletion)에 로직이 수행되는 반면 Filter의 경우 Spring 영역의 Dispatcher Servlet에 요청에 도착하기 이전과 response가 Dispatcher Servlet을 떠난 이후에 실행되게 되죠.

 

 

이러한 차이점 때문에 둘을 사용하는 용도에도 조금 차이가 있는데요. Interceptor의 경우 특정 요청에만 전/후 로직이 필요할 경우 혹은 입/출력 데이터를 가공할 때 사용하고, 모든 요청에 로직이 필요한 경우 혹은 request/response header와 같은 매개변수를 수정해야 할 때 Filter를 주로 사용하죠.

 

 

만약 Interceptor와 Filter를 활용해 인증/인가 로직을 구현한다면 Controller 내에 로직이 존재할 때 생겼던 문제점들(코드 중복, 유지보수 어려움)은 사라지게 됩니다.

 

API Gateway

출처: Microsoft.com

API Gateway란 MSA 환경에서 Client가 각 Microservice를 직접 호출하지 않고 하나의 End-Point를 제공하여 Microservice의 변경이 Client에 영향이 없도록 돕는 장치입니다. 위 그림처럼 Client는 내부에서 어떤 요청들이 일어나는지는 알 수 없고 API Gateway에만 요청을 하게 되죠.

 

 

그림으로도 알 수 있듯이 Client의 모든 요청은 API Gateway를 거치게 되고, 이를 거쳐야만 요청은 Microservice에 도달할 수 있게 됩니다. 때문에 API Gateway는 자연스럽게 공통된 로직을 처리할 수 있게 되는데 그중 하나가 "인증/인가" 로직이죠.

 

 

API Gateway에서 인증/인가가 수행될 때의 장점은 "인증/인가 로직"과 "Microservice 로직"간의 의존성이 줄어든다는 점입니다. API Gateway에서 인증/인가를 수행하고 정상적으로 검증을 마쳤다면 사용자 정보를 매개변수에 추가해 Microservice로 전달하기 때문에 Microservice에서는 "인증/인가"를 고민할 필요가 사라지는 것이죠. 하지만 Microservice에서 거둬낸 인증/인가 로직이 API Gateway에 포함되는 것이니 API Gateway가 무거워진다는 점도 간과할 수는 없을 것 같습니다.

 

 

마무리

지금까지 인증/인가를 구현하는 여러 방식과, 로직이 존재할 수 있는 위치에 대해 알아봤는데요. 인증/인가에 대해 알아보면서 구현 방식은 JWT가 stateless 한 구조를 유지하면서도 다른 인증 방식에 비해 보안성이 떨어지지 않는 것 같다는 점에서 현재로서는 최선의 선택이 아닌가 싶었고, 로직 위치의 경우 서비스의 규모와 아키텍처를 고려해서 결정할 문제인 것 같다는 나름의 결론을 얻을 수 있었습니다.

 

긴 글 읽어주셔서 감사합니다!

 

 

Reference

댓글