Chapter 07. 객체 디자인

Yeseong31·2023년 9월 17일
0

자바 코딩의 기술

목록 보기
7/8

컴퓨터 과학의 모든 문제는 또 다른 간접 계층을 추가해 풀 수 있다.
하지만 대부분 또 다른 문제를 양산한다. - 데이비드 휠러

  • Chapter 07에서는 더 객체 지향적이고 강력한 코드를 만드는 데 유용한 디자인 원리를 알아본다.



불 매개변수로 메서드 분할

일반적으로 메서드는 하나의 작업에만 특화되어야 한다.
불 메서드 매개변수는 메서드가 적어도 두 가지 작업을 수행함을 의미한다.

  • 다음 예제를 살펴보자.
class Logbook {

    static final Path CAPTAIN_LOG = Paths.get("/var/log/captain.log");
    static final Path CREW_LOG = Paths.get("/var/log/crew.log");

    void log(String message, boolean classified) throws IOException {

        if (classified) {
            writeMessage(message, CAPTAIN_LOG);
        } else {
            writeMessage(message, CREW_LOG);
        }
    }

    void writeMessage(String message, Path location) throws IOException {

        String entry = LocalDate.now() + " " + message;

        Files.write(location, Collections.singleton(entry),
                 StandardCharsets.UTF_8, StandardOpenOption.APPEND);
    }
}
  • 위 예제는 Logbook 클래스를 수정한 버전이다.

  • log() 메서드의 두 번째 매개변수로 true/false를 전달하여 공개/비공개 메시지를 작성할 수 있다.

  • log() 메서드를 호출하는 방법은 다음과 같다.

    logbook.log("Aliens sighted!", true);
    logbook.log("Toliet broken", false);
  • 코드에 문제는 없지만 지금의 Logbook 클래스는 읽기 불편하고 구조화가 덜 되었다.

  • 이는 두 번째 매개변수로 전달하는 boolean 변수를 잘못 설정하면 메시지 유형이 쉽게 달라지기 때문이다.


class Logbook {

    static final Path CAPTAIN_LOG = Paths.get("/var/log/captain.log");
    static final Path CREW_LOG = Paths.get("/var/log/crew.log");

    void writeToCaptainLog(String message) throws IOException {
        writeMessage(message, CAPTAIN_LOG);
    }
    void writeToCrewLog(String message) throws IOException {
        writeMessage(message, CREW_LOG);
    }

    void writeMessage(String message, Path location) throws IOException {

        String entry = LocalDate.now() + " " + message;

        Files.write(location, Collections.singleton(entry),
                 StandardCharsets.UTF_8, StandardOpenOption.APPEND);
    }
}
  • 입력 매개변수에 boolean이 사용된 메서드라면 메서드를 여러 개 분리하는 것으로 코드 가독성을 높일 수 있다.
  • 매개변수로 구분하던 각 제어 흐름 경로마다 새로운 메서드를 추가하고, 메서드에 의미 있는 이름을 지어주면 된다.
  • 이제 공개/비공개 메서드 호출은 다음과 같이 변경되었다.
logbook.writeToCaptainLog("Aliens sighted!");
logbook.writeToCrewLog("Toilet broken, Again...");

훌륭한 디자인은 결코 쉽지 않다.

무엇이 훌륭한 디자인인지 처음부터 제대로 이해하는 것은 불가능에 가깝다. (중략)
현실적으로 이러한 직관을 쌓는 유일한 방법은 다양한 시도를 통해 무엇이 실패하고 무엇이 옳은지 깨닫는 것뿐이다.




옵션 매개변수로 메서드 분할

  • 다음 예제를 살펴보자.
class Logbook {

    static final Path CREW_LOG = Paths.get("/var/log/crew.log");

    List<String> readEntries(LocalDate date) throws IOException {

        final List<String> entries = Files.readAllLines(CREW_LOG, StandardCharsets.UTF_8);

        if (date == null) {
            return entries;
        }

        List<String> result = new LinkedList<>();
        for (String entry : entries) {
            if (entry.startsWith(date.toString())) {
                result.add(entry);
            }
        }
        return result;
    }
}
  • readEntries() 메서드는 date라는 매개변수를 전달하고 있다.
  • readEntries()date를 전달하면 해당 날짜의 로그 리스트를 반환하고, null을 전달하면 전체 로그 리스트를 반환한다.
  • readEntries() 메서드는 두 가지 이상의 일을 하고 있다.

앞서 설명했듯 하나의 메서드가 두 가지 이상의 일을 하는 것은 메서드의 의미 파악을 어렵게 만든다.
더군다나 readEntries() 메서드의 null 매개변수는 호출했을 때 그 의미를 파악하기 어렵다.


class Logbook {

    static final Path CREW_LOG = Paths.get("/var/log/crew.log");

    List<String> readEntries(LocalDate date) throws IOException {

        Objects.requireNonNull(date);

        List<String> result = new LinkedList<>();
        for (String entry : readAllEntries()) {
            if (entry.startsWith(date.toString())) {
                result.add(entry);
            }
        }
        return result;
    }

    List<String> readAllEntries() throws IOException {
        return Files.readAllLines(CREW_LOG, StandardCharsets.UTF_8);
    }
}
  • 문제를 해결한 방식은 불 매개변수로 메서드 분할과 마찬가지로 메서드를 분리하는 것이다.
    • readEntries() 메서드는 더 이상 date 매개변수가 null인 것을 허용하지 않는다.
    • 대신 매개변수를 필요로 하지 않는 readAllEntries() 메서드를 새로 만들었다.
    • 이를 통해 의도치 않게 NPE가 일어날 가능성을 줄일 수 있다.



구체 타입보다 추상 타입

  • 다음의 예시를 살펴보자.
class Inventory {

    LinkedList<Supply> supplies = new LinkedList();

    void stockUp(ArrayList<Supply> delivery) {
        supplies.addAll(delivery);
    }

    LinkedList<Supply> getContaminatedSupplies() {
        LinkedList<Supply> contaminatedSupplies = new LinkedList<>();

        for (Supply supply : supplies) {
            if (supply.isContaminated()) {
                contaminatedSupplies.add(supply);
            }
        }

        return contaminatedSupplies;
    }
}
  • 위의 getContaminatedSupplies() 메서드는 stockUp()로 생성한 Supply 객체들의 LinkedList를 순회한다.
  • Inventory 클래스는 다음의 코드로 호출할 수 있다.
Stack<Supply> delivery = cargoShip.unload();
ArrayList<Supply> loadableDelivery = new ArrayList<>(delivery);
inventory.stockUp(loadableDelivery);
  • 코드의 흐름을 천천히 살펴보자.
    • Stack을 통해 제품을 전달하는데, 정작 Inventory에 제품을 채우기 위해서는 ArrayList가 필요하다.
    • InventoryArrayList를 넣으면 stockUp() 메서드가 제품을 내부의 ArrayList로 옮긴다.
    • 이후 getContaminatedSupplies()LinkedList에서 제품을 골라낸다.
  • 불필요한 타입 간 변환이 너무 자주 일어나고 있다.
  • 이 코드를 추상화를 사용하여 더 유연하게 변경해 보자.

자바 API를 보면 인터페이스클래스는 흔히 광범위한 타입 계층 구조를 형성한다.
변수에 추상적인 타입을 사용하면 할수록 코드는 더 유연해진다.


class Inventory {

    List<Supply> supplies = new LinkedList();    // (1) LinkedList to List

    void stockUp(Collection<Supply> delivery) {  // (2) ArrayList to Collection
        supplies.addAll(delivery);
    }

    List<Supply> getContaminatedSupplies() {     // (3) LinkedList to List
        List<Supply> contaminatedSupplies = new LinkedList<>();
        
		for (Supply supply : supplies) {
            if (supply.isContaminated()) {
                contaminatedSupplies.add(supply);
            }
        }

        return contaminatedSupplies;
    }
}
  • 이전의 코드와 비교했을 때 달라진 점은 3가지이다.
  1. supplies 필드에 LinkedList 대신 List 인터페이스 타입을 사용하고 있다.
    • 이제 제품이 ArrayList로 저장되는지 LinkedList로 저장되는지 알 수 없게 되었다.
  1. 이제 stockUp() 메서드의 매개변수에 어떤 Collection이든 허용된다.
    • Collection은 자바에서 자료 구조에 객체를 저장하는 가장 기본적인 인터페이스이다.
    • 따라서 Collection의 어천 하위 타입이든지 이 메서드로 전달할 수 있게 되었다.
  1. getContaminatedSupplies() 메서드가 추상적인 타입의 List를 반환하게 되었다.
    • 제품은 반드시 정렬된 상태로 반환되지만 내부적으로 리스트가 어떻게 구현되었는지는 알 수 없게 되었다.
Stack<Supply> delivery = cargoShip.unload();
inventory.stockUp(delivery);
  • 이제 Inventory아무런 변환 없이도 Stack을 온전히 받아들이게 되었다.
    • Stack 이외에도 Set, List, Vector 등의 자료 구조도 허용된다.

OCP(Open/Closed Principle)
소프트웨어 요소는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다.

OCP는 자유로은 상속을 통한 확장과 재사용성을 추구하는 원칙이다.
이 원칙은 다형성역할/구현 분리의 개념을 적용한다.
스프링에서는 제어의 역전(IoC), 의존 관계 주입(DI)의 개념이 이에 해당한다.




가변 상태보다 불변 상태 사용하기

기본적으로 객체의 상태는 불변이다.
가능하면 객체를 불변으로 만들어야 잘못 사용할 가능성을 줄일 수 있다.

  • 다음의 예시를 살펴보자.
class Distance {

    DistanceUnit unit;
    double value;

    Distance(DistanceUnit unit, double value) {
        this.unit = unit;
        this.value = value;
    }

    static Distance km(double value) {
        return new Distance(DistanceUnit.KILOMETERS, value);
    }

    void add(Distance distance) {
        distance.convertTo(unit);
        value += distance.value;
    }

    void convertTo(DistanceUnit otherUnit) {
        double conversionRate = unit.getConversionRate(otherUnit);
        unit = otherUnit;
        value = conversionRate * value;
    }
}
  • 위의 코드는 비행 계획을 세우는 데 필요한 거리를 계산하고 반환한다.
  • 코드 자체에는 버그가 없지만 Distance 클래스를 오용할 여지가 있다는 것이 문제이다.
  • 우선 Distance 클래스는 다음의 코드와 같이 사용될 수 있다.
Distance toMars = new Distance(DistanceUnit.KILIMETER, 56_000_000);
Distance margToVenus = new Distance(DistanceUnit.LIGHTYEARS, 0.000012656528);

Distance toVenusViaMars = toMars;  // 같은 객체가 됨
toVenusVisMars.add(marsToVenus);
  • toMars는 지구에서 화성까지의 거리, marsToVenus 는 화성에서 금성까지의 거리를 나타내는 변수이다.
  • 이 두 변수를 이용하여 지구에서 금성까지의 거리를 toVenusViaMars 변수로 계산한다.
  • 여기서 문제는 toVenusViaMarstoMars가리키는 객체가 같다는 것이다.
    • toVenusViaMars.add()를 호출하면 toMars의 값까지 간접적으로 변경된다.
  • 이 문제는 컴파일러로 사전에 방지할 수 있다.

final class Distance {

	final DistanceUnit unit;
	final double value;

	Distance(DistanceUnit unit, double value) {
		this.unit = unit;
		this.value = value;
	}

	Distance add(Distance distance) {
		return new Distance(unit, value + distance.convertTo(unit).value);
	}

	Distance convertTo(DistanceUnit otherUnit) {
		double conversionRate = unit.getConversionRate(otherUnit);
		return new Distance(otherUnit, conversionRate + value);
	}
}

객체에서 유효하지 않은 변경이 일어나지 않도록 하려면 가변성을 제한하면 된다.

  • 위 코드는 생성자의 valueunit 필드에 final 키워드를 설정하여 변경이 불가능하다.
  • 따라서 거리를 계산하려면 매번 새로운 인스턴스가 필요하다.
Distance toMars = new Distance(DistanceUnit.KILOMETERS, 56_000_000);
Distance marsToVenus = new Distance(DistanceUnit.LIGHTYEARS, 0.000012656528);

Distance toVenusViaMars = toMars.add(marsToVenus).convertTo(DistanceUnit.MILES);

객체를 더 많이 생성해야 한다는 단점은 있지만 자바에서 작은 객체는 비용이 적게 든다.

  • 소프트웨어 디자인 관점에서 이 방법은 ==값 객체(Value Object)==를 처리하는 방법과 같다.
  • 값 객체는 서로 다른 객체라도 내부의 값이 같으면 구분하기 힘들다.
  • 따라서 값 객체는 항상 주의하고 불변으로 만들어야 한다.

스프링 JPA에서 엔티티 클래스를 만드는 경우, final 키워드를 사용할 수 없다.
자세한 내용은 final 클래스는 왜 JPA 엔티티가 될 수 없을까?를 참고하자.




상태와 동작 결합하기

상태와 동작의 결합은 객체 지향 프로그래밍의 기본 틀 중 하나이다.
동작만 있고 상태가 없는 클래스는 객체 지향 디자인에 문제가 있다는 말이 된다.

  • 다음의 예시를 살펴보자.
class Hull {

	int holes;
}

class HullRepairUnit {

	void repairHole(Hull hull) {

		if (isIntact(null)) {
			return;		
		}
		hull.holes--;
	}

	boolean isIntact(Hull hull) {
		return hull.holes == 0;
	}
}
  • 위 코드에서 Hull 클래스는 상태를 표현하고, holes의 개수를 저장한다.
    • HullRepairUnit 클래스는 holes를 수정하는 동작을 수행한다.
    • 즉 현재의 코드는 상태와 동작이 별도의 클래스로 분리되어 있다.
  • 문제는 이렇게 하면 정보 은닉이 불가능하고, 코드가 너무 장황해진다.
    • 이러한 방식으로는 다른 객체가 holes 개수에 접근하고 수정하는 것을 막을 수 없다.
    • 또한 hull 매개변수도 검증하지 않고 있다.

상태와 동작이 분리되어 있는 것을 알아차리지 못할 경우가 종종 있을 수 있다.
이럴 땐 먼저 너무 큰 클래스자신에게 속한 메서드 매개변수만 연산하는 클래스를 찾고,
비슷한 작업을 수행하는 변수와 메서드를 하나의 클래스로 묶어서 클래스를 간소화해야 한다.
마지막으로 전후(before-and-after) 비교를 통해 디자인적으로 좋아졌는지 확인해야 한다.


class Hull {
	
	int holes;

	void repairHole() {

		if (isIntact()) {
			return;
		}
		holes--;
	}

	boolean isIntact() {
		return holes == 0;
	}
}
  • 상태와 동작을 하나의 클래스로 작성하여 코드의 양을 획기적으로 줄일 수 있다.
    • 클래스의 메서드들은 이제 내부 상태 holes에 쉽게 접근할 수 있다.
    • 메서드의 매개변수의 수도 줄었고, 메서드 검증도 필요없게 되었다.
    • 또한 gettersetter를 두어서 holes 속성을 외부에 노출할 필요도 없게 되었다.

요약하면 메서드 내에서 입력 매개변수만 다루고, 자신이 속한 클래스의 인스턴스 변수는 다루지 않는 경우를 주의깊게 봐야 한다.
이는 상태와 동작이 분리되었음을 의미하고, 정보 은닉이 불가능하기 때문이다.




참조 누수 피하기

명백하지 않은 객체는 외부에서 접근할 수 있는 내부 상태가 거의 항상 비어 있다.
이러한 상태를 어떤 방식으로 조작할지는 신중히 결정해야 한다.

  • 다음의 예시를 살펴보자.
class Inventory {

    private final List<Supply> supplies;

    Inventory(List<Supply> supplies) {
        this.supplies = supplies;
    }

    List<Supply> getSupplies() {
        return supplies;
    }
}
  • 위의 Inventory는 자료 구조를 포함하는 매우 일반적인 클래스이다.
  • 자료 구조는 외부에서 먼저 초기화된 후, Inventory의 생성자에 삽입된다.
  • 일단 클래스 자체에는 문제가 없어보인다. 하지만 다음의 사용 예시를 보자.
// 빈 externalSupplies 생성
List<Supply> externalSupplies = new ArrayList<>();
Inventory inventory = new Inventory(externalSupplies);

inventory.getSupplies().size();  // == 0
externalSupplies.add(new Supply("Apple"));

// inventory는 내부의 제품 리스트를 전혀 보호하지 않음
// 따라서 변경 연산을 수행하면 재고 상태가 바뀌어버림
inventory.getSupplies().size();  // == 1

inventory.getSupplies().add(new Supply("Banana"));
inventory.getSupplies().size();  // == 2
  • 우선 빈 externalSupplies를 새로운 Inventory에 전달하고, 이어서 getSupplies()가 빈 리스트를 반환한다.
  • 하지만 inventory내부의 제품 리스트를 전혀 보호하지 않는다.
    • externalSupplies 리스트에 제품을 추가하거나, getSupplies()가 반환한 리스트에 변경 연산을 수행하면 재고 상태가 바뀌어버린다.
    • 이 현상은 suppliesfinal 키워드를 붙인다고 해도 막을 수 없다.
  • 원인은 메모리에 들어 있는 리스트가 new ArrayList<>()로 생성한 리스트 하나뿐이기 때문이다.
    • inventory는 이 리스트로의 참조supplies 필드에 저장하고, getSupplies()를 통해 그 참조를 반환한다.
    • 결국 Inventory내부 구조로의 참조를 getter를 통해 외부에 노출하는 셈이다.

class Inventory {
	
	private final List<Supply> supplies;

	Inventory(List<Supply> supplies) {
		this.supplies = new ArrayList<>(supplies);
	}

	List<Supply> getSupplies() {
		// unmodifiableList()로 wrapping
		return Collections.unmodifiableList(supplies);
	}
}
  • 위 코드의 Inventory는 내부 구조를 훨씬 더 잘 보호한다.
    • 전달한 리스트의 참조 값이 아니라 리스트 내 Supply 객체로 내부 ArrayList를 채운다.
    • 그리고 null이 들어오면 바로 예외를 발생시킨다.
  • 또한 내부 리스트를 getSupplies()로 바로 노출하지 않고 unmodifiableList()로 래핑한 후 노출하고 있다.
    • 이로써 supplies에 대한 접근은 읽기 접근만 가능하게 된다.
    • 리스트에 원소를 추가하려면 이러한 기능을 하는 명시적인 메서드를 작성해야 한다.
  • 이제 아래처럼 Inventory의 사용법이 바뀌게 된다.
List<Supply> externalSupplies = new ArrayList<>();
Inventory inventory = new Inventory(externalSupplies);

inventory.getSupplies().size();  // == 0
externalSupplies.add(new Supply("Apple"));

inventory.getSupplies().size();  // == 0

// UnsupportedOperationException 발생
inventory.getSupplies().add(new Supply("Banana"));
  • externalSupplies 리스트와 getSupplies()가 반환한 리스트 모두 조작할 수 없게 되었다.
    • 이로써 inventory의 내부 상태에는 전혀 영향이 없게 된다.
    • 또한 getSupplies()가 반환한 리스트를 수정하려 하면 UnsupportedOperationException이 발생하니 더 안전해졌다.

이렇게 객체의 주소를 복사하지 않고 객체 내부 값만 참조하여 복사하는 방식방어 복사(defensive copying)이라고 한다.
자세한 내용은 얕은 복사, 방어 복사, 깊은 복사를 참고하자.

gettersetter는 둘 다 보호해야 하는 대상이다.
보통은 setter를 허용하지 않는 편이 훨씬 편한 방법이다.




널 반환하지 않기

메서드 호출 시 적절히 반환할 값이 없을 때 그냥 null을 반환하는 프로그램이 종종 있다.
이런 코드는 프로그램의 안정성을 크게 해칠 우려가 있다.

  • 다음의 예시를 살펴보자.
class SpaceNations {

	static List<SpaceNation> nations = Arrays.asList(
		new SpaceNation("US", "United States"),
		new SpaceNation("KO", "Korea")
	);

	static SpaceNation getByCode(String code) {
		for (SpaceNation nation : nations) {
			if (nation.getCode().equals(code)) {
				return nation;
			}
		}
		return null;
	}
}
  • 위의 getByCode() 메서드에 알려지지 않은 국가 코드를 넣으면 NPE가 발생한다.
  • 또한 메서드가 null을 반환할 가능성이 있어서 매번 명시적으로 반환값을 확인해야 한다.

class SpaceNations {

	/** 널 객체 */
	static final SpaceNation UNKNOWN_NATION = new SpaceNation("", "");

	static List<SpaceNation> nations = Arrays.asList(
		new SpaceNation("US", "United States"),
		new SpaceNation("KO", "Korea")
	);

	static SpaceNation getByCode(String code) {
        for (SpaceNation nation : nations) {
            if (nation.getCode().equals(code)) {
                return nation;
            }
        }
        return UNKNOWN_NATION;
    }
}
  • 물론 IllegalArgumentException이나 NoSuchElementException과 같은 예외를 던지는 방법도 있다.
    • 예외를 통해 문제가 있다고 밝히는 경우에는 호출하는 쪽에서 명시적으로 문제를 처리하도록 해야 한다.
  • 하지만 위 예제에서는 널 객체 패턴(null object pattern)을 권하고 있다.
    • null을 반환하는 대신 널 객체, 즉 객체에 실질적인 값이 없음을 명시적으로 표현한 객체를 반환하는 방식이다.
    • 이렇게 하면 프로그램의 흐름을 방해하지 않으면서 예외를 피할 수 있다.
String us = SpaceNations.getByCode("US").getName(); // -> "United States"
String anguilla = SpaceNations.getByCode("AI").getName(); // -> ""
  • 인지해야 할 점은 UNKNOWN_NATION이 나올 경우에 대한 대응은 여전히 호출하는 쪽에 있다.
  • 다만 값을 무시하든 예외를 던지든 선택의 여지가 생겼다는 점이 다르다.

널 객체는 빈 문자열, 빈 컬렉션, 또는 특수 클래스 인스턴스 등 다양한 형태를 가진다.
하지만 어떤 형태든 공통 목표는 “비용이 막대한 실수”가 일어나지 않도록 하는 것이다.

누구나 훌륭한 디자인을 할 수 있다.
전체를 더 낫게 만드는 세세한 변경들이 모여 좋은 디자인을 만들어내고 그 방법은 클래스의 결함을 찾아내는 것이다.




이 장의 내용은 [자바 코딩의 기술: 똑똑하게 코딩하는 법]의 7장 내용을 정리한 것입니다.

profile
역시 개발자는 알아야 할 게 많다.

0개의 댓글

관련 채용 정보