늦은 신년 목표 한 달에 한 권 이상 읽기이다.. 잘 정리해보려 한다 ㅎㅎ
1부 객체지향)
객체지향 프로그래밍은 현실 세계의 복잡성을 풀어내는 방법 중 하나로, 현재 가장 인기 있는 프로그래밍 패러다임이라 해도 과언이 아니다.
1. 순차지향, 절차지향, 객체지향 프로그래밍이란?
2. 객체지향 프로그래밍에서 역할, 책임, 협력을 강조하는 이유가 뭘까?
3. VO, DTO, DAO, 엔티티란 뭘까?
4. 행동이 강조되는 이유가 뭘까?
5. SOLID와 디자인 패던은 어떻게 이해하는 게 좋을까?
6. 순환 참조를 피해야 하는 이유가 뭘까?
책의 1부 객체지향을 읽으면 다음과 같은 질문에 대답을 할 수 있을 것이다. (물론 내 글은 Chpter1만 다루고 있다.)
Chapter 1. 절차지향과 비교하기
자바를 사용하면서도 절차지향적인 코드가 나올 수 있다.
자바는 객체지향 언어이다. 하지만 절차지향 코드가 나올 수 있다. 사실 객체지향 패러다임이 등장하기 전에는 순차지향과, 절차지향 방식이 주를 이루었다. 그래서 객체지향이 무엇인지 제대로 이해하려면, 먼저 순차지향과 절차지향의 차이를 알아야 한다.
순차지향 프로그래밍과 절차지향 프로그래밍이 차이는?
순차지향(Sequential oriented programming)이란, Sequential = '순차적으로'라는 의미로 말 그대로 코드를 위에서 아래로 읽겠다는 의미이다. 절차지향(Procedure oriented programming)에서 말하는 Procedure는 직역하면 절차라는 의미가 맞지만 컴퓨터공학에서 의미하는 것은 '함수'이다. 따라서 절차지향 프로그래밍은 사실상 함수 지향 프로그래밍이라고 볼 수 있다. 다시 말해 절차지향은 함수 위주로 생각하고 프로그래밍을 만드는 패러다임이다.
절차지향 프로그래밍은 함수(procedure) 지향 프로그래밍이다.
순차지향 언어인 어셈블리어로 작성된 코드에서는 함수의 개념이 존재하지 않는다. 코드를 위에서 아래로 순차적으로 읽기만 한다. 대신 목적지 주소를 나타내는 레이블이라는 개념과 jmp, goto같은 명령어가 존재해서 해당 키워드를 만나면 실행 위치를 해당 주소로 옮기는 방식으로 동작한다.
절차지향 프로그래밍은 다양한 함수를 만들어서 프로그램을 만드는 방식이다. 복잡한 문제를 개별 함수로 분해하고, 여러 함수를 이용해 문제를 해결하는 방식이다. 따라서, 객체지향 언어를 쓰더라도 함수 위주의 사고 방식으로 프로그램을 만든다면 여전히 절차지향 패러다임으로 개발하고 있는 것이다.
프로그래밍 언어가 곧 프로그래밍 패러다임인 것은 아니다.
class RestaurantChaim {
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();
}
// 결제 금액 3%를 비용으로 잡음
cost += orderPrice * order.getTransactionFeePercent();
}
cost += store.getRentalFee();
}
return calculateRevenue() - cost;
}
}
@Getter
class Store {
private List<Order> orders;
private long rentalFee; // 임대료
}
@Getter
class Order {
private List<Food> foods;
private double transactionFeePercent = 0.03; // 결제 수수료 3%
}
@Getter
class Store {
private long price;
private long originCost; // 원가
}
물론 위의 예시는 극단적인 절차지향 코드이다. 예를 들어, Store, Order, Food 같은 클래스로 도메인을 표현하더라도 이들 클래스에 아무런 책임이나 행동이 부여되지 않고 단순히 데이터를 저장하는 용도로만 사용된다면, 본질적으로는 절차지향적인 코드와 다를 바 없다.
Spring 기반 애플리케이션에서 이러한 현상을 확인해보면, 모든 비즈니스 로직이 하나의 Service 컴포넌트에 몰려있고, 도메인 클래스들은 그저 데이터 전달 객체나 엔티티로만 존재하는 경우가 있다. 즉, 레이어드 아키텍처라는 이름 아래에서 객체지향적인 설계보다는 단순한 절차지향 코드를 그대로 답습하는 상황이 발생할 수 있다. 이렇게 되면 도메인 클래스가 자기 책임을 가지고 객체의 행동을 정의하는 대신, 모든 로직이 Service에 집중되어 있어 객체지향 프로그래밍의 핵심인 캡슐화, 책임 분리, 다형성 등의 장점을 잃을 수 있다.
위의 코드를 객체지향적으로 바꿔보자.
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 store : stores) {
income += store.calculateProfit();
}
return income;
}
}
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 long calculateProfit() {
long income = 0;
for (OrderV2 order: orders) {
income += order.calculateProfit();
}
return income - retnalFee;
}
}
class OrderV2 {
private List<FoodV2> foods;
private double transactionFeePerecent = 0.03;
public long calculateRevenue() {
long revenue = 0;
for (FoodV2 food : foods) {
revenue += food.calculateRevenue();
}
return revenue;
}
public long calculateProfit() {
long income = 0;
for (FoodV2 food : foods) {
income += food.calculateProfit();
}
return (long) (income - calculateRevenue() * transactionFeePerecent);
}
}
class FoodV2 {
private long price;
private long originCost; // 원가
public long calculateRevenue() {
return price;
}
public long calculateProfit() {
return price - originCost;
}
}
비즈니스 로직을 객체가 직접 처리하게 되었다. 이전에는 각 클래스가 단순히 데이터를 전달하는 역할에 그쳤다면, 이제 각 객체가 스스로의 행동을 정의한다. 각 객체는 요청을 받으면 어떤 작업을 수행해야 할지 스스로 알고 있다. 즉, 어떤 메시지(필요한 값이나 목표)를 받으면 그에 따른 일을 담당하여 처리하는 책임이 부여된 것이다.
이러한 변화는 다음 세가지의 변화를 잘 알고 넘어가면 된다.
- 객체에 어떤 메시지를 전달할 수 있게 됐다.
- 객체가 어떤 책임을 지게 됐다.
- 객체는 어떤 책임을 처리하는 방법을 스스로 알고 있다.
데이터와 비즈니스 로직이 한 곳에 잘 들어가 있는 경우엔, 데이터 측면에서 봤을 때도 어떤 행위를 하기 위해 만들어진 행동과 데이터가 한 곳에 잘 응집됐다고 볼 수 있고, 이 경우를 응집도가 높다 한다. 가독성 측면에서는 사람마다 다르고, 시스템 전체적으로 봤을 때는 더 떨어지는 경우도 있다. 하지만 객체지향으로 작성하는 이유가 '가독성을 높이기 위해서'가 아니기 때문에 이상한 것은 아니다.
객체지향은 가독성보다는 책임에 집중한다. 객체들은 각자의 책임을 수행하기 위한 협력 객체가 무엇인지 알고 있으며, 그 밖에 필요한 값은 모두 각자가 가지고 있다. 즉, 본인이 해야할 일은 본인이 제일 잘 알고 있다.
물론 다른 객체와 협력이 강조되면서 전체로직은 분산됐다. 그리고 협력 객체들의 내부 동작이 어떤지는 알 수 없게 되었다. 믿고 있기 때문에 협력 객체가 어떻게 일하는지 신경 쓰지 않고, 제대로 해왔는지만 신경 쓴다. 즉 이것이 캡슐화이다.
클래스와 객체는 자체적으로 온전해야 다루기 쉽다. 비즈니스 로직에 필요한 데이터가 잘 모여 있어야 문제가 발생했을 때 어느 객체가 책임을 다하지 못했는지 쉽게 추적할 수 있다. 결국, 소프트웨어가 달성하려는 큰 목적을 위해 각 객체가 책임을 명확히 나누어 가지는 것이 핵심이다.
책임을 객체가 나눠가져야 한다.
더 나아가 이런 방식으로 작업해야 업무 효율도 높일 수 있다. 객체들이 어떻게 협력할지, 어떤 책임을 맡을지 결정하고 나면 병렬적으로 처리할 수 있기 때문이다. 세세한 구현은 나의 동료들이 해줄 거라 믿고 작업할 수 있게 된다. 개개인은 자신이 담당하는 객체의 책임만 제대로 구현하면 되기 때문에 객체지향 프로그래밍에서는 책임이 강조된다. 책임을 기반으로 동작하는 것이 객체지향이기 때문이다.
(책임은 계약이기 떄문에 테스트 코드를 통해 검사할 수 있다.)
1.1 책임과 역할
절차지향이라고 책임을 구분할 수 없는 것은 아니다. 절차지향에서는 함수 단위로 책임을 지면 된다. 따라서 기존의 Store, Order, Food에 책임이 없다는 말은 객체지향 관점에서 객체에 책임이 없다는 의미이다. 책임은 객체지향에서 굉장히 중요한 부분이지만, 객체지향만의 특징은 아니다. 일반적인 함수에도 책임은 존재한다.
int absolute(int a) {
return a<0? -a:a;
}
absolute 함수는 입력 값을 항상 양수로 반환하는 책임을 가진다. 이처럼 C 언어에서도 '책임'이라는 개념을 설명할 수 있다. 중요한 것은 단순히 객체를 통해서 책임을 설명할 수 있는 것도 아니고, 책임을 가진다고 해서 객체지향 프로그래밍이 되는 것이 아니라, "책임을 어떻게 나누고 어디에 할당하느냐"가 핵심이다.
절차지향에서는 책임을 프로시저로 나누고 프로시저에 할당한다면, 객체지향에서는 책임을 함수가 아닌 객체(또는 객체를 추상화한 역할)로 나누고 객체로 할당한다. 그러나 이 말만으로 객체지향을 완전히 설명할 수는 없다. 예를 들어, 구조체에 함수 포인터를 넣어 구조체 단위로 책임을 할당하는 방식으로도 객체지향적인 접근을 할 수 있다. 그렇다면 C 언어가 왜 객체지향 언어가 아닌지 생각해 보면, 객체지향의 본질은 단순히 책임을 할당하는 대상이 객체라는 것 이상의 문제임을 알 수 있다.
class RestaurantChainV2 implements Calculable { // 역할을 구현
private List<Calculable> stores; // 역할에 의존하도록 변경
// 1. 매출을 계산하는 함수
public long calculateRevenue() {
}
// 2. 순이익을 계산하는 함수
public long calculateProfit() {
}
}
class StoreV2 implements Calculable { // 역할을 구현
private List<Calculable> orders; // 역할에 의존하도록 변경
private long retnalFee;
public long calculateRevenue() {
}
public long calculateProfit() {
}
}
class OrderV2 implements Calculable { // 역할을 구현
private List<Calculable> foods; // 역할에 의존하도록 변경
private double transactionFeePerecent = 0.03;
public long calculateRevenue() {
}
public long calculateProfit() {
}
}
class FoodV2 implements Calculable { // 역할을 구현
private long price;
private long originCost;
public long calculateRevenue() {
}
public long calculateProfit() {
}
}
객체에 할당돼 있던 책임을 인터페이스로 분할해서 역할을 만들었다. 그리고 객체들이 인터페이스라는 역할을 구현하게 했다. 즉, 객체지향에서 흔히 말하는 추상화의 원리를 이용해서 다형성을 지원하게 했다.
- 매출 계산과 관련된 역할
- 순이익 계산과 관련된 역할
여기서 역할의 개념이 나온다. 엄밀히 말하면 객체지향에서 책임을 객체에 할당하지 않는다. 객체를 추상화한 역할에 책임을 할당한다. 그래서 객체지향 언어의 특징 중 하나로 다형성이 있는 것이다.
구현과 역할을 분리하고 역할에 책임을 할당하는 과정은 객체지향에서 정말 중요한 부분이다. 정말 큰 장점을 얻게 되는데, 역할을 이용해서 통신하면 실제 객체가 어떤 객체인지 상관하지 않아도 된다. 내가 부탁한 책임과 역할을 할 수 있는 객체라면 협력 객체가 구체적으로 어떤 객체인지 신경 쓰지 않아도 된다 는 의미이다. 따라서 확장에 유연해진다. 새로운 요구사항이 생기면 그 역할을 다하는 구현체만 만들어주면 되기 때문이다.
실제 예제에서 음식 체인점의 규모가 커지면서 다른 것도 판매한다 했을 때 BrandProduct라는 클래스를 생성해서 Order에 List로 추가할 수 있다. 하지만 구현체가 아닌 역할에 집중하는 코드는 좀 더 유연하게 확장할 수 있을 것이다. 기존의 food를 item으로 변경해서 앞으로 다양한 제품이 들어올 수 있도록 이름을 변경해줬다.변경해 줬다. 코드를 추가하면서 기존 코드에서 부적절해 보이는 변수이름만 같이 변경해 줬다. 이처럼 역할에 집중하니 코드를 크게 변경하지 않고도 기능을 확장할 수 있게 됐다. 이는 곧 구체적인 것(클래스)가 아닌 추상적인 것(역할, 인터페이스)에 집중할 때 유연한 설계를 얻을 수 있게 된다.
정리하면, 객체는 이제 스스로 책임을 지며 자신의 역할이 정해지고, 특정 목표를 달성하기 위해 서로 협력하는 방식으로 설계된다. 따라서 객체지향의 본질은 단순히 언어나 문법, 또는 추상화, 다형성, 상속, 캡슐화 같은 기능적 특징에 있는 것이 아니라, 각 객체가 역할과 책임을 가지고 협력하는 데 있다. 이들 기능은 그러한 협력을 원활하게 지원하기 위해 존재하는 언어적 도구일 뿐, 객체지향의 핵심은 아니다.
또한, 객체지향에서 말하는 객체는 실제 세계를 그대로 반영하지 않는다. 실제 세계를 그대로 반영한다면 음식 객체가 스스로 가격을 계산할 수 있을 리 없으므로, 객체는 현실을 모방하는 대신 자아를 가진 추상적 존재들이 서로 협력하는 모델에 가깝다.
마지막으로, 절차지향 방식이 객체지향보다 열등한 방법론은 아니다. 절차지향은 복잡한 시스템에서는 관리가 어려워질 수 있지만, 소규모 프로젝트에서는 여전히 효과적으로 사용될 수 있다.
1.2 TDA 원칙
어떻게 절차지향적 사고에서 벗어나 객체지향적인 사고방식을 가질 수 있을까?
TDA 원칙을 지켜가며 개발을 하면 된다. TDA란 Tell, Don't Ask의 줄임말이다. 말 그대로 물어보지 말고 시켜라 라는 원칙이다. 객체에게 값에 관해 물어보지 말고 일을 시키라는 의미이다.
단편적으로 이야기하면 TDA 원칙은 무분별하게 사용되는 Getter와 Setter를 줄이라는 의미로 해석될 수도 있다. 그리고 실제로 게터와 세터는 개발자가 객체지향적인 사고를 못하게 하는 방해 요인 중 하나이자, 절차지향적인 사고를 하게 만드는 대표적인 원인이기도 하다. 게터와 세터가 무분별하게 남발되는 객체는 외부에서 모든 데이터에 접근할 수 있고, 그 결과 개발자들은 비즈니스 로직을 작성할 때 Manager라는 컴포넌트를 만들고(스프링 서비스 컴포넌트, 각종 유틸 클래스) 여기서 모든 일을 처리하게 한다. 이렇게 개발하는 편이 빠르고 쉽지만, 결과적으로 프로젝트에는 Manager이나 Utility 같은 이름을 가진 클래스가 무수히 늘어나게 된다.
객체지향을 실천해보고 싶다면 단순히 이 원칙을 적용해 보는 것도 좋다. TDA는 간단하면서도 객체지향을 꽤 관통하는 원칙이기 때문이다.
객체를 데이터 덩이로 보지말고 객체에게 책임을 위임해라.
객체는 마치 자아를 가진 것처럼 움직여야 한다. 객체는 단순히 데이터를 나르기 위한 수동적 존재가 아니며, 능동적으로 움직여야 하는 존재이다. (그렇다고 객체에게 모든 일을 시킬 수만은 없다. Getter는 분명 필요한 메서드이며, 객체에게 일을 최대한 시키려 해도 어딘가에서는 협력을 위해 Getter를 사용해야 하는 상황이 분명 있다. 따라서 이 메서드를 결국 지울 수는 없다.)
Chapter1을 마치며
객체지향에서 중요한 건 결국 책임을 어떻게 나누고, 어디에 할당하느냐라는 점이다. 더불어 이 책임을 단순히 객체에 직접 부여하는 것이 아니라, 추상화된 역할에 할당해 확장성과 유연성을 고려하는 것이 객체지향 프로그래밍의 핵심이다. 솔직히, 이 부분에 대해 깊게 고민해 본 적은 없었던 것 같다. 그래도 다행인 건 나름 객체에 책임을 부여하며 개발해 왔지만, 분명히 이 개념들을 토대로 더 개선해 나가야 할 부분이 많다고 느낀다. 아직 정말 짧게 읽었지만 배운게 많다. 앞으로 나머지 내용도 꼼꼼히 읽고 정리해 볼 예정이다..^^
'책책책 책을 읽어요 > 자바,스프링 개발자를 위한 실용주의 프로그래밍' 카테고리의 다른 글
[자바/스프링 개발자를 위한 실용주의 프로그래밍] Chapter 2. 객체의 종류 (0) | 2025.03.16 |
---|