Effective Java : 아이템8. 완벽공략

    들어가기 전

    이 글은 인프런 백기선님의 이펙티브 자바 강의를 복습하며 작성한 글입니다. 


    완벽 공략

    • p42. Finalizer 공격 (완벽공략 22)
    • p43. AutoClosable (완벽공략 23)
    • p45. 정적이 아닌 중첩 클래스는 자동으로 바깥 객체의 참조를 갖는다
      • 앞에서 static으로 만들라고 했는데, 중첩 클래스로 만드는 경우.. 이해 잘 못할 수 있음. 
    • p45. 람다 역시 바깥 객체의 참조를 갖기 쉽다. 

    아이템 8에서 짚고 넘어가면 좋을 부분은 위의 4가지다. 이 글에서는 위의 4가지를 복습한 내용을 작성한다.

     


    p45. static이 아닌 중첩 클래스는 자동으로 바깥 객체의 참조를 갖는다. 

    OuterClass 내부에 Inner Class가 선언된 구조를 살펴보자. 이 때, InnerClass가 static class가 아니라면 InnerClass는 항상 OuterClass의 참조를 가진다. OuterClass가 생성되어야 InnerClass의 생성자에 접근할 수 있기 때문에 이렇게 생성된 InnerClass는 OuterClass의 참조를 묵시적으로 가지게 된다.

    public class OuterClass {
    
        // static class InnerClass{ }
        public class InnerClass{ }
    
        public static void main(String[] args) {
            OuterClass outerClass = new OuterClass();
            InnerClass innerClass = outerClass.new InnerClass();
            // InnerClass innerClass = new InnerClass();
            System.out.println(innerClass);
    
            outerClass.printConstructor();
        }
    
        private void printConstructor() {
            Field[] declaredFields = InnerClass.class.getDeclaredFields();
            for (Field field : declaredFields) {
                System.out.println("field type: " + field.getType());
                System.out.println("field name: " + field.getName());
            }
        }
    }

    위와 같이 코드를 작성한 후에, main() 메서드를 실행해보면 아래 결과가 나온다. 아래 결과는 innerClass 내부에 선언된 필드 중에 OuterClass의 참조가 존재한다는 것이다. 만약 Cleaner, Finalizer를 Inner 클래스에서 생성해서 사용한다고 하면 이 묵시적 참조는 위험한 동작이 될 수 있다. 묵시적 참조로 인해 Strong Reference가 남게 되어서 절대로 GC가 되지 않을 것이기 때문이다.

    이 문제를 해결하기 위해서는 두 가지 방법이 있다. 

    1. 클래스를 분리한다.
    2. Static Inner 클래스로 생성한다. 

    static Inner Class로 생성한다면, 이 클래스는 OuterClass의 생성 유무와 상관없이 static하게 접근할 수 있다. 따라서 이 녀석을 생성할 때, OuterClass가 필요없기 때문에 묵시적 참조를 가지지 않는다. 

     

    정리

    Cleaner, Finalizer 등을 대상 클래스의 Inner 클래스에서 사용할 것이라면 반드시 static inner 클래스로 생성하자. 그렇지 않다면 묵시적 참조로 인해서 정상적으로 동작하지 않을 수 있다. 


    p45. 람다 역시 바깥 객체의 참조를 갖기 쉽다. 

    클래스 내부 필드로 Lambda Expression이 있고, 이 Expression이 클래스의 또 다른 내부 필드를 참조하는 경우가 있을 수 있다. 이 경우에 람다 익스프레션은 그 클래스 객체를 참조변수 $args1 형태로 가지게 된다. 이 문제는 GC에서 순환참조 문제로 이어지게 되어, GC가 정상적으로 이루어지지 않게 한다. 

    아래 코드에서는 instanceLambda가 내부적으로 LambdaExample 객체를 묵시적으로 참조한다. 

    public class LambdaExample {
    
        private int value = 10;
    
        private Runnable instanceLambda = () -> {
            System.out.println(value);
        };
    
        public static void main(String[] args) throws IllegalAccessException {
            LambdaExample example = new LambdaExample();
            Field[] declaredFields = example.instanceLambda.getClass().getDeclaredFields();
            for (Field field : declaredFields) {
                System.out.println("field type: " + field.getType());
                System.out.println("field name: " + field.getName());
            }
        }
    }

    코드를 실행시켜보면 다음 결과가 나오는 것을 볼 수 있다. $args1은 LambdaExampl 클래스의 인스턴스 참조변수다.

    이 문제를 해결하기 위한 방법은 다음과 같다

    1. 변수를 static으로 선언한다.
    2. 람다 식을 static으로 선언한다.
    3. 클래스 내부의 변수를 참조하지 않도록 한다. 

    1번 방식을 적용해서 아래와 같이 수정한 뒤 코드를 실행해보면 람다 식이 참조하는 변수 중에는 어떠한 필드도 존재하지 않는 것을 알 수 있다.

    public class LambdaExample {
    
        private static int value = 10;
    
        private Runnable instanceLambda = () -> {
            System.out.println(value);
        };
    
        public static void main(String[] args) throws IllegalAccessException {
            LambdaExample example = new LambdaExample();
            Field[] declaredFields = example.instanceLambda.getClass().getDeclaredFields();
            for (Field field : declaredFields) {
                System.out.println("field type: " + field.getType());
                System.out.println("field name: " + field.getName());
            }
        }
    }

     


    p42. Finalizer 공격

    Finalizer 공격은 만들다만 객체를 finalize 메서드에서 사용해서, 보안 취약점을 노려 공격할 수 있는 기법이다. 굳이 Effective Java에서 Finalizer 공격에 대해서 언급한 것은 만약 Finalizer를 사용한다면, 이 부분을 잘 알고 사용해야 하기 때문에 경고 차원에서 이야기 한 것 같다. 

    • Finalizer 공격
    • 방어하는 방법
      • final 클래스로 만든다.
      • finalize() 메서드를 오버라이딩 한 다음 final을 붙여서 하위 클래스에서 오버라이딩 할 수 없도록 막는다. 

    기본 코드

    @Test
    void 일반_계정() {
        Account account = new Account("keesun");
        account.transfer(BigDecimal.valueOf(10.4), "hello");
    }
    
    @Test
    void 푸틴_계정() {
        Account account = new Account("푸틴");
        account.transfer(BigDecimal.valueOf(10.4), "hello");
    }

    아래 Account 클래스를 하나 선언해두었다. 설명은 다음과 같다.

    • Account 클래스는 사용자 ID가 '푸틴'인 경우, 계정을 생성하지 못하도록 IllegalArguemtnException을 던진다. 
    • transfer() 메서드를 이용해서 계좌 이체를 했다는 로그를 출력하도록 한다. 
    public class Account {
    
        private String accountId;
    
        public Account(String accountId) {
            this.accountId = accountId;
    
            if (accountId.equals("푸틴")) {
                throw new IllegalArgumentException("푸틴은 계정을 막습니다. ");
            }
        }
    
        public void transfer(BigDecimal amount, String to) {
            System.out.printf("transfer %f from %s to %s\n",
                    amount, accountId, to);
        }
    }

    아래와 같이 테스트 코드를 작성해보면, 일반_계정 테스트 코드는 성공한다. 그렇지만 푸틴_계정 테스트 코드는 에러가 발생해서 실패한 동작이 된다. 

    @Test
    void 일반_계정() {
        Account account = new Account("keesun");
        account.transfer(BigDecimal.valueOf(10.4), "hello");
    }
    
    @Test
    void 푸틴_계정() {
        Account account = new Account("푸틴");
        account.transfer(BigDecimal.valueOf(10.4), "hello");
    }

     

    Finalizer 공격

    Finalizer 공격은 대상 클래스를 상속 받은 후, finalize() 메서드를 재정의해서 막아둔 동작이 실행되도록 하는 것을 의미한다. 먼저 아래와 같이 BrokenAccount 클래스를 생성할 수 있다. 이 클래스는 finalize를 재정의해서, transfer() 메서드를 호출한다. 

    public class BrokenAccount extends Account{
    
        public BrokenAccount(String accountId) {
            super(accountId);
        }
    
        @Override
        protected void finalize() throws Throwable {
            this.transfer(BigDecimal.valueOf(1000000), "hello");
        }
    }

    그리고 테스트 코드를 다음과 같이 작성한다. 아래 테스트 코드는 다음과 같이 동작한다.

    1. 푸틴 계정을 만들 때, IllegalArgumentException이 발생한다.
    2. 해당 에러를 Catch로 잡은 후, 코드를 계속 진행 시킨다.
    3. System.gc()를 실행한다. gc가 동작하면서 Finalizer는 BrokenAccount의 finalize() 메서드를 실행시키고, 이 때 transfer() 메서드가 호출되어서 돈이 전달된다. 
    @Test
    void 푸틴_계정_finalizer_공격() throws InterruptedException {
        Account account;
        try {
            account = new BrokenAccount("푸틴");
            account.transfer(BigDecimal.valueOf(10.4), "hello");
        } catch (IllegalArgumentException e) {
            System.out.println("here");
        }
        System.gc();
        Thread.sleep(3000);
    
    }

     위 테스트 코드를 실행시켜보면, 아래와 같이 송금된 것을 볼 수 있다. 

     

    Finalizer 공격을 막기 위해서

    Finalizer 공격은 상속을 통해 finalize() 메서드를 재정의해서 발생하는 문제다. 따라서 아래 두 가지 방법으로 처리할 수 있다.

    • 해당 클래스를 FINAL 클래스로 만든다 → 상속 제한
    • finalize() 메서드를 부모 클래스에서 Final로 생성한다 → 재정의 금지 

     


    p43. AutoClosable

    AutoClosable 인터페이스를 구현한 클래스는 try-with-resource에 사용할 수 있다. 이렇게 하면 close()를 자동으로 호출하기 때문에 finally를 사용하지 않더라도 자원 반납을 자동으로 할 수 있다. 여기서는 close()를 구현할 때 좋은 점을 몇 가지 확인하고자 한다. 

    • close()는 idempotence 해야함. 
    • close()에서 발생하는 에러 처리 
      1. 에러를 클라이언트로 던진다.
      2. close() 메서드 내에서 처리
        • Statck Trace만 출력하고 마무리한다.
        • 새로운 예외(Runtime Exception)으로 바꿔서 던진다.
    • AutoClosable의 하위 클래스 Closable을 구현하는 것도 좋음.

     

    close()는 idempotence 해야함. 

    가급적이면 AutoClosable의 close() 메서드는 idempotence 해야한다. AutoClosable의 close()는 단 한번만 호출될 것이기 때문에 항상 idempotence 하지 않아도 된다. 단지 권장사항이다. 그렇지만 예외 경우가 발생할 수 있기 때문에 idempotence 하게 작성해야 한다.

    왜냐하면 close()가 중복으로 호출될 수 있기 때문이다. 예를 들어 try-with-resources를 이용해서 close() 메서드를 호출했지만, 클라이언트가 그 사실을 인지하지 못하고 close()를 한번 더 호출할 수도 있기 때문이다. 이런 경우를 고려한다면, close() 메서드는 idempotence를 구현하는 것이 좋다. 

     

    close()에서 발생하는 에러 처리 

    close() 메서드를 구현하다가보면 에러가 발생하는 경우가 있다. 이 에러를 어떻게 처리해야하는지에 대한 방법을 고려 해볼 수 있다.

    1. 에러를 클라이언트로 던진다.
    2. close() 메서드 내에서 처리
      • Statck Trace만 출력하고 마무리한다.
      • 새로운 예외(Runtime Exception)으로 바꿔서 던진다.

    아래 코드에서 각각의 방법을 볼 수 있다. 

    public class AutoClosableIsGood implements AutoCloseable{
        private BufferedInputStream inputStream;
    
        // 1번 방법
        @Override
        public void close() throws IOException {
            inputStream.close();
        }
        
        // 2번 방법
        /*@Override
        public void close()  {
            try {
                inputStream.close();
            } catch (IOException e) {
                // e.printStackTrace();
                throw new RuntimeException("failed to close " + inputStream);
            }
        }*/
    }

    1번 방법은 클라이언트가 예외를 잡은 후 처리해줘야 한다. 2번째 방법도 괜찮은 방법이 될 수 있다. 

    2번째 방법이 괜찮은 이유는 클라이언트가 이걸 처리하지 않아도 된다. 그리고 2번에서 RuntimeException을 던지는 것도 좋은 방법이 될 수 있다. RuntimeException을 만난 쓰레드는 종료되게 될 것이지만, 일반적인 스프링 환경이라면 '그 요청을 처리하는 쓰레드'만 종료된 것이고 어플리케이션에는 아무런 영향을 미치지 않는다. 

    잘못된 상태라면 에러를 던지고 종료하는 것도 괜찮기 때문에 좋은 방법이 될 수 있다.

     

    AutoClosable의 하위 클래스 Closable을 구현하는 것도 좋음.

    AutoClosable의 하위 클래스로 Closable이 있다. 이 녀석은 다 똑같은데 close() 메서드에서 IOException을 던진다. 만약 우리가 구현하는 클래스가 I/O와 관련된 클래스(파일 입출력, DB 커넥션 등) 이라고 한다면 Closeable을 구현하는 것도 좋은 방법이다. 

    댓글

    Designed by JB FACTORY