• Feed
  • Explore
  • Ranking
/
/
    OOP

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

    저는 왜 '상속'을 피하고 싶은지에 대한 이유와 컴포지션이 어떤 장점들을 제공하는지 비교해 보겠습니다
    상속컴포지션개발방법OOP객체지향설계
    t
    thing_k0
    2024.11.10
    ·
    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
      • 이미지 출처: https://ahnyezi.github.io/java/javastudy-8-interface/

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

    • 재사용의 한계: 상속은 "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를 다른 클래스에 포함하여 로깅 기능을 재사용할 수 있습니다.

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

      • 예를 들어, NotificationService와 AuditService를 모두 포함하는 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

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

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

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







    - 컬렉션 아티클