예외처리 · @Valid 유효성 검사 · application.yml 환경 설정
서버에서 에러가 났을 때 클라이언트에게 명확한 정보를 전달하는 방법. 에러 처리를 제대로 안 하면 스택트레이스가 그대로 노출된다.
{
"status": 500,
"error": "Internal Server Error",
"trace": "java.lang.NullPointerException
at UserService.java:25
at UserController.java:14 ..."
// ← 서버 내부 코드 노출 = 보안 위험!
}
{
"status": 404,
"message": "없는 사용자입니다.",
"code": "USER_NOT_FOUND",
"timestamp": "2024-01-01T12:00:00"
}
@RestController @RequestMapping("/users") public class UserController { @GetMapping("/{id}") public ResponseEntity<UserResponse> 조회(@PathVariable int id) { return ResponseEntity.ok(userService.사용자조회(id)); } // 이 Controller 안에서 발생한 예외만 처리 @ExceptionHandler(UserNotFoundException.class) public ResponseEntity<ErrorResponse> 유저없음처리(UserNotFoundException e) { return ResponseEntity.status(404) .body(new ErrorResponse(404, e.getMessage(), "USER_NOT_FOUND")); } } // 문제점: Controller마다 @ExceptionHandler를 달아야 함 // UserController, OrderController, ProductController... // 전부 같은 코드 반복 → @ControllerAdvice로 해결
@ControllerAdvice // 모든 Controller의 예외를 한 곳에서 처리 public class GlobalExceptionHandler { @ExceptionHandler(UserNotFoundException.class) public ResponseEntity<ErrorResponse> 유저없음(UserNotFoundException e) { return ResponseEntity.status(404) .body(new ErrorResponse(404, e.getMessage(), "USER_NOT_FOUND")); } @ExceptionHandler(DuplicateEmailException.class) public ResponseEntity<ErrorResponse> 이메일중복(DuplicateEmailException e) { return ResponseEntity.status(409) .body(new ErrorResponse(409, e.getMessage(), "DUPLICATE_EMAIL")); } // 예상 못한 모든 에러 처리 @ExceptionHandler(Exception.class) public ResponseEntity<ErrorResponse> 서버에러(Exception e) { return ResponseEntity.status(500) .body(new ErrorResponse(500, "서버 오류가 발생했습니다.", "INTERNAL_ERROR")); } }
// 단순 버전 public class UserNotFoundException extends RuntimeException { public UserNotFoundException(String message) { super(message); } } // 체계적인 버전 — 공통 부모 예외 만들기 public class BusinessException extends RuntimeException { private final String code; private final int status; public BusinessException(String message, String code, int status) { super(message); this.code = code; this.status = status; } public String getCode() { return code; } public int getStatus() { return status; } } // BusinessException 상속 public class UserNotFoundException extends BusinessException { public UserNotFoundException() { super("없는 사용자입니다.", "USER_NOT_FOUND", 404); } } public class DuplicateEmailException extends BusinessException { public DuplicateEmailException() { super("이미 있는 이메일입니다.", "DUPLICATE_EMAIL", 409); } }
@Getter @AllArgsConstructor public class ErrorResponse { private int status; private String message; private String code; private LocalDateTime timestamp; public ErrorResponse(int status, String message, String code) { this.status = status; this.message = message; this.code = code; this.timestamp = LocalDateTime.now(); } } // 응답 예시: // { // "status": 404, // "message": "없는 사용자입니다.", // "code": "USER_NOT_FOUND", // "timestamp": "2024-01-01T12:00:00" // }
| 상태코드 | 의미 | 예외 상황 |
|---|---|---|
| 400 | Bad Request | @Valid 유효성 검사 실패, 잘못된 요청 데이터 |
| 401 | Unauthorized | 로그인 안 됨, 토큰 없음 / 만료 |
| 403 | Forbidden | 로그인은 됐지만 권한 없음 |
| 404 | Not Found | 없는 사용자, 없는 게시글 등 |
| 409 | Conflict | 이미 있는 이메일, 중복 데이터 |
| 500 | Internal Server Error | 예상 못한 서버 내부 오류 |
@RestControllerAdvice public class GlobalExceptionHandler { // BusinessException 상속한 것들 전부 처리 @ExceptionHandler(BusinessException.class) public ResponseEntity<ErrorResponse> 비즈니스예외(BusinessException e) { return ResponseEntity.status(e.getStatus()) .body(new ErrorResponse( e.getStatus(), e.getMessage(), e.getCode(), LocalDateTime.now())); } // @Valid 유효성 검사 실패 @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<ErrorResponse> 유효성검사실패( MethodArgumentNotValidException e) { String message = e.getBindingResult() .getFieldErrors() .get(0) .getDefaultMessage(); return ResponseEntity.status(400) .body(new ErrorResponse(400, message, "VALIDATION_ERROR", LocalDateTime.now())); } // 예상 못한 모든 에러 @ExceptionHandler(Exception.class) public ResponseEntity<ErrorResponse> 서버에러(Exception e) { return ResponseEntity.status(500) .body(new ErrorResponse(500, "서버 오류가 발생했습니다.", "INTERNAL_ERROR", LocalDateTime.now())); } }
클라이언트가 보내는 데이터를 믿으면 안 된다. @Valid 애너테이션 하나로 클린하게 검사할 수 있다.
{
"name": "",
"email": "이건이메일아님",
"age": -999
}
// 이상한 데이터가 DB에 그대로 저장됨
{
"status": 400,
"message": "이름은 필수입니다.",
"code": "VALIDATION_ERROR"
}
// DB까지 가지도 않고 바로 400 응답
implementation 'org.springframework.boot:spring-boot-starter-validation'
public class UserCreateRequest { // ─── 문자열 ─────────────────────────────── @NotNull // null 안 됨 @NotEmpty // null, "" 안 됨 @NotBlank // null, "", " " 안 됨 (공백만 있어도 안 됨) ← 제일 많이 씀 private String name; @Size(min=2, max=50) // 길이 2~50 private String name; @Email // 이메일 형식이어야 함 private String email; @Pattern(regexp = "^[a-zA-Z0-9]{6,12}$") // 정규식 검사 private String password; // ─── 숫자 ───────────────────────────────── @Min(1) // 최솟값 1 이상 @Max(150) // 최댓값 150 이하 private int age; @Positive // 양수만 (0 안 됨) @PositiveOrZero // 양수 또는 0 @Negative // 음수만 @NegativeOrZero // 음수 또는 0 private int count; @DecimalMin("0.0") // 소수 최솟값 @DecimalMax("100.0") // 소수 최댓값 private double score; // ─── 날짜 ───────────────────────────────── @Past // 과거 날짜만 @PastOrPresent // 과거 또는 현재 @Future // 미래 날짜만 @FutureOrPresent // 미래 또는 현재 private LocalDate birthDate; // ─── 불린 ───────────────────────────────── @AssertTrue // true여야 함 @AssertFalse // false여야 함 private boolean agreeTerms; }
// @Valid — javax 표준, 기본 유효성 검사 @PostMapping public ResponseEntity<UserResponse> 회원가입( @Valid @RequestBody UserCreateRequest request) { } // @Validated — 스프링 제공, 그룹 검사 가능 @PostMapping public ResponseEntity<UserResponse> 회원가입( @Validated(CreateGroup.class) @RequestBody UserCreateRequest request) { } // @Validated는 메서드 파라미터에도 사용 가능 (@PathVariable 등) @Validated @RestController public class UserController { @GetMapping("/{id}") public User 조회(@PathVariable @Positive int id) { } // id가 양수인지도 검사 가능 }
| @Valid | @Validated | |
|---|---|---|
| 출처 | javax 표준 | 스프링 제공 |
| 그룹 검사 | 불가 | 가능 |
| 메서드 파라미터 검사 | @RequestBody만 | @PathVariable 등도 가능 |
기본 제공 애너테이션으로 해결 안 되는 경우. 예: 전화번호 형식 검사, 특정 도메인 이메일만 허용 등.
// ① 애너테이션 정의 @Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = PhoneValidator.class) public @interface ValidPhone { String message() default "올바른 전화번호 형식이 아닙니다."; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; } // ② 실제 검사 로직 public class PhoneValidator implements ConstraintValidator<ValidPhone, String> { @Override public boolean isValid(String value, ConstraintValidatorContext ctx) { if (value == null) return false; return value.matches("^01[016789]-?\\d{3,4}-?\\d{4}$"); } } // ③ 사용 public class UserCreateRequest { @ValidPhone private String phone; }
public class OrderCreateRequest { @NotNull private String productName; @Valid // ← 중첩 객체도 검사하려면 @Valid 붙여야 함! @NotNull private Address address; } public class Address { @NotBlank private String street; @NotBlank private String city; } // @Valid 없으면 Address 안의 @NotBlank 검사가 동작 안 함! // 중첩 객체 검사할 때 @Valid 꼭 붙여야 함
// 그룹 인터페이스 정의 public interface CreateGroup {} public interface UpdateGroup {} // DTO에서 그룹 지정 public class UserRequest { @NotNull(groups = UpdateGroup.class) // 수정할 때만 필수 private Integer id; @NotBlank(groups = {CreateGroup.class, UpdateGroup.class}) // 생성/수정 둘 다 private String name; @Email(groups = CreateGroup.class) // 생성할 때만 검사 private String email; } // Controller에서 그룹 지정 @PostMapping public ResponseEntity<?> 생성( @Validated(CreateGroup.class) @RequestBody UserRequest request) { } @PutMapping("/{id}") public ResponseEntity<?> 수정( @Validated(UpdateGroup.class) @RequestBody UserRequest request) { }
// DTO에서 메시지 직접 지정 public class UserCreateRequest { @NotBlank(message = "이름은 필수입니다.") private String name; @Email(message = "이메일 형식이 올바르지 않습니다.") private String email; @Size(min=8, max=20, message = "비밀번호는 8~20자여야 합니다.") private String password; } // GlobalExceptionHandler에서 에러 메시지 꺼내기 @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<ErrorResponse> 유효성검사실패(MethodArgumentNotValidException e) { // 첫 번째 에러 메시지만 String message = e.getBindingResult() .getFieldErrors() .get(0) .getDefaultMessage(); // 모든 에러 메시지 한꺼번에 List<String> messages = e.getBindingResult() .getFieldErrors() .stream() .map(FieldError::getDefaultMessage) .collect(Collectors.toList()); return ResponseEntity.status(400) .body(new ErrorResponse(400, messages.toString(), "VALIDATION_ERROR", LocalDateTime.now())); }
// DTO @Getter public class UserCreateRequest { @NotBlank(message = "이름은 필수입니다.") @Size(min=2, max=50, message = "이름은 2~50자여야 합니다.") private String name; @NotBlank(message = "이메일은 필수입니다.") @Email(message = "이메일 형식이 올바르지 않습니다.") private String email; @NotBlank(message = "비밀번호는 필수입니다.") @Size(min=8, max=20, message = "비밀번호는 8~20자여야 합니다.") @Pattern(regexp="^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d]{8,}$", message="비밀번호는 영문과 숫자를 포함해야 합니다.") private String password; @Min(value=1, message="나이는 1 이상이어야 합니다.") @Max(value=150, message="나이는 150 이하여야 합니다.") private int age; } // Controller @PostMapping public ResponseEntity<UserResponse> 회원가입( @Valid @RequestBody UserCreateRequest request) { // @Valid 실패하면 여기까지 오지도 않음 // GlobalExceptionHandler에서 자동으로 400 처리 return ResponseEntity.status(201) .body(userService.회원가입(request)); }
DB 주소, 포트번호, 비밀번호, JPA 설정 등 애플리케이션 동작에 필요한 모든 설정값을 코드 밖에서 관리하는 파일.
# 반복이 많음 spring.datasource.url=jdbc:mysql://localhost/db spring.datasource.username=root spring.datasource.password=1234 spring.datasource.driver-class-name=com.mysql...
# 계층 구조, 깔끔함 spring: datasource: url: jdbc:mysql://localhost/db username: root password: 1234 driver-class-name: com.mysql...
들여쓰기는 반드시 스페이스(공백)만 사용. 탭(Tab) 쓰면 에러남.
보통 스페이스 2칸으로 들여쓰기.
# 문자열 name: 라희 # 숫자 port: 8080 # 불린 enabled: true # 리스트 servers: - server1 - server2 # 객체 (계층 구조) database: host: localhost port: 3306
server: port: 8080 # 포트번호 (기본값 8080) servlet: context-path: /api # 모든 URL 앞에 /api 자동으로 붙음 # /users → /api/users tomcat: max-threads: 200 # 최대 스레드 수 connection-timeout: 5000 # 연결 타임아웃 (ms)
spring:
datasource:
# MySQL
url: jdbc:mysql://localhost:3306/mydb?useSSL=false&serverTimezone=Asia/Seoul
username: root
password: 1234
driver-class-name: com.mysql.cj.jdbc.Driver
# H2 (테스트용 인메모리 DB)
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
username: sa
password:
# H2 콘솔 (브라우저에서 DB 직접 확인 가능)
h2:
console:
enabled: true
path: /h2-console # localhost:8080/h2-console 로 접속
spring:
jpa:
hibernate:
ddl-auto: update # DB 테이블 자동 생성/수정 전략
show-sql: true # 실행되는 SQL을 콘솔에 출력
properties:
hibernate:
format_sql: true # SQL을 보기 좋게 포맷팅
dialect: org.hibernate.dialect.MySQL8Dialect # DB 방언
| ddl-auto 옵션 | 동작 | 언제 쓰나 |
|---|---|---|
create | 시작할 때 테이블 새로 만듦 | 데이터 전부 삭제됨 |
create-drop | 시작할 때 만들고 종료할 때 삭제 | 테스트 환경 |
update | 변경된 것만 반영 (데이터 유지) | 개발 환경 |
validate | Entity와 테이블 맞는지 검사만 | 검증 목적 |
none | 아무것도 안 함 | 운영 환경 (강력 권장) |
logging:
level:
root: INFO # 전체 기본 로그 레벨
com.myapp: DEBUG # 내 패키지는 DEBUG
org.hibernate.SQL: DEBUG # SQL 로그
org.hibernate.type.descriptor: TRACE # SQL 파라미터 값도 출력
file:
name: logs/app.log # 로그 파일 저장 위치
INFO로 설정하면 INFO, WARN, ERROR만 출력됨
DEBUG로 설정하면 DEBUG, INFO, WARN, ERROR 전부 출력됨
src/main/resources/ ├── application.yml # 공통 설정 + 활성 profile 지정 ├── application-dev.yml # 개발 환경 설정 └── application-prod.yml # 운영 환경 설정
# application.yml — 공통 spring: profiles: active: dev # 운영 배포할 때는 prod로 바꿈 server: port: 8080 --- # application-dev.yml — 개발환경 spring: datasource: url: jdbc:h2:mem:testdb # 개발은 H2 인메모리 DB jpa: hibernate: ddl-auto: create-drop # 매번 새로 만들어도 됨 show-sql: true logging: level: com.myapp: DEBUG --- # application-prod.yml — 운영환경 spring: datasource: url: jdbc:mysql://${DB_HOST}:3306/${DB_NAME} username: ${DB_USERNAME} # 환경변수에서 읽어옴 password: ${DB_PASSWORD} jpa: hibernate: ddl-auto: none # 운영은 절대 자동 변경 안 함! show-sql: false logging: level: root: WARN file: name: logs/app.log
# application.yml jwt: secret: mySecretKey123 expiration: 3600000 // 코드에서 꺼내기 @Component public class JwtUtil { @Value("${jwt.secret}") private String secret; @Value("${jwt.expiration}") private long expiration; // 기본값 지정 — 설정이 없으면 "myapp" 사용 @Value("${jwt.issuer:myapp}") private String issuer; }
@Value → 설정값 하나씩 꺼낼 때
@ConfigurationProperties → 관련 설정값을 묶음으로 관리할 때 (필드 많을 때 권장)
# application.yml jwt: secret: mySecretKey123 expiration: 3600000 issuer: myapp refresh-expiration: 604800000 // 설정값 묶음 클래스 @ConfigurationProperties(prefix = "jwt") @Component @Getter @Setter public class JwtProperties { private String secret; private long expiration; private String issuer; private long refreshExpiration; // refresh-expiration → camelCase 자동 변환 } // 사용 @RequiredArgsConstructor public class JwtUtil { private final JwtProperties jwtProperties; public String generateToken() { return Jwts.builder() .setIssuer(jwtProperties.getIssuer()) .compact(); } }
# ❌ 절대 이렇게 하면 안 됨 — Git에 올라가면 큰일남 spring: datasource: password: 실제비밀번호1234 # ✅ 환경변수로 관리 spring: datasource: username: ${DB_USERNAME} password: ${DB_PASSWORD} jwt: secret: ${JWT_SECRET} # 실행할 때 환경변수 주입 $ export DB_USERNAME=root $ export DB_PASSWORD=1234 $ java -jar app.jar # 또는 실행 시 직접 주입 $ java -jar app.jar \ --spring.datasource.username=root \ --spring.datasource.password=1234 # .gitignore에 추가해서 운영 설정 파일이 Git에 올라가지 않게 application-prod.yml
// build.gradle implementation 'com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.5' # application.yml — 암호화된 값은 ENC()로 감싸기 spring: datasource: password: ENC(암호화된값여기) jasypt: encryptor: password: ${JASYPT_PASSWORD} # 복호화 키는 환경변수로 // 암호화할 때 (직접 실행해서 암호화된 값 생성) BasicTextEncryptor encryptor = new BasicTextEncryptor(); encryptor.setPassword("복호화키"); String encrypted = encryptor.encrypt("실제비밀번호"); // 이 encrypted 값을 ENC(암호화된값여기) 안에 넣으면 됨 // 환경변수 vs Jasypt // 환경변수 → 서버 환경에서 외부로 빼서 관리 // Jasypt → yml 파일 자체에서 암호화해서 관리 // 둘 다 알아야 — 상황에 따라 골라서 씀
// dev 환경에서만 등록되는 Bean @Profile("dev") @Component public class DevDataLoader implements CommandLineRunner { private final UserRepository userRepository; @Override public void run(String... args) { // 개발환경 시작할 때 테스트 데이터 자동 생성 userRepository.save(new User("테스트유저", "[email protected]")); } } // prod 환경에서만 등록 @Profile("prod") @Component public class ProdConfig { } // dev가 아닐 때 @Profile("!dev") @Component public class NotDevConfig { }
// CommandLineRunner — String[] args로 받음 @Component public class DataLoader implements CommandLineRunner { @Override public void run(String... args) throws Exception { // 서버 시작할 때 자동으로 여기 코드 실행됨 System.out.println("서버 시작!"); } } // ApplicationRunner — ApplicationArguments로 받음 (더 편리) @Component public class DataLoader implements ApplicationRunner { @Override public void run(ApplicationArguments args) throws Exception { // java -jar app.jar --mode=test 로 실행하면 args.getOptionValues("mode"); // → ["test"] } } // @Profile("dev") + CommandLineRunner — 개발 시작할 때 테스트 데이터 넣기 @Profile("dev") @Component @RequiredArgsConstructor public class DevDataLoader implements CommandLineRunner { private final UserRepository userRepository; @Override public void run(String... args) { userRepository.save(new User("테스트유저", "[email protected]")); System.out.println("개발 테스트 데이터 생성 완료"); } }
// build.gradle implementation 'org.springframework.boot:spring-boot-starter-actuator' # application.yml management: endpoints: web: exposure: include: health, info, metrics # 노출할 엔드포인트만 지정 endpoint: health: show-details: always # 상세 health 정보 표시 # 제공하는 엔드포인트 # GET /actuator/health → 서버 살아있는지 확인 # GET /actuator/metrics → 메모리, CPU 등 지표 # GET /actuator/info → 앱 정보 # ⚠️ 운영 환경에서 전부 노출하면 위험 # include에 필요한 것만 지정하거나 Security로 보호해야 함
# application.yml — 공통 spring: profiles: active: dev --- # application-dev.yml spring: datasource: url: jdbc:h2:mem:testdb driver-class-name: org.h2.Driver username: sa password: h2: console: enabled: true path: /h2-console jpa: hibernate: ddl-auto: create-drop show-sql: true properties: hibernate: format_sql: true server: port: 8080 jwt: secret: devSecretKey123456789012345678901234 expiration: 3600000 issuer: myapp logging: level: com.myapp: DEBUG org.hibernate.SQL: DEBUG --- # application-prod.yml spring: datasource: url: jdbc:mysql://${DB_HOST}:3306/${DB_NAME} username: ${DB_USERNAME} password: ${DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: none show-sql: false properties: hibernate: dialect: org.hibernate.dialect.MySQL8Dialect server: port: 8080 servlet: context-path: /api jwt: secret: ${JWT_SECRET} expiration: 3600000 issuer: myapp logging: level: root: WARN file: name: logs/app.log management: endpoints: web: exposure: include: health