Building Up And Sanding Down

Matthias Endler

쌓아 올리기와 다듬기

오랜 시간 동안 저는 견고한 소프트웨어 시스템을 만드는 두 가지 상호 보완적인 방식에 끌리게 되었습니다. 바로 ‘쌓아 올리기’와 ‘다듬기’입니다.

쌓아 올리기는 아주 작은 코어에서 시작해 기능을 점진적으로 추가해 나가는 방식입니다. 다듬기는 아주 거친 아이디어에서 출발해 시간을 들여 조금씩 정교하게 다듬어 가는 방식입니다.

두 방식 중 어느 하나가 본질적으로 더 우월한 것은 아닙니다. 팀의 역학과 문제 영역에 대한 친숙도에 따라 달라지는, 거의 스타일에 가까운 선택입니다. 게다가 이 주제에 대한 제 생각은 특별히 새로울 것도 없지만, 지난 몇 년간 배운 것을 정리해 보고 싶었습니다.

쌓아 올리기

고대 이집트에서 단단한 석재 블록을 가공하는 모습
고대 이집트에서 단단한 석재 블록을 가공하는 모습
출처: Wikimedia 퍼블릭 도메인

쌓아 올리기는 단단한 기초를 먼저 만드는 데 초점을 둡니다. 저는 잘 아는 시스템을 다룰 때나 명확하게 참조할 수 있는 명세가 있을 때 이 방식을 즐겨 사용합니다. 예를 들어 프로토콜을 구현하거나 제 MOS 6502 emulator처럼 하드웨어를 에뮬레이션할 때 활용합니다.

저는 ‘바텀업(bottom-up)’보다 ‘쌓아 올리기’라는 표현을 선호합니다. 전자가 건축과 위로 성장하는 이미지를 떠올리게 하기 때문입니다. ‘바텀업’은 더 추상적이고 방향만을 나타내며, 늘 전문 용어처럼 느껴졌습니다. 반면 ‘쌓아 올리기’는 훨씬 직관적이고 시각적이어서 비개발 이해관계자에게 아이디어를 설명하는 데도 도움이 될 수 있습니다.

쌓아 올리기 방식으로 작업할 때 제가 지키려고 하는 몇 가지 규칙이 있습니다:

  • 쉽게 조합하고 테스트할 수 있는 원자적인 빌딩 블록에 집중합니다.
  • 단순하고 검증 가능한 속성으로부터 강력한 보장을 쌓아 올립니다.
  • 성능이 아니라 정확성에 집중합니다.
  • 사고 과정을 검증하기 위해 코드와 함께 문서를 작성합니다.
  • 다음 레이어로 넘어가기 전에 추상화를 확실히 다집니다.

분석적인 성향이 강한 사람들과 협업할 때 이 접근법이 특히 잘 맞습니다. 형식 기법이나 수학을 배경으로 둔 사람들은 ‘빌딩 블록’과 증명이라는 관점에서 사고하는 경향이 있습니다. 함수형 프로그래머들도 이 방식을 선호하는 경우가 많다고 느꼈습니다.

Rust 같은 언어에서는 타입 시스템이 불변식을 강제하고 단순한 컴포넌트로부터 복잡한 시스템을 쌓아 올리는 일을 더 쉽게 만들어 줍니다. 또한 Rust의 트레이트 시스템은 조합을 장려하는데, 이는 이러한 사고방식과 잘 맞아떨어집니다.

‘쌓아 올리기’ 방식의 단점은 가시적인 결과를 보기 전에 기반 레이어에 많은 시간을 쏟게 된다는 점입니다. 이런 식으로는 MVP까지 가는 속도가 느려질 수 있습니다. 또 일단 특정 아키텍처를 정하고 나면 방향을 틀거나 바꾸기 어려워, 이 방식을 너무 경직되고 유연하지 못하다고 느끼는 사람도 있습니다.

예를 들어 웹 프레임워크를 만든다고 해 보겠습니다. 프로젝트 초반에는 수많은 질문이 쏟아집니다:

  • 동기식으로 할까, 비동기식으로 할까?
  • 요청 라우팅은 어떻게 동작하게 할까?
  • 미들웨어를 둘까? 둔다면 어떻게 구현할까?
  • 응답 생성은 어떻게 할까?
  • 에러 처리는 어떻게 할까?

쌓아 올리기 방식이라면 이런 질문들에 먼저 답하고 핵심 추상화부터 설계하는 것부터 시작합니다. 요청과 응답 타입, 라우터, 미들웨어 시스템 같은 기반 컴포넌트는 프레임워크의 뼈대이므로 아주 견고해야 합니다.

핵심 데이터 구조와 그 상호작용을 확정한 뒤에야 공개 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로 게임을 만든다고 해 보겠습니다. 게임의 모든 요소를 조정하면서 게임플레이 메커니즘이 ‘딱’ 맞는 느낌이 들 때까지 빠르게 반복하고 싶을 것입니다.

그러기 위해서는 게임 루프의 뼈대만으로 시작하고 다른 것은 아무것도 두지 않을 수 있습니다. 그 다음 화면에서 움직일 수 있는 플레이어 캐릭터를 추가합니다. 점프 높이와 이동 속도를 느낌이 좋아질 때까지 조정합니다. 이 단계에서는 여러분과 게임 로직 사이에 추상화가 거의 없습니다. 중복된 코드와 하드코딩된 값이 많을 수도 있지만, 지금은 괜찮습니다. 핵심 게임플레이 메커니즘이 자리를 잡은 뒤에야 코드를 리팩터링하기 시작하면 됩니다.

게임 디자인 과정 초반에 Bevy 같은 프레임워크를 쓰면 오히려 Rust가 방해가 될 수 있다고 생각합니다. 엔티티 컴포넌트 시스템이 꽤 무겁게 느껴져 빠른 반복을 방해할 수 있습니다. (적어도 제가 지난번에 Bevy를 써 봤을 때는 그렇게 느꼈습니다.)

macroquad를 이용해 직접 윈도우와 렌더링 루프를 만들었을 때는 훨씬 더 좋은 경험을 했습니다. 네, 전체 코드가 한 파일에 있었고, 아니요, 테스트는 하나도 없었습니다. 논할 만한 아키텍처도 없었습니다.

그럼에도… 게임 작업 자체는 정말 즐거웠습니다! 나중에 언제든 코드를 리팩터링할 수 있다는 것을 알았기에, 일단 지금 이 순간에 집중해 게임플레이를 제대로 만드는 데 매달리고 싶었습니다.

다음은 지극히 명령형으로 작성된 제 게임 루프로, 시작하기 위해 큰 프레임워크를 배울 필요가 없었습니다:

#[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 전문가일 필요는 없습니다.

매 루프마다 제가 하는 일은 단순합니다:

  • 입력을 받고
  • 플레이어 상태를 업데이트하고
  • 플레이어를 그리고
  • 다음 프레임을 기다립니다

이런 종류의 작업에서는 매우 전형적인 설계입니다.

원한다면 이제 코드를 다듬어 프로덕션 수준이 될 때까지 더 모듈화된 설계로 리팩터링할 수도 있습니다. 입력 처리와 플레이어 로직을 분리하기 위해 ‘리스너/콜백’ 시스템을 도입하거나, 여러 게임 오브젝트를 관리하기 위한 씬 그래프, 게임 엔티티와 컴포넌트를 관리하기 위한 온톨로지 시스템을 도입할 수도 있습니다. 하지만 굳이 그럴 필요가 있을까요? 지금은 아키텍처가 아니라 게임 메커니즘이 더 중요합니다.

적절한 균형 찾기

두 방식 모두 올바르고 유지보수 가능하며 효율적인 시스템으로 이어질 수 있습니다. 더 낫고 못함이 정해진 접근법은 없습니다.

대부분의 사람들은 두 접근법 중 하나에 끌리는 경향이 있다고 느꼈습니다. 하지만 두 방식을 모두 잘 알고 언제 어떤 모드를 적용해야 하는지 아는 것이 도움이 됩니다. 신중하게 선택하시기 바랍니다. 두 접근법은 문제의 서로 다른 끝에서 출발하기 때문에 중간에 전환하는 것이 상당히 까다롭기 때문입니다.

원문은 Matthias Endler님이 에 게재했습니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.