본문 바로가기
개발 공부/Java

이펙티브 자바 아이템 8 - finalizer 와 cleaner 사용을 피하라 - 완벽 공략

by 개발인생 2022. 10. 14.
반응형

아이템 8 - finalizer 와 cleaner 사용을 피하라 - 완벽 공략

이 글은 백기선 님의 이펙티브 자바 강의와 이펙티브 자바 3 / E 편을 참고하여 작성하였습니다.

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

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

public class OuterClass {

   class InnerClass {

   }

   public static void main(String[] args) {
      OuterClass outerClass = new OuterClass();
      InnerClass innerClass = outerClass.new InnerClass();

      System.out.println(innerClass);

      outerClass.printFiled();
   }

   private void printFiled() {
      Field[] declaredFields = InnerClass.class.getDeclaredFields();
      for(Field field : declaredFields) {
         System.out.println("field type:" + field.getType());
         System.out.println("field name:" + field.getName());
      }
   }
}

OuterClass 안에 InnerClass 가 있다.

InnerClass 는 static 이 붙지 않았다.

즉, 정적 클래스가 아니라는 뜻이다.

InnerClass innerClass = outerClass.new InnerClass();

InnerClass 의 인스턴스를 만들려면 outerClass 의 인스턴스를 먼저 만들어야한다.

위의 코드를 실행시켜보면 OuterClass 타입의 this$0 이라는 레퍼런스를 확인할 수 있다.

public class OuterClass {

   private void hi() {

   }

   class InnerClass {
      public void hello() {
         OuterClass.this.hi();
      }
   }
}

InnerClass 에서 OuterClass 를 참조하는 방법은 OuterClass.this 를 사용하면 된다.

public class BigObject {
   private List<Object> resource;

   public BigObject(List<Object> resource) {
      this.resource = resource;
   }

   public static class ResourceCleaner implements Runnable {

      private List<Object> resourceToClean;

      public ResourceCleaner(List<Object> resourceToClean) {
         this.resourceToClean = resourceToClean;
      }

      @Override
      public void run() {
         resourceToClean = null;
         System.out.println("cleaned up.");
      }
   }
}

정적이 아닌 중첩 클래스를 사용하면 외부 클래스에 대한 레퍼런스가 생기기 때문에

cleaner 를 등록할 때 static 으로 만들어야 한다.

중첩 레퍼런스가 생기면 자원이 정리가 되지 않는다.

물론 cleaner 를 다른 클래스로 만든다면 static 을 붙일 필요는 없다.


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

public class LambdaExample {
   private int value = 10;

   private Runnable instanceLambda = () -> {
      System.out.println(value);
   };

   public static void main(String[] args) {
      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());
      }
   }
}

위의 클래스는 인스턴스 맴버 instanceLambda 에 람다가 정의되어있다.

Runnable 을 정의해 메서드 바깥의 값을 참조하게 되면

람다를 감싸고 있는 LambdaExample 클래스에 대한 레퍼런스가 람다 안에 들어가게 된다.

LambdaExample 클래스에 있는 어떠한 값을 cleaner 를 이용해 정리하는 작업을 위와 같이 작성하면 안된다.

정리할 객체에 대한 레퍼런스가 생기기 때문이다.

public class LambdaExample {

   private Runnable instanceLambda = () -> {
   };

   public static void main(String[] args) {
      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());
      }
   }
}

바깥 객체를 참조하지 않으면 바깥 객체에 대한 레퍼런스가 생기지 않는다.

public class LambdaExample {
   private static int value = 10;

   private static Runnable instanceLambda = () -> {
      System.out.println(value);
   };

   public static void main(String[] args) {
      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());
      }
   }
}

바깥 객체를 참조하더라도 static 이라면 레퍼런스가 생기지 않는다.


Finalizer 공격

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

Account 클래스를 작성한다.

만약 계정의 아이디가 푸틴 인 경우에는 계정을 막도록 되어있다.

하지만 악의적인 사용자라면 계정이 막히더라도 계정을 사용할 수 있다.

class AccountTest {

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

일반 계정이라면 오류가 발생하지 않는다.

class AccountTest {

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

푸틴 의 계정이라면 오류가 발생한다.

Finalizer 를 이용하면 오류가 발생하더라도 transfer 메서드를 실행시킬 수 있다.

public class BrokenAccount extends Account{
   public BrokenAccount(String accountId) {
      super(accountId);
   }

   @Override
   protected void finalize() throws Throwable {
      this.transfer(BigDecimal.valueOf(1000000), "junhyun");
   }
}

Account 클래스를 상속받아 finalize() 를 재정의 한다.

finalize() 재정의 하면서 Account 클래스의 transfer 를 사용하면된다.

class AccountTest {

   @Test
   void 푸틴_계정() throws InterruptedException {
      Account account = null;
      try {
         account = new BrokenAccount("푸틴");
      } catch (Exception e) {
         System.out.println("이러면?");
      }

      System.gc();

      Thread.sleep(3000L);
   }
}

위의 코드에서는 new BrokenAccount("푸틴"); 으로 생성시 발생하는 오류를 try - catch 블럭으로 잡는다.

그 뒤 GC 를 일으켜 오버라이딩한 finalize() 를 호출하게 한다.

이러한 Finalizer 공격 을 막으려면

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

상속을 허용하지 않도록 final 키워드를 클래스에 붙여준다.

만약 상속을 해야만 한다면

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

   @Override
   protected final void finalize() throws Throwable {
      super.finalize();
   }
}

아무것도 하지 않는 finalize() 를 만든 후 final 키워드를 붙여주면 된다.

final 키워드를 메서드 앞에 붙여주면 오버라이딩을 할 수 없는 메서드가 된다.


AutoClosable

public interface AutoCloseable {
    /**
     * Closes this resource, relinquishing any underlying resources.
     * This method is invoked automatically on objects managed by the
     * {@code try}-with-resources statement.
     *
     * <p>While this interface method is declared to throw {@code
     * Exception}, implementers are <em>strongly</em> encouraged to
     * declare concrete implementations of the {@code close} method to
     * throw more specific exceptions, or to throw no exception at all
     * if the close operation cannot fail.
     *
     * <p> Cases where the close operation may fail require careful
     * attention by implementers. It is strongly advised to relinquish
     * the underlying resources and to internally <em>mark</em> the
     * resource as closed, prior to throwing the exception. The {@code
     * close} method is unlikely to be invoked more than once and so
     * this ensures that the resources are released in a timely manner.
     * Furthermore it reduces problems that could arise when the resource
     * wraps, or is wrapped, by another resource.
     *
     * <p><em>Implementers of this interface are also strongly advised
     * to not have the {@code close} method throw {@link
     * InterruptedException}.</em>
     *
     * This exception interacts with a thread's interrupted status,
     * and runtime misbehavior is likely to occur if an {@code
     * InterruptedException} is {@linkplain Throwable#addSuppressed
     * suppressed}.
     *
     * More generally, if it would cause problems for an
     * exception to be suppressed, the {@code AutoCloseable.close}
     * method should not throw it.
     *
     * <p>Note that unlike the {@link java.io.Closeable#close close}
     * method of {@link java.io.Closeable}, this {@code close} method
     * is <em>not</em> required to be idempotent.  In other words,
     * calling this {@code close} method more than once may have some
     * visible side effect, unlike {@code Closeable.close} which is
     * required to have no effect if called more than once.
     *
     * However, implementers of this interface are strongly encouraged
     * to make their {@code close} methods idempotent.
     *
     * @throws Exception if this resource cannot be closed
     */
    void close() throws Exception;
}

AutoCloseable 은 인터페이스이다.

이 인터페이스를 사용하면 try-with-resource 를 사용할 수 있다.

try-with-resource 를 사용하면 자원 반납을 자동으로 해준다.

public class App {

   public static void main(String[] args) {
      try(AutoClosableIsGood good = new AutoClosableIsGood("")) {
         // TODO 자원 반납 처리가 됨.
      }
   }
}

위의 코드처럼 try-with-resource 를 사용하면 된다.

public class App {

   public static void main(String[] args) {
      try(AutoClosableIsGood good = new AutoClosableIsGood("");
          AutoClosableIsGood good1 = new AutoClosableIsGood("");
          AutoClosableIsGood good2 = new AutoClosableIsGood("");) {
         // TODO 자원 반납 처리가 됨.
      }
   }
}

자원을 반납할 객체를 여러개 사용할 수도 있다.

AutoCloseable 인터페이스는 void close() throws Exception; 메서드를 하나만 가지고있다.

그렇다고 함수형 인터페이스는 아니다.

void close() throws Exception; 에서는 throws Exception 이 붙어있지만

구현하고는 아무런 관련이 없다.

close() 에서 발생하는 Exception 을 처리하는 방법은 크게 세가지가 있다.

import java.io.IOException;

public class AutoClosableIsGood implements AutoCloseable {

   private BufferedReader reader;

   public AutoClosableIsGood(String path) {
      try {
         this.reader = new BufferedReader(new FileReader(path));
      } catch (FileNotFoundException e) {
         throw new IllegalArgumentException(path);
      }
   }

   @Override
   public void close() throws IOException {
      reader.close();
   }
}

첫번째 방법은 throws 를 사용하는 방법이다.

이 방법의 의도는 해당 코드를 사용하는 클라이언트 쪽에 책임을 전가하게 된다.

import java.io.IOException;

public class App {

   public static void main(String[] args) {
      try (AutoClosableIsGood good = new AutoClosableIsGood("")) {
         // TODO 자원 반납 처리가 됨.
      } catch (IOException e) {
         e.printStackTrace();
      }
   }
}

해당 코드의 클라이언트는 위와같이 close() 메서드 호출시 던져지는 예외를 처리해야한다.

예외를 던져야만한다면 구체적인 예외 를 던지길 권장한다.

public class AutoClosableIsGood implements AutoCloseable {

   private BufferedReader reader;

   public AutoClosableIsGood(String path) {
      try {
         this.reader = new BufferedReader(new FileReader(path));
      } catch (FileNotFoundException e) {
         throw new IllegalArgumentException(path);
      }
   }

   @Override
   public void close() {
      try {
         reader.close();
      } catch (IOException e) {
         // logging
      }
   }
}

두번째 방법은 close() 메서드 내에서 예외를 처리하는 것이다.

이렇게하면 클라이언트는 예외처리에 대한 작업을 하지 않아도 된다.

public class AutoClosableIsGood implements AutoCloseable {

   private BufferedReader reader;

   public AutoClosableIsGood(String path) {
      try {
         this.reader = new BufferedReader(new FileReader(path));
      } catch (FileNotFoundException e) {
         throw new IllegalArgumentException(path);
      }
   }

   @Override
   public void close() {
      try {
         reader.close();
      } catch (IOException e) {
         throw new RuntimeException(e);
      }
   }
}

세번째 방법으로는 예외를 변환하는 것이다.

위의 코드는 예외 발생시 해당 스레드를 종료시키게 된다.

그리고 가급적이면 close() 는 멱등성을 유지하도록 권장한다.

즉, 몇번을 실행하더라도 같은 결과를 내야한다.

AutoCloseable 을 상속한 다양한 인터페이스가 존재한다.

반응형

댓글