본문 바로가기

JAVA/JAVA 기초

자바 코딩의 기술 - #7 객체 디자인

반응형

불 매개변수로 메서드 분할

  • 일반적으로 메서드는 하나의 작업에만 특화되어야 한다.
  • 불 메서드 매개변수는 메서드가 적어도 2가지 작업을 수행함을 뜻한다.
class Logbook {
    private final Path CAPTAIN_LOG = Paths.get("/var/log/captain.log");
    private 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, StandardOpenOpiton.APPEND);
        )
    }

}
  • 코드를 읽으면 불 매개변수의 목적을 알 수 있어야 한다.
class Logbook {
    private final Path CAPTAIN_LOG = Paths.get("/var/log/captain.log");
    private final Path CREW_LOG = Paths.get("/var/log/crew.log");

    void writeToCaptainLog(String message) {
        writeMessage(message, CAPTAIN_LOG);
    }

    void writeToCrewLog(String message) {
        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, StandardOpenOpiton.APPEND);
        )
    }

}
  • 메서드를 여러개로 분리함으로써 코드가 향상

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

class Logbook {
    private 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(data == null) {
            return entries;
        }

        List<String> result = new ArrayList<>();

        for(String entry : entries) {
            if (entry.startsWith(data.toString)) {
                result.add(entry);
            }
        }
        return result;
    }
}
  • readEntries() 메서드에서 로그 항목마다 날짜가 있으니 입력 매개변수인 data로 명시해 항목을 선택할 수 있다.
  • 실제 날짜 값이 아닌 null을 삽입하면 readEntries()는 로그 항목 전체를 반환한다.
List<String> completeLog = logbook.readEntries(null);

final LocalDate moonLanding = LocalData.of(1969, Month.JULY, 20);
List<String> moonLandingLog = logbook.readEntries(moonLanding);
  • null을 쓸수 있다는 것은 본질적으로 data 매개변수가 선택사항이라는 뜻이다.
  • boolean 메서드 매개변수를 다른 형태와 같다.
class Logbook {
    private final Path CREW_LOG = Paths.get("/var/log/crew.log");

    List<String> readEntries(LocalDate date) throws IOException {
        Objects.requireNonNull(date);

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


    List<String> readAllEntries() throws IOException {
        return Files.readAllLines(CREW_LOG, StandardCharsets.UTF_8);
    }        
}
  • 각각 제어 흐름 분기를 하나씩 표현한다.
List<String> completeLog = logbook.readAllEntries(null);

final LocalDate moonLanding = LocalData.of(1969, Month.JULY, 20);
List<String> moonLandingLog = logbook.readEntries(moonLanding);
  • 가독성이 높아지고, null을 사용할 필요가 없다.

구체 타입보다 추상 타입

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.siContaminated()) {
                contaminatedSupplies.add(supply);
            }
        }

        return contaminatedSupplies;
    }
}
  • getContaminatedSupplies() 메서드는 stockUp() 메서드를 통해 생성한 Supply 객체들의 LinkedList를 순회한다.
Stack<Supply> delivery = cargoShip.unload();
ArrayList<Supply> loadableDelivery = new ArrayList<>(delivery);
inventory.stockUp(loadableDelivery);
  • Stack을 사용해 제품을 후입선출순으로 전달한다.
  • Inventory에 제품을 채우려면 ArrayList가 필요하다. 그래서 제품을 ArrayList로 옮겨야 한다.
  • Inventory에 ArrayList를 넣으면 stockUp() 메서드가 제품을 내부 LinkedList로 옮긴다.
  • 이후 최종저그로 getContaminatedSupplies()가 LinkedList에서 변질된 제품을 골라낸다.

추상타입

class Inventory {
    List<Supply> supplies = new LinkedList<>();

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

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

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

        return contaminatedSupplies;
    }
}
  • supplies 필드에 LinkedList 대신 List 인터페이스 타입을 사용
  • 제품은(ArrayList배열에 또는 LinkedList의 링크트 래퍼 객체를 통해) 순서대로 저장되지만 어떻게 저장되는지 알수 없다.
  • stockUp() 메서드가 어떤 Collection이든 허용한다.
  • getContaminatedSupplies() 메서드가 더 구체적인 타입이 아닌 List를 반환한다.
  • 코드가 더 유연해진다.
Stack<Supply> delivery = cargoShip.unload();
inventory.stockUp(delivery);

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

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 toMars = new Distance(DistanceUnit.KILOMETERS, 56_000_000);
Distance marsToVenus = new Distance(DistanceUnit.LIGHTYEARS, 0.000012656528);
Distance toVenusViaMars = toMars;
toVenusViaMars.add(marsToVenus);
  • toVenusViaMars와 toMars 가리키는 객체가 같다.
  • toVenusViaMars.add(marsToVenus)를 호출하면 toMars의 값까지 간접적으로 변경된다.
  • 나중에 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);
    }

}
  • 객체는 유효하지 않는 변경이 일어나지 않도록 스스로 보호해야 하는데 가변성을 제한하면 가능하다.
  • final를 생성자의 value와 unit 필드에 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);
  • 객체를 더 많이 생성한다는 단점이 있지만, 자바에서 작은 객체는 적은 비용이 든다.
  • 소프트웨어 디자인 관점에서 이 방법은 값객체를 처리하는 방법으로서 여기에는 백분율, 돈, 통화, 시간, 날짜, 좌표, 거리 포함된다.
  • 이러한 객체는 값이 서로 같으면 구분하기 어렵다. 서로 다른 객체가 각각 10표현하고 있더라도 10은 10이다. 값 객체에 항상 주의하고 불변으로 만들어야 한다.

상태와 동작 결합하기

class Hull {
    int holes;
}

class HullRepairUnit {

    void repairHole(Hull hull) {
        if (isIntact(hull)) {
            return;
        }
        hull.holes--;
    }

    boolean isIntact(Hull hull) {
        return hull.holes == 0;
    }
}
  • 상태와 동작의 결합은 객체 지향 프로그래밍의 기본 틀 중에 하나다.
  • 동작만 있고, 상태가 없는 클래스는 객체지향 디자인에 문제가 있다는 뜻이다.
  • Hull 클래스는 상태를 표현하고 holes의 개수를 저장한다.
  • HullRepairUnit은 holes를 수정함으로써 동작을 표현한다.
  • 상태와 동작이 별개 클래스로 나뉘어 있다.
  • User와 UserController, Order와 OrderManager 등 이렇게 분리하면 정보 은닉이 불가능해지고 코드가 더 장황해진다.
  • Hull 클래스는 HullRepairUnit에게 자신의 상태에 대한 읽기와 쓰기 접근을 제공해야 한다.
  • 다른 객체가 홀 개수에 접근하고 수정하는 것을 막기 어렵다. hull 매개변수도 검증하지 않는다.
  • 상태와 동작이 분리되었는지 알아채기 어려운 경우도 있다. 너무 큰 클래스나 자신에고 속한 메서드 매개변수만 연산하는 클래스를 찾는다.
  • 그리고 비슷한 작업을 수행하는 변수와 메서드를 하나의 클래스로 묶어 클래스를 간소화 한다.
  • 마지막으로 전후 비교를 수행해 디자인이 좋아졌는지 확인한다.
class Hull {
    int holes;

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

    boolean isIntact() {
        return holes == 0;
    }
}
  • HullRepairUnit은 완전히 사라졌다.
  • Hull 스스로 수정한다.
  • 현실적으로 Hull을 수정하는 어떤 로봇이 있을텐데 처음에는 다소 이상해보인다. 하지만 프로그래밍에서 상태와 동작이 없는 단위라면 그러한 단위를 표현하는 클래스도 있으면 안된다.
  • 그 대신 Hull 클래스 스스로 기능을 제공한다.
  • 다른 시스템 유형에 있을 만한 Order나 User도 매니저, 컨트롤러, 서비스, 그 외 비상태 클래스 없이 스스로 그닝을 제공할 수 있다.
  • 일반적으로 이러한 방법으로 상태와 동작을 합칠 수 있다.
  • 클래스의 메서드는 내부 상태를 직접 쉽게 처리할수 있다. 메서드 매개변수도 줄었고 메서드를 이해하는데 더 쉽다.
  • 매개변수를 검증하지 않아도 되고, holes 속성을 외부에 노출할 필요도 없다.
  • 메서든 내에서 입력 매개변수만 다루고 자신이 속한 클래스의 인스턴스 변수를 다루지 않는 경우를 유심히 살펴봐야 한다.
  • 이것은 상태와 동작이 분리되었다는 의미고, 이러한 메서드로는 정보 은닉이 불가능하다.
  • 너무 많은 정보가 공개되면 버그도 발생하기 쉽다.
  • 이러한 규칙을 어겨야하는 프레임워크도 있다. 웹 프레임워크의 컨트롤러에는 필드 없이 메서드 매개변수만 있는 등 전형적으로 상태가 없다.
  • 상태는 데이터베이스에 저장한 채 대량의 병렬 요청을 처리하는 컨트롤러를 많이 생성할 수 있어야 하는 디자인을 따라야 하기 때문이다.

참조 누수 피하기

class Inventory {
    private final List<Supply> supplies;

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

    List<Supply> getSupplies() {
        return supplies;
    }
}
  • 명백하지 않는 객체에는 외부에서 접근할 수 있는 내부 상태가 거의 항상 있다. 이러한 상태를 어떤 방식으로 조작할지 신중히 결정해야 한다.
  • Inventory는 자료구조를 포함하고 있는 매우 일반적은 클래스이다. 자료구조는 외부에서 먼저 초기화된 후 Inventory의 생성자에 삽입된다.
  • 클래스 자체로는 문제가 없지만 사용할때 문제가 발생한다.
List<Supply> externalSupplies = new ArrayList<>();
Inventory inventory = new Inventory(externalSupplies);

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

inventory.getSupplies().add(new Supply("Banana"));
inventory.getSupplies().size(); //2
  • externalSupplies를 새 Inventory에 전달하고 있어서 getSupplies()가 빈 리스트를 반환한다.
    하지만 inventory는 내부의 제품 리스트를 전혀 보호하지 않는다.
  • externalSupplies 리스트에 제품을 추가하거나 getSupplies()가 반환한 리스트에 변경 연산을 수행하면 재고 상태가 바뀐다.
  • supplies 필드에 final 키워드를 붙여도 이러한 동작을 막지 못한다.
  • 나중에 쉽게 예외를 발생시킬 수 있는 null도 생성자에 전달할 수 있다.
  • 원인은 메모리에 들어 있는 리스트가 new ArrayList<>()로 생성한 리스트 하나이기 때문이다.
  • inventory는 이 리스트로의 참조만 supplies 필드에 저장하고 getSupplies()를 통해 그 참조를 반환한다.
  • 사실상 inventory는 내부 구조의 참조를 게터를 통해 바깥으로 노출하는 셈이다.
class Inventory {
    private final List<Supply> supplies;

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

    List<Supply> getSupplies() {
        return Collections.unmodifiableList(supplies);
    }
}
  • Inventory는 내부 구조를 훨씬 더 잘 보호한다.
  • 전달한 리스트의 참조가 아니라 리스트 내 Supply 객체로 내부 ArrayList를 채운다.
  • null이 들어오면 바로 예뢰를 발생킨다.
  • 내부 리스트를 getSupplies()로 바로 노출하지 않고 unmodifiableList()로 래핑한 후 노출한다.(읽기만 가능)
  • 리스트에 원소를 추가하려면 이러한 기능을 하는 명시적은 메서드를 작성해야 한다.
  • 내부 ArrayList 인스턴스는 Inventory 내에서 프라이빗이다.
  • 인스턴스로의 참조가 클래스 밖으로 절대로 나가지 않는다. 완전히 숨겼고, 보호받았다.
List<Supply> externalSupplies = new ArrayList<>();
Inventory inventory = new Inventory(externalSupplies);

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

//UnsupportedOperationExceptioin
inventory.getSupplies().add(new Supply("Banana"));
  • externalSupplies 리스트와 getSupplies()가 반환하는 리스트 모두 조작할 수 없으니 inventory의 내부 상태에 전혀 영향이 없다.
    이러한 기법을 방어복사라고 부른다. 전달된 자료 구조를 재사용하는 대신 복사본을 만들어 제어한다.

널 반환하지 않기

class SpaceNations {
    static List<SpaceNation> nations = Arrays.asList(new SpaceNation("US", "us"), new SpaceNation("RU", "Russia"));

    static SpaceNation getByCode(String code) {
        for (SpaceNation nation :  nations) {
            if (nation.getCode().equals(code)) {
                return nation;
            }
        }
    }

    return null;
}
  • 메서드 호출시 적절히 반환할 값이 없으면 그냥 null을 반환하는 프로그래머가 있다.
  • 이것은 프로그램의 안전성을 크게 해진다.
String us = SpaceNations.getByCode("US").getName(); // us

String anguilla = SpaceNations.getByCode("AI").getName(); //NullpointerException
  • 알려지지 않는 국가코드를 넣으면 NullpointerException가 발생된다.
class SpaceNations {

    static final SpaceNation UNKNOWN_NATION = new SpaceNation("", "");

    static List<SpaceNation> nations = Arrays.asList(new SpaceNation("US", "us"), new SpaceNation("RU", "Russia"));

    static SpaceNation getByCode(String code) {
        for (SpaceNation nation :  nations) {
            if (nation.getCode().equals(code)) {
                return nation;
            }
        }
    }

    return UNKNOWN_NATION;
}
  • IllegalArgumentException이나 NoSuchElementException과 같은 예외를 던지는 방법도 있다. 예외를 통해 문제가 있다고 분명히 밝히는 것이다.
  • 이 경우 호출하는 쪽에서 명시적으로 문제를 처리하도록 해야한다.
  • 여기에서는 널 객체 패턴으로 null을 반환하는 대신 널 객체로 즉 객체에 실질적인 값이 없음을 명시적으로 표현하는 객체를 반환하는 방식이다.
String us = SpaceNations.getByCode("US").getName(); // us

String anguilla = SpaceNations.getByCode("AI").getName(); //""
  • UNKNOWN_NATION이 나올 경우, 어떻게 대응할지 여전히 호출하는 쪽에 달려있다. 다만 값을 무시하든 예외를 던지든 선택의 여지가 생겼다는 점이 다르다.
  • 널 객체는 빈 문자열, 빈 컬렉션, 또는 특수 클래스 인스턴스 등 다양한 형태로 표현된다. 하지만 어떤 형태를 띠든 공통 목표는 "비용이 막대한 한 실수"가 일어나지 않게 하는 것이다.

요약

  • 누구나 훌륭한 디자인을 할수 있다.
  • 전체를 더 낫게 만드는 세세한 변경들이 모여 좋은 디자인을 만들어내고 그 방법은 클래스의 결함을 찾아내는 것이다.
  • 코드 작성자가 어떤 근거로 디자인 결정을 내렸는지 알아내는데 많은 노력을 기울여야 한다.
  • 코드 디자인을 항상 선택의 연속이다. 각 대안이 가져올 득실을 따지는 것은 오직 개발자의 몫이다.
  • 가독성이나 테스트 가능성, 유지보수성, 성능 등 코드 속성에 영향을 미치는 여러 트레이드 오프를 유념해 사실에 근거한 결정을 내려야 한다.
  • 그중 일반적으로 성능을 최소한 개발 초기에는 가장 신경쓰지 않아도 되는 부분이다. 자바 프로그래밍에서는 일반적으로 장황하지만 이해하기 쉬운 코드를 작성한다.
  • 다만 초기에는 코드 이해도가 성능보다 중요하다는 뜻이다. 코드를 벤치마킹하다가 병목을 발견하면 언제든디 다시 최적화할 수 있다.

 

자바의 코딩의 기술
사이먼 하러, 요르기 레너드, 심누스 디에츠 지음
심지현 옮김

 

반응형

'JAVA > JAVA 기초' 카테고리의 다른 글

병렬 프로그래밍  (0) 2021.08.18
자바 코딩의 기술 - #2 코딩 스타일  (0) 2020.11.26
자바 코딩의 기술 - #1 코드 정리  (0) 2020.11.26
자바 코딩 규약  (0) 2019.11.26
Object의 clone() 복사  (0) 2019.03.12