C 언어 시리즈 #03 — 제어 흐름: 분기·스위치 테이블·setjmp와 스택
이 글의 핵심
분기문이 어셈블리로 어떻게 깔릴 수 있는지, longjmp가 왜 호출 규약·프레임 체인과 충돌할 수 있는지, “멋진 트릭”이 왜 현대 코드베이스에서 쓰이지 않는지 정리합니다.
시리즈 안내
#03 | 📋 전체 목차 | 이전: #02 타입 · 다음: #04 함수·호출 규약
제어 흐름 글인데, 교과서 순서(if부터)로 가면 졸린다. 그래서 이번 편은 재미있는 것부터—goto, break, continue—밀고, if / for 같은 건 뒤로 미뤘다. 공룡책 스타일 표는 빼고, 팀에서 겪은 이야기랑 예제로 대신한다.
먼저 goto — 팀에서 한번은 싸워본다
예전 팀에서 goto 쓸지 말지로 며칠간 갈등한 적이 있다. 한쪽은 “리눅스 커널도 쓰는데 뭘”이고, 다른 쪽은 “스파게티만 남는다”였다. 결론만 말하면 정리(cleanup) 블록 하나로 모을 때만 허용하고, 그 밖에는 함수 쪼개기·에러 코드로 가자고 합의했다. 감정 소모는 있었지만, 이후 코드 리뷰에서 “이 goto는 out: 한 군데로만 가니까 OK” 같은 말이 통했다.
goto label;는 같은 함수 안의 label:으로만 점프한다. 다른 함수로는 못 간다—거기서는 return이나 상태 머신, 필요하면 setjmp 쪽을 본다.
정리 패턴은 말로 풀면 이렇다. 열기 실패할 때마다 직전까지 연 것만 역순으로 닫기 싫으면, 실패 지점마다 goto fail; 한 방으로 한 덩어리 정리 구간으로 보낸다. 드라이버·OpenSSL 스타일 코드에서 자주 보인다.
int setup(void) {
int fd = -1;
void *p = NULL;
if (open_f(&fd) != 0) goto out;
if (alloc_p(&p) != 0) goto out_fd;
return 0;
out_fd: close_f(fd);
out: return -1;
}
남용하면 그물망이 된다. 레이블이 둘셋 넘어가면 “이건 함수로 빼자” 쪽을 한번 의심해 보면 된다.
break — 한 겹만 끊는다
break는 가장 안쪽 switch나 while / do / for 딱 한 단계만 빠져나온다. 이중 for 돌다가 “찾았다” 하고 싶으면, 안쪽 break는 안쪽 루프만 끝낸다—바깥은 그대로 도는 거 알지?
그래서 나온 전략이 몇 가지 있다. 플래그를 두고 바깥 조건에 !found를 걸거나, 그냥 find_in_matrix() 같은 걸로 빼서 찾으면 return 해버리거나, 팀에서 OK면 깊은 중첩 한정으로 goto found; 같은 것도 있다. Java 스타일 “바깥 루프까지 break”는 C에 없다.
for (size_t i = 0; i < n; i++) {
for (size_t j = 0; j < m; j++) {
if (M[i][j] == key) {
/* 여기 break는 j 루프만 종료 */
}
}
}
continue — 이번 턴만 건너뛰기
continue는 가장 안쪽 루프에서 “나머지 본문은 이번에 안 할게”라는 뜻이다. 여기서 함정 하나: for에서 continue를 치면, 통상 세 번째 절(step)이 돈 다음에 조건으로 간다. while은 조건만 다시 보러 가니까, continue 아래에만 i++를 두면 영원히 같은 i로 도는 버그가 난다. 인덱스 루프는 step을 for의 세 번째 칸에 몰아 넣는 편이 실수가 적다.
for (size_t i = 0; i < n; i++) {
if (skip(a[i]))
continue;
use(a[i]);
}
switch — 점프 테이블은 “있을 수도”
switch는 정수 같은 값 하나를 보고 case 레이블로 들어간다. break 없으면 아래 case로 그냥 떨어진다(fall-through)—의도면 /* fall through */ 주석 달아 두는 게 관례다.
밀집된 정수 케이스면 컴파일러가 간접 점프 테이블을 만들 수도 있다. 값이 듬성하거나 범위가 넓으면 if 사슬이나 이진 탐색 쪽이 낫다. “우리 switch는 무조건 테이블이다”라고 믿지 말고, 병목이면 측정한다—위 FAQ와 같다.
switch (cmd) {
case 'q': quit(); break;
case 'h': help(); break;
default: unknown(); break;
}
enum 전체를 스위치로 돌릴 때 default로 이상한 값을 흡수해 두는 것도 좋다. C에서는 열거 변수에 이상한 int가 들어올 수 있으니까.
이제 지루한 쪽 — if와 else
여기부터는 기본기. C에서 조건은 0이면 거짓, 0이 아니면 참이다. if (expression) 한 번, else로 갈라지기—다들 알잖아.
dangling else만 짚자. else는 문법상 가장 가까운 if에 붙는다. 들여쓰기랑 다를 수 있어서, 이런 그림이 나온다.
if (a)
if (b)
do_something();
else
other(); /* else는 if(b)에 붙음—들여쓰기랑 다를 수 있음 */
의도를 바로 잡으려면 중괄호로 범위를 닫는 것이 좋다.
if (a) {
if (b)
do_something();
} else {
other();
}
else if 사슬은 부호나 구간 나눌 때 쓰고, 정수 상수가 쫙 있으면 위에서 말한 switch가 읽기 나을 때가 있다.
단락 평가도 기억해 둘 것. if (p && p->len > 0)에서 p가 널이면 p->len은 평가되지 않는다. 조건에 부작용(a++ 같은 것)을 박아 넣으면 리뷰어가 피곤해지니, 가급적 조건은 순수에 가깝게 유지한다.
조건 연산자 ?:
condition ? expr1 : expr2 — 둘 중 하나만 평가된다. 짧으면 좋고, 중첩으로 괴물 되면 if로 쪼갠다.
int m = (a > b) ? a : b;
반복 — while, do-while, for
while (condition)은 들어가기 전에 조건을 본다. 처음부터 거짓이면 본문 0번.
while (condition)
body;
do { } while (condition);은 본문을 한 번은 돌고 나서 조건을 본다. 프롬프트·재시도 같은 데 잘 맞는다.
int n;
do {
n = read_int();
} while (n < 1 || n > 10);
for (init; condition; step)은 익숙한 패턴—특히 인덱스 증가를 세 번째 칸에 두면 continue와도 잘 맞는다. for(;;)와 while(1)은 취향 차이이고, while (true)는 stdbool.h가 있을 때.
for (init; condition; step)
body;
쉼표로 i = 0, j = n - 1 같이 몰아넣을 수는 있으나, 지나치게 복잡하면 함수로 빼는 편이 낫다.
중첩에서 탈출할 때
위에서 말한 대로 플래그, 함수로 추출, 또는 제한된 goto를 쓴다. 예시는 짧게:
int found = 0;
for (i = 0; i < n && !found; i++)
for (j = 0; j < m && !found; j++)
if (a[i][j] == key) found = 1;
핫 루프·Duff’s device·분기 예측 (심화)
호이스팅: 루프마다 똑같이 나오는 strlen 같은 것은 밖으로 빼면 경로가 줄어든다. 컴파일러가 대신해 주는 경우도 많으나, 디버그 빌드나 희미한 부작용이 있으면 소스에 명시하는 것도 방법이다.
/* 의심: strlen이 매 루프 */
for (i = 0; i < strlen(s); i++) use(s[i]);
size_t L = strlen(s);
for (i = 0; i < L; i++) use(s[i]);
Duff’s device는 switch를 do-while 안에 끼워 넣어 수동 언롤하던 옛 트릭이다. 오늘은 컴파일러 -O3가 강하므로, 신규 프로덕션에서 권장하기는 어렵다—리뷰·검증 비용이 크다.
CPU 분기 예측은 핫 루프에서 방향이 들쭉날쭉하면 미스가 난다. 소스에서 할 수 있는 일은 데이터 정렬, 분기 줄이기, 0/1 마스크로 대체(가독성·UB와 트레이드오프) 정도이며, 최종은 벤치다.
실전 예제 몇 가지
입력 검증—scanf 실패하면 버퍼에 쓰레기가 남아서 다음 턴이 막힌다. getchar로 줄을 비운다.
int read_int_in_range(int lo, int hi) {
int x;
for (;;) {
printf("[%d,%d] 정수: ", lo, hi);
if (scanf("%d", &x) == 1 && x >= lo && x <= hi) return x;
int c;
while ((c = getchar()) != '\n' && c != EOF) { }
puts("잘못된 입력/범위.");
}
}
메뉴 루프—continue로 잘못된 입력만 건너뛴다.
void menu(void) {
for (;;) {
int ch;
puts("0=종료 1=Foo 2=Bar");
if (scanf("%d", &ch) != 1) { flush_line(); continue; }
switch (ch) {
case 0: return;
case 1: do_foo(); break;
case 2: do_bar(); break;
default: puts("?"); break;
}
}
}
선형 탐색과 정렬된 배열의 이진 탐색:
int *linsearch(int *a, size_t n, int k) {
for (size_t i = 0; i < n; i++) if (a[i] == k) return a + i;
return NULL;
}
int *bsearch_eq(int *a, size_t n, int k) {
size_t lo = 0, hi = n;
while (lo < hi) {
size_t mid = lo + (hi - lo) / 2; /* 오버플로 방지 */
if (a[mid] == k) return a + mid;
if (a[mid] < k) lo = mid + 1; else hi = mid;
}
return NULL;
}
mid = (lo+hi)/2는 size_t에서 큰 범위에서 이론상 위험할 수 있어 lo + (hi-lo)/2 관용을 쓴다—#02 참고.
패턴 하나씩
센티널: '\0', EOF, NULL처럼 끝을 알리는 값. 플래그: 여러 조건을 ok 하나로 모을 때. 상태 머신: enum + switch로 다음 상태만 정해 주면 goto보다 추론이 쉬울 때가 많다.
문제가 생기면 이렇게 의심해 본다. switch인데 이상하게 실행된다—break가 빠졌는가, fall-through가 의도인가. if-else가 이상하다—else가 어느 if에 붙었는가, 중괄호는 있는가. while(1)이 멈추지 않는다—break/return이 있는가. for에서 i가 늘지 않는다—while과 continue에 스텝만 아래에 두지 않았는가. scanf가 반복해서 실패한다—입력 버퍼를 비웠는가. goto가 함수 밖으로 가려 한다—문법상 불가하다. longjmp 이후에 터진다—VLA·alloca·호출 규약을 #04와 함께 본다.
setjmp / longjmp (심화)
setjmp가 jmp_buf에 환경을 저장하고, longjmp가 그 지점으로 비국소 점프한다. return과 달리 중간 정리·C++ 소멸자 같은 것을 기대하면 안 되며, VLA 같은 것과 섞이면 구현 정의·미정의 구역이 나올 수 있다. C++ 예외 대체로 쓰라는 뜻은 아니다—프론트매터 FAQ와 같다. 파서·코루틴 시뮬레이션 같은 좁은 용도가 아니면 에러 코드·콜백·상태 머신을 먼저 고려하는 편이 안전하다.
흐름을 한 장에
flowchart TD A[입력·이벤트] --> B[검증·파싱] B --> C[연산·상태] C --> D[I/O·저장] D --> E[로그·결과]
불변식을 문장으로 적어 두면 “여기서 멈추는가”가 디버깅할 때 편하다.
부록으로 남기는 디테일
널문장: if (x); 다음 줄이 본문이 아니라 세미콜론만 있는 널문장이 본문이 될 수 있다—if 항상 중괄호 규칙이 있는 팀이 많다.
if (ptr == NULL); /* 실수: 널문장이 if 본문 */
panic("ptr"); /* 조건과 무관하게 실행될 수 있음 */
switch 제어는 정수 계열이어야 한다—부동소수면 if 사슬을 쓴다. 매크로가 if 옆에 붙으면 do { } while(0) 래퍼나 static inline을 고려한다.
반열린 구간 [0, n)으로 인덱스를 통일해 두면 빈 배열·이진 탐색 while (lo < hi)와 맞는다. 혼용하면 오프바이원이 나기 쉽다.
행 우선으로 2차원을 돌 때 M[i][j]가 캐시에 유리한 경우가 많다—열 우선으로 뒤집으면 미스가 늘 수 있다.
임베디드에서 하드웨어 레지스터는 volatile로 최적화에 사라지지 않게 한다. 무한 for(;;)는 main·idle·감시 루프에 쓰인다. 빈 바쁜 대기는 전력·응답과의 트레이드오프다.
Duff’s device 개념 스케치는 역사적으로만 남긴다. 실제로 복붙하지 말 것.
/* 개념 스케치: 실제로 복붙하지 말 것 */
void duff_idea(int *to, int *from, int count) {
int n = (count + 7) / 8;
switch (count % 8) {
case 0: do { *to++ = *from++;
case 7: *to++ = *from++;
case 6: *to++ = *from++;
case 5: *to++ = *from++;
case 4: *to++ = *from++;
case 3: *to++ = *from++;
case 2: *to++ = *from++;
case 1: *to++ = *from++;
} while (--n > 0);
}
}
break·continue·return·exit의 차이를 말로 정리하면: break/continue는 루프·스위치 한 겹, return은 함수, exit는 프로세스—C에는 try가 없으므로 에러는 코드·정리 패턴·드물게 longjmp다.
정리하면
제어 순서는 순차·선택·반복·점프의 조합이다. switch가 점프 테이블일 수는 있어도 필수는 아니다. goto는 정리 한곳으로 모을 때 팀 규칙과 맞으면 쓸 만하고, setjmp는 범위를 작게—예외 대체로 착각하면 위험하다. 동료가 읽기 좋은 if·for 구조가 먼저이고, 마이크로 최적화는 그다음이다.
다음: #04 함수·스택·호출 규약 — longjmp·goto cleanup이 스택·프레임과 어떻게 맞물리는지 더 선명해진다.
같이 보면 좋은 글: #02 타입 · #04 함수·ABI · #05 포인터 · 목차.
키워드: C, 제어 흐름, if, switch, for, while, break, continue, goto, setjmp, 최적화, 스택.
문서·코드·메뉴·이진 탐색·입력·상태까지 한 바퀴 돌았다. 다음 편에서 스택과 호출 규약으로 이어가면 본문의 longjmp·goto 정리가 더 잘 붙는다.