스프링 초급 (2) - Request: 요청에서 값 꺼내기
요청 경로와 쿼리 스트링과 본문에서 값을 꺼내는 세 가지 방법과 고르는 기준을 다룹니다.
스프링 초급 시리즈의 2편입니다. 전체 목차는 0편에 있습니다.
1편에서 만든 컨트롤러에는 문제가 있습니다.
1
2
3
4
@GetMapping("/members/1")
public Member one() {
return new Member(1L, "민아", "songmina37@gmail.com");
}
주소에 1이 박혀 있습니다. 2번 회원을 조회하려면 @GetMapping("/members/2") 메서드를 하나 더 만들어야 합니다. 회원이 천 명이면 메서드가 천 개입니다.
필요한 건 요청에 실려 온 값을 꺼내 쓰는 방법입니다.
값은 요청의 어디에 실려 오나
HTTP 요청은 글자 덩어리입니다. 브라우저가 서버에 보내는 걸 그대로 펼치면 이렇게 생겼습니다.
1
2
3
4
5
POST /members?notify=true HTTP/1.1
Host: localhost:8080
Content-Type: application/json
{"name":"민아","email":"songmina37@gmail.com"}
여기서 값이 실릴 수 있는 자리는 세 군데입니다.
1
2
3
4
5
6
7
8
POST /members?notify=true HTTP/1.1
└──┬───┘ └────┬────┘
│ └── ② 쿼리 스트링: ? 뒤에 key=value
└───────────── ① 경로: 주소 자체의 일부
{"name":"민아","email":"songmina37@gmail.com"}
└──────────────────┬─────────────────────────┘
└── ③ 본문(body): 빈 줄 뒤에 오는 덩어리
스프링에서 값을 꺼내는 방법이 세 가지인 이유가 이겁니다. 값이 실려 오는 자리가 세 군데라서, 자리마다 꺼내는 도구가 다릅니다.
| 자리 | 애노테이션 |
|---|---|
| ① 경로 | @PathVariable |
| ② 쿼리 스트링 | @RequestParam |
| ③ 본문 | @RequestBody |
세 가지를 다 써보기
HelloController는 두고, MemberController를 새로 만듭니다. 위치는 1편에서 확인한 대로 com.example.demo 아래입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package com.example.demo;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/members")
public class MemberController {
// GET /members/7
@GetMapping("/{id}")
public String find(@PathVariable Long id) {
return "id = " + id;
}
// GET /members/search?name=민아&page=2
@GetMapping("/search")
public String search(@RequestParam String name,
@RequestParam(defaultValue = "1") int page) {
return "name = " + name + ", page = " + page;
}
// POST /members + 본문 {"name":"민아","email":"..."}
@PostMapping
public String join(@RequestBody MemberCreateRequest request) {
return "가입: " + request.name() + " (" + request.email() + ")";
}
}
1
2
3
4
package com.example.demo;
public record MemberCreateRequest(String name, String email) {
}
새로 나온 것들부터 정리합니다.
@RequestMapping("/members")— 클래스에 붙이면 아래 메서드들의 주소 앞에 공통으로 붙습니다. 메서드의@GetMapping("/{id}")는 실제로/members/{id}가 됩니다{id}— 중괄호는 “이 자리는 값이 바뀐다”는 표시입니다.@PathVariable Long id가 그 자리의 값을 받습니다@PostMapping— 주소가 클래스에 붙은/members그대로입니다. 같은/members주소라도 GET과 POST는 다른 메서드로 갑니다defaultValue = "1"— 안 넘어오면 1을 씁니다
@PathVariable Long id에서 눈여겨볼 건 타입이 Long이라는 점입니다. URL은 글자인데 자바에서는 숫자로 받았습니다. 스프링이 중간에서 변환해 줍니다.
확인 1: 세 가지를 실제로 호출해보기
서버를 재시작하고 하나씩 보냅니다.
① 경로에서 꺼내기
1
curl localhost:8080/members/7
1
id = 7
주소의 7이 그대로 들어왔습니다. @GetMapping("/members/1") 하나마다 메서드를 만들던 문제가 풀렸습니다.
② 쿼리 스트링에서 꺼내기
1
curl "localhost:8080/members/search?name=민아&page=2"
1
name = 민아, page = 2
page를 빼면 기본값이 들어갑니다.
1
curl "localhost:8080/members/search?name=민아"
1
name = 민아, page = 1
name은 기본값이 없습니다. 빼면 400입니다.
1
curl "localhost:8080/members/search"
1
{"timestamp":"2026-07-30T01:22:07.331+00:00","status":400,"error":"Bad Request","path":"/members/search"}
로그에 진짜 이유가 있습니다.
1
DEBUG o.s.w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.MissingServletRequestParameterException: Required request parameter 'name' for method parameter type String is not present]
Required request parameter 'name' ... is not present — @RequestParam은 기본이 필수입니다. 없어도 되게 하려면 defaultValue를 주거나 required = false를 붙입니다.
③ 본문에서 꺼내기
1
2
3
curl -X POST localhost:8080/members \
-H "Content-Type: application/json" \
-d '{"name":"민아","email":"songmina37@gmail.com"}'
1
가입: 민아 (songmina37@gmail.com)
JSON 문자열이 MemberCreateRequest 객체로 바뀌어 들어왔습니다. 1편에서 객체가 JSON으로 나갔던 것의 반대 방향입니다. 나갈 때도 들어올 때도 Jackson이 합니다.
-H "Content-Type: application/json"을 빼면 415가 납니다.curl은 헤더를 안 주면 폼 형식으로 보낸다고 알려서, 스프링이 “JSON을 기대했는데 폼이 왔다”며 거절합니다.
1 {"timestamp":"...","status":415,"error":"Unsupported Media Type","path":"/members"}
그래서 어느 걸 쓰나
셋 다 값을 받는다는 점은 같습니다. 고르는 기준은 그 값이 무엇을 하는 값인가입니다.
@PathVariable |
@RequestParam |
@RequestBody |
|
|---|---|---|---|
| 실려 오는 곳 | URL 경로 | 쿼리 스트링, 폼 | 요청 본문(JSON) |
| 예시 | /members/7 의 7 |
?name=민아 의 민아 |
{"name":"민아"} |
| 쓰는 값 | 대상을 특정하는 값 | 거르거나 정렬하는 조건 | 새로 만들거나 고칠 내용 |
| 보통 쓰는 메서드 | GET, PUT, DELETE | GET | POST, PUT, PATCH |
문장으로 바꾸면 이렇습니다.
- “어느 것”을 다루는가 → 경로.
/members/7은 7번 회원 하나를 가리킵니다 - “어떻게 걸러서” 보여줄까 → 쿼리. 검색어, 페이지 번호, 정렬 기준이 여기 갑니다
- “무슨 내용”을 저장할까 → 본문. 값이 여러 개고 구조가 있는 데이터입니다
헷갈릴 때 하나만 기억하면 됩니다. 경로에서 값을 빼면 주소가 다른 대상을 가리키게 되고, 쿼리에서 값을 빼면 같은 대상을 다르게 보여줄 뿐입니다. /members/7에서 7을 빼면 아예 다른 주소지만, ?page=2를 빼면 여전히 같은 검색 결과의 1페이지입니다.
GET 요청에는 본문을 안 씁니다. 문법적으로 막혀 있진 않지만 중간의 서버나 캐시가 GET 본문을 버릴 수 있어서, 조회 조건은 쿼리 스트링에 싣는 게 관례입니다.
요청 본문은 record로 받는다
MemberCreateRequest를 record로 만들었습니다. 왜 그런지 일반 클래스로 바꿔 보면서 확인합니다.
확인 2: 기본 생성자를 지우면
record를 평범한 클래스로 바꿉니다. 기본 생성자는 일부러 만들지 않습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.example.demo;
public class MemberCreateRequest {
private String name;
private String email;
// 값을 다 받는 생성자만 두고, 기본 생성자는 없다
public MemberCreateRequest(String name, String email) {
this.name = name;
this.email = email;
}
public String getName() { return name; }
public String getEmail() { return email; }
}
컨트롤러도 request.name() 대신 request.getName()으로 고칩니다. 컴파일은 통과합니다.
1
2
3
curl -i -X POST localhost:8080/members \
-H "Content-Type: application/json" \
-d '{"name":"민아","email":"songmina37@gmail.com"}'
1
2
3
4
HTTP/1.1 400
Content-Type: application/json
{"timestamp":"2026-07-30T01:24:15.902+00:00","status":400,"error":"Bad Request","path":"/members"}
400의 이유는 응답 본문에 안 나옵니다. 로그를 봐야 합니다.
1
DEBUG o.s.w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot construct instance of `com.example.demo.MemberCreateRequest` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)]
no Creators, like default constructor, exist — 기본 생성자가 없다입니다.
Jackson이 JSON을 객체로 바꾸는 순서가 이렇기 때문입니다.
1
2
3
1. 빈 객체를 하나 만든다 ← 여기서 기본 생성자가 필요
2. JSON의 key를 보고
3. 같은 이름의 필드에 값을 넣는다
1번에서 막혔습니다. 기본 생성자를 추가하면 통과합니다.
1
2
public MemberCreateRequest() {
}
그런데 이상합니다. record에는 기본 생성자가 없는데 아까는 잘 됐습니다.
1
2
public record MemberCreateRequest(String name, String email) {
}
1
가입: 민아 (songmina37@gmail.com)
record는 다른 경로로 처리됩니다. 빈 객체를 만들고 값을 채우는 대신, 생성자에 값을 바로 넣어 만듭니다. 그러려면 생성자 파라미터의 이름(name, email)을 알아야 하는데, 컴파일할 때 -parameters 옵션이 켜져 있으면 그 이름이 클래스 파일에 남습니다. 스프링 부트의 Gradle 플러그인이 이 옵션을 기본으로 켜 줍니다.
정리하면 이렇습니다.
| 기본 생성자 | 값을 넣는 방법 | 필드 변경 | |
|---|---|---|---|
| 일반 클래스 | 필요 | 만든 뒤에 필드에 채워 넣음 | 가능 |
record |
불필요 | 생성자에 바로 넘김 | 불가능 |
record는 한 번 만들어지면 값이 안 바뀝니다. 요청 데이터는 받은 뒤에 바뀔 이유가 없으니 오히려 맞습니다.
요청·응답으로 주고받는 데이터 클래스는
record로 씁니다. 기본 생성자, getter,equals를 안 써도 되고 실수로 값을 바꿀 일도 없습니다.
@RestController가 하는 나머지 절반
지금까지 @RestController를 “웹 요청을 처리하는 클래스”라고만 설명했습니다. 실은 두 가지를 합친 것입니다.
1
2
3
@Controller
@ResponseBody
// ↑ 이 둘을 합친 것이 @RestController
@Controller가 요청을 받는 부분, @ResponseBody가 응답을 내보내는 부분입니다. 뒤쪽을 떼면 무슨 일이 생기는지 봅니다.
확인 3: @ResponseBody를 떼면
실험용으로 컨트롤러를 하나 만듭니다. @RestController가 아니라 @Controller입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
package com.example.demo;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class MemberViewController {
@GetMapping("/members") // 주소와
public String list() {
return "members"; // 반환 문자열을 일부러 같게 맞춘다
}
}
MemberController의 @PostMapping은 POST라서 부딪히지 않습니다. 그대로 두고 재시작합니다.
1
curl -i localhost:8080/members
1
2
3
4
HTTP/1.1 500
Content-Type: application/json
{"timestamp":"2026-07-30T01:26:41.117+00:00","status":500,"error":"Internal Server Error","path":"/members"}
500입니다. 로그를 봅니다.
1
ERROR o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Circular view path [members]: would dispatch back to the current handler URL [/members] again. Check your ViewResolver setup! (Hint: This may be the result of an unspecified view, due to default view name generation.)] with root cause
Circular view path [members] — 돌고 있습니다.
스프링이 한 일은 이렇습니다. @ResponseBody가 없으니 반환한 "members"를 응답 내용이 아니라 화면 파일의 이름으로 읽었습니다. 그래서 members라는 이름의 화면을 찾아 요청을 다시 넘겼는데, 그 주소가 방금 처리하던 /members라서 무한히 자기 자신으로 돌아옵니다. 스프링이 이걸 감지하고 멈춘 겁니다.
메서드에 @ResponseBody를 붙입니다.
1
2
3
4
5
@GetMapping("/members")
@ResponseBody // 추가
public String list() {
return "members";
}
1
curl localhost:8080/members
1
members
이제 문자열이 그대로 나갑니다. @ResponseBody가 가르는 건 딱 한 가지입니다.
| 반환한 문자열의 의미 | |
|---|---|
@Controller만 |
화면 파일의 이름 — 그 이름의 HTML 템플릿을 찾아 보여준다 |
@Controller + @ResponseBody |
응답 본문 그 자체 — 그대로 내보낸다 |
@Controller만 쓰는 건 서버가 HTML 화면을 직접 만들어 보내던 방식입니다. 요즘처럼 화면을 프론트엔드가 따로 만들고 서버는 데이터(JSON)만 주는 구조에서는 항상 @ResponseBody가 필요합니다. 그래서 매번 두 개를 붙이는 대신 @RestController 하나를 씁니다.
반환한 문자열이 요청 주소와 다르면
Circular view path대신 “그런 화면 파일이 없다”는 다른 에러가 납니다. 메시지는 달라도 원인은 같습니다. 반환값을 화면 이름으로 읽고 있는 것입니다.
실험이 끝났으면 MemberViewController는 지웁니다.
정리
- HTTP 요청에서 값이 실릴 수 있는 자리는 경로 / 쿼리 스트링 / 본문 세 군데다. 애노테이션이 세 개인 이유가 이거다
- 대상을 특정하면 경로, 거르거나 정렬하면 쿼리, 저장할 내용이면 본문을 쓴다
@RequestParam은 기본이 필수다. 없어도 되면defaultValue나required = false를 준다- Jackson은 빈 객체를 만들고 값을 채우는 방식이라 일반 클래스에는 기본 생성자가 필요하다.
record는 생성자에 바로 넣는 방식이라 없어도 된다 - 요청·응답 데이터 클래스는
record로 쓴다 @RestController=@Controller+@ResponseBody.@ResponseBody가 없으면 반환값을 화면 파일 이름으로 읽는다
다음 3편에서는 컨트롤러 하나에 다 몰아넣은 코드를 계층으로 나눕니다.