톰캣이 하는 일: 서블릿 컨테이너부터 요청 처리 흐름까지 이해하기
빠른 답
- 톰캣은 HTTP 요청을 받아 서블릿 코드로 연결하고 응답까지 돌려주는 자바 웹 실행 환경이다.
- 핵심 흐름은 요청 수신, URL 매핑, 필터 실행, 서블릿 호출, 응답 반환 순서로 이해하면 된다.
- 서블릿은 매 요청마다 새로 만들어지는 것이 아니라 컨테이너가 생명주기를 관리한다.
- 실무에서는 포트 설정, 스레드 풀, 배포 경로, 로그 확인 포인트를 함께 알아야 운영이 쉬워진다.
목차
흐름으로 보기
톰캣을 이해할 때는 이 다섯 단계를 먼저 잡는 것이 가장 좋습니다. 브라우저나 API 클라이언트가 보낸 요청은 톰캣의 포트로 들어오고, 톰캣은 어떤 애플리케이션과 어떤 서블릿이 이 요청을 처리해야 하는지 찾습니다. 그다음 필터 같은 공통 처리 단계를 거쳐 실제 자바 코드가 실행되고, 최종 결과가 다시 HTTP 응답으로 나갑니다.
스프링 부트를 쓰더라도 이 흐름이 사라지는 것은 아닙니다. 눈앞에 보이는 코드가 @RestController로 바뀔 뿐, 아래쪽에서는 여전히 톰캣이 요청을 받고 서블릿 체인으로 넘겨주는 구조가 유지됩니다.
톰캣은 무엇이고 왜 자바 웹 개발에서 자주 등장할까
톰캣은 흔히 WAS라고 부르지만, 실제로는 HTTP 서버 기능과 서블릿 컨테이너를 함께 제공하는 자바 웹 실행 환경이라고 보는 편이 더 정확합니다. 핵심 역할은 단순합니다. HTTP 요청을 읽고, 그 요청을 처리할 자바 웹 컴포넌트를 찾아 실행한 뒤, 결과를 다시 HTTP 응답으로 포장해 돌려보냅니다.
정적 파일만 내려주는 서버와 비교하면 차이가 분명해집니다. /logo.png 같은 요청은 파일 응답으로 끝날 수 있지만, /users, /orders, /login 같은 요청은 서버 쪽 코드 실행이 필요합니다. 톰캣은 이런 동적 요청을 서블릿이나 프레임워크 진입점으로 연결해 주는 역할을 맡습니다.
자바 웹 개발에서 톰캣이 자주 등장하는 이유도 여기 있습니다.
- 서블릿과 JSP 실행 기반을 제공합니다.
- 표준 자바 웹 구조와 잘 맞습니다.
- 스프링 MVC, 스프링 부트 같은 프레임워크가 이 위에서 동작합니다.
- 외장 설치형과 내장형 모두 널리 쓰여 학습 자료와 운영 경험이 많습니다.
즉, 톰캣은 “자바 웹 로직을 실행해 주는 무대”에 가깝습니다. 비즈니스 로직을 대신 만들어 주지는 않지만, 요청과 응답이 오가는 전체 실행 사이클을 책임집니다.
브라우저 요청이 톰캣 안에서 처리되는 전체 흐름
브라우저가 http://localhost:8080/users로 요청을 보내면 먼저 톰캣의 커넥터가 연결을 받습니다. 여기서 요청 라인, 헤더, 바디가 읽히고, 내부적으로 HttpServletRequest, HttpServletResponse 같은 객체가 준비됩니다.
그다음 톰캣은 요청이 어느 애플리케이션으로 가야 하는지, 어느 URL 패턴과 매칭되는지 확인합니다. 내부적으로는 Engine -> Host -> Context -> Wrapper 같은 구조를 따라 대상 서블릿을 찾습니다. 이 단계에서 경로를 못 찾으면 404가 나고, HTTP 메서드가 맞지 않으면 405가 발생하기 쉽습니다.
매핑이 끝나면 요청은 필터 체인을 통과합니다. 필터는 인증 확인, 문자 인코딩 설정, 공통 로깅, CORS 헤더 추가, 응답 시간 측정 같은 공통 처리를 넣는 위치입니다. 필터가 chain.doFilter()를 호출해야 다음 단계로 넘어가며, 여기서 요청을 끊어버리면 서블릿까지 도달하지 못합니다.
그 후에야 실제 서블릿의 service()가 호출되고, HTTP 메서드에 따라 doGet(), doPost() 같은 메서드로 분기됩니다. 서블릿이 상태 코드, 헤더, 응답 본문을 채우면 톰캣이 이를 소켓으로 다시 내보냅니다. 이때 응답 버퍼가 커밋된 뒤에는 상태 코드나 헤더를 바꾸기 어렵기 때문에, 예외 처리 시점도 중요합니다.
이 흐름을 알면 장애를 볼 때도 판단이 빨라집니다.
- 요청이 톰캣까지 오지 않았다면 네트워크, 포트, 프록시 문제일 가능성이 큽니다.
- 톰캣에는 왔지만 매핑이 안 되면 URL, 컨텍스트 경로, 배포 문제일 수 있습니다.
- 서블릿 전에 실패하면 필터, 바인딩, 검증 단계부터 봐야 합니다.
- 서블릿 안에서 예외가 터지면 애플리케이션 코드와 하위 의존성을 의심해야 합니다.
서블릿 생명주기와 톰캣이 객체를 관리하는 방식
톰캣을 이해할 때 가장 중요한 개념 중 하나가 서블릿 생명주기입니다. 서블릿은 요청마다 새 객체가 만들어지는 구조가 아닙니다. 보통 톰캣이 애플리케이션 시작 시점이나 첫 요청 시점에 서블릿 인스턴스를 만들고 init()을 한 번 호출한 뒤, 여러 요청에서 같은 인스턴스를 재사용합니다. 애플리케이션이 내려갈 때는 destroy()를 호출합니다.
실무에서 이 점이 중요한 이유는 같은 서블릿 인스턴스를 여러 스레드가 동시에 사용할 수 있기 때문입니다. 요청마다 달라지는 값을 서블릿 필드에 넣어두면 경쟁 상태가 생기기 쉽습니다. 요청별 데이터는 메서드 내부 지역 변수로 다루고, 필드에는 읽기 전용 설정이나 스레드 안전한 객체만 두는 편이 안전합니다.
간단한 서블릿은 다음처럼 작성합니다.
import java.io.IOException;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@WebServlet("/greet")
public class GreetServlet extends HttpServlet {
@Override
public void init() throws ServletException {
System.out.println("GreetServlet init");
}
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws IOException {
String name = request.getParameter("name");
if (name == null || name.isBlank()) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.setContentType("text/plain;charset=UTF-8");
response.getWriter().write("name 파라미터가 필요합니다.");
return;
}
response.setContentType("text/plain;charset=UTF-8");
response.getWriter().write("안녕하세요, " + name + "님");
}
@Override
public void destroy() {
System.out.println("GreetServlet destroy");
}
}
이 예제에서 name은 요청마다 달라지므로 메서드 내부에서만 다루고 있습니다. 반대로 private String lastName; 같은 필드를 두고 요청 값을 저장하면 다른 요청과 뒤엉킬 수 있습니다. 입문 단계에서 자주 놓치는 포인트지만, 장애와 직결되는 중요한 기본기입니다.
바인딩, 검증, 직렬화는 어디서 일어날까
서블릿만 직접 쓸 때는 요청 파라미터를 꺼내고, 형 변환하고, 검증하고, 응답 본문을 만드는 일을 개발자가 거의 직접 처리합니다. 위의 request.getParameter("name") 같은 코드가 바로 그 예입니다.
하지만 스프링 MVC를 쓰면 이 과정의 상당 부분이 자동화됩니다. 여기서 중요한 점은 톰캣이 사라지는 것이 아니라, 톰캣이 요청을 먼저 받아 스프링의 DispatcherServlet으로 넘긴다는 사실입니다. 즉, 바인딩과 검증, 직렬화는 보통 프레임워크가 담당하지만, 그 작업이 실행되는 기반 요청-응답 사이클은 여전히 톰캣 위에 있습니다.
아래 예시는 그 흐름을 잘 보여줍니다.
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/users")
public class UserController {
@PostMapping
public UserResponse create(@Valid @RequestBody UserRequest request) {
return new UserResponse(request.name(), "CREATED");
}
public record UserRequest(@NotBlank String name) {}
public record UserResponse(String name, String status) {}
}
이 코드를 호출할 때 실제 내부 흐름은 대략 다음과 같습니다.
- 톰캣이 HTTP 요청과 바디를 읽습니다.
- 요청이
DispatcherServlet으로 전달됩니다. - JSON 바디가
UserRequest객체로 바인딩됩니다. @Valid에 따라 검증이 수행됩니다.- 반환된
UserResponse가 JSON으로 직렬화됩니다. - 톰캣이 최종 응답을 클라이언트로 전송합니다.
여기서 400 에러가 났다면 컨트롤러 로직보다 먼저 바인딩이나 검증 단계에서 실패했을 가능성이 큽니다. 예를 들어 JSON 형식이 잘못됐거나 name이 비어 있으면 컨트롤러 본문에 진입하기도 전에 예외가 발생할 수 있습니다. 이 구분이 되면 “톰캣 문제인가, 스프링 문제인가, 내 코드 문제인가”를 훨씬 빨리 가를 수 있습니다.
설정 포인트로 보는 톰캣 운영 감각
개발 환경에서는 내장 톰캣 덕분에 설정이 잘 안 보이지만, 운영이나 장애 대응에서는 몇 가지 지점을 꼭 알아야 합니다. 대표적으로 자주 보는 위치는 다음과 같습니다.
conf/server.xml: 포트, 커넥터, 엔진, 호스트 설정conf/web.xml: 전역 웹 기본 설정webapps/: 배포된 애플리케이션 위치logs/: 톰캣 로그 위치bin/: 시작과 종료 스크립트
외장 톰캣을 직접 다룰 때 가장 먼저 보는 설정은 커넥터입니다.
<Connector port="8080"
protocol="HTTP/1.1"
connectionTimeout="20000"
redirectPort="8443"
maxThreads="200"
acceptCount="100" />
여기서 port는 요청을 받는 포트, connectionTimeout은 연결 대기 시간, maxThreads는 동시 요청 처리량에 직접 영향을 주는 스레드 풀 크기입니다. 요청이 몰릴 때 응답이 느려진다면 단순히 CPU만 볼 것이 아니라 스레드 수, 대기열, 느린 DB 호출, 외부 API 지연을 함께 봐야 합니다.
스프링 부트에서는 같은 개념을 더 익숙한 설정 파일로 다룹니다.
server:
port: 8080
servlet:
context-path: /api
tomcat:
threads:
max: 200
이 설정만 바뀌어도 실제 호출 URL은 크게 달라집니다. 예를 들어 컨텍스트 경로가 /api라면 /users가 아니라 /api/users로 호출해야 합니다. 개발 중 404가 나는 대표적인 이유가 바로 이 지점입니다.
추가로 실무에서 자주 챙기는 설정 포인트도 기억해 둘 만합니다.
- 포트 충돌 여부
- 컨텍스트 경로와 리버스 프록시 경로 일치 여부
- 문자 인코딩 설정
- 접근 로그와 애플리케이션 로그 분리 여부
- 세션 타임아웃과 업로드 크기 제한
- 커넥션 풀, 스레드 풀, 외부 의존성 타임아웃 균형
예외와 디버깅에서 먼저 볼 단서
톰캣 관련 문제를 볼 때는 “어디 단계에서 실패했는가”를 먼저 나눠야 합니다. HTTP 상태 코드와 로그 메시지는 그 구분에 매우 유용한 단서입니다.
404: URL 매핑 오류, 컨텍스트 경로 착각, 배포 누락405: 허용되지 않은 HTTP 메서드 호출400: 요청 파라미터 누락, 형 변환 실패, JSON 파싱 실패, 검증 실패500: 애플리케이션 내부 예외, DB 오류, 직렬화 실패, 널 포인터
예를 들어 톰캣 시작 자체가 안 된다면 포트 충돌부터 의심할 수 있습니다. 이때 로그에는 java.net.BindException: Address already in use 같은 문구가 자주 보입니다. 반대로 404라면 톰캣은 떠 있지만 요청 경로를 처리할 대상이 없다는 뜻에 가깝습니다.
실제로는 아래 정도의 명령만으로도 원인 범위를 많이 좁힐 수 있습니다.
curl -i http://localhost:8080/api/users
lsof -i :8080
tail -f logs/catalina.out
curl -i는 상태 코드와 헤더를 바로 확인할 수 있어 가장 빠른 1차 점검 도구입니다. lsof -i :8080은 누가 포트를 점유 중인지 확인할 때 유용하고, catalina.out이나 애플리케이션 로그를 함께 보면 톰캣 시작 실패인지, 매핑 실패인지, 컨트롤러 내부 예외인지 구분하기 쉬워집니다.
디버깅할 때는 다음 질문 순서로 좁혀 가는 것이 효율적입니다.
- 톰캣 프로세스가 실제로 떠 있는가
- 내가 호출한 포트와 경로가 맞는가
- 컨텍스트 경로를 빼먹지 않았는가
- 필터에서 요청을 차단하고 있지 않은가
- 바인딩이나 검증에서 먼저 실패한 것은 아닌가
- 서블릿이나 컨트롤러 안에서 예외가 난 것은 아닌가
- 톰캣 로그와 애플리케이션 로그를 모두 확인했는가
이 순서를 익혀 두면 “컨트롤러가 안 타니까 톰캣 문제겠지” 같은 막연한 추측 대신, 요청이 어느 단계까지 왔는지를 기준으로 원인을 더 빨리 좁힐 수 있습니다.
톰캣을 이해하면 스프링도 더 잘 보인다
톰캣은 단순히 오래된 자바 서버 프로그램 이름이 아닙니다. HTTP 요청을 받아 애플리케이션 코드로 연결하고, 필터와 서블릿 체인을 거쳐 응답을 다시 내보내는 실행 구조 그 자체입니다. 그래서 톰캣을 이해하면 스프링 MVC의 동작도 더 잘 보이고, 404·400·500이 각각 어디에서 생기는지도 더 선명해집니다.
특히 입문자라면 톰캣을 이렇게 기억하면 좋습니다. “이 요청은 어디로 들어왔고, 어떤 매핑을 거쳐, 어디서 검증되고, 어떤 코드가 응답을 만들었는가?” 이 질문에 답할 수 있으면 프레임워크를 바꿔도 서버 동작을 훨씬 안정적으로 이해할 수 있습니다.
원문 참고
https://www.maeil-mail.kr/question/22
댓글
댓글 쓰기