Lichess 클라이언트를 만들려고 했던 것은 아니었습니다
요약
Lichess API의 모든 엔드포인트를 지원하는 Rust 라이브러리 'litchee' 개발 경험담입니다. AI를 활용해 OpenAPI 스펙으로부터 엔드포인트, DTO, 테스트 코드를 자동 생성함으로써 API 클라이언트 구축의 경제적 한계를 극복한 사례를 다룹니다.
핵심 포인트
- AI를 활용해 OpenAPI 스펙 기반의 엔드포인트 생성을 자동화함
- API 클라이언트 구축 시 엔드포인트 범위(Breadth)의 한계 비용을 획기적으로 낮춤
- AI는 코드의 '양'을 만들어내지만, 라이브러리의 완성도는 별개의 문제임
- Rust 언어를 사용하여 184개의 엔드포인트를 포함한 완전한 클라이언트 구현
AI가 엔드포인트(Endpoints)를 작성했습니다. 저는 라이브러리를 작성했습니다.
Lichess 클라이언트를 만들려고 했던 것은 아니었습니다. 저는 완전한 체스 훈련 애플리케이션을 구축하고 있었고, 이를 작동시키기 위해 몇 가지 Lichess API 기능을 통합해야 했습니다. 쉬운 길은 그 몇 개의 엔드포인트(Endpoints)만 직접 연결하고 넘어가는 것이었습니다. 하지만 오픈 소스는 매일 저에게 무언가를 제공하며, 저만을 위해 구축하기보다는 그중 일부를 돌려주고 싶었습니다. 개인적인 헬퍼(Helper) 대신 실제적이고 완전하며 재사용 가능한 라이브러리를 말이죠.
litchee가 바로 그것입니다. 하나의 우회로가 그 자체로 하나의 결과물이 된 것입니다.
저는 Lichess API에 문서화된 모든 작업을 다루는 Rust 클라이언트를 구축했습니다. 사용자, 게임, 토너먼트, 퍼즐, 스터디(studies), 브로드캐스트(broadcasts), 보드 및 봇 플레이, 오프닝 익스플로러(opening explorer), 테이블베이스(tablebase)까지 총 184개 모두를 포함합니다. 한 사람이, 며칠 동안, 주로 저녁 시간을 활용하여 만들었습니다.
엔드포인트(Endpoints)를 작성하는 것은 쉬운 부분이었습니다.
3년 전이었다면 이 문장은 거짓이었겠지만, 왜 지금은 그렇지 않은지, 그리고 실제로 무엇이 어려웠는지에 대해 솔직하게 말씀드리겠습니다.
API 클라이언트가 보통 미완성인 이유
거의 모든 대규모 API의 클라이언트 라이브러리를 열어보면 똑같은 것을 발견하게 될 것입니다. 인기 있는 20%는 다뤄져 있지만, 나머지는 // TODO로 남아 있거나 아예 없습니다. 이것은 게으름 때문이 아닙니다. 경제성 때문입니다. 팀원 중 아무도 사용하지 않는 엔드포인트(Endpoint)를 위한 바인딩(Binding)을 작성하는 것(사양을 읽고, 응답을 모델링하고, 테스트를 작성하는 것)은 모두가 사용하는 것을 작성하는 것과 비용이 동일하지만, 돌아오는 보상은 훨씬 적습니다. 따라서 단일 유지 관리자는 이성적으로 자신이 필요한 엔드포인트(Endpoints)에서 멈추게 됩니다. 완전함이란 팀을 통해 얻을 수 있는 사치였습니다.
저는 litchee도 마찬가지일 것이라고 가정하고 시작했습니다. 보드 및 봇 플레이를 잘 다루고, 나머지는 흉내만 내는 식으로 말이죠. 하지만 결과는 달랐고, 그 이유는 정확히 짚고 넘어갈 가치가 있습니다.
무엇이 변했는가: 범위(Breadth)의 비용이 낮아졌다
Lichess는 OpenAPI 스펙을 공개합니다. 저는 이를 git 서브모듈(submodule)로 저장소에 포함(vendor)시켰고, 이를 단순히 읽기 위한 문서가 아니라 반드시 준수해야 할 계약(contract)이자 신뢰할 수 있는 단일 출처(source of truth)로 취급했습니다. 그 시점부터 엔드포인트(endpoint)를 생성하는 것은 기계적인 루프가 되었습니다: AI에게 스펙 항목을 지정하면, 요청 형태(request shape), 응답 DTO, 그리고 스펙 자체의 예시를 기반으로 구축된 테스트 픽스처(test fixture)를 얻을 수 있었습니다.
완전한 클라이언트를 만드는 것을 비경제적으로 만들었던 요소인 '범위(breadth)'의 비용이 거의 제로에 가깝게 떨어졌습니다. 40개의 엔드포인트든 400개의 엔드포인트든, 추가되는 엔드포인트의 한계 비용은 더 이상 비싸지 않았습니다.
여기서 주의할 점이 있습니다. 이 부분은 과장되어 팔리곤 하기 때문입니다. AI가 "라이브러리를 구축한" 것은 아닙니다. AI는 양(volume)을 만들어냈습니다. 올바르게 보이고, 스펙의 형태를 갖추었으며, 테스트가 뒷받침된 양이었지만, 결국은 양일 뿐입니다. 그리고 양 그 자체는 라이브러리가 아닙니다. 그것은 잡동사니 서랍일 뿐입니다.
함정: 184개의 엔드포인트가 하나의 라이브러리가 되지는 않는다
여기에 아무도 경고해주지 않는 사실이 있습니다. 범위의 비용이 무료가 되면 엄청난 양을 생성하게 되고, 아주 빠르게 각각 고립된 상태에서 작성된 184개의 엔드포인트를 마주하게 됩니다. 각각은 국소적으로는 합리적입니다. 하지만 함께 모이면 일관성이 없습니다 — 서로 다른 명명 규칙, 서로 다른 에러 처리, 동일한 개념에 대한 서로 다른 형태, 그리고 동일한 DTO가 세 가지의 약간씩 다른 방식으로 모델링되어 있습니다.
라이브러리는 엔드포인트의 더미가 아닙니다. 라이브러리는 모든 엔드포인트에 걸쳐 유지되는 약속(promises)의 집합입니다. 그 일관성(coherence)이 가치의 전부이며, 이것이 바로 고립된 생성(generation-in-isolation)이 파괴하는 핵심입니다. 따라서 진짜 작업, 즉 시간이 걸렸던 부분은 그 약속들이 무엇인지 결정하고, 생성된 모든 조각이 그 약속을 따르도록 강제하는 것이었습니다.
제가 약속을 지켰고 깨뜨리지 않은 몇 가지 사례는 다음과 같습니다:
- 파일당 하나의 관심사 (One concern per file). 각 비즈니스 관심사(board, swiss, puzzles…)는 엔드포인트(endpoints), DTO, 테스트를 함께 담고 있는 단일 플랫 파일(flat file)입니다.
- 엄격한 크기 제한 (Hard size limits). 900라인을 넘는 파일도, 20라인을 넘는 함수도 허용하지 않습니다. 무언가 한계에 다다르면 분리합니다. 이는 임의적으로 들릴 수 있지만, 실제로는 단일 구성 요소가 조용히 엉망이 되는 것을 막아주는 압박 요인으로 작용합니다.
- 모든 DTO에
Lichess접두사 사용.LichessGame,LichessUser,LichessToken. 지루하지만, 무엇을 다루고 있는지 항상 명확히 알 수 있습니다. - 옵션이 있는 모든 것에 빌더 (Builders) 사용. 따라서 12개의 선택적 매개변수가 있는 요청이 12개의 인자를 가진 함수가 되지 않도록 합니다.
이 중 영리한 것은 하나도 없습니다. 그것이 핵심입니다. 이것들은 결정(decisions)이며, AI는 당신을 대신해 이 결정을 내려주지 않습니다. AI는 각 위반 사항이 국소적으로는 괜찮기 때문에, 이 모든 규칙을 위반하는 코드를 기꺼이 생성할 것입니다. 이러한 위반은 전체적으로 모였을 때만 문제가 되는데, '전체(aggregate)'는 엔드포인트별 생성기(per-endpoint generator)가 볼 수 없는 유일한 영역입니다.
내가 넘길 수 없었던 부분: 공개 인터페이스 (the public surface)
끈질기고, 줄일 수 없는 인간적인 영역을 단 하나 꼽아야 한다면, 그것은 무엇을 pub으로 만들지 결정하는 것이었습니다.
무언가를 공개(public)로 표시하는 것은 곧 라이브러리가 무엇인지를 결정하는 것입니다. 그것은 당신을 믿은 모든 사람을 실망시키지 않고서는 되돌릴 수 없는 약속입니다. 생성된 코드는 특정 필드가 공개된다는 것이 하나의 계약(contract)임을 알지 못합니다. 그저 필드가 존재한다는 것만 알 뿐입니다. 따라서 크레이트(crate)의 형태 — 무엇을 내보내고(exported), 무엇을 숨기며, 모듈 트리가 API의 관심사를 어떻게 반영하는지, 호출자가 실제로 무엇을 접하는지 — 는 일련의 작고 의도적이며 일방향적인 결정들이었습니다.
의도한 전체적인 사용성(ergonomics)은 단 한 번의 호출로 확인할 수 있습니다:
let client = LichessClient::builder().token("lip_…").build()?;
let mut games = client.games().export_user("bobby").max(5).stream().await?;
...
이는 제가 라이브러리 전체가 읽히기를 원했던 방식 그대로입니다. 그 단계에 도달하는 것은 생성(generation)의 문제가 아니라, 한 번에 하나의 결정을 내리며 쌓아가는 취향(taste)의 문제였습니다.
에러 핸들링은 생성이 아니라 설계입니다
이러한 점이 가장 명확하게 드러나는 곳은 에러(error)입니다.
"에러를 처리한다"는 말의 게으른 버전은 모든 것을 하나로 뭉뚱그리는 것입니다. 즉, "무언가 잘못되었습니다. 여기 메시지가 있습니다"라고 출력하는 것이죠. AI는 즉시 그렇게 해줄 것이고, 이는 당신의 코드를 호출하는 사람에게 아무런 쓸모가 없습니다. 왜냐하면 그들이 그 에러에 대해 _반응(react)_할 수 없기 때문입니다. 그들은 그저 로그(log)를 남길 수 있을 뿐입니다.
저는 호출자가 무엇이 잘못되었는지에 따라 매칭(match)할 수 있기를 원했습니다. 그래서 저는 분류 체계(taxonomy)를 직접 설계한 다음, AI가 내용을 채우도록 했습니다:
pub enum ApiErrorKind {
BadRequest, // 400
Unauthorized, // 401
...
저 SwissUnauthorizedEdit 변체(variant)가 이 논쟁의 축소판입니다. Lichess API는 당신이 소유하지 않은 스위스 토너먼트(Swiss tournament)를 편집하려고 할 때 401을 반환합니다. 이는 "토큰이 유효하지 않습니다"와 동일한 상태 코드(status code)이지만, 완전히 다른 문제이며 완전히 다른 해결 방법이 필요합니다. 어떤 생성기(generator)도 상태 코드로부터 그 차이를 추론해내지 못합니다. 저는 나중에 직접 디버깅(debug)해야 하는 사람처럼 명세서(spec)를 읽음으로써 겨우 이를 찾아냈습니다. 이를 별도의 변체로 모델링(modelling)하는 것은 설계(design) 작업입니다. AI는 그것이 중요하다는 것을 알 수 없었을 것입니다. 왜냐하면 _중요성(mattering)_은 인간의 판단이기 때문입니다.
그리고 여기서부터는 취향의 문제가 아니라 정확성(correctness)의 문제가 됩니다. API는 단순한 URL의 집합이 아니라 — 하나의 계약(contract)입니다. API에는 그것이 어떻게 사용될 수 있는지에 대한 규칙이 포함되어 있습니다. 준수해야 하는 속도 제한(rate limits), 토큰이 반드시 지녀야 하는 범위(scopes), 그리고 특정 작업이 허용되지 않는 상태(states) 등이 그것입니다. 이러한 규칙들은 클라이언트가 다듬어낼 수 있는 선택적인 추가 사항이 아닙니다. 그것들은 계약의 조건입니다. 진정한 클라이언트란 바로 이러한 규칙들을 구현하는 클라이언트입니다. 즉, "이것은 금지되었습니다"라는 상황을 운영 환경(production)에서 갑작스럽게 발견하게 되는 놀라운 사건이 아니라, 호출자가 보고 따를 수 있는 값(value)으로 변환하는 클라이언트입니다.
여기에는 놓치기 쉬운 자연스러운 적합성이 존재합니다. 결정론적(Deterministic)이고 철저한 에러 처리(Error handling)와 충실하게 준수되는 API 계약(API contract)은 양면에서 바라본 동일한 작업입니다. API가 부과하는 모든 규칙은 클라이언트 측에서 반드시 이름을 명시하고 반환할 수 있어야 하는 에러가 됩니다. 계약을 완전히 매핑하면 에러 타입은 스스로 작성됩니다. 실패 모드(failure mode)당 하나의 특정 변형(variant)으로 에러를 모델링하면, 당신은 계약을 그대로 옮겨 적은 것이 됩니다. 모든 것을 포괄하는 문자열(catch-all string) 방식은 이 두 가지 측면 모두에서 실패합니다. 이는 계약을 준수하지도 않으며, 호출자가 계약을 이행하도록 허용하지도 않습니다. 따라서 이것은 단순히 다듬기 위해 추가하는 미사여구가 아닙니다. 이는 클라이언트가 통신하는 서비스에 대해 갖는 가장 기본적인 의무이며, 한 번에 하나의 엔드포인트(endpoint)씩 작업하는 생성기(generator)가 인지조차 할 수 없는 바로 그 의무입니다.
완전함은 또한 정확함을 의미해야 합니다
범위 확장(breadth-for-free)이 건드리지 못하는, 더 조용한 종류의 작업이 있습니다. 바로 그것이 프로덕션(production) 환경에서 버텨내도록 만드는 것입니다.
이것이 왜 선택 사항이 아닌지 명확히 할 가치가 있습니다. 왜냐하면 이를 나중에 처리할 다듬기 작업으로 취급하고 싶은 유혹이 들기 때문입니다. 완전함(Completeness)은 약속입니다. 제가 litchee가 184개의 모든 작업을 다룬다고 말할 때, 독자는 "184개 모두를 신뢰할 수 있다"라고 듣습니다. 이것이 애초에 완전함을 주장하는 핵심 이유입니다. 따라서 그중 단 하나라도 실제 입력값에서 무너지는 순간, 그 약속은 거짓이 됩니다. 그리고 완전함에 대한 거짓 약속은 정직한 공백보다 더 나쁩니다. 엔드포인트가 없는 것은 미리 진실을 알려주므로 그에 맞춰 계획을 세울 수 있지만, 존재하지만 고장 난 엔드포인트는 최악의 순간—부하가 걸린 프로덕션 환경에서, 당신이 테스트하지 않은 바로 그 입력값에서—까지 당신을 속입니다. 정확하지 않은 범위 확장은 라이브러리가 할 수 있는 일을 늘려주는 것이 아니라, 라이브러리가 당신을 배신할 수 있는 표면적을 넓힐 뿐입니다.
두 번째 이유는 이 코드가 존재하게 된 방식과 더 구체적으로 관련이 있습니다. 명세(Spec)는 해피 패스(happy path)를 기술합니다. 즉, 각 엔드포인트(endpoint)마다 깔끔한 예시를 하나씩 제공하며, 생성기(generator)는 지극히 합리적으로 그 예시가 통과되도록 만듭니다. 하지만 프로덕션(production)은 언해피 패스(unhappy path)입니다. 잘못된 형식의 청크(malformed chunk), 끊긴 연결(dropped connection), 속도 제한(rate limit)을 유발하는 버스트(burst), 한 시간 동안 열려 있는 스트림(stream) 같은 것들 말입니다. 이 중 그 어떤 것도 명세에는 없으며, 따라서 생성된 코드에도 포함되어 있지 않습니다. 예시는 대규모 환경(scale)에서는 거의 볼 일이 없는 바로 그 입력값입니다. 실제로 마주하게 될 입력값들은 아무도 기록해 두지 않은 것들입니다. 그 간극을 메우는 것은 생성기가 빈칸을 채우는 작업이 아닙니다. 그것은 실제 네트워크(wire)가 어떻게 동작할지를 사람이 예측하는 일입니다.
Lichess는 이벤트 스트림(event streams), 실시간 보드 상태(live board state), 게임 내보내기(game exports) 등 많은 엔드포인트를 줄바꿈으로 구분된 JSON(newline-delimited JSON) 형태로 스트리밍합니다. 한 줄에 하나의 JSON 값이 있으며, 그 사이에는 빈 keep-alive 라인이 들어있습니다. 단순한(naive) 방식은 응답을 줄바꿈 기준으로 나누고 각 줄이 온전하기를 기대합니다. 하지만 그렇지 않습니다. 네트워크는 임의의 청크(chunk) 단위로 바이트(bytes)를 전달하며, JSON 객체는 흔히 두 개의 청크에 걸쳐 나뉘어 도착합니다. 따라서 청크 경계에서 줄을 재조립하고, keep-alive를 건너뛰며, 타입이 지정된 값들의 깨끗한 Stream을 산출하는 작고 화려하지 않은 버퍼(buffer)가 필요합니다. 이는 수십 줄 정도의 코드이며, 특정 엔드포인트와는 아무런 관련이 없습니다. 또한 어떤 명세도 기술하지 않고 어떤 생성기도 자원하지 않는 종류의 정확성(correctness)입니다.
비밀 정보가 로그에 유출되지 않도록 하는 토큰 마스킹(token redaction), 오래 지속되는 스트림을 방해하지 않는 읽기 타임아웃(read timeouts), Retry-After를 준수하는 것 등도 마찬가지입니다. 이 모든 것은 범위(breadth)의 문제가 아닙니다. 이 모든 것이 바로 "API를 커버하는 것"과 "의존하기에 안전한 것" 사이의 차이입니다.
병목 지점의 이동
그래서 제가 실제로 배운 것은 이것이며, 이는 이 글에서 제가 강력하게 옹호할 수 있는 유일한 주장입니다.
AI는 아키텍처(Architecture)의 중요성을 줄인 것이 아닙니다. 오히려 아키텍처의 중요성을 더욱 높였습니다. — 그동안 모든 노력을 흡수해 버렸던 요소를 제거했기 때문입니다. 184개의 엔드포인트(Endpoint)를 수작업으로 타이핑하는 것이 비용이었을 때는, 그 비용이 모든 것을 압도했습니다. 설계상의 문제점을 느낄 수 있을 만큼 멀리 나아갈 여유조차 없었죠. 이제 타이핑은 거의 비용이 들지 않으며, 완전히 노출된 채 남겨진 것은 언제나 진짜 작업이었던 부분들입니다: 불변량(Invariants), 에러 분류 체계(Error taxonomy), 공개 인터페이스(Public surface), 그리고 이 라이브러리가 무엇인지에 대한 판단입니다.
병목 지점이 타이핑에서 취향(Taste)으로 이동했습니다. AI는 폭(Breadth)을 확장하는 승수입니다. AI는 아키텍트가 아니며, AI가 더 많은 폭을 제공할수록 당신은 더 뛰어난 아키텍트가 되어야 합니다.
이러한 도구들이 발전함에 따라 그 경계가 어디에 형성될지는 진심으로 불확실합니다. 어쩌면 취향의 일부도 기계적인 영역이 될지도 모릅니다. 하지만 현재, 혼자서 하나의 완전한 클라이언트를 구축하며 얻은 솔직한 보고는 다음과 같습니다: 기계는 엔드포인트를 작성했고, 그것은 결과적으로 제가 필요하지 않은 부분이었습니다. 라이브러리 — 즉, 약속(Promises), 형태(Shape), 판단(Judgement calls) — 가 바로 제가 필요했던 부분이었습니다.
다음 단계
표면(Surface)은 완성되었으며, 이는 언제나 결승선이 아닌 토대로 의도된 것이었습니다. 현재 상태의 클라이언트는 API를 충실히 반영합니다: 각 작업당 하나의 메서드(Method)가 있으며, 타입이 지정되어 입력되고 출력됩니다. 그것은 올바른 기본 레이어(Base layer)이지만, 저수준(Low-level) 레이어입니다. 아무도 아침에 일어나서 "8개의 파라미터를 가진 game-export 엔드포인트를 호출하고 스트림(Stream)을 어큐뮬레이터(Accumulator)로 접어 넣고 싶어" 하지는 않습니다. 사람들은 질문을 품고 일어납니다.
따라서 다음 레이어는 원시 엔드포인트(Raw endpoints) 위에 구축될 파사드(Facade)입니다. 즉, 사람들이 실제로 던지는 질문에 답하는 일련의 고수준(Higher-level) 기능들입니다:
- 최근 20번의 패배에서 상대방의 평균 레이팅(Rating)은 얼마인가? — 내가 실력이 올라가며 지고 있는가, 아니면 떨어지며 지고 있는가?
- 내가 지속적으로 간과하는 퍼즐 테마는 무엇인가? — 나도 모르게 계속 놓치고 있는 전술(Tactics)은 무엇인가?
- 나의 시간 조절(Time control)이나 시간대가 결과와 어떤 상관관계가 있는가?
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기