크레스티드 게코 사진 한 장으로 적정가를 뽑는다 — geckoly-ai 가격 모델 학습기
2026-04-22
1. geckoly는 뭐고, 왜 필요한가
geckoly는 크레스티드 게코(Crested Gecko) 사진 한 장과 최소한의 메타데이터만 있으면 모프를 자동으로 분류하고 적정 판매가를 제시하는 VLM(Vision-Language Model) 기반 시스템이다. 크게 세 개의 파이프라인으로 구성된다.
- 모프 분류기 — 사진에서 멀티라벨 모프 태깅
- 가격 예측기 — 사진 + 모프/성별/체중 → 로그 스케일 가격(KRW)
- 베스트샷 추출기 — 여러 장 중 대표 컷 선별
이 글의 주인공은 가격 예측기다.
morph가 뭐길래
크레스티드 게코는 단순히 "도마뱀 한 종"이 아니라 색상·패턴·구조 변이(morph) 의 조합으로 시장 가격이 수 배 ~ 수십 배씩 벌어지는 생물이다. Lily White, Pinstripe, Harlequin, Dalmatian, Axanthic — 이런 형질들이 어떻게 조합되느냐, 발현 강도가 어떠냐에 따라 같은 크기의 개체가 8만 원일 수도, 300만 원일 수도 있다.
문제는 이게 사진만 보고 정량화하기 어려운 암묵지라는 점이다. 경력 있는 브리더는 한눈에 판단하지만, 그 판단 기준을 텍스트로 풀어쓰긴 까다롭다. 이름이 통일돼 있지도 않고(Lily White ≈ 릴리 ≈ 라일리…), 같은 모프라도 발현 정도에 따라 가격이 갈린다.
파충류 사업 운영자의 pain point
실제 분양샵·개인 브리더가 매일 부딪히는 문제들.
- 가격을 얼마에 내놔야 할지 모른다. 기준이 개인 경험뿐이라 재고 회전과 마진이 운영자마다 제각각이다.
- 모프 판정에 시간이 녹는다. 개체 한 마리당 사진 찍고, 크롭하고, 모프 리스트업하고, 경쟁 매물 시세 확인하고… 한 개체당 10~20분이 쉽게 든다.
- 경력 없는 직원/신규 운영자가 진입하기 어렵다. 판단 기준이 구두로만 전수되니 품질 편차가 심하다.
- 시장 시세가 빠르게 변한다. 작년에 100만 원이던 모프가 올해 40만 원이 되는 일이 흔한데, 개인이 매번 시세를 따라잡기 힘들다.
geckoly의 해법
크롤링으로 모은 실제 분양 매물 데이터(사진 + 가격 + 모프/성별 라벨)로 VLM을 학습시켜, 사진 + 모프/성별 같은 최소 정보만 넣으면 적정 판매가를 바로 뱉는 모델을 만드는 것. 운영자는 그 값을 기준가로 삼고, 경험에 따라 ±20% 범위에서 조정하면 된다.
2. 원래 계획은 이랬다
- 모델: 35B MoE(활성 파라미터 3B)의 비전-언어 모델. 관세/게이트 없이 오픈 가중치, 활성 파라미터가 적어 16GiB GPU 두 장에서 QLoRA로 돌릴 수 있을 거라 봤다.
- 하드웨어: A4000 16GiB × 2 (= 32GiB). DeepSpeed 분산 대신 QLoRA + bnb 4bit으로 단일 노드 처리.
- 학습 기법: 어텐션 projection만 LoRA 타깃 (MoE에서 expert별 FFN까지 어댑터 붙이면 어댑터 규모가 expert 수 배로 폭발). grad_checkpoint + 8bit optimizer + effective batch 8.
- 타깃 표현: 절대값(KRW) 대신
log1p(price). 가격 분포가 long-tail이라 절대값에 MSE를 붙이면 고가 샘플이 loss를 독점한다. - 파이프라인: preprocess → train → evaluate → fuse → infer 의 단순한 5단계.
- 서빙: Docker Compose로 모프/가격 서비스를 분리해 Ollama 호환 엔드포인트로 노출.
깔끔한 계획이었다. 실제로는 계획대로 굴러가지 않았다.
3. 실제로 무슨 일이 있었나
1단계 — 라이브러리가 안 보인다
문제: 학습 스크립트가 시작도 못 하고 죽는다. bitsandbytes / transformers가 CUDA .so 파일을 못 찾는다고 한다.
원인: pip로 깐 nvidia-* 휠들은 가상환경 내부 깊숙한 경로에 .so를 떨어뜨린다. 시스템 LD_LIBRARY_PATH에는 잡히지 않는다. 게다가 그 경로의 패키지들은 namespace package라 __init__.py도 없어서 단순 디렉토리 스캔이 빗나간다. CUDA 12 / 13 사이에서 bitsandbytes가 어느 빌드를 로드하느냐도 매 실행마다 다르게 갈렸다.
해결: 가상환경 안의 nvidia 서브패키지를 제네릭하게 스캔해서 lib 디렉토리를 모두 LD_LIBRARY_PATH에 추가하는 부트스트랩 스크립트를 앞단에 깔았다. bitsandbytes 버전은 일단 CUDA 12 런타임에 붙는 구버전으로 핀.
2단계 — 메시지 없이 죽는 프로세스
이 구간이 진짜 지옥이었다.
문제: Trainer를 세팅하는 도중 프로세스가 에러 메시지 한 줄 없이 그냥 사라진다. train()을 호출하기 전인데도 SIGKILL이 떨어진다. stderr에는 아무것도 안 남는다.
원인 후보가 너무 많았다: OOM, cgroup memory.high 초과, CUDA 드라이버 미스매치, import trl이 끌어오는 transitive import의 메모리 폭발, bnb 로더의 잘못된 CUDA 빌드 선택, 가상화 환경의 RLIMIT_AS 컷오프… 일단 무엇 때문에 죽는지부터 가시화해야 했다.
해결 — 가시화부터:
- import 할 때마다
cgroup의memory.current와memory.high/events를 찍어서 어느 import가 메모리를 폭증시키는지 특정. - 본 학습 전에
torch/bitsandbytesimport만 별도 자식 프로세스로 미리 돌리는 preflight 추가. 죽으면 이 프로세스에서 죽으니까 본 학습 로그가 오염되지 않는다. - preflight를 verbose로 돌려서
OSError(스택 트레이스 남김)와SIGKILL(트레이스 안 남음)을 구분. - preflight의 stderr는 버리고 stdout만 캡처. 둘 다 받으면 파이프가 블로킹 걸린다.
errexit환경에서 SIGKILL이 떨어지면 셸이 exit code 0처럼 처리해버리는 케이스가 있어서 명시적 가드 추가.import trl을 쪼개서 하위 import를 한 줄씩 트레이스 → 어떤 보조 모듈이 메모리를 잡아먹는지 특정.
이 가시화 작업으로 bitsandbytes의 CUDA 빌드 선택이 일관적이지 않다는 게 드러났다. 환경 변수로 빌드를 강제 고정하기로 결정.
3단계 — bitsandbytes 빌드 안정화
문제: 같은 머신에서 BNB_CUDA_VERSION을 바꿔가며 시도하면 CUDA 12.8 빌드는 silent kill, CUDA 13.0 빌드는 nvjitlink 의존성 깨짐, CUDA 12.4 빌드만 깨끗하게 로드된다.
원인: bitsandbytes는 빌드 시점에 정해진 CUDA 라이브러리 ABI에 강하게 묶여 있다. 호스트에 깔린 CUDA 런타임, pip로 들어온 nvidia 휠들의 버전, torch가 컴파일된 CUDA 버전 — 이 셋이 어긋나면 dlopen은 성공하는데 실제 호출에서 죽는다.
해결: CUDA 12.4 빌드로 강제 고정하고, 실제 선택된 빌드를 로그에 박제(나중에 환경이 바뀌었을 때 추적 가능하게). 동시에 nvjitlink 의존성을 통합 휠로 명시 — 처음에 -cu13 suffix 휠을 썼다가 deprecate된 걸 발견하고 unified wheel로 교체했다.
4단계 — 파괴적 의존성 재설치
문제: 매 환경 재구축마다 torch, transformers, accelerate가 멀쩡히 깔려 있던 게 통째로 날아간다. 그러면 위에서 잡아둔 CUDA / bnb 호환성이 무너진다.
원인: 의존성 파일에 정확 버전 핀(==)을 박아두면 pip는 이미 설치된 호환 버전을 uninstall하고 동일 버전을 재설치해버린다. 재설치 과정에서 다른 패키지의 의존성이 동시에 갱신되면서 torch CUDA 빌드가 cu121 → cu124처럼 갈아치워진다.
해결: torch / transformers / accelerate / peft 같은 핵심 ML 라이브러리는 느슨한 범위(>=,<)로 핀. CUDA 빌드는 별도 변수로 고정. preflight로 "지금 깔린 게 우리가 원하는 빌드인지"만 검증.
5단계 — vllm·CUDA_VISIBLE_DEVICES 같은 환경 함정들
학습이 일단 시작되긴 하지만 매번 다른 이유로 죽었다.
- vllm이 깔려 있으면 trl이 자동으로 로드 시도 → bnb 로더와 충돌. trainer 쪽에서
importlib이 vllm을 못 찾게 가렸다. CUDA_VISIBLE_DEVICES=''(빈 문자열) 가 "GPU 0개"로 해석돼 CPU 폴백이 걸리는 케이스 → 빈 문자열은 unset과 동일하게 정규화.- GPU 개수 카운트는 결국
nvidia-smi -L로 — 파이썬에서 세려고 torch를 띄우는 것 자체가 위험. - bitsandbytes 내부 클래스가 transformers 신규 키워드를 못 받음 →
Params4bit/Int8Params/QuantState에 몽키패치를 얹어 호환 처리.
6단계 — TRL/SFT API 호환
문제: SFTConfig가 버전마다 같은 파라미터를 다른 이름으로 받는다. max_seq_length 가 어떤 버전에선 통하고 어떤 버전에선 dataset_text_field 옆 키로 옮겨가 있다. 거기에 더해, TRL이 우리 VLM 데이터셋을 자기 텍스트 포맷으로 재가공하려다 실패한다.
원인: TRL의 SFTTrainer는 텍스트-온리 모델을 가정하고 dataset prep을 강하게 한다. VLM은 이미지 토큰 정렬 때문에 그 재가공을 거치면 안 된다.
해결: SFTConfig에 사용 가능한 키 이름을 런타임에 골라 전달. TRL의 dataset preparation 단계는 우회하고 우리가 만든 VLM 데이터셋을 그대로 trainer에 꽂는다.
7단계 — 35B를 16GiB × 2에 우겨넣기
여기서부터는 순수한 VRAM 사투.
문제: 35B 모델을 4bit으로 양자화하고 어댑터만 학습하는데도 OOM. 가중치 자체는 들어가는데, peft가 LoRA를 부착할 때 일부 weight를 fp32로 임시 upcast 한다. 이 순간 메모리가 폭증한다.
원인 분석:
- 4bit 양자화된 LLM 가중치: 약 17GB
- 양자화되지 않는 부분(임베딩, lm_head, layernorm): 4~5GB
- 비전 인코더(early-fusion 타입): bf16 그대로 → quantize 불가
- quant_state 메타데이터 오버헤드: 약 1GB
- peft 부착 시 fp32 upcast: 일시적으로 +3~4GB
- 활성화 메모리: 1~2GB
전부 더하면 GPU 한 장당 ceiling을 넘는다. 게다가 bnb 4bit은 CPU offload와 호환되지 않아서 분산을 풀어 우회할 수도 없다.
해결 시도:
- per-GPU 메모리 reserve를 1~2GiB까지 쥐어짜며 한계 시도.
- peft가 fp32 upcast를 하는 조건 자체를 가드. 단순히 "dtype이 float32인가"만 체크하던 걸 "사이즈가 작거나 non-float dtype이면 skip"으로 확장 (bnb가 만든 packed weight를 fp32로 올리는 사고 차단).
- Windows 배치 환경에서 의존성 파일에 들어간 em-dash가 cp949 디코드 에러를 내서 ASCII 하이픈으로 교체.
그래도 안 들어갔다. 35B는 16GiB × 2에 안전하게 학습할 수 없다는 결론.
8단계 — 모델을 다시 고른다
문제: 모델 사이즈 자체가 아니라, 비전 인코더가 LLM에 어떻게 붙어 있느냐가 메모리 풋프린트를 결정한다는 걸 늦게 깨달았다.
원인: dense 27B 후보로 옮겨가 보니, 이 모델은 비전 인코더가 LLM과 early-fusion 으로 통합돼 있어서 비전 부분만 따로 양자화하기 어려웠다. 비-양자화 풋프린트가 GPU당 ceiling을 넘어버린다.
해결: 비전 인코더가 분리형(SigLIP) 인 27B 후보로 교체. SigLIP은 작고 별도 모듈이라 bf16 그대로 두고 LLM 본체만 4bit으로 양자화 → 두 장에 들어간다. 학습 안정성과 한국어 숫자 출력 품질을 비교해본 끝에 최종적으로 분리형 비전 인코더를 가진 또 다른 27B 모델로 정착.
부수적으로 단일 24GiB GPU(3090/A5000) 한 장이 16GiB × 2보다 운영상 쉽다는 결론도 같이 문서화. 16GiB × 2는 모델/peft 동작 하나하나가 ceiling에 걸린다.
9단계 — 평가 루프 병목과 마무리
문제: 학습은 됐는데 평가가 너무 느리다.
원인: evaluator가 샘플마다 모델을 새로 로드하고 있었다. 27B 모델 로드는 한 번에 수십 초 걸린다.
해결: eval pass 한 번당 모델을 한 번만 로드하고, 그 안에서 모든 샘플을 순회. 평가 시간이 두 자릿수 배 줄었다.
마지막으로 자잘한 마무리 — Windows 배치 파이프라인이 네이티브 크래시(SIGSEGV / GPU 드라이버 hang)에서 다음 단계로 조용히 넘어가던 버그(|| goto :error 누락) 수정, 매 스텝마다 transformers 프로세서가 찍던 kwargs 경고 침묵(메트릭 로그가 안 보였다).
4. 회고
네 가지만 남기겠다.
- VRAM 계산은 "모델 가중치"에서 끝나지 않는다. peft fp32 upcast, quant_state 메타데이터, 비-양자화 임베딩/lm_head, 비전 타워 — 이 잡다한 항목들이 16GiB 클래스에선 전부 "터지느냐 마느냐"를 결정한다.
- Silent SIGKILL은 최우선으로 가시화해야 한다. 매달리던 시간의 절반은 "왜 죽는지 모르는 상태"였고, preflight를 별 프로세스로 분리하자 원인이 바로 드러났다. 그리고 원인이 드러나자 preflight 자체를 단순화할 수 있었다.
- 버전 핀은 거의 항상 파괴적이다. pip는 정확 핀을 받으면 기존 패키지를 uninstall하고 재설치한다. 핵심 ML 라이브러리는 느슨한 범위로, CUDA 빌드는 환경 변수로.
- 모델 선택은 파라미터 수가 아니라 아키텍처 구조로 결정된다. 35B MoE에서 27B dense로, 다시 분리형 비전 인코더 모델로 옮겨간 건 전부 비전 타워가 어떻게 붙어 있느냐의 문제였지 총 파라미터의 문제가 아니었다.
다음 단계는 실제 분양 데이터셋에서 MAPE / Within-10% / Within-20%를 뽑는 것. 베이스라인이 나오면 이어서 쓴다.
'AI' 카테고리의 다른 글
| Cursor 17개월 사용기 (0) | 2026.02.26 |
|---|---|
| Dify 구축해서 AI Chat Bot 노코드 개발하기 (0) | 2026.02.26 |
| OpenClaw 구축 - Local Model 설정 (0) | 2026.02.26 |
| [Stable Diffusion] 피사체 활용 T-shirts Logo 및 Mock-up 개발 후기 (0) | 2025.11.22 |