구글, 네이버, 카카오와 같은 다양한 플랫폼의 특정한 사용자 데이터에 접근하기 위해 제3자 클라이언트가 사용자의 접근 권한을 위임 받을 수 있는 표준 프로토콜이다.
즉, 우리의 서비스가 우리 서비스를 이용하는 유저의 타사 플랫폼 정보에 접근하기 위해 권한을 타사 플랫폼으로부터 위임 받는 것이다.
OAuth 2.0 구성 요소
Resource OwnerAuthorization Server & Resource ServerClientOAuth 2.0 애플리케이션 등록
OAuth 2.0 서비스를 이용하기 전에는 선행되어야 하는 작업이 있다. Client를 Resource Server에 등록해야 하는 작업이다. 이때, Redirect URI를 등록해야 한다. Redirect URI는 사용자가 OAuth 2.0 서비스에서 인증을 마치고 사용자를 리다이렉션 시킬 위치이다.
Redirect URIClient Id, Client SecretAuthorization Code 란?
Client가 Access Token을 획득하기 위해 사용되는 임시 코드로 수명 시간이 1분 ~ 10분 정도로 매우 짧다.
OAuth 2.0 동작 메커니즘

response_type, client_id, redirect_uri, scope 등의 매개변수를 쿼리 스트링으로 포함하여 보낸다.https://authorization-server.com/auth?response_type=code&client_id=1234&redirect_uri=https://example.com/callback&scope=create+deleteresponse_type: 반드시 code로 값을 설정해야 한다. 만약 인증이 성공할 경우 Client는 Authorization Code를 얻게 된다.client_id: 애플리케이션을 생성했을 때 발급받은 Client Idredirect_uri: 애플리케이션을 생성할 때 등록한 Redirect URIscope: Client가 부여받은 리소스 접근 권한token 엔드 포인트에서 이루어지며 application/x-www-form-urlencoded의 형식에 맞춰 전달해야 한다.POST /oauth/token HTTP/1.1
Host: authorization-server.com
grant_type=authorization_code
&code=xxxxxxxxxxx
&redirect_uri=https://example.com/redirect
$client_id=xxxxxxxxxx
$client_secret=xxxxxxxxxxgrant_type: 항상 authorization_code로 설정되어야 한다.code: 발급받은 Authorization Coderedirect_uri: Redirect URIclient_id: Client Idclient_secret: REC 표준상 필수는 아니지만 Client Secret이 발급된 경우에는 포함하여 요청해야 한다.OAuth 2.0 스코프
OAuth 2.0은 스코프라는 개념을 통해서 유저 리소스에 대한 Client의 접근 범위를 제한할 수 있다.
스코프는 여러 개가 될 수 있으며 대소문자를 구분하는 문자열을 공백으로 구분하여 표현된다. 이때 문자열은 OAuth 2.0 인증 서버에 의해 정의된다.
OAuth에 대한 개념을 알아보았다. 그러면 OAuth 동작 메커니즘 1 ~ 13 중 서버가 처리해야 할 단계는 어디서 부터 어디일까?
플랫폼이 웹인지 앱인지에 따라 단계가 다를 수 있고 클라이언트에서 모두 처리하여 필요한 유저 정보만 서버로 넘겨주는 방식도 가능하다고 생각된다. 왜냐하면 iOS 또는 Android 플랫폼의 경우 각 플랫폼에서 지원하는 SDK를 사용하는 경우, Authorization Code를 발급 받는 과정이 내부적으로 구현되어 있고 최종적으로 Access Token을 받게 되어 자연스럽게 서버에서 처리해야 할 단계가 10 이후로 되기 때문이다. 반면에 웹의 경우 웹 클라이언트에서 Authorization Code를 발급 받고 이후 Access Token을 발급 받는 과정부터 서버에서 처리하도록 위임할 수 있게 된다. 따라서 플랫폼에 따라 서버가 처리해야 할 단계가 다를 수 있다고 보여진다. 그리고 사용자 경험과 보안적인 요소도 고려하여 단계를 설정할 수 있는데, 앞서서 명시한 것처럼 앱 플랫폼의 경우 각 플랫폼이 지원하는 SDK를 사용하지 않고 웹 뷰를 띄워 Authorization Code를 받도록 구현할 수도 있다. 다만 이러한 경우 앱을 사용하는 사용자는 소셜 로그인을 하기 위해 항상 웹 뷰를 거쳐야 돼서 앱 플랫폼에 최적화 되어 있는 SDK를 사용할 때보다 사용자 경험이 낮아질 수 있다. 반대로 앱 플랫폼이 권장하는 SDK를 사용하면 사용자 경험이 높아지지만 Authentication Code가 아닌 Access Token을 서버에 전달해야 하기 때문에 보안적으로 취약해질 수 있는 단점이 존재하게 된다.
코드 예시는 iOS 클라이언트와 협업했던 프로젝트에서 개발했던 코드이다. 해당 프로젝트에서는 iOS SDK를 사용하기로 결정하였기 때문에 서버는 1 ~ 13 중 10 이후의 단계를 처리하도록 구현하였다. 즉, iOS 클라이언트에서 카카오 플랫폼의 Access Token을 발급 받아 스프링 부트 서버로 전달하여 처리하는 흐름이다.
JWT 관련 기능을 담당하는 클래스는 발급, 생성, 검증 세 가지의 책임으로 나누어 구현하였다.
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
implementation group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
implementation group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'
build.gradle에 의존성을 추가해준다.
jwt:
secret: testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttest
access-token-expire-time: 1800000 # 30분 밀리초
refresh-token-expire-time: 604800000 # 1주 밀리초
application.yml에 jwt 관련 설정을 추가해준다. 우리 서비스에서는 access token의 유효 기간은 30분, refresh token의 유효 기간은 1주일로 설정하였다. 해당 유효 기간이 정답은 아니며 각 서비스에 주어진 요구사항과 애플리케이션의 사용성을 고려하여 적절한 기간을 선정하는 것이 중요하다.
@Builder(access = AccessLevel.PRIVATE)
public record Token(
String accessToken,
String refreshToken
) {
public static Token of(String accessToken, String refreshToken) {
return Token.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
}
클라이언트에 응답으로 반환할 JWT DTO 클래스이다.
@Component
public class JwtGenerator {
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.access-token-expire-time}")
private long ACCESS_TOKEN_EXPIRE_TIME;
@Value("${jwt.refresh-token-expire-time}")
private long REFRESH_TOKEN_EXPIRE_TIME;
public String generateToken(Long userId, boolean isAccessToken) {
final Date now = generateNowDate();
final Date expiration = generateExpirationDate(isAccessToken, now);
return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
.setSubject(String.valueOf(userId))
.setIssuedAt(now)
.setExpiration(expiration)
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
public JwtParser getJwtParser() {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build();
}
private Date generateNowDate() {
return new Date();
}
private Date generateExpirationDate(boolean isAccessToken, Date now) {
return new Date(now.getTime() + calculateExpireTime(isAccessToken));
}
private long calculateExpireTime(boolean isAccessToken) {
if (isAccessToken) {
return ACCESS_TOKEN_EXPIRE_TIME;
}
return REFRESH_TOKEN_EXPIRE_TIME;
}
private Key getSigningKey() {
String encoded = Base64.getEncoder().encodeToString(secretKey.getBytes());
return Keys.hmacShaKeyFor(encoded.getBytes());
}
}
JwtGenerator 클래스는 userId(PK)를 Subject로 지정하여 access token과 refresh token을 생성한다.
@RequiredArgsConstructor
@Component
public class JwtProvider {
private final JwtGenerator jwtGenerator;
public Token issueToken(Long userId) {
return Token.of(jwtGenerator.generateToken(userId, true),
jwtGenerator.generateToken(userId, false));
}
public Long getSubject(String token) {
JwtParser jwtParser = jwtGenerator.getJwtParser();
return Long.valueOf(jwtParser.parseClaimsJws(token)
.getBody()
.getSubject());
}
}
JwtProvider 클래스는 userId(PK)를 Subject로 지정한 access token과 refresh token을 발급하고 지정된 Subject를 조회한다.
@RequiredArgsConstructor
@Component
public class JwtValidator {
private final JwtGenerator jwtGenerator;
public void validateAccessToken(String accessToken) {
try {
JwtParser jwtParser = jwtGenerator.getJwtParser();
jwtParser.parseClaimsJws(accessToken);
} catch (ExpiredJwtException e) {
throw new UnauthorizedException(ErrorStatus.EXPIRED_ACCESS_TOKEN);
} catch (Exception e) {
throw new UnauthorizedException(ErrorStatus.INVALID_ACCESS_TOKEN_VALUE);
}
}
public void validateRefreshToken(String refreshToken) {
try {
JwtParser jwtParser = jwtGenerator.getJwtParser();
jwtParser.parseClaimsJws(refreshToken);
} catch (ExpiredJwtException e) {
throw new UnauthorizedException(ErrorStatus.EXPIRED_REFRESH_TOKEN);
} catch (Exception e) {
throw new UnauthorizedException(ErrorStatus.INVALID_REFRESH_TOKEN_VALUE);
}
}
public void equalsRefreshToken(String refreshToken, String storedRefreshToken) {
if (!refreshToken.equals(storedRefreshToken)) {
throw new UnauthorizedException(ErrorStatus.NOT_MATCH_REFRESH_TOKEN);
}
}
}
JwtValidator 클래스는 발급된 access token과 refresh token을 검증한다.
JWT 유효성 검증과 사용자 인증을 위해 Spring Security의 Security Filter, Authentication을 활용하였다.
implementation 'org.springframework.boot:spring-boot-starter-security'
build.gradle에 의존성을 추가해준다.
public class UserAuthentication extends UsernamePasswordAuthenticationToken {
private UserAuthentication(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
super(principal, credentials, authorities);
}
public static UserAuthentication createDefaultUserAuthentication(Long userId) {
return new UserAuthentication(userId, null, null);
}
}
UserAuthentication 클래스는 사용자 정보를 저장한 인증의 주체이다. JWT를 사용하기 때문에 userId(PK)만 principal로 저장하여 사용하였다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtValidator jwtValidator;
private final JwtProvider jwtProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
final String accessToken = getAccessToken(request);
jwtValidator.validateAccessToken(accessToken);
setAuthentication(request, jwtProvider.getSubject(accessToken));
filterChain.doFilter(request, response);
}
private String getAccessToken(HttpServletRequest request) {
String accessToken = request.getHeader(Constants.AUTHORIZATION);
if (StringUtils.hasText(accessToken) && accessToken.startsWith(Constants.BEARER)) {
return accessToken.substring(Constants.BEARER.length());
}
throw new UnauthorizedException(ErrorStatus.INVALID_ACCESS_TOKEN);
}
private void setAuthentication(HttpServletRequest request, Long userId) {
UserAuthentication authentication = createDefaultUserAuthentication(userId);
createWebAuthenticationDetailsAndSet(request, authentication);
SecurityContext securityContext = SecurityContextHolder.getContext();
securityContext.setAuthentication(authentication);
}
private void createWebAuthenticationDetailsAndSet(HttpServletRequest request, UserAuthentication authentication) {
WebAuthenticationDetailsSource webAuthenticationDetailsSource = new WebAuthenticationDetailsSource();
WebAuthenticationDetails webAuthenticationDetails = webAuthenticationDetailsSource.buildDetails(request);
authentication.setDetails(webAuthenticationDetails);
}
}
JwtAuthenticationFilter 클래스는 요청으로 들어온 access token의 유효성 검증, 사용자 인증을 담당한다.
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
handleException(response);
}
private void handleException(HttpServletResponse response) throws IOException {
setResponse(response, HttpStatus.UNAUTHORIZED, ErrorStatus.UNAUTHORIZED);
}
private void setResponse(HttpServletResponse response, HttpStatus httpStatus, ErrorStatus errorStatus) throws IOException {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(Constants.CHARACTER_TYPE);
response.setStatus(httpStatus.value());
PrintWriter writer = response.getWriter();
writer.write(objectMapper.writeValueAsString(ApiResponse.of(errorStatus)));
}
}
JwtAuthenticationEntryPoint 클래스는 JwtAuthenticationFilter에서 사용자 인증에 실패한 경우 발생하는 401 예외를 핸들링한다.
public class ExceptionHandlerFilter extends OncePerRequestFilter {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException {
try {
filterChain.doFilter(request, response);
} catch (UnauthorizedException e) {
handleUnauthorizedException(response, e);
} catch (Exception ee) {
handleException(response);
}
}
private void handleUnauthorizedException(HttpServletResponse response, Exception e) throws IOException {
UnauthorizedException ue = (UnauthorizedException) e;
ErrorStatus errorStatus = ue.getErrorStatus();
HttpStatus httpStatus = errorStatus.getHttpStatus();
setResponse(response, httpStatus, errorStatus);
}
private void handleException(HttpServletResponse response) throws IOException {
setResponse(response, HttpStatus.INTERNAL_SERVER_ERROR, ErrorStatus.INTERNAL_SERVER_ERROR);
}
private void setResponse(HttpServletResponse response, HttpStatus httpStatus, ErrorStatus errorStatus) throws IOException {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(Constants.CHARACTER_TYPE);
response.setStatus(httpStatus.value());
PrintWriter writer = response.getWriter();
writer.write(objectMapper.writeValueAsString(ApiResponse.of(errorStatus)));
}
}
ExceptionHandlerFilter 클래스는 JwtAuthenticationFilter에서 요청으로 들어온 access token의 유효성 검증에 실패한 경우 발생하는 401 예외를 핸들링한다.
@RequiredArgsConstructor
@EnableWebSecurity
@Configuration
public class SecurityConfig {
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtProvider jwtProvider;
private final JwtValidator jwtValidator;
private static final String[] whiteList = {"/api/user/signin", "/api/user/signup", "/api/user/reissue", "/"};
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return web -> web.ignoring().requestMatchers(whiteList);
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.sessionManagement(sessionManagementConfigurer ->
sessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(exceptionHandlingConfigurer ->
exceptionHandlingConfigurer.authenticationEntryPoint(jwtAuthenticationEntryPoint))
.authorizeHttpRequests(authorizationManagerRequestMatcherRegistry ->
authorizationManagerRequestMatcherRegistry.anyRequest().authenticated())
.addFilterBefore(new JwtAuthenticationFilter(jwtValidator, jwtProvider), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new ExceptionHandlerFilter(), JwtAuthenticationFilter.class)
.build();
}
}
WebSecurity
WebSecurityCustomizer를 통해 Spring Security를 적용하지 않을 whiteList를 설정한다.
HttpSecurity
SecurityFilterChain에 ExceptionHandlerFilter, JwtAuthenticationFilter, JwtAuthenticationEntryPoint 클래스를 등록하고 추가적으로 보안 설정을 한다.
우선 Open Feign의 개념을 알아보자. Open Feign은 넷플릭스에 의해 처음 만들어진 선언적인 HTTP Client 도구로 외부 API 호출을 쉽게 할 수 있도록 도와주는 라이브러리이다. 이런 Open Feign은 다음과 같은 장점을 가지고 있다.
즉, Open Feign은 간단히 말해 Spring Data JPA 처럼 인터페이스와 어노테이션으로 특정 동작을 명시하기만 하면 구현이 되어 편리하게 개발을 할 수 있게 된다.
그러면 왜 HTTP Client를 사용했을까?
iOS 클라이언트로부터 전달 받은 카카오 Access Token으로 해당 사용자 정보를 조회하기 위해 스프링 부트 서버에서 카카오 서버로 서버 to 서버 통신을 해야한다. 이때, API 통신을 하기 위한 도구로서 Open Feign 라이브러리를 사용한 것이다.
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign:4.0.3'
build.gradle에 의존성을 추가해준다.
@EnableFeignClients(basePackageClasses = Application.class)
@Configuration
public class FeignClientConfig {
}
@EnableFeignClients 어노테이션을 적용하면 Open Feign을 활성화할 수 있다.
카카오 토큰 정보 보기 API
[Kakao Developers] 카카오 토큰 정보 보기 API

스프링 부트 서버에서 카카오에서 제공하는 Open API 중, 카카오 토큰 정보 보기 API를 사용하여 iOS 클라이언트로부터 전달 받은 카카오 Access Token의 정보를 가져온다. 응답으로 받은 해당 카카오 Access Token의 소유자의 정보 중 회원번호를 우리 서비스의 회원 고유 식별값으로 설정하여 로그인 처리를 한다. 이때 꼭 회원번호를 회원 고유 식별값으로 사용해야 하는 것은 아니며 각 서비스에 주어진 요구사항을 고려하여 적절한 값을 선정하면 된다. 만약, 카카오 로그인뿐만 아니라 애플 로그인 등 한 서비스에서 여러가지 소셜 로그인을 제공해야 한다면 회원번호 + 플랫폼 문자열을 조합해서 회원 고유 식별값으로 사용하면 될 것이다.
@FeignClient(name = "kakao-feign-client", url = "https://kapi.kakao.com/")
public interface KakaoFeignClient {
@GetMapping("v1/user/access_token_info")
KakaoAccessTokenInfo getKakaoAccessTokenInfo(@RequestHeader("Authorization") String accessToken);
}
@FeignClient 어노테이션으로 API 통신을 수행할 클라이언트를 지정한다. 이때, name과 url 속성을 필수로 지정해야 하는데, name은 원하는 이름을 자유롭게 지으면 되고 url은 API를 호출할 대상의 url을 기입하면 된다. 우리 서비스는 카카오 토큰 정보 보기 API를 호출할 것이기 때문에 해당 API의 url을 기입하였다.


@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
public class KakaoAccessToken {
private static final String TOKEN_TYPE = "Bearer ";
private String accessToken;
public static KakaoAccessToken createKakaoAccessToken(String accessToken) {
return new KakaoAccessToken(accessToken);
}
public String getAccessTokenWithTokenType() {
return TOKEN_TYPE + accessToken;
}
}
카카오 서버에 요청으로 보낼 DTO 클래스이다. 매개변수로 전달 받은 카카오 Access Token은 Bearer 문자열이 포함되어 있지 않기 때문에 getAccessTokenWithTokenType 메서드를 구현하여 Bearer 문자열이 포함되도록 기능을 추가하였다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class KakaoAccessTokenInfo {
private Long id;
}
카카오 서버로부터 응답으로 받을 DTO 클래스이다. 우리 서비스는 회원번호만 필요하기 때문에 id 속성만 명시하였다.
@RequiredArgsConstructor
@Component
public class KakaoOAuthProvider {
private final KakaoFeignClient kakaoFeignClient;
public String getKakaoPlatformId(String accessToken) {
KakaoAccessToken kakaoAccessToken = createKakaoAccessToken(accessToken);
String accessTokenWithTokenType = kakaoAccessToken.getAccessTokenWithTokenType();
KakaoAccessTokenInfo kakaoAccessTokenInfo = getKakaoAccessTokenInfo(accessTokenWithTokenType);
return String.valueOf(kakaoAccessTokenInfo.getId());
}
private KakaoAccessTokenInfo getKakaoAccessTokenInfo(String accessTokenWithTokenType) {
try {
return kakaoFeignClient.getKakaoAccessTokenInfo(accessTokenWithTokenType);
} catch (FeignException e) {
throw new UnauthorizedException(ErrorStatus.INVALID_KAKAO_ACCESS_TOKEN);
}
}
}
KakaoOAuthProvider 클래스는 스프링 부트 서버에서 카카오 서버로 서버 to 서버 통신을 수행하며 iOS 클라이언트로부터 전달 받은 카카오 Access Token의 정보 중 회원번호 값을 가져와서 반환한다.
카카오 소셜 회원가입, 카카오 소셜 로그인, JWT 재발급, 로그아웃, 회원탈퇴 기능의 비즈니스 로직을 구현하였다.
@RequiredArgsConstructor
@Transactional
@Service
public class AuthService {
private final JwtProvider jwtProvider;
private final JwtValidator jwtValidator;
private final KakaoOAuthProvider kakaoOAuthProvider;
private final UserRepository userRepository;
public UserAuthResponseDto signIn(String token) {
String platformId = kakaoOAuthProvider.getKakaoPlatformId(token);
User findUser = getUser(platformId);
Token issuedToken = issueAccessTokenAndRefreshToken(findUser);
updateRefreshToken(findUser, issuedToken.getRefreshToken());
return UserAuthResponseDto.of(issuedToken, findUser);
}
public UserAuthResponseDto signUp(String token, UserSignUpRequestDto request) {
validateDuplicateNickname(request.nickname());
String platformId = kakaoOAuthProvider.getKakaoPlatformId(token);
validateDuplicateUser(platformId);
User user = createUser(platformId, request.nickname());
User savedUser = userRepository.save(user);
Token issuedToken = issueAccessTokenAndRefreshToken(savedUser);
updateRefreshToken(savedUser, issuedToken.getRefreshToken());
return UserAuthResponseDto.of(issuedToken, savedUser);
}
@Transactional(noRollbackFor = UnauthorizedException.class)
public Token reissue(String refreshToken, UserReissueRequestDto request) {
Long userId = request.userId();
User findUser = getUser(userId);
validateRefreshToken(userId, refreshToken, findUser.getRefreshToken());
Token issuedToken = issueAccessTokenAndRefreshToken(findUser);
updateRefreshToken(findUser, issuedToken.getRefreshToken());
return issuedToken;
}
public void signOut(Long userId) {
User findUser = getUser(userId);
deleteRefreshToken(findUser);
}
public void withdraw(Long userId) {
userRepository.deleteById(userId);
}
private User getUser(String platformId) {
return userRepository.findUserByPlatformId(platformId)
.orElseThrow(() -> new EntityNotFoundException(ErrorStatus.USER_NOT_FOUND));
}
private void validateDuplicateNickname(String nickname) {
if (userRepository.existsUserByNickname(nickname)) {
throw new ConflictException(ErrorStatus.DUPLICATE_NICKNAME);
}
}
private void validateDuplicateUser(String platformId) {
if (userRepository.existsUserByPlatformId(platformId)) {
throw new ConflictException(ErrorStatus.DUPLICATE_USER);
}
}
private void validateRefreshToken(Long userId, String refreshToken, String storedRefreshToken) {
try {
jwtValidator.validateRefreshToken(refreshToken);
jwtValidator.equalsRefreshToken(refreshToken, storedRefreshToken);
} catch (UnauthorizedException e) {
signOut(userId);
throw e;
}
}
private Token issueAccessTokenAndRefreshToken(User user) {
return jwtProvider.issueToken(user.getId());
}
private void updateRefreshToken(User user, String refreshToken) {
user.updateRefreshToken(refreshToken);
}
private User getUser(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException(ErrorStatus.USER_NOT_FOUND));
}
private void deleteRefreshToken(User findUser) {
findUser.updateRefreshToken(null);
}
}
카카오 소셜 회원가입
카카오 소셜 로그인
JWT 재발급
로그아웃
회원탈퇴
예외는 RuntimeException을 상속 받은 BusinessException을 부모로 각 Http Status에 따라 BusinessException을 한번 더 상속받는 구조로 설계하여 처리하였다. 또한 동일한 Http Status에 서로 다른 예외 케이스를 정의하기 위해 예외 메시지를 관리하는 Enum 클래스를 추가적으로 활용하였다.
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
public enum ErrorStatus {
/**
* 401 Unauthorized
*/
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "리소스 접근 권한이 없습니다."),
INVALID_KAKAO_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "카카오 액세스 토큰의 정보를 조회하는 과정에서 오류가 발생하였습니다."),
INVALID_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "액세스 토큰의 형식이 올바르지 않습니다. Bearer 타입을 확인해 주세요."),
INVALID_ACCESS_TOKEN_VALUE(HttpStatus.UNAUTHORIZED, "액세스 토큰의 값이 올바르지 않습니다."),
EXPIRED_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "액세스 토큰이 만료되었습니다. 재발급 받아주세요."),
INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "리프레시 토큰의 형식이 올바르지 않습니다."),
INVALID_REFRESH_TOKEN_VALUE(HttpStatus.UNAUTHORIZED, "리프레시 토큰의 값이 올바르지 않습니다."),
EXPIRED_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "리프레시 토큰이 만료되었습니다. 다시 로그인해 주세요."),
NOT_MATCH_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "일치하지 않는 리프레시 토큰입니다."),
/**
* 404 Not Found
*/
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 회원입니다."),
/**
* 409 Conflict
*/
DUPLICATE_NICKNAME(HttpStatus.CONFLICT, "이미 존재하는 닉네임입니다."),
DUPLICATE_USER(HttpStatus.CONFLICT, "이미 존재하는 회원입니다."),
/**
* 500 Internal Server Error
*/
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류입니다.");
private final HttpStatus httpStatus;
private final String message;
}
ErrorStatus 클래스는 각 Http Status에 따른 예외 메시지를 Enum으로 관리한다.
@Getter
public class BusinessException extends RuntimeException {
private final ErrorStatus errorStatus;
public BusinessException(ErrorStatus errorStatus) {
super(errorStatus.getMessage());
this.errorStatus = errorStatus;
}
}
BusinessException 클래스는 RuntimeException을 상속 받은 unchecked 예외이다. 비즈니스 로직에서 발생하게 되는 예외 케이스에서 활용하며 각 Http Status에 따라 BusinessException을 한번 더 상속받은 커스텀 예외 클래스들이 사용된다.
public class UnauthorizedException extends BusinessException {
public UnauthorizedException() {
super(ErrorStatus.UNAUTHORIZED);
}
public UnauthorizedException(ErrorStatus errorStatus) {
super(errorStatus);
}
}
UnauthorizedException 클래스는 BusinessException을 한번 더 상속받은 커스텀 예외 클래스들 중 한 가지이며 비즈니스 로직에서 발생하게 되는 예외 케이스들 중 401 Unauthorized 예외 케이스에서 사용된다.
OAuth의 개념부터 JWT 인증을 활용하기 위해 Spring Security의 어떤 Component를 활용하고 어떻게 보안 설정을 하는지 작성해보았다. 또한 자체 회원가입과 로그인 서비스를 제공하지 않고 소셜 플랫폼 중 카카오 플랫폼을 통해 간편하게 회원가입, 로그인 하는 방법을 알아보았다. 아이디와 비밀번호를 입력하지 않고 카카오 플랫폼에서 제공하는 사용자의 고유 값으로 회원가입, 로그인을 하기 위해 필요한 사용자 정보는 무엇이고 이를 획득하기 위해 서버 to 서버 통신이 이루어져야 하는 점과 이를 위해 사용된 Http Client 도구인 Open Feign을 알아보았다. 이 글은 소셜 로그인을 할 수 있는 많은 방법 중 한 가지 방법일 뿐이다. 핵심은 주어진 요구사항을 잘 파악하고 이를 해결하는 과정에 집중하는 것이 중요할 것 같다. 해결하는 과정 속에서 다양한 접근 방법을 시도해보며 어떤 방법이 현재 우리 서비스에서 가장 좋은 방법일지 고민해보고 선택하는게 가장 중요하지 않을까 싶다.