공유된 기술노트 · 자바

newlec · 2026. 9. 23.

객체지향 관계와 의존성 주입(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)과 그 결합력

책임을 나누게 되면 하나의 캡슐이 여러 개의 캡슐로 나누어지기 마련입니다. 예를 들어 OrderServiceOrderRepository가 나누어진 것처럼 말입니다.

이제 두 캡슐은 서로 관계를 가지며 일을 하게 됩니다. OrderService은 업무로직을 처리할 때 데이터를 저장하거나 가져오기 위해서 OrderRepository를 사용해야 합니다.

그 때 OrderServiceOrderRepository의 기능을 사용하기 위해서 객체가 다른 객체를 멤버로 가지고 사용하는 방법이 가장 쉬운 방법인데, 이 관계를 "가진다(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)은 얼마나 강한가?

매우 강함. OrderServiceOrderRepository라는 구체적인 클래스에 의존합니다. 즉, OrderService는 오로지 OrderRepository 클래스만 사용하는 관계로 만들어집니다. 만약에 OrderService가 다른 Repository를 사용하려면 코드를 수정해야만 합니다.

왜 강한가?

  • OrderRepository의 생성자가 바뀌면 OrderService도 수정해야 합니다.
  • OrderRepository를 다른 구현체(예: 파일 기반 저장소)로 바꾸려면 OrderService의 코드를 수정해야만 합니다.
  • 테스트할 때도 실제 OrderRepository를 사용해야 하므로, 테스트가 느리고 복잡해집니다.

4. Association (has-a)과 그 결합력

결합력을 낮추기 위해, OrderServiceOrderRepository를 직접 생성하지 않고, 외부에서 주입받습니다. 이것이 의존성 주입(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에서 파일로), OrderServiceFileOrderRepository로 바꿔서 주입할 수 있어야 하는데, 위의 코드는 여전히 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는 그 약속만을 참조하고 있습니다.

장점

  • 유연성: OrderServiceOrderRepository라는 인터페이스에만 의존합니다.
  • 교체 용이: 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는 "의존성을 주입한다"는 기술이고, 인터페이스는 "어떤 의존성을 주입할지"를 유연하게 만드는 추상화입니다. 이 둘이 결합되어 결합도가 낮은, 유지보수가 쉬운 객체지향 시스템을 만듭니다.

이 주소는 글의 저작자가 공개한 링크입니다. 뉴렉처 스터디의 다른 자료는 로그인 후 볼 수 있어요.