-
코드는 일직선이 아니다: 제어흐름 그래프(CFG)로 함수 속 길을 그려보기IT 2026. 7. 3. 21:00
집에 작은 AI 서버를 한 대 두고, 코드를 넣으면 그 코드의 구조를 설명하는 코드 위키를 자동으로 만들어보는 취미 프로젝트를 굴리고 있다. 함수를 읽고 "이 함수는 무엇을 하고, 어디로 갈라지고, 어디서 되돌아오는가"를 글로 풀어내려면, 그 전에 기계가 코드의 흐름을 이해할 수 있는 형태로 바꿔야 한다. 그 첫 번째 도구가 바로 제어흐름 그래프였다. 이 글은 내가 그걸 공부하며 정리한 노트다.
1. 제어흐름 그래프(CFG)란 무엇인가
제어흐름 그래프(CFG, Control-Flow Graph)는 함수나 프로시저 하나의 내부에서, 실행이 어떤 순서로 진행되고 어디서 갈라지는지를 그린 방향 그래프다. 한마디로 "이 함수 안에서 실행이 흘러갈 수 있는 모든 길의 지도"다.
이 지도에는 두 가지 부품이 있다.
- 노드(node) = 기본 블록(basic block). 중간에 갈라지거나 끼어드는 일 없이, 들어오면 끝까지 일직선으로 쭉 실행되는 코드 덩어리다. 한번 들어가면 도중에 빠져나가거나 건너뛰지 않는다.
- 엣지(edge) = "이 블록 다음에 저 블록이 실행될 수 있다"는 제어 이동. if로 갈라지거나, 반복문으로 되돌아가거나, return으로 빠져나가는 것을 화살표로 그린다.
그리고 길의 시작과 끝을 분명히 하기 위해 진입 노드(entry)와 종료 노드(exit)를 둔다. 모든 길은 진입에서 시작해 종료로 모인다.
다이어그램 설명. 가장 단순한 함수, 즉 분기도 반복도 없이 위에서 아래로만 흐르는 경우다. 진입에서 시작해 입력을 읽고 계산하고 반환한 뒤 종료로 모인다. 이렇게 갈림길이 없는 코드는 사실 전부 하나의 기본 블록으로 묶을 수도 있다. 여기서 굳이 세 칸으로 나눈 것은 다음 그림과의 대비를 위해서다. 함정은 이 그림처럼 "코드는 원래 일직선"이라고 착각하는 것인데, 실제 코드는 대부분 다음부터 나오는 갈림길 모양이다.
2. 배경: 컴파일러 최적화 시대에 태어났다
CFG는 1970년 즈음 컴파일러 최적화가 본격화되던 시기에 자리 잡았다. 컴파일러는 사람이 쓴 코드를 기계어로 번역하는 프로그램인데, 단순 번역에 그치지 않고 "같은 결과를 더 빠르게, 더 적은 자원으로" 만들도록 코드를 다듬는다. 이 다듬기를 최적화라고 부른다.
그런데 최적화를 하려면 컴파일러가 코드의 "실행 모양"을 알아야 한다. 어떤 계산이 반복문 안에서 매번 똑같이 일어나는지, 어떤 코드는 절대 실행될 일이 없는지, 어떤 변수가 어느 지점에서 살아 있는지를 파악해야 안전하게 손댈 수 있다. 이 모든 분석의 밑바탕으로, 프랜시스 E. 앨런(Frances E. Allen)이 1970년에 발표한 Control Flow Analysis가 토대를 놓았다. 코드의 흐름을 그래프로 정식화한 이 작업이 이후 거의 모든 컴파일러 최적화의 출발점이 되었다.
핵심 통찰은 단순하다. 코드의 텍스트 순서와 실행 순서는 다르다. 그렇다면 실행 순서를 따로 그려두면, 거기에 대고 분석을 돌릴 수 있다는 것이다.
3. 어떤 문제를 풀려고 필요한가
코드를 처음 펼치면 글자가 위에서 아래로 죽 나열되어 있다. 마치 책의 한 문단처럼 일직선으로 읽힌다. 하지만 실제로 그 코드가 돌아갈 때는 전혀 일직선이 아니다.
if를 만나면 길이 둘로 갈라진다.- 반복문을 만나면 아래로 가던 흐름이 위로 되돌아온다.
return이나 예외를 만나면 중간에서 빠져나간다.
그래서 "이 함수에서 도대체 어떤 실행 경로들이 가능한가? 어디서 갈라지고, 어디로 되돌아오는가?"라는 질문은, 글자만 위아래로 훑어서는 답하기 어렵다. 사람의 머릿속에서도 if가 세 개만 겹쳐도 가능한 경로 조합이 금방 헷갈린다. 기계라면 더더욱 "텍스트"로는 이걸 다룰 수 없다.
다이어그램 설명. 텍스트로 적힌 코드 한 덩어리에 세 가지 핵심 질문을 던지면 모두 "글자만 봐서는 막힌다"는 같은 벽에 부딪힌다. 위에서 아래로 질문들이 모두 한 벽으로 수렴하는 모양으로 그린 것은, 표현 형식(텍스트)과 우리가 알고 싶은 것(흐름) 사이에 근본적인 간극이 있음을 한눈에 보이기 위해서다. 함정은 "코드를 더 꼼꼼히 읽으면 된다"고 여기는 것인데, 문제는 꼼꼼함이 아니라 표현 형식 자체가 흐름을 담고 있지 않다는 데 있다.
4. 어떻게 해결했나: 코드를 블록으로 쪼개고 화살표로 잇는다
해결책은 의외로 직관적이다. 두 단계로 나뉜다.
4-1. 기본 블록으로 쪼개기
먼저 코드를 기본 블록 단위로 자른다. 규칙은 간단하다. "이 줄로 점프해 들어올 수 있는 곳"이나 "여기서 어디론가 점프해 나가는 곳"을 경계로 삼아 자른다. 그 경계 사이의 일직선 구간이 하나의 블록이다. 블록 안에서는 첫 줄에 들어오면 마지막 줄까지 무조건 순서대로 실행된다 — 중간에 새지 않는다.
4-2. 가능한 점프를 엣지로 잇기
그다음, 블록과 블록 사이에 "여기서 저기로 실행이 넘어갈 수 있다"는 점프를 방향 있는 화살표로 잇는다.
if/else면 한 블록에서 두 블록으로 화살표가 갈라지고, 반복문이면 아래쪽 블록에서 위쪽 조건 블록으로 화살표가 되돌아온다. 이렇게 되돌아오는 화살표를 백 엣지(back-edge)라고 부른다. 반복(루프)의 정체가 바로 이 백 엣지다.다이어그램 설명. if/else가 들어간 함수의 모습이다. 조건을 묻는 칸에서 화살표가 둘로 갈라졌다가, 양쪽 처리가 끝나면 다시 하나의 합류 칸으로 모이고 종료로 빠진다. 이렇게 "갈라졌다 모이는" 다이아몬드 모양이 CFG에서 가장 흔한 기본 패턴이다. 이 패턴 덕분에 "이 함수에는 두 가지 경로가 있다"는 사실이 그림만 봐도 즉시 드러난다. 함정은 합류 칸을 빠뜨리는 것인데, 합류 지점을 명시하지 않으면 이후 공통 코드가 어느 경로에 속하는지 분석이 모호해진다.
다이어그램 설명. 반복문(루프)을 그린 것이다. 본문을 실행한 뒤 다시 조건 칸으로 올라가는 화살표가 핵심인데, 이것이 바로 되돌아오는 백 엣지다. 위에서 아래로 흐르다가 한 화살표만 거꾸로 올라가도록 그린 이유는, 그 한 줄이 "여기 루프가 있다"는 사실을 그래프 차원에서 증명하기 때문이다. 컴파일러는 바로 이 거꾸로 가는 화살표를 찾아 루프를 자동으로 식별한다. 함정은 이 백 엣지를 일반 화살표와 똑같이 취급하는 것인데, 사이클(되돌아옴)을 무시하면 무한히 도는 경로를 분석기가 끝없이 따라가게 된다.
5. 효과: 하나의 그래프가 여러 분석의 토대가 된다
일단 함수를 이 그래프로 바꿔두면, 텍스트로는 어려웠던 분석들이 그래프 위의 자연스러운 질문으로 바뀐다.
분석 그래프 위에서는 이런 질문 죽은 코드(dead code) 탐지 진입에서 출발해 화살표를 따라가도 절대 닿지 못하는 블록이 있는가? (도달 불가능 = 죽은 코드) 루프 식별 거꾸로 올라가는 화살표(백 엣지)가 어디에 있는가? 레지스터 할당 각 변수가 어느 블록 구간에서 살아 있는가? (겹치지 않으면 같은 레지스터 재사용) 테스트 커버리지 테스트가 실제로 밟고 지나간 화살표는 전체 중 몇 개인가? 여기서 죽은 코드란 어떤 실행 경로로도 도달할 수 없어 실행될 일이 없는 코드를 뜻하고, 레지스터 할당이란 한정된 CPU 내부 저장 공간(레지스터)에 변수들을 효율적으로 배치하는 일을 뜻한다.
다이어그램 설명. 죽은 코드 탐지를 보여준다. 진입에서 출발해 화살표를 따라가면 처음 두 블록을 거쳐 종료에 닿지만, 따로 떨어져 종료로만 연결된 블록에는 어떤 경로로도 도착할 수 없다. 들어오는 화살표가 하나도 없는 외톨이 블록으로 그린 이유는, "도달 불가능"이 그래프에서 곧 "들어오는 엣지가 없음"으로 깔끔하게 정의되기 때문이다. 컴파일러는 이런 블록을 안전하게 지워 결과물을 줄인다. 함정은 예외 처리나 동적 호출처럼 그래프에 안 보이는 진입 경로가 있을 때인데, 그걸 놓치면 멀쩡한 코드를 죽은 코드로 오인할 수 있다.
다이어그램 설명. 테스트 커버리지를 그래프 위에서 본 모습이다. 실선 화살표는 테스트가 실제로 지나간 경로, 점선 화살표는 한 번도 밟히지 않은 경로다. 분기 지점에서 한쪽 가지만 실선인 것을 보면 "거짓일 때의 코드는 테스트가 안 됐다"가 즉시 드러난다. 흐름을 위에서 아래로 두고 안 밟힌 가지만 점선으로 구분한 이유는, 커버리지가 결국 "전체 화살표 중 밟은 화살표의 비율"이라는 그래프 문제임을 보이기 위해서다. 함정은 모든 줄을 한 번씩 실행했다고 안심하는 것인데, 줄(라인)을 다 밟아도 분기(엣지)의 한쪽은 영영 안 밟힐 수 있다.
한 번 그려둔 그래프 하나로 이렇게 여러 분석이 갈라져 나온다는 점이 CFG의 진짜 힘이다. 각 분석마다 코드를 다시 읽는 게 아니라, 공통의 흐름 지도 위에서 서로 다른 질문을 던지는 것이다.
6. 코드 위키 자동생성에 왜 핵심인가
다시 내 취미 프로젝트로 돌아오면, 작은 AI 서버가 코드를 읽고 위키를 쓰게 하려면 코드를 "설명할 수 있는 구조"로 먼저 바꿔야 한다. 함수 본문을 글자 그대로 모델에 던지는 것과, "이 함수는 진입 후 한 번 갈라졌다가 한 번 되돌아오는 루프를 거쳐 두 갈래로 끝난다"는 흐름 구조를 함께 주는 것은 결과물의 정확도가 다르다.
그리고 CFG는 끝이 아니라 시작이다. CFG는 함수 한 개의 안쪽 흐름만 본다. 여기에 "어떤 값이 어디서 만들어져 어디서 쓰이는가"를 더하면 데이터흐름 그래프가 되고, 제어와 데이터 의존을 함께 묶으면 프로그램 의존 그래프가 되며, 이들을 통합하면 코드 속성 그래프가 된다. 이 스펙트럼에서 CFG는 가장 안쪽 — 함수 하나의 흐름을 보는 — 첫 번째 층이다.
다이어그램 설명. CFG에서 출발해 점점 더 많은 정보를 얹어가는 표현의 스펙트럼이다. 함수 안쪽 흐름만 보는 가장 단순한 표현이 맨 위에 있고, 아래로 내려갈수록 값의 이동과 의존 관계가 더해져 풍부해진다. 위에서 아래로 한 줄로 쌓은 이유는 이들이 경쟁 관계가 아니라 층층이 쌓이는 관계임을 보이기 위해서다. 가장 안쪽 층을 제대로 세워두지 않으면 그 위 층들도 흔들린다 — 그래서 코드 위키든 정적 분석이든, 출발점은 결국 함수 하나의 흐름을 그리는 이 그래프다.
정리하면, 제어흐름 그래프는 "코드는 일직선이 아니다"라는 단순한 사실을 정직하게 그림으로 옮긴 것이다. 컴파일러를 위해 태어났지만, 죽은 코드 탐지부터 테스트 커버리지, 그리고 코드를 설명하는 위키 자동생성까지 — 함수 속 길을 알아야 하는 모든 일의 공통 출발점이 되었다. 집의 작은 서버에서 코드를 이해시키는 일을 하다 보니, 가장 먼저 손이 가는 도구가 이것이라는 게 새삼 납득됐다.
이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.
'IT' 카테고리의 다른 글
코드를 글자가 아니라 구조로 읽기 — tree-sitter로 함수와 모듈을 뽑아내기 (0) 2026.07.05 코드를 그래프로 본다는 것 — 호출 그래프부터 코드 속성 그래프까지 (0) 2026.07.04 코드를 그래프로 질문하기 — 코드 속성 그래프(CPG) 한 번에 이해하기 (0) 2026.07.04 한 문장이 다른 문장에 기대는 두 가지 방식 — 프로그램 의존 그래프(PDG) (0) 2026.07.03 값은 어디서 와서 어디로 가는가 — 데이터흐름 그래프(DDG)로 코드 읽는 법 배우기 (0) 2026.07.03 스킬의 끝은 스크립트인가 — 하네스 엔지니어링에서 스크립트는 '지능을 파낸 빈자리'다 (0) 2026.07.02 MCP는 왜 Streamable HTTP로 갈아탔나 — 엔드포인트 하나로 스트림을 다스리는 법 (0) 2026.07.01 MCP는 왜 SSE를 골랐나 — HTTP 위에서 서버가 먼저 말하게 하는 법 (0) 2026.07.01 서브 에이전트를 @tool로 감싸는 멀티 에이전트 패턴 (0) 2026.06.30 @tool이 내부에서 하는 일 — Pydantic BaseModel이 LLM의 호출 인터페이스가 되는 과정 (0) 2026.06.30