Ver2.0 Spring Security OAuth2로 소셜 로그인 구현하기 🚀

Spring Security OAuth2로 소셜 로그인 구현하기 🚀
개발자의 인증 고민을 한방에 해결해드립니다!
안녕하세요 여러분! 오늘은 개발자들이 항상 머리 아파하는 인증 시스템에 대해 함께 알아볼게요~ 🤓
특히 요즘 트렌드인 소셜 로그인을 Spring Security와 OAuth2를 사용해서 어떻게 구현하는지 A부터 Z까지 싹 다 파헤쳐볼 거예요! ㅋㅋㅋ
개발 초보자부터 숙련자까지 모두가 이해할 수 있도록 최대한 쉽게 설명해드릴게요. 그럼 고고씽~ 🏃♂️
📚 목차
- OAuth2와 소셜 로그인이 뭔데? 🤔
- Spring Security + OAuth2 환경 설정하기 ⚙️
- 구글 로그인 연동하기 🔍
- 네이버 로그인 연동하기 🟢
- 카카오 로그인 연동하기 🟡
- 사용자 정보 처리와 DB 연동하기 💾
- JWT 토큰 발급 및 관리하기 🔐
- 실전 프로젝트에 적용하기 💻
- 트러블슈팅 & 자주 묻는 질문들 🛠️
1. OAuth2와 소셜 로그인이 뭔데? 🤔
일단 OAuth2가 뭔지부터 알아볼까요? 어려운 용어 때문에 벌써 머리가 지끈지끈하다고요? 걱정 마세요! 초간단 설명 들어갑니다~ 😎
OAuth2란? 다른 웹사이트 상의 자신의 정보에 대한 접근 권한을 부여할 수 있는 공통적인 수단으로 사용되는 개방형 표준 인증 프로토콜이에요.
음... 그래도 어렵죠? ㅋㅋㅋ 쉽게 말해서 "야, 나 네이버(구글/카카오/페이스북 등)인데~ 이 사람 우리 회원 맞아! 너네가 따로 가입시키지 않아도 우리가 인증해줄게~" 하는 개념이에요. 👍
예를 들어볼게요. 여러분이 재능넷 같은 서비스에 가입하려고 할 때, 매번 새로운 아이디와 비밀번호를 만들기 귀찮잖아요? 그래서 "구글로 로그인하기" 버튼을 누르면 구글에서 인증을 대신 해주고, 재능넷은 그 인증 결과만 받아서 처리하는 거예요!
🌟 소셜 로그인의 장점
- 사용자는 새 계정을 만들 필요가 없어요 (편리함 UP! ⬆️)
- 비밀번호 관리 부담이 줄어들어요 (보안 걱정 DOWN! ⬇️)
- 소셜 플랫폼의 신뢰성을 활용할 수 있어요 (믿음직스러움 UP! ⬆️)
- 개발자는 인증 로직을 직접 구현할 필요가 없어요 (개발 시간 절약! ⏱️)
그럼 OAuth2 인증은 어떤 과정으로 이루어질까요? 간단한 흐름을 살펴볼게요!
위 그림처럼 OAuth2는 여러 단계의 인증 과정을 거쳐요. 근데 걱정 마세요! Spring Security가 이 복잡한 과정을 대부분 알아서 처리해줄 거예요. 우리는 설정만 잘 해주면 됩니다! 👌
이제 OAuth2에 대한 기본 개념을 알았으니, 본격적으로 Spring Security와 함께 구현해볼까요? 레츠고~ 🚀
2. Spring Security + OAuth2 환경 설정하기 ⚙️
자, 이제 본격적으로 개발 환경을 세팅해볼게요! 먼저 필요한 의존성부터 추가해볼까요? 😊
Maven 의존성 추가 (pom.xml)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
Gradle 의존성 추가 (build.gradle)
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
의존성을 추가했다면, 이제 application.yml (또는 application.properties) 파일에 OAuth2 관련 설정을 추가해야 해요. 각 소셜 로그인 제공자별로 클라이언트 ID와 시크릿을 설정해줘야 합니다.
application.yml 설정
spring:
security:
oauth2:
client:
registration:
google:
client-id: {구글-클라이언트-ID}
client-secret: {구글-클라이언트-시크릿}
scope: profile, email
naver:
client-id: {네이버-클라이언트-ID}
client-secret: {네이버-클라이언트-시크릿}
redirect-uri: "{baseUrl}/{action}/oauth2/code/{registrationId}"
authorization-grant-type: authorization_code
scope: name, email, profile_image
client-name: Naver
kakao:
client-id: {카카오-클라이언트-ID}
client-secret: {카카오-클라이언트-시크릿}
redirect-uri: "{baseUrl}/{action}/oauth2/code/{registrationId}"
authorization-grant-type: authorization_code
scope: profile_nickname, profile_image, account_email
client-name: Kakao
client-authentication-method: POST
provider:
naver:
authorization-uri: https://nid.naver.com/oauth2.0/authorize
token-uri: https://nid.naver.com/oauth2.0/token
user-info-uri: https://openapi.naver.com/v1/nid/me
user-name-attribute: response
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
어머! 설정이 좀 많죠? ㅋㅋㅋ 하나씩 살펴볼게요!
🔍 설정 파일 설명
client-id & client-secret: 각 소셜 서비스에서 발급받은 앱 키예요. 이걸로 우리 앱을 인증해요!
redirect-uri: 소셜 로그인 후 리다이렉트될 URI예요. {baseUrl}은 우리 서비스 주소, {action}은 보통 'login', {registrationId}는 'google', 'naver' 같은 제공자 ID예요.
scope: 우리가 소셜 서비스로부터 가져올 정보의 범위예요. 이메일, 프로필 등을 요청할 수 있어요.
provider: 구글은 기본 제공되지만, 네이버와 카카오는 직접 설정해줘야 해요. 각 API 엔드포인트를 지정해주는 부분이에요.
user-name-attribute: 소셜 서비스에서 제공하는 JSON 응답에서 사용자 식별자로 사용할 필드명이에요.
이제 Spring Security 설정 클래스를 만들어볼게요. 이 클래스는 OAuth2 로그인 처리와 보안 설정을 담당해요!
SecurityConfig.java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.headers().frameOptions().disable()
.and()
.authorizeRequests()
.antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**").permitAll()
.antMatchers("/api/v1/**").hasRole("USER")
.anyRequest().authenticated()
.and()
.logout()
.logoutSuccessUrl("/")
.and()
.oauth2Login()
.userInfoEndpoint()
.userService(customOAuth2UserService);
return http.build();
}
}
위 코드에서 customOAuth2UserService는 우리가 직접 구현할 서비스예요. 소셜 로그인 후 받아온 사용자 정보를 처리하는 역할을 해요. 이제 이 서비스를 만들어볼게요!
CustomOAuth2UserService.java
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import java.util.Collections;
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
public CustomOAuth2UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
// 현재 로그인 진행 중인 서비스를 구분하는 코드 (구글, 네이버, 카카오 등)
String registrationId = userRequest.getClientRegistration().getRegistrationId();
// OAuth2 로그인 시 키가 되는 필드값 (PK와 같은 의미)
String userNameAttributeName = userRequest.getClientRegistration()
.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
// OAuth2UserService를 통해 가져온 데이터를 담을 클래스
OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
// 사용자 저장 또는 업데이트
User user = saveOrUpdate(attributes);
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())),
attributes.getAttributes(),
attributes.getNameAttributeKey());
}
private User saveOrUpdate(OAuthAttributes attributes) {
User user = userRepository.findByEmail(attributes.getEmail())
.map(entity -> entity.update(attributes.getName(), attributes.getPicture()))
.orElse(attributes.toEntity());
return userRepository.save(user);
}
}
위 코드에서 OAuthAttributes 클래스는 각 소셜 서비스에서 받아온 사용자 정보를 매핑하는 역할을 해요. 이 클래스도 만들어볼게요!
OAuthAttributes.java
import lombok.Builder;
import lombok.Getter;
import java.util.Map;
@Getter
public class OAuthAttributes {
private Map<String, Object> attributes;
private String nameAttributeKey;
private String name;
private String email;
private String picture;
@Builder
public OAuthAttributes(Map<String, Object> attributes, String nameAttributeKey, String name, String email, String picture) {
this.attributes = attributes;
this.nameAttributeKey = nameAttributeKey;
this.name = name;
this.email = email;
this.picture = picture;
}
// OAuth2User에서 반환하는 사용자 정보는 Map이기 때문에 값 하나하나를 변환해야 함
public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes) {
if("naver".equals(registrationId)) {
return ofNaver("id", attributes);
} else if("kakao".equals(registrationId)) {
return ofKakao("id", attributes);
}
return ofGoogle(userNameAttributeName, attributes);
}
private static OAuthAttributes ofGoogle(String userNameAttributeName, Map<String, Object> attributes) {
return OAuthAttributes.builder()
.name((String) attributes.get("name"))
.email((String) attributes.get("email"))
.picture((String) attributes.get("picture"))
.attributes(attributes)
.nameAttributeKey(userNameAttributeName)
.build();
}
private static OAuthAttributes ofNaver(String userNameAttributeName, Map<String, Object> attributes) {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
return OAuthAttributes.builder()
.name((String) response.get("name"))
.email((String) response.get("email"))
.picture((String) response.get("profile_image"))
.attributes(response)
.nameAttributeKey(userNameAttributeName)
.build();
}
private static OAuthAttributes ofKakao(String userNameAttributeName, Map<String, Object> attributes) {
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
Map<String, Object> kakaoProfile = (Map<String, Object>) kakaoAccount.get("profile");
return OAuthAttributes.builder()
.name((String) kakaoProfile.get("nickname"))
.email((String) kakaoAccount.get("email"))
.picture((String) kakaoProfile.get("profile_image_url"))
.attributes(attributes)
.nameAttributeKey(userNameAttributeName)
.build();
}
public User toEntity() {
return User.builder()
.name(name)
.email(email)
.picture(picture)
.role(Role.USER) // 기본 권한 USER
.build();
}
}
마지막으로 사용자 정보를 저장할 User 엔티티와 Role 열거형을 만들어볼게요!
User.java
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
@Getter
@NoArgsConstructor
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String email;
@Column
private String picture;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role;
@Builder
public User(String name, String email, String picture, Role role) {
this.name = name;
this.email = email;
this.picture = picture;
this.role = role;
}
public User update(String name, String picture) {
this.name = name;
this.picture = picture;
return this;
}
public String getRoleKey() {
return this.role.getKey();
}
}
Role.java
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum Role {
GUEST("ROLE_GUEST", "손님"),
USER("ROLE_USER", "일반 사용자");
private final String key;
private final String title;
}
그리고 마지막으로 사용자 정보를 저장할 UserRepository 인터페이스도 필요해요!
UserRepository.java
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}
우와~ 기본 설정이 완료되었어요! 😅 조금 복잡해 보이지만, 각 파일의 역할을 이해하면 그렇게 어렵지 않아요.
🎯 지금까지 한 일 요약
- Spring Security와 OAuth2 의존성 추가
- application.yml에 소셜 로그인 설정 추가
- SecurityConfig 클래스로 보안 설정
- CustomOAuth2UserService로 소셜 로그인 처리
- OAuthAttributes 클래스로 소셜 서비스별 사용자 정보 매핑
- User 엔티티와 Role 열거형으로 사용자 정보 관리
이제 각 소셜 서비스별로 어떻게 연동하는지 자세히 알아볼게요! 다음 섹션에서 계속됩니다~ 🚀
3. 구글 로그인 연동하기 🔍
구글 로그인을 연동하려면 먼저 Google Cloud Console에서 프로젝트를 생성하고 OAuth 클라이언트 ID를 발급받아야 해요. 한 번 같이 해볼까요? 🤓
구글 OAuth 클라이언트 ID 발급 받기
- Google Cloud Console에 접속합니다.
- 새 프로젝트를 생성합니다. (이미 있다면 선택)
- 왼쪽 메뉴에서 "API 및 서비스" > "사용자 인증 정보"를 클릭합니다.
- "사용자 인증 정보 만들기" > "OAuth 클라이언트 ID"를 선택합니다.
- 애플리케이션 유형은 "웹 애플리케이션"을 선택합니다.
- 이름을 입력하고, 승인된 리디렉션 URI에
http://localhost:8080/login/oauth2/code/google을 추가합니다. (개발 환경 기준) - "만들기"를 클릭하면 클라이언트 ID와 클라이언트 보안 비밀번호가 발급됩니다.
발급받은 클라이언트 ID와 시크릿을 application.yml 파일에 추가해주세요!
spring:
security:
oauth2:
client:
registration:
google:
client-id: {발급받은-클라이언트-ID}
client-secret: {발급받은-클라이언트-시크릿}
scope: profile, email
구글은 Spring Security OAuth2에서 기본적으로 제공하는 서비스라서 provider 설정이 필요 없어요! 편리하죠? ㅎㅎ
이제 로그인 버튼을 만들어볼게요! 간단한 HTML 코드를 작성해볼게요.
login.html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>소셜 로그인</title>
<style>
.social-login {
display: inline-block;
padding: 10px 20px;
margin: 10px;
border-radius: 5px;
text-decoration: none;
color: white;
font-weight: bold;
}
.google {
background-color: #4285F4;
}
</style>
</head>
<body>
<h1>소셜 로그인 테스트</h1>
<a href="/oauth2/authorization/google" class="social-login google">
구글 로그인
</a>
</body>
</html>
위 코드에서 /oauth2/authorization/google은 Spring Security OAuth2에서 제공하는 기본 엔드포인트예요. 이 URL로 요청하면 자동으로 구글 로그인 페이지로 리다이렉트됩니다!
💡 TIP
구글 로그인 테스트 시 주의할 점:
- 로컬에서 테스트할 때는
localhost:8080을 승인된 리디렉션 URI에 꼭 추가해야 해요. - 실제 서비스 배포 시에는 실제 도메인 주소도 추가해야 해요.
- 구글 계정으로 테스트할 때 여러 계정이 로그인되어 있다면 선택 화면이 나타날 수 있어요.
자, 이제 애플리케이션을 실행하고 로그인 페이지에 접속해서 "구글 로그인" 버튼을 클릭하면 구글 로그인 화면이 나타나고, 로그인 후에는 우리가 설정한 리다이렉트 URI로 돌아와요!
구글 로그인이 성공하면 CustomOAuth2UserService의 loadUser 메서드가 호출되고, 사용자 정보가 DB에 저장되거나 업데이트돼요. 그리고 인증된 사용자 정보는 세션에 저장되어 로그인 상태가 유지됩니다!
이렇게 간단하게 구글 로그인이 연동되었어요! 😎 다음으로 네이버 로그인을 연동해볼게요!
5. 카카오 로그인 연동하기 🟡
카카오 로그인도 네이버와 비슷한 과정으로 연동할 수 있어요. 카카오도 Spring Security에서 기본 제공하지 않기 때문에 provider 설정을 추가로 해줘야 해요!
카카오 개발자 센터에서 애플리케이션 등록하기
- 카카오 개발자 센터에 접속합니다.
- "시작하기"를 클릭하고 로그인합니다.
- "애플리케이션 추가하기"를 클릭합니다.
- 앱 이름과 회사명을 입력하고 "저장"을 클릭합니다.
- 생성된 애플리케이션을 클릭하고 "플랫폼" 메뉴로 이동합니다.
- "Web 플랫폼 등록" 버튼을 클릭하고 사이트 도메인을 등록합니다. (개발 환경이면 http://localhost:8080)
- "카카오 로그인" 메뉴로 이동하여 "활성화 설정"을 ON으로 변경합니다.
- "Redirect URI 등록"을 클릭하고
http://localhost:8080/login/oauth2/code/kakao를 입력합니다. - "동의항목" 메뉴에서 필요한 동의항목(닉네임, 프로필 사진, 이메일 등)을 설정합니다.
- "앱 키" 메뉴에서 REST API 키(클라이언트 ID)를 확인합니다.
- "보안" 메뉴에서 Client Secret을 발급받습니다.
발급받은 클라이언트 ID와 시크릿을 application.yml 파일에 추가해주세요!
spring:
security:
oauth2:
client:
registration:
kakao:
client-id: {발급받은-REST-API-키}
client-secret: {발급받은-Client-Secret}
redirect-uri: "{baseUrl}/{action}/oauth2/code/{registrationId}"
authorization-grant-type: authorization_code
scope: profile_nickname, profile_image, account_email
client-name: Kakao
client-authentication-method: 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
카카오도 provider 설정이 필요해요. 카카오 API의 엔드포인트 주소와 응답 형식이 다르기 때문이에요!
이제 로그인 페이지에 카카오 로그인 버튼을 추가해볼게요!
login.html (카카오 로그인 버튼 추가)
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>소셜 로그인</title>
<style>
.social-login {
display: inline-block;
padding: 10px 20px;
margin: 10px;
border-radius: 5px;
text-decoration: none;
color: white;
font-weight: bold;
}
.google {
background-color: #4285F4;
}
.naver {
background-color: #03C75A;
}
.kakao {
background-color: #FEE500;
color: #000;
}
</style>
</head>
<body>
<h1>소셜 로그인 테스트</h1>
<a href="/oauth2/authorization/google" class="social-login google">
구글 로그인
</a>
<a href="/oauth2/authorization/naver" class="social-login naver">
네이버 로그인
</a>
<a href="/oauth2/authorization/kakao" class="social-login kakao">
카카오 로그인
</a>
</body>
</html>
카카오 로그인도 /oauth2/authorization/kakao 엔드포인트를 사용해요. 이 URL로 요청하면 카카오 로그인 페이지로 리다이렉트됩니다!
카카오 로그인의 경우 응답 데이터 구조가 구글, 네이버와 다르기 때문에 OAuthAttributes 클래스의 ofKakao 메서드에서 처리해줘야 해요. 이미 위에서 구현했죠? 😉
📝 카카오 응답 데이터 구조
{
"id": 1234567890,
"connected_at": "2022-01-01T00:00:00Z",
"properties": {
"nickname": "홍길동",
"profile_image": "http://k.kakaocdn.net/dn/abcdef/img_110x110.jpg",
"thumbnail_image": "http://k.kakaocdn.net/dn/abcdef/img_110x110.jpg"
},
"kakao_account": {
"profile_needs_agreement": false,
"profile": {
"nickname": "홍길동",
"thumbnail_image_url": "http://k.kakaocdn.net/dn/abcdef/img_110x110.jpg",
"profile_image_url": "http://k.kakaocdn.net/dn/abcdef/img_110x110.jpg"
},
"email_needs_agreement": false,
"email": "user@example.com"
}
}
카카오는 응답 데이터가 복잡한 구조로 되어 있어요. kakao_account 객체 안에 profile 객체가 있고, 그 안에 사용자 정보가 들어있어요. 그래서 ofKakao 메서드에서 이중으로 객체를 추출해서 사용해요!
이제 애플리케이션을 실행하고 카카오 로그인 버튼을 클릭하면 카카오 로그인 화면이 나타나고, 로그인 후에는 우리 서비스로 돌아와요!
카카오 로그인도 성공적으로 연동되었네요! 🎉 이제 세 가지 소셜 로그인이 모두 연동되었어요!
🏆 지금까지의 성과
축하해요! 지금까지 다음과 같은 것들을 구현했어요:
- Spring Security와 OAuth2 설정
- 구글 로그인 연동
- 네이버 로그인 연동
- 카카오 로그인 연동
이제 사용자 정보를 DB에 저장하고 JWT 토큰을 발급하는 방법을 알아볼게요!
6. 사용자 정보 처리와 DB 연동하기 💾
소셜 로그인으로 받아온 사용자 정보를 데이터베이스에 저장하고 관리하는 방법을 알아볼게요! 이미 위에서 User 엔티티와 UserRepository를 구현했지만, 좀 더 자세히 살펴볼게요!
먼저 User 엔티티에 필요한 필드를 추가해볼게요. 소셜 로그인 제공자 정보와 소셜 ID를 저장하면 좋을 것 같아요!
User.java (확장)
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import java.time.LocalDateTime;
@Getter
@NoArgsConstructor
@Entity
@Table(name = "users") // 'user'는 예약어일 수 있어서 'users'로 테이블명 지정
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String email;
@Column
private String picture;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role;
@Column
private String provider; // google, naver, kakao
@Column
private String providerId; // 소셜 식별자 값
@Column
private LocalDateTime createdAt;
@Column
private LocalDateTime modifiedAt;
@PrePersist
public void prePersist() {
this.createdAt = LocalDateTime.now();
this.modifiedAt = this.createdAt;
}
@PreUpdate
public void preUpdate() {
this.modifiedAt = LocalDateTime.now();
}
@Builder
public User(String name, String email, String picture, Role role, String provider, String providerId) {
this.name = name;
this.email = email;
this.picture = picture;
this.role = role;
this.provider = provider;
this.providerId = providerId;
}
public User update(String name, String picture) {
this.name = name;
this.picture = picture;
return this;
}
public String getRoleKey() {
return this.role.getKey();
}
}
provider와 providerId 필드를 추가했어요. 이렇게 하면 어떤 소셜 서비스로 로그인했는지, 그리고 해당 서비스에서의 사용자 ID가 무엇인지 저장할 수 있어요!
이제 OAuthAttributes 클래스도 수정해서 provider와 providerId를 설정하도록 해볼게요!
OAuthAttributes.java (확장)
import lombok.Builder;
import lombok.Getter;
import java.util.Map;
@Getter
public class OAuthAttributes {
private Map<String, Object> attributes;
private String nameAttributeKey;
private String name;
private String email;
private String picture;
private String provider;
private String providerId;
@Builder
public OAuthAttributes(Map<String, Object> attributes, String nameAttributeKey, String name, String email, String picture, String provider, String providerId) {
this.attributes = attributes;
this.nameAttributeKey = nameAttributeKey;
this.name = name;
this.email = email;
this.picture = picture;
this.provider = provider;
this.providerId = providerId;
}
public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes) {
if("naver".equals(registrationId)) {
return ofNaver(registrationId, userNameAttributeName, attributes);
} else if("kakao".equals(registrationId)) {
return ofKakao(registrationId, userNameAttributeName, attributes);
}
return ofGoogle(registrationId, userNameAttributeName, attributes);
}
private static OAuthAttributes ofGoogle(String registrationId, String userNameAttributeName, Map<String, Object> attributes) {
return OAuthAttributes.builder()
.name((String) attributes.get("name"))
.email((String) attributes.get("email"))
.picture((String) attributes.get("picture"))
.attributes(attributes)
.nameAttributeKey(userNameAttributeName)
.provider(registrationId)
.providerId(attributes.get(userNameAttributeName).toString())
.build();
}
private static OAuthAttributes ofNaver(String registrationId, String userNameAttributeName, Map<String, Object> attributes) {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
return OAuthAttributes.builder()
.name((String) response.get("name"))
.email((String) response.get("email"))
.picture((String) response.get("profile_image"))
.attributes(response)
.nameAttributeKey(userNameAttributeName)
.provider(registrationId)
.providerId(response.get("id").toString())
.build();
}
private static OAuthAttributes ofKakao(String registrationId, String userNameAttributeName, Map<String, Object> attributes) {
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
Map<String, Object> kakaoProfile = (Map<String, Object>) kakaoAccount.get("profile");
return OAuthAttributes.builder()
.name((String) kakaoProfile.get("nickname"))
.email((String) kakaoAccount.get("email"))
.picture((String) kakaoProfile.get("profile_image_url"))
.attributes(attributes)
.nameAttributeKey(userNameAttributeName)
.provider(registrationId)
.providerId(attributes.get("id").toString())
.build();
}
public User toEntity() {
return User.builder()
.name(name)
.email(email)
.picture(picture)
.role(Role.USER) // 기본 권한 USER
.provider(provider)
.providerId(providerId)
.build();
}
}
이제 CustomOAuth2UserService에서 사용자 정보를 저장하거나 업데이트할 때 이메일뿐만 아니라 provider와 providerId도 함께 고려하도록 수정해볼게요!
CustomOAuth2UserService.java (확장)
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.Optional;
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
public CustomOAuth2UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
// 현재 로그인 진행 중인 서비스를 구분하는 코드 (구글, 네이버, 카카오 등)
String registrationId = userRequest.getClientRegistration().getRegistrationId();
// OAuth2 로그인 시 키가 되는 필드값 (PK와 같은 의미)
String userNameAttributeName = userRequest.getClientRegistration()
.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
// OAuth2UserService를 통해 가져온 데이터를 담을 클래스
OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
// 사용자 저장 또는 업데이트
User user = saveOrUpdate(attributes);
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())),
attributes.getAttributes(),
attributes.getNameAttributeKey());
}
private User saveOrUpdate(OAuthAttributes attributes) {
// 이메일과 provider, providerId로 사용자 찾기
Optional<User> userOptional = userRepository.findByEmailAndProvider(attributes.getEmail(), attributes.getProvider());
User user;
if (userOptional.isPresent()) {
// 기존 사용자가 있으면 정보 업데이트
user = userOptional.get();
user.update(attributes.getName(), attributes.getPicture());
} else {
// 새 사용자면 등록
user = attributes.toEntity();
}
return userRepository.save(user);
}
}
그리고 UserRepository에도 findByEmailAndProvider 메서드를 추가해야 해요!
UserRepository.java (확장)
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
Optional<User> findByEmailAndProvider(String email, String provider);
}
이렇게 하면 같은 이메일이라도 다른 소셜 서비스로 로그인한 경우 별도의 계정으로 관리할 수 있어요! 물론 서비스 정책에 따라 이메일이 같으면 같은 계정으로 통합하는 방식을 선택할 수도 있어요.
💡 TIP
사용자 정보 관리 전략:
- 이메일 기준 통합: 같은 이메일이면 같은 사용자로 간주하고 소셜 로그인 정보만 추가
- 소셜 서비스별 분리: 같은 이메일이라도 다른 소셜 서비스면 다른 사용자로 간주
- 하이브리드 방식: 사용자가 직접 계정 연동을 선택할 수 있게 함
재능넷 같은 서비스에서는 사용자 경험을 위해 이메일 기준 통합 방식이 좋을 수 있어요!
이제 소셜 로그인으로 받아온 사용자 정보를 DB에 저장하고 관리하는 방법을 알아봤어요! 다음으로 JWT 토큰을 발급하고 관리하는 방법을 알아볼게요!
7. JWT 토큰 발급 및 관리하기 🔐
소셜 로그인 후 JWT(JSON Web Token)를 발급하면 RESTful API를 안전하게 사용할 수 있어요. JWT는 상태를 저장하지 않는(Stateless) 인증 방식으로, 서버 확장성이 좋고 모바일 앱과의 연동도 쉬워요!
먼저 JWT 관련 의존성을 추가해볼게요!
Maven 의존성 추가 (pom.xml)
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
Gradle 의존성 추가 (build.gradle)
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
이제 JWT 토큰을 생성하고 검증하는 JwtTokenProvider 클래스를 만들어볼게요!
JwtTokenProvider.java
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.security.Key;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;
@Component
public class JwtTokenProvider {
@Value("${jwt.secret}")
private String secretKey;
private Key key;
private final long tokenValidityInMilliseconds = 1000 * 60 * 60 * 24; // 24시간
@PostConstruct
public void init() {
this.key = Keys.hmacShaKeyFor(secretKey.getBytes());
}
public String createToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
Date validity = new Date(now + this.tokenValidityInMilliseconds);
return Jwts.builder()
.setSubject(authentication.getName())
.claim("auth", authorities)
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(validity)
.compact();
}
public Authentication getAuthentication(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get("auth").toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
}
그리고 application.yml 파일에 JWT 시크릿 키를 추가해주세요!
jwt:
secret: your-secret-key-should-be-at-least-32-bytes-long-for-hs512-algorithm
이제 JWT 토큰을 처리하는 필터를 만들어볼게요!
JwtFilter.java
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.GenericFilterBean;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
public class JwtFilter extends GenericFilterBean {
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer ";
private JwtTokenProvider tokenProvider;
public JwtFilter(JwtTokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
String jwt = resolveToken(httpServletRequest);
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
Authentication authentication = tokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(servletRequest, servletResponse);
}
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(7);
}
return null;
}
}
이제 SecurityConfig 클래스를 수정해서 JWT 필터를 추가해볼게요!
SecurityConfig.java (JWT 추가)
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final CustomOAuth2UserService customOAuth2UserService;
private final JwtTokenProvider tokenProvider;
private final OAuth2SuccessHandler oAuth2SuccessHandler;
public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, JwtTokenProvider tokenProvider, OAuth2SuccessHandler oAuth2SuccessHandler) {
this.customOAuth2UserService = customOAuth2UserService;
this.tokenProvider = tokenProvider;
this.oAuth2SuccessHandler = oAuth2SuccessHandler;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.headers().frameOptions().disable()
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // JWT를 사용하므로 세션을 사용하지 않음
.and()
.authorizeRequests()
.antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**").permitAll()
.antMatchers("/api/v1/**").hasRole("USER")
.anyRequest().authenticated()
.and()
.logout()
.logoutSuccessUrl("/")
.and()
.oauth2Login()
.successHandler(oAuth2SuccessHandler) // 소셜 로그인 성공 시 JWT 토큰 발급
.userInfoEndpoint()
.userService(customOAuth2UserService);
// JWT 필터 추가
http.addFilterBefore(new JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
마지막으로 소셜 로그인 성공 시 JWT 토큰을 발급하는 OAuth2SuccessHandler 클래스를 만들어볼게요!
OAuth2SuccessHandler.java
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@Component
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtTokenProvider tokenProvider;
private final ObjectMapper objectMapper;
public OAuth2SuccessHandler(JwtTokenProvider tokenProvider, ObjectMapper objectMapper) {
this.tokenProvider = tokenProvider;
this.objectMapper = objectMapper;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException, ServletException {
// JWT 토큰 생성
String token = tokenProvider.createToken(authentication);
// 응답 설정
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_OK);
// 응답 데이터 생성
Map<String, String> data = new HashMap<>();
data.put("token", token);
data.put("message", "로그인 성공");
// 응답 데이터 전송
response.getWriter().write(objectMapper.writeValueAsString(data));
}
}
이제 소셜 로그인 성공 시 JWT 토큰이 발급되고, 이후 API 요청 시 이 토큰을 사용해서 인증할 수 있어요!
📝 JWT 토큰 사용 방법
클라이언트에서 API 요청 시 다음과 같이 Authorization 헤더에 JWT 토큰을 포함시켜야 해요:
// JavaScript 예시
fetch('/api/v1/users/me', {
headers: {
'Authorization': 'Bearer ' + token
}
})
.then(response => response.json())
.then(data => console.log(data));
이렇게 해서 소셜 로그인과 JWT 토큰 발급까지 구현해봤어요! 이제 실제 프로젝트에 적용하는 방법을 알아볼게요!
8. 실전 프로젝트에 적용하기 💻
지금까지 배운 내용을 실제 프로젝트에 적용하는 방법을 알아볼게요! 재능넷 같은 서비스에 소셜 로그인을 적용한다고 생각해볼까요? 🤔
1. 프로젝트 구조 설계
먼저 프로젝트 구조를 잘 설계해야 해요. MVC 패턴이나 계층형 아키텍처를 사용하는 것이 좋아요!
src
├── main
│ ├── java
│ │ └── com
│ │ └── jaenung
│ │ └── oauth2
│ │ ├── config
│ │ │ ├── SecurityConfig.java
│ │ │ └── WebConfig.java
│ │ ├── controller
│ │ │ ├── UserController.java
│ │ │ └── AuthController.java
│ │ ├── domain
│ │ │ ├── User.java
│ │ │ └── Role.java
│ │ ├── dto
│ │ │ ├── UserDto.java
│ │ │ └── TokenDto.java
│ │ ├── repository
│ │ │ └── UserRepository.java
│ │ ├── security
│ │ │ ├── CustomOAuth2UserService.java
│ │ │ ├── OAuth2SuccessHandler.java
│ │ │ ├── JwtTokenProvider.java
│ │ │ ├── JwtFilter.java
│ │ │ └── OAuthAttributes.java
│ │ ├── service
│ │ │ └── UserService.java
│ │ └── OAuth2Application.java
│ └── resources
│ ├── static
│ ├── templates
│ │ └── login.html
│ └── application.yml
└── test
└── java
└── com
└── jaenung
└── oauth2
├── controller
├── service
└── security
2. 프론트엔드 구현
소셜 로그인 버튼과 토큰 처리 로직을 구현해야 해요!
// login.js
document.addEventListener('DOMContentLoaded', function() {
// 로그인 성공 후 토큰 처리
function handleLoginSuccess(data) {
// 토큰을 로컬 스토리지에 저장
localStorage.setItem('token', data.token);
// 메인 페이지로 리다이렉트
window.location.href = '/';
}
// 소셜 로그인 버튼 클릭 이벤트 처리
const googleBtn = document.getElementById('google-login');
const naverBtn = document.getElementById('naver-login');
const kakaoBtn = document.getElementById('kakao-login');
googleBtn.addEventListener('click', function() {
window.location.href = '/oauth2/authorization/google';
});
naverBtn.addEventListener('click', function() {
window.location.href = '/oauth2/authorization/naver';
});
kakaoBtn.addEventListener('click', function() {
window.location.href = '/oauth2/authorization/kakao';
});
});
// api.js
// API 요청 시 토큰을 헤더에 추가하는 함수
function fetchWithToken(url, options = {}) {
const token = localStorage.getItem('token');
if (!token) {
// 토큰이 없으면 로그인 페이지로 리다이렉트
window.location.href = '/login';
return Promise.reject('No token found');
}
// 헤더에 토큰 추가
const headers = options.headers || {};
headers['Authorization'] = `Bearer ${token}`;
return fetch(url, {
...options,
headers
});
}
// 사용자 정보 가져오기
function getUserInfo() {
return fetchWithToken('/api/v1/users/me')
.then(response => {
if (!response.ok) {
throw new Error('Failed to fetch user info');
}
return response.json();
});
}
3. 백엔드 API 구현
사용자 정보를 제공하는 API를 구현해야 해요!
// UserController.java
@RestController
@RequestMapping("/api/v1/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/me")
public ResponseEntity<UserDto> getCurrentUser(Authentication authentication) {
String email = authentication.getName();
UserDto userDto = userService.getUserByEmail(email);
return ResponseEntity.ok(userDto);
}
}
// UserService.java
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public UserDto getUserByEmail(String email) {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("User not found with email: " + email));
return new UserDto(
user.getId(),
user.getName(),
user.getEmail(),
user.getPicture(),
user.getRole().name()
);
}
}
4. 예외 처리
토큰 만료, 인증 실패 등의 예외를 처리해야 해요!
// JwtExceptionFilter.java
@Component
public class JwtExceptionFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (JwtException e) {
// JWT 예외 처리
setErrorResponse(HttpStatus.UNAUTHORIZED, response, e);
} catch (RuntimeException e) {
// 기타 예외 처리
setErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, response, e);
}
}
private void setErrorResponse(HttpStatus status, HttpServletResponse response, Throwable ex) throws IOException {
response.setStatus(status.value());
response.setContentType("application/json");
Map<String, String> errorDetails = new HashMap<>();
errorDetails.put("message", ex.getMessage());
errorDetails.put("status", status.toString());
ObjectMapper mapper = new ObjectMapper();
response.getWriter().write(mapper.writeValueAsString(errorDetails));
}
}
5. 보안 강화
HTTPS 적용, CSRF 보호, XSS 방지 등의 보안 강화 조치를 취해야 해요!
// SecurityConfig.java에 추가
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// ... 기존 설정 ...
// HTTPS 강제
.requiresChannel()
.anyRequest().requiresSecure()
.and()
// XSS 방지
.headers()
.xssProtection()
.and()
.contentSecurityPolicy("script-src 'self'")
.and()
// CSRF 보호 (API에서는 비활성화할 수 있음)
.csrf()
.ignoringAntMatchers("/api/**")
.and();
return http.build();
}
6. 테스트 코드 작성
소셜 로그인과 JWT 인증을 테스트하는 코드를 작성해야 해요!
// JwtTokenProviderTest.java
@ExtendWith(MockitoExtension.class)
public class JwtTokenProviderTest {
private JwtTokenProvider tokenProvider;
@BeforeEach
public void setup() {
tokenProvider = new JwtTokenProvider();
ReflectionTestUtils.setField(tokenProvider, "secretKey", "test-secret-key-for-jwt-token-test-that-is-long-enough");
tokenProvider.init();
}
@Test
public void testCreateAndValidateToken() {
// Given
Collection<GrantedAuthority> authorities = Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"));
Authentication authentication = new UsernamePasswordAuthenticationToken("test@example.com", null, authorities);
// When
String token = tokenProvider.createToken(authentication);
boolean isValid = tokenProvider.validateToken(token);
Authentication resultAuth = tokenProvider.getAuthentication(token);
// Then
assertThat(isValid).isTrue();
assertThat(resultAuth.getName()).isEqualTo("test@example.com");
assertThat(resultAuth.getAuthorities()).hasSize(1);
assertThat(resultAuth.getAuthorities().iterator().next().getAuthority()).isEqualTo("ROLE_USER");
}
}
이렇게 실제 프로젝트에 소셜 로그인과 JWT 인증을 적용할 수 있어요! 물론 프로젝트의 요구사항에 따라 더 많은 기능을 추가하거나 수정할 수 있어요.
💡 실전 팁
- 환경별 설정 분리: 개발, 테스트, 운영 환경별로 설정을 분리하세요.
- 로깅 강화: 인증 관련 로그를 자세히 남겨 문제 발생 시 디버깅하기 쉽게 하세요.
- 토큰 갱신: 액세스 토큰과 리프레시 토큰을 함께 사용해 사용자 경험을 개선하세요.
- 에러 처리: 사용자 친화적인 에러 메시지를 제공하세요.
- 모니터링: 인증 실패, 토큰 만료 등의 이벤트를 모니터링하세요.
이제 소셜 로그인과 JWT 인증을 실제 프로젝트에 적용하는 방법을 알아봤어요! 다음으로 자주 발생하는 문제와 해결 방법을 알아볼게요!
9. 트러블슈팅 & 자주 묻는 질문들 🛠️
소셜 로그인과 OAuth2를 구현하다 보면 여러 가지 문제가 발생할 수 있어요. 자주 발생하는 문제와 해결 방법을 알아볼게요!
Q: 리다이렉트 URI 오류가 발생해요.
A: 소셜 로그인 제공자의 개발자 콘솔에 등록한 리다이렉트 URI와 application.yml에 설정한 URI가 정확히 일치하는지 확인하세요. 대소문자, 슬래시, 포트 번호 등이 모두 일치해야 해요!
# 올바른 예시
redirect-uri: "http://localhost:8080/login/oauth2/code/google"
# 잘못된 예시 (슬래시 누락)
redirect-uri: "http://localhost:8080login/oauth2/code/google"
Q: 소셜 로그인 후 403 Forbidden 오류가 발생해요.
A: 사용자 권한 설정을 확인하세요. SecurityConfig에서 URL 패턴과 필요한 권한을 올바르게 설정했는지 확인하세요.
// 권한 설정 예시
.authorizeRequests()
.antMatchers("/", "/css/**", "/images/**", "/js/**").permitAll() // 누구나 접근 가능
.antMatchers("/api/v1/**").hasRole("USER") // USER 권한 필요
.anyRequest().authenticated() // 그 외에는 인증 필요
Q: 소셜 로그인 시 사용자 정보를 가져오지 못해요.
A: scope 설정을 확인하세요. 필요한 정보에 접근할 수 있는 권한을 요청했는지 확인하세요.
# 이메일과 프로필 정보를 가져오기 위한 scope 설정
google:
scope: profile, email
naver:
scope: name, email, profile_image
kakao:
scope: profile_nickname, profile_image, account_email
Q: JWT 토큰이 금방 만료돼요.
A: 토큰 유효 기간을 조정하거나 리프레시 토큰을 사용하세요.
// 토큰 유효 기간 설정 (예: 24시간)
private final long tokenValidityInMilliseconds = 1000 * 60 * 60 * 24;
// 리프레시 토큰 유효 기간 설정 (예: 14일)
private final long refreshTokenValidityInMilliseconds = 1000 * 60 * 60 * 24 * 14;
Q: 여러 소셜 계정을 하나의 사용자 계정으로 연결하고 싶어요.
A: 사용자 엔티티에 소셜 계정 정보를 리스트로 저장하고, 이메일을 기준으로 계정을 연결하세요.
// SocialConnection.java
@Entity
public class SocialConnection {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String provider; // google, naver, kakao
private String providerId;
@ManyToOne
private User user;
// getters, setters, constructors
}
// User.java에 추가
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<SocialConnection> socialConnections = new ArrayList<>();
public void addSocialConnection(String provider, String providerId) {
SocialConnection connection = new SocialConnection();
connection.setProvider(provider);
connection.setProviderId(providerId);
connection.setUser(this);
this.socialConnections.add(connection);
}
Q: 소셜 로그인 후 특정 페이지로 리다이렉트하고 싶어요.
A: OAuth2SuccessHandler를 수정해서 원하는 페이지로 리다이렉트하세요.
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException, ServletException {
// JWT 토큰 생성
String token = tokenProvider.createToken(authentication);
// 리다이렉트 URL 설정
String targetUrl = determineTargetUrl(request, response, authentication);
// 쿠키에 토큰 저장
Cookie cookie = new Cookie("jwt", token);
cookie.setPath("/");
cookie.setHttpOnly(true);
cookie.setMaxAge(24 * 60 * 60); // 24시간
response.addCookie(cookie);
// 리다이렉트
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
private String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
// 세션에 저장된 리다이렉트 URL이 있으면 사용
String redirectUrl = (String) request.getSession().getAttribute("REDIRECT_URL");
if (redirectUrl != null) {
request.getSession().removeAttribute("REDIRECT_URL");
return redirectUrl;
}
// 기본 URL
return "/";
}
Q: 소셜 로그인 시 추가 정보(전화번호, 주소 등)를 입력받고 싶어요.
A: 소셜 로그인 후 추가 정보 입력 페이지로 리다이렉트하고, 입력된 정보를 저장하세요.
// OAuth2SuccessHandler.java 수정
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException, ServletException {
// 사용자 정보 가져오기
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
String email = oAuth2User.getAttribute("email");
// 사용자 정보 확인
User user = userRepository.findByEmail(email).orElseThrow();
// 추가 정보가 필요한지 확인
if (user.getPhoneNumber() == null || user.getAddress() == null) {
// 임시 토큰 생성 (짧은 유효 기간)
String tempToken = tokenProvider.createToken(authentication, 30 * 60 * 1000); // 30분
// 추가 정보 입력 페이지로 리다이렉트
response.sendRedirect("/additional-info?token=" + tempToken);
} else {
// 정보가 이미 있으면 정상적으로 JWT 토큰 발급
String token = tokenProvider.createToken(authentication);
// 응답 설정
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_OK);
// 응답 데이터 생성
Map<String, String> data = new HashMap<>();
data.put("token", token);
data.put("message", "로그인 성공");
// 응답 데이터 전송
response.getWriter().write(objectMapper.writeValueAsString(data));
}
}
Q: 소셜 로그인 테스트는 어떻게 하나요?
A: 실제 소셜 로그인을 테스트하기는 어려우므로, Mock을 사용해서 테스트하세요.
@ExtendWith(MockitoExtension.class)
public class CustomOAuth2UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private CustomOAuth2UserService customOAuth2UserService;
@Test
public void testLoadUser() {
// Given
ClientRegistration clientRegistration = ClientRegistration.withRegistrationId("google")
.clientId("client-id")
.clientSecret("client-secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("http://localhost:8080/login/oauth2/code/google")
.scope("profile", "email")
.authorizationUri("https://accounts.google.com/o/oauth2/auth")
.tokenUri("https://oauth2.googleapis.com/token")
.userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo")
.userNameAttributeName("sub")
.clientName("Google")
.build();
OAuth2UserRequest userRequest = new OAuth2UserRequest(clientRegistration, new OAuth2AccessToken(
OAuth2AccessToken.TokenType.BEARER, "token-value", Instant.now(), Instant.now().plusSeconds(3600)));
Map<String, Object> attributes = new HashMap<>();
attributes.put("sub", "12345");
attributes.put("name", "Test User");
attributes.put("email", "test@example.com");
attributes.put("picture", "https://example.com/picture.jpg");
OAuth2User oAuth2User = new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
attributes,
"sub");
// Mock DefaultOAuth2UserService
DefaultOAuth2UserService delegate = mock(DefaultOAuth2UserService.class);
when(delegate.loadUser(any(OAuth2UserRequest.class))).thenReturn(oAuth2User);
// When
OAuth2User result = customOAuth2UserService.loadUser(userRequest);
// Then
assertThat(result).isNotNull();
assertThat(result.getName()).isEqualTo("12345");
assertThat(result.getAttributes()).containsKey("sub");
assertThat(result.getAttributes()).containsKey("name");
assertThat(result.getAttributes()).containsKey("email");
}
}
이렇게 자주 발생하는 문제와 해결 방법을 알아봤어요! 소셜 로그인과 OAuth2는 복잡할 수 있지만, 이해하고 나면 매우 유용한 기능이에요!
🎯 정리
이번 글에서는 Spring Security와 OAuth2를 사용해서 소셜 로그인을 구현하는 방법을 알아봤어요!
- OAuth2의 개념과 소셜 로그인의 장점
- Spring Security와 OAuth2 환경 설정
- 구글, 네이버, 카카오 로그인 연동
- 사용자 정보 처리와 DB 연동
- JWT 토큰 발급 및 관리
- 실전 프로젝트 적용 방법
- 트러블슈팅과 자주 묻는 질문들
이제 여러분도 재능넷과 같은 서비스에 소셜 로그인을 구현할 수 있을 거예요! 😊
소셜 로그인을 구현하면 사용자들이 더 쉽게 서비스에 가입하고 이용할 수 있어요. 특히 재능 공유 플랫폼인 재능넷에서는 사용자 경험을 개선하는 데 큰 도움이 될 거예요!
질문이나 의견이 있으시면 언제든지 댓글로 남겨주세요! 함께 성장하는 개발자 커뮤니티를 만들어봐요! 🚀
댓글 작성
이 글에 대한 여러분의 생각을 들려주세요
로그인이 필요합니다
댓글을 작성하려면 먼저 로그인해주세요.
댓글 0
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.
아직 댓글이 없습니다.