안녕하세요. 개발자 Stark입니다.
이번 포스팅은 의존성 주입에 대한 설명입니다. 기존에 작성했던 내용을 개선하여 리팩터링 하였습니다.
특히 기존에는 없던 설명들을 조금 부각했으며 이해하기 쉽도록 구성해 보았습니다. 중간중간 객체 간의 결합에 대한 설명 같은 것들이 있으니 많은 도움이 되었으면 좋겠습니다.
의존성과 의존성 주입이란?
의존성이란?
- 객체 지향 프로그래밍에서 의존성은 클래스나 모듈 간의 관계를 의미합니다. 한 클래스가 다른 클래스에 의존한다는 것은 해당 클래스가 다른 클래스의 인스턴스나 메서드를 사용한다는 뜻입니다. 의존성은 클래스 간의 결합도를 나타내며, 결합도가 높으면 변경에 취약한 코드가 될 가능성이 높아집니다.
의존성 주입이란?
- 의존성 주입(Dependency Injection)은 객체가 필요로 하는 의존성을 직접 생성하거나 관리하지 않고, 외부에서 주입받는 것을 말합니다. 이를 통해 코드의 재사용성과 테스트 가능성이 향상됩니다. 의존성 주입은 객체 생성과 의존성 관리의 책임을 분리하여 코드의 결합도를 낮추는 데 기여합니다.
예시 코드로 이해해 봅시다.
// 의존성 주입을 사용한 예시
public class OrderService {
private final PaymentService paymentService; // 결제 서비스에 대한 의존성
public OrderService(PaymentService paymentService) {
this.paymentService = paymentService; // 외부에서 의존성을 주입받음
}
public void placeOrder() {
paymentService.processPayment(); // 결제 처리 메서드 호출
}
}
// 결제 서비스 클래스
public class PaymentService {
public void processPayment() {
// 결제 처리 로직
}
}
- 위 코드는 OrderService 클래스가 결제 서비스(PaymentService)를 직접 생성하지 않고 생성자를 통해 외부에서 주입받아서 사용하므로, 결합도가 낮아지고 유연성이 증가합니다.
의존성을 직접 생성한다는 것의 의미는 뭘까요?
- "의존성을 직접 생성한다"는 것은 아래와 같이 의존 객체를 new 키워드를 사용해 생성하는 경우를 의미합니다.
PaymentService paymentService = new PaymentService();
- 이 방식은 클래스가 다른 클래스에 강하게 결합되며, 변경 및 테스트가 어려워집니다. 반면, 의존성 주입은 외부에서 의존성을 주입받아 결합도를 낮춥니다. (스프링에서 테스트가 가능한 코드를 만드는 것은 굉장히 중요합니다!)
의존성 주입을 사용하면 클래스는 외부에서 주입받은 의존성 객체를 사용하므로, 의존성과 클래스 간의 결합도가 낮아지며 유연성과 재사용성이 향상됩니다. 이는 객체 지향 설계 원칙 중 하나인 "의존성 역전 원칙(Dependency Inversion Principle)"을 따르는 것이기도 합니다.
Spring에서의 DI(Dependency Injection)
Spring의 의존성 관리
- Spring은 Maven 또는 Gradle을 통해 프로젝트의 의존성을 손쉽게 관리할 수 있습니다. 이를 통해 개발자는 필요한 의존성을 간단히 추가할 수 있으며, 자동 구성(Auto Configuration) 기능으로 특정 설정을 자동화할 수 있습니다.
Spring Framework의 DI 기능
- Spring은 의존성 주입을 기본 기능으로 지원하며, 다음과 같은 주입 방식을 제공합니다.
- 생성자 주입: 생성자를 통해 의존성을 주입
- 세터 주입: 세터 메서드를 통해 의존성을 주입
- 필드 주입: 필드에 직접 주입
코드를 통한 설명
@Component
public class OrderService {
private final PaymentService paymentService;
public OrderService(PaymentService paymentService) {
this.paymentService = paymentService; // 생성자 주입
}
public void placeOrder() {
paymentService.processPayment();
}
}
@Component
public class PaymentService {
public void processPayment() {
// 결제 처리 로직
}
}
- 위 코드에서 OrderService는 PaymentService를 생성자로 주입받아 사용합니다. 의존성을 주입받는다는 의미인 DI가 이렇게 적용되는 것입니다. 이러한 방식은 클래스 간의 결합도를 낮춰줍니다.
@Autowired란?
레거시 코드를 개발 또는 유지보수하다 보면 @Autowired를 굉장히 많이 볼 수 있습니다.
예전에는 필드 주입을 자주 사용했어서 @Autowired가 많았지만 요즘에는 생성자 주입을 주로 사용하기에 1개의 생성자가 있을 때는 @Autowired를 작성하지 않아도 자동으로 생성자에 추가되는 스프링의 동작 덕분에 이 어노테이션을 보기 힘들 것입니다.
간단하게 설명드리자면 @Autowired는 Spring Framework에서 의존성 주입을 수행하기 위해 사용하는 어노테이션입니다. 이 어노테이션을 사용하면 스프링 컨테이너가 빈(Bean)을 찾아 자동으로 주입합니다. (자동 생성이라 우리가 눈으로 보지 못할 뿐 뒤에서 자동으로 적용되어 사용되고 있습니다.)
@Autowired의 주요 특징은 다음과 같습니다.
- 자동 주입: 스프링 컨테이너에 등록된 타입에 맞는 빈을 찾아 주입합니다.
- 타입 기반 매칭: 기본적으로 타입을 기준으로 주입하며, 같은 타입의 빈이 여러 개인 경우 추가 설정이 필요합니다.
- 필수 의존성 여부: 기본적으로 필수 의존성으로 간주되며, required=false로 설정하여 선택적으로 주입할 수 있습니다.
주입 방식 비교하기
- 생성자 주입: 권장되는 방식으로, 객체 불변성을 보장하며 의존성을 명확하게 표현합니다.
@Autowired
public OrderService(PaymentService paymentService) {
this.paymentService = paymentService;
}
- 필드 주입: 간단하지만 테스트 및 유지보수에서 유연성이 떨어질 수 있습니다.
@Autowired
private PaymentService paymentService;
- 세터 주입: 선택적 의존성에 적합합니다.
@Autowired
public void setPaymentService(PaymentService paymentService) {
this.paymentService = paymentService;
}
객체 간의 결합도(Coupling)와 의존성 주입의 깊은 이해
이쯤 되었으니 한번 객체 간의 결합도에 대해 이해하고 넘어갑시다. 소프트웨어 개발에서 "결합도"라는 개념은 마치 두 사람 사이의 관계와 비슷합니다. 너무 강하게 얽혀있는 관계는 서로의 자유로운 변화를 어렵게 만들죠. 객체 지향 프로그래밍에서도 이와 같은 원리가 적용됩니다.
결합도가 높은 코드의 문제점
- 전통적인 방식에서는 객체가 필요한 다른 객체를 직접 생성하고 관리했습니다. 예를 들어보겠습니다.
public class EmailService {
private SmtpClient smtpClient;
public EmailService() {
// EmailService가 직접 SmtpClient를 생성하고 있습니다
this.smtpClient = new SmtpClient("smtp.gmail.com", 587);
}
public void sendEmail(String to, String content) {
smtpClient.send(to, content);
}
}
이 코드의 문제점은 다음과 같습니다.
- EmailService는 특정 SmtpClient 구현에 강하게 결합되어 있습니다.
- 다른 메일 서버로 변경하려면 EmailService 코드를 직접 수정해야 합니다.
- 코드를 테스트하기가 어렵습니다. 실제 SMTP 서버 없이는 테스트가 불가능합니다.
의존성 주입을 통한 개선
- 이제 의존성 주입을 사용하여 같은 기능을 구현해 보겠습니다.
public interface MailClient {
void send(String to, String content);
}
public class SmtpMailClient implements MailClient {
public void send(String to, String content) {
// SMTP 구현
}
}
public class EmailService {
private final MailClient mailClient;
// 외부에서 mailClient를 주입받습니다
public EmailService(MailClient mailClient) {
this.mailClient = mailClient;
}
public void sendEmail(String to, String content) {
mailClient.send(to, content);
}
}
이렇게 변경함으로써 얻는 이점들을 살펴봅시다.
- EmailService는 구체적인 구현체가 아닌 인터페이스에 의존합니다.
- 다른 메일 클라이언트로 쉽게 교체할 수 있습니다 (예: AWS SES, SendGrid 등).
- 테스트가 용이해집니다 - 가짜(Mock) MailClient를 주입할 수 있습니다.
이를 시각적으로 표현하면 다음과 같습니다.
강한 결합:
+---------------+ +-------------+
| | New | |
| EmailService |------>| SmtpClient |
| | | |
+---------------+ +-------------+
느슨한 결합:
+---------------+ +-------------+
| | | MailClient |
| EmailService |------>| (Interface) |
| | +-------------+
+---------------+ ↑
|
+-------------------+
| SmtpMailClient |
| AwsSesClient |
| MockMailClient |
+-------------------+
이처럼 의존성 주입은 단순히 객체를 외부에서 주입받는 테크닉이 아니라, 코드의 유연성과 테스트 용이성을 크게 향상시키는 설계 철학입니다. 마치 레고 블록처럼, 각 부품은 독립적으로 존재하면서도 필요할 때 쉽게 조립하고 교체할 수 있게 되는 것입니다.
스프링은 생성자 주입을 권장한다.
왜 생성자 주입을 권장할까요?
- 불변성 보장: final 키워드를 사용해 객체의 상태를 변경할 수 없게 만듭니다.
- 주입 의존성 명확화: 생성자 주입은 객체가 생성될 때 필요한 의존성을 명확히 선언합니다.
예시코드
@Component
public class OrderService {
private final PaymentService paymentService;
@Autowired
public OrderService(PaymentService paymentService) {
this.paymentService = paymentService; // 반드시 주입받아야 함
}
}
- 위 코드에서 private final 키워드로 필드를 선언하여 불변성을 보장하며, 의존성 주입이 반드시 이루어지도록 강제합니다.
- Lombok을 사용하면 private final 필드만 생성자로 만들어주는 @RequiredArgsConstructor를 사용하면 됩니다.
생성자 주입의 장점
- 테스트 용이성: 의존성을 주입받아 테스트할 수 있습니다.
- 코드 가독성: 주입받는 의존성을 한눈에 파악할 수 있습니다.
- 안정성: 객체가 생성될 때 필요한 의존성을 강제하여 누락을 방지합니다.
DI의 다양한 활용 사례를 살펴봅시다.
테스트 용이성의 향상
- 의존성 주입을 통해 모의 객체(Mock Object)를 주입하여 테스트의 의존성을 분리할 수 있다.
- 테스트할 대상과 의존하는 객체를 분리하여 단위 테스트를 수행할 수 있다.
모듈성과 재사용성의 향상
- 의존성 주입을 통해 모듈 간의 결합도를 낮출 수 있다.
- 인터페이스를 통해 의존성을 주입받아 구현 세부사항에 대한 의존성을 분리할 수 있다.(다형성)
- 이를 통해 모듈의 재사용성과 유지보수성을 향상시킬 수 있다.
AOP (Aspect-Oriented Programming)의 구현
- AOP는 핵심 로직과 부가적인 기능을 분리하여 모듈화 하는 프로그래밍 패러다임이다.
- 의존성 주입을 통해 AOP를 구현할 수 있으며, 횡단 관심사(Cross-cutting Concerns)를 분리하여 적용할 수 있다.
- 예를 들어, 로깅, 트랜잭션, 보안 등의 부가적인 기능을 의존성 주입을 통해 구현할 수 있다.
중요한 건 이 모든 것들이 스프링 컨테이너가 존재하기 때문에 가능하다는 것입니다. 아래의 글을 읽어봅시다!
'Spring > Spring 기초 지식' 카테고리의 다른 글
스프링은 Singleton 패턴을 어떻게 활용할까? (0) | 2023.08.07 |
---|---|
스프링의 제어의 역전 (IoC, Inversion of Control) (0) | 2023.08.07 |
[@Configuration과 @Bean] 스프링 컨테이너의 동작 원리 톺아보기 (0) | 2023.08.07 |
[스프링, 스프링부트] Spring - @Bean과 @Component (0) | 2023.07.20 |
왜 스프링인가? 프레임워크의 철학 가볍게 살펴보기 (0) | 2023.07.20 |