지금부터는 스프링 부트 개발에 어떤 정답이 있는 것은 아니지만 여러 서비스를 개발하면서 정해진 개발 패턴을 정리하려고 해요.
VO(Value Object) 또는 DTO (Data Transfer Object) 객체는 주로 데이터를 저장하고 전달하는데 사용되며, 불변하는 객체에요. 어떤 특별한 형태의 클래스는 아니고 데이터 속성을 지닌 일반적인 클래스에요 (Java 14 이상 부터는 Record라는 키워드를 사용할 수 있어요.
VO를 사용하는 가장 큰 이유는 API 설계 문서를 가장 잘 표현할 수 있는 방법이기 때문이에요. VO 객체를 사용하면 API 문서 상에 데이터 이름, 제약 조건 등을 스프링 Validation과 Jackson 라이브러리의 어노테이션을 통해 표현할 수 있어요.
사용자 정보와 관련된 API를 추가하는 예시를 볼게요.
@Getter
@Setter
public class UserInfo {
@NotBlank(message = "이름은 필수입니다.")
@Size(min = 2, max = 10, message = "이름은 2자 이상 10자 이하이어야 합니다.")
private String name;
@Email(message = "이메일 형식이 올바르지 않습니다.")
private String email;
@JsonProperty("birth_date") // JSON 필드명과 DTO 필드명 매핑
@JsonFormat(pattern = "yyyy-MM-dd") // 날짜 형식 지정
@Past(message = "생년월일은 과거 날짜여야 합니다.")
private Date birthDate;
@Min(value = 1, message = "나이는 1 이상이어야 합니다.")
private int age;
@Pattern(regexp = "^01(?:0|1|[6-9])-\\d{3,4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.")
private String phone;
}
@NotBlank, @Size, @Email, @Past, @Min, @Pattern은 모두 스프링 Validation을 활용한 모습이고 @JsonProperty, @JsonFormat은 Jackson를 사용한 모습이에요.
@JsonProperty는 실제 데이터와 속성 이름 간에 이름 형식이 다를 경우 매핑하는 목적으로 사용돼요. 요청 데이터에서 객체로 (Serialization) 변환할 때나 객체에서 응답 데이터(Deserialization)로 변환 시에 모두 적용돼요.
이제 상기 VO 객체를 사용하는 컨트롤러를 선언해요.
@RestController
@RequestMapping("/api/users")
@Validated // 클래스 레벨 유효성 검증 활성화
public class UserController {
@PostMapping
public ResponseEntity<UserInfo> createUser(@RequestBody @Valid UserInfo userInfo) {
// 유효성 검증 통과 후 사용자 정보 저장
return new ResponseEntity<>(userInfo, HttpStatus.CREATED);
}
}
@Validated, @Valid 어노테이션을 통해 사용자 정보를 저장하는 요청(createUser)에 대한 데이터 유효성 검증을 수행해요.
이제 유효성 검증 실패에 대한 예외 처리를 위해 @ControllerAdivce를 통해 MethodArgumentNotValidException 예외 처리를 위한 Exception Handler를 선언해요.
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import java.util.HashMap;
import java.util.Map;
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Object> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage()));
return new ResponseEntity<>(errors, HttpStatus.BAD_REQUEST);
}
}
저는 위의 @ControllerAdvice 설정에서 모든 오류 응답을 처리하고 있어요. 서비스 로직에서 예외가 발생하거나 오류를 응답해야할 경우 그에 맞는 Exception을 정의하고 throw하고 있어요.
발생한 예외는 @Service나 Controller에서는 처리되지 않고 @ControllerAdvice을 통해 처리되도록 하고 있어요. 위 예시에 몇 가지 요소를 더 해볼게요.
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
@Validated // 클래스 레벨 유효성 검증 활성화
public class UserController {
private final UserService userServie;
@PostMapping
public ResponseEntity<UserInfo> createUser(@RequestBody @Valid UserInfo userInfo) throws Exception {
return userServie.createUser(userInfo);
}
}
실제 서비스 로직을 처리하기 위한 서비스 빈userService을@RequiredArgsConstructor 활용해 생성자를 통해 주입받도록 변경했어요. 그리고 서비스안에서 발생한 예외를 모두 넘기도록 추가했어요 throws Exception.
서비스 로직을 처리하기 위한 @Service 클래스를 선언해요. 지금은 서비스 로직을 옮긴 큰 의미가 없지만 추후에 의존성이 추가되고 Spring Async, Spring Cache 같은 기능들을 추가하다보면 필요해지기 때문에 미리 분리해두어요.
@Service
public class UserService {
public ResponseEntity<UserInfo> createUser(UserInfo userInfo) throws UserExistException {
isExist(userInfo)
return new ResponseEntity<>(userInfo, HttpStatus.CREATED);
}
// 사용자가 이미 등록되어 있는지 확인
private void isExist(UserInfo userInfo) throws UserExistException {
...
}
}
해당 서비스는 사용자 정보가 이미 등록되어 있는지 확인하는 로직이 추가되었어요. 이에 따라 이미 등록된 사용자에 대한 오류 응답을 위해 UserExistException 예외를 던지고 있어요.
이제 상기 UserExistException을 추가하고 @ControllerAdvice을 통해 해당 케이스에 대한 오류 응답을 설정할게요.
public class extends UserExistException {
...
}
이 Exception에 부가적인 정보 (오류 코드, 메시지, 설명 등)을 담도록 설계할 수도 있어요.
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(UserExistException.class)
public String handleUserExistException(UserExistException ex) {
return "해당 사용자가 이미 존재합니다.";
}
}
이런식으로 처리하면 실제 기능을 수행하는 서비스 로직 코드가 간결해지고 API 간의 공통된 오류를 한번에 처리할 수 있어서 재사용성이 높아져요.