안녕하세요! NewCodes 개발자입니다.

NewCodes기술 블로그 큐레이팅 서비스입니다.
각 기업의 최신 기술 블로그를 모아서 한 번에 보여주고 있어요!

2025년 5월부터 저 혼자 군대에서 만들기 시작했어요!
현재에도 서버를 안정적으로 운영하려 노력하고 있습니다 😊

이번 글에서는 검색어 자동완성 API 최적화 경험을 공유하려 합니다.
처음엔 1초 이상 걸리던 응답을 100ms 이내로 90% 이상 개선한 과정을 담아봤어요!

먼저 NewCodes 서버의 아키텍처부터 간단히 알려드릴게요!

  • WAS는 Spring Boot로 돌리고 있습니다.
  • DB는 PostgreSQL을 사용하고 있습니다.

이 두 가지만 간단히 언급하고 넘어갈게요!


검색어 자동완성 추천 기능 소개

네이버나 구글에 보면 항상 검색어 자동완성이 켜져 있죠.
해당 자동완성을 통해 내가 찾고자 하는 검색어를 추천받을 수 있어요!

이 기능이 검색 경험 만족도 향상에 큰 기여를 한다고 생각해서 구현했습니다.

조금만 타이핑하고도 내가 원하는 검색어를 찾을 수 있죠.
또한, 내가 잘 검색하고 있는지 피드백 받을 수도 있습니다!

이걸 NewCodes에서도 구현했습니다!

사용자가 입력하는 단어에 따라 어떤 검색어를 입력할지 추천해줍니다.

1. Term 기반 추천

여러 블로그 글의 제목과 본문을 분석해서 의미 있는 키워드(term)를 추출합니다.

예를 들어 "인덱스로 api 최적화"라는 글에서는 "인덱스", "api", "최적화" 같은 term을 추출해요.

term검색의 기본이 되는 단위입니다. 단순히 단어를 쪼개는 게 아니라, 실제로 기술 블로그에서 자주 등장하는 의미 있는 기술 용어를 추출하는 거죠.

2. 자모 분리 검색

단순히 정확히 일치하는 단어만 찾아주는 게 아니라, 사용자가 입력하는 중간 과정까지 예측해서 추천해줍니다!

사용자가 "프로젝트"를 입력할 때 어떤 과정을 거칠까요?

프 → 프로 → 프롲 → 프로제 → 프로젝 → 프로젝트

여기서 "프롲"은 의미가 없는 중간 상태의 글자입니다. 하지만 이 순간에도 사용자는 "프로젝트"를 찾고 싶어 하죠!

이걸 해결하기 위해 모든 term을 자모 단위로 분해해서 저장했습니다.

"프로젝트" → "ㅍㅡㄹㅗㅈㅔㄱㅌㅡ"로 저장

사용자가 "프롲" 입력
→ "ㅍㅡㄹㅗㅈ"로 분해
→ "ㅍㅡㄹㅗㅈㅔㄱㅌㅡ"와 매칭
→ "프로젝트" 추천!

이 방식 덕분에 타이핑 중간에도 끊김 없이 자동완성이 동작합니다.

3. 초성 검색도 지원

급하게 검색할 때 초성만 입력해도 찾을 수 있어요!

"ㄴㅇㅂ" -> "네이버"
"ㅂㅇㄷ" → "백엔드"
"ㅍㄹㅌㅇㄷ" → "프론트엔드"

초성도 term과 마찬가지로 미리 추출해서 DB에 저장해뒀습니다.

4. 기업 페이지도 함께 추천

기술 용어뿐만 아니라 기업 이름으로도 검색할 수 있어요.

"카카오", "네이버", "토스"처럼 기업명을 입력하면 해당 기업의 기술 블로그 모음 페이지를 바로 추천해줍니다.

기업명도 한글, 영문, 초성 검색을 모두 지원합니다:

"카카오" / "kakao" / "ㅋㅋㅇ" → 모두 카카오 페이지로 연결
"Naver" / "네이버" / "ㄴㅇㅂ" → 모두 네이버 페이지로 연결

자동완성이 너무 느리다!

NewCodes 검색창에 "백엔드"를 입력하면 관련된 기업, 테마, 용어가 자동완성으로 뜨는데요.

초기에는 데이터가 적어서 잘 돌아갔는데, 데이터가 쌓이면서 점점 느려졌습니다.

# select count(*) from term;
 count  
--------
 112336
(1 row)

term 테이블에 무려 11만 개의 데이터가 생겼습니다.

측정해보니 API 응답 시간이 1초 이상 걸렸어요. 자동완성인데 1초면 너무 긴 시간이죠.

'왜 이렇게 느릴까?' 고민하며 최적화를 시작했습니다.

목표는 100ms 이내로 잡았어요! 이 정도 수치면 사용자 경험상 거의 즉각적인 반응이라고 느끼기 때문입니다.

최종 결과 먼저 보기

단계주요 기법응답 시간
초기-1,000ms
1차인덱스 추가700ms
2차LOWER 함수 인덱스110ms
3차JOIN → EXISTS100ms
4차비정규화 & 커버링 인덱스90ms
5차JDBC Template80ms
운영 서버 기준-40ms

최종 개선율: 약 90% 이상 (1,000ms 초과 → 100ms 이내)

어떻게 했는지 지금부터 단계별로 살펴볼게요!


1차 시도: 인덱스를 추가해보자!

가장 먼저 떠오른 건 인덱스였습니다.

예전에 데이터가 몇 개 없었을 때는 빨랐으니까요. 데이터 탐색 시간이 오래 걸려서 그럴 거라 생각했습니다.

또한, 자동완성 관련 테이블들은 write 보다는 read 연산이 훨씬 높았기에 인덱스 선택이 유리했습니다.

가장 빠르고 효과적인 방법인 인덱스를 우선적으로 도입했습니다.

어떻게 했나?

아래 세 가지 인덱스를 만들어줬습니다!

CREATE INDEX CONCURRENTLY idx_term_name_pattern 
ON term(term varchar_pattern_ops);

CREATE INDEX CONCURRENTLY idx_term_decomposed_pattern 
ON term(decomposed_term varchar_pattern_ops);

CREATE INDEX CONCURRENTLY idx_term_chosung_pattern 
ON term(chosung varchar_pattern_ops);

PostgreSQL의 varchar_pattern_ops 연산자 클래스를 사용해 LIKE 접두사 검색에 최적화된 인덱스를 만들었습니다. 자동완성은 항상 접두사를 기준으로 다음에 무엇이 올지 추천하는 기능이니까요!

CONCURRENTLY를 통해 테이블 잠금 없이, 서비스 중단 없이 인덱스를 생성했어요. 이러면 시간이 오래 걸리고 CPU 부하가 생길 수 있음은 감안해야 합니다.

CONCURRENTLY가 없으면 테이블 읽기는 되지만, 쓰기 작업이 잠깁니다. 큰 테이블은 인덱스 생성 시 시간이 걸리기에 이 동안 쓰기 작업을 못하는 걸 방지해야 합니다.

참고로 기업 검색을 위한 corporation 테이블에도 index를 적용했습니다.

결과는?

여전히 700ms 정도로 느렸습니다.
예상보다 저조한 성능이었습니다..!!
뭔가 더 근본적인 문제가 있는 것 같았어요.


2차 시도: LOWER 함수 인덱스

쿼리 실행 계획을 뜯어보니 문제를 발견했습니다.

LOWER() 함수 때문에 인덱스를 타지 못하고 풀스캔이 발생하고 있었어요!

-- 이렇게 쓰면 인덱스를 안 탐
WHERE LOWER(term.name) LIKE LOWER(CONCAT(:query, '%'))

인덱스는 원본 값 name으로 정렬되어 있는데, LOWER(term.name)을 하면 기존 정렬 순서와 달라집니다. 그래서 인덱스를 타지 못하고 풀스캔을 하게 됩니다.

이렇게 인덱스가 걸려져 있는 필드를 가공하게 되면, 일반적인 RDBMS에서는 인덱스를 사용하지 못합니다.

어떻게 했나?

해결법은 간단합니다.
함수 기반 인덱스(Functional Index)를 생성했습니다.

CREATE INDEX CONCURRENTLY idx_term_lower_term 
ON term(LOWER(term) varchar_pattern_ops);

CREATE INDEX CONCURRENTLY idx_term_lower_decomposed 
ON term(LOWER(decomposed_term) varchar_pattern_ops);

CREATE INDEX CONCURRENTLY idx_term_lower_chosung 
ON term(LOWER(chosung) varchar_pattern_ops);

소문자로 바꾸는 것 말고는 따로 가공할 일이 없기에 위와 같이 인덱스를 만들어줬습니다.

결과는?

성능이 크게 개선됐습니다! 하지만 목표인 100ms 이내엔 도달하지 못했습니다.

더욱 더 개선이 필요했어요!


3차 시도: JOIN을 버리고 EXISTS로!

위에서 봤던 것처럼 자동완성 검색어를 조회할 때는 기업도 조회하는데요!

Corporation(기업) 조회 쿼리를 분석해보니, JOIN이 성능 병목을 만들고 있었습니다.

기존 쿼리

@Query("SELECT c FROM Corporation c " +
       "JOIN Article a ON a.corporation.id = c.id " +
       "WHERE c.deletedAt IS NULL " +
       "AND a.deletedAt IS NULL " +
       "AND (LOWER(c.name) LIKE LOWER(CONCAT(COALESCE(:query, ''), '%')) " +
       "OR LOWER(c.alternateName) LIKE LOWER(CONCAT(COALESCE(:query, ''), '%')) " +
       // ... 추가 조건들
       "ORDER BY c.name ASC")
List<Corporation> findCorporationsWithArticlesByNameContaining(
    @Param("query") String query, Pageable pageable);

JOIN은 Corporation과 Article의 모든 매칭 레코드를 조합하니까 Planning Time, Execution Time이 모두 길어집니다.

corporation과 article을 INNER JOIN하는 이유는 블로그 글이 없는 기업을 배제하기 위함입니다. NewCodes에 등록된 기업 중에는 유튜브만 올리는 기업도 있기 때문입니다.

개선된 쿼리

@Query("SELECT DISTINCT c FROM Corporation c " +
       "WHERE c.deletedAt IS NULL " +
       "AND EXISTS (SELECT 1 FROM Article a " +
                   "WHERE a.corporation.id = c.id AND a.deletedAt IS NULL) " +
       "AND (LOWER(c.name) LIKE LOWER(CONCAT(:query, '%')) " +
       "OR LOWER(c.alternateName) LIKE LOWER(CONCAT(:query, '%')) " +
       // ... 추가 조건들
       "ORDER BY c.name ASC")
List<Corporation> findCorporationsWithArticlesByNameOptimized(
    @Param("query") String query, Pageable pageable);

EXISTS는 데이터가 존재하는지 확인하고, 존재하다면 곧바로 true를 반환하고 종료하는 연산자입니다.

"이 기업에 Article이 존재하는가?"만 확인하면 되니까 스캔 범위가 훨씬 줄어듭니다!

INNER JOIN은 모든 행의 매칭 조합을 모두 계산해야 합니다. 반면에 EXISTS는 데이터 존재 여부만 확인하기에 확인만 되면 바로 스캔이 끝나기에 더 유리합니다.

결과는?

대부분의 요청이 100ms 근처로 들어온 걸 볼 수 있습니다!!

하지만 아직 목표를 이룬 건 아니기에 더욱 최적화를 시도했습니다.


4차 시도: 비정규화 컬럼 & 커버링 인덱스

term 조회 기존 쿼리

    /**
     * 자동완성을 위한 Term 검색 (여러 패턴 지원, 빈도수 순)
     * 여러 검색 패턴으로 term, 자모 분리된 term, 초성을 검색
     * 예: "프롲" 입력 시 ["프롲", "프로ㅈ"] 두 패턴으로 검색
     */
    @Query("SELECT at.term.term as term, " +
           "SUM(at.frequency) as totalFrequency " +
           "FROM ArticleTerm at " +
           "WHERE " +
           "(LOWER(at.term.term) LIKE LOWER(CONCAT(COALESCE(:query1, ''), '%')) " +
           "OR LOWER(at.term.chosung) LIKE LOWER(CONCAT(COALESCE(:query1, ''), '%'))) " +
           "OR " +
           "(LOWER(at.term.decomposedTerm) LIKE LOWER(CONCAT(COALESCE(:query2, ''), '%')))" +
           "GROUP BY at.term.term " +
           "ORDER BY SUM(at.frequency) DESC")
    List<AutocompleteSuggestion> findAutocompleteTermsWithPatterns(
        @Param("query1") String query1,
        @Param("query2") String query2,
        Pageable pageable);

해당 쿼리를 보면 term의 빈도 횟수를 집계하는 부분이 있습니다. 이를 위해 GROUP BY, SUM을 사용하고 있습니다.

term 빈도수는 새로운 글이 크롤링되지 않는 이상 잘 바뀌지 않기에, 매번 새롭게 계산하는 건 비효율적입니다.

참고로 term 빈도수를 체크하는 이유는 자동완성 검색어 결과 정렬 시에 사용하기 위함입니다!

어떻게 했나?

term 테이블에 total_frequency 컬럼을 추가하고, 미리 집계된 값을 저장했습니다.

-- 1. 컬럼 추가
ALTER TABLE term ADD COLUMN IF NOT EXISTS total_frequency BIGINT DEFAULT 0;

-- 2. 기존 데이터 업데이트 (배치 처리)
UPDATE term t
SET total_frequency = COALESCE((
    SELECT SUM(at.frequency)
    FROM article_term at
    WHERE at.term_id = t.id
), 0);

또, 새로운 글이 크롤링되거나 삭제될 때 term_frequency를 업데이트하도록 애플리케이션 수준에서 만져줬습니다.

개선된 쿼리

    @Query(value = """
        SELECT term, total_frequency
        FROM term
        WHERE LOWER(term) LIKE :query1
           OR LOWER(chosung) LIKE :query1
           OR LOWER(decomposed_term) LIKE :query2
        ORDER BY total_frequency DESC
        LIMIT :limit
        """, nativeQuery = true)
    List<AutocompleteSuggestion> findAutocompleteTermsOptimizedRaw(
        @Param("query1") String query1,
        @Param("query2") String query2,
        @Param("limit") int limit);

해당 쿼리에서 이제 total_frequency라는 필드를 통해 그룹 연산을 하지 않고도 빈도수를 체크할 수 있습니다!

최적의 커버링 인덱스

더불어, 기존의 인덱스를 삭제하고 새로운 인덱스를 만들었습니다.

바로 커버링 인덱스입니다!

CREATE INDEX CONCURRENTLY idx_term_covering ON term 
    (lower(term) text_pattern_ops)
    INCLUDE (term, total_frequency)
    WHERE total_frequency > 0;

CREATE INDEX CONCURRENTLY idx_chosung_covering ON term 
    (lower(chosung) text_pattern_ops)
    INCLUDE (term, total_frequency)
    WHERE total_frequency > 0;

CREATE INDEX CONCURRENTLY idx_decomposed_covering ON term 
    (lower(decomposed_term) text_pattern_ops)
    INCLUDE (term, total_frequency)
    WHERE total_frequency > 0;

앞서서 total_frequency를 새로운 필드로 뒀었습니다. 여기서 인덱스에 total_frequency까지 포함하게 된다면 I/O 횟수를 더욱 줄일 수 있습니다!

기존에는 term으로 인덱스를 조회한 뒤에 total_frequency를 조회하기 위해서 추가적인 I/O가 필요했을 것이기 때문입니다.

이처럼 커버링 인덱스는 인덱스만으로 모든 데이터를 제공해서 I/O를 줄일 수 있다는 장점이 있습니다.

만약 해당 부분이 잘 이해가 되지 않으신다면 'Index는 왜 빠를까?' 글을 추천드립니다!! 제가 직접 썼던 글입니다 ㅎㅎ

결과는?

결과는 대부분의 테스트 요청이 100ms 내로 들어온 걸 볼 수 있습니다!

하지만 조금 더 안정적으로 100ms 내로 들어오기 위해서 더 시도했습니다!


5차 시도: JPA 버리고 JDBC Template으로!

여기까지 왔는데도 Term 조회에 미세한 오버헤드가 남아있었습니다.

JPA 자체의 오버헤드가 있었어요.

JPA란?

JPA가 뭔지부터 간단히 알아보죠!

JPA란 Java Persistence API의 약자입니다. 말 그대로 자바 진영에서 쓰이는 영속성 API입니다. 데이터를 영구히 저장하기 위해서 쓰는 API인 겁니다.

구체적으로 말하면 자바 진영에서 쓰이는 ORM(Object Relational Mapping) 기술의 표준입니다. 관계형 데이터베이스와 자바 객체 사이의 통역사 역할을 합니다.

ORM이 SQL을 만들어주기에 개발자가 SQL을 직접 작성할 필요가 없어집니다. SQL 중심에서 벗어나 조금 더 객체지향적으로 애플리케이션 코드를 짜는 데 더욱 집중할 수 있죠.

그 외에도 캐시 기능을 제공해 RDB에 직접 쿼리를 안 날려도 객체를 가져올 수 있다는 장점도 있습니다.

JPA는 어떻게 동작해야 하는지 나타내는 명세 역할만 합니다. 그래서 구현체는 따로 여러 개가 있습니다. 그 중에서 대표적으로 쓰이는 게 Hibernate입니다.

JPA의 조회 과정

JPA는 아래와 같은 순서로 데이터를 가져옵니다.

JPA는 개발자에게 비즈니스 로직에 더 집중할 수 있게 편리함을 가져다주긴 합니다. 그렇지만 속도가 정말 중요한 케이스일 때는 오버헤드가 될 수 있습니다.

자동완성처럼 단순 조회만 필요한 경우, Hibernate를 거치지 않고 JDBC를 사용해서 곧바로 데이터를 가져올 수 있습니다!

그래서 자동완성 쿼리 한정으로 JPA를 걷어내기로 했습니다!

JDBC Template 적용

public List<AutocompleteSuggestion> findTermsWithJdbc(
    String query1, String query2, int limit) {
    
    String sql = """
        SELECT term, total_frequency
        FROM term
        WHERE (LOWER(term) LIKE LOWER(CONCAT(?, '%'))
           OR LOWER(chosung) LIKE LOWER(CONCAT(?, '%'))
           OR LOWER(decomposed_term) LIKE LOWER(CONCAT(?, '%')))
        AND total_frequency > 0
        ORDER BY total_frequency DESC
        LIMIT ?
        """;
    
    return jdbcTemplate.query(
        sql,
        (rs, rowNum) -> new AutocompleteSuggestion(
            rs.getString("term"),
            rs.getLong("total_frequency")
        ),
        query1, query1, query2, limit
    );
}

결과는?

모든 테스트 요청이 100ms 내로 들어왔습니다!!

개발 서버에서는 위와 같이 나오고, 실제 운영 서버에서는 더 좋은 성능이 나왔습니다!

이로써 목표를 이뤘습니다 ㅎㅎ

JPA를 지금까지 너무 편하게 잘 쓰고 있었지만, 무엇이든 100% 장점만 있는 기술은 없다는 걸 다시금 느꼈씁니다.


요청이 왜 실패하지?

왜 간헐적으로 api 요청이 실패할까?

자동완성 api를 여러 번 호출하다 보면 간헐적으로 실패하는 경우가 있었습니다.

Network 탭을 열어보면 "connection start → initial connection"에서 멈췄습니다. 그리고 ERR_HTTP2_PROTOCOL_ERROR 에러가 발생했죠.

개발 환경에서는 이러한 문제가 나타나진 않았었습니다. 그렇다면 개발 환경과 운영 환경의 차이를 들여다봐야 합니다.

운영 환경에서는 NginX를 사용하지만, 개발 환경에서는 사용하지 않고 있었습니다. 그래서 NginX 로그부터 살펴봤습니다.

살펴본 결과, 역시나 문제가 있었습니다.

[error] limiting requests, excess: 10.518 by zone "api"

이전에 nginx 설정에서 rate limiting을 걸어둔 적이 있었습니다. 해당 rate limit 설정을 넘어서서 요청이 실패했습니다.

기존 Nginx 설정

기존 설정을 보면 아래와 같습니다.

limit_req_zone $binary_remote_addr zone=api:10m rate=120r/m;
limit_req zone=api burst=10 nodelay;
limit_req_status 444; 
설정의미
rate=120r/m초당 2회 제한
burst=1010개까지 초과 허용
limit_req_status 444초과 시 연결 강제 종료

rate limit은 1초에 2회 제한이고 10개까지 초과 허용이 가능합니다. 자동완성 디바운싱은 100ms로 되어있었고, 이론상 1초에 10개 요청을 보내는 게 가능합니다.

그러므로 계속해서 사용자의 연속된 요청이 있을 때 어느 순간부터 요청이 제한될 수 있습니다.

위는 현재 rate limit 설정에 대한 예시 동작 과정입니다. 자세한 설명은 해당 링크를 참고바랍니다!

사용자가 100ms마다 1개의 요청을 보낸다고 가정하고 계산해봤을 때, 1500s 후가 되면 일부 요청이 실패하게 됩니다.

limit을 넘어서면 444 응답을 하며 ERR_HTTP2_PROTOCOL_ERROR를 발생시킵니다.

444로 설정했었던 이유는 예전에 악성 봇 요청을 막기 위함이었습니다. 444는 NginX 내부적으로 만든 status code이며, TCP 연결을 즉시 끊기에 서버 리소스를 보호할 수 있습니다.

하지만 해당 설정이 정상적인 검색도 방해할 수 있어 별도로 설정했습니다.

이후 NginX 설정

limit_req_zone $binary_remote_addr zone=autocomplete:10m rate=10r/s;  
limit_req zone=autocomplete burst=20 nodelay;
limit_req_status 429;

1초에 10개의 요청까지 허용합니다. 이러면 사용자의 요청에 따라 정상적으로 응답할 수 있습니다!!

이를 통해 배웠던 점들은 아래에 한 번에 정리해볼게요!


중간에 이런 노력도 있었어요!

위에서 최적화를 하면서 성능 before & after를 측정하지 못했지만 중간에 이런 노력도 있었습니다!

응답 데이터 크기 축소

네트워크 전송 시간도 고려해야 했습니다. 그래서 불필요하게 큰 JSON 응답을 줄이기로 했습니다!

사실 이건 네이버 검색어 자동완성 API를 참고해서 개선한 겁니다!
네이버에서는 정말 필요한 최소한의 데이터만 응답해주더라고요!

기존 응답 형식

{
  "corporations": [
    {
      "type": "corporation",
      "name": "네이버",
      "id": 1,
      "logoUrl": "https://..."
    }
  ],
  "themes": [
    {
      "type": "theme",
      "id": 5,
      "name": "백엔드 개발"
    }
  ],
  "terms": [
    {
      "type": "term",
      "term": "kubernetes"
    },
    {
      "type": "term",
      "term": "docker"
    }
  ]
}

개선된 응답 형식

[
  [0, "카카오", 1, "https://..."],
  [1, 5, "백엔드 개발"],
  "kubernetes",
  "docker"
]

배열 첫 번째 요소로 타입을 구분하고 (0: Corporation, 1: Theme, 2: Term), 필드명을 제거해 데이터 크기를 줄였습니다.

가독성은 조금 떨어지지만, 자동완성처럼 빈번한 요청에서는 효과적이에요!

참고로 theme은 NewCodes에서 이번에 새로 출시한 기능이에요!

조금씩 더 추가할 예정이니 많은 관심 부탁드립니다!!

CompletableFuture로 병렬 처리

마지막 최적화는 병렬 처리였습니다.

Corporation, Theme, Term 조회는 서로 독립적이니까 동시에 실행할 수 있습니다!

순차 실행 vs 병렬 실행

순차 실행 (기존)

총 시간 = Corp 시간 + Theme 시간 + Term 시간
예: 6ms + 2ms + 1ms = 9ms

병렬 실행

총 시간 = max(Corp 시간, Theme 시간, Term 시간)
예: max(6ms, 2ms, 1ms) = 6ms

코드

CompletableFuture<List<Corporation>> corpFuture = 
    CompletableFuture.supplyAsync(() -> 
        corporationRepository.findOptimized(query, limit));

CompletableFuture<List<Theme>> themeFuture = 
    CompletableFuture.supplyAsync(() -> 
        themeRepository.findOptimized(query, limit));

CompletableFuture<List<AutocompleteSuggestion>> termFuture = 
    CompletableFuture.supplyAsync(() -> 
        termRepository.findOptimized(query1, query2, limit));

CompletableFuture.allOf(corpFuture, themeFuture, termFuture).join();

List<Corporation> corps = corpFuture.get();
List<Theme> themes = themeFuture.get();
List<AutocompleteSuggestion> terms = termFuture.get();

위와 같이 해서 조금이나마 쿼리 실행 시간을 줄이고자 했습니다!
실제로는 위 예제 코드에서 ExecutorService와 예외 처리를 추가해줬습니다!


정리

최종 성과

단계주요 기법응답 시간
초기-1,000ms
1차인덱스 추가700ms
2차LOWER 함수 인덱스110ms
3차JOIN → EXISTS100ms
4차비정규화 & 커버링 인덱스90ms
5차JDBC Template80ms
운영 서버 기준-40ms

최종 개선율: 약 90% 이상 (1,000ms 초과 → 100ms 이내)

배운 점

1) 무엇이 문제인지 잘 정의하고 해결의 방향성을 잡기

이번 최적화를 하면서 직접 제 손으로 코드를 수정한 일은 적었습니다. Claude Code를 활용한 부분이 많았습니다. CompletableFuture를 활용한 병렬처리, 응답 데이터 크기 축소, 쿼리 및 인덱스 수정 등 AI를 적극적으로 활용했습니다.

AI 시대에서는 확실히 코드를 직접 만질 일이 적어졌습니다. 일각에서는 AI가 코드를 수정하는 건 생성하는 것만큼은 잘 못한다고는 하는데 이젠 그런 말도 무색해질 정도입니다.

기존에 있던 코드를 최적화하는 것 또한 방향성만 잘 잡아준다면 AI가 곧 잘해냅니다. 결국 중요한 건, 무엇이 문제인지 인식한 다음 정의해서 방향성을 잡는 것이라 느꼈습니다.

AI가 낸 결과가 100% 맞지는 않았습니다.

AI가 수정한 쿼리가 의도한 인덱스를 타지 않는 경우가 있었습니다. 또한, NginX Rate Limiting 관련해서 처음에 문제의 원인을 추정할 때 claude code에게 시켜봤습니다. 하지만, nginx 설정을 제대로 읽지 않고 엉뚱한 원인을 제시하더군요. 항상 주도권은 개발자인 나에게 있어야 함을 느끼는 순간이었어요!!

AI가 문제 해결 프로세스의 전 과정에서 굉장한 도움을 주고 있는 건 사실입니다. 하지만, 전 과정에서 개발자의 설계 능력과 검수는 필수라는 걸 다시금 느낍니다.

2) 전체 아키텍처를 보는 시야

NginX Rate Limiting 관련해서 배운 게 있었습니다. 간단한 코드 하나를 배포하더라도 전체 아키텍처에서 어떻게 동작할지를 구상해야 한다는 것입니다. 일부 관점에서 봤을 때는 잘 동작할지 몰라도 관련 아키텍처와 맞물려 동작하면 문제가 생길 수 있습니다.

지금은 개인 프로젝트이고 트래픽이 엄청 많거나 수익을 내야 하는 서비스는 아니라서 장애에 대한 민감도 낮긴 합니다. 하지만, 팀 단위 프로젝트의 현업에서는 더욱 더 검수를 꼼꼼히 해야한다는 걸 느꼈습니다.

3) 복잡하게 해봐야 단순하게 할 수 있다

이번 흑백요리사2를 보면 정말 단순해보이는 요리로도 심사위원에게 인정을 받곤 합니다. 단순한데도 극찬을 받는 이유는 뭘까요?

그 이전에 복잡했던 시행착오들이 있었기 때문이라 생각합니다. 이것저것 다 해보면서 경험을 쌓고 어떻게 해야 가장 재료의 맛을 잘 살릴 수 있는지 직접 체득했을 것입니다.

그렇기에 가장 짧고 빠른 길을 아는 겁니다. 그래서 우리가 겉으로 봤을 때는 단순해 보일 수 있지만 사실은 그렇지 않죠.

이 이야기를 왜 하냐면 개발도 마찬가지이기 때문입니다. 이번에 1차, 2차, 3차, ... 최적화를 거친 글을 썼는데요. 글의 흐름처럼 순조롭게 흘러간 건 아니었습니다. 분명히 특정 부분에서 막혀서 여러 시행착오가 있었습니다.

최적화를 하기 위해 인덱스를 추가하거나, 쿼리를 수정했지만 오히려 성능이 더 안 좋아지는 경험을 하기도 했습니다.

이는 AI가 짜놓은 쿼리를 꼼꼼히 분석하지 않은 탓이었습니다. 그래서 의도한 대로 인덱스를 타지 않을 때도 있었습니다. 쿼리를 조금 더 자세하게 꼼꼼히 살펴봤다면 최적화를 더 빨리 할 수 있었을 것입니다.

또, 커버링 인덱스가 필요한 상황에서 복합 인덱스로 잘못 걸었을 때도 있었습니다. 복합 인덱스는 앞으로 사용할 쿼리에서는 적합하지 않은 인덱스였는데 말이죠.

지나고 보면 '왜 그런 실수들을 했지?', '왜 그걸 몰랐지?' 생각이 듭니다. 다음에는 비슷한 상황이 생기면 더욱 심플하게 핵심만을 건드려낼 수 있을 거 같습니다.

4) 기술적으로 배운 것들

  1. RDB에서 index 걸 때 단일 인덱스, 복합 인덱스, 커버링 인덱스 등 많은 인덱스 중에서 상황에 따라 적절한 것 선택하기. 또한, 어떤 컬럼에 걸지도 고려하기.
  2. RDB 조회 쿼리를 짤 때 LOWER, CONCAT 등 가공을 하면 인덱스 안 탈 수 있으니 주의하기.
  3. JPA 조회 내부 과정이 어떻게 이루어지는지 파악.
  4. EXPLAIN을 통해 쿼리 실행 계획 뜯어보기. Bitmap Scan 방식 학습.

5) 10ms 대로 돌입하려면?

naver의 검색어 자동완성 api는 10ms대로 나오더군요!
NewCodes로 이런 성능을 내기 위해서 어떤 게 필요할까 생각해봤습니다.

  1. trie 자료구조 사용해서 탐색 시간 줄이기
  2. 자주 검색되는 용어는 캐시
  3. 글로벌 서비스라면 CDN 사용

현재는 안정적인 성능이 나오긴 합니다. 하지만, 사용자 트래픽이 늘어나면서 문제가 될 수도 있습니다. 향후 더 최적화가 필요할 시 위 사항들도 고려해봐야겠습니다.

마무리

1초가 넘던 자동완성 API를 10ms 수준으로 만드는 과정은 쉽지 않았지만, 각 단계마다 명확한 개선을 체감할 수 있어서 보람찼습니다.

특히 공군에서 복무하며 제한된 시간과 환경에서 프로젝트를 진행하면서, AI를 적극 활용해 방향성만 잡고 구체적인 구현은 도움받는 방식이 효율적이었어요!

여러분도 성능 이슈를 만나면, 문제를 명확히 정의하고 단계적으로 접근해보시길 권합니다.

그리고 AI의 도움을 적극 활용하되, 최종 판단은 본인이 하는 균형 잡힌 태도가 중요하다고 생각합니다 😊


여기까지 해서 NewCodes에서 검색어 자동완성 API를 최적화하며 배웠던 내용을 정리해봤습니다.

기술 블로그 큐레이팅 서비스 NewCodes 많이 방문해주세요!!

북마크 하시고 시간 날 때 한 번씩 들어와서 글 읽어보시는 거 추천드려요 ㅎㅎ

읽어주셔서 감사합니다!

레퍼런스

profile
기술 블로그 모음 서비스 https://newcodes.net

29개의 댓글

comment-user-thumbnail
2026년 1월 19일

해결하신 과정 재미있게보고 가요~! NewCodes 화이팅입니다..!

1개의 답글
comment-user-thumbnail
2026년 1월 19일

문제 해결 과정이 상당히 인상깊네요! 재미있게 읽고가요!

1개의 답글
comment-user-thumbnail
2026년 1월 20일

도움이 많이 됐어요. 감사합니다!

1개의 답글
comment-user-thumbnail
2026년 1월 20일

정확한 목표를 정하고 수행하신게 멋지네요...bb
과정도 수치를 기반해 자세히 기록되어 있어서 잘 보고갑니다!

1개의 답글
comment-user-thumbnail
2026년 1월 20일

엔지니어링적인 마인드가 느껴지는 글이었습니다. 잘 읽었습니다!

1개의 답글
comment-user-thumbnail
2026년 1월 20일

많은 기술적 고민이 보이는 좋은 글이네요!
예전에 인스턴스를 안두고 간단한 저비용 prefix 자동완성 구현하려고 AWS Dynamodb + Lambda로 만들던 기억이 새록새록 납니다 좋은 글 재미읽게 읽고갑니다!

1개의 답글
comment-user-thumbnail
2026년 1월 21일

고생하셨어요 잘보고가요. 검색빈도가 높은 데이터경로나 과정등을 저장해서 자동완성시키는기능같은건 어려울까요? 전 비개발자지만 혹 도움이 되실까싶어 댓글 남겨봅니다

1개의 답글
comment-user-thumbnail
2026년 1월 21일

좋은 글 보고 갑니다 고생하셨습니다.

1개의 답글
comment-user-thumbnail
2026년 1월 21일

정말 어렵습니다..😢

1개의 답글
comment-user-thumbnail
2026년 1월 21일

재밌게 잘 읽고 여러모로 도움이 많이 되었습니다!

1개의 답글
comment-user-thumbnail
2026년 1월 22일

우와 너무 잘 읽었습니다. 문제를 차근차근 해결해나가시는 태도 배우고 싶네요!

1개의 답글
comment-user-thumbnail
2026년 1월 24일

좋은 글 잘보고 갑니다. 가독성 최고네요
NewCodes 파이팅!!

2개의 답글
comment-user-thumbnail
2026년 1월 25일

고생 많으셨습니다!
글을 읽다가 궁금한 점이 생겨 질문 올립니다. Elasticsearch나 Meilisearch 같은 검색 엔진 대신 PostgreSQL의 LIKE 쿼리 + 인덱스 튜닝으로 구현하신 이유가 있을까요? 데이터가 11만 건 정도라서 검색 엔진을 도입할 필요가 없다고 판단하신 건지, 아니면 운영 복잡도나 인프라 비용 측면에서 결정하신 건지 궁금하여 질문 글 남깁니다!

1개의 답글
comment-user-thumbnail
2026년 1월 30일

글 잘 봤습니다! 혹시 응답시간 같은건 어떻게 확인하신건지 알 수 있을까요?

1개의 답글