Conversation
BE PinGeoRedisService와 동일 규약의 PinGeoRedisPublisher를 추가하고, DB commit 성공 직후 geo:pins·pin:info를 갱신한다. 이슈 핀 create/update와 축제 배치 import 경로에 연동해 지도 핀 조회 API Cache-Aside 캐시 정합성을 맞춘다.
DB commit 이후 캐시 동기화 실패 시에도 API가 500으로 실패하지 않도록 좌표 파싱·region 조회·Redis 적재 예외를 warning 로그로만 남긴다.
[FEAT] AI 핀 생성·축제 적재 후 Redis GEO 캐시 동기화
There was a problem hiding this comment.
Code Review
This pull request introduces a Redis-based GEO cache publisher (PinGeoRedisPublisher) to synchronize pin location data for both festival and issue pins. It integrates this publisher into FestivalEventIngestService and IssueService to update Redis upon pin creation or modification, and adds corresponding unit tests. The review feedback focuses on performance optimizations: addressing potential N+1 database queries during batch imports in FestivalEventIngestService by pre-fetching region mappings, and utilizing Redis pipelines in add_pin and remove_pin to minimize network round trips.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| errors: list[dict[str, Any]] = [] | ||
| items: list[FestivalBatchItemResult] = [] | ||
| pin_ids: list[int] = [] | ||
| geo_cache_items: list[PinGeoCacheItem] = [] |
There was a problem hiding this comment.
💡 배치 작업 시 N+1 쿼리 최적화 제안
대량의 페스티벌 데이터를 배치 임포트할 때, 각 핀마다 LocationRepo.get_region_by_id를 호출하면 핀 개수만큼 DB 조회가 발생합니다.
이를 방지하기 위해 배치 시작 시점에 전체 지역 정보를 미리 조회하여 캐시 맵(region_map)을 구성한 뒤 하위 메서드로 전달하는 것을 권장합니다.
추천 흐름:
- 여기서
region_map을 빌드합니다. _insert_festival_pin및_update_festival_pin호출 시region_map을 인자로 전달합니다.
| geo_cache_items: list[PinGeoCacheItem] = [] | |
| geo_cache_items: list[PinGeoCacheItem] = [] | |
| region_map = {} | |
| try: | |
| locations = await self._location_repo.get_all_ordered() | |
| region_map = {loc.location_id: loc.region for loc in locations} | |
| except Exception: | |
| region_map = {} |
| async def _build_festival_geo_cache_item( | ||
| self, | ||
| *, | ||
| pin_id: int, | ||
| location_id: int, | ||
| detail_address: str, | ||
| pin_point, | ||
| ) -> PinGeoCacheItem: | ||
| lat, lng = wgs84_from_pin_point(pin_point) | ||
| region = await self._location_repo.get_region_by_id(location_id) or "" | ||
| return PinGeoCacheItem( | ||
| pin_id=pin_id, | ||
| pin_type=PinType.FESTIVAL.name, | ||
| lat=lat, | ||
| lng=lng, | ||
| detail_address=detail_address, | ||
| region=region, | ||
| discount=None, | ||
| ) |
There was a problem hiding this comment.
⚠️ N+1 쿼리 발생 우려 (성능 최적화 필요)
_build_festival_geo_cache_item 메서드 내에서 각 핀마다 self._location_repo.get_region_by_id(location_id)를 호출하여 데이터베이스 조회를 수행하고 있습니다.
import_batch나 import_all과 같이 대량의 페스티벌 핀을 배치로 가져오는 과정에서, 등록/수정되는 핀의 개수(N개)만큼 추가적인 단건 SELECT 쿼리가 발생하게 되어 심각한 성능 저하(N+1 Query Problem)를 유발할 수 있습니다.
개선 제안:
_import_handoff_rows 시작 시점에 전체 지역 정보를 한 번에 조회하여 캐시 맵(region_map)을 구성한 뒤, 이를 _insert_festival_pin -> _build_festival_geo_cache_item으로 전달하여 메모리 내에서 조회하도록 개선하는 것을 권장합니다.
async def _build_festival_geo_cache_item(
self,
*,
pin_id: int,
location_id: int,
detail_address: str,
pin_point,
region_map: dict[int, str] | None = None,
) -> PinGeoCacheItem:
lat, lng = wgs84_from_pin_point(pin_point)
if region_map and location_id in region_map:
region = region_map[location_id] or ""
else:
region = await self._location_repo.get_region_by_id(location_id) or ""
return PinGeoCacheItem(
pin_id=pin_id,
pin_type=PinType.FESTIVAL.name,
lat=lat,
lng=lng,
detail_address=detail_address,
region=region,
discount=None,
)| async def add_pin( | ||
| self, | ||
| *, | ||
| pin_id: int, | ||
| pin_type: str, | ||
| lat: float, | ||
| lng: float, | ||
| detail_address: str, | ||
| region: str, | ||
| discount: str | None = None, | ||
| ) -> None: | ||
| try: | ||
| member = str(pin_id) | ||
| await self._redis.geoadd(GEO_KEY_ALL, (lng, lat, member)) | ||
| await self._redis.geoadd(f"{GEO_KEY_PREFIX}{pin_type}", (lng, lat, member)) | ||
| payload = _build_pin_info_json( | ||
| pin_id=pin_id, | ||
| pin_type=pin_type, | ||
| lat=lat, | ||
| lng=lng, | ||
| detail_address=detail_address, | ||
| region=region, | ||
| discount=discount, | ||
| ) | ||
| await self._redis.set( | ||
| f"{PIN_INFO_KEY_PREFIX}{pin_id}", | ||
| payload, | ||
| ex=PIN_INFO_TTL_SECONDS, | ||
| ) | ||
| except Exception as exc: | ||
| logger.warning("Redis GEO 핀 추가 실패 pin_id=%s: %s", pin_id, exc) |
There was a problem hiding this comment.
⚡ Redis 파이프라인 적용을 통한 성능 개선 제안
add_pin 메서드에서도 geoadd 2회와 set 1회로 총 3번의 Redis 명령을 개별적으로 await 호출하고 있습니다.
이 경우 Redis 서버와의 네트워크 왕복 시간(RTT)이 3배로 늘어나게 됩니다. add_pins_batch 메서드와 마찬가지로 비트랜잭션 파이프라인(pipeline(transaction=False))을 사용하여 3개의 명령을 한 번에 전송하면 단건 추가 시에도 성능을 크게 개선할 수 있습니다.
async def add_pin(
self,
*,
pin_id: int,
pin_type: str,
lat: float,
lng: float,
detail_address: str,
region: str,
discount: str | None = None,
) -> None:
try:
member = str(pin_id)
payload = _build_pin_info_json(
pin_id=pin_id,
pin_type=pin_type,
lat=lat,
lng=lng,
detail_address=detail_address,
region=region,
discount=discount,
)
async with self._redis.pipeline(transaction=False) as pipe:
pipe.geoadd(GEO_KEY_ALL, (lng, lat, member))
pipe.geoadd(f"{GEO_KEY_PREFIX}{pin_type}", (lng, lat, member))
pipe.set(
f"{PIN_INFO_KEY_PREFIX}{pin_id}",
payload,
ex=PIN_INFO_TTL_SECONDS,
)
await pipe.execute()
except Exception as exc:
logger.warning("Redis GEO 핀 추가 실패 pin_id=%s: %s", pin_id, exc)| async def remove_pin(self, *, pin_id: int, pin_type: str) -> None: | ||
| try: | ||
| member = str(pin_id) | ||
| await self._redis.zrem(GEO_KEY_ALL, member) | ||
| await self._redis.zrem(f"{GEO_KEY_PREFIX}{pin_type}", member) | ||
| await self._redis.delete(f"{PIN_INFO_KEY_PREFIX}{pin_id}") | ||
| except Exception as exc: | ||
| logger.warning("Redis GEO 핀 삭제 실패 pin_id=%s: %s", pin_id, exc) |
There was a problem hiding this comment.
⚡ Redis 파이프라인 적용을 통한 성능 개선 제안
remove_pin 메서드에서 zrem 2회와 delete 1회로 총 3번의 Redis 명령을 개별적으로 await 호출하고 있습니다.
이 경우 Redis 서버와의 네트워크 왕복 시간(RTT)이 3배로 늘어나게 됩니다. add_pins_batch 메서드와 마찬가지로 비트랜잭션 파이프라인(pipeline(transaction=False))을 사용하여 3개의 명령을 한 번에 전송하면 성능을 크게 개선할 수 있습니다.
| async def remove_pin(self, *, pin_id: int, pin_type: str) -> None: | |
| try: | |
| member = str(pin_id) | |
| await self._redis.zrem(GEO_KEY_ALL, member) | |
| await self._redis.zrem(f"{GEO_KEY_PREFIX}{pin_type}", member) | |
| await self._redis.delete(f"{PIN_INFO_KEY_PREFIX}{pin_id}") | |
| except Exception as exc: | |
| logger.warning("Redis GEO 핀 삭제 실패 pin_id=%s: %s", pin_id, exc) | |
| async def remove_pin(self, *, pin_id: int, pin_type: str) -> None: | |
| try: | |
| member = str(pin_id) | |
| async with self._redis.pipeline(transaction=False) as pipe: | |
| pipe.zrem(GEO_KEY_ALL, member) | |
| pipe.zrem(f"{GEO_KEY_PREFIX}{pin_type}", member) | |
| pipe.delete(f"{PIN_INFO_KEY_PREFIX}{pin_id}") | |
| await pipe.execute() | |
| except Exception as exc: | |
| logger.warning("Redis GEO 핀 삭제 실패 pin_id=%s: %s", pin_id, exc) |
[deploy] issue pin, festival pin redis cache write 파트 구현 배포