설계 기록
어떻게 만들었나
혼자 만드는 앱이라 "지금 안 만들어도 되는 것"을 고르는 일이 설계의 절반이었습니다. 그래도 양보하지 않은 것들이 있습니다. 개인 사진을 다루는 규칙, 큐가 날아가도 작업이 사라지지 않는 구조, 그리고 모델이 내놓은 값을 그대로 믿지 않는 것.
사진 한 장이 거치는 길
접수와 처리를 나눴습니다. 앱은 분석이 끝나기를 기다리지 않고 바로 접수증을 받아 가고, 결과는 나중에 가져갑니다.
접수는 사진을 저장하고 항목을 QUEUED로 데이터베이스에 기록한 뒤 큐에 넣는 것까지만 합니다.
워커가 꺼내 모델을 호출하고, 결과를 검증·정규화해 저장하면서 상태를 완료나 "확인 필요"로 바꿉니다.
설계에서 신경 쓴 것
결정마다 왜 그렇게 했는지, 무엇을 포기했는지를 저장소에 기록으로 남깁니다. 아래는 그중 공개할 수 있는 것들입니다.
큐는 사본, 정답은 데이터베이스
Redis를 작업 큐로 쓰지만 "작업이 Redis에만 있는 순간"을 만들지 않았습니다. 항목을 데이터베이스에 먼저 기록하고 나서 큐에 넣기 때문에, 큐가 통째로 사라져도 해야 할 일의 목록은 그대로 남습니다.
- 복구 작업이 60초마다 돌면서, 접수된 지 15분이 지나도록 시작되지 않았거나 처리 중인 채로 10분이 지난 항목을 데이터베이스에서 찾아 다시 큐에 넣습니다.
- 같은 항목이 반복해서 멈추면 두 번까지만 되살리고 실패로 표시합니다. 영원히 도는 항목을 만들지 않기 위해서입니다.
- Redis가 응답하지 않을 때도 업로드는 계속됩니다. 하루 한도 확인·중복 차단·큐 넣기가 모두 실패를 허용하고 데이터베이스 경로로 넘어가며, Redis가 돌아오면 복구 작업이 분석을 이어받습니다.
- Redis를 실제로 재시작하고 꺼 놓은 채 업로드해 보면서 유실이 없는지 확인했습니다.
같은 사진을 두 번 보내도 항목은 하나
네트워크가 끊겨 앱이 업로드를 다시 시도해도 같은 사진이 두 개로 늘어나지 않습니다. 빠른 차단은 Redis가 맡고, 최종 방어선은 데이터베이스의 유니크 제약입니다. 둘 중 하나가 죽어도 중복은 막힙니다.
여러 기기에서 같은 항목을 고칠 때를 대비해 수정에는 버전을 둬서, 내가 보고 있던 값이 그사이 바뀌었으면 덮어쓰지 않고 최신 값을 받아 무엇이 달라졌는지 보여 줍니다.
모델이 내놓은 값을 그대로 믿지 않는다
- 모델 호출은 인터페이스 하나 뒤에 두어, 다른 모델로 바꿔도 나머지 코드가 바뀌지 않게 했습니다.
- 응답은 정해진 형식인지 검사하고 정규화를 통과한 값만 저장합니다. 날짜·가격처럼 틀리면 곤란한 값은 기준을 더 높게 둡니다.
- 확실하지 않으면 비워 둡니다. 그럴듯하게 채우는 대신 "확인 필요"로 표시하고, 쓰는 사람이 확정하면 그때 완료가 됩니다.
- 사람이 고친 값은 다시 분석해도 덮어쓰지 않습니다.
- 호출마다 어떤 모델·어떤 프롬프트였는지와 토큰 수·걸린 시간을 남겨, 모델을 바꿨을 때 좋아졌는지 비교할 수 있게 했습니다.
AI는 큰 범주까지, 의미는 쓰는 사람이
자동 분류를 어디까지 할지가 제품의 분기점이었습니다. 세부 분류까지 AI가 확정하면 틀렸을 때 고치는 일이 정리보다 번거로워집니다. 그래서 AI는 여섯 갈래까지만 정하고, 그 위의 개인적인 의미는 사용자가 붙이는 태그로 나눴습니다. 아무 태그를 달지 않아도 기본 정리는 되도록 만들었습니다.
고른 사진만, 필요한 동안만
개인 사진을 다루는 앱이라 "기본값이 안전한가"를 먼저 봤습니다. 앨범 전체를 자동으로 올리는 편이 편하지만 고르지 않았습니다.
- 앨범은 기기에서만 읽고, 서버로 보낼 사진은 한 장씩 직접 고릅니다.
- 보내기 전에 기기에서 사진을 다시 만들며 위치·기기 정보를 떼어냅니다.
- 보내기 직전 기기 안에서 한 번 더 확인해, 신분증·카드 번호가 보이면 올리지 않습니다.
- 분석이 끝나면 서버의 원본을 지우고 작은 썸네일만 남깁니다.
- 쿠폰 번호는 뽑지도 저장하지도 않습니다. 바코드는 기기에 있는 원본 사진으로 보여 줍니다.
- 사진에서 읽은 글자·토큰·이미지 주소는 서버 기록에 남기지 않습니다.
비용은 기능이 아니라 구조로 막는다
개인이 내는 돈이라 상한을 코드에 박아 두었습니다. 사용자마다 하루에 분석할 수 있는 장수를 제한해, 실수나 반복 요청이 그대로 요금이 되지 않게 했습니다. 사진은 보내기 전에 기기에서 줄여 전송량과 모델 입력을 함께 낮춥니다.
서버는 한 대에 전부 담되(애플리케이션·데이터베이스·캐시·리버스 프록시), 경계는 모듈로 그어 두었습니다. 규모가 커지면 코드를 고치지 않고 떼어낼 수 있게 하려는 것이고, 지금 단계에서 관리형 서비스의 고정비를 내지 않으려는 선택이기도 합니다. 외부에 열어 두는 경로도 API와 상태 확인 두 가지로 제한했습니다.
무엇으로 만들었나
| 자리 | 쓰는 것 | 고른 이유 |
|---|---|---|
| iOS 앱 | SwiftUI | 화면 밖 로직(서버 통신·업로드·폴링)은 별도 패키지로 떼어, 시뮬레이터 없이도 테스트할 수 있게 했습니다 |
| 서버 | Spring Boot (Java) | 가장 오래 써 온 도구입니다. 모르는 것(iOS·인프라)을 배우는 프로젝트라 서버까지 새로 배우지는 않기로 했습니다 |
| 저장 | PostgreSQL | 카테고리마다 뽑는 값이 달라, 공통 테이블에 JSONB로 담아 카테고리를 늘릴 때 스키마를 바꾸지 않게 했습니다 |
| 큐·제한 | Redis Streams | 하루 한도와 중복 차단에 이미 쓰고 있어 의존성이 늘지 않습니다. 혼자 운영하는 규모에 메시지 브로커는 과합니다 |
| 이미지 | S3 (비공개 버킷) | 인터페이스 뒤에 두어 개발 중에는 로컬 폴더, 운영에서는 S3로 설정만 바꿔 씁니다. 접근 키 대신 인스턴스 역할을 씁니다 |
| 배포 | Docker Compose · Caddy | 한 대 안에서 구성이 끝나고, 인증서 발급·갱신을 신경 쓰지 않아도 됩니다 |
어떻게 확인하나
혼자 만들면 리뷰해 줄 사람이 없습니다. 그래서 "돌려 보면 알 수 있는 것"을 늘리는 쪽에 시간을 썼습니다.
서버 테스트는 진짜 데이터베이스와 Redis를 컨테이너로 띄워 돌립니다. 큐 소비, 복구 작업, 하루 한도, 중복 차단처럼 흉내 내면 의미가 없어지는 부분이 많기 때문입니다. API 계약은 문서를 먼저 고치고 구현을 맞추며, 서버에 실제로 사진을 올려 결과까지 받아 보는 확인용 명령도 따로 두었습니다.
만든 사람
백엔드를 오래 해 온 개발자가 혼자 만들고 있습니다. 기획과 서버는 하던 일이고, iOS와 인프라 운영, 디자인 시스템 적용은 이 프로젝트에서 처음 했습니다. 코드는 공개 저장소에 두지 않았지만, 구조나 코드에 대해 더 이야기할 일이 있으면 메일로 연락 주세요.