멋사 중앙해커톤: Redis 캐싱, SSE, Geolocation 적용기

juhyeok01·2025년 8월 31일

멋쟁이사자처럼

목록 보기
3/3
post-thumbnail

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


아키텍처 설계

먼저 전반적인 구조를 설계했습니다.

                                    erd

                                               아키텍처 다이어그램



🌁 OpenWeather API : Redis 캐싱으로 해결

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

하지만 바로 적용하기에는 두 가지 문제가 있었다.

  • 응답 속도: OpenWeather API는 매 호출마다 평균 200~500ms 정도 지연 발생

  • 호출 제한: 무료 플랜은 일일 1,000회 제한

👉 매번 API를 직접 호출한다면 느린 응답 속도와 호출 제한 초과 문제가 불가피했다.

그래서 우리는 Redis 캐싱을 적용하기로 했다.

  1. 날씨 데이터 조회 요청이 들어오면 먼저 시간 기반으로 생성된 캐시 키를 사용해 Redis에서 데이터를 조회한다.
  2. 만약 캐시에 데이터가 존재한다면 즉시 캐시된 데이터를 반환하여 1ms 미만의 빠른 응답을 제공한다.

  1. 캐시에 데이터가 없는 경우, 실제 OpenWeather API를 호출한다. API 응답을 받으면 방대한 데이터 중에서 필요한 부분만 추출하는 가공 과정을 거친다. 현재 우리 서비스에서는 현재 날씨와 이후 6시간의 날씨가 필요했기 때문에 데이터를 가공하고 있다.
  2. 모든 데이터 가공이 완료되면 현재 날씨와 시간별 예보를 하나의 WeatherBasic 객체로 결합한다. Redis에 저장할 때는 다음 정시까지의 시간을 자동 계산한 TTL을 설정하여, 시간이 지나면 자동으로 캐시가 만료되도록 한다.
  3. 최종적으로 가공된 날씨 데이터를 클라이언트에게 반환하며, 이후 같은 시간대의 요청들은 모두 캐시된 데이터를 사용하게 되어 API 호출 없이도 빠른 응답이 가능하다.




❓장소 좌표는 어떻게 가져올까?

사용자에게 미션을 제공하기 위해, 우리는 지도 기반 서비스를 계획했다.

  • 미션은 크게 5가지의 카테고리로 나뉘게 되고, 각 카테고리마다 이미지 마커를 통해 구분하고 있다.
  • 사용자는 마커를 클릭하면, 해당 미션의 장소명과 수행해야 하는 미션 정보를 알 수 있다.
  • 그래서 우리는 해당 장소의 위도, 경도 좌표를 DB에 저장해야했다.

초기에는 네이버나 카카오 맵 API를 사용하려고 했으나, 치명적인 문제가 있었다.

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

그래서 찾아본 대안이 구글 Geolocation API이다.

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

좌표 정보는 스프링 스케줄러를 사용하여 주기적으로 캐싱 정보를 갱신하기로 하였다.




❓미션은 어떻게 생성할까?

❌ChatGPT API

처음에 ChatGPT API를 활용해 미션을 자동 생성하는 방식을 기획했지만, 여러 문제점이 있었다.

  • 실제 테스트 과정에서 GPT가 제안한 미션은 현장에서 실행하기 어렵거나, 비현실적인 요소가 담겨있었다.
  • 미션을 인증하려면 학습을 해야하는데, GPT가 생성해준 미션은 AI 모델이 학습하기 어려운 형태가 많았다.

  • API 호출이 잦아질수록 비용이 급격히 늘어나, 서비스 운영 측면에서도 부담이 컸다.

🆗 사전 데이터 입력

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

  • 구미시 주요 명소와 상권을 직접 조사하여 실행 가능한 미션들을 데이터베이스에 담는다. 장소의 위도,경도 정보는 위에서 설명했다시피 스케줄러를 통해 주기적으로 위치를 가져와서 캐시 정보를 업데이트한다.

  • 스케줄러가 2주마다 자동으로 5개의 미션을 배포하도록 설계했다.
  • 특히 일정 기간 선택되지 않은 장소를 우선 선별하여 중복을 방지하고, 다양한 카테고리가 나올 수 있도록 설계한다.
  • 생성된 미션은 Mission 엔티티에 추가되고, 이후 미션 조회 api에서 Mission 엔티티를 조회한다.



❓ 미션 인증 설계

우리 서비스의 핵심 기능 중 하나이다. 사용자가 이미지를 업로드 후 모델에 사진 데이터를 전송하면, 모델에서 해당 사진의 진위 여부를 파악한다. 해당 검사 결과를 다시 사용자에게 응답해야 하는데, 이때 어떤 기술을 사용할지에 대해 고민을 해보았다. 우리의 상황을 정리하면 다음과 같았다.

  • 모델에서 학습한 데이터를 Fast API에서 Spring쪽으로 응답한다. 프로젝트 초기 설계 시 모델에서 인증하는데 얼마나 걸릴지 모르는 상황이었다.
  • 미션 인증 데이터 전송 시 서버에서 사용자쪽으로 데이터만 보내주면 된다. 사용자가 서버로 데이터를 보내줄 필요는 없었다. 즉, 단방향 통신만 필요한 상황이었다.
  • 여건이 된다면, 사용자에게 진행 단계를 실시간으로 보여주고 싶었다.

대안은 다음과 같다.

1️⃣ 폴링 방식

폴링 방식은 클라이언트가 주기적으로 GET 요청을 통해 최신 상태를 조회해오는 방식이다.

장점

  • 다른 방식들에 비해 구현이 매우 단순하다. 일정 간격(3초마다, 5초마다)마다 HTTP GET 요청을 보내면 된다.
  • 프록시, 방화벽, CDN 등 네트워크 장비와 호환이 우수하고 디버깅이 쉽다.

단점

  • 불필요한 트래픽이 발생한다. 서버에 변화가 없어도, 변화가 일어났는지 체킹하기 위해 주기적으로 계속 요청을 보낸다. 사진 인증 분석 과정에서 시간이 짧게 소요되면 상관없지만, 만약 시간이 오래 소요된다면 불필요한 요청을 계속 보내어 서버에 부담이 갈 수 있다.
  • 두 번째는 반응 지연이다. 폴링 간격 사이에 서버 응답이 성공하게 되면, 서버에서 인증이 성공했음에도 불구하고 다음 폴링 요청을 보낼 때 까지 사용자는 계속 대기해야 한다.

프로젝트 설계 시점에서는 아직 AI 학습을 하기 전이었기 떄문에, AI 인증 과정에서 시간이 얼마나 소요될지 미지수였다. 만약 소요시간이 몇십초, 길게 몇분이 걸리게 된다면 서버에 불필요한 요청이 지속적으로 가게 되기 때문에 비효율적이라고 생각했다. 따라서 폴링 방식은 제외시켰다.

2️⃣ 웹소켓

웹소켓은 HTTP 연결을 한 번 업그레이드 한 뒤, 그 이후로는 지속 연결을 유지하며 양방향 통신을 하는 프로토콜이다. 일반 HTTP는 요청-응답 모델로 클라이언트가 요청해야 서버가 응답할 수 있지만, 웹소켓은 지속적인 통신이 가능한 구조이다.

장점

  • 실시간성 : 이벤트가 발생하면 클라이언트로 바로 전송이 가능하다.
  • 양방향 통신 : 클라이언트, 서버 모두 실시간으로 메세지를 송수신할 수 있다.
  • 효율성 : HTTP처럼 매 요청마다 헤더를 교환할 필요가 없어, 네트워크 오버헤드가 감소한다.

단점

  • 운영 복잡성 : 단순 HTTP처럼 요청, 응답 로그만으로 트래킹이 어렵다는 특징이 있다. 연결 수 유지, 끊김 감지, 재연결 로직, 세션 관리가 필수적이다.

우리 서비스에서는 서버에서 인증한 데이터를 사용자에게 보여주기만 하면 되기 때문에, 양방향은 굳이 필요없어서 제외했다.

3️⃣ SSE

최종적으로 선택한 방식은 SSE 방식이다.

장점

  • 간단한 개발 : text/event-stream으로 라인 기반 데이터를 전송한다. SSE는 브라우저에서 기본으로 지원하는 EventSource 객체를 통해 서버가 보낸 이벤트를 받을 수 있다
  • 단방향 스트리밍이라 서버가 결과만 푸시하면 되기 때문에 구조가 단순하다.
  • 브라우저가 자동으로 재 연결을 해주고, Last-Event-ID 값을 통해 이어받을 수 있어 네트워크가 끊겨도 안정적으로 다시 연결할 수 있다.

실시간 이미지 검증 시스템에서 사용자가 이미지를 업로드하고 AI 검증 결과를 실시간으로 받아보는 전체 과정을 단계별로 상세히 분석해보겠다. 이 시스템은 Spring Boot 백엔드와 FastAPI AI 서버가 협력하여 SSE를 통해 실시간 통신을 제공한다.

1. 사용자의 이미지 인증 요청

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

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

2. 메타데이터 임시 캐시 저장

JobID가 생성되면, 시스템은 이미지와 관련된 모든 메타데이터를 임시 캐시에 저장한다. 여기에는 이미지의 실제 바이트 데이터, 업로드 키, 미션 ID, 사용자 ID 등이 포함된다.

이 임시 캐시 저장이 핵심인 이유는, AI 검증이 성공했을 때만 실제로 Cloudinary에 업로드하기 위함이다. 만약 검증에 실패한다면 불필요한 저장소 사용을 방지할 수 있고, 성공했을 때만 영구 저장하여 리소스를 효율적으로 관리할 수 있다.

캐시는 5분의 TTL(Time To Live)을 가지며, 이는 일반적인 AI 이미지 분석 시간을 고려한 적절한 설정이다. 만약 이 시간 내에 처리가 완료되지 않으면 자동으로 캐시에서 제거되어 메모리 누수를 방지한다.

3. FastAPI 비동기 호출

메타데이터가 안전하게 캐시에 저장되면, callFastApiAsync 메서드를 통해 FastAPI 서버에 이미지 분석을 요청한다.

Spring Boot는 FastAPI에게 JobID와 함께 이미지 데이터를 전송하고, 즉시 클라이언트에게 JobID를 포함한 응답을 반환한다. 이는 비동기 처리의 핵심으로, 클라이언트는 오래 걸리는 AI 분석을 기다리지 않고 바로 다음 단계로 진행할 수 있다.

4단계: 클라이언트 SSE 연결 수립

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

SSE 연결이 성공적으로 수립되면, createEventStream 메서드가 새로운 SseEmitter 객체를 생성한다. 이 에미터는 5분의 타임아웃을 가지며, 해당 JobID와 연결되어 emitters 맵에 저장된다. 이렇게 저장된 에미터는 나중에 이벤트를 전송할 때 사용된다.

5단계: 초기 이벤트 전송

SSE 연결이 완료되면 즉시 started 이벤트를 클라이언트에게 전송한다. sendEvent(jobId, VerificationEvent.started(jobId))를 통해 "검증이 시작되었습니다"라는 메시지를 보내어, 사용자가 시스템이 정상적으로 작동하고 있음을 인지할 수 있게 한다.

에미터는 emitters 맵에 저장되고, 해당 JobID를 키로 하여 관리된다. 동시에 에미터의 완료, 타임아웃, 에러 시나리오에 대한 콜백도 등록하여, 연결이 종료될 때 자동으로 맵에서 제거되도록 설정된다.

6단계: FastAPI 콜백 처리 시스템

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

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

  • PROGRESS: 진행 상황을 알리는 이벤트로, handleProgressCallback을 호출하여 "진행 중" 메시지를 클라이언트에게 전송한다.
  • COMPLETED: 검증 성공을 의미하며, handleCompletedCallback을 통해 최종 처리를 진행합니다. 검증이 성공했다면, 이제 실제 Cloudinary 업로드가 시작된. saveCompletedMission(metadata)를 호출하여 임시 캐시에 저장되어 있던 이미지 바이트 데이터를 Cloudinary에 업로드하고, 생성된 URL을 데이터베이스에 저장한다. 성공적으로 저장이 완료되면 complete 이벤트를 전송한다. 이 이벤트에는 JobID와 최종 이미지 URL이 포함되어 클라이언트가 결과를 확인할 수 있다. 마지막으로 metadataCacheService.removeMetadata(jobId)를 통해 임시 캐시를 정리한다.
  • FAILED: 검증 실패 시 handleFailedCallback으로 실패 처리를 수행한다.


마무리하며

아이디어 기획 단계를 제외하면 약 3주라는 짧은 시간 동안 개발을 진행했다. 나는 주로 Spring 기반의 API 개발을 맡았고, 다른 팀원분께서는 AI 학습 모델을 담당하셨다. 개인적으로는 처음으로 AI 서버와 직접 통신하는 경험을 해본 것이 큰 도전이자 배움이었다.

또한 처음 사용해본 Google Geolocation APISSE 통신도 기억에 남는다. 왜 이 기술을 선택해야 하는지, 이 방식의 장점과 단점은 무엇인지 스스로에게 계속 질문을 던졌다.

물론 아직 보완해야 할 점은 많은 것 같다. 해커톤이라는 특성상 3주 안에 결과물을 내야 했기 때문에, 선택한 방식이 최선의 방법은 아닐 수 있다는 한계도 분명히 느꼈다

일부 API에서는 불필요하게 많은 쿼리가 나가면서 성능 저하가 발생할 수 있는 구조다. 앞으로는 N+1 문제나 불필요한 조인을 줄이고, 필요한 경우 캐싱이나 조회 전용 쿼리로 최적화할 필요가 있다.

또한 지금은 단일 서버 환경이라 큰 문제가 없지만, 실제 서비스가 확장된다면 SSE 연결을 여러 인스턴스에서 안정적으로 유지하는 방법을 반드시 고민해야 한다. 아울러 네트워크 환경이 언제든 불안정할 수 있기 때문에, 클라이언트에서 연결이 끊겼을 때 어떻게 빠르게 복구할지, Last-Event-ID를 활용한 재연결 전략 같은 부분도 보완해야 한다.

짧은 시간이었지만, 이번 경험은 단순히 구현을 넘어 서비스 운영 단계에서 고려해야 할 다양한 문제들을 직접 체감할 수 있는 계기가 되었다.

다음 블로그에서는 위 리팩토링 사항을 정리해서 작성해야겠다.

profile
백엔드 개발자를 지망하는 컴퓨터공학과 4학년 학생입니다 https://github.com/Juhye0k

0개의 댓글