Spring MVC 아키텍처 대한 정리글

안광현·2025년 4월 15일
post-thumbnail

이번에 PG사 결제 프로젝트를 수행하면서 Spring MVC에 대해 깊이 공부할 기회가 있었습니다. 특히 RESTful API를 구현하는 과정에서 Spring MVC의 아키텍처를 제대로 이해해야만 효율적인 코드를 작성할 수 있다는 것을 알게되어 부랴부랴 다시 김영한 선생님 MVC 강의를 속독했습니다... ㅎㅎ

그래서 이 글에서는 제가 최근에 배운 Spring MVC의 핵심 구조인 DispatcherServlet을 중심으로 전체 아키텍처를 정리해보고자 합니다.

"좋은 아키텍처는 결정을 미루는 것이 아니라, 결정을 쉽게 변경할 수 있도록 만드는 것이다. 스프링 MVC가 최고의 프레임워크가 된 이유는 개발자가 복잡한 구현을 고민하지 않고, 비즈니스 로직에 집중할 수 있게 해주기 때문이다." - 로드 존슨

Spring MVC란 무엇인가?

Spring MVC는 Model-View-Controller(MVC) 디자인 패턴을 웹 애플리케이션에 적용한 프레임워크입니다. 이 패턴의 주요 특징은 프론트 컨트롤러 패턴을 채택하여, 모든 요청을 하나의 중앙 컨트롤러인 DispatcherServlet이 먼저 받아 적절한 핸들러로 위임하는 구조를 갖고 있다는 점입니다.

  1. Model: 애플리케이션의 데이터와 비즈니스 로직을 담당합니다. 데이터베이스와의 상호작용, 계산 등 핵심 기능을 처리하는 역할을 합니다.
  2. View: 사용자에게 보여지는 화면을 담당합니다. HTML, JSP, Thymeleaf 등 다양한 기술을 통해 데이터를 사용자에게 표시하는 역할을 합니다.
  3. Controller: 클라이언트의 요청을 받아 적절한 처리를 하고, 그 결과를 View에 전달하는 역할을 합니다. Controller는 Model과 View를 연결하는 중재자 역할을 합니다.

Spring MVC는 이 구조를 통해 애플리케이션의 각 영역을 명확하게 분리하고, 각 요소가 독립적으로 관리될 수 있도록 도와줍니다. 이로 인해 코드의 재사용성과 유지보수성이 높아지고, 팀 간 협업 시 각 영역을 분담하기도 용이해집니다.

이제 좀 더 딥하게 MVC에 대해서 알아보겠습니다!

Spring MVC의 핵심 요소

1. Controller

Controller는 클라이언트의 요청을 처리하는 중요한 역할을 합니다. Spring MVC에서 Controller는 요청을 받아 비즈니스 로직을 처리하고, 그 결과를 View에 전달합니다.

@Controller
public class HelloController {

    @RequestMapping("/hello")
    public String hello(Model model) {
        model.addAttribute("greeting", "안녕하세요");
        return "hello";  // 'hello'는 View 이름
    }
}

위 예시에서 HelloController/hello 요청을 처리하고, Model에 데이터를 추가한 뒤, hello라는 이름의 View를 반환합니다.

2. Model

Model은 클라이언트에게 응답으로 전달할 데이터를 보관하는 역할을 합니다. 클라이언트의 요청을 처리한 결과로 생성된 데이터를 Model에 담아서 View로 전달합니다.

Spring MVC에서 Model은 웹 애플리케이션에서 데이터 처리를 담당하며, Controller에서 비즈니스 로직을 처리한 후 그 결과를 View에 전달하는 형태로 구성됩니다.

3. View

ViewModel을 이용해 실제 화면에 보이는 콘텐츠를 출력하는 역할을 합니다. Spring MVC에서는 다양한 View 기술을 지원하며, HTML 페이지를 출력하거나, PDF, Excel 등의 문서를 생성할 수 있습니다. 또한 XML이나 JSON 형식으로 응답을 반환하는 등 다양한 형태로 데이터를 변환할 수 있습니다.

4. DispatcherServlet

DispatcherServlet은 Spring MVC의 핵심으로, 모든 요청을 받아 적절한 Controller로 위임하는 역할을 합니다. HandlerMapping을 통해 요청에 맞는 Controller를 찾고, 해당 Controller가 반환한 결과를 ModelAndView 객체로 처리하여 최종적으로 View를 렌더링합니다.

이렇게 Spring MVC는 DispatcherServlet을 중심으로 요청을 처리하는 일련의 흐름을 구성하며, Controller, Model, View가 협력하여 클라이언트에게 응답을 제공합니다.

DispatcherServlet 구조 살펴보기

앞서 말씀드린 것 처럼 스프링 MVC도 프론트 컨트롤러 패턴으로 구현되어 있습니다. 스프링 MVC의 프론트 컨트롤러가 바로 디스패처 서블릿(DispatcherServlet)입니다. 그리고 이 디스패처 서블릿이 바로 스프링 MVC의 핵심입니다.

DispatcherServlet 서블릿 등록

DispatcherServlet도 부모 클래스에서 HttpServlet을 상속 받아서 사용하고, 서블릿으로 동작됩니다. 아래 보시는 것과 같이 조상 클래스가 HttpServlet으로 스프링에서 관리되는 서블릿입니다.

DispatcherServlet → FrameworkServlet → HttpServletBean → HttpServlet

스프링 부트는 DispatcherServlet을 서블릿으로 자동으로 등록하면서 모든 경로(urlPatterns="/")에 대해서 매핑합니다. 이를 통해 해당 URL로 들어오는 모든 요청에 대해서 1차적으로 처리 후 해당 요청을 처리할 수 있는 컨트롤러를 찾는 역할을 하게됩니다. 이 얘기는 아래 더 자세히 설명 드리겠습니다.

  • DispatcherServlet는 더 자세한 경로가 우선순위가 높습니다. 그래서 기존에 등록한 서블릿도 함께 동작할 수 있습니다!

요청 흐름

서블릿이 호출되면 HttpServlet이 제공하는 service()가 호출됩니다. 스프링 MVC는 DispatcherServlet의 부모인 FrameworkServlet에서 service()를 오버라이드 해두었습니다. FrameworkServlet.service()를 시작으로 여러 메서드가 호출되면서 DispatcherServlet.doDispatch()가 호출됩니다.

아래는 요청 흐름을 나태닙니다.

DispatcherServlet의 doDispatch() 분석

지금부터 DispatcherServlet의 핵심인 doDispatch() 메서드를 분석해보겠습니다. 이 메서드는 모든 HTTP 요청을 처리하는 실질적인 로직이 담겨있는 곳입니다. 최대한 간단히 설명하기 위해 예외 처리, 인터셉터 기능은 생략한 점 양해 부탁드립니다 ㅎㅎ..

1. 핸들러 조회

먼저 요청 URL에 매핑된 핸들러(컨트롤러)를 찾습니다.

// 1. 핸들러 조회
mappedHandler = getHandler(processedRequest);
if (mappedHandler == null) {
    noHandlerFound(processedRequest, response);
    return;
}

핸들러 매핑을 통해 요청 URL에 해당하는 핸들러를 찾아오게 됩니다. 만약 해당 URL을 처리할 수 있는 핸들러가 없다면 예외를 발생시킵니다.

2. 핸들러 어댑터 조회

// 2. 핸들러 어댑터 조회 - 핸들러를 처리할 수 있는 어댑터
HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());

찾아온 핸들러를 실행할 수 있는 어댑터를 조회합니다. 핸들러의 종류에 따라 적절한 어댑터가 선택됩니다.

3. 핸들러 어댑터 실행

// 3. 핸들러 어댑터 실행 -> 4. 핸들러 어댑터를 통해 핸들러 실행 -> 5. ModelAndView 반환
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());

핸들러 어댑터를 통해 실제 핸들러(컨트롤러)를 실행하고, 그 결과를 ModelAndView 객체로 반환받습니다. 이 과정에서 핸들러가 반환한 정보는 어댑터에 의해 ModelAndView 형태로 변환됩니다.

4. 뷰 처리 준비

processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);

핸들러 실행 결과를 바탕으로 뷰 렌더링 과정을 준비합니다.

5. 뷰 렌더링

private void processDispatchResult(...) {
    // 뷰 렌더링 호출
    render(mv, request, response);
}

protected void render(ModelAndView mv, HttpServletRequest request, HttpServletResponse response) {
    View view;
    String viewName = mv.getViewName();
    
    // 6. 뷰 리졸버를 통해서 뷰 찾기, 7. View 반환
    view = resolveViewName(viewName, mv.getModelInternal(), locale, request);
    
    // 8. 뷰 렌더링
    view.render(mv.getModelInternal(), request, response);
}

뷰 리졸버를 통해 논리적인 뷰 이름을 실제 뷰 객체로 변환하고, 이 뷰 객체를 사용해 클라이언트에게 보낼 응답을 렌더링합니다.

아래 사진은 위에서 정리한 DispatchServlet에 대한 처리 흐름을 나타냅니다.

동작 순서 정리

  1. 핸들러 조회: 핸들러 매핑을 통해 요청 URL에 매핑된 핸들러(컨트롤러)를 조회합니다.
  2. 핸들러 어댑터 조회: 핸들러를 실행할 수 있는 핸들러 어댑터를 조회합니다.
  3. 핸들러 어댑터 실행: 핸들러 어댑터를 실행합니다.
  4. 핸들러 실행: 핸들러 어댑터가 실제 핸들러를 실행합니다.
  5. ModelAndView 반환: 핸들러 어댑터는 핸들러가 반환하는 정보를 ModelAndView로 변환해서 반환합니다.
  6. viewResolver 호출: 뷰 리졸버를 찾고 실행합니다.
    • JSP의 경우: InternalResourceViewResolver가 자동 등록되고, 사용됩니다.
  7. View 반환: 뷰 리졸버는 뷰의 논리 이름을 물리 이름으로 바꾸고, 렌더링 역할을 담당하는 뷰 객체를 반환합니다.
    • JSP의 경우 InternalResourceView(JstlView)를 반환하는데, 내부에 forward() 로직이 있습니다.
  8. 뷰 렌더링: 뷰를 통해서 뷰를 렌더링합니다.

전체 코드

최종적으로 doDispatch() 메서드의 핵심 로직을 정리하면 다음과 같습니다!

protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
    HttpServletRequest processedRequest = request;
    HandlerExecutionChain mappedHandler = null;
    ModelAndView mv = null;

    // 1. 핸들러 조회
    mappedHandler = getHandler(processedRequest);
    if (mappedHandler == null) {
        noHandlerFound(processedRequest, response); # 요청에 대한 처리 핸들러가 없다면 예외처리 
        return;
    }

    // 2. 핸들러 어댑터 조회 - 핸들러를 처리할 수 있는 어댑터
    HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());

    // 3. 핸들러 어댑터 실행 -> 4. 핸들러 어댑터를 통해 핸들러 실행 -> 5. ModelAndView 반환
    mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
    
    processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);
}

private void processDispatchResult(HttpServletRequest request, HttpServletResponse response, 
    HandlerExecutionChain mappedHandler, ModelAndView mv, Exception exception) throws Exception {
    // 뷰 렌더링 호출
    render(mv, request, response);
}

protected void render(ModelAndView mv, HttpServletRequest request, HttpServletResponse response) throws Exception {
    View view;
    String viewName = mv.getViewName();
    
    // 6. 뷰 리졸버를 통해서 뷰 찾기, 7. View 반환
    view = resolveViewName(viewName, mv.getModelInternal(), locale, request);
    
    // 8. 뷰 렌더링
    view.render(mv.getModelInternal(), request, response);
}

이러한 설계 덕분에 DispatcherServlet은 다양한 핸들러와 뷰 기술을, 코드 변경 없이 유연하게 지원할 수 있습니다.

핸들러 매핑과 핸들러 어댑터

이번엔 DispatchServlet이 호출하는 핸들러 매핑과 핸들러 어댑터가 어떤 것들이고 어떻게 사용되는지 자세히 알아보고 정리해 보겠습니다!

핸들러 매핑과 어댑터의 역할

스프링 MVC에서 HTTP 요청이 들어오면 DispatcherServlet은 두 가지 중요한 작업을 수행해야 합니다.

  1. 핸들러 매핑 - "어떤 컨트롤러가 이 요청을 처리할까?"

    • URL, HTTP 메서드, 헤더 등을 분석하여 적절한 핸들러(컨트롤러)를 찾는 역할을 수행합니다. ex) RequestMapping 어노테이션 등
    • 다양한 전략으로 요청과 핸들러를 매핑할 수 있습니다.
  2. 핸들러 어댑터 - "찾은 컨트롤러를 어떻게 실행할까?"

    • 다양한 유형의 핸들러를 일관된 방식으로 실행하게 해주는 어댑터 역할을 수행합니다.
    • 컨트롤러의 구현 방식에 상관없이 DispatcherServlet이 일관되게 호출할 수 있게합니다.

어노테이션 기반 컨트롤러 예제

스프링 MVC에서 핸들러 매핑과 핸들러 어댑터는 요청을 적절한 컨트롤러에 연결하고 실행하는 핵심 구성 요소입니다. 먼저 가장 많이 사용하는 어노테이션 기반 컨트롤러의 전체 코드를 살펴보겠습니다.

@Controller
public class MemberController {
    
    private final MemberService memberService;
    
    public MemberController(MemberService memberService) {
        this.memberService = memberService;
    }
    
    @GetMapping("/members")
    public String members(Model model) {
        List<Member> members = memberService.findAll();
        model.addAttribute("members", members);
        return "members/memberList";
    }
    
    @GetMapping("/members/{id}")
    public String member(@PathVariable Long id, Model model) {
        Member member = memberService.findById(id);
        model.addAttribute("member", member);
        return "members/memberDetail";
    }
}

이제 이 컨트롤러가 어떻게 요청을 처리하는지 단계별로 상세히 살펴보겠습니다.

1. 핸들러 매핑 과정 (요청 → 컨트롤러 찾기)

클라이언트가 /members/1 경로로 GET 요청을 보내면 DispatcherServlet은 요청을 처리할 핸들러를 찾아야 합니다.

// DispatcherServlet의 doDispatch() 메서드 일부
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
    // ...
    // 1. 핸들러 조회
    mappedHandler = getHandler(processedRequest);
    // ...
}

// getHandler() 메서드 내부 동작
protected HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
    for (HandlerMapping handlerMapping : this.handlerMappings) {
        HandlerExecutionChain handler = handlerMapping.getHandler(request);
        if (handler != null) {
            return handler;
        }
    }
    return null;
}

이 과정을 더 세분화 하면 아래와 같은 프로세스가 작동합니다.

  1. DispatcherServlet은 등록된 모든 HandlerMapping을 순회합니다.

    for (HandlerMapping handlerMapping : this.handlerMappings) {
         HandlerExecutionChain handler = handlerMapping.getHandler(request);
         if (handler != null) {
             return handler;
         }
     }
  2. RequestMappingHandlerMapping이 가장 먼저 검사됩니다.

    // RequestMappingHandlerMapping 내부적으로 수행하는 작업
    public void afterPropertiesSet() {
        // 모든 @Controller 빈을 찾아서 매핑 정보 등록
        for (String beanName : context.getBeanNamesForType(Object.class)) {
            if (isHandler(beanName)) {
                detectHandlerMethods(beanName);
            }
        }
    }
    
    // URL과 HTTP 메서드를 기반으로
    // MemberController의 @GetMapping("/members/{id}")와 매칭됩니다.
  3. 매칭된 컨트롤러 메서드 정보와 함께 인터셉터가 포함된 HandlerExecutionChain이 생성됩니다.

    // "/members/1" 요청에 대해 다음과 같은 핸들러가 매핑됩니다.
    HandlerMethod {
        bean = 'memberController',
        method = 'public String member(@PathVariable Long id, Model model)'
    }

2. 핸들러 어댑터 과정 (컨트롤러 실행 방법 찾기)

이제 DispatcherServlet은 찾은 핸들러를 실행할 적절한 어댑터를 찾아야 합니다.

// DispatcherServlet의 doDispatch() 메서드 계속
// 2. 핸들러 어댑터 조회
HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());

// getHandlerAdapter() 내부 동작
protected HandlerAdapter getHandlerAdapter(Object handler) throws ServletException {
    for (HandlerAdapter adapter : this.handlerAdapters) {
        if (adapter.supports(handler)) {
            return adapter;
        }
    }
    throw new ServletException("No adapter for handler [" + handler + "]");
}

이 단계를 세분화 하면

  1. 모든 어댑터의 supports() 메서드를 호출하여 핸들러 지원 여부를 확인합니다.
    // RequestMappingHandlerAdapter의 supports() 메서드
    @Override
    public boolean supports(Object handler) {
        return handler instanceof HandlerMethod;
    }
  2. RequestMappingHandlerAdapter가 HandlerMethod 타입을 지원하므로 선택됩니다.

3. 핸들러 실행 과정 (파라미터 준비 및 메서드 호출)

선택된 어댑터는 실제 컨트롤러 메서드를 호출하기 위한 모든 준비를 담당합니다.

// DispatcherServlet의 doDispatch() 메서드 계속
// 3. 핸들러 어댑터 실행
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());

// RequestMappingHandlerAdapter의 handle() 메서드 내부 동작
@Override
public ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) {
    return handleInternal(request, response, (HandlerMethod) handler);
}

protected ModelAndView handleInternal(...) {
    // 파라미터 바인딩, 검증 등 수행
    Object[] args = getMethodArgumentValues(request, response, handler);
    
    // 컨트롤러 메서드 호출
    Object returnValue = invokeHandlerMethod(args);
    
    // 반환값 처리 및 ModelAndView 생성
    return getModelAndView(returnValue, model);
}

이 과정에서 다음 작업이 수행됩니다.

  1. 메서드 파라미터 값 준비

    // 경로 변수 {id}=1을 Long 타입으로 변환
    // PathVariableMethodArgumentResolver 사용
    Long id = 1L;
    
    // 새로운 Model 객체 생성
    // ModelMethodProcessor 사용
    Model model = new BindingAwareModelMap();
    
    // 최종 준비된 파라미터 배열
    Object[] args = { id, model };
  2. 컨트롤러 메서드 호출

    // 실제 메서드 호출 (리플렉션 사용)
    String viewName = memberController.member(1L, model);
    // 결과: "members/memberDetail"
  3. 반환값 처리 및 ModelAndView 생성

    // ViewNameMethodReturnValueHandler가 String 반환값을 처리
    ModelAndView mav = new ModelAndView("members/memberDetail");
    mav.addAllObjects(model); // model에는 "member" 객체가 들어 있음

스프링이 제공하는 주요 핸들러 매핑과 어댑터

스프링 부트는 기본적으로 여러 핸들러 매핑과 어댑터를 자동 등록합니다.

핸들러 매핑 구현체들

// 스프링 부트가 자동 구성하는 핸들러 매핑 순서
@Bean
@ConditionalOnMissingBean
public HandlerMapping requestMappingHandlerMapping() {
    // 0순위: 애노테이션 기반 컨트롤러 매핑
    return new RequestMappingHandlerMapping();
}

@Bean
public HandlerMapping beanNameHandlerMapping() {
    // 1순위: 빈 이름과 URL 매칭
    return new BeanNameUrlHandlerMapping();
}

@Bean
@ConditionalOnBean(RouterFunction.class)
public HandlerMapping routerFunctionMapping() {
    // 2순위: 함수형 엔드포인트 매핑
    return new RouterFunctionMapping();
}

핸들러 어댑터 구현체들

// 스프링 부트가 자동 구성하는 핸들러 어댑터 순서
@Bean
@ConditionalOnMissingBean
public RequestMappingHandlerAdapter requestMappingHandlerAdapter() {
    // 0순위: 애노테이션 기반 컨트롤러 실행
    return new RequestMappingHandlerAdapter();
}

@Bean
@ConditionalOnBean(RouterFunction.class)
public HandlerFunctionAdapter handlerFunctionAdapter() {
    // 1순위: 함수형 엔드포인트 실행
    return new HandlerFunctionAdapter();
}

@Bean
public HttpRequestHandlerAdapter httpRequestHandlerAdapter() {
    // 2순위: HttpRequestHandler 인터페이스 구현체 실행
    return new HttpRequestHandlerAdapter();
}

@Bean
public SimpleControllerHandlerAdapter simpleControllerHandlerAdapter() {
    // 3순위: Controller 인터페이스 구현체 실행
    return new SimpleControllerHandlerAdapter();
}

이러한 다양한 매핑과 어댑터를 통해 스프링 MVC는 서로 다른 스타일의 컨트롤러를 모두 지원할 수 있습니다. 대부분의 경우 어노테이션 기반 컨트롤러(@Controller, @RestController)를 사용하지만, 필요에 따라 다른 스타일도 함께 사용할 수 있는 유연성을 제공합니다. 정말 MVC 패턴은 보면 볼 수록 확장성 까지 고려한 완벽한 패턴인 것 같습니다 ㄷㄷ..

실제 어노테이션 기반 컨트롤러의 처리 과정

클라이언트가 /members/1 요청을 보냈을 때 위에서 살펴본 처리 관정을 요약하면 다음과 같습니다!

  1. DispatcherServlet이 모든 HandlerMapping을 순회하며 요청을 처리할 핸들러를 찾음습니다.
  2. RequestMappingHandlerMapping이 @GetMapping("/members/{id}")가 있는 메서드를 찾아 반환합니다.
  3. DispatcherServlet이 모든 HandlerAdapter를 순회하며 적합한 어댑터를 찾게 됩니다.
  4. RequestMappingHandlerAdapter가 선택되어 컨트롤러 메서드 실행 준비를 합니다.
  5. URL에서 추출한 "1"을 Long으로 변환하고 Model 객체를 생성합니다.
  6. 컨트롤러의 member() 메서드를 호출해 비즈니스 로직을 수행합니다. (실제론 서비스 레이어에서 비즈니스 로직 수행)
  7. 반환된 뷰 이름 "members/memberDetail"과 모델 데이터로 ModelAndView 생성합니다.
  8. DispatcherServlet으로 ModelAndView 반환합니다.

이러한 설계 덕분에 개발자는 HTTP 요청 처리나 파라미터 바인딩 같은 복잡한 과정에 신경 쓰지 않고, 비즈니스 로직 구현에만 집중할 수 있는 너무나 큰 이점이 있어 안 쓸래야 안 쓸 수가 없습니다...

핸들러 매핑과 어댑터의 확장성

앞서 말씀드렸지만 제가 생각하기에 이러한 구조의 가장 큰 장점은 확장성입니다. DispatcherServlet의 코드 변경 없이 새로운 종류의 컨트롤러를 만들 수 있습니다.

예를 들어 자체 컨트롤러 유형을 만들고 싶다면 다음 두 가지만 구현하면 됩니다.

  1. 요청-컨트롤러 매핑 규칙을 정의하는 HandlerMapping 구현체
  2. 해당 컨트롤러를 실행하는 HandlerAdapter 구현체

이렇게 하면 DispatcherServlet은 수정 없이도 새로운 컨트롤러 스타일을 지원할 수 있어 엄청난 확장성을 갖게 됩니다.

스프링 부트가 자동 등록하는 핸들러 매핑과 핸들러 어댑터

HandlerMapping

0 = RequestMappingHandlerMapping : 어노테이션 기반의 컨트롤러인 @RequestMapping에서 사용합니다.
1 = BeanNameUrlHandlerMapping : 스프링 빈의 이름으로 핸들러를 찾습니다.

HandlerAdapter

0 = RequestMappingHandlerAdapter : 어노테이션 기반의 컨트롤러인 @RequestMapping에서 사용합니다.
1 = HttpRequestHandlerAdapter : HttpRequestHandler 처리
2 = SimpleControllerHandlerAdapter : Controller 인터페이스(애노테이션X, 과거에 사용) 처리

핸들러 매핑도, 핸들러 어댑터도 모두 순서대로 찾고 만약 없으면 다음 순서로 넘어갑니다.

예를 들어 OldController의 실행 과정을 살펴보면

// Controller 인터페이스 구현
@Component("/old-controller")  // 빈 이름을 URL로 사용
public class OldController implements Controller {
    
    @Override
    public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) {
        System.out.println("OldController.handleRequest 실행");
        return new ModelAndView("old-form");
    }
}
  1. 핸들러 매핑으로 핸들러 조회

    • HandlerMapping을 순서대로 실행해서, 핸들러를 찾습니다.
    • 빈 이름으로 핸들러를 찾아야 하기 때문에 BeanNameUrlHandlerMapping이 실행에 성공하고 핸들러인 OldController를 반환합니다.
  2. 핸들러 어댑터 조회

    • HandlerAdaptersupports()를 순서대로 호출합니다.
    • SimpleControllerHandlerAdapterController 인터페이스를 지원하므로 대상이 됩니다.
  3. 핸들러 어댑터 실행

    • 디스패처 서블릿이 조회한 SimpleControllerHandlerAdapter를 실행하면서 핸들러 정보도 함께 넘겨줍니다.
    • SimpleControllerHandlerAdapter는 핸들러인 OldController를 내부에서 실행하고, 그 결과를 반환합니다.

HttpRequestHandler

또 다른 형태의 핸들러인 HttpRequestHandler에 대해 알아보겠습니다. 이 핸들러는 서블릿과 가장 유사한 형태의 핸들러입니다.

public interface HttpRequestHandler {
    void handleRequest(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException;
}

간단하게 예제를 구현해 보겠습니다.

@Component("/springmvc/request-handler")
public class MyHttpRequestHandler implements HttpRequestHandler {
    @Override
    public void handleRequest(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        System.out.println("MyHttpRequestHandler.handleRequest");
    }
}

이 핸들러의 실행 과정도 OldController와 유사합니다! 그러나 핸들러 어댑터 조회 시 BeanNameUrlHandlerMapping가 아닌 HttpRequestHandlerAdapter가 선택됩니다.

@RequestMapping

가장 우선순위가 높은 핸들러 매핑과 핸들러 어댑터는 RequestMappingHandlerMappingRequestMappingHandlerAdapter입니다. 이것은 @RequestMapping의 앞글자를 따서 만든 이름입니다.

딱 봐도 아시겠지만 이것이 바로 지금 스프링에서 주로 사용하는 어노테이션 기반의 컨트롤러를 지원하는 매핑과 어댑터입니다. 보통 실무에서는 99.9999% 이 방식의 컨트롤러를 사용합니다! (아닌 경우 못봤습니다.)

뷰 리졸버

뷰 리졸버(ViewResolver)는 스프링 MVC에서 컨트롤러가 반환한 뷰 이름(논리적 이름)을 실제 뷰 객체로 변환하는 역할을 담당합니다. 쉽게 말해, 컨트롤러에서 "home"이라는 뷰 이름만 반환해도 뷰 리졸버가 이를 "/WEB-INF/views/home.jsp"와 같은 실제 물리적 위치로 변환해주는 것입니다.

뷰 리졸버의 역할

  1. 논리 이름을 물리적 위치로 변환: 컨트롤러는 단순히 뷰의 논리 이름만 반환하고, 실제 경로와 파일 확장자는 뷰 리졸버가 처리합니다.

  2. 다양한 뷰 기술 지원: JSP, Thymeleaf, FreeMarker, Velocity 등 여러 템플릿 엔진과 PDF, Excel 등 다양한 출력 형식을 지원합니다. 실제 excel 써봤는데 매우 편했습니다 ㅎㅎ..

  3. View 객체 생성: 최종적으로 뷰의 논리 이름을 기반으로 실제 렌더링을 담당할 View 인터페이스 구현체를 생성합니다.

주요 ViewResolver 종류

스프링은 다양한 ViewResolver 구현체를 제공합니다.

  1. InternalResourceViewResolver: JSP 같은 내부 리소스를 처리하는 가장 흔한 뷰 리졸버
  2. BeanNameViewResolver: 뷰 이름과 동일한 이름의 빈을 뷰로 사용 (엑셀, PDF 같은 특수 뷰에 유용)
  3. ContentNegotiatingViewResolver: 클라이언트가 요청한 미디어 타입에 따라 적절한 뷰를 선택
  4. ThymeleafViewResolver: Thymeleaf 템플릿 엔진을 사용하는 뷰 처리
  5. FreeMarkerViewResolver: FreeMarker 템플릿 엔진을 사용하는 뷰 처리

스프링 부트에서는 클래스패스에 있는 라이브러리에 따라 이러한 뷰 리졸버들이 자동 구성됩니다.

뷰 리졸버(ViewResolver)의 동작 원리

뷰 리졸버의 동작 과정은 다음과 같습니다.

  1. 컨트롤러가 뷰 이름(String) 또는 ModelAndView를 반환합니다.
  2. DispatcherServlet이 ViewResolver 목록을 순서대로 확인하며 뷰 이름을 처리할 수 있는지 확인합니다.
  3. 적절한 ViewResolver가 뷰 이름을 실제 View 객체로 변환합니다.
  4. DispatcherServlet이 반환된 View 객체의 render() 메서드를 호출하여 응답을 생성합니다.

이제 실제 코드로 확인해 보겠습니다. 기존 OldController를 수정해서 View를 조회할 수 있도록 해 보겠습니다.

@Component("/springmvc/old-controller")
public class OldController implements Controller {
    @Override
    public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception {
        System.out.println("OldController.handleRequest");
        return new ModelAndView("new-form");
    }
}

여기서 반환한 "new-form"이라는 뷰 이름은 뷰 리졸버에 의해 처리됩니다. 스프링 부트에서는 application.properties에 다음과 같은 설정을 추가하면 됩니다.

spring.mvc.view.prefix=/WEB-INF/views/
spring.mvc.view.suffix=.jsp

이 설정을 통해 InternalResourceViewResolver가 자동으로 등록되고, "new-form"이라는 뷰 이름은 /WEB-INF/views/new-form.jsp 경로로 변환됩니다.

InternalResourceViewResolver 동작 흐름

InternalResourceViewResolver는 JSP를 처리하기 위한 뷰 리졸버입니다. 요즘은 프론트단을 따로 만들거나 Thymeleaf를 많이 쓰는 추세여서 잘 안 보이지만 그래도 한 번 정리하는 김에 정리했습니다!

  1. 핸들러 어댑터 호출

    • 컨트롤러에서 반환한 "new-form"이라는 논리적 뷰 이름을 가져옵니다.
  2. ViewResolver 순차적 호출

    • "new-form"이라는 뷰 이름으로 등록된 ViewResolver들을 순서대로 호출합니다.
    • 먼저 BeanNameViewResolver가 호출되어 "new-form"이라는 이름의 빈을 찾지만 없습니다.
    • 다음으로 InternalResourceViewResolver가 호출됩니다.
  3. InternalResourceViewResolver 처리

    • prefix + 뷰이름 + suffix 조합으로 실제 경로를 만듭니다.
    • /WEB-INF/views/ + new-form + .jsp = /WEB-INF/views/new-form.jsp
    • 이 경로를 사용하는 InternalResourceView 객체를 생성하여 반환합니다.
  4. View 객체 실행

    • InternalResourceView는 JSP를 실행하기 위해 forward() 메서드를 내부적으로 호출합니다.
  5. JSP 렌더링

    • JSP 파일이 실행되면서 최종 HTML이 생성되고 클라이언트에게 반환됩니다.

InternalResourceViewResolver는 JSTL 라이브러리가 있으면 InternalResourceView를 상속받은 JstlView를 반환합니다. JstlView는 JSTL 태그 사용 시 추가 기능을 제공합니다!

JSP와 달리 Thymeleaf 같은 최신 템플릿 엔진들은 forward() 과정 없이 바로 렌더링됩니다. 최근에는 Thymeleaf를 많이 사용하는데, 스프링 부트를 사용하면 의존성만 추가하면 ThymeleafViewResolver가 자동 등록되어 편리합니다.

스프링 MVC - 시작하기

이제 본격적으로 스프링 MVC를 사용하는 프로세스를 보여드리겠습니다. 스프링의 컨트롤러는 애노테이션 기반으로 동작해서 정말 유연하고 실용적입니다.

@RequestMapping으로 시작하기

스프링은 애노테이션을 활용한 굉장히 직관적인 컨트롤러를 제공합니다. 바로 @RequestMapping 어노테이션을 사용하는 방식입니다.

앞서 살펴본 것처럼 스프링 MVC에서 가장 우선순위가 높은 핸들러 매핑과 어댑터는 RequestMappingHandlerMappingRequestMappingHandlerAdapter입니다. 실무에서는 거의 대부분(99.9%) 이 방식의 컨트롤러를 사용합니다!

간단한 예제로 살펴보겠습니다.

@Controller
public class SpringMemberFormControllerV1 {
    @RequestMapping("/springmvc/v1/members/new-form")
    public ModelAndView process() {
        return new ModelAndView("new-form");
    }
}

여기서 사용된 애노테이션들을 살펴보면 다음과 같습니다.

  • @Controller

    • 이 클래스를 스프링 빈으로 자동 등록합니다. (내부적으로 @Component가 있어서 컴포넌트 스캔 대상)
    • 스프링 MVC에서 컨트롤러로 인식하게 합니다.
  • @RequestMapping

    • URL과 메서드를 매핑합니다.
    • 해당 URL이 호출되면 이 메서드가 실행됩니다.
    • 메서드 이름은 자유롭게 지을 수 있습니다. (애노테이션 기반이니까요!)

이제 회원 저장과 목록 조회 컨트롤러도 추가해 보겠습니다.

@Controller
public class SpringMemberSaveControllerV1 {
    private MemberRepository memberRepository = MemberRepository.getInstance();
    
    @RequestMapping("/springmvc/v1/members/save")
    public ModelAndView process(HttpServletRequest request, HttpServletResponse response) {
        String username = request.getParameter("username");
        int age = Integer.parseInt(request.getParameter("age"));
        
        Member member = new Member(username, age);
        memberRepository.save(member);
        
        ModelAndView mv = new ModelAndView("save-result");
        mv.addObject("member", member);
        return mv;
    }
}

여기서 mv.addObject("member", member)는 모델에 데이터를 추가하는 메서드입니다. 뷰에서 이 데이터를 사용할 수 있게 해줍니다.

@Controller
public class SpringMemberListControllerV1 {
    private MemberRepository memberRepository = MemberRepository.getInstance();
    
    @RequestMapping("/springmvc/v1/members")
    public ModelAndView process() {
        List<Member> members = memberRepository.findAll();
        
        ModelAndView mv = new ModelAndView("members");
        mv.addObject("members", members);
        return mv;
    }
}

주의사항
스프링 부트 3.0(스프링 프레임워크 6.0)부터는 클래스 레벨에 @RequestMapping만 있고 @Controller가 없으면 컨트롤러로 인식하지 않습니다. 반드시 @Controller 애노테이션을 붙여야 합니다. (저도 예제 만들다가 해맸습니다.)

이것이 기본적인 스프링 MVC 컨트롤러 사용법입니다. 다음 글에서는 이 컨트롤러들을 통합하고 더 실용적인 방식으로 발전시키는 방법을 알아보겠습니다.

스프링 MVC는 이렇게 애노테이션 기반으로 직관적이고 유연하게 개발할 수 있어서 정말 편리합니다. 확실히 예전에 서블릿으로 개발할 때보다 코드도 간결하고 관리하기 쉬워진 것을 알 수 있습니다.

스프링 MVC - 컨트롤러 통합

@RequestMapping을 잘 보면 클래스 단위가 아니라 메서드 단위에 적용된 것을 확인할 수 있습니다. 따라서 컨트롤러 클래스를 유연하게 하나로 통합할 수 있습니다.

/**
 * 클래스 단위 -> 메서드 단위
 * @RequestMapping 클래스 레벨과 메서드 레벨 조합
 */
@Controller
@RequestMapping("/springmvc/v2/members")
public class SpringMemberControllerV2 {
    private MemberRepository memberRepository = MemberRepository.getInstance();
    
    @RequestMapping("/new-form")
    public ModelAndView newForm() {
        return new ModelAndView("new-form");
    }
    
    @RequestMapping("/save")
    public ModelAndView save(HttpServletRequest request, HttpServletResponse response) {
        String username = request.getParameter("username");
        int age = Integer.parseInt(request.getParameter("age"));
        
        Member member = new Member(username, age);
        memberRepository.save(member);
        
        ModelAndView mav = new ModelAndView("save-result");
        mav.addObject("member", member);
        return mav;
    }
    
    @RequestMapping
    public ModelAndView members() {
        List<Member> members = memberRepository.findAll();
        
        ModelAndView mav = new ModelAndView("members");
        mav.addObject("members", members);
        return mav;
    }
}

이렇게 클래스 레벨에 @RequestMapping을 두면 메서드 레벨과 조합이 됩니다.

조합 결과

  • /springmvc/v2/members/new-form
  • /springmvc/v2/members/save
  • /springmvc/v2/members

스프링 MVC - 실용적인 방식

지금부터는 실무에서 주로 사용하는 방식을 알아보겠습니다.

/**
 * v3
 * Model 도입
 * ViewName 직접 반환
 * @RequestParam 사용
 * @RequestMapping -> @GetMapping, @PostMapping
 */
@Controller
@RequestMapping("/springmvc/v3/members")
public class SpringMemberControllerV3 {
    private MemberRepository memberRepository = MemberRepository.getInstance();
    
    @GetMapping("/new-form")
    public String newForm() {
        return "new-form";
    }
    
    @PostMapping("/save")
    public String save(
            @RequestParam("username") String username,
            @RequestParam("age") int age,
            Model model) {
        Member member = new Member(username, age);
        memberRepository.save(member);
        
        model.addAttribute("member", member);
        return "save-result";
    }
    
    @GetMapping
    public String members(Model model) {
        List<Member> members = memberRepository.findAll();
        model.addAttribute("members", members);
        return "members";
    }
}

이 코드는 다음과 같은 기능을 사용합니다.

  1. Model 파라미터: save(), members()를 보면 Model을 파라미터로 받는 것을 확인할 수 있습니다.

  2. ViewName 직접 반환: 뷰의 논리 이름을 직접 반환하여 코드의 가독성이 좋아졌습니다.

  3. @RequestParam 사용: 스프링은 HTTP 요청 파라미터를 @RequestParam으로 받을 수 있습니다. GET 쿼리 파라미터, POST Form 방식을 모두 지원합니다.

-> 이건 나중에 제대로 정리할 생각입니다. 이 부분만 해도 할 말이 많기 때문에....

  1. @GetMapping, @PostMapping: @RequestMapping은 URL만 매칭하는 것이 아니라, HTTP Method도 함께 구분할 수 있습니다. 이를 더 편리하게 사용할 수 있는 @GetMapping, @PostMapping 어노테이션이 제공됩니다.

실제 코드로 알아보는 Spring MVC

이번에는 제가 최근에 진행한 PG사 결제 시스템 프로젝트에서 어떻게 Spring MVC를 활용했는지 실제 사례를 통해 정리했습니다. 제 프로젝트를 예시로 쓰려고 하니 만감이 교차합니다 ㅎㅎ.. 결제 시스템은 API 기반으로 다양한 결제 관련 요청을 처리하는데, 특히 트랜잭션 처리와 일관된 응답 형식이 중요했습니다. 그래서 더 MVC 예시로 쓰면 좋겠다고 생각합니다. 서론은 여기까지 하고 바로 본론으로 들어가겠습니다.

결제 모듈 컨트롤러 구성

결제 시스템에서는 다음과 같이 REST API 기반 컨트롤러를 구성했습니다.

@RestController
@RequestMapping("/api/v1/payments")
@RequiredArgsConstructor
public class PaymentController {
    private final PaymentService paymentService;
    private final PaymentValidator paymentValidator;
    
    // 결제 요청 처리
    @PostMapping
    public PaymentResponseDto requestPayment(@RequestBody @Valid PaymentRequestDto request) {
        // 비즈니스 유효성 검증
        paymentValidator.validatePaymentRequest(request);
        
        // 결제 처리 및 응답 반환
        return paymentService.processPayment(request);
    }
    
    // 결제 조회
    @GetMapping("/{paymentKey}")
    public PaymentDetailDto getPaymentDetails(@PathVariable String paymentKey) {
        return paymentService.findPaymentByKey(paymentKey);
    }
    
    // 결제 취소 처리
    @PostMapping("/cancel")
    public CancelPaymentResponseDto cancelPayment(@RequestBody @Valid CancelOrder cancelOrder) {
        return paymentService.cancelPayment(cancelOrder);
    }
    
    // 가상계좌 발급
    @PostMapping("/virtual-accounts")
    public VirtualAccountDto issueVirtualAccount(@RequestBody @Valid VirtualAccountRequest request) {
        return paymentService.issueVirtualAccount(request);
    }
}

이 컨트롤러에서는 다양한 Spring MVC 기능을 활용했습니다.

  • @RestController: JSON 응답을 위한 컨트롤러 지정
  • @RequestMapping: API 경로 설정
  • @Valid: 요청 객체 자동 유효성 검증
  • @PathVariable: URL 경로 변수 바인딩

계층형 컨트롤러 구조 활용

대규모 결제 시스템에서는, 기능별로 컨트롤러를 분리하면서도 공통 기능을 추상화했습니다.

// 기본 컨트롤러 추상 클래스
@RequiredArgsConstructor
public abstract class BasePaymentController {
    protected final LoggingService loggingService;
    
    // 요청 로깅 공통 메서드
    protected void logPaymentRequest(String traceId, Object request) {
        loggingService.logApiRequest(traceId, request);
    }
    
    // 응답 로깅 공통 메서드
    protected void logPaymentResponse(String traceId, Object response) {
        loggingService.logApiResponse(traceId, response);
    }
}

// 취소 전용 컨트롤러
@RestController
@RequestMapping("/api/v1/payments/cancel")
@RequiredArgsConstructor
public class CancelController extends BasePaymentController {
    private final PaymentCancelUseCase paymentCancelService;
    private final TransactionManager txManager;
    
    /**
     * 결제 취소 API - 트랜잭션 관리 및 로깅 적용
     */
    @PostMapping
    public CancelPaymentResponseDto cancelPayment(
            @RequestHeader(value = "X-Trace-ID", required = false) String traceId,
            @RequestBody @Valid CancelOrder cancelOrder) throws Exception {
        
        // 요청 로깅
        traceId = StringUtils.isBlank(traceId) ? UUID.randomUUID().toString() : traceId;
        logPaymentRequest(traceId, cancelOrder);
        
        // 트랜잭션 처리
        CancelPaymentResponseDto response = txManager.executeInTransaction(() -> {
            // 취소 가능 여부 검증
            paymentCancelService.validateCancellation(cancelOrder);
            
            // 실제 취소 처리
            return paymentCancelService.processCancellation(cancelOrder);
        });
        
        // 응답 로깅
        logPaymentResponse(traceId, response);
        
        return response;
    }
    
    /**
     * 부분 취소 처리 API
     */
    @PostMapping("/partial")
    public PartialCancelResponseDto partialCancel(
            @RequestBody @Valid PartialCancelRequest request) {
        return paymentCancelService.processPartialCancellation(request);    
    }
}

응답 표준화 처리

프로젝트에서 가장 큰 도전 중 하나는 모든 API 응답을 일관된 형식으로 제공하는 것이었습니다. 처음에는 모든 컨트롤러에서 수동으로 ApiResponse로 감싸는 방식을 사용했지만, 이는 코드 중복과 실수 가능성을 높였습니다.

이를 해결하기 위해 ResponseBodyAdvice를 활용했습니다.

@RestControllerAdvice
@Slf4j
@RequiredArgsConstructor
public class ResponseWrapper implements ResponseBodyAdvice<Object> {
    private final ObjectMapper objectMapper;

    @Override
    public boolean supports(MethodParameter returnType, Class converterType) {
        // ApiResponse로 이미 감싸져 있는 응답은 처리하지 않음
        return !returnType.getParameterType().equals(ApiResponse.class);
    }

    @Override
    public Object beforeBodyWrite(Object body,
                                MethodParameter returnType,
                                MediaType selectedContentType,
                                Class<? extends HttpMessageConverter<?>> selectedConverterType,
                                ServerHttpRequest request,
                                ServerHttpResponse response) {
        
        // String 반환 타입 특수 처리 (Jackson 직렬화 문제 해결)
        if (body instanceof String) {
            try {
                return objectMapper.writeValueAsString(ApiResponse.success(body));
            } catch (JsonProcessingException e) {
                log.error("String response serialization failed", e);
                return ApiResponse.fail("SERIALIZATION_ERROR", "응답 변환 실패");
            }
        }
        
        // 에러 응답 특수 처리
        if (body instanceof ErrorResponse) {
            return ApiResponse.fail(((ErrorResponse) body).getCode(), body);
        }
        
        // 일반 응답 처리
        return ApiResponse.success(body);
    }
}

이 구현을 통해 다음과 같은 일관된 JSON 응답 구조를 자동으로 생성할 수 있었습니다.

{
  "success": true,
  "data": {
    "paymentKey": "5zJ4xY7m9K",
    "amount": 15000,
    "status": "COMPLETED",
    "transactionTime": "2023-04-15T14:30:45"
  },
  "error": null
}

오류가 발생한 경우입니다.

{
  "success": false,
  "data": null,
  "error": {
    "code": "INSUFFICIENT_BALANCE",
    "message": "잔액이 부족합니다",
    "details": {
      "availableAmount": 10000,
      "requiredAmount": 15000
    }
  }
}

예외 처리 통합

API 기반 결제 시스템에서는 다양한 예외 상황을 일관되게 처리하는 것이 중요했습니다. 그래서 @ControllerAdvice를 활용한 글로벌 예외 처리기를 구현했습니다.

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
    
    // 비즈니스 예외 처리
    @ExceptionHandler(PaymentException.class)
    public ResponseEntity<ErrorResponse> handlePaymentException(PaymentException e) {
        log.error("Payment exception occurred: {}", e.getMessage());
        
        ErrorResponse errorResponse = ErrorResponse.builder()
            .code(e.getErrorCode())
            .message(e.getMessage())
            .build();
            
        return ResponseEntity.status(e.getHttpStatus()).body(errorResponse);
    }
    
    // 유효성 검증 예외 처리
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationExceptions(MethodArgumentNotValidException e) {
        log.error("Validation exception occurred: {}", e.getMessage());
        
        Map<String, String> fieldErrors = new HashMap<>();
        e.getBindingResult().getFieldErrors().forEach(error -> 
            fieldErrors.put(error.getField(), error.getDefaultMessage())
        );
        
        ErrorResponse errorResponse = ErrorResponse.builder()
            .code("VALIDATION_ERROR")
            .message("요청 데이터 검증 실패")
            .details(fieldErrors)
            .build();
            
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
    }
    
    // 외부 API 통신 예외 처리
    @ExceptionHandler(ExternalApiException.class)
    public ResponseEntity<ErrorResponse> handleExternalApiException(ExternalApiException e) {
        log.error("External API error: {}, Details: {}", e.getMessage(), e.getResponseBody());
        
        // 외부 에러 로깅 및 알림 처리
        alertService.sendApiFailureAlert(e.getApiName(), e.getMessage());
        
        ErrorResponse errorResponse = ErrorResponse.builder()
            .code("EXTERNAL_API_ERROR")
            .message("외부 시스템 연동 오류")
            .build();
            
        return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(errorResponse);
    }
}

이 프로젝트를 진행하면서 Spring MVC의 다양한 기능이 복잡한 결제 시스템 개발에 얼마나 유용한지 실감했습니다. 특히 AOP 기반의 ResponseBodyAdvice와 예외 처리 통합은 코드 중복을 크게 줄이고 일관성을 높이는 데 큰 도움이 되었습니다.

해당 프로젝트에서 이런 구조를 도입한 후 새로운 API 엔드포인트 추가 시간이 약 30% 단축되었고, 코드리뷰에서 발견되는 응답 형식 불일치 이슈가 거의 사라졌습니다. Spring MVC의 이러한 확장성과 유연함이 정말 도움이 많이 되었습니다.

결론 및 느낀점

Spring MVC 아키텍처를 공부하면서 디스패처 서블릿을 중심으로 한 전체 구조의 효율성에 감탄했습니다. 곧 멀지 않은 미래에 이러한 대형 아키텍처를 구현해 보이겠다는 다짐까지 할 정도였으니 ㅎㅎ.. 처음에는 복잡해 보였지만, 이런 구조 덕분에 컨트롤러 코드가 간결해지고 확장성이 좋아진다는 것을 알게 되었습니다.

특히 Faddy 결제 시스템을 개발하면서 경험한 MVC 패턴의 실제 적용은 이론을 넘어 실무적 가치를 확실히 보여주었습니다. 컨트롤러, 서비스, 레포지토리 계층의 명확한 분리는 코드의 가독성과 유지보수성을 크게 향상시켰고, CrossOrigin 설정과 API 응답 표준화를 통해 프론트엔드와의 원활한 통합도 가능했습니다.

이번 포스팅에서는 MVC의 기본 구조와 흐름을 중심으로 살펴봤지만, 앞으로는 각 컴포넌트별로 더 깊이 있는 학습이 필요하다고 느꼈습니다. 특히 핸들러 인터셉터, 커스텀 어노테이션, 메시지 컨버터 등 좀 더 고급 기능들에 대해 추가 포스팅을 준비해야겠습니다. 또한 WebFlux와 같은 리액티브 프로그래밍 모델과의 비교도 다뤄보면 좋을 것 같은데... 공부량이..

다음 포스팅에서는 좀 더 깊이 있는 스프링 기술에 대해서 공부하고 포스팅해야 겠습니다! 실제 프로젝트에서 경험한 성능 최적화 사례도 공유해보도록 하겠습니다. 긴 글 읽어주셔서 감사합니다!


참고자료

  1. 김영한, "스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술" - 인프런 강의

  2. 이일민, "토비의 스프링 3.1" - 아직도 현역에서 통하는 스프링의 기본 원리 설명서

  3. Rod Johnson, Juergen Hoeller, "Spring Framework Reference Documentation" - 스프링 공식 문서

  4. 우아한형제들 기술 블로그, "Spring MVC 제대로 이해하기" - 현업에서의 MVC 적용 사례

  5. 망나니개발자, "스프링 MVC 구조 이해하기" - 상세한 구조 설명과 다이어그램

  6. Baeldung, "Spring MVC Tutorial" - 영문 튜토리얼과 최신 기술 적용

profile
개발과 캣잎, 두 마리 토끼를 잡는 고양이를 사랑하는 주니어 개발자 입니다!~

0개의 댓글