[Spring] JWT 기반 로그인 기능 구현(React 사용 예제 포함)

Inung_92·2025년 8월 19일

Spring

목록 보기
20/20

🚀 개요

헥사고날 아키텍처(Hexagonal Architecture)를 기반으로 JWT(JSON Web Token)를 활용한 인증 시스템을 구현합니다.

주요 특징

기존에는 accessToken만을 발급하는 방식으로 단순하게 인증 처리를 진행하였으나 보안 강화를 위해 refreshToken까지 발급하여 토큰 로테이션을 통한 인증처리를 구현합니다.

  • AccessToken: 30분 만료, API 인증용
  • RefreshToken: 7일 만료, 토큰 갱신용
  • 토큰 로테이션: 보안 강화를 위한 RefreshToken 교체
  • 사용자별 단일 토큰: 한 사용자당 하나의 RefreshToken만 유지
  • 예외 처리: 잘못된 자격증명, 잠긴 계정, 비활성 계정 등 다양한 예외 상황 처리

🏗️시스템 아키텍처

기능 구현이 포함되는 프로젝트의 구조는 헥사고날 아키텍처를 적용한 멀티모듈 구조입니다.

아키텍처 구조

📦 com.example.project.system
├── 🎯 domain/                    # 도메인 계층
│   ├── Member.java              # 사용자 도메인 모델
│   └── RefreshToken.java        # RefreshToken 도메인 모델
├── 🔄 application/              # 애플리케이션 계층
│   ├── AuthenticationService.java
│   ├── dto/authentication/      # DTO 객체들
│   ├── port/in/authentication/  # 인바운드 포트
│   └── port/out/               # 아웃바운드 포트
├── 🔌 adapter/                  # 어댑터 계층
│   ├── in/web/                 # 웹 어댑터 (컨트롤러)
│   └── out/persistence/        # 영속성 어댑터 (JPA)
└── 🛠️ shared/config/           # 공유 설정
    └── JwtProvider.java        # JWT 처리 유틸리티

인증 플로우

  • 클라이언트의 로그인 요청
  • 사용자 정보를 DB에서 조회
  • 비밀번호 및 사용자 상태(비활성화 등) 검증
  • refreshToken 발급
  • DB에 refreshToken 저장
  • 저장된 refreshToken의 ID를 accessToken Claim에 저장
  • 클라이언트에게 반환
  • 클라이언트는 API 요청 시 accessToken을 요청 헤더로 전달
  • accessToken 만료 시 refreshToken ID를 이용하여 DB에서 조회

지금 구현하는 플로우는 토큰을 DB에 저장하는 방식으로 설계하였으며, 차후 Redis를 세션 스토리지로 사용하는 방식으로 점진적인 마이그레이션 예정입니다.


💻 구현 내용

세부 구현에 대한 내용은 코드가 길어져서 별도로 첨부하지 않았으며, 혹시 궁금한 부분에 대해서는 댓글로 남겨주시면 답변드리겠습니다.🫡

환경 설정

JWT Secret 설정

# application.yml
app:
  jwt:
    secret: "your-secret-key-should-be-at-least-32-characters-long"

의존성 추가

// build.gradle.kts
implementation("io.jsonwebtoken:jjwt-api:0.12.3")
implementation("io.jsonwebtoken:jjwt-impl:0.12.3")
implementation("io.jsonwebtoken:jjwt-jackson:0.12.3")

도메인 계층

RefreshToken 도메인 모델

@Getter
public class RefreshToken {
    private static final int EXPIRY_DAYS = 7;  // 7일 만료
    private static final int MIN_TOKEN_LENGTH = 10;
    
    // 핵심 메서드들
    public static RefreshToken create(Long userId, String token)  // 새 토큰 생성
    public boolean isExpired()                                    // 만료 확인
    public void use()                                            // 사용 기록
    public boolean belongsTo(Long userId)                        // 소유자 확인
}

리프레시 토큰을 발급할 때사용되는 도메인 모델입니다. 토큰에 사용되는 상수를 정의하고, 도메인 규칙에 따른 행위를 수행합니다.

주요 기능:

  • 토큰 생성 시 자동으로 7일 후 만료 시간 설정
  • 토큰 유효성 검증 (최소 길이, null 체크)
  • 사용 기록 및 만료 상태 관리

JWT Provider

토큰 생성 및 검증

@Component
public class JwtProvider {
    private static final int ACCESS_TOKEN_EXPIRY_MINUTES = 30;   // 30분
    private static final int REFRESH_TOKEN_EXPIRY_DAYS = 7;      // 7일
    
    // 핵심 메서드들
    public String generateAccessToken(Long userId, Long refreshTokenId)  // AccessToken 생성
    public String generateRefreshToken(Long userId)                      // RefreshToken 생성
    public Claims parseToken(String token)                               // 토큰 파싱
    public boolean isValid(String token)                                 // 토큰 유효성 검증
}

액세스 토큰과 리프레시 토큰에 대한 생성 및 검증을 담당하는 클래스입니다.

특징:

  • AccessToken에는 RefreshToken ID가 포함되어 토큰 로테이션 지원
  • HMAC-SHA256 알고리즘 사용
  • 토큰 만료 시간 자동 설정

애플리케이션 서비스

인증 비즈니스 로직

@Service
@Transactional
public class AuthenticationService implements LoginUseCase {
    
    public LoginResult login(LoginCommand command) {
        // 1. 사용자 인증
        Member member = authenticateUser(command.account(), command.password());
        
        // 2. 사용자 상태 검증 (잠김, 비활성 등)
        validateUserStatus(member);
        
        // 3. RefreshToken 생성 및 저장
        RefreshToken refreshToken = createAndSaveRefreshToken(member.getMemberId());
        
        // 4. AccessToken 생성
        String accessToken = jwtProvider.generateAccessToken(
            member.getMemberId(), 
            refreshToken.getId()
        );
        
        return LoginResult.from(member, accessToken);
    }
}

처리 과정:
1. 계정 ID로 사용자 조회
2. 비밀번호 검증
3. 사용자 상태 확인 (ACTIVE, LOCKED, INACTIVE)
4. RefreshToken 생성 및 DB 저장
5. AccessToken 생성 (RefreshToken ID 포함)
6. 로그인 결과 반환

웹 어댑터 (Controller)

REST API 엔드포인트

@RestController
@RequestMapping("/api/auth")
public class AuthenticationController {
    
    @PostMapping("/login")
    public ResponseEntity<ApiResponse<LoginResponse>> login(
            @Valid @RequestBody LoginRequest request) {
        
        LoginResult result = loginUseCase.login(request.toCommand());
        LoginResponse response = LoginResponse.from(result);
        
        return ResponseEntity.ok(ApiResponse.success(response));
    }
}

로그인에 대한 요청은 record를 이용하여 아래와 같이 작성 후 입력 값에 대한 검증을 수행합니다.

입력 검증

public record LoginRequest(
    @NotBlank(message = "계정 ID는 필수 입력 항목입니다.")
    @Size(min = 4, max = 25, message = "계정 ID는 4자 이상 25자 이하여야 합니다.")
    String account,
    
    @NotBlank(message = "비밀번호는 필수 입력 항목입니다.")
    @Size(min = 6, max = 20, message = "비밀번호는 6자 이상 20자 이하여야 합니다.")
    String password
) {}

예외 처리

// 잘못된 자격증명
public class InvalidCredentialsException extends RuntimeException

// 잠긴 계정
public class UserLockedException extends RuntimeException

// 비활성 계정  
public class UserNotActiveException extends RuntimeException

각 예외는 RuntimeException을 상속받아 정의하고, 예외 처리가 필요한 부분에서 throw하면 GlobalExceptionHandler에 사전에 정의된 ErrorCode에 맞춰 클라이언트에게 응답을 반환합니다.

API 명세

구현된 API에 대한 요청과 응답에 대한 정보는 다음과 같습니다.

로그인 API

요청

POST /api/auth/login
Content-Type: application/json

{
  "account": "testuser",
  "password": "password123"
}

성공 응답 (200 OK)

{
  "success": true,
  "data": {
    "account": "testuser",
    "memberName": "홍길동",
    "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
  }
}

실패 응답 예시

잘못된 자격증명 (401 Unauthorized):

{
  "success": false,
  "error": {
    "code": "LOGIN_001",
    "message": "계정 정보가 올바르지 않습니다."
  }
}

잠긴 계정 (409 Conflict):

{
  "success": false,
  "error": {
    "code": "MEMBER_905", 
    "message": "계정이 잠겨있습니다."
  }
}

보안 고려사항

구현된 보안 기능

  • 토큰 로테이션

    • 사용자당 하나의 RefreshToken만 유지
    • 새 로그인 시 기존 토큰 교체
  • 토큰 만료 관리

    • AccessToken: 30분 (짧은 만료 시간)
    • RefreshToken: 7일 (토큰 갱신용)
  • 사용자 상태 검증

    • 잠긴 계정 로그인 차단
    • 비활성 계정 로그인 차단
  • 입력 검증

    • 계정 ID: 4-25자
    • 비밀번호: 6-20자
    • Jakarta Bean Validation 사용

추가 고려사항

현재는 토큰을 이용한 로그인에 대한 기능만 구현하여 비밀번호에 대한 보안이 취약한 상태입니다. 따라서 비밀번호 해싱(BCrypt) 등에 대한 구현이 추가로 필요합니다.

또한, 특정 사용자의 반복적인 요청으로 인해 서버 자원 고갈을 방지하기 위해 Rate Limiting 구현이 추가로 필요합니다.

이 외에 추가 고려사항은 서버 규모가 커짐에 따라서 토큰의 블랙 리스트를 관리하거나 HTTPS 요청만을 사용하는 등의 추가적인 관리가 필요합니다.


💡 사용 방법

사용 예시

클라이언트에서 로그인

// 로그인 요청
const response = await fetch('/api/auth/login', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    account: 'testuser',
    password: 'password123'
  })
});

const result = await response.json();

if (result.success) {
  // AccessToken 저장 (메모리나 세션 스토리지 권장)
  const accessToken = result.data.accessToken;
  
  // 이후 API 요청에 토큰 포함
  const apiResponse = await fetch('/api/protected', {
    headers: {
      'Authorization': `Bearer ${accessToken}`
    }
  });
}

API 요청에 토큰 사용

// 보호된 리소스 접근
const headers = {
  'Authorization': `Bearer ${accessToken}`,
  'Content-Type': 'application/json'
};

현재 구현 방식 (Response Body)

구현 내용

  • 로그인 성공 시 토큰 정보를 Response Body로 반환
{
  "success": true,
  "data": {
    "account": "testuser", 
    "memberName": "홍길동",
    "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
  }
}

Body 방식의 장점

먼저 토큰을 쿠키로 전송하면서 httpOnly로 설정하면 브라우저에서 해당 쿠키에 접근할 수 없어 보안적인 측면이 강화되지만 클라이언트에서 토큰 저장 방식을 자유롭게 선택할 수 없다는 단점이 있는데 body는 클라이언트가 토큰의 저장 방식을 자유롭게 선택 할 수 있다는 장점이 있습니다.

또한, 요청에 대한 응답 구조를 명확하게 제시할 수 있어 RESTful API 원칙에 부합하며, 모바일 앱에 대한 호환성을 확보할 수 있기 때문에 다양한 도메인에서 호출 시 쿠키와 관련된 복잡성을 제거할 수 있습니다.

그렇다고 쿠키를 사용하는 것이 치명적인 단점이 존재하는 것은 아닙니다. 아래는 쿠키를 사용했을 때의 장점을 정리해두었으니 참고하시기 바랍니다.

쿠키 방식의 장점

  • XSS 보안: HttpOnly cookie로 설정하면 JavaScript로 접근 불가
  • 자동 전송: 브라우저가 자동으로 쿠키를 요청에 포함
  • CSRF 보호: SameSite 속성으로 CSRF 공격 방지 가능

🤷 클라이언트 사이드 인증 관리

토큰 저장 및 관리 방식 선택

// 로그인 성공 시
const loginResponse = await fetch('/api/auth/login', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ account: 'user', password: 'pass' })
});

const { data } = await loginResponse.json();
const { accessToken } = data;

// 저장 방식 선택
localStorage.setItem('accessToken', accessToken);  // 브라우저 종료해도 유지
// 또는
sessionStorage.setItem('accessToken', accessToken);  // 브라우저 종료시 삭제

라우팅 가드 (React 예시) 적용

function ProtectedRoute({ children }) {
  const token = localStorage.getItem('accessToken');
  
  if (!token) {
    return <Navigate to="/login" />;
  }
  
  return children;
}

// 사용 예시
<Route path="/dashboard" element={
  <ProtectedRoute>
    <Dashboard />
  </ProtectedRoute>
} />

API 요청 시 자동 토큰 첨부

// Axios interceptor 예시
axios.interceptors.request.use(config => {
  const token = localStorage.getItem('accessToken');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// 또는 fetch 래퍼 함수
function authenticatedFetch(url, options = {}) {
  const token = localStorage.getItem('accessToken');
  return fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      'Authorization': token ? `Bearer ${token}` : '',
    }
  });
}

토큰 만료 처리

// 401 응답 시 로그인 페이지로 리다이렉트
axios.interceptors.response.use(
  response => response,
  error => {
    if (error.response?.status === 401) {
      localStorage.removeItem('accessToken');
      window.location.href = '/login';
    }
    return Promise.reject(error);
  }
);

애플리케이션 초기화 시 인증 상태 확인

// App.js 또는 main.js
function App() {
  const [isAuthenticated, setIsAuthenticated] = useState(false);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    const token = localStorage.getItem('accessToken');
    if (token) {
      // 토큰 유효성 검증 (옵션)
      validateToken(token)
        .then(() => setIsAuthenticated(true))
        .catch(() => {
          localStorage.removeItem('accessToken');
          setIsAuthenticated(false);
        });
    }
    setIsLoading(false);
  }, []);

  if (isLoading) return <LoadingSpinner />;
  
  return isAuthenticated ? <AuthenticatedApp /> : <LoginPage />;
}

클라이언트 토큰 관리 시 보안 고려사항

토큰 저장 방식별 보안 특성

  • localStorage:
    • XSS 공격에 취약하지만 영구 저장
    • SPA에서 일반적으로 사용
  • sessionStorage:
    • XSS 공격에 취약하지만 탭 종료시 삭제
    • 보안성이 localStorage보다 높음
  • 메모리 저장:
    • 가장 안전하지만 새로고침 시 사라짐
    • 민감한 애플리케이션에 적합
profile
개발감자🥔

0개의 댓글