쌓아 올리기와 갈아내기
원문은 Matthias Endler님이 에 게재했습니다. 이 블로그 구독하기
오랜 시간 동안 나는 견고한 소프트웨어 시스템을 만드는 두 가지 상호 보완적인 방식에 끌려 왔다. 바로 ‘쌓아 올리기’와 ‘갈아내기’다.
쌓아 올리기는 아주 작은 코어에서 시작해 점진적으로 기능을 추가해 나가는 것을 의미한다. 갈아내기는 아주 거친 아이디어에서 시작해 시간이 지남에 따라 다듬어 나가는 것을 의미한다.
두 접근법 중 어느 하나가 본질적으로 더 뛰어나지는 않다. 거의 스타일에 관한 결정에 가깝고, 팀의 역학과 문제 영역에 대한 친숙도에 따라 달라진다. 게다가 이 주제에 대한 내 생각 자체가 특별히 참신한 것도 아니지만, 지난 몇 년간 배운 것들을 정리해 보고 싶었다.
쌓아 올리기

출처: Wikimedia 퍼블릭 도메인
‘쌓아 올리기’는 단단한 기초를 먼저 만드는 데 초점을 둔다. 나는 잘 아는 시스템을 다룰 때나 참고할 명확한 명세가 있을 때 이 방식을 선호한다. 예를 들어 프로토콜을 구현할 때나 내가 만든 MOS 6502 emulator처럼 하드웨어를 에뮬레이션할 때 이 방식을 사용한다.
나는 ‘바텀업(bottom-up)’이라는 표현보다 ‘쌓아 올리기’라는 표현을 선호하는데, 전자가 건축과 위로 쌓아 올리는 성장의 이미지를 떠올리게 하기 때문이다. ‘바텀업’은 더 추상적이고 방향만을 나타낸다. 또 ‘바텀업’은 항상 전문 용어처럼 느껴지는 반면, ‘쌓아 올리기’는 더 직관적이고 시각적이어서 비개발 직군 이해관계자에게 아이디어를 전달하는 데도 도움이 될 수 있다.
‘쌓아 올리기’ 방식을 쓸 때 나는 몇 가지 원칙을 지키려 노력한다:
- 쉽게 조합하고 테스트할 수 있는 원자적인 빌딩 블록에 집중한다.
- 단순하고 검증 가능한 속성들로부터 강력한 보장을 쌓아 올린다.
- 성능이 아니라 정확성에 집중한다.
- 자신의 논리를 검증하기 위해 코드와 함께 문서를 작성한다.
- 다음 레이어로 넘어가기 전에 추상화를 확실히 다진다.
분석적인 사람들과 협업할 때는 이 접근법이 잘 통한다. 형식 기법(formal methods)이나 수학을 배경으로 하는 사람들은 ‘빌딩 블록’과 증명이라는 관점에서 사고하는 경향이 있다. 함수형 프로그래머들도 이 방식을 선호하는 경우가 많다고 느꼈다.
Rust 같은 언어에서는 타입 시스템이 불변식(invariant)을 강제하고 단순한 컴포넌트로부터 복잡한 시스템을 쌓아 올리는 것을 더 쉽게 만든다. 또 Rust의 트레이트 시스템은 조합(composition)을 장려하는데, 이러한 사고방식과 잘 맞아떨어진다.
‘쌓아 올리기’ 방식의 단점은 눈에 보이는 결과를 보기 전에 기반 레이어에 많은 시간을 쏟게 된다는 점이다. 이런 식으로는 MVP까지 가는 데 시간이 오래 걸릴 수 있다. 또 일단 특정 아키텍처를 정하고 나면 방향을 틀거나 바꾸기가 어려워서, 어떤 사람들은 이 접근법이 너무 경직되고 유연하지 못하다고 느끼기도 한다.
예를 들어 웹 프레임워크를 만든다고 해보자. 프로젝트 초반에는 수많은 질문이 쏟아진다:
- 동기 방식으로 할 것인가, 비동기 방식으로 할 것인가?
- 요청 라우팅은 어떻게 동작할 것인가?
- 미들웨어를 둘 것인가? 어떻게 구현할 것인가?
- 응답 생성은 어떻게 할 것인가?
- 에러 처리는 어떻게 할 것인가?
‘쌓아 올리기’ 접근법이라면 이런 질문들에 먼저 답하고 핵심 추상화를 설계하는 것부터 시작할 것이다. 요청(request)과 응답(response) 타입, 라우터, 미들웨어 시스템 같은 기반 컴포넌트는 프레임워크의 뼈대이므로 아주 견고해야 한다.
핵심 자료구조와 그 상호작용을 확정한 뒤에야 공개 API를 만드는 단계로 넘어간다. 이렇게 하면 매우 견고하고 잘 설계된 시스템이 나올 수 있지만, 거기까지 가는 데 오랜 시간이 걸릴 수도 있다.
예를 들어, 유명한 http 크레이트의 Request 구조체는 여기에서 볼 수 있다:
#[derive(Clone)]
pub struct Request<T> {
head: Parts,
body: T,
}
/// Component parts of an HTTP `Request`
///
/// The HTTP request head consists of a method, uri, version, and a set of
/// header fields.
#[derive(Clone)]
pub struct Parts {
/// The request's method
pub method: Method,
/// The request's URI
pub uri: Uri,
/// The request's version
pub version: Version,
/// The request's headers
pub headers: HeaderMap<HeaderValue>,
/// The request's extensions
pub extensions: Extensions,
_priv: (),
}이 짧은 코드에는 꽤 영리한 설계 결정이 여러 가지 담겨 있다:
Request구조체는 본문 타입T에 대해 제네릭으로 되어 있어, 본문을 어떻게 표현할지(예: 바이트 스트림, 문자열 등)에 유연성을 제공한다.Parts구조체가Request구조체와 분리되어 있어, 본문을 건드리지 않고도 요청 메타데이터에 쉽게 접근할 수 있다.Extensions는 하위 프로토콜에서 도출된 추가 데이터를 저장하는 데 사용할 수 있다._priv: ()필드는 외부 코드가Parts를 직접 생성하는 것을 막기 위한 제로 사이즈 타입이다. 제공된 생성자를 통해서만 생성하도록 강제하고,Parts구조체의 불변식이 유지되도록 보장한다.
extensions를 제외하면, 이 설계는 오랜 시간 검증을 받았다. 2017년 첫 버전 이후로 거의 바뀌지 않고 유지되고 있다.
갈아내기

출처: Wikimedia 퍼블릭 도메인
내가 보기에 못지않게 잘 동작하는 대안적 접근법이 바로 ‘갈아내기’다. 이 방식에서는 거친 프로토타입(또는 버티컬 슬라이스)부터 시작해 시간이 지나면서 다듬어 나간다. 만족할 만한 결과가 나올 때까지 거친 모서리를 계속 갈아내는 것이다. 거친 원목을 가져다 점차 다듬어 예술 작품으로 만드는 목공과 비슷하게 느껴진다. (목공을 해본 적은 없지만, 아마 그런 느낌이지 않을까 싶다.)
중요한 점은 이 방식이 프로토타이핑과 비슷하지만 동일하지는 않다는 것이다. 차이는 작성한 코드를 버릴 계획이 없다는 데 있다. 대신 문제의 반복적인 특성을 활용하여 최종 버전에 도달할 때까지 의도적으로 ‘초안’을 다듬어 나가는 것이다. 필요하다면 언제든지 작업을 멈추고 현재 버전을 그대로 출시할 수도 있다.
나는 실험과 빠른 반복이 필요한 창의적인 프로젝트를 할 때 이 접근법이 잘 맞는다고 느꼈다. 게임 개발이나 스크립트 언어 배경을 가진 사람들은 더 탐색적인 방식으로 일하는 데 익숙해서 이 방식을 선호하는 경향이 있다.
이 방식을 쓸 때는 다음과 같은 원칙을 지키려 한다:
- 내면의 완벽주의를 꺼둔다.
- 첫 초안을 쓰는 동안에는 편집하지 않는다.
- 코드 중복은 얼마든지 허용한다.
- 리팩터링, 또 리팩터링, 계속 리팩터링한다.
- 테스트는 첫 초안이 끝난 뒤로 미룬다.
- 가장 바깥쪽 API부터 집중한다. 그것을 확실히 잡은 다음 내부를 다듬는다.
이 접근법은 코드를 버리고 새로운 것을 시도하기 쉽게 만든다. 반면 미리 계획을 세우고 체계적이며 꼼꼼하게 일하는 걸 좋아하는 사람들에게는 답답하게 느껴질 수 있다는 것도 알게 됐다. 이런 ‘혼돈’이 어떤 사람들에게는 거부감을 주는 듯하다.
예를 들어 Rust로 게임을 만든다고 해보자. 게임의 모든 요소를 조정하면서 게임 플레이 메커니즘이 딱 ‘제대로 된 느낌’이 들 때까지 빠르게 반복하고 싶을 것이다.
그러기 위해서는 게임 루프의 뼈대만 가지고 시작하고 다른 것은 아무것도 두지 않을 수도 있다. 그 다음 화면에서 움직일 수 있는 플레이어 캐릭터를 추가한다. 점프 높이와 이동 속도를 느낌이 좋을 때까지 조정한다. 이 단계에서는 당신과 게임 로직 사이에 추상화가 거의 없다. 중복 코드나 하드코딩된 값이 많을 수도 있지만, 지금은 괜찮다. 핵심 게임 플레이 메커니즘이 잡히면 그때 코드를 리팩터링하기 시작하면 된다.
Rust는 게임 디자인 과정 초기에 Bevy 같은 프레임워크를 사용하면 오히려 방해가 될 수 있다고 생각한다. 엔티티 컴포넌트 시스템(entity component system)이 꽤 무겁게 느껴지고 빠른 반복을 방해할 수 있다. (적어도 내가 마지막으로 Bevy를 써봤을 때는 그렇게 느꼈다.)
나는 macroquad를 이용해 직접 윈도우와 렌더링 루프를 만들었을 때 훨씬 더 좋은 경험을 했다. 네, 전체 코드가 한 파일에 있었고, 아니오, 테스트는 하나도 없었다. 논할 만한 아키텍처도 없었다.
그런데도… 게임을 만드는 과정은 정말 즐거웠다! 코드는 언제든 나중에 리팩터링할 수 있다는 걸 알았기에, 일단 지금 이 순간에 집중해 게임 플레이를 제대로 만드는 데 몰두하고 싶었다.
다음은 나의 게임 루프다. 지극히 명령형(imperative)이고, 시작하기 위해 거대한 프레임워크를 배울 필요가 없었다:
#[macroquad::main("Game")]
async fn main() {
let mut player = Player::new();
let input_handler = InputHandler::new();
clear_background(BLACK);
loop {
// Get inputs - only once per frame
let movement = input_handler.get_movement();
let action = input_handler.get_action();
// Update player with both movement and action inputs
player.update(&movement, &action, get_frame_time());
// Draw
player.draw();
next_frame().await
}
}이 코드를 이해하기 위해 Rust 전문가일 필요는 없다.
매 루프마다 나는 단순히 다음을 수행한다:
- 입력을 받는다
- 플레이어 상태를 업데이트한다
- 플레이어를 그린다
- 다음 프레임을 기다린다
이런 종류의 작업에서는 매우 전형적인 설계다.
원한다면 이제 코드를 갈아내며 프로덕션 수준이 될 때까지 더 모듈러한 설계로 리팩터링할 수도 있다. 입력 처리를 플레이어 로직과 분리하기 위해 ‘리스너/콜백’ 시스템을 도입하거나, 여러 게임 오브젝트를 관리하기 위한 씬 그래프(scene graph), 게임 엔티티와 컴포넌트를 관리하기 위한 온톨로지(ontology) 시스템을 도입할 수도 있다. 하지만 굳이 그럴 필요가 있을까? 지금은 아키텍처가 아니라 게임 메커니즘이 중요하다.
적절한 균형 찾기
두 방식 모두 정확하고, 유지보수 가능하며, 효율적인 시스템으로 이어질 수 있다. 어느 한쪽이 더 낫거나 못하다고 할 수 없다.
대부분의 사람들은 두 접근법 중 한쪽으로 기우는 경향이 있다는 걸 알게 됐다. 하지만 두 접근법에 모두 익숙해지고 언제 어떤 모드를 적용할지 아는 것이 도움이 된다. 두 접근법은 문제의 서로 다른 끝에서 출발하기 때문에 중간에 바꾸기가 꽤 까다로우니, 신중하게 선택해야 한다.
글을 무작위로 읽기
댓글
로그인하고 댓글 남기기