effective java 3/E - 9장. 일반적인 프로그래밍 원칙

 

9장 일반적인 프로그래밍 원칙

9장에서 배울 내용
  • 지역변수, 제어구조, 라이브러리, 데이터 타입
  • 리플렉션과 네티이브 메서드
  • 최적화와 명명 규칙

아이템 57: 지역변수의 범위를 최소화하라

지역변수의 유효 범위를 최소로 줄이면 코드 가독성과 유지보수성이 높아지고 오류 가능성은 낮아진다.

지역변수의 범위를 줄이는 가장 강력한 기법은 역시 ‘가장 처음 쓰일 때 선언 하기’다.
  • 미리 선언부터 해두면 코드가 어수선해져 가독성이 떨어진다.
  • 변수를 실제로 사용하는 시점엔 타입과 초깃값이 기억나지 않을 수 있다.
  • 지역변수를 생각 없이 선언하다 보면 변수가 쓰이는 범위보다 너무 앞서 선언하거나, 다 쓴 뒤에 여전히 살아 있게 되기 쉽다.
거의 모든 지역변수는 선언과 동시에 초기화해야한다.
  • 초기화에 필요한 정보가 충분하지 않으면 충분해 질 때까지 선언을 미뤄야 한다.
  • try-catch 문은 예외이고 변수를 초기화 하는 표현식에서 검사 예외를 던질 가능성이 있다면 try 블록 안에서 초기화 해야한다.
  • 변수 값을 try 블록 바깥에서도 사용해야 한다면 try 블록 앞에서 선언해야 한다.
반복문은 독특한 방식으로 변수 범위를 최소화 해준다.
  • 반복문의 반복 변수의 범위가 반복문의 몸체, 그리고 for 키워드와 몸체 사이의 괄호 안으로 제안된다.
  • 반복 변수의 값을 반복문이 종료된 뒤에도 써야하는 상황이 아니라면 while 보다 for 문을 쓰는 것이 낫다.
컬렉션이나 배열을 순회하는 권장 관용구
for (Element e : c) {
    ... // e
}

반복자를 사용해야 하는 상황이면 for-each 대신 전용적인 for문을 쓰는 것이 좋다

반복자가 필요할 때의 관용구
for (Iterator<Element> i = c.iterator(); i.hasNext(); ){
    Element e = i.next();
    ... // e와 i
}
다음의 while 문은 앞서의 for 문이 더 나은 이유를 알 수 있다.
Iterator<Element> i = c.iterator();
while (i.hasNext()) {
    doSomething(i.next);
}
Iterator<Element> i2 = c2.iterator();
while (i.hasNext()) {  // 버그!
    doSometingElse(i2.next());
}
  • 두 번째 while 문에는 복사해 붙여넣기가 있다.
  • 실수로 이전 while 문의 i를 다시 썼다.
  • 불행히도 i의 유효범위가 아직 끝나지 않아, 이 코드는 컴파일도 잘되고 실행 시 예외도 던지지 않는다.
  • 두 번째 while 문은 c2를 순회하지 않고 곧장 끝나버려 c2가 비었다고 착각하게 만든다.
  • 프로그램 오류가 겉으로 들어나지 않아 오랜 기간 발견되지 않을 수도 있다.
for문의 경우 복사 붙여넣기 오류를 컴파일 타임에 잡아준다.
for (Iterator<Element i = c.iterator(); i.hasNext(); ){
    Element e = i.next();
    ... // e와 i로 무언가를 한다.
}
// i를 찾을 수 없다는 컴파일 오류를 낸다.
for (Iterator<Element i2 = c2.iterator(); i.hasNext(); ){
    Element e2 = i2.next();
    ... // e2와 i2로 무언가를 한다.
}
  • 변수 유효 범위가 for 문 범위와 일치하여 똑같은 이름의 변수를 여러 반복문에 써도 서로 아무런 영향을 주지 않는다.
  • while 문보다 짧아서 가독성이 좋다.
지역변수의 범위를 최소화하는 또 다른 반복문 관용구
for (int i = 0, n = expensiveComputation(); i < n; i++) {
    ... // i로 무언가를 한다.
}
  • 반복 여부를 결정짓는 변수 i의 한곗값을 n에 저장하여, 반복 때마다 다시 계산해야 하는 비용을 없앴다.
지역변수 범위를 최소화 하는 마지막 방법은 메서드를 작게 유지하고 한 가지 기능에 집중하는 것이다.
  • 한 메서드에서 여러 가지 기능을 처리한다면 그중한 기능과만 관련된 지역변수라도 다른 기능을 수행하는 코드에서 접근할 수 있을 것이다.
  • 해결책은 간단하다. 단순히 메서드를 기능별로 쪼개면 된다.

아이템 58: 전통적인 for문보다는 for-each 문을 사용하라

컬렉션 순회하기 - 더 나은 방법이 있다.
for (Iterator<Element> i = c.iterator(); i.hasNext(); ) {
    Element e = i.next();
    ...
}
배열 순회하기 - 더 나은 방법이 있다.
for (int i = 0; i < a.length; i++) {
    ...// a[i]로 무엇인가 한다.
}
  • while 문 보다 낫지만 가장 좋은 방법은 아니다.

  • 반복자와 인덱스 변수는 모두 코드를 지저분하게 할 뿐 우리에게 진짜 필요한건 원소들 뿐이다.

  • 쓰이는 요소 종류가 늘어나면 오류가 생길 가능성이 높아진다.

  • 잘못된 변수를 사용 했을 때 컴파일러가 잡아주리라는 보장도 없다.

  • 컬렉션이냐 배열이냐에 따라 코드 형태가 상당히 달라지므로 주의해야 한다.

for-each 문을 사용하면 해결된다.
  • 반복자와 인덱스 변수를 사용하지 않으나 코드가 깔끔해지고 오류가 날 일도 없다.
  • 하나의 관용구로 컬렉션과 배열 모두 처리할 수 있어서 어떤 컨테이너를 다루는지 신경 쓰지 않아도 된다.
for(Element e : elements) {
    ... // e로 무언가를 한다.
}
  • for-each 문을 사용해도 코드는 그대로이다.
컬렉션을 중첩해 순회해야 한다면 for-each 문의 이점은 더욱 커진다.
public class Card {
    private final Suit suit;
    private final Rank rank;

    // 버그를 찾아보자.
    enum Suit { CLUB, DIAMOND, HEART, SPADE }
    enum Rank { ACE, DEUCE, THREE, FOUR, FIVE, SIX, SEVEN, EIGHT,
        NINE, TEN, JACK, QUEEN, KING }

    static Collection<Suit> suits = Arrays.asList(Suit.values());
    static Collection<Rank> ranks = Arrays.asList(Rank.values());

    Card(Suit suit, Rank rank ) {
        this.suit = suit;
        this.rank = rank;
    }

    public static void main(String[] args) {
        List<Card> deck = new ArrayList<>();
        
        for (Iterator<Suit> i = suits.iterator(); i.hasNext(); )
            for (Iterator<Rank> j = ranks.iterator(); j.hasNext(); )
                deck.add(new Card(i.next(), j.next()));
    }
}
  • 이 코드의 문제는 바깥 컬렉션(suits)의 반복자에서 next 메서드가 너무 많이 불린다는 것이다.

  • deck.add 를 할 때 i.next() 가 사용이 되어, 숫자가 바닥나면 NoSuchElementException 을 던진다.

  • 바깥 컬렉션의 크기가 안쪽 컬렉션 크기의 배수라면 이 반복문은 원하는 일을 수행하지 않고 예외를 던지지 않고 종료한다.

    주사위를 두번 굴렸을 때 나올 수 있는 모든 경우의 수
    public class DiceRolls {
        enum Face { ONE, TWO, THREE, FOUR, FIVE, SIX }
      
        public static void main(String[] args) {
            // 같은 버그, 다른 증상!
            Collection<Face> faces = EnumSet.allOf(Face.class);
      
            for (Iterator<Face> i = faces.iterator(); i.hasNext(); )
                for (Iterator<Face> j = faces.iterator(); j.hasNext(); )
                    System.out.println(i.next() + " " + j.next());
        }
    }
    

    예외를 던지지는 않지만 단 여섯 쌍만 출력하고 끝이난다.

컬렉션이나 배열의 중첩 반복을 위한 권장 관용구
for (Suit suit : suits)
    for (Rank rank : ranks)
        deck.add(new Card(suit, rank));

for-each 문을 중첩하는 것으로 간단히 해결된다.

for-each 문을 사용할 수 없는 세 가지 상황
  • 파괴적인 필터링(destructive filtering) - 컬렉션을 순회하면서 선택된 원소를 제거해야 한다면 반복자의 remove 메서드를 호출해야 한다. 자바 8 부터는 Collection의 removeIf 메서드를 사용해 컬렉션을 명시적으로 순회하는 일을 피할 수 있다.
  • 변형(transforming) - 리스트나 배열을 순회하면서 그 원소의 값 일부 혹은 전체를 교체해야 한다면 리스트의 반복자나 배열의 인덱스를 사용해야 한다.
  • 병렬 반복(parallel iteration) - 여러 컬렉션을 병렬로 순회해야 한다면 각각의 반복자와 인덱스 변수를 사용해 엄격하고 명시적으로 제어해야 한다.
for-each 문은 배열과 Iterable 인터페이스를 구현한 객체라면 무엇이든 순회할 수 있다.
핵심 정리

전통적인 for 문과 비교했을 때 for-each 문은 명료하고, 유연하고, 버그를 예방해준다. 성능 저하도 없다. 가능한 모든 곳에서 for 문이 아닌 for-each 문을 사용하자.


아이템 59: 라이브러리를 익히고 사용하라

많은 프로그래머가 다음과 같은 짤막한 메서드를 만들곤 한다.

흔하지만 문제가 심각한 코드!
static Random rnd = new Random();

static int random(int n) {
    return Math.abs(rnd.nextInt()) & n;
}

문제를 세 가지 내포하고 있다.

  • n이 그리 크지 않은 2의 제곱수라면 얼마 지나지 않아 같은 수열이 반복된다.

  • n이 2의 제곱수가 아니라면 몇몇 숫자가 평균적으로 더 자주 반환된다.

    무작위 수 100만 개를 생성 후, 중간 값 보다 작은 수의 개수를 출력
    int n = 2 * (Integer.MAX_VALUE / 3);
    int low = 0;
    for (int i = 0; i < 1000000; i++)
        if (random(n) < n/2)
            low++;
    System.out.println(low);
    

    돌려보면 666,666에 가까운 값을 얻는다. 무작위로 생성된 수 중에서 2/3 가량이 중간 값보다 낮은 쪽으로 쏠렸다.

  • random 메서드의 세 번째 결함으로, 지정한 범위 ‘바깥’ 의 수가 종종 튀어나올 수 있다.

    이것을 해결하려면 Random.nextInt(int) 를 사용하면 된다.

표준 라이브러리를 사용하면 그 코드를 작성한 전문가의 지식과 다른 프로그래머들의 경험을 활용할 수 있다.
  • Random 보다는 ThreadLocalRandom으로 대체하면 대부분 잘 동작한다.
  • 포크-조인 풀이나 병렬 스트림에서는 SplittableRandom을 사용하라.
  • 표준 라이브러리를 사용하면 핵심적인 일과 크게 관련 없는 문제를 해결하느라 시간을 허비하지 않아도 된다는 것이다.
  • 따로 노력하지 않아도 성능이 지속해서 개선된다는 점도 있다.
  • 기능이 점점 많아진다.
  • 작성한 코드가 많은 사람에게 낯익은 코드가 된다. 자연스럽게 다른 개발자들이 읽기 쉽고, 유지보수 하기 좋고, 재활용하기 쉬운 코드가 된다.
표준 라이브러리 기능의 이점은 많지만 실상은 직접 구현해서 쓰는 편이 많다.
  • 아마도 라이브러리에 그런 기능이 있는지 모르기 때문일 것이다.
  • 메이저 릴리스마다 주목할 만한 수많은 기능이 라이브러리에 추가된다.
  • 라이브러리가 방대하여 모든 API 문서를 공부하기는 벅차겠지만, 자바 프로그래머라면 적어도 java.lang, java.util, java.io와 그 하위 패키지에는 익숙해져야 한다.
때때로 라이브러리가 필요한 기능을 충분히 제공하지 못할 수 있다.
  • 우선은 라이브러리를 사용하려 시도해보자.
  • 어떤 영역의 기능을 제공하는지 살펴보고, 원하는 기능이 아니라 판단되면 대안을 사용하자.
  • 고품질의 서드파티 라이브러리를 사용할 수도 있다.
  • 적합한 서드파티도 없다면 직접 구현하자.
핵심 정리

바퀴를 다시 발명하지 말자. 아주 특별한 나만의 기능이 아니라면 누군가 이미 라이브러리 형태로 구현해놓았을 가능성이 크다. 그런 라이브러리가 있다면, 쓰면 된다. 있는지 잘 모르겠다면 찾아보라. 일반적으로 라이브러리의 코드는 여러분이 직접 작성한 것보다 품질이 좋고, 점차 개선될 가능성이 크다. 여러분의 실력을 폄하하는 게 아니다. 코드 품질에도 규모의 경제가 적용된다. 즉, 라이브러리 코드는 개발자 각자가 작성하는 것보다 주목을 훨씬 많이 받으므로 코드 품질도 그만큼 높아진다.


아이템 60: 정확한 답이 필요하다면 float와 double은 피하라

float와 double 타입은 과학과 공학 계산용으로 설계되었다. 이진 부동소수점 연산에 쓰이며, 넓은 범위의 수를 빠르게 정밀한 ‘근사치’로 계산하도록 세심하게 설계되었다. 따라서 정확한 결과가 필요할 때는 사용하면 안된다.

float와 double 타입은 특히 금융 관련 계산과는 맞지 않는다.

0.1 혹은 10의 음의 거듭 제곱수를 표현할 수 없기 때문이다.

금융 계산에 부동소수 타입을 사용한 코드
double funds = 1.00;
int itemsBought = 0;
for (double price = 0.10; funds >= price; price += 0.10) {
    funds -= price;
    itemsBought++;
}
System.out.println(itemsBought + "개 구입");
System.out.println("잔돈(달러): " + funds);
  • 3개를 구입한 후 잔돈은 0.3999999999999999달러 라는 값이 나온다.
  • 이는 잘못된 계산이며 사용하지 말아야 한다.
금융 계산에는 BigDecimal, int 혹은 long을 사용해야 한다.
BigDecimal을 사용한 해법 - 속도가 느리고 쓰기 불편하다.
final BigDecimal TEN_CENTS = new BigDecimal(".10");

int itemsBought = 0;
BigDecimal funds = new BigDecimal("1.00");
for (BigDecimal price = TEN_CENTS;
     funds.compareTo(price) >= 0;
     price = price.add(TEN_CENTS)) {
    funds = funds.subtract(price);
    itemsBought++;
}
System.out.println(itemsBought + "개 구입");
System.out.println("잔돈(달러): " + funds);
  • 올바른 답이 나오지만 단점이 두가지 있다.
  • 기본 타입보다 쓰기가 훨씬 불편하고, 훨씬 느리다
정수 타입을 사용한 해법
int itemsBought = 0;
int funds = 100;
for (int price = 10; funds >= price; price += 10) {
    funds -= price;
    itemsBought++;
}
System.out.println(itemsBought + "개 구입");
System.out.println("잔돈(센트): " + funds);
  • 다룰 수 있는 크기가 제한되고, 소수점을 직접 관리해야 한다.
핵심 정리

정확한 답이 필요한 계산에는 float나 double을 피하라. 소수점 추적은 시스템에 맡기고, 코딩 시의 불편함이나 성능 저하를 신경 쓰지 않겠다면 BigDecimal을 사용하라. BigDecimal이 제공하는 여덟 가지 반올림 모드를 이용하여 반올림을 완벽히 제어할 수 있다. 법으로 정해진 반올림을 수행해야 하는 비즈니스 계산에서 아주 편리한 기능이다. 반면, 성능이 중요하고 소수점을 직접 추적할 수 있고 숫자가 너무 크지 않다면 int나 long을 사용하라. 숫자를 아홉자리 십진수로 표현할 수 있다면 int를 사용하고 열여덟 자리 십진수로 표현할 수 있다면 long을 사용하라. 열여덟 자리를 넘어가면 BigDecimal을 사용해야 한다.


아이템 61: 박싱된 기본 타입보다는 기본 타입을 사용하라

각 기본 타입에 대응하는 참조 타입이 하나씩 있고, 이를 박싱된 기본 타입이라고 한다.

int, double, boolean은 각각 Integer, Double, Boolean에 대응된다.

기본 타입과 박싱된 기본 타입의 주된 차이 세 가지
  1. 기본 타입은 값만 가지고 있으나, 박싱된 기본 타입은 값에 더해 식별성이란 속성도 갖는다. 박싱된 기본 타입은 두 인스턴스의 값이 같아도 서로 다르다고 식별될 수 있다.
  2. 기본 타입의 값은 언제나 유효하나, 박싱된 기본 타입은 유효하지 않은 값, 즉 null 을 가질 수 있다.
  3. 기본 타입이 박싱된 타입보다 시간과 메모리 사용면에서 더 효율적이다.

이상 세 가지 차이 때문에 주의하지 않고 사용하면 진짜로 문제가 발생할 수 있다.

Integer 값을 오름차순으로 정렬하는 비교자 - 잘못된 구현, 문제를 찾아보자.
Comparator<Integer> naturalOrder = 
    (i, j) -> (i < j) ? -1 : (i == j ? 0 : 1);
  • 이 코드는 심각한 결함이 있다.

  • naturalOrder.compare(new Integer(42), new Integer(42)) 의 값은 같으므로 0을 출력해야 하지만, 실제로는 1을 출력한다.

  • 원인은 naturalOrder의 첫 번째 검사 (i < j) 는 잘 작동한다.

  • 여기에서 i, j 가 참조하는 Integer 인스턴스는 기본 타입 값으로 변환된다.

  • 두 번째 검사인 (i == j) 에서는 객체의 참조 식별성을 검사하게 된다.

  • 그 결과 false가 되고 비교자는 1을 반환한다.

  • 박싱된 기본 타입에 == 연산자를 사용하면 오류가 일어난다.
실무에서 기본 타입을 다루는 비교자가 필요하다면 Comparator.naturalOrder() 를 사용하자.
  • 비교자를 직접 만들면 생성 메서드나 기본 타입을 받는 compare 메서드를 사용해야 한다.
문제를 수정한 비교자

Integer의 매개변수 타입을 기본 타입 정수로 저장한 후 비교를 진행한다.

Comparator<Integer> naturalOrder = (iBoxed, jBoxed) -> {
    int i = iBoxed, j = Boxed;
    return i < j ? -1 : (i == j ? 0 : 1);
}
기본 타입과 박싱된 기본 타입을 혼용한 연산에서는 박싱된 기본 타입의 박싱이 자동으로 풀린다.
  • 박싱된 기본 타입의 초기화를 하지 않으면 null 값을 가진다.
  • null 참조를 언박싱하면 NullPointerException 이 발생한다.
  • 박싱된 기본 타입과 기본 타입을 같이 쓰면 성능 저하의 원인이 된다.
박싱된 기본 타입은 언제 써야 하는가?
  1. 컬렉션의 원소, 키, 값으로 쓰인다. 컬렉션은 기본 타입을 담을 수 없으므로 박싱된 기본 타입만 써야한다.
  2. 매개변수화 타입이나 매개변수화 메서드의 타입 매개변수로는 박싱된 기본 타입을 써야한다.
  3. 리플렉션을 통해 메서드를 호출할 때도 박싱된 기본 타입을 사용해야 한다.
핵심 정리

기본 타입과 박싱된 기본 타입 중 하나를 선택해야 한다면 가능하면 기본 타입을 사용하라. 기본 타입은 간단하고 빠르다. 박싱된 기본 타입을 써야 한다면 주의를 기울이자. 오토박싱이 박싱된 기본 타입을 사용할 때의 번거로움을 줄여주지만, 그 위험까지 없애주지는 않는다. 두 박싱된 기본 타입을 == 연산자로 비교한다면 식별성 비교가 이뤄지는 데, 이는 여러분이 원한게 아닐 가능성이 크다. 같은 연산에서 기본 타입과 박싱된 기본 타입을 혼용하면 언박싱이 이뤄지며, 언박싱 과정에서 NullPointerException을 던질 수 있다. 마지막으로, 기본 타입을 박싱하는 작업은 필요 없는 객체를 생성하는 부작용을 낳을 수 있다.


아이템 62: 다른 타입이 적절하다면 문자열 사용을 피하라

문자열(String)은 텍스트를 표현하도록 설계되었고, 그 일을 아주 멋지게 해낸다. 이번 아이템에서는 문자열을 쓰지 않아야 할 사례를 다룬다.

문자열은 다른 값 타입을 대신하기에 적합하지 않다.
  • 많은 사람이 파일, 네트워크, 키보드 입력으로부터 데이터를 받을 때 주로 문자열을 사용한다.
  • 자연스로워 보이지만, 입력받을 데이터가 진짜 문자열일 때만 사용하는 것이 좋다.
  • 받은 데이터가 수치형이라면 int, float, BigInteger 등 적당한 타입으로 변환해야 한다.
  • 예/아니오 질문의 답이라면 적절한 열거 타입이나 boolean으로 변환해야 한다.
  • 기본 타입이든 참조 타입이든 적절한 값 타입이 있다면 그것을 사용하고 없다면 새로 하나 작성하라.
문자열은 열거 타입을 대신하기에 적합하지 않다.
  • 상수를 열거할 때는 문자열보다 열거 타입이 월등히 낫다
문자열은 혼합 타입을 대신하기에 적합하지 않다.
  • 여러 요소가 혼합된 데이터를 하나의 문자열로 표현하는 것은 대체로 좋지 않은 생각이다.
혼합 타입을 문자열로 처리한 부적절한 예
String compoundKey = className + "#" + i.next();
  • 두 요소를 구분해주는 문자 #이 두 요소 중 하나에서 쓰였다면 혼란스러운 결과를 초래한다.
  • 각 요소를 개별로 접근하려면 문자열을 파싱해야 해서 느리고, 귀찮고, 오류 가능성도 커진다.
  • 적절한 equals, toString, compareTo 메서드를 제공할 수 없으며 String이 제공하는 기능에만 의존해야 한다.
  • 그래서 차라리 전용 클래스를 새로 만드는 편이 낫다.
문자열은 권한을 표현하기에 적합하지 않다.
  • 권한(capacity)을 문자열로 표현하는 경우가 종종있다.
문자열을 사용해 권한을 구분한 잘못된 예제
public class ThreadLocal {
    private ThreadLocal() {} // 객체 생성 불가
    
    // 현 스레드의 값을 키로 구분해 저장한다
    public static void set(String key, Object value);
    
    // 키가 가리키는 현 스레드의 값을 반환한다.
    public static Object get(String key);
}
  • 이 방식의 문제는 스레드 구분용 문자열 키가 전역 이름공간에서 공유된다는 점이다.
  • 이 방식이 의도대로 동작하려면 각 클라이언트가 고유한 키를 제공해야 한다.
  • 만약 두 클라이언트가 서로 소통하지 못해 같은 키를 쓰기로 결정한다면 의도치 않게 변수를 공유하게 된다.
  • 결국 두 클라이언트 모두 제대로 기능하지 못할 것이다.
  • 보안도 취약하다.
  • 악의적인 클라이언트라면 의도적으로 같은 키를 사용하려 다른 클라이언트의 값을 가져올 수 있다.
위조할 수 없는 키를 사용하면 된다. - Key 클래스로 권한을 구분
public class ThreadLocal {
    private ThreadLocal() {} // 객체 생성 불가

    public static class Key {
        Key() {}
    }
    
    // 위조 불가능한 고유 키를 생성한다.
    public static Key getKey() {
        return new Key();
    }
    
    public static void set(Key key, Object value);
    public static Object get(Key key);
}
  • 문자열 기반 API의 문제를 해결해주지만 개선할 여지가 있다.
리팩터링하여 Key를 ThreadLocal 로 변경
public final class ThreadLocal {
    private ThreadLocal() {} // 객체 생성 불가
    public void set(Object value);
    public Object get();
}
매개변수화하여 타입안정성 확보
public final class ThreadLocal<T> {
    private ThreadLocal() {} // 객체 생성 불가
    public void set(Object value);
    public Object get();
}
  • 문자열 기반 API의 문제를 해결해주며, 키 기반 API 보다 빠르고 우아하다.
핵심 정리

더 적합한 데이터 타입이 있거나 새로 작성할 수 있다면, 문자열을 쓰고 싶은 유혹을 뿌리쳐라. 문자열은 잘못 사용하면 번거롭고, 덜 유연하고, 느리고, 오류 가능성도 크다. 문자열을 잘못 사용하는 흔한 예로는 기본 타입, 열거 타입, 혼합 타입이 있다.


아이템 63: 문자열 연결은 느리니 주의하라

문자열 연결 연산자 + 는 여러 문자열은 하나로 합쳐주는 편리한 수단이다. 그런데 한 줄 짜리 출력값 혹은 작고 크기가 고정된 객체의 문자열 표현을 만들 때라면 괜찮지만, 본격적으로 사용하기 시작하면 성능 저하를 감내하기 어렵다.

문자열 연결 연산자로 문자열 n개를 잇는 시간은 n²에 비례한다.
  • 문자열은 불변이라서 두 문자열을 연결할 경우 양쪽의 내용을 모두 복사해야 하므로 성능저하를 피할 수 없다.
문자열 연결을 잘못 사용한 예 - 느리다
public String statement() {
    String result = "";
    for (int i = 0; i < numItems; i++){
        result += lineForItem(i); // 문자열 연결
    }
    return result;
}
  • 품목이 많아지면 심각하게 느려질 수 있다.
성능을 포기하고 싶지 않다면 String 대신 StringBuilder를 사용하자
public String statement2() {
    StringBuilder b = new StringBuilder(numItems() * LINE_WIDTH);
    for (int i = 0; i < numItems(); i++) {
        b.append(lineForItem(i));
    }
    return b.toString();
}
  • String을 사용하는 것보다 성능이 더 낫다.
핵심 정리

원칙은 간단하다. 성능에 신경써야 한다면 많은 문자열을 연결할 때는 문자열 연결 연산자(+)를 피하자. 대신 StringBuilder의 append 메서드를 사용하라. 문자 배열을 사용하거나, 문자열을 (연결하지 않고) 하나씩 처리하는 방법도 있다.


아이템 64: 객체는 인터페이스를 사용해 참조하라

적합한 인터페이스만 있다면 매개변수뿐 아니라 반환값, 변수, 필드를 전부 인터페이스 타입으로 선언하라.
  • 객체의 실제 클래스를 사용해야 할 상황은 오직 생성자로 생성할 때 뿐이다.
좋은 예 - 인터페이스를 타입으로 사용했다.
Set<Son> sonSet = new LinkedHashSet<>();
나쁜 예 - 클래스를 타입으로 사용했다.
LinkedHashSet<Son> sonSet = new LinkedHashSet<>();
인터페이스를 타입으로 사용하는 습관을 길러두면 프로그램이 훨씬 유연해질 것이다.
  • 나중에 구현 클래스를 교체하고자 한다면 그저 새 클래스의 생성자를 호출해주기만 하면 된다.
Set<Son> sonSet = new HashSet<>();
  • 다른 코드는 전혀 손대지 않고 새로 구현한 클래스로의 교체가 완료됐다.
주의할 점 한 가지
  • 원래의 클래스가 인터페이스의 일반 규약 이외의 특별한 기능을 제공하며, 주변 코드가 이 기능에 기대어 동작한다면 새로운 클래스도 반드시 같은 기능을 제공해야 한다.
구현 타입을 바꾸려 하는 동기는 무엇일까?
  • 원래의 것보다 성능이 좋거나 멋진 신기능을 제공하기 때문일 수 있다.
  • 변수를 구현 타입으로 선언해도 괜찮을 거라 생각할 수도 있다. 자칫하면 프로그램이 컴파일 되지 않는다.
적합한 인터페이스가 없다면 당연히 클래스로 참조해야 한다.
  • String이나 BigInteger 같은 값 클래스가 그렇다.
    • 값 클래스를 여러 가지로 구현될 수 있다고 생각하고 설계하는 일은 거의 없다.

    • 값 클래스는 매개변수, 변수, 필드, 반환 타입으로 사용해도 무방하다.

  • 클래스 기반으로 작성된 프레임 워크가 제공된 객체들도 그렇다.
    • 이런 경우라도 특정 구현 클래스보다는 기반 클래스를 사용해 참조하는게 좋다. OutputStream 등 java.io 패키지의 여러 클래스가 이 부류에 속한다.
  • 인터페이스에는 없는 특별한 메서드를 제공하는 클래스들도 그렇다.
    • PriorityQueue 클래스는 Queue 인터페이스에는 없는 comparator 메서드를 제공한다.
    • 클래스 타입을 직접 사용하는 경우는 이런 추가 메서드를 꼭 사용해야 하는 경우로 최소화해야 하며, 절대로 남발하지 말아야 한다.
적합한 인터페이스가 없다면 클래스의 계층구조 중 필요한 기능을 만족하는 가장 덜 구체적인 (상위의) 클래스를 타입으로 사용하자.

아이템 65: 리플렉션보다는 인터페이스를 사용하라

  • 리플렉션 기능(java.lang.reflect)을 이용하면 프로그램에서 임의의 클래스에 접근할 수 있다.
  • Class 객체가 주어지면 그 클래스의 생성자, 메서드, 필드에 해당하는 Constructor, Method, Field 인스턴스를 가져올 수 있고, 이 인스턴스들로는 그 클래스의 멤버 이름, 필드 타입, 메서드 시그니처 등을 가져올 수 있다.
  • 나아가 Constructor, Method, Field 인스턴스를 이용해 각각에 연결된 실제 생성자, 메서드, 필드를 조작할 수도 있다.
리플렉션을 이용하면 컴파일 당시에 존재하지 않던 클래스도 있는데, 물론 단점이 있다.
  • 컴파일타임 타입 검사가 주는 이점을 하나도 누릴 수 없다.
    • 예외 검사도 마찬가지다. 프로그램이 리플렉션 기능을 써서 존재하지 않는 혹은 접근할 수 없는 메서드를 호출하려 시도하면 런타임 오류가 발생한다.
  • 리플렉션을 이용하면 코드가 지저분하고 장황해진다.
    • 지루한 일이고, 읽기도 어렵다.
  • 성능이 떨어진다
    • 리플렉션을 통한 메서드 호출은 일반 메서드 호출보다 훨씬 느리다.
리플렉션은 아주 제한된 형태로만 사용해야 그 단점을 피하고 이점만 취할 수 있다.
  • 컴파일타임에 이용할 수 없는 클래스를 사용해야만 하는 프로그램은 비록 컴파일타임이라도 적절한 인퍼테이스나 상위 클래스를 이용할 수는 있을 것이다.

  • 이런 경우라면 리플렉션은 인스턴스 생성에만 쓰고, 이렇게 만든 인스턴스는 인터페이스나 상위 클래스로 참조해 사용하자.
드물긴 하지만, 리플렉션은 런타임에 존재하지 않을 수도 있는 다른 클래스, 메서드, 필드와의 의존성을 관리할 때 적합하다.
  • 버전이 여러 개 존재하는 외부 패키지를 다룰 때 유용하다.
  • 가동할 수 있는 최소한의 환경, 즉 주로 가장 오래된 버전만을 지원하도록 컴파일 한 후, 이후 버전의 클래스와 메서드 등은 리플렉션으로 접근하는 방식이다.
  • 새로운 클래스나 메서드가 런타임에 존재하지 않을 수 있다는 사실을 반드시 감안해야 한다.
핵심 정리

리플렉션은 복잡한 특수 시스템을 개발할 때 필요한 강력한 기능이지만, 단점도 많다. 컴파일타임에는 알 수 없는 클래스를 사용하는 프로그램을 작성한다면 리플렉션을 사용해야 할 것이다. 단, 되도록 객체 생성에만 사용하고, 생성한 객체를 이용할 때는, 적절한 인터페이스나 컴파일타임에 알 수 있는 상위 클래스로 형변환해 사용해야 한다.


아이템 66: 네이티브 메서드는 신중히 사용하라

자바 네이티브 인터페이스(Java Native Interface, JNI)는 자바 프로그램이 네이티브 메서드를 호출하는 기술이다.

네이티브 메서드 : C나 C++ 같은 네이티브 프로그래밍 언어로 작성한 메서드

네이티브 메서드의 주요 쓰임 세 가지
  1. 레지스트리 같은 플랫폼 특화 기능을 사용한다.
  2. 네이티브 코드로 작성된 기존 라이브러리를 사용한다.
    • 레거시 데이터를 사용하는 레거시 라이브러리
  3. 성능 개선을 목적으로 성능에 결정적인 영향을 주는 영역만 따로 네이티브 언어로 작성한다.
플랫폼 특화 기능을 활용하려면 네이티브 메서드를 사용해야 한다.
  • 하지만 자바가 성숙해가면서 하부 플랫폼의 기능들을 점차 흡수하고 있다.
  • 그래서 네이티브 메서드를 사용할 필요가 계속 줄어들고 있다.
성능을 개선할 목적으로 네이티브 메서드를 사용하는 것은 거의 권장하지 않는다.
  • 대부분의 작업에서 지금의 자바는 다른 플랫폼에 견줄만한 성능을 보인다.
네이티브 메서드에는 심각한 단점이 있다.
  • 네이티브 언어가 안전하지 않으므로 네이티브 메서드를 사용하는 애플리케이션도 메모리 훼손 오류로부터 더 이상 안전하지 않다.
  • 네이티브 언어는 자바보다 플랫폼을 많이타서 이식성도 낮다.
  • 디버깅도 어렵다.
  • 주의하지 않으면 속도가 오히려 느려질 수도 있다.
  • 가비지 컬렉터가 네이티브 메모리는 자동 회수하지 못하고 심지어 추적조차 할 수 없다.
  • 자바 코드와 네이티브 코드의 경계를 넘나들 때마다 비용도 추가된다.
  • 네이티브 메서드와 자바 코드 사이의 접착 코드를 작성해야 하는데 귀찮은 작업이며 가독성도 떨어진다.
핵심 정리

네이티브 메서드를 사용하려거든 한번 더 생각하라. 네이티브 메서드가 성능을 개선해 주는 일은 많지 않다. 저수준 자원이나 네이티브 라이브러리를 사용해야만 해서 어쩔 수 없더라도 네이티브 코드는 최소한만 사용하고 철저히 테스트하라. 네이티브 코드 안에 숨은 단 하나의 버그가 여러분의 애플리케이션 전체를 훼손할 수도 있다.


아이템 67: 최적화는 신중히 하라

모든 사람이 마음 깊이 새겨야할 최적화 격언

그 어떤 핑계보다 효율성이라는 이름 아래 행해진 컴퓨팅 죄악이 더 많다. (심지어 효율을 높이지도 못하면서)

- 윌리엄 울프

자그마한 효율성은 모두 잊자. 섣부른 최적화가 만악의 근원이다.

- 도널드 크누스

최적화를 할 때는 다음 두 규칙을 따르라.

첫 번째, 하지 마라

두 번째, 아직 하지 마라. 다시 말해 완전히 명백하고 최적화되지 않은 해법을 찾을 때까지는 하지 마라.

- M. A. 잭슨

  • 최적화는 좋은 결과 보다는 해로운 결과로 이어지기 쉽고, 섣불리 진행하면 특히 더 그렇다.
  • 빠르지도 않고 제대로 동작하지도 않으면서 수정하기는 어려운 소프트웨어를 탄생시키는 것이다.
빠른 프로그램보다는 좋은 프로그램을 작성하라.
  • 좋은 프로그램이지만 원하는 성능이 나오지 않는 다면 그 아키텍쳐 자체가 최적화할 수 있는 길을 안내해 줄 것이다.
  • 좋은 프로그램은 정보 은닉 원칙을 따르므로 개별 구성요소의 내부를 독립적으로 설계할 수 있다.
  • 시스템의 나머지 영향을 주지 않고도 각 요소를 다시 설계할 수 있다.
  • 구현상의 문제는 나중에 최적화할 수 있지만, 아키텍 결함이 성능을 제한 하는 상황이라면 시스템 전체를 다시 작성하지 않고는 해결하기 불가능할 수 있다.
  • 완성된 설계의 기본 틀을 변경하려다 보면 유지보수하거나 개선하기 어려운 꼬인 구조의 시스템이 만들어지기 쉽다.
성능을 제한하는 설계를 피하라.
  • 완성 후 변경하기가 가장 어려운 설계 요소는 바로 컴포넌트끼리, 혹은 외부 시스템과의 소통 방식이다.
  • API, 네트워크 프로토콜, 영구 저장용 데이터 포맷 등이 대표적이다.
  • 이런 설계 요소는 완성 후에는 변경하기 어렵거나 불가능할 수 있으며, 동시에 시스템 성능을 심각하게 제한할 수 있다.
API를 설계할 때 성능에 주는 영향을 고려하라.
  • public 타입을 가변으로 만들면, 즉 내부 데이터를 변경할 수 있게 만들면 불필요한 방어적 복사를 수없이 유발할 수 있다.

  • 컴포지션으로 해결할 수 있음에도 상속 방식으로 설계한 public 클래스는 상위 클래스에 영원히 종속되며 그 성능 제약까지도 물려받게 된다.

  • 인터페이스도 있는데 굳이 구현 타입을 사용하는 것도 역시 좋지 않다.

    특정 구현체에 종속되게 하여, 나중에 더 빠른 구현체가 나오더라도 이용하지 못하게 된다.

성능을 위해 API를 왜곡하는 건 매우 안 좋은 생각이다.
  • API를 왜곡하도록 만든 그 성능 문제는 해당 플랫폼이나 아랫단 소프트웨어의 다음 버전에서 사라질 수도 있지만, 왜곡된 API와 이를 지원하는 데 따르는 고통은 영원히 계속될 것이다.

신중하게 설계하여 깨끗하고 명확하고 멋진 구조를 갖춘 프로그램을 완성한 다음에야 최적화를 고려해볼 차례가 된다.

프로파일링 도구는 최적화 노력을 어디에 집중해야 할지 찾는 데 도움을 준다.
  • 개별 메서드의 소비 시간과 호출 횟수 같은 타임 정보를 제공하여, 집중할 곳은 물론 알고리즘을 변경해야 한다는 사실을 알려주기도 한다.
  • 시스템 규모가 커질수록 프로파일러가 더 중요해 진다.
최적화 시도 전후의 성능 측정의 중요성은 자바에서 더욱 크다.
  • 자바는 다양한 기본 연산에 드는 상대적인 비용을 덜 명확하게 정의하고 있다.
  • 프로그래머가 작성하는 코드와 CPU에서 수행하는 명령 사이의 추상화 격차가 커서 최적화로 인한 성능 변화를 일정하게 예측하기가 그만큼 더 어렵다.
핵심 정리

빠른 프로그램을 작성하려 안달하지 말자. 좋은 프로그램을 작성하다 보면 성능은 따라오게 마련이다. 하지만 시스템을 설계할 때, 특히 API, 네트워크 프로토콜, 영구 저장용 데이터 포맷을 설계할 때는 성능을 염두에 두어야 한다. 시스템 구현을 완료했다면 이제 성능을 측정해보라. 충분히 빠르면 그것으로 끝이다. 그렇지 않다면 프로파일러를 사용해 문제의 원인이 되는 지점을 찾아 최적화를 수행하라. 가장 먼저 어떤 알고리즘을 사용했는지 살펴보자. 알고리즘을 잘못 골랐다면 다른 저수준 최적화는 아무리 해봐야 소용이 없다. 만족할 때까지 이 과정을 반복하고, 모든 변경 후에는 성능을 측정하라.


아이템 68: 일반적으로 통용되는 명명 규칙을 따르라

자바의 명명 규칙은 크게 철자와 문법, 두 범주로 나뉜다.

철자 규칙은 패키지, 클래스, 인터페이스, 메서드, 필드, 타입 변수의 이름을 다룬다.

이 규칙들은 특별한 이유가 없는 한 반드시 따라야 한다. 이 규칙을 어긴 API는 사용하기 어렵고, 유지보수 하기 어렵다.

패키지
  • 패키지와 모듈 이름은 각 요소를 점(.)으로 구분하여 계층적으로 짓는다. 요소들은 모두 소문자 알파벳, 드물게 숫자로 이루어 진다.
  • 조직 바깥에서도 사용될 패키지라면 인터넷 도메인 이름을 역순으로 한다.
  • 패키지 이름의 나머지는 해당 패키지를 설명하는 하나 이상의 요소로 이뤄진다. 각 요소는 일반적으로 8자 이하의 짧은 단어로 한다. 단어의 약어 또는 첫글자
  • 인터넷 도메인 이름 뒤에 요소 하나만 붙인 패키지가 많지만, 많은 기능을 제공하는 경우엔 계층을 나눠 하위 패키지를 구성해도 좋다.
클래스와 인터페이스
  • 하나 이상의 단어로 이뤄지며, 각 단어는 대문자로 시작한다.
  • 여러 단어의 첫 글자만 딴 약자나, max, min 처럼 널리 통용되는 줄임말을 제외하고, 단어를 줄여 쓰지 않도록 한다.
메서드와 필드
  • 첫 글자를 소문자로 쓰는 것만 빼고 클래스 명명 규칙과 같다.
  • 첫 단어가 약자라면 단어 전체가 소문자여야 한다.
  • 단, 상수 필드는 구성하는 단어는 모두 대문자로 쓰며 단어 사이는 밑줄로 구분한다. 상수 필드는 static final 필드를 말한다.
  • 지역변수에도 다른 멤버와 비슷한 명명 규칙이 적용된다.
  • 타입 매개변수 이름은 보통 한 문자로 표현한다. 임의의 타입은 T, 컬렉션 원소의 타입은 E, 맵의 키와 값에는 K와 V, 예외는 X, 메서드의 반환 타입에는 R
식별자 타입
패키지와 모듈 org.junit.jupiter.api, com.google.common.collect
클래스와 인터페이스 Stream, FutureTask, LinkedHashMap, HttpClient
메서드와 필드 remove, groupingBy, getCrc
상수 필드 MIN_VALUE, NEGATIVE_INFINITY
지역변수 i, denom, houseNum
타입 매개변수 T, E, K, V, X, R, U, V, T1, T2
문법 규칙은 철자 규칙과 비교하면 더 유연하고 논란도 많다.
객체를 생성할 수 있는 클래스
  • 보통 단수 명사나 명사구를 사용한다.(Thread, PriorityQueue, ChessPiece)
객체를 생성할 수 없는 클래스
  • 보통 복수형 명사로 짓는다. (Collectors, Collections)
인터페이스의 이름
  • 클래스와 똑같이 짓거나 able, ible로 끝나는 형용사로 짓는다.
애너테이션
  • 명사, 동사, 전치사, 형용사가 두루 쓰인다.
어떤 동작을 수행하는 메서드의 이름
  • 동사나 동사구로 짓는다.
  • boolean 값을 반환하는 메서드라면 is나 has로 시작하고 명사나 명사구, 혹은 형용사로 기능하는 아무 단어나 구 (isDigit, isProbablePrime, isEmpty, isEnabled, hasSiblings)
반환 타입이 boolean이 아니거나 해당 인스턴스의 속성을 반환하는 메서드
  • 보통 명사, 명사구, 혹은 get으로 시작하는 동사구
  • get으로 시작하는 형태는 주로 자바빈즈 명세에 뿌리를 두고 있다.
객체의 타입을 바꿔서, 다른 타입의 또 다른 객체를 반환하는 인스턴스 메서드
  • 보통 toType의 형태로 짓는다. (toString, toArray)
객체의 내용을 다른 뷰로 보여주는 메서드
  • asType 형태로 짓는다. (asList)
객체의 값을 기본 타입 값으로 반환하는 메서드
  • typeValue 형태 (intValue)
정적 팩터리
  • from, of, valueOf, instance, getInstance, newInstance, getType, newType
필드 이름에 관한 문법 규칙
  • 클래스, 인터페이스, 메서드 이름에 덜 명확하고 덜 중요하다.
핵심 정리

표준 명명 규칙을 체화하여 자연스럽게 베어 나오도록 하자. 철자 규칙은 직관적이라 모호한 부분이 적은 데 반해, 문법 규칙은 더 복잡하고 느슨하다. 자바 언어 명세의 말을 인용하자면 “오랫동안 따라온 규칙과 충돌한다면 그 규칙을 맹종해서는 안 된다.” 상식이 이끄는 대로 따르자.


9장 일반적인 프로그래밍 원칙 끝…