토비의 스프링부트 정리 (3) - DI와 단위 테스트, 디자인 패턴
1. 개요
앞 글에서는 스프링 컨테이너가 HelloController와 HelloService를 만들고 연결하도록 구성했다. 이번에는 그 연결 방식인 의존성 주입(DI, Dependency Injection) 을 테스트에 활용한다.
DI를 적용하면 컨테이너를 띄우지 않고도 필요한 협력 객체만 바꿔 끼워 단위 테스트를 작성할 수 있다. 이어서 같은 인터페이스를 구현하는 객체를 조합해 기능을 덧붙이는 Decorator 패턴과, 호출을 대신 받아 제어하는 Proxy 패턴도 살펴본다.
2. HTTP 요청을 자동으로 검증하기
브라우저나 curl로 직접 요청을 보내는 확인은 한 번 해 보기에는 좋지만, 기능이 바뀔 때마다 반복하기 어렵다. TestRestTemplate으로 HTTP 요청을 보내고 결과를 단언하면 같은 확인을 자동으로 반복할 수 있다.
@Test
void helloApi() {
TestRestTemplate rest = new TestRestTemplate();
ResponseEntity<String> response = rest.getForEntity(
"http://localhost:8080/hello?name={name}",
String.class,
"Spring");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE))
.startsWith(MediaType.TEXT_PLAIN_VALUE);
assertThat(response.getBody()).isEqualTo("Hello Spring");
}
이 테스트는 HTTP 응답의 세 요소를 검증한다.
| 확인 대상 | 검증하는 내용 |
|---|---|
| 상태 코드 | 요청이 성공했는지 (200 OK) |
| 헤더 | 응답의 형식이 일반 텍스트인지 (Content-Type) |
| 본문 | 클라이언트가 받는 실제 문자열 |
서버를 거쳐 요청을 처리하므로 컨트롤러 매핑과 HTTP 응답까지 함께 확인할 수 있다. 반면, 단일 클래스의 규칙만 빠르게 확인하고 싶다면 서버나 스프링 컨테이너가 없는 단위 테스트가 더 알맞다.
3. DI로 단위 테스트를 고립하기
HelloController는 인사말을 직접 만들지 않고 HelloService에 맡긴다. 생성자로 HelloService를 받는 구조라서, 실행 환경에서는 SimpleHelloService를 연결하고 테스트에서는 아주 단순한 대역으로 바꿀 수 있다.

public interface HelloService {
String sayHello(String name);
}
public class HelloController {
private final HelloService helloService;
public HelloController(HelloService helloService) {
this.helloService = helloService;
}
public String hello(String name) {
if (name == null || name.isEmpty()) {
throw new IllegalArgumentException("name is null or empty");
}
return helloService.sayHello(name);
}
}
HelloController 테스트에서는 name -> name 람다를 HelloService의 테스트 대역으로 전달한다. 이 대역은 받은 이름을 그대로 돌려줄 뿐이므로, 인사말을 만드는 구현과 분리한 채 컨트롤러의 검증 규칙과 위임 여부에 집중할 수 있다.
@Test
void helloController() {
HelloController controller = new HelloController(name -> name);
assertThat(controller.hello("Test")).isEqualTo("Test");
}
@Test
void rejectsNullOrEmptyName() {
HelloController controller = new HelloController(name -> name);
assertThatThrownBy(() -> controller.hello(null))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> controller.hello(""))
.isInstanceOf(IllegalArgumentException.class);
}
SimpleHelloService 자체도 별도로 테스트할 수 있다.
@Test
void simpleHelloService() {
HelloService service = new SimpleHelloService();
assertThat(service.sayHello("Test")).isEqualTo("Hello Test");
}
이처럼 테스트 대상을 직접 생성하고 필요한 의존 객체만 전달하면 테스트가 빠르고, 실패 원인도 좁게 파악할 수 있다. DI는 스프링 컨테이너의 기능이기도 하지만, 그보다 먼저 객체가 필요한 협력자를 외부에서 받도록 만드는 설계 방식이다.
4. Decorator 패턴으로 기능 덧붙이기
Decorator 패턴은 기존 객체를 수정하지 않고, 같은 인터페이스를 구현한 다른 객체로 감싸 기능을 추가하는 패턴이다. 장식 객체도 HelloService이고, 내부의 대상 객체도 HelloService이므로 여러 장식을 이어 붙일 수 있다.
public class HelloDecorator implements HelloService {
private final HelloService target;
public HelloDecorator(HelloService target) {
this.target = target;
}
@Override
public String sayHello(String name) {
return "*" + target.sayHello(name) + "*";
}
}

아래처럼 조합하면 컨트롤러는 자신이 기본 서비스와 대화하는지, 장식된 서비스와 대화하는지 알 필요가 없다.
HelloController
→ HelloDecorator
→ SimpleHelloService
HelloDecorator를 연결한 뒤에는 GET /hello?name=Spring의 본문이 Hello Spring에서 *Hello Spring*으로 바뀐다. 후속 커밋의 API 테스트도 이 변경된 결과를 검증한다.
스프링에서 HelloService 타입의 빈이 둘 이상이면 컨트롤러에 어떤 빈을 주입할지 정해야 한다. @Primary를 붙이면 해당 빈을 우선 후보로 선택할 수 있다. 다만 Decorator 자신이 받는 대상은 기본 서비스여야 하므로, 설정 클래스에서 구체 타입으로 받거나 @Qualifier로 대상을 명시하면 의도가 분명해진다.
@Configuration
class HelloConfig {
@Bean
SimpleHelloService simpleHelloService() {
return new SimpleHelloService();
}
@Bean
@Primary
HelloService decoratedHelloService(SimpleHelloService target) {
return new HelloDecorator(target);
}
}

Decorator의 핵심은 대상의 본래 책임을 그대로 호출하면서 앞뒤에 책임을 추가한다는 점이다. 문자열 꾸미기 외에도 로깅, 실행 시간 측정, 권한 확인처럼 여러 기능을 조합할 때 같은 구조를 사용할 수 있다.
5. Proxy 패턴은 접근을 제어한다
Proxy 패턴의 프록시는 대상 객체의 대리자 역할을 한다. Decorator처럼 같은 인터페이스를 구현하고 대상 객체를 호출할 수 있지만, 주된 목적은 기능을 꾸미는 일이 아니라 대상에 대한 접근을 제어하는 데 있다.
public class AccessControlHelloProxy implements HelloService {
private final HelloService target;
private final boolean allowed;
public AccessControlHelloProxy(HelloService target, boolean allowed) {
this.target = target;
this.allowed = allowed;
}
@Override
public String sayHello(String name) {
if (!allowed) {
throw new IllegalStateException("접근 권한이 없습니다.");
}
return target.sayHello(name);
}
}
프록시는 원격 객체에 대한 로컬 접근을 제공하거나, 실제 객체를 필요한 시점까지 만들지 않거나, 보안과 접속 제어를 적용할 때 사용할 수 있다. 이 패턴의 프록시, 일상적으로 말하는 프록시 서버, 자바의 동적 프록시는 관련된 용어이지만 같은 개념으로 단정하면 안 된다.
| 구분 | Decorator | Proxy |
|---|---|---|
| 주된 목적 | 대상 기능에 책임을 추가한다 | 대상에 대한 접근을 제어한다 |
| 대상 호출 | 추가 기능과 함께 보통 호출한다 | 조건에 따라 호출을 허용하거나 대신 처리한다 |
| 공통 구조 | 대상과 같은 인터페이스를 구현하고 대상을 참조한다 | 대상과 같은 인터페이스를 구현하고 대상을 참조한다 |
6. 정리
DI는 객체 사이의 연결을 외부로 꺼내 구현을 교체할 수 있게 한다. 이 성질 덕분에 단위 테스트에서는 람다나 간단한 테스트 대역을 주입해 컨트롤러의 규칙을 고립해서 검증할 수 있고, HTTP 테스트에서는 상태 코드·헤더·본문을 자동으로 확인할 수 있다.
Decorator와 Proxy도 이 구조를 활용한다. 두 패턴 모두 같은 인터페이스를 통해 대상 객체를 감싸지만, Decorator는 기능을 더하고 Proxy는 접근을 관리한다. 인터페이스에 의존하고 객체를 조합하는 설계는 테스트하기 쉬운 코드와 확장하기 쉬운 코드를 함께 만든다.
참고 자료
- 토비의 스프링 부트 - 이해와 원리: 05 DI와 테스트, 애노테이션 활용
- TobySpringBoot-1: DI와 단위 테스트 커밋
- TobySpringBoot-1: Decorator, Proxy 패턴 커밋