
명함 수기 입력을 없애고 싶어서 사내용 명함 OCR CLI 도구를 만들었다
요약
Gemini API를 활용하여 명함 이미지에서 정보를 추출하고 CRM용 CSV 파일을 생성하는 Python CLI 도구 제작 사례를 소개합니다. 프라이버시 요구사항 정의, 모델 선정 근거, 구조화된 출력(Structured Output) 구현 등 실무적인 고려 사항을 다룹니다.
핵심 포인트
- Gemini API를 이용한 명함 이미지 OCR 및 데이터 구조화
- 사내 데이터 보안 및 프라이버시 요구사항 정의 방법
- Python 기반의 모듈화된 CLI 도구 설계 및 구현
- CRM 연동을 위한 CSV 포맷 자동 생성 프로세스
서론
비즈니스 미팅이나 전시회에서 받은 명함을 CRM 도구에 등록하는 작업을 여러분은 어떻게 하고 계신가요?
사내 업무의 효율화는 시중에 나와 있는 SaaS를 그대로 도입해서 해결할 수 있다면 가장 편한 일입니다.
하지만 명함 정보처럼 외부로 전달하는 순간 "어디에 남는가"를 신중하게 다뤄야 하는 데이터의 경우, 고려해야 할 사항이 많습니다. 실제로 만들기 전에 확정해야 할 부분이 은근히 많으며, 대충 만들었다가는 "쓸모가 없는" 수준을 넘어 "사용해서는 안 되는" 상태가 되어버립니다.
이 기사에서는 명함 이미지를 Gemini API로 OCR 하여 CRM 도구 등에 가져오기 위한 CSV를 생성하는 작은 CLI 도구를 사내 이용 목적으로 만들었을 때의 이야기를 정리합니다.
앱 자체는 단순한 구조이지만, 프라이버시 요구사항을 정의하는 방법, 모델 선정의 근거 제시, 구조화된 출력 (Structured Output) 등 실제로 손을 움직이며 알게 된 고안점들을 중심으로 써 내려가겠습니다.
예상 독자
- 사내 업무의 수기 입력 작업을 AI로 효율화하고 싶은 분
- 이미지에서 구조화된 데이터 (Structured Data)를 추출하고 싶은 분
- LLM을 업무에서 사용할 때 프라이버시 요구사항을 어떻게 충족해야 하는지 알고 싶은 분
만든 것
cardscan이라는 이름의 Python CLI입니다.
동작은 매우 단순하여, 명함 이미지 폴더를 인자로 전달하면 CRM 가져오기용 CSV 파일이 생성됩니다.
예를 들어, 다음과 같은 명함 이미지(이하 이미지)를 input/에 둡니다.

(이 이미지는 LLM에 의해 생성된 것이며, 실제 명함이 아닙니다.)
이 상태에서 uv run cardscan input/을 실행하면, 지정한 폴더 안의 명함 이미지가 한 장씩 Gemini API로 전송되어 추출 결과가 모아서 하나의 CSV로 작성됩니다. 실행 중인 터미널에서는 처리 진행 상황이 다음과 같이 흐릅니다.
로그는 이런 이미지입니다.
$ uv run cardscan input/
Processing 12 cards using gemini-3.1-flash-lite...
[1/12] card_001.jpg ... OK
...
첫 번째 줄에서 "처리 대상이 몇 장인지·어떤 모델을 사용 중인지", 이어지는 줄에서 "파일별 성공 여부", 마지막 줄에서 "성공 건수와 출력 파일명"을 알 수 있도록 했습니다.
만약 한 장이 실패하더라도 전체 처리를 중단하지 않고 끝까지 실행하며, CSV에는 성공한 부분만 작성하는 방침입니다.
실행이 끝나면 현재 디렉토리에 contacts_YYYY-MM-DD.csv라는 이름으로 출력 파일이 생성됩니다. 내용은 사용 중인 CRM 도구의 가져오기 포맷에 맞춘 열 이름(First name / Last name / Email / Phone number 등)으로 작성되어 있어, 이후 해당 CSV를 CRM 측의 가져오기 화면에 드래그하는 것만으로 연동이 완료된다는 가정하에 만들었습니다.
【실제로 출력된 CSV】

구성은 3개의 모듈로 나누었습니다.
extractor.py: Gemini API에 이미지를 보내 구조화된CardData를 반환csv_writer.py:CardData리스트를 CRM 가져오기용 CSV로 작성cli.py: 파일 열거·진행 상황 표시·에러 핸들링을 총괄하는 엔트리 포인트 (Entry Point)
각각의 책임을 한 파일에 가두어 두었기 때문에, 테스트도 이 단위로 독립적으로 작성할 수 있도록 했습니다.
고안한 포인트
1. "데이터를 외부에 남기지 않는다"라는 요구사항의 해상도를 높이기
초기 요구사항 정의 단계에서 "명함 정보가 외부 서버에 남지 않도록·도난당하지 않도록 하고 싶다"라는 전제가 나왔습니다.
언뜻 보면 "클라우드 API는 모두 안 되는 것이며, 로컬 LLM (ollama나 Apple Vision 프레임워크 등)으로 해야만 하는 건가?"라고 생각할 수 있지만, 한 번 멈춰서 요구사항을 정리할 필요가 있다고 생각합니다.
"데이터를 외부에 남기지 않는다"라는 말은 사실 두 가지 서로 다른 요구가 섞여 있습니다.
- 네트워크상에 이미지/텍스트가 흐르지 않을 것 (물리적으로 로컬에서 나가지 않을 것)
- 흐른다 하더라도, 상대방 측에 학습 데이터/로그로 남지 않을 것 (계약상의 취급)
전자를 엄격하게 지키려면 Apple Vision 프레임워크나 ollama 같은 로컬 실행 방식으로 가야 합니다. 반면, 후자의 의미로 "남지 않음"을 보장할 수 있다면 상용 API (gemini, gpt, claude 등)도 선택지에 들어옵니다.
이번에는 정확도·개발 속도·비용을 우선시하고 싶었기 때문에, 후자의 방침으로 결정했습니다. Gemini API의 이용 약관에는 무료/유료에 따라 취급이 명확히 나뉘어 있다고 기재되어 있습니다.
이는 유료(Paid) 서비스에 관한 기술로, 무료(Unpaid) 서비스에서는 반대로 products and services의 제공·개선·개발에 이용한다고 명시되어 있습니다.
가격표 페이지에도 같은 취지의 표가 게재되어 있으며, "Used to improve our products"란이 무료는 Yes, 유료는 No로 되어 있습니다.
즉, "외부에 남기지 않는다"라는 요구사항은 Gemini API에 관해서는 "반드시 유료 플랜으로 발급한 API 키를 사용한다"라는 규칙으로 대체함으로써 충족할 수 있다는 뜻입니다.
하지만 이용 약관은 변경될 가능성이 있으므로, 사용자로서는 수시로 이용 약관을 체크하는 것을 잊지 않도록 주의해야 합니다.
(이용 약관을 간과하는 등의 실수는 흔히 발생하므로, 나중에 문제가 되지 않도록 제대로 확인해야 합니다.)
2. Gemini의 3.1 Flash Lite를 선택한 이유
이 도구에서 사용할 LLM을 선택할 때, 처음에는 "Gemini 2.5 Flash Lite가 가장 저렴하니 이것으로 충분하다"라고 결정하려 했습니다.
하지만 사내 누군가에게 "왜 그것을 선택했는가"라고 질문받았을 때, "싸니까"라는 대답만으로는 근거가 약합니다.
그래서 다시 한번 Gemini의 모델 패밀리(Model Family)를 정리하고, "성능" "비용" "미래성"이라는 3가지 축을 기준으로 선정 기준을 명확히 했습니다.
Gemini의 모델 패밀리를 대략적으로 정리하기
LLM 모델은 아주 많아서, 처음 사용하는 사람에게는 갈피를 잡기 어려울 수도 있습니다.
이번 Gemini 모델은 "성능 클래스"와 "세대"라는 2가지 축으로 나뉩니다.
성능 클래스는 대략 3개 층입니다.
- Pro 계열: 추론 능력을 중시한 상위 클래스. 복잡한 태스크나 장문·코드 생성용
- Flash 계열: 성능과 속도의 밸런스형. 일반적인 유스케이스의 주역
- Flash Lite 계열: 속도와 가격을 최우선으로 한 경량 클래스. 태스크가 단순하고 대량으로 돌려야 하는 상황용
세대는 2026년 6월 시점에서 2.5/3.1/3.5의 3개 계통이 공존하고 있는 상태입니다. 3.5는 막 출시되어 에이전트/코딩 특화,
3.1은 최신 범용 세대, 2.5는 구세대이지만 가격이 낮아 지금도 현역이라는 위치에 있습니다.
멀티모달(Multimodal, 이미지 입력)은 Flash 계열·Flash Lite 계열을 포함하여 폭넓게 대응하고 있습니다. 이번처럼 "이미지를 읽어서 구조화하는" 용도에서는 반드시 Pro 계열을 선택할 필요는 없습니다.
비교표
2026년 6월 시점에서의 주요 모델 가격을 가격표에서 정리한 표는 다음과 같습니다.
| 모델 | 입력(이미지/텍스트)/1M tok | 출력/1M tok | 위치付け |
|---|---|---|---|
| Gemini 2.5 Flash Lite | $0.10 | $0.40 | 구세대의 최저가 멀티모달 |
| ... |
이번의 선정
최종적으로 Gemini 3.1 Flash Lite를 채택했습니다. 이유는 다음 3가지입니다.
- 태스크가 "일본어 명함 이미지에서 정해진 필드를 추출하는" 단순한 구조화 추출이므로, Pro 계열이나 Flash 계열의 추론 능력까지는 필요하지 않음
- 같은 Flash Lite 계열이라도 구세대인 2.5가 아닌 최신 세대인 3.1을 선택함으로써, 향후 몇 년간 세대 교체(Generation drop) 리스크를 억제할 수 있음
- 교체 비용(Switching cost)이 작은 구조로 만들어 두었기 때문에, 정확도에 불만이 생기면 Flash 계열·Pro 계열로 즉시 갈아탈 수 있음
교체의 여지를 남겨두기 위해, 모델 ID는 한 곳에 상수로 배치했습니다.
# src/card_scanner/extractor.py
MODEL = "gemini-3.1-flash-lite"
실운용에서 "인명 읽기 오류가 눈에 띈다" "회사명 누락이 많다"와 같은 현상이 발생하면, 이곳을 수정하는 것만으로 모델 변경이 가능하도록 해두었습니다.
3. 구조화 출력(Structured Output)에서 JSON Schema의 함정에 빠진 이야기
Gemini API에는 응답을 JSON 스키마(JSON Schema)로 강제할 수 있는 구조화 출력(Structured Output)이라는 기능이 있습니다. response_mime_type에 application/json을 지정하고, response_schema
JSONSchema 형식의 객체를 전달하면, 모델이 해당 스키마에 따른 형식으로 응답을 반환하는 메커니즘입니다. 이를 사용하면 LLM이 불필요한 서론을 붙여서 파싱(Parsing)에 실패하는 흔한 사고를 방지할 수 있습니다.
처음에는 다음과 같이 JSONSchema 표준의 유니온(Union) 타입 표기법으로 필드를 정의했습니다.
CARD_SCHEMA = {
"type": "object",
"properties": {
...
이것으로 모크(Mock)를 사용해 테스트를 통과했기에 "OK"라고 판단했으나, 실제로 API를 호출하는 순간 다음과 같은 에러가 반환되었습니다.
9 validation errors for Schema
properties.last_name.type
Input should be 'TYPE_UNSPECIFIED', 'STRING', 'NUMBER', 'INTEGER',
...
메시지를 통해 알 수 있는 점은, Gemini SDK 측의 Schema 객체는 type 필드가 단일 enum 값만 받아들인다는 사양(Specification)에 관한 내용인 것 같습니다.
JSONSchema 표준인 ["string", "null"]과 같은 배열 형식의 유니온 타입은 여기에서 통하지 않습니다.
올바른 작성법은 SDK가 제공하는 nullable 필드를 사용하는 패턴이었습니다.
CARD_SCHEMA = {
"type": "object",
"properties": {
...
이렇게 다시 작성했더니 순조롭게 동작하게 되었습니다.
여기서 개인적으로 배운 점은 두 가지가 있습니다. 첫 번째는, 모크로 테스트를 통과했더라도 API 사양 레벨에서 거부되는 케이스는 감지할 수 없다는 것. 두 번째는, SDK에 따라 JSONSchema 표준과 독자적인 사양이 미묘하게 다르므로, 구조화된 출력(Structured Output)을 사용할 때는 공식 문서의 표기법을 한 번 확인해야 한다는 것입니다.
4. 스키마와 데이터 클래스를 나란히 두어, 필드 추가를 한 곳에서 끝내기
이 도구에서는 Gemini 측에 전달할 JSON 스키마와 Python 측에서 받는 데이터 클래스(Data Class) 양쪽 모두에 동일한 필드를 정의하고 있습니다. 일반적인 방식으로 작성하면 "파일이 떨어져 있어서 어느 한쪽의 수정을 잊어버리는" 사고가 발생하기 쉽기 때문에, 의도적으로 같은 파일에 나란히 배치했습니다.
# src/card_scanner/extractor.py
CARD_SCHEMA = {
"type": "object",
...
CARD_SCHEMA는 Gemini에게 "이런 형태로 반환해줘"라고 전달하기 위한 스키마이고, CardData는 그 응답을 Python 측에서 다루기 위한 데이터 클래스입니다.
양쪽을 같은 파일에 둠으로써, 예를 들어 "FAX 번호도 추출하고 싶다"라고 할 때 스키마에 1줄, 데이터 클래스에 1줄, 그리고 CSV 측의 컬럼 매핑에 1줄을 추가하는 것만으로 대응이 완료됩니다. 스키마와 데이터 클래스가 파일을 가로질러 떨어져 있으면, 한쪽만 추가했다가 테스트에서 에러가 나고 나서야 깨닫는 흔한 사고가 발생합니다.
CRM 측의 CSV 컬럼명 매핑은 csv_writer.py로 분리했습니다. Gemini 추출 계층은 "명함 내용으로서 어떤 필드가 있는가"만 알면 되고, "수입 대상이 어떤 컬럼명을 기대하는가"는 다른 계층이 알도록 분리한 것입니다. 수입 대상인 CRM이 바뀌거나 컬럼명이 바뀌더라도 extractor.py는 건드리지 않아도 됩니다.
요약
명함 OCR 자체는 앱 구조로서 매우 단순하지만, 업무에서 실제로 사용할 수준으로 완성하려고 하니 생각할 점이 은근히 많다는 발견을 했습니다. 이번에 특히 의식한 점은 다음 세 가지입니다.
- 프라이버시 요구사항은 추상적인 표현 그대로 받아들이지 말고, 네트워크와 계약(이용약관) 양면으로 분해하여 구체화할 것
- 모델 선정은 가격이나 최신성뿐만 아니라 "나중에 제삼자에게 설명할 수 있는 근거"를 조사할 것
- LLM을 사용한 구현은 모크 테스트만으로는 감지할 수 없는 사양 차이가 있으므로, 빠르게 실제 API로 동작을 확인할 것
사내용의 작은 도구라도 이러한 세세한 설계 판단을 남겨두면, 나중에 다시 검토할 때 "왜 이렇게 되어 있는가"를 추적할 수 있는 자산이 됩니다.
마치며
Solvio에서는 사내 업무 효율화를 위한 작은 도구들을 자체 제작하는 동시에, 생성 AI (Generative AI)를 프로덕트나 업무에 통합하는 시도를 지속하고 있습니다. 이번 사례와 같이 "사내 업무를 AI로 대체하기", "개인정보 보호 요건을 포함하여 LLM을 안전하게 사용하기"와 같은 상담에 관심이 있으시다면, 언제든 편하게 문의해 주세요.
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기