
최근 진행한 프론트-백엔드 협업 팀프로젝트에서 OAuth2를 활용한 사용자 권한 인가&권한 파트를 담당하게 되었다. 구현과정에 앞서 정말 많은 자료들을 찾아봤었는데, 진행했던 프로젝트에 최적화된 방법으로 구현하기 위해 다양한 방법을 종합하였는데, 이번 포스팅에서는 해당 방법에 대해 다룰 예정이다.
| OAuth2란?
OAuth2(Open Authorization 2.0, OAuth2)란 인증 및 권한획득을 위한 표준 인증 프로토콜로,
Third-party Application이 사용자를 대신해서 다른 곳에서 사용 중인 resource에 접근할 수 있도록 한다.
여러 서비스 제공자의 API를 신뢰할 수 있는 방법으로 사용할 수 있다.
OAuth와 로그인은 반드시 분리해서 이해해야 한다. 일상 생활을 예로 들어 OAuth와 로그인의 차이를 설명해 보겠다.
사원증을 이용해 출입할 수 있는 회사를 생각해 보자. 그런데 외부 손님이 그 회사에 방문할 일이 있다. 회사 사원이 건물에 출입하는 것이 로그인이라면 OAuth는 방문증을 수령한 후 회사에 출입하는 것에 비유할 수 있다.
OAuth에서 'Auth'는 'Authentication'(인증) 뿐만 아니라 'Authorization'(허가) 또한 포함하고 있다. 그렇기 때문에 OAuth2 인증을 진행할 때 해당 서비스 제공자는 '제 3자가 어떤 정보나 서비스에 사용자의 권한으로 접근하려 하는데 허용하겠느냐'라는 안내 메시지를 보여주는 것이다.
| OAuth2 관련 주요 용어
- Resource Owner: 사용자
- Client: 리소스 서버에서 제공해주는 자원을 사용하는 외부 플랫폼
- Authorization Server: 외부 플랫폼이 리소스 서버의 사용자 자원을 사용하기 위한 인증 서버
- Resource Server: 사용자의 자원을 제공해주는 서버
| 프로젝트 최적화 방법 고민하기 : OAuth2를 활용한 회원가입 및 소셜 로그인
프로젝트에서는 개인 정보들을 직접 기입하는 일반 회원가입/로그인 기능과 소셜 로그인 기능을 함께 제공하려고 한다. 그렇기 때문에 아래 프레임워크/라이브러리를 함께 조합하여 소셜 로그인을 구현하였다.
- Spring Security
- OAuth2 Client
- Json Web Token(JWT)
보통 OAuth2의 경우, 프론트와 백이 함께 구현할 때 2가지 방법이 있다고 할 수 있다.
방법 1
백엔드에서 Authentication Server와 Resource Server 모두 통신하여 프론트에게 토큰만 던져주는 방식
방법 2
프론트에서 인증 요청을 통해 받은 코드를 백엔드로 넘겨주고, 백엔드에서 코드를 기반으로 토큰과 사용자 정보를 받아 프론트에게 데이터를 전송해주는 방식
결론적으로 채택한 방법은 아래 시퀀스와 같은 1번 방법이다!

왜 1번 방법을 채택했는지에 대한 이유를 정리하였다.
1. 구글, 카카오 등에서 제공하는 Authorization Server를 통해 회원 정보를 인증하고 Access Token을 발급 받는다.
2. 발급 받은 Access Token을 이용해 직접 개발한 서버의 API 서비스를 이용 및 호출한다.
마이크로 서비스이거나 서버 간의 통신이 잦은 경우, Access Token을 자주 주고 받을 수 밖에 없다.
3. 그리고 각 서버는 API 호출 요청에 대해서 전달 받은 Access Token이 유효한 지를 확인해야 한다.
API를 호출할 때마다 Access Token이 유효한지 매번 조회하고 갱신 작업을 해주어야하는데
Auth 서버에 유효성 검증 확인을 위해 요청할 때마다 병목 현상으로 인해 서버의 부하가 발생할 수 있다.
4. Claim 기반 방식은 JWT를 통해 Auth 서버에 검증 요청을 보내야했던 과정을 생략하고, 각 서버에서 API 요청이 가능하도록
stateless 구조를 구성하였다. Auth 서버가 아닌 애플리케이션 서버에서 토큰 유효성 검사를 통해 사용자 인증을 거친다.
기본적으로 Authorization Server에서 클라이언트에게 토큰을 제공하고 해당 토큰을 통해 Resoure Server로 부터 사용자 정보를 제공 받는데, 본 프로젝트에서는 사용자 정보 검증 후, 애플리케이션 서버에서 JWT를 생성하여 프론트에 넘겨줄 수 있도록 하였다.
즉 로직 순서를 보면
- 로그인 버튼을 누르면 동의 화면이 나오고, 진행 시 소셜 인가서버가 인가코드를 반환한다.
- 받은 인가코드와 함께 Access Token을 요청한다.
- Access Token을 받으면 그 토큰과 함께 사용자 정보요청하고 사용자 정보를 받는다.
- 백엔드에서는 받은 유저 정보를 db에 저장하고, Access Token과 Refresh Token 만든다.
- Refresh Token은 Redis DB에 저장하고, 다시 프론트로 Access Token은 url 파라미터로, Refresh Token은 reqeust body로 전달한다.
| OAuth2 구현하기
1. 서비스 등록
OAuth 로그인을 구현하기 위해서는 꼭 각 플랫폼에서 제공하는 OAuth 앱 또는 서비스를 등록해야한다.
구글, 네이버, 카카오 총 세 가지의 플랫폼에 서비스를 등록하는 과정을 거쳤으며,
각 플랫폼에서 서비스 등록 후, 플랫폼별 클라이언트ID와 클라이언트 secret을 얻어 아래와 같이 yml 및 properties 파일에 작성해주어야한다.
spring:
profiles:
include: google, kakao, naver
---
spring:
profiles: google
security:
oauth2:
client:
registration:
google:
client-name: google
client-id: [발급받은 client id]
client-secret: [발급받은 client secret]
redirect-uri: "{baseUrl}/oauth2/callback/{registrationId}"
authorization-grant-type: authorization_code
scope:
- email
- profile
---
spring:
profiles: kakao
security:
oauth2:
client:
registration:
kakao:
client-name: kakao
client-id: [발급받은 client id]
client-secret: [발급받은 client secret]
redirect-uri: "{baseUrl}/oauth2/callback/{registrationId}"
authorization-grant-type: authorization_code
scope:
- account_email
- profile_nickname
- profile_image
client-authentication-method: POST
provider:
kakao:
authorization-uri: https://kauth.kakao.com/oauth/authorize
user-info-uri: https://kapi.kakao.com/v2/user/me
token-uri: https://kauth.kakao.com/oauth/token
user-name-attribute: id
---
spring:
profiles: naver
security:
oauth2:
client:
registration:
naver:
client-name: naver
client-id: [발급받은 client id]
client-secret: [발급받은 client secret]
redirect-uri: "{baseUrl}/oauth2/callback/{registrationId}"
authorization-grant-type: authorization_code
scope:
- email
- name
- profile_image
provider:
naver:
authorization-uri: https://nid.naver.com/oauth2.0/authorize
user-info-uri: https://openapi.naver.com/v1/nid/me
token-uri: https://nid.naver.com/oauth2.0/token
user-name-attribute: response
2. MemberInfo
각 소셜마다 받아오는 데이터의 JSON 형태가 다르므로 기본적으로 필요한 정보를 추상화하고 상속받아 구현한다.
[OAuth2MemberInfoFactory]
public class OAuth2MemberInfoFactory {
public static OAuth2MemberInfo getOAuth2MemberInfo(ProviderType providerType, Map<String, Object> attributes) {
switch (providerType) {
case GOOGLE: return new GoogleMemberInfo(attributes);
case NAVER: return new NaverMemberInfo(attributes);
case KAKAO: return new KakaoMemberInfo(attributes);
default: throw new IllegalArgumentException("Invalid Provider Type");
}
}
}
[OAuth2MemberInfo]
public interface OAuth2MemberInfo {
Map<String, Object> getAttributes();
String getProviderId();
String getProvider();
String getEmail();
String getName();
String getImageUrl();
}
[GoogleMemberInfo]
public class GoogleMemberInfo implements OAuth2MemberInfo {
private final Map<String, Object> attributes;
public GoogleMemberInfo(Map<String, Object> attributes) {
this.attributes = attributes;
}
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
@Override
public String getProviderId() {
return attributes.get("sub").toString();
}
@Override
public String getName() {
return attributes.get("name").toString();
}
@Override
public String getImageUrl() {
return attributes.get("picture").toString();
}
@Override
public String getEmail() {
return attributes.get("email").toString();
}
@Override
public String getProvider() {
return "Google";
}
}
[NaverMemberInfo]
public class NaverMemberInfo implements OAuth2MemberInfo {
private final Map<String, Object> attributes;
public NaverMemberInfo(Map<String, Object> attributes) {
this.attributes = (Map<String, Object>) attributes.get("response");
}
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
@Override
public String getProviderId() {
return attributes.get("id").toString();
}
@Override
public String getName() {
return attributes.get("name").toString();
}
@Override
public String getImageUrl() {
return attributes.get("profile_image").toString();
}
@Override
public String getEmail() {
return attributes.get("email").toString();
}
@Override
public String getProvider() {
return "Naver";
}
}
[KakaoMemberInfo]
public class KakaoMemberInfo implements OAuth2MemberInfo {
private final Map<String, Object> attributes;
private final Map<String, Object> attributesAccount;
private final Map<String, Object> attributesProfile;
public KakaoMemberInfo(Map<String, Object> attributes) {
this.attributes = attributes;
this.attributesAccount = (Map<String, Object>) attributes.get("kakao_account");
this.attributesProfile = (Map<String, Object>) attributesAccount.get("profile");
}
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
@Override
public String getProviderId() {
return attributes.get("id").toString();
}
@Override
public String getEmail() {
return attributesAccount.get("email").toString();
}
@Override
public String getName() {
return attributesProfile.get("nickname").toString();
}
@Override
public String getImageUrl() {
return attributesProfile.get("thumbnail_image_url").toString();
}
@Override
public String getProvider() {
return "Kakao";
}
}
3. Success/FailureHandler
userInfoEndpoint().userService()가 먼저 실행되고, 그 이후에 successHandler()가 실행된다!
Success Handler에 진입했다는 것은, 로그인이 완료되었다는 뜻이다.
이 때가 정말 중요하다. 해당 클래스의 주요 기능은 크게 2가지이다.
- 최초 로그인인지 확인
- Access Token, Refresh Token 생성 및 발급
- token을 포함하여 리다이렉트
로그인에 성공할 시, 토큰을 생성하여 URL Redirect하도록 설정하였다.
프론트에서 리다이렉트된 URL에서 쿼리스트링으로 넘어온 Access Token을 저장한 후, 로그인 성공 완료하여
메인 페이지로 리다이렉트 되도록 하였다.
[OAuth2SuccessHandler]
@Slf4j
@Component
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
@Value("${targetUrl.success}")
private String basicUrl;
private final JwtTokenUtil jwtTokenUtil;
public OAuth2SuccessHandler(JwtTokenUtil jwtTokenUtil) {
this.jwtTokenUtil = jwtTokenUtil;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException {
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
OAuth2AuthenticationToken authToken = (OAuth2AuthenticationToken) authentication;
ProviderType providerType = ProviderType.valueOf(authToken.getAuthorizedClientRegistrationId().toUpperCase());
OAuth2MemberInfo memberInfo = OAuth2MemberInfoFactory.getOAuth2MemberInfo(providerType, oAuth2User.getAttributes());
log.info("Generate Token");
String memberEmail = memberInfo.getEmail();
String token = jwtTokenUtil.generateAccessToken(memberEmail);
String targetUrl = makeRedirectUrl(token);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
private String makeRedirectUrl(String token) {
return UriComponentsBuilder.fromUriString(basicUrl)
.queryParam("token", token)
.build().toUriString();
}
}
실패할 경우, 다시 로그인 페이지로 리다이렉트 되도록 설정하였다.
[OAuth2FailureHandler]
@Slf4j
@Component
public class OAuth2FailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Value("${targetUrl.fail}")
private String targetUrl;
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception)
throws IOException, ServletException {
exception.printStackTrace();
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
}
4. CustomerOAuth2MemberService
받아온 소셜 계정 정보를 OAuth2UserInfo로 반환하고 DB에 등록 및 수정을 진행한다.
소셜로그인 진행과정에서 먼저 providerType에 따라 회원 정보를 요청하고, 데이터베이스에 해당 정보가 있는지 확인한다.
동일 계정에 대한 중복 회원가입을 막기 위해 따로 providerType 일치 여부를 확인한다.
기존에 이미 구글 계정으로 로그인하여 기록이 남아있고, 추후 카카오로 소셜로그인을 시도하였는데 카카오 계정이 구글 계정과 동일하다면 예외가 발생하도록 처리하였다.
신규 로그인에 대해서는 서비스 자체 서버에 회원 정보를 저장하고, 아닐 경우 바로 로그인이 가능하며 업데이트 또한 가능하다.
@RequiredArgsConstructor
@Slf4j
@Service
public class CustomerOAuth2MemberService extends DefaultOAuth2UserService {
private final MemberRepository memberRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
try {
log.info(String.valueOf(userRequest.getAccessToken()));
return this.processOAuth2User(userRequest, oAuth2User);
} catch (AuthenticationException ex) {
throw ex;
} catch (Exception ex) {
ex.printStackTrace();
throw new InternalAuthenticationServiceException(ex.getMessage(), ex.getCause());
}
}
private OAuth2User processOAuth2User(OAuth2UserRequest userRequest, OAuth2User oAuth2User) {
ProviderType providerType = ProviderType.valueOf(userRequest.getClientRegistration().getRegistrationId().toUpperCase());
OAuth2MemberInfo memberInfo = OAuth2MemberInfoFactory.getOAuth2MemberInfo(providerType, oAuth2User.getAttributes());
Optional<Member> savedMember = memberRepository.findByEmail(memberInfo.getEmail());
Member member;
if (savedMember.isPresent()) {
member = savedMember.get();
if (!member.getProviderType().equals(providerType)) {
throw new AuthException(AuthErrorCode.OAuthProviderMissMatch);
}
updateMember(member, memberInfo);
} else {
member = registerMember(memberInfo, providerType);
}
log.info("["+providerType.toString() + "] processOAuth2User :" + member);
return new PrincipalDetail(member, oAuth2User.getAttributes());
}
private Member registerMember(OAuth2MemberInfo memberInfo, ProviderType providerType) {
String uuid = UUID.randomUUID().toString().substring(0, 6);
return memberRepository.saveAndFlush(Member.builder()
.email(memberInfo.getEmail())
.name(memberInfo.getName())
.nickname(providerType+"_"+ uuid)
.password(BCrypt.hashpw("esc" + uuid, BCrypt.gensalt()))
.role(MemberRole.ROLE_USER)
.providerType(providerType)
.status(MemberStatus.ING)
.type(MemberType.USER)
.providerId(memberInfo.getProviderId())
.imgUrl(memberInfo.getImageUrl())
.build());
}
private void updateMember(Member member, OAuth2MemberInfo memberInfo) {
if (memberInfo.getName() != null && !memberInfo.getName().equals(memberInfo.getName())) {
member.setName(memberInfo.getName());
}
if (memberInfo.getImageUrl() != null && !memberInfo.getImageUrl().equals(memberInfo.getImageUrl())) {
member.setImgUrl(memberInfo.getImageUrl());
}
memberRepository.save(member);
}
}
5. Controller
@RestController
@RequestMapping("/members")
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
@ApiOperation(value = "OAuth2 회원 정보 요청", notes = "소셜 로그인 시, 필요한 회원 정보를 전달합니다.")
@PostMapping("/oauth2/info")
public ResponseEntity<OAuthDto.Response> oauth2Info(@RequestBody OAuthDto.Request oauthDto) {
OAuthDto.Response response = memberService.oauthInfo(oauthDto);
return ResponseEntity.ok(response);
}
}
6. DTO
처음에는 refreshToken은 보안상의 이유로 쿠키에 저장해서 프론트에서 받을 수 있도록 하려했지만, httpOnly설정으로 쿠키 정보를 받을 수 없어 response로 따로 전달하였다. 이외의 데이터는 프론트 측에서 추가적으로 마이페이지에 사용하기 위해 필요한 데이터를 함께 담았다.
public class OAuthDto {
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ApiModel(value = "소셜 로그인 Request Body")
public static class Request {
private String email;
}
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ApiModel(value = "소셜 로그인 Response Body")
public static class Response {
private Long id;
private String nickname;
private String imgUrl;
private String refreshToken;
}
}
| 프로젝트 구조 / 코드 링크

위의 구조는 진행했던 프로젝트의 전체적인 Infrastructure이다.
+) 추가로 프로젝트 깃허브 링크 남겨두겠습니다!
(config, redis, jwt, CORS 관련 코드들은 링크 참고 부탁드려요.)
https://github.com/MinWonHaeSo/ESC_SERVER
GitHub - MinWonHaeSo/ESC_SERVER: ⚽ Easy Sports Club Server ⚽
⚽ Easy Sports Club Server ⚽. Contribute to MinWonHaeSo/ESC_SERVER development by creating an account on GitHub.
github.com
참고자료
https://d2.naver.com/helloworld/24942