🖥️ 컴퓨터 공부/부하테스트 & 성능최적화

해커톤 성능 최적화 -2

le2donguk 2026. 4. 24. 15:36

다시 성능 최적화를 위해 노력하였습니다

두 번째 테스트 이후 3가지를 추가로 개선하였습니다.

 

변경 항목 이전 이후 효과
findAnnounceWithFavorite EXISTS 상관 서브쿼리 (행마다 실행) LEFT JOIN 단일 쿼리 페이지 20개 기준 서브쿼리 20번 → JOIN 1번
findFavoritesByMemberId announce.documents.size() (N+1) LEFT JOIN + COUNT 동일하게 JOIN 1번으로 해결
CacheService.listLookAside Thread.sleep(50) 후 캐시 재조회 즉시 DB 폴백 스레드 블로킹 50ms 제거

 

EXISTS → LEFT JOIN 개선

기존 코드에서 즐겨찾기 여부를 확인하는 방식은 다음과 같았습니다.

JPAExpressions.selectOne().from(memberFavorite)
        .where(memberFavorite.member.id.eq(memberId),
               memberFavorite.announce.id.eq(announce.id))
        .exists()

 

이 코드가 SQL로 변환되면 아래처럼 동작합니다.

SELECT announce.*,
       (SELECT 1 FROM member_favorite 
        WHERE member_id=1 AND announce_id=announce.id) -- 행마다 실행
FROM announce

 

공고가 20개면 이 서브쿼리가 20번 실행됩니다. 이를 LEFT JOIN 방식으로 변경하여 한 번의 쿼리로 해결하였습니다.

// 변경 후
.from(announce)
.leftJoin(memberFavorite)
    .on(memberFavorite.announce.id.eq(announce.id)
        .and(memberFavorite.member.id.eq(memberId)))

 

즐겨찾기 여부를 "행마다 DB에 물어보는 것"에서 "JOIN으로 한 번에 가져와 null 여부로 판단하는 것"으로 변경하였습니다.

 


announce.documents.size() → LEFT JOIN COUNT 개선

기존에는 즐겨찾기 목록 조회 시 서류 개수를 다음과 같이 가져왔습니다.

announce.documents.size()  // 행마다 서브쿼리 실행

 

이 코드는 SQL로 변환 시 행마다 SELECT COUNT(*) FROM document WHERE announce_id =?

를 실행합니다. 즐겨찾기 20개라면 20번 실행되는 구조입니다.

 

// 변경 후
document.count().intValue()  // LEFT JOIN + GROUP BY로 한 번에 집계

 

LEFT JOIN과 GROUP BY를 활용해 단 1번의 쿼리로 처리하도록 개선하였습니다


@Transactional 버그 수정 — AnnounceService

Self-invocation (같은 클래스 내 메서드 호출) 시 Spring AOP 프록시가 개입하지 못해 @Transactional이 무시되었습니다.

public AnnounceDetailResponse getAnnounceDetail(Long announceId, Long memberId) {
	AnnounceDetailResponse response = getAnnounceDetailAndPlus(announceId, memberId);
	// ↑ 같은 클래스 호출이라 @Transactional 무시됨
	// → readOnly 트랜잭션 안에서 UPDATE 실행 → 에러
	saveRecentAndPopularToRedis(announceId, memberId, response);
	return response;
}

 

같은 클래스 호출이라서 AOP를 타지 못해 , Transaction이 무시가 되었습니다. 이 때문에 getAnnounceDetail이 Class에 선언된 read-only트랜잭션인 채로 updateViewNum (UPDATE)을 실행하게 되어 Connection is read-only 에러가 발생하였습니다

 

그래서 다음과 같이 수정하였습니다.

@Transactional  // ← 진입점에서 쓰기 트랜잭션 시작
public AnnounceDetailResponse getAnnounceDetail(Long announceId, Long memberId) {
	AnnounceDetailResponse response = getAnnounceDetailAndPlus(announceId, memberId);
	saveRecentAndPopularToRedis(announceId, memberId, response);
	return response;
}

 


다시 부하테스트..!

 

쿼리를 최적화했음에도 불구하고 모니터링 지표에서 특정 구간에 CP, Tomcat Thread, CPU 사용률이 스파이크를 치는 현상이 반복되었고, 이로 인해 P95 응답 시간이 함께 치솟는 패턴이 관찰되었습니다.

쿼리는 충분히 개선되었다고 판단하였기 때문에, 다음 단계로 Redis 병목을 중점적으로 분석하기로 하였습니다


네 번째 성능 개선 — Redis 중심

API별 Redis 사용 현황 분석

병목의 원인을 파악하기 위해 API별 Redis 호출 구조를 분석하였습니다.

 

/announce/{id} — 요청 1건당 Redis 호출 3회

1. ViewCountRedisService.increment()  → INCR + SADD (동기 2회)
2. RedisAsyncService.saveRecentAndPopular()  → LPUSH + LTRIM + EXPIRE + ZINCRBY (비동기)

 

/home — 캐시 미스 시 분산 락까지 발생

캐시가 워밍된 상태라면 파이프라인 1회로 끝나지만, 
TTL 만료 직후 동시 요청이 몰리면 Thundering Herd 현상과 함께 SETNX 락 경합이 발생하는 구조였습니다.

 

이를 토대로 코드를 살펴봤습니다


발견한 핵심 문제 두 가지

① ZINCRBY — 완전한 데드코드

 

RedisAsyncService는 매 요청마다 home:popular:announce 키에 ZINCRBY를 쓰고 있었습니다. 그런데 실제로 HomeService가 읽는 키는 home:popular:announce:cache이고, 이는 PopularAnnounceBatch가 DB에서 직접 10분마다 재빌드하는 구조였습니다.

 

RedisAsyncService  → ZINCRBY "home:popular:announce"       ← 아무도 읽지 않음 
PopularAnnounceBatch → DB 조회 → ZADD "home:popular:announce:cache"  ← HomeService가 읽음

 

매 요청마다 쓰기만 발생하고 아무도 읽지 않는 키였습니다.

 

 

② ViewCountRedisService.increment() — 동기 블로킹 2회

redisTemplate.opsForValue().increment(key);  // 동기
redisTemplate.opsForSet().add(KEY_SET, key); // 동기

 

@Async 없이 메인 스레드에서 Redis 왕복을 2번 수행하고 있었습니다. RedisAsyncService는 비동기로 처리되는데, 이 부분만 동기로 남아 있어 부하테스트에서 직접적인 병목으로 작용하고 있었습니다.

 


조치 내용

 

① ZINCRBY 제거

아무도 읽지 않는 home:popular:announce 키에 대한 쓰기를 삭제하여 매 요청마다 발생하던 불필요한 Redis 쓰기를 제거하였습니다.

 

② 동기 Redis 2회 → 비동기 파이프라인으로 통합


변경 전 : 메인 스레드 동기 블로킹

viewCountRedisService.increment(announceId);   // INCR (동기)
                                               // SADD (동기)
redisAsyncService.saveRecentAndPopular(...)    // 비동기

 

변경 후 : 비동기 파이프라인 1회 

redisAsyncService.incrementViewAndSaveRecent(...)
// └─ pipeline: INCR + SADD + LPUSH + LTRIM + EXPIRE

 

 


다시 또.. 성능 테스트...!!!

 

흠.... 역시... 너무 최악이다...

다시 또다시... 성능 최적화를 해보자...ㅠㅠㅠㅠㅠㅠㅠㅠ