Skip to content

Feat: 부하 테스트 기반 성능 개선 - #116

Merged
docodocod merged 19 commits into
developfrom
feat/k6-test-advancement-after
Jun 1, 2026
Merged

Feat: 부하 테스트 기반 성능 개선#116
docodocod merged 19 commits into
developfrom
feat/k6-test-advancement-after

Conversation

@docodocod

Copy link
Copy Markdown
Contributor
  • findNearby() 바운딩박스 선필터 + (latitude, longitude) 복합 B-tree 인덱스 추가
    • 전수 정렬(풀 스캔) → 반경 5km BETWEEN 후보 축소 후 거리 정렬
  • PostGIS geometry 컬럼 + GIST 인덱스 마이그레이션 스크립트 추가
    • findNearbyWithGist() (ST_DWithin + KNN) 쿼리 추가
  • 인기 매장 Redis 캐시 적용 (CacheConfig 신규, TTL 5분)
    • 캐시 미적용으로 매 요청마다 DB 집계 쿼리 실행되던 문제 해결
  • 리뷰 조회 페이징 적용 (전체 로드 → 최근 20건)
  • 엔티티 인덱스 추가: Store / Reservation / Review / Menu / Bookmark FK 컬럼
  • Bookmark 폴더 삭제 배치 UPDATE (forEach N번 → 벌크 1번)
  • page size 상한 100 제한, findPopular countQuery 최적화
  • k6 07 에러율 오탐 수정 (SLA 체크 분리)
  • k6 08 http_req_failed 오탐 제거, 주석 비관적락→Redis Lua 정정
  • Tomcat 메트릭 수집 활성화
  • k6 run.ps1 prometheus push interval 2s 설정

📢 기능 설명

필요시 실행결과 스크린샷 첨부

연결된 issue

연결된 issue를 자동을 닫기 위해 아래 {이슈넘버}를 입력해주세요.

close #{이슈넘버}

✅ 체크리스트

  • PR 제목 규칙 잘 지켰는가?
  • 추가/수정사항을 설명하였는가?
  • 이슈넘버를 적었는가?

jaebeom79 and others added 15 commits May 27, 2026 01:14
Develop-Fix: 그라파나 datasource UID/대시보드 변수 매칭 수정, main 통합
hotfix : KafkaConfig 하드코딩 제거
k6 부하테스트 스크립트
Refactor: 테스트 파일 수정
Refactor: 테스트 파일 수정
Fix: kafka 알림 동작 안 함: @RetryableTopic 제거 + DefaultErrorHandler 복귀
Fix: kafka 단일 broker 설정 추가
Fix: 서킷브레이커 패널 표시 오류 수정
Fix: 테스트 시나리오 최적화
- findNearby() 바운딩박스 선필터 + (latitude, longitude) 복합 B-tree 인덱스 추가
  - 전수 정렬(풀 스캔) → 반경 5km BETWEEN 후보 축소 후 거리 정렬
- PostGIS geometry 컬럼 + GIST 인덱스 마이그레이션 스크립트 추가
  - findNearbyWithGist() (ST_DWithin + KNN) 쿼리 추가
- 인기 매장 Redis 캐시 적용 (CacheConfig 신규, TTL 5분)
  - 캐시 미적용으로 매 요청마다 DB 집계 쿼리 실행되던 문제 해결
- 리뷰 조회 페이징 적용 (전체 로드 → 최근 20건)
- 엔티티 인덱스 추가: Store / Reservation / Review / Menu / Bookmark FK 컬럼
- Bookmark 폴더 삭제 배치 UPDATE (forEach N번 → 벌크 1번)
- page size 상한 100 제한, findPopular countQuery 최적화
- k6 07 에러율 오탐 수정 (SLA 체크 분리)
- k6 08 http_req_failed 오탐 제거, 주석 비관적락→Redis Lua 정정
- Tomcat 메트릭 수집 활성화
- k6 run.ps1 prometheus push interval 2s 설정

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces performance optimizations and database enhancements, including PostGIS spatial queries, database indexing, bulk soft deletes, and Redis caching for popular stores. It also updates k6 performance test scripts. The review feedback highlights critical improvements: changing the spatial column type from geometry to geography to prevent full table scans and distance calculation inaccuracies, using GenericJackson2JsonRedisSerializer to avoid ClassCastException during cache deserialization, enabling sync = true on @Cacheable to prevent Cache Stampede, configuring @Modifying(clearAutomatically = true) to avoid stale data in the persistence context, and removing a redundant countQuery from a query returning a List.

Comment thread data/migration_v2_postgis_geometry.sql Outdated
Comment on lines +9 to +17
ALTER TABLE stores
ADD COLUMN IF NOT EXISTS location geometry(Point, 4326);

-- 3. 기존 latitude/longitude 데이터를 geometry 컬럼으로 복사
UPDATE stores
SET location = ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)
WHERE location IS NULL
AND latitude IS NOT NULL
AND longitude IS NOT NULL;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

공간 인덱스(GIST)를 정상적으로 활용하고, 위경도 왜곡 없는 정확한 미터 단위 거리 정렬(<->)을 수행하기 위해 location 컬럼 타입을 geometry 대신 geography로 생성하는 것을 권장합니다.

geometry(Point, 4326) 타입에서 ST_DWithin을 사용하기 위해 ::geography로 형변환을 하면 인덱스를 타지 못하고 Full Table Scan이 발생합니다. 처음부터 geography(Point, 4326) 타입으로 컬럼을 정의하면 이러한 문제를 방지할 수 있습니다.

ALTER TABLE stores
    ADD COLUMN IF NOT EXISTS location geography(Point, 4326);

-- 3. 기존 latitude/longitude 데이터를 geometry 컬럼으로 복사
UPDATE stores
SET location = ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)::geography
WHERE location IS NULL
  AND latitude IS NOT NULL
  AND longitude IS NOT NULL;

Comment on lines +48 to +51
// PostGIS geometry 컬럼 — ST_DWithin/KNN 공간 인덱스 활용
// migration_v2_postgis_geometry.sql 실행 후 GIST 인덱스 적용됨
@Column(columnDefinition = "geometry(Point, 4326)", insertable = false, updatable = false)
private Point location;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

데이터베이스 마이그레이션 스크립트에서 location 컬럼을 geography(Point, 4326)로 변경함에 따라, JPA 엔티티의 columnDefinition 설정도 동일하게 geography(Point, 4326)로 맞춰주어야 합니다.

Suggested change
// PostGIS geometry 컬럼 — ST_DWithin/KNN 공간 인덱스 활용
// migration_v2_postgis_geometry.sql 실행 후 GIST 인덱스 적용됨
@Column(columnDefinition = "geometry(Point, 4326)", insertable = false, updatable = false)
private Point location;
// PostGIS geography 컬럼 — ST_DWithin/KNN 공간 인덱스 활용
// migration_v2_postgis_geometry.sql 실행 후 GIST 인덱스 적용됨
@Column(columnDefinition = "geography(Point, 4326)", insertable = false, updatable = false)
private Point location;

Comment on lines +100 to +111
@Query(value = """
SELECT * FROM stores s
WHERE s.is_deleted = false
AND ST_DWithin(
s.location::geography,
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography,
:radiusMeters
)
ORDER BY s.location <-> ST_SetSRID(ST_MakePoint(:lon, :lat), 4326) ASC,
s.id ASC
""",
nativeQuery = true)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

현재 s.location 컬럼은 geometry 타입으로 정의되어 있습니다. 이 상태에서 s.location::geography와 같이 형변환(Cast)을 수행하면, stores 테이블에 생성된 idx_stores_location_gist 공간 인덱스를 사용하지 못하고 **Full Table Scan(전수 스캔)**이 발생하게 됩니다.

또한, geometry 타입에 대해 <-> 연산자를 사용하면 위경도 단위(Degrees)의 평면 직교 거리로 계산되므로, 위도에 따른 경도 거리 왜곡(한국 기준 위도 1도 ≒ 111km, 경도 1도 ≒ 88km)으로 인해 정확한 거리순 정렬이 되지 않습니다.

해결 방안:

  1. stores 테이블의 location 컬럼 타입을 geography(Point, 4326)로 변경합니다.
  2. 쿼리에서 형변환(::geography)을 제거하여 인덱스를 직접 타도록 수정합니다. geography 타입 간의 <-> 연산자는 미터(Meter) 단위의 구면 거리를 반환하므로 왜곡 없는 정확한 KNN 정렬이 가능해집니다.
Suggested change
@Query(value = """
SELECT * FROM stores s
WHERE s.is_deleted = false
AND ST_DWithin(
s.location::geography,
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography,
:radiusMeters
)
ORDER BY s.location <-> ST_SetSRID(ST_MakePoint(:lon, :lat), 4326) ASC,
s.id ASC
""",
nativeQuery = true)
@Query(value = """
SELECT * FROM stores s
WHERE s.is_deleted = false
AND ST_DWithin(
s.location,
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography,
:radiusMeters
)
ORDER BY s.location <-> ST_SetSRID(ST_MakePoint(:lon, :lat), 4326) ASC,
s.id ASC
""",
nativeQuery = true)

Comment on lines +21 to +25
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new Jackson2JsonRedisSerializer<>(Object.class)))
.disableCachingNullValues();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Jackson2JsonRedisSerializer<Object>(Object.class)를 사용하여 값을 직렬화하면, 캐시 데이터를 역직렬화할 때 구체적인 클래스 타입 정보가 유실되어 LinkedHashMap으로 복원됩니다. 이로 인해 StoreService.getPopularStores 등에서 캐시를 조회할 때 ClassCastException이 발생하게 됩니다.

Spring Cache 환경에서는 클래스 타입 정보를 함께 저장해 주는 GenericJackson2JsonRedisSerializer를 사용하는 것이 안전합니다.

Suggested change
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new Jackson2JsonRedisSerializer<>(Object.class)))
.disableCachingNullValues();
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer()))
.disableCachingNullValues();

Comment on lines +71 to 78
// 인기 매장 — Redis 캐시(TTL 5분) 적용, Cache Stampede 방지
@Cacheable(value = "popularStores", key = "#limit")
@Transactional(readOnly = true)
public List<StoreListResponse> getPopularStores(int limit) {
return storeRepository.findPopular(PageRequest.of(0, limit)).stream()
.map(StoreListResponse::from)
.toList();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

주석에는 'Cache Stampede 방지'라고 기재되어 있으나, 기본 @Cacheable 옵션만으로는 대규모 동시 요청이 발생했을 때 캐시가 만료되는 순간 여러 스레드가 동시에 DB 조회 쿼리를 실행하는 Cache Stampede 현상을 막을 수 없습니다.

@Cacheablesync = true 옵션을 추가하면, 캐시가 존재하지 않을 때 단 하나의 스레드만 DB를 조회하고 다른 스레드들은 대기하도록 동기화(Synchronization)하여 Cache Stampede를 효과적으로 방지할 수 있습니다.

Suggested change
// 인기 매장 — Redis 캐시(TTL 5분) 적용, Cache Stampede 방지
@Cacheable(value = "popularStores", key = "#limit")
@Transactional(readOnly = true)
public List<StoreListResponse> getPopularStores(int limit) {
return storeRepository.findPopular(PageRequest.of(0, limit)).stream()
.map(StoreListResponse::from)
.toList();
}
// 인기 매장 — Redis 캐시(TTL 5분) 적용, Cache Stampede 방지
@Cacheable(value = "popularStores", key = "#limit", sync = true)
@Transactional(readOnly = true)
public List<StoreListResponse> getPopularStores(int limit) {
return storeRepository.findPopular(PageRequest.of(0, limit)).stream()
.map(StoreListResponse::from)
.toList();
}

Comment on lines +17 to +20
// 폴더 삭제 시 벌크 소프트 삭제 — forEach UPDATE N번 → UPDATE 1번
@Modifying
@Query("UPDATE Bookmark b SET b.isDeleted = true WHERE b.folder.id = :folderId AND b.isDeleted = false")
void softDeleteAllByFolderId(@Param("folderId") Long folderId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

벌크 연산(@Modifying UPDATE)은 영속성 컨텍스트(1차 캐시)를 거치지 않고 데이터베이스에 직접 쿼리를 실행합니다. 이로 인해 영속성 컨텍스트에 이미 로드되어 있는 엔티티들의 상태와 실제 DB 상태 간의 불일치(Stale Data)가 발생할 수 있습니다.

안전한 처리를 위해 @Modifying(clearAutomatically = true) 옵션을 추가하여 벌크 연산 실행 후 영속성 컨텍스트를 자동으로 비워주도록 설정하는 것을 권장합니다.

Suggested change
// 폴더 삭제 시 벌크 소프트 삭제 — forEach UPDATE N번 → UPDATE 1번
@Modifying
@Query("UPDATE Bookmark b SET b.isDeleted = true WHERE b.folder.id = :folderId AND b.isDeleted = false")
void softDeleteAllByFolderId(@Param("folderId") Long folderId);
// 폴더 삭제 시 벌크 소프트 삭제 — forEach UPDATE N번 → UPDATE 1번
@Modifying(clearAutomatically = true)
@Query("UPDATE Bookmark b SET b.isDeleted = true WHERE b.folder.id = :folderId AND b.isDeleted = false")
void softDeleteAllByFolderId(@Param("folderId") Long folderId);

Comment on lines +24 to 33
@Query(value = """
SELECT s FROM Store s
WHERE s.isDeleted = false
ORDER BY COALESCE(s.averageStar, 0) DESC,
s.reviewCount DESC,
s.bookmarkCount DESC,
s.id ASC
""")
""",
countQuery = "SELECT COUNT(s) FROM Store s WHERE s.isDeleted = false")
List<Store> findPopular(Pageable pageable);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

메서드의 반환 타입이 Page<Store>가 아닌 List<Store>인 경우, Spring Data JPA는 전체 개수를 조회하는 Count 쿼리를 실행하지 않고 Pageable의 Limit/Offset만 적용하여 조회합니다.

따라서 @Query에 작성된 countQuery 속성은 실제로 실행되지 않는 무의미한 코드입니다. 코드의 명확성을 위해 countQuery 속성을 제거하는 것을 권장합니다.

Suggested change
@Query(value = """
SELECT s FROM Store s
WHERE s.isDeleted = false
ORDER BY COALESCE(s.averageStar, 0) DESC,
s.reviewCount DESC,
s.bookmarkCount DESC,
s.id ASC
""")
""",
countQuery = "SELECT COUNT(s) FROM Store s WHERE s.isDeleted = false")
List<Store> findPopular(Pageable pageable);
@Query("""
SELECT s FROM Store s
WHERE s.isDeleted = false
ORDER BY COALESCE(s.averageStar, 0) DESC,
s.reviewCount DESC,
s.bookmarkCount DESC,
s.id ASC
""")
List<Store> findPopular(Pageable pageable);

docodocod and others added 4 commits June 1, 2026 20:34
- location 컬럼 geometry → geography 타입 변경 (GIST 인덱스 직접 활용)
- findNearbyWithGist 쿼리 불필요한 ::geography 캐스팅 제거
- Store.java columnDefinition geography(Point, 4326)로 수정
- CacheConfig GenericJackson2JsonRedisSerializer 복원 (타입 정보 보존)
- @Cacheable sync=true 추가 (Cache Stampede 방지)
- @Modifying clearAutomatically=true 추가 (영속성 컨텍스트 불일치 방지)
- findPopular 무의미한 countQuery 제거 (List 반환 시 미실행)
- migration_v3_geometry_to_geography.sql 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- migration_v2/v3 분리 파일 제거, migration_v2_postgis_geography.sql 하나로 통합
  (운영 서버는 geography 타입으로 단일 마이그레이션 실행)
- after-changes.md 갱신: Gemini 코드리뷰 반영 사항 추가
  (geography 타입, sync=true, clearAutomatically=true, countQuery 제거)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- migration_v2_postgis_geography.sql: geography(Point, 4326) 직접 추가
  (로컬의 geometry→geography 2단계 과정을 운영용으로 통합)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@docodocod
docodocod merged commit 341f589 into develop Jun 1, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants