[자바/스프링 개발자를 위한 실용주의 프로그래밍][chapter01] - 절차지향과객체지향 / 객체지향의 본질 / TDA 원칙
목차
Reference: http://aladin.kr/p/0RDmT
개요
객체지향에서는 복잡한 문제를 역할과 책임에 따라 개별 “객체”로 분해한다.
그렇게 분해된 각기 다른 특성과 기능을 가진 수 많은 객체들이 상호작용하고 협력해 소프트웨어가 당면한 문제를 해결한다.
절차지향과 비교하기
: 순차지향과 절차지향의 차이점은 ?
순차지향 프로그래밍의 “Sequential”이 “순차적으로”라는 뜻이니 말 그대로 코드를 위에서 아래로 읽겠다는 의미다.
절차지향의 “Procedure”는 컴퓨터 공학에서 말하는 “함수”이다. 그래서 절차지향 프로그래밍은 사실상 “함수”지향 프로그래밍이라고 볼 수 있다. 즉, 절차지향 프로그래밍은 함수 위주로 생각하고 프로그램을 만드는 패러다임인 것이다.
정리하자면 절차 지향 프로그래밍은 함수를 만들어서 프로그램을 만드는 방식이다. 복잡한 문제를 함수로 분해하고, 여러 함수를 이용해 문제를 해결하는 방식이다. 그러므로 자바나 코틀린 같은 언어를 쓰고 있더라도 함수 위주의 사고 방식으로 프로그램을 만든다면 여전히 절차 지향으로 패러다임으로 개발하고 있는 셈이다.
:프로그래밍 언어가 곧 프로그래밍 패러다임은 것은 아니다.
class Store {
private List<Order> orders;
public long getRetnalFee() {
return retnalFee;
}
private long retnalFee;
public List<Order> getOrders() {
return orders;
}
}
class Order {
public List<Food> getFoods() {
return foods;
}
private List<Food> foods;
public double getTransactionFeePerecent() {
return transactionFeePerecent;
}
private double transactionFeePerecent = -0.03;
}
class Food {
public long getPrice() {
return price;
}
private long price;
public long getOriginCost() {
return originCost;
}
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.getOriginCost();
}
cost += orderPrice * order.getTransactionFeePerecent();
}
cost += store.getRetnalFee();
}
return calculateRevenue() - cost;
}
}
예제 코드는 객체지향적으로 작성된 코드라고 할 수 없다. calculateRevenue, calculateProfit 같은 코드는 모두 절차지향적인 코드이다. calculateRevenue, calculateProfit 함수를 실행하기 위한 데이터로서 존재할 뿐이기 때문이다. Store, order, Food 클래스로 표현했지만 이 클래스에는 아무런 책임이 존재하지 않는다. 그냥 데이터를 실어 나르는 역할 정도만 할 뿐이다.
지금껏 해왔던 프로젝트를 돌이켜보자. 프로젝트를 돌이켜봤을 때 혹시 모든 비즈니스 로직이 Service 컴포넌트에 들어가 있지는 않았나 ? 안타깝게도 레이어드 아키텍처라는 미명하에 절차지형적인 코드에서 벗어나지 못하는 경우가 많다. 서비스에 모든 비즈니스 로직이 들어가 있는 클래스는 그저 데이터를 저장하는 용도로만 사용되고 있다.
객체지향적 코드 예시
class StoreV2 {
private List<OrderV2> orders;
private long retnalFee;
public long calculateRevenue() {
long revenue = 0;
for (OrderV2 order : orders) {
revenue += order.calculateRevenue();
}
return revenue;
}
public List<OrderV2> getOrders() {
return orders;
}
public long getRetnalFee() {
return retnalFee;
}
}
class OrderV2 {
private List<FoodV2> foods;
private double transactionFeePerecent = -0.03;
public List<FoodV2> getFoods() {
return foods;
}
public double getTransactionFeePerecent() {
return transactionFeePerecent;
}
public long calculateRevenue() {
long revenue = 0;
for (FoodV2 foodV2 : foods) {
revenue += foodV2.calculateRevenue();
}
return revenue;
}
public long calculateProfit() {
long income = 0;
for (FoodV2 foodV2 : foods) {
income += foodV2.calculateProfit();
}
return (long) (income - calculateRevenue() * transactionFeePerecent);
}
}
class FoodV2 {
public long getPrice() {
return price;
}
private long price;
public long getOriginCost() {
return originCost;
}
private long originCost; // 원가
public long calculateRevenue() {
return price;
}
public long calculateProfit() {
return price - originCost;
}
}
class RestaurantChainV2 {
private List<StoreV2> stores;
// 매출을 계산하는 함수
public long calculateRevenue() {
long revenue = 0;
for (StoreV2 store : stores) {
revenue += store.calculateRevenue();
}
return revenue;
}
// 순이익을 계산하는 함수
public long calculateProfit() {
long income = 0;
for (StoreV2 storeV2 : stores) {
income += storeV2.calculateRevenue();
}
return income;
}
}
비즈니스 로직을 객체가 처리하도록 변경, Store / Order / Food 클래스가 갖고 있던 데이터를 그대로 전달하기만 하던 객체가 행동을 갖게 되었다.
- 이것은 굉장히 큰 변화이며 “어떤 요청이 들어왔을 때, 어떤 일을 책임지고 처리한다”라는 책임이 생긴것이다.
- 다시 말해, “어떤 메시지(필요한 값이나 목표)를 받았을때 어떤 일을 책임지고 처리한다”라고도 표현할 수 있다.
정리하자면 다음과 같다.
- 객체 어떤 메시지를 전달할 수 있게됐다.
- 객체가 어떤 책임을 지게 됐다.
- 객체는 어떤 책임을 처리하는 방법을 스스로 알고 있다.
객제 지향적으로 코드를 작성하는 이유가 “가독성을 높이기 위해서”는 아니다
- 그래서 종종 어떤 코드들은 오히려 객체지향적으로 바꿨을 때 시스템 전체로 봤을때 가독성이 더 떨어지는 경우도 있다.
- 객체지향은 가독성보다 “책임”에 더 집중한다. 객체들은 각자의 책임을 수행하기 위한 협력 객체가 무엇인지를 알고 있으며, 그 밖에 필요한 값은 모두 각자가 갖고 있다.
객체 지향을 다루는 상황에서 동료가 업무를 제대로 했는지 어떻게 확인할 수 있을까?
- 책임은 곧 계약, 그럴 때 사용할 수 있는 것이 테스트 코드이다.
- “최초의 요구사항을 충족하는지?”, “기존 요구사항을 여전히 만족하는지?”를 검사할 수 있는 테스트 코드는 책임을 계약으로 만드는 가장 확실한 방법이자 시스템의 계약 명세이다.
책임과 역할
: 절차지향은 책임을 제대로 구분할 수 없는것일까?
- 그렇지 않다. 절차지향적인 코드를 통해서도 책임을 분할할 수 있다. 절차지향에서는 함수 단위로 책임을 지면 된다.
- 앞선 예시 코드의 Store, Order, Food에 “책임이 없다”라는 말은 객체지향 관점에서 책임이 “객체에 책임이 없다”는 의미이다.
: 책임이 있다고 해서 객체지향이 되는 것도 아니다.
- 그보다는 책임을 어떻게 나누고 어디에 할당하느냐고 더 중요하다. 다시 말해 객체지향에서는 “책임을 함수가 아닌 객체에 할당”하는 것이 중요한 것이다.
- 절차지향에서는 책임을 프로시저로 나누고 프로시저에 할당한다. 객체지향에서는 책임을 객체로 나누고 객체에 할당한다. 이것이 객체지향에서 말하는 책임이다.
: 절차지향과 객체지향을 구분짓는 또 다른 요인은 무엇인가?
- C언어도 구조체를 만들고 함수 포인터로 구조체에 함수를 넣으면 구조체 단위로 책임을 할당할 수 있게 되기 때문에 객체지향이 될 수 있다. 그렇다면 무엇이 절차지향과 객체지향을 구분짓는가 ?
- 객체지향 언어는 단순히 “class”를 문법적으로 지원하지 않아서 같은 이유가 아니다.
- 객체에 할당돼 있던 책임을 인터페이스로 분할하여 역할을 만든다. 그리고 객체들이 인터페이스라는 역할을 구현할 수 있게 된다. 쉽게 말해 “추상화의 원리”를 이용해 다형성을 지원하게 만들 수 있는 것
- 엄밀히 말해 객체지향은 객체를 추상화한 역할에 책임을 할당한 것이다. C언어의 구조체는 추상의 개념을 지원하지 못한다.
: 구현과 역할을 분리하고 역할에 책임을 할당하는 과정은 객체지향에서 중요한 부분
- 역할을 이용해서 통신하면 실제 객체가 어떤 객체인지 상관하지 않아도 된다. 내가 부탁한 책임과 역할을 할 수 있는 객체라면 협력 객체자 구체적으로 어떤 객체인지 신경쓰지 않아도 된다는 의미이다. 따라서 확장에도 유연해진다.
: 객체지향의 본질은 언어나 문법, 특징에 있는 것이 아니다
- 역할, 책임, 협력이 더 중요하다.
- 더불어 추상화, 다형성, 상속, 캡슐화도 본질이 아니다. 이러한 문법적 기능들은 역할, 책임, 협력을 잘 다루기 위해 존재하는 프로그래밍 언어적 기능일 뿐이다.
- 따라서 추상화, 다형성, 상속, 캡슐화가 객체지향을 대표하는 기능적인 특징은 될 수 있지만 핵심이 될 수는 없다.
- 더 나아가 객체지향은 실세계를 반영하지 않는다. 객체가 만약 실세계를 반영한다면 음식이 스스로 가격을 계산할 수 없을 것이다.
- 따라서 객체지향은 실세계를 반영하는 패러다임이 아니다. 그보다 오히려 자아를 가진 객체들이 서로 협력하는 방식으로 개발되는 것에 가깝다.
TDA 원칙
: 어떻게 해야 절차지향적 사고에서 벗어나 객체지향적 사고 방식을 가질 수 있을까?
가장 쉬운 방법은 TDA 원칙을 지켜가며 개발하게 하는 것
TDA는 “Tell, Don’t Ask”의 줄임말. 말 그대로 “물어보지 말고 시켜라” 라는 원칙이다. 객체에게 값에 관해 물어보지 말고 일을 시키라는 의미이다.
TDA 원칙을 따르지 않는 코드
class Shop {
public void sell(Account account, Product product) {
long price = product.getPrice();
long mileage = account.getMoney();
if (mileage >= price) {
account.setMoney(mileage - price);
} else {
System.out.println("잔액이 부족");
}
}
}
가게에서 사용자의 잔액을 물어보고 잔액이 물건값보다 큰지 판단한다.
TDA 원칙에 따라 변경한 코드는 다음과 같다.
class ShopV2 {
public void sell(Account account, Product product) {
if (account.canAfford(product.getPrice())) {
account.withdraw(product.getPrice());
} else {
System.out.println("잔액이 부족");
}
}
}
- TDA 원칙에 따라 사용자의 잔액을 물어보지 않고, 잔액이 물건의 가격보다 큰지 확인하고 일을 시킨다. 이렇게 하는 것만으로 객체는 훨씬 능동적으로 바뀐다.
: 단편적인 이야기로하자면..
TDA 원칙은 무분별하게 사용되는 게터와 세터를 줄이라는 의미로 해석될 수도 있다. 실제로 게터와 세터는 개발자가 객체지향적인 사고를 못하게 하는 방해 요인중 하나이자 절차지향적인 사고를 하게 만드는 대표적인 원이이기도 하다.
게터와 세터가 무분별하게 남발되는 객체는 외부에서 모든 데이터에 접근할 수 있게 된다. 그 결과 개발자들은 비즈니스 로직을 작성할 떄 컴포넌트를 만들고 모든 일을 처리하게 한다. (빠르고 쉽기 떄문)
결과적으로 Manger, Utility 같은 이름을 가진 클래스가 무수히 늘어나게 된다.
: 그렇다고 객체에게 모든 일을 시킬 수는 없다.
게터는 분명 필요한 메서드이며 객체에게 일을 최대한 시키려 해도 어딘가에서는 협력을 위해 게터를 사용해야하는 상황이 분명 나온다.