반응형

문제 상황

application.properties에 JSP 경로를 설정하고,

src/main/webapp/WEB-INF/views/ 경로에 JSP 파일도 만들었는데 다음과 같은 에러가 발생한다.

Whitelabel Error Page
This application has no explicit mapping for /error, 
so you are seeing this as a fallback.

There was an unexpected error (type=Not Found, status=404).
JSP file [/WEB-INF/views/index.jsp] not found

application.properties 설정은 아래와 같이 되어 있다.

spring.mvc.view.prefix=/WEB-INF/views/
spring.mvc.view.suffix=.jsp

파일도 있고, 설정에도 문제가 없음에도 404가 뜨는 상황이다.


원인 분석

핵심 원인 : 내장 Tomcat은 webapp 폴더를 자동으로 인식하지 않는다.

외장 Tomcat(직접 설치해서 쓰는 Tomcat)은 src/main/webapp 폴더를 기본 웹 루트로 인식한다.

반면 Spring Boot의 내장 Tomcat은 JSP 파일을 아래 경로에서 찾는다.

target/classes/META-INF/resources/

Maven은 기본적으로 src/main/webapp 폴더를 빌드 시 target으로 복사하지 않는다.

JSP 파일 실제 위치   → src/main/webapp/WEB-INF/views/index.jsp  (파일은 존재함)
내장 Tomcat이 찾는 곳 → target/classes/META-INF/resources/        (비어있음)

파일은 있지만 Tomcat이 보는 위치에 없기 때문에 404가 발생하고 있었다.


해결 방법

pom.xml의 섹션에 아래 설정을 추가한다.

<build>
    <resources>
        <resource>
            <directory>src/main/webapp</directory>
            <targetPath>META-INF/resources</targetPath>
            <includes>
                <include>**/**</include>
            </includes>
        </resource>
        <resource>
            <directory>src/main/resources</directory>
        </resource>
    </resources>
    <plugins>
        <!-- 기존 플러그인 설정 -->
    </plugins>
</build>

이렇게 설정할 경우 Maven에게 빌드 시 src/main/webapp 폴더의 내용을 META-INF/resources로 복사하도록 지시한다.

설정 추가 후 Maven을 리로드 한다.

  • IntelliJ 기준 : 오른쪽 Maven 패널 → 새로고침 버튼 클릭
  • 또는 pom.xml 우클릭 → Maven → Reload project

이후 애플리케이션을 재실행하면 정상적으로 JSP가 렌더링 된다.


정리

항목 외장 Tomcat 내장 Tomcat (Spring Boot)
JSP 탐색 기본 경로 src/main/webapp META-INF/resources
webapp 자동 인식 O X
추가 설정 필요 여부 불필요 pom.xml resources 설정 필요

주의 사항

war 설정으로 WAR 파일을 외장 Tomcat에 배포하는 경우에는 이 설정이 필요 없다.

내장 Tomcat으로 로컬 실행할 때 필요한 설정이다.

반응형
반응형

VM 인스턴스 생성 (웹 콘솔)

1. https://cloud.oracle.com 로그인

2. Compute → Instances → Create Instance

 

세부 설정하기

Basic Information

  • Name -> 생성할 가상 서버의 이름 입력.
  • Compartment ->  리소스를 관리할 폴더 선택.

Placement 설정
Availability Domain은 서버가 위치할 물리적 데이터센터를 선택하는 옵션.

  • 춘천 지역(AP-CHUNCHEON-1)의 1번 가용 영역 선택
  • 기본값으로 설정

Image and Shape

  • Image: Ubuntu 22.04

  • Shape: Virtual machine - VM.Standard.A1.Flex
  • 2 core OCPU, 12GB memory

 


주의 사항 : ARM 인스턴스 용량 부족 문제

ARM Ampere A1은 무료 고성능 서버로 인기가 매우 높아, 용량 부족으로 생성이 실패할 수 있음.

 

대안으로 VM.Standard.E2.1.Micro (AMD)를 선택하여 사용.

  • Shape: Virtual machine - Specialty and previous generation - VM.Standard.E2.1.Micro
  • 1 OCPU, 1GB RAM

Security 설정

Shielded Instance는 고급 보안 기능으로 일반적인 웹 서비스에는 불필요.

  • 기본값(비활성화) 그대로 사용 권장하나 기업 환경이나 높은 보안이 필요한 경우에만 활성화

Networking 설정

새로운 가상 클라우드 네트워크를 생성.

 

Primary VNIC

  • Create new virtual cloud network 선택
  • 자동으로 인터넷 연결과 보안 설정이 구성됨
  • 별도 설정 없이 기본값 사용 권장

Private IPv4 address assignment

  • Automatically assign private IPv4 address 선택
  • 내부 네트워크 통신용

Public IPv4 address assignment & IPv6 address assignment

인스턴스 생성과정에서 비활성화되어 있을 수 있으나, 인스턴스 생성 완료 후 추가로 할당받을 수 있음.

Public IPv4 address assignment

  • 인터넷에서 접근 가능한 공용 IP (외부 접근용)
  • 웹 서버 배포에 필수

IPv6 address assignment

  • 차세대 인터넷 주소 체계 (IPv4의 업그레이드 버전)
  • 거의 무한한 IP 주소 제공 (IPv4 부족 문제 해결)

SSH Keys

Generate a key pair for me 선택

  • Oracle이 자동으로 보안 키 생성
  • Private Key 다운로드 필수 (한 번만 가능)
  • SSH 접속에 필요한 인증서 역할

Private Key 파일을 분실하면 서버 접속이 불가능하므로 반드시 안전한 장소에 보관해야 한다.


Storage 설정

Boot Volume : 서버 운영체제가 설치되는 디스크

  • 비활성화 -> 기본값 유지(46.6GB)

Use in-transit encryption : 데이터 전송 시 암호화

  • 활성화 -> 기본값 활성화 상태

Encrypt this volume with a key that you manage : 개인 암호화 키 관리

  • 비활성화 -> Oracle이 자동으로 보안 관리

생성 완료

반응형
반응형

목차

  1. Swagger란 무엇인가?
  2. 개발 환경
  3. 구현 과정
  4. JWT 인증 테스트
  5. 정리

1. Swagger란 무엇인가?

Swagger는 REST API를 자동으로 문서화해 주는 도구.

 

  • 코드에서 자동으로 API 문서 생성
  • 브라우저에서 바로 API 테스트 가능 (Postman 대체)
  • 코드 변경 시 문서 자동 업데이트
  • 한글 설명 및 예시 값 추가 가능
  • JWT 인증 테스트 지원

 


2. 개발 환경

  • Java 21
  • Spring Boot 3.4.1
  • SpringDoc OpenAPI 2.7.0
  • Gradle
  • Spring Security (JWT 인증)

Spring Boot 3.x에서는 반드시 SpringDoc을 사용해야 한다.


3. 구현 과정

3-1. 의존성 추가

 

build.gradle

dependencies {
    // 기존 의존성들...
    
    // SpringDoc (Swagger) - API 문서화
    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0'
}

 

  • Spring Boot 3.2.x: SpringDoc 2.3.0
  • Spring Boot 3.4.x: SpringDoc 2.7.0 (권장)

Spring Boot 버전과 SpringDoc 버전이 맞지 않으면 NoSuchMethodError가 발생할 수 있다.


3-2. SwaggerConfig 설정

 

src/main/java/com/yourproject/config/SwaggerConfig.java

package com.yourproject.config;

import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class SwaggerConfig {

    @Bean
    public OpenAPI openAPI() {
        String jwtSchemeName = "bearerAuth";

        // 1. API 기본 정보 설정
        Info info = new Info()
                .title("Sample Project API")
                .description("샘플 프로젝트 API 문서입니다.")
                .version("v1.0.0");

        // 2. JWT 인증 스키마 정의
        SecurityScheme securityScheme = new SecurityScheme()
                .type(SecurityScheme.Type.HTTP)
                .scheme("bearer")
                .bearerFormat("JWT")
                .in(SecurityScheme.In.HEADER)
                .name("Authorization");

        // 3. 전역 보안 요구사항 (모든 API에 JWT 적용)
        SecurityRequirement securityRequirement = new SecurityRequirement()
                .addList(jwtSchemeName);

        return new OpenAPI()
                .info(info)
                .components(new Components()
                .addSecuritySchemes(jwtSchemeName, securityScheme))
                .addSecurityItem(securityRequirement);
    }
}

 

 

  • Info: API 제목, 설명, 버전 정보 설정
  • SecurityScheme: JWT Bearer 토큰 인증 방식 정의
  • SecurityRequirement: 모든 API에 JWT 인증을 전역으로 적용

이 설정으로 Swagger UI 우측 상단에 Authorize 버튼이 생성된다.


3-3. SecurityConfig 수정 (Spring Security 사용 시)

Spring Security를 사용하는 경우, Swagger UI 경로를 인증 제외 목록에 추가해야 한다.

 

src/main/java/com/yourproject/config/SecurityConfig.java

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()  // 인증 API 허용
                .requestMatchers(
                        "/swagger-ui/**",        // Swagger UI 리소스
                        "/v3/api-docs/**",       // OpenAPI 3.0 문서
                        "/swagger-resources/**", // Swagger 리소스
                        "/swagger-ui.html"       // Swagger UI 메인 페이지
                ).permitAll()  // Swagger 경로 인증 제외
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthenticationFilter, 
                UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

 

 

  • Swagger 관련 경로를 .permitAll()로 설정
  • JWT 인증 없이도 API 문서에 접근 가능
  • 실제 API 호출 시에는 여전히 JWT 토큰 필요

3-4. Controller 애노테이션 추가

 

Controller 레벨: @Tag

import io.swagger.v3.oas.annotations.tags.Tag;

@Tag(name = "사용자 관리", description = "회원가입, 로그인, 사용자 정보 조회 API")
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class UserController {
    // 컨트롤러 구현...
}

 

  • Swagger UI에서 "사용자 관리" 그룹으로 API들이 분류되어 표시된다.

 

메서드 레벨: @Operation, @ApiResponses

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;

@Operation(
    summary = "회원가입",
    description = "새로운 사용자를 등록합니다. 아이디와 이메일은 중복될 수 없습니다."
)
@ApiResponses({
    @ApiResponse(responseCode = "201", description = "회원가입 성공"),
    @ApiResponse(responseCode = "400", description = "유효성 검증 실패 또는 중복된 아이디/이메일")
})
@PostMapping("/auth/signup")
public ResponseEntity<UserResponse> signup(@Valid @RequestBody UserSignupRequest request) {
    UserResponse response = userService.signup(request);
    return ResponseEntity.status(HttpStatus.CREATED).body(response);
}

 

  • @Operation: API의 제목과 상세 설명
  • @ApiResponses: HTTP 응답 코드별 설명
    • 201: 성공 응답
    • 400: 클라이언트 오류

 

파라미터 설명: @Parameter

import io.swagger.v3.oas.annotations.Parameter;

@Operation(summary = "상품 상세 조회")
@GetMapping("/products/{id}")
public ResponseEntity<ProductResponse> getProduct(
    @Parameter(description = "상품 ID", example = "1")
    @PathVariable Long id
) {
    return ResponseEntity.ok(productService.findById(id));
}
  • Swagger UI에서 파라미터 설명 및 예시 값이 표시된다.

 

SecurityContext 파라미터 숨기기

@GetMapping("/users/me")
public ResponseEntity<UserResponse> getCurrentUser(
    @Parameter(hidden = true) @AuthenticationPrincipal String username
) {
    return ResponseEntity.ok(userService.getCurrentUser(username));
}

 

  • @Parameter(hidden = true)를 사용하면 Swagger UI에 표시되지 않는다. (이 파라미터는 SecurityContext에서 자동 주입되므로 사용자가 입력할 필요 없음)

3-5. DTO 필드 설명 추가

DTO에 @Schema 애노테이션을 추가하면 각 필드의 설명과 예시 값이 Swagger UI에 표시된다.

 

Before (애노테이션 없음)

public record UserSignupRequest(
    String username,
    String password,
    String name,
    String email,
    String phone
) {}

// Swagger UI 표시
{
  "username": "string",
  "password": "string", 
  "name": "string",
  "email": "string",
  "phone": "string"
}

 

  • 필드 설명이 전혀 없음
  • 예시 값이 "string"으로만 표시

 

After (@Schema 추가)

import io.swagger.v3.oas.annotations.media.Schema;

public record UserSignupRequest(
    @Schema(description = "사용자 아이디 (영문 소문자, 숫자만 가능)", example = "sampleuser")
    @NotBlank
    @Size(min = 4, max = 20)
    String username,
    
    @Schema(description = "비밀번호 (영문, 숫자, 특수문자 포함 8~20자)", example = "samplePass123!")
    @NotBlank
    @Size(min = 8, max = 20)
    String password,
    
    @Schema(description = "이름 (2~10자)", example = "김철수")
    @NotBlank
    String name,
    
    @Schema(description = "이메일", example = "sample@email.com")
    @Email
    String email,
    
    @Schema(description = "전화번호 (010-XXXX-XXXX)", example = "010-1234-5678")
    @Pattern(regexp = "^01[0-9]-\\d{4}-\\d{4}$")
    String phone
) {}

// Swagger UI 표시
{
  "username": "sampleuser",          // 사용자 아이디 (영문 소문자, 숫자만 가능)
  "password": "samplePass123!",      // 비밀번호 (영문, 숫자, 특수문자 포함 8~20자)
  "name": "김철수",                   // 이름 (2~10자)
  "email": "sample@email.com",       // 이메일
  "phone": "010-1234-5678"           // 전화번호 (010-XXXX-XXXX)
}

 

 

  • 각 필드마다 한글 설명이 명확히 표시
  • 실제 예시 값으로 표시되어 이해하기 쉬움
  • "Try it out" 클릭 시 예시 값이 자동으로 입력됨
  • API 사용법을 직관적으로 파악 가능

 

 

3-6. 환경별 Swagger 제어

 

개발 환경 (local, dev)

  • Swagger 필요 (API 테스트, 문서 확인에 활용)

프로덕션 환경 (prod):

  • Swagger 노출되면 안 됨
    • 보안 위험: 누구나 API 구조를 확인할 수 있음
    • 공격 벡터: 공격자가 엔드포인트를 쉽게 파악 가능
    • 비즈니스 정보 노출: 내부 로직과 데이터 구조가 드러남

 

application-local.yml (개발 환경)

# Swagger (로컬 개발 환경에서 활성화)
springdoc:
  swagger-ui:
    enabled: true
    path: /swagger-ui.html
  api-docs:
    enabled: true
    path: /v3/api-docs

 

application-prod.yml (프로덕션 환경)

# Swagger (프로덕션에서 비활성화)
springdoc:
  swagger-ui:
    enabled: false  # Swagger UI 완전 차단
  api-docs:
    enabled: false  # OpenAPI 문서도 차단

3-7. 실행 및 확인

애플리케이션을 실행하고 브라우저에서 접속하기.

http://localhost:포트번호/swagger-ui.html

4. JWT 인증 테스트

로그인 API 호출하여 JWT 토큰 받기

  • Try it out -> Execute
  • 응답에서 token 값 복사

 

 

Authorize 버튼 클릭

 

  • 우측 상단 자물쇠 아이콘 클릭
  • 토큰 입력란에 Bearer {토큰} 입력 (또는 토큰만 입력)
  • "Authorize" 클릭

 

보호된 API 테스트

  • 모든 API 요청에 자동으로 Authorization: Bearer {토큰} 헤더가 추가된다.

5. 정리

Swagger를 잘 활용하면

  • API 문서 자동 생성 (수동 작성 불필요)
  • 브라우저에서 바로 API 테스트 가능
  • 프론트엔드 개발자와 원활한 협업
  • 한글 설명과 예시 값으로 가독성 향상
  • JWT 인증 테스트 지원
  • 프로덕션 환경에서 자동 비활성화 (보안)
반응형
반응형

Spring Boot 프로젝트의 로컬 개발 환경에서 PostgreSQL을 Docker로 설정하고 연동하는 과정을 정리합니다.

0. 환경

  • Spring Boot 3.4.1
  • Java 21
  • PostgreSQL 16 (Docker)
  • Docker Desktop

1. Docker PostgreSQL 설정

Docker 설치 확인

docker --version

// 출력 예시
Docker version 27.3.1, build ce12230

 

Docker가 없는 경우

https://www.docker.com/products/docker-desktop 에서 설치 필요.


2. postgres 컨테이너 실행

docker run -d \
    --name camprent-postgres \
    --restart unless-stopped \
    -e POSTGRES_DB=camprent_local \
    -e POSTGRES_USER=postgres \
    -e POSTGRES_PASSWORD=postgres \
    -p 5432:5432 \
    -v camprent-data:/var/lib/postgresql/data \
    postgres:16

옵션 상세설명

옵션 설명 비고
docker run 새 컨테이너 실행 Docker의 기본 실행 명령어
-d Detached mode (백그라운드 실행) 터미널을 차지하지 않고 백그라운드에서 실행
--name camprent-postgres 컨테이너 이름 지정 관리 편의성을 위해 의미있는 이름 부여
--restart unless-stopped Docker 시작 시 자동 실행 PC 재부팅 후에도 자동으로 컨테이너가 시작됨
-e POSTGRES_DB=camprent_local 데이터베이스 이름 설정 초기 생성될 데이터베이스명
-e POSTGRES_USER=postgres 사용자 이름 설정 PostgreSQL 접속용 사용자명
-e POSTGRES_PASSWORD=postgres 비밀번호 설정 로컬 개발용 (실제 운영환경에서는 강력한 비밀번호 사용)
-p 5432:5432 포트 매핑 (호스트:컨테이너) 로컬 PC의 5432 포트로 접속 가능
-v camprent-data:/var/lib/postgresql/data 볼륨 마운트 (데이터 영속성) 컨테이너 삭제해도 데이터베이스 데이터 보존
postgres:16 PostgreSQL 16 이미지 사용 최신 안정화 버전

컨테이너 상태 보기

//컨테이너 상태 확인
docker ps

// 결과
CONTAINER ID   IMAGE          STATUS           PORTS                   NAMES
abc123def456   postgres:16    Up 10 minutes    0.0.0.0:5432->5432/tcp  camprent-postgres

// 컨테이너 로그 확인 (PostgreSQL 시작 로그)
docker logs camprent-postgres

// 결과
database system is ready to accept connections

3. Spring Boot 환경별 설정

// 파일 구조
src/main/resources/
├── application.yml           # 공통 설정
├── application-local.yml     # 로컬 개발 환경
└── application-prod.yml      # 운영 환경

application.yml (공통)

spring:
  profiles:
    active: local
  application:
    name: equipment-rental-system
  jpa:
    open-in-view: false

application-local.yml (로컬 환경)

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/camprent_local
    username: postgres
    password: postgres

  jpa:
    hibernate:
      ddl-auto: validate  # Flyway로 스키마 관리, Hibernate는 검증만
    show-sql: true
    properties:
      hibernate:
        format_sql: true

  flyway:
    enabled: true
    baseline-on-migrate: false  # 빈 DB에서 시작
    locations: classpath:db/migration
    validate-on-migrate: true

logging:
  level:
    com.rental.camprent: DEBUG

application-prod.yml (운영 환경)

spring:
  datasource:
    url: ${DATABASE_URL}
    username: ${DATABASE_USERNAME}
    password: ${DATABASE_PASSWORD}

  jpa:
    hibernate:
      ddl-auto: validate 
    show-sql: false

  flyway:
    enabled: true
    baseline-on-migrate: false  
    locations: classpath:db/migration
    validate-on-migrate: true

logging:
  level:
    com.rental.camprent: INFO

 

Flyway를 사용하는 이유

  • ddl-auto: update는 운영 환경에서 위험 (예상치 못한 스키마 변경)
  • Flyway로 스키마 변경 이력 관리
  • 팀 협업 시 DB 스키마 동기화 가능

기본 마이그레이션 파일 생성

  • src/main/resources/db/migration/V1__Create_initial_tables.sql
-- V1__Create_initial_tables.sql 예시
CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    username VARCHAR(50) NOT NULL UNIQUE,
    password VARCHAR(255) NOT NULL,
    email VARCHAR(100) NOT NULL UNIQUE,
    role VARCHAR(20) NOT NULL CHECK (role IN ('ADMIN', 'USER')),
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE camping_items (
    id BIGSERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    category VARCHAR(50) NOT NULL,
    model VARCHAR(100),
    description TEXT,
    stock_quantity INTEGER NOT NULL DEFAULT 0,
    base_daily_rate DECIMAL(10,2) NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE',
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

5. Spring Boot 실행 및 테스트

프로젝트 경로에서 ./gradlew bootRun 으로 실행

// 애플리케이션 시작
./gradlew bootRun


// Flyway 마이그레이션 로그 확인
Flyway Community Edition 9.8.1 by Redgate
Database: jdbc:postgresql://localhost:5432/camprent_local (PostgreSQL 16.0)
Successfully validated 1 migration (execution time 00:00.008s)
Creating Schema History table "public"."flyway_schema_history" ...
Current version of schema "public": << Empty Schema >>
Migrating schema "public" to version "1 - Create initial tables"
Successfully applied 1 migration to schema "public" (execution time 00:00.012s)

PostgreSQL에서 테이블 확인

docker exec -it camprent-postgres psql -U postgres -d camprent_local

테이블 목록 확인
\dt

출력
                 List of relations
 Schema |         Name          | Type  |  Owner   
--------+-----------------------+-------+----------
 public | camping_items         | table | postgres
 public | customers             | table | postgres
 public | flyway_schema_history | table | postgres
 public | rentals               | table | postgres
 public | users                 | table | postgres

6. GUI 도구로 DB 관리

DBeaver 설치

// Mac 전용 (Homebrew)
brew install --cask dbeaver-community

 

또는

https://dbeaver.io/download/ 접속해서 다운로드 및 설치


연결 설정

  • Dbeaver 실행
  • 새 데이터베이스 연결(New Database Connection 선택

 

  • PostgreSQL 선택

 

  • DB 연결 설정하기(Host, Database, Port, Username, Password를 입력)

  • Test Connection  -> 성공 확인
  • 완료(Finish)

반응형
반응형

목차


1. 전역 예외 처리가 필요한 이유

Spring Boot 애플리케이션을 개발하다 보면, 각 Service에서 동일한 예외 처리 패턴이 반복되는 상황을 자주 만난다.

// UserService
@Service
public class UserService {
    public UserResponse findById(Long id) {
        User user = userRepository.findById(id)
            .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다"));
        return UserResponse.from(user);
    }
}

// ProductService  
@Service
public class ProductService {
    public ProductResponse findById(Long id) {
        Product product = productRepository.findById(id)
            .orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다"));
        return ProductResponse.from(product);
    }
}

// CampingItemService
@Service
public class CampingItemService {
    public CampingItemResponse findById(Long id) {
        CampingItem item = campingItemRepository.findById(id)
            .orElseThrow(() -> new IllegalArgumentException("캠핑 장비를 찾을 수 없습니다"));
        return CampingItemResponse.from(item);
    }
}

 

전역 예외 처리를 구현하는 이유

1. 코드 중복 문제 : 동일한 orElseThrow() 패턴이 모든 Service에서 반복된다.

2. 일관성 부족 : 같은 상황에서도 개발자마다 다른 예외를 던질 수 있다.

// A 개발자
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다"));

// B 개발자  
.orElseThrow(() -> new RuntimeException("User not found"));

// C 개발자
.orElseThrow(() -> new EntityNotFoundException("USER_NOT_FOUND"));

3. 유지보수 어려움 : 에러 메시지나 HTTP 상태 코드를 변경하려면 모든 Service를 수정해야 한다.

4. 예외와 HTTP 응답의 분리 부족: Service에서 던진 예외가 어떤 HTTP 상태 코드로 변환될지 예측하기 어렵다.


2. 구현단계

2-1. 커스텀 예외 생성

먼저 비즈니스 로직에서 발생할 수 있는 커스텀 예외를 정의한다.  예를 들어, 데이터를 찾을 수 없을 때 발생하는 예외를 만든다.

package com.rental.camprent.exception;

public class ItemNotFoundException extends RuntimeException {

    public ItemNotFoundException(String message) {
        super(message);
    }
}
  • RuntimeException을 상속받아 Unchecked Exception으로 만든다. 이렇게 하면 Service 메서드 시그니처에 throws를 선언하지 않아도 되어 코드가 간결해진다.

2-2. 에러 응답 DTO 설계

모든 에러 응답이 동일한 형식을 갖도록 ErrorResponse DTO를 만든다.

 

package com.rental.camprent.dto.response;

import lombok.AllArgsConstructor;
import lombok.Getter;
import java.time.LocalDateTime;

@Getter
@AllArgsConstructor
public class ErrorResponse {

    private int status;           // HTTP 상태 코드 (404, 400 등)
    private String code;          // 에러 코드 (ITEM_NOT_FOUND 등)
    private String message;       // 에러 메시지
    private LocalDateTime timestamp;  // 발생 시각

    // 정적 팩토리 메서드
    public static ErrorResponse of(int status, String code, String message) {
        return new ErrorResponse(status, code, message, LocalDateTime.now());
    }
}
  • 정적 팩토리 메서드 of()를 제공하여 객체 생성을 간편하게 만든다. timestamp는 자동으로 현재 시각이 설정된다.

2-3. GlobalExceptionHandler 구현

@RestControllerAdvice를 사용하여 전역 예외 처리기를 만든다. 이 클래스는 모든 Controller에서 발생하는 예외를 한 곳에서 처리한다.

package com.rental.camprent.exception;

import com.rental.camprent.dto.response.ErrorResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    // ItemNotFoundException 처리 (404)
    @ExceptionHandler(ItemNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleItemNotFoundException(ItemNotFoundException e) {
        ErrorResponse errorResponse = ErrorResponse.of(
                HttpStatus.NOT_FOUND.value(),
                "ITEM_NOT_FOUND",
                e.getMessage()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
    }

    // Validation 실패 처리 (400)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationException(MethodArgumentNotValidException e) {
        String message = e.getBindingResult().getAllErrors().get(0).getDefaultMessage();

        ErrorResponse errorResponse = ErrorResponse.of(
                HttpStatus.BAD_REQUEST.value(),
                "VALIDATION_FAILED",
                message
        );
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
    }

    // IllegalArgumentException 처리 (400)
    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ErrorResponse> handleIllegalArgumentException(IllegalArgumentException e) {
        ErrorResponse errorResponse = ErrorResponse.of(
                HttpStatus.BAD_REQUEST.value(),
                "INVALID_ARGUMENT",
                e.getMessage()
        );
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
    }

    // 그 외 모든 예외 처리 (500)
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(Exception e) {
        ErrorResponse errorResponse = ErrorResponse.of(
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                "INTERNAL_SERVER_ERROR",
                "서버 내부 오류가 발생했습니다."
        );
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
    }
}
        • @RestControllerAdvice: 모든 @RestController에 적용되는 전역 예외 처리기를 선언한다.
        • @ExceptionHandler(XxxException.class): 특정 예외 타입이 발생했을 때 실행될 메서드를 지정한다.
        • 예외 우선순위: 구체적인 예외부터 처리하고, 마지막에 Exception.class로 모든 예외를 받는다.
        • 보안: 최후의 보루인 handleException()에서는 e.getMessage()를 사용하지 않는다(내부 구현 정보가 노출될  수 있기 때문).
        •  

2-4. Service에서 커스텀 예외 사용

기존에 IllegalArgumentException을 던지던 Service 코드를 ItemNotFoundException으로 변경한다.

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CampingItemService {

    private final CampingItemRepository campingItemRepository;

    public CampingItemResponse findById(Long id) {
        CampingItem entity = campingItemRepository.findById(id)
                .orElseThrow(() -> new ItemNotFoundException("장비를 찾을 수 없습니다. id: " + id));

        return CampingItemResponse.from(entity);
    }

    @Transactional
    public CampingItemResponse updateStatus(Long id, CampingItemStatus status) {
        CampingItem entity = campingItemRepository.findById(id)
                .orElseThrow(() -> new ItemNotFoundException("장비를 찾을 수 없습니다. id: " + id));
                
        entity.updateStatus(status);
        return CampingItemResponse.from(entity);
    }
    
    // update, increaseStock, decreaseStock 등 다른 메서드도 동일하게 수정
}

3. 전체 흐름 정리

1. Service에서 예외 발생: throw new ItemNotFoundException(...)

2. GlobalExceptionHandler가 자동으로 예외 감지: Spring이 해당 예외 타입과 매칭되는 @ExceptionHandler를 찾는다.

3. 적절한 핸들러 메서드 실행: handleItemNotFoundException()이 호출된다.

4. ErrorResponse 생성: 상태 코드, 에러 코드, 메시지, 타임스탬프를 포함한 응답 객체를 만든다.

5. ResponseEntity 반환: HTTP 상태 코드와 함께 JSON 응답을 클라이언트에게 보낸다.


4. 실제 응답 예시

성공 케이스 (200 OK)

GET /api/camping-items/1

  HTTP/1.1 200 OK
  {
    "id": 1,
    "name": "4인용 텐트",
    "category": "TENT",
    "stockQuantity": 10
  }

실패 케이스 - 아이템을 찾을 수 없음 (404 NOT FOUND)

GET /api/camping-items/999

  HTTP/1.1 404 Not Found
  {
    "status": 404,
    "code": "ITEM_NOT_FOUND",
    "message": "장비를 찾을 수 없습니다. id: 999",
    "timestamp": "2026-01-21T10:30:45.123"
  }

 


실패 케이스 - 유효성 검사 실패 (400 BAD REQUEST)

POST /api/camping-items
  {
    "name": "",
    "stockQuantity": -5
  }

  HTTP/1.1 400 Bad Request
  {
    "status": 400,
    "code": "VALIDATION_FAILED",
    "message": "이름은 필수입니다.",
    "timestamp": "2026-01-21T10:31:20.456"
  }

실패 케이스 - 서버 내부 오류 (500 INTERNAL SERVER ERROR

GET /api/camping-items/1

  HTTP/1.1 500 Internal Server Error
  {
    "status": 500,
    "code": "INTERNAL_SERVER_ERROR",
    "message": "서버 내부 오류가 발생했습니다.",
    "timestamp": "2026-01-21T10:32:10.789"
  }

5. 주의사항

HTTP 상태 코드를 두 곳에 설정하는 이유

예외 처리 코드를 보면 HTTP 상태 코드가 두 곳에 나온다

ErrorResponse errorResponse = ErrorResponse.of(
        HttpStatus.NOT_FOUND.value(),  // ← ErrorResponse 내부
        "ITEM_NOT_FOUND",
        e.getMessage()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND)  // ← ResponseEntity
                     .body(errorResponse);
  • ResponseEntity의 status: HTTP 프로토콜 레벨의 상태 코드 (헤더에 들어감)
  • ErrorResponse의 status: JSON 본문에 포함되는 정보

프론트엔드에서는 보통 HTTP 상태 코드로 성공/실패를 판단하고, JSON 본문의 code와 message로 구체적인 에러 내용을 파악한다.

 

보안을 위한 메시지 처리

커스텀 예외(ItemNotFoundException, IllegalArgumentException 등)에서는 e.getMessage()를 사용해도 안전하다.

개발자가 직접 작성한 메시지이기 때문이다.

 

안전한 경우 (권장)

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    // 안전: 커스텀 예외 - e.getMessage() 사용 OK
    @ExceptionHandler(ItemNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleItemNotFoundException(ItemNotFoundException e) {
        ErrorResponse errorResponse = ErrorResponse.of(
                HttpStatus.NOT_FOUND.value(),
                "ITEM_NOT_FOUND",
                e.getMessage()  // "장비를 찾을 수 없습니다. id: 1"
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
    }

    // 안전: 최후의 보루 - 고정 메시지 사용
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(Exception e) {
        // 개발자용: 로그에 상세 정보 기록
        log.error("Unexpected error occurred: {}", e.getMessage(), e);

        // 사용자용: 안전한 고정 메시지만 반환
        ErrorResponse errorResponse = ErrorResponse.of(
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                "INTERNAL_SERVER_ERROR",
                "서버 내부 오류가 발생했습니다."  // 민감한 정보 숨김
        );
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
    }
}

하지만 최후의 보루인 Exception 핸들러에서는 고정 메시지를 사용한다. 예상치 못한 예외의 메시지에는 데이터베이스 구조, 파일 경로 등 민감한 정보가 포함될 수 있기 때문이다.

 

위험한 경우

@RestControllerAdvice
public class BadGlobalExceptionHandler {

    // 위험: 모든 예외에서 e.getMessage() 그대로 노출
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(Exception e) {
        ErrorResponse errorResponse = ErrorResponse.of(
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                "INTERNAL_SERVER_ERROR",
                e.getMessage()  // 민감한 정보 노출 가능
        );
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
    }
}

 

실제 노출되는 위험한 메시지들

// 데이터베이스 예외 시
"Connection refused: jdbc:postgresql://localhost:5432/camprent"

// 파일 시스템 예외 시  
"Access denied: /home/admin/config/database.properties"

// SQL 예외 시
"Table 'camprent.camping_items' doesn't exist"

// 클래스 로딩 예외 시
"Could not load class: com.rental.camprent.secret.ApiKeyManager"

 


 

6. 정리

 

전역 예외처리를 구현할 경우 장점

  • Service 코드가 깔끔해진다: 중복된 예외 처리 코드 제거
  • 일관된 에러 응답: 모든 API에서 동일한 형식의 에러 응답
  • 유지보수성 향상: 에러 처리 방식 변경 시 한 곳만 수정
  • 관심사 분리: Service는 비즈니스 로직에만 집중

Service에서 발생하는 중복 예외 처리 패턴을 완전히 해결할 수 있다.

반응형

+ Recent posts