Controller와 RestController는 분리해서 관리해야 한다.View와 Model을 함께 제공하는 Controller,
json 데이터만 딸랑 던져주는 RestController를 지금까지 하나의 클래스에서 혼용했다.
코드를 짜면서도 스스로 짜증났다.
굳이 굳이 @ResponseBody를 붙여줘야 하는게 너무 귀찮고 짜증났다.
하지만 둘은 성격이 매우 다르기 때문에 추후 유지보수를 위해서라도 분리할 필요가 있었다.
단순히 view만 넘겨주는 컨트롤러는 그냥 ViewResolver에게 위임했다.
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/").setViewName("index");
registry.addViewController("/main").setViewName("main");
registry.addViewController("/login").setViewName("login");
registry.addViewController("/signup").setViewName("signup");
registry.addViewController("/signup/set-username").setViewName("signup/set-username");
}
}
그리고 json 데이터만 넘겨주는 RestController들(API 담당)만 따로 모아 클래스를 만들었다.
@RestController
@RequiredArgsConstructor
@Validated
@Slf4j
public class UserController {
private final UserService userService;
@GetMapping("/users/{userId}")
public ResponseEntity<UserInfo> getUser(@PathVariable Long userId) { // <<< 파라미터 검증 필요
UserInfo userinfo = userService.getUser(userId);
return ResponseEntity.ok().body(userinfo);
}
/**
* 로컬 회원가입 요청 로직 (OAuth X)
*/
@PostMapping("/api/v1/users")
public ResponseEntity<Void> createUser(@Valid @RequestBody UserCreateRequest request) {
userService.signUp(request);
log.info("회원 등록 완료 : {}", request.username());
return ResponseEntity.ok().build();
}
/**
* OAuth로 회원가입 시 username을 따로 입력받는 컨트롤러
*/
@PostMapping("/api/v1/users/username")
public ResponseEntity<Void> setUsername(@NotBlank @Size(min=4, max=20) @RequestParam("username") String username,
@AuthenticationPrincipal OAuth2User oAuth2User) {
log.debug("입력받은 아이디 : {}", username);
String email = oAuth2User.getAttribute("email");
// --> 사용자 식별용 이메일
userService.updateUsername(email, username);
return ResponseEntity.ok().build();
}
}
예외처리가 매우 용이해진다.
반대로, 분리하지 않으면 예외처리가 참 거지같다.
애시당초에 분리해야겠다는 생각 자체를 한 것이 예외처리를 하면서였다.
예외처리를 하는데 어떤 건 String을 반환하고 어떤 건 ResponseEntity를 반환하고 하니 자꾸 꼬였다.
그래서 view 컨트롤러와 API 컨트롤러의 Advice를 각각 나눴다.
@Slf4j
@ControllerAdvice(annotations = Controller.class)
public class ViewExceptionAdvice {
@ExceptionHandler({EntityNotFoundException.class, EmailNotFoundException.class})
public String handleNotFoundException(RuntimeException e, Model model) {
log.error("Not found Exception : {}", e.getMessage(), e);
model.addAttribute("exception", e.getMessage());
return "error";
}
}
얘는 view만 넘겨준다.
@Slf4j
@RestControllerAdvice(annotations = RestController.class)
public class ApiExceptionAdvice {
/**
* 회원가입시, email이나, username이 중복된 경우 예외 처리
*/
@ExceptionHandler({EmailDuplicateException.class, UsernameDuplicateException.class})
public ResponseEntity<List<ErrorResponseDto>> handleDuplicateExceptions(RuntimeException e) {
String field = (e instanceof EmailDuplicateException) ? "email" : "username";
// List.of()를 사용해서 딱 하나의 에러만 담긴 리스트 생성
List<ErrorResponseDto> errors = List.of(new ErrorResponseDto(field, e.getMessage()));
return ResponseEntity.status(HttpStatus.CONFLICT).body(errors);
}
/**
* @Valid에 의해서 검증하다 예외가 터지면 얘가 처리함
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<List<ErrorResponseDto>> handleValidationExceptions(MethodArgumentNotValidException e) {
List<ErrorResponseDto> errors = e.getBindingResult().getFieldErrors().stream()
.map(error -> new ErrorResponseDto(error.getField(), error.getDefaultMessage()))
.toList();
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errors);
}
/**
* @Valided에 의해서 파라미터만 검증하다 터지는 예외 처리
*/
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<List<ErrorResponseDto>> handleConstraintViolationException(ConstraintViolationException e) {
List<ErrorResponseDto> errors = e.getConstraintViolations().stream()
.map(violation -> {
String propertyPath = violation.getPropertyPath().toString();
String fieldName = propertyPath.substring(propertyPath.lastIndexOf('.') + 1);
return new ErrorResponseDto(fieldName, violation.getMessage());
})
.toList();
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errors);
}
}
js에 던져줄 적당한 양식을 맞췄고, 중복 코드도 줄었을 뿐더러 가독성도 높아졌다.
얘는 이제 js에 던져줄 List<ErrorResponseDto만 잘 포장해서 넘겨주면 된다.
⚠ 주의할 점
@RestControllerAdvice(annotations = RestController.class)advice에 이런 식으로 못박아버리면
@RestController가 붙은 애한테만 반응한다.
고로, 맨 처음 내가 했던 것처럼 view Controller와 섞인 클래스에는 반응을 안한다!
응답의 형태(HTML vs JSON)에 따라 컨트롤러를 나눴다.
결국 고급진 말로 관심사의 분리를 실천했다.관심사 분리를 통해 유지보수성을 높일 수 있었다.
앞으로도 관심사의 분리를 고려하며 코드를 짜자!
애시당초에 SSR과 CSR을 혼합해서 쓰는게 맞나 싶다
@Valid를 쓰면 객체(DTO)를 검증하는 데 쓴다.BindingResult 안쓰면 MethodArgumentNotValidException이 터지니까 얘를 잡아주면 된다. @Validated를 쓰면 파라미터 단위를 검증 할 수 있다.ConstraintViolationException이 터지니까 얘를 잡아주면 된다.