본문 바로가기
Study/디자인 패턴의 아름다움

[디자인 패턴의 아름다움] 2. 객체지향 프로그래밍 패러다임 - 2.8~2.9 정리

by Nahwasa 2024. 4. 6.

스터디 메인 페이지

목차

    - ☆ 표시가 붙은 부분은 스터디 중 나온 얘기 혹은 제 개인적인 생각이나 제가 이해한대로 적어놓은 것으로, 책에 나오지 않는 내용입니다. 따라서 책에서 말하고자 하는 바와 다를 수 있습니다.

    - 모든 이미지의 출처는 디자인패턴의 아름다움(왕정 저) 책 입니다.

     


     

    2.8 인터페이스 기반 프로그래밍

    인터페이스 기반 프로그래밍: 모든 클래스에 대해 인터페이스를 정의해야 할까?

     

    인터페이스를 이해하는 다양한 방법

    • 구현이 아닌 인터페이스에 대한 프로그래밍(Program to an interface, not an implementation)
      • 이해할 때 특정 프로그래밍 언어를 떠올리면 안된다 (사고가 해당 언어의 인터페이스 관련 문법에 갇혀 버리기 때문)
      • 인터페이스 : 상위 수준의 추상적인 이해이며 실제 코드와는 거의 관련이 없다.
    • 구현이 아닌 인터페이스 기반의 프로그래밍을 통해 구현과 인터페이스를 분리하고, 불안정한 구현을 직접 노출하는 대신 캡슐화하여 감추고, 안정적인 인터페이스만 노출할 수 있다.
    • 구현이 아닌 추상화에 기반한 프로그래밍 : 추상화는 코드의 확장성, 유연성, 유지 보수성을 향상시키는 효과적인 수단이다.

     

    설계 철학을 실제로 적용해보자

    • AWS를 사용해 이미지 처리하는 코드
      • 위의 코드는 AWS에 사진을 저장하는 비즈니스 요구 사항을 완전히 충족 가능
      • 하지만 AWS 대신 프라이빗 클라우드를 구축해서 이미지를 처리한다는 요구 사항이 들어오면, 해당 클래스를 설계하고 구현해야 하며 AWSImageStore를 사용하는 모든 클래스를 수정해야 함.
    public class AWSImageStore {
    	public void createBucketIfNotExisting(String bucketName) {...}
        public String generateAccessToken() {...}
        public String uploadToAWS(Image image, String bucketName, String accessToken) {...}
        public Image downloadFromAWS(String url, String accessToken) {...}
    }

     

    • 만약 코드 변경을 최소화 하기 위해 PrivateImageStore 클래스를 구현할 때 AWSImageSotre 클래스와 동일한 public 메서드를 정의한다면 생기는 문제
      • 이미 AWSImageStore 클래스의 public 메서드의 이름은 uploadToAWS() 처럼 AWS를 사용한다는 세부 구현 정보를 노출하고 있음. PrivateImageStore에서 이걸 그대로 쓰는건 부적절해 보임.
      • AWS와 달리 Private ImageStore는 토큰이 필요하지 않을 수 있는데, 이미 AWSImageStore에는 토큰과 관련된 public 메서드가 있음.

     

    • 근본적인 해결책은 코드를 처음에 작성할 때 부터 구현이 아닌 인터페이스 기반의 설계 철학에 따라야 함.
      • 함수 또는 메서드의 이름은 구현 세부 사항을 노출하지 않아야 한다. uploadToAWS() -> upload()
      • 구체적인 구현 세부 사항을 캡슐화해야 한다. 토큰 사용과 같은 특별한 전략이나 프로세스가 호출자에게 노출되지 않아야 함.
      • 클래스 구현을 위한 추상 인터페이스를 정의해야 한다.
    public interface ImageStore {
    	String upload(Image image, String bucketName);
        Image download(String url);
    }
    
    public class AWSImageStore implements ImageStore {
    	private void createBucketIfNotExisting(String bucketName) {...}
        private String generateAccessToken() {...}
        public String upload(Image image, String bucketName) {
        	createBucketIfNotExisting(bucketName);
            String accessToken = generateAcessToken();
            ...
        }
        public Image download(String url) {
            String accessToken = generateAcessToken();
            ...
        }
    }
    
    public class PrivateImageStore implements ImageStore {
    	...
    }

     

     

    • 인터페이스를 정의할 때 클래스를 먼저 구현하고 그에 맞추어 인터페이스를 정의하는 경우
      • 추상화가 충분히 이루어지지 않는다.
      • 인터페이스 정의가 구체적인 구현에 의존하는 좋지 않은 형태로 고착될 가능성이 높다.
    • 결론적으로 인터페이스 정의는 구현 세부 정보를 노출하지 않으며, 구체적인 수행 방법이 아닌, 어떤 작업을 수행하는지만 고려한다.

     

    인터페이스의 남용을 방지하려면 어떻게 해야 할까?

    • 비즈니스 시나로이에서 특정 기능에 대한 구현 방법이 하나뿐이고, 이후에도 다른 구현 방법으로 대체할 일이 없다면 인터페이스를 정의할 필요가 없다.
    • 이 설계 사상을 남용하게 되면 클래스별로 인터페이스를 정의해야 해서 사방에 펼쳐진 인터페이스는 개발에 불필요한 부담이 된다.
    • 함수의 정의가 충분히 추상적이라면, 인터페이스가 없어도 구현이 아닌 추상화 사상을 만족할 수 있다.

     


     

    2.9 상속보다 합성

    상속이 더 이상 사용되지 않는 이유

    • 상속은 객체지향 프로그래밍의 4대 특성 중 하나로 클래스 간의 is-a 관계를 나타내는 데 사용되며 코드 재사용 문제를 해결할 수 있다.
    • 새에 대한 클래스를 설계하기 위해 추상 클래스인 AbstractBird를 만들고, 거기에 fly() 메서드를 정의할 경우 타조와 같은 날 수 없는 새가 존재하므로 명확한 예외가 존재한다. 이 경우 AbstractFlyableBird와 AbstractUnFlyableBird로 세분화된 추상 클래스를 파생시키는 방법을 생각해볼 수 있다.
    • 이렇게 계속 경우의 수가 추가될 때 마다 상속 단계가 늘어나고 조합의 수가 기하급수적으로 증가하게 된다.

    ☆ 이하 이미지는 이 책의 이미지가 아니라 오브젝트 책의 이미지 입니다. 이 책의 이미지는 충분히 괜찮아보여서 좀 더 조합 수 늘어난 오브젝트 책의 조합의 폭발 설명 이미지로 가져왔슴다.

     

    • 즉, 클래스의 상속 계층은 점점 더 깊어지고, 상속 관계는 점점 더 복잡해진다.
    • 이에 따라 코드의 가독성이 떨어진다. 클래스에 포함된 메서드와 속성을 파악하려면 상위 클래스의 코드뿐만 아니라 상속을 거슬러 올라가 모든 상위 클래스를 파악해야 하기 때문이다. 또한 이는 캡슐화가 깨진 것이기도 하다.
    • 또한 상위 클래스의 코드가 수정되면, 모든 하위 클래스에 영향을 미치게 된다.

     

    • ☆ 이 책엔 나오지 않지만, 그 이외에도 상속으로 인해 아래와 같은 문제들이 발생할 수 있다. (이하 오브젝트 책에서 나왔던 내용들이다. 좀 더 자세히 알고 싶다면 '[오브젝트] 10장. 상속과 코드 재사용' 글에 오브젝트 책의 관련 내용 정리해둔게 있다.)
    • ☆ 불필요한 인터페이스 상속으로 인한 문제 - 자바의 경우 Stack은 Vector를 상속해서 만들었다. 그래서 자료구조 스택에 있으면 안되는 메서드들이 많이 존재해서 아래처럼 이상하게 동작시키는게 가능하다. 저건 2가 출력된다.
    Stack<Integer> stack = new Stack<>();
    stack.push(0);
    stack.push(1);
    stack.push(2);
    stack.add(0, 3);
    System.out.println(stack.pop());

     

    • 메서드 오버라이딩의 오작용 문제 - 직관적으로는 코드 가장 하단의 출력에서 '4'가 떠야할 것 같다. 하지만 실제론 super.addAll()에서 add()를 호출하므로, '7'이 떠버린다. 이걸 파악하기 위해선 부모 클래스까지 모두 분석을 완벽히 해야된다.
    class CountableHashSet<E> extends HashSet<E> {
        private int addCount = 0;
    
        @Override
        public boolean add(E e) {
            addCount++;
            return super.add(e);
        }
    
        @Override
        public boolean addAll(Collection<? extends E> c) {
            addCount += c.size();
            return super.addAll(c);
        }
    
        public int addCount() {
            return addCount;
        }
    }
    
    ...
    
    CountableHashSet<Integer> set = new CountableHashSet<>();
    set.add(10);
    
    List<Integer> list = List.of(20, 30, 40);
    set.addAll(list);
    
    System.out.println(set.addCount);

     

     

    합성이 상속에 비해 나은 장점

    • 상속에는 is-a 관계 표현, 다형성 지원, 코드 재사용이라는 세 가지 주요 기능이 있다. 하지만 이 세 가지 기능은 굳이 상속을 사용하지 않더라도 다른 기술적 수단을 통해 대체할 수 있다.
    • 상속 문제는 합성(composition), 인터페이스, 위임(delegation)이라는 세 가지 기술적 방법을 통해 해결할 수 있다.
    • 이하 위 3가지 기술적 방법으로 코드 중복 문제를 해결한 경우
    public interface Flyable {	// 인터페이스
    	void fly();
    }
    
    public class FlyAbillity implements Flyable {
    	@Override
        public void fly() {
        	...
        }
    }
    
    public class Ostrich implements Flyable {
    	private FlyAbillity flyAbillity = new FlyAbiliity(); // 합성
        
        @Override
        public void fly() {
        	flyAbillity.fly();	// 위임
        }
    }

     

     

    합성을 사용할지 상속을 사용할지 결정하기

    • 클래스와 인터페이스의 수는 코드의 복잡성과 유지 관리 비용을 증가시킨다. 위의 코드 예시에서도 기존보다 더 많은 클래스와 인터페이스를 정의해야 했다.
    • 실제 프로젝트에서는 상황에 따라 상속을 사용할 것인지 합성을 사용할 것인지 선택해야 한다.
    • 클래스 간의 상속 구조가 안정적이어서 쉽게 변경되지 않고 상속 단계가 2단계 이하로 비교적 얕아 상속 관계가 복잡하지 않다면 과감하게 상속을 사용할 수 있다.
    • 반대로 시스템이 불안정하고 상속 계층이 깊고 상속 관계가 복잡하면 상속 대신 합성을 사용해야 한다.
    • 다만 예를들어 FeignClient 클래스가 외부 클래스로 코드를 직접 수정할 수 없지만, 이 클래스에서 실행되는 encode() 함수를 재정의하여 다른 기능을 구현하고자 한다면 현재로서는 상속을 통해서만 이 목적을 달성할 수 있다.
    • 더 많은 합성, 더 적은 상속을 권장하는 이유는 의외로 오랫동안 많은 프로그래머가 상속을 남용했기 때문이다. (☆ 위쪽에 중간에 예시로 들었던 Stack만 해도 자바 라이브러리에 포함되지만 상속 남용한 경우임. 자바의 Queue 같은것도 마찬가지임.) 
    • 합성은 완벽하지 않으며 상속이 항상 쓸모없는 것도 아니다. 따라서 부작용을 잘 다독여서 각각의 장점을 최대한 활용하고, 적절하게 선택하는 것이 우리가 추구해야 할 방향이다.

     

     

    댓글