Spring MVC 요청 처리 흐름: DispatcherServlet부터 폼 바인딩까지

Alchemist·5일 전

KT AIVLE

목록 보기
91/130

프런트엔드에서 fetch('/products')를 호출했을 때 백엔드에서는 무슨 일이 일어날까? Spring MVC의 핵심은 HTTP 요청을 적절한 메서드에 연결하고, 입력값을 자바 객체로 변환한 다음, HTML 또는 JSON 응답을 만드는 것이다.

이 과정을 알면 단순히 @GetMapping을 외우는 수준을 넘어, 요청이 잘못 매핑되거나 데이터가 저장되지 않을 때 어느 부분을 확인할지 판단할 수 있다.

요청이 도착한 후의 순서

브라우저 / 프런트엔드
    ↓ HTTP 요청
내장 웹 서버(Tomcat 등)
    ↓
DispatcherServlet
    ↓ 요청 매핑
Controller 메서드
    ↓
Service → Repository → Database
    ↓
Model + View 이름  또는  JSON 응답
    ↓
HTTP 응답

DispatcherServlet은 Spring MVC의 프런트 컨트롤러다. URL과 HTTP 메서드에 알맞은 핸들러를 찾고, 파라미터 변환과 응답 처리에 필요한 구성요소를 연결한다. 모든 프로젝트가 직접 이 서블릿을 구현하는 것은 아니다. Spring Boot가 기본 설정을 제공한다.

@Controller와 @RestController는 무엇이 다를까?

HTML 페이지를 반환할 때는 대개 @Controller를 사용한다.

package com.example.demo.controller;

import java.util.List;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

@Controller
public class CatalogController {
    private final List<String> productNames = List.of(
        "무선 마우스", "기계식 키보드", "모니터 거치대"
    );

    @GetMapping("/catalog")
    public String catalog(
        @RequestParam(defaultValue = "") String keyword,
        Model model
    ) {
        List<String> filtered = productNames.stream()
            .filter(name -> name.contains(keyword))
            .toList();

        model.addAttribute("items", filtered);
        model.addAttribute("keyword", keyword);
        return "catalog";
    }
}

파일 위치는 src/main/resources/templates/catalog.html이라고 가정한다.

<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>상품 검색</title></head>
<body>
    <form method="get" action="/catalog">
        <input name="keyword" th:value="${keyword}" placeholder="검색어">
        <button type="submit">검색</button>
    </form>
    <ul>
        <li th:each="item : ${items}" th:text="${item}"></li>
    </ul>
</body>
</html>

GET /catalog?keyword=키보드를 요청하면 매칭되는 상품명만 표시된다. 컨트롤러가 반환한 "catalog"는 문자 그대로 응답 본문이 아니라 뷰 이름이고, 뷰 리졸버와 Thymeleaf가 HTML을 렌더링한다.

반대로 REST API라면 @RestController가 자연스럽다.

package com.example.demo.controller;

import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ProductApiController {
    @GetMapping("/api/products")
    public List<String> products() {
        return List.of("마우스", "키보드");
    }
}

이 경우 JSON 응답은 다음과 같다.

["마우스", "키보드"]

@RestController는 @Controller와 @ResponseBody를 결합한 성격을 가진다. 따라서 반환된 데이터를 HTTP 응답 본문으로 직렬화한다.

@RequestParam, @PathVariable, @RequestBody 비교

HTTP 요청 값은 어디에 실려 오는지에 따라 읽는 방법이 다르다.

전달 위치요청 예시Spring에서 읽는 방법
Query stringGET /products?sort=price@RequestParam
URL 경로GET /products/42@PathVariable
JSON 본문POST /products + JSON@RequestBody
HTML form 값POST /products + 폼 필드@RequestParam, @ModelAttribute
public record CreateProductRequest(String name, int price) { }

@PostMapping("/api/products")
public String create(@RequestBody CreateProductRequest request) {
    return request.name() + " / " + request.price();
}

위 코드는 @RestController 클래스 안에 들어가는 메서드 예시다. JSON을 자바 객체로 변환하는 과정에서는 HTTP 메시지 컨버터가 관여한다. HTML 폼 데이터는 메시지 본문 형식과 바인딩 규칙이 달라 같은 방식으로 다루면 안 된다.

폼을 제출한 후 다시 목록으로 돌아가는 이유

전통적인 서버 렌더링 웹 서비스에서는 다음 패턴을 자주 사용한다.

@PostMapping("/catalog/items")
public String create(@RequestParam String name) {
    catalogService.register(name);
    return "redirect:/catalog";
}

여기서 catalogService는 생성자로 주입받았다고 가정했다. redirect:는 브라우저가 새로운 GET 요청을 하도록 응답을 구성한다. 이를 Post/Redirect/Get(PRG) 패턴이라 하며, 사용자가 페이지를 새로고침했을 때 직전 POST 요청이 다시 전송되는 문제를 줄인다.

검색 조건 하나가 바뀌면 어디를 수정할까?

쇼핑몰 상품 검색을 예로 들자. 사용자에게 검색 필드를 보여주는 화면, 사용자의 검색어를 받아들이는 컨트롤러, 실제 검색 조건을 적용하는 저장소가 연관된다.

JPA 기반 조회 메서드를 쓴다면 부분 일치 검색은 다음과 같이 표현할 수 있다.

@Query("select p from Product p where p.name like concat('%', :keyword, '%')")
List<Product> findByNameContaining(@Param("keyword") String keyword);

이 코드는 Product가 JPA 엔티티이고 해당 인터페이스가 적절한 Spring Data JPA 저장소를 확장한다고 가정한다. LIKE 'abc%'는 앞부분 일치, LIKE '%abc%'는 중간 포함 검색이 된다.

주의할 점은 문자열 연결만으로 SQL을 만들지 않는 것이다. 사용자가 보낸 입력을 쿼리에 직접 덧붙이면 SQL 인젝션 위험이 있다. 바인딩된 매개변수를 사용하고, %와 _를 일반 문자로 취급해야 하는 요구사항이 있다면 검색 패턴 이스케이프도 설계해야 한다.

데이터 모델을 바꿨다면 DB도 확인해야 한다

예를 들어 상품에 stockQuantity 속성을 새로 추가했다고 해 보자. Java 클래스에 필드만 넣었다고 기존 DB의 컬럼이 자동으로 항상 생기는 것은 아니다. 일반적으로 다음 사항을 함께 검토해야 한다.

  1. 엔티티나 DTO의 필드 및 검증 조건
  2. 데이터베이스 스키마 또는 마이그레이션 스크립트
  3. 조회·저장 쿼리와 초기 데이터
  4. 폼 입력 필드와 결과 화면
  5. 관련 테스트

개발 환경에서 메모리 DB를 쓰는 경우 재시작할 때 데이터가 사라질 수 있지만, 이것이 모든 DB의 특성은 아니다. 운영 환경에서는 Flyway나 Liquibase 같은 도구로 스키마 변경 이력을 관리하는 편이 안정적이다.

로그와 브레이크포인트로 문제 좁히기

검색 결과가 보이지 않을 때는 순서대로 확인한다.

  • 컨트롤러에 중단점을 걸었는데 멈추지 않는다 → URL, HTTP 메서드, 요청 매핑 점검
  • 컨트롤러에는 들어오는데 keyword가 비어 있다 → 쿼리 문자열이나 폼 name 확인
  • Repository에서 결과가 없다 → 검색 조건, 데이터, DB 연결 확인
  • 데이터는 있는데 화면에 표시되지 않는다 → model.addAttribute 이름과 th:each 식 확인

어떤 계층까지 데이터가 전달됐는지를 확인하면 디버깅 범위를 빠르게 줄일 수 있다.

핵심 정리

  • DispatcherServlet은 Spring MVC의 중앙 요청 진입점이다.
  • @Controller는 일반적으로 뷰를, @RestController는 응답 데이터를 반환한다.
  • URL 경로·쿼리·JSON·HTML 폼은 서로 다른 바인딩 방식을 사용한다.
  • Model에 넣은 값은 Thymeleaf 같은 템플릿에서 사용할 수 있다.
  • POST 이후 리디렉트하는 PRG 패턴은 중복 제출 위험을 줄인다.
  • 검색 조건 변경은 화면·컨트롤러·저장소를 함께 살펴야 한다.
  • 데이터 모델을 수정할 때는 스키마·초기 데이터·검증·테스트도 점검한다.
profile
html_programming_language

0개의 댓글