예전에 프로젝트를 하다 보면 Controller가 Service 구체 클래스를 의존하는 방식을 많이 사용했었는데,
이러한 설계 방식은 Controller가 Service 클래스 단 하나만 의존하기 때문에 다형성이 부족하다.
따라서 객체 지향 설계(DIP)와 향후 다음 버전에 대한 확장성을 위해 Service 계층에 인터페이스를 도입했다.
기존 설계 방식
<digram>
<Controller>
@RestController
@RequiredArgsConstructor
public class Controller {
private final Service service; // 클래스 직접 주입
}
<Service>
@Service
@RequiredArgsConstructor
public class Service {
private final Repository repository;
}
컨트롤러가 서비스 클래스를 직접 의존하고 있다.
이렇게 되면 컨트롤러는 서비스의 특정 클래스만 사용할 수 있다. 컨트롤러가 특정 서비스와 관련해서 단 하나의 클래스만을 의존하기 때문에 다형성이 적다고 할 수 있다.
만약 특정 서비스에 관련된 다른 구현체를 사용해야 하는 경우 private final Service2 service2 같은 코드로 변경해야 한다.
이렇게 서비스 변경에 대해 클라이언트 코드인 컨트롤러가 변경되는 것은 좋은 설계가 아니다.
또한 서비스를 하나로 제한하면 해당 서비스를 배포하고 다음 버전인 V2 API를 개발할 때 하나의 서비스에 V1과 V2를 함께 개발해야 할 수도 있다.
V2를 개발할 때 V1은 건드리지 않는 것이 좋다.
즉, 변경이 아닌 확장하는 설계가 필요하다.
변경된 설계 방식
<diagram>
<ControllerV1>
@RestController
@RequiredArgsConstructor
public class ControllerV1 {
private final ServiceV1 serviceV1; // 인터페이스 주입
}
<ServiceV1(interface)>
public interface ServiceV1 {
}
<ServiceV1Impl(class) - 구현체1>
@Service
@RequiredArgsConstructor
public class ServiceV1Impl implements ServiceV1 {
private final Repository repository;
}
이제 컨트롤러가 서비스 인터페이스에 의존한다.
서비스는 인터페이스 아래에서 컨트롤러 눈치보지 않고 자유롭게 구현체 클래스를 추가하거나 변경할 수 있다 → 이렇게 다형성을 높일 수 있다.
V2 API 개발
이제 현재 구조에서 V2 API를 개발해보자.
<diagram>
<ControllerV2>
@RestController
@RequiredArgsConstructor
public class ControllerV2 {
private final ServiceV1 serviceV1;
}
<ServiceV2Impl - 구현체2>
@Service
@RequiredArgsConstructor
public class ServiceV2Impl implements ServiceV1 {
private final Repository repository;
}
기존 V1을 건들지 않기 위해 ControllerV2와 ServiceV2Impl을 추가했다.
ControllerV2 역시 인터페이스를 의존함으로써 다형성을 확보할 수 있다.
그런데 이렇게만 설계하면 문제가 생길 수 있다.
ServiceV2Impl은 오로지 ServiceV1의 메서드만 재정의할 수 있다.
즉, 기존 V1을 V2로 업데이트하는 방식만 사용할 수 있는 것이다.
만약 V2만의 오리지널 메서드를 추가하고 싶다면?
해당 메서드를 ServiceV1 인터페이스에 추가하면 될까?
인터페이스에 메서드를 추가한다는 것은 ServiceV1Impl에서도 정의해야 한다는 뜻이다.
즉 ServiceV1Impl에서 V1과 관련 없는 V2를 정의해야 한다.
이런 상황을 방지하기 위해 상속을 활용하여 다음과 같이 설계를 할 수 있다.
상속
<diagram>
(그림이 조금 이상한데, ServiceV1Impl은 ServiceV1만 상속한다)
<ControllerV2>
@RestController
@RequiredArgsConstructor
public class ControllerV2 {
private final ServiceV1 serviceV1;
private final ServiceV2 serviceV2;
}
<ServiceV2>
public interface ServiceV2 {
}
<ServiceV2Impl>
@Service
@RequiredArgsConstructor
public class ServiceV2Impl implements ServiceV1, ServiceV2 {
private final Repository repository;
}
ServiceV2 인터페이스를 하나 추가했다.
1. ControllerV2가 기존 V1 기능을 사용해야 한다면, ServiceV1의 메서드를,
2. 기존 V1에서 V2로 업데이트한 기능을 사용해야 한다면, ServiceV2Impl에서 ServiceV1을 오버라이드한 메서드를,
3. 완전히 V2만의 새로운 기능을 사용해야 한다면, ServiceV2에만 메서드를 추가하고, ServiceV2Impl에서 구현한 메서드를,
호출하면 된다.
+) 또 다른 방법으로 인터페이스 간 상속을 활용해도 된다.
<diagram>
<ControllerV2>
@RestController
@RequiredArgsConstructor
public class ControllerV2 {
private final ServiceV2 serviceV2;
}
<ServiceV2>
public interface ServiceV2 extends ServiceV1 {
}
<ServiceV2Impl>
@Service
@RequiredArgsConstructor
public class ServiceV2Impl implements ServiceV2 {
private final Repository repository;
}
이런 경우 ControllerV2가 ServiceV2만 의존하면 된다는 장점이 있다.
합성
앞서 두 가지 방식 모두, V1, V2에 대한 다른 구현체를 사용한다는 장점이 있지만,
ServiceV2Impl이 ServiceV1 인터페이스의 모든 메서드를 구현해야 한다는 단점이 있다.
ServiceV1에서 수정할 필요가 없는 메서드도 똑같이 재정의 해야 하는 비용이 발생한다. (중복)
이처럼 코드를 재사용하는 과정에서 많은 비용이 발생하는 경우 상속보다는 합성을 사용하는 것이 좋다.
합성에 대한 자세한 설명은 아래 참고자료를 참고하자.
버저닝에 합성을 적용한 방식은 다음과 같다.
이처럼 ServiceV1과 ServiceV2를 독립적으로 관리할 수 있다. 단, ServiceV2Impl에서 ServiceV1을 주입한다.
@Service
@RequiredArgsConstructor
public class ServiceV2Impl implements ServiceV2 {
private final ServiceV1 serviceV1; // V1 메서드는 여기서 사용
public void test() { // V2에 새롭게 추가된 메서드
}
public SomeObj getCustomerInfo() {
return serviceV1.getCustomerInfo(); // V1 메서드를 중복 구현하지 않고, 그대로 호출
}
}
상위 버전 서비스가 하위 버전 서비스를 포함하는 구조.
getCustomerInfo()처럼 V2에서 V1의 메서드가 필요할 때, 그대로 호출 혹은 응용할 수도 있다.
여기까지 다형성과 API 버저닝을 위한 인터페이스 도입과 그에 따른 설계 방식에 대해 알아보았다.
결과적으로 각 버전에 관련된 클래스들을 독립적으로 관리할 수 있는 '합성' 방식이 가장 좋아보인다.
그러나 앞선 '상속' 방식들도 실제로 사용하는 방식이기에 알아두면 좋을 것이며,
다형성에 대한 첫 고민부터 '합성' 방식까지 도달하게 된 사고의 흐름을 기록하고자 했다.
+) 또 다른 관점: 중복 줄이기, 다형성 모두 중요하다! 그러나, ...
상속, 합성 방식은 코드의 복잡성이 증가하는 것이 느껴진다.
또한, 버전에 V2가 아닌 V3, V4, ... 점점 늘어나면 이 모든 것을 합성해야 할까?
...
실제 실무에서는 V2와 V1 간의 상속, 합성 관계를 맺지 않고,
그냥 V2는 V2대로, V1은 V1대로 관리하는 경우도 있다.
만약 V2에서 V1의 메서드가 필요하다면 V2에 V1 메서드를 그대로 중복해서 넣는 경우도 있다.
-> 이게 더 명확하기 때문이다. (매번 그런 것은 아니지만...)
사실 협업 과정에서 중요한 것 중 하나가 서로의 코드를 알아볼 수 있게끔 하는 것이다.
중복 제거, 다형성 확보, 좋은 패턴 등등 다 좋지만, 우선 이 코드를 동료가 봤을 때 명확하게 이해할 수 있는가부터 생각하자.
ex) API 버전을 올려야 해서, ControllerV2를 만들었는데,
1. V2 API가 UserServiceV1의 메서드를 그대로 사용해야 하면 UserServiceV1을 사용하자. (API 버전 업만 한다는 관점)
2. V2 API가 UserServiceV1의 메서드를 살짝 수정해야 한다면, UserServiceV2에 비슷한 메서드를 추가하자.
(중복을 허용하는 대신 상속과 같은 복잡한 관계를 줄인다)
3. V2 API가 사용자와 관련된 새로운 기능을 구현해야 한다면 UserServiceV2에 새롭게 추가하자.
좋은 패턴과 코드의 복잡성에 대한 고민은 개발하다 보면 늘 함께하는 것 같다.
개인의 취향, 팀의 성격, 서비스의 성격 등을 고려해서 각 상황에 맞는 의사결정이 필요하다.
예를 들어, 산출물을 빠르게 내야 하고, 도메인에 대한 서로 간의 이해를 위해 명확하게 코딩을 짜야할 필요가 있는 서비스 개발을 하고 있다면, 중복을 어느 정도 허용하는 것도 좋다.
혹은, 안정성과 확장성이 매우 중요한 아키텍처 개발을 하고 있다면, 중복 제거, 다형성이 중요할 수 있다.
의존 관계 자동 주입
앞선 글과 별개로 그동안 설계를 짜면서 문득 궁금한 점이 생겼다.
<diagram>
아무리 다형성을 위해 위와 같이 설계했다 하더라도 결국 컨트롤러가 ServiceV1의 어떤 구현체를 주입하는지에 대한 정보는 어딘가에 있어야 한다.
Spring에서는 이를 @Qualifier 애노테이션을 통해 제공한다. 추가 구분자를 붙여주는 방법인데, 코드로 표현하면 다음과 같다.
<Controller>
@RestController @RequiredArgsConstructor public class ControllerV1 {
@Qualifier("V1") // 추가
private final ServiceV1 serviceV1;
}
<Service>
@Service
@RequiredArgsConstructor
@Qualifier("V1") // 추가
public class ServiceV1Impl implements ServiceV1 {
private final Repository repository;
}
이렇게 컨트롤러와 서비스 구현체 각각에 @Qualifier를 붙이면 태그같이 작용하여 컨트롤러에 ServiceV1Impl을 자동으로 주입한다.
다형성에 대한 의문
여기서 살짝 의문이 생긴다.
Q: 의존관계 주입을 위한 태그를 컨트롤러와 구현클래스에 적용한다면 결국 컨트롤러가 구현체를 의존하고 있는 것 아닐까?
A: 그렇다. 그러나 정확히는 구현체가 아닌 구현체의 이름에 의존하고 있는 것이다.
아무리 인터페이스를 도입해도 의존관계 주입에 대한 정보는 어딘가 반드시 있어야 한다. 의존 관계를 세팅하기 위해,
@Configuration 애노테이션을 통해 설정을 위한 클래스를 만들어도 되고,
따로 xml 파일과 같은 설정 파일을 만들 수도 있다.
그러나 개발을 하다보면 여러 계층에 여러 클래스들을 만들게 되고 그때마다 설정 파일에 의존관계 주입 설정을 하기에는 무리가 있다.
이러한 부분 때문에 Spring은 @Component, @Autowired 등 클래스에 달기만 하면 자동으로 의존관계를 주입해주는 여러 편리한 애노테이션을 제공한다.
이와 비슷한 성격으로 여러 객체에 대한 의존 가능성이 있을 때, 간편하게 주입을 세팅할 수 있는 것이 바로 @Qualifier이 다.
구현체에 대한 이름을 의존하는 것은 어쩔 수 없지만 그만큼 생산성을 높일 수 있다.
나는 @Qualifier를 선택했다.
우선 간편하기 때문에 생산성 측면에서 장점이 있고,
구현 클래스를 의존하는 것은 구현 클래스만 사용할 수 있는 것이지만,
구현 클래스의 이름을 의존하는 이 방식은 결국 인터페이스를 의존하는 것이기 때문에 다형성을 상당히 확보할 수 있다.
개발을 하면 항상 이런 트레이드오프에 대해 나는 어떤 선택을 할 것이며 그 선택에 대한 이유는 무엇인지 고민할 필요가 있겠다.
참고자료
'java > spring' 카테고리의 다른 글
[SpringMVC] 타임리프 - 기본기능 (0) | 2023.07.08 |
---|---|
[Spring] DTO의 사용 범위 (0) | 2023.06.27 |
[Spring] 의존성 역정 원리(DIP) 관련 용어 (0) | 2023.06.23 |
[Spring] @TransactionalEventListener에서 예외가 발생하지 않는 이슈 (2) | 2023.06.23 |
[Spring] 스프링 디렉터리 패키지 구조 (0) | 2023.06.22 |