OAuth2 로그인은 쉬워 보이는데 배포하면 꼭 한 번 이상 박살나는 기능임. POP-SPOT도 로컬에서 잘 됐다가 실제 도메인 붙이면서 완전히 다시 짜야 했음.
용어 설명 먼저
OAuth2 (소셜 로그인) — "구글/카카오/네이버 계정으로 로그인" 하는 기능. 직접 비밀번호를 받는 대신, 구글 같은 외부 서비스가 "이 사람 우리 회원 맞아요"를 대신 확인해줌.
JWT (토큰) — 로그인 성공 후 서버가 발급하는 일종의 출입증. 이 출입증을 가지고 있으면 "나 로그인된 사람이야"를 증명할 수 있음. 카드키 같은 것.
쿠키 — 브라우저가 정보를 저장하는 작은 공간. 서버가 "이걸 기억해둬" 하면 브라우저가 자동으로 들고 다님.
localStorage — 브라우저가 정보를 저장하는 또 다른 공간. 쿠키와 달리 서버 요청할 때 자동으로 붙지 않아서 코드로 직접 꺼내서 써야 함.
처음 구조: 쿠키 방식
처음엔 로그인 성공 후 JWT를 HttpOnly 쿠키에 담아서 전달하는 방식을 씀.
HttpOnly 쿠키 — JavaScript로 접근할 수 없는 쿠키. 해킹 시도로 쿠키를 훔쳐가는 것을 막기 위한 보안 설정. 서버만 읽을 수 있음.
// OAuth2SuccessHandler.java (초기 버전)
// 소셜 로그인 성공했을 때 실행되는 코드
@Override
public void onAuthenticationSuccess(...) {
String token = jwtProvider.generateToken(userId); // JWT 출입증 발급
Cookie cookie = new Cookie("accessToken", token);
cookie.setHttpOnly(true); // JavaScript에서 접근 못하게 막음 (보안)
cookie.setSecure(true); // HTTPS에서만 전송
cookie.setPath("/"); // 모든 경로에서 사용 가능
response.addCookie(cookie);
// 쿠키 심어놓고 프론트로 이동
getRedirectStrategy().sendRedirect(request, response, "https://popspot.co.kr");
}
문제 발생. 프론트(popspot.co.kr)랑 백엔드(api.popspot.co.kr)가 주소가 달라서 브라우저가 쿠키를 차단해버림.
cross-site 쿠키 차단 — 브라우저의 보안 정책. 서로 다른 도메인 간에 쿠키를 주고받지 못하게 막는 기능. popspot.co.kr이 api.popspot.co.kr의 쿠키를 받으려 하면 브라우저가 "다른 사이트 쿠키라 안 돼" 하고 거부함.
쿠키를 포기하고 로그인 성공 시 프론트엔드 URL에 토큰을 쿼리 파라미터로 달아서 리다이렉트하는 방식으로 바꿨어요.
마치 A아파트 출입증이 B아파트 경비원한테 거부당하는 것과 같음.
SameSite=None; Secure 설정을 해봤는데 일부 환경에서 여전히 차단됨. 결국 방향을 바꿔야 했음.
해결: URL에 토큰 직접 실어서 전달
쿠키를 포기하고, 로그인 성공 시 프론트엔드 주소에 토큰을 달아서 이동시키는 방식으로 변경함.
URL 쿼리 파라미터 — 주소창에 ?key=value 형태로 데이터를 담는 방법. 예: https://popspot.co.kr/callback?token=abc123 처럼 token 값을 주소에 직접 포함시킴.
// OAuth2SuccessHandler.java (변경 후)
@Override
public void onAuthenticationSuccess(...) {
String accessToken = jwtProvider.generateToken(userId); // JWT 발급
// 프론트 주소에 ?token=발급된토큰 을 달아서 이동
String targetUrl = UriComponentsBuilder
.fromUriString("https://popspot.co.kr/oauth/callback")
.queryParam("token", accessToken) // 주소창에 토큰 붙임
.build().toUriString();
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
프론트(Next.js)에서는 콜백 페이지에서 주소창의 토큰을 꺼내서 localStorage에 저장함.
// OAuthCallbackPage.tsx
const searchParams = useSearchParams();
useEffect(() => {
const token = searchParams.get("token"); // 주소창에서 token 값 꺼내기
if (token) {
localStorage.setItem("token", token); // 브라우저 저장소에 보관
window.location.href = "/"; // 메인 페이지로 이동 (강제 새로고침)
}
}, []);
router.push("/")가 아닌 window.location.href를 쓰는 이유가 있음.
Next.js의 router.push()는 페이지를 새로고침하지 않고 이동함. 그래서 헤더 같은 컴포넌트가 "아, localStorage에 토큰이 생겼구나"를 바로 눈치채지 못함. window.location.href는 페이지를 완전히 새로 불러오기 때문에 토큰이 바로 적용됨.
JWT 필터 — 모든 요청에서 출입증 확인
로그인 후 API를 호출할 때마다 "이 사람 출입증 있어?" 를 확인하는 코드.
Authorization 헤더 — HTTP 요청에 붙이는 인증 정보. Authorization: Bearer 토큰값 형태로 보냄. 마치 건물 입장할 때 카드키를 태그하는 것처럼, API 요청마다 토큰을 같이 보내는 것.
// JwtAuthenticationFilter.java
// 모든 API 요청이 들어올 때마다 실행됨
@Override
protected void doFilterInternal(HttpServletRequest request, ...) {
// 요청 헤더에서 토큰 꺼내기
String bearerToken = request.getHeader("Authorization");
// "Bearer " 로 시작하면 그 뒤가 실제 토큰
// 예: "Bearer abc123xyz" → "abc123xyz" 만 추출
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
String token = bearerToken.substring(7); // "Bearer " 7글자 제거
try {
// 토큰 해독해서 사용자 정보 꺼내기
Claims claims = Jwts.parserBuilder()
.setSigningKey(secretKey) // 비밀 키로 토큰 검증
.build()
.parseClaimsJws(token)
.getBody();
String userId = claims.getSubject(); // 사용자 ID
String role = claims.get("role", String.class); // 권한 (USER or ADMIN)
// Spring Security는 권한 앞에 "ROLE_" 이 붙어야 인식함
// DB에 "ADMIN" 으로 저장돼있으면 "ROLE_ADMIN" 으로 변환
if (!role.startsWith("ROLE_")) {
role = "ROLE_" + role;
}
// "이 사람은 인증된 사용자야" 라고 Spring Security에 등록
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(userId, null,
List.of(new SimpleGrantedAuthority(role)));
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (Exception e) {
// 토큰이 만료됐거나 위조된 경우 → 그냥 비로그인 상태로 처리
log.warn("JWT 파싱 실패: {}", e.getMessage());
}
}
filterChain.doFilter(request, response); // 다음 단계로 넘김
}
Security 권한 순서
Spring Security에서 권한 설정은 순서가 중요함. 구체적인 규칙을 위에, 넓은 규칙을 아래에 배치해야 함.
순서를 틀리면 생기는 문제: "관리자만 접근 가능" 규칙보다 "모두 접근 가능" 규칙이 먼저 나오면, 관리자 페이지가 누구에게나 열려버림. 법원에서 "모든 사람은 무죄" 규칙을 "살인자는 유죄" 규칙보다 먼저 적용하면 안 되는 것과 같음.
.authorizeHttpRequests(auth -> auth
// 1순위: 사전 확인 요청(OPTIONS)은 무조건 통과
.requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
// 2순위: 관리자 전용 주소 (구체적인 규칙 먼저)
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/actuator/**").hasRole("ADMIN")
// 3순위: 누구나 접근 가능한 공개 주소
.requestMatchers("/", "/api/**", "/login/**", "/oauth2/**").permitAll()
// 4순위: 나머지는 로그인 필요 (가장 넓은 규칙은 마지막)
.anyRequest().authenticated()
)
actuator — 서버 상태를 모니터링하는 Spring Boot 내장 기능. 서버 메모리, 실행 중인 빈(Bean) 목록 등 민감한 정보가 노출될 수 있어서 관리자만 접근하게 막아야 함.
네이버 로그인 "서비스 설정 오류"
네이버 로그인 추가하다가 "서비스 설정에 오류가 있어 로그인할 수 없습니다" 에러를 봤는데 원인은 단순했음.
네이버 개발자 센터에 등록한 Callback URL이랑 application.yml에 쓴 주소가 한 글자라도 다르면 안 됨.
네이버 콘솔: http://localhost:8080/login/oauth2/code/naver
application.yml: http://localhost:8080/login/oauth2/code/naver
↑ 이 두 줄이 완전히 똑같아야 함
슬래시 하나, 포트 번호 하나, 대소문자 하나라도 다르면 네이버가 "모르는 주소네" 하고 차단함.
Callback URL (리다이렉트 URI) — 소셜 로그인 성공 후 "이 주소로 돌아와" 라고 지정하는 주소. 구글/카카오/네이버는 미리 등록된 주소로만 사용자를 돌려보냄. 등록 안 된 주소로 돌려보내면 해킹 시도로 간주해서 차단함.