본문 바로가기
Book

[독서 기록] 자바 코딩의 기술, 사이먼 하러, 요르그 레너드, 리누스 디에츠, 심지현, 길벗

by Renechoi 2022. 11. 1.
 
자바 코딩의 기술
전문가의 코드와 비교하면서 배운다 코딩 스킬을 개선하는 가장 좋은 방법은 전문가의 코드를 읽는 것이다. 오픈 소스 코드를 읽으면서 이해하면 좋지만, 너무 방대하고 스스로 맥락을 찾는 게 어려울 수 있다. 그럴 땐 이 책처럼 현장에서 자주 발견되는 문제 유형 70가지와 해법을 비교하면서 자신의 코드에서 개선할 점을 찾는 것이 좋다. 적절한 코드를 작성하는 법을 배운다 너무 과하지 않으면서 간결하게, 군더더기 없이, 딱 필요한 만큼만 있는 코드를 작성하는 방법을 배워보자. BufferedReader를 br로 선언하는가? buffered로 선언하는가? 왜 reader로 선언해야 한다고 해법을 제시할까? 이 책은 변수명을 어떻게 짓는가, 주석은 어떻게 써야 하는가부터 시작해서 단계별로 주제를 심화해가면서 적절한 코드란 무엇인지 알려준다. 훌륭한 코드란 기능을 구현하는 것 이상이다 별거 아닌 것 같은 디테일 하나가 코드를 더 멋지게 만든다. 가독성이 좋아지고, 유지보수성이 좋아지고, 변경에 유연하고, 강건한 코드, 더 빠른 코드를 작성할 수 있게 해준다. 작지만 중요한 디테일을 꼼꼼하게 지적하고, 더 나은 프로그래머가 되는 여정을 보여줄 것이다. [책 속의 문구] 명심하세요! 바보는 도구를 사용해도 바보입니다! [예제 코드] ㆍ 길벗 깃허브 저장소: https://github.com/gilbutITbook/007025 ㆍ 원서 소스 코드: https://pragprog.com/titles/javacomp/source_code
저자
사이먼 하러, 요르그 레너드, 리누스 디에츠
출판
길벗
출판일
2020.07.30

 

 

 

바보도 컴퓨터가 이해하는 코드는 작성할 수 있다. 훌륭한 프로그래머는 인간이 이해하는 코드를 작성한다. - 마틴 파울러 

- 31 

 

 

 

메서드 안에 '반환'문이 하나인 것과 여러 개인 것 중 무엇이 더 나은지 논하는 데 기나긴 개발 시간을 쏟아 왔습니다(라고 쓰고 낭비해왔다고 읽읍시다). 궁극적으로 옳거나 그른 것은 없습니다. 반환문이 나오면 메서드는 무조건 종료됩니다. 종료 지점이 하나이면 어디서 끝날지 항상 알고 있으니 제어 흐름이 더 구조적입니다. 하지만 입력 매개변수가 유효하지 않는 등 메서드를 일찍 종료하고 싶을 수 있습니다. 이럴 때는 코드가 더 적게 드는 다중 반환문을 사용합니다. 

- 35 

 

 

경험에 비추어보면 부정적 메서드는 모두 제거하는 것이 가장 좋습니다. 비슷한 메서드 두 개는 굳이 유지하지 않아도 됩니다.

- 38 

 

 

 

package org.example;

public class Main {

   String name;
   int missions;

   boolean isValid() {
      if (missions < 0 || name == null || name.trim().isEmpty()) {
         return false;
      } else {
         return true;
      }
   }


}

 

 

불을 반환할 때는 전체 항목을 if문으로 감쌀 필요 없이 아래처럼 값을 바로 반환할 수 있습니다. 

 

package org.example;

public class Main {

   String name;
   int missions;

   boolean isValid() {
      return missions >=0 && name != null && !name.trim().isEmpty();
   }


}

 

- 39 

 

 

 

 

package org.example;

public class Main {

   Crew crew;
   FuelTank fuelTank;
   Hull hull;
   Navigater navigater;
   OxygenTank oxygenTank;
   
   boolean willCrewSurvive(){
      return hull.holes == 0 &&
            fuelTank.fuel >= navigator.requiredFuelToEarth() &&
            oxygenTank.lastfor(crew.size) > navigater.timeToEarth();
   }
   
}

 

한 메서드 안에서는 추상화 수준이 비슷하도록 명령문을 합쳐야 합니다. 더 높은 수준의 메서드가 다음으로 낮은 수준의 메서드를 호출하는 것이 이상적이죠. 

class SpaceShip {

    Crew crew;
    FuelTank fuelTank;
    Hull hull;
    Navigator navigator;
    OxygenTank oxygenTank;

    boolean willCrewSurvive() {
        boolean hasEnoughResources = hasEnoughFuel() && hasEnoughOxygen();
        return hull.isIntact() && hasEnoughResources;
    }

    private boolean hasEnoughOxygen() {
        return oxygenTank.lastsFor(crew.size) > navigator.timeToEarth();
    }

    private boolean hasEnoughFuel() {
        return fuelTank.fuel >= navigator.requiredFuelToEarth();
    }
}

 

먼저 불 변수를 추가해 소모성 자원이라는 주제에 맞게 유사한 특징을 한데 묶었습니다. hasEnoughResources라는 의미 있는 이름도 지었습니다. 변수는 hasEnoughOxygen()과 hasEnoughFuel() 두 메서드 두 개를 호출해 결과를 가져옵니다. 

 

...

 

코드 행은 늘었지만 코드 이해도는 훨씬 향상되었습니다. 이제 커다란 조건문을 한 번에 이해하지 않아도 됩니다. 의미 있게 묶은 덕분에 단계별로 이해할 수 있게 되었습니다. 그뿐만 아니라 변수명과 메서드명으로 원하는 결과를 표현합니다. 각 메서드도 간단해 한 눈에 파악하기 좋습니다. 

 

- 42 (불 표현식 간소화) 

 

 

 

class Logbook {

    void writeMessage(String message, Path location) throws IOException {
        if (Files.isDirectory(location)) {
            throw new IllegalArgumentException("The path is invalid!");
        }
        if (message.trim().equals("") || message == null) {
            throw new IllegalArgumentException("The message is invalid!");
        }
        String entry = LocalDate.now() + ": " + message;
        Files.write(location, Collections.singletonList(entry),
                StandardCharsets.UTF_8, StandardOpenOption.CREATE,
                StandardOpenOption.APPEND);
    }
}

 

위 메서드는 null 참조를 올바르게 확인하지 않습니다. location이 null이면 Files.isDirectory()는 별다른 설명 없이 NullpointerException과 함께 실패합니다. message가 null이면 message.equals("")를 먼저 확인하니 두 번째 조건문에서도 마찬가지입니다. 

class Logbook {

    void writeMessage(String message, Path location) throws IOException {
        if (message == null || message.trim().isEmpty()) {
            throw new IllegalArgumentException("The message is invalid!");
        }
        if (location == null || Files.isDirectory(location)) {
            throw new IllegalArgumentException("The path is invalid!");
        }

        String entry = LocalDate.now() + ": " + message;
        Files.write(location, Collections.singletonList(entry),
                StandardCharsets.UTF_8, StandardOpenOption.CREATE,
                StandardOpenOption.APPEND);
    }
}

 

먼저 모든 인수에 대해 null 값 여부를 확인합니다. 이어서 도메인에 따른 제한을 확인합니다.

 

또한 메서드 서명 내 인수 순서에 따라 확인하도록 바구었습니다. 매개변수 유효성 검사를 적절한 순서로 수행함으로써 읽기 흐름이 크게 향상되었으니 좋은 관례입니다. 이렇게 하면 어떤 매개변수 하나를 빠뜨릴 위험이 거의 없습니다. 끝으로 내장 메서드를 사용해 빈 문자열인지 확인했습니다. 

 

- 43 ~ 45p (조건문에서 NullPointerException 피하기) 

 

 

 

 

코드의 이해도가 얼마나 중요한지 이미 몇 번 강조했습니다. 조건 분기를 대칭적 방법으로 구조화하면 코드를 쉽게 이해하고 파악할 수 있습니다. 나중에 누가 그 코드를 관리하든 기능을 더 빨리 찾고 더 쉽게 버그를 찾아낼 수 있죠. 

class BoardComputer {

    CruiseControl cruiseControl;

    void authorize(User user) {
        Objects.requireNonNull(user);
        if (user.isUnknown()) {
            cruiseControl.logUnauthorizedAccessAttempt();
            return;
        }

        if (user.isAstronaut()) {
            cruiseControl.grantAccess(user);
        } else if (user.isCommander()) {
            cruiseControl.grantAccess(user);
            cruiseControl.grantAdminAccess(user);
        }
    }
}

 

- 51 ~ 52p 

 

 

프로그래밍도 롤플레잉 게임과 비슷해 게임만큼 중독성이 있습니다. 실제이므로 캐릭터를 제어하는 것이 아니라 당신이 바로 그 게임의 주인공이죠. 디지털 세상을 돌아다니며 경험치를 모읍니다. 매우 다양한 언어 패러다임으로 미지의 프로그래밍 언어 세계를 발견하죠. 괄호를 새 줄에 넣을지 말지 등 가장 복잡한 스타일 측면에서 서로 다른 관점을 지닌 동료 프로그래머도 만납니다. 

 

- 56p 

 

 

class CruiseControl {

    private double targetSpeedKmh;

    void setPreset(int speedPreset) {
        if (speedPreset == 2) {
            setTargetSpeedKmh(16944);
        } else if (speedPreset == 1) {
            setTargetSpeedKmh(7667);
        } else if (speedPreset == 0) {
            setTargetSpeedKmh(0);
        }
    }

    void setTargetSpeedKmh(double speed) {
        targetSpeedKmh = speed;
    }
}

프로그래머는 코드에서 옵션 집합을 표현할 때 종종 숫자 집합을 사용합니다. 특별한 맥락없이 이 숫자를 매직 넘버, 즉 표면상 의미가 없는 숫자이지만 프로그램의 동작을 제어합니다. 매직 넘버가 있으면 코드를 이해하기 어려워지고 오류가 발생하기도 쉽습니다. 

 

package general.favor_enums_over_integer_constants.problem;

class CruiseControl {
    static final int STOP_PRESET = 0;
    static final int PLANETARY_SPEED_PRESET = 1;
    static final int CRUISE_SPEED_PRESET = 2;

    static final double STOP_SPEED_KMH = 0;
    static final double PLANETARY_SPEED_KMH = 7667;
    static final double CRUISE_SPEED_KMH = 16944;

    private double targetSpeedKmh;

    void setPreset(int speedPreset) {
        if (speedPreset == CRUISE_SPEED_PRESET) {
            setTargetSpeedKmh(CRUISE_SPEED_KMH);
        } else if (speedPreset == PLANETARY_SPEED_PRESET) {
            setTargetSpeedKmh(PLANETARY_SPEED_KMH);
        } else if (speedPreset == STOP_PRESET) {
            setTargetSpeedKmh(STOP_SPEED_KMH);
        }
    }

    void setTargetSpeedKmh(double speedKmh) {
        targetSpeedKmh = speedKmh;
    }
}

class Main {
    static final int PLANETARY_SPEED_PRESET = 1;

    static void usage() {
        CruiseControl cruiseControl = null;
        cruiseControl.setPreset(PLANETARY_SPEED_PRESET);
        cruiseControl.setPreset(2);
        cruiseControl.setPreset(-1); // targetSpeed not affected
    }
}

 

매직 부분을 없앴습니다. 각 숫자마다 유의미하고 이해하기 쉬운 이름을 달아서요. 

 

코드에 사용 가능한 사전 설정 옵션과 타깃 속도를 나타내는 변수를 추가했습니다. 이러한 변수는 static이고 final입니다. 즉 상수입니다. 두 한정어는 변수를 딱 한 번만 존재하게(static) 하고 변경될 수 없게(final) 강제합니다. 자바 코드 규칙에 따라 상수명은 모두 대문자로 썼습니다. 

 

- 57 ~ 59p (매직 넘버를 상수로 대체) 

 

 

 

 

package general.favor_enums_over_integer_constants.solution;

import java.util.Objects;

class CruiseControl {

    private double targetSpeedKmh;

    void setPreset(SpeedPreset speedPreset) {
        Objects.requireNonNull(speedPreset);

        setTargetSpeedKmh(speedPreset.speedKmh);
    }

    void setTargetSpeedKmh(double speedKmh) {
        targetSpeedKmh = speedKmh;
    }
}
enum SpeedPreset {
    STOP(0), PLANETARY_SPEED(7667), CRUISE_SPEED(16944);

    final double speedKmh;

    SpeedPreset(double speedKmh) {
        this.speedKmh = speedKmh;
    }
}

class Main {
    static void usage() {
        CruiseControl cruiseControl = null;
        cruiseControl.setPreset(SpeedPreset.PLANETARY_SPEED);
        cruiseControl.setPreset(SpeedPreset.CRUISE_SPEED);
    }
}

 

SpeedPreset이라는 새 enum을 생성하고 여기에 주어진 인스턴스의 speedkmh를 저장하는 변수 하나를 넣었습니다. SpeedPreset enum에 STOP과 PLANETARY_SPEED,CRUISE_SPEED라는 가능한 옵션을 모두 열거합니다.

 

- 59 ~ 61 (정수 상수 대신 열거형) 

 

 

class Inventory {

    private List<Supply> supplies = new ArrayList<>();

    void disposeContaminatedSupplies() {
        for (Supply supply : supplies) {
            if (supply.isContaminated()) {
                supplies.remove(supply);
            }
        }
    }
}

 

코드는 무결해보이지만 재고 목록 내 한 supply라도 오염되었을 경우 무조건 충돌합니다. 더 큰 문제는 이 문제가 처음 발생하기 전까지는 코드가 잘 동작한다는 점입니다. ... 

 

 이렇게 실행하면 List 인터페이스의 표준 구현이나 Set이나 Queue와 같은 Collection 인터페이스의 구현은 ConcurrentModificationException을 던집니다. ... 다름 아니라 Collection을 순회하는 동안 그 컬렉션을 수정한다는 뜻입니다. 

class Inventory {

    private List<Supply> supplies = new ArrayList<>();

    void disposeContaminatedSupplies() {
        Iterator<Supply> iterator = supplies.iterator();
        while (iterator.hasNext()) {
            if (iterator.next().isContaminated()) {
                iterator.remove();
            }
        }
    }
}

 

문제를 해결할 직관적인 방법은 리스트를 순회하며 변질된 제품을 찾고 그 후 앞에서 밝녀했던 제품을 모두 제거하는 것입니다. 먼저 순회하고 나중에 수정하는 두 단계 접근법이죠.

 

... 

 

위 해법에서는 supplies 컬렉션의 Iterator를 활용하는 while 루프라는 새로운 순회 방식을 사용합니다. 핵심은 Iterator입니다. Iterator는 첫 번째 원수부터 시작해 리스트 내 원소를 가리키는 포인터처럼 동작합니다. hasNext()를 통해 원소가 남아 있는지 묻고 next()로 다음 원소를 얻고 반환된 마지막 원소를 remove()로 안전하게 제거합니다. 

 

List를 직접 수정할 수는 없지만 iterator가 이것을 완벽히 대신합니다. iterator는 순회 중에도 모든 작업을 올바르게 수행합니다. 

 

- 64 ~ 66p (순회하며 컬렉션 수정하지 않기) 

 

 

 

 

경험에 비추어보면 연관된 코드와 개념은 함께 그루핑하고 서로 다른 그룹은 빈 줄로 각각 분리해야 합니다. 

- 71p

 

 

 

import java.util.ArrayList;
import java.util.List;

import general.Supply;

class Inventory {

    private List<Supply> supplies = new ArrayList<>();

    int getQuantity(Supply supply) {
        if (supply == null) {
            throw new NullPointerException("supply must not be null");
        }

        int quantity = 0;
        for (Supply supplyInStock : supplies) {
            if (supply.equals(supplyInStock)) {
                quantity++;
            }
        }

        return quantity;

    }
}

 

API에 있는 기능을 다시 구현하지 말고 가능하면 재사용해야 합니다. 

 

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;

import general.Supply;

class Inventory {

    private List<Supply> supplies = new ArrayList<>();

    int getQuantity(Supply supply) {
        Objects.requireNonNull(supply, "supply must not be null");

        return Collections.frequency(supplies, supply);
    }
}

 

같은 결과를 생성하되 직접 작성한 코드 대신 자바 API 기능을 사용했습니다. 

 

- 74 ~ 76p (직접 만들지 말고 자바 API 사용하기) 

 

 

 

 

훌륭한 코드는 그 자체로 최고의 설명서다. 주석을 추가하기 전에 "주석이 필요없도록 코드를 향상시킬 방법이 없을까?"라고 자문해보자. - 스티브 맥코넬 

- 79p

 

 

 

class Inventory {
    
    private List<Supply> list = new ArrayList<>();

    void add(Supply supply) {
        list.add(supply);
        Collections.sort(list);
    }

    boolean isInStock(String name) {
        // fast implementation
        return Collections.binarySearch(list, new Supply(name)) != -1;
    }
}

 

프로그래머는 binarySearch를 쓰기로 결정했지만 이 방법에 네라고 대답함으로써 다른 많은 방법에 아니오라고 대답한 셈입니다. 주석이 이 선택의 근거를 타당하게 설명하고 있나요? ... 딱 보아도 이 질문들에 대한 답은 없네요. 그러면 어떻게 향상시킬 수 있을까요? 

 

class Inventory {
    // Keep this list sorted. See isInStock().
    private List<Supply> list = new ArrayList<>();

    void add(Supply supply) {
        list.add(supply);
        Collections.sort(list);
    }

    boolean isInStock(String name) {
        /*
         * In the context of checking availability of supplies by name,
         * facing severe performance issues with >1000 supplies
         * we decided to use the binary search algorithm
         * to achieve item retrieval within 1 second,
         * accepting that we must keep the supplies sorted.
         */
        return Collections.binarySearch(list, new Supply(name)) != -1;
    }
}

 

 

- 90 ~ 92p (구현 결정 설명하기) 

 

 

In the context of [USE CASE],

facing [CONCERN],

we decided for [OPTION],

to acheive [QUALITY],

accepting [DOWNSIDE] 

 

이러한 템플릿을 사용하면 주요 측면을 빠뜨리는 경우가 거의 없습니다. 

 

- 92p

 

 

한 글자로 명명하지 않기 

- 114p 

 

 

축약 쓰지 않기

- 117p

 

 

 

간결한 명명이 항상 미덕인 것은 아닙니다. 장점이라곤 입력이 쉽다는 것 밖에 없는, 수수께기 같은 축약어 대신 길어도 서술적인 이름이 낫습니다. 

- 121p 

 

 

 

개발중인 대부분의 코드는 특정 도메인에 속하고 도메인마다 각기 어휘가 있습니다. 스포츠에 비유하면 각양각색의 공을 놓고 힘차게 내던지거나(hurling) 던지거나(thrwoing) 차거나(kicking) 구부리거나(bending) 밀어 넣는(dunking) 등 종목에 따라 도메인별로 명칭이 다릅니다. 프로그램에 해당하는 도메인 용어를 코드에 많이 넣을수록 코드는 점점 나아집니다.

-123p 

 

 

 

class CruiseControl {
    static final double SPEED_OF_LIGHT_KMH = 1079252850;
    static final double SPEED_LIMIT = SPEED_OF_LIGHT_KMH;

    private double targetSpeedKmh;

    void setTargetSpeedKmh(double speedKmh) {
        if (speedKmh < 0) {
            throw new IllegalArgumentException();
        } else if (speedKmh <= SPEED_LIMIT) {
            targetSpeedKmh = speedKmh;
        } else {
            throw new IllegalArgumentException();
        }
    }
}

 

 

분기를 순서대로 읽다 보면 마지막 분기가 가장 큰 골칫거리입니다. 첫 분기는 검증, 두 번째는 일반적인 경로인데요. 이 시점에서 사용자는 메서드가 어떻게 동작하고 어던 매개변수를 허용하는지 모두 안다고 생각하죠. 하지만 갑자기 새로 검증하는 마지막 분기가 나옵니다. 머릿속에서 생각을 뒤로 돌려 매개변수 검증을 다시 이해해야 하죠. 너무나 불필요한 정신적 에너지 낭비입니다. 

 

코드를 이해할 때 불필요한 요소가 또 있습니다. 조건 분기가 서로 연결되어 있다보니 모든 조건을 함께 이해해야 합니다. 첫 번째 분기 조건인 speedKmh <= 0은 쉽지만 나머지 두 분기에서 점점 복잡해집니다. 

 

class CruiseControl {
    static final double SPEED_OF_LIGHT_KMH = 1079252850;
    static final double SPEED_LIMIT = SPEED_OF_LIGHT_KMH;

    private double targetSpeedKmh;

    void setTargetSpeedKmh(double speedKmh) {
        if (speedKmh < 0 || speedKmh > SPEED_LIMIT) {
            throw new IllegalArgumentException();
        }

        targetSpeedKmh = speedKmh;
    }
}

 

매개변수 검증과 일반적인 경로를 분리하고 나머지 두 조건을 합쳐 메서드 상단에 두었습니다. 조건 중 하나가 참이면 메서드를 바로 반환하고 IllegarArgumentException을 던집니다. 다시 말해 메서드가 빠르게 실패합니다. 

 

... 

 

게다가 위 코드에는 빈 줄을 사용해 두 부분으로 나뉜 구조가 바로 보입니다. 첫 번째는 매개변수 검증이고 그 다음은 일반적인 메서드 경로입니다. 즉 메서드에서 가장 중요한 부분인 일반적인 경로로 바로 넘어갈 수 있고 전체 검증 로직을 미리 읽지 않아도 됩니다. 

 

- 129 ~ 131p (빠른 실패) 

 

 

항상 구체적인 예외 잡기 

- 131p

 

 

메시지로 원인 설명

- 134p 

 

 

 

class Network {

    ObjectInputStream inputStream;
    InterCom interCom;

    void listen() throws IOException, ClassNotFoundException {
        while (true) {
            Object signal = inputStream.readObject();
            CrewMessage crewMessage = (CrewMessage) signal;
            interCom.broadcast(crewMessage);
        }
    }
}

 

위 코드는 InputStream의 readObject() 메서드를 호출해 메시지를 읽는 클래스를 보여줍니다. ...

 

예제에서는 (CrewMessage)로 변환했습니다. 무사히 컴파일되고 스트림 내 객체 타입과 일치하면 순조롭게 실행됩니다. 하지만 스트림이 다른 타입으로 반환되면 잔치는 끝납니다. 메서드는 스트림에 실제로 어떤 타입이 들어올지 제어할 수 없습니다. 스트림 반대편에 있는 누군가가 다른 메시지 타입을 삽입하면 예를 들어 ClassifiedMessage를 삽입하면 어떻게 될까요? 어떤 이유로든 언젠가는 분명히 Crewmessage가 아닌 객체가 들어올 겁니다. 그러면 메서드를 비롯해 아마도 프로그램 전체가 ClassCaseException을 일으키며 충돌하겠죠. 

 

class Network {

    ObjectInputStream inputStream;
    InterCom interCom;

    void listen() throws IOException, ClassNotFoundException {
        while (true) {
            Object signal = inputStream.readObject();
            if (signal instanceof CrewMessage) {
                CrewMessage crewMessage = (CrewMessage) signal;
                interCom.broadcast(crewMessage);
            }
        }
    }
}

 

먼저 스트림으로부터 읽은 결과를 지역 번수인 signal에 저장합니다. 변수 타입이 Object이므로 타입 때문에 실패할 일은 없습니다. 비 원시 자바 타입은 모두 Object로부터 상속받으니까요.

 

다음으로 instanceof 연산자로 타입을 검증하고 signal을 읽습니다. 이 연산자는 signal을 CrewMessage 타입으로 변환할 수 있으면 true를, 그렇지 않으면 false를 반환합니다. 결국 참일 때만 명시적으로 변환할 수 있죠. 따라서 ClassCastException이 절대로 일어나지 않습니다. 

 

- 142 ~ 144p (타입 변환 전에 항상 타입 검증하기)

 

 

 

일반적으로 테스트는 given, when, then이라는 세 개의 핵심 부분으로 구성됩니다. 모범 테스트 명세를 예로 들어 보겠습니다.

 

- 2가 찍혀 있는 계산기가 주어졌을 때(given)

- 숫자 3을 더할 경우(when)

- 그러면(then) 숫자 5가 나타나야 한다. 

 

class Other {

    void otherTest() {
        // given
        CruiseControl cruiseControl = new CruiseControl();

        // when
        cruiseControl.setPreset(SpeedPreset.PLANETARY_SPEED);

        // then
        Assertions.assertTrue(7667 == cruiseControl.getTargetSpeedKmh());
    }
}

 

 

- 157 ~ 159p

 

 

 

JUnit에는 assertEquals()와 assertTrue() 외에도 훨씬 많습니다. 당연히 assertFalse(), assertNotEquals(), assertSame()도 있죠. assertArrayEqauls()와 assertLinesMatch(), assertIterableEquals()로 배열이나 자료 구조에 같은 내용이 들어 있는지도 비교할 수 있습니다. assertAll()로 여러 어서션을 함께 묶을 수도 있고 assertTimeout()으로 실행 시간이 충분히 짧았는지까지 검증할 수 있습니다. 중요한 것은 더 나은 메시지를 얻으려면 검증하려는 테스트에 가장 적합한 어서션을 선택해야 한다는 점입니다. 

 

- 161p 

 

 

 

코드에서 화폐 가치를 다루어야 할 때는 당연히 float이나 double을 쓰고 싶을 것이고 대부분의 초보자가 딱 이렇게 합니다. 어쨌든 초콜릿 바 하나에 1.99달러인 것처럼 일상적으로 돈을 부동소수점 형태로 사용하니까요. 부동소수점 값이 단지 자연스러운 표현처럼 보입니다. 하지만 절대로 이렇게 하지 마세요!

 

머지 않아 끝자릿수 오차로 인해 프로그램이 잘못된 계산 결과를 낼 테니까요. 돈에 대해서는 미세한 차이도 금방 눈에 띕니다. 다행히 해결책은 간단합니다. 대부분의 은행이나 보험사의 자바 기반 시스템에서 쓰는 방법처럼 달러를 double 값에 저장하지 않는 대신 센트를 long 변수에 저장하거나 BigDecimal을 사용하세요. 

 

- 166p 

 

 

 

 

JUnit에는 예외를 처리하는 내장 메커니즘이 있습니다. 

class LogbookTest {

    @Test
    void readLogbook() throws IOException {
        Logbook logbook = new Logbook();

        List<String> entries = logbook.readAllEntries();

        Assertions.assertEquals(13, entries.size());
    }

    @Test
    void readLogbookFail() {
        Logbook logbook = new Logbook();

        Executable when = () -> logbook.readAllEntries();

        Assertions.assertThrows(IOException.class, when);
    }
}

 

첫 번째 테스트는 가장 기초적인 테스트라고 할 수 있습니다. 모든 JUnit 테스트는 어떤 예외도 발생하지 않는다는 암묵적인 어서션을 포함합니다. 그러니 명시적으로 추가하지말고 그냥 JUnit이 알아서 하게 놔두면 됩니다. ... 예외가 발생할 경우, JUnit은 실패에 대한 전체 스택 추적을 표시합니다. 

 

두 번째 테스트에서는 assertThrows()를 사용했습니다. assertThrows 어서션은 테스트에서 어떤 종류의 예외가 던져지길 바라는지를 명시적으로 나타냅니다. 보다시피 try-catch 블록과 fail() 호출이 사라졌습니다. 

 

- 168 ~ 169p (예외 처리는 JUnit에 맡기기) 

 

 

 

 

 

비결은 테스트와 설정 코드를 더 분명히 연결 짓는 것입니다. 

 

class OxygenTankTest {
    static OxygenTank createHalfFilledTank() {
        OxygenTank tank = OxygenTank.withCapacity(10_000);
        tank.fill(5_000);
        return tank;
    }

    @Test
    void depressurizingEmptiesTank() {
        OxygenTank tank = createHalfFilledTank();

        tank.depressurize();

        Assertions.assertTrue(tank.isEmpty());
    }

    @Test
    void completelyFillTankMustBeFull() {
        OxygenTank tank = createHalfFilledTank();

        tank.fillUp();

        Assertions.assertTrue(tank.isFull());
    }
}

 

근본적으로 테스트를 독립적으로 만들었습니다. given, when, then 부분을 하나의 테스트 메서드 안에서 바로 연결해야 독립적인 테스트가 됩니다. 

 

... 

 

@BeforeEach와 @BeforeAll 표기가 아무리 흔히 쓰이는 용법이더라도 가능하면 쓰지 마세요. 두 표기가 만들어내는 암묵적 종속성을 프레임워크는 쉽게 처리할 수 있지만 프로그래머로서는 코드를 읽기 힘들어집니다. 테스트가 독립적이고 테스트 메서드만 보아도 코드 스크롤 없이 전체 테스트를 이해할 수 있다면 더 훌륭한 테스트입니다. 

 

- 173 - 175p (독립형 테스트 사용하기) 

 

 

 

 

 

class DistanceConversionTest {

    @ParameterizedTest(name = "#{index}: {0}km == {0}km->mi->km")
    @ValueSource(ints = {1, 1_000, 9_999_999})
    void testConversionRoundTrip(int kilometers) {
        Distance expectedDistance = new Distance(
                DistanceUnit.KILOMETERS,
                kilometers
        );

        Distance actualDistance = expectedDistance
                .convertTo(DistanceUnit.MILES)
                .convertTo(DistanceUnit.KILOMETERS);

        Assertions.assertEquals(expectedDistance, actualDistance);
    }
}

 

위 방법은 @ParameterizedTest와 @ValueSource 표기로 테스트를 매개변수화합니다. 이렇게 하면 매개변수(예를 들어 입력과 기대 출력)를 실제 테스트 코드와 분리시킬 수 있습니다. 실행해보면 JUnit은 각 매개변수마다 별개의 테스트를 실행합니다. 

 

- 175 - 177p (테스트 매개변수화)

 

 

 

경계 케이스 다루기

- 178p 

 

 

 

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);
    }
}

class Main {
    static void usage() throws IOException {
        Logbook logbook = new Logbook();
        logbook.log("Aliens sighted!", true);
        logbook.log("Toilet broken.", false);
    }
}

 

불 메서드 매개변수는 메서드가 적어도 두 가지 작업을 수행함을 뜻합니다. 

 

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);
    }
}

class Main {
    static void usage() throws IOException {
        Logbook logbook = new Logbook();
        logbook.writeToCaptainLog("Aliens sighted!");
        logbook.writeToCrewLog("Toilet broken. Again...");
    }
}

 

입력 매개변수에 boolean이 쓰인 메서드라면 메서드를 여러 개로 분리함으로써 코드가 향상될 가능성이 높습니다. 

 

방법은 boolean인 메서드 매개변수를 제거하고 이 매개변수로 구분하던 각 제어 흐름 경로마다 새 메서드를 추가하는 것입니다. 더 나아가 새 메서드에 표현적이면서도 의미 있는 이름을 지어주어 코드 가독성까지 높일 수 있습니다!

 

- 185 - 188p (불 매개변수로 메서드 분할)

 

 

 

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;
    }
}

class Main {
    static void usage() throws IOException {
        Logbook logbook = new Logbook();
        List<String> completeLog = logbook.readEntries(null);

        final LocalDate moonLanding = LocalDate.of(1969, Month.JULY, 20);
        List<String> moonLandingLog = logbook.readEntries(moonLanding);
    }
}

 

null을 쓸 수 있다는 것은 본질적으로 date 매개변수가 선택사항이라는 뜻입니다. boolean 메서드 매개변수를 다른 형태로 바꾸었을 뿐이죠. 

 

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);
    }
}

class Main {
    static void usage() throws IOException {
        Logbook logbook = new Logbook();
        List<String> completeLog = logbook.readAllEntries();

        final LocalDate moonLanding = LocalDate.of(1969, Month.JULY, 20);
        List<String> moonLandingLog = logbook.readEntries(moonLanding);
    }
}

 

위 코드의 readEntries() 메서드는 date를 선택 매개변수로 더 이상 허용하지 않는 대신 NullPointerException을 던집니다. 선택 매개변수라는 의미는 이제 매개변수가 필요 없는 readAllEntries() 메서드에 들어 있습니다. 이렇게 바꾸었더니 사용하기도 더 쉽습니다. 

 

 

- 188 ~ 189p (옵션 매개변수로 메서드 분할) 

 

 

 

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;
    }
}

class Usage {
    static void main(String[] args) {
        CargoShip cargoShip = null;
        Inventory inventory = null;
        Stack<Supply> delivery = cargoShip.unload();
        ArrayList<Supply> loadableDelivery = new ArrayList<>(delivery);
        inventory.stockUp(loadableDelivery);
    }
}

 

자바 API만 보아도 알 수 있듯이 인터페이스와 클래스는 흔히 광범위한 타입 계층 구조를 형성합니다. 변수에 더 추상적인 타입을 사용할수록 코드는 더 유연해지죠.

 

위 코드는 Inventory 시스템의 일부입니다. getContaminatedSupplies() 메서드는 stockUp() 메서드를 통해 생성한 supply 객체들의 LinkedList를 순회합니다. 

 

...

 

위 예제에서는 Stack을 사용해 제품을 후입선출(LIFO) 순으로 전달합니다. 불행히도 Inventory에 제품을 채우려면 ArrayList가 필요합니다. 그래서 제품을 ArrayList로 옮겨야 합니다.

 

Inventory에 ArrayList를 넣으면 stockUp() 메서드가 제품을 내부 LinkedList로 옮깁니다. 이후 최종적으로 getContaminatedSupplies()가 LinkedList에서 변질된 제품을 골라냅니다.

 

보다시피 여러 자료 구조 타입간 변환이 많은데 그중 대부분은 실제로 불필요합니다. 게다가 Inventory를 변경할 경우, 다른 부분에 쉽게 영향을 미칩니다. 

 

추상 타입을 사용하면 이러한 문제를 해결할 수 있습니다. 

 

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.isContaminated()) {
                contaminatedSupplies.add(supply);
            }
        }
        return contaminatedSupplies;
    }
}
class Usage {
    static void main(String[] args) {
        CargoShip cargoShip = null;
        Inventory inventory = null;
        Stack<Supply> delivery = cargoShip.unload();
        inventory.stockUp(delivery);
    }
}

 

세 가지 측면에서 앞의 코드와 다릅니다.

 

첫째, supplies 필드에 LinkesList 대신 List 인터페이스 타입을 사용합니다. 이로써 제품은 순서대로 (ArrayList 배열에 또는 LinkedList의 링크드 래퍼 객체를 통해) 저장되지만 어떻게 저장되는지는 알 수 없습니다. 

 

둘째, stockUp() 메서드가 어떤 Collection이든 허용합니다. Collection은 자바에서 자료 구조에 객체를 저장하는 가장 기본적인 인터페이스입니다. 다시 말해 Collection의 어떤 하위 타입이든, 즉 자바의 어떤 복잡한 자료 구조이든 이 메서드로 전달할 수 있다는 뜻이죠. 

 

셋째, getContaminatedSupplies() 메서드가 더 구체적인 타입이 아닌 List를 반환합니다. 제품은 반드시 정렬된 상태로 반환되지만 내부적으로 리스트를 어떻게 구현했는지는 알려지지 않습니다. 이로써 코드가 더 유연해집니다. 

 

- 190 ~ 192p (구체 타입보다 추상 타입)

 

 

 

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);
    }
}

class Main {
    static void usage() {
        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);
    }
}

 

위 코드에서 final 키워드를 어떻게 사용했는지 모세요. 생성자의 value와 unit 필드에 final 키워드를 설정했기 때문에 이후로는 바꿀 수 없습니다. 거리를 계산하려면 매번 새로운 인스턴스가 필요합니다. 

 

...

 

소프트웨어 디자인 관점에서 이 방법은 소위 값 객체(Value Object)를 처리하는 방법으로서 여기에는 백분율, 돈, 통화, 시간, 날짜, 좌표, 당연히 거리도 포함됩니다. 이러한 객체는 값이 서로 같으면 구분하기 어렵습니다. 서로 다른 객체가 각각 10$를 표현하고 있더라도 $10은 $10죠. 그러니 값 객체에 항상 주의하고 불변으로 만들어야 합니다. 

 

- 194 ~ 195p (가변 상태보다 불변 상태 사용하기)

 

 

 

class Inventory {

    private final List<Supply> supplies;

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

    List<Supply> getSupplies() {
        return supplies;
    }
}

 

 

위 코드에서 예로든 Inventory는 자료 구조를 포함하는 매우 일반적인 클래스입니다. ... 클래스 자체로는 문제가 없지만 사용 예를 한 번 살펴봅시다. 

 

 

 

class Usage {

	static void main(String[] args) {
		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<>()로 생성한 리스트 하나뿐이기 때문입니다. 

 

...

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()로 래핑한 후 노출합니다. 이로써 읽기 접근만 가능하죠. 리스트에 원소를 추가하려면 이러한 기능을 하는 명시적인 메서드를 작성해야 합니다. 

 

...

 

class Usage {

    static void main(String[] args) {
        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"));
    }
}

 

... 

 

이러한 기법을 방어 복사(defensive copying)라고도 부릅니다. 전달된 자료 구조를 재사용하는 대신 복사본을 만들어 제어하니까요.

 

세터와 게터 둘 다 보호해야 한다는 사실을 항상 명심하세요. 물론 애당초 세터를 허용하지 않는 편이 훨씬 편합니다. 

 

- 198 ~ 200p (참조 누수 피하기)

 

 

 

class SpaceNations {

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

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

 

메서드 호출시 저절히 반환할 값이 없으면 그냥 null을 반환하는 프로그래머가 가끔 있습니다. 이것은 프로그램의 안전성을 크게 해칩니다!

 

class SpaceNations {

    /** Null object. */
    static final SpaceNation UNKNOWN_NATION = new SpaceNation("", "");

    static List<SpaceNation> nations = Arrays.asList(
            new SpaceNation("US", "United States"),
            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 object pattern)"을 권했습니다. null을 반환하는 대신 널 객체, 즉 객체에 실질적인 값이 없음을 명시적으로 표현한 객체를 반환하는 방식입니다. 

 

- 201 ~ 203p (널 반환하지 않기) 

 

 

객체 지향 프로그래밍은 동작부를 캡슐화해 코드를 이해하기 쉽게 만든다. 함수형 프로그래밍은 동작부를 최소화해 코드를 이해하기 쉽게 만든다. _ 마이클 페더스 

- 205p 

 

 

 

익명 클래스 대신 람다 사용하기

- 207p 

 

 

 

class Inventory {

    List<Supply> supplies = new ArrayList<>();

    long countDifferentKinds() {
        List<String> names = new ArrayList<>();
        for (Supply supply : supplies) {
            if (supply.isUncontaminated()) {
                String name = supply.getName();
                if (!names.contains(name)) {
                    names.add(name);
                }
            }
        }
        return names.size();
    }

 

 

위 코드에는 매우 흔한 작업을 수행하는 짧지만 무척 복잡한 메서드가 나옵니다. 이 메서드는 컬렉션을 순회하며 몇 가지 조건 연산을 수행합니다. 코드가 짧고 명명이 적절한데도 불구하고 무엇을 하는 코드인지 이해하는 데 시간이 다소 걸립니다. 

 

방식면에서 코드는 명령형입니다. 무엇을 해야 하는지, 루프와 조건, 변수 할당문, 메서드 호출로 어떻게 해야 하는지 컴퓨터에게 명령합니다. 

class Inventory {

    List<Supply> supplies = new ArrayList<>();

    long countDifferentKinds() {
        return supplies.stream()
                       .filter(supply -> supply.isUncontaminated())
                       .map(supply -> supply.getName())
                       .distinct()
                       .count();
    }
}

class Whatever {
    void main() {
        Predicate<Supply> uncontaminated = supply -> supply.isUncontaminated();
        Function<Supply, String> supplyToName = supply -> supply.getName();
    }
}

 

우선 stream() 컬렉션을 살펴봅시다. stream()은 컬렉션을 스트림으로 변환함으로써 함수형 프로그래밍의 세계에 발을 들이는 시작 연산자입니다. 강에서 보트 경주를 펼친다고 가정해보세요. 컬렉션 내 각 원소가 바로 보트입니다. 

 

둘째, filter()로 오염된 supplies를 걸러냈습니다. 필터는 특정 조건을 충족하는 보트만 통과시키는 일종의 관문입니다. 예제에서 필터는 오염되지 않은 제품만 여정을 계속하도록 허용했습니다. 타입 측면에서 필터는 어떤 조건이 true인지 false인지 평가하는 predicate이고 인스턴스의 유일한 추상 메서드 서명은 boolean test(Supply supply)입니다. 

 

셋째, 강에 떠 있는 "보트"를 변환(또는 map) 했습니다. 보트 내 화물을 고친다고 새각하면 됩니다. 예제에서는 Supply의 일부인 이름만 남기고 나머지를 물속에 던져버렸습니다. 자바 용어로 바꾸어 말하면 어떤 Function이 타입을 다른 타입으로 매핑했습니다. 

 

넷째, distinct()로 좀 더 걸러냈습니다. 같은 이름은 딱 한 번만 통과되고 나머지 중복은 모두 버려집니다. 

 

마지막으로 count()로 스트림 내 남은 개수를 셌습니다. count()는 스트림을 끝내고 명령형 방식으로 다시 돌려보내는 종료 연산자입니다. 

 

 

- 209 ~ 212p (명령형 대신 함수형)

 

 

class Inventory {

    List<Supply> supplies = new ArrayList<>();

    long countDifferentKinds() {
        return supplies.stream()
                       .filter(Supply::isUncontaminated)
                       .map(Supply::getName)
                       .distinct()
                       .count();
    }
}

 

앞의 코드에 나왔던 람다 표현식을 메서드 참조로 대체했습니다. 

 

...

 

메서드 참조에는 특수한(그리고 새로운) ClassName::MethodName 형식의 문법을 써야 합니다. 예를 들어 Supply::getName은 Supply 클래스의 getName() 메서드를 참조합니다. 

 

- 212 ~ 214p (람다 대신 메서드 참조)

 

 

 

 

interface Supply {

    String getName();

    boolean isUncontaminated();
}

class Inventory {

    List<Supply> supplies = new ArrayList<>();

    long countDifferentKinds() {
        List<String> names = new ArrayList<>();

        Consumer<String> addToNames = name -> names.add(name);

        supplies.stream()
                .filter(Supply::isUncontaminated)
                .map(Supply::getName)
                .distinct()
                .forEach(addToNames);
        return names.size();
    }
}

 

 

위 코드를 봅시다. 목표를 이루기 위해 부수 효과에 크게 의존하고 있습니다.

 

문제는 스트림의 forEach() 부분에서 호출하는 Consumer addToNames에 있습니다. Consumer는 람다 표현식 밖에 있는 리스트에 워소를 추가합니다. 바로 그때 부수 효과가 발생합니다. 

 

... filter()와 map() 연산자는 스트림 원소에만 작용해 아무 부수 효과도 일으키지 않는 반면, 초보자는 종종 명령형 방식으로 돌아가 스트림을 종료시키려고 하는데 이것은 부수 효과를 통해야만 가능합니다. 

 

class Inventory {

    List<Supply> supplies = new ArrayList<>();

    long countDifferentKinds() {
        List<String> names = supplies.stream()
                                     .filter(Supply::isUncontaminated)
                                     .map(Supply::getName)
                                     .distinct()
                                     .collect(Collectors.toList());
        return names.size();
    }
}

 

주목할 부분은 람다 표현식에서 생성하는 리스트입니다. 리스트를 직접 만들지 않고 컬렉션 내 스트림에 남아 있는 각 원소를 collect()했습니다. 

 

class Inventory2 {

    List<Supply> supplies = new ArrayList<>();

    long countDifferentKinds() {
        return supplies.stream()
                       .filter(Supply::isUncontaminated)
                       .map(Supply::getName)
                       .distinct()
                       .count();
    }
}

 

종료 연산자인 count()가 원하는 정보인 스트림 내 남은 원소 수를 반환합니다. count()는 Stream 클래스의 reduce() 연산자, 즉 reduce(0, (currentResult, streamElement) -> currentResult + 1)의 단축형입니다. reduce() 연산자는 리스트를 하나의 정숫값으로 리듀스합니다. 여기서 0은 초깃값이고 스트림 내 각 원소마다 결과에 1씩 더합니다. 

 

요약하면 스트림을 종료시킬 때 forEach()는 쉽게 부수 효과를 일으키니 가능하면 쓰지 맙시다. 

 

- 214 ~ 217 (부수 효과 피하기) 

 

 

class Inventory {

    List<Supply> supplies = new ArrayList<>();

    Map<String, Long> countDifferentKinds() {
        return supplies.stream()
                       .filter(Supply::isUncontaminated)
                       .collect(Collectors.groupingBy(Supply::getName,
                               Collectors.counting())
                       );
    }
}

 

예제에서는 Supply 객체를 이름별로 그루핑해야 했습니다. 그래서 메서드 참조인 Supply::getName을 전달했죠. 참조를 전달함으로써 결과 Map의 키 타입, 즉 String까지 명시했습니다. 이렇게만 해도 표현식은 Map<String, Cllections<Supply>>와 같은 형태로 반환합니다. 이미 눈치챘을 수도 있겠지만 groupingBy() 덕분에 map 연산자는 더 이상 필요 없습니다. 

 

- 219p (복잡한 스트림 종료 시 컬렉트 사용하기) 

 

 

class Communicator {

    Connection connectionToEarth;

    void establishConnection() {
        // used to set connectionToEarth, but may be unreliable
    }

    Optional<Connection> getConnectionToEarth() {
        return Optional.ofNullable(connectionToEarth);
    }
}

Optional은 있을 수도 있고 없을 수도 있는 객체를 위한 임시 저장소입니다. 객체나 null을 가리킬지도 모를 참조를 넣어 Optional.ofNullable()을 호출해 생성합니다. 예제에서 connectionToEarth는 있을 수도 있고 없을 수도 있습니다. 

 

- 224p (널 대신 옵셔널)

 

 

 

 

 

class BackupJob {

    Communicator communicator;
    Storage storage;

    void backupToEarth() {
        Optional<Connection> connectionOptional =
                communicator.getConnectionToEarth();
        if (!connectionOptional.isPresent()) {
            throw new IllegalStateException();
        }

        Connection connection = connectionOptional.get();
        if (!connection.isFree()) {
            throw new IllegalStateException();
        }

        connection.send(storage.getBackup());
    }
}

 

class BackupJob {

    Communicator communicator;
    Storage storage;

    void backupToEarth() {
        Connection connection = communicator.getConnectionToEarth()
                .filter(Connection::isFree)
                .orElseThrow(IllegalStateException::new);
        connection.send(storage.getBackup());
    }
}

위 코드에서는 filter()를 사용해 연결이 이어져 있고 사용할 수 있는지 확인하고 그렇지 않은 경우, orElseThrow를 사용해 예외를 발생시킵니다. 

 

- 228 ~ 230p (옵셔널을 스트림으로 사용하기)

 

 

 

class LaunchChecklist {

    List<String> checks = Arrays.asList("Cabin Pressure",
                                        "Communication",
                                        "Engine");

    Status prepareAscend(Commander commander) {
        System.out.println("Prepare ascend");
        for (String check : checks) {
            if (commander.isFailing(check)) {
                System.out.println(check + " ... FAILURE");
                System.err.println("Abort take off");
                return Status.ABORT_TAKE_OFF;
            }
            System.out.println(check + " ... OK");
        }
        System.out.println("Read for take off");
        return Status.READY_FOR_TAKE_OFF;
    }
}

 

 

class LaunchChecklist {

    private static final Logger LOGGER =
            LogManager.getLogger(LaunchChecklist.class);

    List<String> checks = Arrays.asList("Cabin Pressure",
                                        "Communication",
                                        "Engine");

    Status prepareAscend(Commander commander) {
        LOGGER.info("{}: Prepare ascend", commander);
        LOGGER.debug("{} Checks: {}", checks.size(), checks);
        for (String check : checks) {
            if (commander.isFailing(check)) {
                LOGGER.warn("{}: {} ... FAILURE", commander, check);
                LOGGER.error("{}: Abort take off!", commander);
                return Status.ABORT_TAKE_OFF;
            }
            LOGGER.info("{}: {} ... OK", commander, check);
        }
        LOGGER.info("{}: Read for take off!", commander);
        return Status.READY_FOR_TAKE_OFF;
    }
}

 

- 243 ~ 245p (콘솔 출력 대신 로깅) 

 

 

반응형