🔐 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.java

DDD 패턴으로 깔끔하게 정리!


😵 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 #소셜로그인

+ Recent posts