본문 바로가기

개발공부/원티드 챌린지 정리

9월 백엔드 챌린지 정리 : 클린 코드 (2)

728x90

소프트웨어 모듈이 가져야 할 3가지 기능

1. 필요한 기능이 오류 없이 제대로 동작
2. 변경이 용이한 코드
3. 누가 읽어도 이해할 수 있는 코드, 예상 가능한 동작을 수행하는 코드

절차지향과 객체지향

1) 절차지향 : 메서드와 프로세스의 역할을 분리해 프로그래밍하는 방식 ~ 프로세스는 데이터의 변경에 따라 영향을 받기 때문에 프로세스도 변경을 해야한다. 따라서 이는 변경하기 쉬운 설계가 아니다
 
~ 변경하기 쉬운 설계는 한 번에 하나의 클래스만 변경할 수 있는 설계를 말한다.
1. 하나의 클래스에서는 하나의 처리만 실행될 수 있도록 설계한다(적절하게 나눈다)
2.  나눠진 프로세스를 해당 프로세스와 연관이 있는 데이터가 있는 클래스로 옮긴다 ~ 데이터와 프로세스를 한 곳에 둔다. 데이터와 프로세스가 동일 모듈(클래스)에 위치하도록 프로그래밍하는 방식이 객체지향 프로그래밍이다. 핵심은 캡슐화를 이용해 의존성을 관리하여 객체 간 결합도를 낮추는 것이다
->
절차지향 프로그래밍은 항상 나쁜가? DTO와 Entity를 활용한 개발에서, 해당 클래스는 단순하게 데이터만 가지고 있는 역할을 하며, 이를 처리하는 프로세스는 다른 클래스가 가지고 있다. 이는 절차지향 프로그래밍 방식으로 구현하는 것으로, 무조건 절차지향이 나쁘다고 할 수 없는 중요한 케이스가 된다

객체지향 프로그래밍

객체지향의 목표 : 오류없이 동작하면서 변경이 쉬운 코드

개발을 시작하는 시점에 모든 요구사항 분석은 불가능하다. 또는, 전부 분석 및 수집을 완료했다고 해도 개발이 진행되는 동안 요구사항은 변하기 마련이다. 그렇기 때문에 설계는 다음 사항을 만족해야 한다;
- 필요한 기능을 오류없이 잘 실행되도록 구현
- 작성한 코드를 쉽게 변경할 수 있도록 구현
의존성 관리를 효율적으로 통제하는 다양한 방법을 제공하기 때문에, 객체지향 프로그래밍은 현재까지 위의 사항을 만족하고 대응하기 가장 수월한 방식이다

객체 지향 : 깔끔한 설계

객체지향 설계를 할 때, 보통 어떤 클래스가 필요한지, 어떤 상태와 행동이 필요한지 고민한다. 그러나 클래스 기반으로 설계하는 것은 객체지향 프로그래밍이 아니고, "객체"에 초점이 맞춰져야 한다
1. 어떤 객체가 필요한지 고민하고 나열해본다. 클래스는 여러 객체가 공유하는 상태와 행동을 추상화한 설계도와 같은 개념으로, 클래스를 설계하기 위해선 객체의 행동과 상태가 먼저 결정되어야 한다
2. 객체를 독립적으로 보는 것이 아니라, 기능을 구현하기 위한 협력 공동테의 일원으로 보는 것이다. 이 관점을 통해 설계를 유연하고 확장가능하게 만들 수 있게 된다
객체들의 모양과 윤곽을 잡고나면 공통 특성과 상태를 가진 타입(Type)으로 분류하고 이를 기반으로 클래스를 구현한다. 이렇게 객체 중심적으로 생각하면 설계가 단순해지고 깔끔해진다
 
1) 필요한 객체
가장 먼저 필요한 작업은, 문제를 해결하기 위해서, 문제의 도메인을 정의하는 것이다
객체의 의인화
현실 세계의 객체는 코드를 통해 가상 세계의 객체로 만들어진다. 이 과정을 거치고 나면 가상 셰계의 객체는 현실 세계처럼 수동적인 것이 아니라 능동적으로 스스로 혼자 동작할 수 있다. 이를 객체의 의인화라고 하며, 설계 시 중요하게 인식해야 한다
 
2) 클래스 설계
Type으로 분류한 객체를 가지고 타입에 따라 클래스를 추상화한다. 클래스의 내부와 외부를 구분해 외부와의 협력을 public 인터페이스를 통해서만 한다
-> 객체 스스로가 상태를 관리하고, 판단하고, 행동하는 자율적인 객체가 되기 위해 외부 간섭을 최소화하기 위함
 
3) 도구를 이용해 변경에 유연한 클래스를 구현한다
자율성을 가진 객체란,
1. 캡슐화
2. 단일책임
3. 응집도
올바른 클래스란 내부와 외부의 구분이 명확한 클래스를 말한다. 이를 위해 캡슐화와 접근 제어자의 역할이 중요하다
 
1. 캡슐화
객체 내부의 세부 사항(인스턴스 변수와 구현에 직접 접근)을 감추는 것이다 캡슐화를 통해 객체 내부의 접근을 제한하면 객체와 객체 사이 결합도를 낮출 수 있어 비즈니스 변화에 쉽게 대응할 수 있다

public class Theater {
	private final TicketSeller ticketSeller;
	

	public void enter(Audience audience){
		// 잘못된 코드 Case#1
		// - Theater가 TicketSeller의 인스턴스 변수 ticketOffice에 직접 접근
		ticketSeller.getTicketOffice().getTicket();
		// 수정된 코드 Case#1
		// - TicketSeller 클래스에 getTicketOffice() 삭제;
		ticketSeller.sellTo(audience);

		...
}

public class TicketSeller {
	private TicketOffice ticketOffice;
	...
	
	// 내부 구현을 노출하는 인터페이스(=METHOD)는 삭제
	// 또는 접근 제한자를 public -> private or protected로 수정
	private TicketOffice getTicketOffice(){
        return this.ticketOffice;
  }
	
	public void sellTo(Audience audience){
		// 기존 티켓 구매하는 코드를 여기로 옮긴다.
		
	}
}

=> TicketSeller는 티켓 판매라는 단일 책임을 가지게 되었으며, TicketSeller만 TicketOffice에 있는 티켓 판매 수익과 현금을 이용할 수 있게 설계하여 의존성을 낮출 수 있다
Theater는 TicketSeller가 내부에 TicketOffice 인스턴스를 포함하고 있다는 사실을 알 수 없다. 단지 TicketSeller의 인터페이스에만 의존할 뿐이다
SellTo 메서드를 호출하면 Ticket이 반환되는 사실은 인터페이스의 영역이고, TicketSeller 안에 TicketOffice가 있다는 사실은 구현의 영역이다. 객체를 인터페이스와 구현의 2가지 영역으로 나누고 인터페이스만 공개하는 것은 객체 사이의 결합도를 낮추고 변경하기 쉬운 코드를 작성하기 위한 기본 원칙이다
그 예로 자판기를 들 수 있다. 자판기에 대해 사용자는 자세한 로직을 알지 못해도 돈을 넣고 원하는 음료 버튼(인터페이스)을 누르면 음료를 반환한다는 사실을 알고 있다면 자판기 사용이 가능하다. 만약 자판기 내부에서 음료수를 반출하기까지의 로직이 바뀐다고 해도(내부 비즈니스 로직 변경) 사용자와는 관계가 없다. 인터페이스(음료 버튼 또는 동전 투입구)는 변화되지 않았기 때문이다
내부 구현을 외부에 노출하지 않도록 코드를 수정하고 변화된 부분은 객체 내부에서 책임지고 해결함으로 객체는 자율성을 가진다
- 객체당 주어진 문제들
~ Audience : Ticket 구매, 초대권을 Ticket으로 교환
~ TicketSeller : Ticket 판매, 초대권을 Ticket으로 교환, TicketOffice 내 현금관리
~ Theater : 관람객 티켓 확인 후, 입장처리
 
핵심은 객체 내부 상태를 캡슐화하고 객체 간에는 오직 메서드를 통한 상호작용하도록 만드는 것이다. Theater는 TicketSeller의 내부 구현에 대해서는 알지 못한다. 다만, sellTo 메세지를 이해하고 원하는 결과값을 얻을 수 있다는 사실을아는 것이다. 또한 만약 TicketSeller가 의존하고 있는 TicketOffice의 구현 내용이 변화된다고 해도 이 변화는 TicetSeller의 내부 구현영역까지만 영향을 끼치기 때문에 Theater와는 상관이 없다

public class TicketSeller {
    private final TicketOffice ticketOffice;

    public TicketSeller(TicketOffice ticketOffice) {
        this.ticketOffice = ticketOffice;
    }

    public void sellTo(Audience a) {
        ticketOffice.plusAmount(a.buy(ticketOffice.getTicket()));
    }
}

밀접하게 연관된 작업만 수행하고 연관이 없는 작업은 다른 객체에 위임하는 객체는 응집도가 높다고 표현한다. 자신의 데이터를 스스로 처리하는 자율적인 객체를 만들면 결합도를 낮출 수 있을 뿐 아니라 응집도도 높아진다
객체의 응집도를 높이는 것은 객체를 스스로의 데이터를 스스로 처리할 수 있는 자율적인 존재로 만드는 것부터 시작한다. 외부 간섭을 최대한 배제하고 메세지를 통해 협력하는 자율 객체들의 공동체를 만드는 것이 휼륭한 객체지향 설계의 지름길이다

상속과 다형성 : 변경에 유연한 코드 1

상속은 공통의(기본적인) 상태나 행동은 부모 클래스(추상 클래스)에서 정의하고 특정 행동(public 인터페이스, 메서드)에 대해 각 자식 객체에서 따로 정의할 필요가 있는 경우(템플릿 메서드 패턴)에 사용한다
이렇게 상속받은 자식 클래스는 부모 클래스의 모든 public 인터페이스에 응답할 수 있다. 그래서 외부의 다른 객체가 볼 때는 동일 타입의 객체로 간주한다. 이렇게 되었을 때 다른 객체에서 부모 클래스를 의존할 경우, 이에 응답하는 자식 클래스가 여러 개일 가능성이 생기므로 설계할 때 유의해야 한다

다형성 : 변경에 유연한 코드 2

객체지향 설계에서 객체 간 상호작용은 오직 메세지(메서드 호출)뿐이다. 메세지를 받은 객체는 적절한 메서드를 찾아 실행하고 그 결과를 이용해 응답한다. 다만 항상 하나의 메세지에 하나의 메서드가 응답하는 지 예제로 살펴봐야 한다
*Barista

package com.wanted.preonboarding.cafe.service.handler;

import java.util.UUID;

public class Barista {
    private int rank; // 0: Beginner 1: Middle 2: Master
    private int status; // 0: Waiting 1: Making

    public Barista(int rank, int status) {
        this.rank = rank;
        this.status = status;
    }

    private void setRank(int rank) {
        this.rank = rank;
    }

    private void setStatus(int status) {
        this.status = status;
    }

    public String makeBeverageTo(UUID orderId, Order o) {
        o.changeOrderStatus(1);
        StringBuilder makedOrders = new StringBuilder();
        makedOrders.append("주문ID: ")
            .append(orderId.toString())
            .append("\n");
        o.getOrderDetailInfo().forEach(((beverage, quantity) -> {
            makedOrders.append(beverage.getMenuName())
                .append(":")
                .append(quantity);
        }));
        o.changeOrderStatus(2);
        return makedOrders.toString();
    }

}

*Order

package com.wanted.preonboarding.cafe.service.handler;

import java.util.*;

public class Order {
    private final Map<Beverage, Integer> orderGroup;
    private int status; // 0: pending 1: processing 2: completed

    public Order(Map<Beverage, Integer> o, int s){
        this.orderGroup = o;
        this.status = s;
    }

    public Order(Map<Beverage, Integer> o){
        this(o, 0);
    }

    public Map<Beverage, Integer> getOrderDetailInfo(){
        return this.orderGroup;
    }

    public void changeOrderStatus(int orderStatus) {
        updateOrder(orderStatus);
    }

    private void updateOrder(int status) {
        this.status = status;
    }
}

*Beverage

package com.wanted.preonboarding.cafe.service.handler;


import java.util.Collections;
import java.util.Map;

public class Beverage {
    private final String menuName;
    private final long price;
    private final Map<String, Long> extraRecipe;

    public Beverage(String m, long p, Map<String, Long> r) {
        this.menuName = m;
        this.price = p;
        this.extraRecipe = r;
    }

    public String getMenuName(){
        return menuName;
    }

    public Beverage(String m, long p) {
        this(m, p, Collections.emptyMap());
    }

    public long calculatePrice() {
        long extraRecipeTotalAmount = 0L;
        if (!extraRecipe.isEmpty()) {
            for (String extraMenu : extraRecipe.keySet()) {
                extraRecipeTotalAmount += extraRecipe.get(extraMenu);
            }
        }
        return getPrice() + extraRecipeTotalAmount;
    }

    private long getPrice() {
        return this.price;
    }


}

Barista 클래스에는 컴파일 단계에서 음료를 만드는 makeBeverageTo 메서드가 있고 Beverage 추상 클래스를 의존하는 것으로 구현되어 있지만, 런타임 시 Barista 인스턴스는 Beverage 추상 클래스를 상속받은 인스턴스에 의존한다. 그렇기 때문에 런타임 단게에서 Barista 인스턴스가 의존하는 인스턴스의 메서드가 메세지에 매핑된다
메세지와 메서드는 1:N으로 이루어지며 컴파일 시점과 런타임 시점의 의존성이 다르다. 그래서 동일한 메세지를 수신해도 의존하고 있는 객체 타입(아메리카노, 라떼 등)에 따라 다르게 응답할 수 있다
다형성은 변경의 유연함과 확장성을 가져갈 수 있지만, 어떤 객체가 어떤 객체를 의존하는지 클래스만으로는 확인이 어렵고 디버깅도 어려우며 코드 가독성도 떨어진다. 그래서 추상화가 항상 좋다고 할 수 없는 것이다

구현을 감싼 추상화 : 변경에 유연한 코드 3

추상 클래스의 추상 메서드를 통해 public 인터페이스를 구현하지 않고 자식 클래스에게 위임하여 부모 객체의 구체적인 public 인터페이스에 결합되는 것을 방지하고 변경에 유연하고 확장성을 가지는 설계를 구성할 수 있다
그렇기 때문에 "Barista는 아메리카노를 만든다"와 같은 방법으로 객체 책임을 한정하는 보다 "Barista는 음료를 만든다"와 같이 상위 표현을 바꾸면 결합도를 낮출 수 있다. 이 경우 다음의 장점을 가진다;
- 비즈니스 로직을 설계할 때, 구체적인 내용은 제외하고 일반적인 개념만 이용해 전체적인 큰 그림과 흐름을 그리는 데 효과적이다
- 기능 확장성을 향상할 수 있다. 예를 들어 허브차를 메뉴에 추가하고 싶을 때 Beverage 클래스를 상속하는 허브차 클래스를 구현하기만 하면 된다
- 추상화를 이용해 정의한 전체 비즈니스 로직(상위 협력 흐름)을 그대로 따르게 할 수 있다. JPA의 DataSource의 경우, Database와 상호작용하는 부분에 대해 추상화로 감싸 해당 클래스를 상속받아 구현하게끔 하여 DB의 종류에 상관없이 동일한 인터페이스 흐름을 타도록 강제할 수 있다

객체지향 패러다임의 본질 : 협력, 책임, 역할

객체는 독립적일 수 없다. 객체는 공동체를 이루어야 한다. 애플리케이션에서 제공할 기능을 구현할 때 필요한 더 작은 기능을 찾아내고 객체에 할당하는 과정을 반복하며 객체 공동체를 만드는 게 객체지향 설계이다. 객체지향이란 말 그대로 객체를 지향하는 것이다. 시스템의 목적을 중심으로 필요한 객체를 설계하고 공통 상태와 행동을 가지는 객체를 타입으로 분류하고 타입을 기반으로 클래스를 구현한다
객체의 행동을 결정하는 것은 그 객체가 맡은 기능을 완성하기 위해 수행하는 협력에 따라 결정된다. 상태는 그 행동을 위해 필요한 정보가 상태이며, 필요한 정보란 행동을 처리하기 위한 직접적인 데이터부터 자신이 가지고 있지 않은 정보를 가진 객체의 정보까지를 모두 포함한다

협력 : 애플리케이션의 기능을 구현하기 위해 쪼개진 기능 정의서, Context

협력은 문맥의 일종이다. "음료 주문"이라는 문맥 아래, Cashier는 음료 주문을 처리하고 음료 주문이 진행될 수 있도록 다른 객체들과 협력한다

책임 : 하는 것과 아는 것, 협력하기 위해 객체가 맡는 전체 기능

객체가 정의하는 응집도를 가진 행위의 집합으로, 객체가 유지해야 하는 정보(상태)와 수행할 수 있는 행동(메서드)에 대해 추상적으로 서술한 문장이다
책임과 메세지의 크기는 다르다. 책임은 객체가 수행할 수 있는 행동을 간략하게 서술하기 때문에 메세지보다 추상적이고 더 큰 개념이다
객체의 구성은 "객체가 무엇을 할 수 있는가"와 "객체가 무엇을 알고 있는가"로 나뉜다
1) 할 수 있는 것
- 객체를 생성하거나 계산을 수행하는 등의 행동
- 다른 객체에게 메세지 보내기
- 다른 객체의 활동을 제어하고 조절
2) 아는 것
- 행동하기 위해 필요한 정보
- 관련 객체에 대해 아는 것
- 자신이 유도하거나 계산할 수 있는 것에 대해 아는 것
*Cashier

package com.wanted.preonboarding.cafe.service.handler;

import java.util.Map;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;

public class Cashier {
    private static final Cafe cafe = new Cafe();
    private static final OrderBook orderBook  = new OrderBook();

    public UUID takeOrder(Map<Beverage, Integer> receivedOrders) {
        UUID newOrderId = createOrderId();
        orderBook.add(newOrderId, new Order(receivedOrders));

        return newOrderId;
    }

    public String sendOrder(Barista toBarista, UUID withOrderId){
        return toBarista.makeBeverageTo(withOrderId, orderBook.getOrder(withOrderId));
    }

    public String completeOrder(UUID u, String message){
        orderBook.remove(u);
        return message;
    }

    public long calculateTotalPrice(Map<Beverage, Integer> receivedOrders) {
        AtomicLong totalPrice = new AtomicLong(0L);
        receivedOrders.forEach(((beverage, quantity) -> {
            totalPrice.addAndGet((long) beverage.calculatePrice() * quantity);
        }));

        return totalPrice.get();
    }

    private UUID createOrderId(){
        return UUID.randomUUID();
    }

}

Cashier는 "주문을 받는다"라는 "하는 책임"을 지고, 가격 계산에 대해 "아는 책임"을 진다.
Beverage는 "가격 계산"과 "레시피"에 대해 "하는 책임"을 지고, "할인 정책"에 대해서는 "아는 책임"을 진다.
Barista는 "음료 제조"에 대해 "하는 책임"을 지고, "음료 레시피"에 대해 "아는 책임"을 진다.
Cashier가 order 메세지를 수신하고 Beverage를 인스턴스 변수로 포함하는 이유는 이 객체 공동체 안에서 주문받은 음료의 총 결제금액을 계산할 책임을 가지기 때문이다. 또한 Beverage는 협력 안에서 각 메뉴의 가격을 계산할 책임을 가지므로, 가격 계산 메세지를 수신하고 할인 정책에 대한 속성을 가지는 것이다. 이처럼 협력 안에서 객체에게 할당한 책임이 public 인터페이스와 내부 속성을 결정한다.
객체는 자신의 책임을 수행하기 위해 필요한 정보를 알아야할 책임이 있다. 또한 자신이 할 수 없는(자신의 책임이 아닌) 작업을 협력할 다른 객체를 알아야 할 책임이 있다

역할 : 동일 책임을 수행하는 객체들의 집합체

하나의 협력에 같은 책임을 수행할 수 있는 객체가 하나만 있는 경우, 역할에 특별한 이름이 부여되진 않지만 실제로는 '익명'이라는 역할이 부여되며, 그 역할을 수행할 수 있는 객체를 선택하는 방식으로 설계한다.
하나의 협력에 같은 책임을 수행할 수 있는 객체가 여러 개인 경우, 협력을 개별로 나누게 되면 코드의 중복이 발생하게 된다
이를 막기 위해, 객체에 초점을 맞추는 것이 아니라, 책임에 초점을 맞춰야 한다. 하나의 역할에 동일 책임을 수행가능한 객체들을 포괄할 수 있는 특별한 이름(추상화), 역할을 부여하고, 역할 아래에서 객체들을 슬롯 형태로 관리하다가 필요한 경우, 적절하게 결합해 사용한다. 그래서 역할은 책임의 집합체라고 할 수 있다
역할은 객체들의 공통 책임을 바탕으로 객체의 종류를 숨기므로 역할을 객체의 추상화로 볼 수 있다. 따라서 추상화의 두 가지 장점은 협력 관점에서 역할에도 같이 적용된다
역할이 책임을 포함하는 개념이기 때문에, 협력을 모델링할 때 특정 객체가 아닌 역할에 책임을 할당해야 한다. 이렇게 설계하는 이유는 유연하고 재사용 가능한 협력을 정의하기 위함이다
 
 
 
 

728x90