View와 API를 분리해야 하는 이유

김유진·2026년 4월 22일

오늘 배운 거

목록 보기
1/3
post-thumbnail

ControllerRestController는 분리해서 관리해야 한다.

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();  
    }  
}

View와 API를 서로 분리했더니 좋은 점은?

예외처리가 매우 용이해진다.
반대로, 분리하지 않으면 예외처리가 참 거지같다.

애시당초에 분리해야겠다는 생각 자체를 한 것이 예외처리를 하면서였다.
예외처리를 하는데 어떤 건 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)에 따라 컨트롤러를 나눴다.
결국 고급진 말로 관심사의 분리를 실천했다.

관심사 분리를 통해 유지보수성을 높일 수 있었다.
앞으로도 관심사의 분리를 고려하며 코드를 짜자!

배운 점

  1. 무작정 코드를 짜기보단 '어떻게 구조화를 할 것인지' 생각할 필요가 있는 것 같다.
  2. 차분히 설계하는 시간보다 이렇게 리팩토링 해가는 과정이 더 오래걸리는거 같다(체감 상)
  3. 코드 컨벤션을 맞추는 이유를 알겠다. 왜 API 스펙을 맞추는지도...
    지금이야 혼자 개발하니까 나 혼자 끙끙대지만 다른 사람과의 협업이였으면 한대 맞았을 수 있다는 생각이 든다.
  4. 대 AI 에이전트 시대에 결국 필요한건 구현보단 이런 설계 능력이랑 규격을 정하는 능력이지 않을까? (아님 말고)

애시당초에 SSR과 CSR을 혼합해서 쓰는게 맞나 싶다

기타 알게된 점

  • @Valid를 쓰면 객체(DTO)를 검증하는 데 쓴다.
    BindingResult 안쓰면 MethodArgumentNotValidException이 터지니까 얘를 잡아주면 된다.
  • @Validated를 쓰면 파라미터 단위를 검증 할 수 있다.
    얘는 ConstraintViolationException이 터지니까 얘를 잡아주면 된다.
profile
제가 공부한 내용을 적은 것이기 때문에 틀릴 수 있습니다.

0개의 댓글