이번 편에서는 해커톤을 준비하고 협업하며 느낀 점을 이야기 했었다, 이번 글에서는 조금 더 개발적인 관점에서 회고를 해보려고 한다.
중앙해커톤에서 우리 팀은 “구미를 재미있게 탐험할 수 있는 이유를 만들자”라는 아이디어를 바탕으로, 날씨 기반 탐험 서비스를 기획하고 개발했다.
Redis 캐싱, Google Geolocation API, SSE(Server-Sent Events)와 같은 기술들을 실제 서비스에 적용하면서 고민했던 점과 선택의 이유, 그리고 앞으로의 개선 방향을 정리해보았다.
먼저 전반적인 구조를 설계했습니다.

erd

아키텍처 다이어그램

우리 서비스의 메인 페이지에서는 구미시의 날씨를 표시하는 기능이 있다.
사용자가 해당 장소에 방문하기 전, 날씨 정보를 미리 확인할 수 있도록 하기 위함이다.
하지만 바로 적용하기에는 두 가지 문제가 있었다.

응답 속도: OpenWeather API는 매 호출마다 평균 200~500ms 정도 지연 발생
호출 제한: 무료 플랜은 일일 1,000회 제한
👉 매번 API를 직접 호출한다면 느린 응답 속도와 호출 제한 초과 문제가 불가피했다.
그래서 우리는 Redis 캐싱을 적용하기로 했다.




사용자에게 미션을 제공하기 위해, 우리는 지도 기반 서비스를 계획했다.
초기에는 네이버나 카카오 맵 API를 사용하려고 했으나, 치명적인 문제가 있었다.

API를 실시간으로 호출하여 사용하는 것은 허용하나, 데이터베이스에 저장해서 사용하는 것은 허용하지 않는 것이다. 만약 카카오나 네이버 api를 사용하게 된다면 좌표 정보를 저장할 수 없기 때문에 위 방식은 사용할 수 없다.

그래서 찾아본 대안이 구글 Geolocation API이다.
위와 같이 30일동안 캐싱 가능한 구조라고 한다. 따라서 30일 안에 구글 api을 호출하여 위도,경도 값 캐시를 최신화하면 되지 않을까? 라는 아이디어로 코드를 설계했다.


좌표 정보는 스프링 스케줄러를 사용하여 주기적으로 캐싱 정보를 갱신하기로 하였다.
처음에 ChatGPT API를 활용해 미션을 자동 생성하는 방식을 기획했지만, 여러 문제점이 있었다.

이러한 한계를 확인한 뒤, 사전 미션 데이터셋을 구축하는 방식으로 전환했다.




우리 서비스의 핵심 기능 중 하나이다. 사용자가 이미지를 업로드 후 모델에 사진 데이터를 전송하면, 모델에서 해당 사진의 진위 여부를 파악한다. 해당 검사 결과를 다시 사용자에게 응답해야 하는데, 이때 어떤 기술을 사용할지에 대해 고민을 해보았다. 우리의 상황을 정리하면 다음과 같았다.
대안은 다음과 같다.
폴링 방식은 클라이언트가 주기적으로 GET 요청을 통해 최신 상태를 조회해오는 방식이다.
장점
단점
프로젝트 설계 시점에서는 아직 AI 학습을 하기 전이었기 떄문에, AI 인증 과정에서 시간이 얼마나 소요될지 미지수였다. 만약 소요시간이 몇십초, 길게 몇분이 걸리게 된다면 서버에 불필요한 요청이 지속적으로 가게 되기 때문에 비효율적이라고 생각했다. 따라서 폴링 방식은 제외시켰다.
웹소켓은 HTTP 연결을 한 번 업그레이드 한 뒤, 그 이후로는 지속 연결을 유지하며 양방향 통신을 하는 프로토콜이다. 일반 HTTP는 요청-응답 모델로 클라이언트가 요청해야 서버가 응답할 수 있지만, 웹소켓은 지속적인 통신이 가능한 구조이다.
장점
단점
우리 서비스에서는 서버에서 인증한 데이터를 사용자에게 보여주기만 하면 되기 때문에, 양방향은 굳이 필요없어서 제외했다.
최종적으로 선택한 방식은 SSE 방식이다.
장점
실시간 이미지 검증 시스템에서 사용자가 이미지를 업로드하고 AI 검증 결과를 실시간으로 받아보는 전체 과정을 단계별로 상세히 분석해보겠다. 이 시스템은 Spring Boot 백엔드와 FastAPI AI 서버가 협력하여 SSE를 통해 실시간 통신을 제공한다.

사용자가 미션 인증을 위해 클라이언트에서 인증 요청을 보낸다.

첫 단계의 핵심은 고유한 JobID 생성이다. metadataCacheService.generateJobId()로 각 검증 요청을 식별할 수 있는 JobID를 만들고, 이후 모든 비동기 처리에서 이 값을 키로 추적한다.

JobID가 생성되면, 시스템은 이미지와 관련된 모든 메타데이터를 임시 캐시에 저장한다. 여기에는 이미지의 실제 바이트 데이터, 업로드 키, 미션 ID, 사용자 ID 등이 포함된다.
이 임시 캐시 저장이 핵심인 이유는, AI 검증이 성공했을 때만 실제로 Cloudinary에 업로드하기 위함이다. 만약 검증에 실패한다면 불필요한 저장소 사용을 방지할 수 있고, 성공했을 때만 영구 저장하여 리소스를 효율적으로 관리할 수 있다.
캐시는 5분의 TTL(Time To Live)을 가지며, 이는 일반적인 AI 이미지 분석 시간을 고려한 적절한 설정이다. 만약 이 시간 내에 처리가 완료되지 않으면 자동으로 캐시에서 제거되어 메모리 누수를 방지한다.

메타데이터가 안전하게 캐시에 저장되면, callFastApiAsync 메서드를 통해 FastAPI 서버에 이미지 분석을 요청한다.
Spring Boot는 FastAPI에게 JobID와 함께 이미지 데이터를 전송하고, 즉시 클라이언트에게 JobID를 포함한 응답을 반환한다. 이는 비동기 처리의 핵심으로, 클라이언트는 오래 걸리는 AI 분석을 기다리지 않고 바로 다음 단계로 진행할 수 있다.

클라이언트는 JobID를 받자마자 SSE 연결을 수립한다. 이 연결은 SSE 통신의 시작점이며, 서버에서 발생하는 모든 이벤트를 실시간으로 받아볼 수 있는 통로가 된다.

SSE 연결이 성공적으로 수립되면, createEventStream 메서드가 새로운 SseEmitter 객체를 생성한다. 이 에미터는 5분의 타임아웃을 가지며, 해당 JobID와 연결되어 emitters 맵에 저장된다. 이렇게 저장된 에미터는 나중에 이벤트를 전송할 때 사용된다.
SSE 연결이 완료되면 즉시 started 이벤트를 클라이언트에게 전송한다. sendEvent(jobId, VerificationEvent.started(jobId))를 통해 "검증이 시작되었습니다"라는 메시지를 보내어, 사용자가 시스템이 정상적으로 작동하고 있음을 인지할 수 있게 한다.
에미터는 emitters 맵에 저장되고, 해당 JobID를 키로 하여 관리된다. 동시에 에미터의 완료, 타임아웃, 에러 시나리오에 대한 콜백도 등록하여, 연결이 종료될 때 자동으로 맵에서 제거되도록 설정된다.

FastAPI에서 AI 분석이 진행되면서 다양한 상태 변화가 발생한다. processCallback 메서드는 이러한 상태 변화를 받아 적절히 처리하는 핵심 로직이다.

콜백 요청의 eventType에 따라 다른 처리를 수행한다:

handleProgressCallback을 호출하여 "진행 중" 메시지를 클라이언트에게 전송한다. 
handleCompletedCallback을 통해 최종 처리를 진행합니다. 검증이 성공했다면, 이제 실제 Cloudinary 업로드가 시작된. saveCompletedMission(metadata)를 호출하여 임시 캐시에 저장되어 있던 이미지 바이트 데이터를 Cloudinary에 업로드하고, 생성된 URL을 데이터베이스에 저장한다. 성공적으로 저장이 완료되면 complete 이벤트를 전송한다. 이 이벤트에는 JobID와 최종 이미지 URL이 포함되어 클라이언트가 결과를 확인할 수 있다. 마지막으로 metadataCacheService.removeMetadata(jobId)를 통해 임시 캐시를 정리한다. 
handleFailedCallback으로 실패 처리를 수행한다.아이디어 기획 단계를 제외하면 약 3주라는 짧은 시간 동안 개발을 진행했다. 나는 주로 Spring 기반의 API 개발을 맡았고, 다른 팀원분께서는 AI 학습 모델을 담당하셨다. 개인적으로는 처음으로 AI 서버와 직접 통신하는 경험을 해본 것이 큰 도전이자 배움이었다.
또한 처음 사용해본 Google Geolocation API와 SSE 통신도 기억에 남는다. 왜 이 기술을 선택해야 하는지, 이 방식의 장점과 단점은 무엇인지 스스로에게 계속 질문을 던졌다.
물론 아직 보완해야 할 점은 많은 것 같다. 해커톤이라는 특성상 3주 안에 결과물을 내야 했기 때문에, 선택한 방식이 최선의 방법은 아닐 수 있다는 한계도 분명히 느꼈다
일부 API에서는 불필요하게 많은 쿼리가 나가면서 성능 저하가 발생할 수 있는 구조다. 앞으로는 N+1 문제나 불필요한 조인을 줄이고, 필요한 경우 캐싱이나 조회 전용 쿼리로 최적화할 필요가 있다.
또한 지금은 단일 서버 환경이라 큰 문제가 없지만, 실제 서비스가 확장된다면 SSE 연결을 여러 인스턴스에서 안정적으로 유지하는 방법을 반드시 고민해야 한다. 아울러 네트워크 환경이 언제든 불안정할 수 있기 때문에, 클라이언트에서 연결이 끊겼을 때 어떻게 빠르게 복구할지, Last-Event-ID를 활용한 재연결 전략 같은 부분도 보완해야 한다.
짧은 시간이었지만, 이번 경험은 단순히 구현을 넘어 서비스 운영 단계에서 고려해야 할 다양한 문제들을 직접 체감할 수 있는 계기가 되었다.
다음 블로그에서는 위 리팩토링 사항을 정리해서 작성해야겠다.