[Vite/React + Spring Boot] ์นด์นด์ค ์์ ๋ก๊ทธ์ธ ์๋ฒฝ ์ฐ๋ ๊ฐ์ด๋
๐ 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)
๋ฐฑ์๋๋ก ์ธ์ฆ ์ฝ๋๋ฅผ ์ ์กํ๋ ํจ์๋ฅผ ์ ์ํฉ๋๋ค.
// 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๋ก ๋ฆฌ๋ค์ด๋ ํธํฉ๋๋ค.
// 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 ํฌํผ ๋ฉ์๋์ ์๋ฌ ์์ธ ๋ก๊ทธ๋ฅผ ์ถ๊ฐํ์ฌ ๋๋ฒ๊น ์ ์ฉ์ดํ๊ฒ ํฉ๋๋ค.
// 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 (์๋ฌ ํธ๋ค๋ง)
์ธ์ฆ ์ฝ๋ ๋๋ฝ ์ ์ฌ์ฉ์ ์ ์ ์์ธ๋ฅผ ์ฌ์ฉํ์ฌ ๊น๋ํ๊ฒ ์ฒ๋ฆฌํฉ๋๋ค.
// 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));
}


๐ 4๋จ๊ณ: ๋ฐฑ์๋ (Spring Boot) ๊ตฌํ ์์ธ
๋ฐฑ์๋์์๋ ํ๋ก ํธ์๋์์ ์ ๋ฌ๋ฐ์ ์ธ์ฆ ์ฝ๋(code)๋ฅผ ์ฌ์ฉํ์ฌ ์นด์นด์ค ์๋ฒ์ ํต์ ํ๊ณ , ์ต์ข ์ ์ผ๋ก ์์ฒด ์๋น์ค์ JWT ํ ํฐ์ ๋ฐ๊ธํ๋ ์ญํ ์ ํฉ๋๋ค.
1. UserController.java (API ์๋ํฌ์ธํธ)
/auth/kakao/login ์๋ํฌ์ธํธ๋ ํ๋ก ํธ์๋๋ก๋ถํฐ code๋ฅผ JSON ํํ๋ก ๋ฐ์ UserService๋ก ์ ๋ฌํ๋ ๊ฒ์ดํธ์จ์ด ์ญํ ์ ํฉ๋๋ค.
// 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("์นด์นด์ค ์๋ฒ ํต์ ์ค๋ฅ");
}
}