Effective Java - 객체 생성과 파괴 (2)

Sungjin·2022년 4월 5일
0

Java

목록 보기
3/3
post-thumbnail

객체 생성과 파괴


🎯 수행 목적

  1. 객체를 만들어야 할 때를 구분하는 법
  2. 올바른 객체 생성 방법과 불필요한 생성을 피하는 법
  3. 파괴됨을 보장하고 파괴전에 수행해야 할 정리 작업

📌 References.

Effective Java


🚀 Item5. 자원을 직접 명시하지 말고 의존 객체 주입을 사용하라.

의존 객체 주입 방식은 Spring과 같은 framework에서 많이 사용하는 방식입니다.

예를 들어, servicerepository의 관계를 생각해봅시다.

serviceJpa, MyBatis, JDBC등 다양한 형태의 repository를 사용할 수 있어야 유연하게 코드가 작성되었다고 말할 수 있습니다.

하지만, 다음과 같이

@Service
@Transactional
public class UserServiceImpl implements UserService{
    private final UserRepository userRepository=new JdbcUserRepository();
} 

이런식으로, final자원에 repository의 구체화를 명시해두면 한 repository에만 종속됨과 동시에 코드의 유연한 확장이라곤 기대할 수 없는 코드가 됩니다.

이럴때, 필요한 것이 생성자를 통한 의존 객체 주입입니다.

@Service
@Transactional
public class UserServiceImpl implements UserService{
    private final UserRepository userRepository;
    
    public UserServiceImpl(UserRepository userRepository){
        this.userRepository=userRepository;
    }
}

위와 같은 방식을 말하죠.. 스프링에서는 의존객체 주입을 더욱 더 잘 사용하기 위해서 클라이언트 전략을 취합니다.

이와 같은 내용은 이곳에 정리해 두었습니다 : 바로가기

시리즈별로 정리해 두었습니다.


🚀 Item6. 불필요한 객체 생성을 피하라.

똑같은 기능의 객체를 매번 생성하기 보다는 객체 하나를 재사용하는 것이 더욱 바람직합니다. 예를 들어 String타입과 같은 불변 객체에는 더욱더 그럽니다

String s=new String("Hello"); // 실행될 때 마다 인스턴스를 생성
String s=String.valueOf("Hello"); //객체의 재사용

따라서 Java에서는 new String()은 deprecated시켜놓고 valueOf메소드를 적극 권장합니다.

생성 비용이 아주 비싼 객체도 있습니다. 이럴 경우에 캐싱하여 재사용하는 것은 훌륭한 작업입니다.

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

위의 코드는 문자열이 로마 숫자인지 확인하는 코드입니다.

이 방식의 문제점은 matches에 있습니다. 이유는 matches는 정규 표현식으로 문자열 형태를 확인하는 가장 쉬운 방법이지만
정규 표현식 패턴을 한 번만 쓰고 버린다는 아쉬운 점이 있습니다. Pattern은 입력받은 정규표현식에 해당하는 유한 상태 머신을 만들기 때문에 인스턴스 생성 비용이 높습니다.

그러므로 클래스를 인스턴스화할 적에 Pattern을 미리 캐싱하여두고 사용하는 것이 바람직합니다.

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

    //캐싱하여 사용하는 방식
    static final Pattern Roman=Pattern.compile("^(?=.)M*(C[MD]|D?C{0,3})"
            + "(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$");

    public static boolean isRomanNumeralSecond(String s){
        return Roman.matcher(s).matches();
    }

}

메소드의 시간을 측정해 봅시다. 시간 측정을 하는 메소드는 중복된 코드로서 재사용될 확률이 높습니다. 그러므로 저는 템플릿/콜백 패턴을 이용하였습니다.

템플릿

public class MethodProcessTime  {

    public static void logTime(MethodProceed method,Object target,Object[] args) throws Throwable{
        long start=System.nanoTime();
        Method proceed = method.proceed();
        if(args.length!=0) {
            proceed.invoke(target, args);
        }else{
            proceed.invoke(target);
        }
        System.out.println(proceed.getName()+" : "+(System.nanoTime()-start)+"ns");
    }

}

콜백

public interface MethodProceed {
    Method proceed() throws NoSuchMethodException;
}
public class Main {
   public static void main(String[] args) throws Throwable {
      //메소드 Time Check
      RomanNumerals romanNumerals = new RomanNumerals();
      
      MethodProcessTime.logTime(new MethodProceed(){
         @Override
         public Method proceed() throws NoSuchMethodException {
            return RomanNumerals.class.getMethod("isRomanNumeralSecond", String.class);
         }
      },romanNumerals,new Object[]{"1"});

      MethodProcessTime.logTime(new MethodProceed(){
         @Override
         public Method proceed() throws NoSuchMethodException {
            return RomanNumerals.class.getMethod("isRomanNumeralFirst", String.class);
         }
      },romanNumerals,new Object[]{"1"});
      

   }
}

실행 결과

실행시간이 확연히 차이나는 것을 볼 수 있습니다.

불필요한 객체를 만들어 내는 또다른 예로는 오토박싱이 있습니다.

오토박싱은 프로그래머가 기본 타입과 박싱된 기본 타입을 섞어 쓸 때 자동으로 변환해주는 기능입니다. 오토박싱은 기본 타입과 그에 대응하는 박싱된 기본 타입의 구분을
흐려주지만, 완전히 없애주는 것은 아닙니다. 즉 성능에 차이가 있을 수 있다는 것이죠.

public class Sum {

    public static long AutoboxedSum(){
        Long sum=0l;
        for(int i=0;i<Integer.MAX_VALUE;i++){
            sum+=i;
        }
        return sum;
    }

    public static long UnAutoboxedSum(){
        long sum=0l;
        for(int i=0;i<Integer.MAX_VALUE;i++){
            sum+=i;
        }
        return sum;
    }
    
}

public class Main {
   public static void main(String[] args) throws Throwable {
      //메소드 Time Check
      Sum sum = new Sum();

      MethodProcessTime.logTime(new MethodProceed() {
         @Override
         public Method proceed() throws NoSuchMethodException {
            return Sum.class.getMethod("AutoboxedSum");
         }
      },sum,new Object[]{});
      MethodProcessTime.logTime(new MethodProceed() {
         @Override
         public Method proceed() throws NoSuchMethodException {
            return Sum.class.getMethod("UnAutoboxedSum");
         }
      },sum,new Object[]{});

   }
}

실행 결과

엄청난 차이를 보이고 있습니다. 이는, 불필요한 Long인스턴스를 엄청나게 만들었기 때문입니다.

이는 객체 생성은 비싸니 피하자는 것보다는 조심하자라는 의미를 내재하고 있습니다.

요즘 JVM은 Gc가 잘 최적화 되어서 작은 객체를 만들고 회수하는 일은 크게 성능을 좌지우지 하지 않는다고 합니다.


🚀Item7. 다 쓴 객체 참조를 해제하라.

자바는 JVM을 가지고 있기 때문에 메모리 회수에는 좀 더 관대한 편입니다. 그렇다고 하더라도 메모리 관리에 아예 신경쓰지 않으면 안됩니다.

public class Stack {
    private Object[] elements;
    private int size=0;
    private static final int DEFAULT_INITIAL_CAPACITY=16;

    public Stack(){
        elements=new Object[DEFAULT_INITIAL_CAPACITY];
    }

    public void push(Object e){
        ensureCapacity();
        elements[size++]=e;
    }

    public Object pop(){
       if(size==0){
          throw new EmptyStackException();
       }
        Object pop=elements[--size];
        return pop;
    }

    private void ensureCapacity(){
        if(elements.length==size){
            elements=Arrays.copyOf(elements,size*2+1);
        }
    }
}

위의 코드는 Stack을 간략화한 코드입니다. 겉보기에는 멀쩡해 보이지만, 숨어있는 문제가 있습니다.

그 문제는 메모리 누수이고, 점차적으로 성능이 저하될 것입니다.

어떤 문제가 있을 까요?? 바로 Pop에서의 과정이 문제입니다. 스택에서 꺼내진 객체는 GC가 회수해 가지않습니다. 왜냐하면 현재 활성화 되어있는 배열의 크기만큼만
사이즈를 조정해주고 있을 뿐 따로 회수하지 않기 때문입니다.

그러므로 스택에서 꺼내진 부분의 요소로 null을 집어 넣어야합니다.

public Object pop(){
    if(size==0){
        throw new EmptyStackException();
    }
    Object pop=elements[--size];
    elements[size]=null;
    return pop;
}

이제 null 처리를 하였기 때문에 이 부분을 참조하려고 하면 NullPointerException이 던져집니다.

객체 참조를 Null처리하는 일은 예외 적인 경우여야 합니다.

근데 왜 stack에서는 메모리 누수에 취약했을 까요??

자기 자신이 직접 메모리를 관리했기 때문입니다. 이 스택은 객체 자체가 아니라 객체 참조를 담는 elements배열로 저장소 풀을 만들어 관리했습니다.
즉, 활성 영역과 비활성 영역은 이 stack만이 알 뿐이지 GC는 똑같이 유효한 객체로 판단합니다.
따라서, 취약했었던 것입니다.

캐시 역시 메모리 누수를 일으키는 주범입니다. 객체 참조를 캐시에 넣고 나서, 객체를 다 쓴 뒤로 까먹고 한참을 놔둘 수 있기 때문입니다.

이럴때는, WeakHashMap을 사용하는 것이 권장 됩니다. 약한 참조로서 null선언만 하면 바로 GC의 대상이 되기 때문입니다.


🚀 Item8. finalizer와 cleaner사용을 피하라

자바는 두 가지 객체 소멸자를 제공합니다. 그중 finalizer는 예측할 수 없고, 상황에 따라 위험할 수 있어 일반적으로 불필요합니다.

또한, cleanerfinalizer보다는 덜 위험하지만, 예측 불가능합니다.

C++의 파괴자는 특정 객체와 관련된 자원을 회수하지만, 일반적으로 자바에서는 자원을 회수하고 쓸모 없어진 객체는 JVM의 GC가 담당합니다.

물론 예외의 상황에서 자원을 회수하기 위해서는 try-finallytry-with-resources를 사용해 해결합니다.

finalizercleaner는 제때 수행된다는 보장이 업습니다. 즉, 자원 회수를 얘들한테 맡기면 치명적 오류를 낳을 수도 있습니다.

finalizercleaner는 심각한 성능 문제 또한 야기합니다.

왜냐하면 이들이 GC의 효율을 떨어뜨리기 때문입니다.

finalizer를 사용한 클래스는 finalizer 공격에 노출되어 심각한 보안 문제를 일으킬 수도 있습니다

생성자나 직렬화 과정에서 예외가 발생하면, 생성되다 만 객체에서 악의적인 하위클래스의 finalizer가 수행될수 있게 되기 때문입니다.
또한 , 이 finalizer는 정적 필드에 자신의 참조를 할당하여 GC의 대상에서 빠져나갈 수도 있습니다.
이렇게 일그러진 객체에서 메소드를 호출하여 허용되지 않았을 작업을 수행하는 것은 일도 아니게 됩니다.

final이 아닌 클래스를 finalizer공격으로부터 방어하려면 아무 일도 하지 않는 finalize메소드를 만들고 final로 선언합니다.

그렇다면, 파일이나 스레드 등을 종료해야할 때 대안은 무엇이 있을 까요??
AutoCloseable을 구현하고, 클라이언트에서 인스턴스를 다 쓰고 나면 close메소드를 호출하면 됩니다.

close 메소드에서 이 객체는 더 이상 유효하지 않음을 필드에 기록하고, 다른 메소드는 이 필드를 검사해서 객체가 닫힌 후에 불렸다면 IllegalStateException을 던지는 것입니다.

그렇다면, cleanerfinalizer를 사용하는 경우는 어떤 경우일 까요??

자원을 close하지 않았을 때의 안전망으로서 주로 사용한다고 합니다.

또한, 네이티브 피어와 연결된 객체에서 많이 사용한다고 합니다. 네이티브 피어란 일반 자바 객체가 네이티브 메서드를 통해 기능을 위힘한 네이티브 객체를 말합니다.
이 네이티브 피어는 일반 객체가 아니기 때문에 GC의 대상이 아닙니다. 따라서 cleanerfinalizer가 적절히 활용됩니다. 만일 심각한 자원을 회수해야한다면 이 또한
close를 사용하는 것이 적절합니다.

Cleaner의 사용방법을 봅시다

public class Room implements AutoCloseable{
    private static final Cleaner cleaner=Cleaner.create();

    private static class State implements Runnable{
        int numJunkPiles;

        State(int numJunkPiles){
            this.numJunkPiles=numJunkPiles;
        }

        @Override
        public void run() {
            System.out.println("방 청소");
            numJunkPiles=0;
        }
    }

    //방의 상태. cleanable에 등록할 것
    private final State state;

    //수거 대상을 등록해야함 여기에.
    private final Cleaner.Cleanable cleanable;

    public Room(int numJunkPiles) {
        this.state = new State(numJunkPiles);
        this.cleanable = cleaner.register(this,state);
    }

    @Override
    public void close() throws Exception {
        cleanable.clean();
    }
}

중첩 classstatecleaner가 방을 청소할 때 수거할 자원들을 담고 있습니다.

staterunnable을 구현하고, 그 안의 run메소드는 cleanable에 의해 딱 한번 호출될 것입니다.

보통은 Roomclose 메소드를 호출할 때마다 cleanable에 등록된 staterun을 호출하게 됩니다. 혹은 GC가 Room을 회수할 때 까지
close를 호출하지 않으면 cleanerstaterun 메소드를 호출할 수도 있습니다.

state는 절대 room을 참조해서는 안됩니다. 이 경우 순환 참조가 생겨 GC가 room을 회수해가지 못하기 때문입니다.

이제 한번 close 메소드를 호출해 봅시다.

public class Main {
    public static void main(String[] args) throws Exception {
        try(Room room=new Room(7)){
            System.out.println("Hello");
        }
    }
}

실행 결과


🚀 Item9. try-finally보다는 try-with-resources를 사용하라.

자바 라이브러리에는 close메소드를 호출해 직접 닫아줘야 하는 자원이 많습니다.
Connection, BufferedReader등등이 있죠.

close를 호출하기 위해서는 전통적으로 try-finally를 사용합니다.

public class Resource {
   static void copyV1(String s, String dst) throws IOException {
      InputStream in = new FileInputStream(s);
      try {
         OutputStream out = new FileOutputStream(dst);
         try {
            byte[] buf = new byte[100];
            int n;
            while ((n = in.read(buf)) >= 0) {
               out.write(buf, 0, n);
            }
         } finally {
            out.close();
         }
      } finally {
         in.close();
      }
   }
}

try-finally를 활용한 자원회수를 보여주고 있습니다. 여기서 예기치 못한 예외가 발생해 InputStream이 실행되지 못한다면

outputStream -> out.close() -> in.close()가 차례대로 예외가 발생하여 실행하지 목할 것입니다.

그러면 스택 추적 내용에 in.close()의 예외정보만 남게 되고 나머지 예외에 대한 정보는 남지 않게 될 것입니다. 이것은 실제 시스템에서의 디버깅을 몹시 어렵게 만들 수
있습니다.

이러한 문제를 해결하기 위해서 try-with-resources를 사용합니다.

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

위의 코드는 그 예입니다. 훨씬 코드의 양이 줄어들었을 뿐만 아니라 문제를 진단하기도 훨씬 수월해집니다.
여기서는 InputStream에서 생긴 예외는 코드에서 나타나지 않는 close호출 예외는 숨겨지고 InputStream에서 발생한 예외가 기록됩니다.

또한, try-with-resouces절 또한 catch절 덕분에 try문을 더 중첩하지 않고도 다수의 예외를 처리할 수 있습니다.


이상으로 마치겠습니다. 🙋🏻‍♂️

profile
WEB STUDY & etc.. HELLO!

0개의 댓글