iOS에서 API 응답 속도를 71% 개선한 방법

cheshire0105·2025년 12월 29일

iOS

목록 보기
44/45
post-thumbnail

"10초 걸리던 약품 검색을 3초로 줄이기까지의 여정"

오늘은 개발 중인 EGoYak(이고약) 앱에서 미국 의약품 데이터를 가져오는 DailyMed API의 성능을 개선한 경험을 공유한다.

프로젝트 소개

EGoYak은 한국과 미국의 의약품 정보를 검색하고 비교할 수 있는 iOS 앱이다. 사용자가 한국 약품을 검색하면, 해당 성분을 분석하여 미국에서 판매되는 유사한 약품을 찾아주는 기능이 핵심이다.

문제의 시작

초기 버전의 검색 프로세스는 매우 복잡하고 순차적이었다.

  1. 한국 데이터베이스에서 약품 검색
  2. 성분명을 영어로 번역
  3. DailyMed API에 검색 요청
  4. 받아온 XML 데이터를 순차적으로 파싱
  5. 각 약품의 상세 정보를 추가 요청
  6. 이미지 URL 확보를 위해 HTML 페이지 추가 파싱

결과: 약품 10개 검색 시 평균 10~15초 소요

사용자 입장에서는 답답함을 느낄 수밖에 없는 느린 속도였다.

문제 분석: 왜 이렇게 느릴까?

성능 병목 지점을 찾기 위해 각 단계별 소요 시간을 측정했다.

let start = Date()
// ... 작업 수행 ...
let duration = Date().timeIntervalSince(start)
print("소요 시간: \(String(format: "%.2f", duration))초")

발견한 주요 병목 현상

  • 순차 처리의 한계: 10개의 XML을 하나씩 처리하며 총 10초 이상 소요된다.
  • 중복된 네트워크 요청: 동일 검색어에 대해 매번 API 호출 및 HTML 파싱이 발생한다.
  • 비효율적인 이미지 URL 파싱: 50KB의 HTML 전체를 다운로드한 후 275줄의 복잡한 정규식으로 처리한다.
  • 과도한 번역 API 호출: 모든 성분명을 번역한다(실제로는 주성분만 필요하다).

해결 방법 1: 병렬 처리로 속도 UP!

기존의 for 루프 방식은 앞선 작업이 끝나야 다음 작업이 시작되는 구조였다. 이를 Swift ConcurrencywithTaskGroup을 사용하여 개선했다.

개선된 코드: TaskGroup 활용

let maxConcurrency = 4 // 동시 처리 제한
var iter = spls.makeIterator()
var medicines: [USMedicine] = []

await withTaskGroup(of: USMedicine?.self) { group in
    // 초기 Task 시작
    for _ in 0..<min(maxConcurrency, spls.count) {
        if let spl = iter.next() {
            group.addTask { await self.convertSPLToUSMedicineWithDetails(spl: spl) }
        }
    }
    
    // 파이프라인 방식으로 하나 완료 시 다음 작업 추가
    while let result = await group.next() {
        if let medicine = result { medicines.append(medicine) }
        if let spl = iter.next() {
            group.addTask { await self.convertSPLToUSMedicineWithDetails(spl: spl) }
        }
    }
}
  • 성과: 10개 처리 시간 10초 → 2초 (약 80% 감소)

해결 방법 2: 캐싱으로 중복 제거!

사용자가 동일한 약품을 다시 검색할 때 즉각적인 반응을 주기 위해 3단계 캐싱 전략을 세웠다.

2-1. 이미지 URL 및 상세 정보 캐싱

기존 275줄의 HTML 파싱 로직을 XML 내 파일명 활용 및 메모리 캐싱으로 대체하여 29줄로 줄였다.

2-2. SwiftData를 통한 영구 캐싱

앱 종료 후에도 데이터가 유지되도록 SwiftData를 도입했다. 의약품 데이터가 동적으로 변하는 타입은 아니기 때문에 영구 캐싱을 적용하는 데 무리가 없었다.

@Model
class CachedMedicineResults {
    var koreanDrugName: String
    var searchResults: [USMedicine]
    var cachedDate: Date
    // ... 초기화 로직
}
  • 성과: 재검색 속도 10초 → 0.1초 (100배 향상)

해결 방법 3: 스마트한 XML 파싱

DailyMed의 XML은 매우 방대하다. 기존의 전체 파싱 방식 대신, 필요한 섹션 코드만 타겟팅하는 FastSPLXMLParser를 직접 구현했다.

  • 대상 섹션: Purpose(55105-1), Indications(34067-9), Active Ingredient(55106-9)
  • 성과: 파싱 속도 1초 → 0.3초 (70% 향상), 메모리 사용량 50% 감소.

해결 방법 4: 사용자 경험(UX) 개선

기술적 최적화 외에도 사용자가 '느끼는' 속도를 개선했다.

  • 검색 디바운싱: 타이핑 중 발생하는 불필요한 API 호출을 방지한다(500ms 대기).
  • 백그라운드 이미지 검증: 이미지 유효성 확인 전 리스트를 먼저 보여주고, 검증은 백그라운드에서 수행한다.
  • 번역 최적화: 모든 성분이 아닌 첫 번째 주성분만 번역하여 API 호출을 최소화한다.

최종 성과 지표

항목개선 전개선 후향상률
초기 검색 속도10~15초2~3초80% 감소
재검색 (캐시)10~15초0.1초99% 감소
XML 파싱1초 / 개0.3초 / 개70% 감소
이미지 URL 파싱0.5초0.001초99.8% 감소
파싱 코드 라인 수275줄29줄90% 슬림화

배운 점

  1. 병렬 처리의 적절한 조율: 무조건 많은 Task가 좋은 것은 아니다. 서버 부하와 기기 성능의 균형(4~6개)을 찾는 것이 중요했다.
  2. 측정의 중요성: ContinuousClock 등을 이용해 정밀하게 측정하지 않았다면 어디가 병목인지 정확히 알 수 없었을 것이다.
  3. 체감 속도의 가치: 실제 데이터 로딩에 시간이 다소 소요되더라도 즉각적인 UI 피드백과 캐싱을 통해 사용자 만족도를 극대화할 수 있었다.

앞으로의 개선 방향

  • Prefetching: 사용자의 스크롤 위치에 따라 다음 데이터를 미리 로드한다.
  • GraphQL 검토: 필요한 필드만 요청하여 네트워크 트래픽을 최적화한다.
  • 이미지 최적화: WebP 포맷 사용 및 썸네일 크기를 최적화한다.

마치며
사용자에게 10초는 영겁의 시간과 같다. 이번 최적화를 통해 기술적 성장뿐만 아니라 '사용자 중심'의 사고가 개발에 얼마나 큰 영향을 미치는지 다시 한번 느꼈다.

참고 자료

0개의 댓글