스코프와 숫자 형변환
지역 변수의 생존 범위를 좁히고 자동·명시 형변환의 손실과 오버플로를 검사해 회원가입 계산 경계를 닫습니다.
지역 변수는 선언된 블록 안에서만 이름으로 접근할 수 있습니다.
숫자 값은 다른 타입으로 옮길 때 범위가 넓어지거나 정보가 잘릴 수 있습니다.
두 주제는 모두 값을 어디까지 안전하게 사용할 수 있는가라는 경계를 다룹니다.
변수의 코드 범위를 좁히고 숫자의 값 범위를 확인하면 잘못된 상태가 살아 있는 구간도 줄어듭니다.
NESTED · LEXICAL SCOPE
지역 변수의 블록 수명과 접근 경계
바깥 블록의 변수는 안쪽에서 사용할 수 있지만 그 반대는 성립하지 않는다. 닫는 중괄호는 내부 지역 변수의 접근과 수명이 끝나는 경계다.
바깥에서 안쪽은 가능 안쪽 지역 변수는 닫는 중괄호를 넘지 못합니다.
- main ·
total - 이후 결과에도 필요하므로 바깥 main 블록에 둡니다. 안쪽 if와 for에서 읽고 갱신할 수 있습니다.
- if ·
normalizedAge - if 안에서만 접근할 수 있고, if의 닫는
}에서 접근 가능 범위와 수명이 끝납니다. - for 헤더 ·
memberNumber - for 헤더와 반복 본문에서만 접근할 수 있고, for의 닫는
}밖에서는 사용할 수 없습니다. - 반복 본문 ·
age - 각 회차의 반복 본문에서만 필요하며 그 본문의 닫는
}가 수명 종료 경계입니다.
- 이후까지 필요한 바깥 누적값
- 필요한 가장 좁은 내부 블록
변수는 실제로 필요한 가장 좁은 블록에 선언한다. 단, 반복이 끝난 뒤에도 필요한 누적 결과인 total은 바깥 블록에 남긴다.
지역 변수의 블록 범위
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 normalizedAgenormalizedAge는 if 블록 안에서 선언돼 닫는 중괄호까지 생존합니다.
바깥 main 블록에서 선언한 valid는 안쪽 블록에서도 읽을 수 있지만 반대 방향은 불가능합니다.
임시 변수를 필요 이상으로 넓게 두면 컴파일 오류는 사라져도 검토 범위가 커집니다.
int normalizedAge = 0;
if (valid) {
normalizedAge = 45;
}이제 if가 실행되지 않았을 때의 0도 이후 코드가 처리해야 합니다.
임시 값이 조건 안에서만 필요하다면 그 블록 안에 선언해 유효하지 않은 경로에서 이름 자체를 사용할 수 없게 합니다.
반복 변수의 수명
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 블록에 둡니다.
변수마다 필요한 범위가 다릅니다.
모든 변수를 메서드 첫 줄에 선언하는 방식은 아직 값이 필요하지 않은 코드까지 이름과 초기 상태를 노출합니다.
자동 형변환
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.0int → 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으로 대입됩니다.
변환 위치가 계산 결과를 바꿉니다.
명시적 형변환
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처럼 의도를 나타내는 연산을 별도로 사용합니다.
캐스트를 빼면 컴파일러가 잠재적 손실을 막습니다.
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명시적 캐스트는 안전을 보장하는 문법이 아니라 개발자가 손실 가능성을 인지했다고 컴파일러에 알리는 표식입니다.
실제 범위와 소수 처리 정책은 코드가 확인해야 합니다.
FLOWCHART · NUMERIC CONVERSION
숫자 변환의 허용·정밀도·실패 경계
자동 변환과 안전한 변환은 같은 뜻이 아니다. 변환마다 허용 방식, 보존되는 정보, 손실 방식, 범위 초과를 드러내는지까지 함께 확인한다.
허용 방식과 정보 보존을 분리 자동이라고 언제나 정확한 것은 아닙니다.
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를 사용한다.
정수 축소와 오버플로
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를 사용합니다.
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=2147483648Math.toIntExact도 범위를 넘으면 ArithmeticException을 던져 조용한 잘림을 막습니다.
앞 조건으로 사용자 메시지를 제공하거나 예외를 호출자에게 전달할 수 있습니다.
연산 중간값의 오버플로
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이라는 사실만으로 중간 계산이 안전해지지 않습니다.
FLOWCHART · PAIRED ARITHMETIC TRACE
피연산자 타입이 중간값을 결정한다
대입받는 변수의 타입이 아니라 연산 순간의 피연산자 타입이 중간값의 폭을 정한다. 두 계산은 리터럴의 L 접미사에서 처음 갈라진다.
첫 divergence · L 접미사 결과 변수 long보다 먼저 피연산자 타입이 갈립니다.
L 없음 · OVERFLOW TRACE
- 피연산자
int 100000 × int 60000 - 연산 폭
승격 없이 32-bit int로 곱합니다.
- 중간값
1705032704· int overflow로 이미 잘못된 값입니다. - long 저장
잘못된 int 값을 long으로 넓힐 뿐 overflow를 되돌리지 못합니다.
L 있음 · CORRECT TRACE
- 피연산자
int 100000 × long 60000L - 연산 폭
곱하기 전에 100000을 long으로 승격해 64-bit로 계산합니다.
- 중간값
6000000000· long 범위의 정확한 값입니다. - long 저장
정확한 long 중간값을 결과 변수에 그대로 저장합니다.
- 첫 divergence ·
60000L - 두 trace의 같은 단계
두 trace의 순서는 같지만 첫 피연산자 단계에서 폭이 달라진다. 결과를 받는 변수를 long으로 선언하는 것만으로는 이미 발생한 int overflow를 복구할 수 없다.
안전한 계산 타입
다음 예제는 이메일과 정수 모양의 두 기간 인자가 정확히 세 개 들어온다고 가정합니다.
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);
}
}email=casting@example.com
retentionMillis=7776000000
retentionRatio=0.75email, retentionDays, maximumDays는 main에서 결과까지 필요합니다.
오류 메시지 외에 추가 값이 없는 유효성 실패 경로는 즉시 return해 이후 계산의 전제를 단순하게 합니다.
long 리터럴은 곱셈 전 범위를 넓히고 double 캐스트는 나눗셈 전 정밀도를 바꿉니다.
연습 문제
long 초 값을 받아 분 단위 double과, 범위 안일 때만 int 초를 출력하는 SecondConversion을 작성하세요.
입력은 9_000_000_000을 사용하며 잘린 int를 출력하면 안 됩니다.
해설 보기
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-range60.0이 double 리터럴이라 나눗셈 전에 seconds가 double로 변환됩니다.
int 변환은 범위 조건이 참인 블록 안에서만 일어나며 변수도 그 블록 밖으로 노출되지 않습니다.