스파르타 캠프에서 진행한 환율 계산기 앱입니다. 숫자 데이터를 사람이 이해하기 쉬운 UI로 바꾸고, 사용자의 맥락을 잃지 않도록 상태를 저장·복원하는 데 초점을 맞췄습니다.
- 환율 트렌드 아이콘 (Level 8): 이전 스냅샷과 최신 환율을 비교해 상승 🔼 / 하락 🔽 / 보합 여부를 표시합니다. 변동량이
±0.01이하일 때는 아이콘 대신 정렬용 여백만 두어 UI가 흔들리지 않도록 했습니다. - 앱 상태 저장 & 복원 (Level 10): 마지막으로 본 화면(리스트/계산기)과 통화 정보를 SwiftData에 저장해, 앱을 재시작해도 즉시 동일한 맥락으로 복귀합니다. 계산기의 입력 값과 결과는 일시적 상태로 간주해 저장하지 않습니다.
- 환율 캐시 스냅샷: 최신 환율을 네트워크에서 가져오기 전까지 캐시된 데이터를 먼저 노출해 초기 표시 속도를 높였습니다.
Combine 바인딩 과정에서 CurrencyViewController가 메모리에서 해제되지 않는 누수를 발견했습니다. 문제 정의 → 분석 → 해결 → 검증까지 Xcode 툴을 활용해 정리했습니다.
- 증상: 환율 목록 → 계산기 화면 전환 후, 화면을 닫아도
CurrencyViewController인스턴스가 메모리에 남아 있음. - 기대 행동: 네비게이션 스택에서 pop되면 ViewController가 즉시 해제되어야 함.
- 시뮬레이터에서 목록 ↔ 계산기 화면을 반복 전환.
- Memory Graph Debugger 실행 → pop 후에도
CurrencyViewController인스턴스가 존재. - 그래프를 따라가 보니
CurrencyViewController → BaseViewController.cancellables → AnyCancellable → Combine 클로저 → CurrencyViewController경로로 강한 참조 순환이 형성.
- 동일 시나리오를 Instruments Leaks로 추적.
BaseViewController.bindErrorHandling()구독이 종료되지 않아 누수가 지속되는 것을 재확인.- Leaks 타임라인에서 동일 주소의
CurrencyViewController가 반복적으로 살아 있는 것을 확인.
bindErrorHandling()내부compactMap클로저가self를 강하게 캡처.cancellables가AnyCancellable을 강하게 보유하면서 View → Combine → View 구조가 순환 참조를 만듦.
private func bindErrorHandling() {
viewStore.publisher
.compactMap { $0.errorMessage }
.sink { [weak self] message in
guard let self else { return }
self.presentError(message)
}
.store(in: &cancellables)
}[weak self]로 수정해 Combine 클로저가 ViewController를 약하게 캡처하도록 변경.
| 단계 | 도구 | 결과 |
|---|---|---|
| 수정 전 | Memory Graph Debugger | Pop 후에도 CurrencyViewController가 해제되지 않음 |
| 수정 전 | Instruments (Leaks) | 동일 인스턴스의 누수가 계속 보고됨 |
| 수정 후 | Memory Graph Debugger | 화면 전환 후 컨트롤러가 즉시 해제되는 것을 확인 |
| 수정 후 | Instruments (Leaks) | 누수 항목이 사라지고 메모리 사용량이 안정 유지 |
위 과정을 통해 View ↔ Combine 구독 간 강한 참조 순환을 해소했고, 화면 전환을 반복해도 컨트롤러가 즉시 해제되는 것을 확인했습니다.
| 계층 | 기술 |
|---|---|
| 상태 관리 | TCA (Composable Architecture) |
| 데이터 저장 | SwiftData (@Model + ModelContext) |
| 네트워크/도메인 | Repository + UseCase 계층 구조 |
| UI 레이아웃 | FlexLayout, PinLayout |
| 테스트 | Xcode .xctestplan (swift-testing) |
CurrencyConverter.xcodeproj를 Xcode에서 열기- Scheme
CurrencyConverter선택 후 실행 (⌘ + R) - 테스트는
CurrencyConverter.xctestplan을 이용해 전체 실행 (⌘ + U)
⚠️ 프로젝트는 Swift Package Manager 기반이 아니므로swift test는 사용하지 않습니다.
private func computeTrends(
newRates: [String: Double],
previousRates: [String: Double]
) -> [String: RateTrend] {
newRates.reduce(into: [String: RateTrend]()) { result, entry in
let previous = previousRates[entry.key] ?? entry.value
let diff = entry.value - previous
result[entry.key] = RateTrend(difference: diff)
}
}@Model
final class LastViewedScreenEntity {
@Attribute(.unique) var id: String = "singleton"
var type: ScreenType
var currencyCode: String?
var exchangeRate: Double?
init(type: ScreenType, currencyCode: String?, exchangeRate: Double?) {
self.type = type
self.currencyCode = currencyCode
self.exchangeRate = exchangeRate
}
}
func persistLastScreen(_ screen: LastViewedScreen, context: ModelContext) async {
await context.perform {
try? context.delete(model: LastViewedScreenEntity.self)
let entity = LastViewedScreenEntity(
type: screen.type,
currencyCode: screen.currencyCode,
exchangeRate: screen.exchangeRate
)
context.insert(entity)
try? context.save()
}
}row.addItem(priceLabel).shrink(0).marginLeft(4)
row.addItem(trendImageView)
.width(Layout.trendWidth)
.height(Layout.trendWidth)
.marginLeft(4)