안녕하세요. 스프링 백엔드 개발자 stark입니다!
백엔드 업무를 하면서 개발자들끼리 코드 구조에 대한 대화를 하다 보면 의존성과 결합도라는 용어를 정말 많이 사용합니다. 개발에서 얘기하는 의존성 그리고 결합도는 뭘 의미하는 걸까요? 이 궁금증을 해소하기 위해 이번 포스팅에서는 의존성과 결합도에 대해 가볍게 알아보고 의존성 주입(DI: Dependency Injection)을 통해 어떻게 높은 의존성을 풀어나가는지 알아봅시다.
의존성이란 무엇인가?
의존성은 쉽게 말해 한 클래스가 다른 클래스의 기능에 의존한다는 것을 의미합니다. 예를 들어, OrderService 클래스가 주문 처리를 위해 PaymentService의 기능(메서드)을 필요로 한다면, OrderService 클래스는 PaymentService 클래스에 의존하고 있는 것입니다. 이는 마치 자동차가 움직이려면 '엔진, 바퀴, 연료'와 같은 다양한 구성 요소에 의존하여 움직이는 것과 비슷합니다.
좀 더 설명드리면 자바에서는 한 클래스가 다른 클래스의 기능이나 데이터를 필요로 할 때 이를 의존성을 가진다고 말합니다. 코드 구조가 의존성이 높으면 재사용성과 유지보수성이 낮아지며, 변경에 취약한 구조가 될 수 있습니다.
+---------------+ depends on +----------------+
| OrderService | ----------------------> | PaymentService |
+---------------+ +----------------+
위 다이어그램에서 OrderService 클래스는 PaymentService 클래스에 의존합니다. 즉, 주문 처리를 위해 결제 서비스의 기능을 필요로 합니다. 이러한 의존성이 있을 때, 만약 PaymentService 클래스가 변경된다면 OrderService 클래스에도 영향을 미칠 수 있습니다.
참고로 의존성은 개발자가 직접 만든 클래스들끼리의 관계에서도 존재하고, 외부 라이브러리와의 관계에서도 존재합니다. 만약 의존성이 증가하면 시스템은 더욱 복잡해지고, 의존하고 있는 코드가 변경되는 순간 의존성을 가진 코드 또한 수정이 필요할 수 있으므로 외부의 변화에 쉽게 깨질 수 있는 구조로 변하게 됩니다.
클래스가 외부 라이브러리에 의존한다는 것을 코드로 이해해 봅시다.
Apache Commons Lang 외부 라이브러리를 사용해 문자열 조작을 처리하는 코드를 만들어 보겠습니다.
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
@Service
public class StringService {
public String reverseString(String input) {
if (StringUtils.isBlank(input)) {
return "입력값이 비어있습니다.";
}
return StringUtils.reverse(input);
}
}
StringUtils는 Apache Commons Lang 라이브러리의 일부이며, 지금 이 라이브러리에 의존하는 StringService 클래스는 이 라이브러리가 없으면 제대로 실행될 수 없습니다. 이런 상황이 바로 외부 라이브러리와의 의존성을 나타냅니다.
생각해 보면 객체지향 설계에서 클래스 간의 소통은 필수입니다. 그렇기에 클래스 간의 의존성을 아예 없앨 수는 없습니다. 그래도 우리는 어떻게든 개선점을 찾으려고 노력하고 있습니다. 그리고 우리의 멋진 선배님들께서는 이미 이것에 대해서 생각하셨고 의존성을 느슨하게 만들 수 있는 방법을 만들어주셨습니다. 바로 '인터페이스'를 사용하는 것입니다. SOLID원칙에도 의존 역전 원칙(DIP)으로 인터페이스를 사용하는 것을 권장할 정도로 인터페이스는 클래스 간 의존 관계를 다루는데 중요한 역할을 합니다.
의존성이 높아진다는 것은 무슨 말일까요?
좋은 의존성 관리는 인터페이스 선언으로부터 시작됩니다.
public interface PaymentService {
void pay();
}
이제 의존성이 높은 코드를 봅시다.
OrderService 클래스의 필드에는 paymentService 변수가 선언되어 있습니다. 그리고 이 변수에는 PaymentService 인터페이스의 구현체인 PaymentServiceImpl 클래스의 인스턴스를 생성해서 바로 초기화시키고 있습니다.
@Service
public class OrderService {
// 직접 객체 생성 (의존성 높음)
private PaymentService paymentService = new PaymentServiceImpl();
public void processOrder(Order order) {
// ...
}
}
이러한 방식으로 코드를 작성하게 되면 클래스 간의 결합도가 매우 높아집니다. 그래서 만약 PaymentServiceImpl(구현체)에 변경사항이 생기면(예를 들어 생성자 변경) OrderService 클래스도 수정해야 하므로 직접적인 영향을 미칩니다. 이로 인해 코드의 유지보수성과 테스트가 어려워집니다.
다음으로 의존성이 낮은 코드를 살펴봅시다. (스프링의 IOC를 이용한 의존성 주입 활용)
이번 코드에서는 PaymentService 인터페이스만을 의존하고 있다는 것을 확인할 수 있습니다.
@Service
@RequiredArgsConstructor
public class OrderService {
// 스프링 컨테이너를 통해 IOC(제어의 역정)로 외부에서 클래스 주입받기 (의존성 낮음)
private final PaymentService paymentService;
public void processOrder(Order order) {
// ...
}
}
OrderService 클래스는 이전처럼 PaymentService의 구현체 인스턴스를 직접 생성해서 초기화하는 것이 아니라 스프링을 통해 주입(IOC) 받도록 하고 있습니다. 이 방식은 클래스 간의 결합도를 낮추어서 PaymentService 인터페이스의 구현체를 쉽게 교체하거나 테스트할 수 있도록 해줍니다.
참고사항
@RequiredArgsConstructor는 클래스에 private final로 선언된 필드에 대한 생성자를 자동으로 만들어줍니다. 또한 스프링에서 필드에 생성자가 1개일 때는 @Autowired를 생략해도 스프링이 알아서 @Autowired를 생성자에 적용시켜서 생성자 주입이 됩니다.
우리는 왜 의존성을 관리해야 하는가?
이제부터가 진짜입니다. 모든 개발자들이 입을 모아 의존성에 대해서 말하지만, 정작 "왜 의존성을 관리해야 하지?"라는 의문이 생길 수 있습니다. 특히 멋진 IT 회사의 개발자분들께서 발표하신 세미나에서는 결합도를 낮춘다, 확장성이 좋아진다... 이렇게 말을 많이 합니다. 하지만, 실제로 이게 어떤 의미인지 잘 와닿지 않을 때도 많습니다. 그래서 지금부터는 여러분들이 실무에서 흔히 마주치는 문제들을 통해 의존성 관리의 중요성을 이해해 보도록 하겠습니다.
먼저 간단한 예시로 시작해 보겠습니다. 주문 처리를 하는 OrderService 클래스가 있다고 해봅시다. 이 OrderService는 결제를 처리하기 위해 PaymentService라는 인터페이스에 의존하고 있습니다. 간단하게 코드를 구성하며 상황을 이해해 봅시다.
먼저 결제를 하는 PaymentService라는 인터페이스를 구성합니다.
public interface PaymentService {
void pay();
}
이제 PaymentService 인터페이스를 구현하는 서비스 클래스를 구성해 봅시다.
이름만 봐도 쟁쟁한 굉장히 유명한 kakao, naver, toss가 결제를 위해 기다리고 있습니다.
@Service
class KakaoPayServiceImpl implements PaymentService {
@Override
public void pay() {
// 카카오페이 결제 로직
}
}
@Service
class NaverPayServiceImpl implements PaymentService {
@Override
public void pay() {
// 네이버페이 결제 로직
}
}
@Service
class TossPayServiceImpl implements PaymentService {
@Override
public void pay() {
// 토스페이 결제 로직
}
}
의존성이 높은 코드를 봅시다.
이전 예시코드와는 다르게 필드가 아니라 생성자에서 초기화를 진행하지만 생성자를 보면 PaymentService 인터페이스를 구현한 KakaoPayServiceImpl 클래스의 인스턴스를 생성해서 직접 초기화시키는 것은 동일합니다. 덕분에 OrderService 클래스는 KakaoPayServiceImpl라는 결제 구현체에 강하게 결합되었습니다.
@Service
public class OrderService {
private final PaymentService paymentService;
// 직접 결합 (특정 구현체에 의존)
public OrderService() {
this.paymentService = new KakaoPayServiceImpl();
}
}
이렇게 클래스 간 결합도가 높은(강한) 상황에서는 어떤 문제가 있을까요?
1. 다른 결제 서비스로 교체하기 어렵다.
만약 여러분의 서비스가 성장해서 TossPay나 NaverPay 같은 새로운 결제 수단을 추가하고 싶다면 어떻게 될까요? OrderService 클래스를 열어 결제 관련 생성자 부분을 직접 수정해야 합니다. (KakaoPayServiceImpl을 TossPayServiceImpl로 직접 변경해야 합니다.)
2. 테스트가 어렵다.
테스트를 위해 가짜 결제 객체(Mock)를 사용하고 싶은데, 이 코드에서는 KakaoPayServiceImpl 구현체에 직접 의존하고 있으니 그게 어렵습니다. 만약 KakaoPayServiceImpl이 실제로 외부 API를 호출하는 방식으로 작성되어 있다면, 테스트 시 실제 결제가 일어나 버릴 위험도 있습니다. (이 내용은 잘 이해가 가지 않을 수도 있으니 하단에서 좀 더 자세하게 살펴봅시다.)
3. 변경에 취약하다.
KakaoPayServiceImpl 구현체 클래스의 생성자에 변경이 생긴다면 OrderService 클래스도 수정해야 합니다. 서로 강하게 묶여있기 때문에 한 클래스에 변화가 발생한다면 결합된 쪽의 클래스에도 영향을 주게 됩니다.
4. 유연하게 코드를 유지하기가 매우 어렵습니다.
이렇게 결합도가 높은 경우에는 코드를 유연하게 유지하기 어렵습니다.
자 의존성 주입을 통해 의존성을 낮춘 코드를 다시 봅시다.
이제 OrderService 클래스의 생성자에서는 new를 통해 PaymentService 인터페이스의 구현체 인스턴스를 직접 생성해서 초기화시키지 않습니다. 생성자를 보면 주는 값을 받아서 그대로 paymentService 변수에 대입해 줍니다.
@Service
public class OrderService {
private final PaymentService paymentService;
@Autowired
public OrderService(PaymentService paymentService) { // 인터페이스에 의존
this.paymentService = paymentService;
}
}
또한 OrderService 클래스는 PaymentService라는 인터페이스만 의존하게 되었습니다. PaymentService 인터페이스의 구현체로 kakao, naver, toss 중 어떤 결제서비스 구현체가 주입될지는 스프링이 관리해 주게 됩니다. 그렇다면 이 방법은 어떤 장점이 있을까요?
1. 구현체를 교체하기 쉬워집니다.
만약 결제 수단을 KakaoPay에서 NaverPay로 변경하고 싶다면, 단순히 설정만 바꿔주면 됩니다. 이제 저희는 OrderService 코드에는 직접 손댈 필요가 없습니다.
2. 테스트가 용이합니다.
테스트 시 MockPaymentServiceImpl와 같은 가짜 구현체 인스턴스를 주입해서 테스트할 수 있습니다. 실제 결제 프로세스가 일어날 걱정 없이, 로직만 테스트할 수 있습니다. 이제 테스트를 두려워하지 않아도 됩니다.
3. 변경에 유연하게 대응할 수 있습니다.
PaymentService 인터페이스를 구현한 구현체 클래스(kakao, naver, toss)의 내부 로직이 변경되더라도, OrderService 클래스는 인터페이스만을 의존하므로 구현체 코드 변경에 대한 영향을 받지 않습니다. 각 클래스별로 책임이 분리되어 있기 때문에 서로 독립적으로 변화할 수 있습니다. 물론 인터페이스가 변경된다면 그때는 OrderService를 수정해야 할 수도 있습니다.
근데 의존성이 높으면 왜 테스트하기 어렵다는 거지?
아래의 구현체 인스턴스를 직접 만들어서 대입하는 코드를 봅시다.
@Service
public class OrderService {
// 직접 객체 생성
private final PaymentService paymentService = new KakaoPayServiceImpl();
public void processOrder(Order order) {
// 결제 로직
paymentService.pay();
}
}
이렇게 new로 직접 인스턴스를 만들어서 필드에서 초기화를 하는 경우 테스트 시 문제가 많이 생깁니다. 특히 지금 같은 경우에는 KakaoPayServiceImpl 구현체가 실제 카카오 API를 호출하게 되면서 굉장히 위험한 상황이 발생합니다.
코드를 보면 클래스 생성과 동시에 KakaoPayServiceImpl 구현체의 인스턴스가 생성되어 paymentService에 대입됩니다. 이게 정말 큰 문제입니다. 만약 테스트를 위해 테스트 클래스에 OrderService를 생성하게 되면 클래스가 생성되면서 KakaoPayServiceImpl 구현체가 paymentService에 바로 생성되면서 초기화됩니다.
이 상황에서 processOrder() 메서드가 실행되면 paymentService 변수에 초기화된 실제 KakaoPayServiceImpl 구현체 API가 호출됩니다. 이건 정말 큰 문제입니다. 만약 이게 뭐가 문제라는 거지? 이런 생각이 드셨다면 천천히 한번 더 문제점을 생각해 보시는 것을 추천합니다.
생각해 보셨나요? 이 경우 실제 API 환경에서 테스트를 하게 되므로 네트워크 연결이 필요하며 심지어 잘못하면 실제 결제가 이루어져서 상상도 못 했던 아주 큰 문제가 발생할 수도 있습니다. 단위 테스트는 외부 환경과 독립적으로, 빠르고 안정적으로 수행되어야 하므로 지금 같은 상황은 단위 테스트의 기본 원칙에 맞지 않습니다.
의존성이 높으면 확장성에 문제가 있다는 것은 무슨 말이야?
우리가 만든 서비스가 성장하다 보면 분명 더 많은 결제 수단을 지원해야 할 것입니다.
의존성이 높은 경우 회사의 결제 시스템에 TossPay와 NaverPay를 새롭게 추가하려고 하면 이렇게 코드가 바뀝니다.
@Service
public class OrderService {
// 기존 코드
private final PaymentService paymentService = new KakaoPayService();
// 새로운 결제 수단 추가시...
private final PaymentService tossPaymentService = new TossPayService();
private final PaymentService naverPayService = new NaverPayService();
}
벌써부터 코드가 지저분해 보이지 않나요? 새로운 결제 수단이 추가될 때마다 OrderService 클래스는 계속 수정이 필요합니다.
그럼 의존성이 낮아지면 어떻게 될까요?
의존성이 낮아지면 OrderService가 특정 결제 수단의 구현체에 직접적으로 의존하지 않게 되어, 새로운 결제 수단이 추가되더라도 OrderService를 수정할 필요가 없어집니다. 이처럼 의존성을 줄이는 가장 일반적인 방법 중 하나는 인터페이스와 의존성 주입을 사용하는 것입니다.
이 예시에서는 OrderService가 결제 수단에 대한 구체적인 구현체가 아닌 PaymentService라는 인터페이스에만 의존하도록 변경할 수 있습니다. 그리고 새로운 결제 수단이 추가될 때는 Spring의 DI(Dependency Injection) 또는 Factory 패턴을 활용해 동적으로 결제 수단을 주입할 수 있습니다.
@Service
public class OrderService {
private final PaymentService paymentService;
// 생성자 주입을 통해 PaymentService 의존성 주입
public OrderService(PaymentService paymentService) {
this.paymentService = paymentService;
}
// 결제 프로세스 메서드
public void processOrder() {
paymentService.pay();
}
}
위와 같이 OrderService는 이제 PaymentService 인터페이스에만 의존합니다. 이로 인해 어떤 결제 서비스가 오더라도 OrderService는 그에 대한 직접적인 정보 없이도 결제를 처리할 수 있습니다.
지금까지의 내용을 정리해 보면 결합도가 높은 코드는 유지보수성과 확장성이 낮고, 테스트가 어렵다는 여러 문제를 가지고 있습니다. 반면에 의존성 주입을 통해 결합도를 낮추면 코드의 변경에 유연해지고, 테스트가 쉬워지며, 새로운 요구사항이 생겼을 때 코드 수정 없이도 설정만으로 대응할 수 있는 장점이 있습니다. 이 모든 것은 객체 간의 관계를 적절히 관리하고 스프링이 제어의 역전(Inversion of Control)을 구현했기 때문에 가능한 일입니다.
이제 의존성은 그만하고 결합도를 알려줘!
의존성에 대해 얘기하다 보면 강한 결합도(High Coupling)라는 말 또한 자주 접하게 됩니다. 모듈 간에 서로 구체적으로 참조가 일어나고, 클래스의 의존관계가 인터페이스가 아닌 구현체에 있어 의존성이 강하게 존재하는 상황을 강한 결합도라고 합니다. 결합도가 높다는 것은 하나의 모듈이 다른 모듈에 구체적으로 의존하고 있어서, 한쪽이 변경되면 다른 쪽도 영향을 받는다는 것을 의미하기도 합니다.
이러한 상황에서는 새로운 기능을 추가하거나 기존 기능을 수정할 때, 예상치 못한 영향으로 인해 버그가 발생할 수 있고, 유지보수가 점점 어려워질 수 있습니다.
결제 시스템을 예시로 강한 결합이 무엇인지 확인해 봅시다.
아래 코드는 결제 시스템에서 사용되는 OrderService 클래스입니다. 이 클래스는 KakaoPayService와 TossPayService라는 구체적인 결제 서비스 구현체에 강하게 의존하고 있습니다.
@Service
public class OrderService {
// 구체적인 결제 서비스에 의존
private final PaymentService kakaoPayService = new KakaoPayService();
private final PaymentService tossPayService = new TossPayService();
public void processOrder(String paymentType) {
if (paymentType.equals("KakaoPay")) {
kakaoPayService.pay();
} else if (paymentType.equals("TossPay")) {
tossPayService.pay();
} else {
throw new IllegalArgumentException("지원하지 않는 결제 수단입니다.");
}
}
}
이 코드를 보면 OrderService 클래스가 PaymentService 인터페이스가 아니라 KakaoPayService와 TossPayService라는 구체적인 클래스에 의존하고 있다는 것을 쉽게 알 수 있습니다. 결제 수단이 추가될 때마다 OrderService를 수정해야 하고, 이렇게 될 경우 서비스가 점점 복잡해지면서 유지보수가 어려워집니다. 또한 결제 서비스가 변경되면 OrderService도 함께 수정해야 하기 때문에 유연성이 떨어집니다.
위 코드처럼 강한 결합도가 존재하면 다음과 같은 문제가 발생할 수 있습니다.
1. 확장성 제한
결제 수단을 추가할 때마다 OrderService를 수정해야 합니다. 코드가 점점 복잡해지고 유지보수도 어려워집니다.
2. 유연성 저하
구체적인 구현체에 의존하지 않고 인터페이스에 의존하도록 작성하면 더 유연해질 수 있지만, 강한 결합도는 이러한 유연성을 제한합니다.
3. 테스트의 어려움
강하게 결합되어 있으면 테스트 시 OrderService와 결제 서비스들을 독립적으로 테스트하기 어렵고, 전체 시스템을 테스트하는 방식에 의존하게 되어 테스트가 비효율적입니다.
글을 읽다 보니 의존성과 결합도는 같은 개념 같은데?
특히 이 두 가지 개념은 밀접한 관계이기에 더 그렇게 느껴집니다. 예를 들어 클래스에서 다른 클래스를 의존하는 순간부터는 결합이 생기게 됩니다. 이렇게 이 두 가지는 한 세트라고 볼 수도 있습니다. 그리고 지금까지 제가 보여드린 예시코드가 모두 동일하기 때문에 더 헷갈리는 것도 있습니다. 그럼 의존성과 결합도는 어떻게 다른 걸까요? 먼저 이 두 가지 개념을 다시 한번 이해해 봅시다.
의존성 (Dependency)
의존성이란 어떤 클래스나 모듈이 다른 클래스나 모듈의 기능을 사용해야 하는 관계를 의미합니다. 즉, OrderService 클래스에서 PaymentService 클래스의 기능(메서드)을 필요로 한다면, OrderService는 PaymentService에 의존한다고 볼 수 있습니다.
결합도 (Coupling)
결합도는 이 의존 관계가 얼마나 강하게 연결되어 있는지를 나타냅니다. 결합도가 높으면 두 클래스 간의 연결이 강하게 묶여 있어 변경 시 서로 영향을 받기 쉽고, 결합도가 낮으면 변경이 일어나더라도 영향을 덜 받는 구조입니다.
이렇듯 의존성과 결합도는 밀접하게 연결된 개념일 뿐 전혀 다릅니다. 간단히 말해서, 의존성은 어떤 모듈이 다른 모듈의 기능을 필요로 한다는 관계를 의미하고, 결합도는 그 관계가 얼마나 강하게 묶여 있는지를 뜻합니다.
OrderService 클래스가 PaymentService 인터페이스의 구현체인 KakaoPayService와 TossPayService에 의존하는 지금 상황을 봅시다.
@Service
public class OrderService {
// 구체적인 결제 서비스에 의존
private final PaymentService kakaoPayService = new KakaoPayService();
private final PaymentService tossPayService = new TossPayService();
public void processOrder(String paymentType) {
if (paymentType.equals("KakaoPay")) {
kakaoPayService.pay();
} else if (paymentType.equals("TossPay")) {
tossPayService.pay();
} else {
throw new IllegalArgumentException("지원하지 않는 결제 수단입니다.");
}
}
}
선언된 타입은 인터페이스(PaymentService)이지만, 실제로는 구현체(KakaoPayService, TossPayService)를 new 키워드로 생성하여 초기화하고 있기 때문에 구체적인 구현체에도 의존하고 있는 셈입니다. 이 경우, 다음과 같은 문제가 발생합니다.
1. 강한 의존성
OrderService는 필드에 타입으로 선언된 PaymentService 인터페이스만을 의존하는 것이 아니라 초기화 시에 특정 구현체(KakaoPayService, TossPayService)에 직접적으로 의존하므로, 구현체가 변경되거나 새로운 구현체가 추가될 때 OrderService를 수정해야 합니다.
2. 강한 결합도
구체적인 클래스를 의존하면 인터페이스를 사용하는 경우보다 결합도가 강해집니다. 이는 클래스 간의 연결이 단단히 묶여 있다는 것을 의미하며 변경에 자유롭지 못하기에 코드의 유연성이 떨어지고 테스트나 유지보수가 어려워집니다.
그럼 강한 결합도를 낮추고 의존성을 줄이면 되지!
참고: 필드에 같은 인터페이스 타입의 멤버변수가 2개 선언되어 있어서 @Qualifier로 구현체를 지정해 줍니다.
강한 결합도를 낮추고 의존성을 줄이는 방법은 구체적인 클래스가 아닌 인터페이스에만 의존하도록 설계하는 것입니다. 우리의 예시로 보자면 OrderService 클래스가 결제 서비스(PaymentService)의 구체적인 구현체가 아닌 인터페이스(PaymentService)만 의존하도록 변경하는 것입니다. 이를 통해 새로운 결제 수단이 추가되더라도 OrderService 클래스를 수정할 필요가 없게 만들 수 있습니다.
// OrderService는 결제 인터페이스만 의존하며, 구현체에 대한 정보는 없습니다.
@Service
public class OrderService {
private final PaymentService kakaoPayService;
private final PaymentService tossPayService;
// 각각의 결제 서비스를 직접 주입받음
@Autowired
public OrderService(@Qualifier("KakaoPayService") PaymentService kakaoPayService,
@Qualifier("TossPayService") PaymentService tossPayService) {
this.kakaoPayService = kakaoPayService;
this.tossPayService = tossPayService;
}
// 결제 유형에 따라 동적으로 결제 서비스를 선택하여 결제 처리
public void processOrder(String paymentType) {
if (paymentType.equals("KakaoPay")) {
kakaoPayService.pay();
} else if (paymentType.equals("TossPay")) {
tossPayService.pay();
} else {
throw new IllegalArgumentException("지원하지 않는 결제 수단입니다.");
}
}
}
수정한 위의 코드에서는 OrderService가 생성자를 통해 각각의 결제 서비스 구현체를 하나씩 주입받고 있습니다. new를 통해 구현체 인스턴스를 생성하는 로직은 하나도 보이지 않습니다. @Autowired와 @Qualifier 어노테이션을 통해 각 결제 서비스가 어떤 것인지 명확하게 지정할 수 있으며, 이를 통해 스프링이 구현체를 주입해 주어 결합도를 낮추고 더 명시적인 의존성 관리가 가능합니다.
이 방법을 사용하면, 새로운 결제 서비스가 추가될 때마다 필요한 서비스를 생성자로 주입받을 수 있습니다. 이를 통해 결합도를 낮추고 더 깔끔하고 유지보수가 쉬운 코드를 작성할 수 있습니다.
여기서 하나 더 짚고 넘어갈 것은 스프링의 IOC(제어의 역전) 기능이 있기에 우리는 인터페이스에 대한 구현체를 간단하게 @Service(빈 등록), @Autowired, @Qualifier 어노테이션만으로 생성자에 주입할 수 있게 되었다는 것입니다. 저는 이런 것을 알게 될 때마다 개발을 쉽게 할 수 있도록 도와주는 스프링에 대해 놀라움과 감사한 마음이 생깁니다.
결합도는 개선된 것 같은데 구조가 마음에 안 들어
방금 제가 보여드린 예시코드를 보면 OrderService 클래스 내부에 같은 PaymentService 인터페이스 타입을 가지는 필드가 2개 선언되어 있습니다. 이런 구조는 명확도가 떨어진다고 생각해서 조금 불편합니다. 그러니 이제부터 이 구조를 개선해 봅시다.
저는 클래스를 예쁘게 구성하기 위해서 한 클래스 내부에 동일한 인터페이스를 가지는 필드를 중복으로 선언해서는 안된다고 생각합니다. 대신 클래스 내부에 동일한 인터페이스 타입을 가지는 필드를 한 개만 선언해 두고 구현체도 필요한 것을 한 개만 주입받도록 설계하는 것이 명확성을 높이는 방법이라고 생각합니다. 이렇게 구성하면 이전의 OrderService 예시코드처럼 생성자에서 @Qualifier 어노테이션을 써가면서 주입받을 구현체를 직접 지정해 주면서 관리해야 하는 복잡성을 줄일 수 있습니다.
그럼 어떻게 한 가지 구현체만 주입하도록 할 수 있을까요? @Primay를 사용하면 됩니다!
상황을 다시 돌아보면 지금처럼 PaymentService 인터페이스를 구현한 구현체 클래스(toss, naver, kakao)가 여러 개 작성되어 있는 상황에 아래와 같이 코드를 변경해서 1개의 인터페이스에 1개의 구현체를 주입받아야만 한다면 스프링은 이것들 중 어떤 구현체를 주입해줘야 할지 고민하게 될 것입니다.
@Service
public class OrderService {
private final PaymentService paymentService;
@Autowired
public OrderService(PaymentService paymentService) {
this.paymentService = paymentService;
}
public void processOrder() {
paymentService.pay();
}
}
물론 위에서 보여드린 예제처럼 생성자에서 @Qualifier로 빈 이름을 지정해서 구현체를 직접 지정해 줄 수도 있지만 @Qualifier를 사용하는 경우 OrderService 클래스에 주입될 PaymentService 인터페이스의 구현체를 변경하고 싶다면 OrderService 클래스 내부에 선언된 생성자의 코드를 직접 변경해줘야 한다는 단점이 있습니다.
이 경우 우아한 해결방법으로 @Primary 어노테이션을 사용하면 스프링이 이 어노테이션이 적힌 구현체를 선택해서 주입시켜 주기 때문에 간단하게 문제를 해결할 수 있습니다. 만약 여러 구현체를 선언했지만 필요한 것은 한 개라면 이렇게 필요한 구현체 클래스에 @Primary를 적어주시면 됩니다. 이제 OrderService 클래스 내부에서는 구체적인 결제 구현체를 @Qualifier를 통해 지정할 필요가 없고 덕분에 어떤 구현체가 주입될지도 알 필요가 없어졌습니다. OrderService 클래스에는 아무것도 손대지 않지만 스프링 컨텍스트에서 필요한 구현체를 선택해서 명확하게 주입해 주게 됩니다.
public interface PaymentService {
void pay();
}
@Primary
@Service
public class KakaoPayServiceImpl implements PaymentService {
@Override
public void pay() {
System.out.println("카카오페이로 결제합니다.");
}
}
@Service
public class TossPayServiceImpl implements PaymentService {
@Override
public void pay() {
System.out.println("토스페이로 결제합니다.");
}
}
위의 코드처럼 KakaoPayServiceImpl에 @Primary를 붙였기 때문에 PaymentService 인터페이스 타입으로 빈(클래스) 주입 요청 시 스프링에서는 기본적으로 KakaoPayServiceImpl을 주입시켜 줍니다. 덕분에 OrderService 클래스는 PaymentService 인터페이스에 주입된 KakaoPayServiceImpl 구현체를 기본적으로 사용하며, 명시적인 결제 방식 변경은 필요하지 않게 됩니다.
근데 이런 생각이 드실 수도 있습니다. 만약 OrderService 클래스에서 주문처리 시 지금처럼 PaymentService 인터페이스를 구현하는 결제기능(naver, toss, kakao)중 한 가지를 선택해서 결제를 진행해야 한다면? 그럼 하나만 주입받으면 다른 건 동작 안 하니까 문제가 생기는 거 아닌가? 이렇게 생각하실 수 있습니다.
맞습니다. 이런 경우에는 아래처럼 코드를 구성해서 상위 레벨의 결제 처리 전략으로 전환하는 방법이 필요합니다.
OrderService 클래스가 다양한 결제 방식에 대해 직접 구현체를 관리하기보다는, 결제 방식 선택을 상위 계층에서 처리하는 것이 더 바람직합니다. 결제 로직을 담당하는 별도의 서비스 계층(PaymentContext)이나 전략 패턴을 도입하여 OrderService는 결제 요청만 위임하고, 구체적인 결제 방식 선택은 다른 곳에서 처리하도록 하는 것입니다.
// 인터페이스 선언
public interface PaymentStrategy {
void pay();
}
// 구현체 작성
@Service
public class KakaoPayStrategy implements PaymentStrategy {
@Override
public void pay() {
System.out.println("카카오페이로 결제합니다.");
}
}
@Service
public class TossPayStrategy implements PaymentStrategy {
@Override
public void pay() {
System.out.println("토스페이로 결제합니다.");
}
}
결제를 관리해 주는 PaymentContext 클래스를 선언해 줍니다. 그리고 OrderService 클래스에서는 이제 Payment에 대한 인터페이스를 주입받지 않고 결제 관리 클래스인 PaymentContext를 주입받도록 수정합니다.
// 결제 관리 서비스
@Service
public class PaymentContext {
private final Map<String, PaymentStrategy> paymentStrategies;
@Autowired
public PaymentContext(Map<String, PaymentStrategy> paymentStrategies) {
this.paymentStrategies = paymentStrategies;
}
public void executePayment(String paymentType) {
PaymentStrategy paymentStrategy = paymentStrategies.get(paymentType);
if (paymentStrategy == null) {
throw new IllegalArgumentException("지원하지 않는 결제 수단입니다: " + paymentType);
}
paymentStrategy.pay();
}
}
// 주문 서비스
@Service
public class OrderService {
private final PaymentContext paymentContext;
@Autowired
public OrderService(PaymentContext paymentContext) {
this.paymentContext = paymentContext;
}
public void processOrder(String paymentType) {
paymentContext.executePayment(paymentType);
}
}
이 방법을 사용한 덕분에 OrderService 클래스에서는 결제 구현체를 직접 관리하지 않게 되어 클래스의 책임을 분리되었고 복잡성이 줄어들었습니다. 또한 새로운 결제 방식이 추가되어도 OrderService 클래스는 수정할 필요가 없고, 오직 PaymentContext 클래스에만 새로운 전략을 추가하면 됩니다. 결론적으로 이렇게 상위 서비스에 로직을 위임하여 관심사를 분리하는 설계가 지금처럼 여러 결제 방식이 필요한 상황에서는 더 좋은 구조입니다.
마무리하며
이번 포스팅을 멋지게 작성하기 위해 설명을 만들다 보니 더 알려드리면 좋을만한 부분들이 계속 발견되어서 최종적으로는 기존 구성했던 것보다 내용이 너무 커져버렸습니다. 그래서 간단하게 정리해서 설명드리겠습니다.
제가 이번 포스팅으로 전달드리고 싶었던 것은 '의존성, 결합도' 2가지 개념입니다.
강한 결합도와 높은 의존성은 밀접하게 연결된 개념이고, 두 개념 모두 코드의 유연성과 유지보수성에 큰 영향을 미칩니다. 강한 결합도를 낮추기 위해서는 구체적인 구현체가 아닌 인터페이스에 의존하고, 의존성 주입을 통해 모듈 간의 결합을 느슨하게 만드는 것이 중요합니다.
개발자가 직접 의존성 주입을 할 수도 있지만 Spring 같은 좋은 프레임워크를 사용한다면 IOC(제어의 역전)를 통한 의존성 주입을 활용할 수 있기에 직접 하는 것보다 더 편하게 모듈 간의 결합을 느슨하게 만들고, 유지보수성과 확장성을 높이는 것이 가능합니다.
실무에서 정말 중요한 것은 함께 작업하는 동료들이 업무에서 사용하는 용어가 무엇을 의미하는지 아는 것이라고 생각합니다. 간단해 보이지만 설명하기는 쉽지 않은 이런 개념들을 익혀야만 하는 우리 백엔드 개발자 동료님들! 개발은 항상 어렵고 힘들지만 모두 즐겼으면 좋겠습니다. 오늘은 응원의 한마디로 글을 마치도록 하겠습니다.
'불광불급'이란 말이 있습니다. 바로 '미치지 않으면 미칠 수 없다'는 의미입니다. 저는 요즘 출근하면서 매일 되새기고 있습니다.
우리 개발자분들 힘들어도 개발에 미쳐봅시다! 분명 기뻐하며 성공하는 날이 올 것입니다 :)
'Spring + Java' 카테고리의 다른 글
[Java] Enum NPE 문제 빠르게 해결하기 (feat. equals, switch, AttributeConverter) (0) | 2024.11.03 |
---|---|
[Java] 메서드 추출(Extract Method)로 복잡한 비즈니스 로직 개선하기 (0) | 2024.11.02 |
스프링에서 도메인 객체를 사용하는 건에 대해 (6) | 2024.08.31 |
[Spring] synchronized를 사용한 동시성 문제 해결방법 (1) | 2024.06.07 |
[Spring] StackTrace 상세분석 (예외처리) (0) | 2024.03.30 |