🔐 Spring Boot OAuth 2.0 + JWT 인증 시스템 구축
🎯 오늘의 목표
소셜 로그인 3종 세트(구글, 카카오, 네이버) + JWT 토큰 인증 시스템 완성하기!
✨ 완성된 것들
- ✅ OAuth 2.0 소셜 로그인 (Google, Kakao, Naver)
- ✅ JWT 토큰 발급/검증 시스템
- ✅ Spring Security 연동
- ✅ 사용자 DB 자동 저장
📚 OAuth 2.0가 뭔데?
간단하게 설명하면
"구글아, 이 사람 누군지 확인해줘!"
우리가 직접 회원가입 받고 비밀번호 저장하고... 이런 거 안 해도 됨!
구글, 카카오, 네이버가 대신 해주는 거임.
동작 흐름
사용자: "구글로 로그인" 클릭
↓
구글: "로그인 페이지 띄워줄게"
↓
사용자: 로그인 & 동의
↓
구글: "여기 인증 코드야~"
↓
우리 서버: "이 코드로 토큰 줘"
↓
구글: "여기 Access Token이야"
↓
우리 서버: "이 토큰으로 사용자 정보 줘"
↓
구글: "이메일, 이름 여기 있어~"
↓
우리 서버: DB에 저장 & JWT 토큰 발급
↓
프론트: 토큰 받아서 localStorage에 저장
↓
완료! 🎉🛠️ 1. 프로젝트 설정
build.gradle에 의존성 추가
dependencies {
// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
// OAuth 2.0 Client
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
}
⚠️ 삽질 1: JWT 의존성 오타
// ❌ 이렇게 하면 안 됨
runtimeOnly 'io.jsonwebtoken:jjwt-jackson0.12.6' // 콜론 빠짐!
// ✅ 올바른 버전
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
콜론 하나 빠져서 30분 날림... 😭
⚙️ 2. application.yaml 설정
spring:
security:
oauth2:
client:
registration:
# 구글 설정
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope: [email, profile]
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
# 카카오 설정
kakao:
client-id: ${KAKAO_CLIENT_ID}
client-secret: ${KAKAO_CLIENT_SECRET}
client-authentication-method: client_secret_post
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
scope: [profile_nickname, account_email]
client-name: kakao
# 네이버 설정
naver:
client-id: ${NAVER_CLIENT_ID}
client-secret: ${NAVER_CLIENT_SECRET}
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
scope: [name, email]
client-name: Naver
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
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
jwt:
secret: ${JWT_SECRET}
expiration: 7200000 # 2시간 (밀리초)
🔑 환경 변수 설정
민감 정보는 절대 Git에 올리면 안 됨!
.env 파일 생성:
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
KAKAO_CLIENT_ID=your_kakao_client_id
KAKAO_CLIENT_SECRET=your_kakao_client_secret
NAVER_CLIENT_ID=your_naver_client_id
NAVER_CLIENT_SECRET=your_naver_client_secret
JWT_SECRET=your_jwt_secret_at_least_32_characters
😵 3. 제공자마다 응답이 다르다고?
문제 발견
구글, 카카오, 네이버가 주는 사용자 정보 형식이 다 달라...
구글:
{
"sub": "123456",
"email": "user@gmail.com",
"name": "홍길동"
}
카카오:
{
"id": 789,
"kakao_account": {
"email": "user@kakao.com"
},
"properties": {
"nickname": "홍길동"
}
}
네이버:
{
"response": {
"id": "abc",
"email": "user@naver.com",
"name": "홍길동"
}
}
이걸 어떻게 통일하지? 🤔
💡 4. 해결책: 인터페이스로 추상화
Oauth2UserInfo 인터페이스
public interface Oauth2UserInfo {
String getProvider(); // "google", "kakao", "naver"
String getProviderId(); // 제공자별 고유 ID
String getEmail(); // 이메일
String getName(); // 이름
}
이제 제공자마다 이 인터페이스를 구현하면 됨!
GoogleUserInfo (제일 간단)
public class GoogleUserInfo implements Oauth2UserInfo {
private final Map<String, Object> attributes;
public GoogleUserInfo(Map<String, Object> attributes) {
this.attributes = attributes;
}
@Override
public String getProvider() {
return "google";
}
@Override
public String getProviderId() {
return (String) attributes.get("sub");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getName() {
return (String) attributes.get("name");
}
}
구글은 1단계 파싱으로 끝! 깔끔하다~
KakaoUserInfo (중첩 지옥)
public class KakaoUserInfo implements Oauth2UserInfo {
private final Map<String, Object> attributes;
public KakaoUserInfo(Map<String, Object> attributes) {
this.attributes = attributes;
}
@Override
public String getProvider() {
return "kakao";
}
@Override
public String getProviderId() {
// 카카오는 ID가 Long 타입! String으로 변환 필요
return String.valueOf(attributes.get("id"));
}
@Override
public String getEmail() {
// 2단계 중첩... kakao_account 안에 이메일이 있음
Map<String, Object> kakaoAccount =
(Map<String, Object>) attributes.get("kakao_account");
if (kakaoAccount == null) {
return null; // 이메일 동의 안 한 경우
}
return (String) kakaoAccount.get("email");
}
@Override
public String getName() {
// properties 안에 nickname이 있음
Map<String, Object> properties =
(Map<String, Object>) attributes.get("properties");
if (properties == null) {
return null;
}
return (String) properties.get("nickname");
}
}
카카오... 왜 이렇게 중첩을 많이 하니 😅
⚠️ 삽질 2: 카카오 이메일 null
카카오는 이메일이 선택 동의 항목이라서 null일 수 있음!
null 체크 안 하면 NullPointerException 터짐 💥
NaverUserInfo (response 래핑)
public class NaverUserInfo implements Oauth2UserInfo {
private final Map<String, Object> attributes;
@Override
public String getProviderId() {
// 네이버는 모든 데이터가 "response" 안에 있음
Map<String, Object> response =
(Map<String, Object>) attributes.get("response");
if (response == null) {
return null;
}
return (String) response.get("id");
}
@Override
public String getEmail() {
Map<String, Object> response =
(Map<String, Object>) attributes.get("response");
if (response == null) {
return null;
}
return (String) response.get("email");
}
@Override
public String getName() {
Map<String, Object> response =
(Map<String, Object>) attributes.get("response");
if (response == null) {
return null;
}
return (String) response.get("name");
}
}
네이버는 매번 response 꺼내야 함. 반복적이긴 한데 안전함!
🔄 5. OAuth 로그인 처리
CustomOAuth2UserService
Spring Security가 자동으로 호출하는 핵심 서비스!
@Slf4j
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) {
log.info("🔐 OAuth2.0 로그인 시작");
// 1. 부모 클래스로 OAuth 제공자에서 사용자 정보 받기
OAuth2User oAuth2User = super.loadUser(userRequest);
log.info("📥 받은 정보: {}", oAuth2User.getAttributes());
// 2. 어떤 제공자인지 확인
String registrationId = userRequest
.getClientRegistration()
.getRegistrationId();
log.info("🏢 제공자: {}", registrationId);
// 3. 제공자별로 UserInfo 생성
Oauth2UserInfo userInfo = getOAuth2UserInfo(
registrationId,
oAuth2User.getAttributes()
);
log.info("👤 이메일: {}, 이름: {}",
userInfo.getEmail(), userInfo.getName());
// 4. 이메일 필수 확인
if (userInfo.getEmail() == null || userInfo.getEmail().isEmpty()) {
log.error("❌ 이메일 정보 없음!");
throw new OAuth2AuthenticationException("이메일 필수!");
}
// 5. DB에 저장 또는 업데이트
User user = saveOrUpdate(userInfo);
log.info("✅ 완료 - User ID: {}", user.getUserId());
return oAuth2User;
}
private Oauth2UserInfo getOAuth2UserInfo(
String registrationId,
Map<String, Object> attributes) {
return switch (registrationId.toLowerCase()) {
case "google" -> new GoogleUserInfo(attributes);
case "kakao" -> new KakaoUserInfo(attributes);
case "naver" -> new NaverUserInfo(attributes);
default -> throw new OAuth2AuthenticationException(
"지원 안 하는 소셜 로그인: " + registrationId
);
};
}
private User saveOrUpdate(Oauth2UserInfo userInfo) {
log.info("💾 DB 처리 시작");
LoginProvider provider = LoginProvider.valueOf(
userInfo.getProvider().toUpperCase()
);
User user = userRepository
.findByProviderAndProviderId(provider, userInfo.getProviderId())
.map(existingUser -> {
// 기존 사용자 - 정보 업데이트
log.info("🔄 기존 사용자 업데이트");
existingUser.updateOAuthInfo(
userInfo.getName(),
userInfo.getEmail()
);
return existingUser;
})
.orElseGet(() -> {
// 신규 사용자 - 회원가입
log.info("✨ 신규 사용자 생성");
return User.builder()
.email(userInfo.getEmail())
.username(userInfo.getName())
.provider(provider)
.providerId(userInfo.getProviderId())
.role(MyRole.USER)
.build();
});
User savedUser = userRepository.save(user);
log.info("✅ DB 저장 완료 - User ID: {}", savedUser.getUserId());
return savedUser;
}
}
로그 엄청 찍어놨음. 디버깅할 때 진짜 유용함! 👍
🎫 6. JWT가 뭔데?
간단하게 설명하면
"서버야, 나 로그인한 사람이야!"를 증명하는 입장권
전통적인 방식(세션):
서버 메모리에 "user123 로그인함" 저장
→ 서버가 기억해야 함
→ 서버 여러 대면? 공유 복잡함JWT 방식:
토큰 안에 "userId=1" 정보 포함
→ 서버가 기억 안 해도 됨!
→ 토큰만 검증하면 끝JWT 구조
Header.Payload.Signature
예시:
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjF9.SflKxwRJ...
↑ Header ↑ Payload ↑ Signature- Header: 알고리즘 정보 (HS256)
- Payload: 사용자 정보 (userId, email, exp)
- Signature: 위조 방지 서명
중요한 점
JWT는 암호화가 아니라 Base64 인코딩!
누구나 디코딩 가능 → 비밀번호 절대 넣으면 안 됨!
대신 Signature로 위조는 방지함!
🔧 7. JWT 토큰 시스템 구현
JwtProperties (설정 클래스)
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {
private String secret; // 서명 비밀키
private Long expiration; // 만료 시간 (밀리초)
}
application.yaml의 jwt.* 값을 자동으로 매핑해줌!
JwtTokenProvider (핵심!)
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
private final JwtProperties jwtProperties;
// 서명 키 생성
private SecretKey getSignKey() {
byte[] keyBytes = jwtProperties.getSecret()
.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}
// 토큰 생성
public String createToken(User user) {
log.info("🔐 JWT 토큰 생성 시작 - userId: {}", user.getUserId());
Date now = new Date();
Date expiration = new Date(now.getTime() + jwtProperties.getExpiration());
String token = Jwts.builder()
.subject(String.valueOf(user.getUserId()))
.issuedAt(now)
.expiration(expiration)
.signWith(getSignKey())
.compact();
log.info("✅ JWT 토큰 생성 완료");
return token;
}
// 토큰 검증
public boolean validateToken(String token) {
try {
log.info("🔍 JWT 토큰 검증 시작");
Jwts.parser()
.verifyWith(getSignKey())
.build()
.parseSignedClaims(token);
log.info("✅ JWT 토큰 검증 성공");
return true;
} catch (ExpiredJwtException e) {
log.error("❌ 만료된 토큰");
return false;
} catch (Exception e) {
log.error("❌ 유효하지 않은 토큰");
return false;
}
}
// 토큰에서 userId 추출
public Long getUserIdFromToken(String token) {
log.info("🔍 JWT 토큰에서 userId 추출");
Claims claims = Jwts.parser()
.verifyWith(getSignKey())
.build()
.parseSignedClaims(token)
.getPayload();
Long userId = Long.valueOf(claims.getSubject());
log.info("✅ userId 추출 성공: {}", userId);
return userId;
}
}
🎉 8. 로그인 성공 처리
OAuth2SuccessHandler
로그인 성공하면 JWT 발급하고 프론트로 보내주는 핸들러!
@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtTokenProvider jwtTokenProvider;
private final UserRepository userRepository;
@Override
public void onAuthenticationSuccess(
HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException {
log.info("🎉 OAuth 2.0 로그인 성공!");
// 1. 사용자 정보 가져오기
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
String email = oAuth2User.getAttribute("email");
if (email == null) {
log.error("❌ 이메일 정보 없음");
getRedirectStrategy().sendRedirect(
request, response,
"http://localhost:3000/login?error=email_required"
);
return;
}
log.info("📧 이메일: {}", email);
// 2. DB에서 User 조회
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new RuntimeException("사용자 없음"));
log.info("✅ User 조회 성공 - userId: {}", user.getUserId());
// 3. JWT 토큰 생성
String token = jwtTokenProvider.createToken(user);
log.info("🔐 JWT 토큰 생성 완료 - 길이: {}", token.length());
// 4. 프론트엔드로 리다이렉트 (토큰 포함)
String targetUrl = UriComponentsBuilder
.fromUriString("http://localhost:3000/oauth/callback")
.queryParam("token", token)
.build()
.toUriString();
log.info("🚀 리다이렉트 URL: {}", targetUrl);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
log.info("✅ OAuth 로그인 처리 완료!");
}
}
⚠️ 삽질 3: Handler 주입 방식
// ❌ 처음에 이렇게 했다가 안 됨
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http,
OAuth2SuccessHandler handler // 메서드 파라미터로 주입
) {
// ...
}
// ✅ 필드로 주입해야 함
@RequiredArgsConstructor
public class SecurityConfig {
private final OAuth2SuccessHandler oAuth2SuccessHandler; // 필드 주입
// ...
}
🔒 9. Spring Security 설정
SecurityConfig (최종본)
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomOAuth2UserService customOAuth2UserService;
private final OAuth2SuccessHandler oAuth2SuccessHandler;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/oauth2/**").permitAll()
.requestMatchers("/h2-console/**").permitAll()
.anyRequest().permitAll()
)
.oauth2Login(oauth2 -> oauth2
.successHandler(oAuth2SuccessHandler)
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService)
)
)
.csrf(csrf -> csrf
.ignoringRequestMatchers("/h2-console/**")
.disable()
)
.headers(headers -> headers
.frameOptions(frame -> frame.sameOrigin())
);
return http.build();
}
}
🧪 10. 테스트!
실행 로그
🔐 OAuth2.0 로그인 시작
📥 받은 정보: {sub=123456, email=user@gmail.com, name=홍길동}
🏢 제공자: google
👤 이메일: user@gmail.com, 이름: 홍길동
💾 DB 처리 시작
✨ 신규 사용자 생성
✅ DB 저장 완료 - User ID: 1
🎉 OAuth 2.0 로그인 성공!
📧 이메일: user@gmail.com
✅ User 조회 성공 - userId: 1
🔐 JWT 토큰 생성 시작 - userId: 1
✅ JWT 토큰 생성 완료
🚀 리다이렉트 URL: http://localhost:3000/oauth/callback?token=eyJhbGci...
✅ OAuth 로그인 처리 완료!로그 덕분에 어디서 뭐가 일어나는지 한눈에 보임! 👍
DB 확인
H2 Console에 들어가서 확인:
SELECT * FROM users;
-- 결과:
-- user_id | email | username | provider | provider_id
-- 1 | user@gmail.com | 홍길동 | GOOGLE | 123456789
DB에 자동으로 저장됨! 완벽! 🎉
🗂️ 11. 프로젝트 구조
src/main/java/.../
├── domain/
│ └── user/
│ ├── entity/
│ │ ├── User.java
│ │ ├── LoginProvider.java (GOOGLE, KAKAO, NAVER)
│ │ └── MyRole.java (USER, ADMIN)
│ └── repository/
│ └── UserRepository.java
└── global/
├── config/
│ └── SecurityConfig.java
└── security/
├── jwt/
│ ├── JwtProperties.java
│ └── JwtTokenProvider.java
└── oauth/
├── Oauth2UserInfo.java (인터페이스)
├── GoogleUserInfo.java
├── KakaoUserInfo.java
├── NaverUserInfo.java
├── CustomOAuth2UserService.java
└── OAuth2SuccessHandler.javaDDD 패턴으로 깔끔하게 정리!
😵 12. 삽질 총정리
삽질 1: JWT 의존성 오타
jjwt-jackson0.12.6→ 콜론 빠짐- 30분 날림... 😭
삽질 2: 카카오 이메일 null
- 이메일이 선택 동의라서 null 가능
- null 체크 안 하면 터짐 💥
삽질 3: Handler 주입 방식
- 메서드 파라미터로 주입했다가 안 됨
- 필드 주입으로 변경해서 해결
삽질 4: Git에 민감 정보 커밋
- application.yaml에 OAuth Secret 넣었다가...
- 급하게 .gitignore 추가하고 캐시 제거
- 앞으로 환경 변수 사용!
💡 13. 배운 점
OAuth 2.0
- 제공자마다 응답 형식이 다름 → 추상화 필요
- 카카오는 중첩 구조가 복잡함
- 네이버는 response로 래핑되어 있음
JWT
- Stateless 방식이라 서버 메모리 안 씀
- Base64 인코딩이지 암호화 아님
- Signature로 위조 방지
Spring Security
- OAuth2UserService: 로그인 처리
- AuthenticationSuccessHandler: 성공 후 처리
- SecurityFilterChain: 보안 설정
기타
- 로그를 많이 찍어놓으니 디버깅이 편함!
- 환경 변수 사용이 필수
- .gitignore 먼저 설정하자
🚀 14. 다음에 할 것
- JwtAuthenticationFilter 구현 (API 인증용)
- REST API 엔드포인트 추가
- 프론트엔드 로그인 페이지
- 리프레시 토큰 구현
- Docker 컨테이너화
- AWS 배포
💬 15. 마무리
OAuth 2.0 + JWT 처음 구현해봤는데...
진짜 어려웠다 😭
특히 제공자마다 응답 형식이 다른 거 처리하는 게 제일 힘들었음.
카카오는 왜 그렇게 중첩을 많이 하는지... 😅
근데 완성하고 나니까 뿌듯하네!
이제 Google, Kakao, Naver 3개 소셜 로그인이 다 된다!
내일은 프론트엔드 연동하고 API 만들어야지!
작성일: 2024-12-02
개발 시간: 약 8시간
GitHub: [저장소 링크]
#SpringBoot #OAuth2 #JWT #SpringSecurity #소셜로그인
'그래서 일단 프로젝트 > 개인프로젝트-IdeaMaker(클로드 작성본 md 모음)' 카테고리의 다른 글
| 기록 9 . 최종 배포까지... (도커, AWS) 이제... 잠시 쉬었다가 추후 사후관리를.. (1) | 2025.12.04 |
|---|---|
| 기록 8 . 관리자 기능 바이브코딩으로 만들고 준비작업 (0) | 2025.12.03 |
| 기록 6 . 파이썬 모듈 JAVA 백엔드에 연결하기 .. (1) | 2025.12.01 |
| 기록 5 . 컨트롤러 계층 완성 및 테스트완료 (0) | 2025.11.27 |
| 기록 4 . DTO 작성 및 Test 코드 작성 단계 (0) | 2025.11.26 |