아무래도, 조영호님은 객체지향에 대한 책도 쓰셨고, 이 주제로 많은 곳에서 강의를 하시다보니, 객체지향에 대한 질문을 많이 받으신다고 한다.
그런데 많은 질문들 중, 간혹 이런 질문이 들어온다고한다.
🧐 : "객체 지향은 여전히 유용한가요?"
왜 이런 질문을 할까?
아마도, 내가 하고 있는 개발의 방식과 철학이 맞는지 확인받고 싶은 심리 때문일수도 있다.
혹은, 너무나 빠르게 바뀌고 있는 기술의 발전 속도 때문에 내가 개발하고 있는 방법론이 아직도 현역인가를 알고 싶은 것일 수도 있다.
그러나 좀 더 좋은 성장을 하기 위해서는 질문을 조금 바꿀 필요가 있다.
"객체 지향은 '언제' 유용한가?"
이 세션에서는 한가지 예제를 기준으로 '절차적인 방식' 과 객체지향적인 방식'을 비교하면서 '언제' 유용한지 살펴볼 것이다.
👩🏻💻 예제 : 장바구니에 할인 프로모션을 적용하는 기능을 개발한다.
- 내 장바구니 (Cart) 에 담긴 모든 상품의 가격이 할인 프로모션의 기준 금액보다 클 경우, 프로모션을 적용하도록 한다.
// 행위의 속성
public class PromotionProcess {
public void apply(Promotion promotion, Cart cart) {
if (isApplicableTo(promotion, cart)) {
promotion.setCart(cart.getId());
}
}
private boolean isApplicableTo(Promotion promotion, Cart cart) {
return cart.getTotalPrice() >= promotion.getBasePrice();
}
}
// Data 의 속성
public class Promotion {
private Long cartId;
private Long basePrice;
public Money getBasePrice() {
return basePrice;
}
public void setCart(Long cartId) {
this.cartId = cartId;
}
}
public class Cart {
private List<CartLineItem> items = new ArrayList<>();
public Long getTotalPrice() {
return items.stream().mapToLong(CartLineItem::getPrice).sum();
}
public int getTotalQuantity() {
return items.stream().mapToInt(CartLineItem::getQuantity).sum();
}
}
Promotion
와 Cart
라는 Data 형식의 클래스가 존재한다.PromotionProcess
는 위의 두 Data 형식의 클래스를 활용하여 행위를 진행한다.public class Promotion {
private Cart cart;
private Long basePrice;
public void apply(Cart cart) {
if (cart.getTotalPrice() >= basePrice) {
this.cart = cart;
}
}
}
public class Cart {
private List<CartLineItem> items = new ArrayList<>();
public Long getTotalPrice() {
return items.stream().mapToLong(CartLineItem::getPrice).sum();
}
public int getTotalQuantity() {
return items.stream().mapToInt(CartLineItem::getQuantity).sum();
}
}
Process
와 Cart
는 각자의 책임을 가지고 '행위'를 가진다.
- 하나의 요구사항을 두고, 두가지 방법으로 설계를 하면 위와 같이 간단하게 표현할 수 있다.
이제, 위의 기본 예제를 바탕으로 여러가지 변경을 진행해보자. 레츠고!
요구사항이 변경되었다.
할인 여부 판단하는 데 사용하는 '데이터'가 변경이 되었다.
요구사항 변경
- 내가 주문하려는 전체 물건의 가격이 프로모션 최소금액보다는 커야하고, 프로모션의 최대금액보다는 작아야한다
public class PromotionProcess {
public void apply(Promotion promotion, Cart cart) {
if (isApplicableTo(promotion, cart)) {
promotion.setCart(cart.getId());
}
}
private boolean isApplicableTo(Promotion promotion, Cart cart) {
// 변경지점
return (cart.getTotalPrice() >= promotion.getMinPrice()) &&
(cart.getTotalPrice() <= promotion.getMaxPrice());
}
}
public class Promotion {
private Long cartId;
// 두 필드가 변경되었다.
private Long minPrice;
private Long maxPrice;
public Long getMinPrice() {
return minPrice;
}
public Long getMaxPrice() {
return maxPrice;
}
public void setCart(Long cartId) {
this.cartId = cartId;
}
}
public class Promotion {
private Cart cart;
private Long minPrice;
private Long maxPrice;
public void apply(Cart cart) {
if ((cart.getTotalPrice() >= minPrice) &&
(cart.getTotalPrice() <= maxPrice)) {
this.cart = cart;
}
}
}
public class Cart {
private List<CartLineItem> items = new ArrayList<>();
public Long getTotalPrice() {
return items.stream().mapToLong(CartLineItem::getPrice).sum();
}
public int getTotalQuantity() {
return items.stream().mapToInt(CartLineItem::getQuantity).sum();
}
}
요구사항 변경
- 내가 주문하는 총 금액이 프로모션의 기준 금액보다 높아야한다.
- 추가된 조건 : 주문하는 물건의 총 수량이 프로모션의 총수량 기준보다 많아야한다
public class PromotionProcess {
public void apply(Promotion promotion, Cart cart) {
if (isApplicableTo(promotion, cart)) {
promotion.setCart(cart.getId());
}
}
private boolean isApplicableTo(Promotion promotion, Cart cart) {
switch (promotion.getConditionType()) {
case PRICE:
return cart.getTotalPrice() >= promotion.getBasePrice();
// '총 수량' 이라는 조건을 추가한다.
case QUANTITY:
return cart.getTotalQuantity() >= promotion.getBaseQuantity();
}
return false;
}
}
// 프로며션의 기준이 되는 Enum 타입을 선언한다.
public class Promotion {
public enum ConditionType {
PRICE, QUANTITY
}
private ConditionType conditionType;
private Long cartId;
private Long basePrice;
private int baseQuantity;
public ConditionType getConditionType() {
return conditionType;
}
public Money getBasePrice() {
return basePrice;
}
public int getBaseQuantity() {
return baseQuantity;
}
public void setCart(Long cartId) {
this.cartId = cartId;
}
}
public class Promotion {
private Cart cart;
private DiscountCondition condition;
public void apply(Cart cart) {
if (condition.isApplicableTo(cart)) {
this.cart = cart;
}
}
}
public class Cart {
private List<CartLineItem> items = new ArrayList<>();
public Long getTotalPrice() {
return items.stream().mapToLong(CartLineItem::getPrice).sum();
}
public int getTotalQuantity() {
return items.stream().mapToInt(CartLineItem::getQuantity).sum();
}
}
// 할인 조건이라는 interface 를 생성한다.
public interface DiscountCondition {
boolean isApplicableTo(Cart cart);
}
// 조건이 추가될 때마다 DiscountCondition 을 구현한다.
public class PriceCondition implements DiscountCondition {
private Long basePrice;
@Override
public boolean isApplicableTo(Cart cart) {
return cart.getTotalPrice() >= basePrice;
}
}
public class QuantityCondition implements DiscountCondition {
private int baseQuantity;
@Override
public boolean isApplicableTo(Cart cart) {
return cart.getTotalQuantity() >= baseQuantity;
}
}
DiscountCondition
을 구현하는 클래스를 추가해야 할 것이다.DiscountCondition
이라는 interface
를 구현한다는 강제성을 가지기에, 일관성을 가질 수 있다.
- 위의 사례를 통해 절차지향과 객체지향을 살펴보면 객체지향이 앞도적인 강점을 가진다.
- 확장성 있는 코드 : 다형성을 통해 책임이 분명한 곳만 변경지점이 명확해진다.
- 일관성 있는 코드 :
interface
를 통해 강제성을 가지게되어 일관성을 지킬 수 있게 된다.
그렇다면, 언제나 객체지향이 짱짱맨 일까?
위의 예시만을 보면 객체지향이 무조건 좋아보인다.
또한, 우리는 언제나 '객체지향적으로 코드를 짜자!' 라는 말을 무수히 들어봤으므로 당연히 '객체지향'이 절차지향보다 우수하다고 생각하기 쉽다.
하지만 정답은
No!!! 🙅🏻
다른 예시를 통해 조금 더 절차지향과 객체지향을 비교해보자.
요구사항 변경
- '장바구니 항목' 이라는 데이터가 추가되고, 조건이 이것으로 변경되었다.
CartLineItem
이라는 새로운 데이터가 추가되었다.
이 내용은, case3 을 적용하기 전과 후로 나눠서 비교하도록 하겠다.
case 3 의 핵심은 '새로운 조건을 추가' 하는 것이었다.
public class PromotionProcess {
public void apply(Promotion promotion, Cart cart) {
if (isApplicableTo(promotion, cart)) {
promotion.setCart(cart.getId());
}
}
private boolean isApplicableTo(Promotion promotion, Cart cart) {
return cart.getTotalPrice() >= promotion.getBasePrice();
}
// CartLineItem 이 추가 되었다.
public boolean isDiscountable(Promotion promotion, CartLineItem item) {
return item.getPrice() >= promotion.getBasePrice();
}
}
public class Promotion {
private Long cartId;
private Long basePrice;
public Money getBasePrice() {
return basePrice;
}
public void setCart(Long cartId) {
this.cartId = cartId;
}
}
process
에 CartLineItem
관련된 코드가 추가가 된다.public class Promotion {
private Cart cart;
private Long basePrice;
public void apply(Cart cart) {
if (cart.getTotalPrice() >= basePrice) {
this.cart = cart;
}
}
// 판단 로직은 Promotion 에만 영향을 준다.
public boolean isApplicableTo(CartLineItem item) {
return item.getPrice() >= basePrice;
}
}
public class Cart {
private List<CartLineItem> items = new ArrayList<>();
public Long getTotalPrice() {
return items.stream().mapToLong(CartLineItem::getPrice).sum();
}
public int getTotalQuantity() {
return items.stream().mapToInt(CartLineItem::getQuantity).sum();
}
}
Promotion
에만 영향을 주게된다.이 경우에는, 객체지향적인 설계에는
interface
가 적용이 된 이후다.
- 현재 위와 같은 구조에서 case 4 의 요구사항을 처리하는 것이 case 4-2 의 요구사항이다.
public class PromotionProcess {
public void apply(Promotion promotion, Cart cart) {
if (isApplicableTo(promotion, cart)) {
promotion.setCart(cart.getId());
}
}
private boolean isApplicableTo(Promotion promotion, Cart cart) {
switch (promotion.getConditionType()) {
case PRICE:
return cart.getTotalPrice() >= promotion.getBasePrice();
case QUANTITY:
return cart.getTotalQuantity() >= promotion.getBaseQuantity();
}
return false;
}
// 해당 부분이 추가 되었다.
public boolean isApplicableTo(Promotion promotion, CartLineItem item) {
switch (promotion.getConditionType()) {
case PRICE:
return item.getPrice() >= promotion.getBasePrice();
case QUANTITY:
return item.getQuantity() >= promotion.getBaseQuantity();
}
return false;
}
}
public class Promotion {
public enum ConditionType {
PRICE, QUANTITY
}
private ConditionType conditionType;
private Long cartId;
private Long basePrice;
private int baseQuantity;
public ConditionType getConditionType() {
return conditionType;
}
public Money getBasePrice() {
return basePrice;
}
public int getBaseQuantity() {
return baseQuantity;
}
public void setCart(Long cartId) {
this.cartId = cartId;
}
}
public class Promotion {
private Cart cart;
private DiscountCondition condition;
public void apply(Cart cart) {
if (condition.isApplicableTo(cart)) {
this.cart = cart;
}
}
// 추가 되었다.
public boolean isApplicableTo(CartLineItem item) {
return condition.isApplicableTo(item);
}
}
// interface 에 함수를 하나 더 추가한다.
public interface DiscountCondition {
boolean isApplicableTo(Cart cart);
boolean isApplicableTo(CartLineItem item);
}
// 구현한 클래스 마다 추가된 함수를 구현해야한다.
public class PriceCondition implements DiscountCondition {
private Long basePrice;
@Override
public boolean isApplicableTo(Cart cart) {
return cart.getTotalPrice() >= basePrice;
}
@Override
public boolean isApplicableTo(CartLineItem item) {
return item.getPrice() >= basePrice;
}
}
// 구현한 클래스 마다 추가된 함수를 구현해야한다.
public class QuantityCondition implements DiscountCondition {
private int baseQuantity;
@Override
public boolean isApplicableTo(Cart cart) {
return cart.getTotalQuantity() >= baseQuantity;
}
@Override
public boolean isApplicableTo(CartLineItem item) {
return item.getQuantity() >= baseQuantity;
}
}
객체지향적인 코드 - 타입 계층 전체의 수정이 필요
- 이렇게 된다면 코드는 불필요한 일을 할 가능성이 커진다.
- 즉, 다형성과 기능 추가 사이에 긴장을 가지게 된다.
즉, 상황에 따라 절차지향적인 설계가 더 좋을 수 있다!! 💡
public class PromotionProcess {
public CartWithPromotion convertToCartWithPromotion(
Promotion promotion, Cart cart) {
CartWithPromotion result = new CartWithPromotion();
result.setTotalPrice(cart.getTotalPrice());
result.setTotalQuantity(cart.getTotalQuantity());
result.setPromotionBasePrice(promotion.getBasePrice());
result.setPromotionBaseQuantity(promotion.getBaseQuantity());
return result;
}
}
public class Promotion {
public enum ConditionType {
PRICE, QUANTITY
}
private ConditionType conditionType;
private Long cartId;
private Long basePrice;
private int baseQuantity;
// getter & setter
// ...
}
public class Promotion {
private Cart cart;
private DiscountCondition condition;
// ...
public CartWithPromotion convertToCartWithPromotion() {
CartWithPromotion result = new CartWithPromotion();
result.setTotalPrice(cart.getTotalPrice());
result.setTotalQuantity(cart.getTotalQuantity());
if (condition instanceof PriceCondition) {
result.setPromotionBasePrice(
((PriceCondition) condition).getBasePrice());
}
if (condition instanceof QuantityCondition) {
result.setPromotionBaseQuantity(
((QuantityCondition) condition).getBaseQuantity());
}
return result;
}
}
public interface DiscountCondition {
boolean isApplicableTo(Cart cart);
boolean isApplicableTo(CartLineItem item);
}
public class PriceCondition implements DiscountCondition {
private Long basePrice;
// ...
}
public class QuantityCondition implements DiscountCondition {
private int baseQuantity;
// ...
}
위의 예제로 절차지향과 객체지향에 대해 알아봤다.
이제는 두개의 역할과 장단점에 대해 정리를 해보자.
Controller
로 대부분 이해되는 프리젠테이션 레이어는 '절차지향'적이다.레이어드 아키텍쳐의 4개의 레이어 중 3개가 절차지향적이다.
- 스프링 기반 백엔드 엔지니어가 가장 익숙한 아키텍처인 레이어드 아키텍처.
- 스프링 기반 백엔드 엔지니어가 가장 많이 듣는 단어인 '객체지향'
- 하지만, 정작 레이어드 아키텍처에서 객체지향의 관점으로 코드를 작성하는 지점은 하나의 레이어뿐이다.
- 물론, 도메인 레이어가 핵심이긴하다 😅
여기서 알 수 있는 것은 모든 도구와 관점은 각자의 쓰임새가 있다 는 것이다.
"객체지향은 '여전히' 유용한가요?" 라는 질문으로 시작했다.
이제는 질문을 바꾸자.
"객체지향은 '언제' 유용한가요?"
하나의 기술, 하나의 패러다임으로 프로그램을 만들 수 없다.
우리는 이미, 다양한 기술과 패러다임을 결합하여 코딩을 하고 있다.
우리가 사용하고 있는 것들 또한, 여러가지가 결합되어서 만들어진 것들이다.
모든 기술과 설게는 다 용도가 있다.
단지, 무엇이 좋다 무엇이 나쁘다 라고 하는 기준은 절대적일 수 없다.
지금 내가 어떤 상황인지, 어떤 문제를 해결하는지에 대한 고민이 정말 중요하다.
그렇기에 항상, '언제' 유용하고 좋은지 를 고민해야한다.
단순히 '안좋아', '좋아' 라고 이야기할 수 없다.
??? : 절차지향은 안좋아요!! 무조건 객체지향 만세!!
- 이젠 위와 같은 말을 하는 개발자를 만난다면 피하자.. 😅
다양한 도구들을 배우고, 그 도구를 적절하게 사용하는 것이 중요하다.
정말 많은 패러다임과 도구들이 있는데, 그것을 적합하게 사용해야한다.
즉, 코드의 목적과 변경의 방향성에 따라 언제 어떤 기술을 사용할지 결정하는 것이 중요하다!
유용하지 않은 기술은 없다.
단지, 언제 어떻게 사용하는 것이 좋은 것인지에 대한 시각으로 접근하는 것이 옳다.
백엔드 엔지니어로서, 객체지향의 장점과 코드를 객체지향적으로 짜야하다는 말은 무수히 들었다.
어느 순간부터 아주 단순하게 '절차지향은 별로, 객체지향만 최고' 라는 생각이 자리 잡았었다.
그리고, 이 세션을 통해 아주 철저하게 그 생각이 부서졌다.
개발은 결코 '은탄환'이 없는데, 객체지향을 내가 어느 순간 '은탄환' 으로 여기고 모든 것에 다 접목하려고 잣대를 들이밀었던 것 같다.
(그래 놓고서는 사실 객체지향적인 코드가 뭔지도 잘 모른채 개발을 하고 있기도 하다... 😅)
정말 너무 유익하고 귀한 세션이었다.
얼마나 객체지향적으로 개발해야하는가에 대해 고민 했었는데 너무 깔끔하게 정리된 것 같아요! 감사합니다 😊