goto 문
goto의 함수 내부 레이블 점프, 초기화 우회 제한, 스코프 이탈 시 객체 파괴와 구조적인 대안을 정확히 구분합니다.
goto는 같은 함수 안의 레이블(label)로 실행을 조건 없이 옮기는 문장입니다. 앞 글의 break와 continue가 가장 안쪽의 반복문이나 switch라는 정해진 대상으로만 이동하는 것과 달리, goto는 함수 안의 어느 레이블로든 앞으로도 뒤로도 이동할 수 있습니다.
그만큼 출발점과 도착점 사이에 있는 초기화, 상태 갱신, 정리 코드를 건너뛰기 쉽고, 코드를 읽는 사람은 레이블까지의 경로를 일일이 추적해야 합니다. 이 글은 goto의 규칙과 금지되는 점프, 스코프를 벗어날 때 객체가 정리되는 방식을 보고, 새 코드에서는 지금까지 배운 구조적 제어문으로 대신하는 방법을 정리합니다. 기존 코드에서 goto를 만났을 때 읽고 점검하는 데 필요한 내용이 중심입니다.
C++ · GOTO SCOPE CONTRACT
goto의 합법성과 자원 정리는 따로 확인한다
goto는 현재 함수 안의 레이블로만 이동합니다. 합법적인 도착점인지와 점프 중 활성 상태가 달라지는 automatic 객체를 함께 확인해야 합니다.
| 감사 항목 | 언어 계약 | 코드에서 확인할 결과 |
|---|---|---|
| 도착 레이블 | 식별자 레이블은 현재 함수 안에 있어야 하며 같은 함수에서 이름이 중복될 수 없음 | 다른 함수나 전역 목적지로 이동할 수 없음 |
| 스코프 진입 | 도착점에서 새로 활성화될 automatic 변수의 초기화가 모두 vacuous하지 않다면 jump로 진입할 수 없음 | std::string 같은 비자명한 초기화를 건너뛰는 코드는 ill-formed |
| 스코프 이탈 | 이미 생성됐지만 도착점에서는 활성 상태가 아닌 automatic 객체를 생성의 역순으로 파괴 | goto도 정상적인 소멸자 호출을 억제하지 않음 |
| raw pointer 소유권 | pointer 변수의 파괴는 가리키는 동적 배열에 delete[]를 수행하지 않음 |
수동 해제 경로가 없으면 누수; RAII owner로 교체 |
- 도착점 · 현재 함수의 레이블
- 레이블은 함수 안에서 고유해야 합니다. 다른 함수나 전역 위치로 점프할 수 없습니다.
- 진입 · 초기화 우회 제한
- 도착점에서 새로 활성화될 변수의 초기화가 모두 vacuous한 경우가 아니라면 그 선언을 건너뛰어 들어갈 수 없습니다.
- 이탈 · automatic 객체 파괴
- 스코프를 나가며 이미 생성된 객체를 역순으로 파괴합니다.
goto도 소멸자를 억제하지 않습니다. - 소유권 · raw pointer는 해제하지 않음
- pointer 변수의 수명 종료와 동적 배열의
delete[]는 별개입니다. RAII owner가 누수 경로를 없앱니다.
금지되는 진입
goto ready;
std::string text = "ready";
ready: // ill-formed
use(text);
도착점에서 필요한 text 초기화를 점프가 우회합니다.
허용되는 이탈
{
File file{"log"};
if (failed) goto done;
}
done: report();
점프 전에 생성된 file은 스코프를 나갈 때 파괴된 뒤 레이블에 도착합니다.
점프의 합법성과 자원 안전성은 별도 질문입니다. 합법적인 goto도 수동 delete[]나 상태 커밋을 건너뛸 수 있으므로, 소유권은 RAII 객체로 표현하고 부수 효과를 경로별로 감사합니다.
goto와 레이블
레이블은 식별자 뒤에 콜론을 붙여 문장 앞에 둡니다. goto 레이블;을 실행하면 그 레이블이 붙은 문장으로 바로 이동합니다.
#include <iostream>
void legacy(bool skip) {
if (skip) {
goto done;
}
std::cout << "work ";
done:
std::cout << "finish\n";
}
void structured(bool skip) {
if (!skip) {
std::cout << "work ";
}
std::cout << "finish\n";
}
int main() {
legacy(true);
structured(true);
legacy(false);
structured(false);
}finish
finish
work finish
work finishlegacy(true)는 goto done으로 work 출력을 건너뛰고 레이블 done의 finish만 출력합니다. structured는 같은 출력을 if 하나로 만들며, work를 언제 출력하는지가 그 자리에 바로 드러납니다. goto 버전은 레이블이 어디 있는지 찾아 내려가야 무엇을 건너뛰는지 알 수 있습니다.
레이블의 이름은 한 함수 안에서 중복될 수 없고, 레이블은 goto보다 뒤에 있어도 됩니다. 하지만 goto의 도착점은 반드시 현재 함수 안에 있어야 하며, 다른 함수의 레이블이나 함수 밖으로는 이동할 수 없습니다.
초기화를 건너뛰는 점프는 금지됩니다
같은 함수 안이라도 모든 점프가 허용되지는 않습니다. 점프가 지역 변수의 선언을 건너뛰어 그 변수의 범위 안으로 들어가면, 도착점에서는 쓸 수 있는 변수가 초기화되지 않은 채 남게 됩니다. 그래서 C++은 초기화가 있는 선언을 건너뛰어 그 범위 안으로 들어가는 점프를 컴파일 오류(ill-formed)로 정합니다.
#include <string>
void print_ready() {
goto ready; // 컴파일 오류
std::string message = "ready";
ready:
(void)message;
}goto ready는 message의 선언을 건너뛰어 message를 쓸 수 있는 위치로 들어가므로, GCC는 점프가 초기화를 건너뛴다는 오류를 냅니다. 조건문 switch에서 case 안에 변수를 선언할 때 중괄호가 필요했던 것도 같은 규칙 때문입니다. case 레이블로의 이동도 점프이므로, 다른 case의 초기화를 건너뛸 수 없습니다. 예제에서 레이블 뒤에 둔 (void)message;는 message의 값을 void로 바꾸어 버리는, 아무 일도 하지 않는 문장입니다. C++20에서는 레이블 뒤에 문장이 하나 있어야 하므로 이 줄을 두었습니다.
예외는 초기화가 아무 일도 하지 않는 선언입니다. 표준은 이를 공허한 초기화(vacuous initialization)라고 부르며, 초깃값 없는 int n;처럼 기본 초기화되는 기본 타입 변수가 여기에 해당합니다. int n = 0;이나 std::string s;는 실제로 값을 만드는 초기화가 있으므로 건너뛸 수 없습니다. 건너뛸 수 있는 int n;도 값이 정해지지 않은 상태이므로, 점프 뒤에 값을 넣기 전에 읽으면 안 됩니다.
스코프를 벗어날 때의 정리
반대로 goto로 블록을 빠져나가는 것은 허용되며, 이때 그 블록에서 이미 만들어진 지역 객체는 만들어진 순서의 역순으로 정리됩니다. 이것은 return, break, continue로 블록을 벗어날 때와 같은 규칙입니다. 다음 조각에서 File과 Lock은 파일과 잠금을 맡는 클래스라고 가정합니다.
void write_log(bool failed) {
{
File file{"result.log"};
Lock lock{file};
if (failed) {
goto done;
}
file.write("ok");
} // 정상 경로: lock, file 순서로 정리
done: // 점프 경로도 lock, file 순서로 정리된 뒤 도착
report();
}failed가 참이면 file.write("ok")는 건너뛰지만, 블록을 벗어나면서 lock과 file은 만들어진 순서의 역순으로 정리됩니다. 객체가 사라질 때 자원을 정리하도록 만드는 방식을 RAII(Resource Acquisition Is Initialization)라고 하며, 클래스의 소멸자와 함께 뒤의 장에서 자세히 다룹니다. 이렇게 자원을 객체의 수명에 묶어 두면 어떤 경로로 블록을 벗어나도 정리가 빠지지 않습니다.
문제는 정리가 객체가 아니라 직접 적은 호출에 맡겨져 있을 때 생깁니다. 동적 메모리 할당에서 볼 new로 만든 메모리는 포인터 변수가 사라져도 해제되지 않으므로, 점프가 delete[] 호출을 건너뛰면 메모리가 새어 나갑니다. 아래 두 조각의 use와 report_error도 다른 곳에 선언된 함수라고 가정합니다.
void process_data(bool failed) {
int* data = new int[100];
if (failed) {
goto done;
}
use(data);
delete[] data;
return;
done:
report_error(); // delete[]를 건너뛰어 메모리가 새어 나감
}이 누수는 goto가 포인터의 정리를 막아서 생긴 것이 아닙니다. 포인터 변수는 정상적으로 사라지지만, 포인터는 가리키는 메모리를 해제하지 않기 때문입니다. 메모리를 std::unique_ptr 같은 객체에 맡기면 모든 경로에서 해제되므로, 정리용 레이블이 필요 없어집니다.
#include <memory>
void process_data(bool failed) {
auto data = std::make_unique<int[]>(100);
if (failed) {
report_error();
return;
}
use(data.get());
} // 어느 return 경로에서든 data가 메모리를 해제std::make_unique<int[]>(100)은 int 100개짜리 배열을 만들고, 이렇게 배열을 맡은 unique_ptr는 소멸할 때 delete[]로 해제합니다. use는 원시 포인터(raw pointer)를 받으므로 data.get()으로 주소만 건네고, 해제할 권리는 계속 data가 가집니다. 그래서 일찍 반환하는 경로에도 끝까지 실행하는 경로에도 해제 코드를 따로 적지 않습니다.
더 깊이: GCC의 계산된 goto
GCC는 표준에 없는 확장으로 레이블의 주소를 값으로 다루는 기능을 제공합니다. 레이블 이름 앞에 단항 &&를 붙이면 그 레이블의 주소를 void*로 얻고, goto *식;으로 그 주소로 이동하는 계산된 goto(computed goto)를 할 수 있습니다. 레이블 주소를 배열에 담아 인덱스로 고르면 switch와 비슷한 점프 표가 되며, GCC 문서는 명령을 차례로 해석하는 인터프리터가 다음 동작으로 빠르게 넘어가는 데 이 방법을 쓸 수 있다고 소개합니다.
이 확장에는 표준 goto와 다른 제한이 있습니다. 다른 함수의 레이블로 이동해서는 안 되고, 함수가 인라인 확장되거나 복제되면 같은 레이블의 주소가 서로 다른 값이 될 수 있습니다. 특히 GNU C++에서는 일반 goto와 달리 계산된 goto가 범위를 벗어나는 객체의 소멸자를 호출하지 않으므로, 이 글에서 본 RAII에 의한 정리를 기대할 수 없습니다. 그래서 GCC 문서도 switch가 더 깔끔하니 문제가 switch에 잘 맞지 않을 때만 이 방법을 쓰라고 권합니다.
목적에 맞는 좁은 구조 고르기
goto로 하려던 일은 대부분 범위가 더 좁은 구조로 표현할 수 있습니다. 아래 그림은 의도별로 먼저 고를 구조를 정리한 것입니다.
C++ · NARROWEST CONTROL BOUNDARY
제어 목적에 맞는 범위와 소유권을 고른다
점프 목적을 가장 좁게 표현하는 문법을 고릅니다. 반복 제어, 함수 종료, 자원 정리는 서로 다른 책임이며 하나의 정리 레이블로 합칠 이유가 없습니다.
| 의도 | 우선 구조 | 실제 경계와 점검 |
|---|---|---|
| 가장 가까운 대상 문장 종료 | break |
이 장의 일반 제어문에서는 가장 안쪽 반복문 또는 switch만 끝내고 그 다음 문장으로 이동 |
| 현재 반복의 나머지 생략 | continue |
가장 안쪽 반복문의 반복 계속 지점으로 이동; switch는 대상이 아님 |
| 중첩 탐색 작업 전체 종료 | helper 함수 + return |
탐색의 성공·실패를 함수 결과로 표현하고 모든 나가는 스코프의 automatic 객체를 파괴 |
| 작은 기존 구조에 종료 신호 전달 | 상태 값 + 바깥 조건 | 수정 범위는 작지만 신호 검사가 여러 단계에 퍼지는지 확인 |
| 메모리·파일·잠금 정리 | RAII owner | unique_ptr, container, handle 객체의 수명으로 정리; raw cleanup label에 의존하지 않음 |
남아 있는 레거시 goto |
출발점·레이블·경로 감사 | 현재 함수 대상, 초기화 우회, 역순 파괴, 건너뛴 수동 부수 효과를 모두 확인 |
break· 대상 문장 하나 종료- 이 장의 일반 제어문에서는 가장 안쪽 반복문 또는
switch만 끝냅니다. continue· 현재 반복 생략- 가장 안쪽 반복문의 반복 계속 지점으로 갑니다.
switch는 대상이 아닙니다. - helper +
return· 작업 전체 종료 - 중첩 탐색을 함수 책임으로 묶고 결과와 종료 이유를 함께 표현합니다.
- 상태 값 · 작은 기존 구조 보완
- 바깥 반복마다 신호 검사가 퍼져 오히려 흐름을 숨기지 않는지 확인합니다.
- RAII · 자원 수명 정리
- 메모리, 파일, 잠금은 owner 객체가 모든 구조적 종료 경로에서 정리합니다.
- 레거시
goto· 네 경계 감사 - 현재 함수 레이블, 초기화 우회, automatic 객체 파괴, 수동 부수 효과 누락을 확인합니다.
수동 소유권 경로
new[] → 여러 점프 경로 → 경로마다 delete[] 필요
한 경로라도 해제가 없으면 누수입니다. pointer 변수의 소멸은 동적 배열을 해제하지 않습니다.
RAII 소유권 경로
make_unique<T[]> → 조기 return → owner 소멸
제어 경로와 정리 경로를 분리하지 않고 객체 수명 하나로 소유권을 닫습니다.
새 코드에서 goto는 “여러 단계를 한 번에 빠져나가는 편의”보다 제어 범위를 숨기는 비용이 큽니다. 기존 코드에 남겨야 한다면 합법성뿐 아니라 건너뛴 상태 변경과 수동 정리까지 경로별로 검증합니다.
| 하려는 일 | 먼저 고를 구조 |
|---|---|
가장 가까운 반복문이나 switch 끝내기 | break |
| 현재 회차의 나머지 건너뛰기 | continue |
| 중첩 탐색 전체 끝내기 | 탐색을 함수로 나누고 return |
| 작은 기존 코드에서 바깥 루프까지 끝내기 | 표시 변수와 바깥 조건 |
| 메모리, 파일, 잠금 정리 | RAII 객체 |
중첩 루프 전체를 끝내려고 goto를 쓰고 싶어지면, 그 탐색을 함수로 나누는 것을 먼저 생각합니다. 함수의 return은 모든 루프를 한 번에 끝내고, 찾았는지를 결과로 돌려줄 수 있습니다.
#include <vector>
bool contains(const std::vector<std::vector<int>>& grid, int target) {
for (const auto& row : grid) {
for (int value : row) {
if (value == target) {
return true;
}
}
}
return false;
}return true는 두 루프를 모두 끝내고, 끝까지 찾지 못하면 false를 돌려줍니다. 함수와 std::vector는 뒤의 장에서 자세히 다루지만, 여러 단계를 끝내는 일이 함수 하나의 결과가 된다는 점이 핵심입니다.
이미 있는 코드에서 goto를 만나면 네 가지를 차례로 확인합니다. 도착 레이블이 같은 함수 안에 있는지, 점프가 초기화가 있는 선언을 건너뛰어 들어가지 않는지, 빠져나가는 블록의 지역 객체가 역순으로 정리된다는 점을 반영했는지, 그리고 점프 사이에 delete[]나 close()처럼 객체에 맡기지 않은 정리 호출이 빠지지 않는지입니다. 그다음 위 표의 구조로 바꿀 수 있는지 검토합니다.
정리
goto는 현재 함수 안의 레이블로만 이동하며, 레이블 이름은 함수 안에서 고유해야 합니다.- 초기화가 있는 선언을 건너뛰어 그 변수의 범위 안으로 들어가는 점프는 컴파일 오류이며,
switch의case이동에도 같은 규칙이 적용됩니다. - 점프로 블록을 벗어나면 그 블록에서 만들어진 지역 객체는 역순으로 정리됩니다.
- 직접 적은
delete[]같은 정리 호출은 점프가 건너뛸 수 있으므로, 자원은std::unique_ptr같은 RAII 객체에 맡깁니다. - 새 코드에서는
break,continue, 함수의return, RAII로 의도를 표현하고, 기존goto는 도착점, 초기화, 정리 순서, 빠지는 호출을 점검합니다.
연습 문제
1. 조건을 뒤집으면
goto_vs_if.cpp의 structured에서 if (!skip)만 if (skip)로 바꾸면, main의 두 structured 호출은 각각 무엇을 출력하나요?
해설 보기
structured(true)는 work finish, structured(false)는 finish를 출력합니다. legacy와 출력이 서로 뒤바뀌므로 두 함수는 더 이상 같은 일을 하지 않습니다. if로 쓴 코드는 조건 하나만 보면 이 차이가 보이지만, goto로 쓴 코드는 레이블까지의 경로를 따라가야 같은 판단을 할 수 있습니다.
2. 정리 순서
scope_exit.cpp에서 failed가 참이면, 블록 안의 두 객체는 어떤 순서로 정리되고 어떤 호출을 건너뛰나요?
해설 보기
file을 먼저, lock을 나중에 만들었으므로 lock, file 순서로 정리된 뒤 done의 report()로 갑니다. 건너뛰는 것은 file.write("ok") 호출이며, 이미 만들어진 객체의 정리는 건너뛰지 않습니다.
3. 건너뛸 수 있는 선언
goto 다음과 레이블 사이에 다음 선언이 하나씩 있고, 레이블 뒤에서 그 변수의 범위가 이어진다고 합시다. int n;, int n = 0;, std::string s; 가운데 컴파일 오류가 되는 것은 무엇인가요?
해설 보기
int n = 0;과 std::string s;가 오류입니다. int n = 0;은 초깃값이 있고, std::string s;는 초깃값이 없어도 기본 생성자가 빈 문자열을 만드는 초기화를 하기 때문입니다. int n;은 아무 일도 하지 않는 공허한 초기화여서 건너뛸 수 있지만, 그 뒤에 값을 넣기 전에 n을 읽으면 안 됩니다.
이것으로 4장의 제어문을 마칩니다. 다음 장의 1차원 배열에서는 같은 타입의 값 여러 개를 한 이름으로 묶어 저장하고, 이 장에서 배운 반복문으로 차례로 다루는 방법을 봅니다.