반응형

1. 개요

 readObject 메서드의 역할을 이해하고, 방어적으로 작성해야하는 이유를 알아보자.

 

2. 직렬화, 역직렬화 예제

 일반적으로 Object를 직렬화할 땐 ObjectOutputStream을, 역직렬화 할땐 ObjectOutputStream 인스턴스 인스턴스를 사용하고, 각각 writeObject(), readObject() 메서드를 호출하여 실제 데이터를 처리한다. 간단한 예제를 통해 직렬화와 역직렬화의 흐름을 먼저 이해해보자.

1) Period 인스턴스에 대한 직렬화

 

 

public final class Period implements Serializable {

   private Date start;
   private Date end;

   public Period(Date start, Date end) {
       this.start = new Date(start.getTime()); // 방어적 복사
       this.end = new Date(end.getTime());
       if (this.start.compareTo(this.end) > 0) { // 유효성 검사
           throw new IllegalArgumentException(start + " after " + end);
       }
   }

   public Date start() {
       return new Date(start.getTime());
   }

   public Date end() {
       return new Date(end.getTime());
   }
}

 

SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd");
Date startDate = df.parse("2024-11-14");
Date endDate = df.parse("2024-11-15");
try(
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(baos)){

    oos.writeObject(new Period(startDate, endDate));

    byte[] serialized = baos.toByteArray();
    for(byte b : serialized){
        System.out.print(b +" ");
    }
}

 

결과화면은 다음과 같다.

 

직렬화되어 byte[] 타입으로 변환된 Period 인스턴스

 

 

2) 직렬화된 byte[] 데이터에 대한 역직렬화

    public static void main(String[] args) throws IOException, ParseException {

        SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd");
        Date startDate = df.parse("2024-11-14");
        Date endDate = df.parse("2024-11-15");
        try(
                ByteArrayOutputStream baos = new ByteArrayOutputStream();
                ObjectOutputStream oos = new ObjectOutputStream(baos)){

            oos.writeObject(new Period(startDate, endDate));  // 직렬화

            byte[] serialized = baos.toByteArray();

            Period period = deserializePeriod(serialized); // 역직렬화
            System.out.println(period.start());
            System.out.println(period.end());
        }
    }
    
    public static Period deserializePeriod(byte[] bytes) throws IOException {

        try(
                ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
                ObjectInputStream ois = new ObjectInputStream(bais)){

                Object object = ois.readObject();
                return (Period) object;
        } catch (ClassNotFoundException e) {
            throw new RuntimeException(e);
        }
    }

 

결과 화면은 다음과 같다.

역직렬화 후 메서드 출력 결과

 

 

3. readObject() 메서드의 문제 #1

readObject() 메서드가 수행하는 역할을 보면 byte[] 데이터를 기반으로 인스턴스를 생성하는 역할을 한다. 즉, 생성자의 역할을 하는 것이다. 일반적으로 생성자에는 validation을 통해 데이터의 불변식을 보장한다. 하지만 바이트 데이터가 조작되어 불변식이 깨진 Object에 대한 byte[] 데이터가 들어온다면 readObject() 메서드를 호출했을 때 별다른 validation 체크를 하지 않기때문에 불변식을 보장하지 못하게 된다.

public class BogusPeriod {
    
    // 정상적이지 않은 바이트스트림
    private static final byte[] serializedForm = {
        (byte)0xac, (byte)0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x06,
        0x50, 0x65, 0x72, 0x69, 0x6f, 0x64, 0x40, 0x7e, (byte)0xf8,
        0x2b, 0x4f, 0x46, (byte)0xc0, (byte)0xf4, 0x02, 0x00, 0x02,
        0x4c, 0x00, 0x03, 0x65, 0x6e, 0x64, 0x74, 0x00, 0x10, 0x4c,
        0x6a, 0x61, 0x76, 0x61, 0x2f, 0x75, 0x74, 0x69, 0x6c, 0x2f,
        0x44, 0x61, 0x74, 0x65, 0x3b, 0x4c, 0x00, 0x05, 0x73, 0x74,
        0x61, 0x72, 0x74, 0x71, 0x00, 0x7e, 0x00, 0x01, 0x78, 0x70,
        0x73, 0x72, 0x00, 0x0e, 0x6a, 0x61, 0x76, 0x61, 0x2e, 0x75,
        0x74, 0x69, 0x6c, 0x2e, 0x44, 0x61, 0x74, 0x65, 0x68, 0x6a,
        (byte)0x81, 0x01, 0x4b, 0x59, 0x74, 0x19, 0x03, 0x00, 0x00,
        0x78, 0x70, 0x77, 0x08, 0x00, 0x00, 0x00, 0x66, (byte)0xdf,
        0x6e, 0x1e, 0x00, 0x78, 0x73, 0x71, 0x00, 0x7e, 0x00, 0x03,
        0x77, 0x08, 0x00, 0x00, 0x00, (byte)0xd5, 0x17, 0x69, 0x22,
        0x00, 0x78
    };

    public static void main(String[] args) {
        Period p = (Period) deserialize(serializedForm);
        System.out.println(p);
    }
    
    static Object deserialize(byte[] sf) {
        try {
            return new ObjectInputStream(new ByteArrayInputStream(sf)).readObject();
        } catch (IOException | ClassNotFoundException e) {
            throw new IllegalArgumentException(e);
        }
    }
}

 

출력하면 다음과 같이 start보다 end가 더 이르다는 것을 확인할 수 있다. 생성자를 사용하지 않기 때문에 validation 체크가 이루어지지 않고, 최종적으로 생성된 Period 객체는 불변식이 깨져버렸다.

 


4. readObject() 메서드 커스텀

 

이를 해결하기 위해 readObject() 메서드를 생성해야한다. 역직렬화할 클래스 내에 private 접근 제어자로 readObject() 메서드가 정의되어 있을 경우 ObjectInputStream.readObject() 메서드 호출 시 새로 정의한 메서드를 호출하기 때문이다. (리플렉션 기반)

 단, 반드시 privcate 접근 제어자로 선언해야하고, 메서드 내에서 defaultWriteObject(),  defaultReadObject()와 같은 기본 직렬화, 역직렬화 메서드를 한번 호출해줘야한다.

 

public final class Period implements Serializable {
    private Date start;
    private Date end;

    public Period(Date start, Date end) {
        this.start = new Date(start.getTime()); // 방어적 복사
        this.end = new Date(end.getTime());
        if (this.start.compareTo(this.end) > 0) { // 유효성 검사
            throw new IllegalArgumentException(start + " after " + end);
        }
    }

    public Date start() {
        return new Date(start.getTime());
    }

    public Date end() {
        return new Date(end.getTime());
    }

    private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException{
        s.defaultReadObject();
        if (this.start.compareTo(this.end) > 0) { // 유효성 검사
            throw new InvalidObjectException(start + " after " + end);
        }
    }
}

 

그 후, 불변식이 깨진 byte[] 데이터를 넣어 역직렬화를 해보면 유효하지 않는 오브젝트로 판단하여 역직렬화에 대한 예외를 발생시킨다.

역직렬화 실패

 


5. readObject() 메서드의 문제 #2

하나의 문제가 더 있다. 직렬화된 스트림에 객체 참조 관련 바이트코드를 추가하면, 가변 Period 인스턴스를 만들 수 있다. Period 인스턴스를 가변 인스턴스로 사용할 수 있게 하는 MutablePeriod 클래스를 만든다.

public class MutablePeriod {
   public final Period period;

   public final Date start;

   public final Date end;

   public MutablePeriod() {
       try {
           ByteArrayOutputStream bos = new ByteArrayOutputStream();
           ObjectOutputStream out = new ObjectOutputStream(bos);

           // 불변식을 유지하는 Period 를 직렬화.
           out.writeObject(new Period(new Date(), new Date()));

           /*
            * 악의적인 start, end 로의 참조를 추가.
            */
           byte[] ref = { 0x71, 0, 0x7e, 0, 5 }; // 악의적인 참조
           bos.write(ref); // 시작 필드
           ref[4] = 4; // 악의적인 참조
           bos.write(ref); // 종료 필드

           // 역직렬화 과정에서 Period 객체의 Date 참조를 훔친다.
           ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray()));
           period = (Period) in.readObject();
           start = (Date) in.readObject();
           end = (Date) in.readObject();
       } catch (IOException | ClassNotFoundException e) {
           throw new AssertionError(e);
       }
   }

 

여기서 직렬화한 바이트 코드에 [0x71, 0, 0x7e, 0, 5], [0x71, 0, 0x7e, 0, 4] 코드를 추가한다. 이 값은 Java 직렬화 프로토콜에서 객체 참조를 나타내는 바이트 코드이다.

 

    public static void main(String[] args) {
        MutablePeriod mp = new MutablePeriod();
        Period mutablePeriod = mp.period; // 불변 객체로 생성한 Period
        Date pEnd = mp.end; // MutablePeriod 클래스의 end 필드
        
        pEnd.setYear(78); // MutablePeriod 의 end 를 바꿨는데 ?
        System.out.println(mutablePeriod.end()); // Period 의 값이 바뀐다.
        
        pEnd.setYear(69);
        System.out.println(mutablePeriod.end());
    }

 

결과적으로 이러한 방법을 사용하게 되면, 불변 객체로 생성되어야할 Period 인스턴스의 end 값이 수정됨을 알 수 있다.

이를 해결하기 위해서는 readObject() 메서드 재정의 시 방어적 복사 코드를 추가해야한다.

 

private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
   s.defaultReadObject();

   // 방어적 복사를 통해 인스턴스의 필드값 초기화
   start = new Date(start.getTime());
   end = new Date(end.getTime());

   // 유효성 검사
   if (start.compareTo(end) > 0)
       throw new InvalidObjectException(start +" after "+ end);
}

 

 

정리

 이런 문제는 누군가 고의적으로 바이트코드를 위조한 상황을 가정했을 때 발생하는 문제들이다. 직렬화한 코드를 아무나 접근할 수 있는 외부 서비스에 보냈을 때 불변식이 깨지고, 객체 참조까지 되는 것을 확인할 수 있다.

 앞선 챕터에서 Serializable은 신중하게 작성하라라는 내용이 있었다. 직렬화된 클래스가 외부로 나갔을 때 발생할 수 있는 여러 문제들이 있기 때문이다.

 이번 챕터 역시 이러한 문제를 겪지 않으려면 직렬화를 신중히 하고, 만약 직렬화가 필요하다면, 외부에서 접근할 수 없는 환경에 저장하고, 로드할 수 있도록 하는 것이 중요하다는 것을 다시 한번 느끼게 되었다.

반응형
반응형

1. 개요

 하나의 자바 프로세스 안에서 여러 스레드이 동작한다. 이 스레드들은 스레드 스케줄러에 실행된다. 그렇다면 프로그램의 동작을 스레드 스케줄러에 기대지 말라는 뜻이 뭘까?

 

스레드 스케줄러
운영체제가 프로세스 내 스레드들 사이에서 CPU 시간을 어떻게 분배할지를 결정하는 주체이다. 자바 코드 레벨에서 스레드를 생성해도 마찬가지이다. 스레드 스케줄러의 정책에 따라 실행된다. 정책으로는 라운드 로빈 방식, 우선순위 기반 방식, 최소 남은 시간 우선 방식 등이 있다.

 


2. 운영체제마다 다른 스케줄링 정책

 스레드 스케줄러의 정책은 운영체제마다 다를 수 있다. 프로그램의 동작이 스레드 스케줄러에 기댄다면 운영체제의 스케줄링 정책에 영향을 받게 되는것이다. 즉, '스레드 스케줄러에 기대는 프로그램은 정확성이나 성능이 운영 체제에 따라 달라지게 된다' 는 점에서 프로그램을 다른 플랫폼에 이식하기 어렵게 된다.

 


3. 스레드 스케줄러에 영향을 받지 않으려면 어떻게하나요?

 

1) 실행 가능한 스레드의 수를 지나치게 만들지 않기

 

실행 가능한 스레드
실행 가능한 스레드는 스레드 큐에 위치하여 언제든 CPU의 선택을 받을 수 있는 스레드를 말한다.

 

 실행 가능한 스레드의 평균적인 수를 프로세서 수보다 지나치게 많아지지 않도록 하는 것이다. 프로세서는 CPU인데, 멀티쓰레드를 지원하는 JVM 에서 스레드 수를 프로세서 수보다 지나치게 많아지지 않도록 하라는 게 무슨 의미인지 이해되지 않았다. 예를들어 필자의 컴퓨터는 멀티코어로 두개의 CPU가 있다. 그럼 스레드 수를 2개보다 지나치게 많아지지 않도록해라? 아무리 생각해도 100개의 스레드는 지나치게 많은 개수같은데, 일반적으로 JVM 스레드가 100개를 넘는 일은 허다하지 않나라는 의문이 들었다.

 

 위 내용에 대해 고민을 많이 했는데, 책을 다시 보니 필자가 핀트를 잘못 짚은것이었다. 실행 가능한 스레드의 수가 많으면 좋지 않다는게 아니라 단순히 스레드 스케줄러의 영향을 받지 않는 방법을 명시한 것이었다.  스레드 개수가 적어 스케줄러가 고민할 거리가 줄어든다면 정책의 영향을 덜받지 않겠는가. 당연한 얘기였다.

 

 다시 돌아가 필자의 컴퓨터는 한 코어에 6개의 스레드를 동작시킬 수 있다. 멀티 코어이니 최대 12개의 스레드를 동시처리 할 수 있다. 이보다 지나치게 많아졌을 때 스레드 스케줄러에 의해 스레드들이 제어될것이고, 이 프로그램이 '스레드 스케줄러에 기대도록' 구현이 되어있다면 문제가 생길 수 있다는 것이 포인트였다.

 

2) 스레드는 맡은 작업을 완료할 때까지 계속 실행되도록 하자

 스레드가 작업을 완료하기 전에 대기 상태로 가는 코드가 없도록 코드를 작성해야한다. 대기한다는 뜻은 곧 스레드 스케줄러의 정책에 의해 제어된다는 의미이기 때문이다. 또한 스레드는 당장 처리해야할 작업이 없다면 실행되게 해서도 안된다.

 


4. 당장 처리해야할 작업이 없다면 실행되게 해선 안된다?

 당장 처리할 작업이 없다면 대기 상태에 들어갈것이다. 그리고 다시 실행되기 위해 지속적인 상태체크를 한다. 즉, 스레드가 바쁜 대기(busy waiting) 상태가 되는것이다.

 당장 처리해야할 작업이 없지만 상태를 검사하기 위해 스레드가 실행된다. 의미없이 CPU를 선점하고 있는 격이기에 의미있는 작업을 맡은 스레드들이 당장 실행될 기회를 박탈당하게 된다.

 


5. Busy Waiting 예시

CountDownLatch 클래스의 기능을 가진 커스텀 클래스를 만들었다. Busy Waiting 상태가 나타나는 코드로CountDownLatch 클래스를 삐딱하게 구현한 코드이다.

 

await() 메서드 내 while(true)에 의해 해당 스레드는 계속해서 CPU를 점유할것으로 보이지만, 실제로는 스케줄링 정책에 대기/실행 상태를 왔다갔다하며 다른 스레드들도 중간 중간 실행될것이다. 그런데 이 메서드가 하는 일이 뭔가? 그냥 대기하는것이다. while 문에 의해 count 값만 계속 체크하는 로직인데, countDown()을 수행하는 스레드와 바쁘게 경쟁하게 된다. 경쟁자가 많다보니 컨텍스트 스위칭이 빈번하게 일어날테고, countDown() 호출이 늦어지는 결과를 초래하게 된다.

 

 이런 프로그램이 프로그램의 동작을 스레드 스케줄러에게 기댄 코드라고 할 수 있다.

 

1) SlowCountDownLatch.java

public class SlowCountDownLatch {

    private int count;

    public SlowCountDownLatch(int count){
        if(count < 0){
            throw new IllegalArgumentException(count + " < 0");
        }
        this.count = count;
    }

    public void await(){
        while(true){
            synchronized (this){
                if (count == 0)
                    return;
            }
        }
    }

    public synchronized void countDown(){
        if (count != 0){
            count --;
        }
    }
}

 

1) Main.java

public static void main(String[] args) {

    SlowCountDownLatch ready = new SlowCountDownLatch(1000);
    SlowCountDownLatch start = new SlowCountDownLatch(1);
    SlowCountDownLatch end = new SlowCountDownLatch(1000);
    ExecutorService executorService = Executors.newFixedThreadPool(1000);

    for(int i =0; i< 1000; i++){
        executorService.submit(() -> {
            ready.countDown();

            start.await();
            System.out.println("끼요옥");

            end.countDown();
        });
    }
    ready.await();
    System.out.println("준비완료. 시작!");
    start.countDown();
    long StartNanos = System.nanoTime();
    end.await();
    System.out.println("걸린시간 : " + ( System.nanoTime() - StartNanos));
}

 

스레드 1000개를 생성하여 SlowCountDownLatch를 테스트한 결과, Java에서 제공하는 CountDownLatch보다 2, 3배 느린 것을 확인할 수 있었다. 책에서는 약 10배가 느렸다고 한다.

 

6. 실행을 양보한다면?

그럼 await() 내 상태 체크가 실패했을 경우 현재 스레드를 해제하고 다른 스레드가 작업할 수 있도록 '양보'하는 건 어떨까? 현재는 운영체제의 스레드 스케줄링 정책에 의해 대기상태로 가고 있는데, 당장 스레드 큐로 보내버리는 것이다.

 Thread.yield() 메서드를 이용하면 현재 스레드를 실행 가능한 상태로 보낼 수 있다.

public void await(){
    while(true){
        synchronized (this){
            if (count == 0)
                return;
            else{
                Thread.yield();
            }
        }
    }
}

 

 여기서 중요한 점은 Thread.yield()는 무작정 양보 되는 것이 아니라, 운영체제에게 '요청'하는 메서드이다. 운영체제의 스레드 스케줄러가 이를 무시할 수 있다. 거절당할 수 있는 것이다. 또한, 운영체제마다 yield()의 동작이 달라질 수도 있다는 점에서 OS에 대한 이식성이 좋지 않으며. 호출이 너무 많아지면 컨텍스트 스위칭이 많아져 성능 저하를 초래할 수 있다. 좋지 않은 방법이다.

 

 스레드의 우선순위를 조절하는 것도 방법인데, 이것도 OS에 대한 이식성이 좋지 않다. 특히 심각한 문제를 스레드 우선순위로 해결하려는 시도는 합리적이지 않다.

 


7. 정리

 프로그램의 동작을 스레드 스케줄러에 기대지 말자. 성능과 속도, 이식성을 모두 해치는 행위이다. 같은 이유로 Thread.yield() 와 스레드 우선순위에 의존해서도 안된다.

 스레드 우선순위는 이미 잘 동작하는 프로그램의 서비스 품질을 높이기 위해 드물게 쓰일 수는 있지만, 간신히 동작하는 프로그램을 '고치는 용도'로 사용해서는 안된다.

반응형
반응형

1.1. 개요

 스레드 안전성 수준을 명시해야하는 이유와, 스레드 안전성 수준에 대해 알아보자.

 

1.2. 멀티 스레드 환경에서의 메서드 호출

 멀티 스레드 환경에서 메서드를 호출했을 때, 메서드 내에서 사용하는 변수 및 인스턴스들의 동기화가 필요한지 알아야한다. 이에 따라 클라이언트에서 이 메서드를 사용하는 방식이 달라지기 때문이다.

 여러 스레드가 동시에 호출할 때 메서드가 어떻게 동작하느냐는 해당 클래스와 이를 사용하는 클라이언트 사이의 중요한 계약 사항 중 하나라고 할 수 있다.

 만약 이러한 내용이 없을 경우 클라이언트는 나름의 가정과 함께 사용할것인데, 이 과정에서 심각한 오류를 발생시킬 수 있다.

 

1.3. synchronized == 스레드 안전하다?

 API 문서에 synchronized 한정자가 붙어 있다고 모두 스레드 안전하다고 할 수 없다. 한정자를 스레드 안전한 API 동작을 목적으로 사용했을 수도 있지만 단순 구현 이슈로 인해 사용했을 수도 있기 때문이다. "synchronized == 멀티 스레드 안전" 이라고 단정짓는 것은 위험하다.

 

1.4. 스레드 안전에 대한 명시

 어떤 메서드를 구현했을 때 스레드 안전성 여부를 명시해야한다. 하지만 내부 구현에 따라 멀티 스레드 환경에서 무조건 안전할수도, 조건부 안전할수도, 클라이언트가 동기화 메커니즘을 적용했을 때 안전할 수도, 무슨 짓을 해도 안전하지 않을수도 있다. 결국 안전성 수준을 명시해야 한다.

 

2. 스레드 안전성 수준

2.1. 불변(immutable) - @Immutable

 클래스의 인스턴스가 마치 상수와 같아서 외부 동기화가 필요없는 수준이다. 대표적으로 String, Long이 있다.

 

2.2. 무조건적 스레드 안전(unconditionally thread-safe) - @ThreadSafe

 클래스의 인스턴스는 수정될 수 있으나, 내부에서 충실히 동기화하여 별도의 외부 동기화가 없이 동시에 사용해도 안전한 수준이다. 대표적으로 AtomicLong, ConcurrentHashMap이 있다.

 

*  ConcurrentHashMap

HashMap에서 세분화된 락을 제공하는 Map 자료형을 말한다. 전체 맵을 잠그는 대신, 내부 데이터를 여러 버킷으로 나누고 개별적인 락을 적용한다. 이를 통해 여러 스레드에서 동시에 다른 버킷에 접근 가능하다.

읽기 작업은 락 없이 수행하며, compute(), merge()와 같은 원자적 연산도 제공한다.

이를 잘 활용하면 멀티 스레드에서 정합성을 유지할 수 있다.

 

2.3. 조건부 스레드 안전(conditionally thread-safe) - @ThreadSafe

 무조건적 스레드 안전과 같으나, 일부 메서드는 외부 동기화가 필요한 수준이다. Collections.synchronized 래퍼 메서드가 반환한 컬렉션들이 여기 속한다.

 

* 조건부 스레드 문서화 시에는 어떤 순서로 호출할 때 외부 동기화가  필요한지, 어떤 락을 얻어야 하는지를 알려줘야한다. 예를들어 Collections.synchronizedMap의 API 문서에는 다음과 같이 써 있다.

 

synchronizedMap이 반환한 맵의 컬렉션 뷰를 순회하려면 반드시 그 맵을 락으로 사용해 수동으로 동기화하라.

	Map<K, V> m= Collections.synchronizedMap(new HashMap<>());
        Set<K> s = m.keySet();
        
        synchronized (m){ // s가 아닌 m을 사용해 동기화해야한다
            for (K key : s){
                key.f();
            }
        }

 

2.4. 스레드 안전하지 않음(not thread-safe) - @NotThreadSafe

 클래스의 인스턴스는 수정될 수 있다. 하지만 멀티 쓰레드 환경에서 사용하려면 클라이언트가 외부 동기화 메커니즘을 사용해야한다. 대표적으로 ArrayList, HashMap이 있다.

 

2.5. 스레드 적대적(thread-hostile)

 외부 동기화로 감싸더라도 안전하지 않은 수준을 말한다.

 

 

3. 정리

클래스의 스레드 안전성은 보통 클래스의 문서화 주석에 기재하지만, Collections.synchronizedMap과 같이 조건을 명시해야할 경우엔 코드 레벨의 주석에 기재하는 것이 좋다.

 추가로 클래스가 외부에서 사용할 수 있는 락을 제공하면 클라이언트에서 일련의 메서드 호출을 원자적으로 수행할 수 있다.

반응형
반응형

1. 개요

 수행하려는 일과 관련 없어 보이는 예외가 튀어나올 수 있을까? 있다. 바로 체인된 메서드가 저수준의 예외를 처리하지 않고 바깥으로 던져버릴때이다.

 

예외를 찾아서...

 

 예외 발생의 원인을 찾기 위해 내부 구현을 모조리 뒤져야한다는 것이다. 자연스레 결합도가 높아질것이다.

 만약 buy() 메서드에서 클라이언트에게 정확한 예외 메시지를 주고싶다는 등의 이유로 이 예외를 잡아 처리한다면 어떨까? 내부 구현을 분석한 후 적절한 예외 메시지를 던져야할것이다. 

 최종적으로 IllegalStatusException에 대한 catch 문을 추가하고 "상품 유효성 검사에 실패했습니다. 관리자에게 문의해주세요" 라는 예외를 던지도록 예외를 전환했다. 하지만 이것도 문제가된다.

 

2. checkStock() 에서 발생한 IllegalStatusException

 며칠 뒤 buy() 메서드 호출과 함께 어디선가 IllegalStatusException이 발생했다. 클라이언트에게는 "상품 유효성 검사에 실패했다"라는 예외 메시지 던져주고 있다. 그런데 확인해보니 이번 예외는 productValidation()가 아닌 checkStock() 에서 발생했고, 재고 관련 인스턴스를 생성할 때 발생했다.

 

재고 관련 문제가 발생했는데 "상품 유효성 검사에 실패했다"는 에러 메시지는 사용자와 개발자 모두를 혼란에 빠지게했다.

결론은 예외를 무작정 던져도 문제이지만, 무작정 던진 걸 상위 수준에서 무작정 catch 해서 처리하는 것도 문제라는 것이다.

 

자, 그럼 어떻게 했어야할까? productValidation()에서 예외를 잡아 본인의 추상화 수준에 맞는 예외로 전환해서 던졌어야한다. ProductValidationException과 같이 말이다. 예외를 밖으로 던지는 것 자체가 책임을 전가하는 것이기에 결합도 측면에서도 좋지 않다.

 

try{
    // 제품 유효성 체크 로직
}catch(IllegalStatusException e){
    throw new ProductValidationException("제품 유효성 체크에 실패하였습니다. 관리자에게 문의해주세요!")
}

 

이처럼 자신의 추상화 수준에 맞는 예외 클래스로 바꿔 던지는 것을 예외 번역(exception translation) 또는 예외 전환 이라고 한다.

 

3.  예외 체이닝

예외를 전환하려 했더니, 저수준 예외가 디버깅에 도움이 될것같아 예외를 살리고 싶다면 어떨까? 문제의 근본 원인인 저수준 예외를 고수준 예외에 실어보내는 방법을 사용해야 한다. 이를 예외 체이닝(exception chaining)이라고 한다.

 

try{
    // 제품 유효성 체크 로직
}catch(IllegalStatusException e){
    throw new ProductValidationException(e) // 예외 체이닝
}

 

class ProductValidationException extends Exception{
    ProductValidationException(Throwable cause){
        super(cause);
    }
}

 

대부분의 표준 예외는 예외 체이닝 생성자를 갖추고 있다. 예외 연쇄는 문제의 원인을 프로그램에서 접근(getCause 메서드)할 수 있게 해주며, 원인과 고수준 예외의 스택 추적 정보를 통합해준다.

 

정리

아래 계층의 예외를 예방하거나 스스로 처리할 수 없고, 그 예외를 상위 계층에 그대로 노출하기 곤란하다면 예외 전환을 사용하자. 무턱대고 예외를 전파하는 것보다 예외 전환이 우수한 방법이다. 하지만 남용해서는 안된다. 가능하다면 저수준 메서드가 반드시 성공하도록 해야한다. 예를들어 전달하는 하위 메서드에 매개변수를 전달하기 전에 매개변수에 대한 체크를 하는 방법이 있을것이다.

반응형
반응형

1. 개요

 비지니스 코드를 재사용 하는 것이 좋은 것처럼, 예외도 재사용 하는 것이 좋다. 자바 라이브러리는 대부분 API에서 쓰기 충분한 수의 예외를 제공한다. 커스텀 예외를 사용해도 되지만 표준 예외를 사용했을 때 사용자 입장에서 익숙한 예외가 발생하므로 예외 상황 파악과 코드 이해가 쉬워진다.

 

 재사용할만한 표준 예외들을 알아보자.

 

2. 재사용할만한 예외

 

IllegalArgumentException

 호출자가 인수로 부적절한 값을 넘길 때 던지는 예외이다. 예를 들어 반복 횟수를 지정하는 매개변수에 음수를 건넬 때 쓸 수 있다.

 

public class Call {

    private static final String HI = "hello";

    public void sayHi(int cnt){

        if(cnt < 0){
            throw new IllegalArgumentException("cnt 값은 0보다 커야 합니다.");
        }

        for(int i =0; i<cnt; i++){
            System.out.println(HI);
        }
    }
}

 

 

IllegalArgumentException

 

IllegalStateException

 대상 객체의 상태가 호출된 메서드를 수행하기에 적합하지 않을 때 주로 던진다. 예를 들어 제대로 초기화되지 않은 객체를 사용하려 할 때 던질 수 있다.

 

public class Toy {

    private String weapon;

    public Toy(String weapon){
        this.weapon = weapon;
    }

    public void attack(){
        if(weapon == null){
            throw new IllegalStateException("weapon 값이 초기화되지 않았습니다. 초기화시켜주세요.");
        }

        System.out.println(weapon +" 공격!");
    }
}

 

IllegalStateException

 

NullPointerException

 일반적으로 null 값을 갖고 있는 객체나 변수를 호출할 때 발생하지만, 특정 상황에선 이를 재사용할 수 있다. null 값을 허용하지 않는 메서드에 null을 건네면 관례상 IllegalArgumentException이 아닌 NullPointerException을 던진다.

 아래와 같이 attack() 메서드를 실행하기 전, 생성자 메서드 레벨에서 NullPointerException을 사용하는 방법이다. 인스턴스 생성 시점에 멤버필드에 대한 유효성이 보장된다는 점에서 더 좋은 방법이라 생각한다.

 

public class Toy {

    private String weapon;

    public Toy(String weapon){
        if(weapon == null){
            throw new NullPointerException("weapon 을 null 로 설정할 수 없습니다.");
        }
        this.weapon = weapon;
    }

    public void attack(){
        System.out.println(weapon +" 공격!");
    }
}

NullPointerException

 

UnsupportedOperationException

 클라이언트가 요청한 동작을 대상 객체가 지원하지 않을 때 던진다. 보통 구현하려는 인터페이스의 메서드 일부를 구현할 수 없을 때 쓰는데, 예를 들어 원소를 넣을 수만 있는 List 구현체에 누군가 remove 메서드를 호출하면 이 예외를 던질 것이다.

 

public class NoRemoveList<E> extends ArrayList<E> {

    @Override
    public boolean remove(Object o) {
        throw new UnsupportedOperationException("remove는 지원하지 않습니다.");
    }
}

 

UnsupportedOperationException

 

이 외에도 어떤 시퀀스의 허용 범위를 넘을 때 사용하는 IndexOutOfBoundException, 단일 스레드에서 사용하려고 설계한 객체를 여러 스레드가 동시에 수정하려 할 때 사용하는 ConcurrentModificationException 등이 있다.

 

3. 재사용하지 말아야할 예외

 Exception, RuntimeException, Throwable, Error는 재사용하지 말자. 이 예외들은 다른 예외들의 상위 클래스이기 때문에 예외 상황에 대한 정보를 명확하게 전달하기 어렵다.

 

4. 정리

 상황에 부합한다면 항상 표준 예외를 재사용하자. 단, 표준 예외를 사용하기 전 API 문서를 참고해 그 예외가 어떤 상황에서 던져지는지 꼭 확인해야 한다. 예외의 이름 뿐 아니라 예외가 던져지는 맥락도 부합할 때만 재사용한다. 더 많은 정보를 제공하길 원한다면 표준 예외를 확장해도 좋다.

 

반응형
반응형

개요

 검사 예외 (Checked Exception)과 런타임 예외(Unchecked Exception), 에러의 개념과 차이에 대해 숙지하고 있지만, 어떤 상황에서 이러한 예외들을 적용할지에 대한 명확한 기준은 잡혀있지 않았다. 필자의 경우 모든 예외를 런타임 예외로 던졌다. 검사 예외를 사용할 경우 동일 트랜잭션 내 예외 발생 시 롤백하지 않는다는 것이 이유였다. 이번 아이템을 통해 예외처리에 대한 보다 명확한 기준을 잡도록 하자.

 


검사 예외(Checked Exception)는 언제? 

호출하는 쪽에서 복구하리라 여겨지는 상황에 사용한다.

 

위 내용이 검사 예외와 런타임 예외를 구분하는 기본 규칙이다.

검사 예외를 던지면 호출자는 예외에 대한 처리가 강제된다. 여기서의 예외는 '회복 가능한 예외'이다. 본인이 발생시킨 예외에 대해 호출자가 회복이 가능하다고 판단된다면 검사 예외를 던지면 된다.

 

* 아마 검사 예외에 트랜잭션에 대해 알아본 독자들이라면 검사 예외일 때는 트랜잭션 롤백이 되지 않는다는 것을 알고 있을 것이다. 검사 예외가 발생했고, 로직에서 복구했는데 트랜잭션이 롤백이 된다면, 복구시킨들 무슨 소용이 있으랴... 트랜잭션과 검사 예외에 대한 관계를 생각해보면 '회복 가능한 예외'일때 검사 예외를 던지는 것을 더 쉽게 이해할 수 있을것이다.

 


비검사 예외(Unchecked Exception) 는 언제?

 호출하는 쪽에서 복구가 불가능하리라 여겨지는 상황에 사용한다.

 

비검사 예외는 회복 불가능한 예외이다. Runtime Exception 과 Error 이 두가지로 구성되는데 이 둘 모두 회복이 불가능하다고 여겨질 때 사용하며, 통상적으로 잡지(catch) 말아야 한다. 자신만의 커스텀 예외로 예외 전환을 하는 목적으로 사용하는것은 상관 없지만, 이를 잡아 '복구'시킬 필요는 없다. 오히려 복구시키면 문제가 된다. (feat. 트랜잭션)

 

이처럼 복구가 불가능한 것을 책에서는 '프로그래밍 오류'라고 칭한다.


복구 가능 여부는 어떻게 판단하나?

복구할 수 있는지 아닌지는 명확히 구분되지 않는다. 예를들어 시스템 자원이 고갈된 원인이 엄청난 양의 배열을 생성한 것이라면 프로그래밍 오류라고 할 수 있지만, 폭발적인 요청에 의해 일시적으로 자원이 부족하여 발생했다면 시간을 두고 재요청을 하는 방식으로 복구할 수 있다. 결국, 복구 가능하냐, 불가능하냐에 대한 기준. 검사 예외, 비 검사 예외를 사용하는 것에 대한 기준은 오롯이 API 설계자의 판단에 달렸다. 복구가 가능하다고 믿는다면 검사 예외를, 그렇지 않다면 런타임 예외를 사용할 것이다.

 


정리

복수할 수 있는 상황이라면 예외 검사를, 프로그래밍 오류라면 비검사 예외를 던지자. 확실하지 않다면 비검사 예외를 던지자.

반응형
반응형

개요

 예외를 예외 상황에서만 사용하지 않는 케이스에 대해 알아보고, 어떤 문제를 야기하는지 이해해보자.

 


예외 상황에서 사용하지 않는 예

MyClass[] range = new MyClass[5];
range[0] = new MyClass();
range[1] = new MyClass();
range[2] = new MyClass();
range[3] = new MyClass();
range[4] = new MyClass();

try{
    int i = 0;
    while(true){
        range[i++].myMethod();
    }
}catch (ArrayIndexOutOfBoundsException e){

}
System.out.println("종료");

 

try catch 문 안에 반복문이 있고, range 배열을 순회하며 myMethod()를 호출하고 있다. (myMethod는 단순 print 문을 호출함) 반복을 하다 range[1++] 에서 배열의 최대 길이를 넘어갈 경우 ArrayIndexOutOfBoundsException 예외가 발생하는데, 이를 예상하여 예외를 잡아 처리하고 있다. 여기서 발생한 예외는 예외 상황이라고 하기엔 민망하기에 catch 문 안에 아무런 처리를 하지 않고있다. 즉, 일반적인 제어의 흐름에 사용한 것이다.

 

뭐 어찌됐던간에 range 를 순회하여 myMethod를 호출하는 것에는 문제가 없다. 


개발자의 의도 파악하기

이를 대체할 수 있는 방법은 여러가지가 있겠지만 위 코드를 작성한 개발자의 의도는 뭐였을까? 바로 성능을 높이기 위해서이다. JVM은 배열에 접근할 때마다 경계를 넘지 않는지 검사하는데, 일반적인 반복문도 배열 경계에 도달하면 종료한다. 어쨌든 배열 경계에 도달하면 예외를 발생시켜 종료하기에 검사 코드를 제거한 것이다. 하지만 이는 잘못된 추론이다. 이에 대한 근거를 알아보자.

 


잘못된 추론

 

첫째, 예외는 예외 상황에 쓸 용도로 설계되었다.

JVM 입장에서 예외의 쓰임이 잘못된 것이다. 쓰임이 잘못됐다면, JVM이 지원하는 기능들을 사용하지 못할 확률이 높다.

 

둘째, 배열을 순회하는 표준 관용구는 앞서 걱정한 중복 검사를 수행하지 않는다. JVM이 알아서 최적화해 없애준다.

이게 JVM 이 지원하는 기능 중 하나이다.

 

셋째, 코드를 try-catch 블록 안에 넣으면 JVM이 적용할 수 있는 최적화가 제한된다.

JVM의 기능을 사용하지 못하게 되었다.

 


위 코드에 대한 성능 테스트

테스트를 해보면 예외를 사용한 쪽이 표준 관용구보다 훨씬 느리다는 것을 알 수 있다.

// 테스트 데이터 셋팅
MyClass[] range = new MyClass[10000];
for(int i =0; i<10000;i++){
    range[i] = new MyClass();
}


// 예외 사용
long start = System.currentTimeMillis();
try{
    int i = 0;
    while(true){
        range[i++].myMethod();
    }
}catch (ArrayIndexOutOfBoundsException e){

}
System.out.println("걸린 시간 : "+ (System.currentTimeMillis() - start)); // 40~43

// 표준 반복 관용구 사용
start = System.currentTimeMillis();
for(MyClass a : range){
    a.myMethod();
}

System.out.println("걸린 시간 : "+ (System.currentTimeMillis() - start)); // 20~22
System.out.println("종료");

 

일반적인 반복문이 예외처리를 한 반복문보다 2배는 빠르다. 배열을 순회하는 표준 코드를 JVM이 최적화했다는 것을 간접적으로나마 확인할 수 있었다. 성능이 개선될 줄 알았던 예외를 사용한 반복문은 오히려 느리고, 코드를 헷갈리게 (왜 이렇게 코드를 짰지? 뭐가 있나? 라는 생각이 들지 않는가) 하는데 끝나지 않는다. 버그가 발생하고 디버깅이 어려워진다.

 


디버깅을 어렵게 하는 코드

아래 코드는 try 문 내에서 호출되는 myMethod() 이며, 보면 arr[10] 에 "lastDance"라는 문자열을 입력한다. 길이가 10이니 arr[10] 에 접근하는 부분에서 ArrayIndexOutOfBoundsException 예외가 발생할 것이고, 예외 로그를 확인한 개발자는 이를 수정할것이다.

static class MyClass{

    String[] arr = new String[10];

    public void myMethod(){
        System.out.println("call my method");
        arr[10] = "lastDance";
    }
}

 

그런데 실제로는 예외 로그가 발생하지 않는다. 이를 호출한 main 메서드의 catch에 의해 아무런 처리를 하지 않기 때문이다. 개발자는 예외 로그가 찍히지 않으니 문제가 없다고 생각하고 넘어가버리게 된다. 결국 예외는 오직 예외 상황에서만 써야 한다. 절대로 일상적인 제어 흐름용으로 쓰여선 안된다.

오류 없이 실행된 것 같은(?) 로그

 

 


정리

예외는 예외 상황에서 쓸 의도로 설계되었다. 제어 흐름에서 사용해서는 안된다. 코드를 최적화하려다 오히려 망가뜨릴 수 있다.

반응형
반응형

1. 개요

 JVM의 구성요소 중 하나인 네이티브 메서드의 사용에 대한 내용이다. 아직까지 네이티브 메서드를 직접 사용하는 것에 대한 필요성을 느끼지 못하고 있지만, 직접 사용하게 될 경우 어떤 주의사항이 있는지 알아보자.

 


2. 네이티브 메서드와 JNI

 자바에서의 네이티브 메서드란 C나 C++ 같은 프로그래밍 언어로 작성된 메서드를 말한다. 그렇다면 자바에서 C와 C++ 과 같은 언어로 작성된 코드를 직접 호출할 수 있을까? 아니다. JVM 내에 두 언어 간 중간다리 역할을 하는 인터페이스가 구성되어 있다. 이를 JNI라고 한다.

 참고로 JNI 는 JVM 의 구성 요소 중 하나이다. 이에 대해 알고싶다면 아래 게시글을 읽어보면 좋다.

https://tlatmsrud.tistory.com/148#google_vignette

 

[Java] JVM, JDK, JRE / 차이 / JVM 구조

JVM이 뭐야? JVM(Java Virtual Machine)은 바이트코드(.class)를 OS에 특화된 코드(기계어)로 변환하고, 이를 실행하는 '가상의 머신'이다. 특정 OS에 특화된 코드로 변환하기때문에 OS 종속적이다. JVM은 JRE에

tlatmsrud.tistory.com

 


3. 네이티브 메서드의 사용 목적

 

1) 레지스트리와 같은 플랫폼 특화 기능을 사용하기 위해

 레지스트리는 윈도우 플랫폼에서 사용하는 기능으로, 윈도우 OS의 설정을 담고 있는 DB를 말한다. 즉, OS 설정을 건드릴 때 네이티브 메서드 사용이 하나의 선택지가 될 수 있다.

 

2) 네이티브 코드로 작성된 기존 라이브러리를 사용하기 위해

 

3) 성능 개선을 위해

 성능에 결정적인 영향을 주는 부분을 네이티브 언어로 작성하면 성능 개선을 가져올 수도 있다.

 

 하지만 성능 개선을 위한 네이티브 메서드 사용은 권장하지 않고 있다. 자바가 발전함에 따라 대부분의 작업에서 다른 플랫폼에 견줄만한 성능을 내고 있기 때문이다. 예를들어 자바 1.1 시절 BigInteger는 C로 작성한 네이티브 메서드를 JNI를 통해 사용했으나 자바 3 에서 업데이트되며 원래의 네이티브 구현보다 더 빨라졌다. 

 


4. 네이티브 메서드의 단점

 네이티브 언어는 OS를 직접 건드리기에 안정성을 보장하지 않는다. 잘못 사용할 경우 애플리케이션의 버그를 유발하거나 메모리를 훼손할 수 있다. 가비지 컬렉터가 네이티브 메모리는 자동으로 회수하지 못하고 추적할 수도 없다.

 이식성도 낮다. 자바에서 JNI를 사용하지 않으면 네이티브 메서드를 사용하지 못한다. JNI를 구성한다해도 이 과정에서 접착 코드(glue code)를 작성해야 하는데, 이는 복잡한 작업이고 가독성도 떨어진다.

 


5. 정리

 네이티브 메서드가 성능을 개선해주는 일은 많지 않다. 네이티브 코드 안에 숨은 단 하나의 버그가 애플리케이션 전체를 훼손할 수 있으므로 신중히 고민하고 사용해야한다.

반응형
반응형

개요

박싱된 기본 타입과 기본타입의 차이를 알아보고, 왜 후자의 사용을 지향하는지 알아보자.

 


기본 타입이란?

자바에서의 타입은 '데이터 타입'을 말한다. 이는 해당 데이터가 메모리에 어떻게 저장되고, 어떻게 처리되어야 하는지를 명시적으로 알려주는 역할을 한다.

자바에서는 여러 형태의 '타입'을 미리 정의하여 제공하는데, 이것을 자바의 기본타입(==Primitive type) 이라고 한다. 참고로 기본 타입 외에도 String, List 와 같은 참조 타입이 있다.

 기본 타입은 8 종류가 있으며, 크게는 정수형, 실수형, 문자형, 논리형 타입으로 나뉜다.

 


정수형 타입 (4종류)

정수란 부호를 가지고 있으며, 소수 부분이 없는 수를 의미한다. int, long, short, byte 타입이 있다.

정수형 타입 메모리의 크기 데이터의 표현 범위
byte 1바이트 -128 .. 127
short 2바이트 -32,768 ~ 32,767
int 4바이트 -2,147,483,648 ~ 2,147,483,647
long 8바이트 -9,223,372,036,854,775,808 ~ 9,223,372,036,854,775,807

 

실수형 타입 (2종류)

실수란 소수부나 지수부가 있는 수를 가리키며, 정수보다 훨씬 더 넓은 표현 범위를 가진다. float, double 타입이 있다.

실수형 타입 메모리의 크기 데이터의 표현 범위
float 4바이트 3.4 * 10의 -38승 ... 3.4 * 10의 38승
double 8바이트 1.7 * 10의 -308승 ... 1.7 * 10의 308승

 

 

문자형 타입 (1종류)

컴퓨터는 2진수밖에 인식하지 못하므로 어떤 문자를 어떤 숫자에 대응시키는 약속을 했다. 대표적으로 C언어는 아스키 코드를 사용해 문자를 표현한다. 아스키 코드는 영문 대소문자 및 몇가지 기호를 사용하는 7비트 문자 인코딩 방식이다. 문자 하나를 7비트로 표현하므로 총 128개의 문자를 표현할 수 있다.

 

자바에서는 유니코드를 사용하여 문자를 표현한다. 문자 하나를 16비트로 표현하므로, 총 65,536 개의 문자를 표현할 수 있기에 각 나라의 모든 언어를 표현할 수 있다.

문자형 타입 메모리의 크기 데이터의 표현 범위
char 2 바이트 0 ... 65,536

 

 

논리형 타입 (1종류)

논리형은 참이나 거짓 중 한 가지 값만을 가질 수 있는 타입을 의미한다. boolean 타입이 있다.

boolean 형의 기본값은 false 이며, 1 바이트의 크기를 가진다.

논리형 타입 메모리의 크기 데이터의 표현 범위
boolean 1바이트 true 또는 false

 


 

박싱된 기본 타입이란?

자바에서는 앞서 말한 기본 타입에 대응하는 참조 타입이 하나씩 있는데, 이를 박싱된 기본타입이라고 한다.

예를들어 int, double, boolean 에 대응하는 박싱된 기본 타입은 Integer, Double, Boolean이다.

 


기본 타입과 박싱된 기본 타입의 차이

 

첫째, 기본 타입은 값만 가지고 있으나, 박싱된 기본 타입은 식별성이란 속성을 갖는다.

박싱된 기본 타입의 두 인스턴스는 값이 같아도 서로 다르다고 식별될 수 있다. 메모리의 주소가 다르기 때문이다.

 

둘째, 기본 타입의 값은 언제나 유효하나, 박싱된 기본 타입은 null을 가질 수 있다.

null 에 대한 체크가 이루어지지 않는다면 NPE가 발생할 수 있다.

 

셋째, 기본 타입이 박싱된 기본 타입보다 처리 시간, 메모리 사용면에서 효율적이다.

박싱된 기본 타입이 메모리를 더 잡아먹기 때문이다.

 

이 세가지를 무시하고 생각없이 사용했다가는 문제가 발생할 수 있다. 이를 무시했을 때 발생하는 문제들을 예제로 알아보자.

 


예제 1. 오름차순 정렬

Comparator<Integer> naturalOrder = (i, j) -> (i<j) ? -1 : (i == j ? 0 : 1);
System.out.println(naturalOrder.compare(new Integer(1),new Integer(3)));

 

함수형 인터페이스인 Comparator 의 구현체를 람다식으로 구현한 후, 호출하는 예제이다. i 보다 j 가 클 경우 -1, 같을경우 0, i 가 클 경우 1을 리턴한다. naturalOrder.compare 메서드의 매개변수로 값을 넣어 테스트했을 때는 문제는 발생하지 않는 것처럼 보인다. new Integer(1), new Integer(3)을 넣었을 땐 -1이, 반대로 넣었을땐 1이 리턴되기 때문이다. 그렇다면 위 코드의 문제가 뭘까?

 

 

 

바로 같은 값에 대해서는 0을 리턴하지 않는 점이다.

 

첫번째 검사 (i<j) 는 잘 동작한다. i, j가 참조하는 오토박싱된 Integer 인스턴스는 비교를 위해 기본 타입 값으로 변환된 후 비교 연산을 한다. 그런데 두 번째 검사인 (i == j) 에서는 '객체 참조'의 식별성을 검사하게 된다. i와 j 가 같은 값을 갖고있다고 할지라도 인스턴스가 다르기에 false 가 출력되고 결국 1을 반환한다. 즉, 박싱된 기본 타입에 == 연산자를 사용하게 될 경우 문제가 발생하는 것이다.

 


예제 2. 기이하게 동작하는 프로그램 예제

public class UseCase {

    static Integer i;

    public static void main(String[] args) {

        if (i == 42)
            System.out.println("믿을 수 없군!");
    }
}

 

이 프로그램의 실행 결과는 어떨까?

 

 

이 프로그램의 결과는 "믿을 수 없군!"을 출력하지 않지만, 그 전에 기이한 결과를 보여준다. i == 42를 검사할 때 NullPointerException을 던지는 것이다. 원인은 (i == 42) 코드에서 null을 참조하고 있는 참조 타입 i와 42라는 기본 타입을 비교하기 위해 i를 언박싱하게 되는데, 이때 null 참조를 언박싱하므로 NPE 예외가 발생한다. 기본 타입과 박싱된 타입을 혼용한 연산에서는 박싱된 기본 타입의 박싱이 자동으로 언박싱되기 때문이다. 

 


예제 3.느린 반복문 예제

Long sum = 0L;

for(long i = 0; i <= Integer.MAX_VALUE; i++){
    sum += 1;
}

 

이 프로그램은 지역변수 sum을 박싱된 기본 타입으로 선언하여 느려졌다. sum += 1 부분에서 언박싱과 박싱이 반복해서 일어나기 때문이다. 체감될 정도로 성능이 느려진다.

 


박싱된 기본 타입은 언제 쓰는게 좋을까?

그렇다면 박싱된 기본 타입은 아래와 같은 상황에서 써야한다.

첫째, 컬렉션의 원소, 키, 값으로 쓴다. 컬렉션은 기본 타입을 담을 수 없기때문이다.

둘째, 타입 매개변수로 사용한다. 타입 매개변수로 기본 타입을 지원하지 않기 때문이다.

셋째, 리플렉션을 통해 메서드를 호출할 때도 박싱된 기본 타입을 사용해야 한다.


정리

기본 타입과 박싱된 기본 타입 중 하나를 선택해야 한다면 기본 타입을 사용하자. 간단하고 빠르며, 앞서 말했던 언박싱으로 인한 버그, 성능 이슈를 예방할 수 있기 때문이다. 또한 기본 타입을 박싱하는 작업은 필요 없는 객체를 생성하는 부작용을 나을 수 있다. 박싱된 기본 타입을 꼭 사용해야한다면 위와 같은 문제 상황들을 이해하고 적절히 사용해야 한다.

 

반응형
반응형

개요

 이번 아이템은 라이브러리를 무작정 사용하지 말고, 사용법을 익힌 후에 사용하라는 것인 줄 알았는데, 알고보니 어떤 기능을 구현함에 있어 대체 가능한 라이브러리가 있다면 직접 구현하지 말고 이를 익히고 사용하라는 것이었다.

 

라이브러리를 사용했을 때의 이점

 

1. 코드를 작성한 전문가의 지식과 선배 프로그래머들의 경험을 활용할 수 있다.

 - 라이브러리 자체가 지식과 경험의 집약체인 것이다.

 

2. 비지니스 로직, 핵심 로직에 시간을 집중할 수 있다.

 - 핵심적인 일과 크게 관련없는 문제들은 라이브러리가 대체할 수 있기 때문이다.

 

3. 노력하지 않아도 성능이 개선된다.

 - 라이브러리 사용자가 많다면, 이를 만든 제작자들은 성능 개선을 위해 노력할 수 밖에 없다. 때론 성능이 극적으로 개선되기도 한다.

 

4. 기능이 점점 많아진다.

 - 실제로 커뮤니티에서 얘기가 나오고 논의된 후 다음 릴리스에 기능이 추가되기도 한단다.

 

5. 낯익은 코드가 된다.

 - 범용적으로 사용하는 라이브러리가 우리의 코드에 있다면, 다른 개발자도 읽기 쉽고, 유지보수하기 쉽다.

 

하지만 실상은 직접구현이 많다

대체할 수 있는 표준 라이브러리가 있음에도 직접 구현하는 개발자들이 많다고 한다. 이유는 대체할 수 있는 라이브러리가 있다는 사실을 모르기 때문이다.

메이저 릴리즈마다 주요 기능이 라이브러리에 추가되고 공시된다. 때문에 홈페이지에 직접 들어가 한번씩 읽어보는 것이 좋다.

 

지정한 URL 파싱

예를들어 지정한 URL의 내용을 가져오는 기능을 직접 구현하면 복잡하지만, 자바 9버전에 추가된 InputStream.transferTo 메서드를 사용하면 쉽게 구현할 수 있다.

 

public static void main(String[] args) throws IOException {
    try(InputStream in = new URL("https://www.naver.com/").openStream()){
        in.transferTo(System.out); // 콘솔에 파싱데이터가 출력됨.
    }
}

 

자바 프로그래머라면 적어도...

라이브러리가 너무 방대하여 모든 API 문서를 공부하기는 벅차겠지만, 이 책의 저자는 자바 프로그래머라면 적어도 java.lang, java.util. java.io 패키지들에는 익숙해져야 한다고 말하고 있다.

추가로 컬렉션 프레임워크나 스트림 라이브러리의 경우 유용하게 사용되고 동시성과 관련있는 부분도 있어 알아두는 것을 추천하고 있다.

 

정리

어떤 기능을 개발하기 전 라이브러리가 존재하는지 먼저 살펴보는 습관을 기르자. 그게 프로젝트만의 기능이 아니라면, 누군가는 똑같은 고민을 했을거고, 이를 구현했을 확률이 높다. 누군가 구현했다면 감사히 쓰면된다.

반응형

+ Recent posts