
격동의 90년대 수많은 세계의 소년들이 8KB RAM이라는 제약속에서 태초 마을을 떠났다.
그리고 그 소년들은 이제 수십 GB메모리 위에 또다른 세계를 창조한다.
Z80 CPU - 8비트 아키텍쳐의 작업용 RAM(WRAM)
1MB의 카트리지 ROM은 오늘날 고화질 이미지의 한장의 수천분의 일도 되지 않는 것이었다
그리고 그곳에서 시작한 게임은 세계를 놀라게 하였다.
생존 전략- 모든 것은 수작업으로

(디어셈블리 되어서 공유되는 포켓몬 레드)
Game Boy 시절, 개발자는 "코드가 돌아가도록 만드는 것"이 아니라,
64KB 주소 공간에 어떻게든 게임 전체를 욱여넣는 일을 하고 있었다.
자동화도 없고 컴파일러 최적화도 없던 시대, 모든 것은 수작업이었고, 한 줄 한 줄의 어셈블리 코드는 존재 이유와 비용을 계산한 결과였을 것이다.
자, 이제 그 시대의 최적화를 알아야하기 전 우선 우리는 레지스터란 무엇인가에 대해서 알아야한다.

(Z80 레지스터 하드웨어 핀 다이어그램)
레지스터란 무엇인가?
레지스터는 CPU 내부에 존재하는 초고속 데이터 저장소로,
연산, 제어 흐름, 메모리 접근 등에 사용되는 작고 빠른 기억장치이다.
그리고 우리는 이 Z80 아키텍쳐로 레지스터에 대해서 서술 할 것이다.
레지스터는 왜 중요한가?
종류 | 접근 속도 | 사이클 수 | 역할 |
레지스터 | 매우 빠름(CPU 내부) | 1~4 cycles | 즉시 사용 가능,연산 최적화 |
RAM | 느림(버스 거침) | 8~16cycles | 느린 저장소 보조 역할 |
이 표만 보아도 레지스터가 훨씬 빠르다.
그런데 왜 레지스터와 RAM을 비교했는가?
우선 그 전에 RAM과 레지스터의 차이에 대해서 알아보자
레지스터는 프로그래머의 눈 앞에 펼쳐진 작업대(WorkBench)이다.
작업대는 매우 비좁고, Z80에서는 7~8개의 도구만 올려둘 수 있었지만, 일단 작업대 위에서 프로그래머는 작업 도구를 빠르게 꺼내 쓸 수 있었다.
즉 그래서 시간(Cycle)이 짧게 걸린다.
하지만 RAM은 프로그래머의 공방을 벗어나야하는 자재창고(WareHouse)이다.
창고는 넓어서 많은 재료(데이터)를 보관 할 수 있지만, 필요한 재료가 생길때마다 프로그래머는 하던 일을 멈추고, 원하는 재료를 가져와야했기에
시간(cycle)이 오래걸린다.
그렇다면 포켓몬 레드에서 '최적화'란 무엇인가? 바로, 이 '창고에 다녀오는 횟수'를 최소화하는 처절한 싸움이다.
굼뜬 Z80 CPU로 프레임이 끊기지 않게 부드러운 화면을 구현하려면, 창고에 가지 않고, 최대한 모든 작업을 작업벤치 위에서
단 8개의 도구를 번갈아가며
흥미를 금세 잃는 어린아이에게 꿈을 주어야만했다
우리는 이제 그 장인의 도구가 어땠는지 볼 것이다.

(장인의 도구 Z80 아키텍쳐의 레지스터)
Z80 레지스터에 대한 설명
레지스터는 단순한 8비트 상자가 아니다
그안은 비트 단위로 의미가 다르게 설계된 고정밀 도구였는데,
특히 Z80은 A,F 레지스터 조합(AF) 안에는 플래그(Flags)라는 연산결과를 추적하는 비트들이 있었다
비트 위치 |
플래그 이름 |
의미 |
---|
7 |
S (Sign) |
결과가 음수인가? |
6 |
Z (Zero) |
결과가 0인가? |
4 |
H (Half-Carry) |
BCD 연산에서 자릿수 올림 여부 |
2 |
P/V (Parity/Overflow) |
짝수 패리티 or 오버플로 |
1 |
N (Add/Sub) |
마지막 연산이 뺄셈인가? |
0 |
C (Carry) |
자리올림/내림 발생 여부 |
이들 if문이 없는 Z80에서 조건 분기를 수행하는 핵심이었다.
그리고 여기에 더해서
레지스터 세트(Re-gister Set) 도구를 가지고 있었는데
그리고 EX AF, AF′
/ EXX
명령어로 순간 교체가 가능했다.
즉, 프로그래머는 8개의 레지스터만 가지고 싸운 게 아니라,
‘8개 × 2벌’의 도구를 상황에 맞게 바꿔가며 작업했던 것이다.
그리고 이 도구는 FSM 상태전환으로 사용되며, 전투 루틴, 이벤트 트리거에서 많이 사용되었다.
자, 그럼 이 도구는 어떻게 사용되었을까?

(상록시티)
상록시티의 화면과 상록시티에서 쓰인 이벤트 스크립트들이다
그들의 세계는 상태(state)로 구성되어 있었다
Z80이 가진 한계는 단순히 연산 능력에 그치지 않았다.
"프로그래머는 이벤트를 일일이 기억할 수 없었다."
그 대신 FSM(Finite State Machine, 유한 상태 기계) 이라는 구조가 도입되었다.
FSM이란?
FSM은 현재 상태에 따라 행동이 달라지는 시스템이다.
즉, "지금 뭐 하고 있느냐"에 따라 "다음에 뭘 할 수 있는지"가 정해져 있는 구조이다.
일상적인 가장 간단 FSM 예시: 신호등
현재 상태 |
조건 |
다음 상태 |
---|
빨간불(Red) |
30초 경과 |
초록불(Green) |
초록불 |
30초 경과 |
노란불(Yellow) |
노란불 |
5초 경과 |
빨간불(Red) |
현재 상태(State): 빨간불, 초록불, 노란불
전이 조건(Condition): 일정 시간 경과
동작(Action): 다음 상태로 전환
신호등은 FSM이다.
현재 색(상태)에 따라 다음 동작(Action)이 정해져있기때문이다.
즉 신호등의 경우 상태(State) 다음 색이 바뀌는 동작(Action)을 가지는 FSM이라고 할 수 있다.
FSM이란 ‘상태(state)’라는 개념을 중심으로 동작을 분리하는 설계 방식이며,
하나의 상태는 하나의 동작을 의미하고, 입력이나 조건에 따라 다음 상태로 넘어간다.
그리고 이것이 우리가 포켓몬을 이해하는 열쇠이다.
FSM이 필요한 이유: '게임의 모든 것'은 상태다
포켓몬 레드를 뜯어보면, 모든 것이 FSM이다.
그중에서 가장 상록시티(영문명: VridianCity)를 예로 들어보자

(상록시티 어셈블리 화면)
우선 FSM을 보기전에 포켓몬에서는 어떻게 상태를 옮길까?
FSM의 엔진: 스크립트 포인터 테이블과 디스패처
ViridianCity_Script:
call EnableAutoTextBoxDrawing
ld hl, ViridianCity_ScriptPointers ; 1. 스크립트 주소 목록의 시작점을 HL에 로드
ld a, [wViridianCityCurScript] ; 2. '현재 스크립트 번호'를 A에 로드
jp CallFunctionInTable ; 3. 테이블에서 A번째 함수를 찾아 점프
ViridianCity_ScriptPointers:
def_script_pointers
dw_const ViridianCityDefaultScript, SCRIPT_VIRIDIANCITY_DEFAULT ; 0번
dw_const ViridianCityOldManStartCatchTrainingScript, SCRIPT_VIRIDIANCITY_OLD_MAN_START_CATCH_TRAINING ; 1번
; ... 등등
이것이 상록시티 이벤트 FSM의 심장이자 엔진이다.
상태 저장 변수(The State) wViridianCityCurScript라는 RAM의 특정소에 저장된 1바이트 값이 FSM의 현재 상태를 결정한다
그리고 디스패쳐(The Dispatcher)인 ViridianCity_Script는 매 게임 틱마다 호출되어 wViridianCityCurScript 값을 확인한다
그리고 점프 테이블 CallFunctionInTable은 wViridianCityCurScript값을 인덱스로 사용하여 ViridianCity_ScriptPointers 테이블에서 실행해야할 실제 스크립트(상태)의 주소를 찾아, 그 주소로 JP(점프)한다.
이는 switch 문을 하드웨어 레벨에서 가장 효율적으로 구현한 방식이다. 긴 if-else 비교 없이, 단 몇번의 연산만으로 정확한 로직 블록으로 실행을 옮겨버린다.
상태(State)와 행동(Action)의 구현
테이블이 가리키는 `ViridianCityDefaultScript` 같은 레이블들이 바로 FSM의 각 '상태'에 해당하는 실제 코드이며,
그리고 그 안의 명령어들이 '행동(Action)'이다
실제 게임상의 코드에서는 이렇게 구현되어있다.
ViridianCityCheckGymOpenScript:
CheckEvent EVENT_VIRIDIAN_GYM_OPEN ; 행동 1: 체육관 오픈 이벤트를 체크
ret nz ; 조건이 만족되면(nz), 아무것도 안하고 리턴
; ... (중략) ...
ld a, TEXT_VIRIDIANCITY_GYM_LOCKED ; 행동 2: '문 잠김' 텍스트 ID를 로드
ldh [hTextID], a
call DisplayTextID ; 행동 3: 텍스트 출력
여기서 `CheckEvent`나 `DisplayTextID` 같은 `call` 명령어들이 바로 해당 상태에서 수행되는 행동(Action) 이다..
그렇다면 call은 무엇인가?
기본 설명: call의 동작 단계
call SomeLabel
Z80에서 call이 실행되면 다음이 일어난다:
1.현재 PC (Program Counter + 3) 값을 스택에 push
->즉, 다음 명령어 주소를 기억
2.PC를 SomeLabel 주소로 설정
->곧바로 점프한다
3.이후 ret 명령어를 만나면:
->스택에서 PC를 pop
->원래 위치로 되돌아감
이 call이 중요한 것은
이 저수준에서
행동을 독립적으로 모듈화하는데
call display
call Delay3
등등 각각의 행동이 모듈화된 서브 루틴으로 구현된다.
즉 call은 전화와 같아서,
call로 함수에게 전화를 걸면, Z80은 전화를 끊은 뒤 다시 돌아갈 장소(주소)를 ㅅ스택에 메모해두고, ret은 전화를 끊고, 다시 돌아가는 것이다.
그리고 이렇게 상태와 행동이 구현된다면 FSM의 가장 중요한 부분인 '상태 변경'은 어떻게 일어날까?
바로 바로 wViridianCityCurScript 변수에 새로운 값을 쓰는 것으로 이루어진다.
스크립트 예시를 보자
플레이어 행동에 의한 전이 (ViridianCityOldManText) 예시:
ViridianCityOldManText:
; ... (대화 출력) ...
call YesNoChoice ; "예/아니오" 선택지를 띄운다.
ld a, [wCurrentMenuItem] ; 플레이어의 선택(0=예, 1=아니오)을 A에 로드
and a ; A가 0인지(예) 확인
jr z, .refused ; 0이 아니면(아니오) .refused로 점프
ld hl, .KnowHowToCatchPokemonText ; "예"를 선택했을 경우
call PrintText
; === 상태 전이 발생! ===
ld a, SCRIPT_VIRIDIANCITY_OLD_MAN_START_CATCH_TRAINING ; 새 상태(1번) 번호를 A에 로드
ld [wViridianCityCurScript], a ; 현재 상태 변수에 덮어쓴다.
jr .done
; ...
플레이어가 "예"를 선택하는 순간, wViridianCityCurScript의 값이 스크립터 포인터내의 SCRIPT_VIRIDIANCITY_DEFAULT(0)에서 SCRIPT_VIRIDIANCITY_OLD_MAN_START_CATCH_TRAINING(1)으로 변경된다.
이로써 상태 전이가 완료되고.
다음 게임 틱에서 디스패처는 이제 ViridianCityOldManStartCatchTrainingScript를 실행하게 되는 것이다.
포켓몬 레드의 루트 기반 상태 머신을 간단하게 요약하면 다음과 같다
MainGameLoop:
LD A, (wGameMode)
CP $00
JP Z, InitBattle
CP $01
JP Z, HandleOverworld
CP $02
JP Z, HandleMenu
JP MainGameLoop
포켓몬 레드의 FSM은 다음의 특징을 가진다.
모든 상태는 1바이트 값 (enum 같은 개념)
분기는 CP + JP Z, label 조합
스택 기반 함수 호출이 아닌, 직접 점프 구조
서브 루틴도 가능하면 CALL 대신 JP로 처리하여 스택 사용 최소화
왜 이렇게 짰을까?
Z80의 스택은 RAM 공간을 소모하고, 크기도 작음 (약 128~256 bytes 내외)
CALL → RET 구조는 느리고 오류 위험 존재
그래서 대부분의 FSM은 CP + JP로 플랫 분기 처리 되어있는 것이다.
FSM vs 현대 FSM — 그 구조는 어떻게 바뀌었는가?

(현대 게임 엔진 대표자중 하나인 Unity의 애니메이션 FSM)
Z80에서의 FSM은 말 그대로 기계 그 자체였다.
상태는 레지스터에 저장된 값
전이는 조건 플래그를 기반으로 점프
행동은 call과 jp로 직접 연결
스택, 함수, 추상화는 최소화
하지만 오늘날의 FSM은 구조적으로 완전히 다르다.
1. 상태는 더 이상 레지스터 값이 아니다
포켓몬의 예시
ld a, [wViridianCityCurScript]
jp CallFunctionInTable
OOP로 구현한 FSM

상태는 명시적인 Enum
전이는 의미 기반 조건문
리턴 주소를 걱정할 필요도 없다
2. 전이는 조건 플래그가 아닌, 의미적 조건으로 제어된다
OOP로 구현한 FSM

인간 친화적으로 변했고, 유지 보수 용이하게 변했으며

동작(Action)또한 모듈화하고, 캡슐화된 함수로
클래스 혹은 스크립트 단위로 행동을 나누게 된것이다.
(일반적으로 FSM 코드는 switch 문으로 구현되지만, C#에서는 Dictionary<State, Action>과 같은 매핑 구조를 사용하여 행동을 등록할 수도 있다.
switch 문은 case 값이 조밀(dense) 하고, enum처럼 연속된 상수 값일 경우 컴파일러가 jump table로 최적화하므로 매우 빠른 분기 성능을 보인다.
반면 Dictionary 방식은 상태 값이 희소(sparse) 하거나, 런타임에 동적으로 상태를 주입/확장해야 하는 경우에 유리하다.
특히 상태 수가 많고, 값들이 일정하지 않을 때는 Dictionary가 더 간결하고 유지보수가 쉬우며, 실행 성능에서도 경쟁력을 가진다.)
가장 중요한 차이점: 제어권의 흐름
구분 | Z80 FSM | 현대 OOP FSM |
---|
상태 전이 | 직접 상태 값 변경 (ld [state], a ) | 상태 객체 간 전환 (stateMachine.ChangeState() ) |
흐름 제어 | jp , call , ret (PC 직접 조작) | 프레임 기반 Update() , Coroutine , async/await |
상태 유지 | RAM 변수 (wCurScript ) | 클래스 인스턴스, 컨텍스트 객체 |
디스패치 방식 | 포인터 테이블 + JP | 가상 함수 호출 / 인터페이스 기반 |
물론 저마다의 장점이 있었지만 OOP의 방식이 일반적으로 더 확장성이 뛰어났고, 저수준에서의 조작은 갈수록 방대해지는 게임의 크기에서 힘들어졌기때문에
점차 OOP적 FSM을 사용했다
분명히 OOP FSM은 다음과 같은 장점이 있었다
명시적인 상태 클래스
모듈화된 Enter, Update, Exit 함수
가독성이 좋고 유지보수 용이하다는 장점 등등
하지만 그럼에도 불구하고 성능적 병목이 존재하였다.
상태를 전이한다는 것은 무엇인가? 오브젝트가 재생성이 된다는 것이다. 물론 오브젝트 풀링등의 패턴을 사용하지만 이 역시도 어느정도 한계가 있었고
다음과 같은 성능적 병목들이 존재했다.
한계 | 설명 |
---|
상태 전이 시 오브젝트 재생성 비용 | GC/GPU 캐시 무효 발생 가능 |
Update 함수의 분산 호출 | CPU Branch Prediction 불리 |
상태 정보가 여러 객체에 퍼짐 | 데이터 정렬성 손실, 캐시 미스 증가 |
모바일/대규모 엔티티 대응 어려움 | 병렬화 비효율 |
즉 OOP FSM은 구조는 아름답지만, 대규모 환경에서 성능적 한계가 명확했다.
그리고 이때 DOP가 등장한다.
DOP란? : Data-Oriented Programming을 말한다.

DOP FSM의 핵심
상태는 더 이상 "객체"가 아니다.
상태는 "컴포넌트 데이터"이고,
동작은 "시스템(System)"이 책임지게 변한 것이다.
DoP는 OOP가 가진 시스템이 커질수록 생기는 성능상의 병목을 극복하기 위해, 다시 데이터 중심으로 회귀한 프로그래밍 지향으로써
유니티에서는 ECS라는 이름으로구현된다.
유니티 ECS FSM을 바탕으로 DOP FSM을 구조화하면 다음과 같다.
1. 상태는 구조체로 배열된다

이제 상태는 유닛의 필드가 아니라, 전체 유닛 배열의 하나의 열(Column)이 되고
2. 상태 별로 데이터를 선형 필터링하면서 분기하고

3. 상태 전이를 '쓰기 연산'으로 구현한다.

즉 OOP에서 변했던, 객체간의 철학보다는
이제 오히려 과거의 직접적인 기계에 매핑하던 FSM과 유사해지기 시작한 것이다.
특성 (Feature) | Z80 FSM (과거) | OOP FSM (아름다운 외도) | DOP FSM (과거로의 회귀) |
---|
상태 표현 | RAM의 원시 데이터 (byte ) | 객체 (포인터/참조) | 메모리의 원시 데이터 (struct ) |
로직 실행 | 순차적 루프 + 점프 (JP ) | 분산된 개별 호출 (virtual Update ) | 순차적 루프 + 조건 (System ) |
메모리 접근 | 순차적 접근 ([hli] ) | 무작위 접근 (포인터 추적) | 순차적 접근 (배열 순회) |
핵심 가치 | 하드웨어 효율성 | 인간의 가독성 | 하드웨어 효율성 |
FSM의 진화 3단계
시대 | 구현 방식 | 상태 표현 | 제어 흐름 | 병렬화/성능 |
---|
Z80 시대 | 레지스터 + 점프 | RAM 변수 | JP/RET | 하드웨어 최적 |
OOP 시대 | 상태 클래스 | 객체 필드 | ChangeState() | 유지보수 최적 |
현대 실시간 시스템 | DOP 기반 | 구조체 열(Column) | 데이터 조건 | 성능 최적 |

가장 저렴하고, 빠르며, 믿을 수 있는 부품은 존재하지 않는 부품이다. -Golden Bell PDP 컴퓨터 설계자
8KB의 RAM, 부동소수점 연산 불가
포켓몬 개발자들에게 '존재하지 않는 부품'은 부동소수점 연산 유닛이었고, 넉넉한 RAM이었으며, 똑똑한 컴파일러였다..
그들은 '없는 것'을 당연하게 여기고, 오직 있는 것(레지스터와 약간의 메모리)만으로 세계를 빚었다.
유명한 컴퓨터 공학자는 이렇게 말했다
가장 저렴하고, 빠르며, 믿을 수 있는 부품은 존재하지 않는 부품이다.
Bell은 없는 부품이야말로 최선의 부품이라하였지만, 포켓몬의 개발자는 '없는 부품을 코드로 만든다'라고 답했다.
오늘날 우리에게 '존재하지 않는 부품'은 어쩌면 무한한 CPU 캐시와 완벽한 분기 예측일지 모른다.
우리는 풍요로움 속에서 '하드웨어가 당연히 빠를 것'이라 가정하지만,
최고의 성능은 여전히 기계의 동작 원리를 이해하고 그들의 방식으로 데이터를 구성해 줄 때 나타난다.
FSM의 진화는 '인간을 위한 추상화'와 '기계를 위한 최적화' 사이를 오가는 거대한 진자 운동과 같다.
그리고 오늘날 DOP의 모습에서, 우리는 그 둘을 모두 쟁취하려는 현대 개발자들의 새로운 도전을 목격하고 있다.
도구는 변했지만, 주어진 제약 속에서 최고의 경험을 만들어내려는 '장인의 혼'은 그때나 지금이나 변하지 않았던 것이다.
그리고 그때나 지금이나 가장 저렴하고, 빠르며, 믿을 수 있는 부품
프로그래머는 여전히 살아 숨쉬고 있다.
댓글 영역
획득법
① NFT 발행
작성한 게시물을 NFT로 발행하면 일주일 동안 사용할 수 있습니다. (최초 1회)
② NFT 구매
다른 이용자의 NFT를 구매하면 한 달 동안 사용할 수 있습니다. (구매 시마다 갱신)
사용법
디시콘에서지갑연결시 바로 사용 가능합니다.