@RestController — 주소와 메서드를 잇는다
Article
자바 편의 마지막 페이지를 기억하는지. 스물한 강을 System.out.println 하나로 버티다가, 맨 끝에 이 코드를 설명 없이 한 번 보여주고 닫았다.
@RestController
class BookController { ... }
그리고 이렇게 적었다.
이 표식이 무엇을 하는지 우리는 아직 모른다. 하지만 무엇을 하지 않는지는 안다. 저것은 실행되지 않는다. 아무 일도 하지 않는다.
이제 그 { ... } 를 채운다.
채운다
@RestController
public class BookController {
@GetMapping("/books")
public String list() {
return "책 목록";
}
}
일곱 줄이다. 띄운다.
curl -i http://localhost:8080/books
- HTTP/1.1 200
- Content-Type: text/plain;charset=UTF-8
- Content-Length: 10
- Date: Fri, 10 Jul 2026 06:10:36 GMT
- 책 목록
된다. 1강에서 손으로 조립하던 것이 전부 사라졌다.
1강과 나란히 놓는다
1강에서 우리는 이런 코드를 짰다. ServerSocket 을 열고, accept() 로 기다리고, 요청을 읽고, 상태줄과 헤더를 문자열로 이어붙이고, Content-Length 를 손으로 세고, 바이트로 바꿔 내보냈다.
1강 — Server.java
빈 줄 빼고 30줄
new ServerSocket(8080)while (true) { server.accept(); }- 요청을 한 줄 읽어서 버린다
"HTTP/1.1 200 OK\r\n"를 손으로 적는다Content-Length를 손으로 센다out.write(head.getBytes("US-ASCII"))- 경로도 메서드도 구분하지 않는다
9강 — BookController.java
빈 줄 빼고 7줄
@RestController@GetMapping("/books")return "책 목록";- —
- —
- —
- 경로와 메서드가 표식에 적혀 있다
응답을 자세히 보면 2강과 3강이 되돌아온다.
| 응답에서 보이는 것 | 누가 정했나 | 어디서 배웠나 |
|---|---|---|
Content-Length: 10 | Spring 이 셌다. 책 목록 은 10바이트다 | 2강 |
Content-Type: text/plain;charset=UTF-8 | String 을 돌려줬으니 평문이다 | 2강 |
HTTP/1.1 200 — 뒤가 비었다 | 이유 문구를 안 보낸다. 계약은 숫자에 있다 | 3강 |
200 | 메서드가 정상적으로 값을 돌려줬으니 성공이다 | 3강 |
표식 두 개를 뜯어본다
7강에서 배운 방법 그대로다. 애노테이션도 애노테이션을 물고 있다.
for (Annotation a : RestController.class.getAnnotations())
System.out.println("### " + a.annotationType().getSimpleName());
- ### Target
- ### Retention
- ### Documented
- ### Controller
- ### ResponseBody
- ### Controller 에 Component 가 붙어 있나: true
@RestController 는 두 개를 합친 것이다.
- @Controller→ @Component 다. 그래서 빈이 된다
- @ResponseBody메서드가 돌려준 값을 그대로 응답 본문에 담는다
@ResponseBody 가 붙어 있지 않으면 return "책 목록" 은 **"책 목록 이라는 이름의 화면을 찾아 보여줘라"**는 뜻이 된다. 화면을 그리는 옛 방식이다. @RestController 는 그러지 않고 글자를 그대로 본문에 싣는다.
@GetMapping 도 열어보자.
- ### Target
- ### Retention
- ### Documented
- ### RequestMapping
- ### GetMapping 의 RequestMapping method: [GET]
@GetMapping 은 @RequestMapping(method = GET) 을 짧게 쓴 것이다. @PostMapping, @PutMapping, @DeleteMapping 도 마찬가지다. 3강에서 배운 네 개의 동사가 그대로 표식이 되어 있다.
Spring 이 약속을 강제한다
3강에서 우리 에코 서버는 어떤 메서드로 불러도 200 을 돌려줬다. 없는 책을 달라고 해도 200 이었다. 아무 약속도 안 했으니 어길 것도 없었다.
이제 다시 해보자. @GetMapping("/books") 하나만 등록해뒀다.
- GET /books → 200
- POST /books → 405
- PUT /books → 405
- DELETE /books → 405
- GET /nope → 404
3강의 그 표와 정확히 반대다. 아무것도 시키지 않았는데 약속을 지킨다.
405 는 3강에서 안 다룬 상태코드다. Method Not Allowed — 경로는 맞는데 그 동사는 안 된다는 뜻이다. 헤더를 보면 더 친절하다.
- HTTP/1.1 405
- Allow: GET
- Content-Type: application/json
- Transfer-Encoding: chunked
Allow: GET. 이 경로에서 쓸 수 있는 동사를 알려준다. 우리는 @GetMapping 하나만 적었을 뿐인데 Spring 이 그것을 읽고 헤더로 만들어 보냈다.
본문도 온다.
- {"timestamp":"2026-07-10T06:11:52.352Z","status":405,"error":"Method Not Allowed","path":"/books"}
3강에서 200 에 에러를 담는 것이 가장 흔한 잘못이라고 했다. Spring 은 상태코드에 담고, 본문에는 설명을 담는다. 둘 다 있다.
main 에서 getBean 이 사라졌다
5강부터 우리 main 은 이렇게 생겼었다.
ApplicationContext ctx = SpringApplication.run(BookstoreApplication.class, args);
OrderService svc = ctx.getBean(OrderService.class);
svc.order();
6강에서 OrderService 안의 getBean 은 지웠지만, 누군가 첫 단추는 꿰어야 했다. main 이 그 일을 했다.
이제 main 은 이렇다.
public static void main(String[] args) {
SpringApplication.run(BookstoreApplication.class, args);
System.out.println("### main 이 끝났다. getBean 은 한 번도 안 불렀다");
}
컨트롤러에 생성자 주입을 걸어두고, 요청을 보내봤다.
@RestController
public class BookController {
private final OrderService orderService;
public BookController(OrderService orderService) { // 6강 그대로
this.orderService = orderService;
System.out.println("### BookController 가 만들어졌다");
}
@GetMapping("/books")
public String list() {
System.out.println("### list() 가 불렸다. this = " + System.identityHashCode(this));
return "책 목록";
}
}
- ### BookController 가 만들어졌다
- ### main 이 끝났다. getBean 은 한 번도 안 불렀다
- ### list() 가 불렸다. this = 659672450
- ### list() 가 불렸다. this = 659672450
네 줄에 이 시리즈의 절반이 들어 있다.
- 01
컨트롤러가 요청 전에 만들어졌다
기동할 때 컨테이너가 미리 만들어 상자에 넣는다. 첫 요청을 기다리지 않는다. 5강의 그 146개 중 하나가 됐다.
- 02
생성자에
OrderService가 들어왔다@Component가 붙은 컨트롤러이므로 6강의 생성자 주입이 그대로 적용된다. 우리는 아무것도 안 시켰다. - 03
main이 먼저 끝났다그런데도 서버는 살아 있다. 4강에서 본 톰캣의 비데몬 스레드가 남아 있기 때문이다.
- 04
main이 끝난 뒤에list()가 불렸다우리가 부른 게 아니다. 요청이 들어오자 Spring 이 불렀다. 우리 코드 어디에도
list()를 부르는 줄이 없다. - 05
두 요청의
this가 같다659672450 으로 같다. 컨트롤러도 빈이므로 하나뿐이다. 5강의 싱글턴이다.
제어가 완전히 뒤집혔다
5강에서 제어의 역전을 배웠다. 객체를 만드는 권한이 우리에게서 프레임워크로 넘어갔다.
이제 부르는 권한도 넘어갔다.
- 자바 편내가 main 에서 svc.order() 를 부른다
- 5강~8강객체는 컨테이너가 만든다. 부르는 건 여전히 나
- 9강요청이 오면 Spring 이 내 메서드를 부른다
자바 편 21강의 러너를 다시 떠올려보자.
if (m.isAnnotationPresent(Test.class)) {
m.invoke(new MyTests());
}
표식이 붙은 메서드를 찾아 대신 불러준다. 지금 Spring 이 하는 일이 정확히 이것이다. 다른 것은 표식의 이름(@GetMapping)과, 부르는 시점(요청이 왔을 때)뿐이다.
우리는 이제 부를 코드만 적는다. 언제 부를지는 표식에 적는다.
정리하면
표식에 경로와 메서드를 적어두면, 요청이 왔을 때 Spring 이 그 메서드를 대신 부른다.
- 01
@RestController=@Controller+@ResponseBody@Controller는@Component라서 빈이 되고,@ResponseBody는 돌려준 값을 본문에 그대로 싣는다. - 02
@GetMapping=@RequestMapping(method = GET)3강의 네 동사가 그대로 표식이 되어 있다. 리플렉션으로 열어보면
[GET]이 적혀 있다. - 03
약속은 아무것도 시키지 않아도 지켜진다
등록 안 한 메서드는 405, 등록 안 한 경로는 404.
Allow: GET헤더까지 붙여준다. - 04
상태줄도
Content-Length도 우리가 안 센다책 목록이 10바이트라는 것을 Spring 이 세어 헤더에 넣었다. 2강에서 손으로 하던 일이다. - 05
getBean이 완전히 사라졌다컨트롤러는 기동할 때 만들어지고, 요청이 오면 Spring 이 부른다. 싱글턴이라
this는 언제나 같다.
그런데 지금 우리 API 는 쓸모가 없다. "책 목록" 이라는 글자를 돌려줄 뿐이다.
진짜 API 는 책 한 권의 제목과 가격을 돌려줘야 한다. 자바 편 11강에서 만든 그 Book 을.
class Book {
String title;
int price;
}
이 객체를 어떻게 글자로 바꿔 보낼 것인가. 2강에서 확인했다 — HTTP 요청은 글자 몇 줄이다. 응답도 마찬가지다. 밖으로 나가는 것은 오직 텍스트뿐이고, Book 이라는 타입은 그 경계를 넘지 못한다.
10강에서 그 텍스트의 약속을 배운다. 이름이 JSON 이다.