본문 바로가기

(27)

아이템10 - equals는 일반 규약을 지켜 재정의하라

equals 메서드는 재정의하기 쉬워 보이지만 곳곳에 함정이 도사리고 있다. 문제를 회피하는 가장 쉬운 방법은 아예 재정의하지 않는 것이다. 그냐 두면 그 클래스의 인스턴스는 오직 자기 자신과만 같게 된다. 그러니 아래와 같은 상황 중 하나에 해당한다면 재정의하지 않는 것이 최선이다. 각 인스턴스가 본질적으로 고유하다. 값을 표현하는 게 아니라 동작하는 개체를 표현하는 클래스가 여기 해당한다. Thread가 좋은 예로, Object의 equals 메서드는 이러한 클래스에 딱 맞게 구현되었다. 인스턴스의 '논리적 동치성(logical equlity)'을 검사할 일이 없다. 예컨대 java.util.regex.Pattern은 equals를 재정의해서 두 Pattern의 인스턴스가 같은 정규표현식을 나타내는지..

아이템9 - try-finally보다는 try-with-resources를 사용하라

자바 라이브러리에는 close 메서드를 호출해 직접 닫아줘야 하는 자원이 많다. InputStream, OutputStream, java.sql.Connection 등이 좋은 예다. 자원 닫기는 클라이언트가 놓치기 쉬워서 예측할 수 없는 성능 문제로 이어질 수도 있다. 이런 자원 중 수 상당수가 안전망으로 finalizer를 활용하고는 있지만 finalizer는 그리 믿을만하지 못하다. 전통적으로 자원이 제대로 닫힘을 보장하는 수단으로 try-finally가 쓰였다. 예외가 발생하거나 메서드에서 반환되는 경우를 포함해서 말이다. static String firstLineOfFile(String path) throws IOException { BufferedReader br = new BufferedRea..

아이템8 - finalizer와 cleaner 사용을 피하라

자바는 두 가지 객체 소멸자를 제공한다. 그중 finalizer는 예측할 수 없고, 상황에 따라 위험할 수 있어 일반적으로 불필요하다. 오동작, 낮은 성능, 이식성 문제의 원인이 되기도 한다. finalizer는 나름의 쓰임새가 몇 가지 있긴 하지만 기본적으로 '쓰지 말아야' 한다. 그래서 자바 9에서는 finalizer를 사용 자제 API로 지정하고 cleaner를 그 대안으로 소개했다(하지만 자바 라이브러리에서도 finalizer를 여전히 사용한다). cleaner는 finalizer보다는 덜 위험하지만, 여전히 예측할 수 없고, 느리고, 일반적으로 불필요하다. 자바의 finalizer와 cleaner는 C++의 파괴자(destructor)와는 다른 개념이다. C++에서의 파괴자는 (생성자의 꼭 필요..

아이템6 - 불필요한 객체 생성을 피하라

이전글에 이어서 아이템6의 내용을 정리한다. 똑같은 기능의 객체를 매번 생성하기보다는 객체 하나를 재사용하는 편이 나을때가 많다. 재사용은 빠르고 세련됐다. 특히 불변 객체는 언제든 재사용할 수 있다. 다음 코드는 하지 말아야 할 극단적인 예이다. 자세히 살펴보고 절대 따라하지 말자 String s = new String("bikini"); // 따라 하지 말 것! 이 문장은 실행될 때마다 String 인스턴스를 새로 만든다. 완전히 쓸데없는 행위다. 생성자에 넘겨진 "bikini" 자체가 이 생성자로 만들어내려는 String과 기능적으로 완전히 똑같다. 이 문장이 반복문이나 빈번히 호출되는 메서드 안에 있다면 쓸데없는 String인스턴스가 수백만 개 만들어질 수도 있다. 개선된 버전을 보자 String..

아이템 5. 자원을 직접 명시하지 말고 의존 객체 주입을 사용하라

많은 클래스가 하나 이상의 자원에 의존한다. 예를들어 맞춤법 검사기는 사전에 의존하는데, 이런 클래스를 정적 유틸리티 클래스로 구현한 모습을 드물지 않게 볼 수 있다. public class SpellChecker { private static final Lexicion dictionary = ...; private SpellChecker() { } // 객체 생성 방지 public static boolean isValid(String word) { ... } public static List suggestions(String typo) { ... } } 비슷하게 싱글턴으로 구현하는 경우도 흔하다. public class SpellChecker { private final Lexicon dictionar..

아이템4. 인스턴스화를 막으려거든 private 생성자를 사용하라

이 책을 사주며 꼭 빨리 성장하라고 했던 선배(라고 부르고 은인 + 스승이라 부른다)의 당부를 잊고 벌써 몇개월을 소비한 것인가... 빨리 읽어야지 종종 단순히 정적 메서드와 정적 필드만을 담은 클래스를 만들고 싶을 때가 있다. 객체 지향적으로 사고하지 않는 이들이 종종 남용하는 방식이지만, 분명 나름 쓰임새가 있다. 예를들어 java.lang.Math와 java.util.Arrays처럼 기본 타입 값이나 배열 관련 메서드들을 모아놓을 수 있고, java.util.Collections처럼 특정 인터페이스를 구현하는 객체를 생성해주는 정적 메서드(or 팩터리)를 모아놓을 수도 있다(자바8부터는 이런 메서드를 인터페이스에 넣을 수 있다). 마지막으로, final 클래스와 관련한 메서드들을 모아놓을 때도 사용..

개발서적/이펙티브 자바

아이템10 - equals는 일반 규약을 지켜 재정의하라

728x90

equals 메서드는 재정의하기 쉬워 보이지만 곳곳에 함정이 도사리고 있다. 문제를 회피하는 가장 쉬운 방법은 아예 재정의하지 않는 것이다. 그냐 두면 그 클래스의 인스턴스는 오직 자기 자신과만 같게 된다. 그러니 아래와 같은 상황 중 하나에 해당한다면 재정의하지 않는 것이 최선이다. 

  • 각 인스턴스가 본질적으로 고유하다. 값을 표현하는 게 아니라 동작하는 개체를 표현하는 클래스가 여기 해당한다. Thread가 좋은 예로, Object의 equals 메서드는 이러한 클래스에 딱 맞게 구현되었다.

  • 인스턴스의 '논리적 동치성(logical equlity)'을 검사할 일이 없다. 예컨대 java.util.regex.Pattern은 equals를 재정의해서 두 Pattern의 인스턴스가 같은 정규표현식을 나타내는지를 검사하는, 논리적 동치성을 검사하는 방법도 있다. 하지만 설계자는 클라이언트가 이 방식을 원하지 않거나 애초에 필요하지 않다고 판단할 수도 있다. 설계자가 후자로 판단했다면 Object의 기본 equals만으로 해결된다. 

  • 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다. 예컨대 대부분의 Set 구현체는 AbstractSet이 구현한 equals를 상속받아 쓰고, List 구현체들은 AbstractList로부터, Map 구현체들은 AbstractMap으로부터 상속받아 그대로 쓴다. 

  • 클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없다.  

만약 위험을 철저히 회피하는 스타일이라 equals가 실수로라도 호출되는 걸 막고 싶다면 다음처럼 구현하자! 

@Override
public boolean equals(Object o) {
    throw new AssertionError(); // 호출 금지!
}

그렇다면 equals를 재정의해야 할 때는 언제일까? 객체 식별성(object identity 두 객체가 물리적으로 같은가)이 아니라 논리적 동치성을 확인해야 하는데, 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때다. 주로 값 클래스들이 여기 해당한다. 값 클래스란 Integer와 String처럼 값을 표현하는 클래스를 말한다. 두 값 객체를 equals로 비교하는 프로그래머는 객체가 같은지가 아니라 값이 같은지를 알고 싶어 할 것이다. equals가 논리적 동치성을 확인하도록 재정의해두면, 그 인스턴스는 값을 비교하길 원하는 프로그래머의 기대에 부응함은 물론 Map의 키와 Set의 원소로 사용할 수 있게 된다. 값 클래스라 해도, 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스라면 equals를 재정의하지 않아도 된다. Enum도 여기에 해당한다. 이런 클래스에서는 어차피 논리적으로 같은 인스턴스가 2개 이상 만들어지지 않으니 논리적 동치성과 객체 식별성이 사실상 똑같은 의미가 된다. 따라서 Object의 equals가 논리적 동치성까지 확인해준다고 볼 수 있다. 

equals 메서드를 재정의할 때는 반드시 일반 규약을 따라야 한다. 다음은 Object 명세에 적힌 규약이다.

  • 반사성(reflexivity) : null이 아닌 모든 참조 값 x에 대해, x.equals(x)는 true다.

  • 대칭성(symmetry) : null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)가 true면 y.equals(x)도 true다.

  • 추이성(transitivity) : null이 아닌 모든 참조 값 x, y, z에 대해, x.equals(y)가 true이고 y.equals(z)도 true면 x.equals(z)도 true다.

  • 일관성(consistency) : null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)를 반복해서 호출하면 항상 true를 반환하거나 항상 false를 반환한다.

  • null-아님 : null이 아닌 모든 참조 값 x에 대해, x.equals(null)은 false다. 

이 내용이 조금 어렵다고 해서, 그냥 지나치면 안 됀다! 이 규약을 어기면 프로그램이 이상하게 동작하거나 종료될 것이고, 원인이 되는 코드를 찾기도 굉장히 어려울 것이다. (John Donne의 말을 인용)세상에 홀로 존재하는 클래스는 없다. 한 클래스의 인스턴스는 다른 곳으로 빈번히 전달된다. 그리고 컬렉션 클래스들을 포함해 수 많은 클래스는 전달받은 객체가 equals 규약을 지킨다고 가정하고 동작한다.

다행이도 이 규약은 겉보기와 달리 그리 복잡하지 않다. 이해하고 나면 규약을 따르는 것도 어렵지 않다. 그렇다면 Object 명세에서 말하는 동치관계란 무엇일까? 간단히 말해, 집합을 서로 같은 원소들로 이뤄진 부분집합으로 나누는 연산이다. 이 부분집합을 동치류(equivalence class; 동치 클래스)라 한다. equals 메서드가 쓸모 있으려면 모든 원소가 같은 동치류에 속한 어떤 원소와도 서로 교환할 수 있어야 한다. 이제 동치관계를 만족시키기 위한 다섯 요건을 살펴보자.

반사성 : 단순히 말하면 객체는 자기 자신과 같아야 한다는 뜻이다. 이 요건은 일부러 어기는 경우가 아니라면 만족시키기 못하기가 더 어렵다. 이 요건을 어긴 클래스의 인스턴스를 컬렉션에 넣은 다음 contains 메서드를 호출하면 방금 넣은 인스턴스가 없다고 답할 것이다. 

대칭성 : 두 객체는 서로에 대한 동치 여부에 똑같이 답해야 한다는 뜻이다. 반사성 요건과 달리 대칭성 요건은 자칫하면 어길 수 있다. 대소문자를 구별하지 않는 문자열을 구현한 다음 클래스를 예로 살펴본다. 이 클래스에서 toString 메서드는 원본 문자열의 대소문자를 그대로 돌려주지만 equals에서는 대소문자를 무시한다. 

public final class CaseInsensitiveString {
    private final String s;
    
    public CaseInsensitiveString(String s) {
        this.s = Objects.requireNonNull(s);
    }
    
    // 대칭성 위배!
    @Override
    public boolean equals(Object o) {
        if (o instanceof CaseInsensitiveString) 
            return s.equalsIgnoreCase(
                ((CaseInsensitiveString) o).s);
        
        if (o instanceof String)
            return s.equalsIgnoreCase((String) o);
        return false;
    }
    ... 나머지 코드 생략
}

CaseInsensitiveString의 equals는 순진하게 일반 문자열과도 비교를 시도한다. 다음처럼 CaseInsensitiveString과 일반 String 객체가 하나씩 있다고 가정하자.

CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String s = "polish";

예상할 수 있듯이 cis.equals(s)는 true를 반환한다. 문제는 CaseInsensitiveString의  equals는 일반 String을 알고 있지만 Stringequals는 CaseInsensitiveString의 존재를 모른다는 데 있다. 따라서 s.equals(cis)는 false를 반환하여, 대칭성을 명백히 위반한다. 이번에는 CaseInsensitiveString을 컬렉션에 넣어보자.

List<CaseInsensitiveString> list = new ArrayList<>();
list.add(cis);

이 다음에 list.contains(s)를 호출하면 어떤 결과가 나올까? 현재의 OpenJDK에서는 false를 반환하기는 한다. 하지만 이는 순전히 구현하기 나름이라 다른 경우에는 true를 반환하거나 런타임 예외를 던질 수 도있다 equals 규약을 어기면 그 객체를 사용하는 다른 객체들이 어떻게 반응할지 알 수 없다. 

이 문제를 해결하려면 CaseInsensitiveString의 equals를 String과도 연동하겠다는 허황환 꿈을 버려야 한다. 그 결과 equals는 다음처럼 간단한 모습으로 바뀐다. 

@Override
public boolean equals(Object o) {
    return o instanceof CaseInsensitiveString &&
        ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}

추이성은 첫 번째 객체와 두 번째 객체가 같고, 두 번째 객체와 세 번째 객체가 같다면, 첫 번째 객체와 세 번째 객체도 같아야 한다는 뜻이다. 이 요건도 간단하지만 자칫하면 어기기 쉽다. 상위 클래스에는 없는 새로운 필드를 하위 클래스에 추가하는 상황을 생각해보자. equals 비교에 영향을 주는 정보를 추가한 것이다. 간단히 2차원에서의 점을 표현하는 클래스를 예로 들자.

public class Point {
    private final int x;
    private final int y;
    
    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
    
    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Point))
            return false;
            
        Point p = (Point) o;
        return p.x == x && p.y == y;
    }
    
    ... // 나머지 코드는 생략
}

이제 이 클래스를 확장해서 점에 색상을 더해보자.

public class ColorPoint extends Point {
    private final Color color;
    
    public ColorPoint(int x, int y, Color color) {
        super(x, y);
        this.color = color;
    }
    
    ... // 나머지 코드 생략
}

equals 메서드는 어떻게 해야 할까? 그대로 둔다면 Point의 구현이 상속되어 색상 정보는 무시한 채 비교를 수행한다. equals 규약을 어긴 것은 아니지만, 중요한 정보를 놓치게 되니 받아들일 수 없는 상황이다. 다음 코드처럼 비교 대상이 또 다른 ColorPoint이고 위치와 색상이 같을 때만 true를 반환하는 equals를 생각해보자. 

@Override
public boolean equals(Object o) {
    if (!(o instanceof Point))
        return false;
    
    // o가 일반 Point면 색상을 무시하고 비교한다. 
    if (!(o instanceof ColorPoint))
        return o.equals(this);
        
    // o가 ColorPoint면 색상까지 비교한다. 
    return super.equals(o) && ((ColorPoint) o).color == color;
}

이 방식은 대칭성은 지켜주지만, 추이성을 깨버린다. 

ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);

이제 p.equals(p2)와 p2.equals(p3)는 true를 반환하는데, p1.equals(p3)가 false를 반환한다. 추이성에 명백히 위배된다! p1과 p2, p2와 p3 비교에서는 색상을 무시했지만, p1과 p3 비교에서는 색상까지 고려했기 때문이다. 

또한, 이 방식은 무한 재귀에 빠질 위험도 있다. Point의 또 다른 하위 클래스로 SmellPoint를 만들고, equals는 같은 방식으로 구현했다고 해보자. 그런 다음 myColorPoint.equals(mySmellPoint)를 호출하면 StackOverflowError를 일으킨다. 

이는 객체 지향 언어의 동치관계에서 나타나는 근본적인 문제다. 구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않는다. 객체 지향적 추상화의 이점을 포기하지 않는 한은 말이다. 

 이 말은 얼핏, equals 안의 instanceof 검사를 getClass 검사로 바꾸면 규약도 지키고 값도 추가하면서 구체 클래스를 상속할 수 있다는 뜻으로 들린다. 

@Override
public boolean equals(Object o) {
    if (o == null || o.getClass() != getClass())
        return false;
    
    Point p = (Point) o;
    return p.x == x && p.y == y;
}

이번 equals는 같은 구현 클래스의 객체와 비교할 때만 true를 반환한다. 괜찮아 보이지만 실제로 활용할 수는 없다. Point의 하위 클래스는 정의상 여전히 Point이므로 어디서든 Point로써 활용될 수 있어야 한다. 그런데 이 방식에서는 그렇지 못하다. 예를 들어 주어진 점이 단위 원 안에 있는지를 판별하는 메서드가 필요하다고 해보자. 다음은 이를 구현한 코드다. 

// 단위 원 안의 모든 점을 포함하도록 unitCircle을 초기화한다. 
private static final Set<Point> unitCircle = Set.of(
        new Point(1, 0), new Point(0, 1),
        new Point(-1, 0), new Point(0, -1));
        
public static boolean onUnitCircle(Point p) {
    return unitCircle.contains(p);
}

이 기능을 구현하는 가장 빠른 방법은 아니지만, 어쨋든 동작은 한다. 이제 값을 추가하지 않는 방식으로 Point를 확장하겠다. 만들어진 인스턴스의 개수를 생성자에서 세보도록 하자.

public class CounterPoint extends Point {
    pricate static final AtomicInteger counter = new AtomicInteger();
    
    public CounterPoint(int x, int y) {
        super(x, y);
        counter.incrementAndGet();
    }
    
    public static int numberCreated() {
        return counter.get();
    }
}

리스코프 치환 원칙(SOLID 원칙중 L)에 따르면, 어떤 타입에 있어 중요한 속성이라면 그 하위 타입에서도 마찬가지로 중요하다. 따라서 그 타입의 모든 메서드가 하위 타입에서도 똑같이 잘 작동해야 한다. 이는 앞서의 "Point의 하위 클래스는 정의상 여전히 Point이므로 어디서든 Point로써 활용될 수 있어야 한다"를 격식 있게 표현한 말이다. 

그런데 CounterPoint의 인스턴스를 onUnitCircle 메서드에 넘기면 어떻게 될까? Point 클래스의 equals를 getClass를 사용해 작성했다면 onUnitCircle은 false를 반환할 것이다. CounterPoint 인스턴스의 x, y 값과는 무관하게 말이다. 왜냐하면 컬렉션 구현체에서 주어진 원소를 담고 있는지를 확인하는 방법에 있다. onUnitCircle에서 사용한 Set을 포함하여 대부분의 컬렉션은 이 작업에 equals 메서드를 이용하는데, CounterPoint의 인스턴스는 어떤 Point와도 같을 수 없기 때문이다. 반면, Point의 equals를 instanceof 기반으로 올바로 구현했다면 CounterPoint 인스턴스를 건네줘도 onUnitCircle 메서드가 제대로 동작할 것이다. 

구체 클래스의 하위 클래스에서 값을 추가할 방법은 없지만 괜찮은 우회 방법이 있다. "상속 대신 컴포지션을 사용하라"는 조언을 따르면 된다. Point를 상속하는 대신 Point를 ColorPoint의 private 필드로 두고, ColorPoint와 같은 위치의 일반 Point를 반환하는 뷰(view) 메서드 public으로 추가하는 식이다. 

public class ColorPoint {
    private final Point point;
    private final Color color;
    
    public ColorPoint(int x, int y, Color color) {
        point = new Point(x, y);
        this.color = Objects.requireNonNull(color);
    }
    
    // 이 ColorPoint의 Point 뷰를 반환한다
    public Point asPoint() {
        return point;
    }
    
    @Override
    public boolean equals(Object o) {
        if (!(o instanceof ColorPoint))
            return false;
            
        ColorPoint cp = (ColorPoint) o;
        return cp.point.equals(point) && cp.color.equals(color);
    }
    ... // 나머지 코드는 생략
}

자바 라이브러리에도 구체 클래스를 확장해 값을 추가한 클래스가 종종 있다. 한 가지 예로 java.sql.Timestamp는 java.util.Date를 확장한 후 nanoseconds 필드를 추가했다. 그 결과로 Timestamp의 equals는 대칭성을 위배하며, Date 객체와 한 컬렉션에 넣거나 서로 섞어 사용하면 엉뚱하게 동작할 수 있다. 그래서 Timestamp의 API 설명에는 Date와 섞어 쓸 때의 주의사항을 언급하고 있다. 둘을 명확히 분리해 사용하는 한 문제될 것은 없지만, 섞이지 않도록 보장해줄 수단이 없다. 자칫 잘못하면 디버깅하기 어려운 이상한 오류를 경험할 수도 있다. Timestamp를 이렇게 설계한 것은 실수니 절대 따라 해서는 안된다. 

일관성은 두 객체가 같다면 앞으로도 영원히 같아야 한다는 뜻이다. 가변 객체는 비교 시점에 따라 서로 다를 수도 혹은 같을 수도 있는 반면, 불변 객체는 한번 다르면 끝까지 달라야한다. 클래스를 작성할 때는 불변 클래스로 만드는 게 나을지를 심사숙고하자. 불변 클래스로 만들기로 했다면 equals가 한번 같다고 한 객체와는 영원히 같다고 답하고, 다르다고 한 객체와는 영원히 다르다고 답하도록 만들어야 한다. 

클래스가 불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안 된다. 이 제약을 어기면 일관성 조건을 만족시키기가 아주 어렵다. 예컨대 java.net.URL의 equals는 주어진 URL과 매핑된 호스트의 IP 주소를 이용해 비교한다. 호스트 이름을 IP 주소로 바꾸려면 네트워크를 통해야 하는데, 그 결과가 항상 같다고 보장할 수 없다. 이는 URLequals가 일반 규약을 어기게 하고, 실무에서도 종종 문제를 일으킨다. URL의 equals를 이렇게 구현한 것은 커다란 실수였으니 절대 따라 해서는 안 된다. 하위 호환성이 발목을 잡아 잘못된 동작을 바로잡을 수도 없다. 이런 문제를 피하려면 equals는 항시 메모리에 존재하는 객체만을 사용한 결정적(deterministic) 계산만 수행해야 한다. 

마지막 요건은 공식 이름이 없으니 임의로 'null-아님'이라 부르겠다. null-아님은 이름처럼 모든 객체가 null과 같지 않아햐 한다는 뜻이다. 의도하지 않았음에도 o.equals(null)이 true를 반환하는 상황은 상상하기 어렵지만, 실수로 NullPointerException을 던지는 코드는 흔할 것이다. 이 일반 규약은 이런 경우도 허용하지 않는다. 수많은 클래스가 다음 코드처럼 입력이 null인지를 확인해 자신을 보호한다. 

// 명시적 null 검사 - 필요 없다!
@Override
public boolean equals(Object o) {
    if (o == null) return false;
    ...
}

이러한 검사는 필요하지 않다. 동치성을 검사하려면 equals는 건네받은 객체를 적절히 형변환한 후 필수 필드드의 값을 알아내야 한다. 그러려면 형변환에 앞서 instanceof 연산자로 입력 매개변수가 올바른 타입인지 검사해야 한다. 

// 묵시적 null 검사 - 이 방법이 낫다.
@Override
public boolean equals(Object o) {
    if (!(o instanceof MyType) return false;
    
    MyType = mt = (MyType) o;
}

equals가 타입을 확인하지 않으면 잘못된 타입이 인수로 주어졌을 때 ClassCastException을 던져서 일반 규약을 위배하게 된다. 그런데 instanceof는 첫 번째 피연산자가 null이면 false를 반환한다. 따라서 입력이 null이면 타입 확인 단계에서 false를 반환하기 때문에 null 검사를 명시적으로 하지 않아도 된다. 

지금까지의 내용을 종합해서 양질의 equals 메서드 구현 방법을 단계별로 정리해보겠다. 

  1. == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다. 
  2. instanceof 연산자로 입력이 올바른 타입인지 확인한다.
  3. 입력을 올바른 타입으로 형변환한다.
  4. 입력 객체와 자기 자신의 대응되는 '핵심' 필드들이 모두 일치하는지 하나씩 검사한다. 

float와 double을 제외한 기본 타입 필드는 == 연산자로 비교하고, 참조 타입 필드는 각각의 equals 메서드로, float와 double 필드는 각각 정적 메서드인 Float.compare(float, float)와 Double.compare(double, double)로 비교한다. float와 double을 특별 취급하는 이유는 Float.NaN-0.0f, 특수한 부동소수 값등을 다뤄야 하기 때문이다. Float.equals와 Double.equals 메서드를 대신 사용할 수도 있지만, 이 메서드들은 오토박싱을 수반할 수 있으니 성능상 좋지 않다. 배열 필드는 원소 각각을 앞서의 지침대로 비교한다. 배열의 모든 원소가 핵심필드라면, Arrays.equals 메서드들 중 하나를 사용하자.

때론 null도 정상 값으로 취급하는 참조 타입 필드도 있다. 이런 필드는 정적메서드인 Objects.equals(Object, Object)로 비교해 NullPointerException 발생을 예방하자.

앞서의 CaseInsensitiveString 예처럼 비교하기가 아주 복잡한 필드를 가진 클래스도 있다. 이럴 때는 그 필드의 표준형(canonical form)을 저장해둔 후 표준형끼리 비교하면 훨씬 경제적이다. 이 기법은 특히 불변 클래스에 제격이다. 가변 객체라면 값이 바뀔 때마다 표준형을 최신 상태로 갱신해줘야 한다. 

어떤 필드를 먼저 비교하느냐가 equals의 성능을 좌우하기도 한다. 최상의 성능을 바란다며 다를 가능성이 더 크거나 비교하는 비용이 싼 필드를 먼저 비교하자. 동기화용 락(lock) 필드 같이 객체의 논리적 상태와 관련 없는 필드는 비교하면 안 된다. 핵심 필드로부터 계산해낼 수 있는 파생 필드 역시 굳이 비교할 필요는 없지만, 파생 필드를 비교하는 쪽이 더 빠를 때도 있다. 파생 필드가 객체 전체의 상태를 대표하는 상황이 그렇다. 예컨대 자신의 영역을 캐시해두는 Polygon 클래스가 있다고 해보자. 그렇다면 모든변과 정점을 일일이 비교할 필요 없이 캐시해둔 영역만 비교하면 결과를 곧바로 알 수 있다.

equals를 다 구현했다면 세 가지만 자문해보자. 대칭적인가? 추이성이 있는가? 일관적인가? 자문에서 끝내지 말고 단위 테스트를 작성해 돌려보자. 단, equals 메서드를 AutoValue를 이용해 작성했다면 테스트를 생략해도 안심할 수 있다. 세 요건 중 하나라도 실패한다면 원인을 찾아서 고치자. 물론 나머지 요건인 반사성과 null-아님도 만족해야 하지만, 이 둘이 문제되는 경우는 별로 없다.

다음은 이상의 비법에 따라 작성해본 PhoneNumber 클래스용 equals 메서드다. 

public final class PhoneNumber {
    private final short areaCode, prefix, lineNum;
    
    public PhoneNumber(int areaCode, int prefix, int lineNum) {
        this.areaCode = rangeCheck(areaCode, 999, "지역코드");
        this.prefix = rangeCheck(prefex, 999, "프리픽스");
        this.lineNum = rangeCheck(lineNum, 9999, "가입자 번호");
    }
    
    private static short rangeCheck(int val, int max, String arg) {
        if (val < 0 || val > max) 
            throw new IllegalArgumentException(arg + ": " + val);
        return (short) val;
    }
    
    @Override
    public boolean equals(Object o) {
        if (o == this)
            return true;
        if (!(o instanceof PhoneNumber))
            return false;
        
        PhoneNumber pn = (PhoneNumber) o;
        return pn.lineNum == lineNum && pn.prefix == prefix
            && pn.areaCode == areaCode;
    }
    ... // 나머지 코드 생략
}

마지막으로 주의 사항이다. 

  • equals를 재정의할 땐 hashCode 도 반드시 재정의하자
  • 너무 복잡하게 해결하려 들지 말자. 필드들의 동치성만 검사해도 equals 규약을 어렵지 않게 지킬 수 있다. 오히려 너무 공격적으로 파고들다가 문제를 일으키기도 한다. 오히려 너무 공격적으로 파고들다가 문제를 일으키기도 한다. 일반적으로 별칭(alias)은 비교하지 않는게 좋다. 예컨대 File 클래스라면, 심볼릭 링크를 비교해 같은 파일을 가리키는지를 확인하려 들면 안 된다. 다행히 File 클래스는 이런 시도를 하지 않는다. 
  • Object 외의 타입을 매개변수로 받는 equals 메서드는 선언하지 말자. 많은 프로그래머가 equals를 다음과 같이 작성해놓고 문제의 원인을 찾아 해맨다. 
// 잘못된 예 - 입력 타입은 반드시 Object여야 한다!
public boolean equals(MyClass o) {
    ...
}

이 메서드는 Object.equals를 재정의한 게 아니다. 입력 타입이 Object가 아니므로 재정의가 아니다 다중정의한 것이다. 기본 equals를 그대로 둔 채로 추가한 것일지라도, 이처럼 '타입을 구체적으로 명시한' equals는 오히려 해가 된다. 이 메서드는 하위 클래스에서의 @Override 애너테이션이 긍정 오류를 내게 하고 보안 측면에서도 잘못된 정보를 준다. 이번 절 예제 코드들에서처럼 @Override 애너테이션을 일관되게 사용하면 이러한 실수를 예방할 수 있다. 예를 들어 다음 equals 메서드는 컴파일되지 않고, 무엇이 문제인지를 정확히 알려주는 오류 메시지를 보여줄 것 이다. 

// 여전이 잘못된 예 - 컴파일되지 않음
@Override
public boolean equals(MyClass o) {
    ...
}

equals(hashCode도 마찬가지)를 작성하고 테스트하는 일은 지루하고 이를 테스트하는 코드로 항상 뻔하다. 다행히 이 작업을 대신해줄 오픈소스가 있으니, 그 친구는 바로 구글이 만든 AutoValue 프레임워크다. 클래스에 애너테이션 하나만 추가하면 AutoValue 프레임워크다. 클래스에 애너테이션 하나만 추가하면 AutoValue가 이 메서드들을 알아서 작성해주며, 우리가 직접 작성하는 것과 근본적으로 똑같은 코드를 만들어 줄 것이다.

대다수의 IDE도 같은 기능을 제공하지만 생성된 코드가 AutoValue만큼 깔끔하거나 읽기 좋지는 않다. 또한 IDE는 나중에 클래스가 수정된 걸 자동으로 알아채지는 못하니 테스트 코드를 작성해둬야 한다. 

더보기

핵심정리 

꼭 필요한 경우가 아니면 equals를 재정의하지 말자. 많은 경우에 Object의 equals가 우리가 원하는 비교를 정확히 수행해준다. 재정의해야 할 때는 그 클래스의 핵심 필드 모두를 빠짐없이, 다섯 가지 규약을 확실히 지켜가며 비교해야 한다.

728x90
개발서적/이펙티브 자바

아이템9 - try-finally보다는 try-with-resources를 사용하라

728x90

자바 라이브러리에는 close 메서드를 호출해 직접 닫아줘야 하는 자원이 많다. InputStream, OutputStream, java.sql.Connection 등이 좋은 예다. 자원 닫기는 클라이언트가 놓치기 쉬워서 예측할 수 없는 성능 문제로 이어질 수도 있다. 이런 자원 중 수 상당수가 안전망으로 finalizer를 활용하고는 있지만 finalizer는 그리 믿을만하지 못하다. 전통적으로 자원이 제대로 닫힘을 보장하는 수단으로 try-finally가 쓰였다. 예외가 발생하거나 메서드에서 반환되는 경우를 포함해서 말이다. 

static String firstLineOfFile(String path) throws IOException {
    BufferedReader br = new BufferedReader(new FileReader(path));
    try {
        return br.readLine();
    } finally {
        br.close();
    }
}

위 방식도 나쁘지 않다, 자원을 하나 더 추가해보자! 

static void copy(String src, String dst) throws IOException {
    InputStream in = new FileInputStream(src);
    try {
        OutputStream out = new FileOutputStream(dst);
        try {
            byte[] buf = new byte[BUFFER_SIZE];
            int n;
            while ((n = in.read(buf)) >= 0) {
                out.write(buf, 0, n);
            }
        } finally {
            out.close();
        }
    } finally {
        in.close();
    }
}

try-finally 문을 제대로 사용한 앞의 두 코드 예제에조차 미묘한 결점이 있다. 예외는 try 블록과 finally블록 모두에서 발생할 수 있는데, 예컨데 기기에 물리적인 문제가 생긴다면 firstLineOfFile 메서드 안의 readLine 메서드가 예외를 던지고, 같은 이유로 close 메서드도 실패할 것이다. 이런 상황이라면 두번째 예외가 첫 번째 예외를 완전히 집어삼켜 버린다. 그러면 스택 추적 내역에 첫 번째 예외에 관한 정보는 남지 않게 되어, 실제 시스템에서의 디버깅을 몹시 어렵게 한다. 물론 두 번째 예외 대신 첫 번째 예외를 기록하도록 코드를 수정할 수는 있지만, 코드가 너무 지저분해져서 실제로 그렇게까지 하는 경우는 거의 없다. 

이러한 문제들은 자바 7이 투척한 try-with-resources 덕에 모두 해결되었다. 이 구조를 사용하려면 해당 자원이 AutoCloseable 인터페이스를 구현해야 한다. 단순히 void를 반환하는 close 메서드 하나만 덩그러니 정의한 인터페이스다. 자바 라이브러리와 서드파티 라이브러리들의 수많은 클래스와 인터페이스가 이미 AutoCloseable을 구현하거나 확장해뒀다. 우리들도 닫아야 하는 자원을 뜻하는 클래스를 작성한다면 AutoCloseable을 반드시 구현하도록 하자.

다음은 try-with-resources를 사용해 위의 코드들을 재작성한 예다.

static String firstLineOfFile(String path) throws IOException {
    try (BufferedReader br = new BufferedReader(
            new FileReader(path))) {
        return br.readLine();
    }
}
static void copy(String src, String dst) throws IOException {
    try (InputStream in = new FileInputStream(src);
            OutputStream out = new FileOutputStream(dst)) {
        byte[] buf = new byte[BUFFER_SIZE];
        int n;
        while ((n = in.read(buf)) >= 0) {
            out.write(buf, 0, n);
        }
    }
}

try-with-resources 버전이 짧고 읽기 수월할 뿐 아니라 문제를 진단하기도 훨씬 좋다. firstLineOfFile 메서드를 생각해보자. readLine 과 close 호출 양쪽에서 예외가 발생하면, close에서 발생한 예외는 숨겨지고 readLine에서 발생한 예외가 기록된다. 이처럼 실전에서는 프로그래머에게 보여줄 예외 하나만 보존되고 여러 개의 다른 예외가 숨겨질 수도 있다. 이렇게 숨겨진 예외들도 그냥 버려지지는 않고, 스택 추적 내역에 '숨겨졌다(suppressed)'는 꼬리표를 달고 출력된다. 또한, 자바7에서 Throwable에 추가된 getSuppressed 메서드를 이용하면 프로그램 코드에서 가져올 수도 있다. 보통의 try-finally에서처럼 try-with-resources에서도 catch 절을 쓸 수 있다. catch 절 덕분에 try문을 더 중첩하지 않고도 다수의 예외를 처리할 수 있다. 아래의 코드에서는 firstLineOfFile 메서드를 살짝 수정하여 파일을 열거나 데이터를 읽지 못했을 때 예외를 던지는 대신 기본값을 반환하도록 해봤다.

static String firstLineOfFile(String path, String defaultVal) {
    try (BufferedReader br = new BufferedReader(
            new FileReader(path))) {
        return br.readLine();
    } catch (IOException e) {
        return defaultVal;
    }
}
더보기
핵심 정리
꼭 회수해야 하는 자원을 다룰 때는 try-finally 말고, try-with-resources를 사용하자, 예외는 없다. 코드는 더 짧고 분명해지고, 만들어지는 예외 정보도 훨씬 유용하다. try-finally로 작성하면 실용적이지 못할 만큼 코드가 지저분해지는 경우라도, try-with-resource로는 정확하고 쉽게 자원을 회수할 수 있다. 

 

728x90
개발서적/이펙티브 자바

아이템8 - finalizer와 cleaner 사용을 피하라

728x90

자바는 두 가지 객체 소멸자를 제공한다. 그중 finalizer는 예측할 수 없고, 상황에 따라 위험할 수 있어 일반적으로 불필요하다. 오동작, 낮은 성능, 이식성 문제의 원인이 되기도 한다. finalizer는 나름의 쓰임새가 몇 가지 있긴 하지만 기본적으로 '쓰지 말아야' 한다. 그래서 자바 9에서는 finalizer를 사용 자제 API로 지정하고 cleaner를 그 대안으로 소개했다(하지만 자바 라이브러리에서도 finalizer를 여전히 사용한다). cleaner는 finalizer보다는 덜 위험하지만, 여전히 예측할 수 없고, 느리고, 일반적으로 불필요하다. 

자바의 finalizer와 cleaner는 C++의 파괴자(destructor)와는 다른 개념이다. C++에서의 파괴자는 (생성자의 꼭 필요한 대척점으로) 특정 객체와 관련된 자원을 회수하는 보편적인 방법이다. 자바에서는 접근할 수 없게 된 객체를 회수하는 역할을 가비지 컬렉터가 담당하고, 프로그래머에게는 아무런 작업도 요구하지 않는다. C++의 파괴자는 비메모리 자원을 회수하는 용도로도 쓰인다. 하지만 자바에서는 try-with-resources와 try-finally를 사용해 해결한다.

finalizer와 cleaner는 즉시 수행된다는 보장이 없고 객체에 접근할 수 없게 된 후 finalizer나 cleaner가 실행되기까지 얼마나 걸릴지 알 수 없다. 즉, finalizer와 cleaner로는 제때 실행되어야 하는 작업은 절대 할 수 없다.  예컨대 파일 닫기를 finalizer나 cleaner에 맡기면 중대한 오류를 일으킬 수 있다. 시스템이 동시에 열 수 있는 파일 개수에 한계가 있기 때문이다. 시스템이 finalizer나 cleaner 실행을 게을리해서 파일을 계속 열어 둔다면 새로운 파일을 열지 못해 프로그램이 실패할 수 있다.

finalizer나 cleaner를 얼마나 신속히 수행할지는 전적으로 가비지 컬렉터 알고리즘에 달렸으며, 이는 가비지 컬렉터 구현마다 천차만별이다. finalizer나 cleaner 수행 시점에 의존하는 프로그램의 동작 또한 마찬가지다. 우리가 테스트한 JVM에서는 완벽하게 동작하던 프로그램이 가장 중요한 고객의 시스템에서는 엄청난 재앙을 일으킬지도 모른다. 

자바 언어 명세는 finalizer나 cleaner의 수행 시점뿐 아니라 수행 여부조차 보장하지 않는다. 접근할 수 없는 일부 객체에 딸린 종료 작업을 전혀 수행하지 못한 채 프로그램이 중단될 수도 있다는 얘기다. 따라서 프로그램 생애주기와 상관없는, 상태를 영구적으로 수정하는 작업에서는 절대 finalizer나 cleaner에 의존해서는 안된다. 예를 들어 데이터베이스 같은 공유 자원의 영구 락(lock) 해제를 finalizer나 cleaner에 맡겨 놓으면 분산 시스템 전체가 서서히 멈출 것이다. 

System.gc나 System.runFinalization 메서드에 현혹되지 말자. finalizer와 cleaner가 실행될 가능성을 높여줄 수는 있으나, 보장해주진 않는다. 사실 이를 보장해주겠다는 메서드가 2개 있었다. 바로 System.runFinalizersOnExit와 Runtime.runFinalizersOnExit다. 하지만 이 두 메서드는 심각한 결함때문에 수십 년간 지탄받아 왔다. 

finalizer의 부작용은 여기서 끝이 아니다. finalizer 동작 중 발생한 예외는 무시되며, 처리할 작업이 남았더라도 그 순간 종료된다. 잡지 못한 예외 때문에 해당 객체는 자칫 마무리가 덜 된 상태로 남을 수 있다. 그리고 다른 스레드가 이처럼 훼손된 객체를 사용하려 한다면 어떻게 동작할지 예측할 수 없다. 보통의 경우엔 잡지 못한 예외가 스레드를 중단시키고 스택 추적 내역을 출력하겠지만, 같은 일이 finalizer에서 일어난다면 경고조차 출력하지 않는다. 그나마 cleaner를 사용하는 라이브러리는 자신의 스레드를 통제하기 때문에 이러한 문제가 발생하지 않는다. 

finalizer와 cleaner는 심각한 성능 문제도 동반한다. 내 컴퓨터에서 간단한 AutoCloseable 객체를 생성하고 가비지 컬렉터가 수거하기까지 12ns가 걸린반면, finalizer를 사용하면 550ns가 걸렸다. 다시 말해 finalizer를 사용한 객체를 생성하고 파괴하니 50배나 느렸다. finalizer가 가비지 컬렉터의 효율을 떨어뜨리기 때문이다. cleaner도 클래스의 모든 인스턴스를 수거하는 형태로 사용하면 성능은 finalizer와 비슷하다. 하지만 잠시 후에 살펴볼 안전망 형태로만 사용하면 훨씬 빨라진다. 

finalizer를 사용한 클래스는 finalizer 공격에 노출되어 심각한 보안 문제를 일으킬 수도 있다. finalizer 공격 원리는 간단하다. 생성자나 직렬화 과정에서 예외가 발생하면, 이 생성되다 만 객체에서 악의적인 하위 클래스의 finalizer가 수행될 수 있게 된다. 있어서는 안 될 일이다. 이 finalizer는 정적 필드에 자신의 참조를 할당하여 가비지 컬렉터가 수집하지 못하게 막을 수 있다. 이렇게 일그러진 객체가 만들어지고 나면, 이 객체의 메서드를 호출해 애초에는 허용되지 않았을 작업을 수행하는 건 일도 아니다. 객체 생성을 막으려면 생성자에서 예외를 던지는 것만으로 충분하지만, finalizer가 있다면 그렇지도 않다. 이러한 공격은 끔찍한 결과를 초래할 수 있다. final 클래스들은 그 누구도 finalizer 공격으로부터 방어하려면 아무 일도 하지 않는 finalize 메서드를 만들고 final로 선언하자. 

그렇다면 파일이나 스레드 등 종료해야 할 자원을 담고 있는 객체의 클래스에서 finalizer나 cleaner를 대신해줄 묘안은 무엇일까? 그저 AutoCloseable을 구현해주고, 클라이언트에서 인스턴스를 다 쓰고 나면 close 메서드를 호출하면 된다. 구체적인 구현법과 관련하여 알아두면 좋을 게 하나 있다. 각 인스턴스는 자신이 닫혔는지를 추적하는 것이 좋다. 다시 말해, close 메서드에서 이 객체는 더 이상 유효하지 않음을 필드에 기록하고, 다른 메서드는 이 필드를 검사해서 객체가 닫힌 후에 불렸다면 IllegalStateException을 던지는 것이다.

이쯤이면 cleaner와 finalizer는 대체 언제 쓰는 것인지 궁금해진다. 적절한 쓰임새가 두 가지 있다. 하나는 자원의 소유자가 close 메서드를 호출하지 않는 것에 대비한 안전망 역할이다. cleaner나 finalizer가 즉시 호출되리라는 보장은 없지만, 클라이언트가 하지 않는 자원 회수를 늦게라도 해주는 것이 아예 안 하는 것보다는 나으니 말이다. 이런 안전망 역할의 finalizer를 작성할 때는 그럴만한 값어치가 있는지 심사숙고하자. 자바 라이브러리의 일부 클래스는 안전망 역할의 finalizer를 제공한다. FileInputStream, FileOutputStream, ThreadPoolExecutor가 대표적이다. 

cleaner와 finalizer를 적절히 활용하는 두 번째 예는 네이티브 피어(native peer)와 연결된 객체에서다. 네이티브 피어란 일반 자바 객체가 네이티브 메서드를 통해 기능을 위임한 네이티브 객체를 말한다. 네이티브 피어는 자바 객체가 아니니 가비지 컬렉터는 그 존재를 알지 못한다. 그 결과 자바 피어를 회수할 때 네이티브 객체까지 회수하지 못한다. cleaner나 finalizer가 나서서 처리하기에 적당한 작업이다. 단, 성능 저하를 감당할 수 있고 네이티브 피어가 심각한 자원을 가지고 있지 않을 때에만 해당된다. 성능 저하를 감당할 수 없거나 네이티브 피어가 사용하는 자원을 즉시 회수해야 한다면 앞서 설명한 close메서드를 사용해야 한다.

cleaner는 사용하기에 조금 까다롭다. 다음의 Room 클래스로 이 기능을 설명한다. 방(room) 자원을 수거하기 전에 반드시 청소(clean)해야 한다고 가정해보자. Room 클래스는 AutoCloseable을 구현한다. 사실 자동 청소 안전망이 cleaner를 사용할지 말지는 순전히 내부 구현 방식에 관한 문제다. 즉, finalizer와 달리 cleaner는 클래스의 public API에 나타나지 않는다는 이야기다. 

public class Room implements AutoCloseable {
    private static final Cleaner cleaner = Cleaner.create();
    
    // 청소가 필요한 자원. 절대 Room을 참조해서는 안 된다! 
    private static class State implements Runnable {
        int numJunkPiles; // 방(Room) 안의 쓰레기 수
        
        State(int numJunkPiles) {
            this.numJunkPiles = numJunkPiles;
        }
        
        // close 메서드나 cleaner가 호출된다.
        @Override public void run() {
            Syste.out.println("방 청소");
            numJunkPiles = 0;
        }
    }
    
    // 방의 상태. cleannable과 공유한다.
    private final State state;
    
    // cleanable 객체. 수거 대상이 되면 방을 청소한다. 
    private final Cleaner.Cleanable cleanable;
    
    public Room(int numJunkPiles) {
        state = new State(numJunkPiles);
        cleanable = cleaner.register(this, state);
    }
    
    @Override public void close() {
        cleanable.clean();
    }
}

static으로 선언된 중첩 클래스인 State인 cleaner가 방을 청소할 때 수거할 자원들을 담고 있다. 이 예에서는 단순히 방 안의 쓰레기 수를 뜻하는 numJunkPiles 필드가 수거할 자원에 해당한다. 더 현실적으로 만들려면 이 필드는 네이티브 피어를 가리키는 포인터를 담은 final long 변수여야 한다. State는 Runnable을 구현하고, 그 안의 run 메서드는 cleanable에 의해 딱 한 번만 호출 될 것이다. 이 cleanable 객체는 Room 생성자에서 cleaner에 Room과 State를 등록할 때 얻는다. run 메서드가 호출되는 상황은 둘 중 하나다. 보통은 Room의 close 메서드를 호출할 때다. close 메서드에서 Cleanable의 clean을 호출하면 이 메서드 안에서 run을 호출한다. 혹은 가비지 컬렉터가 Room을 회수할 때까지 클라이언트가 close를 호출하지 않는다면, cleaner가 State의 run 메서드를 호출해줄 것이다. 

State 인스턴스는 '절대로' Room 인스턴스를 참조해서는 안 된다. Room 인스턴스를 참조할 경우 순환참조가 생겨 가비지 컬렉터가 Room 인스턴스를 회수해갈 기회가 오지 않는다. State가 정적 중첩 클래스인 이유가 여기에 있다. 정적이 아닌 중첩 클래스는 자동으로 바깥 객체의 참조를 갖게 되기 때문이다. 이와 비슷하게 람다 역시 바깥 객체의 참조를 갖기 쉬우니 사용하지 않는 것이 좋다. 

앞서 이야기한 대로 Room의 cleaner는 단지 안전망으로만 쓰였다. 클라이언트가 모든 Room 생성을 try-with-resuorces 블록으로 감쌌다면 자동 청소는 전혀 필요하지 않다. 다음은 잘 짜인 클라이언트 코드의 예다. 

public class Adult {
    public static void main(String[] args) {
        try (Room myRoom = new Room(7)) {
            System.out.println("안녕~");
        }
    }
}

기대한 대로 Adult 프로그램을 "안녕~"을 출력한 후, 이어서 "방 청소"를 출력한다. 이번엔 결코 방 청소를 하지 않는 다음 프로그램을 살펴보자. 

public class Teenager {
    public static void main(String[] args) {
        new Room(99);
        System.out.println("아무렴");
    }
}

"아무렴"에 이어 "방 청소"가 출력되리라 기대했는가? 하지만 내 컴퓨터에서 "방 청소"는 한 번도 출력되지 않았다. 앞서 '예측할 수 없다'고 한 상황이다. cleaner의 명세에는 이렇게 쓰여 있다.

더보기

System.exit을 호출할 때의 cleaner 동작은 구현하기 나름이다. 청소가 이뤄질지는 보장하지 않는다. 

명세에선 명시하지 않았지만 일반적인 프로그램 종료에서도 마찬가지다. 내 컴퓨터에서는 Teenager의 main 메서드에 System.gc() 를 추가하는 것으로 종료전에 "방 청소"를 출력할 수 있었지만, 여러분의 컴퓨터에서도 그러리라는 보장은 없다. 

핵심 정리

cleaner(자바 8까지는 finalizer)는 안전망 역할이나 중요하지 않은 네이티브 자원 회수용으로만 사용하자. 물론 이런 경우라도 불확실성과 성능 저하에 주의해야 한다.

 

728x90
개발서적/이펙티브 자바

아이템6 - 불필요한 객체 생성을 피하라

728x90

이전글에 이어서 아이템6의 내용을 정리한다. 

똑같은 기능의 객체를 매번 생성하기보다는 객체 하나를 재사용하는 편이 나을때가 많다. 재사용은 빠르고 세련됐다. 특히 불변 객체는 언제든 재사용할 수 있다. 

다음 코드는 하지 말아야 할 극단적인 예이다. 자세히 살펴보고 절대 따라하지 말자

String s = new String("bikini"); // 따라 하지 말 것! 

이 문장은 실행될 때마다 String 인스턴스를 새로 만든다. 완전히 쓸데없는 행위다. 생성자에 넘겨진 "bikini" 자체가 이 생성자로 만들어내려는 String과 기능적으로 완전히 똑같다. 이 문장이 반복문이나 빈번히 호출되는 메서드 안에 있다면 쓸데없는 String인스턴스가 수백만 개 만들어질 수도 있다. 

개선된 버전을 보자 

String s = "bikini";

이 코드는 새로운 인스턴스를 매번 만드는 대신 하나의 String 인스턴스를 사용한다. 심지어 이 방식을 사용한다면 같은 가상 머신 안에서 이와 똑같은 문자열 리터럴을 사용하는 모든 코드가 같은 객체를 재사용함이 보장된다. 

생성자 대신 정적 팩터리 메서드를 제공하는 불변 클래스에서는 정적 팩터리 메서드를 사용해 불필요한 객체 생성을 피할 수 있다. 예컨대 Boolean(String) 생성자 대신 Boolean.valueOf(String) 팩터리 메서드를 사용하는 것이 좋다(그래서 이 생성자는 자바9에서 사용 자제 API로 지정되었다). 생성자는 호출할 때마다 새로운 객체를 만들지만, 팩터리 메서드는 전혀 그렇지 않다. 불변 객체만이 아니라 가변 객체라 해도 사용 중에 변경되지 않을 것임을 안다면 재사용할 수 있다.

생성 비용이 아주 비싼 객체도 더러 있다. 이런 '비싼 객체'가 반복해서 필요하다면 캐싱하여 재사용하길 권한다. 하지만 자신이 만든 객체가 비싼객체인지를 매번 명확히 알 수는 없다. 예를 들어 주어진 문자열이 유효한 로마 숫자인지를 확인하는 메서드를 작성한다고 해보자. 다음은 정규표현식을 활용한 가장 쉬운 해법이다.

static boolean isRomanNumeral(String s) {
    return s.matches("^(?=.)M*(C[MD]D?C{0,3})"
        + "(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$");
}

이 방식의 문제는 String.matches 메서드를 사용한다는 데 있다. String.matches는 정규표현식으로 문자열 행태를 확인하는 가장 쉬운 방법이지만, 성능이 중요한 상황에서 반복해 사용하기엔 적합하지 않다. 이 메서드가 내부에서 만드는 정규표현식용 Pattern 인스턴스는,한 번 쓰고 버려져서 곧바로 가비지 컬렉션 대상이 된다. Pattern은 입력받은 정규표현식에 해당하는 유한 상태 머신(finite state machine)을 만들기 때문에 인스턴스 생성 비용이 높다.

성능을 개선하려면 필요한 정규표현식을 표현하는 (불변인) Pattern 인스턴스를 클래스 초기화(정적 초기화) 과정에서 직접 생성해 캐싱해두고, 나중에 isRomanNumeral 메서드가 호출될 때마다 이 인스턴스를 재사용한다. 

public class RomanNumerals {
    private static final Pattern ROMAN = Pattern.compile(
        "^(?=.)M*(C[MD]|D?C{0,3}"
        + "(X[CL]|L?X{0,3})(I[XV]|V?{0,3})$");
        
    static boolean isRomanNumeral(String s) {
        return ROMAN.matcher(s).matches();
    }
}

이렇게 개선하면 isRomanNumeral이 빈번히 호출되는 상황에서 성능을 상당히 끌어올릴 수 있다. 성능만 좋아진 것이 아니라 코드도 더 명확해졌다. 개선 전에서는 존재조차 몰랐던 Pattern 인스턴스를 static final 필드로 끄집어내고 이름을 지어주어 코드의 의미가 훨씬 잘 드러난다. 

개선된 isRomanNumeral 방식의 클래스가 초기화된 후 이 메서드를 한 번도 호출하지 않는다면 ROMAN 필드는 쓸데없이 초기화된 꼴이다. isRomanNumeral 메서드가 처음 호출될 때 필드를 초기화하는 지연 초기화로 불필요한 초기화를 없앨 수는 있지만, 권하지는 않는다. 지연 초기화는 코드를 복잡하게 만드는데, 성능은 크게 개선되지 않을 때가 많기 때문이다. 

객체가 불변이라면 재사용해도 안전함이 명백하다. 하지만 훨씬 덜 명확하거나, 심지어 직관에 반대되는 상황도 있다. 어댑터를 생각해보자. 어댑터는 실제 작업은 뒷단 객체에 위임하고, 자신은 제2의 인터페이스 역할을 해주는 객체다. 어댑터는 뒷단 객체만 관리하면 된다. 즉, 뒷단 객체 외에는 관리할 상태가 없으므로 뒷단 객체 하나당 어댑터 하나씩만 만들어지면 충분하다.

Map 인터페이스의 keySet 메서드는 Map 객체 안의 키 전부를 담은 Set 뷰를 반환한다. keySet을 호출할 때마다 새로운 Set 인스턴스가 만들어지리라고 순진하게 생각할 수도 있지만, 사실은 매번 같은 Set 인스턴스를 반환할지도 모른다. 반환된 Set 인스턴스가 일반적으로 가변이더라도 반환된 인스턴스들은 기능적으로 모두 똑같다. 즉, 반환한 객체 중 하나를 수정하면 다른 모든 객체가 따라서 바뀐다. 모두가 똑같은 Map 인스턴스를 대변하기 때문이다. 따라서 keySet이 뷰 객체를 여러 개 만들어도 상관은 없지만, 그럴 필요도 없고 이득도 없다. 

불필요한 객체를 만들어내는 또 다른 예로 오토박싱(auto boxing)을 들 수 있다. 오토박싱은 프로그래머가 기본 타입과 박싱된 기본 타입을 섞어 쓸 때 자동으로 상호 변환해주는 기술이다. 오토박싱은 기본 타입과 그에 대응하는 박싱된 기본 타입의 구분을 흐려주지만, 완전히 없애주는 것은 아니다. 의미상으로는 별다를 것 없지만 성능에서는 그렇지 않다. 다음 메서드를 보자. 모든 양의 정수를 더하여 총합을 구하는 메서드로, long을 사용해 계산하고 있다. 

private static long sum() {
    Long sum = 0L;
    for (long i = 0; i <= Integer.Max_VALUE; i++)
        sum += i;
        
    return sum;
}

이 프로그램이 정확한 답을 내기는 하지만 제대로 구현했을 때보다 훨씬 느리다. 겨우 문자 하나 때문에 말이다. sum 변수를 long이 아닌 Long으로 선언해서 불필요한 Long 인스턴스가 약 231개나 만들어진 것이다(대략, long 타입인 i가 Long 타입인 sum에 더해질 때마다). 단순히 sum의 타입을 long으로만 바꿔주면 성능이 엄청 좋아진다. 박싱된 기본 타입보다는 기본 타입을 사용하고, 의도치 않은 오토박싱이 숨어들지 않도록 주의하자. 

이번 아이템을 "객체 생성은 비싸니 피해야 한다"로 오해하면 안된다. 특히나 요즘의 JVM에서는 별다른 일을 하지 않는 작은 객체를 생성하고 회수하는 일이 크게 부담되지 않는다. 프로그램의 명확성, 간결성, 기능을 위해서 객체를 추가로 생성하는 것이라면 일반적으로 좋은 일이다.

거꾸로, 아주 무거운 객체가 아닌 다음에야 단순히 객체 생성을 피하고자 여러분만의 객체 풀(pool)을 만들지는 말자. 물론 객체 풀을 만드는 게 나은 예가 있긴 하다. 데이터베이스 연결 같은 경우 생성 비용이 워낙 비싸니 재사용하는편이 낫다. 하지만 일반적으로는 자체 객체 풀은 코드를 헷갈리게 만들고 메모리 사용량을 늘리고 성능을 떨어뜨린다. 요즘 JVM의 가비지 컬렉터는 상당히 잘 최적화되어서 가벼운 객체용을 다룰 때는 직접 만든 객체 풀보다 훨씬 빠르다. 

이번 아이템은 방어적 복사(defensive copy)를 다루는 아이템 50과 대조적이다. 이번 아이템이 "기존 객체를 재사용해야 한다면 새로운 객체를 만들지 마라"라면, 아이템 50은 "새로운 객체를 만들어야 한다면 기존 객체를 재사용하지 마라"다. 방어적 복사가 필요한 상황에서 객체를 재사용했을 때의 피해가, 필요 없는 객체를 반복 생성했을 때의 피해보다 훨씬 크다는 사실을 기억하자. 방어적 복사에 실패하면 언제 터져 나올지 모르는 버그와 보안 구멍으로 이어지지만, 불필요한 객체 생성은 그저 코드 형태와 성능에만 영향을 준다. 

 

728x90
개발서적/이펙티브 자바

아이템 5. 자원을 직접 명시하지 말고 의존 객체 주입을 사용하라

728x90

 

많은 클래스가 하나 이상의 자원에 의존한다. 예를들어 맞춤법 검사기는 사전에 의존하는데, 이런 클래스를 정적 유틸리티 클래스로 구현한 모습을 드물지 않게 볼 수 있다. 

public class SpellChecker {
    private static final Lexicion dictionary = ...;
    
    private SpellChecker() { } // 객체 생성 방지
    
    public static boolean isValid(String word) { ... }
    public static List<String> suggestions(String typo) { ... }
}

비슷하게 싱글턴으로 구현하는 경우도 흔하다. 

public class SpellChecker {
    private final Lexicon dictionary = ...;
    
    private SpellChecker(...) {}
    public static SpellChecker INSTANCE = new SpellChecker(...);
    
    public boolean isValid(String word) {...}
    public List<String> suggestions(String typo) {...}
}

두 방식 모두 사전을 단 하나만 사용한다고 가정한다는 점에서 그리 훌륭해 보이지 않다. 실전에서는 사전이 언어별로 따로 있고 특수 어휘용 사전을 별도로 두기도 한다. 심지어 테스트용 사전도 필요할 수 있다. 사전 하나로 이 모든 쓰임에 대응할 수 있기를 바라는 건 너무 어리석은 생각이다. 

SpellChecker가 여러 사전을 사용할 수 있도록 만들어보자. 간단히 dictionary 필드에서 final 한정자를 제거하고 다른 사전으로 교체하는 메서드를 추가할 수 있지만, 아쉽게도 이 방식은 어색하고 오류를 내기 쉬우며 멀티스레드 환경에서는 쓸 수 없다. 사용하는 자원에 따라 동작이 달라지는 클래스에는 정적 유틸리티 클래스나 싱글턴 방식이 적합하지 않다. 

대신 클래스(SpellChecker)가 여러 자원 인스턴스를 지원해야 하며, 클라이언트가 원하는 자원(dictionary)을 사용해야 한다. 이 조건을 만족하는 간단한 패턴이 있으니, 바로 인스턴스를 생성할 때 생성자에 필요한 자원을 넘겨주는 방식이다. 이는 의존 객체 주입의 한 형태로, 맞춤법 검사기를 생성할 때 의존 객체인 사전을 주입해주면 된다. 

public class SpellChecker {
    private final Lexicon dictionary;
    
    public SpellChecker(Lexicon dictionary) {
        this.dictionary = Objects.requireNoneNull(dictionary);
    }
    
    public boolean isValid(String word) {... }
    public List<String> suggestions(String typo) {...}
}

의존 객체 주입 패턴은 아주 단순하여 수많은 프로그래머가 이 방식에 이름이 있다는 사실도 모른 채 사용해왔다. 예에서는 dictionary라는 딱 하나의 자원만 사용하지만, 자원이 몇 개든 의존 관계가 어떻든 상관없이 잘 작동한다. 또한 불변을 보장하여 여러 클라이언트가 의존 객체들을 안심하고 공유할 수 있기도 하다. 의존 객체 주입은 생성자, 정적 팩터리, 빌더, 모두에 똑같이 응용할 수 있다. 

이 패턴의 변형으로, 생성자에 자원 팩터리를 넘겨주는 방식이 있다. 팩터리란 호출할 때마다 특정 타입의 인스턴스를 반복해서 만들어주는 객체를 말한다. 즉, 팩터리 메서드 패턴(Factory Method pattern)을 구현한 것이다. Supplier<T> 인터페이스가 팩터리를 표현한 완벽한 예다. Supplier<T>를 입력으로 받는 메서드는 일반적으로 한정적 와일드카드 타입(bounded wildard type)을 사용해 팩터리의 타입 매개변수를 제한해야 한다. 이 방식을 사용해 클라이언트는 자신이 명시한 타입의 하위 타입이라면 무엇이든 생성할 수 있는 팩터리를 넘길 수 있다. 예컨대 다음 코드는 클라이언트가 제공한 팩터리가 생성한 타일(Tile)들로 구성된 모자이크(Mosaic)를 만드는 메서드다. 

Mosaic create(Supplier<? extends Tile> tileFactory> {... }

의존 객체 주입의 유연성과 테스트 용이성을 개선해주긴 하지만, 의존성이 수천 개나 되는 큰 프로젝트에서는 코드를 어지럽게 만들기도 한다. 대거(Dagger), 주스(Guice), 스프링(Spring) 같은 의존 객체 주입 프레임워크를 사용하면 이런 어질러짐을 해소할 수 있다. 이런 프레임워크들은 의존 객체를 직접 주입하도록 설계된 API를 알맞게 응용해 사용하고 있음을 언급한다.

더보기

핵심 정리

클래스가 내부적으로 하나 이상의 자원에 의존하고, 그 자원이 클래스 동작에 영향을 준다면 싱글턴과 정적 유틸리티 클래스는 사용하지 않는 것이 좋다. 이 자원들은 클래스가 직접 만들게 해서도 안 된다. 대신 필요한 자원들(혹은 그 자원을 만들어주는 팩터리를) 생성자에 (혹은 정적 팩터리나 빌더에) 넘겨주자. 의존 객체 주입이라 하는 이 기법은 클래스의 유연성, 재사용성, 테스트 용이성을 기막히게 개선해준다.  

 

728x90
개발서적/이펙티브 자바

아이템4. 인스턴스화를 막으려거든 private 생성자를 사용하라

728x90

이 책을 사주며 꼭 빨리 성장하라고 했던 선배(라고 부르고 은인 + 스승이라 부른다)의 당부를 잊고 벌써 몇개월을 소비한 것인가... 빨리 읽어야지 

 

종종 단순히 정적 메서드와 정적 필드만을 담은 클래스를 만들고 싶을 때가 있다. 객체 지향적으로 사고하지 않는 이들이 종종 남용하는 방식이지만, 분명 나름 쓰임새가 있다. 예를들어 java.lang.Math와 java.util.Arrays처럼 기본 타입 값이나 배열 관련 메서드들을 모아놓을 수 있고, java.util.Collections처럼 특정 인터페이스를 구현하는 객체를 생성해주는 정적 메서드(or 팩터리)를 모아놓을 수도 있다(자바8부터는 이런 메서드를 인터페이스에 넣을 수 있다). 마지막으로, final 클래스와 관련한 메서드들을 모아놓을 때도 사용한다. final 클래스를 상속해서 하위 클래스에 메서드를 넣는 건 불가능하기 때문이다. 

정적 멤버만 담은 유틸리티 클래스는 인스턴스로 만들어 쓰려고 설계한게 아니다. 하지만 생성자를 명시하지 않으면 컴파일러가 자동으로 기본 생성자를 만들어준다. 즉, 매개변수를 받지 않는 public 생성자가 만들어지며, 사용자는 이 생성자가 자동 생성돈 것인지 구분할 수 없다. 실제로 공개된 API들에서도 이처럼 의도치 않게 인스턴스화할 수 있게 된 클래스가 종종 목격되곤 한다. 

추상 클래스로 만드는 것으로는 인스턴스화를 막을 수 없다. 하위 클래스를 만들어 인스턴스화하면 그만이다. 이를 본 사용자는 상속해서 쓰라는 뜻으로 오해할 수 있으니 더 큰 문제다. 다행히도 인스턴스화를 막는 방법은 아주 간단하다. 컴파일러가 기본 생성자를 만드는 경우는 오직 명시된 생성자가 없을 때뿐이니 private 생성자를 추가하면 클래스의 인스턴스화를 막을 수 있다. 

public class UtilityClass {
    // 기본 생성자가 만들어지는 것을 막는다(인스턴스화 방지)    
    private UtilityClass() {
        throw new AssertionError();
    }
... // 나머지 코드 생략 
}

명시적 생성자가 private이니 클래스 바깥에서 접근할 수 없다. 꼭 AssertionError를 던질 필요는 없지만, 클래스 안에서 실수로라도 생성자를 호출하지 않도록 해준다. 이 코드는 어떤 환경에서도 클래스가 인스턴스화되는 것을 막아준다. 그런데 생성자가 분명 존재하는데 호출할 수는 없다니, 그다지 직관적이지 않다. 그러지 앞의 코드처럼 적절한 주석을 달아주도록 하자, 이 방식은 상속을 불가능하게 하는 효과도 있다. 모든 생성자는 명시적이든 묵시적이든 상위 클래스의 생성자를 호출하게 되는데, 이를 private로 선언했으니 하위 클래스가 상위 클래스의 생성자에 접근할 길이 없다. 

 

728x90