일반적인 프로그래밍 원칙들

규칙 57 : 지역 변수의 유효범위를 최소화하라

C와 같은 오래된 프로그래밍 언어는 지역 변수를 블록 앞부분에 선언한다. 그러나 고칠 필요가 있는 습관이다. 지역 변수의 유효범위를 최소화하는 가장 강력한 기법은, 처음으로 사용하는 곳에서 선언하는 것이다. 사용하기 전에 선언하면 프로그램의 의도를 알고자 소스 코드를 읽는 사람만 혼란스럽게 할 뿐이다. 실제로 변수가 사용될 때쯤 되면, 그 변수의 자료형과 초기값이 무엇이었는지는 잊어버리고 말 것이다.

지역 변수를 너무 빨리 선언하면 유효범위가 너무 앞쪽으로 확장될 뿐 아니라, 너무 뒤쪽으로도 확장된다. 지역 변수의 유효범위는 선언된 지점부터 해당 블록 끝까지다. 어떤 블록 밖에서 선언된 변수는 프로그램이 해당 블록 수행을 끝내고 나서도 계속 사용 가능하다. 어떤 변수를 원래 사용하려고 했던 곳 이외의 장소에서 실수로 사용하게 되면, 끔찍한 결과가 초래될 수 있다.

거의 모든 지역 변수 선언에는 초기값이 포함되어야 한다. 그런데 try-catch 블록이 사용 될 때는 예외적 상황이 생길 수 도 있다. 어떤 변수가 점검지정 예외(checked exception)을 던지는 메서드를 통해 초기화된다면 그 변수는 try 블록 안에서 초기되어야 할 것이다. 그런데 그 변수의 값이 try 블록 밖에서도 사용할 수 있어야 하는 값이라면 선언 위치를 try 블록 앞으로 이동시켜야 한다.

순환문(loop)을 잘 쓰면 변수의 유효범위를 최소화할 수 있다. for문이나 for-each문의 경우, 순환문 변수라는 것을 선언할 수 있는데, 그 유효범위는 선언된 지역(즉, for 다음에 오는 순환문 괄호 ()와 순환문 몸체 {} 내부의 코드) 안으로 제한된다. 따라서 while 문보다는 for 문을 쓰는 것이 좋다. 순환문 변수의 내용은 순환문 수행이 끝난 이후에는 필요 없다는 가정하에서. 예를 들어, 컬렉션을 순회할 때는 아래와 같이 하는 것이 좋다.

// 컬렉션을 순회할 때는 이 숙어대로 하는 것이 바람직
for (Element e : c) {
    doSomething(e);
}

이런 for 순환문이 while 문보다 바람직한 이유는 무엇인가? 아래의 코드를 보자. while 문이 두 개 사용되었고, 버그도 하나 있다.

Iterator<Element> i = c.iterator();
while(i.hasNext()){
    doSomething(i.next());
}

Iterator<Element> i2 = c2.iterator();
while(i.hasNext()){ // 버그 
    doSomething(i2.next());
}

두 번째 순환문에는 코드를 복붙하다보니 생긴 버그가 하나 있다. 새로운 순환문 변수 i2를 초기화 했으나 실제로는 옛날 변수 i를 써버린 것이다. i가 아직도 유효범위 안에 있는 관계로, 이 코드는 컴파일이 잘 될뿐 아니라 예외도 없이 실행되지만 이상하게 동작할 것이다. 이와 비슷한 복붙 버그가 for 문이나 for-each 문에서도 생길 수 있을까? 컴파일조차 되지 않을 것이므로 어려울 것이다. 첫 번째 순환문 안에서 사용된 요소나 반복자의 유효범위는 두 번째 순환문까지 연장될 수 없다. 아래의 예제를 보자.

for(Iterator<Element> i = c.iterator(); i.hasNext();){
    doSomething(i.next());
}

//심볼 i를 찾을 수 없다면서 컴파일 시점에 오류 발생
for(Iterator<Element> i2 = c2.iterator(); i.hasNext();){
    doSomething(i2.next());
}

더욱이 for문을 사용할 때는 순환문마다 다른 이름으 변수를 사용할 필요가 없기 때문에 복붙 버그가 발생할 가능성은 더욱 줄어든다. 각각의 for 문은 서로 의존성이 없으므로, 같은 변수명을 거듭 사용해도 상관없다. 지역 변수의 유효범위를 최소화하는 숙어를 하나 더 살펴보자.

for (int i = 0 , n = expensiveComputation(); i< n ; i++){
    doSomething(i);
}

여기서 주의할 것은 두 개의 순환문 변수가 사용되었다는 것이다. i와 n의 유효범위는 정확히 해당 for문 안으로 제한된다. 두번째 변수 n은 i값의 범위를 제한하는 용도로 쓰이고 있는데, 그 값을 계산하는 비용이 꽤 크다. 따라서 미리 계산해 넣어두고 사용함으로써 매번 재계산할 필요가 없도록 했다. 명심할 것은, 순환문 조건식 안에서 메서드를 호출할 경우, 해당 메서드의 호출 결과로 반환되는 값이 순환문 각 단계마다 달라지지 않는다면, 항상 이 패턴대로 코딩하라는 것이다.

지역 변수의 유효범위를 최소화하는 마지막 전략은 메서드의 크기를 줄이고 특정한 기능에 집중하라는 것이다. 두 가지 서로 다른 기능을 한 메서드 안에 넣어두면 한 가지 기능을 수행하는 데 필요한 지역 변수의 유효범위가 다른 기능까지 확장되는 문제가 생긴다. 이런 일을 막으려면 각 기능을 나눠서 별도 메서드로 구현해야 한다.

규칙 58 : for 문보다는 for-each 문을 사용하라

릴리스 1.5 전에는 컬렉션을 순회할 때 아래으 숙어를 따르는 것이 바람직했다.

// 컬렉션 순회를 위해 한동안 많이 썼던 숙어
for (Iterator i = c.iterator(); i.hasNext(); ){
    doSomething((Element) i.next()); // 1.5 전에는 제네릭 없었음
}

배열을 순회할 때는 이렇게 하는 것이 바람직 했다.

// 배열 순회할 때 한동안 많이 사용한 숙어
for (int i =0; i< a.length; i++){
    doSomething(a[i]);
}

릴리스 1.5부터 도입된 for-each 문은 성가신 코드와 반복자, 첨자 변수들을 완전히 제거해서 오류 가능성을 없앤다.

// 컬렉션이나 배열을 순회할 때는 이 숙어를 따르자
for (Element e : elements){
    doSomething(e);
}

위의 for-each 문에서 : "기호는 안에 있는(in)”이라고 읽는다. 따라서 위의 순환문은 “elements 안에 있는 e 각각에 대해서(for)” 라고 읽으면 된다. for-each 문의 장점은 여러 컬렉션에 중첩되는 순환문을 만들어야 할 때 더 빛난다. 두 개 컬렉션에 대한 순환문을 중첩시킬 때 흔히 저지르는 실수의 사례를 아래에 보였다.

// 버그 있는 코드
enum Suit { CLUB, DIAMOND, HEART, SPADE }
enum Rank { ACE, DEUCE, THREE, FOUR, FIVE, SIX, SEVEN, EIGHT, NINE, TEN, JACK, QUEEN, KING }

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

List<Card> deck = new ArrayList<Card>();
for ( Iterator<Suit> i = suits.iterator(); i.hasNext(); )
    for ( Iterator<Rank> j = rank.iterator(); j.hasNext(); )
        deck.add(new Card(i.next(), j.next()));

바깥쪽 순환문 안에서 카드 종류별로 한 번만 호출되야 하는데 안쪽 순화눈에서 호출되다 보니 너무 빨리 소진되어서 결국 NoSuchElementException이 발생하고 만다. for-each 문을 중첩해서 프로그램을 짜면 이 문제는 바로 사라진다.

// 컬렉션이나 배열에 대한 순환문을 중첩시킬 때 따라야 할 숙어 
for (Suit suit : suits)
    for(Rand rank : ranks)
        deck.add(new Card(suit, rank));

for-each문으로는 컬렉션과 배열뿐 아니라 Iterable 인터페이스를 구현하는 어떤 객체도 순회 할 수 있다. Iterable 인터페이스는 메서드가 하나뿐인 아주 간단한 인터페이스다. for-each 문과 함께 플랫폼에 추가되었으며, 아래처럼 생겼다.

public interface Iterable<E> {
    // 이 Iterable 안에 있는 원소들에 대한 반복자 반환
    Iterator<E> iterator();
}

Iterator 인터페이스는 구현하기 어렵지 않다. 원소들의 그룹을 나타내는 자료형을 작성할 때는, Collection은 구현하지 않더라도 Iterable은 구현하도록 하라. 그러면 클라이언트는 for-each문을 통해 해당 자료형을 순회할 수 있게 될 것이므로 너무 고마워 할 것이다.

그러나 불행히도 아래의 세 경우에 대해서는 for-each문을 적용할 수 없다. 1. 필터링 - 컬렉션을 순회하다가 특정한 원소를 삭제할 필요가 있다면, 반복자를 명시적으로 사용해야 한다. 반복자의 remove 메서드를 호출해야 하기 때문이다. 2. 변환 - 리스트나 배열을 순회하다가 그 원소 가운데 일부 또는 전부의 값을 변경해야 한다면, 원소의 값을 수정하기 위해서 리스트 반복자나 배열 첨자가 필요하다. 3. 병렬 순회 - 여러 컬렉션을 병렬적으로 순회해야 하고, 모든 반복자나 첨자 변수가 발맞춰 나아가도록 구현해야 한다면 반복자나 첨자 변수를 명시적으로 제어할 필요가 있을 것이다.

규칙 59 : 어떤 라이브러리가 있는지 파악하고, 적절히 활용하라

무작위 정수 하나를 생성하고 싶다고 해보자. 값의 범위는 0부터 명시한 수 사이다. 아주 흔히 마주치는 문제로, 많은 프로그래머가 다음과 같은 짤막한 메서드를 만들곤 한다.

static Random rnd = new Random();

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

괜찮은 듯 보여도 문제를 세 가지나 내포하고 있다. 첫 번째, n이 그리 크지 않은 2의 제곱수라면 얼마 지나지 않아 같은 수열이 반복된다. 두 번째, n이 2의 제곱수가 아니라면 몇몇 숫자가 평균적으로 더 자주 반환된다. n 값이 크면 이 현상은 더 두드러진다.

@Test
public void name() {
    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);
}

private int random(int n) {
    Random rnd = new Random();
    return Math.abs(rnd.nextInt()) % n;
}

random 메서드가 이상적으로 동작한다면 약 50만개가 출력돼야 하지만, 실제로 돌려보면 약 66만개에 가까운 값을 얻는다. 무작위로 생성된 수 중에서 2/3 가량이 중간값보다 낮은 쪽으로 쏠린 것이다.

random 메서드의 세 번째 결함으로, 지정한 범위 ‘바깥’의 수가 종종 튀어나올 수 있다. rnd.nextInt()가 반환한 값을 Math.abs를 이용해 음수가 아닌 정수로 매핑하기 때문이다. nextInt()가 Integer.MIN_VALUE를 반환하면 Math.abs도 Integer.MIN_VALUE를 반환하고, 나머지 연산자는 음수를 반환해버린다(n이 2의 제곱수가 아닐 때의 시나리오다). 이렇게 되면 여러분의 프로그램은 실패할 것이고, 문제를 해결하고 싶어도 현상을 재현하기가 쉽지 않을 것이다.

이 결함을 해결하려면 Rnadom.nextInt(int)가 이미 해결해놨으니 가져다 쓰면된다. 하지만 자바 7부터는 Random을 더 이상 사용하지 않는 게 좋다. ThreadLocalRandom으로 대체하면 대부분 잘 작동한다. Random보다 더 고품질의 무작위 수를 생성할 뿐 아니라 속도도 더 빠르다. 한편, 포크-조인 풀이나 병렬 스트림에서는 SplittableRandom을 사용해라.

다음 예제는 지정한 URL의 내용을 가져오는 명령줄 애플리케이션이다(리눅스의 curl 명령을 생각하면 된다).

public static void main(String[] args) throws IOException {
    try (InputStream in = new URL(args[0]).openStream()) {
        in.transferTo(System.out);
    }
}

예전에는 작성하기가 까다로운 기능이었지만, 자바 9에서 InputStream에 추가된 transferTo 메서드를 사용하면 쉽게 구현할 수 있다.

실제로 하려는 일과 큰 관련성도 없는 문제에 대한 해결 방법을 임의로 구현하늬라 시간을 낭비하지 않도록 하자(바퀴를 다시 발명하지 말자). 자바 프로그래머라면 java.lang, java.util, java.io와 그 하위 패키지들에는 익숙해져야 한다.

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

float와 double은 이진 부동 소수점 연산을 수행한다. 하지만 정확한 결과를 제공하지는 않기 때문에 정확한 결과가 필요한 곳에는 사용하면 안 된다. float와 double은 특히 돈과 관계된 계산에는 적합하지 않다. 0.1을 비롯한 10의 음의 거듭제곱 수를(10^-1, 10^-2, 10^-3 ...) 정확하게 나타낼 수 없기 때문이다.

System.out.println(1.03 - .42); // 0.6100000000000001 
System.out.println(1.00 - 9 * .10); // 0.09999999999999998

화면에 출력하기 전에 반올림하면 되지 않을까 싶기도 하겠지만, 그 방법은 항상 통하지 않는다. 예를 들어 주머니에 1달러가 있는데 10센트, 20센트, 30센트 등의 가격이 붙은 사탕들이 있다고 하자. 가장 싼 사탕부터 시작해서 차례로 더 비싼 사탕을 구입해 나갈 때, 얼마나 많은 사탕을 살 수 있는가?

double funds = 1.00;
int itemsBought = 0;
for ( double price = .10; funds >= price; price += .10){
    funds -= price;
    itemsBought++;
}
System.out.println(itemsBought+ "item bought.”); // 3item bought.
System.out.println("Change: $"+ funds); // Change: $0.3999999999999999

금전 계산을 하는 이 프로그램을 돌려 보면 살 수 있는 사탕은 세 개이고 잔돈은 $0.3999999999999999라고 출력될 것이다.

돈 계산을 할 때는 BigDecimal,int 또는 long을 사용한다는 원칙을 지켜야 한다.

// double 대신 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+ "item bought.”); // 4item bought.
System.out.println("Money left over: $"+ funds); // Money left over: $0.00

이렇게 고치면 정확한 답이 나오지만 BigDecimal을 쓰는 방법에는 두 가지 문제가 있다. 첫 째, 기본 산술연산 자료형보다 사용이 불편하며 느리다. BigDecimal의 대안은 int나 long을 사용하는 것이다. 둘 중 어떤 자료형을 쓸 것이냐는 수의 크기, 그리고 소수점 이하 몇 자리까지를 표현할 것이냐에 따라 결정된다. 이 예제에 딱 맞는 접근법은 모든 계산을 달러 대신 센트 단위로 하는 것이다.

int itemsBought = 0;
int funds = 100;
for(int price = 10; funds>= price; price+= 10){
    funds -= price;
    itemsBought++;
}
System.out.println(itemsBought+ "item bought.”); // 4item bought.
System.out.println("Money left over: $"+ funds + " cents”); // Money left over: $0cents

결론 : 기본 자료형보다 사용하기는 불편하고 성능이 떨어져도, 소수점 이하 처리를 시스템이 알아서 해줬으면 좋겠을 때는 BigDecimal을 사용해라. BigDecimal을 쓰면 올림 연산을 어떻게 수행해야 하는지를 여덟 가지 올림 모드 가운데 하나로 지정할 수 있다(법적으로 올림 연산이 필요한 상업적인 계산을 해야 할 때 편리하다). 그러나 성능이 중요하고 소수점 아래 수를 직접 관리해도 상관없으며 계산할 수가 심하게 크지 않을 때는 int나 long을 쓰라(관계된 수치들이 십진수 9개 이하로 표현이 가능할 때는 int, 18개 이하는 long, 그 이상일 때는 BigDecial).

규칙 61 : 객체화된 기본 자료형 대신 기본 자료형을 이용하라

자바의 자료형 시스템은 두 부분으로 나뉜다. 하나는 기본 자료형(int, double, boolean 등)이고 다른 하나는 String과 List 등의 참조 자료형(reference type)이다. 모든 자료형에는 대응되는 참조 자료형이 있는데, 이를 객체화된 기본 자료형이라 부른다. int, double, boolean의 객체화된 기본 자료형은 각각 Integer, Double, Boolean이다.

기본 자료형과 객체화된 기본 자료형 사이에는 세 가지 큰 차이점이 있다. 1. 기본 자료형은 값만 가지지만 객체화된 기본 자료형은 값 외에도 identity를 가진다. 따라서 객체화된 기본 자료형 객체가 두 개 있을 때 그 값은 같더라도 identity는 다를 수 있다. 2. 기본 자료형에 저장되는 값은 전부 기능적으로 완전한 값이지만, 객체화된 자료형에 저장되는 값에는 그 이외에도 아무 기능도 없는 값, 즉 null이 하나 있다는 것이다. 3. 기본 자료형은 시간적이나 공간 요구량 측면에서 일반적으로 객체 표현형보다 효율적이다.

아래의 비교자 예제를 보자.

// 잘못된 반복자 
Comparator<Integer> naturalOrder = new Comparator<Integer>(){
    public int compare(Integer first, Integer second){
        return first < second ? -1 : (first == second ? 0 : 1); 
    }
};

표현식 fisrt < second는 first와 second가 참조하는 Integer 객체를 기본 자료형 값으로 자동 변환한다. 따라서 first의 int 값이 second의 int 값 보다 작다면 음수가 제대로 반환될 것이다. 하지만 first == second 표현식은 두 객체의 identity를 비교한다. 그래서 객체화된 기본 자료형에 == 연산자를 사용하는 것은 거의 항상 오류라고 봐야 한다.

아래의 예제를 보자.

// NullPointerException 에러 발생
public class Unbelievable {
    static Integer i;
    public static void main(String[] args) {
        if(i == 42)
            System.out.println("unb");
    }
}

모든 객체 참조 필드가 그렇듯, 초기값은 null이다. 위의 프로그램이 (i == 42)를 계산할 때 비교되는 것은 Integer 객체와 int 값이다. 거의 모든 경우에, 기본 자료형과 객체화된 기본 자료형을 한 연산 안에 엮어 놓으면 객체화된 기본 자료형은 자동으로 기본 자료형으로 반환된다. 따라서 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을 long이 아니라 Long으로 선언했기 때문에 오류나 경고 없이 컴파일되는 프로그램이지만 변수가 계속해서 객체화와 비객체화를 반복하기 때문에 성능이 느려진다.

객체화된 기본 자료형은 컬렉션의 요소, 키, 값으로 사용할 때다. 컬렉션에는 기본 자료형을 넣을 수 없으므로 객체화된 자료형을 써야 한다. 리플렉션을 통해 메서드를 호출할 때도 객체화된 기본 자료형을 사용해야 한다.

규칙 62 : 다른 자료형이 적절하다면 문자열 사용은 피하라

문자열은 텍스트 표현과 처리에 걸맞도록 설계되었다. 이번 절에서는 문자열로 해서는 안 되는 일들을 짚어본다. 1. 문자열은 값 자료형(value type)을 대신하기에는 부족하다. 데이터가 파일이나 네트워크나 키보드를 통해서 들어올 때는 보통 문자열 형태다. 그러니 그대로 두려는 경향이 있다. 하지만 데이터가 텍스트 형태일 때나 그렇게 하는 것이 좋다. 숫자라면 int, float, BigInteger 같은 수 자료형으로 변환해야 한다. 적당한 자료형이 없다면 새로 만들어야 한다. 2. 문자열은 enum 자료형을 대신하기에는 부족하다. 3. 문자열은 혼합 자료형을 대신하기엔 부족하다. String compundKey = className + “#” + i.next();이런 접근법에는 많은 문제가 있다. 필드 구분자로 사용한 문자가 필드 안에 들어가버리면 문제가 생긴다. 게다가 각 필드를 사용하려고 하면 문자열을 파싱해야 하는데, 느릴 뿐더러 멍청하고 오류 발생 가능성도 높은 과정이다. 4. 문자열은 권한(capability)을 표현하기엔 부족하다. 때로 문자열을 사용해서 접근 권한을 표현하는 경우가 있다. 스레드 지역 변수 기능을 설계하는 경우를 예로 들어 살펴보자. 스레드마다 다른 변수를 제공하는 기능이다.

// 문자열을 권한으로 사용하는 잘못된 예제
public class ThreadLocal {
    private ThreadLocal() { } // 객체를 만들 수 없다.

    // 주어진 이름이 가리키는 스레드 지역 변수의 값 설정
    public static void set(String key, Object value);

    // 주어진 이름이 가리키는 스레드 지역 변수의 값 반환
    public static Object get(String key);
}

이 접근법의 문제는, 문자열이 스레드 지역 변수의 전역적인 이름공간이라는 것이다. 위 접근법이 통하려면 클라이언트가 제공하는 문자열 키의 유일성이 보장되어야 한다.

위 API의 문제는 문자열 대신 위조 불가능 키로 바꾸면 해결된다(이런 키를 때로 ‘권한’이라 부른다).

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

이 방법으로 문자열 키 유일성 보장과 보안 문제도 해결하지만 아직도 개선의 여지는 있다. 정적 메서드들은 사실 더 이상 필요없다. 키의 인스턴스 메서드로 만들 수 있다. 그렇게 하고 나면 키는 더 이상 스레드 지역 변수의 키가 아니라 그것 자체가 스레드 지역 변수가 된다.

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

개략적으로 이것이 바로 java.lang.ThreadLocal이 제공하는 API다.

규칙 63 : 문자열 연결 시 성능에 주의하라

문자열 연결이 많으면 성능에 문제가 생긴다. n개의 문자열에 연결 연산자를 반복 적용해서 연결하는 데 드는 시간은 n^2에 비례한다. 문자열이 변경 불가능 하기 때문이다. 문자열 두 개를 연결할 때, 그 두 문자열의 내용은 전부 복사된다.

// 문자열을 연결하는 잘못된 방법 - 성능이 엉망이다
public String statement() {
    String result = “”;
    for (int i =0 ; i < numItems(); i++){
        result += lineForItem(i); 
    }
}

만족스런 성능을 얻으려면 String 대신 StringBuilder를 써서 저장해야 한다. StringBuilder 클래스는 릴리스 1.5에 추가된 것으로, StringBuffer에서 동기화 기능을 뺀 것이다. StringBuffer는 이제 지원되지 않는다.

public String statement() {
    StringBuilder b = new StringBuilder(numItems() * LINE_WIDTH);
    for (int i =0; i < numItems(); i++)
        b.append(lineForItem(i));

    return b.toString();
}

규칙 64 : 객체를 참조할 때는 그 인터페이스를 사용하라

적당한 인터페이스 자료형이 있다면 인자나 반환값, 변수, 그리고 필드의 자료형은 클래스 대신 인터페이스로 선언하자. 객체의 실제 클래스를 사용해야 할 상황은 오직 생성자로 생성할 때 뿐이다.

// 좋은 예
Set<Son> sonSet = new LinkedHashSet<>();

// 나쁜 예
LinkdHashSet<Son> sonSet = new LinkedHashSet<>();

인터페이스를 자료형으로 쓰는 습관을 들이면 프로그램은 더욱 유연해진다. 단 한 가지 주의할 것이 있다. 원래의 클래스가 인터페이스의 일반 규약 이외의 특별한 기능을 제공하며, 주변 코드가 이 기능에 기대어 동작한다면 새로운 클래스도 반드시 같은 기능을 제공해야 한다. 예컨대 첫 번째 선언의 주변 코드가 LinkedHashSet이 따르는 순서 정책을 가정하고 동작하는 상황에서 이를 HashSet으로 바꾸면 문제가 될 수 있다. HashSet은 반복자의 순회 순서를 보장하지 않기 때문이다.

적합한 인터페이스가 없다면 당연히 클래스로 참조해야 한다. 1. String과 Integer 같은 값 클래스 (값 클래스를 여러 가지로 구현될 수 있다고 생각하고 설계하는 일은 거의 없다) 2. 클래스 기반으로 작성된 프레임워크가 제공하는 객체들 ex) OutputStream 등 java.io 패키지의 여러 클래스 3. 인터페이스에는 없는 특별한 메서드를 제공하는 클래스들 PriorityQueue 클래스는 Queue 인터페이스에 없는 comparator 메서드를 제공한다.

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

규칙 65 : 리플렉션 대신 인터페이스를 이용하라

java.lang.reflect의 핵심 리플렉션 기능을 이용하면 메모리에 적재(load)된 클래스의 정보를 가져오는 프로그램을 작성할 수 있다. Class 객체가 주어지면, 해당 객체가 나타내는 클래스의 생성자, 메서드, 필드 등을 나타내는 Constructor, Method Field 객체들을 가져올 수 있는데, 이 객체들을 사용하면 클래스의 멤버 이름이나 필드 자료형, 메서드 시그니처 등의 정보들을 얻어낼 수 있다.

게다가 Constructor, Method Field 객체를 이용하면, 거기 연결되어 있는 실제 생성자, 메서드, 필드들을 반영적으로(reflectively) 조작할 수 있다. 객체를 생성할 수도 있고, 메서드를 호출할 수도 있으며, 필드에 접근할 수도 있다. Constructor, Method Field 객체의 메서드를 통하면 된다. 예를 들어, Method.invoke를 이용하면 어떤 클래스의 어떤 객체에 정의된 어떤 메서드라도 호출할 수있다(물론 일반적인 보안 제약사항은 준수해야 한다). 또한 리플렉션을 이용하면, 소스 코드가 컴파일 될 당시에는 존재하지도 않았던 클래스를 이용할 수 있다.

하지만 이런 능력에는 대가가 따른다. 1. 컴파일 시점에 자료형을 검사함으로써 얻을 수 있는 이점들을 포기해야 한다(예외 검사 포함). 2. 리플렉션 기능을 이용하는 코드는 가독성이 떨어진다. 3. 성능이 낮다. 리플렉션을 통한 메서드 호출 성능은 일반적인 메서드 호출에 비해 훨씬 낮다. 얼마나 낮은지 정확히 말하기는 어렵다. 고려해야 할 요건들이 다양하기 때문. 필자의 컴퓨터에서 속도 차는 2배에서 50배 가량이었다.

명심할 것은, 일반적인 프로그램은 프로그램 실행 중에 리플렉션을 통해 객체를 이용하려 하면 안된다는 것이다. 리플렉션이 필요한 복잡한 프로그램이 몇 가지 있긴 하다. 클래스 브라우저, 객체 검사도구, 코드 분석도구 등이 그 예다. 또한 리플렉션은 스텁 컴파일러가 없는 원격 프로시저 호출(remote procedure call, RPC) 시스템을 구현하는 데 적당하다.

리플렉션을 아주 제한적으로만 사용하면 오버헤드는 피하면서도 리플렉션의 다양한 장점을 누릴 수 있다. 컴파일 시점에는 존재하지 않는 클래스를 이용해야 하는 프로그램 가운데 상당수는, 해당 클래스 객체를 참조하는 데 사용할 수 있는 인터페이스나 상위 클래스는 컴파일 시점에 이미 갖추고 있는 경우가 많다. 그럴 때는, 객체 생성은 리플렉션으로 하고 객체 참조는 인터페이스나 상위 클래스를 통하면 된다. 호출해야 하는 생성자가 아무런 인자도 받지 않을 때는 java.lang.reflect를 이용할 필요조차 없다. Class.newInstance 메서드를 호출하는 것으로 충분하다. 예를 들어 아래의 프로그램은 명령중에 주어진 첫 번째 인자와 같은 이름의 클래스를 이용해 Set<String> 객체를 만든다. 나머지 인자들은 전부 해당 집합에 집어넣고 출력한다.

public class Reflection {
    public static void main(String[] args) {
        Class<?> cl = null;

        try{
            cl = Class.forName(args[0]);
        }catch (ClassNotFoundException e){
            System.err.println("class not found");
            System.exit(1);
        }

        //해당 클래스의 객체 생성
        Set<String> s = null;
        try {
            s = (Set<String>)cl.newInstance();
        } catch (InstantiationException e) {
            System.err.println("class not instantiable");
            System.exit(1);
        } catch (IllegalAccessException e) {
            System.err.println("class not accessible");
            System.exit(1);
        }

        // 집합 이용
        s.addAll(Arrays.asList(args).subList(1,args.length));
        System.out.println(s);
    }
}

첫 번째 인자가 무엇인지에 관계없이, 이 프로그램은 나머지 인자들에서 중복을 제거한 다음에 출력한다. 하지만 출력 순서는 첫 번째 인자로 어떤 클래스를 지정했느냐에 좌우된다. java.util.HashSet을 지정했다면 무작위 순서로 출력될 것이다. java.util.TreeSet을 지정했으면 알파벳 순서대로 출력될 것이다.

이 프로그램은 하나 이상의 객체를 공격적으로 조작하여 해당 구현이 Set의 일반 규약을 준수하는지 검증하는 일반적 집합 검사 도구로 쉽게 변경될 수 있다. 마찬가지로, 일반적 집합 성능 분석 도구로도 쉽게 바꿀 수 있다. 사실 이 기법은 완벽한 ‘서비스 제공자 프레임워크(규칙1)’를 구현할 수 있을 정도로 강력하다. 대부분의 경우, 리플렉션 기능은 이 정도만 사용해도 충분할 것이다.

리플렉션과 특별히 관련은 없으나, 이 예제가 System.exit를 사용하고 있다는 것은 주의할 필요가 있다. 이 메서드를 호출하는 것은 대체로 바람직하지 않다. 이 메서드는 전체 VM을 종료시켜버린다.

규칙 66 : 네이티브 메서드는 신중하게 사용하라

자바의 네이티브 인터페이스는 C나 C++ 등의 네이티브 프로그래밍 언어로 작성된 네이티브 메서드를 호출하는 데 이용되는 기능이다. 전통적으로 네이티브 메서드는 세 가지 용도로 쓰였다. 1. 레지스트리나 파일 락 같은, 특정 플랫폼에 고유한 기능을 이용할 수 있다. 2. 이미 구현되어 있는 라이브러리를 이용할 수 있으며, 그 라이브러리를 통해 기존 데이터를 활용할 수 있다. 3. 성능이 중요한 부분의 처리를 네이티브 언어에 맡길 수 있다.

그러나 네이티브 메서드를 통해 성능을 개선하는 것은 추천하고 싶지 않다. 1.3 이전의 초기 릴리스라면 필요할 때가 자주 있었을 것이나, 현재 JVM은 훨씬 빠르다. 일례로, 릴리스 1.1에 java.math가 추가될 당시 BigInteger는 C로 작성된 다정밀 연산 라이브러리를 이용하고 있었다. 그러나 릴리즈 1.3부터 BigInteger는 완전히 자바로만 구현되었고 신중하게 최적화되었다.

네이티브 메서드에는 심각한 문제가 있다. 네이티브 언어는 안전하지 않으므로 네이티브 메서드를 이용하는 프로그램은 메모리 훼손 문제로부터 자유로울 수 없다. 게다가 네이티브 언어는 플랫폼 종속적이므로 이식성이 낮다. 또한 디버깅하기도 훨씬 어렵다. 굳이 써야 겠다면 성능 개선 용도로만 써라. 저수준 자원이나 기존 라이브러리를 이용하기 위해 네이티브 메서드를 사용해야 한다면, 네이티브 코드는 가능하면 줄이고 광범위한 테스트를 거치기 바란다. 네이티브 코드에 있는 아주 작은 버그라도 시스템 전체를 훼손 시킬 수 있다.

규칙 67 : 신중하게 최적화하라

최적화는 좋을 때보다 나쁠 때가 더 많으며, 섣불리 시도하면 더더욱 나쁘다. 성능 때문에 구조적인 원칙을 희생하지 마라. 빠른 프로그램이 아닌, 좋은 프로그램을 만들려 노력하라.

설계를 할 때는 성능을 제약할 가능성이 있는 결정들을 피하라. 그리고 API를 설계할 때 내리는 결정들이 성능에 어떤 영향을 끼칠지 생각하라. public 자료형을 변경 가능하게 만들면 쓸데없이 방어적 복사를 많이 해야 할 수 있다. 마찬가지로, 구성(composition) 기법이 적절한 public 클래스에 계승 기법을 적용하면 해당 클래스는 영원히 상위 클래스에 묶이는데, 그 결과로 하위 클래스의 성능에 인위적인 제약이 가해질 수도 있다. 또한 인터페이스가 적당할 API에 구현 자료형을 사용해 버리면 해당 API가 특정한 구현에 종속되므로 나중에 더 빠른 구현이 나와도 개선할 수 없게 된다.

프로그램을 신중히 설계한 결과로 명료하고 간결하며 구조가 잘 짜인 구현이 나왔다면, 바로 그때가 최적화를 고민할 시점일 것이다. 물론 그 프로그램의 성능에 만족하지 못한다는 가정하에서. 그리고 최적화를 시도할 때마다, 전후 성능을 측정하고 비교하라. 측정 결과를 보고 놀라게 될지도 모른다. 최적화 결과로 성능이 개선되지 않거나 더 나빠지는 일이 많기 때문이다. 그 주된 이유는, 프로그램이 어디에 시간을 쓰고 있는지 추측하기 어렵다는 것이다. 전통적인 정적 컴파일 언어들에 비해, 프로그래머가 작성한 코드와 CPU가 실행하는 코드 사이의 ‘의미론적 차이’가 훨씬 크기 때문에 최적화 결과로 성능이 얼마나 좋아질지 안정적으로 예측하기 어렵다.

규칙 68 : 일반적으로 통용되는 작명 관습을 따르라

패키지 이름은 마침표를 구분점으로 사용하는 계층적 이름이어야 한다. 패키지 이름을 구성하는 각각의 컴포넌트는 알파벳 소문자로 구성하고, 숫자는 거의 사용하지 않는다. 패키지명 컴포넌트는 짧아야 하며, 보통 여덟 문자 이하로 의미가 확실한 약어를 활용하면 좋다. 즉, utilities 대신 util 이라고 하면 좋다.

enum이나 애노테이션 자료형 이름을 비롯, 클래스나 인터페이스 이름은 하나 이상의 단어로 구성된다. 각 단어의 첫 글자는 대문자다. 그리고 약어 사용은 피해야 한다.

메서드와 필드 이름은 클래스나 인터페이스 이름과 동일한 철자 규칙을 따른다. 다만 첫 글자는 소문자로 한다.

상수 필드의 이름은 하나 이상의 대문자 단어로 구성되며, 단어 사이에는 밑줄 기호(_)를 둔다. VALUES나 NEGATIVE_INFINITY가 그 예다. 상수 필드는 그 값을 변경 할 수 없는(immutable) static final 필드다.

지역 변수 이름은 멤버 이름과 같은 철자 규칙을 따르는데, 약어가 허용된다는 것만 다르다.

자료형 인자의 이름은 보통 하나의 대문자다. 가장 널리 쓰이는 것은 다섯 가지로, 임의 자료형인 경우에 T, 컬렉션의 요소 자료형인 경우에는 E, 맵의 키와 값에 대해서는 각각 K,V 그리고 예외인 경우에는 X를 사용한다. 임의 자료형이 연속되는 경우에는 T, U, V처럼 하거나 T1, T2, T3처럼 나열한다.

특별히 주의해야 하는 메서드 이름도 있다. 객체의 자료형을 변환하는 메서드, 다른 자료형의 독립적 객체를 반환하는 메서드에는 보통 toType 형태의 이름을 붙인다. toString, toArray 같은 이름이 그 예다. 인자로 전달받은 객체와 다른 자료형의 뷰(view) 객체를 반환하는 메서드에는 asType 형태의 이름을 붙인다. asList 같은 이름이 그 예다. 호출 대상 객체와 동일한 기본 자료형 값을 반환하는 메서드에는 typeValue와 같은 형태의 이름을 붙인다. intValue가 그 예다. 정적 팩터리 메서드에는 valueOf, of, getInstance, newInstance, getType, newType 같은 이름을 붙인다.

Last updated