return과 호출 스택
return이 값을 돌려주고 메서드를 끝내는 방식, 반환 타입이 요구하는 규칙, 메서드가 다른 메서드를 부를 때 실행이 오가는 순서를 호출 스택으로 따라가며 배웁니다.
앞 글에서 코드를 메서드로 나누자 main이 짧아지고 읽기 쉬워졌습니다. 대신 실행 순서가 파일에 적힌 순서와 달라졌습니다. main을 읽다가 total(age, size)를 만나면 실행은 아래쪽의 total 본문으로 건너갔다가, 결과를 들고 다시 main의 그 줄로 돌아옵니다. 메서드 안에서 또 다른 메서드를 부르면 이런 왕복이 겹겹이 쌓입니다.
이 글에서는 실행을 되돌려 보내는 return이 정확히 무슨 일을 하는지, 반환 타입이 메서드에 어떤 규칙을 요구하는지 먼저 살펴봅니다. 그다음 메서드가 메서드를 부를 때 자바가 '어디로 돌아가야 하는지'를 기억하는 방법인 호출 스택을 따라가 봅니다.
모든 경로에서 값을 돌려줘야 하는 이유
먼저 컴파일되지 않는 코드부터 보겠습니다. 나이를 받아 senior나 adult를 돌려주는 메서드입니다.
public final class MissingReturn {
public static void main(String[] args) {
System.out.println(level(50));
}
private static String level(int age) {
if (age >= 65) {
return "senior";
}
if (age >= 18) {
return "adult";
}
}
}lab/MissingReturn.java:13: error: missing return statement
}
^
1 errorlevel의 반환 타입은 String입니다. 이것은 '이 메서드를 호출하면 언제나 String 값이 돌아온다'는 약속입니다. 그런데 age가 18보다 작으면 두 if의 조건이 모두 거짓이 되어, 아무것도 돌려주지 않은 채 본문의 닫는 중괄호에 도달합니다. 그러면 호출한 쪽은 받을 값이 없습니다. 컴파일러는 이 경로를 찾아내고, 오류 위치로 본문 끝의 13번째 줄을 가리킵니다.
main이 실제로는 50만 넘기니 문제없지 않느냐고 생각할 수 있습니다. 하지만 컴파일러는 실행해 보기 전에 메서드 하나만 보고 판단합니다. level은 어떤 int든 받을 수 있으므로, 어떤 값이 들어와도 String을 돌려줄 수 있어야 합니다. 해결 방법은 모든 조건에 해당하지 않을 때 돌려줄 값을 마지막에 두는 것입니다.
private static String level(int age) {
if (age >= 65) {
return "senior";
}
if (age >= 18) {
return "adult";
}
return "underage";
}마지막 return "underage";는 앞의 두 if가 모두 거짓일 때만 실행되므로, 18보다 작은 age는 underage를 받습니다. 이제 값을 돌려주지 않고 본문 끝에 도달하는 경로가 하나도 없으므로 컴파일러도 이 메서드를 받아들입니다.
더 깊이: 컴파일러는 조건의 값을 따지지 않는다
다음 메서드도 missing return statement 오류가 납니다. 이번에는 마지막 return이 없는 대신, 두 번째 if가 첫 번째 if의 조건을 뒤집은 age < 18을 검사합니다.
private static String label(int age) {
if (age >= 18) {
return "adult";
}
if (age < 18) {
return "minor";
}
}사람이 보기에는 두 조건 중 하나는 반드시 참이므로 모든 경로가 값을 돌려줍니다. 하지만 컴파일러는 age >= 18과 age < 18이 서로 반대라는 사실을 따지지 않습니다. Java 언어 명세(JLS 14.22절)는 문장이 끝까지 실행될 수 있는지를 코드의 구조로 판정하며, if의 조건은 그 값이 무엇인지 아예 보지 않습니다. 조건 자리에 상수 true를 써서 if (true) { return 1; }로만 끝나는 메서드조차 같은 오류가 납니다. 이렇게 정한 이유는 if (DEBUG)처럼 상수 조건으로 코드 일부를 켜고 끄는 방식을 허용하기 위해서입니다. 상수가 false일 때 그 블록을 도달할 수 없는 코드로 보고 오류를 내면 이런 코드를 쓸 수 없기 때문입니다.
반복문은 다르게 판정합니다. while (true)처럼 조건이 상수 true이고 반복을 빠져나가는 break가 없으면, 컴파일러는 반복 뒤로 실행이 넘어갈 수 없다고 봅니다. 그래서 반복 안에서만 return하는 메서드는 반복 뒤에 return을 두지 않아도 컴파일됩니다. 위의 label은 두 번째 if를 else로 바꾸거나, 두 번째 조건을 지우고 마지막에 return "minor";를 두면 됩니다.
return은 메서드를 즉시 끝낸다
return은 결과를 돌려주는 문법이기도 하지만, 그보다 먼저 현재 메서드를 그 자리에서 끝내는 문장입니다. return이 실행되면 그 아래에 무엇이 남아 있든 메서드는 끝나고, 실행은 호출한 곳으로 돌아갑니다. 이 성질을 이용하면 잘못된 입력을 메서드 앞부분에서 걸러 낼 수 있습니다.
public final class EarlyReturn {
public static void main(String[] args) {
printMember("[email protected]", 40);
printMember("", 30);
printMember("[email protected]", -5);
}
private static void printMember(String email, int age) {
if (email == null || email.isBlank()) {
System.out.println("rejected: email");
return;
}
if (age < 14 || age > 120) {
System.out.println("rejected: age");
return;
}
System.out.println(email + "=" + age);
}
}[email protected]=40
rejected: email
rejected: ageprintMember는 반환 타입이 void이므로 돌려줄 값이 없습니다. 이런 메서드에서는 값 없이 return;만 써서 메서드를 끝냅니다. 세 번의 호출이 어떤 경로를 지나는지 보겠습니다.
- 첫 번째 호출은 이메일 검사와 나이 검사를 모두 통과하고 마지막 줄에서 회원을 출력합니다. 그다음 본문 끝에 도달해
main으로 돌아갑니다.void메서드는 본문 끝에 도달하면return;이 없어도 끝납니다. - 두 번째 호출은 이메일이 빈 문자열이라 첫 번째
if에서rejected: email을 출력하고return;을 만납니다. 그 아래의 나이 검사와 회원 출력은 실행되지 않습니다. - 세 번째 호출은 이메일 검사를 통과했지만 나이 -5가 범위를 벗어나
rejected: age를 출력하고 끝납니다.
조기 반환은 현재 호출의 다음 문장을 건너뛴다
이메일 검사 실패 시 return으로 현재 호출을 끝내고 나이 검사와 정상 출력을 생략합니다. 통과 경로와 두 조기 종료를 현재 호출의 실행 여부로 표현할 수 있습니다.
이메일 검사에서 종료한 호출은 나이 검사에 도달하지 않는다. 정상 출력은 두 거절 조건이 모두 거짓일 때만 실행된다.
return은 현재 메서드 호출을 끝냅니다
이메일과 나이의 거절 조건을 순서대로 검사합니다. 두 검사 모두 통과해야 정상 회원을 출력합니다.
printMember(email, age)현재 호출만 추적
이메일 검사
email == null || email.isBlank()가 참이면rejected: email을 출력하고return;으로 현재 호출을 끝냅니다. 나이 검사와 정상 출력에는 도달하지 않습니다. 거짓이면 나이를 검사합니다.나이 검사
age < 14 || age > 120이 참이면rejected: age를 출력하고return;으로 현재 호출을 끝냅니다. 거짓이면 정상 출력으로 갑니다.정상 출력
email + "=" + age를 출력하고 본문 끝에서 돌아갑니다. 이 경로에는 별도의 명시적return;이 없습니다.
각 종료는 현재 printMember 호출만 끝냅니다. 호출자인 main의 다음 호출은 계속될 수 있습니다.
이 예의 조건
- return은 현재 printMember 호출만 끝냅니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 조기 반환은 현재 호출의 다음 문장을 건너뛴다 printMember의 이메일·나이 검사와 조기 반환 경로 이메일 검사에서 종료한 호출은 나이 검사에 도달하지 않는다. 정상 출력은 두 거절 조건이 모두 거짓일 때만 실행된다. return은 현재 메서드 호출을 끝냅니다 이메일과 나이의 거절 조건을 순서대로 검사합니다. 두 검사 모두 통과해야 정상 회원을 출력합니다. printMember(email, age) 현재 호출만 추적 이메일 검사 email == null || email.isBlank() 가 참이면 rejected: email 을 출력하고 return; 으로 현재 호출을 끝냅니다. 나이 검사와 정상 출력에는 도달하지 않습니다. 거짓이면 나이를 검사합니다. 나이 검사 age < 14 || age > 120 이 참이면 rejected: age 를 출력하고 return; 으로 현재 호출을 끝냅니다. 거짓이면 정상 출력으로 갑니다. 정상 출력 email + "=" + age 를 출력하고 본문 끝에서 돌아갑니다. 이 경로에는 별도의 명시적 return; 이 없습니다. 각 종료는 현재 printMember 호출만 끝냅니다. 호출자인 main 의 다음 호출은 계속될 수 있습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 이메일 가드 null 또는 공백이면 rejected: email을 출력합니다. |
|
|
| 나이 가드 유효 email에서 age<14 또는 >120을 검사합니다. |
|
|
| 정상과 복귀 남은 입력에 대해서는 email=age를 출력하고 main으로 돌아갑니다. |
|
|
조기 반환은 현재 호출의 다음 문장을 건너뛴다
이메일 검사 실패 시 return으로 현재 호출을 끝내고 나이 검사와 정상 출력을 생략합니다. 통과 경로와 두 조기 종료를 현재 호출의 실행 여부로 표현할 수 있습니다.
이메일 검사에서 종료한 호출은 나이 검사에 도달하지 않는다. 정상 출력은 두 거절 조건이 모두 거짓일 때만 실행된다.
return은 현재 메서드 호출을 끝냅니다
이메일과 나이의 거절 조건을 순서대로 검사합니다. 두 검사 모두 통과해야 정상 회원을 출력합니다.
printMember(email, age)현재 호출만 추적
이메일 검사
email == null || email.isBlank()가 참이면rejected: email을 출력하고return;으로 현재 호출을 끝냅니다. 나이 검사와 정상 출력에는 도달하지 않습니다. 거짓이면 나이를 검사합니다.나이 검사
age < 14 || age > 120이 참이면rejected: age를 출력하고return;으로 현재 호출을 끝냅니다. 거짓이면 정상 출력으로 갑니다.정상 출력
email + "=" + age를 출력하고 본문 끝에서 돌아갑니다. 이 경로에는 별도의 명시적return;이 없습니다.
각 종료는 현재 printMember 호출만 끝냅니다. 호출자인 main의 다음 호출은 계속될 수 있습니다.
이 예의 조건
- return은 현재 printMember 호출만 끝냅니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 조기 반환은 현재 호출의 다음 문장을 건너뛴다 printMember의 이메일·나이 검사와 조기 반환 경로 이메일 검사에서 종료한 호출은 나이 검사에 도달하지 않는다. 정상 출력은 두 거절 조건이 모두 거짓일 때만 실행된다. return은 현재 메서드 호출을 끝냅니다 이메일과 나이의 거절 조건을 순서대로 검사합니다. 두 검사 모두 통과해야 정상 회원을 출력합니다. printMember(email, age) 현재 호출만 추적 이메일 검사 email == null || email.isBlank() 가 참이면 rejected: email 을 출력하고 return; 으로 현재 호출을 끝냅니다. 나이 검사와 정상 출력에는 도달하지 않습니다. 거짓이면 나이를 검사합니다. 나이 검사 age < 14 || age > 120 이 참이면 rejected: age 를 출력하고 return; 으로 현재 호출을 끝냅니다. 거짓이면 정상 출력으로 갑니다. 정상 출력 email + "=" + age 를 출력하고 본문 끝에서 돌아갑니다. 이 경로에는 별도의 명시적 return; 이 없습니다. 각 종료는 현재 printMember 호출만 끝냅니다. 호출자인 main 의 다음 호출은 계속될 수 있습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 이메일 가드 null 또는 공백이면 rejected: email을 출력합니다. |
|
|
| 나이 가드 유효 email에서 age<14 또는 >120을 검사합니다. |
|
|
| 정상과 복귀 남은 입력에 대해서는 email=age를 출력하고 main으로 돌아갑니다. |
|
|
여기서 return이 끝내는 것은 현재 실행 중인 printMember 호출 하나뿐입니다. 프로그램 전체가 끝나는 것이 아닙니다. 두 번째 호출이 return;으로 끝난 뒤에도 main은 다음 줄로 넘어가 세 번째 호출을 실행합니다.
같은 검사를 if와 else를 겹쳐서 쓸 수도 있습니다. 하지만 그러면 정상적인 경우의 코드가 if 안의 if 안쪽으로 들어가고, 검사가 하나 늘 때마다 들여쓰기도 한 단계씩 깊어집니다. 앞의 예제처럼 문제가 있는 경우를 먼저 걸러 내고 바로 return하면, 메서드의 나머지 부분은 '여기까지 왔으면 입력이 올바르다'는 전제 아래 들여쓰기 없이 쓸 수 있습니다. 이런 방식을 조기 반환(early return)이라고 하고, 앞쪽의 검사 if를 흔히 가드(guard)라고 부릅니다.
반환 타입이 있는 메서드에서는 return 뒤에 돌려줄 값을 적습니다. 이 값의 타입은 반환 타입에 대입할 수 있어야 합니다.
private static double average(int total, int count) {
if (count == 0) {
return 0.0;
}
return (double) total / count;
}count가 0이면 첫 번째 return에서 끝나므로 0으로 나누는 줄까지 가지 않습니다. 여기서 (double)을 빼고 return total / count;라고 써도 컴파일은 됩니다. int 결과는 double에 대입할 수 있기 때문입니다. 하지만 정수 나눗셈이 먼저 일어나므로 5와 2를 넘기면 2.5가 아니라 2.0이 돌아옵니다. 반환 타입이 double이라고 해서 나눗셈이 실수 나눗셈으로 바뀌지는 않습니다.
return을 잘못 쓰면 대부분 컴파일 단계에서 걸러집니다. void 메서드에서 return 1;처럼 값을 돌려주려 하면 unexpected return value, 반환 타입이 있는 메서드에서 값 없이 return;만 쓰면 missing return value 오류가 납니다. 또 같은 블록에서 return 바로 아래에 문장을 쓰면 unreachable statement 오류가 납니다. return이 실행되는 순간 메서드가 끝나므로 그 문장은 절대 실행될 수 없기 때문입니다.
메서드가 메서드를 부를 때: 호출 스택
메서드 안에서 다른 메서드를 부르면 실행은 어떤 순서로 진행될까요? 아래 프로그램은 main이 remainingCapacity를 부르고, remainingCapacity가 다시 normalizeRegisteredCount를 부릅니다. 용량 60에 회원 2명이 가입했을 때 남은 자리를 계산하는 프로그램이고, 실행 순서가 보이도록 메서드마다 번호를 붙여 출력합니다.
public final class CallStackTrace {
public static void main(String[] args) {
System.out.println("1 main enter");
int remaining = remainingCapacity(60, 2);
System.out.println("6 main result=" + remaining);
}
private static int remainingCapacity(int capacity, int registeredCount) {
System.out.println("2 remainingCapacity enter");
int normalized = normalizeRegisteredCount(registeredCount, capacity);
System.out.println("4 remainingCapacity resumed");
int result = capacity - normalized;
System.out.println("5 remainingCapacity return");
return result;
}
private static int normalizeRegisteredCount(int registeredCount, int capacity) {
System.out.println("3 normalizeRegisteredCount enter and return");
return Math.max(0, Math.min(registeredCount, capacity));
}
}1 main enter
2 remainingCapacity enter
3 normalizeRegisteredCount enter and return
4 remainingCapacity resumed
5 remainingCapacity return
6 main result=58normalizeRegisteredCount는 가입자 수가 0보다 작거나 용량보다 크게 잘못 들어오는 경우를 대비해 0 이상 용량 이하로 맞춰 줍니다. Math.min(registeredCount, capacity)는 두 값 중 작은 값을, Math.max(0, ...)는 그 결과와 0 중 큰 값을 돌려줍니다. 2는 이미 범위 안이므로 2가 그대로 돌아옵니다.
출력 번호가 1부터 6까지 차례로 나온 것은 번호를 실행 순서대로 붙였기 때문인데, 소스 코드에서 이 번호들이 어디에 있는지 보면 흥미롭습니다. 4번과 5번은 3번보다 위쪽에 적혀 있지만 3번보다 나중에 출력되었습니다. remainingCapacity는 normalizeRegisteredCount를 호출한 줄에서 멈춰 기다렸다가, 그 호출이 2를 돌려준 뒤에야 다음 줄로 넘어갔기 때문입니다. main도 마찬가지로 4번째 줄에서 remainingCapacity가 58을 돌려줄 때까지 기다렸다가 6번을 출력했습니다.
이렇게 기다리는 동안 자바는 각 호출이 어디까지 실행했는지, 그 호출의 매개변수와 지역 변수가 어떤 값인지를 기억해야 합니다. 이 정보를 호출마다 하나씩 묶은 것을 스택 프레임(stack frame)이라고 하고, 프레임을 쌓아 두는 곳을 호출 스택(call stack)이라고 합니다. 메서드를 호출하면 새 프레임이 맨 위에 쌓이고, 메서드가 끝나면 맨 위의 프레임이 사라지면서 바로 아래 프레임의 메서드가 멈췄던 곳부터 다시 실행됩니다. 접시를 쌓을 때처럼 마지막에 올린 것을 가장 먼저 꺼내므로, 이런 구조를 후입선출(LIFO, last in first out)이라고 합니다.
CallStackTrace가 실행되는 동안 호출 스택이 어떻게 바뀌는지 표로 정리하면 다음과 같습니다. 마지막 칸은 각 줄이 출력되는 순간 스택에 있는 프레임을 아래쪽부터 적은 것이고, 화살표의 맨 끝이 지금 실행 중인 메서드입니다.
| 출력 | 그 출력 전에 일어난 일 | 출력하는 순간의 호출 스택 (아래 → 위) |
|---|---|---|
| 1 | 프로그램이 시작되면 JVM이 main을 호출합니다. | main |
| 2 | main이 remainingCapacity(60, 2)를 호출하고 결과를 기다립니다. | main → remainingCapacity |
| 3 | remainingCapacity가 normalizeRegisteredCount(2, 60)를 호출하고 결과를 기다립니다. | main → remainingCapacity → normalizeRegisteredCount |
| 4, 5 | normalizeRegisteredCount가 2를 돌려주며 끝나고 normalized에 2가 대입됩니다. 출력 4와 5 사이에서 result는 60 - 2 = 58이 됩니다. | main → remainingCapacity |
| 6 | remainingCapacity가 58을 돌려주며 끝나고 remaining에 58이 대입됩니다. | main |
안쪽 호출이 반환되어야 바깥 호출이 다시 진행된다
main→remainingCapacity→normalizeRegisteredCount의 프레임이 쌓였다가 2와 58을 반환합니다. 기다리는 호출자와 재개되는 프레임을 고정 역할로 추적할 수 있습니다.
main과 remainingCapacity의 프레임은 하위 호출을 기다리는 동안 유지된다. normalizeRegisteredCount가 2를 반환한 뒤 계산이 재개되고 최종 58이 main에 전달된다.
안쪽 호출이 끝나면 기다리던 호출이 재개됩니다
출력 1~6을 따라 세 호출의 대기와 재개를 읽습니다. 반환값 2로 계산을 마친 뒤 최종값 58이 main에 전달됩니다.
1 · main 진입
main enter를 출력하고remainingCapacity(60, 2)를 호출합니다. main 프레임을 유지한 채 결과를 기다립니다.2 · remainingCapacity 진입
remainingCapacity enter를 출력하고normalizeRegisteredCount(2, 60)를 호출합니다. 이 계산 호출도 프레임을 유지한 채 기다립니다.3 · normalizeRegisteredCount 진입·반환
normalizeRegisteredCount enter and return을 출력합니다.Math.max(0, Math.min(2, 60))의 결과 2를 반환하고 가장 안쪽 호출이 먼저 끝납니다.4 · 계산 호출 재개
normalized = 2대입이 완료되고remainingCapacity resumed를 출력합니다. 이어result = 60 - 2를 계산합니다.5 · 계산 결과 반환
remainingCapacity return을 출력한 뒤 58을 반환합니다. 계산 호출이 끝나 main이 재개됩니다.6 · main 결과 출력
remaining = 58대입이 완료된 뒤main result=58을 출력합니다.
각 호출은 자기 매개변수와 지역 변수를 가집니다. static 메서드도 호출별 지역 변수 공간을 공유하지 않습니다.
이 예의 조건
- 실제 remainingCapacity(60,2) 호출을 추적한다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 안쪽 호출이 반환되어야 바깥 호출이 다시 진행된다 CallStackTrace의 세 호출 프레임과 출력 1~6의 재개 순서 main과 remainingCapacity의 프레임은 하위 호출을 기다리는 동안 유지된다. normalizeRegisteredCount가 2를 반환한 뒤 계산이 재개되고 최종 58이 main에 전달된다. 안쪽 호출이 끝나면 기다리던 호출이 재개됩니다 출력 1~6을 따라 세 호출의 대기와 재개를 읽습니다. 반환값 2로 계산을 마친 뒤 최종값 58이 main에 전달됩니다. 1 · main 진입 main enter 를 출력하고 remainingCapacity(60, 2) 를 호출합니다. main 프레임을 유지한 채 결과를 기다립니다. 2 · remainingCapacity 진입 remainingCapacity enter 를 출력하고 normalizeRegisteredCount(2, 60) 를 호출합니다. 이 계산 호출도 프레임을 유지한 채 기다립니다. 3 · normalizeRegisteredCount 진입·반환 normalizeRegisteredCount enter and return 을 출력합니다. Math.max(0, Math.min(2, 60)) 의 결과 2 를 반환하고 가장 안쪽 호출이 먼저 끝납니다. 4 · 계산 호출 재개 normalized = 2 대입이 완료되고 remainingCapacity resumed 를 출력합니다. 이어 result = 60 - 2 를 계산합니다. 5 · 계산 결과 반환 remainingCapacity return 을 출력한 뒤 58 을 반환합니다. 계산 호출이 끝나 main이 재개됩니다. 6 · main 결과 출력 remaining = 58 대입이 완료된 뒤 main result=58 을 출력합니다. 각 호출은 자기 매개변수와 지역 변수를 가집니다. static 메서드도 호출별 지역 변수 공간을 공유하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| main 호출 remainingCapacity에 60과 2를 전달합니다. |
|
|
| 중첩 호출 normalizeRegisteredCount(2,60)이 2를 계산합니다. |
|
|
| 바깥 재개 2를 받아 60−2를 계산합니다. |
|
|
| main 재개 58을 remaining에 보관합니다. |
|
|
안쪽 호출이 반환되어야 바깥 호출이 다시 진행된다
main→remainingCapacity→normalizeRegisteredCount의 프레임이 쌓였다가 2와 58을 반환합니다. 기다리는 호출자와 재개되는 프레임을 고정 역할로 추적할 수 있습니다.
main과 remainingCapacity의 프레임은 하위 호출을 기다리는 동안 유지된다. normalizeRegisteredCount가 2를 반환한 뒤 계산이 재개되고 최종 58이 main에 전달된다.
안쪽 호출이 끝나면 기다리던 호출이 재개됩니다
출력 1~6을 따라 세 호출의 대기와 재개를 읽습니다. 반환값 2로 계산을 마친 뒤 최종값 58이 main에 전달됩니다.
1 · main 진입
main enter를 출력하고remainingCapacity(60, 2)를 호출합니다. main 프레임을 유지한 채 결과를 기다립니다.2 · remainingCapacity 진입
remainingCapacity enter를 출력하고normalizeRegisteredCount(2, 60)를 호출합니다. 이 계산 호출도 프레임을 유지한 채 기다립니다.3 · normalizeRegisteredCount 진입·반환
normalizeRegisteredCount enter and return을 출력합니다.Math.max(0, Math.min(2, 60))의 결과 2를 반환하고 가장 안쪽 호출이 먼저 끝납니다.4 · 계산 호출 재개
normalized = 2대입이 완료되고remainingCapacity resumed를 출력합니다. 이어result = 60 - 2를 계산합니다.5 · 계산 결과 반환
remainingCapacity return을 출력한 뒤 58을 반환합니다. 계산 호출이 끝나 main이 재개됩니다.6 · main 결과 출력
remaining = 58대입이 완료된 뒤main result=58을 출력합니다.
각 호출은 자기 매개변수와 지역 변수를 가집니다. static 메서드도 호출별 지역 변수 공간을 공유하지 않습니다.
이 예의 조건
- 실제 remainingCapacity(60,2) 호출을 추적한다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 안쪽 호출이 반환되어야 바깥 호출이 다시 진행된다 CallStackTrace의 세 호출 프레임과 출력 1~6의 재개 순서 main과 remainingCapacity의 프레임은 하위 호출을 기다리는 동안 유지된다. normalizeRegisteredCount가 2를 반환한 뒤 계산이 재개되고 최종 58이 main에 전달된다. 안쪽 호출이 끝나면 기다리던 호출이 재개됩니다 출력 1~6을 따라 세 호출의 대기와 재개를 읽습니다. 반환값 2로 계산을 마친 뒤 최종값 58이 main에 전달됩니다. 1 · main 진입 main enter 를 출력하고 remainingCapacity(60, 2) 를 호출합니다. main 프레임을 유지한 채 결과를 기다립니다. 2 · remainingCapacity 진입 remainingCapacity enter 를 출력하고 normalizeRegisteredCount(2, 60) 를 호출합니다. 이 계산 호출도 프레임을 유지한 채 기다립니다. 3 · normalizeRegisteredCount 진입·반환 normalizeRegisteredCount enter and return 을 출력합니다. Math.max(0, Math.min(2, 60)) 의 결과 2 를 반환하고 가장 안쪽 호출이 먼저 끝납니다. 4 · 계산 호출 재개 normalized = 2 대입이 완료되고 remainingCapacity resumed 를 출력합니다. 이어 result = 60 - 2 를 계산합니다. 5 · 계산 결과 반환 remainingCapacity return 을 출력한 뒤 58 을 반환합니다. 계산 호출이 끝나 main이 재개됩니다. 6 · main 결과 출력 remaining = 58 대입이 완료된 뒤 main result=58 을 출력합니다. 각 호출은 자기 매개변수와 지역 변수를 가집니다. static 메서드도 호출별 지역 변수 공간을 공유하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| main 호출 remainingCapacity에 60과 2를 전달합니다. |
|
|
| 중첩 호출 normalizeRegisteredCount(2,60)이 2를 계산합니다. |
|
|
| 바깥 재개 2를 받아 60−2를 계산합니다. |
|
|
| main 재개 58을 remaining에 보관합니다. |
|
|
호출마다 지역 변수가 따로 있다
프레임은 메서드마다가 아니라 호출마다 하나씩 생깁니다. 그래서 같은 메서드를 두 번 호출하면 프레임도 두 개가 생기고, 각 프레임은 자기만의 매개변수와 지역 변수를 가집니다.
int first = remainingCapacity(60, 2);
int second = remainingCapacity(100, 25);첫 번째 호출의 capacity는 60이고 두 번째 호출의 capacity는 100입니다. 첫 번째 호출이 끝나면 그 프레임은 지역 변수와 함께 사라지고, 두 번째 호출은 새 프레임에서 시작합니다. 앞의 호출에서 쓰던 값이 다음 호출에 남아 있지 않습니다. static 메서드라고 해서 지역 변수를 여러 호출이 함께 쓰는 것도 아닙니다. 앞 글에서 main의 size와 change의 size가 이름만 같고 서로 다른 변수였던 것도 같은 이유입니다. 두 변수는 서로 다른 프레임에 있었습니다.
더 깊이: 스택이 넘치면
호출 스택에 쓸 수 있는 메모리는 한정되어 있습니다. 끝나지 않고 자기 자신을 계속 호출하는 메서드처럼 프레임이 끝없이 쌓이면, 공간이 바닥나는 순간 java.lang.StackOverflowError가 발생합니다. 메서드가 자기 자신을 호출하는 재귀와 스택 공간이 실제 메모리에서 어떻게 쓰이는지는 6장의 메모리 영역에서 다룹니다. 이 글의 표와 그림은 호출과 복귀의 순서만 나타낸 것이며, 실제 JVM이 메모리를 배치하는 방식을 그대로 그린 것은 아닙니다.
한 줄에 들어 있는 여러 번의 호출
호출 결과는 값처럼 쓸 수 있으므로, 어떤 메서드의 호출을 다른 메서드의 인수로 바로 넣을 수도 있습니다. 다음 한 줄에는 average와 total 두 호출이 들어 있고, total의 결과가 average의 첫 번째 인수가 됩니다.
double result = average(total(age, size), size);이 한 줄을 실행하려면 먼저 average에 넘길 인수의 값이 정해져야 합니다. 그래서 바깥의 average보다 안쪽의 total(age, size)가 먼저 호출됩니다. 실제 순서를 출력해 보면 다음과 같습니다.
public final class NestedCallOrder {
public static void main(String[] args) {
int[] age = {40, 50, 30};
int size = 3;
double result = average(total(age, size), size);
System.out.println("result=" + result);
}
private static int total(int[] age, int size) {
System.out.println("total called");
int result = 0;
for (int index = 0; index < size; index++) {
result += age[index];
}
System.out.println("total returns " + result);
return result;
}
private static double average(int total, int count) {
System.out.println("average called with " + total + ", " + count);
if (count == 0) {
return 0.0;
}
return (double) total / count;
}
}total called
total returns 120
average called with 120, 3
result=40.0total이 먼저 실행되어 120을 돌려주고, 그 120과 size의 값 3을 인수로 average가 호출됩니다. average가 40.0을 돌려주면 그 값이 result에 대입됩니다. 소스 코드에서는 average가 먼저 적혀 있지만 실행은 안쪽 호출부터 시작합니다. 인수가 여러 개일 때 자바는 인수를 왼쪽부터 오른쪽으로 차례로 계산합니다.
한 줄로 쓴 코드도 실제로는 여러 단계로 실행됩니다. 결과가 이상할 때 중간값을 확인하려면 호출을 나눠 변수에 담는 것이 좋습니다.
int sum = total(age, size);
double result = average(sum, size);두 코드는 같은 계산을 하지만, 나눈 쪽은 sum을 출력해 보거나 디버거로 그 값을 확인할 수 있습니다. 디버거는 프로그램을 원하는 줄에서 멈추고 변수의 값을 살펴보게 해 주는 도구입니다. 짧게 쓰는 것보다 중간 결과를 확인하기 쉬운 것이 중요할 때는 단계마다 이름을 붙여 두세요.
성공 여부를 반환할까, 새 회원 수를 반환할까
앞 글의 add는 새 회원 수를 돌려주고, 호출한 쪽이 그 값을 size에 다시 대입했습니다. 같은 기능을 다르게 나눌 수도 있습니다. 저장하는 메서드는 성공했는지 실패했는지만 boolean으로 알려 주고, size를 늘리는 일은 호출한 쪽이 맡는 방식입니다.
public final class MemberRegistryReturns {
public static void main(String[] args) {
String[] emails = new String[2];
int[] age = new int[2];
int size = 0;
if (store(emails, age, size, "[email protected]", 40)) {
size++;
}
if (store(emails, age, size, "", 30)) {
size++;
}
if (store(emails, age, size, "[email protected]", 50)) {
size++;
}
System.out.println("size=" + size);
System.out.println("total=" + total(age, size));
System.out.println("average=" + average(age, size));
}
private static boolean store(
String[] emails,
int[] age,
int index,
String email,
int value
) {
if (index < 0 || index >= emails.length || index >= age.length) {
return false;
}
if (email == null || email.isBlank() || value < 14 || value > 120) {
return false;
}
emails[index] = email;
age[index] = value;
return true;
}
private static int total(int[] values, int size) {
int result = 0;
for (int index = 0; index < size; index++) {
result += values[index];
}
return result;
}
private static double average(int[] values, int size) {
if (size == 0) {
return 0.0;
}
return (double) total(values, size) / size;
}
}size=2
total=90
average=45.0store는 넘겨받은 index 칸에 회원을 쓰고 성공하면 true를 돌려줍니다. 인덱스가 배열 범위를 벗어나거나 회원 정보가 올바르지 않으면 아무것도 쓰지 않고 false를 돌려줍니다. 두 번째 호출은 이메일이 빈 문자열이라 false를 받으므로 size가 늘지 않습니다. 그래서 세 번째 호출도 size가 1인 상태에서 실행되어, [email protected]은 빈칸 없이 인덱스 1에 저장됩니다. 출력의 size=2, 합계 90, 평균 45.0은 두 회원만 저장되었다는 뜻입니다.
average도 눈여겨볼 만합니다. 합계를 직접 계산하지 않고 total을 호출하므로, average가 실행되는 동안 호출 스택에는 main, average, total이 쌓입니다. 호출이 한 단계 더 깊어지는 대신 합계를 구하는 규칙은 total 한 곳에만 있습니다.
두 방식은 일을 나누는 방법이 다릅니다. add 방식은 저장과 회원 수 갱신을 메서드 하나가 결정하고, 호출한 쪽은 돌려받은 값을 대입하기만 합니다. store 방식은 저장만 메서드가 하고, 회원 수를 언제 늘릴지는 호출한 쪽이 정합니다. 그 대신 store 방식에서는 호출할 때마다 if로 결과를 확인하고 size++를 직접 써야 하므로, 이것을 빠뜨리면 다음 회원이 같은 칸을 덮어씁니다.
boolean 반환에는 한계도 있습니다. 실패했다는 사실만 알려 줄 뿐 왜 실패했는지는 알려 주지 못합니다. 화면에 '저장 공간이 가득 찼습니다'와 '이메일이 올바르지 않습니다'를 구분해 보여 줘야 한다면, 실패 이유마다 다른 값을 돌려줄 방법이 필요합니다. 이를 위해 정해진 상수 중 하나를 나타내는 열거형(enum)이나, 결과와 이유를 함께 담은 객체를 돌려주는 방법이 있습니다. 이런 실패 신호를 고르는 기준은 12장의 예외에서 다시 다룹니다.
실행 흐름을 손으로 따라가는 방법
호출이 겹쳐 헷갈릴 때는 종이에 표를 그려 한 줄씩 따라가 보세요. 다음 순서를 반복하면 됩니다.
- 지금 실행 중인 메서드와 줄을 적습니다.
- 메서드 호출을 만나면 돌아올 줄을 표시하고, 새 프레임을 맨 위에 그립니다.
- 매개변수와 지역 변수의 값은 해당 프레임 안에만 적습니다.
return을 만나면 돌려줄 값을 호출한 자리에 적고, 맨 위 프레임을 지웁니다.- 바로 아래 프레임에서 멈췄던 계산을 이어 갑니다.
예외가 발생했을 때 출력되는 스택 트레이스(stack trace)도 바로 이 호출 스택을 보여 줍니다. 아래는 회원이 없는데 평균을 계산하려다 0으로 나눈 예입니다. 이 프로그램의 average는 반환 타입이 int이고 count가 0인지 검사하지 않으므로, return total / count;에서 정수 나눗셈이 0으로 나누어집니다.
public final class StackTraceDemo {
public static void main(String[] args) {
int[] age = new int[3];
printSummary(age, 0);
}
private static void printSummary(int[] age, int size) {
int total = total(age, size);
System.out.println("average=" + average(total, size));
}
private static int total(int[] age, int size) {
int result = 0;
for (int index = 0; index < size; index++) {
result += age[index];
}
return result;
}
private static int average(int total, int count) {
return total / count;
}
}Exception in thread "main" java.lang.ArithmeticException: / by zero
at StackTraceDemo.average(StackTraceDemo.java:21)
at StackTraceDemo.printSummary(StackTraceDemo.java:9)
at StackTraceDemo.main(StackTraceDemo.java:4)첫 줄은 어떤 예외가 왜 발생했는지 알려 줍니다. 그 아래 at으로 시작하는 줄들은 예외가 발생한 순간의 호출 스택을 맨 위 프레임부터 보여 줍니다. 예외는 21번째 줄의 average에서 발생했고, average는 9번째 줄의 printSummary가, printSummary는 4번째 줄의 main이 호출했습니다. 앞에서 그린 호출 스택 표를 위아래로 뒤집어 놓은 모양입니다.
total은 목록에 없습니다. 예외가 발생하기 전에 이미 0을 돌려주고 끝나서, 그 프레임이 스택에서 사라졌기 때문입니다. 스택 트레이스를 읽을 때는 맨 위에서부터 내가 작성한 코드가 처음 나오는 줄을 찾아 원인을 살펴보고, 아래로 내려가며 문제의 값이 어디서 넘어왔는지 거슬러 올라가면 됩니다. 여기서는 average가 count 0을 받았고, 그 0은 printSummary를 거쳐 main이 넘긴 size였습니다.
정리
- 반환 타입이 있는 메서드는 정상적으로 끝나는 모든 경로에서 그 타입의 값을 돌려줘야 합니다. 컴파일러는 실제로 들어올 인수나 조건의 의미를 따지지 않고 코드 구조만 보고 판단합니다.
return은 현재 메서드 호출을 즉시 끝내고 호출한 곳으로 돌아갑니다. 앞부분에서 잘못된 입력을 걸러 내고 바로return하면 정상 경로의 코드가 깊이 들여쓰기 되지 않습니다.- 메서드를 호출할 때마다 매개변수와 지역 변수를 담은 프레임이 호출 스택에 쌓이고, 메서드가 끝나면 그 프레임이 사라지며 바로 아래 메서드가 멈췄던 곳부터 이어서 실행됩니다.
- 호출을 인수로 넣으면 안쪽 호출이 먼저 실행됩니다. 중간값을 확인해야 할 때는 호출을 나눠 변수에 담습니다.
- 예외의 스택 트레이스는 예외가 발생한 순간의 호출 스택을 위에서부터 보여 줍니다.
연습 문제
1. 나이대 판별 메서드
나이를 받아 65 이상이면 senior, 18 이상이면 adult, 14 이상이면 underage, 그 외에는 invalid를 돌려주는 메서드를 작성하세요. main에서 100, 60, 20, 0을 차례로 넣어 결과를 출력합니다.
해설 보기
public final class MemberAgeGroup {
public static void main(String[] args) {
int[] samples = {100, 60, 20, 0};
for (int age : samples) {
System.out.println(age + "=" + level(age));
}
}
private static String level(int age) {
if (age >= 65) {
return "senior";
}
if (age >= 18) {
return "adult";
}
if (age >= 14) {
return "underage";
}
return "invalid";
}
}100=senior
60=adult
20=adult
0=invalid큰 기준부터 검사했으므로 100은 첫 번째 return에서 끝나고 아래 조건은 확인하지 않습니다. 60은 첫 조건을 통과하지 못하고 두 번째 조건에서 adult를 받습니다. 0은 세 조건을 모두 통과하지 못해 마지막 줄의 invalid에 도달합니다. 이 마지막 return이 없으면 missing return statement 오류가 납니다.
검사 순서를 바꿔 age >= 14를 맨 앞에 두면 100, 60, 20 모두 첫 조건에서 underage를 받고 끝나 버립니다. 조기 반환을 쓸 때는 앞의 return이 뒤의 조건을 가린다는 점을 생각해 검사 순서를 정해야 합니다.
2. 실행 순서 예상하기
다음 프로그램이 출력하는 다섯 줄을 실행하기 전에 순서대로 적어 보세요. inner가 호출되는 순간의 호출 스택도 함께 적어 보세요.
public final class TraceOrder {
public static void main(String[] args) {
System.out.println("A");
int result = outer(3);
System.out.println("E " + result);
}
private static int outer(int n) {
System.out.println("B " + n);
int doubled = inner(n) * 2;
System.out.println("D " + doubled);
return doubled;
}
private static int inner(int n) {
System.out.println("C " + n);
return n + 1;
}
}해설 보기
A
B 3
C 3
D 8
E 8main이 A를 출력하고 outer(3)을 호출합니다. outer는 B 3을 출력한 뒤 inner(n) * 2를 계산하려고 inner(3)을 먼저 호출하고, 그동안 outer는 이 줄에서 기다립니다. 이때 호출 스택은 아래부터 main, outer, inner 순서입니다. inner는 C 3을 출력하고 4를 돌려줍니다. 다시 실행되는 outer는 4 * 2 = 8을 doubled에 담아 D 8을 출력하고 8을 돌려주며, 마지막으로 main이 E 8을 출력합니다.
inner와 outer의 매개변수는 둘 다 이름이 n이지만 서로 다른 프레임에 있는 별개의 변수입니다. 이 프로그램에서는 outer가 자기 n의 값 3을 inner에 넘겼으므로 두 변수의 값이 마침 같을 뿐입니다.
다음 글
지금까지는 메서드 이름마다 메서드가 하나씩만 있었습니다. 자바에서는 매개변수 목록만 다르면 같은 이름의 메서드를 여러 개 선언할 수 있습니다. 메서드 오버로딩과 자동 형변환에서는 인수의 타입이 어떤 메서드가 호출될지를 어떻게 결정하는지 살펴봅니다.