반환과 실행 흐름
return과 반환 타입의 계약을 지키고 중첩된 메서드 호출의 실행 순서와 호출 스택을 추적합니다.
메서드를 나누면 코드는 짧아지지만 실행 순서는 파일의 위에서 아래와 달라집니다.
호출한 메서드로 이동했다가 결과와 함께 돌아오기 때문입니다.
이 절에서는 return의 두 역할, 반환 타입의 규칙, 중첩 호출이 쌓이고 빠지는 순서를 Study Log 계산으로 확인합니다.
반환 타입과 실행 경로
public final class MissingReturn {
public static void main(String[] args) {
System.out.println(level(50));
}
private static String level(int minutes) {
if (minutes >= 60) {
return "long";
}
if (minutes >= 30) {
return "steady";
}
}
}error: missing return statement반환 타입이 String이면 정상적으로 끝나는 모든 경로가 String 값을 반환해야 합니다.
minutes가 30보다 작을 때 두 if를 모두 지나 본문 끝에 도달하므로 계약을 지키지 못합니다.
마지막 기본 결과를 추가하면 해결됩니다.
private static String level(int minutes) {
if (minutes >= 60) {
return "long";
}
if (minutes >= 30) {
return "steady";
}
return "short";
}컴파일러는 호출 시점의 실제 값이 늘 50인지 추측해 누락을 허용하지 않습니다.
메서드는 허용된 모든 int 인수에 대해 선언한 반환 타입의 결과를 제공해야 합니다.
return의 두 역할
return은 단순히 결과 상자에 값을 넣는 문법이 아닙니다.
실행 즉시 현재 메서드를 종료해 호출 지점으로 돌아갑니다.
그래서 잘못된 입력을 초기에 거르는 경계로도 사용할 수 있습니다.
public final class EarlyReturn {
public static void main(String[] args) {
printSession("arrays", 40);
printSession("", 30);
printSession("methods", -5);
}
private static void printSession(String topic, int minutes) {
if (topic == null || topic.isBlank()) {
System.out.println("rejected: topic");
return;
}
if (minutes <= 0) {
System.out.println("rejected: minutes");
return;
}
System.out.println(topic + "=" + minutes);
}
}arrays=40
rejected: topic
rejected: minutes반환값이 없는 void 메서드에서는 return;으로 끝냅니다.
첫 검증이 실패하면 그 아래 시간 검증과 출력은 실행되지 않습니다.
정상 경로가 여러 겹의 else 안에 갇히지 않아 읽기 쉬워지는 장점도 있습니다.
반환값이 있는 메서드는 return 표현식;을 사용하며 표현식의 타입은 반환 타입에 대입 가능해야 합니다.
private static double average(int total, int count) {
if (count == 0) {
return 0.0;
}
return (double) total / count;
}return 뒤에 같은 블록에서 무조건 실행될 문장을 두면 도달 불가능 코드가 되어 컴파일 오류가 납니다.
반환은 분기가 아니라 종료라는 사실을 기억합니다.
호출 스택
public final class CallStackTrace {
public static void main(String[] args) {
System.out.println("1 main enter");
int score = studyScore(40, 2);
System.out.println("6 main result=" + score);
}
private static int studyScore(int minutes, int streak) {
System.out.println("2 studyScore enter");
int base = normalize(minutes);
System.out.println("4 studyScore resumed");
int result = base + streak * 10;
System.out.println("5 studyScore return");
return result;
}
private static int normalize(int minutes) {
System.out.println("3 normalize enter and return");
return Math.max(minutes, 0);
}
}1 main enter
2 studyScore enter
3 normalize enter and return
4 studyScore resumed
5 studyScore return
6 main result=60main이 studyScore를 호출하면 main의 다음 실행 위치와 지역 변수 상태가 호출 스택에 남습니다.
studyScore가 normalize를 호출하면 그 위에 새 호출이 쌓입니다.
normalize가 반환되면 가장 최근 호출부터 제거되고 studyScore의 대입문이 계속됩니다.
studyScore가 반환된 뒤에야 main의 println이 실행됩니다.
호출 스택은 후입선출 구조입니다. 마지막에 호출된 메서드가 먼저 끝납니다. 각 호출은 자기 매개변수와 지역 변수를 따로 가집니다. 같은 메서드를 두 번 호출해도 지역 변수 공간은 공유되지 않습니다.
int first = studyScore(40, 2);
int second = studyScore(10, 1);첫 번째 호출의 minutes와 두 번째 호출의 minutes는 서로 다른 호출 프레임에 있습니다.
static 메서드라고 해서 지역 변수가 전역으로 공유되는 것은 아닙니다.
중첩 호출의 실행 순서
double result = average(total(minutes, size), size);대입을 완료하려면 먼저 average의 인수가 필요합니다.
첫 인수인 total(minutes, size)가 호출되어 int 결과를 돌려주고, 그 결과와 size로 average가 호출됩니다.
마지막 double 결과가 result에 저장됩니다.
한 줄이더라도 실행은 여러 호출 단계입니다.
복잡한 오류를 추적할 때는 중간 결과를 변수로 분리하면 흐름을 관찰하기 쉽습니다.
int sum = total(minutes, size);
double result = average(sum, size);두 코드는 같은 계산을 하지만 디버거 중단점이나 임시 출력으로 sum을 확인할 수 있습니다. 짧은 한 줄보다 오류 위치가 중요한 경우 단계 이름을 남깁니다.
반환값과 실행 결과
배열 기반 추가 메서드는 새 size를 반환할 수도 있지만, 성공 여부를 boolean으로 반환하고 size 증가는 호출자가 맡게 할 수도 있습니다.
두 방식은 책임 배치가 다릅니다.
public final class StudyLogReturns {
public static void main(String[] args) {
String[] topics = new String[2];
int[] minutes = new int[2];
int size = 0;
if (store(topics, minutes, size, "arrays", 40)) {
size++;
}
if (store(topics, minutes, size, "", 30)) {
size++;
}
if (store(topics, minutes, size, "methods", 50)) {
size++;
}
System.out.println("size=" + size);
System.out.println("total=" + total(minutes, size));
System.out.println("average=" + average(minutes, size));
}
private static boolean store(
String[] topics,
int[] minutes,
int index,
String topic,
int value
) {
if (index < 0 || index >= topics.length || index >= minutes.length) {
return false;
}
if (topic == null || topic.isBlank() || value <= 0) {
return false;
}
topics[index] = topic;
minutes[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.0빈 주제는 store가 false를 반환하므로 size가 증가하지 않습니다.
다음 정상 기록은 여전히 index 1에 저장됩니다.
average가 total을 호출하면서 호출 스택이 한 단계 더 쌓이지만, 합계 규칙은 한 메서드에만 존재합니다.
boolean은 실패 이유를 전달하지 못한다는 한계가 있습니다.
현재 범위에서는 성공/실패만 필요하지만, 화면에 용량 초과와 잘못된 주제를 다르게 보여 줘야 한다면 상태 코드, enum, 결과 객체 같은 더 풍부한 반환 모델을 이후에 고려합니다.
실행 흐름 추적법
중첩 호출이 헷갈릴 때 다음 표를 한 줄씩 작성합니다.
- 현재 실행 중인 메서드와 문장 번호를 적습니다.
- 호출을 만나면 돌아올 문장을 표시하고 새 프레임을 위에 쌓습니다.
- 매개변수와 지역 변수 값을 해당 프레임에만 기록합니다.
return을 만나면 반환값을 호출문 자리에 놓고 프레임을 제거합니다.- 호출한 쪽의 다음 연산을 계속합니다.
예외의 스택 추적도 같은 구조를 반대로 보여 줍니다.
첫 줄은 실제 예외와 발생 위치이고, 아래의 at 목록은 그 메서드를 호출한 경로입니다.
가장 위의 사용자 코드부터 원인을 읽고 호출 인수가 어떻게 만들어졌는지 거슬러 올라갑니다.
연습 문제
분 단위 학습 시간을 받아 90 이상은 deep, 45 이상은 focused, 1 이상은 started, 그 외에는 invalid를 반환하는 메서드를 작성하세요.
main에서 100, 60, 20, 0을 차례로 호출해 출력합니다.
해설 보기
public final class SessionLevel {
public static void main(String[] args) {
int[] samples = {100, 60, 20, 0};
for (int minutes : samples) {
System.out.println(minutes + "=" + level(minutes));
}
}
private static String level(int minutes) {
if (minutes >= 90) {
return "deep";
}
if (minutes >= 45) {
return "focused";
}
if (minutes >= 1) {
return "started";
}
return "invalid";
}
}100=deep
60=focused
20=started
0=invalid큰 경계부터 검사했기 때문에 100은 첫 return에서 끝나고 아래 조건을 확인하지 않습니다.
작은 경계부터 minutes >= 1을 검사했다면 100도 started가 되어 잘못된 결과가 납니다.
조기 반환의 순서도 규칙의 일부입니다.