본문 바로가기

[OOP] 상속과 조립 (객체 지향과 디자인 패턴)

 

들어가며

스타트업의 개발자로서 좋은 설계가 우선인가, 아니면 빠른 기능 개발이 우선인가 항상 고민하곤 했습니다. 제가 다녔던 스타트업은 빠르게 기능 개발을 해서, 시장에서 인정받아야 했기에, 빠른 시간 안에 많은 기능을 개발해야 했습니다. 그러다 문득 추가 기능을 개발하거나, 기존 기능을 수정해야 할 때, 많은 어려움을 겪곤 했습니다. 그렇게 일을 마치고, 더 나은 개발자가 되기 위한 공부를 하면서 제가 작성한 코드는 배려가 부족한 코드였다는 것을 깨달았습니다.

 

제가 개발한 코드는 당장의 기능 개발은 빠르게 할 수 있더라도, 재사용성이 대단히 떨어졌고, 가독성 또한 대단히 떨어졌습니다. 또한 기능을 수정하거나 추가할 때도 광범위한 코드를 건드려야만 했기에 효율성도 떨어졌습니다. 빠르게 기능을 개발하기 위해 적었던 코드가 언젠가는 발목을 잡을 수 있는 코드가 될 수 있다는 것을 깨달았습니다. 그렇다면 비즈니스에 도움이 되고, 다른 팀원들에게도 도움이 될 수 있는, 배려가 담긴 코드를 작성하기 위해서는 어떻게 해야 할까 고민하기 시작했습니다. 배려가 담긴 코드를 작성하기 위해 공부한 내용에 대해 작성해보고자 합니다. 이 글은 도서 객체 지향과 디자인 패턴을 바탕으로 작성됐습니다.

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

상속

실생활에서 상속은 부모가 자식에게 무언가 물려준다는 의미입니다. OOP에서도 크게 의미가 다르지 않습니다. 부모 클래스의 메서드 혹은 필드를 자식 클래스에게 물려줍니다. 이런 상속의 개념을 살펴보겠습니다.

 

 

상속의 장점

1. 코드를 재사용할 수 있다.

하위 클래스는 상위 클래스의 메서드나 필드를 이용할 수 있습니다. 다만 protected 혹은 public 접근 제한자를 주어야 합니다. 아무리 하위 클래스라도 상위 클래스의 해당 기능에 private을 설정한다면 접근할 수 없습니다. 우선, 저는 상속을 적용하지 않은 비효율적인 코드부터 작성해 보겠습니다.

 

 

class Boy {
  private name: string;

  public constructor(name: string) {
    this.name = name;
  }

  public study(): void {
    console.log('[' + this.name + '] 공부합니다.');
  }

  public breathe(): void {
    console.log('[' + this.name + '] 숨 쉬고 있습니다.');
  }

  public run(): void {
    console.log('[' + this.name + '] 뛰고 있습니다.');
  }
}

class Girl {
  private name: string;

  public constructor(name: string) {
    this.name = name;
  }

  public study(): void {
    console.log('[' + this.name + '] 공부합니다.');
  }

  public breathe(): void {
    console.log('[' + this.name + '] 숨 쉬고 있습니다.');
  }

  public run(): void {
    console.log('[' + this.name + '] 뛰고 있습니다.');
  }
}

 

 

위 코드는 Boy, Girl 객체가 정의되어 있습니다. study, breathe, run과 같은 행위를 정의하고, 해당 기능을 사용하려면 아래와 같이 사용해야 합니다.

 

 

class Main {
  public static main(): void {
    const boy = new Boy("철수");
    const girl = new Girl("영희");

    boy.study();
    girl.study();

    boy.breathe();
    girl.breathe();

    boy.run();
    girl.run();
  }
}

Main.main();

 

위의 코드를 통해 출력된 내용은 아래와 같습니다.

 

 

 

 

 

 

 

단순한 기능이지만, study, breathe, run의 행위가 중복되는 것을 알 수 있습니다. 중복되는 문제를 상속을 통해 해결할 수 있습니다. 공부라는 특성을 살려서 학생 상위 클래스를 정의해 보겠습니다.

 

 

class Student {
  protected name: string;

  constructor(name: string) {
    this.name = name;
  }

  public study(): void {
    console.log("[" + this.name + "] 공부합니다.");
  }

  public breathe(): void {
    console.log("[" + this.name + "] 숨 쉬고 있습니다.");
  }
}

 

 

공통된 특성을 위와 같이 정의했습니다. 그리고 위 상위 클래스를 상속받은 하위 클래스들을 보겠습니다.

 

 

class Boy extends Student {
  public constructor(name: string) {
    super(name);
  }

  public run(): void {
    console.log("[" + this.name + "] 뛰고 있습니다.");
  }
}

class Girl extends Student {
  public constructor(name: string) {
    super(name)
  }

  public run(): void {
    console.log("[" + this.name + "] 뛰고 있습니다.");
  }
}

 

 

위와 같이 상속을 활용할 때, study와 breathe의 경우 재사용이 가능하다는 장점이 있고, name 필드도 상위 클래스에 있는 것을 활용함으로써 하위 클래스만의 기능에 집중할 수 있게 되었습니다. 만약 Boy가 공부한다의 특징을 변경하고 싶다면 아래처럼 재정의할 수 있습니다. 

 

 

class Boy extends Student {
  public constructor(name: string) {
    super(name);
  }

  override study(): void {
    console.log("[" + this.name + "] 열심히 공부합니다.");
  }

  public run(): void {
    console.log("[" + this.name + "] 뛰고 있습니다.");
  }
}

 

 

다음과 같이 변경할 수 있습니다. 

 

 

 

2. 다형성을 구현한다.

객체와의 관계를 만들어 준다는 것은 다시 말해서 다형성을 살린다는 의미입니다. 다형성 구현 방식으로 오버 로딩, 오버 라이딩 등이 있지만, 사실 오버 라이딩 없는 상속만 사용해도 다형성의 의미를 살릴 수 있습니다.

 

위의 예시에서 Student 타입에는 Boy 혹은 Girl 타입을 대입할 수 있습니다. 만약 다형성이 없다면 아래와 같이 Student로 추출한 메서드들을 하나하나 코드로 작성해야 합니다.

 

 

class Main {
  public static main(): void {
    const boy = new Boy("철수");
    const girl = new Girl("영희");

    boy.study();
    girl.study();

    boy.breathe();
    girl.breathe();
  }
}

 

 

하지만 상속을 통해 다형성을 구현하면, 반복문을 통해 공통된 메서드들을 한 번씩만 코드로 작성하면 됩니다.

 

 

class Main {
  public static main(): void {
    const students = [new Boy("철수"), new Girl("영희")]
    
    students.map(student => {
      student.study();
      student.breathe();
    })
  }
}

 

 

 

 

상속의 문제

객체 지향의 주요 특징으로 재사용을 말하곤 합니다. 그리고 그 예로 상속을 들곤 합니다. 물론, 상속을 사용하면 상위 클래스에 구현된 기능을 그대로 재사용할 수 있기 때문에, 상속을 사용하면 재사용을 쉽게 할 수 있습니다. 하지만 상속을 사용할 경우 몇 가지 문제점이 있습니다. 지금부터 이에 대해 알아보겠습니다. 

 

 

출처 : https://m.blog.naver.com/songintae92/221383560464

 

 

여기서는 공통적인 속성 및 메서드를 상위 클래스에 선언함으로써 하위 클래스에서는 그 속성과 기능을 그대로 사용할 수 있습니다. 또한 하위 클래스에서는 하위 클래스만의 기능을 추가하여 확장할 수 있습니다. 즉, 상속을 사용하면 쉽게 다른 클래스의 기능을 재사용하면서 추가 기능을 확장할 수 있기 때문에, 상속은 기능을 재사용하는 매력적인 방법입니다. 하지만, 상속은 변경의 유연함이라는 측면에서 아래와 같이 치명적인 단점을 갖습니다.

 

 

  • 1. 상위 클래스 변경의 어려움
  • 2. 클래스 개수 증가
  • 3. 상속의 오용 문제

 

지금부터 위의 단점들에 대해 살펴보겠습니다.

 

 

 

 

 

출처 : 객체 지향 프로그래밍 입문(인프런)

1. 상위 클래스 변경의 어려움

첫 번째, 상속은 상위 클래스의 변경을 어렵게 만듭니다. 예를 들어 AbstractController 클래스를 변경했다면, 변경에 의해 AbstractUrlViewController, BaseCommandController의 클래스가 변경될 수 있습니다. 즉 어떤 클래스를 상속받는다는 것은 그 클래스에 의존한다는 뜻입니다. 따라서 의존하는 클래스의 코드가 변경되면 영향을 받을 수 있습니다. 최악의 경우 상위 클래스의 변화가 모든 하위 클래스에 영향을 줄 수 있습니다. 

 

 

 

 

 

출처 : 객체 지향 프로그래밍 입문(인프런)

 

 

2. 클래스의 불필요한 증가

두 번째 문제점은 유사한 기능을 확장하는 과정에서 클래스의 개수가 불필요하게 증가할 수 있다는 것입니다. 예를 들어 위처럼 파일 보관소를 구현한 Storage 클래스가 있다고 할 때, 제품이 출시된 이후 보관소의 용량을 아낄 수 있는 방법과, 보안이 문제가 되어서 파일을 암호화해서 저장해주는 방법을 제공해 달라는 요청이 발생했다고 하면, 우리는 상속을 통해 CompressedStorage, EncryptedStorage 클래스를 추가했습니다. 

 

그런데, 만약 압축을 먼저 하고 암호화, 또는 반대로 암호화를 하고 압축이 필요하다거나 또는 성능 향상을 위한 캐시를 제공하는 저장소가 필요하고, 추가로 암호화된 캐시를 저장해야 하는 요구사항이 발생하면 어떻게 될까요? 상속을 통해 이런 기능을 구현하려면 위 사진의 오른쪽과 같은 클래스 구조가 만들어질 것입니다.

 

즉 조립을 사용하지 않고 상속을 통해 구현하려고 하면 필요한 기능의 조합이 증가할수록 클래스의 개수는 함께 증가하게 됩니다. 

 

 

 

 

3. 상속의 오용

세 번째 문제점은 상속 자체를 잘못 사용할 수 있다는 점입니다. 예를 들어 확인해보겠습니다. 컨테이너의 수화물 목록을 관리하는 클래스가 필요하다고 할 때 이 클래스는 다음의 세 가지 기능을 제공할 수 있을 것입니다.

 

  • 수화물을 넣는다.
  • 수화물을 뺀다.
  • 수화물을 넣을 수 있는지 확인한다.

 

이 기능을 구현해야 할 개발자는 목록 관리 기능을 직접 구현하지 않고 ArrayList 클래스가 제공하는 기능을 상속받아서 사용하기로 결정했고, 아래와 같이 구현했습니다.

 

public class Container extends ArrayList<Luggage> {    
    private int maxSize;
    private int currentSize;
    
    public Container(int maxSize) {
        this.maxSize = maxSize;
    }
    
    public void put(Luggage lug) {
        if(!canContain(lug))
            throw new NotEnoughSpaceException();
        super.add(lug);
        currentSize += lug.size();
    }
    
    
    public void extract(Luggage lug) {
        super.remove(lug);
        currentSize -= lug.size();
    }
    
    public boolean canContain(Luggage lug) {
        return maxSize >= currentSize + lug.size();
    }
}

 

이렇게 하고 다른 개발자들에게 사용하도록 Container 클래스를 제공했다고 하겠습니다. 이것을 만든 개발자는 luggage를 추가할 때 put을 사용하도록 구현했지만, 실제로 개발자들은 자동완성을 통해 발견한 add 메서드를 사용하여 luggage를 추가했습니다. 이렇게 되면 결국 원치 않는 동작을 하게 되고 오류로 직결되게 됩니다.

 

위와 같은 문제가 발생하는 이유는 Container가 사실 ArrayList가 아니기 때문입니다. 상속은 IS-A 관계가 성립할 때에만 사용해야 하는데 지금은 그렇지 않습니다. Container는 수화물을 보관하는 책임을 갖는 반면에 ArrayList는 목록을 관리하는 책임을 갖습니다. 즉, 둘은 서로 다른 책임을 갖는 것입니다. 이렇게 같은 종류가 아닌 클래스의 구현을 재사용하기 위해 상속을 받게 되면, 잘못된 사용으로 인한 문제가 발생하게 됩니다. 그럼 상속의 문제를 조립은 어떻게 해결할 수 있을까요? 이에 대해 알아보겠습니다.

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

조립을 이용한 재사용

조립은 여러 객체를 묶어서 더 복잡한 기능을 제공하는 객체를 만드는 것입니다. 객체 지향 언어에서는 객체 조립은 보통 필드에서 다른 객체를 참조하는 방식으로 구현됩니다. 

 

 

public class FlowController {
    private Encryptor encryptor = new Encryptor();
    
    public void process() {
        ...
        byte[] encryptedData = encryptor.encrypt(data);
    }
}

 

한 객체가 다른 객체를 조립해서 필드로 갖는다는 것은 다른 객체의 기능을 사용한다는 의미를 내포합니다. 조립을 통한 재사용은 앞서 상속을 통한 재사용에서 발생했던 문제들을 해소해 줍니다. 앞서 클래스 증식 문제를 야기했던 Storage 클래스를 조립으로 구현하면 구조는 아래와 같습니다.

 

 

출처 : 객체 지향 프로그래밍 입문(인프런)

 

 

이렇게 조합을 이용하면, 압축을 먼저 하고 암호화를 하거나, 암호화를 먼저 하고 압축을 하는 기능이 추가되어도 추가적인 클래스 없이 Encryptor와 Compressor를 이용하여 구현할 수 있습니다. 또한 만약 Storage 클래스 내용이 변경되어도 Encryptor와 Compressor는 변경될 필요가 없습니다. 즉 변경의 유연함을 가지게 됩니다.

 

 

public class Container{
    private List<Luggage> luggageList = new ArrayList<>(); // 필드로 추가
    private int maxSize;
    private int currentSize;
    public void put(Luggage lug) {
        if(!canContain(lug))
            throw new NotEnoughSpaceException();
        luggageList.add(lug);
        currentSize += lug.size();
    }
....
}

 

 

또한 조립을 사용하면 상속을 잘못 사용해서 발생했던 문제도 제거됩니다. 만약 아까처럼 Container 클래스가 조립을 이용하여 ArrayList를 필드로 가지고 이용한다면 기능을 오용하여 비정상적으로 동작하는 것을 막을 수 있습니다. 

 

 

 

public class Storage {
    private Compressor compressor = new Compressor();
    public void setCompressor(Compressor compressor) {
        this.compressor = compressor;
    }
...
}

 

또한 런타임에 조립 대상 객체를 교체할 수 있다는 것도 큰 장점입니다. 조립은 수정자 메서드(set 메서드)를 구현함으로써 조립에 사용되는 객체를 Runtime시에 변경할 수 있습니다. 이러한 장점들 때문에 상속보다는 객체 조립을 사용하는 것이 좋습니다. 

 

 

 

 

 

 


 

 

 

 

 

 

 

상속은 언제 사용하나?

지금까지 봤을 때, 상속보다 무조건 조립을 사용하는 것이 좋아 보입니다만, 모든 상황에서 객체 조립을 사용해야 한다는 것은 아닙니다. 상속은 클래스의 행동을 확장할 때가 아니라 정제할 때 사용하는 편이 좋다고 합니다. 확장이란 새로운 행동을 덧붙여 기존의 행동을 부분적으로 보완하는 것을 의미하고, 정제란 부분적으로 불완전한 행동을 완전하게 만드는 것을 의미합니다. 즉 상속은 정제할 때 사용하는 것이 바람직하다는 것인데, 인터페이스와 추상 클래스를 활용한 코드를 정제할 때 도움이 될 수 있습니다.

 

추상 클래스나 인터페이스는 추상 메서드가 존재하며, 하위 클래스에서는 해당 추상 메서드를 재정의해야 합니다. 추상 메서드는 불완전한 것이고, 하위 클래스가 불완전한 것을 재정의함으로써 완전한 것으로 탈바꿈시킵니다. 그럼에도 불구하고 부모 클래스를 상속받은 하위 클래스에서 부모 클래스에서 활용하는 특정 메서드는 절대 바뀌지 않는 것이라면, 추상 메서드를 계속 오버 라이딩해서 코드를 구현해줄 필요는 없다고 생각합니다. 그러기에 명확한 IS-A 관계가 성립된다면 상속을 이용해 기능을 확장할 수 있다고 생각합니다. 그렇지만 이후에 클래스의 개수가 불필요하게 증가하는 문제가 발생하거나 변경이 어려워지면 조립으로 전환하는 것을 고려해야 합니다. 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

마치며

객체지향 프로그래밍의 필요성을 점점 느끼고 있습니다. 언젠가, 코드에 대한 역할과 책임을 명확하게 보여줄 수 있는 코드를 작성할 수 있다면 동료들이, 일을 더 수월하게 할 수 있지 않을까 생각합니다. 배려가 담긴 코드를 작성하기 위해 꾸준하게 노력하고 싶습니다. 

 

 

 

 

 

 


 

 

 

 

 

 

출처

 

객체 지향과 디자인 패턴 - YES24

객체 지향 안내서. 객체, 책임, 의존, 캡슐화 등 객체 지향의 주요 개념들을 쉬운 예제와 그림을 통해 이해하기 쉽게 설명한다.

www.yes24.com

 

객체 지향 프로그래밍 입문 - 인프런 | 강의

잘 하는 개발자가 되기 위해서는 유연한 코드를 작성할 줄 알아야합니다. 객체 지향을 이용해서 변경하기 좋은 유연한 코드를 만드는 방법을 알아보세요., - 강의 소개 | 인프런...

www.inflearn.com

 

객체 지향

이번 포스트에서는 객체지향에 대해 포스트를 진행하겠다.절차지향과 객체 지향객체 지향을 잘 하려면 이와...

blog.naver.com

 

[OOP] 상속(Inheritance)이란?

안녕하세요? 제이온입니다. 이번 시간에는 저번 포스팅인 캡슐화에 이어서 다형성에 대해 알아보겠습니다. 상속이란? 위키피디아에 따르면, 상속을 아래와 같이 정의하고 있습니다. 상속은 객

steady-coding.tistory.com

 

[타입스크립트] 추상 클래스와 인터페이스

들어가며 개발을 하면서, 자바스크립트의 언어적 특성 때문에 많은 불편함을 느꼈습니다.  타입 에러를 제대로 확인 못하고 코드를 배포했다가 서버에 문제가 생긴 적이 있었습니다. 런타임 시

overcome-the-limits.tistory.com