애플리케이션을 설계할 때 우리는 코드 재사용성과 구조적 설계를 위해 상속(Inheritance)
과 컴포지션(Composition)
이라는 두 가지 주요 기법을 사용할 수 있습니다.
상속은 자식 클래스가 부모 클래스의 속성과 메서드를 물려받아 쉽게 코드 재사용이 가능하다는 장점을 가지고 있지만, 현실적으로 유지보수성에서 큰 문제를 일으킬 수 있습니다.
이러한 이유로 저는 상속을 피하고 싶은 경우가 많습니다.
대안으로 컴포지션은 객체 간의 느슨한 결합을 유지하며, 설계 유연성과 변경 용이성을 높여줍니다. 이 글에서는 왜 '상속'을 피하고 싶은지에 대한 이유와 컴포지션이 어떤 장점들을 제공하는지 비교해 보겠습니다 !
1. 상속 (Inheritance)
상속은 자식 클래스가 부모 클래스의 속성과 메서드를 물려받는 구조로, 코드 재사용성을 극대화하고 계층적 구조를 쉽게 구현할 수 있습니다. 그러나 이러한 장점들에도 불구하고 상속에는 여러 단점이 존재합니다.
장점:
코드의 재사용성: 부모 클래스에 정의한 기능을 자식 클래스에서 재사용할 수 있어 코드 중복을 줄입니다.
예를 들어, 여러 컨트롤러에서 공통적으로 사용하는 로직을
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(); }
단점:
강한 결합: 부모 클래스와 자식 클래스 간 결합이 강해지기 때문에 부모 클래스가 변경되면 자식 클래스에까지 영향을 미칠 수 있습니다. 이로 인해 유지보수가 매우 어려워질 수 있습니다.
유연성의 부족: 자바는 단일 상속만 지원하므로 설계에 제약이 생기고, 확장성이 떨어질 수 있습니다.
재사용의 한계: 상속은 "is-a" 관계일 때만 적합하며, 무리하게 사용할 경우 구조가 불명확해질 수 있습니다.
상속 관계를 남용하면 결국 코드의 가독성이나 유지보수성이 저하될 수 있습니다.
2. 컴포지션 (Composition)
컴포지션은 객체가 다른 객체를 멤버 변수로 포함하는 방식으로, 객체 간의 느슨한 결합을 유지합니다. 이는 "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. 언제 상속을, 언제 컴포지션을 사용할까?
코드 재사용이 목적일 때: 코드 재사용이 주된 목적이며, "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. 결론
상속과 컴포지션은 각각 장단점이 있으며, 상황에 따라 적절히 선택하는 것이 중요합니다.
그러나 저는 개인적으로 '상속'을 자주 사용하고 싶지 않습니다. 왜냐하면 강한 결합과 유연성 부족으로 인해 유지보수성이 떨어질 수 있기 때문입니다.
반면, 컴포지션은 느슨한 결합과 높은 유연성을 제공하며, 변화와 확장에 강한 구조를 설계할 수 있도록 도와줍니다. 이는 스프링부트 애플리케이션에서 더 유지보수하기 쉬운 설계를 만드는 데 큰 도움이 됩니다.
최종적으로는 애플리케이션의 요구사항과 유지보수성, 확장성을 고려하여 두 가지 접근법을 적절히 혼용하는 것이 바람직합니다.
하지만 장기적인 관점에서 컴포지션을 더 많이 활용하는 것이 더 나은 설계로 이어질 수 있다고 생각합니다 :)