Valve Steam Controller CAD 공개, 3개월 복각 프로젝트 회고

목차

$ FreeCAD --console
>>> import Part
>>> Part.read("steam_controller_v1.STEP")
ImportError: STEP_Reader: Unable to read entity at line 184329
Mesh validation: 17 non-manifold edges, 4 self-intersecting faces
[1]    9412 segmentation fault  FreeCAD --console

Valve가 GitHub에 풀어둔 Steam Controller CAD를 받자마자 만난 첫 화면이다. STEP 파일 한 개에 수만 개의 엔티티가 들어 있는데, FreeCAD 0.21.2(작성 시점 LTS)는 그 중 일부를 못 읽고 죽었다. 원인은 단순했다. SolidWorks 2023 포맷으로 export된 surface trim 정보 일부가 OpenCASCADE 커널에서 호환되지 않는 형태로 남아 있었다. 메시 변환 단계에서 비매니폴드 엣지가 잡히는 게 그 증거다.

물론, 3개월 프로젝트의 시작이 이거였다. 프론트에서 백엔드로 전환한 지 2년, FastAPI랑 PostgreSQL 만지다가 갑자기 STEP 파일 디버깅하고 있다는 게 비현실적이었다.

왜 Steam Controller CAD에 손댔나

한편, Valve가 Steam Controller의 CAD를 공식 저장소에 공개했다는 발표가 났을 때, 솔직히 처음엔 별 감흥이 없었다. Steam Controller는 2019년 단종됐고 새로 살 방법이 없으니까. 흥미가 생긴 건 "제조 파일 전체"가 들어 있다는 부분이다. 외장 케이스 STEP, 내부 보드 Gerber, BOM, 그리고 펌웨어 일부. 일반 사용자가 직접 컨트롤러를 복각할 수 있다는 의미다.

회사 프론트팀에 있을 때부터 Steam Deck 쓰는 동료가 자기 쓰던 Steam Controller 트리거가 망가져서 갈고 싶다는 얘기를 종종 했다. 단종된 부품이라 대체 방법이 없었다. CAD가 풀렸으니 트리거 한 부분만 출력해서 갈아주면 된다고 가볍게 시작한 게 발단이었다.

그러나, 문제는 가볍게 끝나지 않았다는 거다.

1개월 차 — 케이스 출력 톨러런스

첫 단계는 외장 케이스의 일부를 3D 프린팅하는 거였다. Bambu Lab P1S(2025년에 구입한 가정용 FDM)에 PLA로 출력했고, 처음엔 별 의심 없이 STEP을 STL로 변환만 해서 슬라이서에 넣었다.

특히, 결과는 처참했다. 트리거 가이드 부분이 워핑(휨)으로 슬롯에 안 들어갔고, 0.2mm 노즐 기준 톨러런스 부족으로 버튼이 케이스에 끼어 안 눌렸다. 전체 길이가 0.4% 정도 수축해 PCB 마운트 홀 위치도 어긋났다.

게다가, 여기서 백엔드 마인드가 발목을 잡았다. 코드는 컴파일이 되거나 안 되거나 둘 중 하나다. 성공/실패가 분명하다. 그런데 3D 프린팅은 "출력은 됐는데 0.3mm 작다"라는 회색지대가 있다. 처음엔 이걸 어떻게 디버깅해야 할지 감이 안 잡혔다.

톨러런스 보정에 들어간 시간

결국 Fusion 360으로 STEP을 다시 import해서 모든 외부 치수에 +0.4% 배율을 걸었다. 일률적으로 0.4%를 걸면 버튼 슬롯 같은 내부 홀은 더 작아져서 더 안 맞는다. 외부는 키우고 내부는 줄이는 별도 처리가 필요했다.

그러나, Fusion 360 Personal 라이선스(2026년 5월 기준 30일 유예 후 매년 갱신)는 STEP import 자체는 잘 되지만 대용량 어셈블리에선 종종 멈춘다. 어셈블리 전체를 한 번에 import하지 말고 파트 단위로 잘라서 가져오는 게 안정적이었다.

Cursor로 G-code 후처리

여기서 AI 도구를 살짝 끼웠다. Bambu Studio가 export한 G-code의 특정 영역만 수축률을 보정하는 후처리 Python 스크립트를 Cursor(2026년 4월 기준 v0.46)로 작성했다.

# 트리거 가이드 영역 X/Y만 +0.4% 스케일
# Z, E축은 건드리지 않는다
def scale_xy(line: str, factor: float) -> str:
    return re.sub(r"([XY])(-?\d+\.?\d*)",
                  lambda m: f"{m.group(1)}{float(m.group(2))*factor:.4f}",
                  line)

Cursor한테 그냥 "G-code의 X/Y 좌표만 일정 배율로 스케일링해라" 시켰더니 첫 시도에 정규표현식을 잘못 짜서 E축(extrusion) 값까지 스케일링했다. 이게 들어가면 익스트루더가 과압출해서 출력물이 망가진다. 사람이 한 번 검수해야 한다는 걸 다시 확인한 구간이다.

2개월 차 — PCB 발주의 함정

또한, 케이스가 어찌어찌 맞기 시작했을 무렵 PCB로 넘어갔다. Valve가 올린 보드 파일은 Altium Designer 포맷이다. 정확히는 .PcbDoc + .SchDoc + 라이브러리 파일들. KiCad(8.0.6 기준)로 변환하려고 보니 무료 변환 도구가 완벽하지 않다.

[kicad-altium-importer]
Warning: 12 footprints have unmapped pad shapes
Warning: 4 net classes lost during conversion
Error: Polygon pour on layer GND_2 references missing footprint U17

따라서, KiCad의 Altium importer는 footprint 일부를 빈 박스로 떨궜다. U17은 핵심 컨트롤러 IC였는데 그 footprint가 통째로 사라졌다. Altium 평가판(15일)을 깔아서 U17 footprint만 export하고 KiCad에 다시 import하는 방법으로 우회했다.

JLCPCB로 발주를 넣을 때 한 번 더 막혔다. Gerber 파일은 통과했는데 BOM 매칭에서 절반 이상이 LCSC 부품 코드로 자동 매칭되지 않았다. Steam Controller에 들어간 햅틱 모터 칩(LRA driver)이 LCSC 재고에 없어서 결국 Mouser에서 별도 주문했다.

결국, 비용은 이렇게 정리됐다. 단위는 원, 2026년 4월 시점 환율 기준이다.

항목 예상 실제 비고
3D 프린팅 필라멘트 15,000 38,000 재출력 7회
PCB 발주 (JLCPCB 5장) 25,000 25,000 견적대로
BOM 부품 (LCSC) 60,000 71,000 일부 단가 상승
BOM 부품 (Mouser) 0 48,000 LRA driver, 송료 포함
합계 100,000 182,000

결국, 처음 잡은 30만원 예산은 여유가 충분하다고 느꼈는데, 실제론 빠듯했다. 다른 사람이 이 프로젝트를 하려면 적어도 50만원은 잡아야 한다고 본다.

3개월 차 — 펌웨어와 Steam Input 벽

물론, 여기가 가장 길게 막힌 구간이다. Valve가 풀어둔 펌웨어 소스는 부트로더와 입력 처리 일부였고, Steam Input과 통신하는 프로토콜 핵심부는 빠져 있었다. 추정컨대 라이선스 이슈로 보인다. 보드를 만들어도 Steam 클라이언트가 "내 컨트롤러구나"라고 인식하는 부분은 직접 짜거나 우회해야 한다.

오픈소스 펌웨어로 우회 시도

물론, GitHub에 SC-Controller 같은 리버스 엔지니어링 프로젝트가 있긴 한데, 작성 시점 마지막 활성 커밋이 2024년 말이라 최신 SteamOS와는 약간 어긋난다. 결국 GP2040-CE(2026년 3월 v0.7.11) 기반으로 갈아엎었다. 이건 RP2040용 게임패드 펌웨어인데, Steam Controller 보드의 STM32와는 MCU가 다르다.

여기서 한 번 멈췄다. 이미 STM32 기반으로 PCB를 발주한 상태인데 펌웨어를 RP2040용으로 갈려면 보드를 다시 만들어야 한다. 시간과 돈이 한 번 더 들어간다.

결국 한 선택

순정 보드 그대로 가되, Steam Input 인식은 포기하고 표준 XInput 가상 컨트롤러로 동작하게 했다. STM32용 TinyUSB 1.0.4 기반 펌웨어를 직접 짰고, 이 단계에서 Claude Code(2026년 3월 업데이트, 1M 컨텍스트 베타)에 STM32 HAL 코드와 Steam Controller PCB 핀맵을 통째로 넣고 USB HID 서술자 작성을 맡겼다.

// USB HID 서술자 - XInput 호환
// AI 초안 → 사람이 vendor ID/product ID 검증
static const uint8_t hid_report_desc[] = {
    0x05, 0x01,  // Usage Page (Generic Desktop)
    0x09, 0x05,  // Usage (Game Pad)
    // ...
};

즉, Claude가 만든 서술자 초안은 한 번에 동작하지 않았다. 트리거를 아날로그(Z축)로 매핑하는 부분에서 비트 순서가 뒤집혀 있어서, Windows 10 게임 컨트롤러 테스트에서 트리거 값이 0~127만 나왔다. 0~255가 정상이다. 참고한 레퍼런스가 오래된 사양이어서 그런 것으로 보인다. 직접 잡아서 수정.

:::tip AI한테 펌웨어 코드를 맡길 때, 데이터시트 PDF를 같이 던져주는 게 정확도가 훨씬 높다. 텍스트 설명만으로는 비트 단위 매핑에서 자주 틀린다. 특히 USB HID 서술자처럼 바이트 단위로 의미가 갈리는 영역은 더 그렇다. :::

복각 결과 — Steam Controller 원본과의 차이

그런데, 3개월이 지난 시점에 만든 컨트롤러는 이렇다. 외장은 P1S로 출력한 PLA 케이스, 표면 처리는 안 했다. 보드는 자작 PCB, JLCPCB 5장 중 1장만 정상 동작했다. 펌웨어는 STM32 TinyUSB 기반 XInput 가상 패드. Windows 10에서는 일반 게임패드로, Steam에서는 "Generic Controller"로 잡힌다.

원본과 비교했을 때 햅틱 강도가 체감상 절반 이하로 약하다. LRA 드라이버 칩을 원본과 다른 모델로 갈았기 때문일 가능성이 높다. 트리거 응답 지연도 원본 약 4ms 대비 약 8ms로 두 배 느렸다. 펌웨어 폴링 레이트(원본 1kHz 추정, 자작 500Hz) 차이에서 온 것으로 보인다. 터치패드 정확도는 무난한 수준이고, 무게는 원본 약 290g 대비 약 312g로 살짝 무거웠다(케이스 두께를 0.4mm 키운 영향이다). Steam Input 통합은 빠졌다.

작동은 한다. 사람이 쓰기에 큰 불편은 없다. 하지만 "Steam Controller를 복각했다"고 자랑할 수준은 아니라는 게 정직한 평가다.

이번 회고에서 명확해진 두 가지

3개월 동안 가장 많이 느낀 건 "오픈소스 하드웨어"라는 단어가 코드 오픈소스만큼 자유롭지 않다는 거다. CAD 파일이 풀려도 그걸 실물로 만들 때 들어가는 톨러런스, BOM 수급, 펌웨어 호환성은 별개의 벽이다. Valve가 푼 건 출발선이지 결승선이 아니다.

한편, 또 하나는 백엔드 사고방식이 하드웨어 디버깅에 자주 방해가 된다는 점이다. 코드는 git revert로 되돌릴 수 있지만, 한 번 출력한 PLA는 못 되돌린다. PCB 한 번 발주하면 일주일 기다린다. 시간 비용 구조가 다르다. 이 부분이 가장 적응이 안 됐다.

다음엔 이렇게 한다

지금까지 시간순으로 풀어봤는데, 다음 회차에는 이 네 가지부터 바꿀 생각이다.

  • STEP을 받자마자 STL이 아니라 STEP 그대로 Fusion 360에서 열고, 변환은 마지막 단계로 미룬다
  • PCB는 첫 시도부터 KiCad로 다시 그린다. 변환 도구를 믿지 않는다
  • 펌웨어는 보드 발주 전에 RP2040 기반으로 갈아엎고 시작한다(GP2040-CE 호환성이 가장 안정적이다)
  • BOM은 LCSC와 Mouser 두 곳을 미리 매핑해두고 발주에 들어간다

게다가, 다음엔 트리거 햅틱 강도 보정을 위해 LRA 드라이버 칩을 DRV2605L(현재 LCSC 재고 충분)로 바꿔서 Steam Controller 복각 v2를 한 번 더 돌려볼 생각이다.

관련 글