(Mis-)Use Spring 프록시 매직을 이용해 Http request를 비즈니스 레이어에 주입하기 - 해야 할까?

발행: (2026년 2월 1일 오후 11:22 GMT+9)
8 min read
원문: Dev.to

Source: Dev.to

(위에 제공된 소스 링크 외에 번역할 텍스트가 없습니다. 번역이 필요한 본문을 제공해 주시면 한국어로 번역해 드리겠습니다.)

싱글톤 안에 요청‑스코프 빈 – 왜 어색한가

몇 주 전, Spring Boot 코드베이스를 검토하던 중 HttpServletRequest 를 싱글톤 빈의 필드에 주입한 서비스를 발견했습니다. 이 빈은 서비스 레이어에서 도메인 이벤트를 생성하고 발행하는 데 호출되며, 이벤트에는 들어오는 요청의 정보가 필요합니다.

@Component
public class EventFactory {

    @Autowired
    private HttpServletRequest request;

    public void prepareAndSendEvent() {
        OrderEvent ordEvent = new OrderEvent();
        ordEvent.setSentClientIP(request.getRemoteAddr());
    }
}

겉보기엔 코드가 동작 하고 편리해 보이지만, 요청‑스코프와 관련된 내용이 조용히 싱글톤 빈 안에 살아 있고, 전송‑계층 추상화가 비즈니스 로직으로 그대로 새어 나왔습니다. 이 작은 설계 선택은 Spring의 마법이 건축적 경계를 얼마나 쉽게 흐리게 만들 수 있는지에 대한 더 큰 대화를 열어줍니다.

왜 이게 어색하게 느껴지는가?

  • 빈 스코프가 맞지 않는다.
    EventFactory싱글톤 – Spring이 애플리케이션 시작 시 한 번 생성하고 모든 HTTP 요청에 대해 동일한 인스턴스를 재사용합니다.
    HttpServletRequest요청‑스코프 – 들어오는 요청마다 새로운 인스턴스가 생성됩니다.

  • Spring이 어떻게 요청‑스코프 빈을 싱글톤에 주입할 수 있을까?
    시작 시점에는 HTTP 요청이 없지만, Spring은 스코프 프록시를 주입해 런타임에 실제 요청 객체로 위임합니다.

스코프 차이를 연결하는 방법

Spring이 싱글톤 안에 요청‑스코프 의존성을 발견하면, 해당 의존성에 대해 프록시를 생성합니다. 프록시는 현재 HttpServletRequestThreadLocal(또는 최신 버전에서는 스코프 컨텍스트)에서 가져오는 ObjectFactory에 대한 참조를 보유하고 있다가, 프록시 메서드가 호출될 때마다 이를 조회합니다.

따라서 prepareAndSendEvent()request.getRemoteAddr()에 접근하면, 프록시는 현재 요청을 찾아 호출을 전달합니다. 이것이 Spring이 스코프 불일치를 처리하는 표준 방식입니다.

왜 이것이 냄새가 나는가?

Spring의 “스레드당 하나의 요청” 모델과 @Component, @Service, @Repository 같은 스테레오타입 어노테이션은 3계층 아키텍처를 쉽게 구현하도록 돕습니다.

  1. 컨트롤러 계층 – HTTP 요청을 받아 DTO를 만들고 서비스 계층에 위임합니다.
  2. 서비스 계층 – 상태 없는 비즈니스 로직을 담고 DTO만 사용합니다.
  3. 리포지토리/게이트웨이 계층 – 영속성 또는 외부 시스템과 상호작용합니다.

HttpServletRequest를 서비스(또는 어떤 비즈니스 빈) 안에 직접 주입하면 관심사의 분리가 깨집니다. 전송 계층이 비즈니스 계층으로 새어 들어와 코드가 이해하기도, 테스트하기도, 확장하기도 어려워집니다.

우리가 치르는 대가는 아주 작은 편리함 때문에 깨끗한 코드와 계층형 아키텍처가 조용히 무너지는 것입니다.

이것이 초래할 수 있는 문제

1. 비동기 실행에서 무너지기

프록시는 동기적으로 같은 스레드에서 호출될 때 ThreadLocal에 저장된 요청‑스코프 객체를 찾아 정상 동작합니다. 하지만 메서드가 비동기(예: @Async, CompletableFuture, 작업 실행기)로 실행되면 ThreadLocal이 존재하지 않아 IllegalStateException이 발생합니다.

2. 테스트 복잡도 증가

EventFactory를 단위 테스트하려면 HttpServletRequest를 모킹해야 합니다. 요청 객체 모킹은 보통 컨트롤러 테스트에서만 사용되며, 순수 비즈니스 로직 테스트에서는 불필요한 플러밍에 집중하게 만들어 테스트 가치를 떨어뜨립니다.

권장 접근법

필요한 요청 데이터를 비즈니스 계층 밖에서 추출하고 메서드 인자(또는 전용 DTO)로 전달합니다. 이렇게 하면 비즈니스 빈이 HTTP 계층을 전혀 알 필요가 없습니다.

@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderService orderService;

    @GetMapping
    public List getOrders(HttpServletRequest request) {
        // ...
    }
}

Source:

String clientIp = request.getRemoteAddr();
        return orderService.getOrders(clientIp);
    }
}
@Service
public class OrderService {

    private final EventFactory eventFactory;

    public List getOrders(String clientIp) {
        // business logic …
        eventFactory.prepareAndSendEvent(clientIp);
        // …
    }
}
@Component
public class EventFactory {

    public void prepareAndSendEvent(String clientIp) {
        OrderEvent ordEvent = new OrderEvent();
        ordEvent.setSentClientIP(clientIp);
        // publish event …
    }
}

Now:

  • Scope boundaries are respected – 서비스 레이어는 순수 데이터만 받습니다.
  • Asynchronous execution is safe – 숨겨진 ThreadLocal 의존성이 없습니다.
  • Unit tests stay simpleEventFactory를 서블릿 API 목 없이도 테스트할 수 있습니다.

Visual Summary

Layered architecture diagram

TL;DR

  • 요청 범위 빈(HttpServletRequest)을 싱글톤에 주입하는 것은 Spring의 스코프 프록시 덕분에 기술적으로 가능하지만 계층 구조를 위반합니다.
  • 숨겨진 런타임 의존성을 만들고, 비동기 실행으로 옮길 때 깨지며, 단위 테스트를 어렵게 합니다.
  • 필요한 데이터를 컨트롤러(또는 전용 인터셉터)에서 추출해 서비스 계층에 명시적으로 전달하십시오. 이렇게 하면 코드가 깔끔하고 테스트하기 쉬우며 미래에도 견고합니다.

코드 예시

@RequestHeader("X-Tenant-Id") String tenantId) {

    String userAgent = request.getHeader("User-Agent");
    String clientIp = request.getRemoteAddr();

    RequestContext ctx = new RequestContext(tenantId, clientIp);

    return orderService.getOrders(ctx);
}
}
@Component
public class EventFactory {

    public void prepareAndSendEvent(EventInfo eventInfo, RequestContext requestContext) {
        // requestContext object has the needed information 
    }

}

요약

요청 범위의 HttpServletRequest를 비즈니스 계층에 주입하는 것은 Spring의 마법 덕분에 작동하지만, 숨겨진 비용과 위험을 수반합니다. 더 깔끔한 대안이 있을 때 이를 사용하지 않는 것은 개발자의 책임입니다.

Back to Blog

관련 글

더 보기 »

Java 가상 스레드 — 빠른 가이드

Java Virtual Threads — 빠른 가이드 Java 21+ · Spring Boot 3.2+ · Project Loom Java Virtual Threads에 대한 간결하고 실무 중심의 가이드 — 무엇이며, 어떻게…

Unity에 맞춘 MVP 아키텍처

소개 이 기사에서는 Unity 게임에 MVP Model–View–Presenter 아키텍처를 도입한 방법을 설명합니다. 전체 구조를 살펴보고, 주요 내용을 설명합니다.

SPRING BOOT 예외 처리

Java & Spring Boot 예외 처리 노트 1. Exception이란? Exception = 프로그램의 정상 흐름을 방해하는 원하지 않는 상황. 예외 처리의 목표...