본 글은 Do I need an interface with Spring boot?을 번역한 글입니다.
잘 쓰여진 글을 정리 하는 겸 한글로 공유하고 싶어서 번역했습니다.
Spring boot를 사용하다보면, 종종 service
(@Service
annotation을 붙인 bean)을 사용하게 된다. 인터넷 상의 많은 예시에서, 사람들이 service
들을 위해서 interface
를 사용하는 걸 볼 수 있을것이다. 예를 들어서 , 우리가 todo 어플리케이션을 만든다고 할때, TodoService
라는 interface
와 TodoServiceImpl
이라는 구현체를 만들때가 있다.
이 포스트에서, 우리는 왜 그런 것을 하는지와 필요한가에 대해서 알아볼 것이다.
짧은 결론은 꽤나 간단하다. interface
를 만들 필요 없다. service
를 만든다고 하면, class의 자체의 이름을 TodoService
라고 하고 autowire
를 통해서 bean들에 주입하면 된다. 예를 들어서 이런 코드가 있다고 해보자.
@Service
public class TodoService {
public List<Todo> findAllTodos() {
// TODO: Implement
return new ArrayList<>();
}
}
@Component
public class TodoFacade {
private TodoService service;
public TodoFacade(TodoService service) {
this.service = service;
}
}
위에 있는 예시는 @Autowired
를 이용한 field injection을 사용하던 생성자 주입을 사용하던간에 작동할 것이다.
만약, 우리가 그게 필요하지 않다면... 왜 그런 방식(inteface
를 이용한 방식)을 종종 쓰곤 할까? 음, 첫 번째 이유는 사실 좀 역사적인것이다. 하지만 그걸 살펴보기 전에 , Spring에서 annotation이 어떻게 작동하는지를 설명해야만 한다.
만약 @Cacheable
같은 annotation을 사용한다고 하면, cache에서 결과를 얻을것이라고 예상할 수 있다. Spring에서 그것이 작동되는 방식은 bean들을 위한 proxy를 만들고 그 proxy들에 필요한 로직을 추가해주는것이다. 원래 스프링은 JDK dynamic proxies를 사용했다. 이 dynamic proxies는 오직 interface
들만을 위해서 만들어졌고, 이것이 예전에는 interface
를 작성해줘야 했던 이유다.
그러나, 10여 년 전부터 , Spring이 CGLIB proxying도 지원하기 시작했다. 이 proxy들은 별도의 interface
를 필요로 하지 않는다. 심지어 Spring 3.2 버전부터는 CGLIB가 Spring에 내장되어 있어서 별도로 추가해줄 필요도 없다.
아마 두 번째 이유는 두 class 간의 느슨한 결합을 만들기 위해서 일 것이다. interface
를 사용함으로써, service
에 의존하는 class는 더 이상 service
의 구현에 의존하지 않게 된다. 이것이 service
를 독립적으로 사용할 수 있게 해준다. 예를 들어서 이런 코드가 있다.
public interface TodoService {
List<Todo> findAllTodos();
}
@Service
public class TodoServiceImpl {
public List<Todo> findAllTodos() {
// TODO: Implement
return new ArrayList<>();
}
}
@Component
public class TodoFacade {
private TodoService service;
public TodoFacade(TodoService service) {
this.service = service;
}
}
그러나 위의 예시에서, 개인적인 의견으로 TodoFacade
와 TodoServiceImpl
이 함께 한다고 생각한다. 여기서 interface
를 추가하는건 추가적인 복잡도를 늘릴 수 있다. 개인적으로, 그만한 가치는 없어 보인다.
느슨한 결합이 유용한 부분은 여러 가지 구현체를 가질 때이다. 예를 들어서 TodoService가 두 가지 구현체를 가진다고 해보자. 하나는 todo 리스트를 메모리에서 가져오는 것이고, 하나는 DB와 같은 곳에서 가져오는 것이다.
public interface TodoService {
List<Todo> findAllTodos();
}
@Service
public class InMemoryTodoServiceImpl implements TodoService {
public List<Todo> findAllTodos() {
// TODO: Implement
return new ArrayList<>();
}
}
@Service
public class DatabaseTodoServiceImpl implements TodoService {
public List<Todo> findAllTodos() {
// TODO: Implement
return new ArrayList<>();
}
}
@Component
public class TodoFacade {
private TodoService service;
public TodoFacade(TodoService service) {
this.service = service;
}
}
이런 경우에선 느슨한 결합이 매우 유용한데, TodoFacade
가 todo가 메모리에 저장되어 있는지 DB에 저장되어 있는지 알 필요 없기 때문이다. 그건 Facade
의 책임이 아니라 어플리케이션 설정의 책임이다.
원하는 것에 따라서 구현방식은 달라진다. 만약에 TodoFacade
가 모든 구현체를 호출해야 한다면, collection
을 주입해야 한다.
@Component
public class TodoFacade {
private List<TodoService> services;
public TodoFacade(TodoService services) {
this.services = services;
}
}
만약 구현체 중에 하나가 99%의 상황에서 사용되고 나머지들은 아주 특수한 경우에만 사용된다면, @Primary
를 사용해라.
@Primary
@Service
public class DatabaseTodoServiceImpl implements TodoService {
public List<Todo> findAllTodos() {
// TODO: Implement
return new ArrayList<>();
}
}
@Primary
를 사용함으로써, Spring container에게 TodoService
에 의존성 주입을 해야할때, 이 구현체를 사용하라고 알려주는 것이다. 만약 다른 걸 사용해야 한다면, @Qualifier
를 사용하거나 특정 구현체를 주입함으로써 명시적으로 설정해야 한다. 개인적으로 난 이런 방식을 분리된 @Configuration
class에서 사용하는데, 그렇지 않으면 , TodoFacade
를 또 다시 구현체에 관한 정보들로 오염시키기 때문이다.
예시 코드를 보자.
@Configuration
public class TodoConfiguration {
@Bean
// Using @Qualifier
public TodoFacade todoFacade(@Qualifier("inMemoryTodoService") TodoService service) {
return new TodoFacade(service);
}
@Bean
// Or by using the specific implementation
public TodoFacade todoFacade(InMemoryTodoService service) {
return new TodoFacade(service);
}
}
느슨한 결합의 또 다른 방식은 IoC 혹은 제어의 역전이다. 개인적으로 서로에게 의존하는 여러 가지 module을 사용할 때 제어의 역전이 유용했다. 예를 들어서 OrderService
와 CustomerService
가 있다고 해보자. Customer는 자신의 profile을 삭제할 수 있고 그때 pending 상태의 order들은 취소되어야 한다. interface
없이 구현했다면, 이런 방식으로 할것이다.
@Service
public class OrderService {
public void cancelOrdersForCustomer(ID customerId) {
// TODO: implement
}
}
@Service
public class CustomerService {
private OrderService orderService;
public CustomerService(OrderService orderService) {
this.orderService = orderService;
}
public void deleteCustomer(ID customerId) {
orderService.cancelOrdersForCustomer(customerId);
// TODO: implement
}
}
이렇게 한다면, 상황은 매우 나빠질 수 있다. 어플리케이션 내부의 domain들이 모두 결합되게 되고, 결과적으로 강하게 결합된 어플리케이션을 만들게 될것이다.
그러는 대신에, CustomerDeletionListener
라는 interface
를 만들 수 있다.
public interface CustomerDeletionListener {
void onDeleteCustomer(ID customerId);
}
@Service
public class CustomerService {
private List<CustomerDeletionListener> deletionListeners;
public CustomerService(List<CustomerDeletionListener> deletionListeners) {
this.deletionListeners = deletionListeners;
}
public void deleteCustomer(ID customerId) {
deletionListeners.forEach(listener -> listener.onDeleteCustomer(customerId));
// TODO: implement
}
}
@Service
public class OrderService {
public void cancelOrdersForCustomer(ID customerId) {
// TODO: implement
}
}
@Component
public class OrderCustomerDeletionListener implements CustomerDeletionListener {
private OrderService orderService;
public OrderCustomerDeletionListener(OrderService orderService) {
this.orderService = orderService;
}
@Override
public void onDeleteCustomer(ID customerId) {
orderService.cancelOrdersForCustomer(customerId);
}
}
예시를 보면, 제어의 역전이 일어난 것을 볼 수 있다. 첫 번째 예시에서 우리가 OrderService
안에 있는 cancelOrderForCustomer()
를 바꾸면, CustomerService
역시 바뀌어야 한다. 이 말은 OrderService
가 제어되고 있다는 것을 말한다.
두 번째 예시에서는 OrderService
가 제어되고 있지 않다. 우리가 cancelOrderForCustomer()
를 변화시키면, 다른 module의 일부인 오직 OrderCustomerDeletionListener
만 바뀌어야 한다. 이것은 CustomerService
가 제어하고 있음을 말한다. 또, 두 service
들은 느슨하게 결합되어 있기 때문에, 하나가 다른 하나에 직접적으로 의존하고 있지 않다.
비록 두 번째 방법이 복잡도를 더 늘리긴 하지만 (class
와 interface
가 각각 한개씩 늘었으니) domain들이 서로 결합되지 않게 해준다. 리팩토링 하기가 쉬워지는 것이다. 이 listener
는 event-driven
한 구조로 리팩토링 될 수 있다. domain-driven modular design이나 MSA같은 구조로 리팩토링하기 쉽게 해주는 것이다.
마지막으로 말하고 싶은 건 테스트다. 몇몇 사람들은 dummy 구현체를 가지기 위해서 (여러 구현체를 가질 수 있으니) interface
가 필요하다고 주장하곤 한다. 하지만 Mockito같은 mocking 라이브러리가 이 문제를 해결해 준다.
단위 테스트를 작성할 때, MockitoExtension
을 사용할 수 있다.
@ExtendWith(MockitoExtension.class)
public class TodoFacadeTest {
private TodoFacade facade;
@Mock
private TodoService service;
@BeforeEach
void setUp() {
this.facade = new TodoFacade(service);
}
// TODO: implement tests
}
이 방법은 service
가 무엇을 하는지 몰라도 facade
를 적절히 테스트할 수 있게 해준다. Mockito.when()
을 사용함으로써 service
mock이 무엇을 반환하게 하는지 제어할 수 있고, Mockito.verfiy()
를 사용함으로써 특정 method가 호출되었는지 확인할 수 있다.
예시 코드다.
@Test
void findAll_shouldUseServicefindAllTodos() {
Todo todo = new Todo();
when(service.findAllTodos()).thenReturn(todo);
assertThat(facade.findAll()).containsOnly(todo);
verify(service).findAllTodos();
}
심지어 Spring container를 필요로 하는 통합 테스트를 작성할때도,@MockBean
annotation을 이용해서 bean들을 mock할 수 있다. 실제 구현체가 있는 package를 탐색하지 않게 해라.
@ExtendWith(SpringExtension.class)
@SpringBootTest(classes = TodoFacade.class)
public class TodoFacadeTest {
@Autowired
private TodoFacade facade;
@MockBean
private TodoService service;
}
그러니까 대부분의 경우에서, 테스트 할때 interface
는 필요하지 않다.
만약 개인적으로 interface
를 serivce
에 사용해야 하냐는 질문을 받는다면, 내 대답은 아니오다. 유일한 예외는 제어의 역전을 사용하거나 여러개의 구현체를 신경써야 하는 경우다.
만약의 경우를 위해서 interface
를 만드는 게 좋지 않겠냐고 생각할 수 있다. 개인적으로 여전히 아니오다. 첫 번째로, "You aren't going to need it"(YAGNI) 라는 원칙을 믿는다. 필요할지도 몰라 라는 이유로 복잡성을 높일 이유는 없는데 , 일반적으로 필요하지 않기 때문이다. 두 번째로 필요한 경우라도 전혀 문제 없다. 대부분의 IDE들은 기존의 class에서 method만 추출해서 interface
를 만들수 있게 해주고, 모든 코드들을 그 interface
를 사용하게끔 순식간에 만든다.
JDK Dynamic Proxy와 CGLIB의 차이점은 무엇일까?
퍼사드 패턴
느슨한 결합 vs 긴밀한 결합
How Mockito Works?
소프트웨어 개발 3대 원칙 : KISS,YAGNI,DRY
스프링 부트에
interface
가 필요한가에 대해서는 당연히 YES지만 이 글에선service
에interface
가 필요한지, 정확히 말하면service
의 구현체가 필요한지에 대해서 논하고 있습니다.
프로젝트를 시작하면서 Spring boot에서 구현체와 인터페이스를 구분해야하는지 고민이 많았는데 꽤나 자세하고 명쾌해서 도움이 되었습니다.
소프트웨어공학 수업에서 배운 YAGNI를 실제로 보니까 반갑네요. 그냥 무지성으로 외웠는데..
좋은 번역 감사합니다!!
저도 최근에 프로젝트를 하면서 "이거 굳이 interface 만들어서 받아야하나.." 이런 고민을 했는데 명쾌하게 해결되었습니다 !!