BOOK 6 - 이펙티브 자바(9)

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

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

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

지역변수 범위를 줄이는 방법

  • 지역변수는 가장 처음 쓰일 때 선언한다.
    • 지역변수의 범위는 선언된 지점부터 그 지점을 포함한 블록이 끝날 때까지이므로, 실제 사용하는 블록 바깥에서 선언된 변수는 그 블록이 끝난 뒤까지 살아 있게 된다.
    • 실수로 의도한 범위 앞 또는 뒤에서 그 변수를 사용하면 의도치 않은 결과가 나올 수 있다.
  • 거의 모든 지역변수는 선언과 동시에 초기화해야 한다.
    • 초기화에 필요한 정보가 충분하지 않다면 충분해질 때까지 선언을 미뤄야 한다.
    • try-catch 문은 예외이다.
      • 변수를 초기화하는 표현식에서 검사 예외를 던질 가능성이 있다면 try 블록 안에서 초기화해야 한다.
      • 변수 값을 try 블록 바깥에서도 사용해야 한다면 try 블록 앞에서 선언해야 한다.
  • 메서드를 작게 유지하고 한 가지 기능에 집중한다.
    • 한 메서드에서 여러가지 기능을 처리한다면 그 중 한 기능과만 관련된 지역변수라도 다른 기능을 수행하는 코드에서 접근할 수 있을 것이다.

반복문에서의 지역변수

  • 반복문은 독특한 방식으로 변수 범위를 최소화해준다.
  • 반복문에서는 반복 변수의 범위가 반복문의 몸체, 그리고 for 키워드와 몸체 사이의 괄호 안으로 제한된다.
  • 따라서 반복 변수의 값을 반복문이 종료된 후에도 써야 하는 상황이 아니라면 while 문보다 for 문을 쓰는 편이 낫다.
for (Element e : c) {
  ...
}
  • 반복자를 사용해야 하는 상황이면 for-each 문 대신 전통적인 for 문을 쓰는 것이 낫다.
for (Iterator<Element> i = c.iterator(); i.hasNext();) {
  Element e = i.next();
  ...
}

for문의 장점

  • 반복 변수 유효 범위가 for 문 범위와 일치하여 똑같은 이름의 변수를 여러 반복문에서 써도 서로 아무런 영향을 주지 않는다.
    • 복사 붙여넣기 오류를 줄여준다.
    • while 문의 경우 반복 변수를 while 문 바깥에서 선언하면, 변수의 유효 범위가 while 문 밖에서도 끝나지 않아 복사 붙여넣기 오류가 발생할 수 있다.
  • while 문보다 짧아서 가독성이 좋다.
for문에서의 비용 절감
for (int i = 0, n = expensiveComputation(); i < n; i++) {
  ...
}
  • 반복 여부를 결정 짓는 변수 i 의 한곗값을 변수 n 에 저장하여 반복 때마다 다시 계산해야 하는 비용을 없앴다.

  • 같은 값을 반환하는 메서드를 매번 호출한다면 이 관용구를 사용하는 것이 좋다.

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

전통적인 for문의 단점

  • 다음은 전통적인 for 문으로 컬렉션과 배열을 순회하는 코드이다.
for (Iterator<Element> i = c.iterator(); i.hasNext();) {
  Element e = i.next();
  ...
}
for (int i = 0; i < a.length; i++) {
  ...
}
  • 이 관용구들은 while 문보다는 낫지만 가장 좋은 방법은 아니다.
  • 반복자와 인덱스 변수는 코드를 지저분하게 할 뿐 진짜 필요한 건 원소들 뿐이다.
  • 쓰이는 요소 종류가 늘어나면 오류가 생길 가능성이 높아진다.
    • 혹시 잘못된 변수를 사용하더라도 컴파일러가 잡아주리라는 보장도 없다.
  • 또한 컬렉션이냐 배열이냐에 따라 코드 형태가 달라지므로 주의해야 한다.

for-each문

  • 위 문제들은 for-each문을 사용하면 모두 해결된다.
  • 반복자와 인덱스 변수를 사용하지 않으니 코드가 깔끔해지고 오류가 날 일도 없다.
  • 하나의 관용구로 컬렉션과 배열을 모두 처리할 수 있어 어떤 컨테이너를 다루는지 신경쓰지 않아도 된다.
for (Element e : elements) {
  ...
} 

전통적인 for문에서의 중첩 순회

  • 다음 코드에는 버그가 숨어있다.
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());

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());
  }
}
  • 문제는 바깥 컬렉션의 반복자 i 에서 next 메서드가 너무 많이 불린다는 것이다.
  • i.next()Suit 하나당 한 번씩만 불려야 하는데, 안쪽 반복문에서 호출되는 바람에 Rank 하나당 한 번씩 불리고 있다.
    • 따라서 Suit 가 바닥나면 반복문에서 NoSuchElementException 을 던진다.

해결책

  • 문제를 해결하려면 바깥 반복문에 바깥 원소를 저장하는 변수를 하나 추가해야 한다.
for (Iterator<Suit> i = suits.iterator(); i.hasNext()) {
  Suit suit = i.next();
  for (Iterator<Rank> j = ranks.iterator(); j.hasNext()) {
    deck.add(new Card(suit, j.next()));
  }
}
  • 문제 해결은 되었지만 코드가 깔끔하지는 않다.

for-each문에서의 중첩 순회

  • 컬렉션을 중첩 순회해야 한다면 for-each 문의 이점이 더욱 커진다.
for (Suit suit : suits) {
  for (Rank rank : ranks) {
    deck.add(new Card(suit, rank));
  }
}

for-each문을 사용할 수 없는 상황

  • 파괴적인 필터링
    • 컬렉션을 순회하면서 원소를 제거해야 한다면 반복자의 remove 메서드를 호출해야 한다.
    • 자바 8부터는 CollectionremoveIf 메서드를 사용해 컬렉션을 명시적으로 순회하는 일을 피할 수 있다.
  • 변형
    • 리스트나 배열을 순회하면서 그 원소의 값 일부 혹은 전체를 변경해야 한다면 리스트의 반복자나 배열의 인덱스를 사용해야 한다.
  • 병렬 반복
    • 여러 컬렉션을 병렬로 순회해야 한다면 각각의 반복자와 인덱스 변수를 사용해 엄격하고 명시적으로 제어해야 한다.

Iterable 인터페이스

  • for-each문은 컬렉션과 배열은 물론 Iterable 인터페이스를 구현한 객체라면 무엇이든 순회할 수 있다.
  • Iterable 인터페이스는 다음과 같이 메서드가 단 하나 뿐이다.
public interface Iterable<E> {
  Iterator<E> iterator();
}
  • Iterable 을 처음부터 직접 구현하기는 까다롭지만, 원소들의 묶음을 표현하는 타입을 작성해야 한다면 Iterable 을 구현하는 쪽으로 고민하기 바란다.
    • 해당 타입에서 Collection 인터페이스는 구현하지 않기로 했더라도 말이다.
    • Iterable 을 구현해두면 for-each문을 사용할 때 아주 유용할 것이다.

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

표준 라이브러리의 이점

  • 표준 라이브러리를 사용하면 그 코드를 작성한 전문가의 지식과 다른 프로그래머들의 경험을 활용할 수 있다.
  • 핵심적인 일과 크게 관련없는 문제를 해결하느라 시간을 허비하지 않아도 된다.
  • 따로 노력하지 않아도 성능이 지속해서 개선된다.
    • 자바 플랫폼 라이브러리의 많은 부분이 수 년에 걸쳐 지속해서 다시 작성되며, 때론 성능이 극적으로 개선되기도 한다.
  • 기능이 점점 많아진다.
    • 라이브러리에 부족한 부분이 있다면 개발자 커뮤니티에서 이야기가 나오고 논의된 후, 다음 릴리즈에 해당 기능이 추가되곤 한다.
  • 여러분이 작성한 코드가 많은 사람에게 낯익은 코드가 된다.
    • 다른 개발자들이 더 읽기 좋고, 유지보수하기 좋고, 재활용하기 쉬운 코드가 된다.

Random 라이브러리

  • 자바 7부터는 Random 을 더이상 사용하지 않는 것이 좋다.
  • ThreadLocalRandom 으로 대체하면 대부분 잘 동작한다.
    • Random 보다 더 고품질의 무작위 수를 생성하고, 속도도 빠르다.
  • 포크-조인 풀이나 병렬 스트림에서는 SplittableRandom 을 사용하는 게 좋다.

기본적인 라이브러리

  • 라이브러리가 방대하여 모든 API 문서를 알 수는 없지만, 기본적으로 java.lang , java.util , java.io 와 그 하위 패키지들에는 익숙해져야 한다.
  • 또한 컬렉션 프레임워크와 스트림 라이브러리, java.util.concurrent 의 동시성 기능도 알아두면 큰 도움이 된다.

정리

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

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

  • floatdouble 은 과학과 공학 계산용으로 설계되었다.
  • 이진 부동소수점 연산에 쓰이며, 넓은 범위의 수를 빠르게 정밀한 ‘근사치’로 계산하도록 설계되었다.
    • 따라서 정확한 결과가 필요할 때는 사용하면 안된다.
  • floatdouble 은 특히 금융 관련 계산과는 맞지 않는다.
    • 0.1 혹은 10의 음의 거듭제곱 수를 표현할 수 없기 때문이다.

잘못된 double 사용 예제

  • 1달러가 있고, 10센트, 20센트, 30센트, … 1달러짜리의 사탕이 있다고 했을 때, 사탕을 몇 개나 살 수 있고, 잔돈은 얼마나 남을지 계산하는 예제를 보자.
public static void main(String[] args) {
  double funds = 1.00;
  int itemsBought = 0;
  
  for (double price = 0.10; funds >= price; price += 0.10) {
    funds -= price;
    itemsBought++;
  }
  
  System.out.println(itmesBought + " 개 구입");
  System.out.println("잔돈: " + funds);
}
  • 프로그램 실행 시 사탕 3개 구입 후 잔돈은 0.39999999999999 달러가 남았다고 출력한다.
  • 부동소수 타입인 double 을 사용한 결과이다.

BigDecimal을 사용한 해결 방법

  • 금융 계산에는 BigDecimal , int 혹은 long 을 사용해야 한다.
public static void main(String[] args) {
  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.substract(price);
    itemsBought++;
  }
}
  • BigDecimal 의 생성자 중 문자열을 받는 생성자를 사용했다.
    • 계산시 부정확한 값이 사용되는 걸 막기 위함이다.
  • BigDecimal 은 8가지 반올림 모드를 이용하여 반올림을 완벽히 제어할 수 있다.
    • 법으로 정해진 반올림을 수행해야 하는 비즈니스 계산에서 아주 편리한 기능이다.

BigDecimal의 단점

  • 기본 타입보다 쓰기가 훨씬 불편하고 훨씬 느리다.
  • BigDecimal 의 대안으로 int 혹은 long 타입을 쓸 수도 있다.
    • 그럴 경우 다룰 수 있는 값의 크기가 제한되고, 소수점을 직접 관리해야 한다.
    • 성능이 중요하고 소수점을 직접 추적할 수 있고, 숫자가 너무 크지 않다면 intlong 을 사용하는 게 좋다.
    • 숫자를 9자리 십진수로 표현할 수 있다면 int 를 사용하고, 18자리 십진수로 표현할 수 있다면 long 을 사용한다.
    • 18자리를 넘어간다면 BigDecimal 을 사용해야 한다.

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

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

기본 타입과 박싱된 기본 타입의 차이점

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

Integer 비교자 예제

  • Integer 값을 오름차순으로 정렬하는 비교자 코드를 보자.
Comparator<Integer> naturalOrder = (i, j) -> (i < j) ? -1 : (i == j ? 0 : 1);
  • naturalOrder.compare(new Integer(42), new Integer(42)) 를 실행했을 때 0 을 출력해야 하지만, 1 을 출력하는 오류가 있다.
  • 첫번째 검사 i < j 는 잘 작동한다.
    • i, j 가 참조하는 인스턴스는 기본 타입 값으로 변환된다.
  • 두번째 검사인 i == j 에서 두 객체 참조의 식별성을 검사하게 된다.
    • 즉, ij 가 참조하는 인스턴스가 다르기 때문에 비교의 결과가 false 가 되고, 비교자는 1을 반환하는 것이다.
  • 즉, 박싱된 기본 타입에 == 연산자를 사용하면 오류가 일어난다.

  • 실무에서 기본 타입을 다루는 비교자가 필요하다면 Comparator.naturalOrder() 를 사용하는게 좋다.

오토 언박싱

public class Unbelievable {
  static Integer i;
  
  public static void main(String[] args) {
    if (i == 42) {
      System.out.println("unbelievable!");
    }
  }
}
  • 위 프로그램을 실행했을 때 NullPointerException 이 발생한다.
  • i 가 기본 타입이 아닌 Integer 이고, 다른 참조 타입 필드와 마찬가지로 초기값이 null 이기 때문이다.
  • 기본 타입과 박싱된 기본 타입을 혼용한 연산에서는 박싱된 기본 타입의 박싱이 자동으로 풀린다.
  • null 참조를 언박싱하면 NullPointerException 이 발생한다.

성능 문제

public static void main(String[] args) {
  Long sum = 0L;
  for (long i = 0; i <= Integer.MAX_VALUE; i++) {
    sum += i;
  }
  System.out.println(sum);
}
  • 이 프로그램은 성능이 매우 안좋다.
  • sum 변수를 박싱된 기본 타입으로 선언하였기 때문에, sum += i 연산 과정에서 박싱과 언박싱이 반복해서 일어나기 때문이다.

박싱된 기본 타입을 써야하는 경우

  • 컬렉션의 원소, 키, 값으로 쓴다.
  • 매개변수화 타입이나 매개변수화 메서드의 타입 매개변수로는 박싱된 기본 타입을 써야 한다.
  • 리플렉션을 통해 메서드를 호출할 때도 박싱된 기본 타입을 써야한다.

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

  • 문자열은 텍스트를 표현하도록 설계되었다.
  • 그런데 문자열은 흔하고 자바가 잘 지원해주어 원래 의도하지 않은 용도로도 쓰이는 경향이 있다.

문자열을 쓰지 않아야 할 경우

다른 값 타입을 대신하는 경우

  • 많은 경우에 파일, 네트워크, 키보드 입력으로부터 데이터를 받을 때 문자열을 사용한다.
  • 입력받을 데이터가 진짜 문자열일 때만 그렇게 하는게 좋다.
  • 받은 데이터가 수치형이라면 int , float , BigInteger 등 적당한 수치 타입으로 변환해야 한다.
  • ‘예/아니오’ 형태라면 적절한 열거 타입이나 boolean 으로 변환해야 한다.
  • 기본 타입이든 참조 타입이든 적절한 값 타입이 있다면 그것을 사용하고, 없다면 새로 하나 작성하는게 좋다.

열거 타입을 대신하는 경우

  • 상수를 열거할 때는 문자열보다는 열거 타입이 월등히 낫다.

혼합 타입을 대신하는 경우

  • 여러 요소가 혼합된 데이터를 하나의 문자열로 표현하는 것은 대체로 좋지 않다.
String compoundKey = className + "#" + i.next();
  • 각 요소를 개별로 접근하려면 문자열을 파싱해야 해서 느리고, 오류 가능성도 커진다.
  • 적절한 equals , toString , compareTo 메서드를 제공할 수 없으며, String 이 제공하는 기능에만 의존해야 한다.
  • 차라리 전용 클래스를 새로 만드는 편이 낫다.
    • 이런 클래스는 보통 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 ket, Object value);
  public static Object get(Key key);
}
  • setget 은 이제 정적 메서드일 이유가 없으니 Key 클래스의 인스턴스 메서드로 바꿀 수 있다.
  • 이렇게 하면 Key 는 더이상 스레드 지역변수를 구분하기 위한 키가 아니라, 그 자체가 스레드 지역변수가 된다.
  • 결과적으로 톱레벨 클래스인 ThreadLocal 은 별달리 하는 일이 없어지므로 중첩 클래스 Key 의 이름을 ThreadLocal 로 바꿀 수 있다.
public final class ThreadLocal {
  public ThreadLocal();
  public void set(Object value);
  public void get();
}
  • 이 API에서는 get 으로 얻은 Object 를 실제 타입으로 형변환해야 하기 때문에 타입 안전하지 않다.
  • 매개변수화 타입으로 선언하면 문제가 해결된다.
public final class ThreadLocal<T> {
  public ThreadLocal();
  public void set(T value);
  public T get();
}
  • 처음의 문자열 기반 API는 타입안전하게 만들 수 없다.

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

  • 문자열 연결 연산자 + 는 여러 문자열을 하나로 합쳐주는 편리한 수단이다.
  • 문자열 연결 연산자로 문자열 n개를 잇는 시간은 n^2에 비례한다.
  • 문자열은 불변이라서 두 문자열을 연결할 경우 양쪽의 내용을 모두 복사해야 하므로 성능저하는 피할 수 없는 결과다.
public String statement() {
  String result = "";
  for (int i = 0; i < numItems(); i++) {
    result += lineForItem(i);
  }
  return result;
}
  • 아이템이 많을 경우 이 메서드는 심각하게 느려질 수 있다.

StringBuilder를 사용한 성능 개선

  • 성능을 포기하고 싶지 않다면 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();
}
  • StringBuilder 를 전체 결과를 담기에 충분한 크기로 초기화했다.

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

  • 적합한 인터페이스가 있다면 매개변수, 반환값, 변수, 필드를 전부 인터페이스 타입으로 선언하는게 좋다.
    • 객체의 실제 클래스를 사용해야 할 상황은 오직 생성자로 생성할 때 뿐이다.
Set<Son> sonSet = new LinkedHashSet<>();
  • 인터페이스 타입으로 사용하면 프로그램이 훨씬 유연해질 것이다.
    • 나중에 구현 클래스를 교체하고자 한다면 그저 새 클래스의 생성자(혹은 정적 팩터리)를 호출해주기만 하면 된다.
Set<Son> sonSet = new HashSet<>();
  • 주변 코드는 옛 클래스의 존재를 애초부터 몰랐으니 이러한 변화에 아무런 영향도 받지 않는다.

구현 타입 교체

주의점

  • 원래의 클래스가 인터페이스의 일반 규약 이외의 특별한 기능을 제공하며, 주변 코드가 이 기능에 기대어 동작한다면 새로운 클래스도 반드시 같은 기능을 제공해야 한다.
  • 예를 들어, 주변 코드가 LinkedHashSet 이 따르는 순서 정책을 가정하고 동작하는 상황에서 이를 HashSet 으로 바꾸면 문제가 생길 수 있다.
    • HashSet 은 반복자의 순회 순서를 보장하지 않기 때문이다.

동기

  • 원래 클래스보다 성능이 좋거나 멋진 신기능을 제공할 수 있다.
  • 예를 들어, HashMap 을 참조하던 변수를 EnumMap 으로 바꾸면 속도가 빨라지고 순회 순서도 키의 순서와 같아진다.
    • 단, EnumMap 은 키가 열거 타입일 때만 사용 가능하다.
    • 키 타입과 상관없이 사용할 수 있는 LinkedHashMap 으로 바꾼다면 성능은 비슷하게 유지하면서 순회 순서를 예측할 수 있다.

적합한 인터페이스가 없는 경우

  • 적합한 인터페이스가 없다면 당연히 클래스로 참조해야 한다.
  • 적합한 인터페이스가 없다면 클래스의 계층구조 중 필요한 기능을 만족하는 가장 덜 구체적인 상위 클래스를 타입으로 사용하자.

값 클래스

  • StringBigInteger 같은 값 클래스가 그렇다.

  • 값 클래스는 final 인 경우가 많고, 상응하는 인터페이스가 별도로 존재하는 경우가 드물다.
  • 이런 값 클래스는 매개변수, 변수, 필드, 반환 타입으로 사용해도 무방하다.

클래스 기반으로 작성된 프레임워크가 제공하는 객체

  • 이런 경우라도 특정 구현 클래스보다는 추상 클래스같은 기반 클래스를 사용해 참조하는게 좋다.
  • java.io 패키지의 OutputStream 등 여러 클래스가 이 부류에 속한다.

인터페이스에 없는 특별한 메서드를 제공하는 클래스

  • 예를 들어, PriorityQueue 클래스는 Queue 인터페이스에는 없는 comparator 메서드를 제공한다.
  • 클래스 타입을 직접 사용하는 경우는 이런 추가 메서드를 꼭 사용해야 하는 경우로 최소화해야 한다.

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

리플렉션

  • java.lang.reflect 의 리플렉션 기능을 이용하면 프로그램에서 임의의 클래스에 접근할 수 있다.
  • Class 객체가 주어지면 그 클래스의 생성자, 메서드, 필드에 해당하는 Constructor , Method , Field 인스턴스를 가져올 수 있다.
    • 또 그 인스턴스들로 그 클래스의 멤버 이름, 필드 타입, 메서드 시그니처 등을 가져올 수 있다.
  • 인스턴스를 이용해 각각에 연결된 실제 생성자, 메서드, 필드를 조작할 수도 있다.
    • 인스턴스를 통해 해당 클래스의 인스턴스를 생성하거나, 메서드를 호출하거나 필드에 접근할 수 있다.
  • 리플렉션을 이용하면 컴파일 당시에 존재하지 않던 클래스도 이용할 수 있다.

리플렉션의 단점

  • 컴파일타임 타입 검사가 주는 이점을 하나도 누릴 수 없다.
    • 예외 검사도 마찬가지다.
    • 리플렉션 기능을 써서 존재하지 않는 혹은 접근할 수 없는 메서드를 호출하려 시도하면 런타임 오류가 발생한다.
  • 코드가 지저분하고 장황해진다.
  • 성능이 떨어진다.
    • 리플렉션을 통한 메서드 호출은 일반 메서드 호출보다 훨씬 느리다.

리플렉션 활용법

  • 리플렉션은 아주 제한된 형태로만 사용해야 그 단점을 피하고 이점만 취할 수 있다.
  • 컴파일타임에 이용할 수 없는 클래스를 사용해야만 하는 경우에, 비록 컴파일타임이라도 적절한 인터페이스나 상위 클래스를 이용할 수는 있을 것이다.
  • 이런 경우라면 리플렉션은 인스턴스 생성에만 쓰고, 이렇게 만든 인스턴스는 인터페이스나 상위 클래스로 참조해 사용하자.

Set 인스턴스 생성 예제

public static void main(String[] args) {
  // 클래스 이름을 Class 객체로 변환
  Class<? extends Set<String>> cl = null;
  try {
    cl = (Class<? extends Set<String>>)Class.forName(args[0]);
  } catch (ClassNotFoundException e) {
    fatalError("클래스를 찾을 수 없습니다.");
  }
  
  Constructor<? extends Set<String>> cons = null;
  try {
    cons = cl.getDeclaredConstructor();
  } catch (NoSuchMethodException e) {
    fatalError("매개변수 없는 생성자를 찾을 수 없습니다.");
  }
  
  // Set 인스턴스 생성
  Set<String> s = null;
  try {
    s = cons.newInstance();
  } catch (IllegalAccessException e) {
    fatalError("생성자에 접근할 수 없습니다.");
  } catch (InstantiationException e) {
    fatalError("클래스를 인스턴스화할 수 없습니다.");
  } catch (InvocationTargetException e) {
    fatalError("생성자가 예외를 던졌습니다.: " + e.getCause());
  } catch (ClassCastException e) {
    fatalError("Set을 구현하지 않은 클래스입니다.");
  }
  
  // 생성한 Set 사용
  s.addAll(Arrays.asList(args).subList(1, args.length));
  System.out.println(s);
}
  • 명령줄의 첫번째 인수로 클래스 이름을 받아 정확한 클래스를 확정한다.
  • 생성한 Set에 두번째 이후의 인수들을 추가한 다음 출력한다.
  • 인수들이 출력되는 순서는 첫번째 인수로 지정한 클래스가 무엇이냐에 따라 달라진다.
    • HashSet 을 지정하면 무작위 순서가 될 것이고, TreeSet 을 지정하면 알파벳 순서가 될 것이다.
예제에서 보이는 리플렉션 단점
  • 런타임에 총 여섯가지나 되는 예외를 던질 수 있다.
    • 그 모두가 인스턴스를 리플렉션없이 생성했다면 컴파일타임에 잡아낼 수 있었을 예외들이다.
  • 클래스 이름만으로 인스턴스를 생성해내기 위해 코드가 매우 길어졌다.
    • 참고로, 리플렉션 예외를 각각 잡는 대신 ReflectiveOperationException 을 잡도록 하여 코드 길이를 줄일 수도 있다.

리플렉션이 적합한 경우

  • 런타임에 존재하지 않을 수도 있는 다른 클래스, 메서드, 필드와의 의존성을 관리할 때 적합하다.
    • 버전이 여러개 존재하는 외부 패키지를 다룰 때 유용하다.
  • 가동할 수 있는 최소한의 환경, 즉 주로 가장 오래된 버전만을 지원하도록 컴파일한 후, 이후 버전의 클래스와 메서드 등은 리플렉션으로 접근하는 방식이다.
  • 접근하려는 새로운 클래스나 메서드가 런타임에 존재하지 않을 수 있다는 사실을 반드시 감안해야 한다.

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

Java Native Interface

  • 자바 프로그램이 네이티브 메서드를 호출하는 기술이다.
  • 네이티브 메서드란 C나 C++ 같은 네이티브 프로그래밍 언어로 작성한 메서드를 말한다.

네이티브 메서드 사용

  • 레지스트리 같은 플랫폼 특화 기능을 사용한다.
  • 네이티브 코드로 작성된 기존 라이브러리를 사용한다.
  • 성능 개선을 목적으로 성능에 결정적인 영향을 주는 영역만 따로 네이티브 언어로 작성한다.

네이티브 메서드 단점

  • 네이티브 언어가 안전하지 않으므로 네이티브 메서드를 사용하는 어플리케이션도 메모리 훼손 오류로부터 안전하지 않다.
  • 네이티브 언어는 자바보다 플랫폼을 많이 타서 이식성도 낮고 디버깅도 어렵다.
  • 주의하지 않으면 속도가 오히려 느려질수도 있다.
    • 성능 개선을 목적으로 네이티브 메서드를 사용하는 것은 거의 권장하지 않는다.
    • 대부분의 작업에서 지금의 자바는 다른 플랫폼에 견줄만한 성능을 보인다.
  • 가비지 컬렉터가 네이티브 메모리는 자동 회수하지 못하고, 심지어 추적조차 할 수 없다.
  • 자바 코드와 네이티브 코드의 경계를 넘나들 때마다 비용도 추가된다.
  • 네이티브 코드와 자바 코드 사이의 접착 코드를 작성해야 하는데, 귀찮고 가독성도 떨어진다.

최적화는 신중히 하라

  • 최적화는 좋은 결과보다는 해로운 결과로 이어지기 쉽고, 섣불리 진행하면 특히 더 그렇다.
    • 빠르지도 않고 제대로 동작하지도 않으면서 수정하기는 어려운 소프트웨어를 탄생시키는 것이다.
  • 성능때문에 견고한 구조를 희생시키지 말자.
    • 빠른 프로그램보다는 좋은 프로그램을 작성해야 한다.
  • 좋은 프로그램은 정보 은닉 원칙을 따르므로 개별 구성요소의 내부를 독립적으로 설계할 수 있다.
    • 따라서 시스템의 나머지에 영향을 주지 않고도 각 요소를 다시 설계할 수 있다.

설계 단계에서의 성능

  • 성능을 제한하는 설계를 피해야 한다.
    • 완성 후 변경하기가 가장 어려운 설계 요소는 컴포넌트끼리 혹은 외부 시스템과의 소통 방식이다.
    • API, 네트워크 프로토콜 등이 대표적이다.
    • 이런 설계 요소들은 완성 후에는 변경하기 어렵거나 불가능할 수 있으며, 시스템 성능을 심각하게 제한할 수 있다.
  • API를 설계할 때 성능에 주는 영향을 고려해야 한다.
    • public 타입을 가변으로 선언하여 내부 데이터를 변경할 수 있게 만들면, 불필요한 방어적 복사를 수없이 유발할 수 있다.
    • 컴포지션으로 해결할 수 있음에도 상속 방식으로 설계한 public 클래스는 상위 클래스에 영원히 종속되며, 그 성능 제약까지도 물려받게 된다.
    • 인터페이스 대신 구현 타입을 사용하면, 특정 구현체에 종속되게 하여 나중에 더 빠른 구현체가 나오더라도 이용하지 못하게 된다.

성능 문제 예시 - java.awt.Component

  • Component 클래스의 getSize 메서드는 Dimension 인스턴스를 반환한다.
  • Dimension 은 가변으로 설계했기 때문에, getSize 를 호출하는 모든 곳에서 Dimension 인스턴스를 방어적 복사를 위해 새로 생성해야만 한다.
다른 설계 방법
  1. Dimension 을 불변으로 만든다.

  2. getSizegetHeightgetWidth 로 나눈다.

    • 즉, Dimension 객체의 기본 타입 값들을 각각 반환하는 방식이다.

성능 측정

  • 최적화 시도 전후로 성능을 측정해야 한다.
  • 시도한 최적화 기법이 성능을 눈에 띄게 높이지 못하는 경우가 많고, 심지어 더 나빠지게 할 때도 있다.
  • 자바는 프로그래머가 작성하는 코드와 CPU에서 수행하는 명령 사이의 추상화 격차가 크기 때문에, 최적화로 인한 성능 변화를 일정하게 예측하기가 더 어렵다.
  • 자바의 성능 모델은 정교하지 않을 뿐더러, 구현시스템, 릴리스, 프로세서마다 차이가 있다.
    • 프로그램을 여러가지 자바 플랫폼이나 여러 하드웨어 플랫폼에서 구동한다면, 최적화의 효과를 그 각각에서 측정해야 한다.

프로파일링 도구

  • 프로파일링 도구는 최적화 노력을 어디에 집중해야 할지 찾는데 도움을 준다.
  • 개별 메서드의 소비 시간과 호출 횟수 같은 런타임 정보를 제공하여, 집중할 곳은 물론 알고리즘을 변경해야 한다는 사실을 알려주기도 한다.
    • 시간이 거듭제곱으로 증가하는 알고리즘이 숨어있다면 더 효율적인 것으로 교체해야 한다.
  • 시스템 규모가 커질수록 프로파일러가 더 중요해진다.

정리

  • 잘 설계된 API는 성능도 좋은게 보통이다.
  • 성능을 위해 API를 왜곡하는 것은 매우 안좋은 생각이다.
  • 신중하게 설계하여 깨끗하고 명확하고 멋진 구조를 갖춘 프로그램을 완성한 다음에야 최적화를 고려해볼 차례가 된다.

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

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

철자 규칙

  • 철자 규칙은 패키지, 클래스, 인터페이스, 메서드, 필드, 타입 변수의 이름을 다룬다.
  • 특별한 이유가 없는 한 반드시 따라야 한다.
    • 규칙을 어긴 API는 사용하기 어렵고, 유지보수하기도 어렵다.

패키지

  • 패키지와 모듈 이름은 각 요소를 . 으로 구분하여 계층적으로 짓는다.
  • 요소들은 모두 소문자 알파벳 혹은 숫자로 이루어진다.
  • 조직 바깥에서도 사용될 패키지라면, 조직의 인터넷 도메인 이름을 역순으로 사용한다.
    • 예를 들어, com.google 이런식이다.
  • 표준 라이브러리와 선택적 패키지들은 각각 javajavax 로 시작한다.
  • 패키지 이름의 나머지는 해당 패키지를 설명하는 하나 이상의 요소로 이뤄진다.
    • 일반적으로 8자 이하의 짧은 단어로 한다.
    • 요소의 이름은 보통 한 단어 혹은 약어로 이루어진다.
    • util , awt 같은 식이다.
  • 많은 기능을 제공하는 경우엔 계층을 나눠 두 개 이상의 요소로 구성해도 좋다.

클래스와 인터페이스

  • 클래스와 인터페이스의 이름은 하나 이상의 단어로 이뤄지며, 각 단어는 대문자로 시작한다.
  • 약자의 경우 첫글자만 대문자로 할지 전체를 대문자로 할지는 논란이 있다.
    • 첫글자만 대문자로 하는쪽이 훨씬 많다.
    • HttpUrl 처럼 여러 약자가 혼합된 경우라도 각 약자의 시작과 끝을 명확히 알 수 있기 때문이다.

메서드와 필드

  • 메서드와 필드 이름은 첫 글자를 소문자로 쓴다는 점만 빼면 클래스 명명 규칙과 같다.
  • 상수 필드는 모두 대문자로 쓰며, 단어 사이는 밑줄로 구분한다.
    • 예를 들어 NEGATIVE_INFINITY 같은 식이다.
    • 상수 필드는 값이 불변인 static final 필드를 말한다.

지역변수

  • 지역변수에도 다른 멤버와 비슷한 명명규칙이 적용된다.
  • 단, 약어를 써도 그 변수가 사용되는 문맥에서 의미를 쉽게 유추할 수 있기 때문에 써도 좋다.

  • 입력 매개변수도 지역변수의 하나지만, 메서드 설명 문서에까지 등장하는 만큼 일반 지역변수보다는 신경을 써야한다.

타입 매개변수

  • 타입 매개변수의 이름은 보통 한 문자로 표현한다.
  • 대부분 다음 다섯가지 중 하나다.
    • 임의의 타입 : T , U , V
    • 컬렉션 원소의 타입 : E
    • 맵의 키와 값 : K , V
    • 예외 : X
    • 메서드의 반환 타입 : R

문법 규칙

  • 문법 규칙은 철자 규칙과 비교하면 더 유연하고 논란도 많다.
  • 패키지에 대한 규칙은 따로 없다.

클래스와 인터페이스

  • 객체를 생성할 수 있는 클래스의 이름은 보통 단수 명사나 명사구를 사용한다.
    • Thread , ChessPiece
  • 객체를 생성할 수 없는 클래스의 이름은 보통 복수형 명사로 짓는다.
    • Collectors , Collections
  • 인터페이스 이름은 클래스와 똑같이 짓거나 able 혹은 ible 로 끝나는 형용사로 짓는다.
    • Runnable , Iterable , Accessible

메서드

  • 어떤 동작을 수행하는 메서드의 이름은 동사나 동사구로 짓는다.
    • append , drawImage
  • boolean 값을 반환하는 메서드라면 보통 ishas 로 시작하고, 명사나 명사구, 혹은 형용사로 기능하는 단어나 구로 끝나도록 짓는다.
    • isDigit , isEmpty , hasSiblings
  • 해당 인스턴스의 속성을 반환하는 메서드의 이름은 보통 명사, 명사구, 혹은 get 으로 시작하는 동사구로 짓는다.
    • size , hashCode , getTime
    • get 으로 시작하는 형태는 주로 JavaBeans 명세에 뿌리를 두고 있다.
      • 자바빈즈는 재사용을 위한 컴포넌트 아키텍처의 초기버전 중 하나로, 최근의 도구 중에도 이 명명 규칙을 따르는 경우가 제법 많다.
      • 클래스가 한 속성의 게터와 세터를 모두 제공할 때도 적합한 규칙이다.
특별한 형태의 메서드 이름
  • 객체의 타입을 바꿔서 다른 타입의 또 다른 객체를 반환하는 인스턴스 메서드 : toType
    • toString , toArray
  • 객체의 내용을 다른 뷰로 보여주는 메서드 : asType
    • asList
  • 객체의 값을 기본 타입 값으로 반환하는 메서드 : typeValue
    • intValue
  • 정적 팩터리 : from , of , valueOf , instance , getInstance , getType

필드

  • 필드 이름에 관한 문법 규칙은 클래스, 인터페이스, 메서드 이름에 비해 덜 명확하고 덜 중요하다.
    • 필드가 직접 노출될 일은 거의 없기 때문이다.
  • boolean 타입의 필드 이름은 보통 boolean 접근자 메서드에서 앞 단어를 뺀 형태다.
    • initialized , composite
  • 다른 타입의 필드라면 명사나 명사구를 이용한다.
    • height , digits , bodyStyle