Backend 11

Spring @RestController와 JSON 응답: '자동'의 비밀을 풀다

메서드가 Java 객체를 반환했는데 브라우저나 Postman에서는 왜 JSON으로 보이는지 모르겠다.

근거 · 교안 p69-p76

Spring @RestController와 JSON 응답: '자동'의 비밀을 풀다 대표 이미지

1장: 객체 반환, 그런데 JSON?

솔라의 손가락이 노트북 트랙패드 위에서 초조하게 맴돌았다. 화면 한쪽에는 방금 막 실행한 스프링 부트 애플리케이션의 로그가, 다른 한쪽에는 웹 브라우저 창이 열려 있었다. 브라우저에 표시된 내용은 솔라가 기대했던 것과 전혀 달랐다.

{"name":"sola", "email":"sola@example.com"}

분명 코드에서는 User라는 이름의 자바 객체를 반환하도록 만들었다. 그런데 결과는 정체를 알 수 없는 텍스트 덩어리였다. 중괄호와 따옴표, 콜론으로 이루어진, 마치 잘 짜인 암호문 같은 모양새였다.

“언니, 이거 좀 이상해.”

노트북을 들고 거실에 있는 루나에게 다가간 솔라가 화면을 불쑥 내밀었다.

“내 코드는 분명 User 객체를 반환하거든? 그럼 자바 객체에 대한 정보나… 아니면 최소한 에러라도 나야 정상 아니야? 그런데 이건 뭐야? 이건 자바 객체가 아니잖아.”

솔라의 생각은 단순했다. 자바 세상에서 만든 객체는 자바 세상 안에서만 통용되는 물건이다. 웹 브라우저가 자바 객체의 구조를 이해하고 화면에 그려줄 리 만무했다. 당연히 뭔가 잘못되었다고 생각했다.

루나는 소파에 앉은 채로 노트북 화면을 들여다보았다. 솔라가 작성한 코드는 간결했다.

@RestController
public class UserController {

    @GetMapping("/user")
    public User getUser() {
        return new User("sola", "sola@example.com");
    }
}

// User 클래스
public class User {
    private String name;
    private String email;

    // 생성자, getter 등
}

루나는 코드를 쭉 훑어보더니, 설명 대신 짧은 제안을 했다. “솔라야, 저기 맨 위에 있는 @RestController 어노테이션을 @Controller로 바꿔볼래? 딱 Rest 세 글자만 지워봐.”

“그게 무슨 상관인데?” 솔라는 의아했지만, 일단 루나가 시키는 대로 코드를 수정했다.

@Controller
public class UserController {
// ... 내용은 동일
}

그리고 애플리케이션을 다시 실행한 뒤, 아까와 같은 주소로 접속했다. 이번에는 브라우저에 하얀 에러 페이지가 나타났다. 서버 로그에는 뷰(View)를 찾을 수 없다는 내용의 오류 메시지가 가득했다.

“어? 이번엔 아예 깨져버렸네. 나는 그냥 객체가 그대로 출력될 줄 알았는데…” 솔라는 더욱 미궁에 빠진 표정을 지었다. 기대했던 ‘자바 객체의 정직한 모습’은 어디에도 없었다. 그저 망가지거나, 혹은 암호문으로 변할 뿐이었다.

“다시 원래대로 돌려봐.” 루나의 말에 솔라는 @Controller를 다시 @RestController로 고쳤다. 그리고 다시 한번 브라우저를 새로고침했다. 그러자 언제 그랬냐는 듯, 깔끔하게 정돈된 JSON 문자열이 다시 화면에 나타났다.

{"name":"sola", "email":"sola@example.com"}

솔라는 한동안 말없이 에러가 났던 하얀 화면과 지금 눈앞의 JSON 문자열을 번갈아 떠올렸다. 바뀐 것은 단 하나, 클래스 선언부 위에 붙어 있던 어노테이션뿐이었다. 모든 단서가 한곳을 가리키고 있었다.

“아…!”

탄성과 함께 솔라의 눈이 반짝였다.

“범인이 이 녀석이었네! @RestController라는 이 어노테이션이 뭔가 특별한 일을 하고 있었던 거야. 이게 없으니까 스프링은 내가 돌려준 User 객체를 어떻게 처리해야 할지 몰라서 뷰를 찾다가 에러를 낸 거고, 이게 있으니까… 이 텍스트로 ‘자동으로’ 바꿔준 거였어.”

스스로 내린 결론에 솔라는 고개를 끄덕였다. 막연했던 현상에 드디어 이름을 붙일 수 있게 된 것이다. 더 이상 정체불명의 암호문이 아니었다. @RestController가 만들어낸 결과물이었다.

루나가 조용히 말을 보탰다. “응. 그 어노테이션은 스프링에게 주는 하나의 표식이야. ‘이 컨트롤러의 메서드가 반환하는 것은 화면을 그리는 데 사용할 정보가 아니야. 데이터 그 자체를 응답 본문에 실어서 보내줘.’ 하고 알려주는 거지.”

“표식…” 솔라는 그 단어를 되뇌었다. 마법처럼 보였던 자동 변환의 첫 번째 실마리를 잡은 기분이었다. 이제 솔라는 컨트롤러가 객체를 반환했는데 JSON이 나타나는 현상을 보면, 주저 없이 @RestController의 존재를 떠올릴 수 있게 되었다.

하지만 안개가 걷히자 새로운 봉우리가 모습을 드러냈다.

“표식이라는 건 알겠어. @RestController가 ‘이걸 데이터로 보내!’ 하고 명령하는 스위치 같은 거구나. 그런데… 그 명령을 받은 스프링은 대체 어떻게 내 자바 객체를 이런 JSON 형식으로 바꾸는 거지? 심지어 내가 만든 User 객체의 필드 이름인 nameemail까지 정확하게 알아내서 키(key)로 쓰고 있잖아. 그 편리한 ‘자동’이라는 말 뒤에는 분명 뭔가 더 복잡한 과정이 숨어있을 것 같아.”

2장: Spring의 ‘자동 변환’ 비밀: MessageConverter

솔라는 노트북 옆에 작은 메모지를 펼쳐놓고 있었다. 메모지 한가운데에는 큼지막한 물음표가 그려져 있었다. 물음표 왼쪽에는 User 객체라고, 오른쪽에는 JSON이라고 쓰여 있었다. 그 사이를 잇는 화살표 위에는 @RestController라는 단어가 적혀 있었지만, 솔라는 그 단어 위에 펜으로 여러 번 덧칠해 거의 알아볼 수 없게 만들었다. 지난번의 발견은 끝이 아니라 새로운 시작이었기 때문이다.

화면에는 스프링 공식 문서의 @RestController 페이지가 열려 있었다. 하지만 솔라가 스크롤을 아무리 내려봐도, 그녀가 원하는 ‘어떻게’에 대한 답은 보이지 않았다. 문서는 그저 @RestController@Controller@ResponseBody를 합친 편의용 어노테이션이라고만 설명할 뿐, 자바 객체의 필드를 JSON의 키-값으로 바꾸는 섬세한 공정에 대해서는 침묵하고 있었다. 마치 잘 포장된 선물 상자의 겉면만 핥고 있는 기분이었다.

“언니, 이건 그냥 ‘그렇게 된다’고만 말해주네.”

솔라가 거실의 정적을 깨며 말했다. 루나는 책을 읽다 말고 조용히 고개를 들었다.

“스프링 문서 말이야. @RestController가 마법을 부리는 게 아니라는 건 알겠는데, 그럼 그 마법사가 누구인지는 알려줘야 할 거 아냐. 문서에는 온통 ‘이 어노테이션은 응답 본문(response body)을 만들어준다’는 말 뿐이야. 내가 궁금한 건 바로 그 ‘만들어주는’ 과정이라고!”

솔라는 자신이 그린 메모지를 가리켰다. @RestController는 이제 범인이 아니라, 사건 현장을 가리키는 목격자일 뿐이라는 확신이 든 상태였다.

루나는 소파에서 일어나 솔라의 옆으로 다가왔다. 그녀는 솔라의 노트북 화면과 메모지를 번갈아 보더니, 화면 속 @RestController의 설명 중 한 단어를 손가락으로 짚었다. @ResponseBody.

“솔라야, 어제 네가 한 실험을 생각해 봐. @RestController는 ‘표식’이라고 했지. 그 표식의 진짜 이름이 여기 있네. @ResponseBody. 몸통을 만들라는 신호. 그럼 스프링은 그 신호를 보고 누구에게 일을 시킬까? ‘몸통 만들기’ 전문가가 따로 있지 않을까?”

“몸통 만들기 전문가…?”

솔라는 그 말의 의미를 곱씹었다. @RestController가 직접 변환 작업을 하는 게 아니라, 단지 ‘이건 데이터야!’라고 외치는 역할이라면, 그 외침을 듣고 실제로 움직이는 존재가 스프링 어딘가에 있다는 뜻이었다. 그 존재는 컨트롤러가 반환한 User 객체를 받아 들고, HTTP 응답의 ‘몸통(body)‘에 들어갈 내용물로 요리하는 요리사 같은 역할일 것이다.

솔라는 검색창에 새로운 키워드를 입력했다. ‘Spring @ResponseBody mechanism’. 검색 결과의 최상단에는 HttpMessageConverter라는 낯선 이름이 보였다.

“Http… Message… Converter? HTTP 메시지 변환기?”

솔라는 홀린 듯 링크를 클릭했다. 화면 가득 인터페이스의 설명이 나타났다.

public interface HttpMessageConverter<T> {

    boolean canRead(Class<?> clazz, @Nullable MediaType mediaType);

    boolean canWrite(Class<?> clazz, @Nullable MediaType mediaType);

    List<MediaType> getSupportedMediaTypes();

    T read(Class<? extends T> clazz, HttpInputMessage inputMessage)
            throws IOException, HttpMessageNotReadableException;

    void write(T t, @Nullable MediaType contentType, HttpOutputMessage outputMessage)
            throws IOException, HttpMessageNotWritableException;

}

솔라는 코드를 읽는 내내 낮은 감탄사를 터뜨렸다. 이름부터 직설적이었다. canRead, canWrite. ‘이 클래스를 읽을 수 있는가?’, ‘이 클래스를 쓸 수 있는가?’. 마치 여러 명의 전문가에게 ‘이 일을 처리할 수 있는 사람?‘하고 묻는 듯한 모습이었다. 그리고 readwrite 메서드는 그 질문에 ‘예’라고 답한 전문가가 실제로 일을 처리하는 방법을 정의하고 있었다.

“아! 알겠다!”

솔라가 의자 등받이에 몸을 기댔다. 머릿속에 흩어져 있던 조각들이 거대한 기계의 부품처럼 맞물려 돌아가기 시작했다.

“이거였구나. @RestController는 그냥 스위치를 켠 것뿐이야. 스위치를 켜면 @ResponseBody가 활성화되고, 스프링은 반환된 User 객체를 들고 이 HttpMessageConverter라는 전문가들에게 찾아가는 거야. ‘이봐요들, 지금 User 타입 객체가 하나 있는데, 이걸 웹브라우저가 알아들을 수 있는 application/json 형태로 바꿔줄 수 있는 분?’ 하고 외치는 거지.”

솔라는 신이 나서 설명을 이어갔다. “그럼 여러 전문가 중에 JSON을 다룰 줄 아는 녀석이 ‘어, 그거 제가 할 수 있어요!’ 하고 손을 들고 나서는 거야. 그리고 write 메서드를 사용해서 내 User 객체를 JSON 문자열로 바꿔서 응답 본문에 써넣는 거지. ‘자동’이라는 말 뒤에는 이런 질문과 대답의 과정이 숨어 있었던 거야!”

마법이 아니었다. 아주 체계적이고 확장 가능한 위임 시스템이었다. @RestController는 변환의 책임을 직접 지는 대신, 훨씬 더 유연하고 강력한 HttpMessageConverter라는 메커지즘을 활용하고 있었다. 솔라는 어노테이션의 정체를 바꾼 것이 아니라, 그것이 작동하는 방식을 이해함으로써 한 걸음 더 나아갔다.

루나는 조용히 고개를 끄덕였다. 솔라가 스스로 답을 찾아낸 과정을 지켜보는 것만으로도 충분하다는 듯했다.

“그럼 이제… ‘자동’의 비밀은 풀린 건가?”

루나의 나지막한 질문에 솔라는 잠시 생각에 잠겼다. 환하게 켜졌던 시야가 다시 안개 속으로 들어가는 기분이었다.

“아니… 반만. ‘누가’ 하는지는 알았어. HttpMessageConverter라는 전문가 집단이 한다는 걸. 그런데 그 전문가들 중에 정확히 ‘어떤’ 전문가가 JSON 변환을 담당하는 거지? 그리고 그 전문가는 대체 ‘어떻게’ 내 User 객체의 nameemail 필드 이름을 정확히 알고 JSON 키로 만드는 걸까? 이 전문가의 이름은 뭐지? 이력서라도 보고 싶네.”

3장: Jackson, JSON 변환의 장인

솔라의 노트북 화면은 의존성 라이브러리 목록으로 가득 차 있었다. 지난번 루나와의 대화 이후, 솔라는 HttpMessageConverter라는 전문가 집단의 ‘이력서’를 찾아 헤매는 중이었다. 스프링 프로젝트의 라이브러리 목록을 뒤지자, HttpMessageConverter 인터페이스를 구현한 클래스들의 이름이 주르륵 나타났다. StringHttpMessageConverter, ByteArrayHttpMessageConverter, 그리고…

솔라의 시선이 한 이름에 멎었다. MappingJackson2HttpMessageConverter.

“잭슨…? 인명인가?” 마치 수많은 이력서 더미 속에서 유독 눈에 띄는 이름을 발견한 기분이었다. 이름 자체가 ‘나는 잭슨(Jackson)을 써서 매핑하는 변환기요’라고 소리치고 있었다. 솔라는 이 ‘잭슨’이라는 존재가 바로 자신이 찾던 JSON 변환 전문가일 것이라고 직감했다. 하지만 동시에 새로운 의문이 피어올랐다. ‘그럼 이 잭슨은 대체 어떻게 일하는 거지?’ 단순히 객체를 텍스트로 바꾸는 것과는 차원이 다른 일이었다. User 객체의 name 필드를 "name"이라는 키로, 그 값을 "sola"라는 값으로 정확하게 변환했으니까.

“이 전문가, 꽤 꼼꼼한데. 내 객체 속을 어떻게 이렇게 훤히 들여다보는 걸까?”

혼잣말을 들었는지, 책을 넘기던 루나가 고개를 돌렸다. 그녀는 솔라의 화면에 뜬 MappingJackson2HttpMessageConverter라는 이름을 힐끗 보더니, 잠시 생각에 잠겼다.

“그 전문가의 기술을 스프링이라는 큰 공장이 아니라, 작은 개인 작업실에서 한번 시험해보는 건 어때? 스프링 없이, 오직 그 전문가와 네 객체만 남겨두고 말이야.”

“개인 작업실?” 솔라는 루나의 말이 무슨 뜻인지 바로 이해했다. 복잡한 스프링 프로젝트를 잠시 벗어나, 이 ‘잭슨’이라는 기술의 핵심 원리만 따로 떼어내서 확인해보자는 제안이었다.

솔라는 새로운 main 메서드를 하나 만들었다. 스프링의 어노테이션도, 복잡한 설정도 없는 텅 빈 공간. 마치 모든 장비를 치운 깨끗한 작업대 같았다. 그녀는 먼저 실험 대상을 준비했다.

public class Main {
    public static void main(String[] args) {
        User user = new User("sola", "sola@example.com");
    }
}

// User 클래스는 이전과 동일
class User {
    private String name;
    private String email;
    // 생성자, getter ...
}

이제 ‘잭슨’이라는 전문가를 모셔올 차례였다. 솔라는 검색을 통해 잭슨 라이브러리의 핵심 클래스가 ObjectMapper라는 것을 알아냈다. 이름부터 ‘객체 매핑 전문가’의 풍모가 느껴졌다.

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.core.JsonProcessingException;

public class Main {
    public static void main(String[] args) throws JsonProcessingException {
        // 1. JSON으로 변환할 Java 객체 생성
        User user = new User("sola", "sola@example.com");

        // 2. Jackson의 핵심 전문가, ObjectMapper 초빙
        ObjectMapper objectMapper = new ObjectMapper();

        // 3. 전문가에게 객체를 건네며 "JSON 문자열로 만들어주세요" 요청
        String jsonResult = objectMapper.writeValueAsString(user);

        // 4. 결과 확인
        System.out.println(jsonResult);
    }
}

솔라는 떨리는 마음으로 코드를 실행했다. 스프링 서버를 띄울 때처럼 복잡한 로그도, 기다림도 없었다. 결과는 즉시 콘솔 창에 나타났다.

{"name":"sola","email":"sola@example.com"}

“와…” 솔라의 입에서 나지막한 감탄이 터져 나왔다. 스프링 @RestController를 통해 봤던 것과 완벽하게 동일한 JSON 문자열이었다. 마법의 장막이 한 겹 더 걷히는 순간이었다.

‘자동 변환’의 진짜 실체는 스프링 프레임워크 안에 내장된 어떤 비밀스러운 기능이 아니었다. ‘잭슨’이라는 이름의, 고도로 전문화된 외부 라이브러리의 힘이었다. 그리고 그 작업 방식은 단순한 텍스트 변환이 아니었다.

“알겠다! 이 ObjectMapper라는 녀석, 그냥 객체를 글자로 바꾸는 게 아니었어. User 객체를 받아서 그 내부 구조를 분석하는 거구나. ‘어, name이라는 필드가 있네? 이건 문자열 타입이군. 그럼 JSON에서 \"name\"이라는 키를 만들어야지. 값은 user.getName()을 호출해서 넣고.’ 이런 식으로… 객체의 설계도를 보고 JSON이라는 새로운 형태의 결과물을 조립해내는 거였어.”

이 과정은 ‘직렬화(Serialization)’, 즉 객체의 상태를 데이터 스트림(여기서는 JSON 문자열)으로 변환하는 과정 그 자체였다. 잭슨은 자바 객체의 필드나 getter 메서드를 보고 JSON의 키-값 쌍을 만들어내는, 아주 영리한 ‘직렬화 장인’이었던 것이다. 솔라는 자신이 처음 가졌던 생각, ‘잭슨이 단순히 객체를 텍스트로 바꾼다’는 생각이 얼마나 순진했는지 깨달았다.

루나가 조용히 솔라의 깨달음을 지켜보다가 입을 열었다. “스프링은 좋은 부품을 가져와서 잘 조립하는 것에도 능숙하지. 직접 모든 걸 만들기보다는, 잭슨처럼 이미 검증된 최고의 장인들을 기꺼이 고용하는 거야.”

솔라는 고개를 끄덕였다. 이제 솔라에게 @RestController의 JSON 변환은 더 이상 마법이 아니었다. 스프링이 HttpMessageConverter라는 체계를 통해 ‘잭슨’이라는 외부 전문가에게 ‘직렬화’ 작업을 위임하는, 지극히 합리적인 협업 과정으로 보였다.

하지만 그림이 선명해지자, 조립 과정의 마지막 이음새가 궁금해졌다.

“이제 ‘누가’ 하는지도 알았고, 그 전문가가 ‘어떻게’ 일하는지도 알았어. HttpMessageConverter는 일감을 나눠주는 관리자고, 잭슨은 실제 물건을 만드는 장인이라는 것도. 그런데… 스프링은 수많은 HttpMessageConverter 중에서 어떻게 MappingJackson2HttpMessageConverter를 콕 집어서 이 일을 맡기는 거지? ‘이봐, 지금 JSON으로 바꿔야 할 객체가 들어왔으니, 잭슨 담당자가 나와서 처리해!’ 하고 호출하는 신호 같은 게 있나?“

4장: 흐름 조립: 자동 JSON 응답의 완성

솔라의 책상 위에는 포스트잇 세 장이 어지럽게 놓여 있었다. 각 포스트잇에는 솔라가 지금까지 알아낸 ‘자동 변환’의 주역들의 이름이 적혀 있었다. @RestController, HttpMessageConverter, Jackson. 하지만 그 관계를 화살표로 연결하려는 시도는 번번이 실패했다. 어떤 화살표는 중간에 끊겼고, 어떤 화살표는 갈 곳을 잃고 허공을 향했다.

솔라는 부품은 다 찾았지만, 조립 설명서를 잃어버린 사람처럼 막막했다. 그녀의 머릿속에서 각 부품은 독립적으로 작동하는 것처럼 보였다. @RestController는 명령을 내리고, HttpMessageConverter는 전문가들을 관리하고, Jackson은 물건을 만든다. 하지만 관리자가 어떤 신호를 보고 정확히 Jackson이라는 전문가를 호출하는지, 그 연결 고리가 명확하지 않았다. 마치 오케스트라의 지휘자, 파트장, 연주자가 각자 자기 악보만 보고 연주하는 것과 같았다. 이들이 어떻게 하나의 교향곡을 만들어내는지 알 수 없었다.

“흐름이… 안 보여.”

솔라는 포스트잇들을 손가락으로 톡톡 건드리며 중얼거렸다. 바로 그때, 옆에서 조용히 그 모습을 지켜보던 루나가 나지막이 질문을 던졌다.

“솔라야, 만약 클라이언트가 웹 브라우저가 아니라, 이미지 파일을 전문적으로 다루는 프로그램이라고 상상해봐. 그리고 그 프로그램이 우리 서버에 ‘User 객체를 이미지(PNG) 파일로 주세요’라고 요청한다면, 그래도 잭슨이 나설까?”

“이미지 파일로? 잭슨은 JSON 전문가인데… 당연히 안 되지. 잭슨은 이미지를 만들 줄 모르잖아. 아마… 에러가 나거나, 아니면 이미지 전문 컨버터가 있다면 그 녀석이 처리하겠지.”

솔라는 당연하다는 듯 대답했다. 하지만 대답을 끝낸 순간, 그녀의 눈이 동그래졌다. 스스로 한 말속에 결정적인 단서가 숨어 있었다. ‘요청한다면’. ‘이미지 전문 컨버터가 있다면’.

“아…!”

솔라는 황급히 지난번에 봤던 HttpMessageConverter의 인터페이스 코드를 다시 화면에 띄웠다.

boolean canWrite(Class<?> clazz, @Nullable MediaType mediaType);

그녀의 시선은 mediaType이라는 두 번째 파라미터에 꽂혔다. 이전에는 반환될 객체의 Class 타입에만 집중하느라 이 부분을 무심코 지나쳤었다. 미디어 타입. 클라이언트가 원하는 데이터 형식을 의미하는 그 단어. 브라우저가 서버에 요청을 보낼 때 HTTP 헤더에 담아 보내는 Accept: application/json 같은 정보였다.

“그렇구나! 선택의 기준이 있었어! 스프링은 그냥 무턱대고 아무 컨버터에게나 일을 시키는 게 아니었어. 클라이언트가 ‘나는 JSON을 원해요’라고 쓴 주문서(Accept 헤더)와 컨트롤러가 반환한 ‘User 객체’라는 재료를 함께 들고 컨버터들을 찾아다니는 거였어!”

머릿속에서 흩어져 있던 부품들이 제자리를 찾아가며 거대한 기계가 조립되기 시작했다. 솔라는 눈을 감고, 작은 @RestController 예제 코드가 실행되는 과정을 머릿속으로 그려보았다. 가상의 클라이언트가 서버로 요청을 보내는 순간부터, 한 단계 한 단계씩 흐름을 따라가는 시뮬레이션이었다.

[솔라의 머릿속 시뮬레이션]

  1. 요청: 클라이언트가 GET /user 요청을 보낸다. 이때 HTTP 헤더에는 Accept: application/json 이라는 정보가 포함되어 있다.

  2. 수신 및 매핑: 스프링의 심장부인 디스패처 서블릿(DispatcherServlet)이 요청을 받고, /user 경로를 처리할 수 있는 UserControllergetUser() 메서드를 찾아낸다.

  3. 메서드 실행: getUser() 메서드가 실행되고, new User("sola", "sola@example.com") 라는 자바 객체가 반환된다.

  4. 신호 발생: UserController에 붙어있는 @RestController 어노테이션(내부적으로는 @ResponseBody)이 “이 반환된 User 객체는 뷰(View)가 아니라, 응답 본문(Body)에 직접 써야 할 데이터다!” 라는 신호를 보낸다.

  5. 전문가 물색: 신호를 받은 스프링은 등록된 HttpMessageConverter 목록을 순회하기 시작한다. 손에는 User 객체와 클라이언트의 주문서에 적힌 application/json 미디어 타입을 들고 있다.

  6. 협상:

    • 첫 번째 StringHttpMessageConverter에게 묻는다: “혹시 User 클래스를 application/json 타입으로 변환할 수 있나요?” -> “아니요, 전 문자열만 다루는데요.” (canWrite 실패)
    • 두 번째 ByteArrayHttpMessageConverter에게 묻는다: “혹시 User 클래스를 application/json 타입으로 변환할 수 있나요?” -> “아니요, 전 바이트 배열만 다루는데요.” (canWrite 실패)
    • 마침내 MappingJackson2HttpMessageConverter에게 묻는다: “혹시 User 클래스를 application/json 타입으로 변환할 수 있나요?” -> “네, 가능합니다!application/json을 다룰 수 있고, 클래스패스에 잭슨 라이브러리가 있어서 객체 직렬화도 문제없습니다.” (canWrite 성공!)
  7. 작업 위임: 스프링은 마침내 적임자를 찾았다! MappingJackson2HttpMessageConverter를 선택하고, User 객체를 넘겨주며 작업을 위임한다.

  8. 직렬화: MappingJackson2HttpMessageConverter는 내부의 진짜 장인인 ObjectMapper(잭슨)를 호출하여 User 객체를 {"name":"sola","email":"sola@example.com"} 라는 JSON 문자열로 직렬화(Serialization)한다.

  9. 응답 완성: 변환된 JSON 문자열이 HTTP 응답 본문에 실리고, 클라이언트에게 전송된다.

눈을 뜬 솔라의 얼굴에는 환한 미소가 번졌다. 처음 봤던 막연한 문장, @RestController는 메서드 반환값을 자동으로 JSON으로 변환한다는 그 말이 이제는 전혀 다른 의미로 다가왔다. ‘자동’이라는 단어 뒤에 숨어 있던, 이토록 정교하고 질서 있는 협업의 파노라마가 눈앞에 펼쳐진 것이다. 각자 독립적으로 움직이는 것처럼 보였던 부품들은 사실 하나의 목표를 위해 유기적으로 연결된 파이프라인이었다.

“이제 알겠어… 이건 마법이 아니라 아주 잘 짜인 조립 라인이었어.”

솔라는 책상 위의 포스트잇들을 다시 집어 들었다. 그리고 망설임 없이 하나의 흐름으로 깔끔하게 배열했다. 클라이언트의 요청부터 시작해서, 디스패처 서블릿을 거쳐 컨트롤러로, 그리고 여러 HttpMessageConverter와의 협상을 통해 Jackson에게 도달한 뒤, 마침내 JSON 응답으로 나가는 완벽한 통합 요청-응답 흐름이었다.

흐름의 끝을 완성하고 나자, 자연스럽게 흐름의 시작점이 다시 눈에 들어왔다.

“그런데 언니, 방금 내가 머릿속으로 그렸던 과정에서… 2번 단계, ‘디스패처 서블릿이 /user 경로를 처리할 컨트롤러 메서드를 찾아낸다’는 부분을 그냥 당연하게 넘어갔네. 응답이 만들어지는 과정은 이제 알겠는데, 애초에 그 요청은 어떻게 딱 맞는 메서드에게 전달되는 거지? @GetMapping("/user") 이런 어노테이션들은 이 멋진 응답 파이프라인의 어디쯤에 연결되어 있는 걸까?“

5장: 완벽한 Controller: 요청과 응답의 연결

솔라의 책상 위에는 이제 깔끔하게 정리된 한 장의 흐름도가 놓여 있었다. 클라이언트의 GET /user 요청에서 시작해, 디스패처 서블릿, 컨트롤러 메서드 호출, @RestController의 신호, HttpMessageConverter의 협상, Jackson의 직렬화를 거쳐 마침내 JSON 응답이 클라이언트에게 돌아가는, 완벽하게 조립된 파이프라인. 솔라는 그 흐름을 몇 번이고 눈으로 따라가며 만족스러운 미소를 지었다.

하지만 그 흐름도에는 어딘가 어색한 구석이 있었다. 가장 첫 단계인 ‘요청’ 상자에서 ‘컨트롤러 메서드 호출’ 상자로 이어지는 화살표. 그 위에는 @GetMapping("/user")이라는 어노테이션 이름이 작게 적혀 있었지만, 다른 부분처럼 유기적으로 연결된 느낌이 들지 않았다. 마치 잘 만들어진 자동차의 엔진(응답 생성 파이프라인)에, 문손잡이(@GetMapping)가 어떻게 엔진 시동을 거는지 설명이 빠진 것 같았다. 솔라는 요청을 처리하는 부분과 응답을 만드는 부분이 서로 다른 별개의 시스템처럼 느껴졌다.

“이 파이프라인은 컨트롤러 메서드가 일단 실행되고 나서야 움직이기 시작하는 거잖아. 그럼 그전까지는? /user라는 주소를 보고 getUser() 메서드를 콕 집어 호출하는 건, 이 파이프라인과는 상관없는 다른 일인 걸까?”

솔라는 혼잣말을 하며 고민에 잠겼다. 그때, 조용히 솔라의 흐름도를 들여다보던 루나가 펜으로 흐름도의 시작점을 톡 가리켰다.

“솔라야, 지금은 손님이 딱 한 종류지. 항상 똑같이 /user라는 문으로만 들어오잖아. 만약 손님이 다른 문으로 들어오거나, 들어올 때 특별한 요청사항을 외친다면 어떻게 될까? 예를 들어 ‘3번 사용자 정보를 주세요!’라고 하거나, ‘이름이 루나인 사람을 찾아주세요!’라고 한다면?”

루나의 질문은 솔라의 머릿속에 새로운 회로를 연결했다. 지금까지는 단 하나의 고정된 요청만 생각했다. 하지만 실제 서비스는 훨씬 더 다양한 요청을 처리해야 했다. 특정 ID를 가진 사용자 정보, 특정 조건을 만족하는 사용자 검색 등. 이런 다양한 요청들은 어떻게 각각의 올바른 메서드와 연결될까?

솔라는 결심한 듯 API 테스트 도구인 포스트맨(Postman)을 열고, 자신의 UserController 코드를 수정하기 시작했다. 루나의 질문을 코드로 직접 구현해보기로 한 것이다. 이건 더 이상 관찰이 아닌, 설계의 영역이었다.

@RestController
public class UserController {

    // 1. 기존 메서드: 고정된 사용자 정보 반환
    @GetMapping("/user")
    public User getDefaultUser() {
        return new User("sola", "sola@example.com");
    }

    // 2. 새로 추가: 경로의 일부(ID)를 변수로 받아 사용자 정보 반환
    @GetMapping("/users/{id}")
    public User getUserById(@PathVariable Long id) {
        // 실제로는 DB에서 id에 해당하는 사용자를 찾겠지만, 여기서는 예시로 생성
        return new User("user" + id, "user" + id + "@example.com");
    }

    // 3. 새로 추가: 쿼리 파라미터(name)를 받아 사용자 정보 반환
    @GetMapping("/users/search")
    public User findUserByName(@RequestParam String name) {
        return new User(name, name + "@example.com");
    }
}

코드는 훨씬 복잡해졌지만, 한 가지 공통점이 있었다. 세 개의 메서드 모두 여전히 User 객체를 반환한다는 점이다. 솔라는 이 점이 중요하다고 생각했다. ‘어떤 문으로 들어왔든, 일단 메서드 안으로 들어오면 그 뒤의 응답 생성 과정은 똑같지 않을까?’

가설을 확인하기 위해, 솔라는 포스트맨으로 첫 번째 요청을 보냈다.

GET http://localhost:8080/user

예상대로, 응답 창에는 익숙한 JSON이 나타났다. {"name":"sola","email":"sola@example.com"}

다음은 두 번째, 새로운 문을 시험해 볼 차례였다.

GET http://localhost:8080/users/100

엔터 키를 누르자, 응답이 즉시 돌아왔다. {"name":"user100","email":"user100@example.com"}

솔라는 잠시 숨을 멈췄다. 스프링은 URL 경로에 포함된 숫자 100을 정확히 인식해서 getUserById 메서드의 id 파라미터에 전달했고, 메서드는 그 값을 이용해 새로운 User 객체를 만들었다. 그리고 그 뒤는 솔라가 이미 완벽하게 이해하고 있는 파이프라인이 매끄럽게 처리했다. HttpMessageConverterJackson을 호출해 객체를 JSON으로 직렬화한 것이다.

마지막으로 세 번째 요청을 보냈다.

GET http://localhost:8080/users/search?name=luna

결과는 놀라웠다. {"name":"luna","email":"luna@example.com"}

“아…”

솔라의 입에서 탄성이 터져 나왔다. 모든 조각이 맞춰지는 순간이었다. @GetMapping, @PathVariable, @RequestParam 같은 어노테이션들은 응답 생성 파이프라인과 분리된 별개의 시스템이 아니었다. 그것들은 파이프라인의 가장 첫 단계에서 작동하는, 정교한 ‘고객 응대 및 재료 준비’ 시스템이었다.

클라이언트의 요청이라는 원석이 도착하면, 이 어노테이션들이 먼저 나선다. URL 경로를 분석하고(@GetMapping, @PathVariable), 쿼리 문자열에서 필요한 정보를 추출해서(@RequestParam), 컨트롤러 메서드가 작업을 수행하는 데 필요한 모든 ‘재료’를 깔끔하게 다듬어 전달한다.

그리고 일단 메서드가 이 재료들을 받아 요리(비즈니스 로직)를 마치고, User 객체라는 결과물을 내놓는 순간, 바통은 자연스럽게 응답 생성 파이프라인으로 넘어간다. 그 뒤로는 어떤 요청으로 시작했는지와는 상관없이, @RestController의 지휘 아래 모든 것이 ‘자동으로’ 처리되는 것이다.

솔라는 자신이 처음 가졌던 생각, ‘요청 처리와 JSON 변환은 별개일 것이다’라는 판단이 틀렸음을 깨달았다. 그것들은 분리된 기계가 아니라, 하나의 거대한 공장에서 완벽하게 협력하는 두 개의 부서였다. 요청 처리 부서가 원자재를 입고시키면, 응답 생성 부서가 그것을 완제품으로 가공해 출하하는 유기적인 흐름.

흐름도를 가만히 내려다보던 솔라는 펜을 들어 기존의 코드를 지우고, 새로운 컨트롤러의 모습을 스케치하기 시작했다. 더 이상 단 하나의 메서드만 덩그러니 있는 모습이 아니었다.

// 새로운 UserController 구상 (솔라의 메모)

@RestController
@RequestMapping("/api/users") // 공통 경로 설정
public class UserController {

    // 모든 사용자 목록 조회
    // GET /api/users
    public List<User> getAllUsers() { ... }

    // 특정 사용자 조회
    // GET /api/users/1
    public User getUserById(@PathVariable Long id) { ... }

    // 새 사용자 생성
    // POST /api/users
    public User createUser(@RequestBody User user) { ... }
    
    // ... 기타 등등
}

이제 솔라는 단순히 현상을 분석하는 관찰자가 아니었다. @RestController와 다양한 요청 매핑 어노테이션, 그리고 그 뒤에 숨겨진 HttpMessageConverterJackson의 원리를 모두 이해하고, 그것들을 자솔라재로 조합하여 스스로 완전한 RESTful API를 설계하는 개발자가 되어 있었다. ‘자동’이라는 단어의 비밀을 완전히 풀어낸 것이다. 그 모습은 처음 자바 객체가 왜 JSON으로 변하는지 몰라 혼란스러워하던 모습과는 완전히 다른, 자신감 넘치는 설계자의 모습이었다.