Controller의 책임을 떼어내며 — 뷰 렌더링과 파라미터 바인딩

KwonMoYang·2026년 4월 27일

TL;DR: Controller에는 비즈니스 로직 외에도 파라미터 파싱·뷰 결정·렌더링 책임이 섞여있다. 이 셋을 단계별로 떼어내면 Controller는 진짜 컨트롤러 역할만 남고, 결과적으로 Spring MVC와 거의 같은 구조가 만들어진다.


0. 들어가며

지난 글에서 프론트 컨트롤러 패턴으로 공통 로직을 DispatcherServlet에 모았다. 하지만 Controller 자체에는 여전히 여러 책임이 섞여 있었다.

public class LectureController implements Controller {
    @Override
    public void handleRequest(HttpServletRequest req, HttpServletResponse resp) throws Exception {
        // ① 파라미터 파싱
        String title = req.getParameter("title");
        int price = Integer.parseInt(req.getParameter("price"));

        // ② 비즈니스 로직
        Lecture lecture = new Lecture(title, price);
        lectureRepository.put(lecture.getId(), lecture);

        // ③ 뷰 결정 + ④ 렌더링
        RequestDispatcher dispatcher = req.getRequestDispatcher("lecture-list.jsp");
        dispatcher.forward(req, resp);
    }
}

한 메서드 안에 네 가지 책임이 모두 들어있다. 이번 글에서 이 책임들을 단계별로 떼어내본다. 끝나면 Controller에는 ②번 비즈니스 로직만 남는다.


1. View 추상화 — 렌더링 책임을 분리하기

출발점: JSP 렌더링 코드의 정체

기존 코드에서 JSP를 그릴 때 쓴 두 줄을 다시 보자.

final RequestDispatcher requestDispatcher = req.getRequestDispatcher("lecture-list.jsp");
requestDispatcher.forward(req, resp);

이 두 줄이 하는 일은 단순하다 — 렌더링할 파일 이름을 받아서, HttpServletRequest와 HttpServletResponse를 가지고 그 파일을 그린다. 입력 셋이 명확하다는 건 객체로 추출할 수 있다는 뜻이다.

public class JspView {
    private final String viewName;

    public JspView(final String viewName) {
        this.viewName = viewName;
    }

    public void render(final HttpServletRequest req, final HttpServletResponse res)
            throws ServletException, IOException {
        final RequestDispatcher dispatcher = req.getRequestDispatcher(viewName);
        dispatcher.forward(req, res);
    }
}

Controller에서는 이렇게 쓴다.

final JspView view = new JspView("lecture-list.jsp");
view.render(req, resp);

다른 템플릿 엔진을 끼우면 — 인터페이스의 필요

JSP 외에 단순 HTML 파일도 그리고 싶다면, 같은 모양의 클래스를 하나 더 만들 수 있다.

public class HtmlView {
    private final String viewName;

    public HtmlView(final String viewName) { this.viewName = viewName; }

    public void render(final HttpServletRequest req, final HttpServletResponse res) throws IOException {
        final String content = readViewFile(req);
        res.setContentType("text/html;charset=utf-8");
        res.getWriter().print(content);
    }

    private String readViewFile(final HttpServletRequest req) {
        // 파일 읽어서 문자열로 반환
        // ...
    }
}

문제는 타입이 다르다는 것이다. Controller에서 어떤 날은 JspView 를, 어떤 날은 HtmlView 를 쓰려면 변수 타입을 매번 바꿔야 한다. 둘이 정확히 같은 모양인데도.

해결은 명확하다 — 공통 인터페이스로 묶는 것.

public interface View {
    void render(HttpServletRequest req, HttpServletResponse res) throws Exception;
}

public class JspView implements View { /* ... */ }
public class HtmlView implements View { /* ... */ }

이제 Controller는 View 타입만 알면 된다. 어떤 구현체인지는 신경 쓸 필요 없다.

여전히 남은 한계

이렇게 했어도 Controller가 View를 직접 생성하고 호출하는 구조다.

final View view = new JspView("lecture-list.jsp");
view.render(req, resp);

Controller가 "어떤 클래스의 View를 쓸지", "어떤 파일을 그릴지" 를 직접 알아야 한다. 비즈니스 로직과 뷰 결정 로직이 같은 메서드 안에 같이 산다는 뜻이다.


2. ModelAndView — Controller는 "무엇을" 만 알면 된다

발상의 전환

Controller가 View 인스턴스를 직접 만들지 말고, "어떤 뷰를 그려달라"는 의도만 반환 하면 어떨까. 실제 View 생성과 호출은 DispatcherServlet이 처리한다.

이때 필요한 정보는 두 가지다.

  • View 이름 — 어떤 화면을 그릴지 ("lecture-list")
  • Model — 그 화면에 넘길 데이터 (Map<String, Object>)

이 둘을 묶은 게 ModelAndView 다.

public class ModelAndView {
    private final String viewName;
    private final Map<String, Object> model = new HashMap<>();

    public ModelAndView(final String viewName) {
        this.viewName = viewName;
    }

    public ModelAndView(final String viewName, final Map<String, Object> model) {
        this.viewName = viewName;
        this.model.putAll(model);
    }

    public String getViewName() { return viewName; }
    public Map<String, Object> getModel() { return Collections.unmodifiableMap(model); }
}

Controller 인터페이스도 이걸 반환하도록 바꾼다.

@FunctionalInterface
public interface Controller {
    ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception;
}

Controller의 모습

public class LectureController implements Controller {
    @Override
    public ModelAndView handleRequest(HttpServletRequest req, HttpServletResponse resp) {
        if ("GET".equals(req.getMethod())) {
            Collection<Lecture> lectures = lectureRepository.values();

            Map<String, Object> model = new HashMap<>();
            model.put("lectures", lectures);

            return new ModelAndView("lecture-list", model);
        }
        // ...
    }
}

req.getRequestDispatcher() 도, View 객체 생성도 사라졌다. Controller는 "lecture-list 라는 화면에, lectures 데이터를 넘겨라" 는 의도만 반환한다.

ViewResolver — 이름을 객체로 바꿔주는 다리

DispatcherServlet은 Controller가 반환한 ModelAndView 의 view 이름을 받아서, 실제 View 인스턴스를 찾아야 한다. 이 역할을 하는 게 ViewResolver 다.

public interface ViewResolver {
    View resolveViewName(String viewName) throws Exception;
}

public class JspViewResolver implements ViewResolver {
    @Override
    public View resolveViewName(String viewName) {
        return new JspView("/WEB-INF/views/" + viewName + ".jsp");
    }
}

Controller는 "lecture-list" 라는 짧은 이름만 반환하고, ViewResolver가 그걸 /WEB-INF/views/lecture-list.jsp 로 해석한다. 컨트롤러 코드에서 경로와 확장자가 사라진다.

DispatcherServlet에서 렌더링 처리

@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) {
    Controller controller = controllerMapping.get(req.getRequestURI());

    try {
        ModelAndView mav = controller.handleRequest(req, resp);
        render(mav, req, resp);
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
}

private void render(ModelAndView mav, HttpServletRequest req, HttpServletResponse resp) throws Exception {
    View view = viewResolver.resolveViewName(mav.getViewName());
    view.render(mav.getModel(), req, resp);
}

이제 흐름이 이렇게 된다.

  1. DispatcherServlet이 요청을 받는다.
  2. URL에 매핑된 Controller를 찾아서 호출한다.
  3. Controller가 ModelAndView 를 반환한다.
  4. DispatcherServlet이 ViewResolver로 View를 찾는다.
  5. View가 Model을 가지고 렌더링한다.

Controller는 1·4·5에 관여하지 않는다. 2·3에서 비즈니스 로직 + 의도만 반환하면 끝이다.

리다이렉트는 어떻게?

리다이렉트는 forward와 다르다. forward는 서버 안에서 다른 뷰를 그리는 것이고, 리다이렉트는 클라이언트에게 "다른 URL로 다시 요청해라"고 응답하는 것이다.

이 차이를 ViewResolver가 처리하면 된다 — view 이름이 redirect: 로 시작하면 RedirectView 를 반환하도록.

public class JspViewResolver implements ViewResolver {
    private static final String REDIRECT_PREFIX = "redirect:";

    @Override
    public View resolveViewName(String viewName) {
        if (viewName.startsWith(REDIRECT_PREFIX)) {
            return new RedirectView(viewName.substring(REDIRECT_PREFIX.length()));
        }
        return new JspView("/WEB-INF/views/" + viewName + ".jsp");
    }
}

Controller는 그저 return new ModelAndView("redirect:/lectures") 만 하면 된다. 리다이렉트인지 아닌지의 처리는 프레임워크가 알아서 한다.


3. 파라미터 바인딩 — 입력도 객체로 받기

여기까지 오면 Controller는 출력(View 결정·렌더링)에서는 손을 뗐다. 그런데 입력 쪽은 여전히 raw API를 직접 호출한다.

public ModelAndView handleRequest(HttpServletRequest req, HttpServletResponse resp) {
    String title = req.getParameter("title");
    int price = Integer.parseInt(req.getParameter("price"));
    int capacity = Integer.parseInt(req.getParameter("capacity"));
    // ↑ Controller가 매번 파라미터를 하나씩 꺼내고 타입 변환

    Lecture lecture = new Lecture(title, price, capacity);
    // ...
}

이게 새 도메인이 추가될 때마다, 새 메서드가 추가될 때마다 반복된다. 요청 파라미터를 객체로 변환하는 일 자체가 또 다른 보일러플레이트가 됐다.

발상: 객체로 받을 수 있다면

Controller가 이런 모양이 되면 어떨까.

public ModelAndView createLecture(LectureCreateRequest request) {
    Lecture lecture = new Lecture(request.getTitle(), request.getPrice(), request.getCapacity());
    lectureRepository.put(lecture.getId(), lecture);
    return new ModelAndView("redirect:/lectures");
}

req.getParameter() 호출이 사라졌다. 비즈니스 로직만 남았다.

이걸 가능하게 하려면, DispatcherServlet이 Controller 메서드의 매개변수 타입을 보고, HttpServletRequest의 파라미터들을 그 타입의 객체로 채워서 넘겨줘야 한다. 이 변환 책임을 가진 컴포넌트가 필요하다.

파라미터 바인딩의 동작 원리

핵심 단계는 셋이다.

  1. 타입 분석 — 리플렉션으로 파라미터 타입을 본다 (LectureCreateRequest.class).
  2. 인스턴스 생성 — 그 타입의 객체를 만든다 (기본 생성자 호출).
  3. 값 주입 — 객체의 각 필드 이름과 같은 키의 파라미터를 request 에서 꺼내, 타입 변환 후 필드에 채운다.

단순화하면 이런 로직이다.

public <T> T bind(HttpServletRequest req, Class<T> type) throws Exception {
    T instance = type.getDeclaredConstructor().newInstance();
    for (Field field : type.getDeclaredFields()) {
        String value = req.getParameter(field.getName());
        if (value == null) continue;

        field.setAccessible(true);
        field.set(instance, convertType(value, field.getType()));
    }
    return instance;
}

private Object convertType(String value, Class<?> type) {
    if (type == String.class)  return value;
    if (type == int.class)     return Integer.parseInt(value);
    if (type == long.class)    return Long.parseLong(value);
    if (type == boolean.class) return Boolean.parseBoolean(value);
    // ...
    throw new IllegalArgumentException("Unsupported type: " + type);
}

실무 프레임워크는 여기서 훨씬 더 많은 일을 한다 — JSON 바디 처리, 중첩 객체, 컬렉션, 날짜 포맷, 제약 조건 검증(@Valid). 하지만 본질은 위 코드와 같다 — 리플렉션으로 타입을 보고 값을 채운다.

Spring MVC의 @RequestParam, @RequestBody, @ModelAttribute 가 정확히 이 일을 한다. 어떤 어노테이션인가에 따라 값을 어디서 꺼낼지(쿼리 파라미터, JSON 바디, 폼 데이터)만 다를 뿐, 본질은 같다.

Controller의 최종 모습

public class LectureController {
    public ModelAndView list() {
        Map<String, Object> model = new HashMap<>();
        model.put("lectures", lectureRepository.values());
        return new ModelAndView("lecture-list", model);
    }

    public ModelAndView create(LectureCreateRequest request) {
        Lecture lecture = new Lecture(request.getTitle(), request.getPrice());
        lectureRepository.put(lecture.getId(), lecture);
        return new ModelAndView("redirect:/lectures");
    }
}

이 코드에서 HTTP에 대한 흔적이 거의 사라졌다. HttpServletRequest, HttpServletResponse 를 직접 다루지 않는다. 입력은 객체로 받고, 출력은 의도(ModelAndView)로 반환한다. 진짜 컨트롤러 — 흐름의 시작점 — 만 남았다.


4. 도착한 곳

이번 글에서 한 단계씩 떼어낸 책임을 정리하면 이렇다.

단계떼어낸 책임그 책임을 받은 컴포넌트
Stage 1렌더링 코드View 인터페이스
Stage 2View 인스턴스 생성 + 호출ModelAndView + ViewResolver
Stage 3파라미터 파싱 + 타입 변환파라미터 바인더

지난 글까지의 Controller는 비즈니스 로직 + 입력 파싱 + 출력 렌더링이 다 섞여있던 덩어리였다. 이번 글이 끝나는 지점에서, Controller는 "객체 입력을 받아 ModelAndView를 반환하는 함수" 로 압축됐다.

이게 Spring MVC @Controller 의 모양과 거의 같다.

@Controller
public class LectureController {
    @PostMapping("/lectures")
    public String create(@ModelAttribute LectureCreateRequest request) {
        // ...
        return "redirect:/lectures";
    }
}

@PostMapping 이 우리의 Map<URL, Controller> 매핑 등록을 대신하고, @ModelAttribute 가 우리의 파라미터 바인더를 호출하고, String 반환값이 우리의 ModelAndView 의 view 이름 부분을 대신한다. 여기까지 만들어온 구조가 Spring MVC의 핵심 구조 그대로다.


마무리

  • View 추상화 — 렌더링 코드를 별도 객체로 분리하고, 공통 인터페이스로 다양한 템플릿 엔진을 갈아끼울 수 있게 함.
  • ModelAndView + ViewResolver — Controller는 view 이름과 데이터만 반환, 실제 View 생성·호출은 DispatcherServlet이 처리.
  • 파라미터 바인딩 — 리플렉션으로 메서드 파라미터 타입을 분석하고, HTTP 요청 값을 객체로 채워 넘겨줌. Controller에서 raw API 호출이 사라짐.

지난 글의 결론이 "프론트 컨트롤러가 횡단 관심사 보일러플레이트를 처리해준다" 였다면, 이번 글의 결론은 "View 추상화·ModelAndView·파라미터 바인딩이 입출력 보일러플레이트를 처리해준다" 다. Controller는 점점 더 본질에 가까워지고, 그 가벼움의 대가로 프레임워크가 더 많은 일을 떠맡는다.

profile
Dot Your moment.

0개의 댓글