안동민 개발노트

본문 시작

스코프와 숫자 형변환

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

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

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

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

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

main, if, for와 반복 본문의 포함 관계에 따른 지역 변수 접근 및 수명 경계

NESTED · LEXICAL SCOPE

지역 변수의 블록 수명과 접근 경계

바깥 블록의 변수는 안쪽에서 사용할 수 있지만 그 반대는 성립하지 않는다. 닫는 중괄호는 내부 지역 변수의 접근과 수명이 끝나는 경계다.

지역 변수의 블록 수명과 접근 경계 main 블록 안의 total은 if와 for에서도 읽고 갱신할 수 있다. if의 normalizedAge, for 헤더의 memberNumber, 반복 본문의 age는 각 안쪽 블록을 닫는 중괄호 밖에서 접근할 수 없다. 이후 결과에 필요한 누적값은 바깥에, 나머지는 필요한 가장 좁은 블록에 둔다. MAIN BLOCK · OUTER SCOPE int total 안쪽에서 읽기·갱신 가능 IF BLOCK 바깥에서 들어옴 total += normalizedAge 이 블록에서만 선언 int normalizedAge } normalizedAge 수명 종료 FOR HEADER + BLOCK int memberNumber for 전체가 접근 경계 LOOP BODY int age } 각 반복의 age 수명 종료 } memberNumber 수명 종료 DECLARE NARROW · KEEP total OUTER ONLY BECAUSE THE RESULT SURVIVES

바깥에서 안쪽은 가능 안쪽 지역 변수는 닫는 중괄호를 넘지 못합니다.

main · total
이후 결과에도 필요하므로 바깥 main 블록에 둡니다. 안쪽 if와 for에서 읽고 갱신할 수 있습니다.
if · normalizedAge
if 안에서만 접근할 수 있고, if의 닫는 }에서 접근 가능 범위와 수명이 끝납니다.
for 헤더 · memberNumber
for 헤더와 반복 본문에서만 접근할 수 있고, for의 닫는 } 밖에서는 사용할 수 없습니다.
반복 본문 · age
각 회차의 반복 본문에서만 필요하며 그 본문의 닫는 }가 수명 종료 경계입니다.
  • 이후까지 필요한 바깥 누적값
  • 필요한 가장 좁은 내부 블록

변수는 실제로 필요한 가장 좁은 블록에 선언한다. 단, 반복이 끝난 뒤에도 필요한 누적 결과인 total은 바깥 블록에 남긴다.


지역 변수의 블록 범위

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

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

바깥 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 블록에 둡니다.

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

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


자동 형변환

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가 있지만 소스에 쓰지 않아도 됩니다.

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

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) 형변환은 소수 부분을 반올림하지 않고 0 방향으로 절삭합니다.

원본 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

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

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

Java 숫자 widening, narrowing, 정밀도 손실과 checked narrowing의 경계

FLOWCHART · NUMERIC CONVERSION

숫자 변환의 허용·정밀도·실패 경계

자동 변환과 안전한 변환은 같은 뜻이 아니다. 변환마다 허용 방식, 보존되는 정보, 손실 방식, 범위 초과를 드러내는지까지 함께 확인한다.

숫자 변환의 허용·정밀도·실패 경계 int에서 long과 double로 가는 widening은 자동이며 int 값은 정확하다. long에서 double도 자동이지만 큰 정수의 정밀도를 잃을 수 있다. 예시의 double 값 3.9를 int로 바꾸면 0 방향으로 절삭되어 3이다. long에서 int는 하위 비트만 남기는 명시적 캐스트다. Math.toIntExact는 int 범위를 넘는 long 값을 ArithmeticException으로 드러낸다. int 32-bit 정수 long 64-bit 정수 double 부동소수점 자동 · int 정확 자동 · int 정확 자동 · 큰 long은 정밀도 손실 가능 (int) largeLong 하위 비트만 남을 수 있음 명시적 · 손실 허용 (int) 3.9 → 3 0 방향으로 절삭 명시적 · 반올림 아님 명시적 cast 범위 검사 없음 명시적 cast 소수 정보 손실 checked narrowing 범위 초과를 숨기지 않음 Math.toIntExact(long) 범위를 검사해 int로 변환 범위 안 · int 반환 값 보존 범위 밖 · 실패 ArithmeticException 범위 안 범위 밖

허용 방식과 정보 보존을 분리 자동이라고 언제나 정확한 것은 아닙니다.

int → long
자동 widening이며 모든 int 값을 정확히 표현합니다.
int → double
자동 widening이며 int 범위의 정수 값은 정확히 표현합니다.
long → double
자동 변환이지만 큰 long 정수는 정밀도를 잃을 수 있습니다.
double → int
명시적 cast가 필요합니다. 예시의 3.9는 반올림하지 않고 0 방향으로 절삭되어 3이 됩니다.
long → int
명시적 cast가 필요하며 범위를 벗어나면 하위 비트만 남을 수 있습니다.
Math.toIntExact(long)
checked narrowing입니다. int 범위 안이면 값을 반환하고, 범위 밖이면 ArithmeticException으로 실패를 드러냅니다.
  • 자동 또는 명시적 변환
  • 범위를 검사하는 checked narrowing
  • 범위 밖 예외

명시적 cast는 개발자가 정보 손실을 허용했다는 뜻일 뿐 값이 안전하다는 증명은 아니다. 손실을 허용할 수 없다면 범위 검사나 Math.toIntExact를 사용한다.


정수 축소와 오버플로

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 때문에 eventCount를 long으로 자동 변환하고 long 곱셈을 수행합니다.

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

L 접미사 유무에 따라 처음 갈라지는 int overflow와 long 계산의 쌍대 추적

FLOWCHART · PAIRED ARITHMETIC TRACE

피연산자 타입이 중간값을 결정한다

대입받는 변수의 타입이 아니라 연산 순간의 피연산자 타입이 중간값의 폭을 정한다. 두 계산은 리터럴의 L 접미사에서 처음 갈라진다.

피연산자 타입이 중간값을 결정한다 L 접미사가 없는 trace는 int 100000과 int 60000을 int로 먼저 곱해 overflow 값 1705032704를 만들고, 그 잘못된 값을 long에 저장한다. L 접미사가 있는 trace는 int 100000을 long으로 승격한 뒤 long 60000L과 곱해 정확한 6000000000을 만들고 long에 저장한다. 첫 차이는 결과 변수 long이 아니라 피연산자 리터럴의 L이다. L 없음 · OVERFLOW TRACE L 있음 · CORRECT TRACE 첫 divergence · L 접미사 1 · 피연산자 리터럴 int 100000 × int 60000 1 · 피연산자 리터럴 int 100000 × long 60000L 2 · 연산 전 타입 판정 승격 없음 · int 연산 RESULT WIDTH = 32 bit 2 · 연산 전 타입 판정 100000을 long으로 승격 RESULT WIDTH = 64 bit 3 · 중간 계산 1705032704 OVERFLOW · 이미 잘못된 값 3 · 중간 계산 6000000000 CORRECT · long 범위 4 · long 결과 변수에 저장 잘못된 int 값을 widening wrongMillis = 1705032704 4 · long 결과 변수에 저장 정확한 long 값을 그대로 저장 correctMillis = 6000000000 A LONG DESTINATION CANNOT REPAIR AN INT INTERMEDIATE VALUE

첫 divergence · L 접미사 결과 변수 long보다 먼저 피연산자 타입이 갈립니다.

L 없음 · OVERFLOW TRACE

  1. 피연산자

    int 100000 × int 60000

  2. 연산 폭

    승격 없이 32-bit int로 곱합니다.

  3. 중간값

    1705032704 · int overflow로 이미 잘못된 값입니다.

  4. long 저장

    잘못된 int 값을 long으로 넓힐 뿐 overflow를 되돌리지 못합니다.

L 있음 · CORRECT TRACE

  1. 피연산자

    int 100000 × long 60000L

  2. 연산 폭

    곱하기 전에 100000을 long으로 승격해 64-bit로 계산합니다.

  3. 중간값

    6000000000 · long 범위의 정확한 값입니다.

  4. long 저장

    정확한 long 중간값을 결과 변수에 그대로 저장합니다.

  • 첫 divergence · 60000L
  • 두 trace의 같은 단계

두 trace의 순서는 같지만 첫 피연산자 단계에서 폭이 달라진다. 결과를 받는 변수를 long으로 선언하는 것만으로는 이미 발생한 int overflow를 복구할 수 없다.


안전한 계산 타입

다음 예제는 이메일과 정수 모양의 두 기간 인자가 정확히 세 개 들어온다고 가정합니다.

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 [email protected] 90 120
[email protected]
retentionMillis=7776000000
retentionRatio=0.75

email, retentionDays, maximumDays는 main에서 결과까지 필요합니다.

오류 메시지 외에 추가 값이 없는 유효성 실패 경로는 즉시 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.0이 double 리터럴이라 나눗셈 전에 seconds가 double로 변환됩니다.

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