avatar
띵로그

저는 사실 '상속' 을 별로 좋아하지 않아요 ?

저는 왜 '상속'을 피하고 싶은지에 대한 이유와 컴포지션이 어떤 장점들을 제공하는지 비교해 보겠습니다
상속컴포지션개발방법OOP객체지향설계
a month ago
·
11 min read

애플리케이션을 설계할 때 우리는 코드 재사용성과 구조적 설계를 위해 상속(Inheritance)컴포지션(Composition)이라는 두 가지 주요 기법을 사용할 수 있습니다.

상속은 자식 클래스가 부모 클래스의 속성과 메서드를 물려받아 쉽게 코드 재사용이 가능하다는 장점을 가지고 있지만, 현실적으로 유지보수성에서 큰 문제를 일으킬 수 있습니다.

이러한 이유로 저는 상속을 피하고 싶은 경우가 많습니다.

2052

대안으로 컴포지션은 객체 간의 느슨한 결합을 유지하며, 설계 유연성과 변경 용이성을 높여줍니다. 이 글에서는 왜 '상속'을 피하고 싶은지에 대한 이유와 컴포지션이 어떤 장점들을 제공하는지 비교해 보겠습니다 !


1. 상속 (Inheritance)

2053

상속은 자식 클래스가 부모 클래스의 속성과 메서드를 물려받는 구조로, 코드 재사용성을 극대화하고 계층적 구조를 쉽게 구현할 수 있습니다. 그러나 이러한 장점들에도 불구하고 상속에는 여러 단점이 존재합니다.

장점:

  • 코드의 재사용성: 부모 클래스에 정의한 기능을 자식 클래스에서 재사용할 수 있어 코드 중복을 줄입니다.

    • 예를 들어, 여러 컨트롤러에서 공통적으로 사용하는 로직을 BaseController에 정의할 수 있습니다.

      public class BaseController {
          public void logRequest() {
              System.out.println("Request received");
          }
      }
      
      public class UserController extends BaseController {
          public void getUser() {
              logRequest();
              // 사용자 정보 반환 로직
          }
      }
  • 계층적 구조: 클래스 간 관계가 명확해지므로 구조적인 설계가 용이합니다.

    • 예를 들어, UserController와 같은 컨트롤러 클래스가 BaseController를 상속받으면 이를 통해 로직의 구조를 명확하게 나타낼 수 있습니다.

  • 다형성: 부모 타입으로 자식 클래스를 참조하여 코드를 더 유연하게 작성할 수 있습니다.

    • 예를 들어, 다양한 동물 클래스를 Animal이라는 부모 클래스로 묶어 처리할 수 있습니다.

      public class Animal {
          public void makeSound() {
              System.out.println("Some sound");
          }
      }
      
      public class Dog extends Animal {
          @Override
          public void makeSound() {
              System.out.println("Bark");
          }
      }
      
      public class Cat extends Animal {
          @Override
          public void makeSound() {
              System.out.println("Meow");
          }
      }
      
      public void playWithAnimal(Animal animal) {
          animal.makeSound();
      }

단점:

  • 강한 결합: 부모 클래스와 자식 클래스 간 결합이 강해지기 때문에 부모 클래스가 변경되면 자식 클래스에까지 영향을 미칠 수 있습니다. 이로 인해 유지보수가 매우 어려워질 수 있습니다.

    2055
  • 유연성의 부족: 자바는 단일 상속만 지원하므로 설계에 제약이 생기고, 확장성이 떨어질 수 있습니다.

  • 재사용의 한계: 상속은 "is-a" 관계일 때만 적합하며, 무리하게 사용할 경우 구조가 불명확해질 수 있습니다.

    • 상속 관계를 남용하면 결국 코드의 가독성이나 유지보수성이 저하될 수 있습니다.


2. 컴포지션 (Composition)

2054

컴포지션은 객체가 다른 객체를 멤버 변수로 포함하는 방식으로, 객체 간의 느슨한 결합을 유지합니다. 이는 "has-a" 관계를 형성하며, 더 유연한 설계를 가능하게 합니다.

장점:

  • 유연성: 컴포지션을 통해 클래스 간 결합을 느슨하게 만들 수 있으며, 이를 통해 독립적인 변경과 기능 확장이 가능합니다.
    스프링의 @Autowired를 통한 의존성 주입 방식도 이러한 컴포지션을 활용한 사례입니다.

    public class LoggingService {
        public void log(String message) {
            System.out.println(message);
        }
    }
    
    public class UserService {
        private final LoggingService loggingService;
    
        public UserService(LoggingService loggingService) {
            this.loggingService = loggingService;
        }
    
        public void createUser() {
            loggingService.log("Creating user");
            // 사용자 생성 로직
        }
    }
  • 재사용성 향상: 여러 클래스에서 공통으로 사용하는 LoggingService를 다른 클래스에 포함하여 로깅 기능을 재사용할 수 있습니다.

  • 다중 기능 구현: 자바의 단일 상속 제약을 우회하고, 여러 객체의 기능을 동시에 활용할 수 있습니다.

    • 예를 들어, NotificationServiceAuditService를 모두 포함하는 OrderService를 설계할 수 있습니다.

      public class NotificationService {
          public void sendNotification(String message) {
              System.out.println("Notification: " + message);
          }
      }
      
      public class AuditService {
          public void audit(String action) {
              System.out.println("Audit log: " + action);
          }
      }
      
      public class OrderService {
          private final NotificationService notificationService;
          private final AuditService auditService;
      
          public OrderService(NotificationService notificationService, AuditService auditService) {
              this.notificationService = notificationService;
              this.auditService = auditService;
          }
      
          public void placeOrder() {
              auditService.audit("Order placed");
              notificationService.sendNotification("Your order has been placed.");
          }
      }

단점:

  • 코드 복잡도 증가: 객체 간의 관계 설정 및 관리가 복잡해질 수 있습니다. 하지만 이는 객체 간의 독립성과 유연성을 얻기 위한 대가입니다.

  • 인터페이스 의존: 컴포지션을 잘 활용하기 위해서는 인터페이스 설계가 중요하며, 이는 초기 설계의 복잡성을 높일 수 있습니다.

    • 하지만 이런 인터페이스 기반 설계는 장기적으로 볼 때 유지보수성과 확장성에서 이점을 제공할 수 있습니다.


3. 언제 상속을, 언제 컴포지션을 사용할까?

2056
  • 코드 재사용이 목적일 때: 코드 재사용이 주된 목적이며, "is-a" 관계가 명확할 경우 상속을 고려할 수 있습니다.
    예를 들어, 공통 엔티티를 상속받아 여러 도메인 모델을 생성하는 경우입니다.

    public class BaseEntity {
        private Long id;
        private LocalDateTime createdAt;
        private LocalDateTime updatedAt;
        // getter, setter, 공통 로직 등
    }
    
    public class User extends BaseEntity {
        private String name;
        private String email;
        // User-specific 로직
    }
    
    public class Product extends BaseEntity {
        private String productName;
        private double price;
        // Product-specific 로직
    }
  • 유연성과 유지보수성: 시스템의 복잡도가 높고 변화 가능성이 큰 경우 컴포지션이 더 적합합니다. 스프링에서 다양한 Bean을 조합해 애플리케이션을 구성하는 방식이 그 예입니다.

    @Service
    public class OrderService {
        private final PaymentService paymentService;
        private final ShippingService shippingService;
    
        @Autowired
        public OrderService(PaymentService paymentService, ShippingService shippingService) {
            this.paymentService = paymentService;
            this.shippingService = shippingService;
        }
    
        public void processOrder(Order order) {
            paymentService.processPayment(order);
            shippingService.shipOrder(order);
        }
    }
  • 테스트 용이성: 컴포지션은 테스트하기 쉽습니다. 독립적인 Mocking 및 주입을 통한 단위 테스트가 가능하기 때문입니다.

    @Mock
    private PaymentService paymentService;
    
    @Mock
    private ShippingService shippingService;
    
    @InjectMocks
    private OrderService orderService;
    
    @Test
    public void testProcessOrder() {
        Order order = new Order();
        // Mocking 설정
        doNothing().when(paymentService).processPayment(order);
        doNothing().when(shippingService).shipOrder(order);
    
        orderService.processOrder(order);
    
        // 메서드 호출 확인
        verify(paymentService).processPayment(order);
        verify(shippingService).shipOrder(order);
    }
  • 다중 상속의 필요: 자바의 단일 상속 제약을 피하고 여러 객체의 기능을 조합해야 할 때 컴포지션을 사용하는 것이 좋습니다.

    public class ReportService {
        private final NotificationService notificationService;
        private final LoggingService loggingService;
    
        public ReportService(NotificationService notificationService, LoggingService loggingService) {
            this.notificationService = notificationService;
            this.loggingService = loggingService;
        }
    
        public void generateReport() {
            loggingService.log("Generating report...");
            // 리포트 생성 로직
            notificationService.sendNotification("Report generated successfully");
        }
    }

4. 결론

상속과 컴포지션은 각각 장단점이 있으며, 상황에 따라 적절히 선택하는 것이 중요합니다.

그러나 저는 개인적으로 '상속'을 자주 사용하고 싶지 않습니다. 왜냐하면 강한 결합과 유연성 부족으로 인해 유지보수성이 떨어질 수 있기 때문입니다.

2057

반면, 컴포지션은 느슨한 결합과 높은 유연성을 제공하며, 변화와 확장에 강한 구조를 설계할 수 있도록 도와줍니다. 이는 스프링부트 애플리케이션에서 더 유지보수하기 쉬운 설계를 만드는 데 큰 도움이 됩니다.

최종적으로는 애플리케이션의 요구사항과 유지보수성, 확장성을 고려하여 두 가지 접근법을 적절히 혼용하는 것이 바람직합니다.

하지만 장기적인 관점에서 컴포지션을 더 많이 활용하는 것이 더 나은 설계로 이어질 수 있다고 생각합니다 :)


- 컬렉션 아티클






주니어 백엔드 개발자입니다 :)