Project/EATceed

[EATceed] CH05. 실서비스를 고려한 성능 최적화

Kyeong6 2025. 3. 22. 22:17

들어가며

EATceed의 AI 기능은 음식 이미지 판별인 푸드렌즈 기능과 식습관 분석, 일명 AI 영양사 기능을 핵심으로 삼고있다. 유저 관점에서 생각했을 때 보다 정확하고 빠른 서비스를 제공하기 위해 내부적으로 여러 가지 성능 최적화와 안정성 개선을 위해 작업을 진행했는데, 이번 포스팅에서 작업에 대한 과정을 중심으로 어떻게 테스트·구현했는지 공유하고자 합니다.

 

푸드렌즈: 성능 최적화

<FoodLens API>

 

푸드렌즈 기능은 사용자가 업로드한 음식 사진을 인식해 정확한 음식명을 찾아주는 것이 핵심이다. 본 서비스에서는 편의성을 위해 음식명에 따른 영양성분 정보도 함께 제공해야 하므로 AI 모델이 탐지한 음식명과 DB에 사전 적재해둔 음식명 같의 불일치 문제가 발생한다. 예를 들어, AI 모델은 "김치"라고 탐지했는데 DB에는 "배추김치"로 되어있다면 단순 문자열 매칭으로는 검색되지 않는 상황이 벌어진다.

 

이를 해결하기 위해 Vector DB 중 하나인 Pinecone을 사용했는데, 여러 Vector DB 옵션 중 Pinecone을 선택한 이유는 다음과 같다.

  1. 완전 관리형 서비스로 인프라 관리 부담이 적어 EATceed와 같은 초기 서비스에 적합
  2. API 친화적으로 Python에서 쉽게 연동하기 쉽고 개발 생산성이 높음
  3. 프리티어 내에서 EATceed 음식 데이터 개수인 약 1,300개의 데이터를 충분히 처리할 수 있어 비용 효율적

위의 내용을 바탕으로 푸드렌즈 기능에서 핵심 지표로 삼아야할 것은 다음과 같다.

  • 탐지율: 음식 이미지내의 실제 음식 중 몇 가지를 제대로 탐지
  • 정확도: 탐지된 음식 중 정답(Golden Dataset)과 일치 여부
  • 유사도: 탐지된 음식으로부터 Vector DB를 통해 추천된 상위 3개 후보 내에 정답이 포함될 확률

테스트 이미지 100장을 확보하여 한식·양식·중식·일식·간식 등 다양한 음식 유형을 포함했고, 동일 이미지를 중복으로 배치하여 모델의 일관성도 확인했다. 테스트 같은 경우는 자동화 로직을 통해 진행했으며 결과는 Google Sheets에서 관리·분석했다. 

<Test results in Google Sheets>

 

구체적인 지표 정의

지표 설명 수식
탐지율
(Detection Rate)
- 실제 존재하는 음식 중 몇 개의 음식 탐지하는 지 파악 (탐지된 음식 개수 / 실제 존재하는 음식 개수) x 100
정확도
(Detection Accuracy Rate)
- 탐지된 음식 중에서 실제로 정답과 일치하는 비율
- 탐지하지 못한 음식은 정확도 지표에서 실패로 간주
(탐지 성공 개수 / 탐지된 음식 개수) x 100
유사도 성공률
(Similarity Success Rate)
- 추천된 상위 3개의 음식 중 정답 포함 비율
- 탐지하지 못한 음식은 유사도 성공 실패로 간주
(유사도 성공 개수 / 전체 테스트 개수) x 100
전체 기능 성공률
(Overall Success Rate)
- 탐지와 유사도 성공이 모두 만족된 비율, 즉 기능 성공을 의미 ((탐지 성공 + 유사도 성공 개수) / 전체 테스트 개수) x 100

 

기존 로직

초기에는 프롬프트 기법 등의 방법론을 도입하지 않고 기본 구현에 초점을 맞춘 상태에서 테스트를 진행했다. 그 결과는 다음과 같다.

  • 정답 음식 데이터 개수: 338
  • 탐지된 음식 개수: 259
  • 탐지율(%): 76.63%
  • 정확도(%): 59.73%
  • 유사도 성공률(%): 27.54%
  • 전체 기능 성공률(%): 25.74%

탐지율 역시 서비스 운영 관점에서 충분히 높은 편은 아니지만 특히 정확도와 유사도 성공률이 너무 낮아 최종적으로 전체 기능 성공률이 25.74%에 그쳤다. 즉, 사용자가 기능을 4번 사용했을 때 1번 성공한다는 것이다. 이러한 문제를 해결하기 위해 최적화한 경험을 소개하려고 한다. 

 

이미지와 텍스트 순서 조정

첫 번째 개선 작업은 이미지를 AI 모델에 먼저 제공하고, 그 뒤에 텍스트인 프롬프트를 전달하는 방식으로 순서를 바꾸는 것이다. 이러한 변경이 성능을 높인 이유는 모델이 시각 정보를 먼저 이해한 뒤 텍스트와 결합해 더 정확한 응답을 낼 수 있었기 때문이라 생각한다. 또한, 사람도 글을 먼저 보고 이미지를 보는 것보다 이미지를 먼저 확인하고 글을 읽을 때 이해가 더 잘 되는 경우가 많으니까 AI 모델도 동일하다고 생각한다. 

 

아래는 기존 코드와 비교 예시이다.

# OpenAI API 호출
response = client.chat.completions.create(
    model="gpt-4o",
    messages=[
    	# 텍스트인 프롬프트 정보 먼저 제공
        {"role": "system", "content": prompt},
        {
            "role": "user",
            "content": [
                {
                    # 이미지 정보는 나중에 제공
                    "type": "image_url",
                    "image_url": {
                        "url": f"data:image/jpeg;base64,{image_base64}"
                    }
                }
            ]
        }
    ],
    temperature=0.0,
    max_tokens=300
)

result = response.choices[0].message.content

 

기존에는 위와 같이 텍스트 정보인 프롬프트를 먼저 제공하고, 이미지 정보는 나중에 제공하는 방식이었다.

 

# OpenAI API 호출
response = client.chat.completions.create(
    model="gpt-4o",
    messages=[
        {
            "role": "user",
            "content": [
                {
                    # 이미지 먼저 제공
                    "type": "image_url",
                    "image_url": {
                        "url": f"data:image/jpeg;base64,{image_base64}"
                    }
                }
            ]
        },
        # 이후에 텍스트인 프롬프트 제공
        {"role": "system", "content": prompt}
    ],
    temperature=0.0,
    max_tokens=300
)

result = response.choices[0].message.content

 

위의 코드는 이미지를 먼저 제공하고, 프롬프트를 나중에 제공하는 방식으로 변경한 코드이다. 단순히 순서만 바꿨다고 "성능이 얼마나 좋아질 수 있겠어?"라는 생각을 할 수 있다. 코드를 변경하고 난 뒤 테스트 결과는 상당히 달라졌다.

  • 정답 음식 데이터 개수: 338
  • 탐지된 음식 개수: 318
  • 탐지율(%): 94.08%
  • 정확도(%): 75.15%

탐지율과 정확도가 기존 대비 약 15~20% 상승했음을 확인할 수 있다. 단순히 순서를 바꾼게 이렇게 성능 향상을 할 수 있었지만, 개인적으로 실서비스를 운영하려면 정확도가 80% 이상은 나와야한다고 생각하기 때문에 추가적인 방법이 필요하다고 생각했다. 

 

Few-Shot + 인지단계 고려

정확도를 목표한 바까지 달성하기 위해 프롬프트 기법을 도입하였다. 앞에서 언급했듯이, 기존 프롬프트는 상황에 맞춰 답변을 이끌어내는 경향이 있어 특정 기법을 체계적으로 활용한 것은 아니었다.

당신의 임무는 주어진 음식의 이미지를 분석하고 해당 이미지에 어떤 음식들이 있는지 출력하는 일입니다.

# 작업
하나의 음식 이미지에 여러 개의 음식이 존재해도 각각의 음식을 잘 구분하고 음식명을 출력해주어야 합니다.
한식, 일식, 중식, 양식, 간식 등 다양한 종류의 음식이 주어질 수 있습니다.
이미지에 있는 음식의 재료를 기반으로 재료를 포함한 음식명을 출력해주세요.
예를 들어, 미역국에 재료로 소고기가 들어있다면 소고기 미역국, 새우가 들어있다면 새우 미역국과 같은 이름으로 출력해주세요.
다른 음식인 경우에도 마찬가지로 입력된 이미지가 김밥이고 오이가 많이 들어있다면 해당 김밥의 종류를 구분짓는 재료는 오이로, 오이 김밥과 같은 이름으로 출력해주세요.

 

이를 변경해서 처음엔 Zero-Shot과 Few-Shot 기법을 시도했다. 

  • Zero-Shot
    • “당신의 임무는 입력된 음식 이미지를 분석하고, 존재하는 음식을 파악하는 전문가입니다. 아래 단계에 따라 분석 후 결과를 출력하세요.”와 같은 간단한 지시 제공
    • 복합 음식이나 여러 재료가 섞여 있는 경우 제대로 구분하지 못함. 예를 들어 밥 + 카레, 포장지에 인쇄된 식품 등
    • 결과적으로 기존보다 성능이 하락(탐지율: 91.12%, 정확도: 61.24%)
  • Few-Shot
    • Zero-Shot의 지시사항에 더해 구체적인 예시 몇 건을 추가
    • 일부 개선은 있었으나, 여전히 복합 음식이나 변칙적인 상황에 답변이 미흡
    • 기대했던 수준의 정확도에 도달하지 못함

 

최종적으로 반영하게 된 방식은 인지단계를 고려한 프롬프트이다. 위에서 이미지를 먼저 제공하고 텍스트를 제공한 방식이 성능이 올랐던 이유가 결국 사람이 이해하는 방식과 동일시했기 때문이라는 것을 알았다. 그래서 "음식 개수 파악 재료 추론 조리 방식 고려 최종 음식명 선정"이라는 인지단계를 모델이 따르도록 더욱 구체화했다. 이와 함께 마크다운 형식을 활용해 단계별 지침을 명확히 전달했다.

너는 음식 이미지를 보고 이미지 내에 존재하는 음식을 식별하는 푸드 스캐너야.
입력된 음식 이미지를 바탕으로 이미지에 존재하는 모든 음식을 탐지해내고, 해당 음식의 음식명을 너는 출력할 수 있어
 
# 작업

## 1. 음식 탐지 및 구분
- 입력된 음식 이미지에서 **존재하는 음식 개수를 먼저 파악**합니다.
- 각 음식이 **개별적으로 존재**하는지, **같은 접시에 있는지**를 확인합니다.
    - 음식이 개별 접시에 담겨 있는 경우는 개별 음식으로 구분
    - 음식이 하나의 접시에 함께 배열되어 있는 경우는 하나의 음식으로 묶을지, 개별적으로 분리할지 판단
        - 같은 요리 과정에서 조리되었거나, 일반적으로 함께 제공되는 경우 하나로 묶음
            - 예: 떡 + 콩가루 = "콩가루 인절미"
            - 예: 밥 + 카레 = "카레라이스"
        - 독립적인 음식일 경우 개별적으로 분리
            - 예: 피자 + 김치 = 개별적인 음식으로 판단
            - 예: 떡볶이 + 초밥 = 개별적인 음식으로 판단 

## 2. 음식의 특성과 명칭 분석
- 각 음식의 **종류**(한식, 일식, 중식, 양식, 디저트 등)와 **조리 방식**(튀김, 찌개, 볶음, 찜 등)을 파악합니다.
- 각 음식의 **주요 재료**를 파악한 후, **재료를 반영한 정확한 음식명을 도출**해야 합니다.
    - 예: 미역국에 소고기가 들어있다면 "소고기 미역국"
    - 예: 김밥에 오이가 많이 포함되었다면 "오이 김밥"
    - 예: 샐러드에 닭가슴살이 추가되었다면 "닭가슴살 샐러드"

## 3. 포장된 음식도 포함
- 포장지에 인쇄된 음식 이미지나 음식이 담긴 용기(예: 김 포장지, 음료수 병, 와인 병, 떡 포장 등)도 음식 이미지로 간주합니다.
- 단순한 음식 사진이 있는 포장(예: 피자 광고가 있는 박스, 햄버거 포장지)은 음식이 아닙니다.

 

위 방식을 적용한 뒤 목표했던 정확도 수준을 달성할 수 있었고, 복합 음식이나 변칙적인 조합도 어느 정도 인식이 가능해졌다.

  • 정답 음식 데이터 개수: 338
  • 탐지된 음식 개수: 326
  • 탐지율(%): 96.75%
  • 정확도(%): 80.77%

 

임베딩 방식 변경

탐지율과 정확도는 어느 정도 목표치에 도달했지만 기존 유사도 성공률이 27.54%에 그쳐 전체 기능 완성도가 크게 떨어진 상황이었다. 즉,  음식 자체는 잘 탐지해도 데이터베이스 내 정확한 음식명을 찾는 유사도 검색 단계에서 실패하면 사용자 입장에선 기능을 온전히 활용할 수 없다는 것이다. 

 

이 문제를 해결하기 위한 즉, 유사도 성공률을 높인 방식은 간단한데 임베딩 방식 변경이다.

 

  • 기존 방식: OpenAI Embedding
    • 차원(Dimension): 1,536
    • 범용적으로 우수하지만, 영어권 텍스트 위주로 학습되어 한국어 특유의 문법과 형태소 구조를 충분히 반영하지 못할 가능성 존재
  • 변경 방식: Upstage Embedding
    • 차원(Dimension): 4,096
    • 한국어 특화 학습 데이터로 구축하여 짧고 복합적인 한국어 음식명을 더욱 세밀하게 표현 가능
    • OpenAI Embedding 대비 4배 가량 차원이 높아 풍부한 의미 표현 가능

Upstage Embedding 모델이 임베딩 차원이 높아졌다고 무조건 성능이 오르는 것은 아니지만, 차원이 클수록 모델이 더 세밀한 의미 구분을 할 가능성이 커 한국어처럼 어절 단위가 불규칙하거나 형태소 분석이 중요한 언어에서는 미세한 문맥차이를 더욱 표현할 수 있어 성능이 올라갔다고 생각한다.

 

이처럼 Upstage Embedding 모델을 채택한 결과 유사도 성공률은 72.49%으로 기존대비 약 50% 정도 향상했음을 확인했다. 다만,  DB 자체에 없는 음식명은 아무리 임베딩을 잘해도 매칭이 어려워 추후 데이터셋을 보강하는 작업이 필요하다는 것을 알게 되었다. 

 

최종 결과

위에서 언급한 이미지·텍스트 순서 변경, 프롬프트 최적화, 임베딩 변경을 종합적으로 적용한 뒤 얻은 테스트 결과는 다음과 같다.

  • 정답 음식 데이터 개수: 338
  • 탐지된 음식 개수: 326
  • 탐지율(%): 96.75%
  • 정확도(%): 80.77%
  • 유사도 성공률(%): 72.49%
  • 전체 기능 성공률(%): 62.13%

성능 최적화 전과 비교하면 전체 기능 성공률이 25.74%에서 62.13%로 약 36% 정도 상승하여 2.4배 이상 향상되었다. 물론 62.13%가 그리 높은 성공률은 아니라고 생각하지만, 기존 대비 사용자들이 체감하는 편의성은 크게 높아졌다고 생각한다. 

 

AI 영양사: 지연 시간 단축 & 신뢰도 확보

 

<AI Nutritionist API>

 

AI 영양사 기능은 사용자의 신체 정보와 식사 기록을 종합해 맞춤형 식습관 조언과 식단 분석을 제공하는 기능이다. 매주 월요일 0시에 일괄적으로 분석 결과를 제공해야 하므로 0시 이전까지 분석을 완료해 최대한 많은 사용자 데이터를 반영할 필요가 있는데, 분석 시간이 길어진다면 늦게 등록된 식단 정보는 반영하지 못하게 되기 때문에 지연 시간 단축은 서비스 품질에 큰 영향을 미친다.

 

이를 해결하기 위해 다양한 최적화 방법을 단계별로 적용했고 각각이 얼마나 분석 시간을 줄이는지를 실제 테스트롤 통해 확인했다. 테스트는 총 20명의 사용자 데이터를 기반으로 다음과 같은 시나리오를 반영해 진행했다. 

  • 10명: 1일 3식 → 6일간 18회 식사
  • 6명: 1일 2식 → 6일간 12회 식사
  • 4명: 1일 1식 → 6일간 6회 식사

또한, 기능 수행 흐름에 따라 전체 기능 수행 시간을 측정했다.

  1. 사용자 신체 정보 및 식사 기록을 DB에서 조회
  2. 건강한 사람들의 평균 영양 섭취 데이터를 담고 있는 CSV 파일 조회
  3. Langchain 기반으로 식습관 분석, 영양소 분석, 개선점 도출, 식단 추천, 평가 진행 등의 다중 체인(Multi-Chain) 작업 수행
  4. 전체 흐름을 통해 최종 API 응답 시간 측정

 

기존 로직(Before Optimization)

<Before Optimization>

 

위의 결과를 보면 데이터베이스 조회 와 CSV 파일 조회에 소요되는 시간은 미미하지만, LLM 호출 과정에서의 지연이이 전체 API 소요시간의 대부분을 차지하고 있는 것을 알 수 있다. 특히, 식습관 조언 Chain과 Multi-Chain을 순차적으로 수행하면서 OpenAI API를 여러 번 호출하게 되어, 최종적으로 456.8343초에 달하는 실행 시간이 발생했다.

 

이러한 문제를 바탕으로 성능 최적화를 위한 방향을 다음과 같이 설정했다.

  • 비동기 처리 도입(Async): 다수의 사용자르 동시에 분석하여 전체 소요 시간을 줄이자
  • 네트워크 최적화: OpenAI API 호출은 결국 네트워크 요청이므로 전송 데이터를 최소화하여 응답 속도를 개선하자
  • 프롬프트 I/O 최적화: Chain 수행 시 반복해서 사용되는 프롬프트를 캐싱 처리하여 매번 불러오는 발생하는 I/O 지연을 줄이자

 

비동기 처리 도입(Async)

<Async>

 

최종 실행시간을 가장 많이 줄이게 된 방식으로, 기존 로직에서는 모든 사용자 데이터를 순차적으로 처리했기 때문에 분석 대상이 많아질수록 전체 처리 시간이 기하급수적으로 늘어날 수 밖에 없는 구조이다. 이를 해결하기 위해 Python의 비동기 처리(Async/Await) 기법을 도입했다. 

 

비동기 처리란 무엇일까? 간단히 설명하자면 비동기 처리는 프로그램이 한 작업이 끝날 때까지 기다리지 않고 동시에 여러 작업을 병렬로 처리할 수 있도록하는 방식으로, 특히 네트워크 I/O와 같은 API 호출처럼 기다리는 시간이 많은 작업에 효과적이다. 

 

코드 상으로는 Async/Await를 적용해 식습관 조언 Chain과 Multi-Chain 호출을 비동기로 병렬 수행했다. 결과를 보면 단일 사용자에 대한 분석 시간은 기존과 큰 차이가 없지만, 최종 수행시간 59.3113초로 이전 대비 87.04% 개선했다. 

 

 

네트워크 최적화

<Network Optimization>

 

OpenAI API는 텍스트를 처리할 때 토큰(Token) 단위로 처리하기 때문에 토큰 수는 응답 속도와 처리 비용에 직접적인 영향을 미친다. AI 영양사 기능에서는 여러 Chain(Task)에서 프롬프트를 반복적으로 사용하기 때문에 이 토큰 수를 줄이는 것이 중요한 성능 개선 포인트라고 할 수 있다. 

 

이를 해결하기 위해 기존에 한글로 작성된 프롬프트를 영어로 변환했다. OpenAI의 토크나이저(Tokenizer)는 영어 기반으로 설계되어있어 영어 단어는 대부분 하나의 토큰으로 인식된다. 반면 한글은 음절 단위로 분리되기 때문에 한 문장도 여러 개의 토큰으로 쪼개져 동일한 의미를 전달하는 경우에도 한글이 훨씬 많은 토큰을 차지한다. 아래 예시를 보면 약 2배정도 차이가 있음을 확인할 수 있다. 

<Difference in token count between Korean and English>

 

또한, 프롬프트 내부에서 의미가 중복되거나 LLM의 응답 품질에 큰 영향을 주지 않는 불필요한 설명 문장들을 제거했고, 이 과정을 통해 프롬프트를 보다 간결하고 명확한 구조로 리팩토링하여 전체적인 토큰 수를 더욱 줄일 수 있었다.

 

추가적으로 "왜 토큰 수가 줄어든다면 응답 시간이 줄어들까?"라는 것에 대해 고민해봤는데, OpenAI API는 REST 기반의 네트워크 요청이기 때문에 네트워크 전송시 데이터 용량이 커지면 OpenAI 서버에서 LLM이 처리해야 할 입력 토큰이 많아지고 이에 따라 응답을 완료하기 까지 시간이 더 걸리기 때문이다. 따라서 프롬프트 최적화, 즉 네트워크 최적화가 응답 속도 최적화라고 할 수 있다.

이러한 최적화를 적용한 결과, 최종 수행시간 46.9883초가 걸렸다. 

 

프롬프트 캐싱

<Prompt Caching>

 

마지막으로 적용한 성능 최적화 전략은 프롬프트 I/O 지연을 줄이기 위한 캐싱 도입이다. 각 Chain을 수행하기 위해서는 Chain 당 프롬프트를 매번 조회해야하기 때문에 불필요한 I/O 작업 지연 시간이 발생했다.

처음에는 푸드렌즈 API에서 횟수 제한을 위해서 사용한 Redis를 활용해 프롬프트를 캐싱하려 했지만, 프롬프트가 5개가 존재하는 상황에서 오히려 Redis를 사용하는 것(Redis 접근 자체의 네트워크 오버헤드)이 시간이 더 걸렸다. 이에 따라 프롬프트를 서버 메모리 내에 직접 로드(In-Memory 캐싱)하여 실행 중 재사용 하도록 구조를 변경했다. In-Memory 캐싱 적용 이후 최종 수행시간 38.7628초로 단축되었다. 

 

최종 결과

앞서 언급한 3가지의 최적화 전략(비동기 처리, 네트워크 최적화, 프롬프트 캐싱)에 이어 마지막으로 식습관 조언 Chain과 Multi-Chain을 병렬 수행하는 구조를 도입함으로써 최종 API 실행 시간은 35.9987초로 단축되었다. 이는 기존의 456.8434초에서 약 92.1%의 성능 향상을 이룬 것이다.

 

기존에는 100명의 사용자 데이터를 분석하는 데 약 38분 이상이 소요될 것으로 예상되어 안정적인 분석을 위해 전날 밤 23시 10분에 분석을 시작하도록 설정했다.(20명 기준 456초는 약 7분 36초에 해당) 하지만 최적화 이후, 100명 기준 약 3분(20명 기준 약 36초)이면 전체 분석을 마칠 수 있게 되어 분석 시작 시점을 23시 50분으로 늦출 수 있게 되었다. 이를 통해, 사용자의 최신 식사 기록까지 반영 가능한 시간대를 확장했고 분석의 정확성을 확보하는 데 큰 도움이 되었다.