<서론>
https://backend-newbie.tistory.com/28
로그인을 구현하기 전 생각해 본 것들
최근에 테마별 카페 추천 서비스라는 토이 프로젝트를 한개 진행했다. 해당 서비스에서 사용자가 자신이 마음에 드는 카페에 리뷰를 달거나 북마크를 하거나 자신의 북마크 리스트를 조회하는
backend-newbie.tistory.com
이전에 로그인을 직접 구현하기 전에 세션 / 토큰, OAuth, Filter등을 사용하는 것을 고민해 본 적이 있었다. 이번에는 실제로 내 서비스에 카카오 연동 로그인을 구현해 보려고 한다. 내가 만들 서비스에서는 자체 로그인을 따로 만들지 않고 카카오, 네이버, 구글등의 연동 로그인만을 사용할 예정이다. 우선은 카카오만을 이용한 로그인을 구현할 생각인데 추후 네이버, 구글등의 연동 로그인을 손쉽게 확장시킬 수 있는 구조로 구현하는 것을 목표로 했다. 이 과정 중에 마주한 문제들이나 생각해 봤던 점들을 기록하기로 했다.
<카카오 개발자 센터에 내 서비스 등록하기>
https://developers.kakao.com/product/kakaoLogin
Kakao Developers
카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.
developers.kakao.com
우선 카카오 개발자 센터에 들어가서 직접 내 서비스를 등록해 줘야 한다.
우선 가장 먼저 위와 같이 CafeHub 앱을 위와 같이 등록한다. 즉, 내 서비스를 Resource Server에 Client로 등록해 주는 과정이다.
내가 만들 서비스는 웹 서비스이기 때문에 Web 플랫폼 등록으로 가서 로그인을 담당할 백엔드 서버의 도메인 주소를 등록해 줬다. 또한 로컬 테스트용으로 http://localhost:8080을 추가로 등록해 줬다.
이제 카카오 로그인을 OFF → ON으로 변경해 준다.
그 후, 사용자가 카카오에서 직접 로그인을 성공하면 다시 내 백엔드 서버로 돌아오게 해 줄 URI인 Redirect URI를 등록해 준다. 예를 들어 내가 http://localhost:8080/callback과 같이 등록해 주고 백엔드 서버에서 해당 경로로 사용자가 들고 온 임시인증 코드를 받아주면 된다.
그 후, Resource Owner인 사용자가 어디까지 내가 만든 서비스에 자신의 실제 정보인 Resource를 제공할 것인지 Scope 설정을 해준다. 여기서 각각의 항목에 필수 동의, 선택 동의를 설정 할 수 있다. 내 서비스를 운영하기 위해 반드시 필요한 항목은 반드시 필수 동의로 설정해 줘야한다.
내 서비스에서는 마이페이지에 사용자의 이메일을 보여주는 화면이 있기 때문에 이메일 제공을 필수로 만들어 줘야 했다. 하지만 카카오 에서는 실제 비즈니스 앱이 아니라면 닉네임과 프로필 사진을 제외한 어떤 것도 제공해 주지 않았다.
이를 해결하기 위해 개인정보 동의 심사 신청을 하게되면 위와 같은 화면으로 넘어간다. 여기서 나는 실제 사업자 등록을 한 것은 아니기 때문에 카카오 비즈니스 통합 서비스 약관 동의를 눌러줬다. 그 후, 그냥 카카오에서 안내하는 대로 설정해 주면 이메일도 제공받을 수 있다.
이제 최종적으로 위와 같이 Scope를 설정해 주었다. 카카오 프로필 이미지의 경우 반드시 필요하지는 않았기 때문에 선택동의 항목으로 설정했다.
이제 사용자는 내 서비스에 처음 로그인 할 시 위와 같은 화면을 받아 보게 된다.
이제 Client_ID를 확인해 봐야하는데 Client_ID는 앱키 → Rest API키 에서 확인 가능하다. OAuth에는 Client_ID 말고도 Client_Secret이라는 값이 존재한다. Client_ID가 내 서비스의 고유 아이디라면 Client_Secret은 비밀번호와 같다. Client_Secret은 매뉴얼 중 보안을 클릭해서 직접 발급 받을 수 있다.
이렇게 카카오 개발자 센터에서 카카오 로그인 API를 사용하기 위해서 Client_ID, Client_Secret, Redirect_URI까지 모두 확보했으니 코드레벨에서 구현을 시작하면 된다.
<로그인 버튼 클릭 ~ 사용자의 OAuth AccessToken 발급>
우선, 필터 같은 곳에서 인증, 인가 체크를 진행하기 전에 사용자가 실제로 카카오 쪽에 로그인 하고 JWT를 발급받는 과정이 먼저 필요하다.
https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api
Kakao Developers
카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.
developers.kakao.com
이를 위해서 카카오의 공식 로그인 API문서를 보면서 하나하나씩 구현해 봤다. 디테일한 부분들은 각 서비스사의 로그인마다 차이가 있겠지만 이전에 다뤘던 OAuth의 기본 작동 과정에서 크게 벗어나지는 않는다.
우선 전체적인 과정은 위와 같다.
여기서 가장 먼저 해 줄 일은 프론트 쪽에서 사용자가 카카오로 로그인하기 버튼을 누를 경우 발생하는 Request를 처리해 주는 것이다.
해당 요청이 발생하면 백엔드 쪽에서는 Client_Id, Redirect_URI, Response_Type과 실제 카카오 로그인 페이지 URL을 조합해 사용자의 웹 브라우저가 리다이렉트 될 수 있도록 유도해 줘야 한다.
Controller에서 이 요청을 받아서 처리 할 수 있게 만들어 주면 되는데 여기서 중요한 점이 한가지 있었다. 지금은 카카오로 로그인 하기 만을 구현하고 있지만 추후 네이버 로그인, 구글 로그인 등등을 추가해볼 계획이다.
사용자가 어떤 방식의 로그인을 사용할지 나는 미리 알 수 없다. 따라서 Controller에 getKakologinUrl(), getNaverloginUrl() ... 이런식으로 새로운 로그인이 추가될 때마다 계속 코드가 늘어나는 문제가 발생했다.
나는 이를 모두 Controller에서 한가지 메서드로만 처리하고 싶었다. 이를 위해서 프론트 쪽에서 각각의 다른 서비스사의 로그인 버튼을 눌렀을 때 /api/member/login/kakao, /api/member/login/naver 처럼 어떤 로그인 버튼을 눌렀는지 식별할 수 있는 식별자를 Path Variable로 넘겨주게 했다.
@GetMapping("/api/member/login/{provider}")
public ResponseEntity<Void> login(@PathVariable("provider") String provider){
log.info("사용자가 {}로 로그인 하기 버튼을 누름", provider);
...
}
이제 사용자가 어떤 로그인을 선택해도 위의 메서드에서 처리하게 된다. 이제 LoginService를 만들어서 LoginController가 사용하도록 만들어줘야 하는데 여기서 한가지 문제가 또 발생했다.
LoginService를 LoginController에 어떻게 주입해 줘야할까에 대한 문제였다. LoginController는 단 한개만 존재하는데 LoginService의 경우 KakaoLoginService, NaverLoginService, GoogleLoginService 등 여러가지가 존재 했다.
private final LoginSevice loginSevice;
@Autowired
public LoginController(LoginService loginService){
this.loginSevice = loginService;
}
즉, LoginService 인터페이스의 구현체가 여러가지가 있는데 이를 모두 사용하는 상황이었다. 따라서 위와 같이 생성자 주입을 사용하면 굉장히 곤란했다.
그렇다고 Setter 주입을 사용하기에는 LoginService는 LoginController라는 싱글톤으로 관리되고 있는 단 한 개의 컨트롤러에서 공유해서 사용하는 서비스라는 문제가 있었다.
예를 들어 어떤 회원이 카카오 로그인을 시도해서 이를 처리하고 있는데 다른 회원이 네이버 로그인을 시도하게 되면 LoginController가 의존하고 있는 LoginService 인터페이스의 구현체가 NaverLoginService로 변경되면서 문제가 발생한다.
private final Map<String, LoginService> loginServiceMap;
이런 문제를 해결하기 위해서 LoginServiceMap을 만들고 모든 LoginService의 구현체를 Controller에 주입 받는 방법을 선택했다.
public interface OAuth2LoginService {
String getLoginPageUrl();
Map<String, String> loginWithOAuthAndIssueJwt(String authorizationCode);
...
}
우선 LoginService 인터페이스를 조금 더 구체적으로 OAuth2LoginService로 변경했고 해당 인터페이스의 구현체들은 KakaoLoginService, NaverLoginService와 같이 provider+LoginService로 클래스 이름을 지정했다. 이제 각각의 서비스들은 스프링 빈 저장소에 kakaoLoginService, naverLoginService와 같은 이름으로 저장되게 된다.
private final Map<String, OAuth2LoginService> oAuth2LoginServiceMap;
@Autowired
public LoginController(Map<String, OAuth2LoginService> oAuth2LoginServiceMap){
this.oAuth2LoginServiceMap = oAuth2LoginServiceMap;
}
이제 컨트롤러에서 모든 OAuth2LoginService의 구현체를 Map에 주입해 줬다. 이 때, 해당 Map안에 들어있는 데이터는 ReadOnly 상태로 런타임중에 변경될 일은 없었고 변경에 따른 동시성 문제를 고민하지는 않아도 될거 같았다. 따라서 ConcurrentMap을 고려하지는 않았다.
@GetMapping("/api/member/login/{provider}")
public ResponseEntity<Void> login(@PathVariable("provider") String provider) {
OAuth2LoginService loginService = oAuth2LoginServiceMap.get(provider + Login_SERVICE_SUFFIX);
log.info("사용자가 {}로 로그인 하기 버튼을 누름", provider);
...
}
Map안에 Key로는 스프링 빈 객체 네이밍이 Value로는 실제 구현체가 들어있다. 예를 들어 해당 Map에 .get(kakaoLoginService)를 수행하면 KakaoLoginService 구현체를 얻을 수 있게 된다.
@GetMapping("/api/member/login/{provider}")
public ResponseEntity<Void> login(@PathVariable("provider") String provider){
OAuth2LoginService loginService = oAuth2LoginServiceMap.get(provider + Login_SERVICE_SUFFIX);
log.info("사용자가 {}로 로그인 하기 버튼을 누름", provider);
return ResponseEntity.status(FOUND)
.header(LOCATION_HEADER, loginService.getLoginPageUrl())
.build();
}
이제 사용자가 카카오, 네이버 등 어떤 로그인 버튼을 클릭해도 해당 요청에 맞는 로그인 서비스의 .getLoginPageUrl()이 호출된다.
이제 로그인 서비스 구현체를 이용해서 client_id, redirect_uri, response_type이 설정된 LoginPageUrl을 만들어서 Location 헤더와 함께 돌려주면 사용자의 웹브라우저는 카카오 로그인 페이지로 리다이렉트 한다.
@Service
@Transactional
public class KakaoLoginService implements OAuth2LoginService {
private final String clientId;
private final String redirectUri;
private final String clientSecret;
private final String loginPageUrl;
public KakaoLoginService(@Value("${kakao.loginUrl}") String kakaoLoginUrl,
@Value("${kakao.clientId}") String clientId,
@Value("${kakao.redirectUri}") String redirectUri,
@Value("${kakao.clientSecret}") String clientSecret,
...) {
this.clientId = clientId;
this.redirectUri = redirectUri;
this.clientSecret = clientSecret;
this.loginPageUrl = kakaoLoginUrl + "?client_id=" + clientId + "&redirect_uri=" + redirectUri + "&response_type=code";
...
}
@Override
public String getLoginPageUrl() {
...
}
}
이제 실제 OAuth2LoginService 인터페이스의 구현체인 KakaoLoginService를 구현해 주면 된다. 해당 서비스는 카카오 로그인과 관련된 Client_Id, Redirect_Uri등 여러가지 환경 변수들이 필요하다.
이런 외부에서 설정하는 환경변수들이 여러가지일 경우 application.yml 같은 파일로 한번에 관리해 주면 편하다. 해당 파일에 저장한 변수들을 내 앱에서 사용하기 위해서 Enviroment를 사용 할 수 있는데 이를 그대로 사용하는 것보다 위와 같이 @Value를 이용해서 외부파일로 부터 값 주입을 더 편하게 할 수 있었다.
@Value 역시 내부에서 Enviroment를 이용한다. 하지만 위의 코드를 보면 변수 하나하나에 @Value를 사용해서 내가 필요한 외부 환경변수들을 매칭시켜 줘야하는 불편함이 존재한다. 또한 각각의 환경변수에 숫자가 들어와야 하는데 문자가 들어오는 타입 오류와 양수만 들어가야 하는데 음수가 들어가는 환경변수 값이 이상한 경우를 검증하는 것이 쉽지 않다.
이를 해결하기 위해서 스프링은 Type-safe Configuration Properties라는 외부 설정 묶음을 객체로 변경해서 사용하는 방식을 제공한다. @ConfigurationProperties을 이용해서 이를 구현 할 수 있다.
# Kakao Social Login Config
kakao:
loginUrl : https://kauth.kakao.com/oauth/authorize
logoutRedirectUrl : http://localhost:8080/serviceLogout
clientId : 아이디
clientSecret: 시크릿키
redirectUri : http://localhost:8080/oauth/callback
우선 application.yml에 나는 카카오 로그인과 관련된 환경 변수들을 kakao 아래에 모두 모아 뒀다.
@Getter
@ConfigurationProperties("kakao")
public class KakaoPropertiesLoader {
private final String loginUrl;
private final String clientId;
private final String clientSecret;
private final String redirectUri;
private final String logoutRedirectUrl;
public KakaoPropertiesLoader(String loginUrl, String clientId, String clientSecret, String redirectUri, String logoutRedirectUrl) {
this.loginUrl = loginUrl;
this.clientId = clientId;
this.clientSecret = clientSecret;
this.redirectUri = redirectUri;
this.logoutRedirectUrl = logoutRedirectUrl;
}
}
해당 변수들을 실제로 application.yml로 부터 받아 올 KakaoPropertiesLoader 라는 클래스를 하나 만들었다. @ConfigurationProperties를 통해서 외부 환경변수들을 가져올 객체로 만들어 주고, 가져올 변수들의 경로를 지정해 줬다.해당 클래스는 application.yml에 있는 카카오 로그인 관련 환경 변수들을 받아오는 역할을 한다. 런타임 중 이런 환경변수 값의 변경이 일어나지 않게 각각의 필드들은 final로 잠궜고 @Setter를 사용하지 않았다.
이럴 경우 @ConstructorBinding을 이용해서 생성자 주입을 받을 수 있는데 생성자가 한 개 밖에 없다면 이는 생략이 가능하다. 환경 변수들을 끌어올려서 객체로 만들었기 때문에 해당 객체에서 빈 밸리데이션을 이용해서 각각의 값들에 대한 검증도 가능하다.
@Getter
public class KakaoProperties {
private final String loginUrl;
private final String clientId;
private final String clientSecret;
private final String redirectUri;
private final String logoutRedirectUrl;
private final String loginUrlWithParams;
public KakaoProperties(String loginUrl, String clientId,
String clientSecret, String redirectUri,
String logoutRedirectUrl) {
this.loginUrl = loginUrl;
this.clientId = clientId;
this.clientSecret = clientSecret;
this.redirectUri = redirectUri;
this.logoutRedirectUrl = logoutRedirectUrl;
this.loginUrlWithParams = buildLoginUrlWithParams();
}
public String buildLoginUrlWithParams(){
return loginUrl +
"?client_id=" + clientId +
"&redirect_uri=" + redirectUri +
"&response_type=code"+
"&state=kakao";
}
}
이제 실제로 KakaoLoginService에서 사용할 KakaoProperties를 만들었다. 해당 객체는 KakaoLogin과 관련된 환경변수들을 저장하고 자신이 가진 환경변수를 조합해서 최종 로그인 Url을 만들어 관리하는 역할을 맡았다.
여기서 buildLoginUrlWithParams()를 보면 카카오 로그인 API 문서상 반드시 필요한 client_id, redirect_uri와 code타입을 사용하겠다는 쿼리파라미터, state=kakao라는 값이 있다.
카카오 공식 문서를 보면 사용자가 카카오에서 로그인을 성공 후 내 서버의 redirect_uri로 돌아 올 때 state라는 값이 함께 전달되니까 state를 이용해서 provider 정보를 담아주면 된다.
사용자가 직접 화면에서 카카오로 로그인하기, 네이버로 로그인 하기 등을 클릭했을 때는 내 서비스의 프론트에서 어디 회사의 로그인인지 Pathvariable로 정보를 넘겨 줄 수 있었다. 하지만 카카오 인증 서버에 그런 요구사항을 내가 전달 할 수 없다. 나는 모든 로그인 서비스 사의 redirect_uri를 한 개로 통일 할 생각이다.
그렇게 하지 않으면 LoginController에서 각 서비스사의 redirect_uri를 처리할 메서드를 매번 새로 만들어 줘야한다. 따라서 state 파라미터를 이용해서 어떤 서비스사의 로그인 성공 redirect 요청인지 구분 할 수 있는 값인 provider 정보를 유지 시켰다.
@GetMapping("/oauth/callback")
public ResponseEntity<Void> OAuthCallback(@RequestParam ("code") String authorizationCode,
@RequestParam ("state") String provider){
이런식으로 카카오, 네이버, 구글 등 사용자가 어떤 곳의 로그인을 성공해도 돌아오는 리다이렉트 요청은 해당 메서드로 오게 만들었다.
@SpringBootApplication
@ConfigurationPropertiesScan
public class BackendApplication {
public static void main(String[] args) {
SpringApplication.run(BackendApplication.class, args);
}
}
다시 돌아와서 이제 메인 App에 @ConfigurationPropertiesScan을 선언해준다. 해당 어노테이션은 @ConfigurationProperties가 붙어있는 클래스들을 자동 스캔 해 주는 역할을 한다.
@Configuration
public class KakaoPropertiesConfig {
private final KakaoPropertiesLoader kakaoPropertiesLoader;
public KakaoPropertiesConfig(KakaoPropertiesLoader kakaoPropertiesLoader) {
this.kakaoPropertiesLoader = kakaoPropertiesLoader;
}
@Bean
public KakaoProperties kakaoProperties(){
return new KakaoProperties(
kakaoPropertiesLoader.getLoginUrl(),
kakaoPropertiesLoader.getClientId(),
kakaoPropertiesLoader.getClientSecret(),
kakaoPropertiesLoader.getRedirectUri(),
kakaoPropertiesLoader.getLogoutRedirectUrl()
);
}
}
마지막으로 KakaoPropertiesLoader로 부터 외부 파일에서 가져온 환경변수들을 넘겨 받고 KakaoProperties를 생성하면서 스프링 빈으로 등록해준다. 카카오 로그인을 수행할 때 마다 KakaoProperties가 필요할텐데 매번 해당 객체가 생성되면 곤란 할 것이다.
@Service
@Transactional
public class KakaoLoginService implements OAuth2LoginService {
private final KakaoProperties properties;
public KakaoLoginService(KakaoProperties properties,
...) {
this.properties = properties;
...
}
@Override
public String getLoginPageUrl() {
return properties.getLoginUrlWithParams();
}
}
이제 KakaoLoginService에서 스프링 빈으로 등록된 KakaoProperties를 주입받고 카카오 로그인 페이지 Url + client_id + redirect_uri 등 여러가지 정보가 포함된 최종 Url을 컨트롤러에 반환해 주면 된다.
이제 LoginController는 사용자의 웹브라우저에 의해 자동으로 리다이렉트 되고 사용자는 화면에 위와 같은 카카오 로그인 창을 받게 된다.
처음 로그인에 성공한 사용자라면 위와 같은 Scope 동의 화면을 받게 된다. 이제 동의하고 계속하기를 누르면 카카오 인증서버는 사용자에게 Location 헤더에 내 서비스의 redirect_uri?임시인증코드=xx&state=kakao이 포함된 응답을 주게 되고 사용자의 웹브라우저에서 내 서비스의 백엔드 서버로 이를 바로 리다이렉트 한다.
지금까지 구현한 과정이 전체 API 흐름도에서1 ~ 6번 과정이었다.
@GetMapping("/oauth/callback")
public ResponseEntity<Void> OAuthCallback(@RequestParam ("code") String authorizationCode,
@RequestParam ("state") String provider) {
OAuth2LoginService loginService = oAuth2LoginServiceMap.get(provider + LOGIN_SERVICE_SUFFIX);
log.info("사용자가 카카오에 로그인했고 {}에서 CafeHub에 사용자를 통해서 리다이렉트로 콜백 성공", provider);
Map<String, String> tokenMap = loginService.loginWithOAuthAndIssueJwt(authorizationCode);
...
}
이제 카카오에서 직접 로그인에 성공한 사용자의 웹브라우저는 카카오에서 보내준 리다이렉트 응답을 받고 자동으로 나의 서비스의 백엔드서버 Redirect_Uri에 인증코드와 state를 쿼리 파라미터로 보내온다.
이를 매핑해서 LoginController에서 받아준다. 그 후, state에 있는 provider 정보로 LoginServiceMap에서 해당 OAuth 콜백 요청에 알맞는 서비스사의 LoginService 구현체를 가져와준다.
그 후, 해당 서비스의 메서드를 이용하여 OAuth를 이용한 나머지 로그인을 처리하고 결과로 JWT Access Token과 JWT Refresh Token을 반환 받아준다.
public Map<String, String> loginWithOAuthAndIssueJwt(String authorizationCode) {
// 1. 인증코드를 카카오 인증서버에 전달 후 OAuth Access Token 발급
// 2. 액세스토큰을 통해 카카오 Resource 서버에서 회원의 정보를 받아옴
// 3. 신규 회원 체크 후 회원가입 처리
// 4. JWT 발급 후 return
}
이를 위해 해당 메서드에서 처리해 줄 일은 크게 4가지 이다. 우선 인증코드를 카카오 Authorization Server에 전달 하고OAuth Access Token을 발급받는 일 부터 처리해 보자.
일단 내가 처리하고 싶은 부분은 위의 그림에서 빨간색으로 표시된 부분이다. 사용자가 전달한 임시코드를 받는 것은 성공했으니 내 서비스의 백엔드 서버가 클라이언트가 되어 카카오 인증 서버에 임시코드를 전송해 주는 것이 필요하다.
이를 위해서 스프링 앱에서 다른 서버에 HTTP 요청을 날리기 위한 어떤 방법이 필요하다. 스프링에서는 대표적으로RestTemplate나 WebClient라는 것을 사용해서 이를 가능하게 해준다.
우선 RestTemplate는 스프링3.0 버전에 이런 상황을 쉽게 해결하도록 돕기 위해서 등장했다. 하지만 이 기술은 사용법이 직관적이지 않아서 불편한 점이 많다고 한다.
WebClient는 스프링 5.0이 나올 쯤 Spring Webflux와 함께 등장한 기술인데 RestTemplate 보다 훨씬 편리하다고 한다. 하지만 이 기술은 Spring MVC가 아닌 Spring Webflux의 기술이기 때문에 Spring MVC에서 WebClient 하나를 사용하기 위해서 Spring Webflux를 끌고와야 하는 문제가 발생한다. 또한, 이를 가져와서 사용하더라도 Spring Webflux의 Mono, Flux등의 사용법을 숙지해야 제대로 사용 할 수 있다는 단점을 갖고 있다.
이런 RestTemplate와 Webflux의 단점들을 극복하고 잘 병합하여 장점들만을 살려서 Spring MVC에서 사용 할 수 있게 RestClient라는 기술이 최근 스프링 6.1 버전, 스프링 부트로는 3.2 버전 부터 등장했다. 따라서 RestClient의 사용법을 검색해 보면서 이를 내 프로젝트에 적용해 보기로 했다.
RestClient restClient = RestClient.create();
우선, RestClient는 위와 같이 쉽게 객체 생성이 가능하다. 문제는 매 요청마다 이런식으로 RestClient를 생성하게 되면 비효율적일 것이다.
@Configuration
public class RestClientConfig {
@Bean
public RestClient restClient(){
return RestClient.create();
}
}
@Service
@Transactional
@RequiredArgsConstructor
public class KakaoLoginService implements OAuth2LoginService {
private final KakaoProperties properties;
private final RestClient restClient;
...
}
RestClient의 경우 멀티 쓰레드 환경에서 동시성 이슈 없이 사용할 수 있게 Thread Safe하게 설계되어 있고 비동기 요청을 보장한다고 한다. 따라서 이를 스프링 빈으로 등록해서 싱글톤으로 관리하며 사용할 생각이다.
이제 내 서비스의 백엔드 서버가 Client가 되어 카카오 Authorization Server에 Request를 보낼 준비가 완료 되었다. 그럼 어떤 요청을 어떻게 보내야 할지 알아봐야 한다.
이는 카카오 로그인 API 공식 문서에서 찾아 볼 수 있었다. 위의 URL로 POST요청을 날려주면 된다.
이 때, 요청의 헤더에는 contentType이 필수로 위와 같이 설정 되어 있어야 한다.
또한, 필수 파라미터로 무엇을 전달해야 할지도 명시 되어있다.
이제 요청을 날릴 준비를 모두 완료 했는데 한 가지 의문점이 생겼다. 지금 내 서비스의 로직 흐름은 위와 같다. LoginController는 OAuthLoginService라는 인터페이스에만 의존하고 있고 카카오 로그인 요청이기 때문에 구현체로 KakaoLoginService를 사용하고 있다.
이 때, KakaoLoginService는 RestClient라는 외부 기술을 자신 안에 포함하고 있다. 이럴 경우 RestClient보다 추후 더 좋은 기술이 나온다거나 RestClient에 치명적인 결함이 발생해서 다른 기술을 사용해야 한다면 대처하기가 힘들 것이다. 특히나 RestClient는 나온지 얼마 안된 기술이기 때문에 여러 이슈가 발생 할 수 있다.
따라서 위와 같이 OAuthHttpClient라는 인터페이스를 만들어서 구조를 변경시키기로 했다. 해당 인터페이스의 역할은 OAuthLoginService에서 OAuth를 위해 Authorization Server나 Resource Server와 통신을 해야 할 때, 스프링 서버를 Client로 만들어 통신을 하게 해주는 것이다.
public interface OAuthHttpClient {
public KakaoOAuthTokenResponseDTO getKakaoOAuthTokens(String authorizationCode);
public NaverOAuthTokenResponseDTO getNaverOAuthTokens(String authorizationCode);
...
}
이제 KakaoLoginService는 OAuthHttpClient에만 의존하면 구현체가 어떤 하부 기술을 사용했는지에 의존하지 않고 OAuth Access Token, OAuth Refresh Token을 받아 올 수 있다.
하지만 그렇다고 위와 같이 각각의 서비스사 마다 getXXXOAuthTokens 같은 메서드를 만들어 버리게 되면 다른 개발자들이 실수로 네이버 로그인을 구현하다가 getKakaoOAuthTokens()를 호출하는 실수를 할 여지를 만들 수 있고 인터페이스 자체의 가독성이 떨어졌다.
이럴 경우 애초에 OAuthHttpClient를 만들때 너무 많은 선택지를 외부로 제공하고 적당한 제약을 주지 못한 나의 잘못이 100%이다.
public interface OAuthHttpClient {
OAuthTokenResponseDTO getOAuthTokens(String authorizationCode, String provider);
}
따라서 위와 같이 OAuthHttpClient 인터페이스의 단순화가 필요하다.
KakaoLoginService는 OAuthHttpClient 인터페이스의 getOAuthTokens()만 호출하면 그 뒤에 어떤 과정이 일어나는지는 관심 없고 KakaoOAuthTokenResponseDTO만 적절히 반환 받으면 된다.
@Component
@RequiredArgsConstructor
public class OAuthHttpRestClient implements OAuthHttpClient{
private final RestClient restClient;
private final KakaoProperties properties;
@Override
public OAuthTokenResponseDTO getOAuthTokens(String authorizationCode, String provider) {
log.info("{} 인증 서버에 code 발송, AccessToken + RefreshToken + 부가 정보들을 받아옴", provider);
if(provider.equals("kakao")) {
...
}
else if (provider.equals("naver")) {
...
}
else if (provider.equals("google")) {
...
}
...
}
}
그렇다고 OAuthHttpClient의 구현체인 OAuthHttpRestClient를 위와 같이 매번 provider에 맞게 분리 시켜 하나하나 처리하는 건 좋지 않아 보인다.
이를 해결하기 위해서 OAuthRestClient 구현체에 OAuthRestClientProvider 인터페이스를 연결하고 각각 서비스사 별 RestClient를 이용해 통신을 관리하는 Manager들을 모두 스프링 빈으로 등록해서 사용하기로 했다.
@Service
@Transactional
@RequiredArgsConstructor
public class KakaoLoginService implements OAuth2LoginService {
private final KakaoProperties properties;
private final OAuthHttpClient httpClient;
// private final RestClient restClient;
...
}
가장 먼저 KakaoLoginService에서 RestClient, WebClient, RestTemplate등의 하부 기술에 의존하지 않고 OAuthHttpClient라는 인터페이스에만 의존할 수 있게 구조를 변경했다.
@Override
public Map<String, String> loginWithOAuthAndIssueJwt(String authorizationCode, String provider) {
KakaoOAuthTokenResponseDTO oAuthTokens = (KakaoOAuthTokenResponseDTO) httpClient.getOAuthTokens(authorizationCode, provider);
String accessToken = oAuthTokens.getAccessToken();
log.info("카카오 인증서버에서 Access Token 받아오기 성공: ");
...
}
그 후, KakaoLoginService에서 OAuthHttpClient의 getOAuthTokens()를 호출한다.
public interface OAuthHttpClient {
OAuthTokenResponseDTO getOAuthTokens(String authorizationCode, String provider);
}
@Component
@RequiredArgsConstructor
public class OAuthHttpRestClient implements OAuthHttpClient{
private final Map<String,OAuthRestClientProvider> oAuthRestClientProviderMap;
@Override
public OAuthTokenResponseDTO getOAuthTokens(String authorizationCode, String provider) {
return oAuthRestClientProviderMap.get(provider + REST_CLIENT_MANAGER_SUFFIX).getOAuthTokenResponseDTO(authorizationCode);
}
}
이제 OAuthHttpClient 인터페이스는 현재 자신의 적절한 구현체로 등록 되어 있는 OAuthHttpRestClient를 찾아간다. 여기서 OAuthRestClientProviderMap을 이용해 OAuthRestClientProvider인터페이스들의 구현체인 KakaoRestClientProvider, NaverRestClientProvider, GoogleRestClientProvider들을 모두 주입 받는다.
해당 Map의 경우 한 번 주입 받고 더 이상 변경되지 않고 Read Only로만 사용되기 때문에 동시성을 고려한 Map 까지는 사용하지 않았다.
이제 OAuthHttpRestClient 입장에서는 RestClient를 이용하기는 할 건데 provider가 kakao, naver, google 중 무엇이든 상관하지 않고 OAuthRestClientProvider 인터페이스의 getOAuthTokenResponseDTO()만을 호출한다.
그럼 전달된 provider에 알맞은 OAuthRestClientProvider의 구현체가 선택된다. 예를 들어 provider가 kakao면 KakaoRestClientManager의 getOAuthTokenResponseDTO()를 호출하게 된다.
@Component
@RequiredArgsConstructor
public class KakaoRestClientManager implements OAuthRestClientProvider{
private final RestClient restClient;
private final KakaoProperties properties;
@Override
public KakaoOAuthTokenResponseDTO getOAuthTokenResponseDTO(String authorizationCode){
KakaoOAuthTokenRequestDTO oAuthTokenRequestDTO = KakaoOAuthTokenRequestDTO.from(authorizationCode,properties);
return restClient.post()
.uri(KAKAO_OAUTH_TOKEN_REQUEST_URL)
.contentType(KAKAO_OAUTH_TOKEN_CONTENT_TYPE)
.body(oAuthTokenRequestDTO.convertAllFieldsToMultiValueMap())
.retrieve()
.body(KakaoOAuthTokenResponseDTO.class);
}
}
이제 KakaoRestClientManager는 OAuthClientProvider의 getOAuthTokenResponseDTO()를 RestClient와 KakaoProperties를 사용해서 구현해 주면 된다.
public interface OAuthRestClientProvider {
OAuthTokenResponseDTO getOAuthTokenResponseDTO(String authorizationCode);
}
OAuthRestClientProvider 인터페이스의 경우 카카오, 네이버등 어떤 구현체가 선택될지 알 수 없기 때문에 반환 타입으로 KakaoTokenResponseDTO, NaverTokenResponseDTO등의 부모인 OAuthTokenResponseDTO를 사용했다.
/**
* 아직 kakao, naver, google 등 여러가지 소셜 로그인 중 kakao만 구현된 상태라
* 추후 kakao외의 로그인이 추가될 경우 해당 클래스에 각 서비스 사의 OAuth Token Response 중
* 공통 부분을 관리할 최상위 레벨의 DTO임
*/
public class OAuthTokenResponseDTO {
}
카카오, 네이버, 구글등 각각 소셜 로그인 마다 로그인 API에 명시된 스펙들이 다르기 때문에 추후 공통 부분이 보이면 해당 클래스에서 공통 부분을 처리해야 겠다고 생각했다. 일단 해당 객체의 역할은 OAuthRestClientProvider가 자신의 구현체가 무엇인지 모르기 때문에 KakaoOAuthTokenResponseDTO, NaverOAuthTokenResponseDTO 등의 구체적인 ResponseDTO를 모르게 하기 위함이다.
이제 카카오 공식 문서를 보면서 KakaoOAuthTokenResponseDTO를 만들어 주면 된다.
@Getter
public class KakaoOAuthTokenResponseDTO extends OAuthTokenResponseDTO {
@JsonProperty("token_type")
private String tokenType;
@JsonProperty("access_token")
private String accessToken;
@JsonProperty("expires_in")
private Integer expiresIn;
@JsonProperty("refresh_token")
private String refreshToken;
@JsonProperty ("refresh_token_expires_in")
private Integer refreshTokenExpiresIn;
@JsonProperty("scope")
private String scope;
}
이 때, 반드시 최상위 DTO인 OAuthTokenResponseDTO를 상속해 주게 만들었다.
public interface OAuthRestClientProvider {
OAuthTokenResponseDTO getOAuthTokenResponseDTO(String authorizationCode);
}
@Component
@RequiredArgsConstructor
public class KakaoRestClientManager implements OAuthRestClientProvider{
private final RestClient restClient;
private final KakaoProperties properties;
@Override
public KakaoOAuthTokenResponseDTO getOAuthTokenResponseDTO(String authorizationCode){
KakaoOAuthTokenRequestDTO oAuthTokenRequestDTO = KakaoOAuthTokenRequestDTO.from(authorizationCode,properties);
return restClient.post()
.uri(KAKAO_OAUTH_TOKEN_REQUEST_URL)
.contentType(KAKAO_OAUTH_TOKEN_CONTENT_TYPE)
.body(oAuthTokenRequestDTO.convertAllFieldsToMultiValueMap())
.retrieve()
.body(KakaoOAuthTokenResponseDTO.class);
}
}
이제 다시 KakaoRestClientManager로 돌아왔다. 해당 클래스는 카카오 로그인 만을 위한 구현체이기 때문에 getOAuthTokenResponseDTO()의 구현체를 KakaoOAuthTokenResponseDTO로 선언해도 문제가 되지 않는다.
이제 정말 RestClient를 사용해서 구현만 해주면 된다.
이전에 공식문서에서 확인했던 정보들을 바탕으로 KakaoOAuthTokenRequestDTO를 만들어 줄 것이다.
@Getter
public class KakaoOAuthTokenRequestDTO {
private final String grantType;
private final String clientId;
private final String redirectUri;
private final String code;
private final String clientSecret;
@Builder(access = AccessLevel.PRIVATE)
private KakaoOAuthTokenRequestDTO(String clientId, String redirectUri, String clientSecret, String code){
this.grantType = "authorization_code";
this.clientId = clientId;
this.redirectUri = redirectUri;
this.clientSecret = clientSecret;
this.code = code;
}
public static KakaoOAuthTokenRequestDTO from(String authorizationCode, KakaoProperties properties){
return KakaoOAuthTokenRequestDTO.builder()
.clientId(properties.getClientId())
.clientSecret(properties.getClientSecret())
.code(authorizationCode)
.redirectUri(properties.getRedirectUri())
.build();
}
}
위와 같이 KakaoOAuthTokenRequestDTO를 만들어주고 내부에 빌더를 사용해서 해당 객체를 생성 할 수 있는 정적 팩토리 메서드를 하나 만들어 줬다.
@Component
@RequiredArgsConstructor
public class KakaoRestClientManager implements OAuthRestClientProvider{
private final RestClient restClient;
private final KakaoProperties properties;
@Override
public KakaoOAuthTokenResponseDTO getOAuthTokenResponseDTO(String authorizationCode){
KakaoOAuthTokenRequestDTO oAuthTokenRequestDTO = KakaoOAuthTokenRequestDTO.from(authorizationCode,properties);
...
}
}
이제 KakaoRestClientManager에서 KakaoOAuthTokenRequestDTO를 생성했다.
@Override
public KakaoOAuthTokenResponseDTO getOAuthTokenResponseDTO(String authorizationCode){
KakaoOAuthTokenRequestDTO oAuthTokenRequestDTO = KakaoOAuthTokenRequestDTO.from(authorizationCode,properties);
return restClient.post()
.uri(KAKAO_OAUTH_TOKEN_REQUEST_URL)
.contentType(KAKAO_OAUTH_TOKEN_CONTENT_TYPE)
.body(oAuthTokenRequestDTO) // 에러 발생 지점
.retrieve()
.body(KakaoOAuthTokenResponseDTO.class);
}
}
그 후, RestClient의 사용법에 따라서 요청을 전송하려 했다. 처음 retrieve()를 만나기 전까지 메서드 체인들은 Request와 관련되어 있다. 해당 Request의 메시지 바디에 내가 만든 KakaoOAuthTokenRequestDTO를 담아주면 RestClient가 HTTP Message Converter를 잘 사용해서 이를 Form-Data로 바꿔 줄 것이라고 기대했다. 하지만 이 부분에서 에러가 발생했다.
공식문서를 살펴보니 어떤 객체를 HTTPMessageConverter를 이용해서 Form Data로 바꿔주고 싶으면 MultiValueMap이라는 것을 이용하라고 적혀있었다.
public class MultiValueMapConverter {
public static MultiValueMap<String, String> toMultivalueMap(KakaoOAuthTokenRequestDTO requestDTO){
MultiValueMap<String, String> formDataParameters = new LinkedMultiValueMap<>();
formDataParameters.add("grant_type", requestDTO.getGrantType());
formDataParameters.add("client_id", requestDTO.getClientId());
formDataParameters.add("redirect_uri", requestDTO.getRedirectUri());
formDataParameters.add("code", requestDTO.getCode());
formDataParameters.add("client_secret", requestDTO.getClientSecret());
return formDataParameters;
}
}
따라서 위와 같은 Converter를 하나 만들어 줬다.
public KakaoOAuthTokenResponseDTO getOAuthTokenResponseDTO(String authorizationCode){
KakaoOAuthTokenRequestDTO oAuthTokenRequestDTO = KakaoOAuthTokenRequestDTO.from(authorizationCode,properties);
return restClient.post()
.uri(KAKAO_OAUTH_TOKEN_REQUEST_URL)
.contentType(KAKAO_OAUTH_TOKEN_CONTENT_TYPE)
.body(MultiValueMapConverter.toMultivalueMap(oAuthTokenRequestDTO)) // 변경
.retrieve()
.body(KakaoOAuthTokenResponseDTO.class);
}
그 후, KakaoOAuthTokenRequestDTO를 Form Data로 바꿔줬다. retrieve() 아래에 있는 body()는 Response Body에 관한 메서드인데 카카오에 요청을 보낸 후 미리 JSON 형식으로 돌아오는 응답을 받아줄 KakaoOAuthTokenResponseDTO를 매개변수로 넣어주면 RestClient가 HTTPMessageConverter 중 JSON을 객체로 변환해 주는 Converter를 이용해서 이를 반환해 준다.
지금까지 사용자가 카카오 로그인을 통해서 들고온 임시인증 코드를 통해서 OAuth Access Token을 발급 받는 과정을 정리하면 위의 그림의 빨간 화살표를 따라 가면 된다.
이제 Step 2가 끝났다. 남은 일은 처음 로그인 하는 회원이라면 회원가입 처리를 하고 JWT Access Token과 JWT Refresh Token을 발급해 주는 일이다.
<OAuthAccessToken으로 사용자 Resource 받아오기>
@Service
@Transactional
@RequiredArgsConstructor
public class KakaoLoginService implements OAuth2LoginService {
private final KakaoProperties properties;
private final OAuthHttpClient httpClient;
@Override
public String getLoginPageUrl() {
return properties.getLoginUrlWithParams();
}
@Override
public Map<String, String> loginWithOAuthAndIssueJwt(String authorizationCode, String provider) {
KakaoOAuthTokenResponseDTO oAuthTokens = (KakaoOAuthTokenResponseDTO) httpClient.getOAuthTokens(authorizationCode, provider);
String accessToken = oAuthTokens.getAccessToken();
log.info("카카오 인증서버에서 Access Token 받아오기 성공: ");
...
}
}
다시 KakaoLoginService로 돌아왔다. 우선 현재 로그인 한 회원이 신규 회원인지 체크한 후, 신규회원 이라면 회원가입을 시켜줘야 한다. 로그인한 회원이 신규 회원인지 판별하기 위해서 사용자의 OAuth Access Token을 통해 사용자의 Resource를 얻어오고 그 중에서 절대로 변하지 않는 유니크한 필드를 통해서 MemberRepository에 existby를 요청해 보면 될 것이다. 따라서 회원가입에 앞서 OAuth Access Token을 통해서 사용자의 Resource 부터 받아 오기로 했다.
생각보다 OAuth Access Token을 통해서 카카오 Resource Server와 통신해서 Resource를 받아오는 일은 간단했다. 이전에 구현했던 RestClient를 이용한 Request의 흐름도를 보면서 필요한 메서드만 각각의 요소에 추가해 주면 된다.
public interface OAuthHttpClient {
OAuthTokenResponseDTO getOAuthTokens(String authorizationCode, String provider);
OAuthUserResourceResponseDTO getOAuthUserResources(String accessToken, String provider);
}
@Service
@Transactional
@RequiredArgsConstructor
public class KakaoLoginService implements OAuth2LoginService {
private final KakaoProperties properties;
private final OAuthHttpClient httpClient;
...
@Override
public Map<String, String> loginWithOAuthAndIssueJwt(String authorizationCode, String provider) {
KakaoOAuthTokenResponseDTO oAuthTokens = (KakaoOAuthTokenResponseDTO) httpClient.getOAuthTokens(authorizationCode, provider);
log.info("카카오 인증서버에서 Access Token 받아오기 성공 ");
KakaoUserResourceResponseDTO userInfo = (KakaoUserResourceResponseDTO) httpClient.getOAuthUserResources(oAuthTokens.getAccessToken(), provider);
log.info("사용자 Resource 받아오기 성공");
...
}
우선 OAuthHttpClient 인터페이스에 getOAuthUserResources()라는 메서드를 추가해 주고 KakaoLoginService에서 해당 메서드를 호출해 준다.
public interface OAuthRestClientProvider {
OAuthTokenResponseDTO getOAuthTokenResponseDTO(String authorizationCode);
// 추가
OAuthUserResourceResponseDTO getOAuthUserResourceResponseDTO(String accessToken);
}
@Component
@RequiredArgsConstructor
public class OAuthHttpRestClient implements OAuthHttpClient{
private final Map<String,OAuthRestClientProvider> oAuthRestClientProviderMap;
@Override
public OAuthTokenResponseDTO getOAuthTokens(String authorizationCode, String provider) {
return oAuthRestClientProviderMap.get(provider + REST_CLIENT_MANAGER_SUFFIX).getOAuthTokenResponseDTO(authorizationCode);
}
// 추가
@Override
public OAuthUserResourceResponseDTO getOAuthUserResources(String accessToken, String provider) {
return oAuthRestClientProviderMap.get(provider + REST_CLIENT_MANAGER_SUFFIX).getOAuthUserResourceResponseDTO(accessToken);
}
}
그 후, OAuthHttpRestClient에 이전처럼 OAuthRestClientProviderMap을 이용해서 메서드를 구현했다.
@Component
@RequiredArgsConstructor
public class KakaoRestClientManager implements OAuthRestClientProvider{
private final RestClient restClient;
...
@Override
public OAuthUserResourceResponseDTO getOAuthUserResourceResponseDTO(String accessToken) {
return restClient.get()
.uri(KAKAO_USER_INFO_API_URL)
.header(AUTHORIZATION_HEADER, BEARER_TOKEN_TYPE + accessToken)
.retrieve()
.body(KakaoUserResourceResponseDTO.class);
}
}
마지막으로 공식문서의 요구사항 대로 RestClient를 이용해서 사용자의 Resource 조회 요청을 보냈다.
{
"id": "회원번호"
"connected_at": "연결된 시간",
"properties": {
"nickname": "카카오 닉네임",
"profile_image": "프로필 사진",
"thumbnail_image": "프로필 썸네일 사진" },
"kakao_account": {
"profile_nickname_needs_agreement": false,
"profile_image_needs_agreement": false,
"profile": {
"nickname": "카카오 닉네임",
"thumbnail_image_url": "썸네일 사진 URL",
"profile_image_url": "프로필 사진 URL",
"is_default_image": true,
"is_default_nickname": false
},
"has_email": true,
"email_needs_agreement": false,
"is_email_valid": true,
"is_email_verified": true,
"email": "이메일 주소"
}
}
Resource 조회 요청을 하면 위와 같은 JSON 형태의 응답이 돌아온다. 전달받은 데이터 중 내 서비스에서 사용해야 할 데이터로는 회원번호, 닉네임, 프로필 사진 이미지 URL, 이메일 주소가 있다.
따라서 id , kakao_account.profile.nickname , kakao_account.profile.profile_image_url , kakao_account.email 이렇게 4가지 데이터를 받아서 사용하면 될것이다.
@Getter
public class KakaoUserResourceResponseDTO extends OAuthUserResourceResponseDTO{
@JsonProperty("id")
private Long appId;
@JsonProperty("kakao_account")
private KakaoAccount kakaoAccount;
@Getter
public static class KakaoAccount {
@JsonProperty("email")
private String email;
@JsonProperty("profile")
private Profile profile;
}
@Getter
public static class Profile {
@JsonProperty("nickname")
private String nickname;
@JsonProperty("profile_image_url")
private String profileImageUrl;
}
}
이를 위해 위와같은 KakaoUserResourceResponseDTO를 만들었다.
이렇게 이전과 똑같은 과정으로 사용자의 Resource를 조회해 오는 것이 끝났다. 이제 신규 회원 여부를 체크하고 회원가입을 진행해야 하는데 카카오의 Resource에는 id라는 값이 항상 같이 돌아왔다. 이는 내 서비스와 카카오 로그인 서비스 간의 특정 회원에 대한 고유한 번호라고 한다.
따라서 이 값은 MemberRepository를 통해 특정 회원을 식별할 수 있는 유니크한 값으로 사용 할 수 있다. 참고로 사용자의 카카오 이메일은 사용자가 카카오톡에 들어가서 사용자가 대표 이메일 변경하기를 한다면 언제든지 변경될 수 있기 때문에 식별자로 적절하지 않았다. 이미 Member에는 member_id가 존재하기 때문에 이 id는 appId라고 지칭하기로 했다.
<OAuth와 관련한 정보들 테이블로 저장>
OAuth, JWT를 사용하면서 로그인을 진행하다보니 어떤 정보들을 서버에 저장 할지 고민해 봐야 했다. 예를 들어 OAuth Access Token, OAuth Access Token expires in, OAuth Refresh Token, OAuth Refresh Token expires in 등등 OAuth Authorization Server에 사용자의 임시 코드를 전송하고 받아온 값들 중 무엇을 저장할지 생각해 봐야 했고 JWT Access Token, JWT Refresh Token과 관련된 값들 중 어떤 것들을 서버측에 저장할지 생각해 봐야 했다.
이런 데이터들을 Member 테이블에 모두 저장하게 된다면 로그인과 관련 없는 로직에서 Member가 사용될 때 불필요한 필드들이 함께 딸려오게 될 것이다.
따라서 AuthInfo라는 테이블을 새로 만들고 Member와 1대1 관계로 설정해서 해당 테이블에 로그인과 관련된 데이터들을 저장해야 겠다고 생각했다. 우선 OAuth Access Token과 관련된 데이터들은 내 서비스에서 별도로 저장할 필요는 없었다.
로그인이 성공적으로 이루어지면 OAuth Access Token을 이용해서 사용자의 Resource 정보를 받아오고 받아온 appId에 해당하는 Member가 DB에 존재하지 않는다면 회원가입을 진행한 후 더이상 OAuth 관련 토큰들이 필요하지 않다.
만약 내 서비스에서 카카오 로그인 외에도 카카오 지도, 달력 등 사용자의 Resource를 계속 받아와야 하는 요구사항이 있다면 해당 토큰들을 DB에 저장해두고 프론트와 잘 주고 받으면서 추가적인 작업을 해주면 되지만 일단 당장은 저장할 필요가 없었다.
대신 사용자의 Resource를 받아 올 때 같이오는 Id 값을 appId로 저장해 줬고 어떤 서비스사의 로그인 API를 이용하는 것인지 식별 할 수 있는 provider또한 저장해 두기로 했다. JWT와 관련해서 저장해야 할 정보들은 아래에서 JWT를 사용할 때 다루려고 한다.
위와 같이 AuthInfo 테이블을 추가했다. 한 명의 회원은 단 하나의 AuthInfo만을 갖는다. 따라서 Member와 AuthInfo는 1대1 관계이다. 여기서 AuthInfo와 Member중 누가 외래키를 가져가야 할지를 고민해 봐야 했다. 1대1 관계에서 외래키는 누가 가져도 동작하는 데 문제는 없다.
우선 AuthInfo에 외래키로 memberId를 넣는 경우부터 생각해 봤다. AuthInfo는 Member와 다르게 서비스 전체에서 많이 사용되지 않는다. 또한 Member의 부속 테이블 같은 느낌이 강하다. 반면 Member는 서비스 전체에서 아주 많이 사용된다. 이럴 경우 Member를 주 테이블, AuthInfo를 부 테이블 이라고 부를 수 있다.
현재 부 테이블이 외래키를 가지고 있기 때문에 주 테이블인 Member는 조금 더 깔끔하고 순수하게 자신이 필요한 속성들만 가지고 있는 느낌이다. 다만 나는 JPA를 사용하는데 JPA에서 부 테이블이 외래키를 관리할 경우 연관관계를 잘 생각해 줘야 했다.
지금처럼 부 테이블에 외래키가 존재하는데 주 테이블의 엔티티인 Member가 이를 관리하게 하는 것은 JPA 스펙상 불가능하다. 이럴 경우 반드시 양방향 연관관계 매핑을 사용해 줘야한다. AuthInfo에게 외래키와 주인까지 모두 주는 것도 가능하지만 아무래도 AuthInfo는 Member의 부속품 같은 느낌이 강해서 나는 꼭 Member에 외래키를 관리할 주인까지 넘겨 주고 싶었다.
그렇다고 양방향 매핑을 써서 Member에게 주인을 주면 지연로딩이 JPA 스펙상 지원되지 않아서 항상 Member와 AuthInfo는 함께 붙어 다니게 된다. 이는 AuthInfo라는 테이블을 Member에서 따로 분리한 목적과 맞지 않다.
또한 부 테이블에 외래키가 존재하면 코드를 작성할 때 Auth.member와 같은 방식으로 접근을 해야해서 코드의 의미가 조금 이상해진다.
그래서결국 Member에게 외래키를 주고 연관관계의 주인 역시 주 테이블인 Member에게 넘겨주기로 결정했다. 이 경우 기존의 다른 @ManyToOne과 동일하게 사용하면 되는데 외래키에 Unique가 걸려있는 정도로만 생각하면 돼서 굉장히 친숙했다.
또한 주 테이블인 Member에 연관관계의 주인을 줘도 지연로딩을 사용할 수 있고 AuthInfo가 필요한 경우 Member.authinfo로 접근이 가능해진다.
다만, Member에게 AuthInfo 값이 없다면 Member의 외래키에 NULL이 허용된다는 문제가 있다. 하지만 내 서비스에서 AuthInfo는 Member 객체가 생성되는 동시에 항상 같이 생성되기 때문에 이는 문제되지 않을 을것이라고 생각했다.
결국 최대한 Member 테이블 만큼은 순수하게 유지시키고 싶어서 부 테이블에 외래키를 주려고 했었는데 이는 생각보다 좋지 않은 선택지 일 수 있겠다는 생각이 들어 Member 테이블에 외래키를 주기로 결정했다.
@OneToOne (fetch = LAZY, cascade = CascadeType.ALL)
@JoinColumn (name = "auth_info_id", unique = true)
private AuthInfo authInfo;
또한 AuthInfo의 경우 Member와만 연관관계가 있는 Member의 부속품이기 때문에 cascade.ALL을 설정해 줬다. 이제 회원가입이 진행될 때 AuthInfo는 Member가 Save 되는 시점에 저장되고 회원 탈퇴가 진행되면 Member가 Remove되는 시점에 함께 삭제된다.
<신규 회원여부 체크 후 회원가입>
@Service
@Transactional
@RequiredArgsConstructor
public class KakaoLoginService implements OAuth2LoginService {
private final KakaoProperties properties;
private final OAuthHttpClient httpClient;
private final MemberRepository memberRepository;
private final AuthInfoRepository authInfoRepository;
@Override
public Map<String, String> loginWithOAuthAndIssueJwt(String authorizationCode, String provider) {
KakaoOAuthTokenResponseDTO oAuthTokens = (KakaoOAuthTokenResponseDTO) httpClient.getOAuthTokens(authorizationCode, provider);
log.info("카카오 인증서버에서 Access Token 받아오기 성공 ");
KakaoUserResourceResponseDTO resources = (KakaoUserResourceResponseDTO) httpClient.getOAuthUserResources(oAuthTokens.getAccessToken(), provider);
log.info("사용자 Resource 받아오기 성공");
if(!authInfoRepository.existsByAppId(resources.getAppId())) signUp(resources);
...
}
}
이제 다시 KakaoLoginService로 돌아와서 신규회원 여부를 체크하고 회원가입을 진행해 주면 된다. 내 서비스에서 회원가입시 항상 해당 회원에 대한 AuthInfo를 만든 후 Member와 1대1 관계로 연결해 save를 진행하고 회원 탈퇴시 Member와 AuthInfo를 삭제한다.
따라서 OAuth Resource Server에서 받아온 Id인 appId를 통해서 AuthInfo를 조회해 봤을때 이미 DB에 AuthInfo가 존재하고 있다면 기존 회원이고 존재하지 않는다면 신규회원으로 판단 가능하다. 신규 회원의 경우 signup()을 호출한다.
private void signUp(KakaoUserResourceResponseDTO resources) {
String nickname = resources.getKakaoAccount().getProfile().getNickname();
nickname = NicknameResolver.adjustNicknameLength(nickname);
while (memberRepository.existsByNickname(nickname)) nickname = NicknameResolver.adjustDuplicateNickname(nickname);
AuthInfo newMemberAuthInfo = AuthInfo.from(resources.getAppId(), KAKAO_OAUTH_PROVIDER_NAME);
Member newMember = Member.from(newMemberAuthInfo, nickname, resources.getKakaoAccount().getEmail(),
resources.getKakaoAccount().getProfile().getProfileImageUrl());
memberRepository.save(newMember);
}
signup()의 경우 memberRepositroy를 필수로 사용해야 하기 때문에 이를 객체로 분리해 주지는 않았다. 대신 내 서비스에서 회원간 중복 닉네임이나 10자가 넘어가는 닉네임은 허용하고 있지 않기 때문에 NicknameResolver를 따로 만들어 이를 조정했다.
그 후, 신규 회원의 AuthInfo와 Member를 생성해서 이를 save했다. AuthInfo의 경우 cascade 설정으로 인해 member가 persist 될 때 함께 persist 된다.
<JWT 발급>
@Service
@Transactional
@RequiredArgsConstructor
public class KakaoLoginService implements OAuth2LoginService {
private final KakaoProperties properties;
private final OAuthHttpClient httpClient;
private final MemberRepository memberRepository;
private final AuthInfoRepository authInfoRepository;
@Override
public Map<String, String> loginWithOAuthAndIssueJwt(String authorizationCode, String provider) {
KakaoOAuthTokenResponseDTO oAuthTokens = (KakaoOAuthTokenResponseDTO) httpClient.getOAuthTokens(authorizationCode, provider);
log.info("카카오 인증서버에서 Access Token 받아오기 성공 ");
KakaoUserResourceResponseDTO resources = (KakaoUserResourceResponseDTO) httpClient.getOAuthUserResources(oAuthTokens.getAccessToken(), provider);
log.info("사용자 Resource 받아오기 성공");
if(!authInfoRepository.existsByAppId(resources.getAppId())) signUp(resources);
// jwt 발급
return issueJwtTokens(resources.getAppId());
}
}
이제 OAuth의 역할은 완전히 끝났고 JWT만 성공적으로 발급하면 된다. JWT를 발급 처리를 위해서 우선 Member가 필요하다.
현재 Member, AuthInfo의 클래스간 관계는 위와 같다. OAuth Resource Server에서 사용자의 email, nickname, profileImg, appId를 받아 왔지만 식별자로 사용할 수 있는 값은 appId 밖에 없다. email, nickname, profileImg는 사용자가 카카오톡에서 변경이 가능해 회원가입 때 입력했던 값과 달라질 수 있다. provider는 유니크 한 값이 아니기 때문에 이용 할 수 없다.
따라서 appId를 통해서 사용자의 AuthInfo를 DB에서 조회해 오고 AuthInfo를 통해서 Member를 DB에서 조회해 와야 한다. Request, Response를 나눠서 생각하면 이 작업을 위해서 내 앱은 DB와 네트워크를 통해 총 4번 소통하게 된다. 이를 한번에 조회해서 절반인 2번으로 줄일 방법이 필요했다.
@Override
public Member findMemberAndAuthInfoByAppId(Long appId) {
return jpaQueryFactory
.selectFrom(member)
.join(member.authInfo, authInfo)
.fetchJoin()
.where(authInfo.appId.eq(appId))
.fetchOne();
}
편리하게 JPQL을 직접 작성하기 위해서 JPQL 빌더 역할을 할 수 있는 QueryDSL을 사용했고, Member와 AuthInfo를 페치조인하고 appId를 통해 필터링 후 데이터를 가져오는 것을 생각해 봤다. 페치조인 하지 않고 그냥 조인만 사용할 경우 두 테이블을 잘 조인해서 Member만 가져온 후 영속성 컨텍스트에 AuthInfo는 저장되지 않는다. 이럴 경우 추후 member.getAuthInfo()를 호출하게 되면 영속성 컨텍스트에서는 AuthInfo를 찾아오기 위해 다시 DB에 조회를 요청하게 된다. 따라서 페치 조인을 사용했다.
하지만 이럴 경우 Member와 AuthInfo의 모든 데이터를 조인하고 필터링 하기 때문에 DB 서버의 성능 저하로 이어질 것 같았다.
이에 대한 해결책은 성능 모니터링 환경이 모두 구축되고 DB 공부를 더 한다음 성능 개선을 할 때 별도로 다뤄 볼 생각이고 일단은 조인을 이용했다. 일단 DB와 내 앱 사이의 커넥션은 4번에서 2번으로 줄일 수 있었다.
private Map<String, String> issueJwtTokens(Long appId) {
Member member = memberRepository.findMemberAndAuthInfoByAppId(appId);
...
}
이제 현재 로그인한 회원의 Member, AuthInfo를 확보했으니 JWT를 발급만 하면 된다.
@Getter
public class JwtPayloadCreateDTO {
private final Long memberId;
private final String provider;
@Builder(access = AccessLevel.PRIVATE)
private JwtPayloadCreateDTO(Long memberId, String provider){
this.memberId = memberId;
this.provider = provider;
}
public static JwtPayloadCreateDTO from(Long memberId, String provider){
return JwtPayloadCreateDTO.builder()
.memberId(memberId)
.provider(provider)
.build();
}
}
우선 JWT Payload에 담아 줄 정보를 담당하는 DTO를 하나 만들었다. 조회해온 Member에서 JWT를 만들 때 필요한 최소한의 정보를 해당 DTO에 담아서 JWT 발급을 담당할 JwtProvider에게 넘겨주면 된다.
어떤 정보까지 해당 DTO에 nickname 같은 정보까지 담아줄까 생각을 해봤는데 나의 서비스에는 닉네임 변경하기라는 기능이 있었다. 이 때, 만약 어떤 사용자가 닉네임을 변경하게 되면 JWT 또한 새로 발급해줘야 하는 문제가 발생했다. 닉네임 변경 하나를 위해서 로직이 늘어나는 것도 문제인데 닉네임이 변경 되어서 사용자의 Access Token을 교체해 준다고 해서 기존의 Access Token이 만료처리 되지는 않기 때문에 Access Token이 여러개가 되는 문제가 발생했다. 따라서 회원의 정보중 변경가능한 데이터는 Payload에 담지 않기로 했다.
// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
JWT를 생성하고 검증하고 읽는등의 작업은 내가 직접 구현하지 않고 외부 라이브러리의 도움을 받을 생각이다. 따라서 위와 같은 의존성을 추가해주면 된다.
@Component
public class JwtProvider {
private final SecretKey secretKey;
public JwtProvider(@Value("${spring.jwt.secret}") String secret) {
secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
}
...
}
그 후, JWT Provider를 구현했다. secretKey에는 application.yml에 있는 JWT발급 관련 비밀 키를 주입 받게 했다. 이 때, 원래는 String에 주입 받으려고 했는데 Java의 암호화 관련 API에서 제공하는 SecretKey라는 인터페이스를 제공했다. 이를 사용하면 JWT에 쓰이는 HMAC 같은 암호 생성기를 사용할 때, 서명이나 암호화를 쉽고 안전하게 처리할 수 있도록 도와준다고 한다.
SecretKeySpec은 Java Cryptography Extension (JCE)에서 제공하는 클래스인데 SecretKey 인터페이스의 구현체를 생성할 때 사용된다. 해당 클래스의 생성자의 첫번째 파라미터로는 시크릿 키 값의 바이트 배열을, 두 번째 파라미터에는 암호화 알고리즘을 필요로 한다. Jwts.SIG.HS256.key().build().getAlgorithm()은 HMAC-SHA256 (HS256) 서명 알고리즘을 지정하는 코드다. 즉, Secret key를 안전하게 사용하기 위해서 String이 아닌 SecretKey라는 객체에 저장 할건데 해당 객체를 초기화 하기 위해서 SecretKeySpec클래스를 이용할 수 있다.
@Component
public class JwtProvider {
private final SecretKey secretKey;
public JwtProvider(@Value("${spring.jwt.secret}") String secret) {
secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
}
public String createJwtAccessToken(JwtPayloadCreateDTO payload) {
return Jwts.builder()
.claim("tokenType", "jwt_access")
.claim("memberId", payload.getMemberId())
.claim("OAuthProvider", payload.getProvider())
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRATION_MS))
.signWith(secretKey)
.compact();
}
...
}
이제 비밀키와 payload를 이용해서 서명을 생성하고 JWT를 발급 받으면 된다. 이 때, 해당 토큰이 Access Token인지 Refresh Token인지 구분하기 위해 tokenType을 payload에 추가했다.
Refresh Token도 같은 방법으로 발급하는 메서드를 만들어 주면 된다. Refresh Token에 관해서는 아래에서 자세하게 다루기로 하고 일단은 다시 KakaoLoginService로 넘어왔다.
private Map<String, String> issueJwtTokens(Long appId) {
Member member = memberRepository.findMemberAndAuthInfoByAppId(appId);
JwtPayloadCreateDTO payload = JwtPayloadCreateDTO.from(member.getId(), KAKAO_OAUTH_PROVIDER_NAME);
String jwtAccessToken = jwtProvider.createJwtAccessToken(payload);
String jwtRefreshToken = jwtProvider.createJwtRefreshToken(payload);
// 서버에 refresh를 저장하는 임시 로직, 수정 될 부분
member.getAuthInfo().updateAuthInfoByJwtIssue(jwtRefreshToken, jwtPayloadReader.getExpiration(jwtRefreshToken));
log.info("JWT 발급 성공!");
return Map.of(
JWT_ACCESS_TOKEN, jwtAccessToken,
JWT_REFRESH_TOKEN, jwtRefreshToken
);
}
JwtProvider를 통해서 JWT를 발급한 후 LoginController에 이를 반환해 주게 만들었다. 중간에 발급된 토큰 중 Refresh Token에 관련된 정보를 서버 측에 저장하는 로직이 추가 되었는데 이는 아래에서 Refresh Token 관련 내용을 다룰때 다시 살펴보자.
코드를 완성하고 나니 조금 쎄한 느낌이 들었다. KakaoLoginService를 이용하든 NaverLoginService를 이용하든 LoginService에서 JWT를 발급해 주는 로직은 모두 공통 부분이다. 이를 담당할 객체를 만들지 않고 KakaoLoginService에 직접 코드를 다 넣어버리게 되면 추후 다른 로그인들이 추가되면 똑같은 로직을 또 구현해야 하고 변경이라도 발생하면 여러가지 로그인 서비스를 모두 변경해 줘야한다.
@Component
@AllArgsConstructor
public class JwtTokenManager {
private final JwtProvider jwtProvider;
private final JwtPayloadReader jwtPayloadReader;
public Map<String, String> issueJwtTokens(Member member, String provider) {
JwtPayloadCreateDTO payload = JwtPayloadCreateDTO.from(member.getId(),provider);
String jwtAccessToken = jwtProvider.createJwtAccessToken(payload);
String jwtRefreshToken = jwtProvider.createJwtRefreshToken(payload);
// 서버측에 refresh token을 저장하는 문제의 부분
member.getAuthInfo().updateAuthInfoByJwtIssue(jwtRefreshToken, jwtPayloadReader.getExpiration(jwtRefreshToken));
return Map.of(
JWT_ACCESS_TOKEN, jwtAccessToken,
JWT_REFRESH_TOKEN, jwtRefreshToken
);
}
}
그래서 JwtTokenManager라는 것을 만들고 JWT와 관련된 로직을 담당하게 했다. 하지만 JWT Refresh Token을 DB에 저장하는 부분에서 문제가 발생했다.
클라이언트가 인증, 인가가 필요한 API를 이용하다가 자신의 액세스 토큰이 만료되면 직접 다시 로그인 하지 않고 리프레시 토큰을 서버로 보내 액세스 토큰을 재 발급 받아서 사용 할 수 있다.
액세스 토큰은 네트워크 상에서 빈번히 돌아다니기 때문에 탈취 위험이 높고, 이를 대비하기 위해 액세스 토큰은 만료시간을 매우 짧게 가져가고, 리프레시 토큰은 만료 시간을 길게 가져가고 액세스 토큰 재발급시 에만 네트워크 상에서 돌아다니게 된다.
이런식으로 액세스 토큰 탈취에 대해서는 대비를 했지만, 리프레시 토큰도 액세스 토큰에 비해서 덜 주고 받을 뿐이지 탈취될 위험이 존재한다. 이를 막아주기 위해서 리프레시 토큰을 통해서 액세스 토큰을 재발급 할 때, 리프레시 토큰도 함께 재발급 해서 클라이언트에게 넘겨주는 리프레시 토큰 회전 방식을 사용한다.
하지만, 클라이언트가 가지고 있는 기존의 리프레시 토큰을 새 것으로 교체 해주는 이 과정에서 기존의 리프레시 토큰은 아직 유효한 리프레시 토큰이다. 리프레시 토큰을 교체해준 것이지, 강제로 만료 시킨게 아니라서 기존의 리프레시 토큰은 아직 만료기간이 지나지 않았다. 즉, 누군가 리프레시 토큰을 통한 액세스 토큰 재발급 요청에서 리프레시 토큰을 탈취했거나 최초로 리프레시 토큰을 발급 받던 시점에 탈취당한 적이 있다면 리프레시 토큰 회전 방식을 사용해도 리프레시 토큰 탈취를 방어 할 수가 없어진다.
즉, 강제로 기존의 리프레시 토큰을 폐기하고 새로운 리프레시 토큰을 재 발급 해줄 방법이 필요하다. 이를 위해서 서버측의 저장소에 기존의 리프레시 토큰을 저장해 두고, 해당 저장소에 들어있는 리프레시 토큰은 모두 블랙리스트로 관리할 수 있다.
즉, 만료 시간이 긴 기존의 리프레시 토큰들을 강제로 만료 처리 할 수 있는 방법이 없으니 서버 측에서 각 회원별 리프레시 토큰 블랙리스트를 저장하고 있다가 클라이언트에서 액세스토큰 재발급을 위해서 리프레시 토큰을 보내온다면 블랙리스트 된 토큰인지 확인하는 방법을 사용하는 건데 나는 이런 과정을 구현하기 위해서 기존의 토큰들을 블랙리스트 처리하나 현재 회원의 유효한 리프레시 토큰을 화이트리스트로 생각하고 서버에 저장하고 있다가 화이트 리스트에 있는 토큰을 제외하고는 모두 블랙리스트로 간주하나 똑같지 않나? 라는 생각을 했다.
이를 위해서 Member의 AuthInfo에 리프레시 토큰을 저장하기로 했고 최초 로그인시 이를 업데이트 하는 로직을 넣어줬던 것이었다.
위와 같이 회원 1명당 단 한개의 유효한 refresh token만 화이트 리스트로 갖고 있다가 액세스 토큰 재발급시 기존의 리프레시 토큰도 같이 재 발급 하고 화이트 리스트 토큰도 업데이트 해주면 되겠다고 생각했다.
하지만 만약 PC로 로그인해서 내 서비스를 사용하던 사용자가 모바일 웹 브라우저로 내 서비스에 로그인 하게 되면 PC쪽에서는 액세스 토큰이 만료되어 자신의 리프레시 토큰을 보내게 되면 이미 블랙리스트 처리된 토큰이라는 에러가 발생한다. 즉, 한 아이디로 여러기기에서 동시에 로그인 하는 것을 고려하지 않았다.
실제로 서로 다른 웹 브라우저에서 테스트를 해본 결과 이전에 접속했던 웹 브라우저는 액세스 토큰 재발급 요청을 위해서 리프레시 토큰을 보내자 차단된 토큰으로 인한 비즈니스 예외가 발생했다.
이렇게 여러기기나 여러명이 한번에 로그인 해서 문제가 발생하는 게 싫으면 세션 방식의 로그인을 사용해야 했다. 하지만 내 서비스의 경우 한 아이디로 단 한명의 사람이나 기기만 허용할 필요가 없는 서비스 라고 생각했고 토큰 방식의 로그인을 유지하고 싶었다.
이를 위해서는 리프레시 토큰 화이트 리스트를위한 별도의 테이블이 필요했다. 또한, 리프레시 토큰을 통한 재발급 요청이 발생하면 DB에서 해당 회원의 member_id로 모든 refresh Token을 조회 하는 과정이 필요했다.
하지만 사용자의 인증, 인가등의 작업과 관련된 정보를 담당하는 테이블이 이미 존재 했고 여러가지 화이트 리스트를 컬렉션으로 한 속성에 바로 저장할 수 있으면 좀 더 직관적인 ERD가 나올 것 같았다. 나는 MySQL을 사용하고 있고 해당 DB에서는 JSON 형태의 String으로 {"refresh1", "refresh2",...}등을 저장해 두었다가 이를 내 서버에서 파싱해서 사용하는 방법을 제공했다.
이렇게 문제가 해결 되는 줄 알았는데 만약 어떤 사용자가 로그인 하고 refresh Token 만료기간보다 더 오래 활동하지 않다가 다시 로그인 하고 또 refresh Token 만료기간 보다 더 오래 활동하지 않다가 다시 로그인하는 것을 반복한다면 큰 문제가 발생했다. 리프레시 토큰의 만료기간이 액세스 토큰보다 길기는 하지만 보통 길어야 몇일에서 1주일 정도이기 때문에 이는 충분히 발생할 수 있는 시나리오 였다.
여기에 사용자가 여러기기에서 동시 로그인 하는 상황까지 고려하면 화이트 리스트 관리는 더욱 어려워 지게 된다. 이런 문제가 발생하게 되면 사용자의 AuthInfo에 화이트 리스트인 refresh Token은 계속 쌓여나갈 것이다. 이런 일이 발생한 이유는 서버측에서 재발급에 활용되지 않더라도 유효기간 자체가 만료된 리프레시 토큰들을 폐기 처리하지 않고 있기 때문이었다.
하지만 리프레시 토큰은 현재 RDB의 AuthInfo 테이블의 JSON 형태의 컬럼에 저장 되어있기 때문에 서버에서 주기적으로 모든 사용자의 리프레시 토큰 화이트리스트를 탐색하고 기간이 만료된 토큰을 삭제해 주는 작업이 필요하다. 이를 위해서는 스프링 서버에서 주기적으로 이벤트를 발생시키는 방법을 생각해 볼 수 있지만, 이는 DB에 읽기, 쓰기 작업도 너무 많이 발생하고 WAS쪽의 자원도 굉장히 많이 소모되는 일이다.
따라서 Redis를 사용해서 이 문제를 해결하고자 했다. Redis는 우선 Key : Value 형태로 쉽게 데이터를 저장할 수 있는 DB이기 때문에 memberId : {token1, token2, ...} 같은 구조의 데이터도 쉽게 저장이 가능하다. 또한 In Memory에서 사용되는 DB이기 때문에 데이터에 액세스 하는 속도도 RDB보다 굉장히 빠르다.
또한 Redis는 주로 비싼 메모리에서 성능향상을 위한 캐싱 서버 역할을 많이 하기 때문에 어떤 데이터를 저장하면서 해당 데이터를 얼만큼의 시간동안 유지시킬지 결정하고 이 기간이 지나면 자동으로 메모리에서 삭제해 버리는 TTL기능을 제공한다.
즉, 컬렉션 형식으로 데이터도 저장가능하고 만료기간이 지나면 자동으로 데이터도 삭제해 주는 Redis는 현재 상황에서 내가 선택할 수 있는 최고의 선택지 였다.
처음 Redis를 사용하면서 내가 기대했던 건 각각의 회원들을 Key로 저장해 두고 Value에 레디스에서 제공하는 컬렉션을 사용해서 members:1 , members:2로 쉽게 해당 회원의 토큰 화이트 리스트에 접근 하고 개별 토큰들에 TTL을 부여해서 알아서 기간이 만료된 토큰은 삭제 되는 것이었다.
하지만, Redis 자체가 Key : Value로 이루어진 거대한 해시 구조였고 Value에 컬렉션을 넣을 수 있는 구조였다. 하지만 TTL 기능은 Redis 자체에서 Key에 부여할 수 있는 기능이기 때문에 {member1 : {토큰1, 토큰2, 토큰3}} or {member1 : {키1 : 토큰1, 키2 : 토큰2 ...}} 같은 구조에서 각각의 컬렉션의 키를 통해서 개별 TTL을 부여하는 기능은 지원하지 않았다. 따라서 이를 해결할 방법이 필요했다.
그래서 우선은 위와 같이 members:{memberId}:token:{idx}를 Key로 두고 Value에 토큰을 저장하기로 했다. 이렇게 저장할 경우 개별 토큰들 모두에게 TTL을 부여할 수는 있다. 하지만 keys members:1:* 같은 명령어를 수행하거나 scan ~ match로 명령어를 수행했을 때 조회 성능이 쓸만하게 나올지는 잘 모르겠다.
이는 성능 모니터링 환경이 구축되면 레디스에 데이터를 1000만개 정도 마구 집어넣은 후 진행해 보면서 개선할 생각이다. 아직은 레디스 사용이 처음이라 성능까지 모두 챙길 수는 없을 것 같고, 처음에 레디스를 도입하려고 했던 가장 중요한 목적인 TTL을 개별 토큰들에 적용하는 것을 우선으로 했다.
// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
Redis 사용을 위해서 해당 의존성을 우선 추가해 줬다.
@Configuration
@RequiredArgsConstructor
public class RedisConfig {
private final RedisProperties redisProperties;
@Bean
public LettuceConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(new RedisStandaloneConfiguration(redisProperties.getHost(), redisProperties.getPort()));
}
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
}
Redis 사용을 위한 Config를 설정했다. 외부환경 변수는 이전의 KakaoProperties와 같이 객체로 변환해서 받았다. 우선 LettuceConnectionFactory라는 것을 생성해 준 후, 스프링 빈으로 등록해 줘야 했다.
자바에서 Redis를 쉽게 사용하기 위한 클라이언트로는 대표적으로 Jedis와 Lettuce가 있다. Redis는 하나의 서버처럼 작동하기 때문에 이런 클라이언트들의 도움을 받지 않고 Redis를 사용하려면 네트워크 소켓 프로그래밍 단계부터 하나하나 직접 내가 개발해야 한다.
따라서 Jedis나 Lettuce같은 클라이언트를 사용하는게 좋은데 Lettuce는 Jedis보다 복잡하지만 기능이 더 많고 비동기 요청이 가능하고 성능이 더 좋다고 한다. Jedis는 조금 더 단순하지만 기능이 적고, 성능이 낮고 비동기 요청을 지원하지 않는다. Jedis에서 비동기 요청을 사용하기 위해서는 추가적인 설정이 필요하다고 한다.
RedisTemplate는 Spring Data Redis 에서 제공하는 Redis와 실제로 스프링에서 쉽게 상호작용 할 수 있게 도와주는 클래스다. RedisTemplate 인스턴스를 생성해서 Lettuce커넥션과 연결하고 레디스에 값을 저장할 때 Key : Value를 모두 String:String으로 설정해줬다. 그후 redisTemplate는 스프링 빈으로 관리한다.
@Repository
@RequiredArgsConstructor
public class RedisRepositoryImpl implements RedisRepository{
private final JwtPayloadReader jwtPayloadReader;
private final RedisTemplate<String, String> redisTemplate;
@Override
public void save(Long memberId, String refreshToken) {
String memberKey = "members:" + memberId;
// idx 필드를 관리하고, 새 토큰을 저장
Long idx = redisTemplate.opsForHash().increment(memberKey, "idx", 1); // idx 값을 증가시킴
// 고유한 토큰 키
String tokenKey = memberKey + ":token:" + idx;
// ttl 계산
long ttl = (jwtPayloadReader.getExpiration(refreshToken).getTime() - System.currentTimeMillis() + 100)/1000;
redisTemplate.opsForValue().set(tokenKey,refreshToken,ttl, TimeUnit.SECONDS);
}
}
RedisRepository를 만들고 각각의 토큰들을 members:{memberId}:token:{idx} = refreshToken의 형태로 저장하고 TTL을 부여 했다. idx 관리를 위해서 각 members:1, members:2 마다 몇번째 토큰을 저장하고 있는지 관리할 Index를 따로 저장했다.
레디스에는 이런식으로 토큰이 저장되고 해당 회원의 여러 토큰들의 인덱스는 members:1을 키로 삼고 해쉬로 저장된다.
이런식으로 마지막으로 사용한 인덱스는 17임을 알 수 있었다.
해당 회원의 토큰도 ttl이 부여된 상태로 잘 관리가 되고 있음을 확인했다.
@Slf4j
@Component
@AllArgsConstructor
public class JwtTokenManager {
private final JwtProvider jwtProvider;
private final RedisRepository redisRepository;
public Map<String, String> issueJwtTokens(Member member, String provider) {
JwtPayloadCreateDTO payload = JwtPayloadCreateDTO.from(member.getId(),provider,member.getRole());
String accessToken = jwtProvider.createJwtAccessToken(payload);
String refreshToken = jwtProvider.createJwtRefreshToken(payload);
redisRepository.save(member.getId(), refreshToken);
log.info("JWT 발급 성공");
return Map.of(JWT_ACCESS_TOKEN, accessToken, JWT_REFRESH_TOKEN, refreshToken);
}
}
이제 로그인과 동시에 jwt를 발급하고 발급한 refreshToken을 해당 회원의 WhiteList에 저장하고 LoginController에 발급된 토큰들을 반환하기만 하면 정말 끝이었다.
@Service
@Transactional
@RequiredArgsConstructor
public class KakaoLoginService implements OAuth2LoginService {
private final KakaoProperties properties;
private final OAuthHttpClient httpClient;
private final JwtTokenManager jwtTokenManager;
private final MemberRepository memberRepository;
private final AuthInfoRepository authInfoRepository;
...
@Override
public Map<String, String> loginWithOAuthAndIssueJwt(String authorizationCode, String provider) {
KakaoOAuthTokenResponseDTO oAuthTokens = (KakaoOAuthTokenResponseDTO) httpClient.getOAuthTokens(authorizationCode, provider);
log.info("카카오 인증서버에서 Access Token 받아오기 성공 ");
KakaoUserResourceResponseDTO resources = (KakaoUserResourceResponseDTO) httpClient.getOAuthUserResources(oAuthTokens.getAccessToken(), provider);
log.info("사용자 Resource 받아오기 성공");
if(!authInfoRepository.existsByAppId(resources.getAppId())) signUp(resources);
Member member = memberRepository.findMemberAndAuthInfoByAppId(resources.getAppId());
return jwtTokenManager.issueJwtTokens(member,provider);
}
}
이제 이를 KakaoLoginService에서 호출 해 주기만 하면 된다. 마지막으로 LoginController로 Token들을 반환한 후 LoginController에서 이를 사용자에게 전달하기만 하면 되는데 JWT를 Controller에서 어디에 어떻게 담아서 응답을 만들어야 할 지 고민이 생겼다.
@GetMapping("/oauth/callback")
public ResponseEntity<Void> OAuthCallback(@RequestParam ("code") String authorizationCode,
@RequestParam ("state") String provider){
...
Map<String, String> tokenMap = loginService.loginWithOAuthAndIssueJwt(authorizationCode, provider);
// 잘못된 예시
return ResponseEntity.status(200)
.header(SET_COOKIE_HEADER, JWT_ACCESS_TOKEN + "=" + jwtTokens.get(JWT_ACCESS_TOKEN) + JWT_ACCESS_TOKEN_SETTING)
.header(SET_COOKIE_HEADER, JWT_REFRESH_TOKEN + "=" + jwtTokens.get(JWT_REFRESH_TOKEN) + JWT_REFRESH_TOKEN_SETTING)
.body(ResponseDTO.success("ok"));
}
처음에는 내가 이렇게 JWT 토큰들을 쿠키에 담아서 200 OK 응답을 반환해 주면 될 것이라고 생각했다.
하지만 로그인에 성공한 사용자의 웹 브라우저 화면은 위와 같았다.
이유를 추측해 봤을 때 아마 위의 그림에서 보는 것 처럼 사용자가 직접 카카오에 아이디와 비밀번호를 입력해 로그인을 성공하면 사용자의 웹 브라우저가 내 백엔드 서버의 redirect uri로 바로 리다이렉트를 처리했기 때문인 것 같았다. 해당 요청에 대한 응답을 보내면 곧바로 사용자의 웹 브라우저로 응답이 전송되고 이는 굉장히 곤란한 상황이었다.
이를 해결하기 위해서 200 OK로 응답을 주는 게 아니라 토큰들을 쿠키에 담아서 프론트의 로그인 성공 처리 URL로 리다이렉트 시켜버리기로 했다.
토큰들을 쿠키에 담아서 전달하는 이유는 백엔드에서 응답을 웹 브라우저로 전송하고 이를 프론트로 다시 리다이렉트 시키면서 넘겨줄 때 쿠키나 쿼리스트링 같은 방식으로 토큰 값을 유지시켜 줘야 하기 때문이다. 쿼리 스트링 보다는 쿠키로 넘겨주는게 조금 더 안전하다고 생각해서 쿠키를 사용했다.
다만 이를 받아서 사용자의 웹 브라우저에 저장하는 것은 또 다른 문제였다. JWT Refresh Token의 경우 웹브라우저 쿠키 저장소에 저장해두고 React를 통해서 추후 Refresh Token이 필요한 경우에만 WithCredentiol = true를 사용해서 백엔드 서버까지 전송할 것이고 JWT Access Token의 경우 프론트에서 로그인 성공 처리 URL로 처리 요청이 들어왔을 때, 이 토큰만 Session Storage나 Local Storage로 옮겨 준 후, 쿠키에서 삭제할 것이다. 그 후 AccessToken은 HTTP Request의 Authorization 헤더의 값에 넣어서 사용하기로 했다.
return ResponseEntity.status(FOUND)
.header(SET_COOKIE_HEADER, JWT_ACCESS_TOKEN + "=" + tokenMap.get(JWT_ACCESS_TOKEN) + JWT_ACCESS_TOKEN_SETTING)
.header(SET_COOKIE_HEADER, JWT_REFRESH_TOKEN + "=" + tokenMap.get(JWT_REFRESH_TOKEN) + JWT_REFRESH_TOKEN_SETTING)
.header(LOCATION_HEADER, FRONT_LOGIN_SUCCESS_URI)
.build();
이를 위해 위와 같이 응답을 만들어서 사용자의 웹브라우저로 보내줬다. 이렇게 사용자가 직접 로그인 버튼을 누르는 경우에 대한 처리과정을 완성 할 수 있었다.
<인증, 인가 필터 구현>
이제 사용자가 OAuth를 이용해서 로그인 하고 JWT를 발급 받는 과정까지 성공했으니 클라이언트가 인증, 인가 체크가 필요한 API를 요청하면서 자신의 JWT를 보여주는 상황을 처리해야 한다.
이는 이전 포스팅에서 생각해 본 대로 필터 레벨에서 구현 할 생각이다. 이를 위해서 크게 3가지 필터가 필요할 것 같았다. 우선 클라이언트가 들고온 JWT자체가 유효한지 확인하는 JWT 검증 필터, 사용자의 신원을 확인하는 인증 필터, 해당 요청에 대한 사용자의 권한을 체크하는 인가 필터를 만들어 줄 생각이다.
그런데 이런 필터들을 구현하기 전에 서블릿 필터는 Spring Context 외부에 존재하는데 스프링 빈과 스프링 DI 컨테이너를 사용할 수가 없지 않을까? 라는 생각이 들었다. 이 문제를 해결할 방법이 없다면 필터레벨의 로그인을 모두 인터셉터로 이동시켜야 하는 상황이었다.
@Configuration
@RequiredArgsConstructor
public class FilterConfig {
private final JwtValidator jwtValidator;
private final JwtPayloadReader jwtPayloadReader;
...
@Bean
public FilterRegistrationBean<Filter> jwtValidationFilter() {
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new JwtValidationFilter(jwtValidator));
filterRegistrationBean.setOrder(1);
filterRegistrationBean.addUrlPatterns("/api/auth/*","/api/optional-auth/*","/swagger-ui.html", "/swagger-ui/*");
return filterRegistrationBean;
}
}
이를 위해서 FilterRegistrationBean을 이용해서 내가 만든 필터를 직접 스프링 빈으로 등록하고 그 과정에서 필요한 스프링 빈 객체들을 직접 주입해 주는 방법으로 문제를 해결할 수 있었다.
나는 스프링 부트를 통해서 스프링을 사용한다. 스프링 부트는 스프링 컨텍스트 뿐만 아니라 서블릿 컨테이너 까지 영향을 끼칠 수 있는데 스프링 컨테이너에 수동으로 내가 만든 커스텀 필터들을 등록하고 해당 필터에 필요한 스프링 빈들을 직접 주입한 후, FilterRegistrationBean 타입의 빈으로 등록하게 되면 스프링 부트가 FilterRegistrationBean 타입으로 등록된 스프링 빈들을 서블릿 필터로 등록해 주는 것 같았다.
다만, DelegatingFilterProxy라는 스프링 컨테이너와 서블릿 컨테이너를 이어주는 연결해 주는 다리 역할을 제공하는 필터를 사용하는 방식으로도 내 필터들을 등록할 수 있는 것 같다. 실제로 스프링 시큐리도 해당 필터를 이용하면서 시작하는 것으로 알고 있는데 스프링 부트 + FilterRegistrationBean 방식을 사용하는게 필터 등록에 대해서 더 세밀한 컨트롤이 가능하다는 얘기도 있어서 우선은 지금의 방식을 유지하고 DelegatingFilterProxy를 사용하는 방식도 추후 공부해 볼 생각이다.
내 프로젝트에서 스프링 부트 없이 스프링을 사용하게 되는 식의 변경사항은 없을 것이라서 성능 상 엄청난 차이가 존재하는게 아니라면 굳이 지금 당장 변경할 필요는 없을 것 같다. 또한 필터의 숫자가 엄청나게 많은 것은 아니기 때문에 수동으로 등록하고 의존관계들을 관리한다고 해도 유지보수나 확장에 크게 문제가 되지는 않을 것이라고 판단했다.
@RequiredArgsConstructor
public class JwtValidationFilter implements Filter {
private final JwtValidator jwtValidator;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 1.Request 헤더에서 "토큰타입 + 토큰" 추출
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String authorizationHeader = httpServletRequest.getHeader(AUTHORIZATION_HEADER);
// 2.JWT가 필요한 요청인데 Authorization Header에 아무 값이 존재하지 않는 경우 예외 처리
if(authorizationHeader == null){
// 비 로그인 회원의 optional-auth 요청으로 식별
if(httpServletRequest.getRequestURI().startsWith("/api/optional-auth")) {
chain.doFilter(request, response);
return;
}
throw new AuthorizationHeaderNotExistException();
}
...
}
}
우선 JwtValidationFilter에서 가장 먼저 해야 할 일은 HTTP Request의 Header중 Authorization 헤더에서 토큰 타입 + 토큰을 꺼내오는 것이었다. JWT 토큰이라면 "Bearer eaegemgkehnwlhnlkwlknlk.sagagasgsagas.gasgas" 같은 식으로 HttpRequest의 Authorization Header에 전송이 되었을 것이다. 우선 이를 추출해서 String에 저장했다.
만약 이 때, Authorization Header에 아무 값이 들어있지 않다면 JWT가 필요한 요청을 시도했는데 아무런 토큰을 들고 오지 않은 것으로 간주하고 예외를 발생시켰다. 다만 내 서비스에는 로그인 하지 않은 사용자와 로그인 한 사용자에게 모두 허용되지만 로그인 한 사용자에게 더 많은 것을 제공하는 API들이 있다.
위와 같은 식으로 같은 카페 상세페이지 조회 요청이지만 로그인 된 회원에게는 자신이 해당 카페를 북마크 한적이 있는지에 대한 여부를 추가적으로 제공하는 API들이 존재했다.
나는 이를 optional-auth API라고 부르기로 했다. optional-auth API로 어떤 요청이 들어올 경우 토큰이 존재하지 않는다면 비로그인 회원의 요청으로 간주하고 JWT 검증 필터를 정상 통과 시켜줬다. optional-auth API에 대한 내용은 뒤에서 자세히 생각해 보기로 하고 우선 JWT 검증 필터를 완성 시키는 것에 집중했다.
// 3.Authorization Header가 존재하는데 토큰 타입이 Bearer가 아닌 경우 예외 처리
if(!authorizationHeader.startsWith(BEARER_TOKEN_TYPE)) throw new InvalidAuthorizationTokenTypeException();
// 4.Authorization Header에서 실제 토큰 추출
String accessToken = authorizationHeader.substring(7);
// 5.추출한 jwt AccessToken JWT Validator로 검증
jwtValidator.validateJwtAccessToken(accessToken);
// 6.다음 필터로 넘어감
httpServletRequest.setAttribute("JwtAccessToken", accessToken);
chain.doFilter(request,response);
이제 Authorization 헤더에 아무런 데이터가 들어있지 않은 경우는 처리했으니 Authorization 헤더에 들어있는 토큰 타입 + 토큰에서 토큰타입이 Bearer가 아닌 경우를 걸러 줬다. 이를 무사히 통과했다면 토큰타입을 제거하고 진짜 Access Token을 추출했다.
이를 JWTValidator로 검사하고 예외가 발생하지 않으면 정상적으로 다음 필터로 넘겨주면 JWT 검증 필터의 역할은 끝이었다.
@Component
public class JwtValidator {
private final JwtPayloadReader jwtPayloadReader;
private final SecretKey secretKey;
public JwtValidator(JwtPayloadReader jwtPayloadReader, @Value("${spring.jwt.secret}") String secret) {
this.jwtPayloadReader = jwtPayloadReader;
secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
}
public void validateJwtAccessToken(String token) {
try {
Jwts.parser()
.verifyWith(secretKey) // 시크릿 키 세팅
.build()
.parseSignedClaims(token); // 토큰 검증
// tokenType이 access인지 검증
isAccessToken(token);
} catch (ExpiredJwtException e){ // ExpiredJwtException -> ClaimJwtException -> JwtException
throw new JwtAccessTokenExpiredException();
} catch (JwtException | IllegalArgumentException e) {
throw new InvalidJwtAccessTokenException();
}
}
private void isAccessToken(String token){
if(!jwtPayloadReader.getTokenType(token).equals("jwt_access")) throw new InvalidJwtAccessTokenException();
}
JWT 검증기의 경우 서버에 보관하고 있는 JWT 발급키와 JWT의 Payload들을 읽을 수 있는 JwtPayLoadReader를 구현해서 주입해 줬다.
우선 나는 Jwt 0.12.3 이라는 라이브러리를 사용하고 있는데해당 라이브러리에서는 parseSignedClaims()로 시크릿 키를 통한 토큰 검증이 가능하다.
해당 메서드를 살펴보면 호출 중 예외가 발생한 경우 JwtException과 IllegalArgumentException을 던지게 되어있다. 따라서해당 메서드를 호출한 Jwt Validator에서 이를 잡아 처리해 주면 되는데 여기서 JwtException은 토큰 만료, 서명 불일치, 토큰 타입 불일치 등등 JWT에서 발생하는 여러가지 예외의 최상위 예외이다.
여기서 토큰이 만료된 경우는 단순히 유효하지 않은 토큰이라고 처리하는 것을 넘어서 재발급 로직을 수행하는 등의 추가적인 처리가 필요하기 때문에 ExpiredJwtException은 따로 처리하기로 했다. 해당 예외도 JwtException을 상속한다.
따라서 Jwt 검증 중 예외가 발생하면 모두 커스텀 예외로 변환 시켜서 던져주었다. 토큰 만료라는 특수한 상황을 제외하고 모두 InvalidJwtAccessTokenException으로 일괄 처리해버렸는데 이는 해커가 JWT를 입력해 보면서 왜 유효하지 않은지 추측 할 수 없게 만들기 위해서다.
또한 AccessToken 자리에 RefreshToken을 넣어보는 경우를 막아주기 위해서 해당 토큰이 AccessToken이 맞는지 검증하는 작업을 추가했다.
이제 검증기를 정상 통과했다면 다음 필터로 넘어가면 된다. 추가적으로 Filter레벨에서 발생하는 모든 예외는 스프링 MVC의 예외 공통 처리를 사용할 수 없기 때문에 이를 따라한 GlobalFilterExceptionHandler라는 필터를 구현해서 항상 가장 처음에 작동하는 필터로 등록해서 예외를 처리해 버리기로 했다.
이번에는 JWT 자체에 대한 검증이 모두 끝났으니 인증 필터를 만들어 보기로 했다.
public class AuthenticationFilter implements Filter {
private final JwtPayloadReader jwtPayloadReader;
private final JwtThreadLocalStorageManager jwtThreadLocalStorageManager;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 1. 이전 필터에서 넘겨준 검증된 Jwt Access Token 추출
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String accessToken = String.valueOf(httpServletRequest.getAttribute("JwtAccessToken"));
// 2. 사용자의 실제 토큰으로 부터 사용자의 인증 정보를 추출
MemberAuthentication authentication = new MemberAuthentication(jwtPayloadReader.getMemberId(accessToken),
Role.valueOf(jwtPayloadReader.getMemberRole(accessToken)),
jwtPayloadReader.getProvider(accessToken));
// 3. 사용자의 실제 인증 정보 ThreadLocal에 저장 후 다음 필터로 넘김
try{
log.info("ThreadLocal을 사용하는 Auth Request 발생");
jwtThreadLocalStorageManager.setMemberAuthentication(authentication);
chain.doFilter(request,response);
}
finally {
jwtThreadLocalStorageManager.clear();
log.info("자원 정리");
}
}
}
인증 필터에서 해줄 일은 유효한 JWT로 부터 사용자의 신원을 확인하고 이를 해당 Request의 모든 곳에서 쉽게 사용할 수 있게 어딘가에 저장해 둔 다음 인가 필터로 넘어가는 일이라고 생각했다.
보통 ID, Password를 입력하는 로그인 방식에서 인증필터라고 하면 사용자의 Id, Password가 DB에 있는 정보와 일치하는지 확인하고 사용자의 신원 정보를 확인하는 일이지 않을까 생각했다.
하지만 JWT 로그인에서 굳이 JWT 검증이 끝났는데 Payload의 정보들과 DB상의 회원 정보들을 일일히 비교해야 할까? 라는 생각이 들었다. JWT의 시크릿 키를 누군가 알아낸게 아니라면 모든 JWT는 내 서버에서 직접 발급해 준 정보일 것이다. 또한 나는 JWT를 만들 때 애초에 사용자의 정보 중 변할 가능성이 있는 데이터는 집어 넣지 않았다. 즉, 어떤 회원의 신원을 확인하기 위해서 굳이 DB와 비교하는 로직이 필요없다고 생각했다.
다만 이렇게 할 경우 해당 필터의 로직 자체는 줄일 수 있지만 회원의 신원 정보를 저장할 때 JWT에 없는 정보는 저장할 수 없고 차단 처리된 회원 같은 경우를 해당 필터에서 거를 수 없다는 단점이 있을 것 같았다. 하지만 당장은 내 서비스에서는 이런 것들을 이용하지 않기 때문에 주석으로 해당 내용을 남겨두고 넘어가기로 했다.
따라서 인증 필터에서는 JWT Payload로 부터 회원의 정보를 추출하고 이를 MemberAuthentication 객체에 담아서 다음 필터로 넘겨 주는 일만 하면 된다.
하지만 이 과정에서 MemberAuthentication 객체를 계속 파라미터로 넘기거나 HttpServeletRequest에 담아서 뒤로 넘긴후 실제로 필요한 곳에서 HttpServletRequest에 의존해서 이를 꺼내 사용하는 방식을 사용해야 했다.
이를 해결하기 위해 ThreadLocal을 사용하기로 했다. ThreadLocal 저장소에 각각의 요청 별로 별도의 저장소를 만들어서 현재 로그인한 회원의 정보를 저장했다. ThreadLocal을 사용했고 나는 톰캣이 내장된 스프링 부트를 사용해서 프로젝트를 하고 있기 때문에 어떤 요청이 발생했을 때, 톰캣에서 제공하는 쓰레드 풀을 통해서 쓰레드들이 계속 재사용 된다.
따라서 ThreadLocal저장소에 저장했던 자원을 모든 요청이 처리된 후 삭제하지 않으면 다른 회원의 MemberAuthentication 객체에 접근 할 수 있거나 계속 ThreadLocal 저장소에 자원이 쌓여가다가 메모리가 부족해지는 문제가 발생할 것이라고 생각했다. 따라서 try ~ finally를 통해 ThreadLocal 저장소는 항상 사용 후 비워줬다.
이제 인증 필터를 지나서 인가 필터로 요청을 보내주면 되는데 나의 서비스에는 회원별로 Gold, Silver, Bronze 같은 등급 구분이 있지는 않다. 다만 API 서버에 스와거나 모니터링과 관련된 중요한 자원에 일반 회원이 접근 하지 못하도록 USER, ADMIN으로 회원을 구분하고 있다.
@Slf4j
@RequiredArgsConstructor
public class AuthorizationFilter implements Filter {
private final JwtThreadLocalStorageManager jwtThreadLocalStorageManager;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// ThreadLocal에 저장된 MemberAuthentication 정보 중 Member Role을 가져옴
if (jwtThreadLocalStorageManager.getMemberRole().equals(Role.ADMIN)) {
log.info("관리자 권한 확인 완료");
chain.doFilter(request,response);
}
else {
throw new AuthorizationException();
}
}
}
따라서 인증된 사용자 중에서 USER와 ADMIN을 구분하여 인가처리를 할 필터를 작성해 주었다.
이 외에도 CORS관련 문제를 처리할 필터와 필터 레벨 에서 발생하는 모든 예외를 모아서 처리해줄 필터, Optional-auth API에 대해서 따로 추가적인 처리를 도와줄 필터를 만들고 위와 같은 순서로 등록했다.
만약 모든 회원에게 허용된 인증, 인가가 필요없는 요청이 발생한다면 해당 요청은 위와 같이 필터 체인을 지나갈 것이다.
만약 반드시 인증된 회원에게만 제공하는 요청이 발생하면 위와 같이 필터 체인을 지나간다. 내 서비스에서는 인가 필터는 관리자 권한으로 실행하는 요청이 아니면 지나가지 않아도 문제가 없다.
optional-auth API 처럼 인증되지 않은 사용자도 사용가능하지만 인증된 사용자에게 추가적인 정보를 제공하는 요청에서는 위와 같이 필터 체인을 지나간다. 반드시 인증된 사용자만 사용가능한 요청과는 다르게 Authorization 헤더가 Null인 경우도 유효한 의미를 갖고 처리되어야 하기 때문에 지나가는 필터를 다르게 구성해서 처리했다.
스와거나 프로메테우스 처럼 반드시 인증되고 ROLE이 ADMIN인 사용자에게만 허용된 요청은 위와 같이 필터를 지날 수 있도록 FilterConfig를 구성했다.
<CorsFilter 구현>
옛날 웹 서비스에서는 프론트, 백엔드가 따로 구분되어 있지 않았고 Same origin policy (SOP) 라는 브라우저의 보안 정책만이 존재했다. 사용자가 웹브라우저를 통해서 어떤 사이트에 접속하게 되면 해당 사이트에서는 HTML, CSS, JavaScript, 정적 리소스등등 많은 것들을 보내주게 된다.
이 때, 해당 사이트에서 보내오는 JS가 특히 문제가 될 수 있다. 웹 브라우저에서 받아온 JS를 통해서 악의적인 요청을 실행할 수 있기 때문이다.
예를 들어 어떤 사용자가 크롬 웹브라우저를 통해서 네이버에 로그인 하고 인증 토큰을 받고, 블로그 글을 작성하고 메일을 전송하는 등의 작업을 하고 있었다고 해보자.
심심했던 사용자가 잠깐 웹 서핑을 하다가 CafeHub라는 테마별 카페 추천 서비스를 발견했다. 이에 흥미가 생긴 사용자는 해당 사이트에 접속하기 링크를 클릭했다. 하지만 사실 해당 서비스는 겉으로는 테마별 카페 추천 서비스 인척 하면서 실제로는 페이지 응답에 악의적인 JavaScript를 숨겨서 전송해오는 해커가 만든 서비스라고 가정해보자.
해커가 CafeHub 페이지와 함께 WithCredentiol=true를 통해 사용자의 쿠키 저장소에 저장된 인증토큰을 Naver로 전송해서 악성 이메일을 전송하게 하는 API를 실행시켰다고 해보자. 이 때, 사용자의 인증 토큰이 멀쩡히 들어있으니 Naver는 정상적으로 이메일을 발송 할 것 같지만 웹브라우저의 SOP가 이를 막아서 해당 요청은 실패해 버린다.
SOP는 Protocol + Host +Port를 합친 Origin이 모두 동일하지 않다면 서로 다른 사이트간에 리소스를 공유할 수 없게 막아버리는 정책이다. 즉, 웹브라우저가 누군가 만들어 둔 웹사이트가 안전한 사이트인지 신뢰할 수 없기 때문에 적용하는 정책이다.
현재 JS를 통해서 CafeHub라는 사이트에서 Naver로 사용자의 토큰을 갖고 악성 요청을 보내려고 하고 있다. 이 때 웹 브라우저가 해당 요청을 발생시킨 Origin인 CafeHub와 해당 요청을 받아야 하는 Naver간의 Origin이 다른 것을 확인하고 웹 브라우저에서 에러가 발생하고 요청은 정상 수행되지 않는다.
이번에는 CafeHub가 악성 유저가 만든 사이트가 아니라 정말 테마별 카페 추천 서비스를 제공하는 정상적인 서비스라고 가정하자. 해당 서비스는 프론트와 백엔드가 분리되어 있고 정적 컨텐츠나 간단한 요청은 프론트에서 바로 처리하지만 복잡한 요청은 프론트에서 백엔드와 API 통신을 통해서 처리해야 한다. 단순한 페이지를 요청했을 때는 프론트에서 바로 처리가 가능하다.
하지만 DB를 이용해야 하거나 복잡한 요구사항이 포함된 요청의 경우 프론트 혼자서 이 요청을 처리할 수 없고 해당 요청에 대한 응답으로 백엔드 서버에 API 요청을 날리는 JS를 함께 응답으로 반환한다.
하지만 이 때, CafeHub.back으로 요청을 할 건데 해당 요청을 발생 시킨 Origin은 CafeHub.front이기 때문에 SOP에 위배되는 문제가 발생한다.
웹 브라우저의 보안 정책을 처음 만들 때는 어떤 서비스가 프론트, 백으로 분리되거나 네이버 지도 같은 외부 API를 이용하거나 하는 다양한 상황들이 없었다. 하지만 시간이 흐르면서 이런 요구사항들이 생겨났고 웹브라우저에서는 이를 Cross Origin Resource Sharing, CORS를 만들어서 해결하기로 했다.
즉, 위와 같은 상황이 발생하면 웹 브라우저에서는 이런 CORS error를 발생 시킨다.
지금 처럼 프론트에서 API 조회 JS를 함께 응답으로 준 경우 사용자의 웹 브라우저에서 백엔드 서버로 실제 API 요청을 전송하기 전에 예비 요청을 보낸다. 해당 요청의 헤더에 Origin 정보, 실제 요청에서 사용할 HTTP 메서드 정보 등등을 담아서 전송하면 서버에서 이에 대한 응답의 헤더에 동일한 Origin 정보, 허용할 메서드를 담아서 응답하게되면 정상적으로 본 요청이 처리가 가능하다.
실제로 Preflight Request가 발생하는지 테스트를 해보면 OPTIONS 메서드로 Preflight Request가 발생하고 있음을 확인 할 수있었다. 해당 요청 헤더에는 실제 요청이 어떤 메서드를 사용할거고 어떤 헤더를 필요로 하는지 등등에 대한 정보가 모두 들어있었다.
이제 해당 요청에 대해서 적절한 Access Control Allow Origin과 해당 요청에서 필요하다고 하는 헤더정보, 메서드 정보를 보내주기만 하면 이를 사용자의 웹브라우저에서 실제 요청과 비교해보고 실제 요청을 보내올 것이다. 이를 위해서 서버에서 CORS 설정을 해줘야 하는데 크게 3가지 방식으로 CORS 설정을 해 줄 수 있다.
@CrossOrigin(
origins = "http://localhost:3000",
methods = RequestMethod.GET,
allowedHeaders = "Authorization",
allowCredentials = "true",
maxAge = 10000)
@GetMapping("/cafeList/{theme}/{sortedType}/{currentPage}")
public ResponseEntity<?> func();
우선, 각각의 API를 처리하는 컨트롤러 메서드 위에 하나하나 직접 상황에 맞는 @CrossOrigin 설정을 걸어주는 방법이 있는데 이는 API가 많아지면 현실적으로 관리하기 너무 힘들 것 같다.
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:3000")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true) // 쿠키 허용
.maxAge(10000); // preflight request 캐싱
}
}
이를 해결하기 위해서 CorsConfig라는클래스를 하나 만들고 WebMvcConfigurer에서제공하는 CORS 설정 메서드를 오버라이딩 하여 나의 모든 컨트롤러의 API들에게 동일한 CORS 설정을 전역적으로 적용해 주는 방법도 있다.
하지만 내 서비스에서는 로그인이 필요한 요청은 반드시 내가 만들어둔 필터들을 모두 통과해야 CORS설정이 걸려있는 컨트롤러까지 도착할 수 있다는 문제가 발생했다.
예를 들어, 어떤 인증이 필요한 API가 있을 때, PreflightRequest가 발생한다면 해당 요청이 CORS 처리 설정이 걸려있는 곳까지 도착하지 못하고 필터에서 걸러져 버리는 문제가 발생한다.
이런 식으로 마이페이지 요청이라는 API를 실행시켜 봤는데 preflight request가 발생한 이후 로그인 필터를 지나지 못하고 더이상 아무런 작업을 하지 못하는 것을 확인했다.
문제는 이것 뿐만이 아니었다. 만약 어떤 요청이 발생했을 때 필터체인을 지나가는 도중에 예외가 발생한다면 아직 CORS 설정이 걸려있는 컨트롤러로 가기도 전에 예외가 발생한 것이라서 에러에 대한 응답 또한 웹 브라우저가 차단해 버리는 문제가 발생할 수 있었다.
이런 문제들을 종합적으로 해결하기 위해서 CORS에 대한 설정을 해줄 필터를 모든 필터들의 가장 앞에 끼워 넣고 해당 필터에서 CORS를 처리하기로 했다.
public class CorsFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
corsConfig(httpServletResponse);
if ("OPTIONS".equalsIgnoreCase(httpServletRequest.getMethod())) {
log.info("preflight request 발생");
httpServletResponse.setStatus(HttpServletResponse.SC_OK);
} else {
log.info("실제 request 도착");
chain.doFilter(request, response);
}
}
private void corsConfig(HttpServletResponse response) {
response.setHeader("Access-Control-Allow-Origin", CORS_ALLOW_ORIGIN);
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
response.setHeader("Access-Control-Allow-Credentials", "true");
response.setHeader("Access-Control-Max-Age", "10800");
}
}
위와 같은 필터를 만들어서 필터 체인의 가장 앞에 끼워 넣었다. 여기서 Preflight Request가 먼저 발생되는 요청도 있지만 가끔 특정 조건이 맞아 떨어지면 Preflight Request 가 발생하지 않고 본 요청이 먼저 도착했다가 해당 요청을 처리한 응답 안에 들어있는 CORS에 관련된 헤더들을 보고 CORS문제를 처리하는 Simple Reqeust와 인증 정보와 관련된 요청이 포함되어 있으면 CORS 설정 헤더들에 * 와일드 카드를 사용할 수 없는 점에 주의해서 잘 처리를 해야 했다.
나는 이를 모두 한번에 처리하기 위해서 CORS 관련 응답 헤더에 와일드 카드를 사용하지 않고 Prefilght Request에 대한 응답과 SimpleRequest에 대한 응답, 본요청에 대한 응답 모두에 내 API 서버에서 필요한 동일한 CORS 설정을 담아서 넘겨 주기로 했다.
<JwtRefreshToken을 통한 JwtAccessToken 재발급 처리>
이제 OAuth를 활용해서 로그인하고 JWT를 발급 받고 각 요청에서 이를 검증하고 인증 인가 처리를 할 필터도 구현했고 전반적인 로그인 구현이 끝이 났다. 하지만 JWT 사용하면 토큰 탈취를 방지하기 위해서 토큰을 AccessToken과 RefreshToken 두가지로 나눠서 발급하고 사용해야 했고 자주 돌아다니는 AccessToken의 생명주기는 짧게 RefreshToken의 생명주기는 길게 설정해 줬다. 이 때, Refresh Token도 AccessToken에 비해 상대적으로 안전한 편이지 탈취당할 위험이 있기 때문에 Refresh Token Rotate와 동시에 기존의 RefreshToken을 블랙리스트 하거나 가장 최근에 발급한 새 Refresh Token을 WhiteList로 저장하는 방법을 적용하기로 했었다. 이 과정만 구현해 주면 JWT를 이용한 로그인은 구현이 끝난다.
이를 위해서 JwtValidationFilter를 구현하면서 토큰이 만료된 경우 액세스 토큰 만료예외를 발생 시켰었다.
public void validateJwtAccessToken(String token) {
try {
Jwts.parser()
.verifyWith(secretKey) // 시크릿 키 세팅
.build()
.parseSignedClaims(token); // 토큰 검증
// tokenType이 access인지 검증
isAccessToken(token);
} catch (ExpiredJwtException e){ // ExpiredJwtException -> ClaimJwtException -> JwtException
throw new JwtAccessTokenExpiredException();
} catch (JwtException | IllegalArgumentException e) {
throw new InvalidJwtAccessTokenException();
}
}
private void isAccessToken(String token){
if(!jwtPayloadReader.getTokenType(token).equals("jwt_access")) throw new InvalidJwtAccessTokenException();
}
JwtValidationFilter에서 JwtValidator의 토큰 검증 메서드를 호출했을 때 액세스 토큰이 만료되었다면 ExpredJwtException이 발생하고 이를 JwtAccessTokenExpiredException으로 전환시켜 JwtValidationFilter로 던지게 된다. JwtValidationFilter에서는 이를 처리하지 않고 자신을 호출한 GlobalFilterExceptionHandleFilter로 해당 런타임 예외를 던지게 된다.
@RequiredArgsConstructor
public class GlobalFilterExceptionHandleFilter implements Filter {
private final ObjectMapper objectMapper;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
try{
chain.doFilter(request, response);
}
catch (InvalidAuthorizationTokenTypeException e){
ErrorReason errorReason = e.getErrorReason();
log.info("JWT 액세스 토큰 전달 시 토큰 타입이 \"Bearer \"가 아닌 예외 발생 : {}", errorReason.getCode());
createErrorResponse(httpServletResponse, errorReason);
}
catch(JwtAccessTokenExpiredException e){
ErrorReason errorReason = e.getErrorReason();
log.info("만료된 JWT 액세스 토큰으로 인한 예외 발생 : {}", errorReason.getCode());
createErrorResponse(httpServletResponse, errorReason);
}
...
}
private void createErrorResponse(HttpServletResponse httpServletResponse, ErrorReason errorReason) throws IOException {
httpServletResponse.setStatus(errorReason.getStatus());
httpServletResponse.setContentType(JSON);
httpServletResponse.setCharacterEncoding("UTF-8");
String jsonResponse = objectMapper.writeValueAsString(ResponseDTO.fail(errorReason));
httpServletResponse.getWriter().write(jsonResponse);
}
}
해당 필터에서 JwtAccessTokenExpiredException을 잡아서 처리하게 된다. 나는 클라이언트에게 LOGIN_401_1 이라는 에러 코드를 넘겨주기로 했다.
내가 구현해 주려는 전체 과정은 위와 같다. AccessToken이 만료되어 LOGIN_401_1 에러코드를 응답으로 받은 클라이언트는 자신의 쿠키저장소에 저장된 리프레시 토큰을 통해 토큰 재발급 요청을 보내온다.
@PostMapping("/reissue/token")
public ResponseEntity<ResponseDTO<Void>> reissueJwtTokens(@CookieValue("JwtRefreshToken") String jwtRefreshToken){
Map<String, String> reIssueTokens = jwtReIssueService.reIssueJwt(jwtRefreshToken);
...
}
LoginController에 해당 요청을 받아 줄 메서드를 만들어 준다. 클라이언트가 쿠키저장소에 저장된 JwtRefreshToken을 보내왔으니 우선 쿠키로 전달된 해당 토큰을 검증부터 해야한다. 이를 위해서 JwtRefreshValidationFilter를 따로 만들어 줄까 고민해 봤다. JwtRefreshValidationFilter를 지나가는 요청이 토큰 재발급 뿐이라면 이는 너무 과한 것 같았다. 컨트롤러나 서비스 단에서 검증해도 문제 될건 없을 것 같았다.
하지만, 추후 다루겠지만 로그아웃 시 기존의 사용자가 가지고 있는 리프레시 토큰을 화이트 리스트에서 제거 할 것이기 때문에 로그아웃에서도 해당 필터를 사용할 것이었다. 따라서 Jwt Refresh Token 검증이라는 공통 작업을 필터에서 처리하는 게 나빠보이지 않았다.
따라서 필터 구조를 위와 같이 변경했다.
토큰 재발급 요청은 위와 같이 필터를 지나가게 설정했다. 토큰의 검증과정은 Jwt Access Token 검증 과정과 같은데 Access Token은 Request의 Authorization 헤더로 전송되었던 것과 다르게 Refresh Token은 Cookie 헤더로 전송 되었다는 점만 다르게 처리했다.
@Service
@RequiredArgsConstructor
public class JwtReIssueService {
private final JwtTokenManager jwtTokenManager;
private final JwtThreadLocalStorageManager jwtThreadLocalStorageManager;
private final RedisRepository redisRepository;
public Map<String, String> reIssueJwt(String jwtRefreshToken) {
Long memberId = jwtThreadLocalStorageManager.getMemberId();
// 화이트리스트에 토큰이 없다면 차단 처리
String whiteListTokenKey = redisRepository.findWhiteListTokenKey(memberId, jwtRefreshToken);
if(whiteListTokenKey==null) throw new JwtRefreshTokenBlockedException();
return jwtTokenManager.reIssueJwtTokens(whiteListTokenKey);
}
}
LoginController 까지 요청이 무사히 도착했으면 쿠키에 담긴 Jwt Refresh Token을 이용해서 JwtReIssueService에 토큰 재발급을 요청한다. 이 때, 만약 클라이언트가 보내온 Refresh Token이 WhiteList로 등록된 토큰이 아니라면 이미 차단된 블랙리스트 토큰을 들고 온 것으로 간주하고 예외를 발생 시켰다.
정상적으로 Redis에 존재하는 WhiteList 토큰이 맞다면 토큰 발급, 재발급 등을 담당하는 JwtTokenManager에게 보내서 토큰을 재발급하고 토큰 재발급에 사용된 Refresh Token은 WhiteList에서 삭제하고 새로 발급한 토큰을 WhiteList로 등록 시켰다.
이 때, 여기서 사용된 RedisRepository 인터페이스는 RedisTemplate로 간단하게 구현된 구현체를 사용했고 추후 Spring Data Redis에서 제공하는 CRUD가 더 좋아 보인다면 구현체만 갈아 낄 생각이다.
@PostMapping("/reissue/token")
public ResponseEntity<ResponseDTO<Void>> reissueJwtTokens(@CookieValue("JwtRefreshToken") String jwtRefreshToken){
Map<String, String> reIssueTokens = jwtReIssueService.reIssueJwt(jwtRefreshToken);
String accessToken = reIssueTokens.get(JWT_ACCESS_TOKEN);
String refreshToken = reIssueTokens.get(JWT_REFRESH_TOKEN);
return ResponseEntity.status(200)
.header(SET_COOKIE_HEADER, JWT_ACCESS_TOKEN + "=" + accessToken + env.getJwtAccessCookieSetting())
.header(SET_COOKIE_HEADER, JWT_REFRESH_TOKEN + "=" + refreshToken + env.getJwtRefreshCookieSetting())
.build();
}
이제 재발급 받은 토큰들을 Response Header에 넣고 반환해 주면 액세스 토큰과 리프레시 토큰 재발급이 끝이났다.
<참고 자료>
김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 강의 | 김영한 - 인프런
김영한 | 멀티스레드와 동시성을 기초부터 실무 레벨까지 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다?
www.inflearn.com
스프링 부트 - 핵심 원리와 활용 강의 | 김영한 - 인프런
김영한 | 실무에 필요한 스프링 부트는 이 강의 하나로 모두 정리해드립니다., 백엔드 개발자를 위한 스프링 부트 끝판왕! 실무에 필요한 내용을 모두 담았습니다. [임베딩 영상] 김영한의 스
www.inflearn.com
스프링 핵심 원리 - 기본편 강의 | 김영한 - 인프런
김영한 | 스프링 입문자가 예제를 만들어가면서 스프링의 핵심 원리를 이해하고, 스프링 기본기를 확실히 다질 수 있습니다., 스프링 핵심 원리를 이해하고, 성장하는 백엔드 개발자가 되어보
www.inflearn.com
https://devtalk.kakao.com/t/id/131759
[카카오 로그인] 회원정보 사용 중 회원 id 문의사항
안녕하세요. 카카오 싱크를 사용중이며 문의 사항이 있어 질문 글 남깁니다. https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info 의 API를 사용하여 사용자 정보를 전달받고 있습니다. 회
devtalk.kakao.com
OAuth2 인가서버로부터 access token 발급 후 저장 ... - 인프런 | 커뮤니티 질문&답변
누구나 함께하는 인프런 커뮤니티. 모르면 묻고, 해답을 찾아보세요.
www.inflearn.com
https://okky.kr/questions/1452862
OAuth 2.0 token 저장에 관해 질문이 있습니다 ! | OKKY Q&A
안녕하십니까 ! 현재 소셜로그인만 활용하여 회원가입을 진행하려고 계획중에 있습니다.access token 이나 refresh token 을 DB 에 저장하지 않고 access token 에서 제공하는 이용자 식별값 만 DB에 저장한
okky.kr
https://devtalk.kakao.com/t/access-token-refresh-token/133410
안녕하세요 access token과 refresh token에 대해 궁금한 점이 있습니다
카카오 로그인시 현재 스프링 시큐리티로 개발중이고 https://kauth.kakao.com/oauth/authorize https://kauth.kakao.com/oauth/token https://kapi.kakao.com/v2/user/me application.properties에서 provider로 등록 후 카카오 로그인
devtalk.kakao.com
https://www.inflearn.com/course/Querydsl-%EC%8B%A4%EC%A0%84
실전! Querydsl 강의 | 김영한 - 인프런
김영한 | Querydsl의 기초부터 실무 활용까지, 한번에 해결해보세요!, 복잡한 쿼리, 동적 쿼리는 이제 안녕! Querydsl로 자바 백엔드 기술을 단단하게. 🚩 본 강의는 로드맵 과정입니다. 본 강의는 자
www.inflearn.com
https://www.devyummi.com/page?id=66937e102991346fee18ea37
개발자 유미 | 커뮤니티
www.devyummi.com
💠 전략(Strategy) 패턴 - 완벽 마스터하기
Strategy Pattern 전략 패턴은 실행(런타임) 중에 알고리즘 전략을 선택하여 객체 동작을 실시간으로 바뀌도록 할 수 있게 하는 행위 디자인 패턴 이다. 여기서 '전략'이란 일종의 알고리즘이 될 수
inpa.tistory.com
https://mangkyu.tistory.com/303
[Spring] Spring Boot3.2에 새롭게 추가될 RestClient
이번에는 Spring Boot3.2에 새롭게 추가될 RestClient에 대해 알아보도록 하겠습니다. 1. Spring Boot3.2에 새롭게 추가될 RestClient [ RestClient가 필요한 이유 ] Spring에서는 RestTemplate, WebClient와 같은 Http Client를
mangkyu.tistory.com
https://docs.spring.io/spring-framework/reference/integration/rest-clients.html
REST Clients :: Spring Framework
WebClient is a non-blocking, reactive client to perform HTTP requests. It was introduced in 5.0 and offers an alternative to the RestTemplate, with support for synchronous, asynchronous, and streaming scenarios. WebClient supports the following: Non-blocki
docs.spring.io
https://www.inflearn.com/course/ORM-JPA-Basic
자바 ORM 표준 JPA 프로그래밍 - 기본편 강의 | 김영한 - 인프런
김영한 | JPA를 처음 접하거나, 실무에서 JPA를 사용하지만 기본 이론이 부족하신 분들이 JPA의 기본 이론을 탄탄하게 학습해서 초보자도 실무에서 자신있게 JPA를 사용할 수 있습니다., 실무에서도
www.inflearn.com
https://akku-dev.tistory.com/105
Redis Configuration - Lettuce vs Jedis & RedisTemplate 이란?
이전 글에서 자세히 다루지 못 했던 Redis Configuration을 뜯어보려고 한다. @Configuration public class RedisConfig { @Value("${spring.data.redis.host}") private String host; @Value("${spring.data.redis.port}") private int port; @Bean pu
akku-dev.tistory.com
https://jojoldu.tistory.com/418
Jedis 보다 Lettuce 를 쓰자
Java의 Redis Client는 크게 2가지가 있습니다. Jedis Lettuce 둘 모두 몇천개의 Star를 가질만큼 유명한 오픈소스입니다. 이번 시간에는 둘 중 어떤것을 사용해야할지에 대해 성능 테스트 결과를 공유하
jojoldu.tistory.com
https://mangkyu.tistory.com/221
[Spring] 필터(Filter)가 스프링 빈 등록과 주입이 가능한 이유(DelegatingFilterProxy의 등장) - (2)
몇몇 포스팅과 조금 오래된 책들을 보면 필터(Filter)는 서블릿 기술이라서 Spring의 빈으로 등록할 수 없으며 빈을 주입받을수도 없다는 내용이 나옵니다. 하지만 실제로 테스트를 해보면 Filter 역
mangkyu.tistory.com
https://overcome-the-limits.tistory.com/741
[Web] 토큰을 사용할 때 Bearer는 무엇인가?
들어가며 JWT를 인증 방법으로 활용하면서, 헤더 값으로 bearer + token 값을 받아서 사용했습니다. 이때 bearer가 무엇인지, 제대로 모르고 사용했습니다. 이번 기회에 토큰 값에 붙어있는 bearer가 무
overcome-the-limits.tistory.com
🌐 악명 높은 CORS 개념 & 해결법 - 정리 끝판왕 👏
악명 높은 CORS 에러 메세지 웹 개발을 하다보면 반드시 마주치는 멍멍 같은 에러가 바로 CORS 이다. 웹 개발의 신입 신고식이라고 할 정도로, CORS는 누구나 한 번 정도는 겪게 된다고 해도 과언이
inpa.tistory.com
[Spring Boot] CORS 이슈해결하기(WebMVCConfigurer를 통한 설정)
팀 프로젝트 진행 시 발생했던 CORS 이슈에 대하여 개념과 해결 방법을 알아본다.
medium.com
'etc' 카테고리의 다른 글
[실험] 로그인을 구현하기 전 생각해 본 것들 (2) | 2024.12.19 |
---|