@RequestParam vs @PathVariable 완벽 비교 – 차이·선택 기준


@RequestParam @PathVariable 차이를 제대로 이해하지 못하면, 상품 상세 조회 URL을 /products?id=42로 설계해야 할지 /products/42로 설계해야 할지 매번 고민하거나, @PathVariable로 처리해야 하는 리소스 식별자에 쿼리 파라미터를 쓰거나, 선택적 파라미터를 @PathVariable로 선언해 MethodArgumentTypeMismatchException이 발생하는 상황을 겪게 됩니다. 둘 다 “URL에서 값을 꺼내는 어노테이션”이지만 사용 위치와 의미, 필수 여부, REST 설계 관점에서 근본적으로 다릅니다. 이 글에서는 두 어노테이션의 바인딩 원리부터 URL 구조 차이, 타입 변환, 선택적 파라미터 처리, 정규식 제약, 보안·인코딩 주의사항, 그리고 언제 무엇을 써야 하는지 명확한 선택 기준까지 실무 예제 코드와 함께 완벽하게 정리합니다.


목차

  1. 두 어노테이션의 기초 개념 – URL의 어디에서 값을 꺼내는가
  2. Spring MVC 바인딩 원리 – 요청이 파라미터로 변환되는 과정
  3. @PathVariable 완전 정복 – 경로 변수의 모든 것
  4. @RequestParam 완전 정복 – 쿼리 파라미터의 모든 것
  5. 핵심 비교와 실무 선택 기준 – 언제 무엇을 써야 하는가
  6. 전문가 관점 – REST 설계 원칙·보안·테스트 전략

1. 두 어노테이션의 기초 개념 – URL의 어디에서 값을 꺼내는가

HTTP 요청 URL의 구조 분해

모든 HTTP 요청 URL은 크게 두 부분으로 나뉩니다. 이 차이가 두 어노테이션의 존재 이유입니다.

[HTTP 요청 URL 구조 완전 분해]

https://api.example.com/products/42/reviews?sort=latest&page=1&size=20

          ┌─────────────────────────────┐ ┌───────────────────────────────┐
          │         경로(Path)           │ │       쿼리 스트링(Query String) │
          └─────────────────────────────┘ └───────────────────────────────┘
                        │                                  │
                        ▼                                  ▼
              @PathVariable로 추출                @RequestParam으로 추출

구체적으로:
  /products/{productId}/reviews   → {productId} = "42" (@PathVariable)
  ?sort=latest                    → sort = "latest"     (@RequestParam)
  &page=1                         → page = 1            (@RequestParam)
  &size=20                        → size = 20           (@RequestParam)

URL 구분자:
  경로: 슬래시(/)로 계층 구분
  쿼리: 물음표(?) 이후, 앰퍼샌드(&)로 구분

한 문장으로 정리하는 핵심 차이

@PathVariable:  URL 경로(Path)의 일부를 변수로 추출
                /products/{id} → id = 42

@RequestParam:  URL 쿼리 스트링의 파라미터를 추출
                /products?category=books → category = "books"

가장 단순한 예제로 이해하기

java

@RestController
@RequestMapping("/products")
public class ProductController {

    // @PathVariable: URL 경로 일부를 변수로 받음
    // GET /products/42
    @GetMapping("/{id}")
    public ProductDto getProduct(@PathVariable Long id) {
        return productService.findById(id);
        // id = 42
    }

    // @RequestParam: 쿼리 스트링 파라미터를 받음
    // GET /products?category=books&page=0&size=20
    @GetMapping
    public Page<ProductDto> getProducts(
            @RequestParam String category,
            @RequestParam int page,
            @RequestParam int size) {
        return productService.findByCategory(category, page, size);
        // category = "books", page = 0, size = 20
    }

    // 두 어노테이션 동시 사용
    // GET /products/42/reviews?sort=latest&page=0
    @GetMapping("/{productId}/reviews")
    public Page<ReviewDto> getProductReviews(
            @PathVariable Long productId,      // 경로에서
            @RequestParam String sort,         // 쿼리에서
            @RequestParam int page) {          // 쿼리에서
        return reviewService.findByProduct(productId, sort, page);
    }
}

2. Spring MVC 바인딩 원리 – 요청이 파라미터로 변환되는 과정

DispatcherServlet과 HandlerMethodArgumentResolver

두 어노테이션이 어떻게 동작하는지 이해하려면 Spring MVC의 요청 처리 흐름을 알아야 합니다.

[Spring MVC 요청 처리 전체 흐름]

클라이언트 HTTP 요청
    ↓
DispatcherServlet (Front Controller)
    ↓
HandlerMapping → 어떤 컨트롤러 메서드가 처리할지 결정
    ↓
HandlerAdapter → 컨트롤러 메서드 실행 준비
    ↓
HandlerMethodArgumentResolver → 메서드 파라미터 값 해석 (핵심!)
    ├── @PathVariable → PathVariableMethodArgumentResolver
    │     URL 템플릿 변수 추출 → 타입 변환 → 파라미터에 바인딩
    ├── @RequestParam → RequestParamMethodArgumentResolver
    │     쿼리 스트링 파싱 → 타입 변환 → 파라미터에 바인딩
    └── 기타 (@RequestBody, @ModelAttribute, ...)
    ↓
컨트롤러 메서드 실행
    ↓
HandlerMethodReturnValueHandler → 반환값 처리
    ↓
클라이언트 HTTP 응답

PathVariableMethodArgumentResolver 동작 원리

[@PathVariable 내부 처리 과정]

요청: GET /products/42/reviews

① HandlerMapping이 URL 템플릿과 실제 URL 매칭
   템플릿: /products/{productId}/reviews
   실제:   /products/42/reviews
   → 변수 추출: {productId} = "42"

② URI 템플릿 변수를 Map으로 저장
   Map<String, String> uriTemplateVars = {"productId": "42"}

③ 메서드 파라미터의 @PathVariable(name = "productId") 확인
   파라미터 타입: Long

④ TypeConverter로 String → Long 변환
   "42" → 42L

⑤ 컨트롤러 메서드 파라미터에 바인딩
   productId = 42L

RequestParamMethodArgumentResolver 동작 원리

[@RequestParam 내부 처리 과정]

요청: GET /products?category=books&page=0&size=20

① HttpServletRequest.getParameterMap() 호출
   Map<String, String[]> params = {
       "category": ["books"],
       "page":     ["0"],
       "size":     ["20"]
   }

② 메서드 파라미터의 @RequestParam(name = "category") 확인
   파라미터 타입: String

③ TypeConverter로 필요 시 타입 변환
   "0" → int 0, "20" → int 20

④ required=true인데 값 없으면 → MissingServletRequestParameterException
   defaultValue 있으면 → 기본값 사용

⑤ 컨트롤러 메서드 파라미터에 바인딩
   category = "books", page = 0, size = 20

타입 변환 – ConversionService

두 어노테이션 모두 Spring의 ConversionService를 통해 자동 타입 변환을 지원합니다.

java

@RestController
@RequestMapping("/products")
public class ProductController {

    // String → Long 자동 변환
    @GetMapping("/{id}")
    public ProductDto getById(@PathVariable Long id) { ... }
    // "42" → 42L

    // String → LocalDate 자동 변환
    @GetMapping("/launched/{date}")
    public List<ProductDto> getByDate(
            @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
            LocalDate date) { ... }
    // "2026-05-18" → LocalDate.of(2026, 5, 18)

    // String → Enum 자동 변환
    @GetMapping
    public List<ProductDto> getByStatus(
            @RequestParam ProductStatus status) { ... }
    // "ACTIVE" → ProductStatus.ACTIVE

    // String → List<Long> 자동 변환 (쉼표 구분)
    @GetMapping("/batch")
    public List<ProductDto> getByIds(
            @RequestParam List<Long> ids) { ... }
    // "1,2,3" → [1L, 2L, 3L]
    // ?ids=1&ids=2&ids=3 → [1L, 2L, 3L]

    // 커스텀 컨버터 등록 (특수 타입 변환)
    // @Configuration에서 등록 가능
}

// 커스텀 컨버터 예시
@Component
public class StringToProductCategoryConverter
        implements Converter<String, ProductCategory> {

    @Override
    public ProductCategory convert(String source) {
        return Arrays.stream(ProductCategory.values())
            .filter(c -> c.getCode().equalsIgnoreCase(source))
            .findFirst()
            .orElseThrow(() -> new IllegalArgumentException(
                "알 수 없는 카테고리 코드: " + source));
    }
}

3. @PathVariable 완전 정복 – 경로 변수의 모든 것

기본 사용법

java

@RestController
@RequestMapping("/api/v1")
public class ResourceController {

    // 단일 경로 변수
    @GetMapping("/users/{userId}")
    public UserDto getUser(@PathVariable Long userId) {
        return userService.findById(userId);
    }

    // 변수명 명시 – 파라미터명과 다를 때
    @GetMapping("/users/{user_id}/orders")
    public List<OrderDto> getUserOrders(
            @PathVariable("user_id") Long userId) {
        //              ↑ URL의 {user_id}를 Java 변수 userId에 매핑
        return orderService.findByUserId(userId);
    }

    // 여러 경로 변수
    @GetMapping("/users/{userId}/orders/{orderId}")
    public OrderDto getOrder(
            @PathVariable Long userId,
            @PathVariable Long orderId) {
        return orderService.findByIdAndUser(orderId, userId);
    }

    // Map으로 모든 경로 변수 한 번에 받기
    @GetMapping("/resources/{type}/{id}/{action}")
    public ResponseEntity<?> handleResource(
            @PathVariable Map<String, String> pathVars) {
        String type   = pathVars.get("type");   // "products"
        String id     = pathVars.get("id");     // "42"
        String action = pathVars.get("action"); // "activate"
        return processResource(type, id, action);
    }
}

정규식 제약 – 경로 변수 형식 제한

java

@RestController
@RequestMapping("/products")
public class ProductController {

    // 숫자만 허용
    @GetMapping("/{id:[0-9]+}")
    public ProductDto getProduct(@PathVariable Long id) {
        return productService.findById(id);
    }
    // /products/42    → OK (id = 42)
    // /products/abc   → 404 (패턴 불일치, 다른 핸들러로 넘어감)
    // /products/-1    → 404 (음수 불허)

    // 영문 소문자만 허용
    @GetMapping("/category/{name:[a-z]+}")
    public List<ProductDto> getByCategory(
            @PathVariable String name) {
        return productService.findByCategory(name);
    }
    // /products/category/electronics → OK
    // /products/category/ELECTRONICS → 404

    // 형식 제약 – 날짜 패턴
    @GetMapping("/launched/{date:\\d{4}-\\d{2}-\\d{2}}")
    public List<ProductDto> getByLaunchDate(
            @PathVariable String date) {
        return productService.findByDate(LocalDate.parse(date));
    }
    // /products/launched/2026-05-18 → OK
    // /products/launched/20260518   → 404

    // 확장자 포함 경로
    @GetMapping("/files/{filename:.+}")
    public ResponseEntity<Resource> downloadFile(
            @PathVariable String filename) {
        // .+ : 점(.)을 포함한 모든 문자
        Resource file = storageService.load(filename);
        return ResponseEntity.ok()
            .contentType(MediaType.APPLICATION_OCTET_STREAM)
            .body(file);
    }
    // /products/files/report.pdf  → filename = "report.pdf"
    // /products/files/data.csv    → filename = "data.csv"
}

@PathVariable의 선택적 처리

java

@RestController
@RequestMapping("/products")
public class ProductController {

    // ⚠️ @PathVariable은 기본적으로 필수
    // required = false 선언 가능하지만 URL 설계 관점에서 권고하지 않음

    // ❌ 권장하지 않는 패턴 – 선택적 경로 변수
    @GetMapping({"", "/{id}"})
    public Object getProducts(
            @PathVariable(required = false) Long id) {
        if (id != null) {
            return productService.findById(id); // 단건 조회
        }
        return productService.findAll();        // 전체 조회
    }
    // → URL 설계가 모호해짐 → 다른 개발자 혼란 유발

    // ✅ 권장 패턴 – 목적별 URL 분리
    @GetMapping("/{id}")
    public ProductDto getProduct(@PathVariable Long id) {
        return productService.findById(id);     // 단건 조회
    }

    @GetMapping
    public List<ProductDto> getProducts() {
        return productService.findAll();        // 전체 조회
    }
}

4. @RequestParam 완전 정복 – 쿼리 파라미터의 모든 것

기본 사용법

java

@RestController
@RequestMapping("/products")
public class ProductController {

    // 기본 필수 파라미터
    @GetMapping("/search")
    public List<ProductDto> search(
            @RequestParam String keyword) {
        return productService.search(keyword);
    }
    // /products/search?keyword=노트북 → keyword = "노트북"
    // /products/search               → MissingServletRequestParameterException

    // 파라미터명 명시 – 변수명과 다를 때
    @GetMapping("/by-seller")
    public List<ProductDto> getBySeller(
            @RequestParam("seller_id") Long sellerId) {
        //               ↑ URL의 seller_id를 Java 변수 sellerId에 매핑
        return productService.findBySellerId(sellerId);
    }
    // /products/by-seller?seller_id=10 → sellerId = 10

    // 여러 파라미터
    @GetMapping
    public Page<ProductDto> getProducts(
            @RequestParam String category,
            @RequestParam int page,
            @RequestParam int size,
            @RequestParam String sort) {
        return productService.findByCategory(category, page, size, sort);
    }
}

선택적 파라미터 처리 – 4가지 방법

java

@RestController
@RequestMapping("/products")
public class ProductController {

    // 방법 1: required = false + 기본값 null
    @GetMapping("/search")
    public List<ProductDto> search(
            @RequestParam(required = false) String keyword,
            @RequestParam(required = false) String category) {
        // keyword null 가능 → 직접 null 체크 필요
        return productService.search(keyword, category);
    }

    // 방법 2: defaultValue – 파라미터 없을 때 기본값
    @GetMapping
    public Page<ProductDto> getProducts(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size,
            @RequestParam(defaultValue = "createdAt") String sort,
            @RequestParam(defaultValue = "DESC") String direction) {
        return productService.findAll(page, size, sort, direction);
    }
    // /products           → page=0, size=20, sort=createdAt, direction=DESC
    // /products?page=1    → page=1, size=20, sort=createdAt, direction=DESC

    // 방법 3: Optional<T> – 명시적 선택적 처리
    @GetMapping("/filter")
    public List<ProductDto> filter(
            @RequestParam Optional<String> keyword,
            @RequestParam Optional<BigDecimal> minPrice,
            @RequestParam Optional<BigDecimal> maxPrice) {
        return productService.filter(
            keyword.orElse(null),
            minPrice.orElse(BigDecimal.ZERO),
            maxPrice.orElse(BigDecimal.valueOf(Long.MAX_VALUE))
        );
    }

    // 방법 4: @RequestParam + @PageableDefault (Pageable 통합)
    @GetMapping("/paged")
    public Page<ProductDto> getPagedProducts(
            @RequestParam(required = false) String category,
            @PageableDefault(size = 20, sort = "createdAt",
                           direction = Sort.Direction.DESC)
            Pageable pageable) {
        return productService.findByCategory(category, pageable);
    }
}

다중값 파라미터 처리

java

@RestController
@RequestMapping("/products")
public class ProductController {

    // 같은 이름 파라미터 여러 개 → List로 받기
    @GetMapping("/batch")
    public List<ProductDto> getByIds(
            @RequestParam List<Long> ids) {
        return productService.findAllById(ids);
    }
    // /products/batch?ids=1&ids=2&ids=3 → ids = [1, 2, 3]
    // /products/batch?ids=1,2,3         → ids = [1, 2, 3] (쉼표 구분)

    // Set으로 받기 (중복 제거)
    @GetMapping("/by-tags")
    public List<ProductDto> getByTags(
            @RequestParam Set<String> tags) {
        return productService.findByTags(tags);
    }
    // /products/by-tags?tags=java&tags=spring&tags=java → tags = {"java", "spring"}

    // MultiValueMap – 전체 파라미터를 맵으로 받기
    @GetMapping("/dynamic-filter")
    public List<ProductDto> dynamicFilter(
            @RequestParam MultiValueMap<String, String> params) {
        // 어떤 파라미터가 올지 미리 알 수 없을 때 유연하게 처리
        log.info("요청 파라미터: {}", params);
        return productService.dynamicFilter(params);
    }
    // /products/dynamic-filter?color=red&color=blue&size=M
    // → params = {color: [red, blue], size: [M]}

    // Map<String, String> – 단일값 파라미터를 맵으로 받기
    @GetMapping("/simple-filter")
    public List<ProductDto> simpleFilter(
            @RequestParam Map<String, String> params) {
        return productService.simpleFilter(params);
    }
}

@RequestParam과 폼 데이터 (Content-Type: application/x-www-form-urlencoded)

java

@RestController
public class FormController {

    // POST 요청의 폼 데이터도 @RequestParam으로 받을 수 있음
    @PostMapping("/products/search")
    public List<ProductDto> searchByForm(
            @RequestParam String keyword,
            @RequestParam(required = false, defaultValue = "0") int page) {
        return productService.search(keyword, page);
    }
    // Content-Type: application/x-www-form-urlencoded
    // Body: keyword=노트북&page=0

    // ⚠️ JSON Body는 @RequestBody 사용
    @PostMapping("/products")
    public ProductDto createProduct(
            @RequestBody ProductCreateRequest request) {
        // Content-Type: application/json
        // @RequestParam은 JSON Body 파싱 불가
        return productService.create(request);
    }
}

5. 핵심 비교와 실무 선택 기준 – 언제 무엇을 써야 하는가

두 어노테이션의 결정적 차이 한눈에 보기

구분@PathVariable@RequestParam
위치URL 경로 /products/{id}쿼리 스트링 ?key=value
필수 여부기본 필수 (URL 구조상)기본 필수, 선택적 설정 용이
기본값불가defaultValue 지원
선택적 처리required=false (비권장)required=false, Optional
다중값불가List, Set, MultiValueMap
의미론리소스 식별자필터·정렬·페이징 옵션
REST 의미어떤 리소스인가어떻게 조회할 것인가
북마크/공유URL로 바로 공유 가능URL로 공유 가능
캐싱경로 기반 캐시 설정 용이파라미터 조합별 캐시 복잡
정규식 제약지원 ({id:[0-9]+})미지원 (검증 별도 필요)

실무 선택 기준 – 5가지 판단 규칙

[언제 @PathVariable을 써야 하는가]

✅ Rule 1: 특정 리소스를 식별하는 고유 ID
  /users/{userId}        → 특정 사용자
  /orders/{orderId}      → 특정 주문
  /products/{productId}  → 특정 상품

✅ Rule 2: 리소스의 계층적 관계 표현
  /users/{userId}/orders/{orderId}   → 특정 사용자의 특정 주문
  /categories/{catId}/products       → 특정 카테고리의 상품 목록

✅ Rule 3: REST API에서 리소스 위치 명확히 표현
  DELETE /products/{id}  → id로 특정된 리소스 삭제
  PUT /users/{id}        → id로 특정된 리소스 전체 수정

✅ Rule 4: 반드시 있어야 하는 값
  경로 변수가 없으면 의미 없는 URL이 되는 경우


[언제 @RequestParam을 써야 하는가]

✅ Rule 1: 검색·필터 조건 (0개 이상 조건 조합)
  /products?category=books&minPrice=10000&maxPrice=50000

✅ Rule 2: 정렬 옵션
  /products?sort=price&direction=asc

✅ Rule 3: 페이징 정보
  /products?page=0&size=20

✅ Rule 4: 선택적 파라미터 (있어도 되고 없어도 되는)
  /products?keyword=노트북  (keyword 없으면 전체 조회)

✅ Rule 5: 여러 값을 받아야 하는 경우
  /products?ids=1&ids=2&ids=3

실무 URL 설계 패턴 예시

java

@RestController
@RequestMapping("/api/v1")
public class ProductApiController {

    //──────────────────────────────────────────────────────
    // ✅ 올바른 URL 설계 패턴
    //──────────────────────────────────────────────────────

    // [단건 조회] 특정 리소스 → @PathVariable
    // GET /api/v1/products/42
    @GetMapping("/products/{id}")
    public ProductDto getProduct(@PathVariable Long id) {
        return productService.findById(id);
    }

    // [목록 조회] 필터·페이징 → @RequestParam
    // GET /api/v1/products?category=electronics&page=0&size=10&sort=price
    @GetMapping("/products")
    public Page<ProductDto> getProducts(
            @RequestParam(required = false) String category,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size,
            @RequestParam(defaultValue = "createdAt") String sort) {
        return productService.findAll(category, page, size, sort);
    }

    // [생성] 본문 데이터 → @RequestBody (두 어노테이션 모두 아님)
    // POST /api/v1/products
    @PostMapping("/products")
    public ResponseEntity<ProductDto> createProduct(
            @RequestBody ProductCreateRequest request) {
        ProductDto created = productService.create(request);
        return ResponseEntity
            .created(URI.create("/api/v1/products/" + created.getId()))
            .body(created);
    }

    // [전체 수정] 식별자는 Path, 데이터는 Body
    // PUT /api/v1/products/42
    @PutMapping("/products/{id}")
    public ProductDto updateProduct(
            @PathVariable Long id,
            @RequestBody ProductUpdateRequest request) {
        return productService.update(id, request);
    }

    // [부분 수정] 식별자는 Path, 데이터는 Body
    // PATCH /api/v1/products/42
    @PatchMapping("/products/{id}")
    public ProductDto patchProduct(
            @PathVariable Long id,
            @RequestBody ProductPatchRequest request) {
        return productService.patch(id, request);
    }

    // [삭제] 식별자는 Path
    // DELETE /api/v1/products/42
    @DeleteMapping("/products/{id}")
    public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
        productService.delete(id);
        return ResponseEntity.noContent().build();
    }

    // [중첩 리소스] 계층 구조 → 여러 PathVariable
    // GET /api/v1/products/42/reviews?page=0&size=10
    @GetMapping("/products/{productId}/reviews")
    public Page<ReviewDto> getProductReviews(
            @PathVariable Long productId,              // 어떤 상품의
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "10") int size) {
        return reviewService.findByProductId(productId, page, size);
    }

    //──────────────────────────────────────────────────────
    // ❌ 잘못된 URL 설계 패턴
    //──────────────────────────────────────────────────────

    // ❌ 리소스 ID를 쿼리 파라미터로 (비REST)
    // GET /api/v1/products?id=42
    @GetMapping("/products/find")  // ← 이런 이름도 비REST
    public ProductDto getProductWrong(@RequestParam Long id) {
        return productService.findById(id);
    }

    // ❌ 필터 조건을 경로 변수로
    // GET /api/v1/products/category/electronics/page/0
    @GetMapping("/products/category/{category}/page/{page}")
    public List<ProductDto> getProductsWrong(
            @PathVariable String category,
            @PathVariable int page) {
        // URL이 길고, 선택적 필터 처리가 매우 어려워짐
        return productService.findByCategory(category, page);
    }
}

헷갈리는 경계선 케이스 처리

java

@RestController
@RequestMapping("/api/v1")
public class EdgeCaseController {

    // 케이스 1: 검색 자체가 리소스인 경우
    // POST /api/v1/products/search (검색 조건이 복잡해 Body 사용)
    @PostMapping("/products/search")
    public Page<ProductDto> searchProducts(
            @RequestBody ProductSearchRequest request) {
        // 조건이 많고 복잡 → @RequestParam 대신 @RequestBody 사용
        return productService.search(request);
    }

    // 케이스 2: 액션(동작)을 URL로 표현할 때
    // POST /api/v1/products/42/activate
    @PostMapping("/products/{id}/activate")
    public ProductDto activateProduct(@PathVariable Long id) {
        return productService.activate(id);
    }

    // 케이스 3: 슬래시를 포함하는 경로 변수 (Spring Security 인코딩 주의)
    // GET /api/v1/files/2026/05/18/report.pdf
    @GetMapping("/files/{*filePath}")  // Spring 5.3+: **로 슬래시 포함 경로 캡처
    public ResponseEntity<Resource> downloadFile(
            @PathVariable String filePath) {
        // filePath = "2026/05/18/report.pdf"
        return fileService.download(filePath);
    }

    // 케이스 4: 버전 관리
    // GET /api/v2/products/42 (Path로 버전 관리)
    // GET /api/products/42?version=2 (@RequestParam으로 버전 관리 → 비권장)
    @GetMapping("/v{version}/products/{id}")
    public ProductDto getVersionedProduct(
            @PathVariable int version,
            @PathVariable Long id) {
        return productService.findById(id, version);
    }
}

6. 전문가 관점 – REST 설계 원칙·보안·테스트 전략

REST 설계 원칙에서 바라보는 URL 구조

[REST API URL 설계 원칙과 두 어노테이션의 관계]

REST의 핵심: URL은 리소스(명사)를, HTTP 메서드는 행위(동사)를 표현

경로(Path) = 리소스의 위치·계층
  → @PathVariable: 리소스를 특정하는 식별자

쿼리 스트링(Query String) = 리소스에 대한 질의 옵션
  → @RequestParam: 조회 방식·필터·정렬·페이징

[RESTful URL 설계 예시 - 블로그 서비스]

컬렉션 리소스:
  GET  /posts              → 게시글 목록 (필터: ?tag=spring&page=0)
  POST /posts              → 새 게시글 생성

단일 리소스:
  GET    /posts/{postId}         → 특정 게시글 조회
  PUT    /posts/{postId}         → 특정 게시글 전체 수정
  PATCH  /posts/{postId}         → 특정 게시글 부분 수정
  DELETE /posts/{postId}         → 특정 게시글 삭제

중첩 리소스:
  GET  /posts/{postId}/comments        → 특정 게시글의 댓글 목록
  POST /posts/{postId}/comments        → 특정 게시글에 댓글 추가
  GET  /posts/{postId}/comments/{id}   → 특정 댓글 조회
  DELETE /posts/{postId}/comments/{id} → 특정 댓글 삭제

검색·필터 (Query String):
  GET /posts?tag=spring&author=kim&sort=latest&page=0&size=10

보안 주의사항

java

@RestController
@RequestMapping("/products")
public class SecureProductController {

    // ⚠️ 주의 1: @PathVariable 타입 불일치 → 400 또는 404 자동 반환
    @GetMapping("/{id}")
    public ProductDto getProduct(@PathVariable Long id) {
        // /products/abc → MethodArgumentTypeMismatchException → 400 Bad Request
        // 자동 처리되므로 추가 검증 불필요
        return productService.findById(id);
    }

    // ⚠️ 주의 2: SQL Injection 방지 – Spring Data JPA는 파라미터 바인딩 사용
    @GetMapping("/search")
    public List<ProductDto> search(
            @RequestParam String keyword) {
        // Spring Data JPA는 PreparedStatement 사용 → SQL Injection 안전
        // 직접 쿼리 문자열 조합은 절대 금지
        return productRepository.findByNameContaining(keyword); // ✅ 안전
        // ❌ 금지: "SELECT * FROM products WHERE name LIKE '%" + keyword + "%'"
    }

    // ⚠️ 주의 3: URL 인코딩 – 특수문자 처리
    @GetMapping("/by-keyword")
    public List<ProductDto> getByKeyword(
            @RequestParam String keyword) {
        // URL: /products/by-keyword?keyword=%EB%85%B8%ED%8A%B8%EB%B6%81
        // Spring이 자동으로 URL 디코딩: keyword = "노트북"
        // 추가 디코딩 불필요
        return productService.findByKeyword(keyword);
    }

    // ⚠️ 주의 4: @PathVariable 슬래시(/) 포함 시 인코딩 필요
    @GetMapping("/files/{filename:.+}")
    public ResponseEntity<Resource> getFile(
            @PathVariable String filename) {
        // "report.pdf" → filename = "report.pdf" (OK)
        // "2026/05/report.pdf" → 슬래시가 경로 구분자로 해석됨!
        // → 슬래시 포함 경로: {*path} 또는 %2F로 인코딩 필요
        return fileService.download(filename);
    }

    // ⚠️ 주의 5: 파라미터 최대 크기 제한 (DoS 방어)
    @GetMapping("/search")
    public List<ProductDto> searchSafe(
            @RequestParam @Size(max = 100) String keyword) {
        // Bean Validation으로 파라미터 길이 제한
        return productService.search(keyword);
    }
}

예외 처리 – 파라미터 바인딩 실패 처리

java

// 파라미터 바인딩 실패 시 발생하는 예외들
@RestControllerAdvice
public class RequestParameterExceptionHandler {

    // @PathVariable 타입 변환 실패
    // 예: /products/abc (Long 타입에 문자열)
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public ResponseEntity<ErrorResponse> handleTypeMismatch(
            MethodArgumentTypeMismatchException e) {
        String message = String.format(
            "파라미터 '%s'의 값 '%s'이 유효하지 않습니다. 예상 타입: %s",
            e.getName(), e.getValue(),
            e.getRequiredType() != null
                ? e.getRequiredType().getSimpleName() : "알 수 없음"
        );
        return ResponseEntity.badRequest()
            .body(ErrorResponse.of(400, "INVALID_PARAMETER", message));
    }

    // @RequestParam required=true인데 파라미터 없을 때
    @ExceptionHandler(MissingServletRequestParameterException.class)
    public ResponseEntity<ErrorResponse> handleMissingParam(
            MissingServletRequestParameterException e) {
        String message = String.format(
            "필수 파라미터 '%s'(%s)가 누락되었습니다.",
            e.getParameterName(), e.getParameterType()
        );
        return ResponseEntity.badRequest()
            .body(ErrorResponse.of(400, "MISSING_PARAMETER", message));
    }

    // URL 패턴 불일치 (@PathVariable 경로 없음)
    @ExceptionHandler(NoHandlerFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(
            NoHandlerFoundException e) {
        return ResponseEntity.notFound().build();
    }
}

테스트 전략 – MockMvc를 활용한 파라미터 테스트

java

@WebMvcTest(ProductController.class)
class ProductControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private ProductService productService;

    // @PathVariable 테스트
    @Test
    void PathVariable_단건_조회_성공() throws Exception {
        // given
        ProductDto product = ProductDto.builder()
            .id(42L).name("MacBook Pro").build();
        given(productService.findById(42L)).willReturn(product);

        // when & then
        mockMvc.perform(get("/products/{id}", 42L))  // PathVariable 전달
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value(42))
            .andExpect(jsonPath("$.name").value("MacBook Pro"));
    }

    @Test
    void PathVariable_잘못된_타입_400반환() throws Exception {
        mockMvc.perform(get("/products/{id}", "abc"))  // Long에 문자열
            .andExpect(status().isBadRequest());
    }

    // @RequestParam 테스트
    @Test
    void RequestParam_목록_조회_성공() throws Exception {
        // given
        given(productService.findAll("electronics", 0, 20, "createdAt"))
            .willReturn(Page.empty());

        // when & then
        mockMvc.perform(get("/products")
                .param("category", "electronics")  // RequestParam 전달
                .param("page", "0")
                .param("size", "20")
                .param("sort", "createdAt"))
            .andExpect(status().isOk());
    }

    @Test
    void RequestParam_필수파라미터_누락_400반환() throws Exception {
        // required=true 파라미터(keyword) 없이 요청
        mockMvc.perform(get("/products/search"))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.code").value("MISSING_PARAMETER"));
    }

    @Test
    void RequestParam_기본값_적용_확인() throws Exception {
        // 파라미터 생략 → defaultValue 적용 확인
        given(productService.findAll(null, 0, 20, "createdAt"))
            .willReturn(Page.empty());

        mockMvc.perform(get("/products"))  // 파라미터 없이 요청
            .andExpect(status().isOk());

        // page=0, size=20, sort=createdAt이 기본값으로 전달됐는지 검증
        verify(productService).findAll(null, 0, 20, "createdAt");
    }

    // 두 어노테이션 동시 사용 테스트
    @Test
    void PathVariable과_RequestParam_동시_사용_테스트() throws Exception {
        given(reviewService.findByProductId(42L, 0, 10))
            .willReturn(Page.empty());

        mockMvc.perform(get("/products/{productId}/reviews", 42L)
                .param("page", "0")
                .param("size", "10"))
            .andExpect(status().isOk());
    }
}

@RequestParam vs 관련 어노테이션 전체 비교

java

@RestController
public class ComprehensiveParamController {

    // @RequestParam – 쿼리 스트링 단일/다중 파라미터
    @GetMapping("/example1")
    public String example1(@RequestParam String name) { ... }
    // ?name=value

    // @PathVariable – URL 경로 변수
    @GetMapping("/example2/{id}")
    public String example2(@PathVariable Long id) { ... }
    // /example2/42

    // @RequestBody – HTTP Body (주로 JSON)
    @PostMapping("/example3")
    public String example3(@RequestBody UserDto user) { ... }
    // Content-Type: application/json, Body: {"name":"홍길동"}

    // @ModelAttribute – 폼 데이터 또는 쿼리 파라미터 → 객체 바인딩
    @GetMapping("/example4")
    public String example4(@ModelAttribute ProductSearchRequest req) { ... }
    // ?name=노트북&category=electronics → ProductSearchRequest 객체로 자동 바인딩

    // @RequestHeader – HTTP 헤더 값 추출
    @GetMapping("/example5")
    public String example5(
            @RequestHeader("Authorization") String token,
            @RequestHeader(value = "Accept-Language",
                         defaultValue = "ko") String lang) { ... }

    // @CookieValue – HTTP 쿠키 값 추출
    @GetMapping("/example6")
    public String example6(
            @CookieValue(value = "sessionId", required = false)
            String sessionId) { ... }

    // @RequestPart – Multipart 파일 업로드
    @PostMapping("/example7")
    public String example7(
            @RequestPart("file") MultipartFile file,
            @RequestPart("metadata") String metadata) { ... }
}

결론

@RequestParam @PathVariable 차이는 단순히 “어디서 값을 꺼내는가”의 차이를 넘어 REST API 설계 철학과 연결됩니다. @PathVariable은 “어떤 리소스인가”를 식별하는 고유 ID와 계층 구조를 표현할 때 사용하고, @RequestParam은 “어떻게 조회할 것인가”에 해당하는 필터·정렬·페이징 옵션을 처리할 때 사용합니다. 선택적 파라미터는 @RequestParamdefaultValuerequired = false로, 다중값은 List<T>MultiValueMap으로 처리하고, @PathVariable에는 정규식으로 형식 제약을 걸 수 있습니다. 바인딩 실패 예외는 @RestControllerAdvice로 중앙 처리하고, MockMvc로 각 파라미터 시나리오를 테스트하는 습관을 가지면 실무에서 파라미터 처리 관련 장애를 대부분 예방할 수 있습니다.

지금 바로 현재 프로젝트의 API URL을 점검해 리소스 식별자가 쿼리 파라미터로 잘못 설계된 경우가 있는지, 필터·정렬 조건이 불필요하게 경로 변수로 표현된 경우가 있는지 확인해 보세요.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다