본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
10장 : 래퍼·열거형·시간 API

래퍼와 null 허용 값

기본형의 메서드·null·제네릭 한계를 래퍼로 해결하고 박싱·언박싱·비교·파싱·성능 선택을 실제 실패에서 익힙니다.

기본형 int는 작고 빠르지만 객체가 아니므로 메서드를 가질 수 없고 null을 표현하지 못하며 객체 타입을 요구하는 제네릭 컬렉션에 직접 넣을 수 없습니다.

Integer 같은 래퍼 클래스는 기본형 값을 객체로 감싸 값 관련 기능과 “값 없음” 상태를 제공합니다.

편의가 늘어나는 만큼 null 언박싱과 참조 비교라는 새 위험도 생깁니다.


자동 언박싱과 null 실패

게시글에서 아직 집계하지 않은 조회 수를 null로 표현했습니다.

산술 연산을 시작하면 컴파일러가 Integer의 int 값을 꺼내려 하고, 대상 객체가 없어서 실패합니다.

lab/NullAutoUnboxingFailure.java
public final class NullAutoUnboxingFailure {
    public static void main(String[] args) {
        Integer measuredViewCount = null;
        int total = measuredViewCount + 10;
        System.out.println(total);
    }
}
실패 관찰
Exception in thread "main" java.lang.NullPointerException

오토 언박싱 문법이 null 안전성을 보장하지 않습니다.

null이 “미집계”라는 실제 상태라면 계산 전에 if로 분기합니다.

null이 허용되지 않는 필드라면 생성 경계에서 거부해 산술 코드에 불가능한 상태가 들어오지 않게 합니다.

값의 존재 여부를 타입으로 표현하는 방법은 뒤의 선택 결과 장에서 배웁니다.


직접 만든 래퍼 클래스

값과 행동을 한 객체에 묶으면 비교 메서드를 값 가까이에 둘 수 있습니다.

MyViewCount는 int를 감싸고 음수 검증과 비교를 책임집니다.

src/MyViewCountWrapper.java
public final class MyViewCountWrapper {
    public static void main(String[] args) {
        MyViewCount viewCount = new MyViewCount(40);
        System.out.println("vs30=" + viewCount.compareTo(30));
        System.out.println("vs40=" + viewCount.compareTo(40));
        System.out.println("vs50=" + viewCount.compareTo(50));
    }

    private static final class MyViewCount {
        private final int value;

        MyViewCount(int value) {
            if (value < 0) throw new IllegalArgumentException("viewCount");
            this.value = value;
        }

        int compareTo(int other) {
            return Integer.compare(value, other);
        }
    }
}
vs30=1
vs40=0
vs50=-1

표준 래퍼도 비슷하게 값을 감싸고 compareTo, equals, toString 같은 객체 계약을 제공합니다.

정수 계열 객체 타입은 Byte·Short·Integer·Long이고, 실수 계열에는 Float·Double이 있습니다.

문자와 논리는 각각 Character와 Boolean으로 감쌉니다.

도메인 규칙까지 필요하면 Integer 대신 MyViewCount 같은 별도 값 타입이 더 명확합니다.


명시적 박싱과 언박싱

기본형을 래퍼 객체로 만드는 일을 박싱, 래퍼에서 기본 값을 꺼내는 일을 언박싱이라 합니다.

valueOfintValue를 직접 사용할 수 있고, 대입 문맥에서는 컴파일러가 오토 박싱·오토 언박싱 코드를 넣습니다.

src/BoxingUnboxingBoard.java
public final class BoxingUnboxingBoard {
    public static void main(String[] args) {
        int primitive = 40;
        Integer explicitBox = Integer.valueOf(primitive);
        int explicitUnbox = explicitBox.intValue();

        Integer autoBox = 50;
        int autoUnbox = autoBox;
        Integer[] viewCounts = {explicitBox, autoBox};

        int sum = 0;
        for (Integer viewCount : viewCounts) {
            sum += viewCount.intValue();
        }

        System.out.println("explicit=" + explicitUnbox);
        System.out.println("auto=" + autoUnbox);
        System.out.println("sum=" + sum);
    }
}
explicit=40
auto=50
sum=90

Integer[]는 래퍼 객체 참조를 담는 배열입니다.

향상된 for에서 viewCount.intValue()로 값을 명시적으로 꺼냈습니다.

같은 위치에 sum += viewCount라고 쓰면 오토 언박싱이 일어납니다.

값이 반드시 존재하고 수치 계산만 한다면 int[]가 더 단순합니다.


래퍼 값의 동등성 비교

Integer도 객체이므로 ==는 참조 동일성을 비교합니다.

일부 작은 정수는 valueOf 캐시 덕분에 같은 참조가 나올 수 있어 == 버그가 입력 값에 따라 숨어 버립니다.

src/IntegerComparisonBoundary.java
public final class IntegerComparisonBoundary {
    public static void main(String[] args) {
        Integer smallA = 100;
        Integer smallB = 100;
        Integer largeA = 1_000;
        Integer largeB = 1_000;

        System.out.println("small-identity=" + (smallA == smallB));
        System.out.println("large-identity=" + (largeA == largeB));
        System.out.println("large-value=" + largeA.equals(largeB));
        System.out.println("compare=" + Integer.compare(largeA, largeB));
    }
}
small-identity=true
large-identity=false
large-value=true
compare=0

캐시 범위에 기대지 않고 값 동등성은 equals, 순서는 compare 또는 compareTo로 판단합니다.

한쪽이 null일 수 있다면 Objects.equals를 사용하고 정렬에서는 null 우선순위를 Comparator에 명시합니다.


문자열 파싱과 범위 검증

Integer.parseInt는 기본형 int를, Integer.valueOf(String)은 Integer를 반환합니다.

숫자 형식이 아니거나 int 범위를 넘으면 NumberFormatException이 발생합니다.

사용자 입력이라면 메시지와 재입력 정책을 입력 계층에 둡니다.

src/WrapperParsingBoundary.java
public final class WrapperParsingBoundary {
    public static void main(String[] args) {
        int first = Integer.parseInt("40");
        Integer second = Integer.valueOf("50");
        double ratio = Double.parseDouble("1.25");

        System.out.println("total=" + (first + second));
        System.out.println("ratio=" + ratio);

        try {
            Integer.parseInt("forty");
        } catch (NumberFormatException error) {
            System.out.println("rejected=forty");
        }
    }
}
total=90
ratio=1.25
rejected=forty

파싱 성공이 도메인 유효성을 뜻하지는 않습니다.

-40은 int로 파싱되지만 조회 수로는 거부할 수 있습니다.

형식 변환과 업무 검증을 단계별로 나누면 오류 메시지가 정확해집니다.


기본형과 래퍼의 선택

  • 값이 반드시 있고 수치 연산 중심이면 기본형을 우선합니다.
  • 객체 참조만 받는 API에 전달해야 하면 래퍼가 필요합니다.
  • 값 없음이 실제 상태라면 null 처리 계약을 명시합니다.
  • 고빈도 대량 계산은 기본형 배열을 우선 검토합니다.
  • 유지보수성과 의미가 중요하면 도메인 값 타입을 고려합니다.

일반 애플리케이션에서 몇 번의 박싱 비용보다 null 오류와 잘못된 비교가 더 큰 문제입니다.

CPU 집중 루프가 병목으로 확인된 때만 프로파일링 결과를 근거로 기본형 최적화를 적용합니다.


연습 문제

Integer[]에 null이 포함되어 있습니다.

null은 미측정 값으로 건너뛰고 나머지 합계와 측정 개수를 출력하세요.

언박싱 전에 null을 처리해야 합니다.

해설 보기
src/NullableViewCountExercise.java
public final class NullableViewCountExercise {
    public static void main(String[] args) {
        Integer[] measured = {40, null, 50};

        int total = 0;
        int count = 0;
        for (Integer value : measured) {
            if (value == null) continue;
            total += value;
            count++;
        }

        System.out.println("count=" + count);
        System.out.println("total=" + total);
    }
}
count=2
total=90

null과 0은 의미가 다릅니다.

조회 0회를 실제 집계값으로 허용할지 별도 규칙으로 정하고, 상태 종류가 더 늘면 enum이나 전용 결과 타입이 낫습니다.