Dev/SpringBoot

Spring Boot 3 kakao oauth2 Login 구현 - 1

VIPeveloper 2025. 7. 14. 17:58
728x90
반응형

그동안 프로젝트를 여러번 시작하면서, 가장 중요하다고 생각하는 기능은 로그인 및 로그아웃 기능이였다.(필수이기 때문)

 

때문에, 포스팅 코드를 복붙하기만 한다면 기초 구현은 가능한 정도로 코드를 만들어 추후 프로젝트 신규 진행 시 유용하게 사용하고 싶은 마음에 포스팅 하게 되었다.
(카카오톡 계정 하나만 있으면 로그인, 로그아웃 가능한 서비스 완성!)

---

1️⃣ 사용 기술 스택

- Spring Boot 3.5.3

- Java 17

- Spring Security 6.x
- Thymeleaf
- Gradle
- Kakao Developers (REST API)

---

2️⃣ 목표

- 카카오 로그인 버튼 클릭 → Kakao 인증 → 사용자 정보 DB 및 세션 저장  
- 로그인한 유저만 관리자 페이지(`/admin/index`) 접근 가능  
- 로그아웃 시 카카오 로그아웃 + 세션/인증정보 모두 초기화

---

3️⃣ 🔧 의존성 추가 (`build.gradle`)

- 디펜던시는 아래정도 추가해 주었다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    runtimeOnly 'com.h2database:h2'
    runtimeOnly 'com.mysql:mysql-connector-j'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

 

4️⃣ 🔐 application.yml 설정

- console path 설정 필요

- kakao 의 client-id, client-secret 은 developers 의 앱을 참고하여 개인이 설정하면 된다.

server:
  port: 12341
spring:
  h2:
    console:
      enabled: true # H2 웹 콘솔을 사용하겠다는 의미
      path: /test  # 콘솔의 경로
      settings:
        web-allow-others: true
  jpa:
    show_sql: true
    hibernate:
      ddl-auto: none
    properties:
      hibernate:
        format_sql: true
        dialect: org.hibernate.dialect.H2Dialect
  datasource:
    driver-class-name: org.h2.Driver
    url: jdbc:h2:~/test
    username: sa
    password:
  security:
    oauth2:
      client:
        registration:
          kakao:
            client-id: [REST_API_KEY] # REST API key
            client-secret: [ADMIN_KEY] # Admin key
            authorization-grant-type: authorization_code
            redirect-uri: http://localhost:12341/login/oauth2/code/kakao
            client-authentication-method: client_secret_post
            scope: profile_nickname,profile_image
        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
kakao:
  authorize_url: https://kauth.kakao.com/oauth/authorize
  redirect_url: http://localhost:12341

🔑 Kakao Developers에서 애플리케이션 등록 후 Redirect URI
http://localhost:12341/login/oauth2/code/kakao 로 설정해 주세요.

 

oauth 가 신기한게,, 로그인 그냥 href 로 저 URL 을 입력했더니 알아서 인증이 된다.

 

메인 화면에서는 /oauth2/autorization/kakao 로 입력해줘야한다.

 

5️⃣ Security 설정 (SecurityConfig.java)

- 사용자 정보 관련

- 로그인 성공 관련

- 로그인 실패 관련

- 권한 및 접근 제어 관련

기본 설정을 적용해볼 수 있는 자바 설정을 기록했다.

import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
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.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    private final CustomOAuth2UserService customOAuth2UserService;
    private final CustomOAuth2SuccessHandler customOAuth2SuccessHandler;
    private final CustomAuthExceptionHandler customAuthExceptionHandler;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                // H2 콘솔 사용을 위해 CSRF 비활성화
                .csrf(AbstractHttpConfigurer::disable) // CSRF 비활성화 (H2 콘솔 사용을 위해 필요)
                .headers(headers ->
                        headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)) // 프레임 사용 허용
                .authorizeHttpRequests(
                        authorize -> authorize
                        .requestMatchers("/admin/**").hasAnyRole("ADMIN") // 관리자 권한만 허용
                        .requestMatchers("/","/kakaoLogout").permitAll()    // login page
                        .requestMatchers(PathRequest.toH2Console()).permitAll()
                        .anyRequest().authenticated()
                );

        http.oauth2Login(config -> config
                .successHandler(customOAuth2SuccessHandler)
                .failureHandler(customAuthExceptionHandler)
                .userInfoEndpoint(endpointConfig -> endpointConfig
                        .userService(customOAuth2UserService)));
        return http.build();
    }
}

 

6️⃣ 사용자 정보 처리

✅ CustomOAuth2UserService.java

- 사용자 정보 처리 관련 자바 설정

- 제일 중요

import com.silencelog.silencelog.member.Member;
import com.silencelog.silencelog.member.MemberRepository;
import com.silencelog.silencelog.member.MemberStatus;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.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.time.LocalDateTime;
import java.util.List;
import java.util.Map;

@Service
@Slf4j
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
    private final MemberRepository memberRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oauth2User = super.loadUser(userRequest);
        log.info("oAuth2User: {}", oauth2User);

        final Map<String, Object> attributes = oauth2User.getAttributes();
        final String oauthId = String.valueOf(attributes.get("id"));
        final String oauthType = userRequest.getClientRegistration().getRegistrationId();

        // 회원 조회 또는 생성
        Member member = memberRepository.findByOAuthIdAndOAuthType(oauthId, oauthType)
                .orElseGet(() -> createNewMember(attributes, oauthId, oauthType));

        // 권한 설정
        List<SimpleGrantedAuthority> authorities = getAuthorities(member);

        return new DefaultOAuth2User(authorities, oauth2User.getAttributes(), "id");
    }

    private Member createNewMember(Map<String, Object> attributes, String oauthId, String oauthType) {
        log.info("신규 회원 생성: oauthId={}, oauthType={}", oauthId, oauthType);

        // OAuth 제공자별 정보 추출
        String email = extractEmail(attributes, oauthType);
        String name = extractName(attributes, oauthType);
        String profileImage = extractProfileImage(attributes, oauthType);

        Member newMember = Member.builder()
                .OAuthId(oauthId)
                .OAuthType(oauthType)
                .memberStatus(MemberStatus.USER)
                .createdAt(LocalDateTime.now())
                .build();

        return memberRepository.save(newMember);
    }

    private String extractEmail(Map<String, Object> attributes, String oauthType) {
        switch (oauthType) {
            case "kakao":
                Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
                return kakaoAccount != null ? (String) kakaoAccount.get("email") : null;
            case "google":
                return (String) attributes.get("email");
            case "naver":
                Map<String, Object> response = (Map<String, Object>) attributes.get("response");
                return response != null ? (String) response.get("email") : null;
            default:
                return null;
        }
    }

    private String extractName(Map<String, Object> attributes, String oauthType) {
        switch (oauthType) {
            case "kakao":
                Map<String, Object> properties = (Map<String, Object>) attributes.get("properties");
                return properties != null ? (String) properties.get("nickname") : null;
            case "google":
                return (String) attributes.get("name");
            case "naver":
                Map<String, Object> response = (Map<String, Object>) attributes.get("response");
                return response != null ? (String) response.get("name") : null;
            default:
                return null;
        }
    }

    private String extractProfileImage(Map<String, Object> attributes, String oauthType) {
        switch (oauthType) {
            case "kakao":
                Map<String, Object> properties = (Map<String, Object>) attributes.get("properties");
                return properties != null ? (String) properties.get("profile_image") : null;
            case "google":
                return (String) attributes.get("picture");
            case "naver":
                Map<String, Object> response = (Map<String, Object>) attributes.get("response");
                return response != null ? (String) response.get("profile_image") : null;
            default:
                return null;
        }
    }

    private List<SimpleGrantedAuthority> getAuthorities(Member member) {
        switch (member.getMemberStatus()) {
            case ADMIN:
                return List.of(new SimpleGrantedAuthority("ROLE_ADMIN"));
            case USER:
                return List.of(new SimpleGrantedAuthority("ROLE_USER"));
            default:
                return List.of(new SimpleGrantedAuthority("ROLE_GUEST"));
        }
    }
}

 

✅ CustomOAuth2SuccessHandler.java

- 성공 시 로직

- 뭐 커스텀으로 추가하고 싶으면 넣어도 된다. 여기서는 기본 리다이렉트만 시켜준다.

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;

@RequiredArgsConstructor
@Component
@Slf4j
public class CustomOAuth2SuccessHandler implements AuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException, ServletException {
        response.sendRedirect("/");
    }
}

 

✅CustomAuthExceptionHandler.java

- 로그인 하다가 뭔가 에러가 난 경우

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Slf4j
@Component
public class CustomAuthExceptionHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
        AuthenticationException exception) throws IOException, ServletException {
        // 여기에 로그인 실패 후 처리할 내용을 작성하기!
        log.info("무언가 에러가 발생했어요");
        response.sendRedirect("/login-failure");
    }
}

 

맴버 관련은 2편에서,, 

728x90
반응형