Spring Boot로 백엔드 개발을 시작하면 @RestController, @GetMapping 같은 애노테이션만 쓰면 API가 뚝딱 만들어진다.
근데 내부적으로 HTTP 요청이 어떻게 처리되는지 궁금하지 않았나?
그 밑바닥에 있는 게 바로 Servlet(서블릿)이다.
Servlet은 Java에서 HTTP 요청을 처리하고 응답을 돌려주는 서버 측 프로그램이다.
정확히는 Jakarta EE(구 Java EE)에서 정의한 인터페이스인 jakarta.servlet.Servlet을 구현한 클래스를 말한다.
한 줄 요약: "HTTP 요청이 들어오면 Java 코드로 처리하고 응답을 만들어주는 것"이 Servlet의 역할이다.
웹 서버는 기본적으로 정적인 파일(HTML, 이미지 등)만 서빙할 수 있다. 근데 우리가 만드는 서비스는 로그인 처리, DB 조회, 동적 데이터 응답 같은 비즈니스 로직이 필요하다.
그럼 HTTP 요청이 왔을 때 Java 코드를 실행하려면 어떻게 해야 하나? 그냥 웹 서버만으로는 안 된다. Java 코드를 실행할 수 있는 환경, 즉 Servlet Container(서블릿 컨테이너)가 필요하다. 그리고 그 컨테이너가 실행시키는 Java 클래스가 바로 Servlet이다.
Servlet이 없었다면 Spring MVC도, Spring Boot도 존재하지 않는다.
Spring의 핵심인 DispatcherServlet도 결국 Servlet을 확장한 클래스다.
브라우저에서 GET /api/users 요청을 보냈을 때 어떤 일이 벌어지는지 순서대로 보자.
[Client - 브라우저 / Postman]
|
| HTTP Request (GET /api/users)
↓
[Web Server - Nginx / Apache]
|
| 정적 파일이 아닌 경우 → WAS로 넘김
↓
[WAS - Tomcat (Servlet Container)]
|
| 1. HTTP 요청을 파싱
| 2. HttpServletRequest 객체 생성
| 3. HttpServletResponse 객체 생성
| 4. URL 패턴에 맞는 Servlet 찾아서 실행
↓
[Servlet - DispatcherServlet (Spring Boot)]
|
| 5. service() 메서드 호출
| 6. HTTP 메서드에 따라 doGet() / doPost() 호출
| 7. 비즈니스 로직 처리 (Controller → Service → Repository)
| 8. HttpServletResponse에 결과 작성
↓
[WAS - Tomcat]
|
| 9. Response를 HTTP 응답 메시지로 변환
↓
[Client]
HTTP Response (200 OK, JSON body)
핵심은 Tomcat이 HTTP 메시지를 파싱해서 Java 객체(HttpServletRequest, HttpServletResponse)로 만들어준다는 점이다.
덕분에 우리는 raw HTTP 텍스트를 직접 파싱할 필요 없이 편하게 데이터를 꺼내 쓸 수 있다.
클라이언트가 보낸 HTTP 요청 정보를 담고 있는 객체다.
request.getMethod() — HTTP 메서드 (GET, POST 등)request.getRequestURI() — 요청 URL 경로request.getParameter("name") — 쿼리 파라미터request.getHeader("Authorization") — 요청 헤더request.getInputStream() — 요청 바디 (JSON 등)request.getSession() — 세션 접근request.getCookies() — 쿠키 접근서버가 클라이언트에게 돌려줄 HTTP 응답을 담는 객체다.
response.setStatus(200) — HTTP 상태 코드 설정response.setContentType("application/json") — Content-Type 설정response.getWriter().write("...") — 응답 바디 작성response.setHeader("key", "value") — 응답 헤더 설정
Servlet의 생명주기를 관리하는 컨테이너다.
Spring Boot는 내장 Tomcat을 가지고 있어서 별도 설치 없이 java -jar로 바로 실행된다.
init())service() 호출destroy() 호출
Spring Boot에서 우리가 직접 Servlet을 작성할 일은 거의 없다.
대신 DispatcherServlet이 모든 요청의 진입점 역할을 한다.
HTTP 요청
↓
DispatcherServlet (Front Controller)
↓
HandlerMapping → 어떤 Controller 메서드를 호출할지 결정
↓
HandlerAdapter → Controller 메서드 실행
↓
@RestController의 메서드 실행
↓
MessageConverter → 반환값을 JSON으로 직렬화
↓
HttpServletResponse에 JSON 작성
↓
HTTP 응답
DispatcherServlet은 HttpServlet을 상속한 클래스다.
즉, Spring MVC는 Servlet 위에 올라간 프레임워크다.
Spring Boot가 자동으로 DispatcherServlet을 / 경로에 등록해주기 때문에
우리는 그냥 @RestController만 만들면 된다.
Spring Boot 없이 순수 Servlet만 쓰면 이렇게 된다. 얼마나 번거로운지 느껴보자.
@WebServlet("/hello")
public class HelloServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String name = request.getParameter("name"); // 쿼리 파라미터 꺼내기
response.setContentType("text/plain;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter writer = response.getWriter();
writer.write("Hello, " + name + "!");
}
}
Spring Boot를 쓰면 이렇게 간단해진다.
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello(@RequestParam String name) {
return "Hello, " + name + "!";
}
}
Spring Boot가 내부적으로 Servlet 관련 작업을 전부 추상화해주기 때문에
HttpServletRequest, HttpServletResponse를 직접 다룰 필요가 없어진다.
간혹 HttpServletRequest나 HttpServletResponse를 직접 써야 할 때도 있다.
@RestController
public class TokenController {
@PostMapping("/logout")
public ResponseEntity<Void> logout(HttpServletRequest request, HttpServletResponse response) {
// 쿠키 직접 삭제
Cookie cookie = new Cookie("refreshToken", null);
cookie.setMaxAge(0);
cookie.setPath("/");
response.addCookie(cookie);
return ResponseEntity.ok().build();
}
}
Spring Boot는 내장 Tomcat(WAS)을 포함하고 있어서 별도의 Tomcat 설치 없이 바로 실행된다. 실무에서는 앞단에 Nginx를 두고, Nginx가 요청을 내장 Tomcat으로 프록시하는 구조를 많이 사용한다.
Servlet 인스턴스는 하나만 생성되고, 여러 요청이 동시에 그 인스턴스를 공유한다.
즉, Servlet 클래스에 인스턴스 변수(상태)를 두면 동시성 문제가 생길 수 있다.
service() 메서드는 요청마다 별도 스레드에서 실행되지만, 인스턴스 자체는 공유된다는 걸 기억하자.
// 위험한 코드 — 인스턴스 변수 사용
public class BadServlet extends HttpServlet {
private int count = 0; // ← 동시 요청 시 race condition 발생!
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse res) {
count++; // 스레드 안전하지 않음
}
}
JWT 인증, CORS 처리, 요청 로깅 같은 공통 관심사는 Filter 또는 Interceptor로 분리한다.
Spring Security도 내부적으로 FilterChain을 이용해 인증/인가를 처리한다.
@Component
public class LoggingFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
System.out.println("요청 URI: " + httpRequest.getRequestURI());
chain.doFilter(request, response); // 다음 필터 or Servlet으로 넘김
}
}
application.yml에서 내장 Tomcat 관련 설정을 간단히 조정할 수 있다.
server:
port: 8080
tomcat:
threads:
max: 200 # 최대 스레드 수
min-spare: 10 # 최소 스레드 수
connection-timeout: 20000
스레드 수는 곧 동시 처리 가능한 요청 수와 직결된다. 트래픽이 많은 서비스에서는 이 값을 튜닝하는 것도 중요한 포인트다.
HttpServletRequest와 HttpServletResponse로 요청/응답을 객체로 다룬다DispatcherServlet이 모든 요청의 진입점이다@RestController는 결국 Servlet 위에서 동작하는 추상화 레이어다
Spring Boot만 쓰다 보면 Servlet이 뭔지 몰라도 개발은 된다.
근데 에러 메시지에 HttpServletRequest가 등장하거나, Spring Security 코드를 읽다 보면
결국 이 기초 개념을 모르면 막히는 순간이 온다.
직접 Servlet 코드를 짜봤더니 Spring Boot가 얼마나 많은 걸 자동으로 해주는지 체감이 됐다. 추상화의 편리함을 제대로 느끼려면 그 밑에 뭐가 있는지 알아야 한다는 걸 다시 한번 느꼈다.