⟡ 주제 정의
Spring 프로젝트에서 @RequiredArgsConstructor를 사용한 생성자 주입은 일반적인 패턴이다.
하지만 이 방식이 모든 상황에서 안전한 것은 아니다.
특히 HttpServletRequest 같은 request-scoped 빈을 주입할 때는 계층별로 다른 결과가 나타난다.
이 글에서는 Spring 공식 문서를 기반으로 실무에서 마주치는 케이스들을 분석한다.
⟡ Spring 공식 문서의 의존성 주입 방식
Spring은 세 가지 의존성 주입 방식을 제공한다.
// 1. 생성자 주입 (Constructor Injection)
private final UserRepository userRepository;
// 2. 필드 주입 (Field Injection)
@Autowired
private UserRepository userRepository;
// 3. 세터 주입 (Setter Injection)
@Autowired
public void setUserRepository(UserRepository userRepository) {
this.userRepository = userRepository;
}
Spring 공식 문서는 생성자 주입을 권장한다.
주요 이유는 불변성 보장, 순환 참조 조기 감지, 테스트 용이성이다.
@RequiredArgsConstructor는 Lombok이 제공하는 어노테이션으로,
final 필드에 대한 생성자를 자동 생성하여 생성자 주입을 간결하게 만든다.
Spring 공식 문서 - Dependency Injection
Dependency Injection :: Spring Framework
Constructor-based DI is accomplished by the container invoking a constructor with a number of arguments, each representing a dependency. Calling a static factory method with specific arguments to construct the bean is nearly equivalent, and this discussion
docs.spring.io
⟡ 일반적인 싱글톤 빈 주입 케이스

대부분의 Spring 빈은 싱글톤 스코프다. 이 경우 @RequiredArgsConstructor는 문제없이 작동한다.
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
private final AuthService authService;
}
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
}
이런 구조는 전형적인 계층형 아키텍처이며,
모든 빈이 싱글톤이므로 애플리케이션 시작 시 한 번만 생성되고 이후 재사용된다.
코드가 간결하고, final 키워드로 불변성이 보장되며, 테스트 시 Mock 객체 주입도 쉽다.

⟡ Request-scoped 빈과의 조합
문제는 HttpServletRequest 같은 request-scoped 빈을 주입할 때 발생한다.
@RestController
@RequiredArgsConstructor
public class AuthController {
private final HttpServletRequest request; // 프록시 주입됨
private final AuthService authService;
@PostMapping("/login")
public ResponseEntity<?> login() {
String clientIp = request.getRemoteAddr();
return authService.login(clientIp);
}
}
이 코드는 정상 작동한다. Spring은 HttpServletRequest를 직접 주입하는 대신 프록시 객체를 주입한다.
프록시는 실제 요청이 들어올 때마다 RequestContextHolder에서 현재 스레드의 실제 request를 찾아서 위임한다.
// Spring이 내부적으로 처리하는 방식 (의사 코드)
HttpServletRequest request = createProxy(() -> {
return RequestContextHolder.currentRequestAttributes().getRequest();
});
Controller에서는 이 패턴이 자연스럽다.
Controller는 웹 계층이고, HTTP 요청을 직접 처리하는 것이 역할이기 때문이다.
⟡ Service 계층에서의 안티패턴

문제는 Service 계층에서 HttpServletRequest를 주입받을 때 발생한다.
@Service
@RequiredArgsConstructor
public class AuthService {
private final HttpServletRequest request; // 작동은 하지만 안티패턴
public LoginResult login(String username, String password) {
String clientIp = request.getRemoteAddr();
String userAgent = request.getHeader("user-agent");
// 로그인 로직...
}
}
이 코드는 컴파일도 되고 런타임에도 정상 작동한다.
Spring이 프록시를 주입하기 때문이다. 하지만 이는 계층형 아키텍처의 원칙을 위반한다.
Controller (웹 계층 - HTTP 프로토콜 처리)
↓
Service (비즈니스 로직 계층 - HTTP 독립적이어야 함)
↓
Repository (데이터 접근 계층)
Service가 HTTP에 의존하면 다음 문제가 발생한다.
첫째
재사용성이 떨어진다. 배치 작업이나 스케줄러에서 같은 비즈니스 로직을 호출할 수 없다.
HTTP 요청이 없는 환경에서는 HttpServletRequest가 존재하지 않기 때문이다.
둘째
테스트가 복잡해진다. Service 계층만 단위 테스트하려면 HttpServletRequest를 Mock으로 만들어야 한다.
본래 Service 계층은 순수한 비즈니스 로직만 테스트해야 하는데, HTTP 계층의 Mock이 필요해진다.
셋째
의존성이 명확하지 않다. Service 메서드 시그니처만 봐서는 이 메서드가 웹 요청에 의존한다는 사실을 알 수 없다.
⟡ 개선 방안
Service에서 HTTP 정보가 필요하면, Controller에서 추출해서 파라미터로 전달한다.
@RestController
@RequiredArgsConstructor
public class AuthController {
private final HttpServletRequest request;
private final AuthService authService;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest loginReq) {
String clientIp = request.getRemoteAddr();
String userAgent = request.getHeader("user-agent");
return authService.login(
loginReq.getUsername(),
loginReq.getPassword(),
clientIp,
userAgent
);
}
}
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
public LoginResult login(String username, String password,
String clientIp, String userAgent) {
// HTTP 의존성 없이 순수 비즈니스 로직만 처리
}
}
HTTP 정보가 많아지면 DTO로 묶는다.
@Getter
@Builder
public class RequestContext {
private final String clientIp;
private final String userAgent;
private final String requestUri;
}
// Controller에서 생성해서 전달
RequestContext context = RequestContext.builder()
.clientIp(request.getRemoteAddr())
.userAgent(request.getHeader("user-agent"))
.requestUri(request.getRequestURI())
.build();
authService.login(loginReq, context);
이렇게 하면 Service는 HTTP 프로토콜에서 독립적이 되고, 배치나 스케줄러에서도 재사용할 수 있으며,
테스트 시 단순히 문자열이나 DTO만 전달하면 된다.
⟡ 순환 참조와 생성자 주입

생성자 주입의 또 다른 장점은 순환 참조를 애플리케이션 시작 시점에 감지한다는 것이다.
@Service
@RequiredArgsConstructor
public class ServiceA {
private final ServiceB serviceB;
}
@Service
@RequiredArgsConstructor
public class ServiceB {
private final ServiceA serviceA;
}
이 코드는 컴파일은 되지만 Spring 애플리케이션 시작 시 BeanCurrentlyInCreationException이 발생한다.
필드 주입(@Autowired)을 사용하면 런타임에 실제로 메서드를 호출할 때까지 발견되지 않는 문제다.
⟡ 주요 Exception 정리
@RequiredArgsConstructor 사용 시 마주치는 대표적인 Exception들을 정리한다.
BeanCurrentlyInCreationException
순환 참조가 있을 때 발생한다.
Error creating bean with name 'serviceA':
Requested bean is currently in creation: Is there an unresolvable circular reference?
발생 원인:
@Service
@RequiredArgsConstructor
public class OrderService {
private final PaymentService paymentService;
}
@Service
@RequiredArgsConstructor
public class PaymentService {
private final OrderService orderService; // 순환 참조
}
해결 방법:
첫째, 의존성 구조를 재설계한다. 보통 순환 참조는 설계 문제를 의미한다.
// 공통 로직을 별도 Service로 분리
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderValidator orderValidator;
}
@Service
@RequiredArgsConstructor
public class PaymentService {
private final OrderValidator orderValidator;
}
둘째, 불가피한 경우 @Lazy를 사용한다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final @Lazy PaymentService paymentService;
}
UnsatisfiedDependencyException
필요한 빈을 찾을 수 없거나 생성할 수 없을 때 발생한다.
Error creating bean with name 'userController':
Unsatisfied dependency expressed through constructor parameter 0
발생 원인:
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserService userService; // UserService 빈이 없음
}
// @Service 어노테이션 누락
public class UserService {
// ...
}
해결 방법:
의존하는 클래스에 적절한 Spring 어노테이션(@Service, @Component 등)이 있는지 확인한다. Component Scan 범위도 확인해야 한다.
@Service // 어노테이션 추가
public class UserService {
// ...
}
NoSuchBeanDefinitionException
특정 타입이나 이름의 빈이 존재하지 않을 때 발생한다.
No qualifying bean of type 'com.example.UserRepository' available
발생 원인:
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
}
// @Repository 어노테이션 누락
public interface UserRepository extends JpaRepository<User, Long> {
}
해결 방법:
인터페이스의 경우 Spring Data JPA가 자동으로 구현체를 생성하므로 @Repository는 선택사항이지만, 구현 클래스는 반드시 빈으로 등록되어야 한다.
// JPA Repository는 자동 등록됨
public interface UserRepository extends JpaRepository<User, Long> {
}
// 직접 구현한 Repository는 어노테이션 필요
@Repository
public class CustomUserRepositoryImpl implements CustomUserRepository {
}
IllegalStateException (Request Scope)
Request-scoped 빈을 잘못된 컨텍스트에서 사용할 때 발생한다.
No thread-bound request found:
Are you referring to request attributes outside of an actual web request?
발생 원인:
@Service
@RequiredArgsConstructor
public class ScheduledTask {
private final HttpServletRequest request; // 스케줄러에서 사용 불가
@Scheduled(fixedRate = 60000)
public void task() {
String ip = request.getRemoteAddr(); // IllegalStateException 발생
}
}
해결 방법:
Request-scoped 빈은 웹 요청 컨텍스트에서만 사용한다. 스케줄러나 배치에서는 사용하지 않는다.
@Service
@RequiredArgsConstructor
public class ScheduledTask {
private final LogService logService;
@Scheduled(fixedRate = 60000)
public void task() {
logService.writeLog("Scheduled task executed");
}
}
BeanCreationException
빈 생성 과정에서 예외가 발생할 때의 포괄적인 Exception이다.
Error creating bean with name 'dataSource':
Invocation of init method failed
발생 원인:
@Configuration
@RequiredArgsConstructor
public class DatabaseConfig {
private final DataSourceProperties properties;
@Bean
public DataSource dataSource() {
// properties가 null이거나 잘못된 설정일 때
return DataSourceBuilder.create()
.url(properties.getUrl()) // null pointer 가능
.build();
}
}
해결 방법:
빈 생성 시점의 초기화 로직을 검증한다. 설정 파일(application.yml)의 값이 올바른지 확인한다.
@Configuration
@RequiredArgsConstructor
public class DatabaseConfig {
private final DataSourceProperties properties;
@Bean
public DataSource dataSource() {
if (properties.getUrl() == null) {
throw new IllegalArgumentException("Database URL must not be null");
}
return DataSourceBuilder.create()
.url(properties.getUrl())
.build();
}
}
⟡ Exception 발생 시 디버깅 팁
첫째, 스택 트레이스에서 Caused by 부분을 먼저 확인한다. 실제 원인은 하단에 있다.
둘째, Spring Boot의 --debug 옵션으로 빈 생성 과정을 상세히 확인한다.
java -jar application.jar --debug
셋째, @ComponentScan 범위를 확인한다. 빈이 스캔 범위 밖에 있으면 등록되지 않는다.
@SpringBootApplication
@ComponentScan(basePackages = {"com.example.app", "com.example.common"})
public class Application {
}
⟡ 결론 및 실무 팁
Controller나 Component에서 HttpServletRequest를 주입받는 것은 괜찮지만,
Service 계층에서는 HTTP 정보를 파라미터로 받아야 한다.
일반적인 싱글톤 빈들 간의 의존성에는 @RequiredArgsConstructor를 적극 사용하되,
계층 간 책임 분리 원칙은 지킨다. 순환 참조가 발생하면 애플리케이션 구조 자체를 재검토해야 한다.
'🌱 𝐅𝐫𝐚𝐦𝐞𝐰𝐨𝐫𝐤' 카테고리의 다른 글
| 👩🏻💻 Springframwork Mig 기록 : 레거시 프로젝트에 단위 테스트 도입하기(feat.실패하고 배운 이야기) (1) | 2026.01.01 |
|---|---|
| 👩🏻💻 Springframwork Mig 기록 : Spring Boot에서 Jasypt 자동 암복호화가 동작하는 원리 (0) | 2025.12.23 |
| 👩🏻💻 Springframwork Mig 기록 : 폐쇄망 환경에서 Gradle 빌드 설정 삽 질기 (0) | 2025.12.22 |
| 👩🏻💻 Springframwork Mig 기록 : 레거시 코드 고도화 작업 계획 (0) | 2025.12.09 |
| 👩🏻💻 Springframwork Mig 기록 : Springframework와 SpringBoot의 주요 설정파일 기능 정리 (0) | 2025.11.08 |