변수 스코프와 숫자 형변환
변수를 쓸 수 있는 범위인 스코프와 변수를 필요한 블록에만 두는 습관을 익히고, 숫자 타입을 바꿀 때 값이 잘리거나 넘치는 경우와 이를 막는 방법을 배웁니다.
앞 글에서 for 머리에 선언한 카운터는 반복이 끝나면 쓸 수 없다는 것을 보았습니다. 변수 이름마다 쓸 수 있는 코드의 범위가 정해져 있다는 뜻입니다. 값에도 비슷한 문제가 있습니다. 산술 연산과 문자열 결합에서는 int끼리 나누면 소수 부분이 사라지는 것을 보았고, int에 담을 수 없을 만큼 큰 값을 만들면 오류 없이 엉뚱한 값이 되기도 합니다.
이 글은 두 가지 질문을 다룹니다. 하나는 '이 변수 이름을 코드의 어디까지 쓸 수 있는가'이고, 다른 하나는 '이 값을 다른 숫자 타입으로 옮겨도 값이 그대로 남는가'입니다. 앞의 질문에서는 변수를 필요한 곳에만 두는 습관을 익히고, 뒤의 질문에서는 값이 잘리거나 넘치는 곳을 미리 알아보고 막는 방법을 배웁니다.
변수는 선언한 블록 안에서만 쓸 수 있다
다음 프로그램은 if 안에서 선언한 변수를 if 바깥에서 출력하려고 합니다. normalizedAge를 선언한 줄은 if의 중괄호 안에 있고, 마지막 println은 그 닫는 중괄호 뒤에 있습니다. 이 프로그램이 컴파일될지 먼저 예상해 보세요.
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);
}
}javac -d out lab/OutOfScope.java로 컴파일하면 다음 오류가 납니다. 컴파일에 실패하면 .class 파일이 만들어지지 않으므로, if 안의 inside=45도 출력될 기회가 없습니다.
lab/OutOfScope.java:10: error: cannot find symbol
System.out.println("outside=" + normalizedAge);
^
symbol: variable normalizedAge
location: class OutOfScope
1 error중괄호로 묶은 문장 묶음을 블록(block)이라고 하고, 메서드나 블록 안에서 선언한 변수를 지역 변수(local variable)라고 합니다. 지역 변수는 선언한 줄부터 그 변수를 감싼 블록의 닫는 중괄호까지만 이름으로 쓸 수 있는데, 이 범위를 스코프(scope)라고 합니다. normalizedAge는 if 블록 안에서 선언했으므로 if의 닫는 중괄호에서 스코프가 끝납니다. 그래서 그 뒤인 10번째 줄에서는 컴파일러가 이 이름을 찾지 못하고(cannot find symbol) 컴파일을 멈춥니다. 오류 메시지의 symbol: 줄이 찾지 못한 이름을 알려 줍니다.
반대 방향은 됩니다. valid는 바깥의 main 블록에서 선언했으므로, 그 안에 들어 있는 if 블록에서도 읽을 수 있습니다. 즉 안쪽 블록에서는 바깥 블록의 변수를 쓸 수 있지만, 바깥 블록에서는 안쪽 블록의 변수를 쓸 수 없습니다. 앞 글에서 for 머리의 카운터가 반복 밖에서 보이지 않았던 것도 같은 규칙입니다. for 머리에서 선언한 변수는 그 for 문 전체가 스코프입니다.
스코프는 소스 코드에서 이름을 쓸 수 있는 범위이고, 컴파일러가 컴파일할 때 검사합니다. 닫는 중괄호를 지나는 순간 그 값이 메모리에서 지워진다는 뜻은 아닙니다. 프로그램이 실행될 때 값이 메모리의 어디에 머무는지는 JVM 메모리 구조: 스택, 힙, 메서드 영역에서 따로 다룹니다.
같은 이름을 다시 선언할 수 있을까
안쪽 블록에서 바깥 지역 변수와 같은 이름으로 변수를 또 선언하면 컴파일 오류가 납니다. 다음 코드는 main에서 선언한 age의 스코프가 아직 끝나지 않은 for 본문 안에서 int age를 한 번 더 선언합니다.
public final class Redeclare {
public static void main(String[] args) {
int age = 30;
for (int i = 0; i < 2; i++) {
int age = i;
}
}
}lab/Redeclare.java:5: error: variable age is already defined in method main(String[])
int age = i;
^
1 error안쪽 블록에서도 바깥의 age가 보이므로, 같은 이름을 하나 더 만들면 age가 어느 변수를 가리키는지 헷갈리게 됩니다. 그래서 Java는 지역 변수끼리 이렇게 이름이 겹치는 것을 허용하지 않습니다. 반면 서로 겹치지 않는 두 블록에서는 같은 이름을 각각 써도 됩니다. 한 for가 끝난 뒤 또 다른 for (int i = 0; ...)를 쓰는 일이 흔한 것도, 첫 번째 i의 스코프가 이미 끝났기 때문입니다.
변수는 필요한 블록 안에 둔다
앞의 컴파일 오류를 없애는 가장 쉬운 방법은 변수 선언을 바깥으로 옮기는 것입니다. 선언을 if 앞으로 끌어올리면 마지막 println도 그 변수의 스코프 안에 들어갑니다. 다만 선언하는 시점에는 아직 나이를 모르므로, 아래 코드는 일단 0을 넣어 둡니다.
int normalizedAge = 0;
if (valid) {
normalizedAge = 45;
}
System.out.println("outside=" + normalizedAge);컴파일은 되지만 새로운 문제가 생깁니다. valid가 거짓이라 if가 실행되지 않은 경우에도 normalizedAge는 0을 들고 이후 코드로 흘러갑니다. 0은 '아직 값이 없다'는 뜻으로 넣은 임시값이지만, 이후 코드는 그 사실을 모르고 0살로 계산할 수 있습니다.
초깃값 없이 int normalizedAge;로만 선언하면 Java가 이 문제를 직접 잡아 줍니다. if가 실행되지 않은 경로에서는 값이 정해지지 않은 변수를 출력하게 되므로, 컴파일러가 variable normalizedAge might not have been initialized 오류를 내기 때문입니다. Java는 지역 변수에 값이 확실히 대입되었다고 판단할 수 없으면 그 변수를 읽지 못하게 합니다. 이 오류를 없애려고 의미 없는 0을 넣는 것은 컴파일러가 알려 준 문제를 덮어 버리는 셈입니다.
그래서 값이 if 안에서만 의미가 있다면 변수도 if 블록 안에 선언합니다. 그러면 값이 없는 경로에서는 이름 자체를 쓸 수 없으니, 잘못된 값을 읽는 실수가 컴파일 단계에서 막힙니다. 일반적으로 변수는 실제로 필요한 가장 좁은 블록에서, 처음 값을 정할 수 있는 곳에 선언하는 것이 좋습니다. 반복문에서 이 원칙을 적용하면 다음과 같습니다.
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);
}
}1=20
2=40
3=60
total=120이 프로그램의 세 변수는 필요한 범위가 서로 다릅니다. 반복을 세는 데만 쓰는 memberNumber는 for 머리에, 회원 한 명의 나이인 age는 한 회차 안에서만 쓰므로 반복 본문에 두었습니다. 반면 total은 반복이 끝난 뒤 출력해야 하므로 main 블록에 둡니다. 반복 본문의 age는 회차마다 새로 만들어지는 변수라서, 첫 회차의 20이 두 번째 회차로 이어지지 않습니다. 회차를 넘어 값을 쌓아야 하는 total만 반복 바깥에 있어야 하는 이유입니다.
오래된 코드에서는 메서드 첫머리에 변수를 모두 선언해 두는 방식도 볼 수 있습니다. 그렇게 하면 아직 그 값이 필요 없는 코드에서도 이름이 보이므로, 변수가 어디서 바뀌는지 확인하려면 메서드 전체를 읽어야 합니다. 변수를 좁은 블록에 두면 그 변수를 읽고 바꾸는 코드도 그 블록 안으로 한정됩니다. 아래 그림은 main, if, for, 반복 본문이 겹겹이 놓인 구조에서 각 변수를 어디까지 쓸 수 있는지 보여 줍니다.
NESTED · LEXICAL SCOPE
지역 변수 이름의 접근 범위
바깥 블록의 변수는 안쪽에서 사용할 수 있지만 그 반대는 성립하지 않는다. 닫는 중괄호는 내부 지역 변수의 접근과 이름으로 접근할 수 있는 범위가 끝나는 경계입니다.
바깥에서 안쪽은 가능 안쪽 지역 변수는 닫는 중괄호를 넘지 못합니다.
- main ·
total - 이후 결과에도 필요하므로 바깥 main 블록에 둡니다. 안쪽 if와 for에서 읽고 갱신할 수 있습니다.
- if ·
normalizedAge - if 안에서만 접근할 수 있고, if의 닫는
}에서 이름으로 접근할 수 있는 범위가 끝납니다. - for 헤더 ·
memberNumber - for 헤더와 반복 본문에서만 접근할 수 있고, for의 닫는
}밖에서는 사용할 수 없습니다. - 반복 본문 ·
age - 각 회차의 반복 본문에서만 필요하며 그 본문의 닫는
}가 접근 종료 경계입니다.
- 이후까지 필요한 바깥 누적값
- 필요한 가장 좁은 내부 블록
변수는 실제로 필요한 가장 좁은 블록에 선언한다. 단, 반복이 끝난 뒤에도 필요한 누적 결과인 total은 바깥 블록에 남긴다.
숫자를 다른 타입으로 바꾸기
이제 두 번째 질문으로 넘어가겠습니다. 값을 한 타입에서 다른 타입으로 바꾸는 것을 형변환(type conversion)이라고 합니다. 기본 타입과 리터럴에서 본 것처럼 숫자 타입마다 담을 수 있는 범위가 다르므로, 형변환에서는 값이 그대로 옮겨지는지가 중요합니다. 범위가 넓은 타입으로 바꿀 때는 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.0long longAge = age;처럼 int 값을 long 변수에 대입하면 Java가 자동으로 long으로 바꿉니다. long은 int의 모든 값을 담을 수 있으니 바꿔도 잃는 것이 없기 때문입니다. 소스에는 없지만 long longAge = (long) age;라고 쓴 것과 같습니다. 이처럼 표현 범위가 넓은 타입으로 바꾸는 것을 확대 변환(widening conversion)이라고 합니다. 숫자 타입에서는 byte → short → int → long → float → double 순서에서 오른쪽으로 가는 변환과, char를 int 이상으로 바꾸는 변환이 자동으로 일어납니다. 출력의 45.0은 값은 같고 타입만 double이 되었다는 표시입니다.
자동 형변환이 늘 정확하지는 않다
자동 형변환은 컴파일러가 허락한다는 뜻이지, 값이 언제나 정확히 보존된다는 뜻은 아닙니다. long을 double로 바꿀 때가 대표적입니다.
public final class LongPrecisionLoss {
public static void main(String[] args) {
long original = 9_007_199_254_740_993L;
double widened = original;
long restored = (long) widened;
System.out.println("original=" + original);
System.out.println("restored=" + restored);
System.out.println("equal=" + (original == restored));
}
}original=9007199254740993
restored=9007199254740992
equal=falsedouble은 아주 큰 수까지 표현할 수 있지만, 저장할 수 있는 유효 숫자의 자릿수는 정해져 있습니다. 그래서 2의 53제곱인 9,007,199,254,740,992를 넘어서면 연속된 정수를 모두 표현하지 못하고, 표현할 수 있는 가까운 값으로 반올림합니다. 9,007,199,254,740,993은 2의 53제곱보다 1 큰 수라서 double로 바뀌는 순간 9,007,199,254,740,992로 반올림되었고, long으로 되돌려도 사라진 1은 돌아오지 않습니다. 오류도 경고도 없이 값만 달라졌다는 점이 문제입니다. 회원 번호처럼 한 자리라도 틀리면 안 되는 정수를 double에 담으면 안 되는 이유입니다.
더 깊이: 정밀도를 잃을 수 있는 확대 변환
Java 언어 명세(JLS)는 확대 변환 가운데 int → float, long → float, long → double 세 가지가 정밀도를 잃을 수 있다고 밝힙니다(JLS 5.1.2). double의 유효 숫자는 이진수 53자리, float는 24자리뿐이기 때문입니다. 그래서 float는 2의 24제곱인 16,777,216을 넘으면 연속된 정수를 모두 담지 못합니다. 예를 들어 float f = 16_777_217;은 컴파일 오류 없이 16,777,216이 됩니다. 명세는 이런 확대 변환이 정밀도를 잃더라도 실행 중 오류는 일으키지 않는다고 정해 두었으므로, 손실은 직접 확인하지 않으면 드러나지 않습니다.
서로 다른 타입끼리 계산하면
계산할 때도 자동 형변환이 일어납니다. 서로 다른 숫자 타입끼리 계산하면 Java는 먼저 좁은 쪽을 넓은 쪽 타입으로 바꾼 뒤 계산하는데, 이를 숫자 승격(numeric promotion)이라고 합니다. 다음 네 줄은 모두 3을 2로 나누지만 결과가 둘로 갈립니다.
double a = 3.0 / 2; // 2가 2.0으로 바뀐 뒤 실수 나눗셈: 1.5
double b = 3 / 2; // 정수 나눗셈 1이 먼저 끝나고 대입: 1.0
double c = (double) 3 / 2; // 3을 3.0으로 바꾼 뒤 실수 나눗셈: 1.5
double d = (double) (3 / 2); // 정수 나눗셈 1을 바꿈: 1.0a에서는 int 2가 double 2.0으로 승격되어 실수 나눗셈이 일어납니다. b에서는 두 피연산자가 모두 int이므로 정수 나눗셈이 먼저 끝나 1이 되고, 그 결과를 double 변수에 대입할 때 1.0으로 바뀝니다. 대입받는 변수가 double이라는 사실이 이미 끝난 계산을 되돌리지는 못합니다.
c와 d의 차이는 형변환 연산자가 어디까지 적용되느냐입니다. (double)처럼 괄호 안에 타입을 적는 형변환 연산자는 바로 뒤의 피연산자 하나에, 나눗셈보다 먼저 적용됩니다. 그래서 c는 3만 double로 바꾼 뒤 나누고, d는 괄호 안의 정수 나눗셈을 먼저 끝낸 뒤 그 결과를 바꿉니다. 산술 연산과 문자열 결합에서 평균을 구할 때 '나눗셈 전에' 타입을 바꿔야 한다고 한 이유가 이것입니다.
범위가 좁은 타입으로: 명시적 형변환
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=42double을 int로 바꾸는 것처럼 범위가 좁은 타입으로 바꾸는 것을 축소 변환(narrowing conversion)이라고 합니다. 축소 변환은 값을 잃을 수 있으므로 Java가 자동으로 해 주지 않고, (int)처럼 괄호 안에 바꿀 타입을 직접 적어야 합니다. 이렇게 적는 것을 캐스트(cast)라고 부릅니다. (int) 42.9는 반올림하지 않고 소수 부분을 버려 42가 됩니다. 정확히는 0에 가까운 쪽으로 버리므로, 음수인 (int) -42.9는 -43이 아니라 -42입니다.
출력의 첫 줄 before=42.9는 형변환을 한 뒤에 출력했는데도 원래 값 그대로입니다. 형변환은 변수 preciseAge를 바꾸는 것이 아니라, 거기서 읽어 온 값을 바탕으로 새 값을 만들기 때문입니다. 반올림이 필요하다면 소수 부분을 버리는 캐스트 대신 Math.round를 씁니다. Math.round(42.9)는 43입니다. 다만 double을 받는 Math.round는 long을 돌려주므로, int 변수에 담으려면 (int) Math.round(42.9)처럼 한 번 더 바꿔야 합니다.
캐스트를 빼면 어떻게 되는지 보겠습니다.
public final class LossyAssignment {
public static void main(String[] args) {
double preciseAge = 42.9;
int wholeAge = preciseAge;
System.out.println(wholeAge);
}
}lab/LossyAssignment.java:4: error: incompatible types: possible lossy conversion from double to int
int wholeAge = preciseAge;
^
1 error컴파일러가 '값을 잃을 수 있는 변환(possible lossy conversion)'이라며 컴파일을 멈췄습니다. 그러니 캐스트는 변환을 안전하게 만드는 장치가 아니라, 값을 잃을 수 있다는 것을 알고 바꾼다고 컴파일러에게 밝히는 표시입니다. 소수 부분을 버려도 되는지, 값이 범위 안에 있는지는 캐스트를 쓰는 코드가 직접 판단해야 합니다.
복합 대입에 숨은 형변환
증감·비교·논리·대입 연산자에서 total += 30이 total = total + 30과 대체로 같지만 완전히 같지는 않다고 했습니다. 그 차이가 바로 형변환입니다. += 같은 복합 대입 연산자는 계산 결과를 왼쪽 변수의 타입으로 자동 캐스트한 뒤 대입합니다.
public final class CompoundAssignmentCast {
public static void main(String[] args) {
byte small = 127;
small += 1;
int total = 10;
total += 3.7;
System.out.println("small=" + small);
System.out.println("total=" + total);
}
}small=-128
total=13먼저 byte small = 127;에 캐스트가 없는 이유부터 짚겠습니다. 정수 리터럴을 바로 대입할 때는 값이 byte 범위 안에 있다는 것을 컴파일러가 알 수 있으므로 캐스트 없이 허용됩니다. small += 1은 small = (byte) (small + 1)처럼 동작합니다. small + 1은 int 128인데, byte는 -128부터 127까지만 담을 수 있으므로 이 값을 byte로 캐스트하면 -128이 됩니다. 범위를 넘는 값을 좁힐 때 이런 일이 생기는 이유는 다음 절에서 봅니다. total += 3.7도 total = (int) (total + 3.7)이므로 13.7의 소수 부분을 버리고 13이 됩니다.
두 줄 모두 캐스트를 적지 않았는데도 컴파일 오류가 나지 않았다는 점이 함정입니다. 같은 계산을 small = small + 1;로 쓰면 possible lossy conversion from int to byte 컴파일 오류가 납니다. Java는 byte, short, char 값을 계산할 때 먼저 int로 승격하므로 small + 1의 결과가 int이고, 그 결과를 캐스트 없이 byte에 넣으려 했기 때문입니다. JDK 20부터는 javac에 -Xlint:lossy-conversions 옵션을 주면 이런 복합 대입에 경고를 표시하므로, 의심스러운 코드를 찾을 때 쓸 수 있습니다.
범위를 넘는 정수를 좁히면
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=-21474836482,147,483,648은 int의 최댓값 2,147,483,647보다 1 큽니다. 이 값을 (int)로 바꾸면 오류가 나는 대신 int의 최솟값인 -2,147,483,648, 즉 Integer.MIN_VALUE가 됩니다. long을 int로 바꿀 때 Java는 범위를 검사하지 않고, long을 이루는 64개의 비트 가운데 아래쪽 32개만 남기기 때문입니다. 십진수로 보면, 담을 수 없는 값에서 4,294,967,296(2의 32제곱)을 필요한 만큼 빼거나 더해 int 범위 안으로 접어 넣은 값과 같습니다.
long 값 | (int)로 바꾼 결과 | 계산 |
|---|---|---|
| 2,147,483,647 | 2147483647 | 범위 안이라 그대로 |
| 2,147,483,648 | -2147483648 | 2,147,483,648 − 4,294,967,296 |
| 3,000,000,000 | -1294967296 | 3,000,000,000 − 4,294,967,296 |
| 4,294,967,296 | 0 | 4,294,967,296 − 4,294,967,296 |
문제는 이 변환이 아무 신호도 남기지 않는다는 것입니다. 예외도 경고도 없이 음수가 된 값이 다음 계산으로 넘어가므로, 시간이나 금액처럼 큰 값을 다루는 코드에서는 엉뚱한 결과가 한참 뒤에야 드러납니다. 그래서 좁히기 전에 값이 범위 안에 있는지 확인해야 합니다.
범위를 확인하고 좁히기
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=2147483648value가 Integer.MIN_VALUE보다 작거나 Integer.MAX_VALUE보다 크면 메시지를 출력하고 return으로 main을 끝냅니다. 2,147,483,648은 최댓값보다 크므로 메시지 한 줄만 출력되었습니다. 검사를 통과한 값이라면 그 아래에서 (int) value로 바꿔도 결과는 같습니다. 그런데도 Math.toIntExact를 쓴 것은 한 겹 더 막아 두기 위해서입니다. 이 메서드가 어떻게 동작하는지는 다음 프로그램으로 확인할 수 있습니다.
public final class ExactNarrowing {
public static void main(String[] args) {
long inRange = 2_147_483_647L;
long outOfRange = 2_147_483_648L;
System.out.println(Math.toIntExact(inRange));
System.out.println(Math.toIntExact(outOfRange));
}
}2147483647
Exception in thread "main" java.lang.ArithmeticException: integer overflowMath.toIntExact는 long 값이 int 범위 안이면 그대로 돌려주고, 범위를 벗어나면 ArithmeticException이라는 실행 중 오류를 일으킵니다. 그래서 첫 호출은 2147483647을 출력했고, 두 번째 호출에서 프로그램이 멈췄습니다. 실제 출력에는 이 아래에 오류가 난 위치를 알려 주는 at으로 시작하는 줄들이 이어집니다. CheckedNarrowing에서도 나중에 누군가 위의 범위 검사를 지우거나 잘못 고치면, 잘린 값이 조용히 흘러가는 대신 이 오류가 납니다.
두 방법은 쓰임이 다릅니다. 사용자가 잘못 입력할 수 있는 값이라면 직접 검사해서 안내 메시지를 보여 주는 편이 낫습니다. 반대로 범위를 벗어나는 것이 프로그램의 버그라면 Math.toIntExact로 그 자리에서 멈추는 편이 원인을 찾기 쉽습니다. 지금까지 본 변환을 정리하면 다음 그림과 같습니다.
FLOWCHART · NUMERIC CONVERSION
숫자 변환의 허용·정밀도·실패 경계
자동 변환과 안전한 변환은 같은 뜻이 아니다. 변환마다 허용 방식, 보존되는 정보, 손실 방식, 범위 초과를 드러내는지까지 함께 확인한다.
허용 방식과 정보 보존을 분리 자동이라고 언제나 정확한 것은 아닙니다.
int → long- 자동 widening이며 모든 int 값을 정확히 표현합니다.
int → double- 자동 widening이며 int 범위의 정수 값은 정확히 표현합니다.
long → double- 자동 변환이지만 큰 long 정수는 정밀도를 잃을 수 있습니다.
double → int- 명시적 cast가 필요합니다. 예시의
42.9는 반올림하지 않고 0 방향으로 절삭되어42이 됩니다. long → int- 명시적 cast가 필요하며 범위를 벗어나면 하위 비트만 남을 수 있습니다.
Math.toIntExact(long)- checked narrowing입니다. int 범위 안이면 값을 반환하고, 범위 밖이면
ArithmeticException으로 실패를 드러냅니다.
- 자동 또는 명시적 변환
- 범위를 검사하는 checked narrowing
- 범위 밖 예외
명시적 cast는 개발자가 정보 손실을 허용했다는 뜻일 뿐 값이 안전하다는 증명은 아니다. 손실을 허용할 수 없다면 범위 검사나 Math.toIntExact를 사용한다.
더 깊이: 비트로 본 축소 변환과 실수의 경우
Long.toBinaryString(2_147_483_648L)을 출력하면 1 뒤에 0이 31개 붙은 32자리 이진수가 나옵니다. long에서는 이 1이 64비트 중 아래에서 32번째 자리에 있는 평범한 값이지만, (int)로 아래 32비트만 남기면 이 1이 int의 맨 위 비트가 됩니다. Java의 정수는 2의 보수(two's complement)로 음수를 표현하므로 맨 위 비트가 1이면 음수이고, 정확히 이 비트 패턴이 Integer.MIN_VALUE입니다. 앞의 표에서 2의 32제곱을 빼는 계산이 맞아떨어진 이유도 위쪽 32비트를 버리기 때문입니다. 2의 보수가 음수를 어떻게 나타내는지는 음수·보수·넘침에서 자세히 다룹니다.
같은 (int) 캐스트라도 원래 값이 실수라면 규칙이 다릅니다. double을 int로 바꿀 때 범위를 넘는 값은 접히지 않고 int의 최댓값이나 최솟값에 고정되며, 숫자가 아님을 뜻하는 NaN은 0이 됩니다(JLS 5.1.3). 그래서 (int) 1e10은 2147483647, (int) -1e10은 -2147483648, (int) Double.NaN은 0입니다. 어느 쪽이든 실행 중 오류는 나지 않으므로, 범위 검사가 필요하다는 결론은 같습니다.
계산 도중에 일어나는 오버플로
형변환을 하나도 쓰지 않았는데 값이 망가지는 경우도 있습니다. 다음 프로그램은 이벤트 십만 건에 1분(60,000밀리초)씩을 곱해 전체 밀리초를 구합니다.
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결과는 6,000,000,000이어야 하는데, 첫 줄은 1,705,032,704라는 엉뚱한 값을 출력했습니다. 결과를 담는 wrongMillis가 long인데도 그렇습니다. Java는 대입할 변수의 타입이 아니라 계산하는 순간의 피연산자 타입으로 계산 방식을 정하기 때문입니다. eventCount와 60_000이 모두 int이므로 곱셈도 int 곱셈이고, 6,000,000,000은 int에 담을 수 없어 4,294,967,296을 뺀 1,705,032,704가 됩니다. 이렇게 계산 결과가 타입의 범위를 넘어 엉뚱한 값이 되는 것을 오버플로(overflow)라고 합니다. long 변수에 대입하는 것은 그다음 일이라, 이미 틀린 값을 long으로 넓혀 담을 뿐입니다.
두 번째 식에서는 60_000L이 long 리터럴이므로, 곱하기 전에 eventCount가 long으로 승격되고 long 곱셈으로 정확한 값을 얻습니다. 그러니 해결의 핵심은 결과 변수가 아니라, 피연산자 가운데 하나를 계산 전에 long으로 만드는 것입니다. (long) eventCount * 60_000처럼 캐스트를 써도 됩니다. 캐스트가 곱셈보다 먼저 eventCount에 적용되기 때문입니다. 하지만 (long) (eventCount * 60_000)은 괄호 안의 int 곱셈이 먼저 끝나므로 똑같이 1,705,032,704가 됩니다.
피연산자 타입이 중간값을 결정한다
피연산자 타입→승격→중간 곱셈→long 저장의 두 계산이 이미 생긴 overflow를 대비합니다. 같은 식의 역할을 고정해 손실이 발생하는 지점을 순서대로 확인할 수 있습니다.
FLOWCHART · PAIRED ARITHMETIC TRACE
피연산자 타입이 중간값을 결정한다
대입받는 변수의 타입이 아니라 연산 순간의 피연산자 타입이 중간값의 폭을 정한다. 두 계산은 리터럴의 L 접미사에서 처음 갈라진다.
첫 차이 · L 접미사 결과 변수 long보다 먼저 피연산자 타입이 갈립니다.
L 없음 · int 오버플로
- 피연산자
int 100000 × int 60000 - 연산 폭
승격 없이 32-bit int로 곱합니다.
- 중간값
1705032704· int overflow로 이미 잘못된 값입니다. - long 저장
잘못된 int 값을 long으로 넓힐 뿐 overflow를 되돌리지 못합니다.
L 있음 · long 계산
- 피연산자
int 100000 × long 60000L - 연산 폭
곱하기 전에 100000을 long으로 승격해 64-bit로 계산합니다.
- 중간값
6000000000· long 범위의 정확한 값입니다. - long 저장
정확한 long 중간값을 결과 변수에 그대로 저장합니다.
- 첫 차이 ·
60000L - 두 trace의 같은 단계
두 trace의 순서는 같지만 첫 피연산자 단계에서 폭이 달라진다. 결과를 받는 변수를 long으로 선언하는 것만으로는 이미 발생한 int overflow를 복구할 수 없다.
이 예의 조건
- 두 식은 독립적인 비교 trace입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 피연산자 타입이 중간값을 결정한다 L 접미사 유무에 따라 처음 갈라지는 int overflow와 long 계산의 쌍대 추적 FLOWCHART · PAIRED ARITHMETIC TRACE 피연산자 타입이 중간값을 결정한다 대입받는 변수의 타입이 아니라 연산 순간의 피연산자 타입이 중간값의 폭을 정한다. 두 계산은 리터럴의 L 접미사에서 처음 갈라진다. 첫 차이 · L 접미사 결과 변수 long 보다 먼저 피연산자 타입이 갈립니다. L 없음 · int 오버플로 피연산자 int 100000 × int 60000 연산 폭 승격 없이 32-bit int로 곱합니다. 중간값 1705032704 · int overflow로 이미 잘못된 값입니다. long 저장 잘못된 int 값을 long으로 넓힐 뿐 overflow를 되돌리지 못합니다. L 있음 · long 계산 피연산자 int 100000 × long 60000L 연산 폭 곱하기 전에 100000을 long으로 승격해 64-bit로 계산합니다. 중간값 6000000000 · long 범위의 정확한 값입니다. long 저장 정확한 long 중간값을 결과 변수에 그대로 저장합니다. 첫 차이 · 60000L 두 trace의 같은 단계 두 trace의 순서는 같지만 첫 피연산자 단계에서 폭이 달라진다. 결과를 받는 변수를 long 으로 선언하는 것만으로는 이미 발생한 int overflow를 복구할 수 없다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 피연산자 판정 L 접미사가 곱셈의 계산 타입을 바꿉니다. |
|
|
| 중간 계산 각 연산 타입으로 중간값을 만듭니다. |
|
|
| long 저장 두 중간값을 각 long 변수에 저장합니다. |
|
|
피연산자 타입이 중간값을 결정한다
피연산자 타입→승격→중간 곱셈→long 저장의 두 계산이 이미 생긴 overflow를 대비합니다. 같은 식의 역할을 고정해 손실이 발생하는 지점을 순서대로 확인할 수 있습니다.
FLOWCHART · PAIRED ARITHMETIC TRACE
피연산자 타입이 중간값을 결정한다
대입받는 변수의 타입이 아니라 연산 순간의 피연산자 타입이 중간값의 폭을 정한다. 두 계산은 리터럴의 L 접미사에서 처음 갈라진다.
첫 차이 · L 접미사 결과 변수 long보다 먼저 피연산자 타입이 갈립니다.
L 없음 · int 오버플로
- 피연산자
int 100000 × int 60000 - 연산 폭
승격 없이 32-bit int로 곱합니다.
- 중간값
1705032704· int overflow로 이미 잘못된 값입니다. - long 저장
잘못된 int 값을 long으로 넓힐 뿐 overflow를 되돌리지 못합니다.
L 있음 · long 계산
- 피연산자
int 100000 × long 60000L - 연산 폭
곱하기 전에 100000을 long으로 승격해 64-bit로 계산합니다.
- 중간값
6000000000· long 범위의 정확한 값입니다. - long 저장
정확한 long 중간값을 결과 변수에 그대로 저장합니다.
- 첫 차이 ·
60000L - 두 trace의 같은 단계
두 trace의 순서는 같지만 첫 피연산자 단계에서 폭이 달라진다. 결과를 받는 변수를 long으로 선언하는 것만으로는 이미 발생한 int overflow를 복구할 수 없다.
이 예의 조건
- 두 식은 독립적인 비교 trace입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 피연산자 타입이 중간값을 결정한다 L 접미사 유무에 따라 처음 갈라지는 int overflow와 long 계산의 쌍대 추적 FLOWCHART · PAIRED ARITHMETIC TRACE 피연산자 타입이 중간값을 결정한다 대입받는 변수의 타입이 아니라 연산 순간의 피연산자 타입이 중간값의 폭을 정한다. 두 계산은 리터럴의 L 접미사에서 처음 갈라진다. 첫 차이 · L 접미사 결과 변수 long 보다 먼저 피연산자 타입이 갈립니다. L 없음 · int 오버플로 피연산자 int 100000 × int 60000 연산 폭 승격 없이 32-bit int로 곱합니다. 중간값 1705032704 · int overflow로 이미 잘못된 값입니다. long 저장 잘못된 int 값을 long으로 넓힐 뿐 overflow를 되돌리지 못합니다. L 있음 · long 계산 피연산자 int 100000 × long 60000L 연산 폭 곱하기 전에 100000을 long으로 승격해 64-bit로 계산합니다. 중간값 6000000000 · long 범위의 정확한 값입니다. long 저장 정확한 long 중간값을 결과 변수에 그대로 저장합니다. 첫 차이 · 60000L 두 trace의 같은 단계 두 trace의 순서는 같지만 첫 피연산자 단계에서 폭이 달라진다. 결과를 받는 변수를 long 으로 선언하는 것만으로는 이미 발생한 int overflow를 복구할 수 없다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 피연산자 판정 L 접미사가 곱셈의 계산 타입을 바꿉니다. |
|
|
| 중간 계산 각 연산 타입으로 중간값을 만듭니다. |
|
|
| long 저장 두 중간값을 각 long 변수에 저장합니다. |
|
|
곱셈이 범위를 넘는지 직접 확인하고 싶다면 Math.multiplyExact를 쓸 수 있습니다. Math.multiplyExact(100_000, 60_000)은 결과가 int 범위를 넘으므로 Math.toIntExact처럼 ArithmeticException을 일으킵니다. 덧셈과 뺄셈에도 같은 역할의 Math.addExact와 Math.subtractExact가 있습니다.
회원 정보 보관 기간 계산하기
지금까지의 내용을 회원가입 프로그램에 적용해 보겠습니다. 다음 프로그램은 회원 정보를 보관할 일수와 최대 보관 일수를 실행 인자로 받아, 보관 기간을 밀리초로 바꾸고 최대 기간 대비 비율을 계산합니다. 하루는 24 × 60 × 60 × 1,000 = 86,400,000밀리초입니다. 예제를 짧게 유지하려고 이메일, 보관 일수, 최대 일수의 세 인자가 모두 들어오고 두 일수는 정수 모양이라고 가정합니다.
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 protected]
retentionMillis=7776000000
retentionRatio=0.75retentionMillis는 90 × 86,400,000 = 7,776,000,000으로, int의 최댓값인 약 21억을 훌쩍 넘습니다. 86_400_000L의 L 덕분에 곱셈이 long으로 이루어져 정확한 값이 나왔습니다. L을 빼면 int 곱셈에서 오버플로가 일어나 -813934592가 출력됩니다. retentionRatio는 (double)이 나눗셈보다 먼저 retentionDays를 90.0으로 바꾸므로 90.0 / 120 = 0.75입니다. 캐스트가 없으면 int 나눗셈 90 / 120의 결과 0이 대입되어 0.0이 됩니다.
변수의 위치도 이 글의 원칙을 따릅니다. email, retentionDays, maximumDays는 검사와 계산, 출력까지 계속 쓰이므로 main 첫머리에 두고, retentionMillis와 retentionRatio는 값을 계산하는 자리에서 선언했습니다. 입력이 잘못되었을 때는 메시지를 출력하고 바로 return합니다. 그래서 if 아래의 코드는 보관 일수가 1 이상이고 최대 일수가 보관 일수 이상이라고 믿고 계산할 수 있습니다. 최대 일수가 보관 일수 이상이므로 최대 일수도 1 이상이고, 0으로 나누는 일도 생기지 않습니다.
정리
- 지역 변수는 선언한 줄부터 감싼 블록의 닫는 중괄호까지만 쓸 수 있습니다. 안쪽 블록은 바깥 변수를 쓸 수 있지만, 그 반대는 안 됩니다.
- 변수는 필요한 가장 좁은 블록에, 값을 정할 수 있는 곳에서 선언합니다. 반복이 끝난 뒤에도 필요한 누적값만 바깥에 둡니다.
- 범위가 넓은 타입으로는 자동으로 바뀌지만,
long을double로 바꾸는 것처럼 정밀도를 잃는 경우가 있습니다. - 범위가 좁은 타입으로 바꾸려면 캐스트를 적어야 합니다. 실수를 정수로 바꾸면 소수 부분을 버리고, 범위를 넘는 정수는 아래쪽 비트만 남아 엉뚱한 값이 됩니다. 복합 대입 연산자에는 이 캐스트가 숨어 있습니다.
- 계산 타입은 대입할 변수가 아니라 피연산자의 타입으로 정해집니다. 큰 값을 곱할 때는 계산 전에 피연산자 하나를
long으로 만들고, 범위를 넘으면 안 되는 곳에서는Math.toIntExact나Math.multiplyExact를 씁니다.
연습 문제
long초 값을 받아 분 단위double값과,int범위 안일 때만int로 바꾼 초를 출력하는SecondConversion을 작성하세요. 입력은9_000_000_000초를 쓰고, 잘린int값을 출력하면 안 됩니다.- 다음 네 식의 결과를 실행하기 전에 예측하세요.
(int) 7.99,(int) -7.99,(double) 7 / 2,(double) (7 / 2) long microsPerDay = 24 * 60 * 60 * 1_000_000;을 출력하면 하루의 마이크로초인 86400000000이 아니라 500654080이 나옵니다. 이유를 설명하고 고치세요.- 다음 코드에서 컴파일 오류가 나는 이름을 모두 찾고, 그 이유를 설명하세요.
public final class SquareScope {
public static void main(String[] args) {
int sum = 0;
for (int i = 1; i <= 3; i++) {
int square = i * i;
sum += square;
}
System.out.println(i + " " + square + " " + sum);
}
}해설 보기
1번. 범위를 검사한 블록 안에서만 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로 승격되어 실수 나눗셈이 일어납니다. 1.5E8은 1.5 × 10⁸, 즉 150,000,000을 뜻합니다. Java는 절댓값이 10,000,000 이상인 double을 이렇게 지수 표기로 출력합니다. 9,000,000,000은 int의 최댓값보다 크므로 else 쪽이 실행되었습니다. exactSeconds는 범위 검사가 참인 블록 안에서만 선언되므로, 범위를 벗어난 경로에서는 이 변수를 쓸 수조차 없습니다.
2번. 차례로 7, -7, 3.5, 3.0입니다. 캐스트는 소수 부분을 0에 가까운 쪽으로 버리므로 7.99는 7, -7.99는 -7이 됩니다. (double) 7 / 2는 캐스트가 7에 먼저 적용되어 7.0 / 2 = 3.5이고, (double) (7 / 2)는 정수 나눗셈 7 / 2 = 3을 먼저 끝낸 뒤 바꾸므로 3.0입니다.
3번. 오른쪽 식의 피연산자가 모두 int라서 곱셈이 전부 int로 계산되기 때문입니다. 왼쪽부터 계산하면 24 × 60 × 60 = 86,400까지는 int 범위 안이지만, 86,400 × 1,000,000 = 86,400,000,000은 범위를 넘어 오버플로가 일어납니다. 그 결과 4,294,967,296의 20배를 뺀 500,654,080이 long 변수에 담깁니다. 첫 피연산자에 L을 붙여 24L * 60 * 60 * 1_000_000으로 고치면 첫 곱셈부터 long으로 계산되어 86400000000이 출력됩니다. 곱셈은 왼쪽부터 차례로 계산되므로, 맨 앞 피연산자를 long으로 만들면 모든 단계가 long 곱셈이 됩니다.
4번. i와 square 두 이름에서 cannot find symbol 오류가 납니다. i는 for 머리에서 선언했으므로 for 문 안에서만 쓸 수 있고, square는 반복 본문 블록 안에서 선언했으므로 그 블록 안에서만 쓸 수 있습니다. 마지막 줄은 두 변수의 스코프가 모두 끝난 뒤입니다. 반면 sum은 main 블록에서 선언했으므로 반복이 끝난 뒤에도 쓸 수 있습니다. 마지막 줄을 System.out.println(sum);으로 고치면 1 + 4 + 9 = 14가 출력됩니다.
다음 글
2장에서는 값을 담고 계산하고, 조건에 따라 고르고, 되풀이하는 방법을 모두 익혔습니다. 지금까지는 실행 인자로만 입력을 받았는데, 3장의 첫 글에서는 프로그램이 실행되는 동안 키보드로 입력을 받는 Scanner를 배웁니다.