카테고리 없음

결국 상속 구조를 포기 하기까지. (상속 구조 사용시 주의점)

scsi to. 2024. 12. 20. 16:39

A. 상속을 사용 했던 이유.

  • 현재 프로젝트에서 사용자는 3가지 종류(체크인 요청, 줄서기 요청, 기타 요청)의 요청을 할 수 있습니다.
  • 이 요청들은 조금 다른 점도 있었지만 대부분이 중복되는 필드와 매서드였습니다.
  • 그래서 이들은 is-a 관계라고 생각해서 상속으로 구현하였습니다.
  • 조회를 할 때도 이들을 Help 라는 부모로 묶어서 다형성을 통해 전체를 조회할 수도 있었기에 상속의 단점들에도 불구하고 괜찮은 선택이라고 생각했습니다. 
  • 요약하자면 '코드 재사용'과 '다형성 활용'을 위해서 상속을 사용하였습니다. 

B. 객체지향에서 상속을 사용하기 애매한 이유. 

조슈아 블로치님의 책을 참고하시면 상속의 취약점과 설계법 관련해 더 알 수 있습니다.

 

  • 상속은 객체지향에서 아이러니한 위치에 있는 기능이라고 생각합니다.
  • 처음 자바와 객체지향을 배울 때는 객체지향의 4가지 특징 중 하나로 배울만큼 상속은 주요 기능으로 보입니다.
  • 그렇지만 상속을 사용하게 되면 객체의 책임과 역할이 모호해지기 쉽고 오히려 좋은 객체지향 설계를 하기가 어려워 집니다. 
    1. 상속을 사용하는 순간 부모의 책임과 자식의 책임이 모호해져 SOLID의 원칙 중 SRP 원칙(Single Responsibility Principle, 단일 책임 원칙)을 위반하게 됩니다.
      • 또한, 현재 클래스로 상속 받은 상위 클래스의 기능과 명세를 알기 어려워 각 클래스의 책임 범위를 가늠하기 어렵습니다.  
    2. 하위 클래스가 다양해질 수록 불필요한 부모의 기능을 받아야 할 수 있어 ISP 원칙(Interface Segregation Principle, 인터페이스 분리 원칙)을 따르기도 힘들어 집니다. 
    3. 상속 관계가 is-a 관계임이 확실하다면 LSP의 원칙을 따를 수 있어야 하지만 LSP 원칙(Liskov Substitution Principle, 리스코프 치환 법칙)을 완벽히 따르는 것은 어려울 수 있습니다.
      • 처음부터 모든 요구사항을 반영한 설계를 하기 어렵습니다.
      • 개발 시작 후 요구사항의 변경이 있을 가능성도 높습니다.
      • 상속구조는 한번 만들어지면 변경이 필요 할때 하위 클래스에 미치는 영향이 커서 수정이 어렵습니다.
      • 결국 상속 관계를 나중에서는 포기하거나 LSP를 위반하고 사용할 수 밖에 없습니다. (그렇기에 LSP 위반 사례는 Stack이 Vector를 상속받은 경우 처럼 자바의 기본 Collection 라이브러리에서도 나타납니다.)
  • 이렇듯 상속을 섣불리 사용할 경우 프로젝트의 복잡성을 많이 증가 시킬수 있습니다. 
  • 확실히 런타임에서 의존성 변경이 쉽고 코드 레벨에서도 변경이 쉬운 합성(Composition)을 사용하는것이 편리 하겠다는 생각이 조금 들었습니다.
  • 하지만 이미 상속구조는 리포지토리 구조와 JPA등 프로젝트 전반에 영향을 끼치고 있었고 이것을 덜어내는 것은 쉬운 결정은 아니였습니다.

C. 결국 상속을 포기한 이유 - instance of 패턴

   private Help mapToDomain(HelpJPAEntity helpJPAEntity) {
        if (helpJPAEntity instanceof CheckInJPAEntity) {
            return CheckIn.from((CheckInJPAEntity) helpJPAEntity);
        } else if (helpJPAEntity instanceof EtcJPAEntity) {
            return Etc.from((EtcJPAEntity) helpJPAEntity);
        } else if (helpJPAEntity instanceof LineUpJPAEntity) {
            return LineUp.from((LineUpJPAEntity) helpJPAEntity);
        }
        throw new RuntimeException("Help not found");
    }
  • 간단한 상속 관계이고 is-a 관계도 맞다고 생각해 문제가 없을 줄 알았는데, 상속 구조에 instance of 패턴이 나타나기 시작했습니다.
  • instance of와 if else가 결합된 인스턴스 타입체킹(Instance Type Checking)클래스 캐스팅(Class Casting)다형성(Polymorphism)을 제대로 사용하지 못 하고 있다는 대표적인 코드 스맬(Code Smell)입니다
    • 아래에도 있지만 instance of 는 OCP, SRP를 위반하게 하기에 instance of 대신 다형성을 이용하여 각자의 기능을 구현하는 것이 장려 됩니다. (일상 속 둔치님, Maxi Contieri)
    • 클래스 캐스팅 또한 다형성을 이용하지 않고 각자의 클래스에서 기능을 만들어 이용하고 있다는 뜻이므로 이보다는 다형성을 이용하는 것이 장려됩니다. (Erik Dietrich,  Yegor Bugayenko)
  • 또한,  instance of 패턴을 사용하게 되며 좋은 객체 지향의 원칙에서 멀어지기 시작했습니다. (BE_성하님, 일상 속 둔치님)
    • SRP 위반: 해당 클래스는 부모를 받았지만 내부에서는 타입을 검사해서 자식의 로직을 사용하기 때문에 사실상 부모의 동작이 아닌 아닌 자식의 동작을 책임지고 있게 됩니다.  
    • OCP 위반(Open-Closed Principle, 개방 패쇄 원칙): 확장시 기존 코드의 변경 없이 기능 추가가 불가능하고 instance of가 쓰인 모든 곳에 코드를 추가해야 합니다.  
    • LSP 위반 가능성: 상속 받은 기능이 아닌 특정 클래스에 종속된 기능이 생기고 있다는 코드 스맬입니다. 상황에 따라 is-a 관계에서 벗어나고 있을 수 있어 장기적으로 LSP 위반 가능성이 있습니다. (Seunghyuk Shin님, symfonycasts, Eugene Yarovoi)

D. 변경 과정

1. 심플 팩토리 패턴 + 전략 패턴

      static final List<HelpSelectResponse> responses = List.of(
                new CheckInSelectResponse(),
                new EtcSelectResponse(),
                new LineUpSelectResponse()
        );

        public static HelpSelectResponse from(Help help) {
            return responses.stream()
                    .filter(response -> response.canHandle(help))
                    .findFirst()
                    .map(response -> response.createResponse(help))
                    .orElseThrow(() -> new HelpException(HELP_RESPONSE_CREATION_ERROR));
        }

 

 

  • 이전에는 매퍼가 부모를 받아서 직접 어떤 자식을 사용할지 선택해서 부모를 받았지만 자식을 사용하는 SRP위반 이였지만 지금의 팩토리는 단순히 각 객체에 다가 변환 가능한지 물어보고 받은걸 돌려주는 역할만 하기에 SRP위반을 하지 않습니다
  • 코드가 아닌 클래스의 확장으로 확장 및 변경이 가능하기에 OCP위반을 하지 않습니다. 
  • instance of 패턴과 클래스 캐스팅을 제거하고 다형성을 사용하여 구현하였지만 고민이 되었습니다.
    • 이런 패턴이 나타난건 사실 이들 각자의 로직이 필요하기에 나타난 것이기도 합니다.
    • 그래서 현재 로직들은 부모 클래스로  묶어서 다형성으로 구현하기가 힘듦니다. 
    • 오히려 is-a 관계이고 LSP를 지킨다고 섣불리 상속을 도입한것이 아닐까 생각하게 되었습니다. 
    • 차라리 상속 구조를 사용하지 않는 다면 어떻게 구현할 수 있을지도 고민해 보게 되었습니다.

2. 코드 분리 (중복을 두려워 말자.)

public final class CheckIn {

    private final Long id;
    private final Long helpRegisterId;
    private final String title;
    private final LocalDateTime start;
    private final LocalDateTime end;
    private final String placeId;
    private final Long reward;
    private final Progress progress;
}
public final class LineUp {

    private final Long id;
    private final Long helpRegisterId;
    private final String title;
    private final LocalDateTime start;
    private final LocalDateTime end;
    private final String placeId;
    private final Long reward;
    private final Progress progress;
}
public interface CheckInRepository {

    public Long save(CheckIn checkIn);

    public CheckIn findById(Long id);

    public CheckIn update(CheckInService.Update dto);
}
public interface LineUpRepository {

    public Long save(LineUp lineUp);

    public LineUp findById(Long id);
    
    public LineUp update(LineUpSeervice.Update dto);
}
  • instance of 패턴을 풀기 위해 다형성을 풀기는 했지만 기능적인 부분에서 다형성이 쓰이는 일은 조회때 밖에 없었습니다. 
  • 사실상 instance of 패턴이 나타났던 것도 중복되는 필드가 있기는 해도 각자의 비즈니스 로직들이 존재하기 때문이었습니다. 
  • 그래서 DDD의 관점으로 보았을때 이들은 하나의 '도움'이라는 개념보다 '체크인', '줄서기', '기타 요청'이라는 각각 다른 바운디드컨텍스트라는 생각이 들었습니다. 
  • 코드 중복을 피하기 위해 섣불리 상속을 도입하면서 애그리거트 루트들이 무리하게 통합되어 있다는 생각이 들어서 코드 중복을 두려워 하지 않고 분리하기로 하였습니다. 

3. 값객체를 통한 중복 제거 & 인터페이스를 통한 다형성 구현

@Getter
public final class HelpDetail {

    private final Long helpRegisterId;
    private final String title;
    private final LocalDateTime start;
    private final LocalDateTime end;
    private final String placeId;
    private final Long reward;
    
    public interface DTO {
        Long getHelpRegisterId();

        String getTitle();

        LocalDateTime getStart();

        LocalDateTime getEnd();

        String getPlaceId();

        Long getReward();
    }
}
  • 상속을 사용해야 되다는 생각에서 벗어나자 값객체(Value Object)를 이용하여 중복되는 필드들을 각각의 다른 바운디드 컨텍스트에서 사용할 수 있었습니다. 
  • HelpDetail이라는 엔티티를 가지지만 이것은 각각 CheckIn, LineUp, Etc의 바운디드 컨텍스트에서 CheckIn의 HelpDetail, LineUp의 HelpDetail, Etc의 HelpDetail라는 다른 의미로 사용되어 집니다. 
  • 각각의 컨텍스트에 알맞게 사용될 수 있게 인터페이스를 통해 다형성을 구현하도록 하여 각 엔티티들에게서 필요한 값들을 받음.

E. 결론

    공통된 필드를 가지고 재사용성을 위해서만 상속을 도입하는 것은 예상치 못한 결과들을 나았습니다. 코드가 조금 중복되는 것을 두려워하다가 오히려 먼길을 돌아오게 되었습니다. 객체지향의 원칙들과 점점 멀어져가고 복잡해져가는 로직을 보며 상속은 사용하기전에 꼭 주의가 필요하다는 생각입니다. 중복을 두려워 하지말고 또한, 상속보다 합성을 사용하자는 원칙의 중요성을 다시 한번 깨우치는 경험이였습니다. 

 

 

출처

상속

elliee.strong님의 상속은 정말 나쁜가?

Jake Seo님의 자바에서 상속이 갖는 단점들

GoldenDusk님의 🤔 자바 기초 배울 때 앞쪽에 나오는 상속 왜 현업에서는 잘 안쓴다고 하는 걸까? (feat. 코틀린에서 상속은?)

 

LSP

Seunghyuk Shin님의 [SOLID] 리스코프 치환 원칙(LSP)

GhostCat의 답변 - Difference between the IS-A and Liskov Substitution Principle?(Stack Overflow)

Liskov: Unexpected Expections (symfonycasts)

 

instance of

Eugene Yarovoi의 답변 - Is Using instanceof in Java consider bad practice? Any alternative to using this keyword? (Quora)

BE_성하님의 instanceof 사용 지양하기 - Why? Soultion?

일상 속 둔치님의 [Kotlin/Java] instanceof를 지양하자

Code Smell 23 - Instance Type Checking by Maxi Contieri.

 

Class Casting

Class Casting Is a Discrminating Anti-Pattern by Yegor Bugayenko

Casting is a Polymorphism Fail by Erik Dietrich

 

Composition

Runtime Inheritance

코딩공장공장장님의 컴파일 타임 의존성과 런타임 의존성

Inpa Dev님의 💠 상속을 자제하고 합성(Composition)을 이용하자

Namhoon Kim님의 018. (Objects) 11. 합성과 유연한 설계