grep

iOS

복잡한 검색 홈, 구조는 유연하게 화면은 부드럽게 개선하기

Danny Sung여기어때

2025년 12월 22일

원문에서 보기 ↗

안녕하세요! iOS 개발팀 대니입니다.

여기어때 앱에서 검색 화면은 사용자 여정이 시작되는 곳입니다. 국내외 숙소부터 항공, 레저티켓까지 다양한 도메인을 한곳에서 탐색할 수 있어야 하죠.

하지만 기능이 고도화될수록 코드는 복잡해졌고, 도메인별로 상이한 추천 로직과 UX가 얽히면서 작은 수정에도 예상치 못한 사이드 이펙트가 발생하곤 했습니다. 결국 비대해진 단일 모듈 구조는 빌드 속도 저하와 유지보수의 어려움으로 이어졌습니다.

이번 글에서는 이러한 문제를 해결하기 위해 검색홈과 검색 결과 화면을 모듈 단위로 분리하고, SwiftUI로 전환하며 적용한 탭 구조 개선 과정을 공유하려 합니다. 더불어 탭 이동 시 중간 페이지를 건너뛰는 부드러운 화면 전환을 구현하여 사용자 경험을 한층 더 개선한 경험도 함께 소개하겠습니다.

검색 홈의 요구사항

본격적인 구조 설명에 앞서, 검색 홈이 어떤 요구사항을 가지고 있는지 먼저 살펴보겠습니다.

4개의 탭

각 탭마다 서로 다른 도메인 모델과 UX를 가지고 있어 자동완성 API, 추천 검색어, 캘린더 정보, 인원/객실 정보 등이 탭별로 다르게 동작합니다.

  1. 국내숙소
    • 지역∙명소, 지하철역, 숙소 검색
    • 일정 및 인원 선택
    • 최근 검색 기록
    • 여기어때 검색 순위
  2. 해외숙소
    • 지역∙명소, 지하철역, 숙소 검색
    • 일정 및 인원, 객실 선택
    • 최근 검색 기록
  3. 항공
    • 왕복∙편도 선택
    • 출도착지 선택 및 스왑
    • 일정 및 인원, 좌석 선택
    • 직항 여부 선택
    • 최근 검색 기록
  4. 레저티켓
    • 국내∙해외 지역, 액티비티, 티켓 검색
    • 최근 검색 기록
    • 추천 검색어
    • 추천 지역

공통적으로 필요한 기능

이러한 요구사항을 효과적으로 수용하기 위해, 검색 홈을 하나의 피처 모듈로 분리하고 내부 구조를 체계적으로 나누었습니다.

검색 모듈 분리

GCSearch → GCSearchHome, GCSearchResult

구조 개선의 첫 번째 단계는 모듈 분리였습니다.

기존에는 검색 홈과 검색 결과 화면이 GCSearch라는 하나의 모듈에 함께 들어있었습니다. 처음에는 검색이라는 같은 도메인이니까 한 곳에 두는 게 자연스러워 보였지만, 시간이 지나면서 여러 문제가 드러났습니다.

기존 구조의 문제점

GCSearch/
├── Home/ # 검색 홈 (탭, 추천, 최근 검색)
├── Result/ # 검색 결과 (필터, 정렬, 리스트)
├── Shared/ # 공용 컴포넌트
└── …

GCSearch 모듈을 GCSearchHome, GCSearchResult으로 분리하여 구조 개선

이렇게 모듈을 분리한 뒤, 검색 홈 내부 구조를 본격적으로 재설계했습니다.

검색 홈 모듈 구조 정의

GCSearchHome 모듈 내부는 크게 세 가지 레이어로 구성했습니다.

컨테이너 레벨

**“**현재 어떤 탭인지”, “페이지 인덱스는 몇 번인지” 정도의 최소한의 상태만 관리하고, 실제 콘텐츠는 각 탭별 뷰가 독립적으로 처리합니다.

공용 컴포넌트 레벨

어떤 탭에서든 재사용할 수 있는 공용 UI 컴포넌트

탭(도메인) 레벨

도메인별로 완전히 다른 UX와 비즈니스 로직을 담은 화면

이렇게 구조를 나누면 다음과 같은 이점이 있습니다

탭별 상태 분리와 프로토콜 활용

검색 홈에는 4개의 탭(국내숙소, 해외숙소, 항공, 레저티켓)이 있고, 각 탭마다 검색 조건과 히스토리 구조가 다릅니다. 하지만 상위 레벨에서는 “현재 선택된 탭”이나 “탭 전환” 같은 공통 동작을 일관되게 처리해야 했습니다.

이를 위해 TabInfoAppModel이라는 공통 프로토콜을 정의하고, 각 탭의 상태 모델이 이를 채택하도록 설계했습니다.

여기서 AppModel이란, 검색 결과, 추천 데이터, 검색 기록 등 다양한 원천 데이터를 기반으로 실제 화면 렌더링에 필요한 UI 모델과 사용자 로그 수집을 위한 트래킹 모델을 하나로 통합하여, View가 바로 사용할 수 있도록 최적화한 객체 입니다.

struct State {
 var selectedTabIndex: Int
 
 var domesticTabInfo: DomesticTabInfoAppModel
 var overseasTabInfo: OverseasTabInfoAppModel
 var airLineTabInfo: AirLineTabInfoAppModel
 var ticketTabInfo: TicketTabInfoAppModel
 
 // 전체 탭 AppModels를 프로토콜로 추상화
 var tabInfos: [any TabInfoAppModel] {
   [domesticTabInfo, overseasTabInfo, airLineTabInfo, ticketTabInfo]
 }
...
}

이렇게 데이터를 추상화해두면, 뷰 계층에서 실제 화면을 그릴 때도 일관된 방식으로 처리할 수 있습니다. SwiftUI View에서는 이 tabInfos 배열을 순회하면서, 각 모델의 실제 타입에 맞는 뷰를 생성하여 반환합니다.

private func tabPages(from tabInfos: [any TabInfoAppModel]) -> [AnyView] {
    tabInfos.enumerated().compactMap { index, tabInfo in
        let shouldFocus = shouldFocusTabIndex == index // InputBox 포커싱 여부
        
        switch tabInfo {
        case let info as DomesticTabInfoAppModel:
            return AnyView(
                domesticTabView(
                    tabInfo: info,
                    shouldFocus: shouldFocus
                )
            )
        case let info as OverseasTabInfoAppModel:
            return AnyView(
                overseasTabView(
                    tabInfo: info,
                    shouldFocus: shouldFocus
                )
            )
        case let info as AirLineTabInfoAppModel:
            return AnyView(
                airLineTabView(
                    tabInfo: info
                )
            )
        case let info as TicketTabInfoAppModel:
            return AnyView(
                ticketTabView(
                    tabInfo: info,
                    shouldFocus: shouldFocus
                )
            )
        default:
            return nil
        }
    }
}

이렇게 만들어진 뷰 배열은 뒤에서 설명할 커스텀 전환 뷰인 PageTransitionView에 주입됩니다.

PageTransitionView(
    pages: tabPages(from: observedReducer.observableState.tabInfos),
    currentIndex: currentIndex,
    targetIndex: targetIndex,
    slideAnimationDuration: slideAnimationDuration,
    fadeAnimationDuration: fadeAnimationDuration
)

PageTransitionView의 구체적인 구현 방식은 아래에서 자세히 다루겠습니다.

이 방식을 통해 개별 탭은 각자의 도메인 로직과 UI를 자유롭게 구현하면서도, 상위 컨테이너는 구체적인 구현 내용을 알 필요 없이 탭 목록을 받아 화면을 구성할 수 있게 됩니다.

도메인별 데이터 변환 로직의 분리

각 탭은 서로 다른 도메인 정책과 UI 구성을 가지고 있습니다.

특히 서버 API 응답이나 로컬 DB 등 원천 데이터의 형태가 도메인마다 제각각이고 복잡했기 때문에, 이 모든 변환 로직을 Reducer 안에서 처리할 경우 코드가 비대해지고 유지보수가 어려워질 위험이 있었습니다.

이를 해결하기 위해 각 도메인별로 Converter를 분리하여 데이터 변환 책임을 위임했습니다. Converter는 API 응답이나 로컬 DB 데이터를 AppModel로 변환하는 역할을 담당합니다.

let domesticConverter = DomesticSearchConverter()
let overseasConverter = OverseasSearchConverter()
let airLineConverter = AirLineSearchConverter()
let ticketConverter = TicketSearchConverter()
init(...) {
  ...
  // 국내숙소
  let domesticTabInfo = domesticConverter.convertToTabInfoAppModel(
    from: initDataSearchHomeInfo,
    calendarData: calendarData,
    historyInfos: domesticHistoryInfos
   )
  
  // 해외숙소
  let overseasTabInfo = overseasConverter.convertToTabInfoAppModel(
    from: initDataSearchHomeInfo,
    dtsInfo: overseasDTSInfo,
    historyInfos: overseasHistoryInfos
   )

   // 항공
   let airLineTabInfo = airLineConverter.convertToTabInfoAppModel(
     from: airLineSearchData,
     searchHomeInfo: initDataSearchHomeInfo,
     histories: airLineHistories,
     gcDateManager: gcDateManager
   )

    // 레저티켓
   let ticketTabInfo = ticketConverter.convertToTabInfoAppModel(
     from: initDataSearchHomeInfo,
     calendarData: calendarData,
     historyInfos: ticketHistoryInfos
   )

   self.initialState = State(
     selectedTabIndex: selectedTab.tabIndex,
     domesticTabInfo: domesticTabInfo,
     overseasTabInfo: overseasTabInfo,
     airLineTabInfo: airLineTabInfo,
     ticketTabInfo: ticketTabInfo,
     initDataSearchHomeInfo: initDataSearchHomeInfo
   )
}

Reducer 내부에서도 네트워크 응답을 뷰 모델로 변환할 때 Converter를 활용합니다.

case let .setDomesticKeywordSearchResults(searchText, searchResults):
    let searchResultKeywords = domesticConverter.convertToSearchKeywordAppModels(
        from: searchResults,
        searchText: searchText
    )
    newState.domesticTabInfo.searchResultKeywords = searchResultKeywords
    newState.domesticTabInfo.searchText = searchText

Converter로 변환 로직 분리의 이점

단방향 데이터 흐름 유지

UIKit + ReactorKit 기반의 기존 코드를 SwiftUI로 리팩토링하면서, 단방향 데이터 흐름은 그대로 유지하고자 했습니다.

이렇게 하면 상태 변경의 흐름을 한눈에 파악할 수 있고, 디버깅 시에도 “어디서 상태가 바뀌었는지”를 추적하기가 훨씬 수월해집니다.

중간 페이지를 건너뛰는 페이지 전환 뷰 만들기

iOS에서 페이지 전환 애니메이션을 구현할 때, 보통은 TabView에 PageTabViewStyle을 적용해 많이 사용합니다.

하지만 이 방식에는 한 가지 아쉬운 점이 있습니다. 바로 여러 페이지를 한 번에 건너뛸 때 중간 페이지들이 화면에 노출된다는 것입니다.

기본 TabView는 스크롤 기반으로 동작하기 때문에, 1페이지에서 3페이지로 이동하려면 반드시 그 사이에 있는 2페이지를 거쳐가야 합니다. 결과적으로 사용자는 의도치 않게 중간 페이지가 빠르게 스쳐 지나가는 현상을 보게 됩니다.

중간 페이지가 빠르게 스쳐 지나가는 기존 방식

이러한 어색함을 해결하기 위해 탭 이동 시에는 다음과 같은 새로운 동작 방식이 필요했습니다.

1페이지 -> 3페이지로 이동할 때 2페이지를 거쳐 가는 게 아니라

마치 1페이지 바로 옆에 3페이지가 있는 것처럼 한 번에 슬라이드되면 좋겠다.

이 글에서는 이런 요구사항을 만족시키기 위해 구현한 PageTransitionView를 소개합니다.

중간 페이지 없이 목표 화면으로 바로 연결되는 개선된 방식

어떤 기능이 필요한가?

정리해보면, 다음과 같은 조건을 만족해야 합니다.

전체 구조 간단히 보기

PageTransitionView는 제네릭 View로 되어 있고, 내부에서는 다음 값들을 관리합니다.

애니메이션 관련 상태값

콜백

레이아웃의 핵심 아이디어는 간단합니다.

모든 페이지를 ZStack으로 겹쳐놓고 각 페이지마다 offset과 opacity만 바꿔가며 마치 두 페이지만 존재하는 것처럼 보이게 전환을 만드는 방식입니다.

struct PageTransitionView<Page: View>: View {
    @State private var isAnimating = false
    @State private var transitionOffset: CGFloat = 0
    @State private var fadeProgress: CGFloat = 0

    private let pages: [Page]
    private var currentIndex: Int
    private var targetIndex: Int
    
    private let slideAnimationDuration: Double
    private let fadeAnimationDuration: Double

    private var onChangeAnimating: ((Bool) -> Void)?
    private var onChangePage: ((Int) -> Void)?

    init(
        pages: [Page],
        currentIndex: Int,
        targetIndex: Int,
        slideAnimationDuration: Double,
        fadeAnimationDuration: Double
    ) {
        self.pages = pages
        self.currentIndex = currentIndex
        self.targetIndex = targetIndex
        self.slideAnimationDuration = slideAnimationDuration
        self.fadeAnimationDuration = fadeAnimationDuration
    }

    var body: some View {
        GeometryReader { geo in
            ZStack {
                ForEach(Array(pages.enumerated()), id: \.offset) { index, page in
                    page
                     // 각 페이지를 화면 전체 크기로 설정
                    .frame(width: geo.size.width, height: geo.size.height)
                    // 계산된 x 위치로 페이지 이동 (슬라이드 효과)
                    .offset(x: computeX(for: index, width: geo.size.width))
                    // 계산된 투명도 적용 (페이드 효과)
                    .opacity(computeOpacity(for: index))
                    // 애니메이션 중에는 터치 비활성화
                    .allowsHitTesting(!isAnimating)
                }
            }
            // ZStack 밖으로 벗어나는 부분은 잘라냄 (오프스크린 페이지가 보이지 않도록)
            .clipped()
            // targetIndex가 변경되면 페이지 전환 애니메이션 시작
            .onChange(of: targetIndex) { newValue in
                // 유효성 검사: 현재 인덱스와 다르고, 범위 내에 있는 경우만 실행
                guard newValue != currentIndex,
                      newValue >= 0,
                      newValue < pages.count else { return }
                // 새 페이지로 전환 애니메이션 실행
                animate(to: newValue, width: geo.size.width)
            }
            // 애니메이션 상태가 변경되면 외부에 알림
            .onChange(of: isAnimating) { isAnimating in
                onChangeAnimating?(isAnimating)
            }
            // 뷰가 처음 나타날 때 상태 초기화
            .onAppear {
                reset()
            }
        }
    }

요약하면

위치 계산: 중간 페이지는 끝까지 오프스크린

computeX(for:width:)는 각 페이지가 화면에서 어디에 위치해야 하는지를 계산하는 부분입니다.

// 각 페이지의 x 위치 계산 로직
private func computeX(for index: Int, width: CGFloat) -> CGFloat {
    if !isAnimating {
        // 정적 상태: 오직 현재 페이지만 화면에 (다른 페이지는 보이지 않게 오프스크린 처리)
        return (index == currentIndex) ? 0 : width * 2
    } else {
        // 전환 중: 현재와 목표 페이지만 나란히 배치하고 공백이 보이지 않도록 transitionOffset으로 이동
        let direction: CGFloat = (targetIndex > currentIndex) ? 1 : -1
        if index == currentIndex {
            return 0 + transitionOffset
        } else if index == targetIndex {
            return width * direction + transitionOffset
        } else {
            // 화면 바깥으로 밀어내되 뷰는 여전히 존재 (상태 유지)
            return width * 2
        }
    }
}

애니메이션 중이 아닐 때

애니메이션 중일 때

이렇게 하면 1 -> 3으로 이동할 때도 실제로 슬라이드 애니메이션 되는건 1, 3 두 페이지뿐이라, 2 페이지는 화면에 보여지지 않습니다.

투명도 계산: 자연스럽게 사라지는 느낌

슬라이드만 있으면 전환이 조금 딱딱하게 느껴질 수 있기때문에 간단한 페이드 효과를 주어 자연스럽게 페이지 전환을 할 수 있습니다.

// 각 페이지의 fade를 위한 투명도 계산
private func computeOpacity(for index: Int) -> Double {
    if !isAnimating {
        return index == currentIndex ? 1 : 0
    } else {
        let progress = min(max(Double(fadeProgress), 0), 1)
        
        if index == currentIndex {
            return 1 - progress
        } else if index == targetIndex {
            // 다음 페이지가 fade-in 없이 바로 나타나도록 opacity를 1로 고정
            return 1
        } else {
            return 0
        }
    }
}

fadeProgress를 0 -> 1로 애니메이션 시키고 computeOpacity(for:)에서 이 값을 참고해 투명도를 계산합니다.

동작 방식은 다음과 같습니다.

애니메이션 전

애니메이션 중

즉, 기존 화면은 살짝 페이드 아웃되지만, 새 화면은 딜레이 없이 바로 보여주도록 설계했습니다.

필요하다면 목표 페이지도 천천히 페이드 인되게 바꿀 수 있습니다.

예를 들어, 새 페이지의 opacity도 progress값을 사용해 조절해주면 됩니다.

페이지 전환 애니메이션 트리거

전환은 targetIndex가 바뀌는 순간 시작됩니다.

// 페이지 전환
private func animate(to newIndex: Int, width: CGFloat) {
    isAnimating = true
    reset()
    
    let direction: CGFloat = (newIndex > currentIndex) ? 1 : -1
    // 화면 전환 Fade 애니메이션용
    withAnimation(.easeOut(duration: fadeAnimationDuration)) {
        fadeProgress = 1
    }
    
    // 스크롤 애니메이션: transitionOffset 을 0 -> -direction * width 로 애니메이션
    withAnimation(.easeOut(duration: slideAnimationDuration)) {
        transitionOffset = -direction * width
    }

    DispatchQueue.main.asyncAfter(deadline: .now() + slideAnimationDuration + 0.1) {
        reset()
        isAnimating = false
        onChangePage?(newIndex)
    }
}

마지막에 0.1초 정도의 딜레이를 두어 애니메이션 직후에 서치바 포커스 같은 동작이 들어올 때 전환이 끊기는 느낌을 완화할 수 있습니다.

외부에서 PageTransitionView 사용 방법

PageTransitionView 는 여러 페이지 뷰를 배열로 넘기고 currentIndex, targetIndex 를 바깥에서 관리하는 방식으로 사용할 수 있습니다.

이렇게 하면 SwiftUI의 선언형 패턴을 유지하면서도 UIKit에서 커스텀 전환을 직접 만들던 것과 비슷한 수준으로 페이지 전환을 제어할 수 있습니다.

요약하면, PageTransitionView는

위와 같은 요구사항이 있다면, PageTransitionView 을 응용해서 다양한 전환 효과를 구현해 볼 수 있습니다.

마치며

검색 화면은 사용자 여정의 시작점이자, 여러 도메인이 복잡하게 얽힌 영역입니다. 기능이 확장될수록 구조가 쉽게 비대해지고, 작은 변경도 큰 영향으로 이어질 수 있습니다.

이번 개선에서는 역할을 기준으로 모듈을 분리하고, 검색 홈 내부 구조를 도메인 단위로 재정리해 각 탭이 독립적으로 변경·확장될 수 있도록 했습니다. 또한 Converter를 통해 데이터 변환 책임을 분리하고, SwiftUI 환경에서도 단방향 데이터 흐름을 유지해 구조의 안정성을 높였습니다.

UX 측면에서는 중간 페이지를 건너뛰는 전환 방식으로 사용자에게 더 자연스러운 탐색 경험을 줄 수 있었습니다.

앞으로도 검색 경험을 더 단순하고, 빠르고, 안정적으로 만들기 위한 개선은 계속될 예정입니다

긴 글 읽어주셔서 감사합니다.