[유지보수성 높이는 코드 설계 패턴] 의존성 주입? 이게 뭔데 다들 강조하는거야??
스프링 같은 프레임워크에서 의존성 주입 이라는 용어를 많이 보셨을꺼에요. 의존성 주입 처음들으면 어렵고 복잡해보이지만, 사실 의존성 주입은 코드를 좀 더 유연하고 테스트하기 쉽게, 유지보수 하기 쉽게 만드는 아주 강력한 기법입니다.
의존성에 대해서 공부를 해보고 의존성 주입이 무엇이길래 코드를 유연하고 테스트하기 쉽다는지 한번 같이 확인해 봅시다.
1. 의존성이 뭐길래??
어떤 클래스(A) 가 다른 클래스(B)의 기능을 사용해야 할 때, 우리는 클래스 A가 클래스 B에 의존한다라고 합니다. 예를 들어 UserService 클래스가 UserRepository 클래스의 있는 기능을 사용해 데이터베이스에서 사용자 정보를 가져온다면, UserService 클래스는 UserRepository 클래스에 의존하는거죠.
이러한 구조를 의존성 주입 없는, 그러니깐 일반적인 코드를 먼저 살펴보시죠.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class EmailNotificationService {
public void send(String recipient, String message) {
System.out.println("[이메일] " + recipient + "님께: " + message);
}
}
class OrderService {
// OrderService가 EmailNotificationService에 의존!
private EmailNotificationService notificationService;
public OrderService() {
// OrderService가 직접 EmailNotificationService 객체를 생성 (강한 결합의 원인!)
this.notificationService = new EmailNotificationService();
}
public void completeOrder(String userName, String orderId) {
System.out.println(orderId + "번 주문 처리 완료.");
String message = userName + "님, 주문 #" + orderId + "이(가) 성공적으로 완료되었습니다.";
notificationService.send(userName + "@example.com", message); // 이메일로만 알림!
}
}
public class Main {
public static void main(String[] args) {
OrderService orderService = new OrderService();
orderService.completeOrder("홍길동", "ORD12345");
}
}
위 코드에서 OrderService는 EmailNotificationService에 의존하게 되고 직접 객체를 생성하기 때문에 강한 결합의 문제가 생깁니다.
2. 강함 결합이 생기면 왜 문제가 되는거죠?
강함 결함이 생기면 크게 3가지의 어려움이 생깁니다.
- 테스트의 어려움이 생김
- OrderService를 테스트하려면 반드시 실제 EmailNotificationService가 필요해집니다.
- EmailNotificationService를 Mock(가짜) 객체로 바꿔치기하기 어려워집니다.
- 유지보수의 어려움이 생김
- EmailNotificationService가 아니라 SMS나 알림톡으로 변경될때 즉 방식이 변경될 때마다 OrderService도 수정을 해야 되기 때문에 유지보수가 어려워집니다.
- 확장성의 어려움이 생김
결국 OrderService와 EmailNotificationService는 서로 의존하고 있어 한쪽이 변경되면 다른쪽도 영향을 받게 됩니다.
의존성 주입은 어떻게 이러한 강한 결합도를 낮출수 있을까요?
3. 의존성 주입(DI) 는 어떻게 결합도를 낮출까?
- 의존성의 핵심 아이디어는 간단합니다. 객체 스스로 자신의 의존 객체를 생성하는것이 아니라, 외부에서 주입받는다!는 것입니다.
위에 예시를 의존성 주입 방식으로 변경해볼게요!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import com.sun.nio.sctp.Notification;
// 1. 추상화된 인터페이스 도입 (유연성을 높이는 핵심!)
interface NotificationService {
void send(String recipient, String message);
}
// 2. 실제 구현체 1 : 이메일 알림 서비스
class EmailNotificationService implements NotificationService {
@Override
public void send(String recipient, String message) {
System.out.println("[이메일 전송] : " + recipient + "님께 : " + message);
}
}
// 3. 실제 구현체 2 : SMS 알림 서비스
class SmsNotification implements NotificationService {
@Override
public void send(String recipient, String message) {
System.out.println("[SMS 전송] : " + recipient + "님께 : " + message);
}
}
// 4. 테스트 구현체 : 가짜 구현체 (Mock)
class MockNotification implements NotificationService {
@Override
public void send(String recipient, String message) {
System.out.println("[Mock 전송] : " + recipient + "님께 : " + message);
}
}
class OrderService {
// 이제 '인터페이스'에 의존!
private NotificationService notificationService;
public OrderService(NotificationService notificationService) {
this.notificationService = notificationService;
}
public void completeOrder(String userName, String OrderId, String recipientContact) {
System.out.println(OrderId + " 주문처리 완료");
String message = userName + "님, 주문 #" + orderId + "이(가) 성공적으로 완료되었습니다.";
notificationService.send(recipientContact, message); // 주입받은 서비스로 알림 발송!
}
}
public class Main {
public static void main(String[] args) {
System.out.println("--- 이메일로 알림 보내기 ---");
NotificationService emailService = new EmailNotificationService();
OrderService emailOrderService = new OrderService(emailService);
emailOrderService.completeOrder("홍길동", "ORD12345", "hong@example.com");
System.out.println("--- SMS로 알림 보내기 ---");
// OrderService 코드를 변경하지 않고, 알림 방식만 SMS로 변경!
NotificationService smsService = new SmsNotificationService();
OrderService smsOrderService = new OrderService(smsService);
smsOrderService.completeOrder("김철수", "ORD67890", "010-1234-5678");
System.out.println("--- 테스트 환경에서 알림 (실제 발송 없음) ---");
NotificationService mockService = new MockNotificationService();
OrderService testOrderService = new OrderService(mockService);
testOrderService.completeOrder("테스트유저", "ORD00000", "test@example.com");
}
}
위 코드의 변화를 잘 보세요.
OrderService는 이제 NotificationService 인터페이스에 의존합니다.
특정 구현체(EmailNotificationService, SmsNotificationService 등)에 직접 의존하지 않아요. OrderService는 더 이상 NotificationService 객체를 직접 생성하지 않습니다.
대신, 생성자를 통해 외부로부터 NotificationService 구현체를 ‘주입’ 받습니다. 이로 인해 OrderService는 자신이 사용할 알림 서비스가 이메일이든, SMS든, 아니면 다른 어떤 알림 서비스든 전혀 알 필요가 없게 됩니다.
그저 NotificationService 인터페이스가 제공하는 send() 기능만 사용할 뿐이죠.
이것이 바로 결합도가 낮아졌다는 의미입니다. OrderService와 NotificationService 구현체 간의 의존성이 느슨해진 것입니다.
4. 의존성 주입의 핵심 장점
DI를 통해서 얻을 수 있는 핵심 장점을 정리해봐요
- 높은 유연성 및 확장성
- 다양한 구현체로 손쉬운 교체 : OrderService 코드를 한 줄도 건드리지 않고 NotificationService의 구현체를 쉽게 교체할 수 있습니다.
- 예를 들어, 기본 알림을 이메일로 보내다가, 긴급 알림은 SMS로 전송하고 싶으면 OrderService는 그대로 두고 SmsNotificationService를 만들어서 주입해주기만 하면 됩니다.
- 쉬운 테스트
- OrderService를 테스트 할 때, 실제 이메일/SMS 발송 로직 대신 가벼운 MockNotification를 주입하여 OrderService 자체의 주문 완료 로직만 독립적으로 테스트 할 수 있습니다.
- 역전의 제어
- 객체가 자신의 의존 객체를 직접 생성하는것이 아니라, 외부가 객체의 생명주기와 의존성 주입을 제어합니다. 이것이 IoC의 핵심입니다.
5. 마무리
의존성 주입은 다소 낮설게 느껴질 수 있지만, 배우고 나면 “아, 그래서 그렇게 프레임워크에서 DI를 강조했구나!” 라고 느끼게 되실겁니다. DI는 코드를 단순하게 만드는 것 뿐 아니라 대규모 시스템을 유연하고 효율적으로 관리 할 수 있게 해주는 핵심 설계 원칙입니다.
강함 결함으로 인해 유지보수나 확장이 어려웠다면, 이제 의존성 주입을 통해 유연한 설계와 확장성이 띄어난 소프트웨어를 설계할 수 있을 겁니다.