📘 독초 판별 서비스(POISON)

프로젝트를 여러 번 진행하면서 느낀 점이 하나 있습니다.
구현은 했는데, 막상 면접이나 회고에서 “왜 이 기술을 선택했는가?”를 설명하려고 하면 생각보다 답이 선명하지 않았습니다.
특히 “그 기술이 문제를 어떻게 해결했는지”, “다른 대안과 비교해 왜 이 선택이 적절했는지”를 말하려면 구현 경험만으로는 부족했습니다.

이번 글에서는 제가 처음 진행했던 프로젝트인 독초 판별 서비스(POISON) 를 다시 정리해보려고 합니다.
기능 소개보다는, 어떤 문제를 해결하기 위해 어떤 구조를 선택했는지에 더 집중해서 적어보겠습니다.


서비스 내용

POISON은 사용자가 이미지를 업로드하면 AI가 식물 중 가장 비슷한 종을 판별해 결과를 제공하는 서비스입니다.

단순히 판별 결과만 보여주는 데서 끝나지 않고, 다음 기능도 함께 구현했습니다.

  • 학습된 식물 도감 리스트 / 상세 페이지
  • 전체 로딩이 아닌 무한 스크롤 기반 점진적 로딩
  • 오타/유사어를 고려한 도감 검색
  • 판별 결과 기준으로 많이 조회된 식물 랭킹 제공

기능 자체는 크게 판별 / 도감 / 검색·랭킹으로 나뉘지만, 이번 글에서는 기능 설명보다는 기술 선택 이유와 그 효과를 중심으로 정리합니다.


메인 API 서버: Django

  • 사용자 요청 처리
  • 도감/랭킹/검색 API 제공
  • 인증/인가
  • DB 트랜잭션 및 비즈니스 로직 처리
  • 작업 상태 조회 API 제공

AI 서버 : Flask 또는 경량 Python 서비스 / Worker

  • 모델 로딩
  • 이미지 전처리
  • 추론 실행

여기서 중요한 점은, 클라이언트가 AI 추론 영역을 직접 호출하지 않도록 한 것입니다.
프론트엔드는 기본적으로 Django API만 호출하고, Django가 내부적으로 추론 작업을 등록하거나 내부 서비스를 호출하는 구조가 더 안전합니다.

이렇게 하면 인증, 권한, 요청 검증, 장애 처리 책임이 한 곳에 모여서 운영이 단순해집니다.


왜 굳이 분리했는가

이 구조를 택한 이유는 “Python은 비동기 처리가 안 되기 때문”이 아닙니다.
Python도 비동기 처리가 가능합니다.

문제의 본질은 언어가 아니라, AI 추론이라는 작업의 성격입니다.

이미지 판별은 일반적인 CRUD 요청과 달리 다음 특징이 있습니다.

  • 처리 시간이 상대적으로 길다
  • CPU/GPU/메모리 사용량이 크다
  • 모델 로딩 비용이 있다
  • 요청마다 자원 사용 편차가 크다

이런 작업을 일반 웹 요청 처리와 같은 실행 영역에 두면, 서비스 전체 응답성이 쉽게 흔들립니다.
그래서 아래 이유로 분리하는 것이 더 합리적이라고 판단했습니다.

1. 관심사 분리

메인 API는 웹 서비스와 데이터 처리에 집중하고,
추론 영역은 모델 실행에 집중하도록 나눴습니다.

이렇게 분리하면 코드베이스의 책임이 명확해지고,
문제가 발생했을 때도 웹 로직 문제인지, 추론 로직 문제인지를 더 빨리 좁힐 수 있습니다.

초기 프로젝트일수록 “일단 한 곳에 다 넣는 방식”이 구현은 빠를 수 있습니다.
하지만 시간이 지나 기능이 늘어나면, 인증/도감/랭킹/검색 로직과 모델 추론 코드가 한 프로젝트 안에 섞여 유지보수 난도가 급격히 올라갑니다.

2. 자원 특성이 다르기 때문

웹 API는 보통 짧고 빠른 응답이 중요합니다.
반면 AI 추론은 느리더라도 연산을 안정적으로 끝내는 것이 더 중요합니다.

즉, 둘은 같은 백엔드라도 최적화 기준이 다릅니다.

  • API 서버는 동시 요청 처리, 응답 시간, 트랜잭션 안정성이 중요
  • AI 추론은 모델 적재, 연산 성능, 메모리 관리가 중요

이 둘을 하나의 실행 환경에 섞어두면,
AI 작업이 순간적으로 자원을 많이 점유하는 시점에 API 응답까지 같이 느려질 수 있습니다.

3. 운영 안정성

이 부분이 실제 운영 관점에서 가장 중요하다고 생각합니다.

추론 장애가 메인 서비스 전체 장애로 번지면 안 됩니다.

예를 들어 추론 영역에서 아래와 같은 문제가 발생할 수 있습니다.

  • 특정 이미지에서 전처리 예외 발생
  • 모델 메모리 부족(OOM)
  • 추론 시간이 길어져 타임아웃 발생
  • 외부 스토리지/S3 접근 실패
  • 워커 프로세스 비정상 종료

이런 문제가 발생했을 때,
도감 조회나 랭킹 조회 같은 일반 기능까지 같이 막혀버리면 사용자는 “서비스 전체가 죽었다”고 느끼게 됩니다.

그래서 추론을 별도 영역으로 분리하면 다음이 가능해집니다.

  • 판별 기능만 부분 장애로 격리
  • 도감/검색/랭킹 API는 계속 정상 제공
  • 장애 범위를 좁혀 원인 파악과 복구가 쉬워짐
  • 추론 워커만 재시작하거나 스케일 조정 가능

즉, 분리의 목적은 단순 성능이 아니라 장애 전파 차단에도 있습니다.

4. 확장성(Scale-out)

AI 추론은 트래픽이 늘어날수록 일반 API와는 다른 방식으로 병목이 생깁니다.
특히 이미지 처리와 모델 실행은 CPU/GPU와 메모리 사용량이 크기 때문에, 웹 서버를 늘리는 방식과 같은 기준으로 확장하면 비효율적입니다.

역할을 분리하면:

  • API 서버는 일반 인스턴스 기준으로 확장
  • AI 추론 영역은 필요 시 고사양 또는 GPU 환경으로 확장

처럼 서로 다른 전략을 가져갈 수 있습니다.

즉, 부하 특성에 맞는 독립 확장이 가능해집니다.

5. 배포/유지보수성

모델을 교체하거나 추론 로직을 수정하는 작업은 웹 API 변경과 성격이 다릅니다.

예를 들어:

  • 모델 버전 교체
  • 전처리 로직 수정
  • confidence 계산 방식 수정

같은 변경은 추론 영역에만 영향을 주도록 격리하는 편이 안전합니다.

반대로 회원 기능, 도감 API, 랭킹 API는 비교적 안정적으로 운영하면서,
AI 쪽만 별도로 테스트하고 배포하는 전략도 가능합니다.

이렇게 배포 단위를 나누면 릴리스 리스크를 줄이기 좋습니다.


비동기 처리(Celery 등)를 사용한 이유

이 프로젝트에서 비동기 처리의 핵심은 “동시에 많이 처리한다”보다,
장시간 걸리는 추론을 요청-응답 사이클에서 분리하는 것이었습니다.

이미지 업로드 요청이 들어오면 Django는 다음 역할만 수행합니다.

  1. 요청 검증
  2. 이미지 저장(S3 등)
  3. 추론 작업을 큐에 등록
  4. 사용자에게 즉시 작업 식별자(task id)와 진행 상태를 응답

그 후 실제 추론은 백그라운드에서 워커가 수행하고, 결과를 저장합니다.

이 방식을 선택한 이유는 명확합니다.

요청 타임아웃 방지

추론 시간이 길다고 해서 HTTP 요청을 오래 붙잡고 있으면,
프록시/웹 서버/클라이언트 어디에서든 타임아웃이 발생할 수 있습니다.

비동기 처리로 분리하면 API는 빠르게 응답하고,
시간이 오래 걸리는 작업은 별도 흐름으로 넘길 수 있습니다.

웹 서버 워커 점유 방지

만약 판별 요청이 들어올 때마다 웹 요청 스레드/워커가 그대로 추론까지 맡으면,
일부 느린 요청 때문에 전체 API 처리량이 급격히 떨어질 수 있습니다.

특히 도감 조회나 검색처럼 원래 빨라야 하는 API가,
무거운 판별 요청과 같은 서버 자원을 경쟁하게 되는 구조는 좋지 않습니다.

재시도와 상태 추적

작업 큐 기반으로 분리하면 다음 운영 기능을 붙이기 좋습니다.

  • 실패 시 재시도
  • 작업 상태 추적
  • 워커 수 조절
  • 특정 큐만 분리 운영
  • 장애 시 추론 작업만 선별 복구

즉, 비동기 처리는 단순 편의 기능이 아니라
장시간 작업을 운영 가능한 형태로 만드는 구조적 선택이었습니다.


폴링(Polling) 방식을 사용한 이유

비동기로 작업을 분리하면, 클라이언트는 “이 작업이 끝났는지”를 확인할 방법이 필요합니다.
이때 선택한 방식이 폴링입니다.

흐름은 단순합니다.

  1. 업로드 요청 후 task id 수신
  2. 클라이언트가 /tasks/{taskId}/status 같은 API를 주기적으로 호출
  3. 완료 상태가 되면 결과를 조회해 UI 갱신

이 프로젝트에서 필요한 것은 채팅이나 실시간 알림처럼 서버가 지속적으로 이벤트를 밀어줘야 하는 기능이 아니었습니다. 이미지 업로드 이후 백그라운드에서 수행되는 판별 작업이 완료되었는지 확인하고, 완료 시 결과를 받아오는 것이 핵심이었습니다.

이런 성격의 기능에서는 상시 연결을 유지하는 WebSocket보다, 일정 주기로 작업 상태를 조회하는 폴링 방식이 더 현실적이라고 판단했습니다.

폴링은 일반 HTTP 요청 기반으로 동작하기 때문에 기존 API 인프라와 자연스럽게 맞물렸고, 인증 처리, 상태 조회, 실패 대응도 일관된 방식으로 가져가기 쉬웠습니다. 또한 사용자가 많지 않은 초기 프로젝트 규모에서는 적절한 조회 주기만 설정하면 충분한 수준의 사용자 경험을 제공할 수 있었습니다.

물론 폴링은 주기적으로 요청이 발생하므로, 대규모 트래픽이나 높은 실시간성이 필요한 환경에서는 비효율적일 수 있습니다. 하지만 이 프로젝트에서는 “즉시 푸시”보다 안정적인 상태 조회가 더 중요했고, 기능의 성격상 폴링으로도 충분했습니다.

즉, 폴링을 선택한 이유는 WebSocket보다 단순해서라기보다, 이 프로젝트가 요구하는 실시간성 수준과 운영 복잡도를 고려했을 때 가장 균형이 좋은 방식이었기 때문입니다.


S3를 사용한 이유

이미지 파일을 DB에 바이너리로 직접 저장하면 다음 문제가 생깁니다.

  • DB 용량이 빠르게 증가
  • 백업/복구 부담 증가
  • 대용량 바이너리 처리로 인한 성능 저하
  • 애플리케이션과 파일 저장 책임이 강하게 결합

그래서 이미지는 객체 스토리지인 S3에 저장하고,
DB에는 이미지 자체가 아니라 S3 Object Key 또는 참조 정보만 저장하는 구조를 선택했습니다.

이 방식의 장점은 명확합니다.

  • 파일 저장과 메타데이터 저장 책임 분리
  • 확장성 있는 파일 저장소 활용
  • 애플리케이션 서버 디스크 의존성 감소
  • 추후 CDN, 접근 제어, 수명 주기 관리 적용이 쉬움

또한 접근 제어가 필요한 경우에는:

  • 버킷은 비공개로 유지
  • 업로드/다운로드 시 Presigned URL 발급

방식을 적용하면 보안과 권한 관리 측면에서도 더 안전합니다.


검색엔진(Atlas Search)을 사용한 이유

도감 검색에서는 사용자가 항상 정확한 식물명을 입력하지 않는다는 점이 중요했습니다.

실제 사용자는:

  • 오타를 입력할 수 있고
  • 정확한 명칭 대신 비슷한 표현을 쓸 수 있고
  • 일부 단어만 기억할 수도 있습니다

이런 상황에서 단순 LIKE 검색만으로는 검색 경험이 좋지 않습니다.
그래서 오타 허용과 유사어 대응을 위해 MongoDB Atlas Search를 적용했습니다.

선택 이유는 다음과 같습니다.

검색 경험 개선

정확히 일치하는 키워드만 찾는 것이 아니라,
사용자가 의도한 결과에 더 가깝게 접근할 수 있었습니다.

즉, 검색의 목표를 “문자열 일치”가 아니라 사용자 의도 탐색에 가깝게 두었습니다.

운영 복잡도 절감

별도 검색엔진을 독립적으로 두는 방식도 가능하지만,
프로젝트 규모를 고려했을 때 Atlas Search는 MongoDB 생태계 안에서 비교적 빠르게 적용할 수 있었습니다.


무한 스크롤(도감 리스트)을 적용한 이유

도감 리스트를 한 번에 전체 로딩하면 초기 응답이 느려지고,
서버와 네트워크에 불필요한 부하가 생깁니다.

특히 사용자는 대부분 목록 전체를 끝까지 보지 않기 때문에,
처음부터 모든 데이터를 가져오는 것은 낭비가 큽니다.

그래서 필요한 만큼만 점진적으로 가져오는 무한 스크롤 방식을 적용했습니다.

이 선택의 장점은 다음과 같습니다.

  • 초기 로딩 속도 개선
  • 사용자가 실제로 보는 범위만 우선 제공
  • 네트워크 사용량 절감
  • 대량 데이터 목록에서 UX 개선

추가로 대량 데이터에서는 단순 offset 기반 페이지네이션보다,
lastId, createdAt 등을 기준으로 다음 페이지를 가져오는 cursor 기반 페이지네이션이 더 유리합니다.

offset 방식은 뒤로 갈수록 조회 비용이 커질 수 있고,
중간 데이터 변경 시 중복/누락 문제도 생기기 쉽습니다.

반면 cursor 방식은 데이터가 많아질수록 더 안정적이고 예측 가능한 성능을 기대할 수 있습니다.


정리

이 프로젝트에서 가장 중요했던 설계 판단은
“기능을 많이 붙였다”가 아니라, 성격이 다른 작업을 같은 방식으로 처리하지 않았다는 점이었습니다.

일반 웹 요청과 AI 추론은 겉으로 보면 모두 백엔드 작업이지만,
실제로는 요구하는 응답 특성, 자원 사용 방식, 장애 대응 방식이 완전히 다릅니다.

그래서 이 프로젝트에서는:

  • 웹 요청을 처리하는 API 영역
  • 시간이 오래 걸리고 자원을 많이 쓰는 AI 추론 영역
  • 이를 연결하는 비동기 작업 처리
  • 완료 여부를 확인하는 폴링 기반 상태 조회

로 역할을 나눠 설계했습니다.

결국 이 구조의 목적은 단순히 “서버를 두 개 뒀다”는 데 있지 않습니다.

  • 느린 추론 때문에 빠른 API가 같이 느려지지 않게 하고
  • 추론 장애가 전체 서비스 장애로 번지지 않게 하고
  • 배포와 확장을 기능 특성에 맞게 분리하고
  • 장시간 작업을 운영 가능한 형태로 바꾸는 것

이 네 가지가 더 본질적인 이유였습니다.

태그:

카테고리:

업데이트: