Skip to main content
Summary

코드는 결국 명령어의 흐름이다

개발자가 작성한 코드는 사람이 읽기 쉬운 형태다. 하지만 CPU는 for, if, class, async 같은 문법을 그대로 이해하지 않는다. CPU가 실제로 처리하는 것은 훨씬 단순한 명령어들이다. 값을 메모리에서 가져오기, 두 값을 더하기, 결과를 저장하기, 조건에 따라 다른 위치로 이동하기 같은 작업이 그 예다.

프로그램 실행을 이해한다는 것은 이 차이를 이해하는 것이다. 우리가 보기에는 한 줄의 코드처럼 보여도, CPU 입장에서는 여러 개의 명령어가 순서대로 실행된다.

x = a + b;

이 코드는 개념적으로 다음과 같은 흐름으로 바뀐다.

  1. 필요한 경우 메모리에서 a 값을 가져온다.

  2. 필요한 경우 메모리에서 b 값을 가져온다.

  3. 두 값을 CPU 안에서 더한다.

  4. 결과를 x 위치에 저장한다.

실제 명령어는 CPU 구조와 컴파일러의 최적화 방식에 따라 달라진다. 어떤 값은 이미 레지스터에 있을 수도 있고, 컴파일러가 불필요한 메모리 접근을 없앨 수도 있다. 그래도 핵심은 같다. CPU는 복잡한 비즈니스 로직을 한 번에 이해하지 않는다. 작은 명령어를 빠르게 반복 실행할 뿐이다.

이 관점을 가지면 성능 문제를 볼 때 질문이 달라진다. '이 코드가 왜 느리지?'에서 멈추지 않고, 'CPU가 실제로 어떤 데이터를 어디서 가져오고 있는가?', '반복문 안에서 메모리에 얼마나 자주 접근하는가?', '조건 분기가 CPU의 흐름을 자주 깨는가?'를 묻게 된다.

CPU와 프로그램 실행 원리AI 생성 이미지

가져오고, 해석하고, 실행한다

CPU의 기본 동작은 흔히 명령어 사이클이라고 부른다. 크게 보면 세 단계다.

  1. 명령어 가져오기

  2. 명령어 해석하기

  3. 명령어 실행하기

먼저 CPU는 다음에 실행할 명령어가 있는 메모리 주소를 확인한다. 이 주소를 들고 있는 특별한 레지스터를 프로그램 카운터라고 한다. 레지스터는 CPU 내부에 있는 아주 빠른 저장 공간이다. 프로그램 카운터가 가리키는 위치에서 명령어를 가져오면, CPU는 그 명령어가 덧셈인지, 메모리 읽기인지, 점프인지 해석한다. 그리고 필요한 연산을 실행한다.

실행이 끝나면 프로그램 카운터는 보통 다음 명령어 주소로 이동한다. 하지만 if, while, 함수 호출처럼 실행 흐름이 바뀌는 코드를 만나면 다른 주소로 이동할 수 있다. 이것이 프로그램의 제어 흐름이다.

예를 들어 다음 코드를 보자.

if (count > 0) {
    total += count;
}

CPU 수준에서는 조건을 비교하고, 조건이 맞지 않으면 특정 명령어 위치를 건너뛰는 방식으로 실행된다. 즉, 조건문은 단순히 '비교'와 '이동'의 조합이다. 함수 호출도 마찬가지다. 현재 위치를 저장하고, 함수가 있는 위치로 이동한 뒤, 함수가 끝나면 원래 위치로 돌아온다.

이 구조를 알면 스택 오버플로 같은 오류도 더 잘 이해된다. 재귀 함수가 너무 깊게 호출되면 CPU가 돌아갈 위치와 지역 변수 정보를 계속 쌓아야 한다. 이 정보가 저장되는 공간이 스택이고, 스택이 한계를 넘으면 프로그램이 중단된다. 겉으로는 함수 호출 문제처럼 보이지만, 실제로는 실행 흐름을 관리하기 위한 메모리 공간이 고갈된 것이다.

레지스터, 캐시, 메인 메모리는 같은 저장소가 아니다

CPU는 연산을 매우 빠르게 할 수 있다. 하지만 연산할 데이터가 CPU에 도착하지 않으면 기다려야 한다. 성능 문제의 상당수는 계산 자체보다 데이터를 가져오는 비용에서 발생한다.

데이터가 놓이는 위치는 크게 세 층으로 볼 수 있다.

  • 레지스터: CPU 내부의 가장 빠른 저장 공간

  • 캐시: CPU 가까이에 있는 작은 고속 메모리

  • 메인 메모리: 일반적으로 RAM이라고 부르는 큰 저장 공간

레지스터는 매우 빠르지만 개수가 적다. CPU가 실제 산술 연산을 할 때는 보통 데이터를 레지스터에 올려놓고 처리한다. 캐시는 메인 메모리보다 작지만 훨씬 빠르다. 실제 CPU에는 보통 L1, L2, L3처럼 여러 단계의 캐시가 있으며, CPU는 최근에 사용했거나 곧 사용할 가능성이 높은 데이터를 캐시에 보관한다. 메인 메모리는 캐시보다 훨씬 크지만 접근 속도는 느리다.

이 차이는 개발자가 생각하는 것보다 중요하다. 다음 두 코드는 모두 여러 값을 순회하며 더하지만, 메모리 접근 방식이 다르다.

for (i = 0; i < n; i++) {
    sum += arr[i];
}
node = head;
while (node != NULL) {
    sum += node->value;
    node = node->next;
}

배열은 메모리에 연속적으로 배치되는 경우가 많다. CPU는 연속된 데이터를 읽을 때 다음 데이터도 곧 필요할 것이라고 예상하고 캐시에 함께 가져올 수 있다. 반면 연결 리스트처럼 포인터를 따라가는 구조는 다음 데이터가 메모리의 전혀 다른 위치에 있을 수 있다. 이 경우 CPU는 매번 새로운 위치를 찾아가야 하고, 캐시를 잘 활용하지 못한다.

그래서 자료구조 선택은 단순히 빅오 표기법만의 문제가 아니다. 이론적으로는 같은 O(n) 순회라도, 메모리 배치가 좋은 구조가 훨씬 빠를 수 있다. 자료구조의 선택 기준을 살펴볼 때도 이 관점이 중요하다. 데이터가 어떻게 저장되는지가 CPU의 실행 속도에 직접 영향을 준다.

캐시 미스는 CPU를 기다리게 만든다

캐시는 책상 위에 올려둔 자료와 비슷하다. 자주 보는 문서를 책상 위에 두면 빠르게 찾을 수 있다. 하지만 필요한 문서가 책장이나 창고에 있으면 가지러 가야 한다. CPU에게 캐시 미스는 이런 상황이다. 필요한 데이터가 캐시에 없어서 더 느린 메인 메모리까지 접근해야 하는 것이다.

캐시 미스가 자주 발생하면 CPU는 연산 능력이 남아 있어도 대기한다. 이를 메모리 병목이라고 볼 수 있다. 겉으로는 CPU를 쓰는 코드인데 처리량이 기대만큼 나오지 않거나, 단순 반복문이 예상보다 오래 걸리는 경우에는 메모리 접근 패턴을 의심해야 한다.

특히 대용량 데이터를 다루는 서버 코드, 이미지 처리, 로그 분석, 머신러닝 전처리, 게임 엔진 같은 영역에서는 캐시 효율이 성능에 큰 영향을 준다. 객체를 많이 만들고 흩어진 포인터를 따라가는 구조는 코드상으로는 깔끔해 보여도 CPU에게는 불친절할 수 있다.

개발자가 항상 캐시 라인 크기까지 계산해야 하는 것은 아니다. 하지만 다음 원칙은 기억할 만하다.

  • 연속된 메모리를 순서대로 읽는 코드는 대체로 빠르다.

  • 작은 데이터를 반복해서 쓰면 캐시에 잘 남을 가능성이 높다.

  • 큰 객체 그래프를 포인터로 따라가면 캐시 미스가 늘 수 있다.

  • 불필요한 객체 생성은 메모리 사용량뿐 아니라 캐시 효율도 떨어뜨린다.

성능 최적화는 무조건 낮은 수준의 코드를 쓰는 일이 아니다. CPU가 데이터를 덜 기다리게 만드는 일이다.

컴파일 언어와 인터프리터 언어의 실행 차이

소스 코드는 CPU가 직접 실행할 수 없다. 중간에 번역 과정이 필요하다. 이 번역을 언제, 어떤 방식으로 하느냐에 따라 컴파일 언어와 인터프리터 언어를 구분한다.

컴파일 언어는 실행 전에 소스 코드를 기계어 또는 그에 가까운 형태로 변환한다. C, C++, Rust, Go 같은 언어가 대표적이다. 컴파일이 끝나면 실행 파일에는 CPU가 실행할 수 있는 명령어가 들어간다. 실행 시점에는 번역 부담이 적기 때문에 빠르게 시작하고 예측 가능한 성능을 내기 쉽다.

인터프리터 언어는 실행 중에 코드를 읽고 해석하면서 동작한다. Python, Ruby, JavaScript 같은 언어가 여기에 속한다고 설명하는 경우가 많다. 다만 현대 언어 구현은 단순히 한 줄씩 읽어 실행하는 방식만 사용하지 않는다. 실행 전에 바이트코드로 변환하거나, 자주 실행되는 코드를 실행 중에 기계어로 컴파일하는 JIT 컴파일을 사용하기도 한다.

예를 들어 Java는 소스 코드를 JVM 바이트코드로 컴파일한 뒤, JVM이 이를 실행한다. JVM은 실행 중 자주 호출되는 코드를 기계어로 최적화할 수 있다. JavaScript 엔진도 코드를 해석하다가 자주 실행되는 부분을 최적화된 기계어로 바꿀 수 있다. 그래서 현실의 언어 실행 방식은 '컴파일 언어는 빠르고 인터프리터 언어는 느리다'처럼 단순히 나눌 수 없다.

개발자에게 중요한 질문은 이것이다.

  • 실행 전에 얼마나 많은 최적화를 할 수 있는가?

  • 실행 중 타입 정보와 호출 패턴을 이용해 최적화할 수 있는가?

  • 런타임이 메모리 관리, 동적 디스패치, 가비지 컬렉션 같은 비용을 얼마나 부담하는가?

동적 언어에서 같은 함수가 매번 다른 타입의 인자를 받으면 런타임 최적화가 어려워질 수 있다. 반대로 같은 타입과 같은 구조로 반복 실행되는 코드는 JIT가 잘 최적화할 수 있다. 즉, 언어의 이름보다 실행 모델과 런타임의 특성을 이해하는 것이 더 중요하다.

파이프라이닝은 여러 명령어를 겹쳐 실행한다

현대 CPU는 명령어 하나를 완전히 끝낸 뒤 다음 명령어를 시작하지 않는다. 공장의 조립 라인처럼 여러 명령어를 단계별로 겹쳐 처리한다. 이를 파이프라이닝이라고 한다.

예를 들어 명령어 실행을 가져오기, 해석하기, 실행하기, 결과 저장하기로 나눌 수 있다고 하자. 첫 번째 명령어가 실행 단계에 있을 때, 두 번째 명령어는 해석 단계에 있고, 세 번째 명령어는 가져오기 단계에 있을 수 있다. 이렇게 하면 CPU의 여러 부분을 동시에 활용할 수 있다.

하지만 파이프라인은 실행 흐름이 예측 가능할 때 잘 동작한다. 문제가 되는 대표적인 경우가 분기다.

if (score >= 90) {
    grade = 'A';
} else {
    grade = 'B';
}

CPU는 조건 결과가 나오기 전에도 다음에 실행할 명령어를 미리 준비하고 싶어 한다. 그래서 어느 쪽 분기로 갈지 예측한다. 이를 분기 예측이라고 한다. 예측이 맞으면 파이프라인은 계속 흘러간다. 예측이 틀리면 미리 준비하던 작업을 버리고 올바른 경로의 명령어를 다시 가져와야 한다. 이때 비용이 발생한다.

대부분의 업무 코드에서 개발자가 분기 예측까지 직접 고려할 필요는 없다. 하지만 성능이 중요한 반복문에서는 조건 패턴이 영향을 줄 수 있다. 예를 들어 정렬된 데이터는 조건 결과가 일정하게 이어져 CPU가 예측하기 쉽다. 반면 무작위 데이터는 예측이 자주 빗나갈 수 있다. 같은 알고리즘이라도 입력 데이터의 분포에 따라 속도가 달라지는 이유 중 하나다.

현대 CPU는 여기서 더 나아가 여러 명령어를 순서를 바꾸어 실행하거나, 아직 확정되지 않은 경로를 미리 실행해 보기도 한다. 세부 동작은 CPU마다 다르지만, 개발자 입장에서는 한 가지를 기억하면 충분하다. CPU는 쉬지 않고 다음 일을 준비하려고 하며, 예측이 자주 깨지는 코드는 그 흐름을 방해할 수 있다.

CPU 성능은 클럭만으로 설명되지 않는다

예전에는 CPU 성능을 클럭 속도로 설명하는 경우가 많았다. 클럭은 CPU가 동작하는 박자에 가깝다. 하지만 현대 CPU 성능은 단순히 클럭이 높다고 결정되지 않는다.

명령어를 얼마나 병렬로 처리할 수 있는지, 캐시가 얼마나 잘 맞는지, 분기 예측이 얼마나 정확한지, 메모리 접근을 얼마나 줄였는지가 모두 성능에 영향을 준다. 같은 3GHz CPU라도 어떤 코드는 빠르고 어떤 코드는 느리다. 코드가 CPU의 구조와 잘 맞느냐가 중요하기 때문이다.

실무에서 이 차이는 다음과 같은 현상으로 나타난다.

  • CPU 사용률은 높은데 처리량이 기대만큼 나오지 않는다.

  • 단순 계산보다 객체 변환과 메모리 복사가 더 큰 비용을 차지한다.

  • 데이터가 커진 뒤 갑자기 응답 시간이 증가한다.

  • 같은 로직인데 입력 순서나 데이터 분포에 따라 성능이 달라진다.

  • 로컬에서는 빠르지만 서버에서는 컨테이너 제한, CPU 공유, 캐시 경쟁 때문에 느려진다.

이런 문제를 해결하려면 코드 줄 수보다 실행 비용을 봐야 한다. 반복문 안에서 데이터베이스를 호출하는 문제는 네트워크와 I/O 병목이지만, 반복문 안에서 거대한 객체를 계속 탐색하는 문제는 CPU와 메모리 병목일 수 있다. 겉으로는 둘 다 느린 코드지만 원인은 다르다.

개발자가 실행 원리를 알아야 하는 이유

대부분의 애플리케이션 개발자는 직접 어셈블리 코드를 작성하지 않는다. 그럼에도 CPU 실행 원리를 알아야 하는 이유는 명확하다. 고수준 코드도 결국 낮은 수준의 비용 위에서 실행되기 때문이다.

성능 문제가 생겼을 때 단순히 서버를 늘리는 것은 쉬운 선택이다. 하지만 원인이 캐시 미스가 많은 자료구조인지, 불필요한 객체 생성인지, 분기 패턴인지, 인터프리터 런타임의 한계인지 모르면 같은 문제가 다시 나타난다. 반대로 실행 원리를 알고 있으면 어디를 측정해야 하는지 판단할 수 있다.

이 장의 핵심은 CPU가 똑똑한 마법사가 아니라는 점이다. CPU는 명령어를 가져오고, 해석하고, 실행한다. 데이터를 레지스터와 캐시에 올려 빠르게 처리하려 하고, 파이프라인과 분기 예측으로 쉬지 않고 일하려 한다. 하지만 데이터가 멀리 있거나, 실행 흐름이 자주 바뀌거나, 런타임이 많은 부가 작업을 해야 하면 CPU는 기다리거나 다시 일해야 한다.

코드는 문법으로 작성되지만 성능은 실행으로 결정된다. 개발자는 이 간극을 이해해야 한다. 그래야 추상화가 제공하는 편리함을 누리면서도, 필요할 때 그 아래에서 어떤 비용이 발생하는지 정확히 볼 수 있다.

책 목록

개발자가 알아야 할 컴퓨터 과학 지식 정리

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기