본문으로 건너뛰기

안동민 개발노트

본문 시작

스코프와 숫자 형변환

지역 변수의 생존 범위를 좁히고 자동·명시 형변환의 손실과 오버플로를 검사해 회원가입 계산 경계를 닫습니다.

지역 변수는 선언된 블록 안에서만 이름으로 접근할 수 있습니다.

숫자 값은 다른 타입으로 옮길 때 범위가 넓어지거나 정보가 잘릴 수 있습니다.

두 주제는 모두 값을 어디까지 안전하게 사용할 수 있는가라는 경계를 다룹니다.

변수의 코드 범위를 좁히고 숫자의 값 범위를 확인하면 잘못된 상태가 살아 있는 구간도 줄어듭니다.

HTML 다이어그램: /docs/java/ch2/ch2-10/1.html

지역 변수의 블록 범위

lab/OutOfScope.java
public final class OutOfScope {
    public static void main(String[] args) {
        boolean valid = true;

        if (valid) {
            int normalizedAge = 45;
            System.out.println("inside=" + normalizedAge);
        }

        System.out.println("outside=" + normalizedAge);
    }
}
error: cannot find symbol
        System.out.println("outside=" + normalizedAge);
                                          ^
  symbol:   variable normalizedAge

normalizedAgeif 블록 안에서 선언돼 닫는 중괄호까지 생존합니다.

바깥 main 블록에서 선언한 valid는 안쪽 블록에서도 읽을 수 있지만 반대 방향은 불가능합니다.

임시 변수를 필요 이상으로 넓게 두면 컴파일 오류는 사라져도 검토 범위가 커집니다.

int normalizedAge = 0;
if (valid) {
    normalizedAge = 45;
}

이제 if가 실행되지 않았을 때의 0도 이후 코드가 처리해야 합니다.

임시 값이 조건 안에서만 필요하다면 그 블록 안에 선언해 유효하지 않은 경로에서 이름 자체를 사용할 수 없게 합니다.


반복 변수의 수명

src/ScopedCounter.java
public final class ScopedCounter {
    public static void main(String[] args) {
        int total = 0;

        for (int memberNumber = 1; memberNumber <= 3; memberNumber++) {
            int age = memberNumber * 20;
            total += age;
            System.out.println(memberNumber + "=" + age);
        }

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

memberNumber와 각 회원의 age는 반복 안에서만 필요하고 total은 반복 결과를 밖에서 출력해야 하므로 main 블록에 둡니다.

변수마다 필요한 범위가 다릅니다.

모든 변수를 메서드 첫 줄에 선언하는 방식은 아직 값이 필요하지 않은 코드까지 이름과 초기 상태를 노출합니다.

HTML 다이어그램: /docs/java/ch2/ch2-10/2.html

자동 형변환

src/WideningConversions.java
public final class WideningConversions {
    public static void main(String[] args) {
        int age = 45;
        long longAge = age;
        double doubleAge = age;
        double fromLong = longAge;

        System.out.println("int=" + age);
        System.out.println("long=" + longAge);
        System.out.println("double=" + doubleAge);
        System.out.println("fromLong=" + fromLong);
    }
}
int=45
long=45
double=45.0
fromLong=45.0

int → long → double 방향은 더 넓은 표현 범위로 대입하므로 Java가 자동 형변환합니다.

개념적으로는 (long) age가 있지만 소스에 쓰지 않아도 됩니다.

다만 아주 큰 longdouble로 바꿀 때 모든 정수 자릿수가 정확히 보존된다는 뜻은 아닙니다.

double은 범위는 넓지만 정밀도가 제한됩니다.

서로 다른 타입의 산술은 넓은 타입 쪽으로 변환한 뒤 계산합니다.

double result = 3.0 / 2; // 2가 2.0으로 변환된 뒤 1.5

반대로 double result = 3 / 2;는 정수 계산 1이 먼저 끝난 뒤 1.0으로 대입됩니다.

변환 위치가 계산 결과를 바꿉니다.


명시적 형변환

src/NarrowingConversions.java
public final class NarrowingConversions {
    public static void main(String[] args) {
        double preciseAge = 42.9;
        int wholeAge = (int) preciseAge;

        System.out.println("before=" + preciseAge);
        System.out.println("after=" + wholeAge);
    }
}
before=42.9
after=42

명시적 (int) 형변환은 소수 부분을 반올림하지 않고 버립니다.

원본 preciseAge의 타입과 값은 42.9로 그대로이며 읽어 온 복사 값만 변환됩니다.

반올림이 요구라면 Math.round처럼 의도를 나타내는 연산을 별도로 사용합니다.

캐스트를 빼면 컴파일러가 잠재적 손실을 막습니다.

lab/LossyAssignment.java
public final class LossyAssignment {
    public static void main(String[] args) {
        double preciseAge = 42.9;
        int wholeAge = preciseAge;
        System.out.println(wholeAge);
    }
}
error: incompatible types: possible lossy conversion from double to int

명시적 캐스트는 안전을 보장하는 문법이 아니라 개발자가 손실 가능성을 인지했다고 컴파일러에 알리는 표식입니다.

실제 범위와 소수 처리 정책은 코드가 확인해야 합니다.

HTML 다이어그램: /docs/java/ch2/ch2-10/3.html

정수 축소와 오버플로

lab/NarrowingOverflow.java
public final class NarrowingOverflow {
    public static void main(String[] args) {
        long justOverMax = 2_147_483_648L;
        int narrowed = (int) justOverMax;

        System.out.println("source=" + justOverMax);
        System.out.println("narrowed=" + narrowed);
    }
}
source=2147483648
narrowed=-2147483648

예외가 발생하지 않고 하위 비트만 남아 Integer.MIN_VALUE가 됩니다.

시간이나 금액에서 이런 조용한 오버플로는 매우 위험합니다.

캐스트 전에 범위를 확인하거나 정확한 변환 API를 사용합니다.

src/CheckedNarrowing.java
public final class CheckedNarrowing {
    public static void main(String[] args) {
        long value = 2_147_483_648L;

        if (value < Integer.MIN_VALUE || value > Integer.MAX_VALUE) {
            System.out.println("outside int range=" + value);
            return;
        }

        int narrowed = Math.toIntExact(value);
        System.out.println(narrowed);
    }
}
outside int range=2147483648

Math.toIntExact도 범위를 넘으면 ArithmeticException을 던져 조용한 잘림을 막습니다.

앞 조건으로 사용자 메시지를 제공하거나 예외를 호출자에게 전달할 수 있습니다.


연산 중간값의 오버플로

src/MultiplicationWidth.java
public final class MultiplicationWidth {
    public static void main(String[] args) {
        int eventCount = 100_000;

        long wrongMillis = eventCount * 60_000;
        long correctMillis = eventCount * 60_000L;

        System.out.println("wrong=" + wrongMillis);
        System.out.println("correct=" + correctMillis);
    }
}
wrong=1705032704
correct=6000000000

첫 곱셈의 두 피연산자가 int라 결과도 int에서 먼저 오버플로한 뒤 잘못된 값을 long으로 넓힙니다.

둘째 식은 60_000L 때문에 eventCountlong으로 자동 변환하고 long 곱셈을 수행합니다.

결과 변수가 long이라는 사실만으로 중간 계산이 안전해지지 않습니다.

HTML 다이어그램: /docs/java/ch2/ch2-10/4.html

안전한 계산 타입

src/SignupRetentionMath.java
public final class SignupRetentionMath {
    public static void main(String[] args) {
        String email = args[0];
        int retentionDays = Integer.parseInt(args[1]);
        int maximumDays = Integer.parseInt(args[2]);

        if (email.isBlank() || retentionDays < 1 || maximumDays < retentionDays) {
            System.out.println("invalid retention policy");
            return;
        }

        long retentionMillis = retentionDays * 86_400_000L;
        double retentionRatio = (double) retentionDays / maximumDays;

        System.out.println("email=" + email);
        System.out.println("retentionMillis=" + retentionMillis);
        System.out.println("retentionRatio=" + retentionRatio);
    }
}
java -cp out SignupRetentionMath casting@example.com 90 120
email=casting@example.com
retentionMillis=7776000000
retentionRatio=0.75

email, retentionDays, maximumDaysmain에서 결과까지 필요합니다.

오류 메시지 외에 추가 값이 없는 유효성 실패 경로는 즉시 return해 이후 계산의 전제를 단순하게 합니다.

long 리터럴은 곱셈 전 범위를 넓히고 double 캐스트는 나눗셈 전 정밀도를 바꿉니다.


연습 문제

long 초 값을 받아 분 단위 double과, 범위 안일 때만 int 초를 출력하는 SecondConversion을 작성하세요.

입력은 9_000_000_000을 사용하며 잘린 int를 출력하면 안 됩니다.

해설 보기
src/SecondConversion.java
public final class SecondConversion {
    public static void main(String[] args) {
        long seconds = 9_000_000_000L;
        double minutes = seconds / 60.0;

        System.out.println("minutes=" + minutes);
        if (seconds >= Integer.MIN_VALUE && seconds <= Integer.MAX_VALUE) {
            int exactSeconds = Math.toIntExact(seconds);
            System.out.println("intSeconds=" + exactSeconds);
        } else {
            System.out.println("intSeconds=outside-range");
        }
    }
}
minutes=1.5E8
intSeconds=outside-range

60.0double 리터럴이라 나눗셈 전에 seconds가 double로 변환됩니다.

int 변환은 범위 조건이 참인 블록 안에서만 일어나며 변수도 그 블록 밖으로 노출되지 않습니다.