본문 바로가기
IT/Spring & Spring Boot

[Spring Boot] 통합 회원관리 시스템(IAM) 구현 기록

by 저당단 2025. 11. 26.

(AI 생성 이미지) 글자 하나는 어디다 팔아먹었냐고..

 

사내에서 개발한 통합 회원관리 시스템 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
    • 기존 방식은 메일로 임시 비밀번호를 보내는 것이었으나, 이메일로 비밀번호를 보내는 것은 결국 사용자의 비밀번호가 외부로 유출된다는 것이라 보안 문제가 있고, 사용자가 비밀번호를 수동으로 바꿔줘야 해서 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를 사용하는 편이 더 적절하다고 판단됨.

 

 


현재도 구현 진행중이라 내용은 추가 및 수정될 수 있습니다.