공유된 기술노트 · 자바
객체지향 관계와 의존성 주입(DI)의 본질
1. 캡슐화 바로잡기
많은 학생들이 캡슐화를 "필드와 메서드를 하나의 클래스 안에 묶어놓는 것"으로 이해는 경우가 많습니다. 하지만 이는 캡슐화의 형식일 뿐이고, 본질이 아닙니다.
- 본질: 캡슐화는 책임(Responsibility) 을 하나의 단위로 묶고, 내부 구현을 외부에 숨기는 것입니다.
- 왜 여러 캡슐로 나누는가?
- 하나의 클래스가 너무 많은 책임을 지면, 한 부분의 변경이 다른 부분에 영향을 미쳐 유지보수가 어려워집니다.
- 책임별로 분리하면 각 객체는 자신의 역할에만 집중할 수 있고, 변경의 범위가 제한됩니다.
핵심: 캡슐화는 단순히 코드를 한데 모으는 것이 아니라, "이 객체는 이 일만 한다"라는 경계를 명확히 하는 것입니다.
2. 역할(책임) 나누기 예제
주문 처리 도메인을 예로 들어보겠습니다.
❌ 나쁜 예: 단일 책임 원칙 위반
처음에는 모든 로직을 OrderManager라는 한 클래스에 넣습니다. DB나 파일에서 데이터를 제공하는 기능과 그것을 이용하는 업무를 처리하는 로직을 하나의 클래스에 넣습니다.
public class OrderManager {
// 데이터 저장소 접근 로직 (조회 책임)
public Order findOrderById(Long id) {
// DB나 파일에서 데이터를 읽어오는 복잡한 로직...
return new Order(id, "Item A");
}
// 비즈니스 로직 (주문 처리 책임)
public void createOrder(String item) {
// 재고 확인, 가격 계산, 주문 생성 등 복잡한 로직...
System.out.println("Order created for " + item);
}
}이 방식의 문제점:
findOrderById의 데이터 저장위치가 달라지거나 구현 API가 바뀌면, 그것을 책임지고 있는 코드만 수정하면 됩니다. 하지만 지금은 데이터 저장소의 변경에 전혀 영향을 받지 않는createOrder와 같은 업무로직도 그 수정범위에 함께 포함됩니다. 또한 테스트할 때도 같이 영향을 받습니다.- 하나의 캡슐에 여러 책임이 섞여 있으면 수정 범위를 파악하기도 어렵습니다.
✅ 좋은 예: 책임 분리
책임별로 클래스를 분리합니다.
OrderRepository: 주문 데이터를 조회/저장하는 데이터 접근 책임OrderService: 주문 생성, 검증 등 비즈니스 로직 책임
// 데이터 접근 책임
public class OrderRepository {
public Order findOrderById(Long id) {
// DB나 파일에서 데이터를 읽어오는 로직
return new Order(id, "Item A");
}
}
// 비즈니스 로직 책임
public class OrderService {
// OrderRepository를 어떻게 사용할지 아직 결정하지 않음 (아래에서 설명)
public void createOrder(String item) {
// 재고 확인, 가격 계산 등
System.out.println("Order created for " + item);
}
}이제 각 클래스는 자신의 책임에만 집중합니다.
3. Composition (has-a)과 그 결합력
책임을 나누게 되면 하나의 캡슐이 여러 개의 캡슐로 나누어지기 마련입니다. 예를 들어 OrderService와 OrderRepository가 나누어진 것처럼 말입니다.
이제 두 캡슐은 서로 관계를 가지며 일을 하게 됩니다. OrderService은 업무로직을 처리할 때 데이터를 저장하거나 가져오기 위해서 OrderRepository를 사용해야 합니다.
그 때 OrderService가 OrderRepository의 기능을 사용하기 위해서 객체가 다른 객체를 멤버로 가지고 사용하는 방법이 가장 쉬운 방법인데, 이 관계를 "가진다(has-a)" 관계라고 합니다.
어떤 객체가 멤버로 가지는 객체를 직접 생성해서 가지고 있는 관계를 Composition Has A 관계라고 하는데, 그 내용을 좀 더 자세히 알아보겠습니다.
직접 생성 (Strong Coupling)
가장 직관적인 방법은 OrderService 안에서 OrderRepository를 직접 생성하는 것입니다.
public class OrderService {
private OrderRepository repository;
public OrderService() {
// OrderService가 OrderRepository를 직접 생성
this.repository = new OrderRepository();
}
public void createOrder(String item) {
// repository를 사용
Order existing = repository.findOrderById(1L);
System.out.println("Order created for " + item);
}
}이 관계의 결합력(Coupling)은 얼마나 강한가?
매우 강함. OrderService는 OrderRepository라는 구체적인 클래스에 의존합니다. 즉, OrderService는 오로지 OrderRepository 클래스만 사용하는 관계로 만들어집니다. 만약에 OrderService가 다른 Repository를 사용하려면 코드를 수정해야만 합니다.
왜 강한가?
OrderRepository의 생성자가 바뀌면OrderService도 수정해야 합니다.OrderRepository를 다른 구현체(예: 파일 기반 저장소)로 바꾸려면OrderService의 코드를 수정해야만 합니다.- 테스트할 때도 실제
OrderRepository를 사용해야 하므로, 테스트가 느리고 복잡해집니다.
4. Association (has-a)과 그 결합력
결합력을 낮추기 위해, OrderService는 OrderRepository를 직접 생성하지 않고, 외부에서 주입받습니다. 이것이 의존성 주입(Dependency Injection, DI) 입니다.
DI는 프레임워크(스프링 등)의 전유물이 아닙니다. 객체 간 결합도를 낮추기 위한 설계 패턴입니다.
Constructor DI (생성자 주입)
생성자 주입이란 의존성을 생성 시점에 주입받는 것을 말합니다.
public class OrderService {
private final OrderRepository repository; // final: 변경 불가
// 생성자를 통해 repository를 주입받음
public OrderService(OrderRepository repository) {
this.repository = repository;
}
public void createOrder(String item) {
Order existing = repository.findOrderById(1L);
System.out.println("Order created for " + item);
}
}
// 사용 예시
public class App {
public static void main(String[] args) {
// 외부에서 어떤 repository를 사용할지 결정
OrderRepository repo = new OrderRepository();
OrderService service = new OrderService(repo);
service.createOrder("Item B");
}
}Setter DI (세터 주입)
세터 주입이란 생성 후 메서드를 통해 주입받는 것을 말합니다.
public class OrderService {
private OrderRepository repository;
// 주입을 위한 setter
public void setRepository(OrderRepository repository) {
this.repository = repository;
}
public void createOrder(String item) {
if (repository == null) {
throw new IllegalStateException("Repository not set");
}
Order existing = repository.findOrderById(1L);
System.out.println("Order created for " + item);
}
}
// 사용 예시
public class App {
public static void main(String[] args) {
OrderService service = new OrderService();
service.setRepository(new OrderRepository()); // 주입
service.createOrder("Item C");
}
}Constructor DI vs Setter DI
| 특징 | Constructor DI | Setter DI |
|---|---|---|
| 결합력 | 낮음 (외부에서 결정) | 낮음 (외부에서 결정) |
| 불변성 | final 필드 사용 가능, 객체가 완성된 상태로 생성됨 |
필드가 변경 가능, 상태가 불완전할 수 있음 |
| 필수 의존성 | 필수 의존성 표현에 적합 | 선택적 의존성 표현에 적합 |
| 테스트 용이성 | 항상 의존성이 주입된 상태로 테스트 가능 | 주입 전 상태에서는 NPE 발생 가능 |
| 추천 | 대부분의 경우 권장 | 선택적 의존성이나 복잡한 그래프일 때 |
핵심: DI의 목적은
new를 제거하여 결합도를 낮추는 것입니다. 누가 어떤 객체를 생성하는지 결정권을 객체 내부가 아닌 외부(구성자) 에게 넘깁니다.
다시 말하면 OrderService 가 "내가 사용하는 Repository 객체를 내가 만들지 않을 거에요. 나를 생성하는 구성자가 만들어서 넘겨주세요."라고 말하는 것과 같습니다.
5. 인터페이스에 의존하는 이유
DI를 사용해도, 구체 클래스(OrderRepository)에 의존하면 여전히 결합도가 높습니다.
문제: 구체 클래스에 의존
public class OrderService {
private final OrderRepository repository; // 구체 클래스
public OrderService(OrderRepository repository) {
this.repository = repository;
}
}OrderRepository의 구현이 바뀌면 (예: DB에서 파일로),OrderService는FileOrderRepository로 바꿔서 주입할 수 있어야 하는데, 위의 코드는 여전히OrderRepository만 대입할 수 있게 되어 있습니다.- 만약에 새로운 구현체
FileOrderRepository를 사용하도록 만들려면,OrderService의 코드에서 Repository 를 참조하는 형식명을 수정해야만 합니다.
해결: 인터페이스에 의존
약속 기반인 인터페이스를 통해 Service가 사용할 Repository를 추상화합니다.
// 인터페이스: 계약(Contract)
public interface OrderRepository {
Order findOrderById(Long id);
}
// 구현체 1: DB 기반
public class JdbcOrderRepository implements OrderRepository {
@Override
public Order findOrderById(Long id) {
// JDBC로 DB 조회
return new Order(id, "From DB");
}
}
// 구현체 2: 파일 기반
public class FileOrderRepository implements OrderRepository {
@Override
public Order findOrderById(Long id) {
// 파일에서 조회
return new Order(id, "From File");
}
}
// OrderService: 인터페이스에 의존
public class OrderService {
private final OrderRepository repository; // 인터페이스 타입
public OrderService(OrderRepository repository) {
this.repository = repository;
}
public void createOrder(String item) {
Order existing = repository.findOrderById(1L);
System.out.println("Order created for " + item);
}
}위의 코드에서는 OrderRepository라는 약속을 정의하고 OrderService는 그 약속만을 참조하고 있습니다.
장점
- 유연성:
OrderService는OrderRepository라는 인터페이스에만 의존합니다. - 교체 용이:
App에서 어떤 구현체를 사용할지 결정합니다.
public class App {
public static void main(String[] args) {
// 필요에 따라 구현체를 쉽게 교체
OrderRepository repo = new JdbcOrderRepository();
// OrderRepository repo = new FileOrderRepository();
OrderService service = new OrderService(repo);
service.createOrder("Item D");
}
}- 테스트 용이: 테스트 시에는
OrderRepository의 모의 객체(Mock)를 주입할 수 있습니다.
결론: DI는 "의존성을 주입한다"는 기술이고, 인터페이스는 "어떤 의존성을 주입할지"를 유연하게 만드는 추상화입니다. 이 둘이 결합되어 결합도가 낮은, 유지보수가 쉬운 객체지향 시스템을 만듭니다.
이 주소는 글의 저작자가 공개한 링크입니다. 뉴렉처 스터디의 다른 자료는 로그인 후 볼 수 있어요.