Next.js + Spring Boot OAuth 구현

시나리오

프론트엔드(Next.js)에서 Authorization Code까지 획득하고, 서버(Spring Boot)에서 PKCE 검증 및 Token 교환, 사용자 데이터 조회 후 자체 JWT 발급하는 완전한 OAuth 구현 예제입니다.

이 방식은 PKCE를 사용하여 Authorization Code 탈취를 방지하면서 서버 측에서 사용자 인증을 완전히 제어할 수 있는 안전한 구현 방식입니다.

아키텍처 개요

시스템 구성도

Loading diagram...

데이터 플로우

전체 인증 프로세스는 다음 순서로 진행됩니다.

Loading diagram...

책임 분리

각 레이어의 명확한 책임 분리로 보안과 유지보수성을 확보합니다.

프론트엔드 (Next.js)

책임설명
UI 렌더링로그인 페이지, 로딩 상태, 에러 처리 UI
PKCE code_verifier 생성43-128자 랜덤 문자열 생성 및 저장
PKCE code_challenge 생성code_verifier를 SHA-256 해싱
리다이렉트 처리DataGSM 로그인 페이지로 이동 및 콜백 받기
Authorization Code 획득URL 파라미터에서 code 추출
백엔드 API 호출Code와 code_verifier를 서버로 전달하고 JWT 받기
토큰 관리JWT를 sessionStorage에 저장 및 관리
인증 상태 관리로그인 여부에 따른 UI 분기 처리

백엔드 (Spring Boot)

책임설명
PKCE 검증code_verifier를 OAuth 서버로 전달하여 검증
토큰 교환Authorization Code → Access Token 변환
사용자 데이터 조회DataGSM API에서 사용자 데이터 획득
DB 동기화사용자 데이터를 자체 DB에 저장/업데이트
자체 JWT 발급애플리케이션 인증용 JWT 생성
비즈니스 로직사용자 권한 관리, 리소스 접근 제어

보안 고려사항

이 아키텍처에서 구현된 주요 보안 요소입니다.

보안 요소구현 위치설명
PKCE 사용Next.js & Spring Bootcode_verifier와 code_challenge로 Authorization Code 탈취 방지
HTTPS 강제전체 시스템프로덕션에서 모든 통신은 HTTPS 사용
State 파라미터Next.jsCSRF 공격 방지를 위한 랜덤 토큰 생성 및 검증
code_verifier 보호Next.jssessionStorage에 안전하게 저장 (일회성 사용)
토큰 분리양쪽DataGSM Access Token은 서버만, 자체 JWT는 프론트엔드에서 관리
HttpOnly CookieSpring BootRefresh Token을 XSS로부터 보호
JWT 검증Spring Boot모든 API 요청에서 JWT 서명 및 만료 검증

Step 1: 프론트엔드 (Next.js)

로그인 페이지

사용자가 로그인 버튼을 클릭하면 DataGSM OAuth 서버로 리다이렉트합니다.

import { useRouter } from 'next/router';
import { useState } from 'react';

// PKCE 유틸리티 함수
function generateCodeVerifier(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}

async function generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(new Uint8Array(hash));
}

function base64UrlEncode(array: Uint8Array): string {
return btoa(String.fromCharCode(...array))
  .replace(/\+/g, '-')
  .replace(/\//g, '_')
  .replace(/=/g, '');
}

const LoginPage = () => {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);

const handleLogin = async () => {
  setIsLoading(true);

  // State 파라미터 생성 (CSRF 방지)
  const state = crypto.randomUUID();
  sessionStorage.setItem('oauth_state', state);

  // PKCE code_verifier 생성 및 저장
  const codeVerifier = generateCodeVerifier();
  sessionStorage.setItem('code_verifier', codeVerifier);

  // PKCE code_challenge 생성
  const codeChallenge = await generateCodeChallenge(codeVerifier);

  // 환경 변수에서 설정 가져오기
  const clientId = process.env.NEXT_PUBLIC_DATAGSM_CLIENT_ID;
  const redirectUri = process.env.NEXT_PUBLIC_REDIRECT_URI;

  // DataGSM OAuth 인가 엔드포인트로 리다이렉트
  const authUrl = new URL('https://oauth.data.hellogsm.kr/v1/oauth/authorize');
  authUrl.searchParams.append('client_id', clientId);
  authUrl.searchParams.append('redirect_uri', redirectUri);
  authUrl.searchParams.append('state', state);
  authUrl.searchParams.append('code_challenge', codeChallenge);
  authUrl.searchParams.append('code_challenge_method', 'S256');

  window.location.href = authUrl.toString();
};

return (
  <div className="min-h-screen flex items-center justify-center bg-gray-50">
    <div className="max-w-md w-full space-y-8 p-8 bg-white rounded-lg shadow-md">
      <div>
        <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
          로그인
        </h2>
        <p className="mt-2 text-center text-sm text-gray-600">
          DataGSM 계정으로 로그인하세요
        </p>
      </div>
      <div>
        <button
          onClick={handleLogin}
          disabled={isLoading}
          className="w-full flex justify-center py-3 px-4 border border-transparent
                   rounded-md shadow-sm text-sm font-medium text-white
                   bg-blue-600 hover:bg-blue-700 focus:outline-none
                   focus:ring-2 focus:ring-offset-2 focus:ring-blue-500
                   disabled:bg-gray-400 disabled:cursor-not-allowed"
        >
          {isLoading ? (
            <span className="flex items-center">
              <svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
                   xmlns="http://www.w3.org/2000/svg" fill="none"
                   viewBox="0 0 24 24">
                <circle className="opacity-25" cx="12" cy="12" r="10"
                        stroke="currentColor" strokeWidth="4"></circle>
                <path className="opacity-75" fill="currentColor"
                      d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
                </path>
              </svg>
              처리 중...
            </span>
          ) : (
            'DataGSM으로 로그인'
          )}
        </button>
      </div>
    </div>
  </div>
);
};

export default LoginPage;

OAuth 콜백 페이지

DataGSM 인증 후 리다이렉트되는 페이지에서 Authorization Code를 추출하고 백엔드로 전달합니다.

import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';

const CallbackPage = () => {
const router = useRouter();
const [error, setError] = useState<string | null>(null);

useEffect(() => {
  const handleCallback = async () => {
    try {
      // URL에서 code와 state 추출
      const { code, state } = router.query;

      if (!code || typeof code !== 'string') {
        throw new Error('Authorization code가 없습니다.');
      }

      // State 검증 (CSRF 방지)
      const savedState = sessionStorage.getItem('oauth_state');
      if (state !== savedState) {
        throw new Error('State 파라미터가 일치하지 않습니다.');
      }

      // code_verifier 가져오기
      const codeVerifier = sessionStorage.getItem('code_verifier');
      if (!codeVerifier) {
        throw new Error('code_verifier가 없습니다.');
      }

      // State와 code_verifier 제거 (일회용)
      sessionStorage.removeItem('oauth_state');
      sessionStorage.removeItem('code_verifier');

      // 백엔드에 code와 code_verifier 전달하여 JWT 받기
      const response = await fetch('/api/auth/callback', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ code, codeVerifier }),
      });

      if (!response.ok) {
        const errorData = await response.json();
        throw new Error(errorData.message || '로그인에 실패했습니다.');
      }

      const { token, user } = await response.json();

      // JWT를 sessionStorage에 저장
      sessionStorage.setItem('auth_token', token);
      sessionStorage.setItem('user', JSON.stringify(user));

      // 메인 페이지로 이동
      router.push('/');
    } catch (err) {
      console.error('OAuth callback error:', err);
      setError(err instanceof Error ? err.message : '알 수 없는 오류가 발생했습니다.');

      // 3초 후 로그인 페이지로 이동
      setTimeout(() => {
        router.push('/login');
      }, 3000);
    }
  };

  if (router.isReady) {
    handleCallback();
  }
}, [router.isReady, router.query]);

if (error) {
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="max-w-md w-full p-8 bg-white rounded-lg shadow-md">
        <div className="text-center">
          <div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
            <svg className="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
            </svg>
          </div>
          <h3 className="mt-4 text-lg font-medium text-gray-900">로그인 실패</h3>
          <p className="mt-2 text-sm text-gray-500">{error}</p>
          <p className="mt-2 text-xs text-gray-400">잠시 후 로그인 페이지로 이동합니다...</p>
        </div>
      </div>
    </div>
  );
}

return (
  <div className="min-h-screen flex items-center justify-center bg-gray-50">
    <div className="max-w-md w-full p-8 bg-white rounded-lg shadow-md">
      <div className="text-center">
        <svg className="animate-spin mx-auto h-12 w-12 text-blue-600"
             xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
          <circle className="opacity-25" cx="12" cy="12" r="10"
                  stroke="currentColor" strokeWidth="4"></circle>
          <path className="opacity-75" fill="currentColor"
                d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
          </path>
        </svg>
        <h3 className="mt-4 text-lg font-medium text-gray-900">로그인 처리 중</h3>
        <p className="mt-2 text-sm text-gray-500">잠시만 기다려 주세요...</p>
      </div>
    </div>
  </div>
);
};

export default CallbackPage;

API 라우트 핸들러

Next.js API Routes를 사용하여 Spring Boot 백엔드와 통신합니다.

import type { NextApiRequest, NextApiResponse } from 'next';

interface CallbackRequestBody {
code: string;
codeVerifier: string;
}

interface BackendResponse {
token: string;
user: {
  id: number;
  email: string;
  name: string;
  grade: number;
  classNum: number;
  number: number;
};
}

export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
  return res.status(405).json({ message: 'Method not allowed' });
}

try {
  const { code, codeVerifier } = req.body as CallbackRequestBody;

  if (!code) {
    return res.status(400).json({ message: 'Authorization code is required' });
  }

  if (!codeVerifier) {
    return res.status(400).json({ message: 'code_verifier is required' });
  }

  // Spring Boot 백엔드 API 호출 (code + codeVerifier 함께 전달)
  const backendUrl = process.env.BACKEND_URL || 'http://localhost:8080';
  const response = await fetch(backendUrl + '/api/auth/oauth/callback', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ code, codeVerifier }),
  });

  if (!response.ok) {
    const errorData = await response.json();
    return res.status(response.status).json({
      message: errorData.message || '백엔드 인증 처리에 실패했습니다.',
    });
  }

  const data: BackendResponse = await response.json();

  return res.status(200).json(data);
} catch (error) {
  console.error('OAuth callback error:', error);
  return res.status(500).json({
    message: '서버 오류가 발생했습니다.',
  });
}
}

Step 2: 백엔드 (Spring Boot - Java)

프로젝트 구조

Loading diagram...

AuthController

OAuth 인증 플로우를 처리하는 컨트롤러입니다.

package com.example.app.controller;

import com.example.app.dto.OAuthCallbackRequest;
import com.example.app.dto.AuthResponse;
import com.example.app.service.OAuthService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

  private final OAuthService oAuthService;

  /**
   * OAuth 콜백 처리 및 JWT 발급
   *
   * @param request Authorization Code와 code_verifier를 포함한 요청
   * @return JWT 토큰 및 사용자 데이터
   */
  @PostMapping("/oauth/callback")
  public ResponseEntity<AuthResponse> handleOAuthCallback(
          @RequestBody OAuthCallbackRequest request) {

      try {
          // Authorization Code와 code_verifier로 토큰 교환 및 사용자 데이터 조회
          AuthResponse response = oAuthService.processOAuthCallback(
              request.getCode(),
              request.getCodeVerifier()
          );

          return ResponseEntity.ok(response);
      } catch (IllegalArgumentException e) {
          return ResponseEntity.badRequest().build();
      } catch (Exception e) {
          return ResponseEntity.internalServerError().build();
      }
  }

  /**
   * 현재 로그인한 사용자 데이터 조회
   *
   * @param authHeader Authorization 헤더 (Bearer 토큰)
   * @return 사용자 데이터
   */
  @GetMapping("/me")
  public ResponseEntity<?> getCurrentUser(
          @RequestHeader("Authorization") String authHeader) {

      try {
          if (!authHeader.startsWith("Bearer ")) {
              return ResponseEntity.status(401).body("Invalid token format");
          }

          String token = authHeader.substring(7);
          var user = oAuthService.getUserFromToken(token);

          return ResponseEntity.ok(user);
      } catch (Exception e) {
          return ResponseEntity.status(401).body("Invalid or expired token");
      }
  }

  /**
   * 로그아웃
   *
   * @param authHeader Authorization 헤더
   * @return 성공 메시지
   */
  @PostMapping("/logout")
  public ResponseEntity<?> logout(
          @RequestHeader("Authorization") String authHeader) {

      try {
          String token = authHeader.substring(7);
          oAuthService.invalidateToken(token);

          return ResponseEntity.ok().body("Logged out successfully");
      } catch (Exception e) {
          return ResponseEntity.badRequest().build();
      }
  }
}

OAuthService

OAuth 토큰 교환 및 사용자 데이터 조회를 담당하는 핵심 서비스입니다.

package com.example.app.service;

import com.example.app.dto.*;
import com.example.app.entity.User;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;

@Slf4j
@Service
@RequiredArgsConstructor
public class OAuthService {

  private final UserService userService;
  private final JwtService jwtService;
  private final HttpClient httpClient = HttpClient.newHttpClient();
  private final ObjectMapper objectMapper = new ObjectMapper();

  @Value("${datagsm.oauth.token-url:https://oauth.data.hellogsm.kr/v1/oauth/token}")
  private String tokenUrl;

  @Value("${datagsm.oauth.userinfo-url:https://oauth-userinfo.data.hellogsm.kr/userinfo}")
  private String userInfoUrl;

  @Value("${datagsm.oauth.client-id}")
  private String clientId;

  @Value("${datagsm.oauth.redirect-uri}")
  private String redirectUri;

  /**
   * OAuth 콜백 처리: Code → Token → UserInfo → JWT 발급
   */
  @Transactional
  public AuthResponse processOAuthCallback(String code, String codeVerifier) throws Exception {
      // 1. Authorization Code와 code_verifier로 Access Token 교환
      TokenResponse tokenResponse = exchangeCodeForToken(code, codeVerifier);

      // 2. Access Token으로 사용자 데이터 조회
      DataGsmUserInfo userInfo = fetchUserInfo(tokenResponse.getAccessToken());

      // 3. DB에 사용자 데이터 저장/업데이트
      User user = userService.syncUser(userInfo);

      // 4. 자체 JWT 발급
      String jwt = jwtService.generateToken(user);

      // 5. 응답 생성
      return AuthResponse.builder()
              .token(jwt)
              .user(UserInfoResponse.from(user))
              .build();
  }

  /**
   * Authorization Code를 Access Token으로 교환 (PKCE 사용)
   */
  private TokenResponse exchangeCodeForToken(String code, String codeVerifier) throws Exception {
      // 요청 본문 생성 (PKCE: code_verifier 포함)
      Map<String, String> requestBody = Map.of(
          "grant_type", "authorization_code",
          "code", code,
          "client_id", clientId,
          "redirect_uri", redirectUri,
          "code_verifier", codeVerifier
      );

      String requestBodyJson = objectMapper.writeValueAsString(requestBody);

      // HTTP 요청 생성
      HttpRequest request = HttpRequest.newBuilder()
              .uri(URI.create(tokenUrl))
              .header("Content-Type", "application/json")
              .POST(HttpRequest.BodyPublishers.ofString(requestBodyJson))
              .build();

      // 요청 전송
      HttpResponse<String> response = httpClient.send(
              request,
              HttpResponse.BodyHandlers.ofString()
      );

      if (response.statusCode() != 200) {
          log.error("Failed to exchange token: {}", response.body());
          throw new RuntimeException("Token exchange failed: " + response.body());
      }

      // 응답 파싱
      return objectMapper.readValue(response.body(), TokenResponse.class);
  }

  /**
   * Access Token으로 DataGSM 사용자 데이터 조회
   */
  private DataGsmUserInfo fetchUserInfo(String accessToken) throws Exception {
      HttpRequest request = HttpRequest.newBuilder()
              .uri(URI.create(userInfoUrl))
              .header("Authorization", "Bearer " + accessToken)
              .GET()
              .build();

      HttpResponse<String> response = httpClient.send(
              request,
              HttpResponse.BodyHandlers.ofString()
      );

      if (response.statusCode() != 200) {
          log.error("Failed to fetch user info: {}", response.body());
          throw new RuntimeException("Failed to fetch user info: " + response.body());
      }

      return objectMapper.readValue(response.body(), DataGsmUserInfo.class);
  }

  /**
   * JWT에서 사용자 데이터 추출
   */
  public UserInfoResponse getUserFromToken(String token) {
      Long userId = jwtService.extractUserId(token);
      User user = userService.findById(userId);
      return UserInfoResponse.from(user);
  }

  /**
   * 토큰 무효화 (로그아웃)
   */
  public void invalidateToken(String token) {
      // JWT는 stateless하므로 블랙리스트에 추가하거나
      // Redis 같은 캐시에 만료 처리할 수 있습니다.
      // 여기서는 간단히 로그만 남깁니다.
      log.info("Token invalidated");
  }
}

UserService

사용자 데이터를 DB에 동기화하고 관리하는 서비스입니다.

package com.example.app.service;

import com.example.app.dto.DataGsmUserInfo;
import com.example.app.entity.User;
import com.example.app.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;

@Slf4j
@Service
@RequiredArgsConstructor
public class UserService {

  private final UserRepository userRepository;

  /**
   * DataGSM 사용자 데이터를 DB에 동기화
   *
   * - 신규 사용자: 생성
   * - 기존 사용자: 데이터 업데이트
   */
  @Transactional
  public User syncUser(DataGsmUserInfo userInfo) {
      return userRepository.findByEmail(userInfo.getEmail())
              .map(existingUser -> updateUser(existingUser, userInfo))
              .orElseGet(() -> createUser(userInfo));
  }

  /**
   * 신규 사용자 생성
   */
  private User createUser(DataGsmUserInfo userInfo) {
      DataGsmUserInfo.StudentInfo s = userInfo.getStudent();
      User user = User.builder()
              .email(userInfo.getEmail())
              .name(s != null ? s.getName() : "")
              .grade(s != null ? s.getGrade() : 0)
              .classNum(s != null ? s.getClassNum() : 0)
              .number(s != null ? s.getNumber() : 0)
              .createdAt(LocalDateTime.now())
              .updatedAt(LocalDateTime.now())
              .build();

      User saved = userRepository.save(user);
      log.info("Created new user: {}", saved.getEmail());
      return saved;
  }

  /**
   * 기존 사용자 데이터 업데이트
   */
  private User updateUser(User user, DataGsmUserInfo userInfo) {
      DataGsmUserInfo.StudentInfo s = userInfo.getStudent();
      if (s != null) {
          user.setName(s.getName());
          user.setGrade(s.getGrade());
          user.setClassNum(s.getClassNum());
          user.setNumber(s.getNumber());
      }
      user.setUpdatedAt(LocalDateTime.now());

      User updated = userRepository.save(user);
      log.info("Updated user: {}", updated.getEmail());
      return updated;
  }

  /**
   * ID로 사용자 조회
   */
  public User findById(Long id) {
      return userRepository.findById(id)
              .orElseThrow(() -> new RuntimeException("User not found: " + id));
  }

  /**
   * 이메일로 사용자 조회
   */
  public User findByEmail(String email) {
      return userRepository.findByEmail(email)
              .orElseThrow(() -> new RuntimeException("User not found: " + email));
  }
}

JwtService

자체 JWT 토큰을 발급하고 검증하는 서비스입니다.

package com.example.app.service;

import com.example.app.entity.User;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import javax.crypto.SecretKey;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

@Slf4j
@Service
public class JwtService {

  @Value("${jwt.secret}")
  private String secretKey;

  @Value("${jwt.expiration:3600000}") // 기본 1시간
  private Long expirationTime;

  private Key getSigningKey() {
      return Keys.hmacShaKeyFor(secretKey.getBytes());
  }

  /**
   * JWT 토큰 생성
   */
  public String generateToken(User user) {
      Map<String, Object> claims = new HashMap<>();
      claims.put("userId", user.getId());
      claims.put("email", user.getEmail());
      claims.put("name", user.getName());
      claims.put("grade", user.getGrade());
      claims.put("classNum", user.getClassNum());
      claims.put("number", user.getNumber());

      return Jwts.builder()
              .claims(claims)
              .subject(user.getEmail())
              .issuedAt(new Date())
              .expiration(new Date(System.currentTimeMillis() + expirationTime))
              .signWith(getSigningKey(), Jwts.SIG.HS256)
              .compact();
  }

  /**
   * JWT에서 사용자 ID 추출
   */
  public Long extractUserId(String token) {
      Claims claims = extractAllClaims(token);
      return claims.get("userId", Long.class);
  }

  /**
   * JWT에서 이메일 추출
   */
  public String extractEmail(String token) {
      return extractAllClaims(token).getSubject();
  }

  /**
   * JWT 유효성 검증
   */
  public boolean validateToken(String token) {
      try {
          extractAllClaims(token);
          return !isTokenExpired(token);
      } catch (Exception e) {
          log.error("Invalid JWT token: {}", e.getMessage());
          return false;
      }
  }

  /**
   * JWT에서 모든 클레임 추출
   */
  private Claims extractAllClaims(String token) {
      return Jwts.parser()
              .verifyWith((SecretKey) getSigningKey())
              .build()
              .parseSignedClaims(token)
              .getPayload();
  }

  /**
   * JWT 만료 여부 확인
   */
  private boolean isTokenExpired(String token) {
      return extractAllClaims(token).getExpiration().before(new Date());
  }
}

DTO 클래스들

데이터 전송을 위한 DTO 클래스입니다.

package com.example.app.dto;

import com.example.app.entity.User;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.*;

// OAuth 콜백 요청
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class OAuthCallbackRequest {
  private String code;
  private String codeVerifier;
}

// DataGSM 토큰 응답
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class TokenResponse {
  private String accessToken;
  private String refreshToken;
  private Integer expiresIn;
}

// DataGSM 사용자 데이터 (중첩 구조)
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class DataGsmUserInfo {
  private Long id;
  private String email;
  private String role;
  private Boolean isStudent;
  private StudentInfo student;

  @Getter
  @Setter
  @NoArgsConstructor
  @AllArgsConstructor
  public static class StudentInfo {
      private Long id;
      private String name;
      private Integer grade;
      private Integer classNum;
      private Integer number;
      private String major;
  }
}

// 인증 응답 (JWT + 사용자 데이터)
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuthResponse {
  private String token;
  private UserInfoResponse user;
}

// 사용자 데이터 응답
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserInfoResponse {
  private Long id;
  private String email;
  private String name;
  private Integer grade;
  private Integer classNum;
  private Integer number;
  private String profileImage;

  public static UserInfoResponse from(User user) {
      return UserInfoResponse.builder()
              .id(user.getId())
              .email(user.getEmail())
              .name(user.getName())
              .grade(user.getGrade())
              .classNum(user.getClassNum())
              .number(user.getNumber())
              .profileImage(user.getProfileImage())
              .build();
  }
}

Entity & Repository

사용자 엔티티와 레포지토리입니다.

package com.example.app.entity;

import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDateTime;

@Entity
@Table(name = "users")
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @Column(nullable = false, unique = true)
  private String email;

  @Column(nullable = false)
  private String name;

  private Integer grade;

  private Integer classNum;

  private Integer number;

  private String profileImage;

  @Column(nullable = false, updatable = false)
  private LocalDateTime createdAt;

  @Column(nullable = false)
  private LocalDateTime updatedAt;

  @PrePersist
  protected void onCreate() {
      createdAt = LocalDateTime.now();
      updatedAt = LocalDateTime.now();
  }

  @PreUpdate
  protected void onUpdate() {
      updatedAt = LocalDateTime.now();
  }
}

Step 3: 환경 설정

Next.js 환경 변수

# DataGSM OAuth 설정
NEXT_PUBLIC_DATAGSM_CLIENT_ID=your-client-id-here
NEXT_PUBLIC_REDIRECT_URI=http://localhost:3000/callback

# 백엔드 API URL
BACKEND_URL=http://localhost:8080

Spring Boot 환경 변수

spring:
datasource:
  url: jdbc:mysql://localhost:3306/myapp
  username: root
  password: password
  driver-class-name: com.mysql.cj.jdbc.Driver

jpa:
  hibernate:
    ddl-auto: update
  show-sql: true
  properties:
    hibernate:
      format_sql: true

# DataGSM OAuth 설정
datagsm:
oauth:
  token-url: https://oauth.data.hellogsm.kr/v1/oauth/token
  userinfo-url: https://oauth-userinfo.data.hellogsm.kr/userinfo
  client-id: ${OAUTH_CLIENT_ID}
  redirect-uri: ${OAUTH_REDIRECT_URI:http://localhost:3000/callback}

# JWT 설정
jwt:
secret: ${JWT_SECRET:your-secret-key-minimum-256-bits-long}
expiration: 3600000  # 1시간 (밀리초)

# CORS 설정
cors:
allowed-origins: http://localhost:3000
allowed-methods: GET,POST,PUT,DELETE,OPTIONS
allowed-headers: "*"
allow-credentials: true

의존성 설정

{
"name": "nextjs-oauth-app",
"version": "1.0.0",
"scripts": {
  "dev": "next dev",
  "build": "next build",
  "start": "next start"
},
"dependencies": {
  "next": "^14.0.0",
  "react": "^18.2.0",
  "react-dom": "^18.2.0"
},
"devDependencies": {
  "@types/node": "^20.0.0",
  "@types/react": "^18.2.0",
  "typescript": "^5.0.0",
  "tailwindcss": "^3.3.0"
}
}

Step 4: 보안 고려사항

1. PKCE 사용

PKCE (Proof Key for Code Exchange)는 Authorization Code 탈취 공격을 방지하는 표준 방식입니다.

보안 요소설명
code_verifier 생성43-128자의 랜덤 문자열을 암호학적으로 안전한 방식으로 생성
code_challenge 생성code_verifier를 SHA-256으로 해싱하여 생성
일회성 사용각 인증 요청마다 새로운 code_verifier 생성
안전한 저장code_verifier를 sessionStorage에 저장 (일회성 사용 후 즉시 삭제)

PKCE 구현 예시:

// code_verifier 생성 (43-128자 랜덤 문자열)
function generateCodeVerifier(): string {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  return base64UrlEncode(array);
}

// code_challenge 생성 (SHA-256 해싱)
async function generateCodeChallenge(verifier: string): Promise<string> {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const hash = await crypto.subtle.digest('SHA-256', data);
  return base64UrlEncode(new Uint8Array(hash));
}

2. HTTPS 사용

프로덕션 환경에서는 반드시 HTTPS를 사용해야 합니다.

프로토콜환경사용 여부이유
HTTP로컬 개발허용localhost는 안전한 컨텍스트
HTTP프로덕션금지토큰 탈취 위험
HTTPS프로덕션필수데이터 암호화

3. CSRF 보호

State 파라미터를 사용하여 CSRF 공격을 방지합니다.

// 로그인 시 State 생성
const state = crypto.randomUUID();
sessionStorage.setItem('oauth_state', state);

// 콜백에서 State 검증
const savedState = sessionStorage.getItem('oauth_state');
if (state !== savedState) {
  throw new Error('CSRF attack detected');
}

4. State 파라미터 검증

OAuth 플로우의 무결성을 보장합니다.

단계작업목적
로그인 시작랜덤 State 생성 및 저장CSRF 토큰 생성
DataGSM 리다이렉트State를 쿼리 파라미터로 전달요청 추적
콜백 수신State 파라미터 검증공격 탐지
검증 완료State 삭제 (일회용)재사용 방지

전체 플로우 다이어그램

시퀀스 다이어그램

Loading diagram...

에러 처리 플로우

Loading diagram...

다음 단계

기능 확장

구현을 완료한 후 다음 기능들을 추가로 고려해보세요.

기능설명우선순위
Token RefreshAccess Token 만료 시 자동 갱신높음
Remember Me로그인 상태 유지 기능중간
소셜 로그인 통합다른 OAuth 제공자 추가낮음
다중 기기 로그인여러 기기에서 동시 로그인 지원중간
로그인 히스토리로그인 기록 추적 및 보안 알림중간

모니터링 및 로깅

프로덕션 환경에서는 다음 항목들을 모니터링하세요.

// 로그 예시
log.info("OAuth login started for user: {}", email);
log.info("Token exchange successful for user: {}", email);
log.warn("Failed login attempt for user: {}", email);
log.error("Token exchange failed: {}", errorMessage);

성능 최적화

  • Redis를 사용한 JWT 블랙리스트 캐싱
  • DB 쿼리 최적화 (인덱스 추가)
  • Connection Pool 설정
  • CDN을 통한 정적 리소스 제공

추가 리소스