본문으로 건너뛰기

버그 제보

이상한 점을 알려 주시면 고칠게요. 지금 보고 있는 페이지와 브라우저 정보가 함께 담겨요.

페이지:

Backend

스프링 애플리케이션 설계: DI, 프록시 트랜잭션, 멀티모듈, WebFlux

작성 2026. 10. 21.추가
목차12개 항목
출처와 참고

IoC와 DI

제어의 역전(IoC, Inversion of Control)은 객체의 생성·연결·생명주기를 개발자 코드가 아니라 프레임워크(컨테이너)가 맡는 것이다. 스프링 고유 개념은 아니다. 템플릿 메서드 패턴처럼 "프레임워크가 내 코드를 부른다"는 구조 전반을 가리킨다.

의존성 주입(DI, Dependency Injection)은 IoC를 구현하는 방법 중 하나다. 객체가 협력 객체를 직접 new로 만들지 않고 외부(스프링 컨테이너)에서 받는다. 인터페이스 타입으로 받으면 구현을 바꿔 끼울 때 사용하는 쪽 코드를 고치지 않는다.

생성자 주입을 기본으로

비교생성자 주입필드 주입(@Autowired 필드)
필수 의존성 누락객체를 만들 수 없으니 바로 드러난다의존성 없이도 만들어져 호출할 때 NPE
불변성final 필드 가능final 불가
테스트생성자에 가짜 객체를 넘기면 된다컨테이너나 리플렉션 없이는 넣을 방법이 없다
설계 신호매개변수가 많아지면 책임 과다가 눈에 보인다의존성이 얼마든지 늘어도 티가 안 난다
  • 생성자가 하나면 @Autowired를 생략할 수 있다. Lombok @RequiredArgsConstructor와 final 필드 조합이 흔하다.
  • Spring Boot 2.6부터 빈 사이의 순환 참조는 주입 방식과 무관하게 기본으로 금지되어 구동 시점에 실패한다. 순환이 생기면 설정을 풀지 말고 책임을 다시 나눈다.

Service와 ServiceImpl

MemberService 인터페이스 + MemberServiceImpl 구현을 습관처럼 짝지어 만드는 관행이 있다. 인터페이스의 가치는 구현을 숨기고 여러 구현을 바꿔 끼우는 데 있으므로, 구현이 영원히 하나라면 이득이 거의 없다.

  • 인터페이스는 책임 하나를 표현할 때 의미가 있다. 예를 들어 ChangePasswordService에 "인증 코드로 변경", "기존 비밀번호로 변경" 두 구현이 있는 경우다.
  • 조회·수정·삭제를 모두 담은 거대한 XxxService는 인터페이스로 감싸도 결합도가 줄지 않는다. 먼저 MemberFindService, MemberUpdateService처럼 작은 클래스로 쪼갠다.
  • 결제 수단처럼 구현이 늘어날 게 분명한 도메인은 처음부터 인터페이스를 둔다. 그렇지 않으면 구체 클래스로 시작하고 필요할 때 추출한다. IDE로 인터페이스 추출은 어렵지 않다.

스프링 코드에 SOLID 적용하기

원칙스프링 코드에서의 모습
SRP책임은 변경을 요구하는 주체(actor) 단위로 나눈다. 국내 결제와 해외 결제가 다른 부서의 요구로 바뀐다면 다른 클래스다
OCP결제 수단별 if 분기 대신 전략 인터페이스와 구현 빈을 두고, Map<String, PaymentStrategy>로 주입받아 고른다. 새 수단은 빈 추가로 끝난다
LSP하위 타입이 상위 타입 자리에서 그대로 동작해야 한다. instanceof로 특정 하위 타입을 골라 다르게 처리하면 위반 신호다
ISP쓰지 않는 메서드에 의존하지 않도록 인터페이스를 쪼갠다
DIP서비스는 구체 클래스가 아니라 추상화에 의존한다. 요청 DTO도 특정 구현(카드사)에 묶이지 않게 설계한다

객체에게 데이터를 꺼내 판단하지 말고 일을 시킨다(Tell, Don't Ask). 원칙 자체의 설명은 프론트엔드에 적용하는 SOLID, 패턴은 GoF 디자인 패턴에 있다.

패키지 구조

방식모양특징
계층형controller/, service/, repository/작은 프로젝트에 단순하다. 기능 하나를 고치려면 여러 폴더를 오간다
도메인형member/{api,application,domain,dao,dto}, global/, infra/기능이 한곳에 모인다. 도메인 사이 의존을 관리하기 쉽다

도메인형에서 global에는 공통 설정·예외 처리·공통 응답, infra에는 외부 시스템 연동을 둔다.

Lombok 사용 기준

어노테이션기준
@Data쓰지 않는다. @Setter, @EqualsAndHashCode, @ToString까지 한꺼번에 붙어 부작용이 많다
@Setter엔티티에는 두지 않는다. 상태 변경은 의도가 드러나는 메서드(changePassword)로 한다
@ToString양방향 연관관계에서 서로 부르며 무한 재귀가 난다. 연관 필드는 제외한다
@EqualsAndHashCode엔티티에는 신중히. 가변 필드나 지연 로딩 연관을 포함하면 컬렉션 동작이 깨진다
@NoArgsConstructorJPA 프록시용 기본 생성자는 access = AccessLevel.PROTECTED로 막는다
@Builder클래스가 아니라 필요한 값만 받는 생성자에 붙인다. 클래스에 붙이면 id·생성 시각처럼 자동 관리돼야 할 값까지 외부에서 넣을 수 있다
@Getter쓰되, 꺼낸 값으로 밖에서 계산하지 말고 객체에 메서드를 둔다

lombok.config에 lombok.setter.flagUsage = error, lombok.data.flagUsage = error처럼 적어 팀 규칙을 컴파일러가 강제하게 한다.

@Transactional과 프록시: 자기 호출 함정

스프링의 선언적 트랜잭션은 빈을 감싼 프록시가 메서드 호출을 가로채서 동작한다. 그래서 프록시를 거치지 않는 호출에는 적용되지 않는다.

flowchart LR
  ext["외부 호출"] --> proxy["프록시: 트랜잭션 시작"]
  proxy --> outer["target.outer()"]
  outer -->|"프록시를 거치지 않음"| inner["this.inner()<br/>(@Transactional(REQUIRES_NEW) 무시)"]
증상원인
같은 클래스의 @Transactional 메서드를 내부에서 불렀는데 트랜잭션이 없다this.inner()는 실제 객체를 바로 부른다
내부 메서드의 REQUIRES_NEW가 무시되어 전체가 함께 롤백된다같은 이유
private 메서드의 @Transactional이 동작하지 않는다프록시가 재정의할 수 없는 메서드다

해결은 트랜잭션 경계가 다른 메서드를 별도 빈으로 분리하고 주입받아 부르는 것이다. 코드로 경계를 직접 다루려면 TransactionTemplate을 쓴다. 자기 자신을 주입받는 self-injection이나 AspectJ 위빙도 가능하지만 의도가 숨고 설정이 복잡해진다.

  • 롤백 기본값은 unchecked 예외와 Error다. checked 예외 처리 전략은 Java 실무 관용구의 「예외」에 있다.
  • 같은 프록시 원리가 @Async, @Cacheable에도 똑같이 적용된다.

멀티모듈 설계

하나의 저장소에서 여러 애플리케이션(API 서버, 배치, 어드민)을 만들 때 Gradle 멀티모듈로 나눈다. 가장 흔한 실패는 모든 것을 넣는 common 모듈이다.

common 모듈의 문제결과
여러 앱의 코드가 쌓인다한 곳을 고치면 모든 앱이 영향을 받는 스파게티
의존성을 다 끌어안는다DB가 필요 없는 앱에도 DataSource 자동 설정이 켜진다
스레드 풀·커넥션 풀·타임아웃 설정이 한곳에 모인다한 앱에 맞춘 설정이 다른 앱의 장애가 된다

계층과 의존 방향

계층담는 것규칙
독립 모듈프로젝트와 무관한 라이브러리성 코드프로젝트 내 다른 모듈에 의존하지 않는다
공통 모듈타입, 유틸처럼 아주 얇은 공용 코드외부 의존성 없음. 최소한으로 유지한다
도메인 모듈엔티티, 리포지토리, 도메인 서비스서비스별 비즈니스를 모른다. 인프라 하나만 책임진다(RDB 도메인, Redis 도메인을 나눈다)
내부 모듈외부 API 클라이언트, 이벤트 발행 등 시스템 지원 기능도메인 모듈에 의존하지 않는다
애플리케이션 모듈하위 모듈을 조립한 실행 단위모든 하위 계층에 의존할 수 있다. 다른 앱 모듈에는 의존하지 않는다
flowchart LR
  subgraph oneway["한 방향으로만"]
    app["애플리케이션"] --> dom["내부 / 도메인"]
    dom --> com["공통 / 독립"]
  end
  • Gradle java-library 플러그인의 api는 의존성을 상위로 전파하고, implementation은 숨긴다. 앱 모듈이 하위 모듈의 내부 의존성에 직접 손대지 못하게 implementation을 기본으로 쓴다.
  • 하위 모듈의 빈을 상위 모듈이 바꿀 수 있게 @ConditionalOnMissingBean이나 Customizer 같은 확장점을 열어 둔다.
  • 모듈마다 역할·책임·사용법을 README에 적는다.

모노레포 자체의 장단점은 모노레포와 폴리레포에 있다.

사용하지 않는 API 찾기

접근 로그에는 /orders/A0001처럼 실제 URL만 남아서, 컨트롤러에 정의된 URL 패턴 단위로 호출 여부를 세기 어렵다.

  1. HandlerInterceptor.preHandle에서 요청 속성 HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE(매칭된 패턴, 예: /orders/{id})와 HTTP 메서드를 기록한다.
  2. 기록은 요청 스레드를 막지 않게 비동기 버퍼에 모았다가 묶어서 검색 저장소로 보낸다. 버퍼가 넘치면 버린다.
  3. RequestMappingHandlerMapping.getHandlerMethods()로 정의된 전체 API 목록을 얻는다.
  4. 기간 동안 호출 0건인 패턴이 삭제 후보다.

같은 데이터는 Micrometer의 http.server.requests 메트릭(uri 태그가 패턴이다)으로도 얻을 수 있다.

Spring MVC와 WebFlux

비교Spring MVCSpring WebFlux
기반서블릿 API, 요청당 스레드서블릿과 무관한 리액티브 스택. 기본 서버는 Netty(Reactor Netty)
I/O블로킹논블로킹, 이벤트 루프
반환 타입일반 객체Publisher: Mono(01개), Flux(0N개)
진입점어노테이션 컨트롤러어노테이션 컨트롤러와 함수형 라우터(RouterFunction) 모두 가능
어울리는 곳블로킹 라이브러리(JDBC 등)를 쓰는 대부분의 서비스많은 동시 연결, 스트리밍, 다른 서비스 호출이 많은 게이트웨이
  • Mono<List<T>>는 목록을 한 번에, Flux<T>는 원소를 스트림으로 내보낸다. Flux는 text/event-stream과 묶어 SSE로 쓸 수 있고, collectList()로 Mono<List<T>>가 된다.
  • 리액티브 스택에서는 이벤트 루프를 막으면 안 된다. 원리와 블로킹 작업 분리는 비동기 서버의 이벤트 루프에 있다.
  • Java 21 가상 스레드를 켜면(Spring Boot 3.2+ spring.threads.virtual.enabled=true) MVC의 블로킹 코드로도 높은 동시성을 얻을 수 있어, 리액티브로 옮길 이유가 줄었다.
  • Armeria는 Netty 기반 비동기 서버·클라이언트 라이브러리다. 한 포트에서 HTTP·gRPC·Thrift를 함께 서비스하고, 브라우저에서 RPC를 호출해 보는 DocService를 제공한다. WebFlux와 결합하면 빈·트랜잭션은 스프링이, RPC 서빙은 Armeria가 맡는다. gRPC 자체는 gRPC에 있다.

정리

  • 의존성은 생성자로 받고 final로 둔다. 순환 참조는 설계 문제다.
  • 인터페이스는 책임 하나와 여러 구현이 있을 때 만든다. 습관적인 ServiceImpl은 이득이 없다.
  • Lombok은 @Data·@Setter를 막고, @Builder는 생성자에 붙인다.
  • @Transactional은 프록시를 거친 외부 호출에만 걸린다. 경계가 다르면 빈을 나눈다.
  • 멀티모듈은 common을 비대하게 두지 말고 계층과 한 방향 의존을 지킨다.

관련 노트: 스프링의 싱글톤 빈과 요청 스레드, JPA와 영속성 컨텍스트, Java 실무 관용구, 비동기 서버의 이벤트 루프, DTO, VO, DAO, Repository, 소프트웨어 아키텍처 패턴

원본 기록12

댓글

GitHub 계정으로 댓글을 남길 수 있어요.