[finsight]Spring Bean 생성자 불일치, 인앱 알림 추가 후 컴파일이 깨진 이유와 해결 과정

궁금하면 500원·2026년 9월 4일

미생의 개발 이야기

목록 보기
94/95

FinSight NotificationService + InboxService 의존성 주입 수정기

들어가며

FinSight의 기존 뉴스 알림 기능에 인앱 알림함(Inbox) 기능을 연결했습니다.

기존에는 관심종목에 새로운 뉴스가 발생하면 이메일이나 푸시 같은 외부 채널을 통해 알림을 전달하는 구조였습니다.

여기에 인앱 알림을 추가하면서 다음과 같은 흐름으로 확장했습니다.

관심종목 사용자 조회
        ↓
사용자 알림 설정 확인
        ↓
EMAIL / PUSH 발송
        ↓
Inbox 알림 생성

기능 구현 후 기존 알림 시나리오까지 함께 테스트하기 위해 백엔드를 다시 빌드하던 중 컴파일이 실패했습니다.

처음에는 인앱 알림 로직 자체에 문제가 있는 것으로 생각했지만, 원인은 전혀 다른 곳에 있었습니다.

NotificationService에 새로운 의존성이 추가되었는데, 이 서비스를 직접 생성하던 Spring 설정 코드는 예전 생성자 형태를 그대로 사용하고 있었습니다.

이번 글에서는 이러한 Spring Bean 생성자 불일치가 왜 발생했는지, 그리고 비슷한 문제를 어떻게 추적할 수 있는지 정리해보겠습니다.


1. 인앱 알림을 추가하면서 의존성이 하나 늘었습니다

FinSight에는 뉴스 관심종목 알림을 처리하는 NotificationService가 있습니다.

기존 역할은 대략 다음과 같았습니다.

관심종목 사용자 조회
→ 사용자 알림 설정 확인
→ 이메일 / 푸시 알림 발송

인앱 알림함 기능을 추가하면서 같은 알림을 사용자의 Inbox에도 저장할 필요가 생겼습니다.

그래서 다음과 같은 호출이 추가되었습니다.

inboxService.createForUsers(...);

그 결과 NotificationService에도 자연스럽게 InboxService 의존성이 추가되었습니다.

@Service
public class NotificationService {

    private final UserPersistencePort userPersistencePort;
    private final NotificationSenderPort notificationSenderPort;
    private final InboxService inboxService;

    public NotificationService(
            UserPersistencePort userPersistencePort,
            NotificationSenderPort notificationSenderPort,
            InboxService inboxService
    ) {
        this.userPersistencePort = userPersistencePort;
        this.notificationSenderPort = notificationSenderPort;
        this.inboxService = inboxService;
    }
}

기존에는 두 개의 의존성만 필요했습니다.

UserPersistencePort
NotificationSenderPort

인앱 알림 기능이 추가된 이후에는 다음 세 개가 필요합니다.

UserPersistencePort
NotificationSenderPort
InboxService

서비스 내부 코드만 보면 특별한 문제는 없어 보였습니다.

문제는 프로젝트의 다른 위치에서 이 서비스를 직접 생성하고 있었다는 점이었습니다.


2. 기존 알림 시나리오를 테스트하다 컴파일 오류가 발생했습니다

인앱 알림 기능을 연결한 뒤 다음과 같은 경우를 함께 확인하려고 했습니다.

관심종목 뉴스 발생
→ 기존 EMAIL/PUSH 동작 확인
→ Inbox에도 알림 생성 확인

이를 위해 전체 백엔드 컴파일을 다시 수행했습니다.

gradlew :core:compileJava

그 과정에서 NotificationService 생성자와 관련된 컴파일 문제가 발생했습니다.

Java에서 생성자 호출 인자가 실제 생성자와 다르면 보통 다음과 같은 형태의 오류가 발생합니다.

error: constructor NotificationService in class NotificationService
cannot be applied to given types;

required:
UserPersistencePort,
NotificationSenderPort,
InboxService

found:
UserPersistencePort,
NotificationSenderPort

reason:
actual and formal argument lists differ in length

핵심은 requiredfound입니다.

required = 현재 생성자가 요구하는 값

found = 실제 호출 코드가 전달한 값

현재 클래스는 세 개의 의존성을 요구하는데,

required:
A, B, C

어딘가에서는 아직 두 개만 전달하고 있었습니다.

found:
A, B

즉 문제를 단순하게 표현하면 다음과 같습니다.

서비스의 생성자는 변경되었는데, 서비스를 생성하는 코드는 이전 상태에 머물러 있었습니다.


3. 가장 먼저 new NotificationService()를 찾아보았습니다

이런 경우 생성자 자체를 다시 수정하기 전에 먼저 확인해야 할 부분이 있습니다.

프로젝트 전체에서 해당 서비스를 직접 생성하는 코드가 있는지 확인하는 것입니다.

IDE에서는 다음과 같이 검색할 수 있습니다.

new NotificationService(

검색 결과 AdvancedDependencyInjectionConfig에서 다음과 같이 직접 Bean을 생성하고 있었습니다.

@Bean
public NotificationService notificationService() {
    return new NotificationService(
            userPersistencePort,
            notificationSenderPort
    );
}

여기에서 원인이 바로 드러났습니다.

현재 생성자는:

new NotificationService(
        userPersistencePort,
        notificationSenderPort,
        inboxService
);

를 요구하지만,

Spring 설정은 아직:

new NotificationService(
        userPersistencePort,
        notificationSenderPort
);

형태였습니다.

즉 서비스는 인앱 알림을 지원하도록 변경되었지만 Bean 조립 코드는 인앱 알림이 없던 시점에 머물러 있었습니다.


4. Before: 서비스와 Bean 설정의 생성자 정보가 달랐습니다

기존 설정은 다음과 같은 형태였습니다.

@Bean
public NotificationService notificationService() {
    return new NotificationService(
            userPersistencePort,
            notificationSenderPort
    );
}

기존 NotificationService 생성자가 두 개의 인자만 받았을 때는 문제가 없습니다.

public NotificationService(
        UserPersistencePort userPersistencePort,
        NotificationSenderPort notificationSenderPort
) {
    ...
}

하지만 인앱 알림이 추가되면서 생성자가 변경되었습니다.

public NotificationService(
        UserPersistencePort userPersistencePort,
        NotificationSenderPort notificationSenderPort,
        InboxService inboxService
) {
    ...
}

이 시점부터 두 코드는 서로 다른 계약을 가지고 있게 됩니다.

NotificationService
    → 3개 의존성 필요

AdvancedDependencyInjectionConfig
    → 2개만 전달

Java 컴파일러 입장에서는 해당 생성자가 존재하지 않는 것과 같습니다.


5. After: InboxService를 Spring Bean으로 주입했습니다

설정 메서드에서도 현재 생성자와 동일한 의존성을 전달하도록 변경했습니다.

@Bean
public NotificationService notificationService(
        InboxService inboxService
) {
    return new NotificationService(
            userPersistencePort,
            notificationSenderPort,
            inboxService
    );
}

여기에서 InboxService를 직접 생성할 필요는 없습니다.

new InboxService(...)

와 같이 만드는 대신 @Bean 메서드의 파라미터로 선언하면 Spring Container가 등록된 Bean을 찾아 주입합니다.

@Bean
public NotificationService notificationService(
        InboxService inboxService
) {
    ...
}

개념적으로는 다음과 같습니다.

Spring Container

UserPersistencePort ─────┐
                         │
NotificationSenderPort ──┼─→ NotificationService
                         │
InboxService ────────────┘

이렇게 현재 생성자와 Bean 조립 코드가 다시 일치하게 되었습니다.


6. 왜 서비스 파일에서는 문제가 바로 보이지 않았을까?

이번 문제에서 흥미로웠던 점은 NotificationService 자체만 보고 있으면 문제가 쉽게 보이지 않았다는 것입니다.

서비스에서는 정상적으로 InboxService를 받습니다.

@Service
public class NotificationService {

    private final InboxService inboxService;

    ...
}

즉 서비스 내부의 의존성 관계 자체는 자연스럽습니다.

문제는 해당 클래스를 프로젝트의 다른 위치에서 다시 직접 만들고 있었다는 점입니다.

NotificationService.java
        ↑
        │
생성자는 이미 변경됨
        │

AdvancedDependencyInjectionConfig.java
        ↑
        │
예전 생성자 방식으로 직접 new

기능 변경 지점과 객체 조립 지점이 서로 다른 파일에 있기 때문에 발생하기 쉬운 문제입니다.

특히 멀티모듈 프로젝트에서는 이러한 문제가 더 눈에 잘 띄지 않을 수 있습니다.

예를 들어:

core
 └─ NotificationService

configuration
 └─ AdvancedDependencyInjectionConfig

web
 └─ Controller

처럼 실제 기능 코드와 DI 설정이 다른 위치에 존재한다면 서비스 의존성을 추가하면서 설정 파일까지 함께 수정해야 한다는 사실을 놓치기 쉽습니다.


7. Lombok을 사용하더라도 동일한 문제가 발생합니다

명시적으로 생성자를 작성하지 않고 Lombok의 @RequiredArgsConstructor를 사용하더라도 원리는 같습니다.

예를 들어 기존 코드가 다음과 같다고 가정해보겠습니다.

@RequiredArgsConstructor
@Service
public class NotificationService {

    private final UserPersistencePort userPersistencePort;
    private final NotificationSenderPort notificationSenderPort;
}

Lombok은 내부적으로 두 인자를 받는 생성자를 만들어줍니다.

개념적으로는 다음과 같습니다.

public NotificationService(
        UserPersistencePort userPersistencePort,
        NotificationSenderPort notificationSenderPort
) {
    ...
}

여기에 다음 필드를 추가하면,

private final InboxService inboxService;

생성자도 자동으로 세 인자로 변경됩니다.

public NotificationService(
        UserPersistencePort userPersistencePort,
        NotificationSenderPort notificationSenderPort,
        InboxService inboxService
) {
    ...
}

하지만 기존 테스트나 설정에 다음 코드가 남아 있다면,

new NotificationService(
        userPersistencePort,
        notificationSenderPort
);

즉시 컴파일 오류가 발생합니다.

그래서 @RequiredArgsConstructor를 사용하는 경우에는 코드상 생성자가 직접 보이지 않기 때문에 오히려 final 의존성 추가 시 호출부 검색이 더 중요할 수 있습니다.


8. 테스트 코드에서도 같은 문제가 생길 수 있습니다

이번 경우에는 수동 @Bean 설정이 문제였지만, 같은 현상은 단위 테스트에서도 자주 발생합니다.

예를 들어 기존 테스트가 다음처럼 서비스를 직접 생성한다고 가정해보겠습니다.

@BeforeEach
void setUp() {

    notificationService =
            new NotificationService(
                    userPersistencePort,
                    notificationSenderPort
            );
}

서비스에 InboxService가 추가되면 이 테스트 역시 깨집니다.

@BeforeEach
void setUp() {

    notificationService =
            new NotificationService(
                    userPersistencePort,
                    notificationSenderPort,
                    inboxService
            );
}

Mock을 사용하는 테스트라면 다음과 같이 추가될 수 있습니다.

@Mock
private InboxService inboxService;

따라서 새로운 의존성을 추가했을 때는 서비스 코드만 확인하는 것이 아니라 다음 위치를 같이 확인하는 것이 좋습니다.

@Service
@Configuration / @Bean
단위 테스트
통합 테스트
Fixture
Factory
직접 new 하는 코드

9. @Service와 수동 @Bean이 동시에 존재한다면 한 번 더 확인해야 합니다

이번 구조에서는 한 가지 더 생각해볼 부분이 있습니다.

NotificationService에 이미 다음과 같이 @Service가 붙어 있다면,

@Service
public class NotificationService {
    ...
}

Spring의 Component Scan이 자동으로 Bean을 생성할 수 있습니다.

그런데 별도의 설정 클래스에서도 다시:

@Bean
public NotificationService notificationService(...) {
    return new NotificationService(...);
}

를 사용하고 있다면 동일한 타입의 Bean을 두 경로에서 관리하게 될 가능성이 있습니다.

개념적으로는 다음과 같습니다.

@ComponentScan
      ↓
@Service
      ↓
NotificationService Bean

그리고 동시에

@Configuration
      ↓
@Bean
      ↓
NotificationService Bean

프로젝트 설정과 Bean 이름에 따라 중복 등록이나 의존성 선택 문제로 이어질 수 있기 때문에 장기적으로는 둘 중 하나의 DI 방식으로 통일할 수 있는지 확인하는 것이 좋습니다.

예를 들어 단순한 서비스라면:

@Service
@RequiredArgsConstructor
public class NotificationService {

    private final UserPersistencePort userPersistencePort;
    private final NotificationSenderPort notificationSenderPort;
    private final InboxService inboxService;
}

처럼 Component Scan에 맡기는 방법이 가장 단순합니다.

반대로 객체 생성 과정에 별도의 설정이나 조건부 생성이 필요하다면 @Bean으로 명시적으로 관리할 수도 있습니다.

중요한 것은 두 방식을 무조건 섞는 것이 아니라 왜 수동 Bean 등록이 필요한지 이유가 명확해야 한다는 점입니다.

이번 작업에서는 우선 기존 프로젝트의 DI 구조를 유지하면서 생성자 불일치 문제를 수정했고, DI 방식 통일은 별도의 리팩터링 대상으로 두었습니다.


10. JDK 버전이 맞지 않으면 실제 생성자 오류까지 도달하지 못할 수도 있습니다

컴파일 오류를 확인할 때 한 가지 변수도 있었습니다.

프로젝트는 Java 21을 기준으로 하는데 로컬 Java 환경이 더 낮다면 생성자 오류를 확인하기 전에 Gradle 컴파일 자체가 먼저 실패할 수 있습니다.

예를 들어 다음과 같은 오류입니다.

error: invalid source release: 21

이 경우에는 NotificationService의 생성자 오류까지 출력되지 않을 수 있습니다.

먼저 Java 버전을 확인할 수 있습니다.

java -version

Gradle이 실제로 어떤 JVM을 사용하고 있는지도 확인합니다.

gradlew -version

Java 21 환경이 맞다면 다음과 같이 컴파일을 다시 수행합니다.

gradlew :core:compileJava --stacktrace

하지만 환경 문제 때문에 컴파일 로그를 바로 확인할 수 없는 경우에도 정적 비교로 어느 정도 문제를 추적할 수 있습니다.

현재 생성자 확인

NotificationService(
    UserPersistencePort,
    NotificationSenderPort,
    InboxService
)

호출부 확인

new NotificationService(
    userPersistencePort,
    notificationSenderPort
)

두 코드를 나란히 보면:

현재 생성자 = 3개
호출 코드   = 2개

라는 불일치를 바로 확인할 수 있습니다.

그래도 최종적으로는 프로젝트가 요구하는 JDK 환경을 맞춘 뒤 실제 컴파일까지 통과시키는 것이 안전합니다.


11. 이 문제는 런타임 오류가 아니라 빌드 단계에서 차단됩니다

이번 문제는 서버를 실행한 뒤 특정 API를 호출해야 나타나는 오류가 아니었습니다.

컴파일 단계에서 바로 발견되는 문제입니다.

Java Source
    ↓
compileJava
    ↓
NotificationService 생성자 불일치
    ↓
BUILD FAILED

따라서 결과적으로:

백엔드 JAR 생성 실패
CI 실패
애플리케이션 배포 불가

상태가 됩니다.

재미있는 점은 인앱 알림 기능 자체의 로직이 잘못된 것이 아니더라도 애플리케이션 전체를 배포할 수 없다는 점입니다.

객체지향 애플리케이션에서는 기능 로직뿐 아니라 객체를 어떻게 조립하는지도 애플리케이션의 일부라는 것을 보여주는 사례였습니다.


12. 이번 테스트에서 추가로 확인한 시나리오

생성자 문제를 수정한 뒤 단순히 컴파일만 확인하는 것으로 끝내지 않고 인앱 알림이 추가되면서 영향을 받을 수 있는 흐름을 다시 확인했습니다.

예를 들어 다음과 같은 경우입니다.

시나리오확인 내용
기존 뉴스 알림EMAIL/PUSH 기존 흐름이 유지되는지
인앱 알림 추가대상 사용자 Inbox에 알림이 생성되는지
Inbox 의존성 주입NotificationService 생성이 정상적인지
Bean 조립Spring 설정에서 현재 생성자를 사용하는지
전체 컴파일core 모듈이 정상적으로 컴파일되는지
기존 호출부오래된 new NotificationService(...)가 남아 있지 않은지

처음에는 단순히:

Inbox 알림이 생성되는가?

만 확인하려 했습니다.

하지만 기능 하나를 추가하면 실제 영향 범위는 다음처럼 넓어질 수 있습니다.

기능 로직
   ↓
서비스 의존성
   ↓
생성자
   ↓
Bean 설정
   ↓
테스트 Fixture
   ↓
전체 빌드

그래서 서비스에 새로운 의존성을 추가하는 작업은 단순히 필드 하나를 추가하는 변경으로만 보면 안 된다는 점을 다시 확인했습니다.


같은 문제를 줄이기 위한 체크리스트

이후에는 서비스에 새로운 final 의존성을 추가할 때 다음 순서로 확인하려고 합니다.

  1. 현재 생성자 시그니처를 확인합니다.
  2. Lombok을 사용한다면 새 final 필드가 생성자에 포함되는지 확인합니다.
  3. 프로젝트 전체에서 new ServiceName(을 검색합니다.
  4. @Configuration, @Bean 설정을 확인합니다.
  5. 단위 테스트와 Fixture에서 서비스를 직접 생성하는 코드가 있는지 확인합니다.
  6. @Service와 수동 @Bean이 중복으로 존재하는지 확인합니다.
  7. 프로젝트가 요구하는 JDK 버전으로 compileJava를 수행합니다.
  8. 기능 추가 이전의 기존 시나리오까지 회귀 테스트합니다.

특히 다음과 같은 변경은 주의할 필요가 있습니다.

private final SomeNewService someNewService;

한 줄만 추가한 것처럼 보이지만 실제 영향 범위는 다음과 같을 수 있습니다.

필드 추가
 ↓
생성자 변경
 ↓
Bean 생성 코드 변경
 ↓
테스트 생성 코드 변경
 ↓
DI 그래프 변경

마무리

이번 문제의 원인은 복잡하지 않았습니다.

인앱 알림 기능을 추가하면서 NotificationService의 생성자에 InboxService가 추가되었지만, 서비스를 직접 생성하던 AdvancedDependencyInjectionConfig에서는 여전히 기존 두 인자 생성자 형태를 사용하고 있었습니다.

Before

NotificationService(A, B, C)

하지만 설정에서는

new NotificationService(A, B)

이를 현재 생성자에 맞춰 수정했습니다.

After

NotificationService(A, B, C)

설정에서도

new NotificationService(A, B, C)

수정 코드 자체는 작았습니다.

하지만 이번 사례를 통해 더 중요하게 본 것은 의존성을 하나 추가하면 그 의존성이 객체 생성 지점까지 제대로 전파되었는지 확인해야 한다는 점이었습니다.

특히 Spring 프로젝트에서는 @Service, @Bean, 테스트 Fixture 등 객체를 만드는 경로가 여러 개 존재할 수 있습니다.

그래서 앞으로는 서비스 의존성이 변경되면 다음 흐름으로 확인하려고 합니다.

서비스 변경
   ↓
생성자 확인
   ↓
new 호출 검색
   ↓
@Bean 확인
   ↓
테스트 객체 생성 확인
   ↓
compileJava
   ↓
회귀 테스트

기능 로직이 정상이라고 해서 변경이 끝난 것은 아니었습니다.

Spring 애플리케이션에서는 비즈니스 로직뿐 아니라 객체가 어떻게 만들어지고 연결되는지까지 함께 테스트해야 한다는 것을 확인한 사례였습니다.

profile
레거시를 이해하면서도 새로운 기술을 현실적으로 적용할 수 있는 백엔드 개발자가 되는 것이 목표입니다.

0개의 댓글