Spring & SpringBoot

[Vite/React + Spring Boot] ์นด์นด์˜ค ์†Œ์…œ ๋กœ๊ทธ์ธ ์™„๋ฒฝ ์—ฐ๋™ ๊ฐ€์ด๋“œ

์ฐฝ๋”ฐ์˜ค 2025. 12. 11. 18:43
728x90

๐Ÿ“ 1๋‹จ๊ณ„: ์นด์นด์˜ค ๊ฐœ๋ฐœ์ž ์„ผํ„ฐ ๋ฐ ํ™˜๊ฒฝ ์„ค์ •

1. ์นด์นด์˜ค ๊ฐœ๋ฐœ์ž ์„ผํ„ฐ ์„ค์ • ์š”์•ฝ

  • REST API ํ‚ค: ๋ฐœ๊ธ‰ ํ›„ ํ™•์ธ. (Client ID๋กœ ์‚ฌ์šฉ)
  • ๋กœ๊ทธ์ธ Redirect URI: http://localhost:3000/oauth/kakao ๋“ฑ๋ก.
  • ๋กœ๊ทธ์•„์›ƒ Redirect URI (SSO ํ•ด์ œ์šฉ): http://localhost:3000 ๋“ฑ๋ก.
  • ๋™์˜ ํ•ญ๋ชฉ: ๋‹‰๋„ค์ž„, ์ด๋ฉ”์ผ ๋“ฑ ํ•„์ˆ˜ ํ•ญ๋ชฉ ์„ค์ •.

2. ํ™˜๊ฒฝ ๋ณ€์ˆ˜ (.env) ์„ค์ •

 
# .env (ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ)
VITE_KAKAO_REST_API_KEY="YOUR_REST_API_KEY"
VITE_KAKAO_REDIRECT_URI="http://localhost:3000/oauth/kakao"

๐Ÿ’ป 2๋‹จ๊ณ„: ํ”„๋ก ํŠธ์—”๋“œ (React + Zustand) ๊ตฌํ˜„ ์ƒ์„ธ

1. LoginPage.jsx (์นด์นด์˜ค ์ธ์ฆ ์‹œ์ž‘)

ํ™˜๊ฒฝ ๋ณ€์ˆ˜๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์นด์นด์˜ค ์ธ์ฆ ์„œ๋ฒ„๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธํ•˜๋Š” URL์„ ๊ตฌ์„ฑํ•ฉ๋‹ˆ๋‹ค.


 
// src/pages/login/LoginPage.jsx
// ...
const KAKAO_REST_API_KEY = import.meta.env.VITE_KAKAO_REST_API_KEY; 
const KAKAO_LOGOUT_REDIRECT_URI = "http://localhost:3000"; 

const handleKakaoLogin = () => {
    // ๐Ÿ’ก SSO ์„ธ์…˜์„ ๋Š๊ธฐ ์œ„ํ•ด ๋จผ์ € ์นด์นด์˜ค ๋กœ๊ทธ์•„์›ƒ ํŽ˜์ด์ง€๋กœ ์ด๋™
    const kakaoLogoutUrl = 
        `https://kauth.kakao.com/oauth/logout?client_id=${KAKAO_REST_API_KEY}&logout_redirect_uri=${KAKAO_LOGOUT_REDIRECT_URI}`;
    
    window.location.href = kakaoLogoutUrl; 
};

2. KakaoRedirectHandler.jsx (์ธ์ฆ ์ฝ”๋“œ ์ฒ˜๋ฆฌ ๋ฐ ์ด์ค‘ ํ˜ธ์ถœ ๋ฐฉ์ง€)

๊ฐ€์žฅ ์ค‘์š”ํ•˜๊ฒŒ invalid_grant ์˜ค๋ฅ˜๋ฅผ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•œ isProcessed ํ”Œ๋ž˜๊ทธ๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.


 
// src/pages/login/KakaoRedirectHandler.jsx
// ...
function KakaoRedirectHandler() {
    const { setAuthInfo } = useAuthStore();
    const [isProcessed, setIsProcessed] = useState(false); // โญ๏ธ ์ด์ค‘ ํ˜ธ์ถœ ๋ฐฉ์ง€
    
    useEffect(() => {
        const code = new URLSearchParams(location.search).get("code");

        if (!code) { /* ... ์‹คํŒจ ์ฒ˜๋ฆฌ ... */ return; }
        if (isProcessed) { return; } // โญ๏ธ ์ด๋ฏธ ์ฒ˜๋ฆฌํ–ˆ์œผ๋ฉด ์ข…๋ฃŒ

        setIsProcessed(true); // ํ”Œ๋ž˜๊ทธ ์„ค์ •
        
        const handleKakaoCode = async (authCode) => {
            try {
                const res = await kakaoLogin(authCode); // ๋ฐฑ์—”๋“œ API ํ˜ธ์ถœ
                if (res.success && res.data) {
                    setAuthInfo(res.data);
                    navigate("/home");
                } else {
                    // ๋ฐฑ์—”๋“œ์—์„œ ๋น„์ฆˆ๋‹ˆ์Šค ์˜ค๋ฅ˜ ๋ฐœ์ƒ ์‹œ ์ฒ˜๋ฆฌ
                    customError(res.msg || "๋กœ๊ทธ์ธ ์ฒ˜๋ฆฌ ์˜ค๋ฅ˜");
                    navigate("/");
                }
            } catch (error) {
                // HTTP ํ†ต์‹  ์˜ค๋ฅ˜ ๋ฐœ์ƒ ์‹œ ์ฒ˜๋ฆฌ (400, 500 ๋“ฑ)
                console.error("API ์ตœ์ข… ์‹คํŒจ:", error.response || error);
                customError("์„œ๋ฒ„ ํ†ต์‹  ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.");
                navigate("/");
            } finally {
                setLoading(false);
            }
        };

        handleKakaoCode(code);
    }, [location, navigate]); 
    // ... (๋ฆฌํ„ด UI)
}

3. API ํด๋ผ์ด์–ธํŠธ (authApi.js)

๋ฐฑ์—”๋“œ๋กœ ์ธ์ฆ ์ฝ”๋“œ๋ฅผ ์ „์†กํ•˜๋Š” ํ•จ์ˆ˜๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค.

JavaScript
 
// src/api/authApi.js
import axiosInstance from "../api/axiosInstance";

export const kakaoLogin = async (code) => {
    try {
        // ๋ฐฑ์—”๋“œ๋Š” { "code": "..." } JSON ๊ฐ์ฒด๋ฅผ ๊ธฐ๋Œ€ํ•ฉ๋‹ˆ๋‹ค.
        const response = await axiosInstance.post("/auth/kakao/login", { code });
        return response.data; 
    } catch (error) {
        console.error("โŒ ์นด์นด์˜ค ๋กœ๊ทธ์ธ ์š”์ฒญ ์‹คํŒจ:", error);
        throw error;
    }
};

4. ์ƒํƒœ ๊ด€๋ฆฌ (useAuthStore.js) - SSO ๋กœ๊ทธ์•„์›ƒ

logout ์‹œ ์„œ๋น„์Šค ์ƒํƒœ ์ดˆ๊ธฐํ™” ํ›„ ๋ธŒ๋ผ์šฐ์ €๋ฅผ ์นด์นด์˜ค ๋กœ๊ทธ์•„์›ƒ URL๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธํ•ฉ๋‹ˆ๋‹ค.

JavaScript
 
// src/store/useAuthStore.js
// ... (KAKAO_REST_API_KEY, KAKAO_LOGOUT_REDIRECT_URI ์ƒ์ˆ˜ ์ •์˜)

logout: () => {
    // 1. ์„œ๋น„์Šค ์ƒํƒœ ์ดˆ๊ธฐํ™” (Zustand, localStorage)
    // ...

    // 2. โญ๏ธ ์นด์นด์˜ค SSO ์„ธ์…˜ ๋กœ๊ทธ์•„์›ƒ์„ ์œ„ํ•ด ๋ธŒ๋ผ์šฐ์ € ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ โญ๏ธ
    if (KAKAO_REST_API_KEY && KAKAO_LOGOUT_REDIRECT_URI) {
        const kakaoLogoutUrl = 
            `https://kauth.kakao.com/oauth/logout?client_id=${KAKAO_REST_API_KEY}&logout_redirect_uri=${KAKAO_LOGOUT_REDIRECT_URI}`;
        window.location.href = kakaoLogoutUrl;
    } else {
        window.location.href = "/";
    }
},

๐ŸŒ 4๋‹จ๊ณ„: ๋ฐฑ์—”๋“œ (Spring Boot) ๊ตฌํ˜„ ์ƒ์„ธ

1. UserService.java (ํ•ต์‹ฌ ๋กœ์ง)

getKakaoToken ํ—ฌํผ ๋ฉ”์„œ๋“œ์— ์—๋Ÿฌ ์ƒ์„ธ ๋กœ๊ทธ๋ฅผ ์ถ”๊ฐ€ํ•˜์—ฌ ๋””๋ฒ„๊น…์„ ์šฉ์ดํ•˜๊ฒŒ ํ•ฉ๋‹ˆ๋‹ค.

Java
 
// UserService.java (getKakaoToken ํ—ฌํผ ๋ฉ”์„œ๋“œ)

private KakaoTokenResponse getKakaoToken(String authCode) {
    // ... (Headers ๋ฐ Params ์„ค์ •)

    try {
        ResponseEntity<KakaoTokenResponse> response = restTemplate.exchange(
                kakaoTokenUri, HttpMethod.POST, request, KakaoTokenResponse.class
        );
        // ... (์„ฑ๊ณต ์ฒ˜๋ฆฌ)

    } catch (HttpClientErrorException e) { // โญ๏ธ 4xx ์—๋Ÿฌ ์ƒ์„ธ ๋กœ๊ทธ
        // 401 Unauthorized, 400 Invalid Grant ๋“ฑ์˜ ์˜ค๋ฅ˜ ์ƒ์„ธ ์ •๋ณด๋ฅผ ๋กœ๊ทธ์— ๊ธฐ๋ก
        log.error("์นด์นด์˜ค ํ† ํฐ ๋ฐœ๊ธ‰ ์‹คํŒจ (HTTP Error {}): {}", e.getStatusCode(), e.getResponseBodyAsString());
        throw new AccountInfoException("์นด์นด์˜ค ์„œ๋ฒ„ ํ†ต์‹  ์˜ค๋ฅ˜");
    } catch (Exception e) {
        log.error("์นด์นด์˜ค Access Token ์š”์ฒญ ์ค‘ ์˜ˆ์™ธ ๋ฐœ์ƒ: {}", e.getMessage());
        throw new AccountInfoException("์นด์นด์˜ค ์„œ๋ฒ„ ํ†ต์‹  ์˜ค๋ฅ˜");
    }
}

// kakaoLogin ๋ฉ”์„œ๋“œ (ํšŒ์›๊ฐ€์ž…/๋กœ๊ทธ์ธ ์ฒ˜๋ฆฌ)
@Transactional
public UserResponse kakaoLogin(String authCode) {
    // 1. ํ† ํฐ ๋ฐ›๊ธฐ (getKakaoToken)
    // 2. ์‚ฌ์šฉ์ž ์ •๋ณด ๋ฐ›๊ธฐ (getKakaoUserInfo)
    // 3. ์นด์นด์˜ค ID๋กœ DB ์กฐํšŒ (์ž๋™ ํšŒ์›๊ฐ€์ž… ๋˜๋Š” ๋กœ๊ทธ์ธ)
    // 4. JWT ๋ฐœ๊ธ‰ ๋ฐ UserResponse ๋ฐ˜ํ™˜
}

2. UserController.java (์—๋Ÿฌ ํ•ธ๋“ค๋ง)

์ธ์ฆ ์ฝ”๋“œ ๋ˆ„๋ฝ ์‹œ ์‚ฌ์šฉ์ž ์ •์˜ ์˜ˆ์™ธ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๊น”๋”ํ•˜๊ฒŒ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.

Java
 
// UserController.java
@PostMapping("/auth/kakao/login")
public SingleResult<UserResponse> kakaoLogin(@RequestBody Map<String, String> request) {
    String code = request.get("code");
    
    // ๐Ÿ’ก ์ธ์ฆ ์ฝ”๋“œ ๋ˆ„๋ฝ ์‹œ ์‚ฌ์šฉ์ž ์ •์˜ ์˜ˆ์™ธ throw
    if (code == null || code.isEmpty()) {
        throw new KakaoCodeNullException("์นด์นด์˜ค ์ธ์ฆ ์ฝ”๋“œ๊ฐ€ ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.");
    }
    
    return responseService.handleSingleResult(userService.kakaoLogin(code));
}

๋กœ๊ทธ์ธํŽ˜์ด์ง€
๋กœ๊ทธ์•„์›ƒ ์‹œ, sso ์ข…๋ฃŒ์„ค์ •

๐ŸŒ 4๋‹จ๊ณ„: ๋ฐฑ์—”๋“œ (Spring Boot) ๊ตฌํ˜„ ์ƒ์„ธ

๋ฐฑ์—”๋“œ์—์„œ๋Š” ํ”„๋ก ํŠธ์—”๋“œ์—์„œ ์ „๋‹ฌ๋ฐ›์€ ์ธ์ฆ ์ฝ”๋“œ(code)๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์นด์นด์˜ค ์„œ๋ฒ„์™€ ํ†ต์‹ ํ•˜๊ณ , ์ตœ์ข…์ ์œผ๋กœ ์ž์ฒด ์„œ๋น„์Šค์˜ JWT ํ† ํฐ์„ ๋ฐœ๊ธ‰ํ•˜๋Š” ์—ญํ• ์„ ํ•ฉ๋‹ˆ๋‹ค.

1. UserController.java (API ์—”๋“œํฌ์ธํŠธ)

/auth/kakao/login ์—”๋“œํฌ์ธํŠธ๋Š” ํ”„๋ก ํŠธ์—”๋“œ๋กœ๋ถ€ํ„ฐ code๋ฅผ JSON ํ˜•ํƒœ๋กœ ๋ฐ›์•„ UserService๋กœ ์ „๋‹ฌํ•˜๋Š” ๊ฒŒ์ดํŠธ์›จ์ด ์—ญํ• ์„ ํ•ฉ๋‹ˆ๋‹ค.

Java
 
// src/main/java/.../controller/UserController.java

@RestController
@RequiredArgsConstructor
@Slf4j
public class UserController {
    // ... (์˜์กด์„ฑ ์ฃผ์ž…)

    /** โœ… ์นด์นด์˜ค ๋กœ๊ทธ์ธ */
    @PostMapping("/auth/kakao/login")
    public SingleResult<UserResponse> kakaoLogin(@RequestBody Map<String, String> request) {
        String code = request.get("code");
        
        // ๐Ÿ’ก ์ธ์ฆ ์ฝ”๋“œ ๋ˆ„๋ฝ ๊ฒ€์ฆ: ์‚ฌ์šฉ์ž ์ •์˜ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ
        if (code == null || code.isEmpty()) {
            throw new KakaoCodeNullException("์นด์นด์˜ค ์ธ์ฆ ์ฝ”๋“œ๊ฐ€ ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.");
        }
        
        // Service Layer๋กœ ์ฝ”๋“œ ์ „๋‹ฌ ๋ฐ ๋กœ๊ทธ์ธ ์ฒ˜๋ฆฌ ์œ„์ž„
        return responseService.handleSingleResult(userService.kakaoLogin(code));
    }
    
    // ... (๊ธฐ์กด API ๋ฉ”์„œ๋“œ)
}

2. UserService.java (ํ•ต์‹ฌ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง)

UserService๋Š” ์นด์นด์˜ค API ํ†ต์‹ ์„ ๋‹ด๋‹นํ•˜๋Š” ํ—ฌํผ ๋ฉ”์„œ๋“œ๋ฅผ ํ†ตํ•ด Access Token๊ณผ ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ํš๋“ํ•˜๊ณ , ์ด ์ •๋ณด๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ DB ์—ฐ๋™(ํšŒ์›๊ฐ€์ž…/๋กœ๊ทธ์ธ)์„ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.

A. kakaoLogin (ํšŒ์› ์—ฐ๋™ ๋ฐ JWT ๋ฐœ๊ธ‰)

์นด์นด์˜ค์˜ ๊ณ ์œ  ID๋ฅผ ์„œ๋น„์Šค์˜ userId๋กœ ์‚ฌ์šฉํ•˜์—ฌ ํšŒ์›๊ฐ€์ž…/๋กœ๊ทธ์ธ ์—ฌ๋ถ€๋ฅผ ๊ฒฐ์ •ํ•ฉ๋‹ˆ๋‹ค.

 
// src/main/java/.../service/UserService.java

@Transactional
public UserResponse kakaoLogin(String authCode) {
    // 1. Authorization Code๋กœ Access Token ์š”์ฒญ ๋ฐ ํš๋“
    KakaoTokenResponse tokenResponse = getKakaoToken(authCode);

    // 2. Access Token์œผ๋กœ ์‚ฌ์šฉ์ž ์ •๋ณด ์š”์ฒญ (์นด์นด์˜ค ๊ณ ์œ  ID, ๋‹‰๋„ค์ž„, ์ด๋ฉ”์ผ ๋“ฑ)
    KakaoUserInfo userInfo = getKakaoUserInfo(tokenResponse.getAccessToken());

    // 3. ์„œ๋น„์Šค DB ์—ฐ๋™ (์นด์นด์˜ค ID๋ฅผ ์„œ๋น„์Šค userId๋กœ ์‚ฌ์šฉ)
    String kakaoUniqueId = String.valueOf(userInfo.getId());
    
    User user = authUserRepository.findByUserId(kakaoUniqueId)
            .orElseGet(() -> {
                // 3-1. DB์— ํ•ด๋‹น ID๊ฐ€ ์—†์œผ๋ฉด, ์ž๋™ ํšŒ์›๊ฐ€์ž… (์ž„์‹œ ๋น„๋ฐ€๋ฒˆํ˜ธ ์„ค์ •)
                String tempPassword = passwordEncoder.encode(UUID.randomUUID().toString()); 
                
                User newUser = User.builder()
                        .userId(kakaoUniqueId) 
                        .username(userInfo.getNickname())
                        // ์ด๋ฉ”์ผ ๋ฏธ์ œ๊ณต ์‹œ ์ž„์‹œ ์ด๋ฉ”์ผ ์‚ฌ์šฉ
                        .email(userInfo.getKakaoAccount().getEmail() != null ? userInfo.getKakaoAccount().getEmail() : kakaoUniqueId + "@kakao.temp.com")
                        .password(tempPassword) // ๐Ÿ’ก ์ง์ ‘ ๋กœ๊ทธ์ธ์„ ๋ง‰๊ธฐ ์œ„ํ•ด ์ž„์‹œ ๋น„๋ฐ€๋ฒˆํ˜ธ ์•”ํ˜ธํ™” ์ €์žฅ
                        .role(Role.USER)
                        .useYn("Y")
                        .build();
                return authUserRepository.save(newUser);
            });

    // 4. ์„œ๋น„์Šค JWT ํ† ํฐ ๋ฐœ๊ธ‰
    String serviceToken = jwtUtil.generateToken(user.getUserId());

    // 5. UserResponse DTO๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ ํ”„๋ก ํŠธ์—”๋“œ์— ์‘๋‹ต
    return new UserResponse(user.getId(), user.getUserId(), user.getEmail(), user.getUsername(), serviceToken);
}

B. getKakaoToken (Access Token ์š”์ฒญ)

RestTemplate์„ ์‚ฌ์šฉํ•˜์—ฌ application/x-www-form-urlencoded ํ˜•์‹์œผ๋กœ ์นด์นด์˜ค ํ† ํฐ ์„œ๋ฒ„์— POST ์š”์ฒญ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค.


 
// UserService.java (ํ—ฌํผ ๋ฉ”์„œ๋“œ)

private KakaoTokenResponse getKakaoToken(String authCode) {
    RestTemplate restTemplate = new RestTemplate();
    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

    MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
    params.add("grant_type", "authorization_code");
    params.add("client_id", kakaoRestApiKey);
    params.add("redirect_uri", kakaoRedirectUri);
    params.add("code", authCode); // ํ”„๋ก ํŠธ์—์„œ ๋ฐ›์€ ์ธ์ฆ ์ฝ”๋“œ

    HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);

    try {
        ResponseEntity<KakaoTokenResponse> response = restTemplate.exchange(
                kakaoTokenUri, HttpMethod.POST, request, KakaoTokenResponse.class
        );
        return response.getBody();

    } catch (HttpClientErrorException e) { 
        // ๐Ÿšจ 401 Unauthorized, 400 Invalid Grant ๋“ฑ ์˜ค๋ฅ˜ ๋ฐœ์ƒ ์‹œ
        log.error("์นด์นด์˜ค ํ† ํฐ ๋ฐœ๊ธ‰ ์‹คํŒจ (HTTP Error {}: {}): {}", e.getStatusCode(), e.getStatusText(), e.getResponseBodyAsString());
        throw new AccountInfoException("์นด์นด์˜ค ์„œ๋ฒ„ ํ†ต์‹  ์˜ค๋ฅ˜");
    }
}