Effective Java - Chapter 9

Updated:

Effective Java

[Effective Java 3/E] 9장 일반적인 프로그래밍 원칙

이번 장에서는 지역변수, 제어구조, 라이브러리, 데이터 타입 등 다양한 요소들에 대하여 다루어 보았다.
그리고 최적화와 명명 규칙으로 마무리하였다.

Item 57 - 지역변수의 범위를 최소화하라

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

  1. 지역변수는 가장 처음 쓰일때 선언하자.
    지역변수의 범위를 줄이는 가장 강력한 기법이며, 쓰기 전 미리 선언해두면 가독성을 줄이고 실수를 유발할 수 있다.
  2. 선언과 동시에 초기화 하자
    초기화에 필요한 정보가 충분하지 않다면, 선언을 미뤄야한다.
  3. 반복 변수의 값을 반복 이후에도 쓸것이 아니라면, while문 보다 for문을 쓰자
    • while 문을 사용하면 반복문 밖으로 불필요한 변수가 선언된다.
    • for문을 사용하면 변수의 범위가 몸체 사이의 괄호 안으로 제한된다.
    • while문보다 잛아서 가독성이 좋으며, 실수로 인한 오류를 줄여준다.
  4. 메서드를 작게 유지하고 한 가지 기능에만 집중하자 여러 가지 기능을 처리하게 되면 다른 기능을 수행하는 코드에서 접근할 가능성이 있다.
    메서드를 기능별로 나누면 간단해진다.

지역변수의 범위를 최소화해야 잠재적인 오류를 줄일 수 있다.

Item 58 - 전통적인 for 문보다는 for-each 문을 사용하라

배열과 컬렉션의 요소를 탐색할 때 보통 for 문을 사용했다. 특히 반복자(iterator)나 인덱스 탐색을 위한 루프 변수는 실제로 필요한 원소를 얻기 위한 코드일 뿐이다. 따라서 불필요하며 오히려 잘못 사용한 경우 오류가 발생할 가능성이 높다.

따라서, 향상된 for문(for-each)를 사용하자.
반복자와 인덱스 변수를 사용하지 않아 코드가 깔끔해지고 오류가 날 일도 없다.

더욱이 컬렉션을 중첩해 순회해야 한다면, for-each문의 이점이 더 커진다.

// 2중 for each
for (Suit suit : suits) {
  for (Rank rank : ranks) {
    deck.add(new Card(suit, rank));
  }
}

위의 경우, 알아서 rank가 한번 전체 순회를 한 다음에 suit가 다음 순회를 한다.

for-each를 사용할 수 없는 상황은 다음과 같다.

  • 파괴 / 변형 컬렉션을 순회하면서 선택된 엘리먼트를 제거하거나 변경 한다면 아래와 같이 반복자(iterator)를 명시적으로 사용해야만 한다.
    for-each는 read only이다.
  • 병렬 순회: Parallel iteration

for-each 문은 명료하고 유연하며 버그를 예방해주며 성능 저하도 없다.

Item 59 - 라이브러리를 익히고 사용하라

원하는 기능을 직접 구현하는 것보다 표준 라이브러리를 사용하는 것은 다양한 이점이 있다.

  • 그 코드를 작성한 전문가의 지식과 경험을 활용할 수 있다.
  • 핵심적인 일과 관련없는 시간 소비가 줄어든다.
  • 따로 노력하지 않아도 성능이 지속해서 개선된다.
  • 기능이 점점 많아진다. 요구, 논의되는 기능은 대부분 다음 릴리즈에 기능이 추가된다.
    자바는 메이저 릴리스마다 새로운 기능을 설명하는 웹페이지를 공시하여 읽어보자.
  • 많은 사람들에게 익숙한 코드가 되기 때문에 읽기 좋고, 유지보수하기 좋고, 재활용하기 좋다.

라이브러리는 매우 방대하기 때문에 최소한 java.lang, java.util, java.io은 익숙해지자.

원하는 기능이 있으면 표준 라이브러리를 찾아보자.
직접 작성한 것보다 품질이 좋고, 개선될 가능성이 크다.

Item 60 - 정확한 답이 필요하다면 float와 double은 피하라

floatdouble은 넓은 범위의 수를 빠르고 정밀한 ‘근사치’로 계산하도록 설계되었다.
따라서 0.1 또는 10의 음의 거듭 제곱 등을 표현할 수 없기 때문에 금융 관련 계산에는 적합하지 않다.

System.out.println(1.03 - 0.42);
// 예상: 0.61
// 결과: 0.6100000000000001

정확한 계산이 필요할 경우, BigDecimal, int 또는 long을 사용하자.

BigDecimal a = new BigDecimal("1.03");
BigDecimal b = new BigDecimal("0.42");

System.out.println(a.subtract(b));
// 결과: 0.61

하지만 BigDecimal에는 기본 타입보다 사용하기 불편하고 성능적으로 훨씬 느리다.
이럴때 int 또는 long 타입을 사용해야 하는데, 값의 크기가 제한되고 소수점을 직접 관리해야 하는 점이 있다.

성능 저하에 신경쓰지 않으면 BigDecimal를 사용하자.
9자리 십진수로 표현할 수 있다면 int, 18자리 십진수로 표현할 수 있다면 long, 18자리가 넘어가면 BigDecimal을 사용하면 된다.

정확한 계산이 필요할 때는 float와 double은 피하라.

Item 61 - 박싱된 기본 타입보다는 기본 타입을 사용하라

자바의 데이터 타입은 기본 타입과 참조 타임 두개로 나누어진다.
기본 타입 : int, double, boolean
참조 타입 : String, List

또한 각각의 기본형에 대응되는 참조 타입이 존재하며, 이를 박싱된 기본 타입이라 한다.
박싱 타입 : Integer, Double, Boolean

자바엔 오토 박싱과 오토 언박싱 덕분에 두 타입을 크게 구분하지 않고 사용할 수는 있지만, 차이가 없는 것은 아니다. 둘의 차이는 다음과 같다.

  1. 박싱된 기본타입은 식별성을 갖는다.
  2. 기본 타입의 값은 언제나 유효하지만, 박싱된 기본타입은 null을 가질 수 있다.
  3. 기본타입은 박싱된 기본타입보다 시간과 메모리 사용면에서 더 효율적이다.

박싱된 기본타입을 기본타입처럼 사용하려 할 때 문제가 발생할 수 있다.
문제 사례 1

Comparator<Integer> naturalOrder = (i, j) -> (i < j) ? -1 : (i == j ? 0 : 1);

// 반환되는 값은 1이다.
naturalOrder.compare(new Integer(42), new Integer(42));

위의 코드에서는 반환 값이 1, 즉 같지 않고 앞의 수가 크다고 한다.
이는 박싱된 기본 타입에 == 연산자를 사용하면 객체 참조의 식별성을 검사하여 값이 같더라도 다르다고 반환한다.
따라서, 박싱된 기본 타입에 == 연산자를 사용하면 오류가 일어난다.

문제 사례 2

public class Unbelievable {
  static Integer i;

  public static void main(String[] args) {
    if (i == 42) {    // NullPointerException
      System.out.println("Hello!");
    }
  }
}

위는 NullPointerException를 유발한다.
기본 타입인 42와 박싱 타입 i가 서로 비교할 때, 언 박싱되어 null 참조를 하게되기 때문이다.
따라서, i를 기본타입으로 선언해야한다.

문제 사례 3

private static long sum() {
    Long sum = 0L;
    for (long i = 0; i <= Integer.MAX_VALUE; i++) {
      sum += i;   // 기본 타입 i가 매번 boxing을 수행한다.
    }
    return sum;
}

위 예시는 오류는 없지만, 박싱과 기본타입을 혼용하여 사용하기 때문에 매번 박싱과 언박싱이 반복해서 일어나 성능이 느려진다.

그럼에도 박싱된 기본 타입이 필요할 상황이 있다.

  1. 컬렉션의 원소, 키, 값에 사용
    컬렉션에서는 기본 타입을 담을 수 없기 때문에 박싱된 기본 타입을 사용
  2. 매개변수화 타입이나 메서드의 타입 매개변수로는 박싱된 기본 타입을 사용
  3. 리플렉션을 통한 메서드 호출을 할 때도 박싱된 기본 타입을 사용

박싱된 기본 타입을 필요할 때가 아니면, 기본 타입을 사용하자

item 62 - 다른 타입이 적절하다면 문자열 사용을 피하라

이번 장에서는 문자열을 쓰지 않아야 할 사례를 제공한다.

  • 문자열은 다른 값 타입을 대신하기에 적합하지 않다.
    받은 데이터가 수치라면 int, float, BigInteger 등 적당한 수치 타입으로 변환해야 한다.
    기본 타입이든 참조 타입이든 적절한 값 타입이 있다면 그것을 사용하고, 없다면 새로 하나 작성해야 한다.

  • 문자열은 열거 타입을 대신하기에 적합하지 않다.
    상수를 열거할 때는 문자열보다는 열거 타입이 월등히 낫다

  • 문자열은 흔한 타입을 대신하기에 적합하지 않다.
    여러 요소가 혼합된 타입의 데이터를 하나의 문자열로 표현하는 것을 지양하자.
    String compoundKey = className + "#" + i.next(); // 혼합 타입을 문자열로 처리  
    

    요소를 구분해주는 문자 #가 요소 중 하나에 쓰였다면 혼란스러운 결과를 초래한다.
    equals, toString, compareTo 메서드를 제공할 수 없다.
    따라서, 전용 클래스를 새로 만들어 private 클래스로 정적 멤버 클래스로 선언하자.

  • 문자열은 권한을 표현하기에 적합하지 않다.
    public class ThreadLocal { 
    private ThreadLocal() { } // 객체 생성 불가 
    // 현 스레드의 값을 키로 구분해 저장한다. 
    public static void set(String key, Object value); // (키가 가리키는) 현 스레드의 값을 반환한다. 
    public static Object get(String 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의 문제를 해결해주지만 개선할 부분이 아직 있다.
set과 get 메서드는 static 메서드일 필요없이 Key 클래스의 인스턴스 메서드로 변경한다.
이렇게 되면 Key는 더이상 스레드 지역변수를 구분하는 용도가 아니라 그 자체가 스레드 지역변수가 된다.

public final class ThreadLocal {
  public THreadLocal();
  public void set(Object value);
  public Object get();
}

ThreadLocal에서 쓰는 key를 String타입 대신 ThreadLocal 타입으로 만들어서 캡슐화하였다.
위를 통해 문자열 값으로 스레드를 구분하는 게 아니라 ThreadLocal 인스턴스 객체로 구분해 명확해졌다.

마지막으로 타입 안전성을 위해 Object를 제네릭으로 변경한다.

public final class ThreadLocal<T> {
  public ThreadLocal();
  public void set(T value);
  public T get();
} 

이제 결과적으로 java.lang.ThreadLocal과 비슷해졌다.
문자열 기반 API의 문제는 별도의 타입을 만들어 해결하면 된다.

item 63 - 문자열 연결은 느리니 주의하라

문자열 연산 +로 문자열 n개를 잇는 시간은 n2에 비례한다.
왜냐하면, 문자열은 불변이기 때문에 두 문자열을 연결하는 경우에 양쪽의 내용을 모두 복사해야 하기 때문에 성능 저하가 발생하기 때문이다.

따라서 StringBuilder를 사용하자.

item 64 - 객체는 인터페이스를 사용해 참조하라

적합한 인터페이스만 있다면 매개변수뿐 아니라 반환 값, 변수, 필드를 전부 인터페이스 타입으로 선언하라.

// 인터페이스 타입으로 변수를 선언하였다.  
Set<Son> sonSet = new LinkedHashSet<>();

인터페이스 타입을 사용하면 차후에 구현 클래스를 교체할 때 유연하다.
또한 손쉬운 교체로 성능 향상 또는 새로운 기능을 기대할 수 있다.
예를 들어 HashMapEnumMap으로 바꾸면 속도가 향상되고 순회 순서도 키의 순서와 같아지는 장점을 얻을 수 있다.

적합한 인터페이스가 없다면 클래스의 계층구조 중 필요한 기능을 만족하는 가장 덜 구체적인(상위의) 클래스를 타입으로 사용한다.
StringBigInteger와 같은 값 클래스가 대표적이다.
그리고 클래스 기반으로 작성된 프레임워크가 제공된 객체도 클래스를 참조해야 한다.
예로는 java.io 패키지의 여러 클래스가 있다.
마지막으로 PriorityQueue와 같이 인터페이스에는 없는 특별 메서드를 제공하는 클래스들이 그렇다.

가능한 인터페이스 타입으로 변수를 선언하자.

Item 65 - 리플렉션보다는 인터페이스를 사용하라

리플렉션 이란? 이미 로딩이 완료된 클래스에서 또 다른 클래스를 동적으로 로딩(Dynamic Loading)하여
생성자(Constructor), 멤버 필드(Member Variables) 그리고 멤버 메서드(Member Method) 등을 사용할 수 있도록 합니다.
클래스의 이름과 메서드 이름(String)을 전달하여 호출 할 수 있으며, 다른 클래스의 멤버 필드의 값도 수정 가능하다.

리플렉션을 이용하면, 컴파일 당시에 존재하지 않는 클래스도 사용 할 수있는 등 강력한 기능을 제공한다.
하지만 많은 단점이 있다.

  • 컴파일타임 타입 검사가 주는 이점을 하나도 누릴 수 없다.
    예외 검사의 이점을 누릴 수 없고 프로그램이 리플렉션 기능을 써서 존재하지 않는 혹은 접근할 수 없는 메서드를 호출하려 시도하면 런타임 오류가 발생한다.

  • 리플렉션을 이용하면 코드가 지저분하고 장황해진다.

  • 성능이 떨어진다.
    일반 메서드 호출보다 훨씬 느리고 고려해야 하는 요소가 많아 느리다.

리플렉션을 사용해야 하는 경우

그럼에도, 리플렉션이 필요한 경우가 있는데 런타임에 존재하지 않을 수도 있는 다른 클래스, 메서드, 필드와의 의존성을 관리할 때 적합하다.
버전이 여러 개 존재하는 외부 패키지를 다룰 때, 가장 오래된 버전만을 지원하도록 컴파일한 후 이후 버전의 클래스와 메서드 등은 리플렉션으로 접근한다.

리플렉션은 특수 시스템을 개발할 때 필요한 강력한 기능이지만, 단점도 많다.

Item 66 - 네이티브 메서드는 신중히 사용하라

자바 네이티브 인터페이스? 자바 프로그램이 네이티브 메서드를 호출하는 기술
C나 C++같은 네이티브 프로그래밍 언어로 작성한 메서드를 호출함을 말한다.

네이티브 메서드는 전통적으로 다음과 같은 상황에서 사용하였다.

  1. 플랫폼에 특화된 기능을 사용할 때
    하지만 자바 버젼이 올라가면서 필요성이 줄어들고 있다.
    특히 Java 9 에서는 Process API가 추가되어 OS 프로세스도 접근할 수 있다.

  2. 네이티브 코드로 작성된 기존 라이브러리를 사용할 때

  3. 성능 개선을 목적으로 성능에 결정적인 영향을 주는 영역만 따로 네이티브 언어로 작성
    하지만 이 또한, JVM이 발전하면서 기존에 사용하던 네이티브 라이브러리 보다 성능이 좋아진 경우도 있다.
    예를 들어 java.math가 처음 추가된 JDK 1.1의 BigInteger 클래스는 C언어로 작성된 라이브러리에 의존했지만
    JDK 1.3 버전부터 순수 자바로 구현되었고 보다 더 세심한 튜닝을 통해 원래의 네이티브 구현보다 더 빨라졌다.

또한 네이티브 메서드는 다양한 단점이 존재한다.

  • 네이티브 메서드를 사용하는 애플리케이션이 메모리 훼손 오류로부터 더 이상 안전하지 않다.
  • 플랫폼간 이식성이 낮다.
  • 디버깅이 어렵고, 부주의시 속도가 느려진다.
  • GC가 네이티브 메모리는 자동으로 회수하지 못하고, 추적도 못한다.
  • 자바와 네이티브 코드의 경계를 넘나들 때마다 비용이 추가 된다.
  • 네이티브 메서드와 자바 코드 사이의 접착 코드(glue code)를 작성하는 것은 번거롭고 가독성이 떨어진다.

Item 67 - 최적화는 신중히 하라

최적화를 하는 행위가 오히려 성능을 떨어뜨릴 수 있다.
따라서 이번 아이템은 다음과 같은 당부를 하였다.

빠른 프로그램보다는 좋은 프로그램을 작성하라 성능 때문에 견고한 구조를 희생하지 말자.
좋은 프로그램이지만 원하는 성능이 나오지 않는다면 그 아키텍처 자체가 최적화할 수 있는 길을 안내해줄 것이다.
좋은 프로그램은 정보 은닉 원칙을 따르므로 개별 구성요소의 내부를 독립적으로 설계할 수 있어 시스템의 영향을 주지 않고도 각 요소를 다시 설계할 수 있다.
완성된 설계의 기본 틀을 변경하려다 보면 유지보수하거나 개선하기 어려운 꼬인 구조의 시스템이 만들어지기 쉽기 때문에 설계 단계에서 성능을 반드시 염두에 두어야 한다.

성능을 제한하는 설계를 피하라 컴포넌트나 외부 시스템과의 소통 방식인 API, 네트워크 프로토콜, 영구 저장용 데이터 포맷등은 완성 후 변경하기 어렵다.
따라서 설계 단계에서 부터 성능을 염두해야한다.

API를 설계할 때 성능에 주는 영향을 고려하라 public 타입을 가변으로 만들면 내부 데이터를 변경할 수 있게 만들어 불필요한 방어적 복사를 수 없이 유발할 수 있다.
컴포지션으로 해결할 수 있음에도 상속 방식으로 설계한 public 클래스는 상위 클래스에 영원히 종속되며 그 성능까지도 물려받게 된다.
인터페이스가 아닌 구현 타입을 사용하는 것 역시 좋지 않으며 특정 구현체에 종속되어 더 빠른 구현체가 나오더라도 이용하지 못하게 된다.

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

각각의 최적화 시도 전후로 성능을 측정하라 90%의 시간을 단 10%의 코드에서 사용한다.
따라서 프로파일링 도구는 최적화 노력을 어디에 집중할지 도와준다.
가장 먼저 어떤 알고리즘을 사용했는지 살펴보는 것이 도움이 된다.

최적화를 섣불리하면 안된다. 왠만해서는 하지 말자.

Item 68 - 일반적으로 통용되는 명명 규칙을 따르라

자바 언어 명세에 명명 규칙이 잘 정립되어 있다.
크게 철자와 문법, 두 범주로 나누어진다.

철자 규칙

패키지, 클래스, 인터페이스, 메서드, 필드, 타입 변수의 이름을 다룬다.
특별한 이유가 없다면 반드시 따르자. 따르지 않으면 유지보수가 어려워지고 의미를 오해할 수 있어 오류 발생 가능성을 높인다.

패키지와 모듈

  • 각 요소를 점(.)으로 구분하여 계층적으로 짓는다
    java.util.function
  • 요소들은 모두 소문자 알파벳 또는 숫자로 짓는다
  • 외부에서도 사용될 패키지라면 조직의 인터넷 도메인 이름을 역순으로 한다
    com.ssafy.service
  • 각 요소는 일반적으로 8글자 이하의 짧은 단어로 짓는다
    utilities 보다는 의미가 통하는 약어인 util로 짓는다

클래스와 인터페이스

  • 하나 이상의 단어로 이루어진다
  • 각 단어의 첫 번째 글자는 대문자이다
    List, FutehrTask
  • max, min과 같이 널리 통용되는 줄임말이 아니면 단어는 줄여쓰지 않는다
  • 약자(줄임말)의 경우는 첫 글자만 대문자로 하는 경우가 많다
    HttpUrl

메서드와 필드

  • 첫 글자는 소문자이고 나머지 단어의 첫 글자는 대문자로 작성한다
    requireNonNull
  • 첫 단어가 약자라면 그 단어 전체는 소문자로 작성한다
  • 상수 필드는 예외로 모두 대문자로 작성하며 단어 사이에는 언더바(_)로 구분한다
    MAX_ID_NUM

지역변수

  • 약어를 사용해도 좋다. 변수가 사용된 문맥에서 의미를 쉽게 유추할 수 있기 때문이다
    Ex) For문의 i

타입 매개변수

  • 보통 한 문자로 표현한다
  • T: 임의의 타입, Type
  • E: 컬렉션 원소의 타입, Element
  • K: 맵의 키, Key
  • V: 맵의 값, Value
  • X: 예외, Exception
  • R: 메서드의 반환 타입, Return
  • T, U, V, T1, T2, T3: 임의 타입의 시퀀스

표로 정리 | 식별자 타입 | 예시 | |—-|—-| | 패키지와 모듈 | org.junit.jupiter.api, com.google.common.collect | | 클래스와 인터페이스 | Stream, FutureTask, HttpClient | | 메서드와 필드 | remove, groupingBy | | 상수 필드 | MIN_VALUE, MAX_VALUE | | 지역변수 | i, denom, houseNum | | 타입 매개변수 | T, E, K, V, X, R, U, V, T1, T2 |

문법 규칙

객체를 생성할 수 있는 클래스와 Enum

  • 보통 단수 명사 또는 명사구를 사용한다
    Thread, PriorityQueue

객체를 생성할 수 없는 클래스

  • 보통 복수형 명사를 사용한다
    Collectors, Collections

인터페이스

  • 클래스와 동일하다
    Collection, Comparator
  • able 또는 ible로 끝나는 형용사를 사용한다
    Runnable, Accessible

어노테이션

  • 큰 규칙 없이 명사, 동사, 형용사, 전치사가 두루 쓰인다
    BindingAnnotation, Inject, Singleton

메서드

  • 동사나 동사구를 사용한다
    append, drawImage
  • boolean을 반환한다면 is나 has로 시작하고 명사, 명사구, 형용사로 끝난다
    isBlank, hasSiblings
  • boolean을 반환하지 않거나 인스턴스의 속성을 반환한다면 보통 명사, 명사구 혹은 get으로 시작하는 동사구를 사용한다
    size, hashCode, getTime
  • 클래스가 한 속성의 getter와 setter를 모두 제공한다면 get~, set~이 좋다

특별한 메서드

  • 타입을 바꿔서 다른 타입의 객체를 반환하는 역할을 한다면 to~
    toString, toArray
  • 객체의 내용을 다른 뷰로 보여주는 메서드는 as~
    asList, asType
  • 객체의 값을 기본형(primitive) 타입으로 반환한다면 ~Value
    intValue
  • 정적 팩터리라면 from, of, valueOf, newInstance, getType 등을 흔히 사용한다

필드

  • 규칙이 덜 명확하고 덜 중요하다
  • boolean 타입의 필드명은 보통 boolean 접근자 메서드에서 앞 단어를 뺀 형태이다
    initialized, composite
  • 다른 타입의 필드는 명사, 명사구를 사용한다
    height, digits, bodyStyle
  • 지역변수 명은 비슷하지만 조금 더 느슨하다

출처

Joshua Bloch. Effective Java 3/E. n.p.: 인사이트, 2018년 11월 1일.

Reference