반응형

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. 개요

직렬화(Serializable)의 개념을 이해하고, Serializable을 구현할 때 주의해야할 점을 알아보자.

 

2. 직렬화(Serializable)란?

 

인스턴스 >> Byte

 

인스턴스를 Byte로 변환하는 것을 말한다. 간혹 클래스를 Byte로 변환하는 것이라고도 하는데 목적 측면에서 보거나, 일반적으로 사용하는 방식을 보면 인스턴스를 직렬화하는게 더 맞는 표현이라고 생각한다.

 

 

3. 직렬화를 하는 이유

그렇다면 직렬화를 하는 이유는 뭘까? 도대체 뭘 위해서 직렬화를 하는걸까?

 

 

 

바로 인스턴스를 저장하거나, 전송하기 위함이다.

 

그럼 인스턴스를 저장하거나 전송하는 이유는 뭘까?

 

 

 

객체의 상태를 저장해야할 상황이 있기 때문이다. 아래 예시를 통해 알아보자.

 

4. 객체의 상태를 저장해야할 상황 

1) HTTP 세션 관리

 Spring 웹 어플리케이션에서는 HTTP 세션을 사용해 사용자 상태를 유지한다. 사용자의 ID나 이름, 국적 등과 같은 '상태값'을 세션에 저장해두고 필요할때마다 가져온다.

 만약 세션 클러스터링 환경이라면 어떨까? 세션을 다른 서버와 공유해야한다. 정확히 말하면 상태값을 갖고 있는 인스턴스를 다른 서버에 전달해야한다. 이를 위해 직렬화하고, Byte 데이터를 Stream 방식으로 전달하는 것이다.

 

2) 인메모리 저장소 활용(ex. Redis)

spring Session이나 캐싱기능을 위해 저장소로 Redis를 사용하고자 한다면, Redis에 저장할 인스턴스의 클래스에 Serializable을 구현했을 것이다. 상태 값을 가진 인스턴스를 Redis라는 외부 저장소에 전달해야하기 때문이다. 이때도 마찬가지로 외부로 전달하기 위해 직렬화를 한다.

 

3) 영속적인 저장 / 데이터 복원

 HTTP 세션, 클러스터링 환경, 인메모리 저장소는 서버가 재시작되면 저장된 데이터가 날아간다. 서버가 재시작되도 상태값을 유지하고 싶다면 상태 값을 필드로 구분하여 DB에 저장하는 방법도 있지만, 인스턴스 자체를 DB에 저장하는 방법도 있다. 디스크에 저장하는 방식이다. 이 경우 서버에 문제가 생기거나 재시작이 되어도 데이터를 쉽게 복원할 수 있다.

 

5. Serializable 주의사항

 

주의할 점이 많은 Serializable

1) 릴리스 한 뒤에는 수정이 어렵다.

클래스가 Serializable을 구현하면 직렬화된 바이트 스트림 인코딩도 하나의 공개 API가 된다. 누군가 역직렬화를 하면 클래스 구조와 상태값들을 확인할 수 있기 때문이다. 그래서 이 클래스가 널리 퍼지게 된다면, 그 직렬화 형태도 영원히 지원해야 한다. 만약 특정 필드를 제거하거나, 타입을 바꾸는 등의 수정을 한다면, 타 시스템의 역직렬화 과정에서 에러가 발생할 수 있기 때문이다.

직렬화 가능 클래스를 만들고자 한다면, 길게 보고 수정이 일어나지 않는 형태로 주의해서 설계해야한다.

 

2) 버그와 보안 구멍

객체는 생성자를 사용해 만드는게 기본이다. 역직렬화는 기본 방식을 우회하는 생성 기법이다. 마치 숨은 생성자와 같다. 생성자를 우회한다는건 객체 초기화에 필요한 모든 내부 코드를 무시한다는 측면에서 버그를 유발 가능성을 재기한다.

 또한 공격자가 객체 내부를 들여다 볼 수 있다면, 직렬화된 코드를 조작하여 서버로 전달할 수 있다. 해당 서버에서 조작된 메서드를 호출이라도 한다면 공격자가 의도한 작업을 서버 내에서 실행하게 될것이다.

따라서 공격자가 이러한 데이터를 볼 수 없는 환경, 즉 private한 환경에서 사용해야한다.

 

3) 신버전을 릴리스할 때 테스트가 늘어난다.

 직렬화 가능 클래스가 수정되면 신버전 인스턴스를 직렬화 한 후 구버전으로 역직렬화할 수 있는지, 그 반대도 가능한지 테스트 해야한다. 직렬화 가능 클래스의 수가 많을수록 테스트할 횟수도 증가한다.

 

4) 상속용으로 설계된 클래스나 인터페이스는 Serializable을 구현하거나 확장해선 안된다.

해당 클래스를 클래스를 상속하거나 구현하는 모든 클래스는 앞선 문제들에 위험을 안고가게 된다.

 

 

그런데... 직렬화 클래스를 수정하면 역직렬화가 무조건 실패하는게 아닐까? 필자의 경우도 그랬던 기억이 있었다. 

 

6. 직렬화 클래스를 수정하면 역직렬화가 무조건 실패하는거 아냐?

직렬화 클래스를 수정해도 구버전 데이터를 역직렬화할 수 있는 조건이 있다. 아래 두 조건을 모두 만족해야한다.

 

첫째, SerialVersionUID 가 일치할때

 클래스가 변경되었지만 serialVersionUID가 동일하다면 역직렬화가 가능하다. 클래스 구조가 약간 달라져도 serialVersionUID가 같다면 역직렬화를 시도할 수 있다.

 

둘째, 새로운 필드가 추가되거나 제거됐을 때

 - 직렬화된 클래스에 신규 필드를 추가할 경우, 역직렬화 시 해당 필드는 기본값 (null, 0, false) 등으로 설정된다.

 - 기존 필드가 삭제되어도 역직렬화가 가능하다. 역직렬화 시 해당 필드는 무시된다.

 

위 두 조건을 만족하면 직렬화 클래스가 수정되도 역직렬화가 가능하다.

단, 클래스의 인터페이스 혹은 상속 구조를 변경하거나, 필드 타입, 클래스 이름이 변경되면 역직렬화에 실패하게 된다.

 

serialVersionUID
클래스의 직렬 버전을 나타내는 ID 로 모든 직렬화된 클래스는 이 값을 부여받는다. 만약 필드에 명시한다면 그 값이 ID가 되겠지만, 명시하지 않는다면 클래스의 정보를 기반으로 해시함수를 적용해 UID가 자동으로 부여된다.

 

즉, 추후 조금의 수정 가능성을 염두해둔다면 SerialVersionUID의 값을 지정해주는 것이 좋다.

 

 

7. 정리

 Serializable은 구현하기는 아주 쉽지만, 주의해야할 사항이 많다. 때문에 클래스의 여러 버전이 상호작용할 일이 없고, 서버가 신뢰할 수 없는 데이터에 노출될 가능성이 없는 등, 보호된 환경에서만 쓰일 클래스가 아니라면 Serializable 구현은 아주 신중하게 이뤄져야한다. 상속할 수 있는 클래스라면 주의사항이 더욱 많아진다.

 

그럼 2만

퇴긘~

반응형
반응형

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. 정리

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

반응형
반응형

개요

코드 분석중...

 

 누군가 작성한 코드를 분석하기 위해 100줄 남짓한 메서드를 보고있다. 최상위에 지역변수들이 초기화되어 있고, 아래로 비지니스 로직이 주욱 구현되어 있다. 코드를 분석해나가는 도중 어떤 지역변수가 사용되었지만, 이 값이 어떤 값을 갖고있는지를 잊어버려 최상위에 적힌 지역변수를 다시 확인했다. 어떤 변수는 반복문에서도, try 문 안에서도, 다른 변수에 값을 할당하는 부분에도 사용됐다. 변수의 유효 범위가 너무 넓은 것이다. 변수를 체크하다보니 로직의 흐름을 까먹어 다시 분석하기도 한다. 이런 과정을 반복하면서 코드 분석을 마무리했다.

 


지역변수가 최상위에 선언되어 있지 않았다면?

 지역변수들이 최상위에 선언되어 있지 않고, 쓰일 때 초기화되어 있거나, 현재 보이는 코드라인에 초기화 된 값이 보인다면 어땠을까? 다시 확인할 필요도 없고, 코드 분석에 대한 집중력도 유지할 수 있다. 즉, 지역변수의 범위를 최소화한다면 유지보수성과 가독성을 향상시킬 수 있다.

 


지역변수의 범위를 최소화하는 방법

 가장 강력한 방법은 '가장 처음 쓰일 때 선언하는 방법'이다. 앞서 개요에서 말했던 방법이다. 사용하려면 멀었는데 미리 선언부터 해두면 이를 다시 확인하거나 잘못 사용하게 되는 상황이 발생할 수 있다. 사실 거의 모든 지역변수는 선언과 동시에 초기화해야한다. 여기서 거의라고 말한 이유는 그렇지 않은 예외 케이스가 존재하기 때문인데 바로 try-catch 문이다. 예외 처리를 해야하는 경우 선언은 try 문 밖에서, 초기화는 try 문 안에서 해야 catch나 finally 에서 이에 맞게 핸들링을 하거나 리소스를 제거하는 행위를 할 수 있다.

 

아래와 같이 FileInputStream 과 같은 타입의 값을 초기화할 때 예외처리를 하지 않을 경우 아래와 같이 컴파일 타임에 에러가 발생한다. 이를 처리하기 위해 예외를 외부로 던질수도 있지만, 이는 예외에 대한 책임을 전가하기에 메서드 내에서 처리를 하려는 사람도 있을 것이다.

예외 처리를 하지 않아 컴파일 에러 발생

 

 

그 경우 inputStream을 try 외부에서 먼저 선언하고 내부에서 초기화하게 된다.

public void myMethod(){
    File file = new File("");

    FileInputStream inputStream = null;
            
    try{
        inputStream = new FileInputStream(file);
    }catch (FileNotFoundException e){
        e.printStackTrace();
    }finally {
        try{
            inputStream.close();
        }catch (IOException e){
            e.printStackTrace();
        }
    }
}

 

물론 InputStream은 autoClose 가 구현되어 있으니 try-resource를 사용한다면 try 괄호 안에서 초기화가 되겠지만, 그게 아닐 경우 위처럼 선언과 동시에 초기화되지 않을 수도 있다. 어쨌든 중요한건 대부분의 지역변수는 사용할때, 선언과 동시에 초기화해야 한다는 것이다.

 


반복문의 반복변수

 지역변수는 반복문에서도 사용된다. 바로 '반복변수'이다. 일반적으로 for나 while 과 같은 반복문에서 사용하는데 만약 반복 변수의 값을 반복문이 종료된 뒤에도 써야 하는 상황이 아니라면 while 보다 for 문을 쓰는 게 낫다.

 

예를들어 컬렉션을 순회하는 코드를 작성할 경우 for문은 아래와 같이 for-each나 전통적인 for문을 사용해서 구현할 수 있다.

List<String> list = new ArrayList<>();
//...


// for-each
for(String e : list){
    //...
}

// 전통 for
for(Iterator<String> i = list.iterator(); i.hasNext();){
	//...
}

 

 

while의 경우는 아래와 같이 구현되는데, while 구문 안에 조건을 넣어야하므로 while 구문 전에 반복 변수를 초기화를 해야한다. 그런데 아래와 같이 while 반복문이 두번 사용될 경우 복붙 과정에서 실수로 조건부의 i를 i2로 수정하지 않아 버그가 발생할 수 있다. 이 경우 두번째 반복문이 실행되지 않을 것이다.

Iterator<String> i = list.iterator();
while(i.hasNext()){
    //...
}

Iterator<String> i2 = list.iterator();
while(i.hasNext()){ // 버그유발
    //...
}

 

 

for문의 경우는 어떨까? 반복 변수의 유효범위가 for문 안으로 한정되어 있기 때문에 위와 같은 버그가 발생하지 않는다. 심지어 변수명을 다르게 설정할 필요도 없다.

for(Iterator<String> i = list.iterator(); i.hasNext();){
    //...
}
for(Iterator<String> i = list.iterator(); i.hasNext();){
    //...
}

 

 

여기서 키포인트는 초기화를 내부에서 했냐, 외부에서 했냐가 아니다. while에 사용되는 반복변수는 for문에 사용되는 반복변수보다 유효 범위가 훨씬 넓다는 것이다. while의 반복변수는 메서드 전체인데 반해, for문의 반복변수는 for문 안으로 한정되어 있다. 즉, 지역변수의 유효범위가 넓을수록 버그를 발생시킬 확률이 높고, 가독성과 유지보수성을 헤친다는 것을 말하고 있다.

 


정리

 지역변수의 범위를 최소화하는 목적은 가독성과 유지보수성을 높이는 것이다. 이를 위해 지역변수를 선언과 동시에 초기화하거나, 실제로 쓰일 때 초기화 하거나, while 보다는 for문을 쓴것인데, 만약 이를 다 지킨다고 하더라도, 코드가 길면 어떨까? 긴 코드 안에는 여러 책임들이 얽혀있고, 여러 기능들을 하고, 여러 예외들을 처리한다면 지역변수의 범위를 최소화한다 한들 가독성과 유지보수성을 높인다는 목적을 이루지 못한다.

 

때문에 지역변수의 범위를 최소화하기에 앞서 선행되어야 할 가장 중요한 것은 단일 책임원칙에 맞게 코드를 구현하여 메서드를 작게 만드는 것이다. 이게 선행됐을 때 비로소 지역변수의 범위를 최소화하는 것이 의미있는 행위가 될것이다.

반응형
반응형

1. 개요

 자바 8 이전에는 빈 값에 대한 처리 선택지가 '예외' 혹은 'null 반환'이었다. 이 둘 모두 허점이 있다.

예외는 정말 예외적인 상황에서만 사용해야한다는 것과(빈 값이라고 예외를 날려서는 안될 케이스도 있다. ex. 검색결과)

예외를 생성할 때 스택 추적을 하므로 이에 대한 비용 문제가 있다. null을 반환하면 위 문제가 생기지 않지만 언젠가, 그리고 어디선가 NullPointException 이 발생하여 시스템 버그를 초래할 수 있다. 그런데 자바 8 이후 또 하나의 선택지가 생겼다. 그게 바로 Optional<T> 이다. 

 


2. Optional<T> 이란?

 옵셔널은 1개의 null이 아닌 T 타입 객체를 담거나, 아무것도 담지 않을 수 있는 불변 컬렉션이다. 옵셔널을 사용하지 않는다면 보통 T를 반환할테지만 상황에 따라 아무것도 반환하지 않아야 할때가 있다면 T 대신 Optional<T> 를 반환하도록 선언하면 된다. 옵셔널을 반환하는 메서드는 예외를 던지는 메서드보다 유연하고 사용하기 쉬우며, null을 반환하는 메서드보다 오류 가능성이 낮다.

 


3. max 값을 구하는 예제

 

최댓값을 구하는 메서드이다. 파라미터로 들어온 Collection의 요소가 없을 경우 예외를 발생시키고 있다.

public static <E extends Comparable<E>> E max(Collection<E> c){
    if(c.isEmpty())
        throw new IllegalArgumentException("빈 컬렉션"); // 클래스 내부에서 예외를 던지고 있다.

    E result = null;
    for(E e : c)
        if (result == null || e.compareTo(result) > 0)
            result = Objects.requireNonNull(e);
    
    return result;
}

 

 

이처럼 예외 처리가 메서드 내에 강하게 결합되어 있기 때문에 여러가지 한계를 맞이하게 된다. 이를 호출하는 클래스의 상황에 따라 예외 메시지를 다르게 하고싶을 수도, 예외를 발생시키지 않을 수도 있지 않은가? 하지만 이 방식은 그게 쉽지않다.

예외 메시지를 다르게 하고싶다면 클라이언트는 이 런타임 예외가 발생한다는 것을 알고 있어야하고, 이 예외에 대해 외부에서 catch 문에서 예외 전환 로직을 작성해야한다. 원치않는 결합도가 생겨버렸다.

예외를 발생시키고 싶지 않다면 마찬가지로 catch 문에서 예외를 잡고 아무것도 수행하지 않도록 설정해야한다. 이상하다.

 

이를 Optional 로 구현해보면 어떨까?

public static <E extends Comparable<E>> Optional<E> max2(Collection<E> c){
    if(c.isEmpty())
        return Optional.empty(); // 빈 옵셔널을 반환함으로써 클래스 외부에서 예외를 발생시킬 수도, 시키지 않을 수도 있다. Null도 아니다!

    E result = null;
    for(E e: c)
        if(result == null || e.compareTo(result) > 0)
            result = Objects.requireNonNull(e);

    return Optional.of(result); // result 를 참조하는 Optional 타입을 반환한다.
}

 

 

빈 옵셔널은 Optional.empty(), 값이 든 옵셔널은 Optional.of(value)로 처리한게 끝이다. 이 방식은 위 방식과는 다르게 호출한 클라이언트에서 Optional 값에 대한 처리가 가능하다. 원하는 예외메시지를 발생시킬수도, 발생시키지 않을수도 있고, 더 나아가 기본 값을 넣을 수도 있다. 앞선 코드보다 훨씬 유연하고 깔끔한 것을 볼 수 있다.

Integer maxValue = max2(list).orElse(0); // 빈 옵셔널일 경우 0으로 설정

//클라이언트에 따른 예외 메시지 변경
Integer maxValue2 = max2(list).orElseThrow(() -> new IllegalArgumentException("요청 리스트가 비어있습니다."));

 

 

참고로 Optional.of(value)에 null을 넣으면 NPE가 발생하므로 주의해야한다. 또한 옵셔널을 반환하는 메서드는 절대 null을 반환하면 안된다. 옵셔널을 도입한 취지를 저버리는 행위이기 때문이다.

 


4. Optional 을 사용해야하는 기준

그렇다면 null, 예외를 던지는 대신 옵셔널을 선택하는 기준은 뭘까? 옵셔널은 검사 예외와 취지가 비슷하다. 즉, 반환 값이 없을 수도 있음을 API 사용자에게 명확히 알려준다. 반환 값이 없을 경우에 대한 처리를 사용자가 작성해야하므로 null 보다 안전하고 깔끔하게 처리할 수 있다.

 

클라이언트 입장에서 이를 사용한다면 클라이언트는 옵셔널에 대한 처리를 클라이언트 코드에서 선택하면 된다.

1) 기본 값 설정

int maxValue = max2(list).orElse(0);  // 기본 값 설정

 

2) 원하는 예외 설정

// 원하는 예외 설정
String maxString = max2(wordList).orElseThrow(() -> new RuntimeException("단어 리스트가 비어있습니다. 리스트를 확인해주세요"));

 

3) 항상 값이 유효하다고 가정할때 설정

int value = max2(list).get(); // 항상 값이 있다고 가정하고 get!

 

 

이 외에도 filter, map, ifPresent 등 다양한 메서드들을 지원하고 있다. 앞선 기본 메서드로 처리가 힘들다면 API 문서를 참조해 문제를 해결해줄 수 있는 메서드가 있는지 찾아보자. 만약 적합한 메서드를 찾지 못했다면 isPresent 메서드를 활용할 수 있다. isPresent 메서드는 가스레인지의 안전벨브 역할로, 옵셔널 값이 채워져있다면 true, 비어있다면 false를 리턴한다. 

 

자바 9 버전부터 지원하는 Optional.map 메서드를 사용해 원하는 타입을 갖는 Optional 객체로 변환할 수도 있다. 아래는 Integer 타입이 들어있는 최대값을 받아 String 타입으로 변환한다. 값이 없을 경우에 대해 "N/A" 값이 반환되도록 orElse 체인 메서드로 처리했다.

String res = max2(list).map(val -> Integer.toString(val)).orElse("N/A");

 

 

참고로 이를 지원하지 않는 자바 8 버전의 경우 아래와 같이 작성할 수 있다. isPresent로 필터링 후 get으로 빼온다.

streamOfOptionals
    .filter(Optional::isPresent)
    .map(Optional::get)

 

 

자바 9에서는 Optional 에 stream() 메서드가 추가되었다. Optional을 stream으로 변환해주는 어댑터다. 옵셔널에 값이 있으면 그 값을 원소로 담은 스트림으로 한단계 벗겨주는 것이다. 어찌됐든 이런 저런 메서드들을 많이 지원하니 찾아보는 것을 권장한다.

 


5. 반환값으로 옵셔널을 사용했을때의 단점

결합도를 낮추고 유연성과 코드의 깔끔함을 더해주지만 구조상 단점이 있다. 객체를 박싱하는 옵셔널 특성 상 박싱하고자 하는 객체가 많을수록 Optional 객체를 생성하고 박싱하는 비용이 발생한다. 언박싱할때도 비용이 발생하는 건 마찬가지이다. 때문에 리스트, 스트림, 배열 등에 사용할 요소들에 대해 별 생각없이 Optional 을 사용한다면 성능 측면에서 문제가 발생할 수 있다. 성능이 중요한 상황에서는 옵셔널을 사용하지 않는 것이 좋다.

 


6. 기본 타입을 담는 옵셔널도 있다.

int, long, double 과 같은 기본 타입에 대해 Optional 을 사용할 땐 Integer, Long, Double 과 같은 참조 타입으로 박싱하여 사용하지 말고 OptionalInt, OptionalLong, OptionalDouble 과 같은 옵셔널 타입을 사용하자. 박싱된 기본 타입을 담은 옵셔널을 반환하는 일은 없도록 해야한다.

 


7. 정리

반환 값이 없을 수도 있는 메서드라면 옵셔널 사용을 고려해야한다. 하지만 성능 저하가 뒤따르니, 성능에 민감한 메서드라면 null을 반환하거나 예외를 던지는 편이 나을 수 있다.

반응형
반응형

개요

메서드 시그니처를 신중히 설계하라. 개별 아이템으로 두기 애매한 API 설계 요령들을 정리한 아이템이다.


1. 메서드 이름을 신중히 짓자

메서드 명은 표준 명명 규칙을 따라야 한다. 아래는 점프 투 자바에서 제공한 명명 규칙이다.

https://wikidocs.net/1936

 

02-03 이름 짓는 규칙

자바 코드를 작성하면서 클래스, 메서드, 변수 등의 이름을 지을 때 개발자들이 가장 고민한다. 하지만 규칙을 알아 두면 부담을 크게 줄일 수 있다. 여러 사람이 프로그래밍할 때 …

wikidocs.net

 

첫째, 메서드 명은 동사로 한다.

둘째, 메서드 명은 소문자로 시작한다.

셋째, 카멜케이스 방식을 사용한다.

 

이 사항을 기본적으로 지키면서 이해하기 쉽고 일관되게 네이밍을 지어야 한다.

실제로 협업 간에는 변수, 클래스, 메서드 등에 대한 네이밍 컨벤션을 미리 정리해놓고 사용하기도 한다. 


2. 편의 메서드를 너무 많이 만들지 말자

편의메서드
편의를 위한 메서드로 클래스의 책임에 충실한 클래스와는 거리가 멀다는 특징이 있다.
예를들어 게시글을 CRUD 하는 BoardService 클래스에 max, min, extractString 과 같이 편의를 위한 메서드가 있다면 편의 메서드로 볼 수 있다.

 

메서드가 너무 많은 클래스는 유지보수가 어렵다. 인터페이스도 마찬가지이다. 아주 자주 쓰일 경우에만 별도의 약칭 메서드를 만들고, 그 외에는 편의 메서드를 만들지 않는게 좋다.

 


3. 매개변수 목록은 짧게 유지하자

매개변수는 4개 이하가 적당하다. 만약 긴 매개변수 목록을 유지해야한다면 아래 내용 적용을 고려해야한다.

 

첫째, 여러 메서드로 쪼갠다.

둘째, 매개변수 여러 개를 묶어주는 정적 멤버 클래스를 만들어 활용한다.

셋째, 빌더 패턴과 정적 멤버클래스를 함께 사용한다. 매개변수가 많거나 일부를 생략해도 될때 좋다.

 


4. 매개변수의 타입으로는 클래스보다 인터페이스를 사용하자

예를들어 HashMap을 매개변수로 사용하는 대신 Map을 사용할 경우 HashMap 뿐만 아니라 TreeMap, ConcurrentHashMap 등 다양한 타입의 구현체 클래스를 전달할 수 있다.

인터페이스 대신 클래스를 사용하면 클라이언트에게 특정 구현체만 사용하도록 제한하는 꼴이다.

 


5. boolean 보다는 원소 2개짜리 열거 타입이 낫다.

* 단, 메서드 이름상 boolean을 받아야 의미가 더 명확할 때는 예외다.

 

열거 타입을 사용하면 코드를 읽고 쓰기가 쉽고, 확장하기도 용이하다.

다음은 화씨온도와 섭씨온도를 원소로 정의한 열거타입이다.

public enum TemperatureScale { FAHRENHEIT, CELSIUS }

 

온도계 클래스의 정적 팩터리 메서드가 이 열거 타입을 입력받아 적합한 온도 인스턴스를 생성해준다고 생각해보자. Thermometer.newInstance(true) 보다는 Thermometer.newInstance(TemperatureScale.CELSIUS) 가 명확하다. 만약, 나중에 캘빈 온도도 지원해야 한다면 열거타입에 추가만 하면 된다.

반응형

+ Recent posts