# 웹 서버, 웹 애플리케이션 서버 (WAS)

  • 웹서버
    • HTTP 기반으로 동작
    • 정적 리소스 제공
    • NGINX, APACHE
  • WAS
    • HTTP 기반으로 동작
    • 웹서버 기능 + 프로그램 코드를 실행하여 애플리케이션 로직 수행
    • 동적 HTML / JSON
    • 톰캣, Jetty, Undertow
  • WAS가 모든 요청을 처리할 수 있지만, 실제로는 역할을 분리하여 운영한다.
    • 정적 리소스는 웹 서버(Nginx 등)가 처리하고, 동적 로직이 필요한 경우에만 WAS에 위임한다.
    • 이렇게 하면 정적 리소스 트래픽이 많아질 때는 웹 서버를, 애플리케이션 로직 부하가 커질 때는 WAS를 독립적으로 증설할 수 있다.
  • 정적 리소스만 제공하는 웹서버는 상대적으로 안정적이고, 애플리케이션 로직이 수행되는 WAS는 더 잘 죽는다.
    • WAS / DB 장애시 웹서버가 오류 화면을 제공할 수 있게 된다.
  • 서블릿은 Java로 HTTP 요청/응답을 처리하기 위한 표준 인터페이스이다.
    • 소켓/스트림 처리, 메서드 구분, URL 파싱, 헤더 파싱 등 반복적인 저수준 작업을 추상화한다.
    • 덕분에 개발자는 HTTP 스펙을 편리하게 사용하면서 비즈니스 로직 작성에만 집중할 수 있다.
  • HTTP 요청 처리 흐름
    1. WAS(Tomcat)가 HTTP 요청을 받아 HttpServletRequest, HttpServletResponse 객체 생성
    2. 요청 URL에 매핑된 서블릿을 서블릿 컨테이너에서 조회 후 실행
    3. 서블릿의 비즈니스 로직 수행 (개발자 작성 코드)
    4. 로직 종료 후 HttpServletResponse 객체를 기반으로 HTTP 응답 메시지 생성
    5. 클라이언트에 응답 전달
  • 서블릿을 지원하는 WAS를 서블릿 컨테이너라고 함. (ex - 톰캣)
    • 서블릿 컨테이너는 서블릿 객체를 생성, 초기화, 호출, 종료하는 생명주기를 관리
    • 서블릿 객체는 싱글톤으로 관리
  • WAS 최대 튜닝포인트는 최대 스레드 수이다.
    • 낮으면: 동시 요청이 많을때 서버 리소스가 여유롭지만 클라이언트의 응답 지연
    • 높으면: 동시요청이 많으면 서버 리소스 임계점 초과 가능성 존재
  • 클라우드 기반 환경에서 장애 발생시에는 서버부터 늘리고 튜닝
  • 스레드 풀 적정 숫자는 아파치 ab, 제이미터, nGrinder등의 툴을 활용하여 성능 테스트 후 결정한다.
  • 스레드 생성 소멸 / 풀 관리 / 할당 등의 작업들은 WAS에서 해주기 때문에, 개발자는 서블릿 로직만 작성하면 된다.
    • Spring Bean / 서블릿 객체는 싱글톤 기반이기 때문에 멤버 변수를 사용하지 않는 것이 관례

# 서블릿

  • 서블릿은 HTTP 요청 / 응답 처리의 저수준 작업을 대신 해주는 자바 표준 인터페이스이다.
  • 서블릿은 톰캣같은 WAS를 직접 설치하고 그 위에 서블릿 코드를 클래스 파일로 빌드하여 올린 후 톰캣 서버를 실행해야 한다.
  • 이 과정은 번거롭지만 스프링에는 톰캣이 내장되어 있기 때문에 저 과정을 거치지 않아도 된다.
  • @ServletComponentScan을 사용하면 서블릿을 직접 등록하여 사용 가능하다.
@ServletComponentScan
@SpringBootApplication
public class ServletApplication {

	public static void main(String[] args) {
		SpringApplication.run(ServletApplication.class, args);

	}
}
// ..
@WebServlet(name = "helloServlet", urlPatterns = "/hello")
public class HelloServlet extends HttpServlet {

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        System.out.println("HelloServlet.service");
        System.out.println("request = " + req);
        System.out.println("response = " + resp);

        String username = req.getParameter("username");
        System.out.println("username = " + username);

        resp.setContentType("text/plain");
        resp.setCharacterEncoding("utf-8");
        resp.getWriter().write("hello " + username);
    }
}
  • HTTP를 통해 매핑된 url이 호출되면 서블릿 컨테이너가 service 메서드를 실행한다.
    • urlPatterns에 와일드카드가 없으면 정확히 일치할때만 매핑에 성공한다.

# HttpServletRequest

  • 서블릿은 개발자가 HTTP 요청 메시지를 편리하게 사용할 수 있도록 개발자 대신 HTTP 요청 메시지를 파싱한다. 이후 결과를 HttpServletRequest 객체에 담아 제공한다.
  • HttpServletRequest는 HTTP 요청 정보 외에 아래 부가 기능도 제공한다.
    1. 임시 저장소 기능
      • HTTP 요청 시작부터 종료까지 유지되는 임시 저장소
      • request.setAttribute(name, value) — 데이터 저장
      • request.getAttribute(name) — 데이터 조회
    2. 세션 관리 기능
      • request.getSession(create) — true면 세션 없을 때 새로 생성, false면 기존 세션만 반환

# HTTP 요청 데이터 전달

  • HTTP 요청 메시지를 통해 클라이언트에서 서버로 데이터를 전달하는 방법은 다음과 같다.
    1. GET - 쿼리 파라미터
    2. POST - HTML Form
    3. HTTP message body
  • GET 쿼리 파라미터
    • URL에 ?로 시작하여 &로 이어 붙인다.
    • 예: /hello?username=jun&age=29
    • request.getParameter(keyName): 단일 파라미터 조회
    • request.getParameterNames() 등 다양한 조회 메서드를 제공한다.
  • HTML Form
    • Content-Type: application/x-www-form-urlencoded 형식으로 메시지 바디에 담아 전송
    • GET 쿼리 파라미터와 형식이 동일해서 서버에서 request.getParameter()로 동일하게 조회 가능
    • POST 방식이므로 반드시 Content-Type 헤더를 명시해야 한다.
  • HTTP message body
    • InputStream으로 바디 데이터를 읽어들이며, 바이트 코드를 반환하므로 Charset 지정이 필요하다.
    • 단순 텍스트: Content-Type: text/plain, JSON: Content-Type: application/json
    • JSON 결과를 Java 객체로 변환하기 위해 Jackson 라이브러리의 ObjectMapper를 사용한다.
private ObjectMapper objectMapper = new ObjectMapper();

@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    ServletInputStream inputStream = req.getInputStream();
    String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);

    System.out.println("messageBody = " + messageBody);
    HelloData helloData = objectMapper.readValue(messageBody, HelloData.class);
    System.out.println("helloData.username = " + helloData.getUsername());
    System.out.println("helloData.age = " + helloData.getAge());

    resp.getWriter().write("ok");
}

Lombok Getter Setter

  • Lombok 라이브러리의 @Getter, @Setter 어노테이션을 사용하면 컴파일 시점에 자동으로 getter/setter 메서드를 생성해준다.
  • ObjectMapper가 JSON을 객체로 변환할 때 내부적으로 getter/setter를 사용하므로, Lombok으로 간결하게 대체할 수 있다.
import lombok.Getter;
import lombok.Setter;

@Getter @Setter
public class HelloData {
    private String username;
    private int age;
}

# HttpServletResponse

  • HTTP 응답 메시지 생성
    • HTTP 응답 코드 지정
    • 헤더 생성
    • 바디 생성
  • Content-Type, 쿠키, Redirect 기능 제공

# HTTP 응답 데이터 전달

  • HTTP 응답 데이터를 전달하는 방법들은 다음과 같다.
    • writer.println("message");를 통한 단순 텍스트 응답 전달
    • HTML 응답
    • HTTP API - MessageBody JSON 응답
  • HTML 응답 방식
    • resp.setContentType("text/html")로 지정
    • resp.getWriter()로 PrintWriter객체를 얻고, writer.println("<html></html>")로 html을 작성한다.
  • HTTP 응답으로 데이터를 JSON으로 반환할때는 Content-Type을 application/json으로 지정해야 한다.
  • objectMapper.writeValueAsString()을 사용하면 객체를 JSON 문자로 변환해준다.
private ObjectMapper objectMapper = new ObjectMapper();

@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    resp.setHeader("content-type", "application/json");
    resp.setCharacterEncoding("utf-8");

    HelloData data = new HelloData();
    data.setUsername("kim");
    data.setAge(20);

    String result = objectMapper.writeValueAsString(data);
    resp.getWriter().write(result);
}

# JSP

  • HTTP 응답 메시지에 HTML을 직접 작성하는 방식은 매우 번거롭다.
  • JSP와 같은 템플릿 엔진을 사용하면 동적 데이터를 HTML에 삽입하여 완성된 페이지를 쉽게 만들 수 있다.
  • 서블릿만 사용 시 HTML 작업과 자바 코드가 섞여 지저분하고 복잡했다.
    • JSP를 통해 HTML과 자바 코드를 어느 정도 분리하고, 동적 변경이 필요한 부분에만 자바 코드를 부분적으로 적용할 수 있었다.
    • 단, JSP 내에 비즈니스 로직과 자바 코드가 여전히 혼재하는 한계가 있었다.
  • 이를 해결하기 위해 MVC 패턴이 등장했다. 비즈니스 로직은 서블릿(Controller)에서 처리하고, JSP는 뷰를 그리는 일에만 집중하는 방식으로 역할을 분리한다.
    • JSP는 현재 잘 사용되지 않으며, Thymeleaf 등의 템플릿 엔진으로 대체됨

# MVC 패턴

  • MVC 패턴은 하나의 서블릿이나 하나의 JSP로 처리하던 것을 컨트롤러와 뷰 영역으로 역할을 나눈 것을 의미한다.
  • Controller: HTTP요청을 받아 파라미터 검증 / 비즈니스 로직 실행 / 뷰에 전달할 결과 데이터 조회 후 모델에 담음
    • 컨트롤러가 비대해지는 문제를 개선하기 위해 비즈니스 로직은 Service 계층을 만들어 처리하는 것이 일반적이다.
  • Model: 뷰에 출력할 데이터를 담아둠. 뷰는 비즈니스 로직이나 데이터 접근을 몰라도 되고 화면 렌더링에만 집중 가능
  • View: 모델에 담겨있는 데이터를 사용하여 화면 그리는 일에 집중
@WebServlet(name = "mvcMemberFormServlet", urlPatterns = "/servlet-mvc/members/new-form")
public class MvcMemberFormServlet extends HttpServlet {

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String viewPath = "/WEB-INF/views/new-form.jsp";
        RequestDispatcher dispatcher = req.getRequestDispatcher(viewPath);
        dispatcher.forward(req,resp);
    }
}
  • Java EE(Jakarta EE)의 WAS 자체 스펙상 /WEB-INF 하위 경로는 외부 접근이 불가능하다.
    • MVC 패턴 기준으로는 항상 컨트롤러 내에서의 비즈니스 로직을 통해서만 JSP를 호출하겠다는 것을 의미한다.
  • RequestDispatcher는 요청을 다른 리소스로 전달하는 객체이다.

redirect vs forward

  • 리다이렉트는 실제 클라이언트에 응답이 나갔다가 클라이언트가 redirect 경로로 다시 요청하게 된다. 클라이언트가 인지할 수 있고 URL 경로도 변경된다.
  • 포워드는 서버 내부에서 일어나는 호출이기 때문에 클라이언트가 인지하지 못한다.
  • JSP 기반 MVC 패턴의 한계
    • HttpServletRequest, HttpServletResponse를 사용하지 않는 컨트롤러도 있어 인터페이스 통일이 어렵다.
    • 포워드, 뷰 경로 설정 등 공통 로직이 각 컨트롤러마다 중복된다.
    • 공통 처리가 어렵다는 문제가 있다.

# 프론트 컨트롤러 패턴

  • 기본적인 MVC는 컨트롤러마다 공통 로직이 중복되는 한계가 있다.
  • 프론트 컨트롤러 패턴은 모든 클라이언트 요청을 서블릿 하나로 받아 요청에 맞는 컨트롤러를 찾아 호출하는 패턴이다.
    • 공통 로직을 프론트 컨트롤러 한 곳에서 처리할 수 있다.
    • HTTP 관련 처리를 프론트 컨트롤러가 담당하므로, 나머지 컨트롤러는 서블릿 없이 순수 Java 클래스로 작성 가능하다.
  • Spring MVC의 DispatcherServlet이 프론트 컨트롤러 패턴으로 구현되어 있다.

# V1

@WebServlet(name = "frontControllerServletV1", urlPatterns = "/front-controller/v1/*")
public class FrontControllerServletV1 extends HttpServlet {
    private Map<String, ControllerV1> controllerMap = new HashMap<>();
    public FrontControllerServletV1() {
        controllerMap.put("/front-controller/v1/members/new-form", new MemberFormControllerV1());
        controllerMap.put("/front-controller/v1/members/save", new MemberSaveControllerV1());
        controllerMap.put("/front-controller/v1/members", new MemberListControllerV1());
    }
    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String requestURI = req.getRequestURI();
        ControllerV1 controller = controllerMap.get(requestURI);
        if (controller == null) {
            resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }
        controller.process(req, resp);
    }
}
  • URI와 컨트롤러 매핑 정보를 Map으로 관리하고, 요청 URI로 컨트롤러를 조회한다.
    • 조회된 컨트롤러가 있으면 process() 실행, 없으면 404 반환
    • 모든 컨트롤러는 ControllerV1 인터페이스를 구현하여 process() 메서드를 반드시 구현하도록 추상화되어 있다.

# V2

  • 기존에는 모든 컨트롤러에서 JSP 포워딩 코드가 중복되었다.
  • 이를 해결하기 위해 뷰 이동 로직을 MyView 객체로 분리하고, 렌더링은 프론트 컨트롤러가 담당하도록 위임한다.
public class MyView {
    private String viewPath;
    public MyView(String viewPath) {
        this.viewPath = viewPath;
    }
    public void render(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
        dispatcher.forward(request, response);
    }
}
  • 동작 흐름
    1. 클라이언트 요청 → 프론트 컨트롤러
    2. 프론트 컨트롤러 → 컨트롤러 호출
    3. 컨트롤러 → MyView 객체 반환 (뷰 경로만 담아서)
    4. 프론트 컨트롤러 → myView.render() 호출 → JSP 포워딩 → 응답

# V3

  • 리팩토링 이후 컨트롤러들은 서블릿 관련 코드 없이 비즈니스 로직에만 집중할 수 있다.
    • HttpServletRequest, HttpServletResponse 불필요
    • JSP 물리 경로는 프론트 컨트롤러의 viewResolver가 처리
    • 컨트롤러는 논리적 뷰 이름만 반환
  • 컨트롤러 인터페이스와 ModelView
// 인터페이스: 서블릿 의존성 제거, paramMap만 받아 ModelView 반환
public interface ControllerV3 {
    ModelView process(Map<String, String> paramMap);
}

// ModelView: 논리적 뷰 이름 + JSP 렌더링에 필요한 데이터 보관
public class ModelView {
    @Getter @Setter
    private String viewName;
    @Getter @Setter
    private Map<String, Object> model = new HashMap<>();
    public ModelView(String viewName) {
        this.viewName = viewName;
    }
}
  • 컨트롤러 구현체 예시 (멤버 리스트 조회)
public class MemberListControllerV3 implements ControllerV3 {
    private MemberRepository memberRepository = MemberRepository.getInstance();
    @Override
    public ModelView process(Map<String, String> paramMap) {
        List<Member> members = memberRepository.findAll();
        ModelView mv = new ModelView("members");
        mv.getModel().put("members", members);  // JSP에서 접근할 데이터 저장
        return mv;
    }
}
  • 프론트 컨트롤러
@WebServlet(name = "frontControllerServletV3", urlPatterns = "/front-controller/v3/*")
public class FrontControllerServletV3 extends HttpServlet {
    private Map<String, ControllerV3> controllerMap = new HashMap<>();
    // ...
    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String requestURI = req.getRequestURI();
        ControllerV3 controller = controllerMap.get(requestURI);
        if (controller == null) {
            resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }
        Map<String, String> paramMap = createParamMap(req);  // request 파라미터 추출
        ModelView mv = controller.process(paramMap);          // 컨트롤러 실행
        MyView view = viewResolver(mv.getViewName());         // 논리 → 물리 경로 변환
        view.render(mv.getModel(), req, resp);                // 렌더링
    }
    private Map<String, String> createParamMap(HttpServletRequest request) {
        Map<String, String> paramMap = new HashMap<>();
        request.getParameterNames().asIterator()
                .forEachRemaining(paramName -> paramMap.put(paramName, request.getParameter(paramName)));
        return paramMap;
    }
    private MyView viewResolver(String viewName) {
        return new MyView("/WEB-INF/views/" + viewName + ".jsp");
    }
}
  • MyView 렌더링
public class MyView {
    private String viewPath;
    public MyView(String viewPath) {
        this.viewPath = viewPath;
    }
    public void render(Map<String, Object> model, HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        modelToRequestAttribute(model, request);  // model 데이터를 request attribute로 이동
        RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
        dispatcher.forward(request, response);    // JSP 포워딩
    }
    private void modelToRequestAttribute(Map<String, Object> model, HttpServletRequest request) {
        model.forEach((key, value) -> request.setAttribute(key, value));
    }
}
  • 동작 흐름
    1. HTTP 요청 → 프론트 컨트롤러 호출
    2. 요청 URI로 컨트롤러 맵 조회 (없으면 404 반환)
    3. createParamMap()으로 request 파라미터를 Map으로 추출
    4. 컨트롤러 process(paramMap) 실행 → ModelView 반환
      • ModelView.model에 JSP 렌더링에 필요한 데이터 저장
    5. viewResolver()로 논리적 뷰 이름 → 물리적 JSP 경로 변환, MyView 생성
    6. view.render() 호출
      • model 데이터를 request.setAttribute()로 옮김
      • RequestDispatcher.forward()로 JSP 포워딩
    7. JSP에서 request attribute 접근하여 동적 HTML 렌더링 후 클라이언트에 응답

# V4

  • 각 컨트롤러에서 ModelView 객체를 직접 생성하고 있었는데, 뷰 이름 문자열값만 리턴하는 역할로 리팩토링 할 수 있다.
public class MemberListControllerV4 implements ControllerV4 {

    MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    public String process(Map<String, String> paramMap, Map<String, Object> model) {
        List<Member> members = memberRepository.findAll();
        model.put("members", members);

        return "members";
    }
}
  • 파라미터의 모델뷰 맵 객체를 참조하여 JSP에서 활용할 데이터를 직접 넣어주는 방식이다.

# 어댑터 패턴 활용

  • 서로 다른 인터페이스 명세를 가진 컨트롤러들을 동시에 지원하기 위해 핸들러 어댑터 패턴을 사용한다.
public interface ControllerV3 {
    ModelView process(Map<String, String> paramMap);
}

public interface ControllerV4 {
    String process(Map<String, String> paramMap, Map<String, Object> model);
}

public interface MyHandlerAdapter {
    boolean supports(Object handler);
    ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException, IOException;
}
  • 프론트 컨트롤러는 핸들러 매핑 맵과 어댑터 목록을 초기화하여 관리한다.
@WebServlet(name = "frontControllerServletV5", urlPatterns = "/front-controller/v5/*")
public class FrontControllerServletV5 extends HttpServlet {
    private final Map<String, Object> handlerMappingMap = new HashMap<>();
    private final List<MyHandlerAdapter> handlerAdapters = new ArrayList<>();
    public FrontControllerServletV5() {
        initHandlerMappingMap();
        initHandlerAdapters();
    }
    private void initHandlerMappingMap() {
        handlerMappingMap.put("/front-controller/v5/v3/members/new-form", new MemberFormControllerV3());
        handlerMappingMap.put("/front-controller/v5/v3/members/save", new MemberSaveControllerV3());
        handlerMappingMap.put("/front-controller/v5/v3/members", new MemberListControllerV3());
        handlerMappingMap.put("/front-controller/v5/v4/members/new-form", new MemberFormControllerV4());
        handlerMappingMap.put("/front-controller/v5/v4/members/save", new MemberSaveControllerV4());
        handlerMappingMap.put("/front-controller/v5/v4/members", new MemberListControllerV4());
    }
    private void initHandlerAdapters() {
        handlerAdapters.add(new ControllerV3HandlerAdapter());
        handlerAdapters.add(new ControllerV4HandlerAdapter());
    }
    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        Object handler = getHandler(req);
        if (handler == null) {
            resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }
        MyHandlerAdapter adapter = getHandlerAdapter(handler);
        ModelView mv = adapter.handle(req, resp, handler);
        MyView view = viewResolver(mv.getViewName());
        view.render(mv.getModel(), req, resp);
    }
    private Object getHandler(HttpServletRequest request) {
        return handlerMappingMap.get(request.getRequestURI());
    }
    private MyHandlerAdapter getHandlerAdapter(Object handler) {
        for (MyHandlerAdapter adapter : handlerAdapters) {
            if (adapter.supports(handler)) {
                return adapter;
            }
        }
        throw new IllegalArgumentException("handler adapter를 찾을 수 없음: " + handler);
    }
    private MyView viewResolver(String viewName) {
        return new MyView("/WEB-INF/views/" + viewName + ".jsp");
    }
}
  • URL 경로에 v3/v4가 포함되어 어떤 컨트롤러 타입으로 요청할지 구분한다.
    • /front-controller/v5/v3/members/new-form → V3 컨트롤러
    • /front-controller/v5/v4/members/new-form → V4 컨트롤러
  • 핸들러 매핑 맵은 Object 타입으로 컨트롤러를 저장하고, 조회 후 어댑터 내에서 다운캐스팅하여 사용한다.
  • 어댑터는 supports()로 자신이 처리할 수 있는 핸들러인지 확인하고, 처리 가능하면 handle()을 통해 비즈니스 로직을 수행한다.
// V3 어댑터
public class ControllerV3HandlerAdapter implements MyHandlerAdapter {
    @Override
    public boolean supports(Object handler) {
        return (handler instanceof ControllerV3);
    }
    @Override
    public ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException, IOException {
        ControllerV3 controller = (ControllerV3) handler;
        Map<String, String> paramMap = createParamMap(request);
        return controller.process(paramMap);
    }
}

// V4 어댑터: ControllerV4는 viewName(String)을 반환하므로 ModelView로 변환
public class ControllerV4HandlerAdapter implements MyHandlerAdapter {
    @Override
    public boolean supports(Object handler) {
        return (handler instanceof ControllerV4);
    }
    @Override
    public ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException, IOException {
        ControllerV4 controller = (ControllerV4) handler;
        Map<String, String> paramMap = createParamMap(request);
        Map<String, Object> model = new HashMap<>();
        String viewName = controller.process(paramMap, model);
        ModelView mv = new ModelView(viewName);
        mv.setModel(model);
        return mv;
    }
}
  • V3는 ModelView를 직접 반환하지만, V4는 String(논리적 뷰 이름)을 반환하므로 어댑터에서 ModelView로 변환하여 프론트 컨트롤러에 반환한다.
  • 각 컨트롤러의 인터페이스 차이는 어댑터 내부에서 처리되므로, 프론트 컨트롤러는 항상 ModelView만 받아 동일하게 처리할 수 있다.
    • 새로운 컨트롤러 인터페이스 추가 시 어댑터만 구현하면 된다.
  • 동작 흐름
    1. HTTP 요청 → 프론트 컨트롤러 호출
    2. handlerMappingMap에서 요청 URI로 핸들러 조회 (없으면 404 반환)
    3. handlerAdapters를 순회하며 supports()로 처리 가능한 어댑터 획득
    4. 어댑터의 handle() 호출 → 내부에서 paramMap 생성 후 컨트롤러 process() 실행
    5. 어댑터가 ModelView 반환 (V4는 String → ModelView 변환 포함)
    6. viewResolver()로 논리적 뷰 이름 → 물리적 JSP 경로 변환
    7. view.render()로 model 데이터를 request attribute로 옮긴 뒤 JSP 포워딩
    8. JSP에서 동적 HTML 렌더링 후 클라이언트에 응답

# Spring MVC 구조

  • 직접 구현한 구조와 Spring MVC 구조 비교
직접 구현 Spring MVC 역할
FrontController DispatcherServlet 모든 요청을 받아 핸들러 조회 → 어댑터 실행 → 렌더링까지 담당
handlerMappingMap HandlerMapping URL과 핸들러(컨트롤러) 매핑 정보 관리
MyHandlerAdapter HandlerAdapter supports()로 처리 가능 여부 확인, handle()로 컨트롤러 실행 후 ModelView 반환
ModelView ModelAndView 뷰 이름과 데이터 모델을 함께 보관하는 객체
viewResolver ViewResolver 논리적 뷰 이름 → 물리적 뷰 경로로 변환
MyView View RequestDispatcher.forward()로 실제 뷰 렌더링 수행

# DispatcherServlet 구조

  • 스프링 MVC 역시 프론트 컨트롤러 패턴 기반으로 구현되어 있고, 스프링 MVC의 프론트 컨트롤러가 DispatcherServlet이다.
  • DispatcherServlet도 HttpServlet을 상속받아 사용하여 서블릿으로 동작한다.
  • Spring MVC 요청 흐름
    1. 클라이언트 요청 → DispatcherServlet 호출 → HttpServlet.service() 실행
    2. FrameworkServlet.service()가 오버라이딩되어 있어 이를 거쳐 DispatcherServlet.doDispatch()까지 호출된다.
  • doDispatch() 처리 흐름
    1. getHandler() — 요청 URI 기반으로 핸들러 조회
    2. getHandlerAdapter() — 핸들러를 처리할 수 있는 어댑터 조회
    3. adapter.handle() — 어댑터 실행 → 실제 핸들러 실행 → 컨트롤러 비즈니스 로직 수행 → ModelAndView 반환
    4. processDispatchResult() — 뷰 리졸버로 뷰 조회 후 렌더링 수행
  • HandlerMapping, HandlerAdapter, ViewResolver, View 인터페이스 구현 후 DispatcherServlet에 등록하면 직접 컨트롤러를 만들 수 있다.

# HandlerMapping, HandlerAdapter

@Component("/springmvc/controller")
public class MyController implements Controller {
    // ..
}
  • HandlerMapping과 HandlerAdapter는 쌍으로 동작한다.
HandlerMapping HandlerAdapter 방식
RequestMappingHandlerMapping RequestMappingHandlerAdapter @Controller 어노테이션 기반 (현재 주류)
BeanNameUrlHandlerMapping SimpleControllerHandlerAdapter Controller 인터페이스 구현 (레거시)
BeanNameUrlHandlerMapping HttpRequestHandlerAdapter HttpRequestHandler 인터페이스 구현 (레거시)
  • 실무에서는 RequestMappingHandlerMapping + RequestMappingHandlerAdapter 조합만 사용한다.
  • 여러 HandlerMapping이 등록된 경우 우선순위 순으로 조회하며, RequestMappingHandlerMapping이 가장 높은 우선순위(0)를 가진다.

# ViewResolver

@Component("/springmvc/old-controller")
public class OldController implements Controller {
    @Override
    public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception {
        return new ModelAndView("new-form");  // 논리적 뷰 이름만 반환
    }
}
  • Spring Boot는 InternalResourceViewResolver를 자동으로 등록하며, application.properties의 설정을 기반으로 논리적 뷰 이름을 물리적 경로로 변환한다.
spring.mvc.view.prefix=/WEB-INF/views/
spring.mvc.view.suffix=.jsp
  • 위 설정 시 "new-form" 반환 → /WEB-INF/views/new-form.jsp로 변환되어 렌더링된다.
  • Spring Boot가 자동으로 등록하는 주요 뷰 리졸버
우선순위 뷰 리졸버 설명
1 BeanNameViewResolver 뷰 이름과 일치하는 Bean을 찾아 반환
2 InternalResourceViewResolver prefix + 뷰 이름 + suffix로 JSP 경로를 생성하여 반환
  • 뷰 리졸버는 우선순위 순으로 조회하며, 앞 순위에서 뷰를 찾으면 이후 리졸버는 조회하지 않는다.
  • InternalResourceViewResolver는 우선순위가 가장 낮아 다른 리졸버에서 찾지 못한 경우 마지막에 조회된다.

# Spring MVC 시작

@Controller
public class SpringMemberFormControllerV1 {
    @RequestMapping("/springmvc/v1/members/new-form")
    public ModelAndView process() {
        return new ModelAndView("new-form");
    }
}
  • @Controller
    • 내부에 @Component가 포함되어 있어 컴포넌트 스캔 대상이 되며 Spring Bean으로 자동 등록된다.
    • RequestMappingHandlerMapping@RequestMapping 계열 어노테이션을 스캔하여 핸들러로 인식한다.
  • @RequestMapping: 해당 URL로 요청이 오면 메서드를 호출한다.
    • 어노테이션 기반이므로 메서드 이름은 자유롭게 정의 가능하다.
  • ModelAndView: 뷰 이름과 모델 데이터를 함께 담아 반환하는 객체
@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;
    }
}
  • ArgumentResolver가 메서드 파라미터 타입을 분석하여 HttpServletRequest, HttpServletResponse 등을 자동으로 주입한다. 파라미터 순서는 무관하다.
  • @RequestMapping에 매핑된 URL로 요청이 오면 메서드가 호출된다. GET/POST 구분 없이 모두 처리하며, 특정 메서드만 허용하려면 @GetMapping, @PostMapping을 사용한다.
  • mv.addObject(): 모델에 데이터를 추가하며, ViewResolver를 거쳐 JSP에서 해당 데이터에 접근할 수 있다.

# Spring MVC 통합

@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 mv = new ModelAndView("save-result");
        mv.addObject("member", member);
        return mv;
    }

    @RequestMapping
    public ModelAndView members() {
        List<Member> members = memberRepository.findAll();
        ModelAndView mv = new ModelAndView("members");
        mv.addObject("members", members);
        return mv;
    }
}
  • 메서드 단위로 적용되는 매핑을 하나의 클래스에 모아 처리할 수 있다.

# Spring 부가기능 활용

@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";
    }
}
  • @GetMapping, @PostMapping: @RequestMapping에 HTTP 메서드 제한을 추가한 어노테이션으로, 더 직관적인 표현이 가능하다.
  • 클래스 레벨에 @RequestMapping을 선언하면 하위 메서드의 URL 앞에 자동으로 붙는다.
    • 예: /springmvc/v3/members + /new-form/springmvc/v3/members/new-form
  • @RequestParam: HTTP 요청 파라미터를 메서드 파라미터로 바인딩하며, 타입 변환도 자동으로 처리한다.
    • GET 쿼리 파라미터, POST Form 방식 모두 지원
  • Model: ArgumentResolver가 자동 주입하며, addAttribute()로 데이터를 담으면 ViewResolver를 거쳐 JSP에서 접근 가능하다.
  • 논리적 뷰 이름을 반환하면 InternalResourceViewResolverapplication.properties의 prefix/suffix를 조합하여 물리적 JSP 경로로 변환한다.

@ModelAttribute

  • 추후 나올 ModelAttribute를 사용하면 addAttribute를 내부적으로 자동으로 처리해준다.
@PostMapping("/add")
public String addItem(
        @ModelAttribute Item item  // Model model 파라미터 생략 가능
) {
    itemRepository.save(item);
    // model.addAttribute("item", item); - @ModelAttribute가 자동 처리
    return "basic/item";
}

# Spring MVC 기본기능

# HTTP 메서드 매핑

@RequestMapping(value = "/hello-basic", method = RequestMethod.POST)
public String helloBasic() {
    // ..
    return "ok";
}
  • 위와 같이 method 파라미터로 메서드를 명시하면 해당 메서드만 처리한다. 명시하지 않으면 HTTP 메서드와 무관하게 호출된다.
    • 메서드 지정을 해둔 상태로 다른 메서드로 요청을 하게 되면 405 (Method not allowed)를 반환한다.
  • @GetMapping, @PostMapping, @PutMapping, @DeleteMapping, @PatchMapping으로 매핑 축약이 가능하다.
  • PathVariable을 사용하면 리소스 경로에 식별자를 넣는 케이스에 쉽게 대응이 가능하다.
@RestController
public class MappingController {

    private Logger log = LoggerFactory.getLogger(getClass());

    @GetMapping("/mapping/{userId}")
    public String helloBasic(@PathVariable("userId") String data) {
        log.info("data: {}", data);
        return "ok";
    }
}
  • public String mappingPath(@PathVariable String userId, @PathVariable Long orderId)와 같이 다중 파라미터도 받을 수 있다.
  • @PostMapping(value="/mapping-consume", consumes="application/json") 형태로 미디어 타입 기준 매핑 조건을 걸 수 있다.
    • 매핑에 실패한 경우 415 (Unsupported Media Type) 발생
  • produces = "text/html" 구조로 미디어 타입 매핑도 가능하다.
    • 매핑 실패한 경우 406 (Not Acceptable) 발생

# HTTP 헤더 조회

@Slf4j
@RestController
public class RequestHeaderController {

    @RequestMapping("/headers")
    public String headers(
            HttpServletRequest request,
            HttpServletResponse response,
            HttpMethod httpMethod,
            Locale locale,
            @RequestHeader MultiValueMap<String, String> headerMap,
            @RequestHeader("host") String host,
            @CookieValue(value = "myCookie", required = false) String cookie
    ) {
        log.info("request = {}", request);
        log.info("response = {}", response);
        log.info("httpMethod = {}", httpMethod);
        log.info("locale = {}", locale);
        log.info("headerMap = {}", headerMap);
        log.info("header host ={}", host);
        log.info("myCookie = {}", cookie);

        return "ok";
    }
}
  • @RequestHeader MultiValueMap<String, String> headerMap: 모든 헤더 정보를 Map으로 조회
  • @RequestHeader("host") String host: 특정 헤더 정보 조회
  • required = true(기본값): 해당 값이 요청에 없으면 400 Bad Request 반환
  • defaultValue 설정 시 해당 값이 요청에 없어도 매핑에 성공하며, 설정한 기본값이 파라미터에 주입된다.
  • MultiValueMap: 하나의 키에 여러 값을 List로 저장할 수 있는 자료구조
    • map.get("key")List<String> 반환
    • map.getFirst("key") → 첫 번째 값만 반환
    • 동일한 키로 여러 값이 오는 요청에서 사용 (예: ?username=jun&username=kim)

# HTTP 요청 파라미터 조회

  • 클라이언트에서 서버로 요청 데이터를 전달하는 방법
    1. GET 쿼리 파라미터: http://..?username=park&age=28
    2. POST HTML Form: HTTP 메시지 바디에 포함
    • 두 방식 모두 request.getParameter()로 동일하게 조회 가능
// V1: HttpServletRequest 직접 사용
@RequestMapping("/request-param-v1")
public void requestParamV1(HttpServletRequest request, HttpServletResponse response) throws IOException {
    String username = request.getParameter("username");
    int age = Integer.parseInt(request.getParameter("age"));
    response.getWriter().write("ok");
}
// V2: @RequestParam 사용 - 타입 변환 자동 처리
@ResponseBody
@RequestMapping("/request-param-v2")
public String requestParamV2(
        @RequestParam("username") String memberName,
        @RequestParam("age") int memberAge
) {
    return "ok";
}
  • @RequestParam: 요청 파라미터를 메서드 파라미터로 바인딩하며 타입 변환도 자동 처리
  • @ResponseBody: 뷰 조회 없이 반환값을 HTTP 메시지 바디에 직접 입력
  • HTTP 파라미터 이름과 변수명이 같으면 @RequestParam 자체도 생략 가능
    • String username@RequestParam("username") String username과 동일
// required, defaultValue 예시
@ResponseBody
@RequestMapping("/request-param-required")
public String requestParamRequired(
        @RequestParam(required = true) String username,
        @RequestParam(required = false) Integer age  // int → Integer로 변경
) {
    return "ok";
}
  • required = true(기본값): 파라미터 없이 요청 시 400 에러
  • required = false: 파라미터 없으면 null 주입
    • 기본 타입(int, long)은 null을 받을 수 없으므로 래퍼 타입(Integer, Long)을 사용해야 함
  • defaultValue: 파라미터가 없을 때 주입할 기본값 (문자열로만 지정, 파라미터 타입으로 자동 변환)
    • defaultValue 설정 시 required는 의미 없음 (항상 매핑 성공)
    • 빈 문자열("") 요청도 기본값으로 처리됨
  • @RequestParam Map<String, Object> paramMap: 전체 파라미터를 Map으로 한번에 조회
    • 하나의 키에 여러 값이 올 수 있는 경우 MultiValueMap 사용

@ModelAttribute

  • 요청 파라미터를 받아 객체를 생성하고 값을 주입하는 과정을 @ModelAttribute로 자동화할 수 있다.
// @ModelAttribute 없이 직접 처리
@RequestParam String username;
@RequestParam int age;
HelloData helloData = new HelloData(username, age);
// @Data: @Getter, @Setter, @ToString, @EqualsAndHashCode, @RequiredArgsConstructor 한번에 적용
@Data
public class HelloData {
    private String username;
    private int age;
}
// @ModelAttribute 사용
@ResponseBody
@RequestMapping("/model-attribute-v1")
public String modelAttributeV1(@ModelAttribute HelloData helloData) {
    log.info("username={}, age={}", helloData.getUsername(), helloData.getAge());
    return "ok";
}
  • @ModelAttribute 동작 순서
    1. HelloData 객체 생성
    2. 요청 파라미터 이름으로 객체의 프로퍼티 조회
    3. 해당 프로퍼티의 setter 호출하여 값 바인딩
      • 예: username 파라미터 → setUsername() 호출
  • @ModelAttribute도 생략 가능하다. (단, @RequestParam과 혼용 시 혼란스러울 수 있어 명시하는 것이 좋다.)

# HTTP 요청 메시지 바디 조회

  • GET 쿼리 파라미터, HTML Form 방식과 달리 HTTP 메시지 바디로 전달되는 텍스트/JSON은 별도 방식으로 읽어야 한다.
// V1: InputStream 직접 사용
@PostMapping("/request-body-string-v1")
public void requestBodyString(HttpServletRequest request, HttpServletResponse response) throws IOException {
    ServletInputStream inputStream = request.getInputStream();
    String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
    log.info("messageBody={}", messageBody);
    response.getWriter().write("ok");
}
// V2: 스프링이 지원하는 InputStream, Writer 파라미터 직접 사용
@PostMapping("/request-body-string-v2")
public void requestBodyStringV2(InputStream inputStream, Writer responseWriter) throws IOException {
    String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
    log.info("messageBody={}", messageBody);
    responseWriter.write("ok");
}
// V3: HttpEntity 사용
@PostMapping("/request-body-string-v3")
public HttpEntity<String> requestBodyStringV3(HttpEntity<String> httpEntity) {
    String messageBody = httpEntity.getBody();
    log.info("messageBody={}", messageBody);
    return new HttpEntity<>("ok");
}
  • HttpEntity: HTTP 헤더 + 바디를 편리하게 조회/반환 가능
    • 요청 파라미터 조회 불가 (쿼리 파라미터, Form 데이터는 @RequestParam 사용)
    • 응답으로 사용 시 뷰 조회 없이 메시지 바디에 직접 입력
  • RequestEntity: HttpEntity 상속, HTTP 메서드 + URL 정보 추가 (요청에 사용)
  • ResponseEntity: HttpEntity 상속, HTTP 상태코드 설정 가능 (응답에 사용)
    • return new ResponseEntity<>("Hello", responseHeaders, HttpStatus.CREATED)
// V4: @RequestBody 사용 (가장 간편)
@ResponseBody
@PostMapping("/request-body-string-v4")
public String requestBodyStringV4(@RequestBody String messageBody) {
    log.info("messageBody={}", messageBody);
    return "ok";
}
  • @RequestBody: HTTP 메시지 바디를 직접 파라미터에 바인딩
  • 헤더 정보가 필요한 경우 @RequestHeader 또는 HttpEntity 사용

# HTTP JSON 데이터 형식 조회

// V1: InputStream + ObjectMapper 직접 사용
@PostMapping("/request-body-json-v1")
public void requestBodyJsonV1(HttpServletRequest request, HttpServletResponse response) throws IOException {
    ServletInputStream inputStream = request.getInputStream();
    String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
    HelloData data = objectMapper.readValue(messageBody, HelloData.class);
    log.info("username={}, age={}", data.getUsername(), data.getAge());
    response.getWriter().write("ok");
}
// V2: @RequestBody로 바디 문자열 조회 후 ObjectMapper로 변환
@ResponseBody
@PostMapping("/request-body-json-v2")
public String requestBodyJsonV2(@RequestBody String messageBody) throws IOException {
    HelloData data = objectMapper.readValue(messageBody, HelloData.class);
    log.info("username={}, age={}", data.getUsername(), data.getAge());
    return "ok";
}
// V3: @RequestBody에 객체 타입 지정 → HttpMessageConverter가 JSON을 객체로 자동 변환
@ResponseBody
@PostMapping("/request-body-json-v3")
public String requestBodyJsonV3(@RequestBody HelloData data) {
    log.info("username={}, age={}", data.getUsername(), data.getAge());
    return "ok";
}
// V4: HttpEntity 제네릭 타입으로 객체 지정
@ResponseBody
@PostMapping("/request-body-json-v4")
public String requestBodyJsonV4(HttpEntity<HelloData> httpEntity) {
    HelloData data = httpEntity.getBody();
    log.info("username={}, age={}", data.getUsername(), data.getAge());
    return "ok";
}
  • V3, V4처럼 객체 타입을 지정하면 HttpMessageConverter가 JSON을 객체로 자동 변환해준다.
    • 요청 헤더의 Content-Type: application/json이 반드시 필요하다.
  • @ResponseBody로 객체를 반환하면 HttpMessageConverter가 객체를 JSON으로 자동 변환하여 응답 바디에 입력한다.

@RestController

  • @Controller + @ResponseBody 조합으로, 클래스 내 모든 메서드에 @ResponseBody가 적용된다.
  • 뷰 템플릿을 사용하지 않고 HTTP 메시지 바디에 데이터를 직접 입력하는 방식으로 동작한다.
  • 현재 REST API 서버 개발의 표준 방식이다.

# HTTP 메시지 컨버터

  • 뷰 템플릿으로 HTML을 생성하여 응답하는 것이 아닌 HTTP API처럼 JSON 데이터를 HTTP 메시지 바디에 직접 읽거나 쓰는 경우 HTTP 메시지 컨버터를 사용하면 편리하다.
  • @ResponseBody 사용 원리는 다음과 같다.
    1. HTTP 바디에 문자 내용을 직접 반환한다.
    2. viewResolver 대신 HttpMessageConverter가 동작한다.
    3. plain text 처리는 StringHttpMessageConverter
    4. 기본 객체 처리는 (JSON) MappingJackson2HttpMessageConverter
    5. 바이트 처리 등 (byte[]) HttpMessageConverter가 기본 등록되어 있음
  • 스프링 MVC는 다음 경우에 HTTP 메시지 컨버터를 적용한다.
    • HTTP 요청: @RequestBody, HttpEntity(RequestEntity)
    • HTTP 응답: @ResponseBody, HttpEntity(ResponseEntity)
  • 메시지 컨버터는 대상 클래스 타입과 미디어타입을 체크하여 사용여부를 결정한다.
    • 여러 컨버터 타입이 있는데, 해당되지 않으면 다음 순위로 넘어간다.
  • 요청 데이터를 읽는 과정은 다음과 같다.
    1. HTTP 요청이 오고 컨트롤러에서 @RequestBody, HttpEntity 파라미터를 사용한다.
    2. 메시지 컨버터가 메시지를 읽을 수 있는지 canRead 메서드를 내부적으로 호출한다.
      • byte[], String, 커스텀 클래스 타입 지원여부
      • HTTP Content-Type 미디어 타입 체크 (text/plain, application/json)
    3. canRead 조건을 만족하면 read를 호출하여 객체 생성 후 반환
  • 응답 데이터를 생성하는 과정은 다음과 같다.
    1. @ResponseBody, HttpEntity로 값 반환
    2. 메시지 컨버터가 메시지를 쓸수있는지 확인하기 위해 canWrite 호출
      • 클래스 타입 지원여부
      • 클라이언트 요청 헤더의 Accept 미디어 타입 지원여부
    3. 조건 만족시 write 호출 후 메시지 바디에 데이터 쓰기

# RequestMappingHandlerAdapter 구조

  • RequestMappingHandlerAdapter가 HTTP 메시지 컨버터를 포함한 전체 처리를 담당한다.

  • 동작 흐름

    1. ArgumentResolver: 컨트롤러 메서드의 파라미터 타입과 어노테이션을 분석하여 필요한 데이터를 생성 및 주입
      • 지원 타입: HttpServletRequest, Model, @RequestParam, @ModelAttribute, @RequestBody, HttpEntity
      • @RequestBody, HttpEntity 처리 시 내부적으로 HttpMessageConverter 호출
    2. 핸들러(컨트롤러 메서드) 호출
    3. ReturnValueHandler: 컨트롤러 반환값을 변환하여 응답 생성
      • 지원 타입: ModelAndView, @ResponseBody, HttpEntity
      • @ResponseBody, HttpEntity 처리 시 내부적으로 HttpMessageConverter 호출
  • HttpMessageConverterArgumentResolverReturnValueHandler 양쪽에서 호출된다.

    • 요청: Content-Type 헤더 기반으로 컨버터 선택 → JSON을 객체로 변환
    • 응답: Accept 헤더 기반으로 컨버터 선택 → 객체를 JSON으로 변환

ArgumentResolver

  • 어노테이션 기반 컨트롤러가 다양한 파라미터를 유연하게 지원할 수 있는 이유는 ArgumentResolver 덕분이다.
    1. supportsParameter() 호출 → 해당 파라미터 타입/어노테이션 지원 여부 확인
    2. 지원 시 resolveArgument() 호출 → 실제 객체 생성
    3. 생성된 객체를 컨트롤러 메서드 호출 시 파라미터로 전달
  • ReturnValueHandler도 유사하게 동작한다.
    1. supportsReturnType() 호출 → 반환 타입 지원 여부 확인
    2. 지원 시 handleReturnValue() 호출 → 반환값 변환 및 응답 처리
  • HTTP 메시지 컨버터는 ArgumentResolver, ReturnValueHandler에 위치하고 있다.

웹 브라우저 새로고침

  • 웹 브라우저의 새로고침은 마지막으로 서버에 전송한 요청을 다시 전송한다.
    • 마지막 요청이 POST였다면 새로고침 시 중복 POST 요청이 발생한다.
  • 이를 해결하기 위해 PRG(Post-Redirect-Get) 패턴을 사용한다.
    • POST 처리 후 리다이렉트로 GET 요청을 유도하면, 새로고침 시 GET 요청만 반복된다.
  • 리다이렉트 시 동적 값을 URL에 포함하려면 RedirectAttributes를 사용한다.
    • 직접 문자열로 URL을 조합하면("/basic/items/" + item.getId()) 인코딩 문제로 오동작할 수 있음
@PostMapping("/add")
public String addItem(
        @ModelAttribute Item item,
        RedirectAttributes redirectAttributes
) {
    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());  // URL 템플릿 {itemId}에 바인딩
    redirectAttributes.addAttribute("status", true);               // URL 템플릿에 없으면 쿼리 파라미터로 자동 추가
    return "redirect:/basic/items/{itemId}";  // → /basic/items/3?status=true
}
  • addAttribute 동작 방식
    • URL 템플릿({변수명})에 해당하는 값 → URL에 바인딩
    • URL 템플릿에 없는 값 → 쿼리 파라미터로 자동 추가
  • URL에 노출시키지 않으려면 addFlashAttribute 사용 (세션에 임시 저장, 리다이렉트 후 1회 사용 후 자동 삭제)

# 자주 쓰이는 Lombok 어노테이션

어노테이션 설명 주요 사용처
@Getter 모든 필드의 getter 메서드 생성 엔티티, DTO
@Setter 모든 필드의 setter 메서드 생성 지양 (엔티티에는 사용 자제)
@NoArgsConstructor 파라미터 없는 기본 생성자 생성 JPA 엔티티 (필수)
@AllArgsConstructor 모든 필드를 파라미터로 받는 생성자 생성 DTO
@RequiredArgsConstructor final / @NonNull 필드 생성자 생성 Service, Repository (의존성 주입)
@Builder 빌더 패턴 자동 생성 엔티티, DTO 생성 시
@ToString toString() 메서드 자동 생성 디버깅 (양방향 연관관계 주의)
@EqualsAndHashCode equals(), hashCode() 자동 생성 (양방향 연관관계 주의)
@Data @Getter + @Setter + @ToString + @EqualsAndHashCode + @RequiredArgsConstructor 실무에서 지양
@Slf4j log 변수 자동 생성 (Logger) 모든 계층

# 실무에서 자주 쓰는 조합

// Service - 의존성 주입
@Service
@RequiredArgsConstructor
@Slf4j
public class ItemService {
    private final ItemRepository itemRepository;
}

// JPA 엔티티
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class Item {
    @Id @GeneratedValue
    private Long id;
    private String itemName;
}

// DTO
@Getter
@AllArgsConstructor
public class ItemResponse {
    private Long id;
    private String itemName;

    public static ItemResponse from(Item item) {
        return new ItemResponse(item.getId(), item.getItemName());
    }
}