@RequestParam @PathVariable 차이를 제대로 이해하지 못하면, 상품 상세 조회 URL을 /products?id=42로 설계해야 할지 /products/42로 설계해야 할지 매번 고민하거나, @PathVariable로 처리해야 하는 리소스 식별자에 쿼리 파라미터를 쓰거나, 선택적 파라미터를 @PathVariable로 선언해 MethodArgumentTypeMismatchException이 발생하는 상황을 겪게 됩니다. 둘 다 “URL에서 값을 꺼내는 어노테이션”이지만 사용 위치와 의미, 필수 여부, REST 설계 관점에서 근본적으로 다릅니다. 이 글에서는 두 어노테이션의 바인딩 원리부터 URL 구조 차이, 타입 변환, 선택적 파라미터 처리, 정규식 제약, 보안·인코딩 주의사항, 그리고 언제 무엇을 써야 하는지 명확한 선택 기준까지 실무 예제 코드와 함께 완벽하게 정리합니다.
목차
- 두 어노테이션의 기초 개념 – URL의 어디에서 값을 꺼내는가
- Spring MVC 바인딩 원리 – 요청이 파라미터로 변환되는 과정
- @PathVariable 완전 정복 – 경로 변수의 모든 것
- @RequestParam 완전 정복 – 쿼리 파라미터의 모든 것
- 핵심 비교와 실무 선택 기준 – 언제 무엇을 써야 하는가
- 전문가 관점 – 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은 “어떻게 조회할 것인가”에 해당하는 필터·정렬·페이징 옵션을 처리할 때 사용합니다. 선택적 파라미터는 @RequestParam의 defaultValue와 required = false로, 다중값은 List<T>와 MultiValueMap으로 처리하고, @PathVariable에는 정규식으로 형식 제약을 걸 수 있습니다. 바인딩 실패 예외는 @RestControllerAdvice로 중앙 처리하고, MockMvc로 각 파라미터 시나리오를 테스트하는 습관을 가지면 실무에서 파라미터 처리 관련 장애를 대부분 예방할 수 있습니다.
지금 바로 현재 프로젝트의 API URL을 점검해 리소스 식별자가 쿼리 파라미터로 잘못 설계된 경우가 있는지, 필터·정렬 조건이 불필요하게 경로 변수로 표현된 경우가 있는지 확인해 보세요.
답글 남기기