
호환성 작업을 안전하게 굴리는 법: scikit-learn array API adoption을 내 프로젝트로 번역하기
실험 백엔드를 하나 붙였다. main에는 영향이 없을 줄 알았다. 그런데 어느 날부터 CI가 일주일에 한두 번씩, 같은 테스트가 다른 이유로 깨지기 시작했다. 더 골치 아픈 건, 로그/메트릭까지 흐려져서 평소에 잡히던 신호가 안 잡히는 날이 생겼다는 거다.
그때 깨달았다. 새 백엔드가 문제라기보다, 실험이 프로덕션 동선에 섞인 게 문제였다.
본문에는 링크를 넣지 않고, 읽을거리는 맨 아래 참고자료로만 모았다.
array API adoption을 내 프로젝트로 가져오면, 먼저 인터페이스가 흔들린다
scikit-learn의 array API adoption 업데이트 글을 읽으면(참고자료), “새 배열 표준을 지원한다”는 문장보다 더 크게 보이는 게 있다. 호환성 작업을 ‘한 번의 마이그레이션’으로 보지 않고, 사용자의 입력/출력 경로를 조금씩 넓혀가는 작업으로 다룬다는 점이다.
이걸 내 프로젝트로 번역하면 질문이 바뀐다.
- 우리 코드가 받는 입력은 진짜로 “numpy 배열”뿐인가?
- 내부에서 너무 빨리 타입을 고정해버리는 습관(예: 특정 라이브러리로의 캐스팅)은 없는가?
- 실패했을 때 어디서 결정할 건가? (조용히 기존 경로로 돌아갈지, 에러로 멈출지)
실무에서 array API 호환성은 보통 성능 문제가 아니라 경계 문제로 터진다. 타입/백엔드 결정이 여기저기 흩어져 있으면, 어느 경로는 새 백엔드, 어느 경로는 기존 백엔드가 된다. 그리고 그 섞임은 테스트가 잡기 전에 프로덕션 신호부터 망친다.
그래서 첫 단계는 “지원한다”가 아니라 “결정하는 곳을 한 군데로 모은다”다.
호환 레이어(backend 선택 래퍼): ‘결정’은 한 군데에서만 한다
호환성 작업에서 제일 위험한 코드가 뭔지 아나. 새 백엔드 코드를 ‘조심스럽게’ 여러 파일에 조금씩 넣는 거다. 그 순간부터 main은 혼합물이 된다.
그래서 나는 백엔드 선택을 얇은 래퍼로 강제한다. 아래 코드는 의사코드 수준이지만, 의도는 명확하다.
- backend를 누가/어디서 정하는지
- 입력을 어떤 네임스페이스(xp)로 통일하는지
- fallback 정책을 어디서 한 번만 결정하는지
# compat/array_backend.py (의사코드)
from __future__ import annotations
class BackendUnavailable(RuntimeError):
pass
def get_xp(*arrays, backend: str = "auto"):
"""배열 네임스페이스를 결정하는 자리를 한 군데로 모은다."""
if backend == "numpy":
import numpy as xp
return xp
if backend == "array_api":
# 예: array_api_compat 같은 도우미를 통해 namespace를 얻는다
# xp = array_api_compat.array_namespace(*arrays)
# return xp
raise BackendUnavailable("array_api backend not wired")
# auto: 플래그/환경/입력 타입을 보고 결정(여기서만)
# 실패 시 fallback도 여기서만 결정
import numpy as xp
return xp
def matmul(a, b, *, backend: str = "auto"):
xp = get_xp(a, b, backend=backend)
# 이후 연산은 가능한 한 xp.* 형태로 작성(backend 종속을 흩뿌리지 않기)
return xp.matmul(a, b)
이 래퍼 하나가 생기면 팀의 대화가 달라진다.
- “어디서 array API로 가는 거지?”가 아니라
- “get_xp에서만 결정한다”가 된다.
그리고 이게 ‘동선’의 시작이다. 실험이 섞이지 않게 하려면, 선택의 문이 한 개여야 한다.
테스트 매트릭스: ‘돌아간다’가 아니라 ‘어디까지 보장하나’를 문장으로 만든다
호환성 작업이 길어지면, 팀은 두 가지를 동시에 한다.
- 새 백엔드에 맞춰 코드를 조금씩 바꾼다
- 기존 백엔드로 돌아가는 경로를 계속 유지한다
이 상태에서 “테스트가 통과했다”는 말은 점점 의미가 없어지기 쉽다. 어떤 백엔드에서? 어떤 입력에서? 어떤 기능에서?
그래서 나는 테스트를 더 많이 만들기 전에, 먼저 매트릭스를 만든다. 체크리스트 문서가 아니라, 테스트가 실제로 돌 수 있는 축으로.
- backend 축: numpy / array_api(또는 후보)
- dtype 축: float32 / float64 (프로젝트가 민감한 축만)
- 기능 축: 핵심 연산 몇 개(가장 많이 호출되는 경로)
이 매트릭스는 ‘전부 다 하자’가 아니라 ‘우리가 보장하는 범위를 좁게 정의하자’에 가깝다. scikit-learn이 adoption을 진행하면서도 사용자 호환성에 신경을 쓰는 이유는, 결국 여기가 무너지면 라이브러리의 신뢰가 무너져서다.
pytest로는 다음처럼 시작하는 편이 현실적이다.
# tests/test_backend_matrix.py (예시)
import numpy as np
import pytest
# 프로젝트에서는 아래 두 함수를 compat 모듈에서 가져온다고 가정
# from compat.array_backend import matmul, BackendUnavailable
def matmul(a, b, *, backend: str = "auto"):
# 예시를 위한 더미: 실제 구현은 compat.get_xp를 통해 xp.matmul을 호출
return a @ b
BACKENDS = ["numpy"] # array_api 백엔드는 실제 도입 시점에만 축으로 연다
@pytest.mark.parametrize("backend", BACKENDS)
@pytest.mark.parametrize("dtype", ["float32", "float64"])
def test_matmul_smoke(backend, dtype):
a = np.asarray([[1, 2], [3, 4]], dtype=dtype)
b = np.asarray([[1, 0], [0, 1]], dtype=dtype)
out = matmul(a, b, backend=backend)
# 최소 계약: 실패하지 않고, 형상이 맞고, 숫자가 깨지지 않는다
assert out.shape == (2, 2)
assert np.isfinite(out).all()
# array_api 전용 테스트는 실제 도입 시점에만 추가한다(지금은 축을 열지 않는다)
여기서 중요한 건, 완벽한 정답 비교가 아니다.
- 먼저 “예외 없이 돌아가냐”를 본다
- 다음에 “형상/타입 같은 최소 계약”을 본다
- 마지막에 “정확도/성능 차이”를 다룬다
이 순서를 지키면, 실험 백엔드가 main에 섞이더라도 ‘어디서 깨졌는지’가 빨리 보인다. 즉, 신호가 흐려지는 걸 늦출 수 있다.
그리고 매트릭스는 프로덕션 동선과도 연결된다.
- array_api 경로는 기본값이 아니니까, 프로덕션 요청의 일부에서만 켠다
- 그 일부를 어떤 기준으로 고를지(샘플링/플래그/테넌트)를 정한다
- 실패 시 어디서 어떻게 돌아갈지(폴백)를 “compat 레이어”에서만 결정한다
테스트는 그 결정을 확인해주는 장치가 된다.
프로덕션 동선 분리: 실험이 ‘신호’를 망치지 않게 하는 방법
실험이 main에 섞이는 순간부터 팀이 제일 먼저 잃는 건 성능이 아니라 관측 가능성이다.
- 에러가 늘었는데, 새 백엔드 때문인지 원래 문제인지 분간이 안 된다
- 지표가 흔들리는데, 샘플링이 섞였는지 코드가 섞였는지 분간이 안 된다
그래서 동선 분리는 코드보다 먼저, 운영 신호에서 시작한다.
- 새 백엔드 경로에는 로그에 “backend=…” 같은 꼬리표를 붙인다
- 메트릭은 가능하면 분리된 라벨로 기록한다
- 롤백은 “기능 플래그”가 아니라 “backend 선택 래퍼”에서 한 번만 한다
여기서 또 하나의 원칙은 ‘조용한 폴백’을 신중하게 쓰는 거다.
조용한 폴백은 장애를 막지만, 실패 신호도 같이 삼킨다. 그래서 나는 폴백을 하더라도 흔적은 남긴다. “성공”으로 보이게 하면, 그게 다음 릴리즈 전날에 다시 터진다.
ruff는 보조 도구다: 경계를 코드 스타일로 ‘조금만’ 강제하기
ruff 이야기를 길게 할 생각은 없다. 하지만 이런 마이그레이션에서 린터는 꽤 현실적인 안전장치다. 이유는 간단하다. 사람은 바쁠 때 경계를 넘고, 도구는 바쁠 때도 동일하게 막는다.
예를 들어, 호환 레이어를 만들어 놓고도 각 파일에서 다시 타입을 고정해버리는 습관(무심코 캐스팅하거나, 임시 코드가 남아있는 패턴)을 줄이는 규칙만 걸어도 효과가 있다. ruff는 “규칙을 사람이 아니라 도구가 지키게” 만드는 데 도움이 된다(참고자료는 릴리즈 노트).
코드 스타일 전체를 바꾸지 말고, 호환성 작업에 직접 도움이 되는 규칙만 최소로 묶어두는 편이 낫다.
마지막으로: “get_xp 하나”만 먼저 PR로 자르기
호환성 작업은 길다. 길어질수록 팀은 지치고, 지치면 경계는 무너진다.
그래서 나는 마이그레이션을 완료 계획표로 관리하지 않는다. 섞이지 않게 하는 구조로 관리한다.
나는 보통 “결정(get_xp)→보장(매트릭스)→신호(라벨/폴백 흔적)” 순서로만 작게 끊는다.
이 세 가지가 되면, array API adoption은 “큰 전환”이 아니라 “작은 변화들의 축적”이 된다.
그날 main에서 배운 건 하나였다. 실험 코드가 새는 건 막기 어렵다. 대신 새었을 때 바로 알아차릴 수 있게 만들 수는 있다. 호환성 작업은 결국 그 감각을 코드와 테스트와 동선으로 박아두는 일이다.
참고자료
- scikit-learn Blog — Update on array API adoption in scikit-learn
- https://blog.scikit-learn.org/updates/update-array-api/
- GitHub Releases — astral-sh/ruff 0.15.5
- https://github.com/astral-sh/ruff/releases/tag/0.15.5
댓글
댓글 쓰기