이 포스팅은 조영호님의 '객체지향은 여전히 유용한가?│인프콘2024' 에서 발췌한 내용임을 알립니다.
첫 컴공 수업 때부터, 면접 그리고 실무까지 죽지도 않고 살아나 쫒아 다니는 그 녀석. 객체 지향. 무엇인지는 닳고 닳도록 배우는데 '정말 그만큼 만능 패턴일까' 라는 고민은 해본 적이 없습니다. 이번 포스팅은 객체 지향을 절차 지향과 비교하면서 어떤 점이 우월한지, 그리고 어떤 점이 부족한지 탐구해 보고자 합니다.
시작하기전 객체지향의 중요 특성을 알아야 합니다.
추상화 -> 추상 클래스, 인터페이스
상속 -> extends, implements
다형성 -> 오버로딩, 오버라이딩
캡슐화 -> private 변수로 외부에서 접근 제어
그리고, 한 사례를 중심으로 이것 저것 추가하며 확장해 보면서 비교 대조를 통해 패러다임을 비교하려고 합니다.
물건들을 장바구니에 넣고 총액이 일정 금액 이상이면 할인을 해준다는 시나리오를 살펴 보겠습니다.
절차지향적 설계
public class PromotionProcess {
public void apply(Promotion promotion, Cart cart) {
if (isApplicableTo(promotion, cart)) {
promotion.setCart(cart.getCartId());
}
}
private boolean isApplicableTo(Promotion promotion, Cart cart) {
return cart.getTotalPrice() >= promotion.getBasePrice();
}
}
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 클래스에서 basePrice를 관리하면서 PromotionProcess를 컨트롤 합니다.
객체지향적 설계
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();
}
}
Promotion 클래스 안에서 선언된 변수를 통해 메서드를 컨트롤 합니다.

절차지향
public class PromotionProcess {
public void apply(Promotion promotion, Cart cart) {
if (isApplicableTo(promotion, cart)) {
promotion.setCart(cart.getCartId());
}
}
//변경부분
//외부 데이터가 추가되면 로직을 통으로 변경해야 한다.
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 Money getMinPrice() {
return basePrice;
}
public Money getMaxPrice() {
return basePrice;
}
public void setCart(Long cartId) {
this.cartId = cartId;
}
}
특히, 이런 구조는 maxPrice가 사라지는 경우 isApplicableTo에서 에러를 만들어 내는 것을 늦게 발견 할 수 있습니다.
객체지향
public class Promotion {
private Cart cart;
private Long minPrice;
private Long maxPrice;
//데이터 추가도, 로직 변경도 한 클래스에서만 이루어져 외부 dependancy가 없다.
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();
}
}
첫번째 특성인 캡슐화를 통해 외부에서 minPric, maxPrice 알지 못해도 할인을 받을 수 있는지 확인할 수 있습니다. 반대로, minPrice, maxPrice를 외부에서 조작 할 수 없어 결합도가 낮습니다. 또한 모든 변경분이 하나의 클래스에서 이루어져, 유지보수성이 남다릅니다.

절차지향적 설계는 데이터를 바꾸는 순간 데이터에 의존한 모든 프로세스가 바뀌게 됩니다. 반대로 객체지향은 '캡슐화'를 통해 클래스 내부의 로직만 바꿔주면 됩니다.
특정 총액을 넘거나, 특정 개수를 넘기면 할인!
절차지향
public class PromotionProcess {
public void apply(Promotion promotion, Cart cart) {
if (isApplicableTo(promotion, cart)) {
promotion.setCart(cart.getCartId());
}
}
//switch문으로 분기처리. 변경분은 적지만, 새로운 타입이 등장하면 계속 더해야한다.
//지금은 메서드가 하나지만, 여러개의 메서드를 업데이트 해야한다면?
private boolean isApplicableTo(Promotion promotion, Cart cart) {
switch(promotion.getConditionType()){
case PRICE:
return cart.getTotalPrice() >= promotion.getMinPrice();
case QUANTITY:
return cart.getTotalQuantity() >= promotion.getBaseQuantity()
}
return false;
}
}
public class Promotion {
public enum ConditionType {
PRICE, QUANTITY
}
private ConditionType conditionType;
private Long cartId;
private Long minPrice;
private Long maxPrice;
private int baseQuantity;
public ConditionType getConditionType(){
return conditionType;
}
public int getBaseQuantity(){
return baseQuantity;
}
public Money getMinPrice() {
return basePrice;
}
public Money getMaxPrice() {
return basePrice;
}
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 interface DiscountCondition {
boolean isApplicableTo(Cart cart);
}
public class PriceCondition implements DiscountCondition {
private Long basePrice;
@Override
public boolean isApplicableTo(Cart cart){
return cart.getTotalPrice() >= basePrice;
}
}
public class QuantityCondition implements DiscountCondition {
private Long baseQuantity;
@Override
public boolean isApplicableTo(Cart cart){
return cart.getTotalQuantity() >= baseQuantity;
}
}
//새로운 할인이 생긴다면 클래스만 만들어 주면 된다.
두번째 특징인 추상화와 다형성으로 해결한 모습입니다. 리팩토링에 기초격으로, switch문이나 if문을 없애고 추상화를 통해 분기 처리하는 방법입니다. 이런 방법은 초기에 큰 리팩토링이 필요합니다. 인터페이스를 만들고 확장 클래스를 만들어주어야 하는데, 한번 만들고나면 일관성있는 확장이 가능합니다. 또한, 모든 데이터의 책임소재가 더욱 명확해졌습니다.
지금까지는 객체지향이 확장성도, 구조도 훨씬 안정적이였습니다. 그렇다면 객체지향은 어디에나 적용 가능한 모델 인걸까요?
특정 상품이 장바구니에 있다면 할인을 적용해주는 시나리오를 살펴보겠습니다. 이 과정은 위에서 인터페이스를 더하기 전의 절차지향과 객체지향을, 그리고 이터페이스를 더한 후의 절차지향과 객체지향의 코드를 살펴보겠습니다.
절차지향
public class PromotionProcess {
public void apply(Promotion promotion, Cart cart) {
if (isApplicableTo(promotion, cart)) {
promotion.setCart(cart.getCartId());
}
}
private boolean isApplicableTo(Promotion promotion, Cart cart) {
return cart.getTotalPrice() >= promotion.getBasePrice();
}
private boolean isApplicableTo(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;
}
}
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 Promotion {
private Cart cart;
private Long basePrice;
public void apply(Cart cart) {
if (cart.getTotalPrice() >= basePrice) {
this.cart = cart;
}
}
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();
}
}
사실 여기도 오버로딩만 해준다면 그만입니다.
절차지향
public class PromotionProcess {
public void apply(Promotion promotion, Cart cart) {
if (isApplicableTo(promotion, cart)) {
promotion.setCart(cart.getCartId());
}
}
private boolean isApplicableTo(Promotion promotion, Cart cart) {
switch(promotion.getConditionType()){
case PRICE:
return cart.getTotalPrice() >= promotion.getMinPrice() &&
cart.getTotalPrice() <= promotion.getMaxPrice();
case QUANTITY:
return cart.getTotalQuantity() >= promotion.getBaseQuantity()
}
return false;
}
// 오버로딩 된 부분.
private boolean isApplicableTo(Promotion promotion, CartLineItem item) {
switch(promotion.getConditionType()){
case PRICE:
return item.getPrice() >= promotion.getMinPrice() &&
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 minPrice;
private Long maxPrice;
private int baseQuantity;
public ConditionType getConditionType(){
return conditionType;
}
public int getBaseQuantity(){
return baseQuantity;
}
public Money getMinPrice() {
return basePrice;
}
public Money getMaxPrice() {
return basePrice;
}
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);
}
}
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 Long baseQuantity;
@Override
public boolean isApplicableTo(Cart cart){
return cart.getTotalQuantity() >= baseQuantity;
}
@Override
public boolean isApplicableTo(CartLineItem item){
return item.getQuantity() >= baseQuantity;
}
}
타입이 확장되는데는 굉장히 유리하지만, 기능이 추가된다면 인터페이스 및 구현 클래스들에 모두 오버로딩을 해야하는 번거로움이 있습니다. 예시로는 2개지만, 수십개가 된다면 생산성이 떨어지게 됩니다.
한 클래스 내에서 다른 클래스를 생성해야 하는 경우를 생각해 봅시다.
절차지향
public class PromotionProcess {
public void apply(Promotion promotion, Cart cart) {
if (isApplicableTo(promotion, cart)) {
promotion.setCart(cart.getCartId());
}
}
public CartWithPromotion convertToCartWithPromotion(
Promotion promotion,
Cart cart
){
CartWithPromotion result = new CartWithPromotion();
result.setTotalPrice(cart.getTotalPrice());
...
}
}
모든 정보가 한 곳에 모여있기 때문에(Promotion), 그냥 무지성 생성이 가능합니다.
객체지향
public class Promotion {
private Cart cart;
private DiscountCondition condition;
...
public CartWithPromotion convertToCartWithPromotion(){
CartWithPromotion result = new CartWithPromotion();
result.setTotalPrice(cart.getTotalPrice());
//여기서 좀 곤란해 집니다.
if(condition instanceof PriceCondition){
result.setPromotionBasePrice((PriceCondition)condition.getBasePrice());
}
if(condition instanceof QuantityCondition){
result.setPromotionBaseQuantity(
(QuantityCondition)condition.getBaseQuantity()
);
}
...
}
}
모든 인스턴스마다 체크를 해서, 타입캐스팅을 하고, 그 다음 메서드로 접근해야 합니다.

그래서, 나의 서비스(프로젝트)가 기능 확장과 데이터 변화에 중점적이다 라고생각하면 절차 지향적인 코드를, 반대로 타입과 데이터를 추가하며 확장하는 서비스라면 객체 지향적인 코드를 작성하는 것이 유리해 보입니다.
실제로 서비스를 운영하다 보면 객체 지향적인 코드보다는 절차 지향적인 코드가 유용할 때가 더 많다고 합니다. 그래서 객체 지향이 무엇인지 아는 것도 중요하지만, 객체 지향의 특징이 어떻게 장점으로 연결되고, 그것이 구현에서 어떻게 강점을 갖는지 파악하면 더 깊은 이해를 얻을 수 있을 것이라 생각합니다.
P.S.
이런 관점에서 서버의 대표적인 각각의 레이어들은 어떤 패턴이 유용할까요? 객체 지향? 절차 지향?

https://www.youtube.com/watch?v=usOfawBrvFY&t=1463s&ab_channel=%EC%9D%B8%ED%94%84%EB%9F%B0inflearn