순차지향, 절차지향, 객체지향

Socra·2025년 4월 17일
post-thumbnail

‘순차지향 프로그래밍’과 ‘절차지향 프로그래밍’은 다르다

  • 순차지향 프로그래밍(Sequeatial oriented programming)
  • 절차지향 프로그래밍(Procedure oriented programming)

순차지향 프로그래밍의 Sequential은 코드를 위에서 아래로 읽겠다는 의미다.

절차지향 프로그래밍의 Procedure는 직역하면 ‘절차’이지만, 컴퓨터 공학에서 Procedure는 함수다.

절차지향 프로그래밍은 함수(procedure) 지향 프로그래밍이다.

  • 순차지향 언어인 어셈블리에는 함수가 없다. jmp나 goto 명령어로 흐름 제어를 한다.
  • 절차지향 언어인 C에는 함수가 있다.
int add(int a, int b) {
		return a + b;
}

int main() {
    int a = 2;
    int b = 3;
    return add(a, b);
}

Java는 객체지향 언어이지만 절차지향 코드를 작성할 수 있다

@Getter
class Store {
	private List<Order> orders;
	private long rentalFee; // 임대료
}
@Getter
public class Order {
	private List<Food> foods;
	private double transactionFeePercent = 0.03; // 결제 수수료 3%
}
@Getter
public class Food {
	private long price;
	private long originCost; // 원가
}
class RestaurantChain {
	private List<Store> stores;

	/**
	 * 음식 체인점의 매출을 계산한다
	 */
	public long calculateRevenue() {
		long revenue = 0;
		for (Store store : stores) {
			for (Order order : store.getOrders()) {
				for (Food food : order.getFoods()) {
					revenue += food.getPrice();
				}
			}
		}
		return revenue;
	}

	/**
	 * 음식 체인점의 순이익을 계산한다.
	 */
	public long calculateProfit() {
		long cost = 0;
		for (Store store : stores) {
			for (Order order : store.getOrders()) {
				long orderPrice = 0;
				for (Food food : order.getFoods()) {
					orderPrice += food.getPrice();
					cost += food.getPrice();
				}
				// 결제 금액의 3%를 비용으로 잡는다.
				cost += orderPrice * order.getTransactionFeePercent();
			}
			cost += store.getRentalFee();
		}
		return calculateRevenue() - cost;
	}
}

RestaurantChain의 calculateRevenue, calculateProfit는 모두 절차지향적인 코드다.

  • 클래스와 객체들이 RestaurantChain의 함수를 실행하기 위한 데이터로써 존재할 뿐이다.
  • Store, Order, Food에는 아무런 책임이 존재하지 않는다. C의 구조체와 다를 바 없다.

Spring에서 절차지향적인 코드를 작성하는 경우

스프링을 프로젝트에서 모든 비즈니스 로직이 서비스 컴포넌트에 있는 코드를 작성하는 경우가 많다.

@Service
@RequiredArgsConstructor
class RestaurantChainService {

	private final StoreRepository storeRepository;

	/**
	 * 음식 체인점의 매출을 계산한다
	 */
	public long calculateRevenue(long restaurantId) {
		List<Store> stores = storeRepository.findByRestaurantId(restaurantId);
		long revenue = 0;
		for (Store store : stores) {
			for (Order order : store.getOrders()) {
				for (Food food : order.getFoods()) {
					revenue += food.getPrice();
				}
			}
		}
		return revenue;
	}

	/**
	 * 음식 체인점의 순이익을 계산한다.
	 */
	public long calculateProfit(long restaurantId) {
		List<Store> stores = storeRepository.findByRestaurantId(restaurantId);
		long cost = 0;
		for (Store store : stores) {
			for (Order order : store.getOrders()) {
				long orderPrice = 0;
				for (Food food : order.getFoods()) {
					orderPrice += food.getPrice();
					cost += food.getPrice();
				}
				// 결제 금액의 3%를 비용으로 잡는다.
				cost += orderPrice * order.getTransactionFeePercent();
			}
			cost += store.getRentalFee();
		}
		return calculateRevenue(restaurantId) - cost;
	}
}

절차지향적인 코드에서 벗어나지 못하고 클래스를 데이터를 저장하는 용도로만 사용하고 있다.


객체지향적으로 바꿔보기

class RestaurantChain {
	private List<Store> stores;

	public long calculateRevenue() {
		long revenue = 0;
		for (Store store : stores) {
			revenue += store.calculateRevenue();
		}
		return revenue;
	}

	public long calculateProfit() {
		long income = 0;
		for (Store store : stores) {
			income += store.calculateProfit();
		}
		return income;
	}
}
@Getter
class Store {
	private List<Order> orders;
	private long rentalFee; // 임대료

	/**
	 * 가게의 매출을 계산한다.
	 */
	public long calculateRevenue() {
		long revenue = 0;
		for (Order order : orders) {
			revenue += order.calculateRevenue();
		}
		return revenue;
	}

	/**
	 * 가게의 순이익을 계산한다.
	 */
	public long calculateProfit() {
		long income = 0;
		for (Order order : orders) {
			income += order.calculateProfit();
		}
		return income - rentalFee;
	}
}
@Getter
class Order {
	private List<Food> foods;
	private double transactionFeePercent = 0.03; // 결제 수수료 3%

	/**
	 * 주문의 총 금액을 계산한다.
	 */
	public long calculateRevenue() {
		long revenue = 0;
		for (Food food : foods) {
			revenue += food.calculateRevenue();
		}
		return revenue;
	}

	/**
	 * 주문으로 발생하는 순이익을 계산한다.
	 */
	public long calculateProfit() {
		long income = 0;
		for (Food food : foods) {
			income += food.calculateProfit();
		}
		return (long) (income - calculateRevenue() * transactionFeePercent);
	}
}
@Getter
class Food {
	private long price;
	private long originCost; // 원가

	/**
	 * 음식의 총 금액을 계산한다.
	 */
	public long calculateRevenue() {
		return price;
	}

	/**
	 * 음식으로 발생하는 순이익을 계산한다.
	 */
	public long calculateProfit() {
		return price - originCost;
	}
}

객체에 책임이 생겼다

  • 비즈니스 로직을 객체가 처리하도록 변경했다
  • Store, Order, Food의 객체가 행동을 갖게 됐다
  • 각 객체가 매출, 순이익 계산을 어떻게 처리할지 알고 있다

정리하자면,

  • 객체에 어떤 메시지를 전달할 수 있게 됐다
  • 객체가 어떤 책임을 지게 됐다
  • 객체는 어떤 책임을 처리하는 방법을 스스로 알고 있다.

응집도가 높아졌다

  • 결제 수수료를 계산하는 로직을 더이상 RestaurantChain에서 처리하지 않는다
  • 응집도가 높다: 데이터 측면에서 행위를 하기 위해 만들어진 행동과 데이터가 한 곳에 잘 응집되었다.

가독성의 측면

  • 개인의 차이가 있지만 오히려 절차지향적으로 작성된 코드가 더 잘 읽힐 수 있다.
  • 하지만 객체지향으로 코드를 작성하는 이유는 ‘가독성을 높이기 위함’이 아니다.

객체지향은 가독성보다 책임에 집중한다.

객체지향은 책임을 기반으로 동작한다.

객체지향으로 코드를 작성하면,

  • 객체들은 자신의 책임에 집중한다.
  • 객체들은 각자의 책임을 수행하기 위한 협력 객체가 무엇인지 알고 있다.
  • 그 밖에 필요한 값은 각자가 가지고 있다.

그리고

  • 객체와의 협력이 강조되면서 전체 로직은 분산되었다.
  • 협력 객체들의 내부 동작을 알 수 없게 되었다.

하지만, 믿는다

  • 캡슐화: 내 요청에 협력 객체가 알아서 처리하고 데이터를 잘 돌려줄 것이라고 믿는다.
    • 협력 객체가 어떻게 일을 하는지 신경쓰지 않는다.

책임계약이다.

수많은 객체가 협력하는 객체지향 프로그래밍에선 협력 객체들이 계약을 제대로 지킬 것을 가정하고 프로그램을 만든다. 이를 위해 테스트 코드를 사용할 수 있다.


절차지향에도 책임은 존재한다

int absolute(int a) {
		return a < 0 ? -a : a;
}
  • 책임은 객치지향만의 특징은 아니다. 절차지향에서는 함수 단위로 책임을 지면 된다.
  • 단순히 책임이 있다고 객체지향이 되는 것은 아니다.

책임을 어떻게 나누고 어디에 할당하는지가 중요하다.

객체지향에서는 책임을 함수가 아닌 객체에 할당하는 것이 중요하다.


책임을 객체에 할당하는 것 이상으로

‘책임을 객체에 할당한다’로 객체지향을 설명하기엔 부족하다.

  • C 언어도 구조체에 함수 포인터로 함수를 넣으면 구조체 단위로 책임을 할당할 수는 있다.
  • 하지만, C언어는 객체지향 언어가 아니다.

C언어에는 객체지향을 지원하기 위한 무언가가 부족하다.

인터페이스로 역할을 만들기

interface Calculable {
	/**
	 * 총 금액을 계산한다.
	 */
	long calculateRevenue();

	/**
	 * 총 순이익을 계산한다.
	 */
	long calculateProfit();
}
class RestaurantChain implements Calculable {
	private List<Calculable> stores;

	@Override
	public long calculateRevenue() {
		long revenue = 0;
		for (Calculable store : stores) {
			revenue += store.calculateRevenue();
		}
		return revenue;
	}

	@Override
	public long calculateProfit() {
		long income = 0;
		for (Calculable store : stores) {
			income += store.calculateProfit();
		}
		return income;
	}
}
@Getter
class Store implements Calculable {
	private List<Calculable> orders;
	private long rentalFee; // 임대료

	@Override
	public long calculateRevenue() {
		long revenue = 0;
		for (Calculable order : orders) {
			revenue += order.calculateRevenue();
		}
		return revenue;
	}

	@Override
	public long calculateProfit() {
		long income = 0;
		for (Calculable order : orders) {
			income += order.calculateProfit();
		}
		return income - rentalFee;
	}
}
@Getter
class Order implements Calculable {
	private List<Calculable> foods;
	private double transactionFeePercent = 0.03; // 결제 수수료 3%

	@Override
	public long calculateRevenue() {
		long revenue = 0;
		for (Calculable food : foods) {
			revenue += food.calculateRevenue();
		}
		return revenue;
	}

	@Override
	public long calculateProfit() {
		long income = 0;
		for (Calculable food : foods) {
			income += food.calculateProfit();
		}
		return (long) (income - calculateRevenue() * transactionFeePercent);
	}
}
@Getter
class Food implements Calculable {
	private long price;
	private long originCost; // 원가

	@Override
	public long calculateRevenue() {
		return price;
	}

	@Override
	public long calculateProfit() {
		return price - originCost;
	}
}

객체를 추상화한 역할에 책임을 할당한다

  • 객체에 할당되어 있던 책임을 인터페이스로 분할해 ‘역할’을 만들었다.
  • 객체들이 인터페이스라는 ‘역할’을 구현하게 했다.
  • 추상화의 원리를 이용해 다형성을 지원하게 했다.

→ 엄밀히는, 객체지향에서는 책임을 객체에 할당하지 않는다.

객체를 추상화한 역할에 책임을 할당한다.

C언어의 구조체는 추상 개념을 지원하지 못하므로 절차지향적 언어다.

이로 인한 장점

  • 역할을 이용해 통신하면, 실제 객체가 어떤 객체인지 상관하지 않아도 된다.

내가 부탁한 책임과 역할을 할 수 있는 객체라면 협력 객체가 어떤 객체인지 신경쓰지 않아도 된다.

→ 새로운 요구사항에도 역할을 다하는 새로운 구현체만 만들면 되므로 확장에도 유연해진다.


객체지향의 본질

객체지향의 본질은

  • 언어나 문법, 특징에 있는 것이 아니다.
  • 추상화, 다형성, 상속, 캡슐화도 본질이 아니다.

객체지향의 본질은 역할, 책임, 협력 이다.

문법적 기능들은 역할, 책임, 협력을 잘 다루기 위해 존재하는 프로그래밍 언어적 기능일 뿐이다.

캡슐, 상속, 추상화, 다형성은 객체지향을 대표하는 기능적인 특징일 순 있지만 핵심은 아니다.


객체지향이 절차지향보다 낫다?

절차지향이 객체지향에 비해 뒤떨어진 방법론인 것은 아니다.

  • 오히려 절차지향 개발이 소규모에선 효율적일 수 있다.
  • 오히려 객체지향적인 코드가 읽기 어려울 수 있다.
  • 일부 간단한 코드는 절차지향적 코드가 나을 수도 있다.

패러다임의 장단점을 이해하고, 어떤 방법을 쓸지 상황에 따라 정해서 사용해야 한다.


TDA, 물어보지 말고 시켜라

TDA: Tell, Don’t Ask

TDA 원칙을 지키며 개발하면 객체지향적 사고방식을 할 수 있다.

  • ❌ 얼마있냐? 뒤져서 나오면 10원에 한 대씩이다?
  • ✅ 몇대 맞을래?

사용자가 물건을 구입할 때 물건값을 계산하기

public void sell(Account account, Product product) {
		if (account.canAfford(product.getPrice()) {
				account.withdraw(product.getPrice());
				System.out.println(product.getName() + "를 구매했습니다");
		} else {
				System.out.println("잔액이 부족합니다");
		}
}
  • ❌ 사용자의 잔액을 물어보지 않고
  • ✅ 잔액이 물건의 가격보다 큰 지 확인하고 일을 시킨다.

Account 클래스는 수동적인 데이터 덩어리가 아니라 책임을 갖는 객체가 된다.

Getter와 Setter를 줄여라

  • TDA 원칙은 GetterSetter를 줄이라는 의미로 해석할 수도 있다.
  • 실제로 객체지향적인 사고를 방해하고 절차지향적인 사고를 하게 만드는 요인 중 하나이기도 하다.

→ 외부에서 객체의 모든 데이터에 접근하게 되고, Manager, Utility 같은 클래스가 무수히 늘어난다

하지만 객체에게 모든 일을 시킬 수는 없다. Getter는 필요한 메서드일 수 있다.

Getter는 그 자체로 ‘가격을 알려줘야 하는 책임’ 등으로 결국 사용해야 하는 상황이 생긴다.


정리

  • 객체지향의 핵심은 책임, 역할, 협력에 있다.
  • 객체지향에서는 객체를 추상화한 역할에 책임을 할당한다.
  • TDA: 객체를 데이터 덩어리로 보지 말고 객체에게 책임을 위임해라.

0개의 댓글