이번에 진행한 프로젝트가 웹 기반이다 보니, 전부터 한 번 써보고 싶었던 Spring Security를 통해 소셜 로그인을 구현할 수 있는 기회가 생겼다. Spring Security로 해보는 건 처음이었는데, 구현할 당시에는 개발 자체보다 프론트 측에 어떤 형식으로 데이터를 넘겨줄지에 대한 이야기를 더 많이 나눴던 것 같다. 사용자에 따라 소셜 로그인 후에 분기 처리를 하는 과정이 가장 고민이 많이 됐다.
Spring Security OAuth2 Client는 표준 Authorization Code Flow를 따른다. 전체 흐름은 다음과 같다.
/oauth2/authorization/{provider}로 접근하면 Spring이 해당 Provider의 인증 페이지로 리다이렉트한다redirect-uri로 인가 코드(authorization code)를 전달한다OAuth2UserService에서 이 정보를 기반으로 내부 회원 정보와 매핑한다SuccessHandler가 호출되어 후처리를 진행한다이 중 대부분은 Spring이 자동으로 처리해주고, 우리가 직접 커스터마이징해야 하는 부분은 5번 사용자 정보 매핑과 6번 인증 완료 후처리 정도다.
Kakao Developers에서 애플리케이션을 생성한 뒤 다음 설정을 진행한다.
1) 웹 도메인 등록

앱 → 제품 링크 관리 → 웹 도메인에서 서비스 도메인을 등록한다. 로컬, 개발 서버, 운영 서버 세 개의 도메인을 모두 등록해두면 환경별로 번거로운 설정 변경이 없다.
2) 카카오 로그인 활성화

제품 설정 → 카카오 로그인 → 일반에서 카카오 로그인을 활성화한다.
3) 동의 항목 설정

제품 설정 → 카카오 로그인 → 동의 항목에서 받고 싶은 사용자 정보를 설정한다. 이때 이메일을 받으려면 비즈 앱 전환이 필요한데, 비즈니스 정보 심사를 완료하면 추가 기능 신청이 가능하다.
4) Client ID / Secret 확인

앱 → 플랫폼 키에서 확인한다.
client-id : REST API 키client-secret : REST API 키 세부 정보에 들어가면 Client Secret 코드를 확인할 수 있다
5) Redirect URI 등록

마찬가지로 로컬, 개발, 운영 서버 세 개의 URI를 모두 등록한다. 형식은 {도메인}/login/oauth2/code/kakao로, Spring Security OAuth2 Client의 기본 경로를 따른다.
Google Cloud Console에서 프로젝트를 생성한 뒤 다음 설정을 진행한다.
1) OAuth 동의 화면 설정
API 및 서비스 → OAuth 동의 화면으로 이동해 앱 정보를 등록한다.
2) OAuth 클라이언트 ID 생성

클라이언트 → OAuth 클라이언트 ID 생성에서 웹 애플리케이션 유형으로 클라이언트를 생성한다.
3) 승인된 리디렉션 URI 등록

카카오와 마찬가지로 {도메인}/login/oauth2/code/google 형식으로 등록한다.
4) Client ID / Secret 확인
client-id : 클라이언트 ID
client-secret : 클라이언트 보안 비밀번호
발급받은 값들은 외부에 노출되면 안 되므로 환경 변수로 관리한다.
# 카카오
KAKAO_CLIENT_ID=카카오_REST_API_키
KAKAO_CLIENT_SECRET=카카오_Client_Secret_코드
# 구글
GOOGLE_CLIENT_ID=구글_클라이언트_ID
GOOGLE_CLIENT_SECRET=구글_클라이언트_보안_비밀번호
Spring Security OAuth2 Client의 설정 파일이다.
spring:
config:
activate:
on-profile: local
security:
oauth2:
client:
registration:
kakao:
client-id: ${KAKAO_CLIENT_ID}
client-secret: ${KAKAO_CLIENT_SECRET}
client-name: Kakao
authorization-grant-type: authorization_code
redirect-uri: http://localhost:8090/login/oauth2/code/kakao
client-authentication-method: client_secret_post
scope:
- account_email
- profile_nickname
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
client-name: Google
authorization-grant-type: authorization_code
redirect-uri: http://localhost:8090/login/oauth2/code/google
client-authentication-method: client_secret_post
scope:
- email
- profile
provider:
kakao:
authorization-uri: https://kauth.kakao.com/oauth/authorize
token-uri: https://kauth.kakao.com/oauth/token
user-info-uri: https://kapi.kakao.com/v2/user/me
user-name-attribute: id
google:
user-name-attribute: sub
success-url: http://localhost:8090/auth/callback
failure-url: http://localhost:8090/auth/error
각 설정의 역할은 다음과 같다.
client-authentication-method: client_secret_post : 토큰 요청 시 client_secret을 요청 바디에 포함하는 방식이다. 카카오는 이 방식만 지원하기 때문에 명시적으로 지정해야 한다.provider.kakao.* : 카카오는 Spring이 기본 제공하는 Provider가 아니기 때문에 authorization-uri, token-uri, user-info-uri를 직접 지정해야 한다. 반면 구글은 기본 Provider로 등록되어 있어서 user-name-attribute만 지정하면 된다.user-name-attribute : Provider가 반환하는 사용자 식별자의 JSON 키를 의미한다. 카카오는 id, 구글은 sub로 서로 다르다.success-url / failure-url : 인증 결과를 프론트엔드로 전달할 리다이렉트 URL이다.Spring Security의 OAuth2 로그인을 활성화하고 커스텀 핸들러를 등록한다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final OAuth2UserService oAuth2UserService;
private final JwtService jwtService;
@Value("${success-url}")
private String oauth2SuccessUrl;
@Value("${failure-url}")
private String oauth2FailureUrl;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.oauth2Login(oauth2 -> oauth2
.userInfoEndpoint(userInfo -> userInfo
.userService(oAuth2UserService))
.successHandler(successHandler())
.failureHandler(failureHandler())
)
.csrf(AbstractHttpConfigurer::disable)
.cors(withDefaults());
return http.build();
}
}
userInfoEndpoint에 커스텀 OAuth2UserService를 등록하면, Provider로부터 사용자 정보를 받아온 뒤 내부 로직을 태울 수 있다. REST API 서버이므로 CSRF는 비활성화했다.
DefaultOAuth2UserService를 상속받아 Provider로부터 받은 사용자 정보를 내부 회원 정보와 매핑한다. 이 부분은 프로젝트의 비즈니스 로직에 따라 커스터마이징해서 진행하면 되고, Provider로부터 사용자 정보를 받는 부분 정도만 참고하면 된다.
@Service
@RequiredArgsConstructor
public class OAuth2UserService extends DefaultOAuth2UserService {
private final MemberQueryService memberQueryService;
private final MemberService memberService;
private final JwtService jwtService;
@Override
@Transactional
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
// 1. Provider 구분 (kakao / google)
String registrationId = userRequest.getClientRegistration().getRegistrationId();
Constant.SocialType socialType = Constant.SocialType.getSocialType(registrationId);
// 2. Provider별 사용자 정보 파싱
Map<String, Object> attributes = oAuth2User.getAttributes();
OAuth2AccountInfo oAuth2AccountInfo;
if (socialType == KAKAO) oAuth2AccountInfo = OAuth2AccountInfo.fromKakao(attributes);
else if (socialType == GOOGLE) oAuth2AccountInfo = OAuth2AccountInfo.fromGoogle(attributes);
else throw new RuntimeException("LOGIN FAIL");
// 3. 기존 회원 조회
String id = oAuth2AccountInfo.getId();
Member member = memberQueryService.findMemberBySocialIdAndSocialType(id, socialType, Status.ACTIVE);
Map<String, Object> newAttributes = new HashMap<>(attributes);
if (member == null) {
// 신규 회원 → 임시 멤버 생성 (NOT_JOINED)
OAuth2Account account = oAuth2AccountInfo.getOAuth2Account();
member = memberService.createSocialMember(account.getEmail(), account.getName(), id, socialType);
newAttributes.put("userCode", id);
} else if (member.getJoinStatus() == Constant.JoinStatus.NOT_JOINED) {
// 회원가입 미완료 → userCode만 전달
newAttributes.put("userCode", id);
} else {
// 기존 회원 (JOINED) → JWT 발급
String refreshToken = jwtService.createRefreshToken(member.getId());
newAttributes.put("accessToken", jwtService.createAccessToken(member.getId()));
newAttributes.put("refreshToken", refreshToken);
memberService.saveRefreshToken(member, refreshToken);
}
newAttributes.put("memberStatus", member.getJoinStatus());
BoardPrincipal principal = BoardPrincipal.from(member, newAttributes);
String nameAttributeKey = registrationId.equals("google") ? "sub" : "id";
return new DefaultOAuth2User(principal.getAuthorities(), principal.getAttributes(), nameAttributeKey);
}
}
회원 상태를 세 가지로 나눠 분기했다.
NOT_JOINED 상태로 생성하고 userCode를 전달한다.userCode만 전달한다.SuccessHandler에서 사용할 데이터를 newAttributes에 미리 담아두었다. Spring Security 내부적으로 OAuth2User의 attributes는 이후 단계에서도 계속 참조 가능하기 때문에, 이 방식으로 상태를 넘기면 별도의 세션 저장소 없이 깔끔하게 처리할 수 있다.
카카오와 구글은 사용자 정보 응답의 JSON 구조가 다르다. Provider별 파싱 로직을 분리해서 이후 로직은 동일한 객체(OAuth2AccountInfo, OAuth2Account)로 다루도록 추상화했다.
1) OAuth2AccountInfo : 소셜 고유 ID와 계정 정보를 담는다
public class OAuth2AccountInfo {
private String id;
private OAuth2Account oAuth2Account;
public static OAuth2AccountInfo fromKakao(Map<String, Object> attributes) {
return OAuth2AccountInfo.builder()
.id(String.valueOf(attributes.get("id")))
.oAuth2Account(OAuth2Account.fromKakao(
(Map<String, Object>) attributes.get("kakao_account")))
.build();
}
public static OAuth2AccountInfo fromGoogle(Map<String, Object> attributes) {
return OAuth2AccountInfo.builder()
.id(String.valueOf(attributes.get("sub")))
.oAuth2Account(OAuth2Account.fromGoogle(attributes))
.build();
}
}
2) OAuth2Account : 이름, 이메일을 담는다
public class OAuth2Account {
private String name;
private String email;
public static OAuth2Account fromKakao(Map<String, Object> attributes) {
return OAuth2Account.builder()
.name(getName((Map<String, Object>) attributes.get("profile")))
.email((String) attributes.get("email"))
.build();
}
public static OAuth2Account fromGoogle(Map<String, Object> attributes) {
return OAuth2Account.builder()
.name(String.valueOf(attributes.get("name")))
.email(String.valueOf(attributes.get("email")))
.build();
}
}
구글은 name, email, sub가 모두 최상위에 있지만, 카카오는 kakao_account 객체 안에 email이 있고 kakao_account.profile 안에 닉네임이 있다.
Spring Security는 로그인 방식에 따라 인증 주체(Principal)의 타입이 다르다. 일반 로그인은 UserDetails, 소셜 로그인은 OAuth2User를 사용한다. 두 인터페이스를 따로 구현하면 로그인 방식에 따라 Principal 타입을 분기해야 하는 번거로움이 생긴다.
public class BoardPrincipal implements UserDetails, OAuth2User {
private String email;
private String pw;
private Collection<? extends GrantedAuthority> authorities;
private String nickname;
private String profileImgUrl;
private Map<String, Object> oAuth2Attributes;
// 일반 회원용
public static BoardPrincipal from(Member member) { ... }
// OAuth2 회원용 (attributes 포함)
public static BoardPrincipal from(Member member, Map<String, Object> oAuth2Attributes) { ... }
// Spring Security 필수 메서드
@Override public String getUsername() { return email; }
@Override public String getPassword() { return pw; }
// OAuth2 필수 메서드
@Override public Map<String, Object> getAttributes() { return oAuth2Attributes; }
@Override public String getName() { return email; }
}
BoardPrincipal 하나에서 두 인터페이스를 모두 구현함으로써, 컨트롤러나 서비스 계층에서는 로그인 방식과 무관하게 동일한 타입으로 인증 주체를 다룰 수 있다.
인증이 완료되면 SuccessHandler에서 회원 상태에 따라 프론트엔드로 다른 정보를 전달한다.
@Bean
public AuthenticationSuccessHandler successHandler() {
return (request, response, authentication) -> {
DefaultOAuth2User user = (DefaultOAuth2User) authentication.getPrincipal();
Map<String, Object> attributes = user.getAttributes();
Constant.JoinStatus joinStatus = Constant.JoinStatus.valueOf(
String.valueOf(attributes.get("memberStatus")));
String targetUrl;
if (joinStatus == Constant.JoinStatus.NOT_JOINED) {
// 회원가입 미완료 → userCode 전달 (프론트에서 추가 정보 입력 폼으로 이동)
String userCode = attributes.get("userCode").toString();
targetUrl = UriComponentsBuilder.fromUriString(oauth2SuccessUrl)
.queryParam("userCode", userCode)
.build().toUriString();
} else {
// 기존 회원 → JWT 쿠키 설정 + 토큰 쿼리 파라미터 전달
String accessToken = attributes.get("accessToken").toString();
String refreshToken = attributes.get("refreshToken").toString();
response.addHeader(HttpHeaders.SET_COOKIE,
jwtService.createAccessTokenCookie(accessToken).toString());
response.addHeader(HttpHeaders.SET_COOKIE,
jwtService.createRefreshTokenCookie(refreshToken).toString());
targetUrl = UriComponentsBuilder.fromUriString(oauth2SuccessUrl)
.queryParam("accessToken", accessToken)
.queryParam("refreshToken", refreshToken)
.build().toUriString();
}
response.sendRedirect(targetUrl);
};
}
NOT_JOINED : {success-url}?userCode={socialId} 형태로 리다이렉트한다. 프론트에서는 이 userCode를 가지고 닉네임 등 추가 정보 입력 폼으로 사용자를 유도하고, 회원가입을 완료한다.JOINED : {success-url}?accessToken={token}&refreshToken={token} 형태로 리다이렉트하면서 쿠키에도 JWT를 동시에 설정한다. 쿼리 파라미터와 쿠키 둘 다 설정한 이유는 프론트엔드 구현 방식에 따라 선택해서 쓸 수 있도록 하기 위함이다.인증 실패 시에는 단순히 failure-url로 리다이렉트한다.
Spring Security의 OAuth2 Client는 표준 플로우의 대부분을 자동으로 처리해주기 때문에, 우리가 집중해야 할 부분은 Provider에서 회원 정보를 받아와서 비즈니스 로직에 맞게 처리하는 정도다.
또한 틀이 정해져있기 때문에 소셜 로그인을 한 번 구현해두면 확장이 쉽다. 실제로 카카오를 먼저 구현하고 구글을 나중에 구현했는데, 설정 추가 및 구글에서 제공하는 회원 정보에 맞게 구조를 수정하는 작업 정도만 진행하면 됐다. 카카오를 구현했을 때보다 시간이 훨씬 단축된 걸 보고 Spring Security로 구현해두길 잘했다는 생각이 들었다. 나중에 다른 소셜 로그인 방식이 추가되어도 큰 어려움 없이 확장할 수 있겠다.