점프 문 - `break`, `continue`, `return`, `goto`
점프 문 - break, continue, return, goto
점프 문은 조건을 따지지 않고 무조건 제어 흐름을 다른 위치로 옮기는 문입니다. 흐름을 끊고 싶을 때 어디로 이동할지 명확하게 지정해 주죠. C#의 점프 문으로는 break, continue, return, goto가 있는데, 하나씩 어떤 상황에서 쓰면 좋은지 살펴볼게요.
출처: Microsoft Learn
본문
점프 문은 무조건적으로 제어를 전달합니다. break 문은 자신을 감싸는 가장 가까운 반복 문이나 switch 문을 종료합니다. 편의상 라벨(label)이 붙은 반복 문이나 switch 문도 마찬가지로 종료시킬 수 있죠. continue 문은 자신을 감싸는 가장 가까운 반복 문의 새 반복을 시작합니다. return 문은 자신이 들어 있는 함수의 실행을 끝내고 제어를 호출한 쪽으로 돌려줍니다. goto 문은 라벨로 표시된 다른 문으로 제어를 이동시킵니다.
예외를 던지면서 역시 무조건적으로 제어를 전달하는 throw 문에 대한 내용은 예외 처리 문 문서의 The throw statement 단락을 참고하세요.
이 문서의 예시는 C#의 최초 버전부터 쓰인 기능을 기준으로 합니다. 버전별로 새로 추가된 기능은 해당 단락에서 따로 설명할게요.
break 문
break 문은 자신을 감싸는 가장 가까운 반복 문 — 즉 for, foreach, while, do 반복 — 이나 switch 문을 종료합니다. break 문은 제어를, 종료된 문 뒤에 따라오는 문으로 옮깁니다(그런 문이 있다면요).
int[] numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
foreach (int number in numbers)
{
if (number == 3)
{
break;
}
Console.Write($"{number} ");
}
Console.WriteLine();
Console.WriteLine("End of the example.");
// Output:
// 0 1 2
// End of the example.
숫자가 3이 되는 순간 break가 실행되면서 foreach 반복이 통째로 끝나고, 반복 밖의 다음 문인 Console.WriteLine()으로 넘어가요. 반복을 도중에 멈추고 싶을 때 이렇게 씁니다.
반복이 중첩되어 있으면 break 문은 자신이 직접 들어 있는 가장 안쪽 반복만 종료합니다. 바깥쪽 반복에는 아무 영향도 주지 않아요. 다음 예시를 볼게요.
for (int outer = 0; outer < 5; outer++)
{
for (int inner = 0; inner < 5; inner++)
{
if (inner > outer)
{
break;
}
Console.Write($"{inner} ");
}
Console.WriteLine();
}
// Output:
// 0
// 0 1
// 0 1 2
// 0 1 2 3
// 0 1 2 3 4
여기서 break는 안쪽 for 반복만 멈추죠. 바깥 outer 반복은 계속 돌면서 다음 행을 처리해요. 그래서 결과가 삼각형 모양으로 나옵니다.
중첩 반복을 한꺼번에 빠져나가고 싶을 때가 있죠. C# 15부터 break 문은 빠져나갈 대상인 바깥 반복 문이나 switch 문을 가리키는 라벨을 선택적으로 지정할 수 있게 되었어요. 라벨은 대상 구문 바로 앞에 붙입니다. outer: for (...) {...}처럼요. 그러고 나서 안쪽 구문에서 break outer;를 쓰면, 그 라벨이 붙은 구문의 뒤에 오는 문으로 제어가 이동합니다. 아래 예시를 볼게요.
string[,] stockChecks =
{
{ "Clear", "Clear", "Clear" },
{ "Clear", "Blocked", "Clear" },
{ "Clear", "Clear", "Clear" }
};
outer: for (int aisle = 0; aisle < stockChecks.GetLength(0); aisle++)
{
for (int bay = 0; bay < stockChecks.GetLength(1); bay++)
{
Console.WriteLine($"Checking aisle {aisle}, bay {bay}.");
if (stockChecks[aisle, bay] == "Blocked")
{
Console.WriteLine("Blocked bay found. Stop the inspection.");
break outer;
}
}
}
// Output:
// Checking aisle 0, bay 0.
// Checking aisle 0, bay 1.
// Checking aisle 0, bay 2.
// Checking aisle 1, bay 0.
// Checking aisle 1, bay 1.
// Blocked bay found. Stop the inspection.
재고 검사 중에 "Blocked" 칸을 만나면 안쪽 반복만 멈추는 게 아니라, outer라는 라벨이 붙은 바깥 for 반복까지 한 번에 종료돼요. 검사를 즉시 중단하는 상황에 딱 맞는 표현이죠.
반복 안에서 switch 문을 쓰면, switch 구역 끝의 break 문은 switch 문만 빠져나갑니다. 그 switch를 담고 있는 반복은 건드리지 않아요. 다음 예시처럼요.
double[] measurements = [-4, 5, 30, double.NaN];
foreach (double measurement in measurements)
{
switch (measurement)
{
case < 0.0:
Console.WriteLine($"Measured value is {measurement}; too low.");
break;
case > 15.0:
Console.WriteLine($"Measured value is {measurement}; too high.");
break;
case double.NaN:
Console.WriteLine("Failed measurement.");
break;
default:
Console.WriteLine($"Measured value is {measurement}.");
break;
}
}
// Output:
// Measured value is -4; too low.
// Measured value is 5.
// Measured value is 30; too high.
// Failed measurement.
여기서 각 case의 break는 해당 switch 구역을 끝낼 뿐이에요. 그래서 foreach는 계속 돌면서 모든 측정값을 처리합니다.
continue 문
continue 문은 자신을 감싸는 가장 가까운 반복 문 — 즉 for, foreach, while, do 반복 — 의 새 반복을 시작합니다. 현재 반복의 나머지 부분을 건너뛰고 다음 반복으로 넘어가고 싶을 때 쓰죠. 다음 예시를 볼게요.
for (int i = 0; i < 5; i++)
{
Console.Write($"Iteration {i}: ");
if (i < 3)
{
Console.WriteLine("skip");
continue;
}
Console.WriteLine("done");
}
// Output:
// Iteration 0: skip
// Iteration 1: skip
// Iteration 2: skip
// Iteration 3: done
// Iteration 4: done
i가 3보다 작을 동안 continue가 실행되면서 Console.WriteLine("done")을 건너뛰고 바로 다음 반복으로 넘어가요. 그래서 처음 세 번은 "skip", 그 뒤 두 번은 "done"이 출력됩니다.
break와 마찬가지로 C# 15부터 continue 문도 계속할 대상인 바깥 반복 문을 가리키는 라벨을 선택적으로 지정할 수 있어요. 대상은 반드시 반복 문이어야 하고, switch 문은 대상이 될 수 없습니다. 라벨을 대상 반복 바로 앞에 붙인 뒤, 안쪽 구문에서 continue outer;를 쓰면 그 반복의 다음 반복이 시작돼요. 다음 예시를 볼게요.
string[][] dailyRoutes =
[
["North", "West"],
["South", "Bridge closed", "East"],
["Central", "Harbor"]
];
outer: for (int day = 0; day < dailyRoutes.Length; day++)
{
Console.WriteLine($"Day {day + 1}:");
foreach (string route in dailyRoutes[day])
{
if (route == "Bridge closed")
{
Console.WriteLine(" Bridge closed; move to the next day.");
continue outer;
}
Console.WriteLine($" Schedule the {route} route.");
}
Console.WriteLine(" All routes scheduled.");
}
// Output:
// Day 1:
// Schedule the North route.
// Schedule the West route.
// All routes scheduled.
// Day 2:
// Schedule the South route.
// Bridge closed; move to the next day.
// Day 3:
// Schedule the Central route.
// Schedule the Harbor route.
// All routes scheduled.
2일차에 "Bridge closed" 경로를 만나면 continue outer;가 실행돼서 바깥 for 반복의 다음 반복(3일차)으로 바로 넘어가요. 그래서 2일차의 "All routes scheduled"은 출력되지 않죠.
라벨이 붙은 break와 라벨이 붙은 continue 모두, 라벨이 붙은 문에 바로 이웃한 문만 그 라벨을 갖습니다. 예를 들어 a: b: while (...) {...}처럼 적으면 while 문의 라벨은 b이지 a가 아니에요.
라벨이 붙은 점프 문은, 중첩 반복을 통해 바깥으로 break나 continue를 전파하는 Boolean 플래그나, 반복들을 통째로 건너뛰는 **goto**를 자주 대체합니다. IDE0410 스타일 규칙이 그런 패턴을 감지해서, 더 명확한 라벨 break나 continue로 바꿔 쓰도록 제안해 줍니다.
return 문
return 문은 자신이 들어 있는 함수의 실행을 종료합니다. 함수의 결과값이 있으면 그 값까지 함께 제어를 호출한 쪽으로 돌려주죠.
함수 멤버가 값을 계산하지 않는다면, 표현식 없이 return 문만 씁니다. 다음 예시를 볼게요.
Console.WriteLine("First call:");
DisplayIfNecessary(6);
Console.WriteLine("Second call:");
DisplayIfNecessary(5);
void DisplayIfNecessary(int number)
{
if (number % 2 == 0)
{
return;
}
Console.WriteLine(number);
}
// Output:
// First call:
// Second call:
// 5
DisplayIfNecessary는 값을 돌려주지 않는 void 함수예요. 짝수일 때는 return;으로 함수를 일찍 끝내서 아무것도 출력하지 않고, 홀수일 때만 넘어와서 숫자를 출력하죠.
앞 예시에서 보듯, 표현식 없는 return 문은 일반적으로 함수 멤버를 일찍 종료할 때 씁니다. 함수 멤버에 return 문이 아예 없다면 마지막 문이 실행된 뒤 자연스럽게 종료됩니다.
함수 멤버가 값을 계산한다면 표현식과 함께 return 문을 씁니다. 다음 예시를 볼게요.
double surfaceArea = CalculateCylinderSurfaceArea(1, 1);
Console.WriteLine($"{surfaceArea:F2}"); // output: 12.57
double CalculateCylinderSurfaceArea(double baseRadius, double height)
{
double baseArea = Math.PI * baseRadius * baseRadius;
double sideArea = 2 * Math.PI * baseRadius * height;
return 2 * baseArea + sideArea;
}
CalculateCylinderSurfaceArea는 계산 결과를 return 문의 표현식으로 돌려줘요. 호출한 쪽에서는 이 값을 surfaceArea 변수에 담아 사용합니다.
return 문이 표현식을 갖는다면, 그 표현식은 함수 멤버의 반환 타입으로 암시적으로 변환될 수 있어야 합니다. 단, async 함수라면 얘기가 좀 달라져요. async 함수에서 반환되는 표현식은 함수의 반환 타입인 xref:System.Threading.Tasks.Task`1 또는 xref:System.Threading.Tasks.ValueTask`1의 타입 인수로 암시적으로 변환될 수 있어야 합니다. 그리고 async 함수의 반환 타입이 xref:System.Threading.Tasks.Task 또는 xref:System.Threading.Tasks.ValueTask라면, 표현식 없이 return 문을 씁니다.
Ref 반환(Ref returns)
기본적으로 return 문은 표현식의 값을 돌려줍니다. 그런데 변수에 대한 **참조(reference)**를 돌려줄 수도 있어요. 참조 반환값(혹은 ref 반환)이란 메서드가 호출자에게 값을 참조로 돌려주는 것을 말합니다. 즉 호출자가 메서드가 돌려준 값을 수정하면, 그 변경이 호출된 메서드 쪽 객체의 상태에도 그대로 반영되죠. 이렇게 하려면 return 문을 ref 키워드와 함께 쓰면 됩니다. 다음 예시를 볼게요.
int[] xs = new int [] {10, 20, 30, 40 };
ref int found = ref FindFirst(xs, s => s == 30);
found = 0;
Console.WriteLine(string.Join(" ", xs)); // output: 10 20 0 40
ref int FindFirst(int[] numbers, Func<int, bool> predicate)
{
for (int i = 0; i < numbers.Length; i++)
{
if (predicate(numbers[i]))
{
return ref numbers[i];
}
}
throw new InvalidOperationException("No element satisfies the given condition.");
}
FindFirst는 조건에 맞는 요소의 참조를 돌려주는 ref int 함수예요. 호출자는 found라는 ref int 지역 변수에 그 참조를 담고, found = 0;으로 값을 바꿉니다. 그러면 원래 배열 xs의 해당 위치 값이 실제로 30에서 0으로 바뀌어서 10 20 0 40이 출력되는 거죠.
정리하자면, 참조 반환값은 메서드가 변수 자체의 **별칭(alias)**을 돌려주는 방식이에요. 호출자는 돌려받은 변수를 값으로 다룰지 참조로 다룰지 고를 수 있고, 돌려받은 값의 참조인 새 변수, 곧 ref 지역 변수를 만들 수도 있습니다. 여기서 핵심은 이러해요.
- 참조 반환값이 가리키는 변수의 범위(scope)는 메서드를 포함해야 하고
- 그 변수의 수명(lifetime)은 메서드 반환 이후까지 이어져야 합니다.
- 호출자가 메서드의 반환값을 수정하면, 그 변경은 메서드가 돌려준 바로 그 변수에 적용됩니다.
메서드가 참조 반환값을 돌려준다고 선언했다는 건, 곧 메서드가 어떤 변수의 별칭을 돌려준다는 뜻이에요. 설계 의도는 대개 호출 코드가 그 변수에 별칭을 통해 접근하도록 하는 것, 그리고 그걸 수정하도록 하는 겁니다. 참조로 반환하는 메서드는 반환 타입이 void일 수 없어요.
호출자가 객체의 상태를 수정하려면, 참조 반환값을 참조 변수로 명시적으로 선언된 변수에 저장해야 합니다.
ref 반환값은 호출된 메서드 범위에 있는 다른 변수의 별칭이에요. 그래서 ref 반환의 어떤 사용이든, 자신이 별칭을 가리키는 변수를 쓰는 것으로 해석할 수 있습니다.
- 값을 할당하면 별칭이 가리키는 변수에 값을 할당하는 것이고
- 값을 읽으면 별칭이 가리키는 변수의 값을 읽는 것이며
- 그 값을 참조로 반환하면 같은 변수의 별칭을 반환하는 것이고
- 다른 메서드에 참조로 넘기면 별칭이 가리키는 변수의 참조를 넘기는 것이며
- ref 지역 변수 별칭을 만들면 같은 변수에 새 별칭을 하나 더 만드는 겁니다.
ref 반환은 호출 메서드에 대해 ref-safe-context여야 합니다. 다시 말해 다음 조건을 만족해야 해요.
- 반환값의 수명이 메서드 실행 이후까지 이어져야 합니다. 즉, 반환하는 메서드 안의 지역 변수일 수 없어요. 클래스의 인스턴스 필드나 정적 필드일 수도 있고, 메서드로 전달된 인수일 수도 있습니다. 지역 변수를 반환하려 하면 컴파일러 오류 CS8168, "Can't return local 'obj' by reference because it isn't a ref local."가 발생합니다.
- 반환값은 리터럴
null일 수 없습니다. 단, ref 반환 메서드가 현재 값이null(초기화되지 않은 값)인 변수의 별칭이나, 값 타입의 nullable 값 타입의 별칭을 돌려주는 것은 허용됩니다. - 반환값은 상수, 열거형 멤버, 프로퍼티의 값 반환, 또는
class나struct의 메서드일 수 없습니다.
게다가 async 메서드에서는 참조 반환값을 사용할 수 없어요. 비동기 메서드는 실행이 끝나기 전에 반환될 수 있는데, 그 시점엔 반환값이 아직 정해지지 않았기 때문이죠.
참조 반환값을 돌려주는 메서드는 다음 두 조건을 반드시 지켜야 합니다.
다음은 그 조건을 모두 만족하면서 p라는 Person 객체의 참조를 돌려주는 메서드의 예시입니다.
public ref Person GetContactInformation(string fname, string lname)
{
// ...method implementation...
return ref p;
}
다음은 ref 반환을 좀 더 전체적으로 보여주는 예시로, 메서드 시그니처와 메서드 본문을 모두 담았어요.
public static ref int Find(int[,] matrix, Func<int, bool> predicate)
{
for (int i = 0; i < matrix.GetLength(0); i++)
for (int j = 0; j < matrix.GetLength(1); j++)
if (predicate(matrix[i, j]))
return ref matrix[i, j];
throw new InvalidOperationException("Not found");
}
Find는 2차원 행렬에서 조건을 만족하는 요소를 찾아 그 참조를 돌려주는 메서드예요. 찾는 요소가 없으면 예외를 던집니다.
호출된 메서드는 반환값을 ref readonly로 선언해서 참조로 값을 돌려주되, 호출 코드가 그 값을 수정할 수 없도록 강제할 수도 있어요. 호출 메서드는 돌려받은 값을 지역 ref readonly 참조 변수에 저장해서 복사를 피할 수 있습니다.
다음 예시는 두 개의 xref:System.String 필드 Title과 Author를 가진 Book 클래스를 정의하고, Book 객체의 private 배열을 담은 BookCollection 클래스도 정의해요. 개별 책 객체들은 GetBookByTitle 메서드를 호출해 참조로 돌려받습니다.
public class Book
{
public string Author;
public string Title;
}
public class BookCollection
{
private Book[] books = { new Book { Title = "Call of the Wild, The", Author = "Jack London" },
new Book { Title = "Tale of Two Cities, A", Author = "Charles Dickens" }
};
private Book nobook = null;
public ref Book GetBookByTitle(string title)
{
for (int ctr = 0; ctr < books.Length; ctr++)
{
if (title == books[ctr].Title)
return ref books[ctr];
}
return ref nobook;
}
public void ListBooks()
{
foreach (var book in books)
{
Console.WriteLine($"{book.Title}, by {book.Author}");
}
Console.WriteLine();
}
}
여기서 주목할 점은 GetBookByTitle이 Book을 값으로 돌려주는 대신 ref Book을 돌려준다는 거예요. 그래서 호출자가 돌려받은 책 객체를 수정하면 그 변경이 BookCollection 내부 배열에 그대로 반영됩니다.
호출자가 GetBookByTitle 메서드가 돌려준 값을 ref 지역 변수로 저장하면, 호출자가 반환값에 가한 변경이 BookCollection 객체에 그대로 반영됩니다. 다음 예시처럼요.
var bc = new BookCollection();
bc.ListBooks();
ref var book = ref bc.GetBookByTitle("Call of the Wild, The");
if (book != null)
book = new Book { Title = "Republic, The", Author = "Plato" };
bc.ListBooks();
// The example displays the following output:
// Call of the Wild, The, by Jack London
// Tale of Two Cities, A, by Charles Dickens
//
// Republic, The, by Plato
// Tale of Two Cities, A, by Charles Dickens
ref var book으로 받아온 책 참조에 새 Book 객체를 할당하자, 배열 안의 원본까지 함께 바뀌었어요. 첫 번째 책이 "Republic, The, by Plato"로 교체된 걸 출력에서 확인할 수 있죠.
goto 문
goto 문은 라벨로 표시된 다른 문으로 제어를 옮깁니다. 다음 예시를 볼게요.
var matrices = new Dictionary<string, int[][]>
{
["A"] =
[
[1, 2, 3, 4],
[4, 3, 2, 1]
],
["B"] =
[
[5, 6, 7, 8],
[8, 7, 6, 5]
],
};
CheckMatrices(matrices, 4);
void CheckMatrices(Dictionary<string, int[][]> matrixLookup, int target)
{
foreach (var (key, matrix) in matrixLookup)
{
for (int row = 0; row < matrix.Length; row++)
{
for (int col = 0; col < matrix[row].Length; col++)
{
if (matrix[row][col] == target)
{
goto Found;
}
}
}
Console.WriteLine($"Not found {target} in matrix {key}.");
continue;
Found:
Console.WriteLine($"Found {target} in matrix {key}.");
}
}
// Output:
// Found 4 in matrix A.
// Not found 4 in matrix B.
행렬에서 목표값 4를 찾으면 goto Found;로 점프해 Found: 라벨의 문을 실행해요. 찾지 못하고 반복이 끝나면 Not found를 출력하죠. 앞 예시에서 보듯, goto 문으로 중첩 반복을 빠져나가는 용도로 쓸 수 있습니다.
[!TIP] 중첩 반복을 다룰 때는 한 번 고려해 볼 점이 있어요. 각각의 반복을 별도의 메서드로 분리해 보는 겁니다.
goto문 없이도 더 단순하고 읽기 좋은 코드가 될 수 있어요.
goto 문을 switch 문 안에서 써서, 상수 case 라벨이 있는 switch 구역으로 제어를 옮길 수도 있어요. 다음 예시를 볼게요.
using System;
public enum CoffeeChoice
{
Plain,
WithMilk,
WithIceCream,
}
public class GotoInSwitchExample
{
public static void Main()
{
Console.WriteLine(CalculatePrice(CoffeeChoice.Plain)); // output: 10.0
Console.WriteLine(CalculatePrice(CoffeeChoice.WithMilk)); // output: 15.0
Console.WriteLine(CalculatePrice(CoffeeChoice.WithIceCream)); // output: 17.0
}
private static decimal CalculatePrice(CoffeeChoice choice)
{
decimal price = 0;
switch (choice)
{
case CoffeeChoice.Plain:
price += 10.0m;
break;
case CoffeeChoice.WithMilk:
price += 5.0m;
goto case CoffeeChoice.Plain;
case CoffeeChoice.WithIceCream:
price += 7.0m;
goto case CoffeeChoice.Plain;
}
return price;
}
}
WithMilk를 선택하면 우유 값 5.0을 더한 뒤 goto case CoffeeChoice.Plain;으로 점프해서 Plain 구역의 10.0까지 더해 15.0이 되고, WithIceCream은 7.0 + 10.0 = 17.0이 돼요. goto case로 서로 다른 case 구역의 처리를 이어 붙이는 패턴이죠.
switch 문 안에서는 goto default; 문을 써서 default 라벨이 붙은 switch 구역으로 제어를 옮길 수도 있습니다.
만약 현재 함수 멤버에 그 이름의 라벨이 없거나, goto 문이 그 라벨의 범위(scope) 안에 있지 않으면 컴파일 오류가 발생해요. 그리고 goto 문으로 현재 함수 멤버를 벗어나거나, 어떤 중첩 범위 안으로 들어갈 수는 없습니다.
더 알아보기
yield문- IDE0410: Use labeled jump statement
- C# 언어 사양의 관련 단락:
break문,continue문,goto문,return문