변수는 선언한 위치에 따라 지역 변수(Local Variable), 클래스 변수(Class Variable), 인스턴스 변수(Instance Variable)로 분류된다. 지금 단계에서 다루는 것은 지역 변수다.
지역 변수는 자신이 선언된 코드 블록({}) 안에서만 존재한다. 블록이 끝나는 순간 메모리에서 제거되며, 이후에는 접근할 수 없다. 이처럼 변수에 접근할 수 있는 범위를 스코프(Scope), 즉 유효 범위라 한다.
package scope;
publicclassScope1 {
publicstaticvoidmain(String[] args) {
int m = 10; // m 생존 시작if (true) {
int x = 20; // x 생존 시작System.out.println("if m = " + m); // 외부 블록 접근 가능System.out.println("if x = " + x);
} // x 생존 종료// System.out.println("main x = " + x); // 컴파일 오류: x에 접근 불가System.out.println("main m = " + m);
} // m 생존 종료
}
m은 main{} 블록 전체에서 살아 있으므로 스코프가 넓다. x는 if{} 블록 안에서만 살아 있으므로 스코프가 좁다. if{} 블록이 끝난 뒤 x에 접근하면 cannot find symbol 컴파일 오류가 발생한다.
for 문 초기식에서 선언한 변수도 마찬가지다.
package scope;
publicclassScope2 {
publicstaticvoidmain(String[] args) {
int m = 10;
for (int i = 0; i < 2; i++) { // i 생존 시작System.out.println("for m = " + m);
System.out.println("for i = " + i);
} // i 생존 종료// System.out.println("main i = " + i); // 컴파일 오류System.out.println("main m = " + m);
}
}
for (int i = 0; ...) 처럼 초기식에서 선언한 i는 for{} 블록 안에서만 유효하다.
스코프가 존재하는 이유
변수를 선언 시점부터 끝까지 살려두면 간단하지 않을까 생각할 수 있다. 아래 두 코드를 비교해 보자.
스코프가 불필요하게 넓은 코드
publicclassScope3_1 {
publicstaticvoidmain(String[] args) {
int m = 10;
int temp = 0; // main 전체 스코프if (m > 0) {
temp = m * 2;
System.out.println("temp = " + temp);
}
System.out.println("m = " + m);
}
}
스코프를 최소화한 코드
publicclassScope3_2 {
publicstaticvoidmain(String[] args) {
int m = 10;
if (m > 0) {
int temp = m * 2; // if 블록 스코프로 제한System.out.println("temp = " + temp);
}
System.out.println("m = " + m);
}
}
temp는 if 블록에서만 필요한 임시 변수다. 첫 번째 코드는 temp를 main 전체 스코프에 선언해 두 가지 문제를 야기한다.
비효율적인 메모리: temp는 if 블록 종료 후에도 main 종료까지 메모리에 유지된다. JVM 스택 프레임의 지역 변수 슬롯을 불필요하게 점유한다.
코드 복잡성 증가: if 블록이 끝나도 temp를 어디서든 접근할 수 있다. 유지보수 시 m뿐 아니라 temp까지 신경 써야 한다. 코드가 복잡해질수록 이 부담은 기하급수적으로 커진다.
두 번째 코드는 temp를 if{} 안으로 이동시켰다. 블록이 끝나는 즉시 JVM이 해당 스택 슬롯을 회수하고, 코드를 읽는 사람도 temp의 유효 범위를 즉시 파악할 수 있다.
Joshua Bloch의 Effective Java에서도 "지역 변수의 스코프를 최소화하라(Minimize the scope of local variables)"를 명시적 원칙으로 제시한다. 변수를 처음 사용하는 지점에서 선언하고, 선언과 동시에 초기화하는 것이 핵심이다. 좋은 프로그램은 무한한 자유가 있는 프로그램이 아니라 적절한 제약이 있는 프로그램이다.
while vs for — 스코프 관점 비교
카운터 변수 i를 예로 들면 while과 for의 스코프 차이가 명확하다.
// while: i의 스코프가 main 전체int i = 1;
int endNum = 3;
while (i <= endNum) {
System.out.println("i=" + i);
i++;
}
// 이 시점에도 i에 접근 가능 — 불필요한 노출// for: i의 스코프가 for 블록으로 한정int endNum = 3;
for (int i = 1; i <= endNum; i++) {
System.out.println("i=" + i);
}
// 이 시점에서 i는 존재하지 않음
루프 카운터처럼 루프 내부에서만 필요한 변수라면 for를 선택해 스코프를 제한하는 것이 메모리와 가독성 양쪽에서 유리하다.
JVM 스택 프레임과 스코프
스코프가 메모리와 어떻게 연결되는지 이해하려면 JVM 스택 구조를 알아야 한다. 메서드가 호출될 때마다 JVM은 스레드 전용 스택에 스택 프레임(Stack Frame)을 하나 생성한다.
프레임은 지역 변수 배열, 오퍼랜드 스택, 런타임 상수 풀 참조로 구성된다. 지역 변수 배열의 슬롯 크기는 컴파일 시점에 결정된다. int, float 같은 4바이트 타입은 슬롯 1개, long, double 같은 8바이트 타입은 슬롯 2개를 차지한다. 코드 블록이 끝나면 그 블록에서 선언된 변수의 슬롯을 이후 변수가 재사용할 수 있다. 메서드 실행이 완전히 끝나면 프레임 전체가 스택에서 제거되고, 모든 지역 변수도 함께 사라진다. 힙(Heap)에 남지 않으므로 가비지 컬렉션 대상도 아니다.
2. 형변환(Type Casting)
타입 계층과 표현 범위
자바 기본 숫자 타입의 표현 범위는 아래와 같다. 단순히 바이트 수가 많을수록 범위가 큰 것은 아니다. 실수형은 정수형과 비트 해석 방식이 다르다.
타입
크기
표현 범위
비고
byte
1 byte
-128 ~ 127
정수
short
2 byte
-32,768 ~ 32,767
정수
int
4 byte
약 -21억 ~ 21억
정수 기본
long
8 byte
약 -920경 ~ 920경
정수
float
4 byte
약 ±3.4E38, 유효 7자리
IEEE 754
double
8 byte
약 ±1.7E308, 유효 15자리
IEEE 754, 실수 기본
자동 형변환 (묵시적 형변환)
작은 범위 타입을 큰 범위 타입에 대입하면 컴파일러가 자동으로 형변환을 수행한다. 개발자가 직접 코드를 작성할 필요가 없다.
package casting;
publicclassCasting1 {
publicstaticvoidmain(String[] args) {
int intValue = 10;
long longValue;
double doubleValue;
longValue = intValue; // int → long 자동 형변환System.out.println("longValue = " + longValue); // 10
doubleValue = intValue; // int → double 자동 형변환System.out.println("doubleValue1 = " + doubleValue); // 10.0
doubleValue = 20L; // long → double 자동 형변환System.out.println("doubleValue2 = " + doubleValue); // 20.0
}
}
JVM 바이트코드 수준에서는 i2d(int to double), i2l(int to long), l2d(long to double) 같은 전용 변환 명령어가 사용된다. 이 명령어들은 런타임 예외를 발생시키지 않으며, 값의 크기 정보를 손실 없이 보존한다. 넓히는 방향의 변환이므로 항상 안전하다.
명시적 형변환 (캐스팅)
큰 범위 타입을 작은 범위 타입에 대입하면 컴파일 오류가 발생한다.
double doubleValue = 1.5;
int intValue = 0;
intValue = doubleValue; // 컴파일 오류// java: incompatible types: possible lossy conversion from double to int
double은 int보다 표현 범위가 크고 소수점도 있다. 손실 없이 담기 불가능하다. 그러나 개발자가 손실을 의도적으로 감수하고 변환하고 싶다면 명시적 형변환(캐스팅)을 사용한다.
캐스팅(casting)은 금속이나 물질을 녹여 특정 형태의 틀에 부어 만드는 과정에서 유래한 용어다. 데이터를 다른 타입의 틀로 강제로 바꾸는 행위와 의미가 맞닿아 있다. 형변환을 한다고 해서 원본 변수 doubleValue의 타입이나 값이 바뀌는 것은 아니다. 그 값을 읽어서 변환하는 것이며, doubleValue는 여전히 1.5를 유지한다.
maxIntOver는 int 범위를 1 초과하므로 오버플로우가 발생한다. 이진수 수준에서 어떤 일이 일어나는지 보면 이렇다.
오버플로우가 발생하면 결과를 예측하기가 매우 어렵다. 오버플로우 자체가 발생하지 않도록 설계하는 것이 올바른 방향이다. 이 예제에서는 intValue의 타입을 int에서 long으로 바꾸면 문제가 해결된다. 오버플로우가 발생했을 때 결과가 어떻게 되는지 계산하는 데 시간을 쓰지 않아야 한다.
계산과 형변환
형변환은 대입뿐 아니라 계산 과정에서도 발생한다. 자바 산술 연산의 두 가지 원칙이다.
같은 타입끼리의 연산은 같은 타입의 결과를 낸다.int + int는 int, double + double은 double.
서로 다른 타입의 연산은 큰 범위로 자동 형변환이 일어난다.int + long은 long + long, int + double은 double + double.
package casting;
publicclassCasting4 {
publicstaticvoidmain(String[] args) {
int div1 = 3 / 2;
System.out.println("div1 = " + div1); // 1 (int / int = int)double div2 = 3 / 2;
System.out.println("div2 = " + div2); // 1.0 (int / int = int → double로 대입 시 자동 형변환)double div3 = 3.0 / 2;
System.out.println("div3 = " + div3); // 1.5 (double / int → double / double)double div4 = (double) 3 / 2;
System.out.println("div4 = " + div4); // 1.5 (명시적 형변환으로 double / double 유도)int a = 3;
int b = 2;
double result = (double) a / b;
System.out.println("result = " + result); // 1.5
}
}
div2의 경우가 흔한 실수 포인트다. double 변수에 대입하더라도 오른쪽 연산(3 / 2)이 먼저 int끼리 계산되어 1이 된 뒤, 그 결과가 double로 변환돼 1.0이 된다. 소수점 결과를 얻으려면 연산 전에 피연산자 중 하나를 double로 바꿔야 한다.
변수를 사용하는 경우 단계별 처리 과정은 이렇다.
double result = (double) a / b;
double result = (double) 3 / 2; // 변수 값 읽기double result = (double) 3 / (double) 2; // double + int → int가 double로 자동 형변환double result = 3.0 / 2.0; // double / double → doubledouble result = 1.5;