웹 서비스가 많아질수록 사용자 입장에서는 매번 회원가입을 해야 하고, 사이트마다 다른 비밀번호를 기억해야 한다. 서비스 운영자 입장에서도 회원 개인정보를 안전하게 보호해야 한다는 부담이 크다.
이 문제를 해결하기 위해 등장한 것이 OAuth 2.0 기반 소셜 로그인이다.
OAuth 2.0 : 사용자가 비밀번호를 직접 제공하지 않고, 신뢰하는 서비스(카카오, 구글 등)에게 제한된 권한을 위임하는 HTTP 기반 표준 프로토콜
사용자 → 우리 서버 → 카카오 인증 서버
↑ ↓
← JWT 발급 ← 사용자 정보 ←
단계별로 풀어보면:
이 흐름이 바로 OAuth 2.0의 Authorization Code Grant 방식이다.
예제에서는 RestTemplate을 직접 사용해서 카카오 API를 호출하는 방식으로 구현한다.
Controller → KakaoService → 카카오 API (HTTP 직접 호출)
카카오 로그인 버튼 클릭 시 아래 URL로 이동한다.
https://kauth.kakao.com/oauth/authorize
?client_id={REST_API_KEY}
&redirect_uri={REDIRECT_URI}
&response_type=code
사용자가 동의하면 카카오는 설정한 Redirect URI로 인가 코드를 쿼리 파라미터로 전달한다.
http://localhost:8080/api/user/kakao/callback?code=zAGhy36K0...
// GET /api/user/kakao/callback 으로 카카오가 인가 코드를 전달
@GetMapping("/user/kakao/callback")
public String kakaoLogin(
@RequestParam String code, // 카카오가 전달한 인가 코드
HttpServletResponse response
) throws JsonProcessingException {
// KakaoService에서 전체 처리 후 JWT 반환
String token = kakaoService.kakaoLogin(code);
// JWT를 쿠키에 담아 브라우저로 전달
Cookie cookie = new Cookie(JwtUtil.AUTHORIZATION_HEADER, token.substring(7));
cookie.setPath("/");
response.addCookie(cookie);
// 메인 페이지로 리다이렉트
return "redirect:/";
}
// 카카오 로그인 전체 처리 흐름
public String kakaoLogin(String code) throws JsonProcessingException {
// Step 1. 인가 코드 → 액세스 토큰 교환
String accessToken = getToken(code);
// Step 2. 액세스 토큰 → 카카오 사용자 정보 조회
KakaoUserInfoDto kakaoUserInfo = getKakaoUserInfo(accessToken);
// Step 3. 신규 사용자라면 회원가입 처리
User kakaoUser = registerKakaoUserIfNeeded(kakaoUserInfo);
// Step 4. 우리 서버 JWT 발급 및 반환
return jwtUtil.createToken(kakaoUser.getUsername(), kakaoUser.getRole());
}
Step 1 — 액세스 토큰 요청
private String getToken(String code) throws JsonProcessingException {
// 카카오 토큰 발급 엔드포인트 URI 구성
URI uri = UriComponentsBuilder
.fromUriString("https://kauth.kakao.com")
.path("/oauth/token")
.encode()
.build()
.toUri();
// 요청 헤더 설정
HttpHeaders headers = new HttpHeaders();
headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");
// 요청 바디 — 카카오 문서 기준 필수 파라미터
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "authorization_code"); // 고정값
body.add("client_id", "본인의 REST API키");
body.add("redirect_uri", "http://localhost:8080/api/user/kakao/callback");
body.add("code", code); // 방금 받은 인가 코드
RequestEntity<MultiValueMap<String, String>> requestEntity = RequestEntity
.post(uri)
.headers(headers)
.body(body);
// HTTP 요청 실행
ResponseEntity<String> response = restTemplate.exchange(requestEntity, String.class);
// JSON 응답에서 access_token 파싱
JsonNode jsonNode = new ObjectMapper().readTree(response.getBody());
return jsonNode.get("access_token").asText();
}
Step 2 — 사용자 정보 요청
private KakaoUserInfoDto getKakaoUserInfo(String accessToken) throws JsonProcessingException {
// 카카오 사용자 정보 API 엔드포인트
URI uri = UriComponentsBuilder
.fromUriString("https://kapi.kakao.com")
.path("/v2/user/me")
.encode()
.build()
.toUri();
// Authorization 헤더에 액세스 토큰 포함
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "Bearer " + accessToken);
headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");
RequestEntity<MultiValueMap<String, String>> requestEntity = RequestEntity
.post(uri)
.headers(headers)
.body(new LinkedMultiValueMap<>());
ResponseEntity<String> response = restTemplate.exchange(requestEntity, String.class);
// JSON 응답 파싱 — 카카오 응답 구조에 맞게 접근
JsonNode jsonNode = new ObjectMapper().readTree(response.getBody());
Long id = jsonNode.get("id").asLong();
String nickname = jsonNode.get("properties").get("nickname").asText();
String email = jsonNode.get("kakao_account").get("email").asText();
return new KakaoUserInfoDto(id, nickname, email);
}
Step 3 — 조건부 회원가입
private User registerKakaoUserIfNeeded(KakaoUserInfoDto kakaoUserInfo) {
Long kakaoId = kakaoUserInfo.getId();
// kakaoId로 기존 회원 조회
User kakaoUser = userRepository.findByKakaoId(kakaoId).orElse(null);
if (kakaoUser == null) {
String kakaoEmail = kakaoUserInfo.getEmail();
// 동일 이메일로 일반 회원가입된 계정이 있는지 확인
User sameEmailUser = userRepository.findByEmail(kakaoEmail).orElse(null);
if (sameEmailUser != null) {
// 기존 계정에 kakaoId만 업데이트 (계정 연동)
kakaoUser = sameEmailUser.kakaoIdUpdate(kakaoId);
} else {
// 완전 신규 회원가입
String password = passwordEncoder.encode(UUID.randomUUID().toString());
kakaoUser = new User(
kakaoUserInfo.getNickname(),
password,
kakaoEmail,
UserRoleEnum.USER,
kakaoId
);
}
userRepository.save(kakaoUser);
}
return kakaoUser;
}
{
"id": 1632335751,
"properties": {
"nickname": "홍길동"
},
"kakao_account": {
"email": "hong@example.com",
"is_email_verified": true
}
}
예제의 방식은 동작하는 데 문제는 없다. 다만, 현재 개발 생태계는 꽤 많이 바뀌었다.
Spring Framework는 공식적으로 RestTemplate의 deprecation 계획을 발표했다. Spring Framework 7.0(2025년 11월)에서 deprecation 의도를 선언하고, 7.1에서 @Deprecated 어노테이션을 적용하며, 8.0에서는 완전히 제거될 예정이다.
즉, 기존 레거시 프로젝트에서는 RestTemplate이 여전히 동작하지만, 새 프로젝트에서는
RestClient를 사용하는 것이 옳다.
마이그레이션 경로는 간단하다. RestClient.create(restTemplate) 형태로 기존 인스턴스를 감싸는 것이 가능하며, 새 코드에서는 RestTemplate을 건너뛰고 바로 RestClient를 사용하면 된다.
강의 코드의 구조(Controller → Service → 카카오 API 직접 호출)를 그대로 유지하면서 RestTemplate만 RestClient로 교체하는 방법이다. 구조 변경이 최소화되어 레거시 코드를 점진적으로 마이그레이션할 때 적합하다.
// RestTemplateConfig 대신 RestClientConfig로 교체
@Configuration
public class RestClientConfig {
@Bean
public RestClient restClient() {
return RestClient.builder()
// 5초 안에 연결 안 되면 예외 발생
.requestFactory(clientHttpRequestFactory())
.build();
}
private ClientHttpRequestFactory clientHttpRequestFactory() {
// Spring Framework 6.1+ 기본 제공 팩토리
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(5000);
factory.setReadTimeout(5000);
return factory;
}
}
KakaoService에서 RestTemplate 대신 RestClient 사용:
// 기존: restTemplate.exchange(requestEntity, String.class)
// 변경: RestClient의 플루언트(Fluent) API 스타일
// 액세스 토큰 요청 예시
String responseBody = restClient
.post()
.uri("https://kauth.kakao.com/oauth/token")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(body) // MultiValueMap
.retrieve()
.body(String.class);
Spring Security 6.4에서는 RestClient를 기반으로 한 새로운 OAuth2AccessTokenResponseClient 구현체들이 도입되어, OAuth 2.0 토큰 요청 커스터마이징이 훨씬 일관성 있게 처리된다.
spring-boot-starter-oauth2-client 의존성만 추가하면, 토큰 교환과 사용자 정보 요청을 Spring Security가 자동으로 처리해준다.
의존성 추가 (build.gradle)
// OAuth2 클라이언트 자동 처리를 위한 스타터
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
application.yml 설정
spring:
security:
oauth2:
client:
registration:
kakao:
client-id: ${KAKAO_CLIENT_ID} # 카카오 REST API 키
client-secret: ${KAKAO_CLIENT_SECRET} # 카카오 Client Secret (설정한 경우)
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
authorization-grant-type: authorization_code
scope:
- profile_nickname # 카카오 동의항목 scope명 (2021년 이후 변경됨)
- account_email
client-name: Kakao
client-authentication-method: client_secret_post # 카카오는 POST 방식 사용
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 # 카카오 응답 JSON의 최상위 id 필드
주의 : 카카오 scope 이름이 변경되었다. 예전
profile,account_email대신 현재는profile_nickname,account_email등으로 분리되어 있다. 카카오 디벨로퍼스 콘솔에서 동의항목 설정 시 scope 이름을 반드시 확인해야 한다.
SecurityConfig 설정
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final CustomOAuth2UserService customOAuth2UserService;
public SecurityConfig(CustomOAuth2UserService customOAuth2UserService) {
this.customOAuth2UserService = customOAuth2UserService;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 세션 대신 JWT를 사용하는 경우 STATELESS 설정
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// 접근 권한 설정
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login/**", "/oauth2/**").permitAll()
.anyRequest().authenticated()
)
// OAuth2 로그인 설정
.oauth2Login(oauth2 -> oauth2
// 사용자 정보를 커스텀 서비스로 처리
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService)
)
// 로그인 성공 후 JWT 발급 등 후처리 핸들러
.successHandler(oAuth2AuthenticationSuccessHandler())
);
return http.build();
}
}
CustomOAuth2UserService
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
// Spring Security가 토큰 교환, 사용자 정보 요청을 이미 처리한 상태
OAuth2User oAuth2User = super.loadUser(userRequest);
// 어떤 OAuth2 제공자인지 식별 (ex: "kakao", "google")
String registrationId = userRequest.getClientRegistration().getRegistrationId();
// 카카오 응답 JSON에서 필요한 정보 추출
Map<String, Object> attributes = oAuth2User.getAttributes();
Long kakaoId = (Long) attributes.get("id");
Map<String, Object> kakaoAccount =
(Map<String, Object>) attributes.get("kakao_account");
Map<String, Object> profile =
(Map<String, Object>) kakaoAccount.get("profile");
String nickname = (String) profile.get("nickname");
String email = (String) kakaoAccount.get("email");
// DB에 없으면 자동 회원가입
User user = registerIfNeeded(kakaoId, nickname, email);
// Spring Security가 인식할 수 있는 OAuth2User 반환
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(user.getRole().name())),
attributes,
"id" // user-name-attribute와 일치해야 함
);
}
private User registerIfNeeded(Long kakaoId, String nickname, String email) {
return userRepository.findByKakaoId(kakaoId).orElseGet(() ->
userRepository.save(new User(
nickname,
passwordEncoder.encode(UUID.randomUUID().toString()),
email,
UserRoleEnum.USER,
kakaoId
))
);
}
}
| 구분 | 강의 방식 (RestTemplate 수동) | 현대적 방식 (oauth2-client) |
|---|---|---|
| 토큰 교환 | 직접 HTTP 요청 코드 작성 | Spring Security 자동 처리 |
| 사용자 정보 요청 | 직접 HTTP 요청 코드 작성 | Spring Security 자동 처리 |
| 코드량 | 많다 | 적다 |
| 커스터마이징 | 자유도 높음 | 정해진 확장 포인트 활용 |
| 유지보수 | RestTemplate deprecation 영향 받음 | Spring Security 업데이트 따라감 |
| 적합한 상황 | 레거시 코드 유지, 흐름 학습 목적 | 신규 프로젝트 |
kakaoId를 DB에 저장해 두면, 같은 카카오 계정이 재로그인할 때 신규 회원가입 없이 기존 계정과 연결할 수 있다kakaoId만 업데이트하는 방식으로 계정을 연동할 수 있다RestTemplate은 동작은 하지만 Spring Framework 7.x 이후로 공식 deprecated 예정이므로, 신규 코드에서는 RestClient 사용을 권장한다