
사내에서 개발한 통합 회원관리 시스템 IAM(Identity and Access Management) 구현 기록.
참고: 분류를 Spring Boot로 해두었지만 코드는 없고 정책 관련 내용만 있습니다.
JWT(JSON Web Token)를 통한 토큰 방식 사용 이유
- 구현하려는 것은 SSO(Single Sign On)였음.
- 그래서 세션 방식을 사용한다면 각 서비스들에서 로그인 세션을 검증하기 위해 매번 중앙 서버에 확인을 해야 함. 결국 중앙 서버에 부하가 가해짐.
- 그래서 서비스 개수가 늘어나더라도 각 서비스에서 관리하도록 하는 stateless한 토큰 방식이 좀더 적절.
라이브러리는 jjwt를 사용하였다.
발급하는 토큰 종류
보편적인 방식과 같이 액세스 토큰(Access Token)과 리프레시 토큰(Refresh Token) 두 가지가 존재.
- 액세스 토큰
- stateless한 상태로 보관되며, 따라서 브라우저를 새로고침하면 사라지므로 재발급받아야 함.
- 각 서비스들의 API 요청시 Authorization 헤더에 액세스 토큰을 담아서 보내고, 각 서비스들의 백엔드에서 토큰을 검증함. 이를 파싱해서 유저의 식별 정보(시퀀스)와 이메일, 이름을 알 수 있음. (탈취당할 위험이 있으니 핸드폰 번호처럼 민감한 정보는 넣으면 안됨)
- 각 서비스들의 프론트 전역 저장소에 저장.
- TTL은 30분으로 정했으나 후술할 이유 때문에 10분으로 줄임. (강제 로그아웃 API 참고)
- 리프레시 토큰
- 브라우저 쿠키에 보관함. 쿠키의 만료 기간은 TTL과 같은 기간으로 함.
- 리프레시 토큰을 통해 액세스 토큰을 발급받으므로, 만료되면 무조건 새로 로그인해야 함.
- 액세스 토큰과는 달리 리프레시 토큰은 로그아웃을 구현하기 위해 따로 저장해두어야 하는데, Redis에 저장하기도 하고 RDBMS에 저장하기도 함.
- 나는 토큰 교체 로직과 최대 로그인 가능 기기 3개 제한 요구사항 때문에 조건 기반 조회가 간편한 RDBMS에 저장하기로 함.
- 최대 로그인 가능 기기가 3대이므로 한 사용자가 최대 3개의 리프레시 토큰을 가질 수 있고, 3대를 초과하면 가장 오래된 기기의 리프레시 토큰을 만료시키는 식으로 구현함.
- 만료시킬 때는 delete를 해도 되고 플래그를 둬도 되며, 후자의 경우 데이터가 계속 쌓이므로 스케줄러나 크론을 돌려서 만료된 토큰 로우들을 지우면 됨.
- TTL은 2주로 함.
인증/인가 API
- 로그인 API
- 아이디와 비밀번호가 맞으면 액세스 토큰과 리프레시 토큰을 발급함.
- 액세스 토큰에는 아이디, 사용자 이름, 권한에 대한 정보가 들어 있음.
- 리프레시 토큰은 DB 로우와의 매칭을 위해 시퀀스가 들어 있음.
- 재발급 API
- 새로고침 시 리프레시 토큰을 통해 액세스 토큰을 재발급받는 API.
- UX를 위해 재발급 시 리프레시 토큰도 rotation(교체)시킴. 교체 로직이 없다면 리프레시 토큰은 2주가 지나면 무조건 만료되기 때문.
- 로그아웃 API
- 리프레시 토큰을 만료시키는 API.
- 액세스 토큰은 프론트에서 삭제시켜야 함.
- 강제 로그아웃 API
- 관리자가 사용자의 세션을 강제 종료시키기 위해 만든 API.
- 요청 시 같이 넘어온 관리자의 액세스 토큰을 파싱해서 관리자용으로 발급된 토큰이 맞는지 검증함.
- 특정 사용자의 모든 기기의 리프레시 토큰을 삭제함.
- 토큰 방식의 단점 중 하나로, 리프레시 토큰을 삭제해도 액세스 토큰이 브라우저에 남아있음. 즉 액세스 토큰의 TTL이 만료되지 않았다면 곧바로 로그아웃 처리는 안 됨.
- 이를 보완하기 위해 블랙리스트를 사용하는데, 만료된 액세스 토큰을 블랙리스트에 넣어두고 만료되었는지 확인함.
- 블랙리스트 방법을 쓰려고 했으나 롤백한 이유
- 매 API 요청마다 블랙리스트에 포함된 토큰인지 검사해야 하며, 그 로직은 SSO 아래에 있는 모든 서비스들에 들어가야 함.
- 위의 로직은 만들고자 하는 서비스의 보안 요구사항을 생각했을 때 오버엔지니어링이라고 판단됨.
- 블랙리스트 방법을 쓰더라도 stateless하게 저장되는 액세스 토큰 성격상 목록으로 관리되는 게 아니므로 특정 사용자에 대한 모든 기기의 액세스 토큰을 삭제하는 것은 어차피 불가능함
- 블랙리스트 방식을 롤백하면서 즉각적인 액세스 토큰 무효화는 불가능해졌지만, 대신 액세스 토큰의 TTL을 30분에서 10분으로 줄여서, 리프레시 토큰이 없다면 보다 빠른 시간 안에 만료되도록 설정함.
회원 관리 API
- 회원가입 API
- API는 총 5개 사용
- 이메일(아이디) 중복 검사 API
- 핸드폰 번호 중복 검사 API
- 이메일 인증번호 발송 API
- 이메일 인증번호 검사 API
- 최종 회원가입 API (이때 중복검사 다시 진행하며, 이메일 인증됐는지 검사)
- 기존 서비스는 메일에 있는 버튼을 누르면 인증되는 링크 방식을 사용했으나, 인증번호 방식으로 변경함.
- 변경한 이유는 UX 때문으로, 링크 방식은 회원가입 화면에서 인증되었다는 상태를 바로 확인하기가 어려워서임.
- 회원가입 폼에 있는 메일 인증 버튼을 누르면 사용자의 이메일로 랜덤한 n자리 숫자가 전송됨. (TTL 5분)
- 이메일 인증번호 검사 버튼을 누르면 숫자를 검증, 인증되면 숫자는 Redis에서 지워버리고 인증됐다는 플래그를 Redis에 추가함 (TTL 30분)
- 보안과 UX를 모두 잡기 위해 위와 같은 방법을 씀. 최종 회원가입 API에서 인증번호 검사를 한다면 이메일 인증을 끝내고 남은 가입 정보를 입력하는 데 5분 이상이 걸릴 경우 회원가입에 실패하기 때문.
- 최종 회원가입 API에서는 다시 요소들의 중복과 유효성 검사를 수행하며, 메일 인증 플래그가 있는지 검사하고 인증 플래그가 없다면 이메일 인증이 완료되지 않았다는 내용의 에러를 리턴.
- API는 총 5개 사용
- 비밀번호 재설정 API
- 기존 방식은 메일로 임시 비밀번호를 보내는 것이었으나, 이메일로 비밀번호를 보내는 것은 결국 사용자의 비밀번호가 외부로 유출된다는 것이라 보안 문제가 있고, 사용자가 비밀번호를 수동으로 바꿔줘야 해서 UX도 불편하기 때문에 요즘은 잘 쓰이지 않는 방식.
- 그래서 대신 Redis를 이용한 토큰 방식을 사용. 토큰(TTL 1시간)을 포함한 URL을 생성한 후 메일에 버튼 UI를 하나 만들고 그 버튼에다가 URL을 포함시키는 방식.
- URL을 클릭하면 비밀번호 변경 페이지가 표시되도록 함. 해당 페이지는 렌더링되자마자 토큰을 검사하는 API를 호출, 토큰이 유효하지 않다면 페이지를 비활성 처리함.
- 그래서 비밀번호 재설정 로직을 위해서는 API가 총 세 개가 있어야 함.
- 비밀번호 재설정 요청 API - 사용자에게 재설정 메일을 보냄.
- 토큰 검사 API - 재설정 페이지 렌더링 직후 호출할 API.
- 비밀번호 재설정 완료 API - 비밀번호 재설정 페이지에서 비밀번호 변경 버튼 눌렀을 때 부르는 API이며 이때도 토큰은 검증해야 함.
- 마음에 걸렸던 건 비밀번호 재설정 요청 API의 취약점
- 이 API를 호출하는 경우의 특성상 요청자에 대한 인증 정보를 검증할 방법이 없음.
- 즉 브라우저 클라이언트가 아닌 이상 CORS로 막히지도 않으니 postman, curl 등을 이용하면 아무나 이 API를 호출할 수 있음.
- 방법은 IP 차단이 최선인 것 같으며, 아직 구현하지는 않음.
- 회원 탈퇴 API
- 각 서비스들에서도 모두 탈퇴 처리가 되어야 함.
- 팀원분께서 웹훅 방식을 제안하셔서 채택됨.
- DB 테이블에 각 서비스들의 탈퇴 API 엔드포인트를 저장해놓고 SSO에서 해당 API들을 일괄 호출하는 방식.
정책상 논의되고 있는 부분
- 사용자 차단 기능?
- 토큰 단위가 아닌 특정 사용자를 차단하는 기능이 필요할 것 같다는 의견이 나옴.
- 이 기능을 위해선 각 서비스들에서 매 요청마다 SSO 서버로 사용자를 검증하는 API 요청을 보내야 함.
- 그래서 위 기능을 넣는다고 하면, 토큰 검증하는 로직도 차라리 SSO에 두는 게 낫지 않냐는 의견이 나옴.
- 하지만 이 기능은 고작 TTL 10분(줄이면 5분도 될 수 있음)의 액세스 토큰을 차단하자고 SSO에 트래픽을 과중하게 몰리게 하는 비효율성을 가져온다는 의견도 있음. 이 방법을 쓰려면 오히려 토큰 방식이 아닌 세션 방식을 택하는 것이 더 철학에 맞을 것.
- 사용자 정보는 각 서비스의 DB에도 존재해야 하는가?
- 현재 액세스 토큰에는 시퀀스(유저 식별용), 이메일, 이름이 들어 있는데 토큰은 평문이므로 개인정보 탈취의 위험이 존재함. 그리고 혹시나 각 서비스들에 핸드폰 번호 등이 필요하다면 어차피 SSO의 DB로 요청해야 함.
- 그럴 바엔 사용자 정보(이메일, 이름, 핸드폰 번호 등)를 각 서비스들의 DB에도 넣어주자는 의견.
- 물론, 사용자 차단 기능을 위해 매 요청마다 SSO에 사용자를 검증할 거면 그때 사용자 정보도 리턴해주면 되긴 함.
- 그렇지 않은 경우에는 각 서비스들의 DB에서 유저 정보를 조회해오는 편이 조금 더 깔끔해질 것.
논의사항들에 대해 나온 의견
- 사용자 차단 기능
- 꼭 있어야 된다는 쪽으로 가는 중.
- 차단된 사용자 목록은 Redis로 관리해서 API 요청마다 검사하기. SSO에 트래픽이 집중되는 것보단 이 방법이 성능상 더 적절함.
- 검증은 각 서비스들에서 하되, JWT 토큰 암호화 방식에 비대칭키를 사용하여 각 서비스에는 복호화 키만을 전달하기.
- 사용자 정보는 각 서비스의 DB에도 존재해야 하는가
- 이 방법을 쓰려면 회원 탈퇴 API와 궤를 같이하는 방안인 웹훅으로 가야 함. 즉 SSO에서 사용자 이름이 변경되었을 경우 각 서비스들의 DB에도 이를 반영시켜야 하는 것.
- 하지만 자체적으로 웹훅(SSO -> 서비스 HTTP 요청)을 구현하여 쓸 때 가장 걸렸던 것은 싱크 문제임. 만약 서비스 중 하나가 꺼져있거나 예상치 못한 에러로 인해 요청이 실패했다면 SSO와 해당 서비스의 데이터가 불일치하는 문제가 생김.
- 그래서 이런 문제를 RabbitMQ나 Kafka로 해결하자는 의견이 나옴. Kafka는 대규모 서비스에 많이 사용되는 만큼 우리 서비스 규모에 비해 운용이 복잡한 문제가 있어 RabbitMQ를 사용하는 편이 더 적절하다고 판단됨.
현재도 구현 진행중이라 내용은 추가 및 수정될 수 있습니다.

'IT > Spring & Spring Boot' 카테고리의 다른 글
| [Spring Boot] 네이티브 쿼리 vs JPQL (0) | 2025.12.24 |
|---|---|
| [Spring Boot/NGINX] 로그 없는 웹소켓 연결 에러 (3) | 2025.07.08 |
| [Java] JVM이 죽었음다 ㅡㅡ; (Java heap space 에러) (1) | 2025.05.18 |
| [IT/일상/Java] MQTT 통신 코드 개선 일지 (feat. QueueChannel, Future) (1) | 2024.12.14 |
| [Spring Boot] RequestBody 필드에 값이 매핑되지 않는 문제 (2) | 2024.11.21 |