학습 자료

@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 "책 목록";
  • 경로와 메서드가 표식에 적혀 있다
지운 것이 아니라 옮긴 것이다. 30줄이 하던 일은 지금도 누군가 하고 있다. 우리가 안 쓸 뿐이다.

응답을 자세히 보면 2강과 3강이 되돌아온다.

우리가 적은 적 없는 것들
응답에서 보이는 것누가 정했나어디서 배웠나
Content-Length: 10Spring 이 셌다. 책 목록10바이트2강
Content-Type: text/plain;charset=UTF-8String 을 돌려줬으니 평문이다2강
HTTP/1.1 200 — 뒤가 비었다이유 문구를 안 보낸다. 계약은 숫자에 있다3강
200메서드가 정상적으로 값을 돌려줬으니 성공이다3강
2강에서 "안녕"이 6바이트라 Content-Length: 6 이라고 손으로 적었다. 이제 셀 필요가 없다.

표식 두 개를 뜯어본다

7강에서 배운 방법 그대로다. 애노테이션도 애노테이션을 물고 있다.

for (Annotation a : RestController.class.getAnnotations())
    System.out.println("###   " + a.annotationType().getSimpleName());
@RestController 에 붙은 것
  • ### Target
  • ### Retention
  • ### Documented
  • ### Controller
  • ### ResponseBody
  • ### Controller 에 Component 가 붙어 있나: true

@RestController 는 두 개를 합친 것이다.

@RestController 를 풀면
  1. @Controller→ @Component 다. 그래서 빈이 된다
  2. @ResponseBody메서드가 돌려준 값을 그대로 응답 본문에 담는다
7강에서 @Service 가 @Component 였던 것과 같은 구조다. 컨테이너에게는 다 똑같은 @Component 다.

@ResponseBody 가 붙어 있지 않으면 return "책 목록" 은 **"책 목록 이라는 이름의 화면을 찾아 보여줘라"**는 뜻이 된다. 화면을 그리는 옛 방식이다. @RestController 는 그러지 않고 글자를 그대로 본문에 싣는다.

@GetMapping 도 열어보자.

@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 — 경로는 맞는데 그 동사는 안 된다는 뜻이다. 헤더를 보면 더 친절하다.

curl -i -X POST http://localhost:8080/books
  • HTTP/1.1 405
  • Allow: GET
  • Content-Type: application/json
  • Transfer-Encoding: chunked

Allow: GET. 이 경로에서 쓸 수 있는 동사를 알려준다. 우리는 @GetMapping 하나만 적었을 뿐인데 Spring 이 그것을 읽고 헤더로 만들어 보냈다.

본문도 온다.

405 의 본문
  • {"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 "책 목록";
    }
}
서버 로그 — 기동한 뒤 /books 를 두 번 불렀다
  • ### BookController 가 만들어졌다
  • ### main 이 끝났다. getBean 은 한 번도 안 불렀다
  • ### list() 가 불렸다. this = 659672450
  • ### list() 가 불렸다. this = 659672450

네 줄에 이 시리즈의 절반이 들어 있다.

로그를 순서대로 읽는다
  1. 01

    컨트롤러가 요청 전에 만들어졌다

    기동할 때 컨테이너가 미리 만들어 상자에 넣는다. 첫 요청을 기다리지 않는다. 5강의 그 146개 중 하나가 됐다.

  2. 02

    생성자에 OrderService 가 들어왔다

    @Component 가 붙은 컨트롤러이므로 6강의 생성자 주입이 그대로 적용된다. 우리는 아무것도 안 시켰다.

  3. 03

    main 이 먼저 끝났다

    그런데도 서버는 살아 있다. 4강에서 본 톰캣의 비데몬 스레드가 남아 있기 때문이다.

  4. 04

    main 이 끝난 뒤에 list() 가 불렸다

    우리가 부른 게 아니다. 요청이 들어오자 Spring 이 불렀다. 우리 코드 어디에도 list() 를 부르는 줄이 없다.

  5. 05

    두 요청의 this 가 같다

    659672450 으로 같다. 컨트롤러도 빈이므로 하나뿐이다. 5강의 싱글턴이다.

제어가 완전히 뒤집혔다

5강에서 제어의 역전을 배웠다. 객체를 만드는 권한이 우리에게서 프레임워크로 넘어갔다.

이제 부르는 권한도 넘어갔다.

누가 누구를 부르는가
  1. 자바 편내가 main 에서 svc.order() 를 부른다
  2. 5강~8강객체는 컨테이너가 만든다. 부르는 건 여전히 나
  3. 9강요청이 오면 Spring 이 내 메서드를 부른다
자바 편 21강에서 우리가 만든 러너가 @Test 붙은 메서드를 invoke 했던 것과 같다. 이번엔 @GetMapping 이 붙은 메서드다.

자바 편 21강의 러너를 다시 떠올려보자.

if (m.isAnnotationPresent(Test.class)) {
    m.invoke(new MyTests());
}

표식이 붙은 메서드를 찾아 대신 불러준다. 지금 Spring 이 하는 일이 정확히 이것이다. 다른 것은 표식의 이름(@GetMapping)과, 부르는 시점(요청이 왔을 때)뿐이다.

우리는 이제 부를 코드만 적는다. 언제 부를지는 표식에 적는다.

정리하면

표식에 경로와 메서드를 적어두면, 요청이 왔을 때 Spring 이 그 메서드를 대신 부른다.

다시 짚어보기
  1. 01

    @RestController = @Controller + @ResponseBody

    @Controller@Component 라서 빈이 되고, @ResponseBody 는 돌려준 값을 본문에 그대로 싣는다.

  2. 02

    @GetMapping = @RequestMapping(method = GET)

    3강의 네 동사가 그대로 표식이 되어 있다. 리플렉션으로 열어보면 [GET] 이 적혀 있다.

  3. 03

    약속은 아무것도 시키지 않아도 지켜진다

    등록 안 한 메서드는 405, 등록 안 한 경로는 404. Allow: GET 헤더까지 붙여준다.

  4. 04

    상태줄도 Content-Length 도 우리가 안 센다

    책 목록 이 10바이트라는 것을 Spring 이 세어 헤더에 넣었다. 2강에서 손으로 하던 일이다.

  5. 05

    getBean 이 완전히 사라졌다

    컨트롤러는 기동할 때 만들어지고, 요청이 오면 Spring 이 부른다. 싱글턴이라 this 는 언제나 같다.

그런데 지금 우리 API 는 쓸모가 없다. "책 목록" 이라는 글자를 돌려줄 뿐이다.

진짜 API 는 책 한 권의 제목과 가격을 돌려줘야 한다. 자바 편 11강에서 만든 그 Book 을.

class Book {
    String title;
    int price;
}

이 객체를 어떻게 글자로 바꿔 보낼 것인가. 2강에서 확인했다 — HTTP 요청은 글자 몇 줄이다. 응답도 마찬가지다. 밖으로 나가는 것은 오직 텍스트뿐이고, Book 이라는 타입은 그 경계를 넘지 못한다.

10강에서 그 텍스트의 약속을 배운다. 이름이 JSON 이다.

@RestController — 주소와 메서드를 잇는다 — 디코드랩(DCODELAB)