본문 바로가기

Development/Software Design

클래스 설계 시 상속 관계, 합성 관계, 위임 관계의 개념(in Java)

 

 

상속(Inheritance) 관계

 

 

상속은 한 클래스가 다른 클래스의 필드와 메서드를 그대로 재사용하면서 자신의 필드와 메서드를 추가해 확장할 수 있는 개념이다. 또한 상속받은 메서드는 Overriding을 통해 자신만의 행위를 추가하거나 행위 전체를 변경할 수 있다.

 

상속 관계는 'is-a' 관계로서, 하위(자식) 타입은 상속 대상 부모 타입을 포함한 자신의 상위 타입 모두와 '하위 타입 is a 상위 타입'의 관계가 성립해야 한다.

 

예) Rabbit(하위 타입) is an animal(상위 타입). —> 토끼는 동물이다. (O)

      Animal(상위 타입) is a rabbit(하위 타입). —> 동물은 토끼다. (X)

 

예) Rabbit(하위 타입) is a mammal(상위 타입). —> 토끼는 포유류다. (O)

      Mammal(상위 타입) is a rabbit(하위 타입). —> 포유류는 토끼다. (X)

 

동물과 포유류는 토끼에 비해 포괄적이고 추상적인 개념이므로, 토끼의 상위 클래스에 속해 있다. 따라서 '토끼 is a 동물'과 '토끼 is a 포유류'는 성립하지만, '동물 is a 토끼'와 '포유류 is a 토끼'는 성립하지 않는다.

 

또한 하위 타입은 상위 타입을 행위적으로 순응해야 하며, 상위 타입으로 대체될 수 있어야 한다. 그렇지 않다면 객체지향 설계의 SOLID 원칙 중 LSP(Liskov Substitution Principle, 리스코프 치환 원칙)에 위배되므로, 올바르게 설계된 계층 관계라고 할 수 없다. 이와 관련된 예시는 아래의 '예시 코드'에서 다룰 것이다.

 

일반적으로 상속 관계를 사용하는 이유는 다음과 같다.

 

 

코드 재사용

 

 

위에서 언급했듯이, 하위 클래스는 상위 클래스의 코드를 재사용할 수 있다. 상속받은 필드와 메서드는 공통 특성을 공유하는 클래스들이 중복 코드를 반복 작성하는 것을 방지하고, 설계와 코딩 과정의 편리함과 시스템 구조에 대한 직관적인 이해를 돕는다. 이는 개발자의 편의성을 도모해 생산성을 향상하며, 소프트웨어 개발 기업이 시간과 노동력의 절약을 통해 비용 절감을 달성할 수 있게 해 준다.

 

이는 하위 클래스가 필수적으로 소유해야 할 상태와 행위를 누락하지 않도록 방지하는 데에도 도움이 된다.

 

 

계층 구조

 

 

다중 상속을 지원하는 C++와 같은 언어와 달리 Java에서는 모든 클래스가 한 부모만 상속할 수 있으므로, Java에서의 상속 관계는 하나의 Root Node로부터 내려오는 직관적인 트리(tree) 구조를 형성한다. 따라서 상속 관계는 소프트웨어 시스템을 구성하는 클래스들의 구조를 추상적으로 도식화하고, 이를 통해 각 클래스 간의 관계를 체계적으로 파악하는 데 도움을 줄 수 있다.

 

참고로 인터페이스의 경우 클래스와 달리 Java에서도 다중 상속이 가능하다.

 

 

확장성

 

 

다른 클래스를 상속할 때는 클래스 이름 뒤에 extends 상속 대상 클래스명을 붙인다. 즉, 다른 클래스를 확장한다. 이는 객체가 기존의 코드에서 상태와 기능이 추가된 버전의 객체를 제공할 수 있게 해 준다. 이는 최대한 기존 코드 구조를 변경하지 않고 편리한 확장을 가능하게 함으로써, 객체지향의 설계의 SOLID 원칙 중 OCP(Open-Closed Principle, 개방-폐쇄의 원칙)를 보다 쉽게 달성할 수 있도록 해 준다.

 

 

다형성

 

 

상속을 통해 상위 클래스의 코드를 재사용할 뿐만 아니라, Overriding을 통해 같은 메서드를 다르게 정의할 수 있다. 또한 상위 클래스의 상태와 기능에 더해 새로운 상태와 기능을 추가할 수 있다. 이는 실행 시간에 클라이언트의 서로 다른 입력값에 대한 다양한 결과를 제공함으로써, 소프트웨어가 다형성을 구현할 수 있게 해 준다.

 

 

예시 코드

 

다음과 같이 상위 클래스를 상속함으로써, 코드 재사용과 Overriding, 확장을 통해 다형성을 실현할 수 있다.

 

public class Parent {
    public void walk() {
        System.out.println("부모가 걷습니다.");
    }
}

 

public class Child1 extends Parent {

    @Override
    public void walk() {
        super.walk();
        System.out.println("자식 1도 따라 걷습니다.");
    }

    public void run() {
        System.out.println("자식 1이 달립니다.");
    }

}

 

public class Child2 extends Parent {

    @Override
    public void walk() {
        super.walk();
        System.out.println("자식 2도 따라 걷습니다.");
    }

    public void run() {
        System.out.println("자식 2가 달립니다.");
    }

}

 

public class Main {

    public static void main(String[] args) {

        Parent child1 = new Child1();
        Parent child2 = new Child2();

        child1.walk(); // 부모가 걷습니다.\n 자식 1도 따라 걷습니다.
        child2.walk(); // 부모가 걷습니다.\n 자식 2도 따라 걷습니다.

        ((Child1) child1).run(); // 자식 1이 달립니다.
        ((Child2) child2).run(); // 자식 2가 달립니다.

    }

}

 

여기서 만약 하위 클래스가 상위 클래스를 행위적으로 순응하지 못한다면, 즉 Child1과 Child2 클래스가 walk() 메서드에 대해 걷는 행위를 하지 않고 다른 행위를 수행하도록 설계한다면, LSP를 위반하게 된다.

 

상속 관계는 다른 말로 일반화(Generalization) 관계라고도 한다.

 

이전 포스팅(https://hellmir.tistory.com/entry/정적-타입-언어Java에서-타입-변환Type-Casting이-필요한-이유)에 상속에 관한 보다 상세한 예시가 있다.

 

정적 타입 언어(Java)에서 타입 변환(Type Casting)이 필요한 이유

타입 변환의 개념 타입 변환(Type Casting 혹은 Type Conversion)은 특정 타입의 값을 다른 타입으로 변환하는 과정이다. 여기에는 변수의 자료형(Data Type)이나 객체의 타입(Object Type)을 다른 타입으로 변

hellmir.tistory.com

 

 

클래스 다이어그램

 

 

상속은 클래스 다이어그램에서 속이 빈 화살표로 표현한다. 이를 통해 클래스 간의 상속 관계를 직관적으로 파악할 수 있다.

 

Java에서 모든 클래스 상속 관계는 직관적인 트리(Tree) 구조를 형성하므로, 구조도를 쉽고 명확하게 파악할 수 있다.

 

 

합성(Composition) 관계

 

 

합성 관계는 한 클래스가 다른 클래스 객체의 주소를 참조하는 field를 가지고 있으며, 해당 field가 가리키는 객체가 자신을 참조하고 있는 객체에 포함되는 관계이다. 포함하는 객체는 포함 대상 객체의 생명 주기에 관여하는 경우가 많다. 상속 관계와 달리 한 객체의 참조 변수는 여러 클래스에 포함될 수 있으며, 이를 통해 복잡한 포함 관계를 구현할 수 있다. 상속 관계가 'is-a' 관계를 형성하는 것처럼, 합성 관계는 'has-a' 관계를 형성한다.

 

예) Car(포함하는 타입) has a wheel(포함되는 타입). —> 자동차는 바퀴를 가진다. (O)

      Wheel(포함되는 타입) has a car(포함하는 타입). —> 바퀴는 자동차를 가진다. (X)

 

일반적으로 합성 관계를 사용하는 이유는 다음과 같다.

 

 

코드 재사용

 

 

상속 관계와 마찬가지로 합성 관계를 통해 코드를 재사용할 수 있다. 매번 대상 객체를 생성할 필요 없이, 한 번 생성된 객체의 메서드를 저장된 참조 field를 통해 클래스 내부의 다른 메서드에서 반복적으로 호출할 수 있다. 이는 새로 객체를 생성하는 방식과 달리 작업하던 객체의 주소를 보존하고, 다른 메서드에서 이어서 사용할 수 있음을 뜻한다. 저장할 당시의 상태를 반영하는 primitive type과 달리, 객체의 주소만을 참조하는 object type은 중간 상태를 따로 저장해 두어도 결국 최종 상태로 갱신되므로 사용 시 이 점에 특히 유의해야 한다.

 

 

캡슐화

 

 

합성 관계를 사용하면 객체 간 결합도를 낮춰, 직접 객체를 생성하는 방식에 비해 SOLID 원칙 중 하나인 SRP(Single Responsibility Principle, 단일 책임 원칙)를 준수하기 용이해진다. 객체는 생성될 때부터 자신의 책임 수행에 필요한 객체의 참조 field를 저장해 두고, 언제든 해당 객체에게 책임을 위임할 수 있다. 이는 하나의 객체가 자신의 책임을 수행하는 도중 파생되는 책임들을 모두 스스로 수행하게 되는 상황을 방지해 준다. 이는 아래에 설명할 연관 관계에서도 마찬가지이다.

 

 

클래스 관계의 구조적 표현

 

 

상속 관계와 마찬가지로, 합성 관계는 객체 간 포함 관계를 구조적으로 표현해 소프트웨어 시스템 구조에 대한 이해를 명확히 할 수 있도록 돕는다. 다만 Java에서 한 객체의 참조 field는 여러 클래스에 동시에 포함될 수 있으므로, 상속에 비해 복잡한 구조를 형성하게 될 수 있다는 점을 유의해야 한다.

 

 

생명 주기 관리

 

 

포함 객체는 포함되는 객체의 생명 주기를 관리하는 경우가 많으므로, 객체의 생성과 소멸 관리가 단순하고 용이해진다. 이는 개발자의 편의성을 도모하고 메모리 관리의 효율성을 높인다.

 

 

Spring Framework에서의 활용

 

 

Spring Framework의 대표적인 기능 중 하나인 DI(Dependency Injection, 의존성 주입)에 활용된다. @Component, @Controller, @Configuration 등의 어노테이션을 통해 Bean을 등록하고, @Autowired 어노테이션을 사용하면 Spring  IoC(DI) Container가 자동으로 의존성을 주입한다. 개발자는 이를 통해 객체 생성, 생명 주기 관리, 의존 관계 관리에 대한 부담을 덜고 핵심 비즈니스 로직에 집중할 수 있게 된다. 의존성 주입의 개념과 의존성 주입 방법에 관해서는 예전에 포스팅한 적 있다(https://hellmir.tistory.com/entry/Spring-Framework의-DI의존성-주입와-IoC제어의-역전-개념-정리, https://hellmir.tistory.com/entry/의존성-주입DI-방법-정리).

 

Spring Framework의 DI(의존성 주입)와 IoC(제어의 역전) 개념 정리

DI DI(의존성 주입)는 클라이언트(다른 객체를 사용하려는 대상)가 의존할 객체를 외부에서 지정해 주는 것을 말한다. 역할이 배타적이지 않고 다른 객체의 변화에 영향받는다면 의존성이 있는

hellmir.tistory.com

 

 

의존성 주입(DI) 방법 정리

의존성 주입(Dependency Injection, DI)은 클라이언트가 의존할 대상을 외부에서 지정해 주는 것을 말한다. 이를 통해 클라이언트의 코드 변경 없이 다형성을 구현할 수 있다. 의존성을 주입하기 위해

hellmir.tistory.com

 

의존성 주입은 각 객체를 분리 생성하고, 엄격히 구분해야 하는 Entity나 DTO 클래스에 적용하기에는 적합하지 않다. 주로 객체 간 상태를 구분할 필요가 없으며, 로직을 수행하는 것이 주 역할이 되는 컴포넌트에 활용된다. 의존성 주입은 아래에 설명할 연관 관계에서 보다 많이 사용된다.

 

 

예시 코드

 

예시 코드를 포함한 전체 소스 코드는 이곳(https://github.com/hellmir/cafe-hellmir)에 있다.

 

GitHub - hellmir/cafe-hellmir

Contribute to hellmir/cafe-hellmir development by creating an account on GitHub.

github.com

 

public enum CoffeeName {

    AMERICANO(4_500, "아메리카노"), CAFFE_LATTE(5_000, "카페라떼");

    private final Integer price;
    private final String koreanName;

    CoffeeName(Integer price, String koreanName) {

        this.price = price;
        this.koreanName = koreanName;

    }

    public Integer getPrice() {
        return price;
    }

    public String getKoreanName() {
        return koreanName;
    }

}

 

public enum CoffeeSize {

    TALL(0), GRANDE(500), VENTI(1_000);

    private final Integer price;

    CoffeeSize(Integer price) {
        this.price = price;
    }

    public Integer getPrice() {
        return price;
    }

}

 

public class Menu {

    private CoffeeName coffeeName;
    private Integer price;
    private CoffeeSize coffeeSize;
    private Boolean isIced;

    private Menu(CoffeeName coffeeName, CoffeeSize coffeeSize, Boolean isIced) {

        this.coffeeName = coffeeName;
        price = coffeeName.getPrice();

        this.coffeeSize = coffeeSize;
        price += coffeeSize.getPrice();

        this.isIced = isIced;

    }

    public static Menu createMenu(CoffeeName coffeeName, CoffeeSize coffeeSize, Boolean isIced) {
        return new Menu(coffeeName, coffeeSize, isIced);
    }

    public CoffeeName getCoffeeName() {
        return coffeeName;
    }

    public Integer getPrice() {
        return price;
    }

    public CoffeeSize getCoffeeSize() {
        return coffeeSize;
    }

    public Boolean isIced() {
        return isIced;
    }

}

 

public class Coffee {

    CoffeeName coffeeName;
    CoffeeSize coffeeSize;
    Boolean isIced;

    private Coffee(CoffeeName coffeeName, CoffeeSize coffeeSize, Boolean isIced) {

        this.coffeeName = coffeeName;
        this.coffeeSize = coffeeSize;
        this.isIced = isIced;

    }

    public static Coffee createCoffee(CoffeeName coffeeName, CoffeeSize coffeeSize, Boolean isIced) {
        return new Coffee(coffeeName, coffeeSize, isIced);
    }

}

 

CoffeeName과 CofeeSize 객체의 참조 field는 Menu 클래스와 Coffee 클래스에 모두 포함된다. Menu 객체와는 생명 주기를 공유하지만, Coffee 객체와는 생명 주기를 공유하지 않는다. Menu 객체는 CoffeeName, CoffeeSize 객체와 항상 동시에 존재해야 하지만, Coffee 객체는 이미 생성된 CoffeeName과 CoffeeSize의 상태를 토대로 생성되고 독립적으로 소멸한다.

 

합성 관계는 보통 생명 주기를 공유하는 관계를 뜻하며, 생명 주기를 공유하는 경우 포함(Containment) 관계, 공유하지 않는 경우 집합(Aggregation) 관계로 나눌 수 있다. 각각을 합성 관계와 집합 관계의 범주로 따로 분류하기도 한다.

 

 

클래스 다이어그램

 

 

합성 관계는 객체 간 생명 주기를 공유하는 경우 속이 꽉 찬 마름모, 공유하지 않는 경우 속이 빈 마름모로 표현한다.

CoffeeName과 CoffeeSize는 Menu와 Coffee 클래스에 포함된다. Menu 객체와는 생명 주기를 공유하지만, Coffee 객체와는 생명 주기를 공유하지 않는다.

 

 

위임(Delegation) 관계

 

 

위임 관계는 자신의 책임을 다른 객체에게 위임하는 관계를 뜻한다. 위임 관계는 클래스 간 관계도에 관한 표현이라기보다는 객체지향 설계의 핵심이 되는 디자인 패턴의 일종이라고 할 수 있다. 따라서 위임은 객체지향 설계에서 가장 중요한 개념 중 하나이다.

 

일반적으로 위임 관계를 사용하는 이유는 다음과 같다.

 

 

코드 재사용

 

 

위임을 통해 다른 객체의 코드를 재사용할 수 있다. 이는 개발자가 직접 작성할 코드의 분량을 획기적으로 줄일 수 있게 해 준다. 이를 통해 개발자는 생산성을 향상하고, 사용자는 노무비용을 절약할 수 있다.

 

아래에서 설명할 연관 관계의 경우 합성 관계와 마찬가지로 같은 객체를 여러 메서드에서 재사용할 수 있지만, 의존 관계의 경우 해당 메서드에서만 사용할 수 있으며, 지속적으로 사용하려면 매개변수를 통해 전달해야 한다.

 

 

캡슐화

 

 

합성 관계와 마찬가지로, 위임 관계는 객체 간 결합도를 낮춰 SRP 원칙을 달성할 수 있게 해 준다. 모든 객체는 자신의 상태를 스스로 관리하고, 수행 불가능한 책임을 메시지를 통해 다른 객체에게 위임하며,  다른 객체의 상태나 로직에 관여하지 않고, 자신이 직접 해결해야 하는 책임만을 수행한다.

 

 

다형성

 

 

클라이언트로부터 전달된 책임을 어떤 객체에게 위임할 것인지는 실행 시간에 결정될 수 있다. 이를 통해 소프트웨어는 다형성을 구현하고, 클라이언트에게 다양한 결과를 제공할 수 있다.

 

 

예시 코드

 

위임 관계는 주로 연관(Association) 관계 또는 의존(Dependency) 관계를 통해 설계할 수 있다. 연관 관계는 객체 간 지속적인 관계를 맺고 있는 구조로서, 합성 관계처럼 직접 해당 객체의 참조 field를 소유하므로 'has-a' 관계를 형성한다. 의존 관계는 책임을 위임하기 위해 일시적으로 형성되는 관계로서, 처음에는 대상 객체에 대한 정보를 가지고 있지 않다. 매개변수를 통해 일시적으로 다른 객체의 정보를 받아 오거나, 책임을 위임할 객체가 필요할 때 직접 객체를 찾아 생성함으로써 의존 관계가 형성된다.

 

public class Customer {

    private String name;
    private Wallet wallet;
    private Coffee coffee;

    public Customer(String name, Wallet wallet) {

        this.name = name;
        this.wallet = wallet;

    }

    public void orderCoffee(Integer money, Barista barista, String coffeeName, String coffeeSize, String iceOption) {

        wallet = wallet.chargeMoney(money);

        new CoffeeInputValidationHandler().validateCoffeeInputFormat(coffeeName, coffeeSize, iceOption);

        Menu menu = Menu.createMenu
                (CoffeeName.valueOf(coffeeName), CoffeeSize.valueOf(coffeeSize), iceOption.equals("ICE"));

        System.out.printf("%s : '%s %s 한 잔 %s(으)로 주세요.'\n"
                , name, menu.getCoffeeSize(), menu.getCoffeeName().getKoreanName(), menu.isIced() ? "아이스" : "핫");

        wallet = wallet.payMoney(name, menu.getPrice());

        PickUpTable pickUpTable = barista.makeCoffee(name, menu);

        System.out.printf("%s : '감사합니다.'", name);

        coffee = pickUpTable.getCoffee();

    }

}

 

Customer 클래스는 Coffee 객체와 상호작용하기 위해 Coffee 객체의 참조 field를 소유하므로, 단방향의 연관 관계를 맺고 있다. 만약 Coffee 클래스에서도 Customer 클래스의 존재를 인지할 수 있다면 양방향 연관 관계가 된다.

 

반면에 Menu 객체의 경우 orderCoffee 메서드에서 직접 생성해 참조하고 있다. 이는 Menu 객체와 지속적으로 상호작용하지는 않을 것이라는 사실을 나타낸다. Customer 객체는 메뉴판에서 Coffee를 주문하고 나면 다른 메서드에서 Menu 객체와 관계를 맺을 일이 없다. 따라서 클래스의 확장성을 고려해도, Coffee 객체와 달리 직접 객체를 생성하는 편이 적합하다고 할 수 있다.

 

일시적인 관계에서 연관 관계가 아닌 의존 관계를 사용하는 이유는 남용되는 연관 관계가 클래스 구조를 복잡하게 만들 수 있으며, 객체 간 결합도를 높이기 때문이다. 또한 의존 관계로 설정하면 가비지 컬렉션을 통해 더 이상 사용하지 않는 메모리를 회수해 메모리 누수를 방지하고, 메모리를 효율적으로 관리할 수 있게 된다.

 

 

클래스 다이어그램

 

 

단방향 연관 관계는 실선 화살표로, 의존 관계는 점선 화살표로 표시한다.

Customer 클래스는 Coffee 객체와 지속적인 연관 관계를 맺고 있지만, Menu 객체와는 일시적인 의존 관계를 맺고 있다.