FastAPI 기후 시뮬레이션 API에서 30초 타임아웃 문제가 발생했습니다.
# 기존 코드 (문제)
for code, coord in REGION_COORD_MAP.items(): # 250번 반복
target_lat = coord["lat"]
target_lng = coord["lng"]
query = """
SELECT DISTINCT ON (target_year)
target_year, score, latitude, longitude
FROM hazard_results
WHERE risk_type = %s
AND target_year BETWEEN %s AND %s
ORDER BY target_year, (
POW(latitude - %s, 2) + POW(longitude - %s, 2)
) ASC
"""
# 매번 DB에 쿼리 실행
region_rows = db.execute_query(query, (risk_type, start_year, end_year, lat, lng))
문제점:
# 1. 250개 좌표를 VALUES 절로 변환
coords_values = []
for code, coord in REGION_COORD_MAP.items():
coords_values.append(f"('{code}', {coord['lat']}, {coord['lng']})")
coords_clause = ', '.join(coords_values)
# 결과: "('11010', 37.5, 127.0), ('11020', 37.6, 127.1), ..."
# 2. 단일 쿼리로 모든 지역의 모든 연도 데이터 조회
query_region_batch = f"""
WITH target_coords AS (
SELECT * FROM (VALUES {coords_clause})
AS t(region_code, target_lat, target_lng)
)
SELECT DISTINCT ON (tc.region_code, hr.target_year)
tc.region_code,
hr.target_year,
hr.{score_col} as score
FROM target_coords tc
CROSS JOIN LATERAL (
SELECT target_year, {score_col}
FROM hazard_results
WHERE risk_type = %s
AND target_year BETWEEN %s AND %s
ORDER BY (
POW(latitude - tc.target_lat::numeric, 2) +
POW(longitude - tc.target_lng::numeric, 2)
) ASC
LIMIT 1
) hr
ORDER BY tc.region_code, hr.target_year
"""
# 한 번만 실행
region_rows = db.execute_query(
query_region_batch,
(request.hazard_type, str(request.start_year), str(request.end_year))
)
WITH target_coords AS (
SELECT * FROM (VALUES
('11010', 37.5, 127.0),
('11020', 37.6, 127.1),
-- ... 250개
) AS t(region_code, target_lat, target_lng)
)
FROM target_coords tc
CROSS JOIN LATERAL (
SELECT ...
FROM hazard_results
WHERE ...
ORDER BY 거리계산
LIMIT 1
) hr
일반 JOIN vs LATERAL JOIN:
| 구분 | 일반 JOIN | LATERAL JOIN |
|---|---|---|
| 동작 | 고정된 테이블끼리 조인 | 왼쪽 행마다 서브쿼리 실행 |
| 참조 | 서브쿼리에서 외부 테이블 참조 불가 | 서브쿼리에서 tc 참조 가능 |
| 용도 | 정적 조인 | 동적 계산, 최근접 검색 |
LATERAL의 장점:
SELECT DISTINCT ON (tc.region_code, hr.target_year)
tc.region_code,
hr.target_year,
hr.score
FROM ...
ORDER BY tc.region_code, hr.target_year
개발자: "서울 종로구 근처 데이터 찾아줘"
DB: "찾았어요"
개발자: "서울 중구 근처 데이터 찾아줘"
DB: "찾았어요"
... 248번 더 반복
개발자: "이 250개 좌표 근처 데이터를 한번에 찾아줘"
[서울 종로구, 서울 중구, ... 250개]
DB: "250개 전부 찾아서 한 번에 줄게"
| 항목 | Before | After | 개선율 |
|---|---|---|---|
| 쿼리 횟수 | 250회 | 1회 | 99.6% 감소 |
| 네트워크 왕복 | 250번 | 1번 | 99.6% 감소 |
| 응답 시간 | 30초+ | 1~3초 | 약 10~30배 향상 |
| 타임아웃 | 실패 | 성공 | 문제 해결 |
-- 복합 인덱스로 검색 속도 향상
CREATE INDEX idx_hazard_results_risk_year_coords
ON hazard_results (risk_type, target_year, latitude, longitude);
-- 좌표 기반 검색 최적화
CREATE INDEX idx_hazard_results_coords_btree
ON hazard_results (latitude, longitude);
이 패턴은 다음과 같은 경우에 유용합니다:
다중 좌표 최근접 검색
배치 데이터 조회
Python 루프 → SQL 변환
# 너무 많은 좌표는 쿼리 크기 초과 가능
if len(REGION_COORD_MAP) > 1000:
# 배치를 나눠서 처리
batch_size = 500
for i in range(0, len(coords), batch_size):
batch = coords[i:i+batch_size]
# 배치별 쿼리 실행
# BAD: f-string으로 사용자 입력 직접 삽입
coords_clause = f"('{user_input}', ...)" # 위험!
# GOOD: 고정된 데이터만 VALUES에 사용
# 동적 파라미터는 %s 사용
ORDER BY POW(...) 계산은 인덱스 사용 불가WHERE latitude BETWEEN %s - 0.1 AND %s + 0.1
AND longitude BETWEEN %s - 0.1 AND %s + 0.1
region_scores_map = {}
for code, coord in REGION_COORD_MAP.items():
target_lat = coord["lat"]
target_lng = coord["lng"]
query_region = f"""
SELECT DISTINCT ON (target_year)
target_year, {score_col} as score
FROM hazard_results
WHERE risk_type = %s
AND target_year BETWEEN %s AND %s
ORDER BY target_year, (
POW(latitude - %s, 2) + POW(longitude - %s, 2)
) ASC
"""
region_rows = db.execute_query(
query_region,
(hazard_type, start_year, end_year, target_lat, target_lng)
)
if code not in region_scores_map:
region_scores_map[code] = {}
for row in region_rows:
year = str(row['target_year'])
score = float(row['score'] or 0.0)
region_scores_map[code][year] = score
region_scores_map = {}
if REGION_COORD_MAP:
# 1. 좌표 값들을 VALUES 절로 변환
coords_values = []
for code, coord in REGION_COORD_MAP.items():
coords_values.append(f"('{code}', {coord['lat']}, {coord['lng']})")
coords_clause = ', '.join(coords_values)
# 2. 단일 쿼리로 모든 지역의 모든 연도 데이터 조회
query_region_batch = f"""
WITH target_coords AS (
SELECT * FROM (VALUES {coords_clause})
AS t(region_code, target_lat, target_lng)
)
SELECT DISTINCT ON (tc.region_code, hr.target_year)
tc.region_code,
hr.target_year,
hr.{score_col} as score
FROM target_coords tc
CROSS JOIN LATERAL (
SELECT target_year, {score_col}
FROM hazard_results
WHERE risk_type = %s
AND target_year BETWEEN %s AND %s
ORDER BY (
POW(latitude - tc.target_lat::numeric, 2) +
POW(longitude - tc.target_lng::numeric, 2)
) ASC
LIMIT 1
) hr
ORDER BY tc.region_code, hr.target_year
"""
region_rows = db.execute_query(
query_region_batch,
(hazard_type, start_year, end_year)
)
# 결과를 region_scores_map에 저장
for row in region_rows:
code = row['region_code']
year = str(row['target_year'])
score = float(row['score'] or 0.0)
if code not in region_scores_map:
region_scores_map[code] = {}
region_scores_map[code][year] = score
Python 루프에서 반복적으로 DB 쿼리를 실행하는 대신, SQL의 강력한 기능(CTE, LATERAL JOIN)을 활용하면:
핵심 원칙: "데이터를 애플리케이션으로 가져와서 처리하지 말고, DB에서 처리해서 결과만 가져오자"