본문으로 건너뛰기

안동민 개발노트

본문 시작

서블릿 생명주기

실제 embedded Tomcat에서 Servlet 등록·초기화·HTTP 메서드 디스패치·동시 요청·소멸을 계측하고 요청별 상태의 소유권을 검증합니다.

Spring MVC 컨트롤러에 도달하기 전에 Servlet 컨테이너는 URL 매핑으로 Servlet을 고르고 요청·응답 객체를 전달합니다.

이 단원에서는 /raw/post-count에 직접 등록한 HttpServlet 하나를 실제 embedded Tomcat에서 실행합니다.

핵심은 "Servlet 클래스가 JVM 전체에서 영원히 하나"가 아니라, 이 Servlet 등록에 연결된 한 인스턴스를 컨테이너가 초기화한 뒤 여러 요청에 재사용할 수 있다는 점입니다.


등록된 인스턴스의 생명주기

이 예제에서는 구성 코드가 new PostCountServlet(query)로 인스턴스를 만들고 ServletRegistrationBean에 넘깁니다.

그 뒤의 init, service, destroy 호출 순서는 Servlet 컨테이너가 관리합니다.

Servlet.init은 인스턴스 생성 뒤 정확히 한 번 호출되며 성공적으로 끝나야 요청을 받을 수 있습니다.

초기화가 실패하면 그 인스턴스에는 servicedestroy를 호출하지 않습니다.

service는 한 번도 호출되지 않을 수도 있고, 같은 인스턴스에 여러 요청 스레드가 동시에 들어올 수도 있습니다.

컨테이너가 인스턴스를 서비스에서 제거할 때는 실행 중인 service가 끝나거나 서버가 정한 제한 시간이 지난 뒤 destroy를 호출합니다. destroy 뒤에는 그 인스턴스로 새 요청을 보내지 않습니다.

구성 코드가 만든 PostCountServlet 인스턴스를 ServletRegistrationBean이 등록하면 컨테이너가 init, 0회 이상의 동시 service, drain, destroy 순서를 관리합니다. 초기화 실패 인스턴스에는 service와 destroy를 호출하지 않습니다. 같은 등록 인스턴스에 요청 A와 B가 동시에 들어와도 request, response, memberId는 각 호출 범위에 두고, 공유 협력 객체만 명시적인 동시성 계약 아래 필드로 유지합니다.

REGISTRATION · INIT · CONCURRENT SERVICE · DRAIN · DESTROY

한 등록 인스턴스가 여러 요청을 함께 처리하므로, 요청 상태는 호출 안에 둔다

구성 코드가 인스턴스를 만들고 컨테이너가 생명주기를 호출합니다. 성공한 init 뒤 같은 등록 인스턴스에 service가 0회 이상, 때로는 동시에 들어오며, 제거 시에는 실행 중 호출을 정리한 뒤 destroy로 끝납니다.

Servlet 등록 인스턴스의 상태와 동시 요청 소유권 등록, 초기화, 서비스, 드레이닝, 소멸 상태를 왼쪽에서 오른쪽으로 보여 줍니다. 초기화 실패는 서비스와 소멸 없이 해제되는 별도 경로입니다. 서비스 상태에는 같은 인스턴스에 겹쳐 실행되는 요청 A와 B를 표시하고, 아래에는 인스턴스 필드, 요청 범위, 금지할 공유 상태를 구분합니다. NEW SUCCESS REMOVE DRAIN service(req_i, res_i) · 0..N INIT THROWS REGISTERED 구성 코드가 생성 instance · /raw/post-count INITIALIZING container init loadOnStartup=1 · exactly once SERVING · SAME REGISTRATION INSTANCE 동시 service 호출 Worker A req_A · res_A · memberId=7 Worker B req_B · res_B · memberId=9 DRAINING 새 요청 중지 완료 또는 server limit DESTROYED destroy() 이후 service 없음 RELEASED no service · no destroy OWNERSHIP DURING SERVING INSTANCE FIELD final PostQuery query 참조 고정 ≠ thread safety 협력 객체가 stateless · immutable 또는 concurrent-safe인지 별도 확인 REQUEST CALL SCOPE A와 B의 값은 각각 지역 소유 request · response · memberId · count 현재 동기 dispatch 안에서만 사용 status · header는 commit 전에 결정 DO NOT SHARE REQUEST STATE currentMemberId · req/res 보관 금지 mutable field는 호출 사이에 덮어쓰기 doGet 전체 synchronized는 요청 직렬화 공유 상태 제거가 우선

01 · CREATE / REGISTER

구성 코드가 인스턴스를 만들고 컨테이너에 등록한다

ServletRegistrationBean이 인스턴스와 /raw/post-count를 연결합니다. 이후 init, service, destroy 호출은 컨테이너가 관리합니다.

02 · LIFECYCLE

init 성공 뒤 service 0..N, 제거 때 drain 뒤 destroy

초기화 실패 인스턴스에는 servicedestroy가 없습니다. 정상 제거에서는 실행 중 호출의 완료 또는 서버 제한 시간을 기다리고, destroy 뒤에는 요청을 보내지 않습니다.

03 · CONCURRENT SERVICE

같은 등록 인스턴스에도 A와 B의 요청 값은 각각 지역 소유다

Servlet과 공유 협력 객체는 함께 쓰지만 request, response, memberId, 계산 결과는 각 호출 안에 둡니다. async는 별도의 수명·동시성 계약이 필요합니다.

04 · REMOVE STATE FIRST

final 참조와 전체 동기화는 공유 객체를 자동으로 안전하게 만들지 않는다

협력 객체 자체의 동시 접근 계약을 확인하고 요청별 mutable field를 제거합니다. 전체 doGet 동기화는 이 인스턴스의 요청을 직렬화하며, destroy는 강제 종료 내구성 보장이 아닙니다.

범위는 이 비분산 Servlet 등록의 한 인스턴스입니다. 같은 클래스를 여러 이름으로 등록하거나 분산 배포하면 인스턴스 수가 달라질 수 있습니다. Request/Response의 async 수명은 startAsyncAsyncContext.complete 규칙을 별도로 따라야 합니다.


URL 매핑과 HTTP 메서드는 서로 다른 선택입니다

먼저 Servlet 컨테이너가 URL 패턴으로 Servlet 등록을 선택합니다.

/raw/post-count는 기본 / 매핑의 DispatcherServlet보다 구체적이므로 원시 Servlet이 선택됩니다.

그 다음 HttpServlet.service가 HTTP 메서드에 맞는 doGet, doPost, doPut, doDelete, doPatch 등의 콜백으로 디스패치합니다.

애플리케이션은 필요한 콜백만 재정의합니다.

아래 예제의 PostCountServletdoGet만 재정의하므로 GET은 게시글 수를 반환하고, HTTP/1.1 POST는 상속한 기본 doPost에서 405 Method Not Allowed가 됩니다. Servlet 6.1 기본 구현은 HTTP/1.0 요청에는 400 Bad Request를 사용하므로 "미재정의 메서드는 언제나 405"라고 일반화하면 안 됩니다.

Servlet 컨테이너는 먼저 URL 패턴으로 등록을 선택합니다. 정확한 /raw/post-count 매핑이면 postCountServlet으로 보내고, 기본 / 매핑이면 DispatcherServlet 경계로 보냅니다. 선택된 PostCountServlet 안에서는 HttpServlet.service가 HTTP 메서드를 판별합니다. GET은 재정의한 doGet과 PostQuery를 거쳐 200 text/plain이 되고, 재정의하지 않은 HTTP/1.1 POST는 상속한 doPost에서 405가 됩니다.

URL MAPPING FIRST · HTTP METHOD DISPATCH SECOND

컨테이너가 Servlet을 고르고, 선택된 HttpServlet이 doXxx를 고른다

두 선택을 한 단계로 섞지 않습니다. URL의 가장 구체적인 등록으로 실행 인스턴스를 정한 뒤, 그 인스턴스의 HttpServlet.service가 GET·POST 같은 메서드 콜백을 선택합니다.

Servlet URL 매핑과 HttpServlet 메서드 디스패치 흐름 GET raw post-count 요청에서 컨테이너의 URL 매핑, raw Servlet 또는 DispatcherServlet 선택, HttpServlet service의 GET 또는 POST 분기, doGet 성공 응답과 상속한 doPost의 HTTP/1.1 405 응답까지를 위에서 아래로 보여 줍니다. EXACT MAPPING DEFAULT / GET POST HTTP/1.1 request GET /raw/post-count?memberId=7 Servlet container URL mapping 가장 구체적인 Servlet 등록을 먼저 선택 어느 URL pattern인가? exact /raw/post-count · default / postCountServlet 선택 HttpServlet.service(req, res) DispatcherServlet MVC 내부 선택은 ch5-9의 범위 HTTP method? service가 doXxx callback 선택 재정의한 doGet memberId 지역 값 → PostQuery.countByMember 재정의하지 않은 doPost HttpServlet의 inherited default 처리 200 · text/plain · 7:70 HTTP/1.1 · 405

01 · URL MAPPING

Servlet 컨테이너가 URL로 실행할 등록을 먼저 고른다

정확한 /raw/post-count 등록이 기본 /보다 구체적입니다. 따라서 이 요청은 postCountServlet으로 들어갑니다.

02 · METHOD DISPATCH

선택된 HttpServlet 안에서 service가 doXxx를 고른다

service(req, res)가 요청 메서드를 확인합니다. GET과 POST만의 규칙이 아니며 Servlet 6.1에는 doPatch를 포함한 다른 콜백도 있습니다.

03 · GET OVERRIDE

GET은 doGet의 지역 값과 PostQuery를 거쳐 200이 된다

memberId=7을 현재 호출의 지역 값으로 변환하고, 공유 협력 객체의 동시 접근 계약 아래 게시글 수를 조회해 text/plain 응답을 씁니다.

04 · INHERITED DEFAULT

미재정의 HTTP/1.1 POST는 상속한 doPost에서 405가 된다

HTTP/1.0에서는 기본 응답이 400이므로 프로토콜 조건을 함께 읽습니다. 기본 / 분기는 DispatcherServlet 경계까지만 표시하고 MVC 내부는 뒤 단원으로 넘깁니다.

HttpServlet.service의 전체 분기표가 아니라 이 예제의 GET과 POST 경로입니다. HEAD는 기본적으로 doHead를 거쳐 doGet과 연결될 수 있고, OPTIONS·TRACE·PUT·DELETE·PATCH도 각각의 Servlet 6.1 규칙을 따릅니다.


실제 embedded Tomcat에서 계약을 검증합니다

다음 파일은 production 예제의 Servlet과 등록 구성, 실제 loopback 검증을 한 compilation unit에 둔 test fixture입니다.

Spring mock 객체에서 init → service → destroy를 직접 호출하지 않습니다. Boot 4.1.1이 random port로 Tomcat을 열고, JDK HttpClient127.0.0.1로 실제 HTTP/1.1 요청을 보냅니다.

src/test/java/board/servlet/EmbeddedServletLifecycleTest.java
package board.servlet;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.Test;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.web.server.servlet.context
        .ServletWebServerApplicationContext;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.context.annotation.Bean;
class EmbeddedServletLifecycleTest {
    private static final HttpClient CLIENT = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(5))
            .version(HttpClient.Version.HTTP_1_1)
            .build();
    @Test
    void loadOnStartup은_첫_요청_전에_한_인스턴스를_초기화한다() {
        var probe = new LifecycleProbe();
        try (var server = RunningServer.start(probe)) {
            var registration = server.context()
                    .getServletContext()
                    .getServletRegistration("postCountServlet");
            assertNotNull(registration);
            assertAll(
                    () -> assertEquals(
                            Set.of("/raw/post-count"),
                            Set.copyOf(registration.getMappings())),
                    () -> assertEquals(1, probe.initCalls.get()),
                    () -> assertEquals(0, probe.getCalls.get()),
                    () -> assertEquals(1, probe.servletIdentities.size()),
                    () -> assertEquals(0, probe.destroyCalls.get()));
        }
        assertAll(
                () -> assertEquals(1, probe.destroyCalls.get()),
                () -> assertEquals(
                        List.of("init", "destroy"),
                        List.copyOf(probe.events)));
    }
    @Test
    void service는_GET을_doGet으로_보내고_HTTP1_1_POST는_405로_응답한다()
            throws Exception {
        var probe = new LifecycleProbe();
        try (var server = RunningServer.start(probe)) {
            var get = send(
                    server,
                    "GET",
                    "/raw/post-count?memberId=7");
            var post = send(server, "POST", "/raw/post-count");
            assertAll(
                    () -> assertEquals(200, get.statusCode()),
                    () -> assertEquals("7:70", get.body()),
                    () -> assertTrue(get.headers()
                            .firstValue("content-type")
                            .orElseThrow()
                            .startsWith("text/plain")),
                    () -> assertEquals(405, post.statusCode()),
                    () -> assertEquals(1, probe.getCalls.get()),
                    () -> assertEquals(1, probe.servletIdentities.size()));
        }
    }
    @Test
    void 같은_인스턴스가_두_GET을_동시에_처리해도_지역_값은_섞이지_않는다()
            throws Exception {
        var probe = new LifecycleProbe();
        try (var server = RunningServer.start(probe);
                var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            var first = executor.submit(() -> send(
                    server,
                    "GET",
                    "/raw/post-count?memberId=7&hold=true"));
            var second = executor.submit(() -> send(
                    server,
                    "GET",
                    "/raw/post-count?memberId=9&hold=true"));
            try {
                assertTrue(probe.concurrentEntries.await(5, SECONDS));
                assertAll(
                        () -> assertEquals(2, probe.activeCalls.get()),
                        () -> assertEquals(2, probe.maxActiveCalls.get()));
            } finally {
                probe.releaseConcurrentCalls.countDown();
            }
            var firstResponse = first.get(5, SECONDS);
            var secondResponse = second.get(5, SECONDS);
            assertAll(
                    () -> assertEquals(
                            Set.of("7:70", "9:90"),
                            Set.of(
                                    firstResponse.body(),
                                    secondResponse.body())),
                    () -> assertEquals(2, probe.requestIdentities.size()),
                    () -> assertEquals(2, probe.responseIdentities.size()),
                    () -> assertEquals(1, probe.servletIdentities.size()),
                    () -> assertEquals(0, probe.activeCalls.get()));
        }
    }
    @Test
    void context를_닫으면_마지막_service_뒤_destroy를_한_번_호출한다()
            throws Exception {
        var probe = new LifecycleProbe();
        HttpResponse<String> response;
        try (var server = RunningServer.start(probe)) {
            response = send(
                    server,
                    "GET",
                    "/raw/post-count?memberId=11");
        }
        assertAll(
                () -> assertEquals(200, response.statusCode()),
                () -> assertEquals(1, probe.destroyCalls.get()),
                () -> assertEquals(
                        List.of("init", "get:11", "destroy"),
                        List.copyOf(probe.events)));
    }
    private static HttpResponse<String> send(
            RunningServer server,
            String method,
            String path)
            throws IOException, InterruptedException {
        var request = HttpRequest.newBuilder(
                        URI.create(server.origin() + path))
                .timeout(Duration.ofSeconds(5))
                .method(method, HttpRequest.BodyPublishers.noBody())
                .build();
        return CLIENT.send(
                request,
                HttpResponse.BodyHandlers.ofString(UTF_8));
    }
    private record RunningServer(
            ServletWebServerApplicationContext context,
            String origin)
            implements AutoCloseable {
        static RunningServer start(LifecycleProbe probe) {
            var application = new SpringApplication(TestApplication.class);
            application.setWebApplicationType(WebApplicationType.SERVLET);
            application.setRegisterShutdownHook(false);
            application.setDefaultProperties(Map.of(
                    "server.address", "127.0.0.1",
                    "server.port", "0",
                    "spring.main.banner-mode", "off",
                    "logging.level.root", "OFF"));
            application.addInitializers(context -> context
                    .getBeanFactory()
                    .registerSingleton("lifecycleProbe", probe));
            var context = (ServletWebServerApplicationContext)
                    application.run();
            var origin = "http://127.0.0.1:"
                    + context.getWebServer().getPort();
            return new RunningServer(context, origin);
        }
        @Override
        public void close() {
            context.close();
        }
    }
    @SpringBootConfiguration(proxyBeanMethods = false)
    @EnableAutoConfiguration
    static class TestApplication {
        @Bean
        ServletRegistrationBean<PostCountServlet> postCountServlet(
                LifecycleProbe probe) {
            var servlet = new PostCountServlet(
                    probe,
                    memberId -> memberId * 10);
            var registration = new ServletRegistrationBean<>(
                    servlet,
                    "/raw/post-count");
            registration.setName("postCountServlet");
            registration.setLoadOnStartup(1);
            return registration;
        }
    }
    @FunctionalInterface
    interface PostQuery {
        long countByMember(long memberId);
    }
    static final class PostCountServlet extends HttpServlet {
        private final LifecycleProbe probe;
        private final PostQuery query;
        PostCountServlet(
                LifecycleProbe probe,
                PostQuery query) {
            this.probe = probe;
            this.query = query;
        }
        @Override
        public void init() {
            probe.initCalls.incrementAndGet();
            probe.servletIdentities.add(
                    System.identityHashCode(this));
            probe.events.add("init");
        }
        @Override
        protected void doGet(
                HttpServletRequest request,
                HttpServletResponse response)
                throws IOException, ServletException {
            probe.getCalls.incrementAndGet();
            probe.servletIdentities.add(
                    System.identityHashCode(this));
            probe.requestIdentities.add(
                    System.identityHashCode(request));
            probe.responseIdentities.add(
                    System.identityHashCode(response));
            var rawMemberId = request.getParameter("memberId");
            final long memberId;
            try {
                memberId = Long.parseLong(rawMemberId);
            } catch (NumberFormatException exception) {
                response.sendError(
                        HttpServletResponse.SC_BAD_REQUEST,
                        "memberId must be an integer");
                return;
            }
            probe.events.add("get:" + memberId);
            var active = probe.activeCalls.incrementAndGet();
            probe.maxActiveCalls.accumulateAndGet(active, Math::max);
            try {
                if ("true".equals(request.getParameter("hold"))) {
                    probe.concurrentEntries.countDown();
                    if (!probe.releaseConcurrentCalls.await(5, SECONDS)) {
                        response.sendError(
                                HttpServletResponse.SC_SERVICE_UNAVAILABLE,
                                "concurrency gate timed out");
                        return;
                    }
                }
                var count = query.countByMember(memberId);
                response.setStatus(HttpServletResponse.SC_OK);
                response.setContentType("text/plain");
                response.setCharacterEncoding(UTF_8);
                response.getWriter().write(memberId + ":" + count);
            } catch (InterruptedException exception) {
                Thread.currentThread().interrupt();
                throw new ServletException(
                        "request was interrupted",
                        exception);
            } finally {
                probe.activeCalls.decrementAndGet();
            }
        }
        @Override
        public void destroy() {
            probe.destroyCalls.incrementAndGet();
            probe.events.add("destroy");
        }
    }
    static final class LifecycleProbe {
        final AtomicInteger initCalls = new AtomicInteger();
        final AtomicInteger getCalls = new AtomicInteger();
        final AtomicInteger destroyCalls = new AtomicInteger();
        final AtomicInteger activeCalls = new AtomicInteger();
        final AtomicInteger maxActiveCalls = new AtomicInteger();
        final Set<Integer> servletIdentities =
                ConcurrentHashMap.newKeySet();
        final Set<Integer> requestIdentities =
                ConcurrentHashMap.newKeySet();
        final Set<Integer> responseIdentities =
                ConcurrentHashMap.newKeySet();
        final ConcurrentLinkedQueue<String> events =
                new ConcurrentLinkedQueue<>();
        final CountDownLatch concurrentEntries =
                new CountDownLatch(2);
        final CountDownLatch releaseConcurrentCalls =
                new CountDownLatch(1);
    }
}

네 테스트가 각각 증명하는 경계는 다음과 같습니다.

  1. loadOnStartup=1인 등록은 첫 요청 전 init을 한 번 호출하고 정확한 URL 매핑을 갖습니다.
  2. 실제 HTTP/1.1 GET은 doGet으로, 미재정의 POST는 기본 doPost의 405로 갑니다.
  3. 두 Tomcat worker가 같은 Servlet 인스턴스에 동시에 들어와도 memberId와 request/response는 요청별로 분리됩니다.
  4. 정상적인 application context 종료는 마지막 완료된 요청 뒤 destroy를 한 번 호출합니다.

setLoadOnStartup(1)1은 단순 boolean이 아니라 시작 순서 값입니다. ServletRegistrationBean에 음수를 주면 지연 초기화를 허용하고, 0 이상이면 시작 시 초기화합니다.

테스트는 정상 종료의 콜백을 검증할 뿐, 프로세스 강제 종료나 전원 장애에서도 destroy가 실행된다고 증명하지 않습니다.


공유 인스턴스에는 요청 상태를 저장하지 않습니다

동시에 들어온 두 요청은 PostCountServlet 인스턴스와 query 필드를 공유합니다.

반면 request, response, rawMemberId, memberId, count는 각 doGet 호출의 파라미터와 지역 변수입니다.

요청 A가 memberId=7을 읽은 뒤 멈추고 요청 B가 memberId=9를 읽어도 서로 덮어쓸 필드가 없습니다.

currentMemberId 같은 mutable instance field를 추가하면 호출 사이에 값이 섞일 수 있습니다. doGet 전체를 synchronized로 막으면 이 등록의 요청을 직렬화하므로, 요청 상태를 없애는 것이 우선입니다.

final PostQuery queryfinal은 참조 재할당만 막습니다. 실제 협력 객체도 stateless, immutable 또는 명시적으로 thread-safe여야 합니다.

대상기본 소유 범위안전한 규칙
Servlet 인스턴스이 등록의 service 수명요청별 mutable 상태를 필드에 두지 않음
공유 협력 객체여러 service 호출불변 또는 동시 접근 계약을 확인
Request·Response현재 동기 dispatch현재 호출 범위에서만 사용하고 다른 스레드에 넘기지 않음
요청 파라미터·계산값현재 service 호출파라미터와 지역 변수에 유지

Request와 Response 구현은 thread-safe가 보장되지 않으며 컨테이너가 재사용할 수 있습니다.

비동기 처리는 예외입니다. request.startAsync()가 성공하면 객체 수명이 service 반환 뒤 AsyncContext.complete()까지 연장되지만, 그것만으로 여러 스레드의 임의 접근이 안전해지는 것은 아닙니다. timeout과 complete를 포함한 별도 소유권 규칙이 필요합니다.

응답 상태·헤더는 응답이 커밋되기 전에 결정합니다. 본문을 일부 전송한 뒤에는 정형 오류 응답으로 바꾸기 어렵습니다. 입력 스트림과 응답 커밋의 상세 규칙은 이어지는 요청·응답 단원에서 다룹니다.


연습 문제

fixture의 PostCountServletprivate long currentMemberId 필드를 추가하고, memberId를 그 필드에 쓴 뒤 concurrency gate를 통과하도록 바꿔 보세요.

두 응답이 섞이는 실패를 재현한 다음 필드를 제거해 세 번째 테스트를 다시 통과시키세요.

추가로 memberId=abcmemberId 누락 요청이 400인지 실제 loopback 테스트를 작성하고, 검증을 본문 쓰기 전에 끝내세요.

다음 문서에서는 같은 HttpServletRequest에서 쿼리 파라미터, 폼 필드, JSON 본문이 서로 다른 API와 프레이밍을 거치는 이유를 읽습니다.