[리팩토링] 05. Swift Concurrency로 메모리 누수와 Callback 지옥을 해결해보자 (온보딩 리팩토링).

2026. 1. 19. 23:39·탭탭 - TapTap/리팩토링
728x90

탭탭의 온보딩은 사용 흐름을 따라가며 기능을 익히도록 설계된 인터렉션 중심의 화면입니다. 이를 표현하기 위해 다양한 애니메이션과 뷰 전환, 복잡한 상태관리가 필요했습니다. 이처럼 구조가 점점 복잡해지는 온보딩 화면을 구현하면서 몇 가지 개선할 수 있는 지점들을 발견했고, 리팩토링을 진행하게 되었습니다. 이번 글에서는 온보딩 리팩토링에 관한 내용을 정리해보려고 합니다.

기존 코드의 문제점

private func highlightAnimation() {
    showSecondHighlightTip = true
    
    DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
      showSecondHighlightTip = false
      showOneFinger = true
      
      DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
        withAnimation(.easeInOut(duration: 0.8)) {
          highlightProgress = 0.0
        }
        
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
          fingerImageName = "OneFinger"
          
          DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
            showOneFinger = false
            showThirdHighlightTip = true
            
            DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
              showThirdHighlightTip = false
            }
          }
        }
      }
    }
  }

개발 초기에는 인터랙션을 구현하기 위해 asyncAfter를 중첩하는 방식으로 이벤트를 처리했습니다. 하지만 기존 코드에는 몇 가지 명확한 문제점이 있었습니다.

 

첫 번째는 구조적인 문제였습니다. asyncAfter가 여러 단계로 중첩되면서 Callback 지옥이 되었고, 후에 이벤트를 수정할 때 전체 흐름을 파악하기가 어렵다는 문제가 있습니다.

 

두 번째는 메모리 문제입니다. asyncAfter는 단순히 클로저를 큐에 등록하는 방식이기 때문에, 클로저 내부에서 self를 강하게 참조하지 않는 한 즉각적인 메모리 누수가 발생하지 않습니다. 또한 SwiftUI의 @State나 @Published와 같은 프로퍼티를 변경하는 클로저 역시 강한 참조 사이클을 만들지 않습니다...! 만! View가 이미 사라진 이후에도 예약된 클로저가 실행될 수 있는 여지가 있었고, 이로 인해 불필요한 상태 변경이나 성능 저하가 발생할 가능성이 있다고 판단했습니다. 

 

 

순차적으로 진행되지 않는 모습

세 번째는 애니메이션 흐름입니다. 온보딩 애니메이션은 단계별로 순차 진행되어야 하는데, 상태가 명확하게 분리되어 있지 않다 보니 특정 단계에서만 동작해야 할 인터랙션이 예상 보다 먼저 작동하는 문제가 발생했습니다. 예를 들어 더블 탭 제스처 가이드가 아직 안내되지 않은 시점임에도 불구하고 먼저 동작하는 등, 애니메이션의 단계가 명확하게 구분되지 않는 문제가 있습니다.

 

어떻게 리팩토링 했나요?

case .onAppear:
  guard state.animationPhase == .onAppear else {
    return .none
  }
        
  state.animationPhase = .doubleTapGuideEvent
        
  return .run { send in
    try await clock.sleep(for: .seconds(2))
      await send(.doubleTapGuideEvent)
   }
   .cancellable(id: CancelID.onboarding)
    
case .doubleTapDragEvent:
  guard state.animationPhase == .doubleTapDragEvent else {
    return .none
  }
        
  state.visibleOverlay = false
  state.visibleSecondTip = false
  state.visibleOverlay = false
        
  state.visibleDrag = true
  state.visibleDragFirstTip = true
  state.visibleToolTip = true
        
  state.animationPhase = .dragAnimationEvent
        
  return .run { send in
    try await clock.sleep(for: .seconds(2.3))
      await send(.changeDragSecondTip)
      
    try await clock.sleep(for: .seconds(2.0))
      await send(.showDragHand)
      
    try await clock.sleep(for: .seconds(1.5))
        await send(.dragAnimationEvent)
   }
   .cancellable(id: CancelID.dragAnimation, cancelInFlight: true)

DispatchQueue.asyncAfter로 인한 메모리 이슈와 Callback 지옥 문제를 해결하기 위해, asyncAfter 기반 구현을 제거하고, TCA에서 제공하는 continuousClock와 Swift Concurrency를 활용해 리팩토링 했습니다. continuouseClock은 내부적으로 컨커런시의 Task를 사용하기 때문에, 뷰가 사라질 경우 작업을 명확히 취소할 수 있었고, 시간에 의존하는 온보딩 애니메이션을 보다 선언적으로 표현할 수 있게 되었습니다.

 

enum AnimationPhase {
  case onAppear
  case doubleTapGuideEvent
  case doubleTapDragEvent
  case dragAnimationEvent
  case selectColorGuideEvent
  case tapColorEvent
  case tapHighlightEvent
  case memoEvent
  case finisheEvent
 }

애니메이션 흐름을 명확하게 관리하기 위해, 온보딩 페이즈를 enum으로 정의했습니다.

case .doubleTapGuideEvent:
  guard state.animationPhase == .doubleTapGuideEvent else {
    return .none
  }

각 애니메이션과 인터랙션은 특정 단계에서만 동작하도록 제한했고, 액션 처리 시 guard문을 통해 현재 상태의 애니메이션 단계와 일치하는 경우에만 로직이 실행되도록 리팩토링했습니다. 

'탭탭 - TapTap > 리팩토링' 카테고리의 다른 글

[리팩토링] 03. 구조개선 - App진입점을 TCA로 컨버팅  (1) 2026.01.15
[리팩토링] 01. TapTap 리팩토링을 시작하다. - 도식화 및 문서화  (1) 2026.01.05
'탭탭 - TapTap/리팩토링' 카테고리의 다른 글
  • [리팩토링] 03. 구조개선 - App진입점을 TCA로 컨버팅
  • [리팩토링] 01. TapTap 리팩토링을 시작하다. - 도식화 및 문서화
여성일
여성일
  • 여성일
    성일노트
    여성일
  • 전체
    오늘
    어제
    • 분류 전체보기 N
      • 탭탭 - TapTap N
        • 리팩토링 N
        • 트러블슈팅
        • 개발일지
      • 애플 디벨로퍼 아카데미
        • 챌린지 회고
        • 하루의 날씨
      • Swift Student Challenge 202..
      • AI를 잘쓰는 개발자가 될래요
      • 우리 같이 협업하자
      • ToyProject - 사카마카 (살까말까 고민 ..
      • ToyProject - Book2OnNon (모바..
      • ToyProject - 바꿔조 (환율 계산기)
      • iOS N
        • iOS N
        • Vapor
        • Design Pattern
        • CoreData
        • Tuist
        • RxSwift
        • ReactorKit
        • TCA N
      • Swift
        • Swift 기본기
        • UIkit
        • SwiftUI
      • 원티드 프리온보딩 챌린지 iOS 과정
  • 블로그 메뉴

    • 링크

    • 공지사항

    • 인기 글

    • 태그

    • 최근 댓글

    • 최근 글

    • hELLO· Designed By정상우.v4.10.6
    여성일
    [리팩토링] 05. Swift Concurrency로 메모리 누수와 Callback 지옥을 해결해보자 (온보딩 리팩토링).
    상단으로

    티스토리툴바