Java로 HTTP 서버 구현 - (5) URL 매핑 & 공통 로직 처리

Sunwoo Bae·2025년 3월 12일

스프링 부트

목록 보기
6/6

개요

이전 글에서, Java로 HTTP 서버 구현 - (4) 스프링을 이용해서 어떻게 객체 관리를 자동화하여 개발의 편의성 및 효율성을 높이는 지에 대해 알아 보았다.
이번 글에서는 스프링의 DispatcherServlet이 실제 HTTP 요청을 어떻게 처리하는 지, 그리고 이 방식이 왜 현대 웹에서 효율적인 구조로 채택되어 사용되는 지에 대해 알아 보려고 한다.
설명에 앞서, Servlet이란 자바에서 제공하는 HTTP 요청 처리를 위한 클래스를 말한다. 서블릿은 doGet(), doPost() 등의 메서드를 이용해서 HTTP 요청을 처리할 수 있는 인터페이스를 제공한다.
스프링 또한 서블릿을 사용하는데, DispatcherServlet이라는 하나의 서블릿을 통해 여러 컨트롤러 메서드를 연결하고, 공통 기능들을 모듈화해서 제공한다.
"바닐라 자바 서블릿으로 /auth/info/ api를 구현하는 경우"와 "스프링 DispatcherServlet을 활용하는 경우" 를 비교해서 스프링 DispatcherServlet의 동작 원리와 효율성에 대해 알아 보자.

Java Servlet으로 API 구현

먼저, /auth/info/ api 에 대해서 간단하게 정의해 보자.

  1. URL : /auth/info/{orderId}
  2. 이 API는 로그인된 사용자만 사용가능
  • Header에 로그인 시 발급한 JWT가 포함되어야 함.
  • JWT 페이로드에는 userId가 들어감.
  1. 자신의 주문에 대해서만 접근할 수 있음.
  2. 주문 정보를 DB에서 꺼내서, 주문의 이름을 반환

이 API를 구현하기 위해서, Java Servlet을 사용해 보자.
톰캣 혹은 다른 커낵터가 Servlet에게 HttpServletRequest를 잘 전달해준다고 가정하자. 해당 과정에 대해 궁금하다면 이전 글 (1), (2), (3)을 살펴보기 바란다.

AuthInfoServlet

package ioc.vanilla;

import javax.servlet.*;
import javax.servlet.http.*;
import java.io.IOException;
import java.io.PrintWriter;

// web.xml의 설정을 통해서 서블릿을 매핑
@WebServlet("/auth/info/*")
public class AuthInfoServlet extends HttpServlet {

    private final DaoService daoService = new DaoService();

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {

        // 1) URI 확인: "/Auth/info/123"에서 orderId 추출
        String orderIdStr = request.getPathInfo().substring(1); // "123"
        Long orderId = null;
        try {
            orderId = Long.parseLong(orderIdStr);
        } catch (NumberFormatException e) {
            response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid orderId format");
            return;
        }

        // 2) JWT 토큰 검증 JWT 페이로드에서는 userId가 들어간다고 가정. 
        String authHeader = request.getHeader("Authorization"); // e.g. "Bearer JWT_TOKEN"
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Missing or invalid Authorization header");
            return;
        }
        String jwt = authHeader.substring(7); // "SOME_JWT_TOKEN"
        // 여기서 JWT 검증 로직을 간단히 가정: 유효하면 userId 반환
        Long userId = TokenUtil.verifyTokenAndGetUserId(jwt);
        if (userId == null) {
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid token");
            return;
        }

        // 3) userId와 orderId 매칭 검증 (DaoService)
        // DaoService는 잘 동작한다고 가정. 
        boolean isOwned = daoService.checkOrderOwner(userId, orderId);
        if (!isOwned) {
            response.sendError(HttpServletResponse.SC_FORBIDDEN, "You do not own this order");
            return;
        }

        // 4) Order 이름 조회
        String orderName = daoService.getOrderName(orderId);
        if (orderName == null) {
            response.sendError(HttpServletResponse.SC_NOT_FOUND, "Order not found");
            return;
        }

        // 5) 정상 응답
        response.setStatus(HttpServletResponse.SC_OK);
        response.setContentType("text/plain; charset=UTF-8");
        PrintWriter out = response.getWriter();
        out.println(orderName);
        out.flush();
    }
}

이 서블릿 코드는 해당 API를 다음의 과정을 통해 구현한다.

  1. URI를 확인해서 orderId를 가져옴
  2. 유저가 보낸 JWT를 확인해서, 이 유저가 로그인한 유저인지 검증
  3. user가 order에 대한 접근 권한이 있는 지를 확인한 뒤, order의 이름 반환.

실제로 API를 구현하는 서블릿을 직접 짜보면, 짜는 도중에도 여러 의아한 점들이 느껴진다.
하나의 서블릿 내부에서 인증 및 토큰 검증, DB 접근, 응답 작성이 모두 이루어지는 것이 맞는가?
로그인 인증을 수행하는 것은 공통 로직인데, 해당 로직이 서블릿마다 반복되는 것이 생산적일까?

AuthInfoServlet의 문제점

파악한 AuthInfoServlet 의 문제점을 정리하면 다음과 같다.

  1. 서블릿 내부에서의 모든 작업을 수행
  • 인증(토큰 검증), DB 접근, 응답 작성까지 모두 한 메서드에 몰려 있다.
  • 인증 로직이 바뀌어도, DB 접근 로직이 바뀌어도, 응답 작성 로직이 바뀌어도 비즈니스 로직인 서블릿을 항상 수정해야 한다.
  1. 공통 로직의 중복
  • 서블릿, 즉 API마다 JWT 검증 코드, URL 및 인자 파싱 코드, 에러 처리 코드가 매번 반복된다.

이를 해결하기 위해서 스프링은 어떤 구조로 요청을 처리하게 될까? 스프링 방식으로 해당 API를 구현하기 전에, 스프링의 DispatcherServlet이 어떻게 동작하는 지 간단하게 알아 보자.

DispatcherServlet의 동작 원리

스프링은 DispatcherServlet이라는 단 하나의 서블릿이 등록되어, 프론트 컨트롤러 역할을 한다. 프론트 컨트롤러란, 서블릿 컨테이너 맨 앞에서 애플리케이션으로 오는 모든 요청을 받아서 처리하는 컨트롤러를 의미한다.
AuthInfoServlet과 달리, 미리 Web.xml 등에 URL을 미리 정의해 둘 필요가 없이, @Controller의 메서드들 중 URL과 HTTP 메서드에 대응되는 컨트롤러 메서드를 찾아서 요청을 처리하게 된다.

org.springframework.web.servlet.DispatcherServlet#doDispatch() 기준으로, 주요 처리 흐름을 정리해 보자.

  1. 클라이언트 요청
  • 톰캣의 CoyoteAdapter는 들어온 클라이언트 요청을 HttpServletRequest로 변환하고, 이를 DispatcherServlet에 전달한다.
  1. Handler 조회
  • 스프링 컨텍스트 안에 등록된 컨트롤러 중(HandlerMapping 중)에서 해당 요청에 대응되는 적절한 Handler를 찾는다.
  1. HandlerAdapter 조회
  • 해당 Handler를 실행할 수 있는 적절한 HandlerAdapter를 찾는다.
  • 일반적으로 RequestMappingHandlerAdapter를 사용한다.
  1. (선택적) HTTP 캐시 검증
  • Last-Modified 등이 설정된 경우, 캐시 유효성을 체크해서 캐싱된 응답을 보내줄 수 있다.
  1. Interceptor 실행
  • preHandle 과정을 통해서, 개발자가 설정한 작업들을 실제로 컨트롤러 메서드를 실행하기 전에 수행 가능
  1. Handler 실행
  • 대응되는 실제 컨트롤러 메서드를 실행한다.
  • 컨트롤러 메서드에 설정한 @PathVariable, @RequestParam 등은 RequestMappingHandlerAdapterHandlerMethodArgumentResolver를 이용해서 인자를 바인딩해준다.
  • @Controller 사용시
    • ViewResolver 를 찾고 실행하며, 반환된 View의 이름에 걸맞는 View 객체를 가져온다.
    • View 에게 Model 을 전달하고, 화면 표시를 요청한 뒤, Model 데이터가 렌더링된 View 결과를, ModelAndView 를 반환.
  • @RestController 사용시
    • View , ViewResolver를 거치지 않고, Controller 로부터 반환받은 데이터를 MessageConverter 를 거쳐서 Json 형식으로 반환
  1. Interceptor 실행
  • postHandle 과정을 통해서, 컨트롤러 메서드 실행 후에 수행되어야 할 동작들을 수행
  1. 결과 처리 및 응답 전송
  • DispatcherServletHttpServletResponse를 생성하면, 톰캣 서블릿 컨테이너가 이를 네트워크로 전송한다.

동작 원리를 정리해 보았는데, 이 내용만으로는 아직 동작 과정에 대해서 감이 잡히지 않을 것이다. 간단하게 Spring 코드를 작성하고, 해당 코드를 이용해서 API를 구현한 다음, IntellijDEBUG를 찍어가면서 확인해 보자!

스프링으로 API 구현

먼저, 스프링의 초기설정을 하는 과정은 생략하겠다.
필요하다면 구글링을 통해서 스프링 부트 초기 설정 하는 과정을 찾아 보자.
또한, 이번 글에서는 로그인 과정을 구현하는 것이 본 목적이 아니므로, 로그인 과정 및 JWT 토큰 검증 등의 과정은 코드에서 생략하거나 간단하게 구현하려 한다.
JwtAuthFilter, AuthInfoController, InfoService 를 통해 API를 구현해 보자.

JwtAuthFilter

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;

@Component
public class JwtAuthFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {

        // 1. 먼저, 유저가 성공적으로 로그인을 해서 validate한 JWT가 있다고 가정하자.
        String authHeader = request.getHeader("Authorization");
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            // 이 과정에서 JWT 토큰이 없거나, 검증이 실패하면 401 에러를 반환한다.

//            // DispatcherServlet에서 401 응답을 주도록 설정

//            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Missing Authorization");
//            return;
            // 필터 레벨에서 바로 반환
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("Unauthorized");
            return;
        }

        // 2. 보통 이 과정에서 userId에 대응되는 UserDetails 를 DB 혹은 메모리에서 불러와야 하지만,
        // 이번 글에서는 로그인 과정을 구현하는 것이 목적이 아니기 때문에, 이 과정은 생략하도록 하겠다.
        String jwt = authHeader.substring(7);
        Long userId = Long.parseLong(jwt);

        // 3. JWT를 성공적으로 검증했고, userId를 이용해서 User의 정보(이름, 이메일, 권한 등)을 불러왔다면,
        // 보통 SecurityContext에 해당 정보를 담은 Authentication 객체를 저장하지만, 이번에는 USER_ID만 request에 기록해 두자.
        request.setAttribute("USER_ID", userId);

        // 4. 다음 Filter 또는 DispatcherServlet으로 진행
        filterChain.doFilter(request, response);
    }
}

필터는 톰캣 서블릿 컨테이너 레벨에서 동작하며, 스프링에서 DispatcherServlet이 요청을 처리하기 전/후로 실행되는 로직들을 말한다.
스프링에서는 클라이언트가 해당 API에 대해 접근할 수 있는지에 대한 Authentication(인증) 과정을 보통 필터에서 수행한다.
실제로 401 응답을 주는 경우에는, 해당 필터에서 바로 응답을 주는 것보다, Spring Security 로직을 이용해서 응답을 주는 것이 좋다.

AuthInfoController


import jakarta.servlet.http.HttpServletRequest;
import org.example.jdbTemplate.service.InfoService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/auth/info")
public class AuthInfoController {

    private final InfoService infoService;

    // Spring DI
    public AuthInfoController(InfoService infoService) {
        this.infoService = infoService;
    }


    // 원래는 유저의 정보나 권한을 Authentication 객체를 SecurityContext에서 불러와서
    // Customize한 UserPrincipal 등을 넣어주는 것이 일반적이지만,
    // 여기에서는 해당 내용 구현을 건너 뛰고 user_id를 request에서 가져오는 것으로 하자.
    @GetMapping("/{orderId}")
    public ResponseEntity<Object> getOrderInfo(HttpServletRequest request, @PathVariable("orderId") Long orderId) {
        return infoService.checkAuthorityAndGetOrderName((Long) request.getAttribute("USER_ID"), orderId);
    }
}

주석에도 적어 놓았는데, userId는 HttpServletRequestAttributes에 저장해 두는 것으로 구현했다.

InfoService

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
@Service
public class InfoService {

    private final DaoService daoService;

    public InfoService(DaoService daoService) {
        this.daoService = daoService;
    }

    public ResponseEntity<Object> checkAuthorityAndGetOrderName(Long userId, Long orderId) {

        // 소유주 검증
        boolean isOwned = daoService.checkOrderOwner(userId, orderId);
        if (!isOwned) {
            return ResponseEntity.status(HttpStatus.FORBIDDEN).body("user does not own order");
        }
        // DB 조회
        String orderName = daoService.getOrderName(orderId);
        return ResponseEntity.ok().body(orderName);

    }
}

InfoService에서는 userId, orderId를 DB에 접근하는 DaoService를 이용해 검증하는 과정을 거친다.

DaoService

import org.springframework.stereotype.Service;
@Service
public class DaoService {
    public boolean checkOrderOwner(Long userId, Long orderId) {
        return orderId.equals(userId);
    }
    public String getOrderName(Long orderId) {
        return "order_" + orderId;
    }
}

DaoService는 실제 DB에 접근하지는 않고, "잘 동작하는 DAO"를 가정하고 진행해 보자.

동작 과정 검증

이 API는 JWT를 통해서 유저의 인증 여부를 검증하고, 서비스 로직에서 유저의 주문 접근 여부를 확인한 다음, 유저의 주문 정보를 응답해야 한다.
크게 두 개를 체크해 보자.

  1. 토큰 검증 과정
  • 유저가 토큰을 가지고 있지 않거나, 토큰이 유효하지 않은 경우, API는 401 에러를 응답해야 한다.
  • JWT 토큰 검증 과정은 생략하였으니, 여기서는 토큰을 가지고 있지 않은 경우 401 에러를 응답하는 것으로 확인한다.
  1. 주문 정보 응답
  • 유저가 정합한 토큰을 가지고 정합한 요청을 한 경우, 주문 정보를 응답해야 한다.
  • 유저가 권한이 없는 경우, 403 에러를 응답해야 한다.

postman을 통해서 확인해 보자.

  1. 먼저, 토큰을 가지고 있지 않은 경우 API는 401을 응답해야 한다.
  • 401 응답이 오는 것을 확인할 수 있다.

2-1. 토큰을 가지고 있지만, 유저가 권한이 없는 경우, API는 403 응답을 줘야 한다.

  • 403 응답이 오는 것을 확인할 수 있다.

2-2. 토큰을 가지고 있는 경우, API는 정상 응답을 주어야 한다.

  • 정상 응답이 오는 것을 확인할 수 있다.

스프링 동작 과정 확인

자 이제, 각 상황에 대해서, IntellijDEBUG를 찍어 보면서, 스프링이 어떻게 요청을 처리하는 지를 간략하게 확인하고, 앞서 설명한 DispatcherServlet의 동작 원리처럼 동작하는 지를 확인해 보자.

JWT 토큰이 없어 필터에서 걸러지는 경우

  1. Tomcat에서 요청 처리
  • org.apache.catalina.connector.CoyoteAdapter#service() 호출
  • org.apache.catalina.core.ApplicationFilterChain#doFilter() 호출
  1. JWT 인증 필터 실행
  • JwtAuthFilter#doFilterInternal() 호출

필터에서 바로 응답을 주므로, DispatcherServlet을 거치지도 않았다.

JWT 토큰이 있지만, 유저의 권한이 없어 403 응답을 주는 경우

  1. Tomcat 내부 요청 처리 시작
  • org.apache.catalina.connector.CoyoteAdapter#service() 호출
  • org.apache.catalina.core.ApplicationFilterChain#doFilter() 호출
  1. JWT 인증 필터 실행
  • JwtAuthFilter#doFilterInternal() 호출
  1. DispatcherServlet 실행
  • DispatcherServlet#doDispatch() 호출
  • mappedHandler = this.getHandler(processedRequest); 호출
  • HandlerAdapter ha = this.getHandlerAdapter(mappedHandler.getHandler()); 호출
  1. Controller 핸들러 실행
  • AuthInfoController#getOrderInfo() 호출
  1. Service 레이어 실행
  • InfoService#checkAuthorityAndGetOrderName() 호출
  • 주문 소유주 검증에 실패해서 ResponseStatusException 발생
  1. ExceptionHandler 실행
  • DispatcherServlet#processDispatchResult()에서, 예외가 발생했음을 감지
  • HandlerExceptionResolverComposite#resolveException() 호출
  • ResponseStatusExceptionResolver#doResolveException() 호출
  1. 클라이언트 응답
  • 이후 다시 doDispatch()를 통해서 BasicErrorController를 거쳐 클라이언트에게 403 응답.

JWT 토큰이 있어서 정상 응답을 주는 경우

  1. Tomcat 내부 요청 처리 시작
  • org.apache.catalina.connector.CoyoteAdapter#service() 호출
  • org.apache.catalina.core.ApplicationFilterChain#doFilter() 호출
  1. JWT 인증 필터 실행
  • JwtAuthFilter#doFilterInternal() 호출
  1. DispatcherServlet 실행
  • DispatcherServlet#doDispatch() 호출
  • mappedHandler = this.getHandler(processedRequest); 호출
  • HandlerAdapter ha = this.getHandlerAdapter(mappedHandler.getHandler()); 호출
  1. Controller 핸들러 실행
  • AuthInfoController#getOrderInfo() 호출
  1. Service 레이어 실행
  • InfoService#checkAuthorityAndGetOrderName() 호출
  1. 클라이언트 응답
  • 이후 다시 톰캣 서블릿 컨테이너를 거쳐서 응답을 준다.

스프링에서의 이점

스프링 내부의 동작 과정을 모두 설명한 것은 아니지만, 로직이 처리되는 과정을 직접 확인하다 보면, 스프링의 이점이 확연히 드러난다.
인증 / 인가를 모듈화해서 처리할 수 있으며, 인자 파싱을 컨트롤러 메서드 안에서 구현할 필요도 없고, 예외가 발생하더라도 예외 처리 또한 모듈화해서 구현할 수 있다. 간단하게 장점을 정리해 보자.

  1. 보일러플레이트 감소
  • 보일러플레이트란 자주 쓰이는 코드 뭉치들을 말한다.
  • 스프링에서는 인증 / 인가, 인자 파싱 등을 Filter, Interceptor로 분리해서, @Service 레이어 안에서는 핵심 비즈니스 로직만 남길 수 있도록 도와준다.
  1. 관심사별 로직 분리
  • 예시에서 볼 수 있듯이, 스프링은 인증 / 인가, 예외 처리, 인자 파싱 등을 모듈화해서 각각이 분리되어 처리될 수 있도록 한다.
  • 즉, 서블릿 안에서 예외 처리에 대한 코드, 인증 처리 등을 구현하고 관리할 필요 없이 따로 분리된 모듈에서 해당 과정에 대한 고민을 할 수 있게 된다.

스프링의 응답 생성 과정 구현에 대한 설명을 마치며

이전 글 Java로 HTTP 서버 구현 - (4)과 이번 글을 통해서 스프링이 객체를 어떻게 관리하는 지, 그리고 HTTP 요청을 어떻게 처리하는 지에 대해서 알아보았다. 스프링이 HTTP 요청을 처리하는 과정에 대해서는 하나의 글로 요약하기 어려울 정도로 많은 내용이 들어 있어, 이번 글에서는 간략한 구조만 설명했지만 개괄적으로 내용을 이해하는 데에는 큰 지장이 없었을 거라 생각한다.
Spring Security와 JWT 토큰 방식의 로그인을 구현한다고 할 때, Filter, Interceptor, AOP는 각각 어떤 역할을 맡을 지에 대해서는 챕터를 분리해서 따로 글을 써볼까 한다.

다음 글에서는?

자, 이제 스프링 부트가 제공하는 세 가지 기능 - 클라이언트 요청 관리, 응답 생성 과정 구현, DB 접근 - 중에서 클라이언트 요청 관리, 응답 생성 과정 구현이 어떻게 구현되었는 지를 확인했다.
Java로 HTTP 서버 구현 - (6), (7), (8)에서는 스프링 부트에서 JPA를 사용하는 이유에 대해서 적어보려 한다.

profile
오늘보다 더 나은 내일

0개의 댓글