프레임워크 · Spring · 검증 · 보안 기초

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-validationspring-boot-starter-security + JWT? 를 씁니다.

파트 A · 입력 검증 (Bean Validation)

1. 개요 — 들어오는 데이터를 자동으로 검사

Bean Validation? 은 DTO 같은 객체의 필드에 규칙(제약) 애너테이션을 붙여 두면, 스프링이 요청을 받을 때 그 규칙을 자동으로 검사해 주는 표준입니다. 코드 곳곳에 if (name == null) ... 같은 검사를 흩뿌리지 않고, 한곳에 선언만 해두는 방식이에요.

서식 칸마다 "필수", "10자 이내", "이메일 형식"이라고 미리 적어 둔 양식지와 같습니다. 접수처는 그 표시만 보고 자동으로 빈칸·형식 오류를 잡아냅니다.

@Valid vs @Validated 차이 — 둘 다 검사를 켜지만 출신과 기능이 다릅니다.

구분@Valid@Validated
출처Jakarta 표준(jakarta.validation.Valid)스프링 전용(org.springframework...Validated)
그룹(group) 지원없음있음 — 상황별로 규칙 묶음을 골라 적용
주 사용 위치컨트롤러 파라미터, 중첩 객체 필드클래스 레벨(서비스 메서드 파라미터 검증 등)

대부분의 경우 컨트롤러에서는 @Valid 로 충분합니다. "등록할 때와 수정할 때 규칙이 다르다" 같이 그룹이 필요할 때만 @Validated 를 씁니다.

2. 제약 애너테이션 총정리

아래는 jakarta.validation.constraints 패키지의 표준 제약 애너테이션 전부입니다. 모두 message 속성으로 실패 메시지를 지정할 수 있어요(예: @NotNull(message = "이름은 필수입니다")). null 값은 대부분의 제약을 자동 통과한다는 점이 중요합니다(즉 "값이 있다면 이 규칙을 지켜라"는 뜻이라, 필수 여부는 @NotNull 계열로 따로 표시해야 합니다).

애너테이션의미주로 붙이는 타입
@NotNullnull 이 아니어야 함(빈 문자열·공백은 허용)모든 타입
@NotEmptynull 도 아니고 비어 있지도 않아야 함(길이 ≥ 1)String, Collection, Map, 배열
@NotBlanknull 이 아니고 공백을 뺀 글자가 하나 이상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 미지원)
@Positive0 보다 큰 양수(0 불가)숫자
@PositiveOrZero0 또는 양수숫자
@Negative0 보다 작은 음수(0 불가)숫자
@NegativeOrZero0 또는 음수숫자
@Email이메일 형식의 문자열String(CharSequence)
@Pattern(regexp)지정한 정규식과 일치하는 문자열String(CharSequence)
@Past과거의 시각/날짜날짜·시간 타입(LocalDate 등)
@PastOrPresent과거 또는 현재날짜·시간 타입
@Future미래의 시각/날짜날짜·시간 타입
@FutureOrPresent미래 또는 현재날짜·시간 타입
@AssertTrue값이 true 여야 함boolean / Boolean
@AssertFalse값이 false 여야 함boolean / Boolean
@Digits(integer, fraction)정수부·소수부 자릿수가 허용 범위 안인 숫자숫자(BigDecimal 등)
"빈칸"을 막는 세 가지의 차이

@NotNullnull만 막고("" 통과), @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)"이걸 해도 돼?"관리자만 삭제 가능, 본인 글만 수정 가능
인증은 신분증으로 "당신이 홍길동임"을 확인하는 단계, 인가는 "홍길동은 3층까지만 출입 가능"처럼 권한을 확인하는 단계입니다. 인증을 통과해야 인가를 따집니다.

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);
해시는 과일을 갈아 만든 주스 같아서, 주스를 다시 원래 과일로 되돌릴 수 없습니다. 그래서 DB가 유출돼도 원래 비밀번호는 알 수 없습니다. BCrypt는 매번 다른 "솔트"를 섞어, 같은 비밀번호라도 저장값이 달라집니다.

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() { ... }
}
@Secured · @RolesAllowed 는 기본 꺼져 있다

Spring Security 6의 @EnableMethodSecurityprePostEnabled 만 기본값이 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)의 실제 코드는 백엔드 트랙에서 자세히 다룹니다. 여기서는 "검증 → 인증 → 인가"의 큰 그림만 잡으면 충분합니다.

한눈에 — 제약 애너테이션 카탈로그

분류애너테이션한 줄 의미
존재/빈값@NotNullnull 아님
@NotEmptynull 아니고 길이 ≥ 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 / @AssertFalsetrue 여야 / false 여야

한눈에 — 보안 애너테이션

애너테이션용도
@EnableWebSecurity웹 보안 설정 활성화(설정 클래스에)
@EnableMethodSecurity메서드 단위 보안 활성화
@PreAuthorize메서드 실행 권한 검사(SpEL)
@PostAuthorize메서드 실행 반환값 기준 검사
@Secured역할 이름으로 검사(스프링)
@RolesAllowed역할 이름으로 검사(Jakarta 표준)
이 프로젝트와의 관계

이 프로젝트는 모든 입력 DTO에 Bean Validation을 걸고, Spring Security 6?SecurityFilterChain + JWT? 필터로 인증을 처리합니다. 실제 설정 코드와 권한 체계, 검증 오류 응답 포맷은 백엔드 트랙에서 다룹니다: be-06 보안 · JWTbe-07 권한(RBAC) · 오류 처리 로 이어집니다.

다음 단계

  • 객체로 DB 테이블을 다루는 방법 → JPA
  • Spring 전체 개요로 돌아가기 → Spring
  • 실제 코드로 배우는 보안 → be-06 보안 · JWT