M5StickC-Plus2용 모듈형 스타터 키트 구축: 지저분한 코드에서 클린 아키텍처로

발행: (2025년 12월 15일 오전 06:35 GMT+9)
10 min read
원문: Dev.to

Source: Dev.to

왜 또 다른 M5Stack 프로젝트인가?

M5StickC‑Plus2를 처음 손에 넣었을 때, 뭔가 멋진 것을 만들고 싶었습니다.
하지만 많은 개발자들처럼, 금방 지루한 초기 설정 작업에 부딪혔습니다: 버튼 설정, 디스플레이 좌표 관리, 메뉴 처리, 전원 관리, 타이머 설정… 재미있는 부분을 시작하기 전에 이미 수백 줄의 인프라 코드를 작성하고 말았습니다.

기존 라이브러리의 문제점

  • 블랙 박스 – 내부를 볼 수도 수정할 수도 없습니다.
  • 과도한 추상화 – 때때로 세밀한 제어가 필요합니다.
  • 학습 곡선 – 각 라이브러리가 자체 API를 가집니다.
  • 의존성 – 하나의 라이브러리가 여러 다른 라이브러리를 끌어옵니다.

얻을 수 있는 것

  • ✅ 읽고 이해할 수 있는 전체 소스 코드
  • ✅ 수정 가능한 동작 예제
  • ✅ 확장 가능한 아키텍처 패턴
  • ✅ 숨겨진 의존성이나 마법이 없습니다

메뉴 스크롤 방식이 마음에 안 든다면? 바꾸세요. 색상이 다르고 싶다면? 디스플레이 핸들러를 수정하세요. 버튼 레이아웃을 바꾸고 싶다면? 컨트롤을 업데이트하세요.

VSCode + PlatformIO 로 전환

저는 Arduino IDE(많은 사람들이 그렇듯)에서 시작했지만 곧 VSCode + PlatformIO 로 전환했습니다. 이유는 다음과 같습니다:

// Where is this function defined?
// Which library does this come from?
// Good luck finding it...
M5.Lcd.setCursor(10, 80);
  • IntelliSense – 실제로 동작하는 자동 완성
  • Go to Definition – 함수 정의로 바로 이동
  • Project Structure – 올바른 파일 조직(제가 가장 큰 문제였음)
  • Library Management – 명확한 의존성 관리
  • Modern C++ – 완전한 C++11/14/17 지원

전환에 한 시간 정도 걸렸고, 이후 수십 시간을 절약했습니다.

페이지 관리

초기에 여러 “스크린” 혹은 “페이지”가 필요했습니다. 순진한 접근 방식은 금방 엉망이 되었습니다:

// ❌ The messy way
if (currentPage == 0) {
    drawClock();
} else if (currentPage == 1) {
    drawMenu();
} else if (currentPage == 2) {
    drawSettings();
}

PageManager 소개

class PageBase {
public:
    virtual void setup()   = 0;   // Called when entering page
    virtual void loop()    = 0;   // Called every frame
    virtual void cleanup() = 0;   // Called when leaving page
};

이제 각 페이지가 스스로를 관리합니다:

void ClockPage::setup() {
    display->clearScreen();
    clockHandler->drawClock(0);
}

void ClockPage::loop() {
    if (hasActiveMenu()) return;   // Pause if menu is open
    // Update clock every second
}

교훈: 각 컴포넌트에 자체 생명 주기를 부여하세요. main() 에서 모든 것을 관리하지 마세요.

스택 기반 메뉴 관리

메뉴는 생각보다 어려웠습니다. 필요했던 것은:

  • 메인 메뉴
  • 서브 메뉴(예: 설정 → 디스플레이 설정 → 밝기)
  • 올바르게 동작하는 “뒤로” 버튼

스택 기반 MenuManager

class MenuManager {
private:
    MenuHandler* menuStack[MAX_MENU_STACK];
    int stackSize = 0;

public:
    void pushMenu(MenuHandler* menu) {
        menuStack[stackSize++] = menu;
        menu->draw();
    }

    void popMenu() {
        if (stackSize == 0) return;
        --stackSize;
        if (stackSize > 0) {
            menuStack[stackSize - 1]->draw();   // Redraw previous menu
        }
    }
};

결과적인 네비게이션 흐름:

Clock Page
  → Open Menu
    → Settings
      → Display
        → [Back]
      → [Back]
    → [Back]
Clock Page (restored)

교훈: 올바른 자료구조를 선택하세요. 스택은 중첩된 네비게이션을 자연스럽게 처리합니다.

std::function 으로 콜백 구현

클래스 멤버에 접근할 수 있는 메뉴 콜백이 필요했습니다:

// Desired:
mainMenu->addItem("Start Timer", [this]() {
    clockHandler->startTimer();   // Access class member
});

예전 MenuItem 은 C‑스타일 함수 포인터를 사용했으며, this 를 캡처할 수 없었습니다:

// ❌ Old way
struct MenuItem {
    void (*callback)();   // Can't capture 'this'!
};

현대 C++ 해결책

// ✅ New way
#include <functional>

struct MenuItem {
    std::function<void()> callback;   // Can capture anything!
};

이제 람다식이 정상 동작합니다:

mainMenu->addItem("Settings", [this]() {
    openSettingsSubmenu();   // 'this' captured, works perfectly
});

교훈: 현대 C++에서는 콜백에 std::function 을 사용하세요. 람다 캡처를 자유롭게 처리할 수 있습니다.

디스플레이 추상화

초기 코드는 저수준이며 반복적이었습니다:

// ❌ What does this even mean?
M5.Lcd.setCursor(10, 80);
M5.Lcd.setTextSize(3);
M5.Lcd.print("Hello");

DisplayHandler 를 도입해 의미 있는 메서드를 제공했습니다:

display->displayMainTitle("Hello");
display->displaySubtitle("Subtitle");
display->displayStatus("Ready", MSG_SUCCESS);

구현 예시:

void displayMainTitle(const char* text, MessageType type) {
    M5.Lcd.setTextSize(SIZE_TITLE);
    M5.Lcd.setTextColor(getColorForType(type));

    int x = (SCREEN_WIDTH - textWidth) / 2;   // Auto‑center
    int y = ZONE_CENTER_Y - 20;

    M5.Lcd.setCursor(x, y);
    M5.Lcd.print(text);
}

교훈: 저수준 세부 사항을 추상화하세요. 미래의 자신이 고마워합니다.

딥슬립 전원 관리

M5StickC‑Plus2는 딥슬립을 올바르게 사용하면 배터리 수명을 크게 늘릴 수 있습니다.

실패한 시도

// ❌ Crashes on wake‑up
esp_deep_sleep_start();

문제: GPIO4 가 슬립 중에 HIGH 상태를 유지해야 하는데, 그렇지 않으면 전원이 꺼집니다.

정상 동작 솔루션

void M5deepSleep(uint64_t microseconds) {
    // CRITICAL: Keep power pin high
    pinMode(4, OUTPUT);
    digitalWrite(4, HIGH);
    gpio_hold_en(GPIO_NUM_4);
    gpio_deep_sleep_hold_en();

    esp_sleep_enable_timer_wakeup(microseconds);
    esp_deepSleep_start();
}

이 코드는 포모도로 타이머에 사용됩니다: 25분 슬립 → 깨기 → 알람 울리기 → 시계 표시.

교훈: 하드웨어 특유의 트릭은 하드웨어 특화된 해결책이 필요합니다. 표준 API 가 바로 동작한다고 가정하지 마세요.

버튼 레이아웃

M5StickC‑Plus2에는 세 개의 버튼이 있습니다:

      ______PWR          (side)
                    A    (front)
      ___B_____          (side, opposite)

여러 레이아웃을 시험한 뒤 최종 선택은 다음과 같습니다:

상태PWRAB (짧게)B (길게)
메뉴 없음페이지 전환메뉴 열기페이지 전용 동작(예: 타이머 시작)
메뉴 활성아래로 이동선택위로 이동메뉴 닫기

왜 이런 레이아웃인가?

  • 측면 버튼은 네비게이션에 적합: 기기를 잡고 있을 때 누르기 쉬움
  • 중앙 버튼은 액션에 사용: 가장 중요한 버튼이 최적 위치에 배치
  • 길게 누르면 “뒤로”: 실수로 나가는 것을 방지

교훈: 버튼 인체공학은 중요합니다. 실제 하드웨어에서 테스트하세요.

프로젝트 구조

lib/
├── display_handler.h      # Display abstraction
├── menu_handler.h         # Individual menu logic
├── menu_manager.h         # Menu stack
├── page_manager.h         # Page lifecycle
├── clock_handler.h        # Time & timers
├── battery_handler.h      # Power management
└── pages/
    ├── page_base.h        # Abstract base class
    └── clock_page.h       # Default clock page

네이밍 규칙

  • *_handler.h – 집중된 재사용 가능한 컴포넌트
  • *_manager.h – 복합적인 오케스트레이션
  • *_utils.h – 유틸리티 함수

각 페이지는 DisplayHandler*MenuManager* 를 전달받습니다. 이들은 상속이 아니라 구성을 통해 기능을 결합합니다.

마무리 생각

  • 콜백에 C‑스타일 함수 포인터는 피하고 std::function 을 사용하세요.
  • 전원 관리는 초기에 테스트하세요—하드웨어 의존성이 크고 전체가 깨질 수 있습니다.
  • 버튼 레이아웃을 먼저 스케치하고 코딩을 시작하세요; 나중에 바꾸면 프로젝트 전체에 파급 효과가 있습니다.
  • VSCode + PlatformIO 로 시작하세요; 마이그레이션이 곧 큰 이득을 줍니다.

전체 스타터 키트는 GitHub 에서 제공됩니다. 클론하고, M5StickC‑Plus2에 업로드한 뒤 바로 빌드를 시작하세요!

Back to Blog

관련 글

더 보기 »

결정이 너무 많고, 전략은 부족

!‘Decisões demais, estratégia de menos’ 표지 이미지 https://media2.dev.to/dynamic/image/width=1000,height=420,fit=cover,gravity=auto,format=auto/https%3A%2F%2F...