<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>dev_dlfrl.log</title>
        <link>https://velog.io/</link>
        <description>:&gt;</description>
        <lastBuildDate>Fri, 19 Sep 2025 11:06:03 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>dev_dlfrl.log</title>
            <url>https://velog.velcdn.com/images/dev_dlfrl/profile/1c9d4c86-9db4-4407-94cb-89e5abc7c308/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. dev_dlfrl.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/dev_dlfrl" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[스와이프로 셀 삭제하기]]></title>
            <link>https://velog.io/@dev_dlfrl/%EC%8A%A4%EC%99%80%EC%9D%B4%ED%94%84%EB%A1%9C-%EC%85%80-%EC%82%AD%EC%A0%9C%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dev_dlfrl/%EC%8A%A4%EC%99%80%EC%9D%B4%ED%94%84%EB%A1%9C-%EC%85%80-%EC%82%AD%EC%A0%9C%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 19 Sep 2025 11:06:03 GMT</pubDate>
            <description><![CDATA[<h3 id="1-uipangesturerecognizer-설정">1. UIPanGestureRecognizer 설정</h3>
<pre><code class="language-swift">private func setupPanGesture() {
    panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:)))
    panGesture.delegate = self
    addGestureRecognizer(panGesture)
}</code></pre>
<h3 id="2-스와이프-액션-ui-구성">2. 스와이프 액션 UI 구성</h3>
<ul>
<li><strong>빨간색 배경</strong>: 삭제 액션을 나타내는 배경 뷰</li>
<li><strong>삭제 아이콘</strong>: 휴지통 아이콘으로 삭제 의도를 명확히 표시</li>
<li><strong>버튼 영역</strong>: 실제 삭제 동작을 처리하는 투명 버튼</li>
</ul>
<pre><code class="language-swift">private let swipeActionView = UIView().then {
    $0.backgroundColor = .error
    $0.layer.cornerRadius = Metrics.cornerRadius
    $0.isHidden = true  // 기본적으로 숨김
}

private let deleteIconView = UIImageView().then {
    let image = UIImage(named: &quot;trash&quot;)?.withRenderingMode(.alwaysTemplate)
    $0.image = image
    $0.tintColor = .appWhite
    $0.isHidden = true
}</code></pre>
<h3 id="3-스와이프-제스처-처리-로직">3. 스와이프 제스처 처리 로직</h3>
<h4 id="제스처-상태별-처리">제스처 상태별 처리</h4>
<pre><code class="language-swift">@objc private func handlePanGesture(_ gesture: UIPanGestureRecognizer) {
    let translation = gesture.translation(in: self)
    let velocity = gesture.velocity(in: self)

    switch gesture.state {
    case .began:
        originalTransform = mainContentView.transform

    case .changed:
        // 왼쪽으로만 스와이프 허용
        guard translation.x &lt;= 0 else { return }

        // 최대 이동 거리 제한 (안전구역 고려)
        let leftInset = frame.minX
        let safeLeft = window?.safeAreaInsets.left ?? 0
        let maxTranslation: CGFloat = -(80 + leftInset + safeLeft)
        let clampedTranslation = max(translation.x, maxTranslation)
        mainContentView.transform = CGAffineTransform(translationX: clampedTranslation, y: 0)

        // 스와이프 액션 표시 조건
        let shouldShowAction = translation.x &lt; -25
        if shouldShowAction != isSwipeActionVisible {
            isSwipeActionVisible = shouldShowAction
            swipeActionView.isHidden = !shouldShowAction
            deleteIconView.isHidden = !shouldShowAction
        }

    case .ended, .cancelled:
        // 스와이프 거리나 속도에 따라 액션 결정
        if translation.x &lt; -40 || velocity.x &lt; -400 {
            showSwipeAction()  // 삭제 액션 표시
        } else {
            resetSwipeAction()  // 원래 위치로 복원
        }
    }
}</code></pre>
<h3 id="4-애니메이션-처리">4. 애니메이션 처리</h3>
<h4 id="삭제-액션-표시">삭제 액션 표시</h4>
<pre><code class="language-swift">private func showSwipeAction() {
    UIView.animate(withDuration: 0.25, delay: 0, usingSpringWithDamping: 0.85, initialSpringVelocity: 0.6) {
        let leftInset = self.frame.minX
        let safeLeft = self.window?.safeAreaInsets.left ?? 0
        self.mainContentView.transform = CGAffineTransform(translationX: -(80 + leftInset + safeLeft), y: 0)
    }
    swipeActionView.isHidden = false
    deleteIconView.isHidden = false
}</code></pre>
<h4 id="원래-위치로-복원">원래 위치로 복원</h4>
<pre><code class="language-swift">private func resetSwipeAction() {
    UIView.animate(withDuration: 0.25, delay: 0, usingSpringWithDamping: 0.85, initialSpringVelocity: 0.6) {
        self.mainContentView.transform = .identity
    }
    deleteIconView.isHidden = true
    isSwipeActionVisible = false
    swipeActionView.isHidden = true
}</code></pre>
<h3 id="5-삭제-확인-및-실행">5. 삭제 확인 및 실행</h3>
<h4 id="셀에서-뷰컨트롤러로-이벤트-전달">셀에서 뷰컨트롤러로 이벤트 전달</h4>
<pre><code class="language-swift">// 셀에서 콜백 설정
cell.onDeleteTapped = { [weak self] in
    guard let self = self else { return }
    let timer = self.timers[indexPath.item]
    self.showDeleteConfirmation(for: timer, at: indexPath)
}</code></pre>
<h4 id="삭제-확인-다이얼로그">삭제 확인 다이얼로그</h4>
<pre><code class="language-swift">private func showDeleteConfirmation(for timer: TimerModel, at indexPath: IndexPath) {
    let presenter = navigationController ?? self
    PodoAlertController.presentDeleteTimerAlert(
        from: presenter,
        title: &quot;이 타이머를 삭제할까요?&quot;,
        message: &quot;삭제한 타이머는 복구할 수 없어요.&quot;,
        cancelTitle: &quot;취소&quot;,
        confirmTitle: &quot;삭제하기&quot;
    ) { [weak self] in
        self?.deleteTimer(timer, at: indexPath)
    }
}</code></pre>
<h4 id="실제-삭제-실행">실제 삭제 실행</h4>
<pre><code class="language-swift">private func deleteTimer(_ timer: TimerModel, at indexPath: IndexPath) {
    do {
        try repository.delete(id: timer.timerID)
        // 데이터 삭제 후 UI 업데이트
        timers.remove(at: indexPath.item)
        collectionView.deleteItems(at: [indexPath])
        updateUI()
        updateHeaderTotalFocusTime()
    } catch {
        // 에러 처리
        PodoAlertController.presentErrorAlert(
            from: presenter,
            title: &quot;삭제 실패&quot;,
            message: &quot;타이머 삭제 중 오류 발생&quot;
        )
    }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[브랜치 생성 기준과 PR 커밋 포함 차이]]></title>
            <link>https://velog.io/@dev_dlfrl/%EB%B8%8C%EB%9E%9C%EC%B9%98-%EC%83%9D%EC%84%B1-%EA%B8%B0%EC%A4%80%EA%B3%BC-PR-%EC%BB%A4%EB%B0%8B-%ED%8F%AC%ED%95%A8-%EC%B0%A8%EC%9D%B4</link>
            <guid>https://velog.io/@dev_dlfrl/%EB%B8%8C%EB%9E%9C%EC%B9%98-%EC%83%9D%EC%84%B1-%EA%B8%B0%EC%A4%80%EA%B3%BC-PR-%EC%BB%A4%EB%B0%8B-%ED%8F%AC%ED%95%A8-%EC%B0%A8%EC%9D%B4</guid>
            <pubDate>Mon, 15 Sep 2025 11:31:48 GMT</pubDate>
            <description><![CDATA[<p>Git에서 새 브랜치를 만들 때 어느 브랜치에서 갈라내느냐에 따라 이후 PR에 포함되는 커밋이 달라진다.
    1.    develop에서 바로 feature/one 브랜치를 만들면 → PR에는 오직 feature/one에서 만든 커밋만 포함된다.
    2.    feature/one에서 또 feature/two를 만들면 → feature/one의 커밋 + feature/two의 커밋이 모두 PR에 들어간다. (첫 번째 PR이 아직 머지되지 않았다면 겹쳐 보이는 문제 발생)</p>
<h3 id="내가-겪은-문제">내가 겪은 문제</h3>
<pre><code>•    feature/one 브랜치에서 바로 또 새로운 브랜치를 만들어 작업을 이어갔다
•    그래서 두 번째 PR을 열었더니 첫 번째 PR의 커밋까지 전부 포함되어 버렸다
•    결과적으로 PR 커밋이 꼬여 보이고, 리뷰어 입장에서도 구분이 힘들었다</code></pre><h3 id="해결-방법">해결 방법</h3>
<pre><code>•    일단은 앞선 PR(feature/one)을 먼저 머지하고,
•    그다음 뒤의 PR(feature/two)을 머지하는 방식으로 문제를 해결했다.</code></pre><h3 id="배운-점">배운 점</h3>
<pre><code>•    새로운 작업을 시작할 때는 반드시 develop(또는 main) 기준으로 브랜치를 파는 것이 안전
•    만약 이전 작업 위에서 브랜치를 만들었다면, 그건 사실상 스택 PR 구조가 되므로, 리뷰/머지 순서를 잘 지켜야 한다
•    git fetch origin 후 항상 develop을 최신으로 맞추고, 그 위에서 새 브랜치를 시작하면 커밋 꼬임을 예방할 수 있다</code></pre><h3 id="작업-단위마다-develop에서-새-브랜치를-생성하는-습관-들이기">작업 단위마다 develop에서 새 브랜치를 생성하는 습관 들이기</h3>
]]></description>
        </item>
        <item>
            <title><![CDATA[9/12 (금) 모의 면접]]></title>
            <link>https://velog.io/@dev_dlfrl/912-%EA%B8%88-%EB%AA%A8%EC%9D%98-%EB%A9%B4%EC%A0%91</link>
            <guid>https://velog.io/@dev_dlfrl/912-%EA%B8%88-%EB%AA%A8%EC%9D%98-%EB%A9%B4%EC%A0%91</guid>
            <pubDate>Fri, 12 Sep 2025 11:56:25 GMT</pubDate>
            <description><![CDATA[<ul>
<li>모의면접 회고:<ul>
<li>Q: View와 ViewModel 간 데이터 흐름을 어떻게 설계했나요?<ul>
<li>View와 ViewModel 간의 데이터 흐름은 크게 단방향으로 설계되어 있습니다
사용자가 View에서 어떤 행동을 하면, 그 이벤트가 ViewModel로 전달됩니다 타이머를 추가하거나 삭제하는 버튼을 누르면 그 신호가 ViewModel의 입력으로 들어가고, ViewModel은 내부에서 Repository를 호출해 실제 데이터를 변경하거나 가져옵니다 이렇게 처리된 결과는 다시 ViewModel의 출력 값으로 방출되고, View가 이를 구독해서 화면을 갱신하는 구조입니다</li>
</ul>
</li>
<li>Q: Struct와 class를 동시에 활용해야 하는 상황은?<ul>
<li>값 타입과 참조 타입의 장점을 같이 가져가고 싶을 때 입니다 데이터 자체는 struct, 데이터를 관리하거나 상태를 공유해야 하는 부분은 class로 설계합니다 타이머 앱에서는 TimerModel은 struct로 만들었습니다 복사될 때마다 안전하게 분리되고, 여러 화면에서 같은 데이터를 의도치 않게 공유하는 걸 막을 수 있었습니다 TimerEditViewModel 같은 건 class로 두었습니다 뷰랑 바인딩돼서 상태를 계속 관찰하고 업데이트해야 하기 때문에 참조 타입이 적합했습니다  이렇게struct와 class를 나누면, 데이터는 유지하면서도  상태를 공유할 수 있습니다 그래서  모델은 struct, 로직이나 뷰모델은 class로 나눠서 활용했습니다</li>
</ul>
</li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[git commit --amend]]></title>
            <link>https://velog.io/@dev_dlfrl/git-commit-amend</link>
            <guid>https://velog.io/@dev_dlfrl/git-commit-amend</guid>
            <pubDate>Thu, 11 Sep 2025 11:22:17 GMT</pubDate>
            <description><![CDATA[<p>git commit --amend로 메시지만 수정하려 했는데 staging 되어 있던 작업 중 코드까지 같이 들어가 버림.</p>
<ul>
<li>amend는 메시지만 바꾸는 게 아니라 현재 스테이징된 변경까지 커밋에 포함된다는 걸 배움..</li>
<li>해결 과정<ol>
<li>git reset HEAD^으로 잘못 amend된 커밋을 되돌림.</li>
<li>원래 커밋은 메시지만 다시 수정해서 반영.</li>
<li>따로 들어가 버린 변경 사항은 새 커밋으로 분리해 올림.
•    교훈: 메시지만 수정할 때는 반드시 staging 상태 확인!
→ 불필요한 변경이 있으면 미리 git restore --staged 같은 명령으로 되돌리고 amend 해야 함.</li>
</ol>
</li>
</ul>
<p>reset 쓰는 게 석연치 않았지만.. 결과적으론 잘 해결 됨</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[podoit 모의 면접]]></title>
            <link>https://velog.io/@dev_dlfrl/podoit-%EB%AA%A8%EC%9D%98-%EB%A9%B4%EC%A0%91</link>
            <guid>https://velog.io/@dev_dlfrl/podoit-%EB%AA%A8%EC%9D%98-%EB%A9%B4%EC%A0%91</guid>
            <pubDate>Tue, 09 Sep 2025 12:08:08 GMT</pubDate>
            <description><![CDATA[<h2 id="mvvm이-뭐고-어떤-방식을-프로젝트에-대입했나요">mvvm이 뭐고 어떤 방식을 프로젝트에 대입했나요?</h2>
<pre><code>•    M(Model): 순수 데이터/비즈니스 객체. 저장·불러오기 로직은 Repository에서
•    V(View/Controller): 화면 그리는 쪽(UIKit에서는 UIView/UIViewController). 입력 이벤트를 받고, ViewModel의 출력만 바인딩해서 그려줌
•    VM(ViewModel): 화면에 필요한 상태/로직을 소유. 모델을 가공해 UI에서 바로 쓸 수 있는 형태로 내보내고, 사용자 입력을 받아 Repository를 통해 Model을 변경</code></pre><h3 id="프로젝트에서는">프로젝트에서는</h3>
<ul>
<li>파일 매핑
 •    Model/Repository
 •    TimerModel(SwiftData @Model)
 •    SwiftDataManager : TimerRepository (CRUD 담당)
 •    View (UIKit)
 •    TimerViewController(리스트 화면), TimerEditViewController(편집 화면)
 •    TimerCell, TimerHeaderView, EmptyStateView (UI 컴포넌트)
 •    ViewModel
 •    TimerEditViewModel (편집 상태·중복 검사·저장/수정 로직)
 •    (통계 쪽은 StatsViewModel에서 Rx로 출력 제공)</li>
</ul>
<h3 id="역할-분리">역할 분리</h3>
<ul>
<li>ViewController
  •    UI 레이아웃(SnapKit/Then), 버튼 탭/셀 선택 처리
  •    알럿/토스트 표시, 화면 전환(push/present)
  •    ViewModel의 출력값 바인딩 + 이벤트를 입력으로 전달
  •    ViewModel
  •    입력 상태(제목, 이모지, 목표시간 등) 보유
  •    유효성 검증(예: hasDuplicateTitle)
  •    저장/수정/삭제는 Repository 호출로 처리
  •    UI가 바로 쓸 수 있는 가공값 제공(문구, 포맷팅, 버튼 활성화 등)</li>
</ul>
<p>⸻</p>
<h3 id="흐름">흐름</h3>
<ul>
<li><p>타이머 추가</p>
<ol>
<li>TimerViewController의 “추가하기” 버튼 탭</li>
<li>TimerEditViewController 표시(빈 상태의 TimerEditViewModel 주입)</li>
<li>사용자가 입력 → VC가 VM에 값 전달 (혹은 텍스트 변경 시 VM 바인딩)</li>
<li>저장 버튼 탭 → VC가 viewModel.save() 호출</li>
<li>VM이 중복 검사 → 통과 시 TimerRepository.insert(...)</li>
<li>성공 콜백 → VC가 목록 리로드(또는 델리게이트/노티로 상위에 반영) → 닫기</li>
</ol>
</li>
<li><p>타이머 편집</p>
<ol>
<li>리스트 셀 탭 → 편집 VC 표시(기존 엔티티 담긴 TimerEditViewModel(editing:) 주입)</li>
<li>기존 값 프리필(VC는 VM의 출력으로 UI 세팅)</li>
<li>수정 후 저장 → VM이 update(...) 호출 → 성공 시 VC가 스냅샷 갱신</li>
</ol>
</li>
<li><p>타이머 삭제</p>
<ol>
<li>리스트에서 삭제 액션 → VC가 커스텀 알럿(PodoAlertController) 표시</li>
<li>확인 시 Repository의 delete(...) 호출(여긴 VC에서 직접 or VM 통해서 — 한 곳으로 일원화 권장)</li>
<li>성공 후 Diffable Snapshot 재적용 → 셀 사라짐</li>
</ol>
</li>
</ul>
<h2 id="레포지토리가-뭐고-왜-사용하셨나요">레포지토리가 뭐고 왜 사용하셨나요?</h2>
<p>데이터 접근 로직(저장·조회·삭제 등)을 한 곳에 모아두는 계층.</p>
<p>Model(TimerModel 등)과 Persistence Layer(SwiftData, CoreData, Realm 등) 사이에서 중간 추상화 계층.
    •    ViewModel이나 ViewController는 데이터가 어디에/어떻게 저장되는지 몰라도 됨. 그냥 repo.fetchAll() 같은 API만 부르면 됨.</p>
<p>관심사 분리 때문에 사용
VC/VM은 데이터가 필요하다만 알면 되고, 저장 방식은 Repository가 책임
나중에 SwiftData → CoreData, Realm, CloudKit 등 바꿔도 VC/VM은 안 바뀜</p>
<h2 id="레포지토리-말고도-프로젝트에서-객체지향적으로-처리한-게-있나요">레포지토리 말고도 프로젝트에서 객체지향적으로 처리한 게 있나요?</h2>
<p>-&gt; 의존성 주입
VC에서 직접 SwiftData 안 쓰고, 생성자에 TimerRepository를 주입.</p>
<pre><code class="language-swift">init(repository: TimerRepository) {
  self.repository = repository
  super.init(nibName: nil, bundle: nil)
}</code></pre>
<h2 id="vc에서-직접-swiftdata-안-쓰고-생성자에-timerrepository를-주입한-이유가-무엇인가요">VC에서 직접 SwiftData 안 쓰고, 생성자에 TimerRepository를 주입한 이유가 무엇인가요?</h2>
<p>-&gt; 결합도 낮추기</p>
<ul>
<li>만약 VC가 SwiftData를 직접 쓴다면?
→ VC는 UI도 하고, DB 접근 코드도 다 알아야 함 → 강하게 결합됨</li>
<li>지금처럼 VC가 TimerRepository 프로토콜만 의존하면
VC는 저장/불러오기라는 역할만 알고, 구현이 SwiftData든, CoreData든, Realm이든, Mock이든 상관없음.</li>
</ul>
<h2 id="rxswift에서-observable이-무엇인가요">Rxswift에서 Observable이 무엇인가요?</h2>
<p>시간에 따라 변하는 이벤트 스트림을 나타내는 객체
값이 생길 때마다(onNext), 에러가 날 때(onError), 끝날 때(onCompleted) 이벤트를 방출 옵저버(구독자)가 그걸 듣고 반응</p>
<p>CalendarView.swift</p>
<pre><code class="language-swift">private let selectedDateRelay = BehaviorRelay&lt;Date&gt;(value: Date())
var selectedDate: Observable&lt;Date&gt; { selectedDateRelay.asObservable() }</code></pre>
<p>뷰 내부에서 선택된 날짜 상태를 Relay로 관리.
외부에서는 selectedDate Observable을 구독해서 반응.</p>
<h2 id="relay랑-subject-차이가-무엇인가요">Relay랑 Subject 차이가 무엇인가요?</h2>
<ul>
<li>Subject<ul>
<li>onNext, onError, onCompleted 모두 호출 가능</li>
</ul>
</li>
<li>Relay<ul>
<li>에러/완료를 방출하지 않음</li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[SwiftData 스키마 불일치로 ModelContainer 초기화 실패]]></title>
            <link>https://velog.io/@dev_dlfrl/SwiftData-%EC%8A%A4%ED%82%A4%EB%A7%88-%EB%B6%88%EC%9D%BC%EC%B9%98%EB%A1%9C-ModelContainer-%EC%B4%88%EA%B8%B0%ED%99%94-%EC%8B%A4%ED%8C%A8</link>
            <guid>https://velog.io/@dev_dlfrl/SwiftData-%EC%8A%A4%ED%82%A4%EB%A7%88-%EB%B6%88%EC%9D%BC%EC%B9%98%EB%A1%9C-ModelContainer-%EC%B4%88%EA%B8%B0%ED%99%94-%EC%8B%A4%ED%8C%A8</guid>
            <pubDate>Thu, 04 Sep 2025 13:51:32 GMT</pubDate>
            <description><![CDATA[<p>TimerModel에 createdAt: Date 필드를 추가하고, SwiftDataManager가 직접 ModelContainer를 생성하도록 리팩터링하면서 발생.</p>
<p>앱 실행 하니까 크래시가 뜸 [ fatalError(&quot;Failed to create ModelContainer: (error)&quot;) ]
결론적으로 시뮬레이터에서 앱 삭제하고 재실행 하니까 해결됨</p>
<h2 id="원인">원인</h2>
<p>디스크에 이미 존재하던 SwiftData 스토어의 스키마가 이전 모델 구조를 기준으로 만들어져 있음.
이번 커밋에서 TimerModel에 새 필드(createdAt) 추가 &amp; ModelContainer 생성 방식 변경 → 스키마가 변함
SwiftData가 이 변경을 자동 마이그레이션으로 처리하지 못해 컨테이너 초기화 단계에서 실패.</p>
<p>시뮬레이터에서 앱 삭제 후 재실행하면 기존 스토어가 제거되어 새 스키마로 정상 생성됨
난 그냥 시뮬에서 지웠는데 CLI에서도 가능한가봄</p>
<pre><code class="language-Linux"># 부들 ID 바꿔서 실행
xcrun simctl uninstall booted &lt;com.your.bundleid&gt;
# (전체 리셋이 필요할 땐) 모든 시뮬레이터 지우기
xcrun simctl erase all</code></pre>
<h2 id="추가">추가</h2>
<p>fetchAll()에서 createdAt 기준 내림차순 정렬을 추가했는데, 기존 레코드에는 createdAt 컬럼 자체가 없음 → 스키마 불일치.
SwiftDataManager가 ModelContainer(for: TimerModel.self, StatsModel.self)를 직접 생성하도록 바꾼 것도 기존 스토어 파일을 그대로 사용하게 만들어 충돌을 더 빨리 드러냄.</p>
<h2 id="교훈">교훈</h2>
<p>모델 스키마를 바꾸면 디스크 스토어도 같이 생각해야 한다
SwiftData의 자동 마이그레이션은 일부 변경(추가/옵셔널화 등)만 커버하고, 모든 변경을 해결해주지 않는다
개발 단계에선 스토어 초기화 전략을 준비해두면 트러블슈팅 시간이 줄어든다.</p>
<h2 id="재발-방지">재발 방지</h2>
<p>컨테이너 생성 실패 시 메모리 스토어로 폴백(개발/테스트 한정)</p>
<pre><code class="language-swift">let schema = Schema([TimerModel.self, StatsModel.self])
do {
  return try ModelContainer(for: schema)
} catch {
  // 디스크 실패 시 메모리로라도 구동
  let mem = ModelConfiguration(isStoredInMemoryOnly: true)
  return try! ModelContainer(for: schema, configurations: mem)
}</code></pre>
<p>단순 필드 추가니까 괜찮겠지라고 생각했다가 컨테이너 초기화에서 막힌 케이스
SwiftData가 모든 변경을 매끈하게 마이그레이션해주진 않으니, 스키마 변경 = 스토어 수명주기 관리라고 항상 같이 생각해야 한다는 걸 깨달음
지금처럼 리스트 정렬 키로 새 필드를 도입할 땐, 구버전 데이터에 대한 기본값/옵셔널 처리 전략을 먼저 세우는 게 안전</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[타이머 편집/리스트 초 단위 테스트]]></title>
            <link>https://velog.io/@dev_dlfrl/%ED%83%80%EC%9D%B4%EB%A8%B8-%ED%8E%B8%EC%A7%91%EB%A6%AC%EC%8A%A4%ED%8A%B8-%EC%B4%88-%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8</link>
            <guid>https://velog.io/@dev_dlfrl/%ED%83%80%EC%9D%B4%EB%A8%B8-%ED%8E%B8%EC%A7%91%EB%A6%AC%EC%8A%A4%ED%8A%B8-%EC%B4%88-%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8</guid>
            <pubDate>Wed, 03 Sep 2025 11:52:55 GMT</pubDate>
            <description><![CDATA[<h2 id="배경">배경</h2>
<p>목표 시간의 최소값이 5분인데 실제 동작을 빠르게 검증하려면 5분은 너무 김 → QA/개발 속도가 느려짐
#if DEBUG 빌드에서만 초 단위로 목표 시간을 선택/표시할 수 있도록 테스트 모드를 넣어, 30초·60초 같은 짧은 값으로 흐름을 즉시 확인하고자 함</p>
<h2 id="시도한-접근">시도한 접근</h2>
<ol>
<li>TimerEditViewController</li>
</ol>
<ul>
<li>#if DEBUG에서 isTestMode = true.</li>
<li>테스트 모드일 때 피커 옵션을 초 단위로 구성(timeOptions = 30~600초)</li>
<li>UI 단위 레이블“분/초)도  토글.</li>
</ul>
<ol start="2">
<li>TimerCell</li>
</ol>
<ul>
<li>리스트에서 목표 시간을 초로 보여주기 위한 플래그 추가</li>
</ul>
<h3 id="겉으로-드러난-문제">겉으로 드러난 문제</h3>
<p>편집 화면에서 30초로 선택했는데, 저장 후 리스트에는 60초로 보임.</p>
<p>-&gt; 모델 저장 스키마가 ‘분’ 단위였음.
편집 화면 saveTapped()에서</p>
<pre><code class="language-swift">// DEBUG(초 선택) → 분 스키마에 맞추기 위해 올림저장
minutes = max(1, Int(ceil(Double(selectedTime) / 60.0)))</code></pre>
<p>30초 선택 → ceil(30/60) = 1분으로 저장</p>
<p>리스트는 저장된 값을 기준으로 초를 만들거나 그대로 표시
1분 저장 → 1 * 60 = 60초로 표기됨
→ 저장(분) vs 표시(초) 단위가 달라서 생긴 불일치</p>
<h3 id="ui-버그">UI 버그</h3>
<p>TimerEditViewController.updateCollapsedLabelText()가 단위를 항상 분으로 하드코딩해 테스트 모드에서도 분이 붙는 문제</p>
<h2 id="해결-방법-스키마-변경-없이">해결 방법 (스키마 변경 없이)</h2>
<p>스키마를 건드리지 않고 디버그에서만 실제 선택한 초값을 따로 저장해서 표시 우선으로 쓰도록 함</p>
<h3 id="1-디버그-전용-저장소-추가">1) 디버그 전용 저장소 추가</h3>
<ul>
<li>UserDefaults를 이용해 timerID별 초값을 보관</li>
<li>DebugGoalSecondsStore 유틸</li>
</ul>
<pre><code class="language-swift">#if DEBUG
enum DebugGoalSecondsStore {
  private static let keyPrefix = &quot;debug.goalSeconds.&quot;

  static func set(_ secs: Int, for id: String) {
    UserDefaults.standard.set(secs, forKey: keyPrefix + id)
  }
  static func get(for id: String) -&gt; Int? {
    UserDefaults.standard.object(forKey: keyPrefix + id) as? Int
  }
  static func remove(for id: String) {
    UserDefaults.standard.removeObject(forKey: keyPrefix + id)
  }

  // UUID 오버로드(편의)
  static func set(_ secs: Int, for id: UUID) { set(secs, for: id.uuidString) }
  static func get(for id: UUID) -&gt; Int? { get(for: id.uuidString) }
  static func remove(for id: UUID) { remove(for: id.uuidString) }
}
#endif</code></pre>
<h3 id="2-저장-직후-선택-초값을-기록">2) 저장 직후, 선택 초값을 기록</h3>
<p>TimerEditViewController.saveTapped()의 save 성공 직후</p>
<pre><code class="language-swift">#if DEBUG
if let id = viewModel.editing?.timerID {
  let secs = TimerEditViewController.isTestMode ? selectedTime : (minutes * 60)
  DebugGoalSecondsStore.set(secs, for: id) // UUID 지원 버전 사용
}
#endif</code></pre>
<h3 id="3-리스트-표시에서-초값을-우선-사용">3) 리스트 표시에서 초값을 우선 사용</h3>
<p>TimerCell.updateContent / 접근성 라벨에서</p>
<pre><code class="language-swift">#if DEBUG
if Self.showSecondsInList, let secs = DebugGoalSecondsStore.get(for: timer.timerID) {
  focusValueLabel.text = &quot;\(secs)초&quot;
} else {
  focusValueLabel.text = &quot;\(timer.goalTime)분&quot;
}
#else
focusValueLabel.text = &quot;\(timer.goalTime)분&quot;
#endif</code></pre>
<pre><code class="language-swift">#if DEBUG
if Self.showSecondsInList, let secs = DebugGoalSecondsStore.get(for: timer.timerID) {
  accessibilityLabel = &quot;\(timer.title), 집중 목표 \(secs)초, 오늘 \(today)&quot;
} else {
  accessibilityLabel = &quot;\(timer.title), 집중 목표 \(timer.goalTime)분, 오늘 \(today)&quot;
}
#else
accessibilityLabel = &quot;\(timer.title), 집중 목표 \(timer.goalTime)분, 오늘 \(today)&quot;
#endif</code></pre>
<h3 id="4-접힘-라벨-단위-버그-수정">4) 접힘 라벨 단위 버그 수정</h3>
<p>updateCollapsedLabelText()에서 단위 하드코딩 제거</p>
<pre><code class="language-swift">let unitText = TimerEditViewController.isTestMode ? &quot;초&quot; : &quot;분&quot;
let unit = Typography.attributed(unitText, style: .headingXl(weight: .semibold), color: .gray600)
number.append(unit)</code></pre>
<hr>
<p>데이터 스키마를 바꾸지 않음 → 마이그레이션/리스크 0.
변경 범위가 DEBUG 빌드에만 한정 → 릴리즈 품질에 영향 없음.
리스트/접근성 같은 표시부만 조건부로 분기 → 사이드이펙트 최소.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[입력 검증 UI(에러 스트로크) & 15자 입력 제한]]></title>
            <link>https://velog.io/@dev_dlfrl/%EC%9E%85%EB%A0%A5-%EA%B2%80%EC%A6%9D-UI%EC%97%90%EB%9F%AC-%EC%8A%A4%ED%8A%B8%EB%A1%9C%ED%81%AC-15%EC%9E%90-%EC%9E%85%EB%A0%A5-%EC%A0%9C%ED%95%9C</link>
            <guid>https://velog.io/@dev_dlfrl/%EC%9E%85%EB%A0%A5-%EA%B2%80%EC%A6%9D-UI%EC%97%90%EB%9F%AC-%EC%8A%A4%ED%8A%B8%EB%A1%9C%ED%81%AC-15%EC%9E%90-%EC%9E%85%EB%A0%A5-%EC%A0%9C%ED%95%9C</guid>
            <pubDate>Tue, 02 Sep 2025 11:37:06 GMT</pubDate>
            <description><![CDATA[<h2 id="배경">배경</h2>
<p>타이머 생성/수정 화면에서 이름이 비었을 때 저장을 막고 시각적으로 에러를 보여줘야 함.
타이머 이름은 15자로 제한하고 한글 조합 시 오동작이 없어야 함</p>
<h2 id="문제">문제</h2>
<p>단순히 text.isEmpty만 체크하면 한글 조합 중에도 에러가 깜빡거릴 수 있음
shouldChangeCharactersIn에서 길이 제한을 걸면 붙여넣기나 중간 삽입에서 잘리는 로직이 어색해질 수 있음
시각적 에러 상태는 애니메이션/해제 타이밍이 부자연스러울 수 있음</p>
<h2 id="해결">해결</h2>
<h3 id="1-빈-값-에러-스트로크">1) 빈 값 에러 스트로크</h3>
<p>validateNameField()에서 공백 제거 후 빈 문자열이면 빨간 테두리 표시.
nameEditingChanged에서 IME 조합 중(markedTextRange)이면 패스하여 깜빡임 방지
에러 표시/해제는 UIView.animate(withDuration:)로 전환</p>
<pre><code class="language-swift">private func validateNameField() {
  let text = (nameTextField.text ?? &quot;&quot;).trimmingCharacters(in: .whitespacesAndNewlines)
  setNameFieldError(text.isEmpty)
}

@objc private func nameEditingChanged(_ sender: UITextField) {
  // IME 조합 중이면 건너뜀
  if let marked = sender.markedTextRange,
     sender.position(from: marked.start, offset: 0) != nil { return }
  validateNameField()
}

private func setNameFieldError(_ show: Bool, animated: Bool = true) {
  let updates = {
    self.nameTextField.layer.borderWidth = show ? 1 : 0
    self.nameTextField.layer.borderColor = show ? self.errorStrokeColor() : UIColor.clear.cgColor
    self.nameTextField.layer.cornerRadius = Metrics.cornerRadius
  }
  animated ? UIView.animate(withDuration: 0.15, animations: updates) : updates()
}</code></pre>
<p>저장 시 비어 있으면 UIImpactFeedbackGenerator(style: .light).impactOccurred()로 햅틱 제공.</p>
<p>⸻</p>
<h3 id="2-15자-제한">2) 15자 제한</h3>
<p>shouldChangeCharactersIn에서 바뀐 결과 문자열을 미리 만든 뒤 길이 판단
초과면 남은 자리수만큼 자른 문자열로 직접 세팅하고 false 반환.
한글 조합 중에는 무조건 허용하여 조합이 끊기지 않게 함.</p>
<pre><code class="language-swift">func textField(_ textField: UITextField,
               shouldChangeCharactersIn range: NSRange,
               replacementString string: String) -&gt; Bool
{
  guard textField === nameTextField else { return true }

  // IME 조합 중은 제한 미적용
  if let marked = textField.markedTextRange,
     textField.position(from: marked.start, offset: 0) != nil { return true }

  let limit = 15
  let current = textField.text ?? &quot;&quot;
  guard let swiftRange = Range(range, in: current) else { return true }
  let proposed = current.replacingCharacters(in: swiftRange, with: string)

  if proposed.count &lt;= limit { return true } // 정상 범위

  // 초과 → 남은 길이만 허용
  let replacingCount = current[swiftRange].count
  let remaining = limit - (current.count - replacingCount)
  guard remaining &gt; 0 else {
    UIImpactFeedbackGenerator(style: .light).impactOccurred()
    return false
  }

  let allowedPrefix = String(string.prefix(remaining))
  let truncated = current.replacingCharacters(in: swiftRange, with: allowedPrefix)
  textField.text = truncated
  UIImpactFeedbackGenerator(style: .light).impactOccurred()
  return false
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[이모지 선택 기능 구현]]></title>
            <link>https://velog.io/@dev_dlfrl/%EC%9D%B4%EB%AA%A8%EC%A7%80-%EC%84%A0%ED%83%9D-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@dev_dlfrl/%EC%9D%B4%EB%AA%A8%EC%A7%80-%EC%84%A0%ED%83%9D-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Mon, 01 Sep 2025 13:28:46 GMT</pubDate>
            <description><![CDATA[<p>타이머 생성/편집 화면에서 사용자가 이모지를 선택할 수 있는 기능 구현</p>
<h2 id="1-커스텀-emojitextfield-클래스">1. 커스텀 EmojiTextField 클래스</h2>
<pre><code class="language-swift">final class EmojiTextField: UITextField {
  override var textInputContextIdentifier: String? { &quot;&quot; }

  override var textInputMode: UITextInputMode? {
    if let emojiMode = UITextInputMode.activeInputModes.first(where: { $0.primaryLanguage == &quot;emoji&quot; }) {
      return emojiMode
    }
    return super.textInputMode
  }
}</code></pre>
<p>textInputMode를 오버라이드해서 이모지 키보드를 우선적으로 표시
activeInputModes에서 primaryLanguage == emoji인 모드를 찾아 반환
이모지 키보드가 없으면 기본 키보드로 폴백</p>
<h2 id="2-숨겨진-텍스트필드-패턴">2. 숨겨진 텍스트필드 패턴</h2>
<pre><code class="language-swift">private lazy var emojiInputField = EmojiTextField().then {
  $0.autocorrectionType = .no
  $0.spellCheckingType = .no
  $0.autocapitalizationType = .none
  $0.returnKeyType = .done
  $0.tintColor = .clear // 커서 숨김
  $0.textColor = .clear // 텍스트 숨김
  $0.backgroundColor = .clear
  $0.isHidden = true
  $0.delegate = self
  $0.addTarget(self, action: #selector(emojiTextChanged), for: .editingChanged)
}</code></pre>
<p>실제 UI에는 보이지 않지만 키보드 입력을 받는 숨겨진 텍스트필드
모든 시각적 요소를 숨김 (tintColor, textColor clear, isHidden true)
editingChanged 이벤트로 입력 감지</p>
<h2 id="3-이모지-버튼-상태-관리">3. 이모지 버튼 상태 관리</h2>
<pre><code class="language-swift">@objc private func emojiButtonTapped() {
  emojiInputField.becomeFirstResponder()
}

@objc private func emojiTextChanged(_ sender: UITextField) {
  let text = sender.text ?? &quot;&quot;
  guard let firstGrapheme = text.first else { return }
  let emoji = String(firstGrapheme)

  setEmojiOnButton(emoji)

  // 햅틱 &amp; 정리
  UIImpactFeedbackGenerator(style: .soft).impactOccurred()
  sender.text = &quot;&quot;
  sender.resignFirstResponder()
}</code></pre>
<p>버튼 탭 시 숨겨진 텍스트필드가 first responder가 되어 키보드 표시
첫 번째 grapheme만 추출하여 이모지로 사용
입력 후 즉시 텍스트필드를 정리하고 키보드 닫기</p>
<h2 id="4-이모지-표시-및-리셋-기능">4. 이모지 표시 및 리셋 기능</h2>
<pre><code class="language-swift">private func setEmojiOnButton(_ emoji: String) {
  selectedEmoji = emoji

  // 이미지 제거 후 타이틀로 이모지 표시
  emojiButton.setImage(nil, for: .normal)
  emojiButton.setAttributedTitle(
    Typography.attributed(emoji, style: .headingXl(weight: .semibold), color: .appBlack),
    for: .normal
  )
  emojiButton.tintColor = Palette.Primary.p600
  dashedCircleView.isHidden = true

  // 살짝 튀는 애니메이션
  UIView.animate(withDuration: 0.08, animations: {
    self.emojiButton.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
  }) { _ in
    UIView.animate(withDuration: 0.18) {
      self.emojiButton.transform = .identity
    }
  }
}

@objc private func resetEmoji(_ gr: UILongPressGestureRecognizer) {
  guard gr.state == .began else { return }
  let image = UIImage(named: &quot;plus&quot;)?.withRenderingMode(.alwaysTemplate)
  emojiButton.setAttributedTitle(nil, for: .normal)
  emojiButton.setImage(image, for: .normal)
  emojiButton.tintColor = Palette.Primary.p600
  dashedCircleView.isHidden = false
  UIImpactFeedbackGenerator(style: .light).impactOccurred()
}</code></pre>
<blockquote>
<p>이모지 선택 시 : 기본 plus 이미지 제거 → 이모지를 title로 설정 → 점선 원 숨김
롱프레스로 리셋 : 이모지 제거 → plus 이미지 복원 → 점선 원 표시
스케일 애니메이션으로 시각적 피드백 제공</p>
</blockquote>
<p>UITextInputMode를 활용해 특정 키보드 모드를 프로그래밍적으로 선택 가능
시스템에서 활성화된 입력 모드 중 이모지 키보드를 우선적으로 찾는 방식</p>
<p>UI 요소와 입력 처리를 분리하는 깔끔한 방법
버튼은 시각적 표현만 담당, 실제 입력은 숨겨진 텍스트필드가 처리
복잡한 커스텀 입력 뷰를 만들지 않고도 원하는 UX 구현 가능</p>
<p>String.first로 첫 번째 grapheme을 안전하게 추출
이모지는 여러 유니코드 문자로 구성될 수 있어 character 단위 처리가 중요</p>
<p>저장 시 아이콘 결정 로직</p>
<pre><code class="language-swift">let attributed = emojiButton.attributedTitle(for: .normal)?.string
let icon = selectedEmoji ?? (attributed?.isEmpty == false ? attributed! : &quot;🟣&quot;)</code></pre>
<p>selectedEmoji (사용자가 새로 선택한 이모지)
기존 button의 attributedTitle (편집 모드에서 기존값)
기본값 🟣</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[커스텀 알럿 만들기]]></title>
            <link>https://velog.io/@dev_dlfrl/%EC%BB%A4%EC%8A%A4%ED%85%80-%EC%95%8C%EB%9F%BF-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@dev_dlfrl/%EC%BB%A4%EC%8A%A4%ED%85%80-%EC%95%8C%EB%9F%BF-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Thu, 28 Aug 2025 13:21:19 GMT</pubDate>
            <description><![CDATA[<p>UIKit 기반으로 앱 전반에서 재사용 가능한 커스텀 알럿 컨트롤러를 설계/구현
시스템 UIAlertController 대신 디자인 가이드를 반영하고 전환 효과/햅틱/접근성까지 고려한 컴포넌트를 만듦</p>
<h3 id="목표">목표</h3>
<pre><code>•    디자인 일관성: 앱의 타이포/컬러/라운딩을 그대로 적용.
•    간단한 API: presenter에서 한 줄로 띄우고, onConfirm 클로저로 후처리.
•    애니메이션: dim 페이드 + 카드 스프링 인, 디스미스도 부드럽게.
•    접근성/테스트: VoiceOver 읽기, UI 테스트 식별자.
•    확장성: 버튼 수/아이콘/체크박스/텍스트필드 등 확장 가능.</code></pre><h3 id="최종-api">최종 API</h3>
<pre><code>PodoAlertController.presentDeleteTimerAlert(
  from: self,
  title: &quot;이 타이머를 삭제할까요?&quot;,
  message: &quot;삭제한 타이머는 복구할 수 없어요.&quot;,
  cancelTitle: &quot;취소&quot;,
  confirmTitle: &quot;삭제하기&quot;
) {
  // 삭제 실행
}</code></pre><p>modalPresentationStyle = .overFullScreen, modalTransitionStyle = .crossDissolve
배경이 투명하고 dimView를 직접 그릴 수 있도록 전체 화면 위에 덮는 구성</p>
<h3 id="내부-구조-설계">내부 구조 설계</h3>
<h4 id="뷰-계층">뷰 계층</h4>
<pre><code>view
 ├─ dimView (UIControl)  // 배경 딤 + 탭으로 닫힘
 └─ containerView        // 카드 컨테이너 (cornerRadius 16, continuous)
     └─ contentStack (VStack, insets: 22/16/20/16)
         ├─ titleLabel
         ├─ messageLabel (multiline, center)
         └─ buttonStack (HStack, spacing 8, fillEqually)
             ├─ cancelButton
             └─ confirmButton</code></pre><h3 id="스타일링-포인트">스타일링 포인트</h3>
<pre><code>•    Typography 시스템을 그대로 사용:
•    titleLabel → .headingLg
•    messageLabel → .bodyLg(.regular)
•    버튼 타이틀 → .labelLg(.semibold)
•    팔레트 적용:
•    cancel: 배경 .gray100, 타이틀 .gray900
•    confirm: 배경 .error, 타이틀 .appWhite
•    라운딩: container 16, 버튼 12 (continuous curve)</code></pre><h3 id="자동-레이아웃">자동 레이아웃</h3>
<pre><code>•    center 스타일: center.equalToSuperview() + 좌우 inset 20
•    bottom 스타일: leading/trailing inset 12 + safeArea.bottom = 8
•    contentStack는 내부에서 마진 관리 + width ≤ 500로 아이패드/가로모드 대응</code></pre><h3 id="상호작용--애니메이션">상호작용 &amp; 애니메이션</h3>
<h4 id="바인딩">바인딩</h4>
<pre><code>•    바깥 탭(딤) → 닫힘
•    취소 버튼 → 닫힘
•    확인 버튼 → 가벼운 햅틱 + 닫힘 후 confirmHandler() 호출</code></pre><h4 id="등장퇴장-효과">등장/퇴장 효과</h4>
<pre><code>•    animateIn
•    dim: alpha 0 → 1 (0.2s)
•    container: y: +20, alpha 0에서 시작 → 스프링으로 자연스럽게 (0.28s, damping 0.9, velocity 0.6)
•    animateOut
•    dim: alpha 1 → 0 (0.18s)
•    container: alpha 0, y: +10로 살짝 밀면서 페이드</code></pre><p>시스템 알럿 대비 더 가벼운 느낌을 주고 햅틱으로 확신을 제공</p>
<h3 id="접근성--테스트성">접근성 &amp; 테스트성</h3>
<pre><code>•    VoiceOver 기본 흐름
•    컨트롤러 표시 시 포커스를 titleLabel로 이동시키는 개선余地(아래 TODO).
•    UI 테스트 식별자
•    cancelButton → &quot;podoAlert.cancel&quot;
•    confirmButton → &quot;podoAlert.confirm&quot;
•    Dynamic Type 준비
•    UILabel/UIButton에 attributedText를 쓰는 경우 **adjustsFontForContentSizeCategory = true**와 폰트 스케일러 적용이 필요(아래 개선안).</code></pre><p>⸻</p>
<h3 id="연결">연결</h3>
<pre><code>func showDeleteAlert(for timer: TimerModel) {
  PodoAlertController.presentDeleteTimerAlert(from: self) { [weak self] in
    guard let self else { return }
    do {
      try repository.delete(id: timer.timerID)
      // UI 갱신
      self.reload()
    } catch {
      // 에러 핸들링 (토스트/얼럿 등)
    }
  }
}</code></pre><h3 id="트러블슈팅--배운-점">트러블슈팅 &amp; 배운 점</h3>
<pre><code>1.    overFullScreen 없이 투명 배경 안 나오는 문제
•    기본 프레젠테이션은 배경을 불투명하게 처리할 수 있음 → overFullScreen으로 해결.
2.    상위 컨트롤러에서 present 시 애니메이션 이중 적용
•    animated: false로 present 후 내부에서 custom 애니메이션.
3.    스택 내부 spacing/마진 꼬임
•    isLayoutMarginsRelativeArrangement = true + layoutMargins로 일원화하니 레이아웃 충돌이 줄어듦.
4.    바텀시트 키보드 이슈(향후 확장)
•    바텀시트 모드에 텍스트필드를 추가할 경우 키보드 높이만큼 bottom 보정 필요. 현재 구조는 제약 변경으로 대응 가능.</code></pre><hr>
<h2 id="계기">계기</h2>
<p>기본 UIAlertController로 끝내려 했지만
디자인 요구사항을 반영하려면 제약이 커서 커스텀 알럿 컨트롤러로 전환</p>
<h2 id="마주한-문제">마주한 문제</h2>
<ol>
<li>투명 배경이 안 나옴
기본 모달 프레젠테이션(pageSheet 등)에서 백그라운드가 불투명하게 처리됨.</li>
<li>present 애니메이션이 이중 적용
상위에서 present(animated: true) + 내부 UIView.animate가 겹쳐 보임.</li>
<li>StackView 간격/마진 꼬임
arrangedSubview 개별 inset/constraint와 스택 spacing이 뒤엉켜 레이아웃 튐.</li>
<li>바텀시트 + 키보드 충돌
텍스트 입력 시 키보드가 시트 하단을 가림.</li>
</ol>
<h2 id="해결-과정">해결 과정</h2>
<h3 id="1-투명-배경">1) 투명 배경</h3>
<p>프레젠테이션을 덮기 모드로 강제.</p>
<pre><code class="language-swift">let vc = PodoAlertController(...)
vc.modalPresentationStyle = .overFullScreen   // &lt;- 투명 배경/커스텀 딤 뷰 가능
vc.modalTransitionStyle = .crossDissolve
present(vc, animated: false)                  // 이중 애니 방지(아래 2번과 연결)</code></pre>
<p>.overCurrentContext를 쓸 경우엔 presenting 쪽 definesPresentationContext = true 필요 iPad나 iOS13+ 기본 pageSheet는 의도치 않은 반투명 시트가 되기 쉬워서 .overFullScreen이 안전.</p>
<h3 id="2-애니메이션-이중-적용-방지">2) 애니메이션 이중 적용 방지</h3>
<p>외부는 정적 표시, 내부에서만 트랜지션 제어.</p>
<pre><code class="language-swift">// 외부
present(vc, animated: false)

// 내부 커스텀 인/아웃
func animateIn() {
  containerView.transform = CGAffineTransform(translationX: 0, y: 20)
  dimView.alpha = 0
  UIView.animate(withDuration: 0.22, delay: 0, options: [.curveEaseOut]) {
    self.dimView.alpha = 1
    self.containerView.transform = .identity
  }
}

func animateOut(completion: @escaping () -&gt; Void) {
  UIView.animate(withDuration: 0.18, delay: 0, options: [.curveEaseIn]) {
    self.dimView.alpha = 0
    self.containerView.alpha = 0
    self.containerView.transform = CGAffineTransform(translationX: 0, y: 10)
  } completion: { _ in completion() }
}</code></pre>
<h3 id="3-stackview-간격마진-일원화">3) StackView 간격/마진 일원화</h3>
<p>스택뷰의 margin만 진리로 삼고, 자식 뷰 개별 inset은 제거</p>
<pre><code class="language-swift">let vStack = UIStackView(arrangedSubviews: [titleLabel, messageLabel, buttonsStack])
vStack.axis = .vertical
vStack.spacing = 12
vStack.alignment = .fill
vStack.distribution = .fill

vStack.isLayoutMarginsRelativeArrangement = true
vStack.layoutMargins = UIEdgeInsets(top: 16, left: 20, bottom: 16, right: 20)

// 버튼 스택도 동일 원칙
let buttonsStack = UIStackView(arrangedSubviews: [cancelButton, confirmButton])
buttonsStack.axis = .vertical
buttonsStack.spacing = 8
buttonsStack.isLayoutMarginsRelativeArrangement = true
buttonsStack.layoutMargins = .zero</code></pre>
<p>spacing과 margins의 역할을 분리하고 자식에 임의 constraint/inset을 섞지 않기</p>
<h3 id="4-바텀시트-키보드-대응">4) 바텀시트 키보드 대응</h3>
<p>키보드 프레임 변경 시 bottom 제약을 실시간 업데이트</p>
<pre><code class="language-swift">private var bottomC: Constraint!

containerView.snp.makeConstraints {
  $0.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(20)
  bottomC = $0.bottom.equalTo(view.safeAreaLayoutGuide).inset(20).constraint
}

override func viewDidLoad() {
  super.viewDidLoad()
  NotificationCenter.default.addObserver(self,
    selector: #selector(keyboardChanged(_:)),
    name: UIResponder.keyboardWillChangeFrameNotification,
    object: nil)
}

@objc private func keyboardChanged(_ note: Notification) {
  guard
    let info = note.userInfo,
    let end = info[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect,
    let dur = info[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval,
    let curveRaw = info[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt
  else { return }

  let kb = view.convert(end, from: nil)
  let overlap = max(view.bounds.maxY - kb.origin.y, 0)
  bottomC.update(inset: 20 + overlap) // 기본 20 + 키보드 높이

  UIView.animate(withDuration: dur,
                 delay: 0,
                 options: UIView.AnimationOptions(rawValue: curveRaw &lt;&lt; 16)) {
    self.view.layoutIfNeeded()
  }
}</code></pre>
<p>additionalSafeAreaInsets.bottom = overlap도 가능. 스크롤 콘텐츠면 contentInset 업데이트도 선택지</p>
<p>= 디자인 요구사항을 충족하는 커스텀 알럿 완성(?)
투명 딤/카드 애니메이션/간결한 레이아웃/키보드 확장성까지 기반 마련함</p>
<pre><code>•    .overFullScreen이 커스텀 알럿의 전제조건이다
•    애니메이션의 내부 제어가 가장 깔끔하고 예측 가능하다.
•    스택뷰는 layoutMargins 기반 일원화가 레이아웃 충돌을 줄인다.
•    바텀시트는 초기에 키보드 플랜(노티→제약 업데이트)을 설계해두면 확장 비용이 낮다.</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[TimerRepository, SwiftDataManager]]></title>
            <link>https://velog.io/@dev_dlfrl/TimerRepository-SwiftDataManager</link>
            <guid>https://velog.io/@dev_dlfrl/TimerRepository-SwiftDataManager</guid>
            <pubDate>Wed, 27 Aug 2025 11:53:14 GMT</pubDate>
            <description><![CDATA[<p>한 거</p>
<ul>
<li><p>TimerRepository 프로토콜 정의
→ fetchAll, insert, update, delete 추상화로 CRUD 규격 확립</p>
</li>
<li><p>SwiftDataManager 구현
→ TimerRepository 채택, @Dependency(.modelContext)를 이용해 ModelContext 주입
→ 실제 SwiftData 기반의 CRUD 메서드 구현 완료</p>
</li>
<li><p>에러 처리를 RepositoryError enum으로 정리
→ entityNotFound, saveFailed, fetchFailed 케이스 정의
→ LocalizedError 채택해 사용자 친화적인 에러 메시지 제공</p>
</li>
<li><p>의존성 역전 : ViewModel이나 VC에서는 TimerRepository만 바라보므로, 저장소 구현체를 교체하거나 확장하기 쉬움</p>
</li>
<li><p>SwiftData FetchDescriptor 사용법:
predicate와 sortBy 조합으로 원하는 조건과 정렬 적용 가능
fetchLimit으로 단일 엔티티만 가져오는 최적화 가능</p>
</li>
<li><p>에러 처리 일원화 : 다양한 CRUD 과정에서 발생할 수 있는 문제를 RepositoryError 하나로 캡슐화해 관리가 용이</p>
</li>
</ul>
<p>처음에는 modelContext.save() 누락으로 데이터 반영이 안 됨 → try-catch 구문 추가
특정 엔티티 조회 시 fetchAll을 반복 호출하는 비효율 발견 → fetch(by:) 헬퍼 메서드로 리팩터링</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[목표시간 Picker 구현]]></title>
            <link>https://velog.io/@dev_dlfrl/%EB%AA%A9%ED%91%9C%EC%8B%9C%EA%B0%84-Picker-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@dev_dlfrl/%EB%AA%A9%ED%91%9C%EC%8B%9C%EA%B0%84-Picker-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Tue, 26 Aug 2025 13:11:30 GMT</pubDate>
            <description><![CDATA[<p>기본 높이 113pt에서 값만 보이다가 탭하면 312pt로 확장돼서 UIPickerView로 시간을 고르는 UX 필요.
분은 고정 라벨, 숫자만 스크롤. 기본 선택은 50분, 항목은 5분 단위.</p>
<blockquote>
<p>중앙 흰색 밴드(Selection Band) 제거 → 시스템 피커의 중앙 5행 자연스러운 강조 사용.
분은 피커 외부의 고정 UILabel로 처리 → 행간/정렬 이슈 제거
접힘/펼침 전환은 height 제약 업데이트 + 애니메이션
가시성은 피커/고정라벨/접힘라벨 show/hide로 전환</p>
</blockquote>
<pre><code class="language-swift">// 데이터
let minuteOptions = Array(stride(from: 5, through: 180, by: 5))
var selectedMinutes = 50

// 접힘/펼침 상태
private var isPickerExpanded = false
private var goalHeightConstraint: Constraint?

// 접힘용 중앙 라벨
private let collapsedValueLabel = UILabel()

// 고정 &quot;분&quot; 라벨
private let unitLabel = UILabel().then {
  $0.attributedText = Typography.attributed(&quot;분&quot;, style: .headingXl(weight: .bold), color: .appBlack)
}

// 피커
private lazy var minutePicker = UIPickerView().then {
  $0.dataSource = self
  $0.delegate = self
  $0.showsSelectionIndicator = false
}</code></pre>
<pre><code class="language-swift">// 제약: 컨테이너 높이(113 → 312로 전환)
goalContainerView.snp.makeConstraints {
  goalHeightConstraint = $0.height.equalTo(113).constraint
}

// “분” 고정 라벨은 오른쪽, 피커는 왼쪽-중앙
unitLabel.snp.makeConstraints {
  $0.centerY.equalToSuperview()
  $0.trailing.equalToSuperview().inset(16)
}
minutePicker.snp.makeConstraints {
  $0.centerY.equalToSuperview()
  $0.leading.equalToSuperview()
  $0.trailing.equalTo(unitLabel.snp.leading).offset(-8)
}</code></pre>
<pre><code class="language-swift">// 토글 액션
@objc private func togglePicker() {
  isPickerExpanded.toggle()
  goalHeightConstraint?.update(offset: isPickerExpanded ? 312 : 113)
  unitLabel.isHidden = !isPickerExpanded
  minutePicker.isHidden = !isPickerExpanded
  collapsedValueLabel.isHidden = isPickerExpanded
  UIView.animate(withDuration: 0.25) { self.view.layoutIfNeeded() }
}</code></pre>
<pre><code class="language-swift">// Picker 표시 스타일: 선택행만 진하게
private func minuteAttributed(_ value: Int, selected: Bool) -&gt; NSAttributedString {
  Typography.attributed(&quot;\(value)&quot;,
                        style: .displayMd(weight: .bold),
                        color: selected ? .appBlack : Palette.Gray.g400)
}

func pickerView(_ pickerView: UIPickerView, viewForRow row: Int, forComponent comp: Int, reusing view: UIView?) -&gt; UIView {
  let label = (view as? UILabel) ?? UILabel()
  label.attributedText = minuteAttributed(minuteOptions[row],
                                          selected: row == pickerView.selectedRow(inComponent: comp))
  label.textAlignment = .center
  return label
}

// 스크롤 중 가시 행들 리프레시
private func updateVisibleRowStyles() {
  let sel = minutePicker.selectedRow(inComponent: 0)
  for off in -3...3 {
    let r = sel + off
    guard (0..&lt;minuteOptions.count).contains(r),
          let label = minutePicker.view(forRow: r, forComponent: 0) as? UILabel else { continue }
    label.attributedText = minuteAttributed(minuteOptions[r], selected: r == sel)
  }
}</code></pre>
<p>밴드를 쓰면 행간/정렬 흔들림 → 고정 분 라벨로 해결.
viewForRow에서 라벨 재사용 시 속성 누락 주의 → attributedText 매번 갱신
저장/선택시 collapsedValueLabel 동기화 필요 → updateCollapsedLabelText() 호
높이 변경 전 isHidden만 바꾸면 오토레이아웃 충돌 가능 → 제약 업데이트 + animate 순서로 처리</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[이모지랑 이모지 Frame 정렬]]></title>
            <link>https://velog.io/@dev_dlfrl/%EC%9D%B4%EB%AA%A8%EC%A7%80%EB%9E%91-%EC%9D%B4%EB%AA%A8%EC%A7%80-Frame-%EC%A0%95%EB%A0%AC</link>
            <guid>https://velog.io/@dev_dlfrl/%EC%9D%B4%EB%AA%A8%EC%A7%80%EB%9E%91-%EC%9D%B4%EB%AA%A8%EC%A7%80-Frame-%EC%A0%95%EB%A0%AC</guid>
            <pubDate>Mon, 25 Aug 2025 12:35:39 GMT</pubDate>
            <description><![CDATA[<p>UILabel이랑 뒤에 배경 뷰를 정렬할 때 이모지가 중앙 정렬이 안 되고 좀 치우쳐 있는 일이 있었음
이모지가 폰트마다 baseline/ascender 차이가 큰 편이라 center 정렬만 하니까 시각적으로 좀 안 맞는 듯 보였음
특히 iOS에서 이모지가 텍스트 글리프 취급 -&gt; bounding box가 예상보다 크거나 작을 수 있다고 함</p>
<p>iconLabel을 frameView의 center에 맞추지 않고 오토레이아웃으로 leading/trailing/top/bottom inset을 줘서 고정을 해서 고침</p>
<pre><code class="language-swift">iconLabel.snp.makeConstraints {
    $0.center.equalToSuperview() // 단순 중앙 정렬
}
// 대신
iconLabel.snp.makeConstraints {
    $0.edges.equalToSuperview().inset(4) // 여백으로 보정
}</code></pre>
<hr>
<p>여러 UILabel을 UIStackView에 넣고 alignment = .firstBaseline으로 설정했을 때 앱이 실행 중에 크래시가 났음
firstBaseline/lastBaseline 정렬은 arrangedSubview가 intrinsicContentSize를 가진 텍스트 계열 뷰여야 했음 근데 UIView 같이 baseline이 없는 뷰를 같이 넣으면서 충돌이 나서 크래시가 났던 것임
Baseline 정렬이 필요한 라벨들만 따로 StackView 구성하고 이모지 frameView 같은 non-text 뷰는 다른 StackView에 감싸거나 별도 제약으로 정렬해야함</p>
<pre><code class="language-swift">let textStack = UIStackView(arrangedSubviews: [numberLabel, unitLabel])
textStack.alignment = .firstBaseline

// 이모지 + 텍스트 스택은 별도
let mainStack = UIStackView(arrangedSubviews: [emojiFrameView, textStack])
mainStack.alignment = .center</code></pre>
<p>baseline 차이로 인한 어긋남 해결 → 가독성 개선
baseline이 없는 뷰와 라벨을 따로 둬서 안정성을 확보함</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Design System 정리 - Color & Font 모듈화]]></title>
            <link>https://velog.io/@dev_dlfrl/Design-System-%EC%A0%95%EB%A6%AC-Color-Font-%EB%AA%A8%EB%93%88%ED%99%94</link>
            <guid>https://velog.io/@dev_dlfrl/Design-System-%EC%A0%95%EB%A6%AC-Color-Font-%EB%AA%A8%EB%93%88%ED%99%94</guid>
            <pubDate>Thu, 21 Aug 2025 14:12:58 GMT</pubDate>
            <description><![CDATA[<p>색상, 폰트 스타일을 디자인 시스템으로 분리해서 구조화함
기존에는 Asset에 Color만 등록해서 사용했는데 이번에는 Color와 Font 관련 파일들을 만들어 코드로 일관성 있게 관리할 수 있도록 개선해봄</p>
<h2 id="기존-방식-asset에만-등록한-경우">기존 방식 (Asset에만 등록한 경우)</h2>
<p>색상은 Assets에만 등록해 사용 :</p>
<pre><code class="language-swift">view.backgroundColor = UIColor(named: &quot;Violet500&quot;)
titleLabel.textColor = UIColor(named: &quot;Gray200&quot;)</code></pre>
<p>폰트도 별도 스타일 없이 직접 지정 :</p>
<pre><code class="language-swift">label.font = UIFont(name: &quot;Pretendard-SemiBold&quot;, size: 20)</code></pre>
<p>이름 오타 시 컴파일 에러 없음</p>
<p>색상 이름이나 폰트 크기 일관성 관리 어려움</p>
<p>하나 바꾸려면 찾아서 하나하나 다 바꿔야 함 (폰트 진짜 헬)</p>
<p>다크 모드 대응 등 복잡한 처리가 어려움</p>
<h2 id="개선된-방식-designsystem-폴더-분리">개선된 방식 (DesignSystem 폴더 분리)</h2>
<blockquote>
<p>📁 DesignSystem
  ├── 📁 Color
  │   ├── ColorBook.swift
  │   ├── GrayColor.swift
  │   ├── VioletColor.swift
  │   └── PaletteApp.swift
  └── 📁 Font
      ├── PodoItFont.swift
      └── PodoItFontStyle.swift</p>
</blockquote>
<h3 id="color-사용">Color 사용</h3>
<pre><code class="language-swift">view.backgroundColor = Palette.Violet.v500
titleLabel.textColor = Palette.Gray.g200</code></pre>
<h3 id="font-사용">Font 사용</h3>
<pre><code class="language-swift">titleLabel.attributedText = Typography.attributed(
  &quot;테스트 문장입니다.&quot;,
  style: .title1,
  color: Palette.Violet.v500
)</code></pre>
<p>디자인 시스템 분리는 유지보수에 도움이 됨. 팀 프로젝트에서 일관성 있는 UI/UX 유지에도 좋음.</p>
<p>enum 기반 접근은 오타 방지 및 자동완성 덕분에 생산성이 향상됨.</p>
<p>Asset은 이미지 위주로 유지하고, 색상/폰트는 코드로 통일해서 관리하는 구조가 가장 이상적.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[소셜 로그인 vs iCloud 로그인]]></title>
            <link>https://velog.io/@dev_dlfrl/%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-vs-iCloud-%EB%A1%9C%EA%B7%B8%EC%9D%B8</link>
            <guid>https://velog.io/@dev_dlfrl/%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-vs-iCloud-%EB%A1%9C%EA%B7%B8%EC%9D%B8</guid>
            <pubDate>Tue, 19 Aug 2025 12:42:08 GMT</pubDate>
            <description><![CDATA[<h2 id="1-소셜-로그인-apple--google--kakao">1. 소셜 로그인 (Apple / Google / Kakao)</h2>
<p>사용자가 선택한 외부 계정을 통해 인증</p>
<p>로그인 후 발급되는 토큰을 이용해 서버/DB랑 연동</p>
<p>보통 백엔드 서버 필요</p>
<h3 id="장점">장점</h3>
<p>iOS/애플 생태계에 한정되지 않고 안드로이드, 웹, 크로스 플랫폼까지 확장 가능</p>
<p>하나의 앱 계정을 여러 기기/OS에서 동일하게 사용 가능</p>
<p>사용자 식별이 명확 → user_id 기반 데이터 관리 용이</p>
<h3 id="단점">단점</h3>
<p>서버 운영/인증 관리가 필요해 구현 난이도 ↑</p>
<p>개인정보 처리/보안 고려 필요</p>
<h3 id="언제-좋나">언제 좋나?</h3>
<p>iOS 외 멀티 플랫폼 지원할 때</p>
<p>사용자 간 친구/커뮤니티 기능이 필요한 앱</p>
<p>데이터 소유권을 앱 계정 단위로 명확히 해야 할 때</p>
<h2 id="2-icloud-cloudkit--apple-id-기반">2. iCloud (CloudKit / Apple ID 기반)</h2>
<p>Apple ID 자동 인증 (사용자가 따로 로그인할 필요 없음)</p>
<p>iOS 기기 간 데이터가 자동 동기화</p>
<p>별도의 서버 구축 불필요 → Apple의 CloudKit DB 활용</p>
<h3 id="장점-1">장점</h3>
<p>MVP 개발 속도 빠름 (로그인 UI/백엔드 불필요)</p>
<p>기기 교체/분실 시에도 자동 복구 가능</p>
<p>애플 보안, 개인정보 보호 준수 → 안정성 높음</p>
<h3 id="단점-1">단점</h3>
<p>iOS/macOS 한정 → 안드로이드/웹 확장 불리</p>
<p>계정 소유권을 앱이 아니라 Apple ID에 의존</p>
<p>사용자 관리/통계, 마케팅 툴과의 연동은 제약 많음</p>
<h3 id="언제-좋나-1">언제 좋나?</h3>
<p>iOS 전용 앱 (특히 개인 생산성, 유틸리티, 학습 앱 등)</p>
<p>MVP 단계 → 빠른 검증이 필요할 때</p>
<p>로그인/계정 관리보다는 데이터 동기화/복구가 핵심일 때</p>
<h2 id="3-결론">3. 결론</h2>
<p>MVP 단계 : iCloud(CloudKit) → 구현 빨라서 사용자에게 바로 전달 가능</p>
<p>확장 단계 : 소셜 로그인 도입 → 플랫폼 확장, 사용자 관리/커뮤니티 기능 강화</p>
<p>혼합 가능 : 초기엔 iCloud로 → 나중에 계정 시스템 붙일 때 owner_id 마이그레이션</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[언제 MVVM, 언제 클린 아키텍처?]]></title>
            <link>https://velog.io/@dev_dlfrl/%EC%96%B8%EC%A0%9C-MVVM-%EC%96%B8%EC%A0%9C-%ED%81%B4%EB%A6%B0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98</link>
            <guid>https://velog.io/@dev_dlfrl/%EC%96%B8%EC%A0%9C-MVVM-%EC%96%B8%EC%A0%9C-%ED%81%B4%EB%A6%B0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98</guid>
            <pubDate>Mon, 18 Aug 2025 12:32:38 GMT</pubDate>
            <description><![CDATA[<h2 id="mvvm가-유리한-환경">MVVM가 유리한 환경</h2>
<blockquote>
<p>소규모/짧은 일정 : 1–3명, MVP·해커톤·프로토타입. 빨리 찍고 학습이 목적인 경우.
도메인 단순 : CRUD 위주, 복잡한 비즈니스 규칙/정책이 거의 없음.
변화 범위가 UI 중심 : 뷰/상호작용이 자주 바뀌지만, 핵심 규칙은 얕음.
테스트 범위가 제한적 : 핵심 1~2 로직만 단위 테스트면 충분.
외부 연동이 단순: 단일 REST API, 버전 변동 적음.
출시 후 대규모 확장 계획이 불확실 : 처음엔 가볍게 가고, 필요하면 이후 계층 분리.</p>
</blockquote>
<h2 id="클린-아키텍처가-유리한-환경">클린 아키텍처가 유리한 환경</h2>
<blockquote>
<p>중/대규모/장기 : 팀 4명+, 기능 라인 병렬 개발, 버전 2/3가 확정적.
도메인 복잡 : 결제/권한/캘린더·타이머 규칙/오프라인 동기화/여러 정책(국가별, 유저 타입별).
변화 축이 여러 개 : UI는 계속 바뀌고, 데이터 소스/비즈니스 규칙도 잦게 바뀜.
강한 테스트 요구 : 유즈케이스/도메인 단위로 UI 없이도 테스트하고 싶음.
외부 연동 다양 : REST + GraphQL + 로컬 캐시 + 백그라운드 작업 + 알림 등 데이터 소스 교체/복수화가 예정.
멀티 플랫폼 : iOS, watchOS, 위젯/익스텐션, 향후 macOS 공유 고려(도메인 재사용).</p>
</blockquote>
<p>규칙이 자주 바뀌거나 복잡해질 것 같다 → 클린</p>
<p>UI를 빨리 실험해야 한다. 규칙 단순 → MVVM</p>
<p>도메인 규칙이 3개 이상으로 얽혀 있다 (타이머 + 캘린더 + 알림 정책) → 클린</p>
<p>외부 데이터 소스 교체 가능성 높다 (CoreData↔Realm↔CloudKit) → 클린</p>
<p>팀원이 병렬 개발해야 한다 (UI/도메인/데이터 분업) → 클린</p>
<p>4주 미만 일정의 MVP, 데모, PoC → MVVM</p>
<p>유지보수자가 1~2명, 배포 후 큰 확장 계획 없음 → MVVM</p>
<h2 id="구조-비교">구조 비교</h2>
<h3 id="mvvm가벼운-형태">MVVM(가벼운 형태)</h3>
<p>View ↔ ViewModel ↔ Service/Repository(합쳐도 됨)</p>
<p>DI는 간단한 초기자 주입 정도.</p>
<p>폴더 : UI/Feature + Services + Models</p>
<h3 id="클린-아키텍처전형">클린 아키텍처(전형)</h3>
<p>Presentation(ViewModel)</p>
<p>Domain(Entities, UseCases, Repository Interfaces)</p>
<p>Data(Repository Impl, DataSources: Remote/Local)</p>
<p>DI 레이어 또는 컴포지션 루트 필요.</p>
<p>멀티 모듈이면 App / Presentation / Domain / Data 로 분리.</p>
<h2 id="보일러플레이트가-늘어나는-지점들-클린">보일러플레이트가 늘어나는 지점들 (클린)</h2>
<p>UseCase 파일 : 유즈케이스당 프로토콜/구현 1~2개씩 증가</p>
<p>Repository 인터페이스/구현 분리 : Domain에 인터페이스, Data에 구현</p>
<p>데이터 소스 계층화 : RemoteDataSource, LocalDataSource</p>
<p>매핑 코드 : DTO ↔ Entity ↔ ViewModel 변환자(Mapper)</p>
<p>DI/조립 코드 : 각 레이어 연결(팩토리/컴포지션 루트)</p>
<p>테스트 더블 : 인터페이스가 늘면서 Mock/Fake/Stub 파일 증가</p>
<p>대가 : 파일 수/초기 세팅은 늘지만, 변경 충격 최소화 + 테스트 용이 + 교체 용이를 얻음.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[AlarmApp]]></title>
            <link>https://velog.io/@dev_dlfrl/AlarmApp</link>
            <guid>https://velog.io/@dev_dlfrl/AlarmApp</guid>
            <pubDate>Wed, 13 Aug 2025 12:12:33 GMT</pubDate>
            <description><![CDATA[<h2 id="1-데이터-모델-설계">1. 데이터 모델 설계</h2>
<p>Alarm 구조체</p>
<pre><code class="language-swift">struct Alarm: Codable, Equatable, Identifiable {
  let id: UUID
  var time: String        // &quot;오전 7:00&quot; 형태
  var subtitle: String    // &quot;주중&quot;, &quot;월요일마다&quot; 등
  var isOn: Bool          // 알람 활성화 상태
}</code></pre>
<p>Codable : UserDefaults 저장을 위한 JSON 직렬화
Equatable : 변경사항 감지 및 중복 저장 방지
Identifiable : SwiftUI 호환성 및 고유 식별</p>
<h2 id="2-요일-선택-시스템">2. 요일 선택 시스템</h2>
<p>Weekday 열거형</p>
<pre><code class="language-swift">enum Weekday: Int, CaseIterable, Hashable {
  case mon = 0, tue, wed, thu, fri, sat, sun

  var shortKo: String { [&quot;월&quot;, &quot;화&quot;, &quot;수&quot;, &quot;목&quot;, &quot;금&quot;, &quot;토&quot;, &quot;일&quot;][rawValue] }
  var rawValueForCalendar: Int { [2, 3, 4, 5, 6, 7, 1][rawValue] }
}</code></pre>
<p>Calendar 매핑 : iOS Calendar는 1=일요일, 2=월요일 사용
한글 지원 : 한국어 요일명 자동 변환
Set 활용 : 선택된 요일들의 집합 연산</p>
<h2 id="3-요일-선택-ui-컴포넌트">3. 요일 선택 UI 컴포넌트</h2>
<p>WeekdaySelectorView</p>
<pre><code class="language-swift">final class WeekdaySelectorView: UIView {
  var selectedDays: Set&lt;Weekday&gt; = [] { didSet { updateAll() } }
  var onChange: ((Set&lt;Weekday&gt;) -&gt; Void)?

  // 반응형 레이아웃: 화면 크기에 따라 버튼 크기/간격 자동 조정
  private func relayoutForWidth(_ fullWidth: CGFloat) {
    let available = max(0, fullWidth - horizontalInset*2)
    let count = CGFloat(buttons.count)

    // 1) 기본 크기로 간격 계산
    var buttonSize = preferredSize
    var spacing = floor((available - count*buttonSize) / (count - 1))

    // 2) 간격이 너무 좁으면 버튼 크기 줄이기
    if spacing &lt; minSpacing {
      spacing = minSpacing
      buttonSize = floor((available - (count - 1)*spacing) / count)
      buttonSize = max(minSize, min(preferredSize, buttonSize))
    }
  }
}</code></pre>
<p>반응형 디자인 : 화면 크기 변화에 따른 자동 레이아웃 조정
didSet : 선택 상태 변경 시 자동 UI 업데이트
Closure 패턴 : 부모 뷰와의 데이터 동기화</p>
<h2 id="4-알람-편집-화면">4. 알람 편집 화면</h2>
<p>Mode 열거형으로 생성/편집 구분</p>
<pre><code class="language-swift">enum Mode { 
  case create
  case edit(Alarm) 
}

init(mode: Mode) {
  self.mode = mode
  super.init(nibName: nil, bundle: nil)
}</code></pre>
<p>Associated Value : 편집 모드일 때 기존 알람 데이터 전달
타입 안전성 : 컴파일 타임에 모드별 데이터 보장</p>
<h2 id="5-날짜시간-파싱-시스템">5. 날짜/시간 파싱 시스템</h2>
<p>다국어 지원 DateFormatter</p>
<pre><code class="language-swift">private lazy var parserKO: DateFormatter = {
  let f = DateFormatter()
  f.locale = Locale(identifier: &quot;ko_KR&quot;)
  f.dateFormat = &quot;a h:mm&quot;  // &quot;오전 7:00&quot;
  return f
}()

private lazy var parserEN: DateFormatter = {
  let f = DateFormatter()
  f.locale = Locale(identifier: &quot;en_US_POSIX&quot;)
  f.dateFormat = &quot;a h:mm&quot;  // &quot;AM 7:00&quot;
  return f
}()</code></pre>
<p>로케일별 포맷 : 한국어/영어 시간 표기 자동 변환
Lazy Loading : 필요할 때만 초기화하여 메모리 효율성
Fallback 처리 : 한국어 파싱 실패 시 영어로 재시도</p>
<h2 id="6-요일-문자열-변환-로직">6. 요일 문자열 변환 로직</h2>
<p>선택된 요일 → 표시 텍스트</p>
<pre><code class="language-swift">private func subtitleFromSelectedDays(_ selected: Set&lt;Weekday&gt;) -&gt; String {
  let count = selected.count

  // 특수 패턴 우선 처리
  let weekdays: Set&lt;Weekday&gt; = [.mon, .tue, .wed, .thu, .fri]
  let weekend: Set&lt;Weekday&gt; = [.sat, .sun]

  if selected == weekdays { return &quot;주중&quot; }
  if selected == weekend { return &quot;주말&quot; }

  // 개별 요일 처리
  if count == 1, let one = selected.first {
    return &quot;\(one.shortKo)요일마다&quot;
  }

  // 여러 요일: &quot;월, 화, 수&quot;
  let ordered = Weekday.allCases.filter { selected.contains($0) }
  return ordered.map { $0.shortKo }.joined(separator: &quot;, &quot;)
}</code></pre>
<p>Set 연산 : 집합 비교로 특수 패턴 감지
순서 보장 : Weekday.allCases 순서대로 정렬
사용자 친화적 : &quot;주중&quot;, &quot;주말&quot; 등 직관적인 표현</p>
<h2 id="7-알림-권한-관리">7. 알림 권한 관리</h2>
<p>UNUserNotificationCenter 권한 요청</p>
<pre><code class="language-swift">private func requestNotificationPermission() {
  UNUserNotificationCenter.current().getNotificationSettings { settings in
    guard settings.authorizationStatus != .authorized else { return }

    UNUserNotificationCenter.current().requestAuthorization(
      options: [.alert, .sound]
    ) { _, err in
      if let err = err { print(&quot;requestAuthorization error: \(err)&quot;) }
    }
  }
}</code></pre>
<p>권한 상태 확인 : 이미 허용된 경우 중복 요청 방지
비동기 처리 : 권한 요청 결과를 콜백으로 처리
에러 핸들링 : 권한 요청 실패 시 적절한 처리</p>
<h2 id="8-변경사항-감지-및-저장">8. 변경사항 감지 및 저장</h2>
<p>편집 모드에서 변경사항 확인</p>
<pre><code class="language-swift">case let .edit(old):
  var updated = old
  updated.time = display
  let sub = subtitleFromSelectedDays(selectedDays)
  if !sub.isEmpty { updated.subtitle = sub }

  // 변경사항이 없으면 저장하지 않음
  if updated == old {
    showNoChangesAlert()
    return
  }

  onSave?(updated)</code></pre>
<p>Equatable 활용 : 구조체 비교로 변경사항 감지
불필요한 저장 방지 : 실제 변경이 있을 때만 저장
사용자 피드백 : 변경사항 없을 때 알림 표시</p>
<h2 id="9-로컬-알림-스케줄링">9. 로컬 알림 스케줄링</h2>
<p>AlarmManager에서 알림 예약</p>
<pre><code class="language-swift">private func schedule(_ alarm: Alarm) {
  guard let tm = parseTimeComponents(from: alarm.time),
        let hour = tm.hour, let minute = tm.minute else { return }

  let content = UNMutableNotificationContent()
  content.body = alarm.subtitle.isEmpty ? &quot;알람&quot; : alarm.subtitle
  content.sound = UNNotificationSound(named: .init(&quot;radial.caf&quot;))

  // 요일별 반복 예약
  let weekdays = parseWeekdays(from: alarm.subtitle)
  for w in weekdays {
    var dc = DateComponents()
    dc.weekday = w
    dc.hour = hour
    dc.minute = minute
    let trigger = UNCalendarNotificationTrigger(dateMatching: dc, repeats: true)
    let req = UNNotificationRequest(identifier: &quot;\(base).weekday.\(w)&quot;, content: content, trigger: trigger)
    center.add(req)
  }
}</code></pre>
<p>시간 파싱 : &quot;오전 7:00&quot; → DateComponents 변환
요일별 반복 : UNCalendarNotificationTrigger로 주간 반복
고유 식별자 : 알람별로 고유한 알림 ID 생성</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[면접스터디 3회차]]></title>
            <link>https://velog.io/@dev_dlfrl/%EB%A9%B4%EC%A0%91%EC%8A%A4%ED%84%B0%EB%94%94-3%ED%9A%8C%EC%B0%A8</link>
            <guid>https://velog.io/@dev_dlfrl/%EB%A9%B4%EC%A0%91%EC%8A%A4%ED%84%B0%EB%94%94-3%ED%9A%8C%EC%B0%A8</guid>
            <pubDate>Tue, 12 Aug 2025 14:25:41 GMT</pubDate>
            <description><![CDATA[<p>iOS</p>
<ul>
<li>질문 : Hot Observable과 Cold Observable의 차이는 무엇인가요?</li>
<li>나의 답변 : Cold Observable은 구독을 시작해야 데이터 흐름이 발생하는 Observable이고,  Hot Observable은 구독 여부와 관계없이 데이터가 흘러가고 있는 Observable 입니다</li>
</ul>
<p>iOS:</p>
<ul>
<li>질문 : UIView와 CALayer의 차이점</li>
<li>나의 답변 : UIView는 화면에 표시되는 UI요소의 기본 클래스이고 사용자 이벤트 처리를 지원합니다 CALayer는 실제 화면에 그려지는 그래픽, 애니메이션을 담당합니다 사용자 이벤트 처리를 지원하지 않고 뷰의 시각적 표현을 관리하는 데 사용됩니다</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[면접 스터디 2회차 회고]]></title>
            <link>https://velog.io/@dev_dlfrl/%EB%A9%B4%EC%A0%91-%EC%8A%A4%ED%84%B0%EB%94%94-2%ED%9A%8C%EC%B0%A8-%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@dev_dlfrl/%EB%A9%B4%EC%A0%91-%EC%8A%A4%ED%84%B0%EB%94%94-2%ED%9A%8C%EC%B0%A8-%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Thu, 07 Aug 2025 13:10:33 GMT</pubDate>
            <description><![CDATA[<ul>
<li><p>Swift:</p>
<ul>
<li>질문 : @escaping 클로저가 무엇이며, 언제 사용하나요?<ul>
<li>나의 답변 : swift에서 클로저는 기본적으로 non-escaping이라 함수의 스코프 내에서 실행이 끝나야 하는데 클로저가 함수의 실행이 끝난 이후에도 실행될 수 있는 경우에 @escaping을 사용합니다</li>
</ul>
</li>
</ul>
</li>
<li><p>iOS:</p>
<ul>
<li>질문 : retain cycle이 무엇인가요?<ul>
<li>나의 답변 : 두 객체가 서로를 강하게 참조하고 있어서 ARC)가 객체들을 메모리에서 해제하지 못하는 상태</li>
</ul>
</li>
</ul>
</li>
<li><p>iOS: NotificationCenter는 언제, 왜 사용하나요?</p>
<ul>
<li>질문 : 앱 내부에서 비동기적으로 이벤트를 전달 할 때 사용합니다 한 객체에서 발생한 이벤트를 여러 객체들에게 알릴 수 있는 방식입니다 화면 간 의존성을 줄이기 위해 delegate나 closure 대신 사용되기도 합니다</li>
</ul>
</li>
</ul>
<p>8/8 금요일 모의 면접</p>
<ul>
<li>retain cycle이 무엇인가요?<ul>
<li>두 개의 객체가 서로 강한 참조를 하고 있어서 ARC가 메모리에서 해제하지 못하는 상태 입니다</li>
</ul>
</li>
<li>ReactorKit은 무엇인가요?<ul>
<li>RxSwift 기반의 단방향 데이터 흐름을 따르는 아키텍처 프레임워크 입니다</li>
</ul>
</li>
<li>sync/async의 차이는 무엇인가요?<ul>
<li>Sync는 순차적으로 한 작업이 완료돼야 다음 작업으로 넘어가고 Async는 호출한 함수가 바로 반환됩니다</li>
</ul>
</li>
<li>async/await에 대해 설명해주세요!<ul>
<li>Async/Await은 비동기 코드를 동기 코드처럼 작성할 수 있게 하는 것 입니다</li>
</ul>
</li>
<li>Behavior Relay와 Publish Relay의 차이점에 대해서 설명해주세요!<ul>
<li>BehaviroRelay는 생성했을 때 초기값을 넣어야 하고 PublishRelay는 초기값이 필요없고 빈 상태로 시작합니다</li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[알람 틀 잡기]]></title>
            <link>https://velog.io/@dev_dlfrl/%EC%95%8C%EB%9E%8C-%ED%8B%80-%EC%9E%A1%EA%B8%B0</link>
            <guid>https://velog.io/@dev_dlfrl/%EC%95%8C%EB%9E%8C-%ED%8B%80-%EC%9E%A1%EA%B8%B0</guid>
            <pubDate>Tue, 05 Aug 2025 12:40:37 GMT</pubDate>
            <description><![CDATA[<h2 id="알람">알람</h2>
<h3 id="알람-데이터-모델링">알람 데이터 모델링</h3>
<p>Alarm 모델을 정의하여 알람에 필요한 정보를 담는다</p>
<pre><code class="language-swift">struct Alarm {
    var id: UUID
    var time: Date
    var isEnabled: Bool
    var repeatDays: [Weekday] // 반복 요일
    var label: String
}</code></pre>
<p>CoreData로 저장</p>
<h3 id="알람-추가--ui">알람 추가 &amp; UI</h3>
<ul>
<li><p>알람 리스트 화면 (UITableView or UICollectionView)</p>
</li>
<li><p>각 셀에는 알람 시간, 설명, 스위치</p>
</li>
<li><p>알람 추가 화면</p>
</li>
<li><p>UIDatePicker로 시간 설정</p>
</li>
<li><p>요일 선택 (반복 설정)</p>
</li>
<li><p>알람 라벨 설정</p>
</li>
<li><p>저장 버튼 → 알람 저장 및 알림 예약</p>
</li>
</ul>
<h3 id="알람-onoff-스위치-구현">알람 on/off 스위치 구현</h3>
<ul>
<li><p>셀에서 스위치 toggle → 모델의 isEnabled 값 변경</p>
</li>
<li><p>isEnabled == true → 알림 예약</p>
</li>
<li><p>isEnabled == false → 예약된 알림 제거</p>
</li>
</ul>
<h2 id="알림-스케줄링-unusernotificationcenter">알림 스케줄링 (UNUserNotificationCenter)</h2>
<p>알림 권한</p>
<pre><code class="language-swift">UNUserNotificationCenter.current().requestAuthorization(...)</code></pre>
<p>알람 등록 시 UNNotificationRequest 생성</p>
<pre><code class="language-swift">let content = UNMutableNotificationContent()
content.title = &quot;알람&quot;
content.body = label
content.sound = .default

let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true/false)
let request = UNNotificationRequest(identifier: alarm.id.uuidString, content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request)</code></pre>
<p>반복 요일 설정 시 요일마다 UNNotificationRequest 여러 개 등록</p>
<p>알람 삭제 또는 off 시</p>
<pre><code class="language-swift">UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [alarm.id.uuidString])</code></pre>
<h2 id="알람-울릴-때-사운드-재생">알람 울릴 때 사운드 재생</h2>
<p>UNNotificationContent에 .sound = .default ?? .named(&quot;custom.caf&quot;) 설정</p>
<p>백그라운드에서 시스템 알림으로 울리므로 앱이 실행 중일 필요 없음</p>
<p>iOS 알람 벨소리 설정 및 구현 분석</p>
<h3 id="기본-시스템-사운드-사용-unnotificationsound">기본 시스템 사운드 사용 (UNNotificationSound)</h3>
<p>가장 간단한 방식은 iOS가 제공하는 기본 사운드를 재생하는 것</p>
<p>UNMutableNotificationContent에 .sound = .default 설정</p>
<h3 id="커스텀-사운드-사용">커스텀 사운드 사용</h3>
<ul>
<li><p>앱 번들에 포함되어 있어야 함</p>
</li>
<li><p>caf, aiff, wav 형식만 지원</p>
</li>
<li><p>용량은 30초 미만이어야 함 (30초 초과 시 무음 처리)</p>
</li>
<li><p>사운드 파일 추가</p>
</li>
</ul>
<h2 id="코어데이터">코어데이터</h2>
<p>식별(id)</p>
<p>시간(time)</p>
<p>반복 요일(repeatWeekdays)</p>
<p>레이블(label)</p>
<p>사운드(soundName)</p>
<p>스누즈(snoozeEnabled)</p>
<p>활성화(isEnabled)</p>
]]></description>
        </item>
    </channel>
</rss>