Spring · 입력 검증과 보안 기초
서버는 바깥에서 들어오는 데이터를 그대로 믿으면 안 됩니다. 이 장은 두 개의 문지기를 다룹니다. 하나는 들어온 값이 규칙에 맞는지 검사하는 Bean Validation?, 다른 하나는 "누구인지"와 "무엇을 해도 되는지"를 지키는 Spring Security? 입니다.
Spring Boot 3.3.1 / Spring Framework 6.x / Java 17 / Jakarta EE 10 기준입니다.
검증은 Jakarta Bean Validation 3.0(애너테이션 패키지 jakarta.validation.constraints.*, 구현체 Hibernate Validator),
보안은 Spring Security 6? 입니다.
Spring Security 6에서는 옛 방식인 WebSecurityConfigurerAdapter 가 제거되어,
SecurityFilterChain 빈과 람다 DSL로 설정합니다.
실제 이 프로젝트는 spring-boot-starter-validation 과 spring-boot-starter-security + JWT? 를 씁니다.
파트 A · 입력 검증 (Bean Validation)
1. 개요 — 들어오는 데이터를 자동으로 검사
Bean Validation? 은 DTO 같은 객체의 필드에 규칙(제약) 애너테이션을 붙여 두면,
스프링이 요청을 받을 때 그 규칙을 자동으로 검사해 주는 표준입니다.
코드 곳곳에 if (name == null) ... 같은 검사를 흩뿌리지 않고, 한곳에 선언만 해두는 방식이에요.
@Valid vs @Validated 차이 — 둘 다 검사를 켜지만 출신과 기능이 다릅니다.
| 구분 | @Valid | @Validated |
|---|---|---|
| 출처 | Jakarta 표준(jakarta.validation.Valid) | 스프링 전용(org.springframework...Validated) |
| 그룹(group) 지원 | 없음 | 있음 — 상황별로 규칙 묶음을 골라 적용 |
| 주 사용 위치 | 컨트롤러 파라미터, 중첩 객체 필드 | 클래스 레벨(서비스 메서드 파라미터 검증 등) |
대부분의 경우 컨트롤러에서는 @Valid 로 충분합니다. "등록할 때와 수정할 때 규칙이 다르다" 같이 그룹이 필요할 때만 @Validated 를 씁니다.
2. 제약 애너테이션 총정리
아래는 jakarta.validation.constraints 패키지의 표준 제약 애너테이션 전부입니다. 모두 message 속성으로 실패 메시지를 지정할 수 있어요(예: @NotNull(message = "이름은 필수입니다")). null 값은 대부분의 제약을 자동 통과한다는 점이 중요합니다(즉 "값이 있다면 이 규칙을 지켜라"는 뜻이라, 필수 여부는 @NotNull 계열로 따로 표시해야 합니다).
| 애너테이션 | 의미 | 주로 붙이는 타입 |
|---|---|---|
@NotNull | null 이 아니어야 함(빈 문자열·공백은 허용) | 모든 타입 |
@NotEmpty | null 도 아니고 비어 있지도 않아야 함(길이 ≥ 1) | String, Collection, Map, 배열 |
@NotBlank | null 이 아니고 공백을 뺀 글자가 하나 이상 | String(CharSequence) |
@Size(min, max) | 크기(길이/원소 수)가 min~max 사이(경계 포함) | String, Collection, Map, 배열 |
@Min | 값이 지정한 값 이상 | BigDecimal · BigInteger · 정수형(byte/short/int/long)과 래퍼 (double·float 미지원) |
@Max | 값이 지정한 값 이하 | BigDecimal · BigInteger · 정수형(byte/short/int/long)과 래퍼 (double·float 미지원) |
@DecimalMin | 값이 지정한 값 이상(소수까지 정밀 비교, 문자열로 지정) | BigDecimal · BigInteger · 문자열(CharSequence) · 정수형 (double·float 미지원) |
@DecimalMax | 값이 지정한 값 이하(소수까지 정밀 비교) | BigDecimal · BigInteger · 문자열(CharSequence) · 정수형 (double·float 미지원) |
@Positive | 0 보다 큰 양수(0 불가) | 숫자 |
@PositiveOrZero | 0 또는 양수 | 숫자 |
@Negative | 0 보다 작은 음수(0 불가) | 숫자 |
@NegativeOrZero | 0 또는 음수 | 숫자 |
@Email | 이메일 형식의 문자열 | String(CharSequence) |
@Pattern(regexp) | 지정한 정규식과 일치하는 문자열 | String(CharSequence) |
@Past | 과거의 시각/날짜 | 날짜·시간 타입(LocalDate 등) |
@PastOrPresent | 과거 또는 현재 | 날짜·시간 타입 |
@Future | 미래의 시각/날짜 | 날짜·시간 타입 |
@FutureOrPresent | 미래 또는 현재 | 날짜·시간 타입 |
@AssertTrue | 값이 true 여야 함 | boolean / Boolean |
@AssertFalse | 값이 false 여야 함 | boolean / Boolean |
@Digits(integer, fraction) | 정수부·소수부 자릿수가 허용 범위 안인 숫자 | 숫자(BigDecimal 등) |
@NotNull 은 null만 막고("" 통과), @NotEmpty 는 길이 0까지 막고(" "는 통과), @NotBlank 는 공백만 있는 문자열까지 막습니다. 사람이 입력하는 이름·제목에는 보통 @NotBlank 가 가장 안전합니다.
3. 컨트롤러에서 검증하기
요청 본문을 받는 DTO에 제약을 달고, 컨트롤러 파라미터에 @Valid 를 붙이면 끝입니다. 규칙을 어기면 스프링이 본문을 만들기 전에 MethodArgumentNotValidException 을 던집니다.
// 1) DTO — 필드마다 규칙을 선언
public record SignupRequest(
@NotBlank(message = "아이디는 필수입니다")
@Size(min = 4, max = 20, message = "아이디는 4~20자")
String username,
@NotBlank
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).{8,}$",
message = "비밀번호는 영문+숫자 8자 이상")
String password,
@Email(message = "이메일 형식이 아닙니다")
String email,
@Min(value = 0, message = "나이는 0 이상")
int age
) {}// 2) 컨트롤러 — @Valid 하나로 검사 켜기
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserEndpoint {
private final UserService userService;
@PostMapping("")
public UserDTO signup(@Valid @RequestBody SignupRequest req) {
// 여기까지 왔다면 req 는 이미 모든 규칙을 통과한 상태
return userService.create(req);
}
}검증이 실패하면 어떤 필드가 왜 틀렸는지를 MethodArgumentNotValidException 안의 BindingResult 가 담고 있습니다. 이걸 전역 예외 처리기?(@RestControllerAdvice)에서 받아 깔끔한 400(Bad Request) 응답으로 바꿔 줍니다.
// 3) 전역 검증 예외 처리 — 400 으로 변환
@RestControllerAdvice
public class ValidationExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST) // 400
public Map<String, String> handle(MethodArgumentNotValidException e) {
Map<String, String> errors = new HashMap<>();
// BindingResult 에서 필드별 메시지를 꺼낸다
for (FieldError fe : e.getBindingResult().getFieldErrors()) {
errors.put(fe.getField(), fe.getDefaultMessage());
}
return errors; // 예: { "username": "아이디는 필수입니다" }
}
}4. 커스텀 제약 애너테이션 (간단 소개)
표준 애너테이션으로 표현하기 어려운 규칙(예: "이미 가입된 아이디인지")은 직접 만들 수 있습니다. @Constraint 로 새 애너테이션을 정의하고, 실제 검사 로직은 ConstraintValidator 구현체에 넣습니다.
// 1) 애너테이션 정의 — 어떤 Validator 가 검사할지 연결
@Documented
@Constraint(validatedBy = UniqueUsernameValidator.class)
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface UniqueUsername {
String message() default "이미 사용 중인 아이디입니다";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// 2) 실제 검사 로직
@RequiredArgsConstructor
public class UniqueUsernameValidator
implements ConstraintValidator<UniqueUsername, String> {
private final UserRepo userRepo;
@Override
public boolean isValid(String value, ConstraintValidatorContext ctx) {
if (value == null) return true; // null 은 @NotNull 의 몫
return !userRepo.existsByUsername(value);
}
}
// 3) 사용 — 표준 애너테이션처럼 그냥 붙인다
public record SignupRequest(@UniqueUsername String username) {}파트 B · 보안 기초 (Spring Security 6)
5. 인증 vs 인가
보안의 두 기둥은 헷갈리기 쉽지만 역할이 분명합니다.
| 개념 | 질문 | 예 |
|---|---|---|
| 인증(authentication) | "너 누구야?" | 아이디/비밀번호 로그인, JWT? 토큰 확인 |
| 인가(authorization) | "이걸 해도 돼?" | 관리자만 삭제 가능, 본인 글만 수정 가능 |
6. SecurityFilterChain 빈으로 설정 (람다 DSL)
Spring Security 6? 에서는 옛 WebSecurityConfigurerAdapter 가 제거되었습니다.
이제는 SecurityFilterChain 을 반환하는 빈? 을 만들어, HttpSecurity 를 람다 DSL 로 설정합니다.
경로 매칭도 옛 antMatchers 가 아니라 requestMatchers, 인가 설정도 authorizeHttpRequests 를 씁니다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtFilter jwtFilter; // 직접 만든 JWT 검증 필터
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// REST + JWT 라서 CSRF·세션·폼로그인을 끈다
.csrf(csrf -> csrf.disable())
.sessionManagement(sm ->
sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.formLogin(form -> form.disable())
.httpBasic(basic -> basic.disable())
// 인가 규칙: 위에서 아래로, 먼저 맞는 규칙이 적용된다
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll() // 로그인은 누구나
.requestMatchers(HttpMethod.GET, "/api/public/**").permitAll()
.anyRequest().authenticated() // 나머지는 인증 필요
)
// 표준 인증 필터 앞에 JWT 필터를 끼워 넣는다
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}authorizeHttpRequests 의 규칙은 위에서부터 검사되어 처음 일치하는 것이 적용됩니다. 그래서 넓은 규칙(anyRequest)은 항상 맨 마지막에 둬야 합니다. STATELESS 는 서버가 세션을 만들지 않겠다는 뜻으로, 매 요청을 토큰만으로 판단하는 JWT? 방식에 맞습니다.
7. PasswordEncoder 로 비밀번호 해시
비밀번호는 절대 그대로 저장하지 않습니다. 한 방향으로만 변환되는 해시로 바꿔 저장하고, 로그인 때는 입력값을 같은 방식으로 해시해 비교합니다. 표준은 BCryptPasswordEncoder 입니다.
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // 솔트 포함, 일방향 해시
}
// 저장할 때
String hash = passwordEncoder.encode(rawPassword);
// 로그인 검증할 때 (해시끼리가 아니라 matches 로 비교)
boolean ok = passwordEncoder.matches(rawPassword, hash);8. 메서드 보안
경로 단위가 아니라 메서드 단위로도 권한을 걸 수 있습니다. @EnableMethodSecurity 를 켜고, 메서드에 권한 애너테이션을 붙입니다.
@Configuration
// 기본은 prePostEnabled=true (@PreAuthorize/@PostAuthorize 만 켜짐).
// @Secured, @RolesAllowed 를 쓰려면 옵션을 명시적으로 켜야 한다.
@EnableMethodSecurity(securedEnabled = true, jsr250Enabled = true)
public class MethodSecurityConfig {}
@Service
public class PostService {
@PreAuthorize("hasRole('ADMIN')") // 호출 전 권한 검사
public void deleteAll() { ... }
@PreAuthorize("#userId == authentication.name") // 본인만
public void updateProfile(String userId) { ... }
@PostAuthorize("returnObject.owner == authentication.name") // 반환 후 검사
public Post getMine(Long id) { ... }
@Secured("ROLE_ADMIN") // 단순 역할 확인 (예전부터 있던 방식)
public void purge() { ... }
@RolesAllowed("ADMIN") // Jakarta 표준 역할 확인
public void archive() { ... }
}Spring Security 6의 @EnableMethodSecurity 는 prePostEnabled 만 기본값이 true 입니다.
securedEnabled(@Secured)와 jsr250Enabled(@RolesAllowed)는 기본 false 라,
옵션을 켜지 않으면 두 애너테이션이 조용히 무시되어 권한 검사가 전혀 일어나지 않습니다.
위처럼 @EnableMethodSecurity(securedEnabled = true, jsr250Enabled = true) 로 켜야 합니다.
| 애너테이션 | 언제 검사 | 특징 |
|---|---|---|
@PreAuthorize | 메서드 실행 전 | SpEL 식 사용, 인자·인증정보 활용 가능(가장 많이 씀) |
@PostAuthorize | 메서드 실행 후 | 반환값(returnObject)을 보고 판단 |
@Secured | 실행 전 | 역할 이름만 나열(SpEL 불가) |
@RolesAllowed | 실행 전 | Jakarta(JSR-250) 표준 애너테이션 |
9. JWT 연계 개념
JWT?(JSON Web Token)는 사용자 정보를 담아 서명한 문자열 토큰입니다.
서버가 세션을 들고 있지 않아도(STATELESS) 토큰만 보면 누구인지 알 수 있어, 분산 서버 환경에 잘 맞습니다.
- 로그인 — 아이디/비밀번호를 확인하고 서명된 토큰을 발급해 클라이언트에 준다
- 요청 — 클라이언트가 매 요청 헤더(
Authorization: Bearer ...)에 토큰을 실어 보낸다 - 검증 필터 — 위 6번의 JWT 필터가 토큰 서명·만료를 확인하고, 통과하면 인증 정보를
SecurityContext에 채운다
토큰 발급·갱신, 필터 구현, 권한 체계(RBAC)의 실제 코드는 백엔드 트랙에서 자세히 다룹니다. 여기서는 "검증 → 인증 → 인가"의 큰 그림만 잡으면 충분합니다.
한눈에 — 제약 애너테이션 카탈로그
| 분류 | 애너테이션 | 한 줄 의미 |
|---|---|---|
| 존재/빈값 | @NotNull | null 아님 |
@NotEmpty | null 아니고 길이 ≥ 1 | |
@NotBlank | 공백 뺀 글자 ≥ 1 | |
| 크기 | @Size(min,max) | 길이/원소 수 범위 |
| 숫자 범위 | @Min / @Max | 정수 하한/상한 |
@DecimalMin / @DecimalMax | 소수 정밀 하한/상한 | |
@Positive / @PositiveOrZero | 양수 / 0 이상 | |
@Negative / @NegativeOrZero | 음수 / 0 이하 | |
@Digits | 정수·소수 자릿수 제한 | |
| 문자열 | @Email | 이메일 형식 |
@Pattern(regexp) | 정규식 일치 | |
| 날짜·시간 | @Past / @PastOrPresent | 과거 / 과거·현재 |
@Future / @FutureOrPresent | 미래 / 미래·현재 | |
| 불리언 | @AssertTrue / @AssertFalse | true 여야 / false 여야 |
한눈에 — 보안 애너테이션
| 애너테이션 | 용도 |
|---|---|
@EnableWebSecurity | 웹 보안 설정 활성화(설정 클래스에) |
@EnableMethodSecurity | 메서드 단위 보안 활성화 |
@PreAuthorize | 메서드 실행 전 권한 검사(SpEL) |
@PostAuthorize | 메서드 실행 후 반환값 기준 검사 |
@Secured | 역할 이름으로 검사(스프링) |
@RolesAllowed | 역할 이름으로 검사(Jakarta 표준) |
이 프로젝트는 모든 입력 DTO에 Bean Validation을 걸고, Spring Security 6? 의 SecurityFilterChain + JWT? 필터로 인증을 처리합니다.
실제 설정 코드와 권한 체계, 검증 오류 응답 포맷은 백엔드 트랙에서 다룹니다:
be-06 보안 · JWT 와
be-07 권한(RBAC) · 오류 처리 로 이어집니다.
다음 단계
- 객체로 DB 테이블을 다루는 방법 → JPA
- Spring 전체 개요로 돌아가기 → Spring
- 실제 코드로 배우는 보안 → be-06 보안 · JWT