My-Books 프로젝트에서 초기 JWT 방식의 보안 취약점을 개선하기 위해 리프래시 토큰을 Redis에 저장하고 IP와 User-Agent 정보를 활용해 보안성을 강화했습니다.
토큰에는 UUID를 사용하고 Redis에 사용자 정보를 매핑해 추가적인 보안을 구현했습니다.
이를 통해 확장성과 보안성을 모두 충족하는 인증/인가 시스템을 설계했습니다.
My-Books 프로젝트에서의 경험을 다룬 글입니다.
My-Books 프로젝트에서 저의 주된 역할은 인증/인가 프로세스를 설계하고 구현하는 것이었습니다. 이번 글에는 JWT 안정성을 개선한 과정에 대해서 설명하려 합니다.
JWT의 안전성
이전 포스팅 'JWT는 왜 사용할까?'의 마지막 부분에서 다음과 같은 언급을 한 적이 있습니다.
초기의 방법은 엑세스 토큰만 운용하는 방식이었습니다. 엑세스 토큰을 운용하여 토큰의 유효기간에 가까워 진다면(5~10분 이내) 엑세스 토큰을 refresh하는 요청을 주어 갱신해주는 것이죠
하지만 클라이언트가 쿠키로 내려진 토큰을 탈취당하는 경우에 이 방법은 보안이 매우 취약해집니다. (공격자가 계속해서 토큰을 갱신하며 사용가능하기 때문에)
또한 매 요청마다 유효기간을 확인해 줘야 하기 때문에 불필요한 추가 연산이 필요해집니다. 이러한 방법을 해결하고자 다음과 같은 과정을 거치게됩니다.
JWT 안전성 개선 - 쿠키
처음에는 쿠키의 탈취를 어렵게 만드는 방법을 우선적으로 고려했습니다. 쿠키는 다양한 보안 옵션을 설정할 수 있으며, 제가 사용한 옵션은 다음과 같습니다.
Secure - HTTPS에서만 쿠키전송
HttpOnly - JavaScript로 쿠키 접근 불가 , XSS 방어
SameSite - CSRF 방어를 위한 정책
이를 통해 클라이언트-서버 간 통신에서 쿠키 탈취를 어렵게 만들었습니다. 그러나 쿠키가 탈취된 이후에는 여전히 대응할 방법이 부족하다는 문제가 남았습니다.
JWT 안전성 개선 - 리프래시 토큰
여러 문서를 찾아본 결과 가장 많이 사용되는 방법이 액세스 토큰과 리프래시 토큰을 사용하는 것이었습니다.
짧은 유효기간을 가진 액세스 토큰과 긴 유효기간을 가진 리프래시 토큰을 두어 액세스 토큰 탈취 시에도 짧은 유효기간으로 인해 공격을 막을 수 있다는 것입니다.
또한 크게 2가지 운용방식이 존재했습니다.
액세스 토큰과 리프래시 토큰을 모두 쿠키로 내려주기
액세스 토큰은 쿠키로 리프래시 토큰은 Redis와 같은 별도의 서버 자원에 저장
액세스 토큰과 리프래시 토큰을 모두 쿠키로 내려주기의 경우에 서버의 자원을 사용하지 않기 때문에 JWT의 Stateless 한 특성을 잘 살릴 수 있지만 액세스 토큰(쿠키)가 탈취당한 경우를 가정한다면 똑같이 쿠키로 저장돼있는 리프래시 토큰은 멀쩡할까?라는 의구심이 들었습니다.
액세스 토큰은 쿠키로 리프래시 토큰은 Redis와 같은 별도의 서버에 저장의 경우에 탈취가 어려워진다는 장점이 있지만 서버에 데이터가 저장됨으로 Stateful 해진 다는 단점이 존재합니다.
저의 경우 액세스 토큰은 쿠키로 리프래시 토큰은 Redis와 같은 별도의 서버에 저장방법을 선택했는데요
부분적으로 Stateful 해지는 것은 맞으나 Stateless의 이점을 전부 사용 가능함
Redis에 리프래시 토큰을 저장하고 부르는 로직은 자주 변경 및 호출되지 않는다
JWT 일반 문자열이기 때문에 매우 많은 사용자를 커버할 수 있다
MSA 환경이나 Scale-Out으로 구성된 서버에서 확장성을 보장하는 것이 JWT를 사용하는 가장 큰 이유라 생각합니다.
리프래시 토큰을 각각의 서버에 저장시킨다면 이는 확장성에 제약이 생길 수 있지만 목적이 아예 분리된 Redis 서버 사용 시 Front와 Back 서버에 확장에 영향을 주지 않을 것이라 생각했습니다.
또한 리프래시 토큰을 저장하고 부르는 로직은 최초 로그인 요청 혹은 액세스 토큰은 유효하지만 유효기간이 만료돼 갱신하는 경우만 존재합니다. 이는 자주 변경되거나 호출될 일이 적음을 의미하고 별도의 서버를 둠으로써 버그 발생의 위험이나 복잡도가 크게 증가한다고 생각하지 않았습니다.
마지막으로 JWT는 결국 문자열 덩어리기 때문에 큰 자원 소모 없이 많은 사용자를 커버할 수 있다고 생각했습니다.
그렇게 해서 개선된 코드는 다음과 같습니다.
Redis에 저장된 리프래시 토큰을 액세스 토큰만으로 접근이 가능하다면 문제가 생길 수 있기 때문에 IP주소를 추가로 조합해 주었습니다. 또한 액세스 토큰과 리프래시 토큰을 일회용으로 사용하게끔 만들어 보안성을 높이려 했습니다.
RemoteAddr 이슈
그런데.. 이상한 일이 발생했습니다. 왜.. Redis에 저장되는 IP 주솟값이 다 똑같지?, 심지어 내 IP 주소도 아니잖아?
로그를 남겨 IP와 관련된 모든 값을 확인해 봤습니다. 그럼에도 문제점을 파악하지 못했기 때문에 Front 서버에서 직접 헤더에 정보를 넣어 보내줬습니다.
여기서 알게된 것은 사용자의 요청이 Front 서버를 거쳐 Auth 서버로 넘어오는 것이기 때문에 실제로 확인되는 IP 정보는 Front 서버의 IP라는 것입니다.
따라서 Front 서버에서 직접 IP 주소와 User-Agent 정보를 DTO 형태로 넘겨주게 변경했습니다. (IP 주소뿐 아니라 User-Agent 정보를 이용해 보안성을 더욱 높였습니다.)
JWT 안전성 개선 - PayLoad
그런데 코드 리뷰 중 다음의 피드백을 받게 됩니다.
지금 user_id를 그대로 토큰에 기입하고 있는데 이거 그냥 보여줘도 돼?
처음에는 사용자에게 쿠키를 내려주는 것이기 때문에 디코딩 해서 내용을 까 볼 사람이 있을까? 정도로 생각했었지만 더 깊게 생각해 보니 이 정보를 기반으로 사용자의 정보나 API의 형태를 유추 가능해짐을 알았습니다.
또한 user_id 정보를 굳이 알려줄 이유도 없기 때문에 토큰에는 UUID를 기입하고 Redis에 user_id정보를 넣어줬습니다.
Key : UUID+IP+User-Agent , Value : user_id
최종적으로 구성된 코드입니다.
UUID를 기반으로 새로운 키를 생성해 Redis에 user_id를 기입하는 코드가 추가됐습니다.
또한 인증/인가 프로세스의 추가적인 개선 가능성을 모색했습니다. 정보를 추가적으로 저장하는 것에 비해 얻는 보안상에 이점을 더 갖고싶었기 때문입니다.
그 과정에서 기존 로그아웃 프로세스를 점검하던 중 로그아웃 이후에도 기존 액세스 토큰이 유효해 재사용이 가능하다는 문제또한 발견할 수 있었습니다.
JWT 안전성 개선 - UUID를 활용한 추가 보안 로직
기존의 GatewayFilter입니다.
토큰의 유효성과 사용자의 권한 및 상태를 확인하는 절차를 가집니다.
로그아웃시 사용자의 쿠키에 있는 정보는 지워지지만 그 정보를 기억하고 있다면 토큰 자체는 유효하기 때문에 Filter를 통과해 요청을 보낼 수 있게됩니다.
기존에는 이를 처리할 방법이 없었지만 추가된 UUID를 통해서 이제는 해결이 가능합니다.
사용자가 로그아웃하면, Redis에서 해당 UUID에 매핑된 정보를 즉시 삭제
Gateway에서 Redis 상태를 조회하도록 로직 추가
이로 인해 이후의 액세스 토큰 검증 과정에서 UUID를 찾을 수 없게 되어 토큰을 Invalid로 판단
Redis와 UUID를 활용한 상태 검증으로 로그아웃 이후 액세스 토큰 재사용을 완벽히 차단
인증서버에 로그아웃시 UUID정보를 삭제하는 로직 추가 후
Gateway에도 UUID를 기반으로 유저를 찾는 로직을 추가해줬습니다.
그 후 Filter의 리펙토링 과정을 거치게되면...
redisService의 isValidateUser를 이용해 액세스 토큰 재사용을 막은 후 토큰과 유저의 권한 및 상태를 확인하게 됩니다.
이 Filter를 기반으로 최종적으로는 다음과 같은 프로세스가 완성됩니다.
이렇게 단계별로 JWT의 안전성을 개선한 여정은 마무리됩니다. 같이 보시면 좋은 발표 영상입니다.
My-Books 프로젝트에서 Spring Security를 검토했지만, MSA 환경에서의 인증정보 불일치와 자원 소모 문제로 사용하지 않았습니다.
대신, 간단한 인증/인가 요구사항을 충족하기 위해 Spring AOP를 활용해 효율적인 인가 처리를 설계했습니다.
이를 통해 사용자 경험을 개선하고 시스템 자원 소비를 최소화했습니다.
My-Books 프로젝트에서의 경험을 다룬 글입니다.
My-Books 프로젝트에서 저의 주된 역할은 인증/인가 프로세스를 설계하고 구현하는 것이었습니다. 처음에는 당연히 Spring Security를 기반으로 구현하려 했지만 결과적으로는 선택하지 않았는데요 그 과정을 얘기하려 합니다.
Spring Security란?
Spring Security는 Spring Framework 기반의 애플리케이션에서 인증(Authentication)과 인가(Authorization)를 손쉽게 구현할 수 있도록 제공되는 강력한 보안 프레임워크입니다.
사용자가 애플리케이션에 로그인하면, Authentication 객체가 생성되며 Authentication 객체는 Spring Security의 SecurityContext에 저장되어 애플리케이션 전반에서 사용자의 인증 상태를 관리합니다.
또한 Filter Chain을 통해 모든 HTTP 요청을 가로채고, 보안 검사를 수행합니다.
사용하지 않은 이유
Spring Framework에 완벽하게 호환되면서 쉽게 구현이 가능하다니.. 사용하지 않을 이유가 없어 보입니다. 하지만 다음과 같은 이유로 사용하지 않았습니다.
기존의 Spring Security를 활용한 접근 방식에서는 다음과 같은 문제가 있었습니다.
Security Context의 오버헤드
Security Context는 기본적으로 세션에 저장하여 상태를 유지하기 때문에 MSA로 구성된 My-Books 프로젝트에서는 인증정보 불일치 문제가 발생 가능합니다.
이 문제는 SessionCreationPolicy를 Stateless하게 설정하면 해결이 가능하지만 세션을 사용하지 않을 뿐 매 요청마다 SecurityContext를 생성하기 때문에 추가적인 자원 소비가 이뤄지게 됩니다.
이는 서버에 불필요한 오버헤드를 발생시켰고, MSA 환경에서 효율적이지 않다고 판단했습니다.
과정의 복잡성
Spring Security는 내부적으로 DelegatingFilterProxy가 요청을 먼저 받고, 이후 여러 Security 필터를 거치는 구조로 동작합니다. 이 과정이 간단한 인가 로직을 구현하는 데 불필요하게 복잡하다고 생각했습니다.
테스트 코드의 생산성 저하
Security가 활성화된 상태에서는 테스트 코드 작성 시 Security 옵션을 비활성화하거나, Security가 제공하는 User 객체를 주입해야 했습니다. 이는 유닛 테스트 시 불필요한 설정을 요구해 생산성을 떨어뜨렸습니다.
해결 방법
위 문제를 해결하기 위해 다음과 같이 인증/인가 프로세스를 다음과 같이 설계했습니다. 1. JWT 기반 인증: • 로그인 시, 액세스 토큰을 쿠키에 저장하고 리프레시 토큰을 Redis에 저장. • 모든 요청 시 클라이언트는 액세스 토큰을 헤더에 담아 전송. 2. Spring Gateway에서 인가 처리 • Gateway의 AbstractGatewayFilterFactory를 상속받아 커스텀 필터를 구현 • 이 필터는 헤더에서 JWT를 추출하고, 유효성을 검증한 후 권한을 확인
하지만 이 과정에서 예외사항에 대한 처리의 필요성이 생겼고(Invalid 토큰,유효기간 만료,권한 불일치..) 이를 해결하기 위해 횡단 관심사를 처리할 수단을 찾게됐습니다.
Filter vs Interceptor vs AOP
Spring Security의 대안으로 생각한 것은 Filter, Interceptor, AOP였습니다. 위에서 언급한 것들은 모두 횡단 관심사를 처리할 수 있는 수단으로, 인가 처리에 사용 가능합니다.
이들은 비슷해 보이지만 차이점이 존재했는데요. Filter는 Spring 스펙이 아닌 Servlet의 스펙으로, DispatcherServlet 이전에 실행되어 모든 HTTP 요청을 대상으로 작동합니다. 그렇기 때문에 Filter는 Spring 컨테이너와 무관하게 작동하므로, Spring Bean이나 컨트롤러 또는 서비스 계층의 로직과 연동된 처리는 어렵습니다.
Interceptor는 Spring MVC에서 제공하는 기능으로, 컨트롤러 실행 전후에 추가 로직을 삽입하는 데 사용됩니다. Spring MVC 컨트롤러 수준에서 동작하기 때문에 Filter보다 Spring Application과 밀접하게 협력한다는 장점은 있지만, 컨트롤러보다 추상화 정도가 낮은(실제 비즈니스 로직이 존재하는) 서비스 계층에서의 상세한 처리는 불가능합니다.
또한, Filter와 Interceptor 모두 요청 전과 후에 대한 처리를 지원하지만, 이를 하나의 메서드에서 전후를 동시에 처리하는 방법은 없기 때문에 AOP를 선택하게 됐습니다.
내가 사용한 Spring AOP
현재 My-Books는 Front Server에서 사용자 요청 시 Gateway Server로 전달되어 인가 처리를 하는 구조를 가집니다.
또한 인가 처리 실패 시 에러가 발생하고 Front Server는 그에 따른 알맞은 응답을 반환해 사용자의 사용성을 높이는 목표가 존재했습니다.따라서 호출 후 에러발생에 따른 추가적인 로직 실행이 가능해야 됐으므로 Spring AOP의 @Around를 사용하기로 결정했습니다.
인가 처리가 실패하는 경우에 따라서 액세스 토큰을 재발급하거나 특정 페이지로 보내는 등의 작업을 상세 설계하여 사용자의 사용성을 높일 수 있었습니다.
My-Books 프로젝트에서 세션 불일치 문제를 해결하기 위해 JWT 인증을 도입했습니다.
JWT는 서버 자원 없이 확장성이 높고 MSA에 적합합니다.
사용 시 보안에 유의해야 합니다.
My-Books 프로젝트에서의 경험을 다룬 글입니다
기존에 진행했던 토이 프로젝트에서는 Cookie-Session 기반으로 인증/인가를 구현했기에, My-Books에서도 동일한 방식을 적용하려 했지만 MSA 환경이라는 특수성 때문에 다음과 같은 문제에 직면했습니다.
세션 불일치 문제
Cookie-Session 방식은 세션에 유저 정보를 담아두고 쿠키에 세션 id를 기입하는 방식입니다. 쿠키의 경우 사용자가 HTTP 요청 시 자동으로 헤더에 담겨 서버로 전송되기 때문에 간단하고 직관적으로 구현이 가능하며 세션 데이터는 서버에 저장되므로 보안성도 챙긴 방식입니다.
여기서 중요한 건 세션 데이터는 서버에 저장된다는 것입니다. 세션 자체가 서버 자원의 일부이기 때문에 서버에 종속적임을 의미하고 이는 MSA 환경 혹은 Sacle-Out으로 서버가 구성돼있는 경우에 서버 간 세션 불일치 문제가 발생합니다.
사용자가 A 서버에서 로그인을 한 후 B 서버로 라우팅 된다면 세션에 유저 정보가 존재하지 않기 때문에 다시 로그인을 하라는 요청을 보낼 것입니다.
이는 사용자의 경험을 저하시키고 서버 측에서는 불필요한 데이터를 중복으로 갖게 되는 문제를 발생시킵니다. 또한 사용자의 트래픽이 몰려 요청을 분산시켜야 하는 경우에 확장성에 문제또한 발생합니다.
세션 불일치
이러한 문제는 세션을 모아주는 Session Clustering, 혹은 동일한 서버로만 요청을 주고받는 Sticky Session으로 해결이 가능하지만 추가적인 작업이 필요해 복잡도가 올라가기 때문에 또 다른 제약이 생기게 됩니다.
Session ClusteringSticky Session
해결법
따라서 저는 JWT를 기반으로 한 인증/인가 방식을 도입했습니다. JWT는 Json Web Token의 약자로 Json 포맷의 데이터를 인코딩하여 사용하는 토큰입니다.
JWT 구조
방식은 Cookie-Session과 동일합니다.
사용자가 로그인 시 JWT 토큰을 발급
쿠키로 JWT 내려줌
사용자의 요청에 JWT 정보를 헤더에 담아 보냄
여기서 차이점은 JWT는 그 자체로 사용자의 상태와 권한 정보를 포함하고 있다는 것입니다.
JWT를 디코딩시 유저정보가 나오는 모습
서버의 자원인 세션을 사용할 필요가 없으며 각각의 서버에 JWT를 검증하고 읽어내는 코드만 추가되면 됩니다.
오픈소스인 auth0 사용 간단한 구현
서버에 종속되지 않기 때문에 자유롭게 Scale-Out 하거나 Load Balancing을 통해 요청을 분산시켜도 문제가 발생하지 않습니다. 또한 앞서 설명한 Session Clustering과 Sticky Session보다 훨씬 낮은 복잡도를 가지며 직관적입니다.
만약 모든 요청이 몰리는 Gateway 서버를 둔다면 JWT 관련 코드를 백서버와 아예 분리시켜 관리가 가능하며 My-Books에서도 동일한 방법을 사용 중입니다.
My-Books프로젝트 아키텍처
이외에도 서버의 자원인 세션을 사용하지 않기 때문에 서버의 자원을 아낄 수 있다는 이점도 존재합니다.
다만 주의할 것은 JWT는 그 자체로 암호화된 무언가가 아니라 그냥 인코딩한 문자열이라는 것입니다. 따라서 사용 시 고려해야 할 사항이 많으며 이와 관련해 추가적인 링크를 남깁니다.
안녕하세요 오랜만에 글을 쓰네요 12월 11일 정보처리기사를 취득했습니다. 개발자에게 자격증이 필요한가?에 대해서는 그럴 수도 있고 아닐 수도 있다고 생각합니다.
서비스 기업에 지원하는 경우 자격증이 채용에 있어 유의미한 영향을 줄 것 같진 않습니다만 SI/SM 혹은 전통적인 기업들(제조, 금융..)은 기사 자격증에 대한 채용 우대가 존재하기 때문에 지원하고자 하는 기업의 성향에 따라서는 필수적으로도 따야 할 수도 있습니다.
하지만 요즘 채용시장을 보면 빅 테크는 신입 채용문을 아예 닫아버렸고 스타트업도 혹한기를 겪고 있기 때문에 SI/SM이 대부분의 공고를 차지하고 있습니다.
따라서.. 개인적으로 기사 자격증은 필요하다고 생각합니다.
공부시간/느낀점
이번 시험을 보면서 느낀 것은 전공/비전공의 차이가 극심하게 갈릴 것 같다였습니다.
저의 경우 필기 3일 실기 2일의 공부시간을 가졌는데요 실기의 경우에 코딩 문제가 절반 이상이 출제됐기 때문에 기출만 풀어보고 이론 암기에 집중했습니다. 그래서 필기를 준비한 시간이 실기 준비 시간보다 더 깁니다.
정처기 시험 자체가 이론은 쉽게, 코딩은 어렵게 출제해 변별력을 갖추려 하기 때문에 비전공자에게는 정말 어려운 시험이 될 것 같습니다. 만약 1달 이상의 시간 투자가 필요해 보인다면 개인적으로는 다른 것에 투자하는 게 더 좋아 보입니다. 필요한 건 맞아도 그 정도의 가치는 없다고 생각해요
전공자분의 경우에는 안 딸 이유가 없다고 생각합니다. 어차피 공부에 많은 시간이 필요하진 않을 테니 "개발자는 자격증 필요 없어~"이러시지 말고 꼭! 필수로 따시길 바랍니다. 나중에 원서 쓸 때 우대사항에 기사 자격증 있는 거 보면 괜히 기분 나빠요..