1. Controller에서의 예외처리 (@Valid 기반)
- RequestDto에 여러 어노테이션 기반으로 제약을 걸고, 이를 Controller 단의 RequestBody에 @Valid 어노테이션을 추가하는 방안입니다.
- Controller 진입 전 Dto 내부 필드의 유효성을 간단하게 검사할 때 주로 사용하며, 유효성 검사 실패 시 GlobalExceptionHandler와 연계하여 관련 예외를 손쉽게 처리할 수 있다는 특징이 있습니다.
- 다만 필드에 대한 더 복잡한 유효성 검사의 경우 온전하게 수행하기 어려우며, 이를 위해 Service 내부에서 Validator 객체를 활용하거나 AOP 기반으로 추가적인 유효성 검사를 추가하기도 합니다.
- 또한 @Valid는 기본적으로 Controller 계층에서만 동작하며 다른 계층에서는 검증이 되지 않습니다. 다른 계층에서 파라미터를 검증하기 위해서는 @Validated와 결합되어야 합니다.
build.gradle
implementation 'org.springframework.boot:spring-boot-starter-validation'
JoinRequestDto
@Getter
@NoArgsConstructor
public class JoinRequestDto {
@NotNull(message = "이름은 필수 입력 값입니다.")
@NotBlank(message = "이름은 공백일 수 없습니다.")
private String name;
@Email(message = "이메일 형식이 아닙니다.")
private String email;
@NotNull(message = "비밀번호는 필수 입력 값입니다.")
@NotBlank(message = "비밀번호는 공백일 수 없습니다.")
private String password;
@Range(min = 1, max = 100, message = "1~100 사이의 값을 입력해주세요.")
private int size;
}
- RequestDto 내부 필드에 대한 여러 제약을 어노테이션으로 추가합니다.
- @NotNull:
null
인 경우를 허용하지 않음
- @NotEmpty:
null
과 ""
둘 다 허용하지 않음
- @NotBlank:
null
과 ""
과 " "
모두 허용하지 않음
- @DecimalMin(value = ??): value 미만인 경우를 허용하지 않음
- @DecimalMax(value = ??): value 초과인 경우를 허용하지 않음
- @Email: 이메일 형식에 부합해야 함
- @Range(min = 1, max = 5): 값이 1 이상 5 이하여야 함
AuthController
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/user")
public class AuthController {
private final AuthService authService;
@PostMapping("/join")
public ResponseEntity<Void> join(@RequestBody @Valid ****JoinRequestDto dto) {
authService.join(dto);
return ResponseEntity.ok().build();
}
- 이렇게 @Valid를 통해 Dto 객체 내부 필드에 대한 유효성 검사를 진행할 수 있습니다.
GlobalExceptionHandler
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(value = {MethodArgumentNotValidException.class})
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, String> handleMethodArgumentNotValidException(MethodArgumentNotValidException exception) {
Map<String, String> errors = new HashMap<>();
exception.getBindingResult().getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage())
);
return errors;
}
}
- Valid로 인한 필드 유효성 검사가 실패할 경우 Spring에서는 MethodArgumentNotValidException을 발생시킵니다.
- 이는 GlobalExceptionHandler에서 관리하여, 어떤 필드가 왜 유효성 검사에 실패하는지에 대한 메시지를 반환하도록 설정할 수 있습니다.
- 실제로 @Valid 어노테이션이 없다면 500이 뜨지만, @Valid 어노테이션이 존재할 경우 의도한 대로 <필드, 에러메시지>의 형태로 응답이 반환되는 것을 확인할 수 있습니다.
2. Service에서의 예외처리 (AOP 기반)
- 비즈니스 로직에서 DB에 접근하는 것을 통해 유효성 검사가 진행되는 경우도 있습니다.
- 예: 이메일 중복 여부 검증, 비밀번호 정/오 판정, PK로 Entity 존재 여부 판정 등
- 이렇게 Service Layer에서 예외처리를 진행할 경우 AOP 기반으로 수행할 수 있습니다.
AuthService
@Transactional
public void join(JoinRequestDto dto) {
String name = dto.getName();
String email = dto.getEmail();
String password = dto.getPassword();
if(memberRepository.existsByEmail(email)) {
throw new IllegalArgumentException("이미 존재하는 회원입니다.");
}
memberRepository.save(Member.of(name, passwordEncoder.encode(password), email));
}
- 이 메서드는 DB에 접근하여 이메일로 유저의 중복 여부를 판정하는 유효성 검사가 존재합니다.
- 반환 객체가 없기에 별도로 분리해도 무방합니다.
- private 메서드로 분리하는 것이 가장 간단한 방법이지만, 다른 Service에서도 유효성 검사 내용이 반복된다면 Spring AOP 기반으로 분리할 수 있습니다.
CheckEmailDuplicate
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckEmailDuplicate {
}
AuthValidationAspect
@Aspect
@Component
@RequiredArgsConstructor
public class AuthValidationAspect {
private final MemberRepository memberRepository;
@Before("@annotation(csw.practice.security.annotation.CheckEmailDuplicate) && args(dto)")
public void checkEmailDuplicate(JoinRequestDto dto) {
if (memberRepository.existsByEmail(dto.getEmail())) {
throw new IllegalArgumentException("이미 존재하는 회원입니다.");
}
}
}
- 특정 어노테이션을 조건으로 하여 메서드를 실행하여, @Before, @After, @Around 등으로 구체적인 실행 시점을 지정할 수 있습니다.
AuthService
@CheckEmailDuplicate
@Transactional
public void join(JoinRequestDto dto) {
String name = dto.getName();
String email = dto.getEmail();
String password = dto.getPassword();
memberRepository.save(Member.of(name, passwordEncoder.encode(password), email));
}
- 유효성 검사 과정을 AOP 메서드로 분리하였습니다.
- 이렇게 이메일 유효성 검사가 AOP 메서드에서 실행되며 Validation이 정상적으로 동작하는 것을 확인할 수 있습니다.
부록: AOP를 활용한 로깅
- AOP 기반으로 특정 메서드의 실행 시간을 측정할 수도 있습니다.
MeasureTime
@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface MeasureTime {
}
@MeasureTime
@CheckEmailDuplicate
@Transactional
public void join(JoinRequestDto dto) {
String name = dto.getName();
String email = dto.getEmail();
String password = dto.getPassword();
memberRepository.save(Member.of(name, passwordEncoder.encode(password), email));
}
LogAspect
@Slf4j
@Aspect
@Component
public class LogAspect {
@Pointcut("@annotation(csw.practice.security.annotation.MeasureTime)")
private void timer(){}
@Around("timer()")
public void loggingExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
joinPoint.proceed();
stopWatch.stop();
long totalTimeMillis = stopWatch.getTotalTimeMillis();
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String methodName = signature.getMethod().getName();
log.info("실행 메서드: {}, 실행시간 = {}ms", methodName, totalTimeMillis);
}
}
- @Pointcut을 통해 어디에서 AOP가 적용될지를 지정합니다.
- @Around를 통해 메서드 실행 전후에 특정 로직을 실행할 수 있습니다. 위 로직에서는 메서드가 실행되기 전과 후에 시간을 측정하고 그 결과를 로그로 남기는 역할을 합니다.
- ProceedingJoinPoint는 현재 JointPoint, 즉 AOP가 적용된 메서드에 대한 정보를 담고 있는 객체입니다. 이 객체를 통해 대상 메서드를 실행하거나 메서드에 대한 다양한 정보를 가져을 수 있습니다.