[소프트웨어 아키텍처] 인터페이스 분리 원칙(ISP)에 대해서 알아보자.

비대한 클래스(Fat Class) 문제

위에 그림을 살펴보자. User1 ~ 3 클래스가 OPS 클래스의 함수를 사용한다. User1은 오직 op1을 User2는 op2, User3는 op3만 사용한다고 가정해보자. 그리고 OPS가 정적 타입 언어로 작성된 클래스라고 가정해보자. 이 경우 User1에서는 op2와 op3를 전혀 사용하지 않음에도 User1의 소스 코드는 이 두 메서드에 의존하게 된다. 이러한 의존성으로 인해 OPS클래스에서 op2 소스코드가 변경이 되면 User1도 다시 컴파일 한 후 재배포를 해야 한다.

예제 코드

  • 위 코드에서 User1의 소스 코드를 보면 op2()op3()를 호출하지 않지만, import OPS 또는 private OPS ops 선언을 통해 OPS 클래스 자체와 강하게 결합되어 있습니다.
  • 소스 코드 의존성: User1OPS 클래스 전체 타입에 의존합니다.
  • 재컴파일 유발: 만약 요구사항 변경으로 OPS 클래스의 op2() 메서드 코드가 수정된다면?
    OPS 클래스가 다시 컴파일됩니다.
    – 정적 타입 언어(C++, 등)의 컴파일 및 링크 방식에 따라, OPS를 의존하고 있는 User1도 함께 재컴파일하고 재배포해야 하는 상황이 발생할 수 있습니다.
  • 불필요한 결합: User1 입장에서는 자신이 쓰지도 않는 op2의 변경 때문에 자신이 영향을 받는 억울한 상황입니다.
// 1. 모든 기능이 다 들어있는 거대 클래스 (Fat Class)
class OPS {
    // User1이 사용하는 기능
    public void op1() {
        System.out.println("Operation 1 executed.");
    }

    // User2가 사용하는 기능 (User1은 안 씀)
    public void op2() {
        System.out.println("Operation 2 executed.");
    }

    // User3가 사용하는 기능 (User1은 안 씀)
    public void op3() {
        System.out.println("Operation 3 executed.");
    }
}

// 2. User1 클래스
class User1 {
    private OPS ops; // 불필요한 op2, op3가 포함된 OPS 전체에 의존함

    public User1(OPS ops) {
        this.ops = ops;
    }

    public void doWork() {
        // User1은 오직 op1만 사용함
        ops.op1();
    }
}

해결책 – ISPInterface Segregation Principle 원칙 사용

아래 그림의 경우 위에 문제에 대한 해결책이라고 볼 수 있다. 함수를 인터페이스 단위로 분리를 해 놓는 것 이다. OPS 클래스를 정적 타입 언어로 구현 해 놓았지만 User1의 소스 코드는 U1Ops와 op1에는 의존하지만 OPS에는 의존하지 않게 된다. OPS에서 발생한 변경이 User1과는 전혀 관계없는 변경이라면 User1을 다시 컴파일하고 새로 배포하는 상황은 초래하지 않는다.

// ==========================================
// 1. 인터페이스 정의 (역할별로 분리)
// ==========================================

// USER1을 위한 인터페이스
interface U1Ops {
    void op1();
}

// USER2를 위한 인터페이스 (이번 예제에서는 사용 안 함)
interface U2Ops {
    void op2();
}

// USER3를 위한 인터페이스 (이번 예제에서는 사용 안 함)
interface U3Ops {
    void op3();
}

// ==========================================
// 2. 구현 클래스 정의 (모든 인터페이스를 구현)
// ==========================================
// OPS 클래스는 여전히 모든 기능을 가지고 있지만,
// 외부에는 각각의 인터페이스로 보여질 수 있습니다.
class OPS implements U1Ops, U2Ops, U3Ops {
    @Override
    public void op1() {
        System.out.println("OPS: op1() 실행됨 (USER1이 사용)");
    }

    @Override
    public void op2() {
        // 이 코드가 변경되어도 USER1은 영향을 받지 않습니다.
        System.out.println("OPS: op2() 실행됨 (USER1과 무관)");
    }

    @Override
    public void op3() {
        System.out.println("OPS: op3() 실행됨 (USER1과 무관)");
    }
}

// ==========================================
// 3. 클라이언트 클래스 정의 (USER1)
// ==========================================
class USER1 {
    // 핵심: OPS 클래스 전체가 아닌, 필요한 인터페이스(U1Ops)에만 의존합니다.
    private final U1Ops myOps;

    // 생성자를 통해 의존성을 주입받습니다 (Dependency Injection).
    // 매개변수 타입이 U1Ops이므로, U1Ops를 구현한 어떤 객체든 들어올 수 있습니다.
    public USER1(U1Ops ops) {
        this.myOps = ops;
    }

    public void doWork() {
        System.out.println("USER1: 작업 시작");
        // USER1은 오직 op1()만 호출할 수 있습니다.
        // myOps.op2(); // 컴파일 에러! USER1은 op2의 존재를 모릅니다.
        myOps.op1();
        System.out.println("USER1: 작업 종료");
    }
}

// ==========================================
// 4. 메인 클래스 (실행 및 테스트)
// ==========================================
public class IspExampleMain {
    public static void main(String[] args) {
        // 1. 구체적인 구현체 생성 (OPS는 모든 기능을 다 가지고 있음)
        OPS opsImplementation = new OPS();

        // 2. USER1 생성 및 의존성 주입
        // OPS 객체는 U1Ops 인터페이스를 구현했으므로 USER1에게 전달될 수 있습니다.
        // 이때 USER1 입장에서 이 객체는 'OPS'가 아니라 'U1Ops를 구현한 무언가'로 보입니다.
        USER1 user1 = new USER1(opsImplementation);

        // 3. USER1 작업 실행
        System.out.println("--- USER1 실행 ---");
        user1.doWork();
        
        System.out.println("\n--- 결과 확인 ---");
        System.out.println("USER1은 OPS의 op2, op3가 변경되어도 재컴파일이 필요 없습니다.");
        System.out.println("왜냐하면 USER1이 의존하는 U1Ops 인터페이스는 변하지 않았기 때문입니다.");
    }
}

코드 실행 결과

--- USER1 실행 ---
USER1: 작업 시작
OPS: op1() 실행됨 (USER1이 사용)
USER1: 작업 종료

--- 결과 확인 ---
USER1은 OPS의 op2, op3가 변경되어도 재컴파일이 필요 없습니다.
왜냐하면 USER1이 의존하는 U1Ops 인터페이스는 변하지 않았기 때문입니다.

ISP와 아키텍처

일반적으로 필요이상으로 많은 모듈에 의존하는 것은 좋은 않은 방식이다. 소스 코드 의존성의 경우 이는 분명한 사실인데 불필요한 재컴파일과 재배포를 강제하기 때문이다. 하지만 더 고수준인 아키텍처 수준에서도 마찬가지 상황이 발생한다.

아래 그림의 경우 S 시스템 구축에 참여하고 있는 아키텍트가 있다가 가정해 보자. 아키텍트는 F라는 프레임워크를 시스템에 도입하기를 원한다. 그리고 F 프레이워크 개발자는 D 데이터베이스를 반드시 사용하도록 만들었다고 가정해보자. S -> F에 의존하고 F는 D에 의존하게 된다.

F에서는 불필요한 기능, 따라서 S와는 전혀 관계 없는 기능이 D에 포함된다고 가정하자. 그 기능 때문에 D 내부가 변경되면 F를 재배포해야 할 수도 있고 따라서 S까지 재배포 해야할지 모른다. 더 심각한 문제는 D 내부의 기능중 F와 S에서 불필요한 그 기능의 문제가 생기더라도 F와 S에 영향은 준다는 사실이다.

관련 글 보기