이 문서는 Claude Code가 참조하는 구현 명세입니다 Xcode 프로젝트 설정, 인프라, 배포 등 인간 작업은
docs/handoff/h3-prelaunch-manual.md를 참조하세요 CLAUDE.md의 코딩 규칙을 반드시 준수하세요
Models/Term.swift — AI 캐시 및 북마크용
@Model
class Term {
#Unique<Term>([\.keyword])
#Index<Term>([\.isBookmarked], [\.createdAt], [\.category])
var keyword: String // 정규화된 용어 (영문 소문자)
var aliases: [String] // 대체 표기 (한글, 풀네임 등)
var category: String // 카테고리 (동시성, 자료구조, 네트워크, DB, 패턴, 기타)
var summary: String // 한 줄 요약
var etymology: String // 어원 설명
var namingReason: String // 작명 이유
var source: String // "bundle" | "ai"
var isBookmarked: Bool
var createdAt: Date
init(keyword: String, aliases: [String] = [], category: String,
summary: String, etymology: String, namingReason: String,
source: String = "ai",
isBookmarked: Bool = false) {
self.keyword = keyword
self.aliases = aliases
self.category = category
self.summary = summary
self.etymology = etymology
self.namingReason = namingReason
self.source = source
self.isBookmarked = isBookmarked
self.createdAt = .now
}
}
// MARK: - TermEntry 변환
extension Term {
/// TermEntry → Term 변환 (aliases + category 보존 필수)
convenience init(from entry: TermEntry, source: String, isBookmarked: Bool = false) {
self.init(
keyword: entry.keyword.lowercased(),
aliases: entry.aliases,
category: entry.category,
summary: entry.summary,
etymology: entry.etymology,
namingReason: entry.namingReason,
source: source,
isBookmarked: isBookmarked
)
}
/// Term → TermEntry 역변환
func toEntry() -> TermEntry {
TermEntry(
keyword: keyword,
aliases: aliases,
category: category,
summary: summary,
etymology: etymology,
namingReason: namingReason
)
}
}SwiftData 마이그레이션 주의: 배포 전 개발 단계에서
category필드를 추가할 경우, 기존 SwiftData 저장소와 스키마 불일치가 발생할 수 있음. 개발자는 앱 삭제 후 재설치 또는 시뮬레이터 데이터 리셋으로 대응. 릴리즈 이후 필드를 추가하는 경우엔VersionedSchema+MigrationPlan필요.
Models/SearchHistory.swift
@Model
class SearchHistory {
#Unique<SearchHistory>([\.keyword])
#Index<SearchHistory>([\.searchedAt])
var keyword: String
var searchedAt: Date
init(keyword: String) {
self.keyword = keyword
self.searchedAt = .now
}
}Models/TermEntry.swift — 번들 DB + AI 응답 공통 DTO
struct TermEntry: Codable {
let keyword: String
let aliases: [String]
let category: String
let summary: String
let etymology: String
let namingReason: String
}TermEntry ↔ Term 변환 시 aliases + category를 반드시 포함할 것 변환은 Term.init(from:source:isBookmarked:)와 Term.toEntry()만 사용
카테고리 값 (번들 DB·AI 응답 공통 고정 집합):
동시성·자료구조·네트워크·DB·패턴·기타- 6개 외의 값을 허용하지 않음 (AI 응답 포함)
Models/TermResult.swift — 검색 결과 분기
enum TermResult {
case found(TermEntry)
case notDevTerm
case possibleTypo(String)
}Models/AIErrorResponse.swift — AI 오류 응답
struct AIErrorResponse: Codable {
let error: String // "NOT_DEV_TERM" | "POSSIBLE_TYPO"
let suggestion: String?
}Utils/Constants.swift
enum Constants {
static let reportEmail = "devetym@gmail.com"
// Anthropic API 공식 모델 ID — 변경 시 https://docs.anthropic.com 확인
static let claudeModel = "claude-sonnet-4-6"
static let apiTimeout: TimeInterval = 30
static let autocompleteDebounceMs: Int = 300
static let recentSearchLimit: Int = 5
}Utils/EnvironmentKeys.swift
import SwiftUI
/// SwiftUI .environment()에 프로토콜 타입을 직접 전달하면 컴파일 오류 발생
/// 반드시 커스텀 EnvironmentKey를 통해 TermServiceProtocol을 주입
private struct TermServiceKey: EnvironmentKey {
static let defaultValue: any TermServiceProtocol = PlaceholderTermService()
}
extension EnvironmentValues {
var termService: any TermServiceProtocol {
get { self[TermServiceKey.self] }
set { self[TermServiceKey.self] = newValue }
}
}
/// 기본값용 더미 — 실제 사용 시 반드시 DevEtymApp에서 실제 TermService로 교체
/// Preview에서는 MockTermService로 교체
@MainActor
private class PlaceholderTermService: TermServiceProtocol {
func fetch(keyword: String) async throws -> TermResult { .notDevTerm }
func autocomplete(prefix: String) -> [TermEntry] { [] }
func toggleBookmark(for entry: TermEntry) throws -> Bool { false }
func bookmarkedTerms() -> [Term] { [] }
func recentSearches(limit: Int) -> [SearchHistory] { [] }
func deleteSearchHistory(_ keyword: String) throws {}
func clearAllSearchHistory() throws {}
}Resources/terms.json — 초기 20개 용어로 시작 (Phase 4에서 200개로 확장)
[
{
"keyword": "mutex",
"aliases": ["뮤텍스", "mutual exclusion"],
"category": "동시성",
"summary": "동시 접근을 막는 잠금 장치",
"etymology": "라틴어 mutuus(상호의) + exclusio(배제) → Mutual Exclusion의 줄임말",
"namingReason": "두 스레드가 동시에 같은 자원에 접근하지 못하도록 서로(mutual) 차단(exclusion)하는 개념에서 유래"
}
]스키마: keyword(필수), aliases(필수, 최소 1개), category(필수, 6개 값 중 하나), summary, etymology, namingReason
App/DevEtymApp.swift
@main
struct DevEtymApp: App {
let termService: TermService
init() {
let container = try! ModelContainer(for: Term.self, SearchHistory.self)
self.termService = TermService(modelContext: container.mainContext)
}
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.termService, termService)
}
}
}
.environment(\.termService, ...)— EnvironmentKey 기반 주입 ViewModel은@Environment(\.termService) var termService로 수신
✅ Phase 1 완료 조건: 모든 모델 파일 + EnvironmentKeys + DevEtymApp 컴파일 오류 없음
Services/BundleDBService.swift
protocol BundleDBServiceProtocol {
func search(keyword: String) -> TermEntry?
func autocomplete(prefix: String) -> [TermEntry]
}- terms.json을 앱 시작 시 1회 로드, 메모리 캐시
- search: keyword + aliases 대소문자 무시 완전 매칭
- autocomplete: keyword prefix 매칭 (타이핑 중 자동완성용)
func search(keyword: String) -> TermEntry? {
let normalized = keyword
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
return terms.first { entry in
entry.keyword.lowercased() == normalized ||
entry.aliases.contains(where: { $0.lowercased() == normalized })
}
}
func autocomplete(prefix: String) -> [TermEntry] {
let normalized = prefix.lowercased()
guard !normalized.isEmpty else { return [] }
return terms.filter { $0.keyword.lowercased().hasPrefix(normalized) }
}Services/ClaudeAPIService.swift
protocol ClaudeAPIServiceProtocol {
func generate(keyword: String) async throws -> TermEntry
}- 엔드포인트: POST
Constants.proxyBaseURL(백엔드 프록시devetym-proxy경유 — Anthropic 직접 호출 아님) - 모델: Constants.claudeModel
- API 키: 앱에 없음. 키는 프록시 서버 시크릿에만 존재한다.
- 기기 식별:
X-Device-Id헤더(DeviceIdentifier.current()— 익명 UUID)로 프록시가 기기당 일일 한도 강제 - 타임아웃: Constants.apiTimeout
일일 호출 한도:
- 프록시가 기기당 일일 한도(시작값 10회) 초과 시 HTTP 429 반환 → 앱은
.dailyLimitExceededthrow - 한도는 서버(KV)에서만 강제 (클라 카운터는 디컴파일로 우회되므로 비용 방어 불가)
에러 타입:
enum ClaudeAPIError: Error {
case dailyLimitExceeded
case timeout
case networkError(Error)
case invalidResponse
case notDevTerm
case possibleTypo(suggestion: String)
}시스템 프롬프트:
당신은 개발 용어의 어원을 설명하는 사전 데이터 제공자입니다
반드시 아래의 엄격한 JSON 형식으로만 응답해야 하며, 그 외의 어떤 텍스트나 마크다운(```)도 포함해서는 안 됩니다
[개발 용어인 경우의 응답 구조]
{
"keyword": "mutex",
"aliases": ["뮤텍스", "mutual exclusion"],
"category": "동시성",
"summary": "동시 접근을 막는 잠금 장치",
"etymology": "라틴어 mutuus(상호의) + exclusio(배제)",
"namingReason": "두 스레드가 동시에 접근하지 못하도록..."
}
[개발 용어가 아닌 경우의 응답 구조]
{"error": "NOT_DEV_TERM", "suggestion": null}
[개발 용어는 아니지만 오타로 추정되는 경우의 응답 구조]
{"error": "POSSIBLE_TYPO", "suggestion": "올바른 용어"}
[엄격한 출력 제한]
응답의 첫 글자는 반드시 '{'로 시작하고, 마지막 글자는 '}'로 끝나야 합니다
어떠한 경우에도 마크다운 백틱(```)이나 부연 설명을 텍스트에 포함하지 마세요
[카테고리 규칙]
- category 필드는 반드시 다음 6개 값 중 하나여야 합니다: "동시성", "자료구조", "네트워크", "DB", "패턴", "기타"
- 6개 분류에 애매하게 걸치는 경우 가장 핵심적인 분류를 선택하세요
- 어느 분류에도 명확히 속하지 않으면 "기타"를 사용하세요
[주의사항]
- 어원이 불확실한 경우 "정확한 어원은 불분명하나"로 시작하여 알려진 설만 서술하세요
- 추측이나 민간어원(folk etymology)을 사실처럼 서술하지 마세요
- 약어의 경우 반드시 각 글자가 무엇의 약자인지 명시하세요
응답 파싱 로직:
- content[0].text에서 앞뒤 공백 제거
json ...또는...마크다운 블록 감싸기가 있으면 정규식으로 제거 (프롬프트로 금지했으나 방어적 전처리)- 결과 문자열로 JSON 디코딩 시도
- "error" 키 존재 → AIErrorResponse로 디코딩
- NOT_DEV_TERM → throw ClaudeAPIError.notDevTerm
- POSSIBLE_TYPO → throw ClaudeAPIError.possibleTypo(suggestion:)
- "error" 키 없음 → TermEntry로 디코딩
- 디코딩 실패 → throw ClaudeAPIError.invalidResponse
Services/TermService.swift
@MainActor 필수: SwiftData mainContext는 메인 스레드 전용 async 작업(AI API 호출) 후 modelContext 접근 시 @MainActor가 없으면 크래시
@MainActor
protocol TermServiceProtocol {
// 검색
func fetch(keyword: String) async throws -> TermResult
func autocomplete(prefix: String) -> [TermEntry]
// 북마크
func toggleBookmark(for entry: TermEntry) throws -> Bool
func bookmarkedTerms() -> [Term]
// 히스토리
func recentSearches(limit: Int) -> [SearchHistory]
func deleteSearchHistory(_ keyword: String) throws
func clearAllSearchHistory() throws
}
@MainActor
class TermService: TermServiceProtocol {
private let modelContext: ModelContext
private let bundleDBService: BundleDBServiceProtocol
private let claudeAPIService: ClaudeAPIServiceProtocol
init(modelContext: ModelContext,
bundleDBService: BundleDBServiceProtocol = BundleDBService(),
claudeAPIService: ClaudeAPIServiceProtocol = ClaudeAPIService()) {
self.modelContext = modelContext
self.bundleDBService = bundleDBService
self.claudeAPIService = claudeAPIService
}
}모든 ViewModel은 이 프로토콜에만 의존한다 검색, 자동완성, 북마크, 히스토리 CRUD 모두 이 프로토콜을 통해 호출 ViewModel은 modelContext, BundleDBService, ClaudeAPIService를 직접 참조하지 않음
입력 정규화:
private func normalize(_ input: String) -> String {
input
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
}autocomplete 구현:
func autocomplete(prefix: String) -> [TermEntry] {
bundleDBService.autocomplete(prefix: prefix)
}fetch 오케스트레이션 순서:
- 입력 정규화
- 정규화 결과가 빈 문자열이면 즉시
.notDevTerm반환 (API 호출 안 함) - BundleDBService.search(keyword) → 히트 시
.found반환 + 히스토리 upsert - SwiftData에서 Term 조회 (keyword 매칭) → 히트 시
.found반환 + 히스토리 upsert - ClaudeAPIService.generate(keyword) 호출
- 성공 → SwiftData에 Term upsert (source: "ai", aliases 포함) + 히스토리 upsert +
.found반환 - .notDevTerm →
.notDevTerm반환 (히스토리 저장 안 함) - .possibleTypo →
.possibleTypo(suggestion)반환 (히스토리 저장 안 함) - 기타 에러 → throw (히스토리 저장 안 함)
- 성공 → SwiftData에 Term upsert (source: "ai", aliases 포함) + 히스토리 upsert +
SwiftData upsert 정책:
- Term upsert: 동일 keyword 존재 시 필드 업데이트 (isBookmarked, source 보존), 없으면 insert
- SearchHistory upsert: 동일 keyword 존재 시 searchedAt만 갱신, 없으면 insert
북마크 토글 (toggleBookmark):
toggleBookmark(for entry) →
SwiftData에 Term 존재? → isBookmarked 토글, 변경된 값 반환
미존재 (번들 용어)? → Term(from: entry, source: "bundle", isBookmarked: true) 저장, true 반환
bookmarkedTerms:
- SwiftData에서 isBookmarked == true인 Term 목록 반환
- createdAt 내림차순 정렬
히스토리 메서드:
- recentSearches(limit:) → searchedAt 내림차순, 상위 limit개 반환
- deleteSearchHistory(keyword:) → 해당 keyword의 SearchHistory 삭제
- clearAllSearchHistory() → 모든 SearchHistory 삭제
Tests/TermServiceTests.swift
- test_fetch_emptyInput_returnsNotDevTerm
- test_fetch_bundleHit_returnsImmediately
- test_fetch_bundleAlias_returnsCorrectTerm
- test_fetch_bundleMiss_callsClaudeAPI
- test_fetch_cachedResult_skipsAPI
- test_fetch_apiError_throwsError
- test_fetch_notDevTerm_returnsNotDevTerm
- test_fetch_possibleTypo_returnsSuggestion
- test_fetch_success_savesHistory
- test_fetch_failure_doesNotSaveHistory
- test_fetch_existingTerm_updatesFieldsPreservesBookmark
- test_autocomplete_delegatesToBundleDB
- test_toggleBookmark_existingTerm_togglesValue
- test_toggleBookmark_bundleTerm_createsTerm
- test_bookmarkedTerms_returnsOnlyBookmarked
- test_recentSearches_returnsInOrder
- test_deleteSearchHistory_removesEntry
- test_clearAllSearchHistory_removesAll
Tests/BundleDBServiceTests.swift
- test_search_exactKeyword_returnsEntry
- test_search_alias_returnsEntry
- test_search_caseInsensitive_returnsEntry
- test_search_notFound_returnsNil
- test_autocomplete_prefix_returnsMatches
- test_autocomplete_empty_returnsEmpty
Tests/ClaudeAPIServiceTests.swift
- test_generate_validTerm_returnsTermEntry
- test_generate_notDevTerm_throwsNotDevTerm
- test_generate_possibleTypo_throwsWithSuggestion
- test_generate_timeout_throwsTimeout
- test_generate_invalidJSON_throwsInvalidResponse
- test_generate_markdownWrappedJSON_parsesCorrectly
- test_generate_missingAPIKey_throwsInvalidAPIKey
✅ Phase 2 완료 조건: 모든 테스트 통과, Mock으로 API 호출 검증
모든 ViewModel은 TermServiceProtocol에만 의존한다
@Environment(\.termService)로 주입받아 사용 modelContext, BundleDBService, ClaudeAPIService 직접 참조 금지 SwiftData @Query 직접 사용 금지 모든 ViewModel은 @MainActor로 선언
상태 동기화: @Query를 사용하지 않으므로 데이터 변경 시 자동 반영되지 않음 북마크 토글, 히스토리 삭제 등 변경 액션 직후 ViewModel이 조회 메서드를 다시 호출하여 배열 갱신 모든 목록 View(Bookmark, History)는
.onAppear에서도 데이터 최신화
네비게이션: NavigationStack + .navigationDestination(for:) 패턴 다크모드: 시스템 설정 자동 대응, 커스텀 컬러는 Color asset 사용
참고: docs/design/wireframe.html
컬러 팔레트 (다크모드 우선, Asset Catalog에 등록):
bg=#0a0a0a— 앱 배경surface=#111111— 카드/섹션 배경surface2=#1a1a1a— 입력창/검색 박스border=#222222— 구분선accent=#c8f060— 주요 강조(카테고리 태그, CTA, 활성 탭)accent2=#60c8f0— 보조 강조(떠오르는 용어 등, P2)accentAI=#f0a060— AI 생성 뱃지 전용text=#f0f0f0— 본문textDim=#999999— 2차 본문textMuted=#666666— 라벨/캡션
라이트모드 값은 Asset Catalog의 Appearances에서 별도 지정. 이번 버전에선 다크모드만 완성도 있게 맞추고, 라이트모드는 시스템 기본 팔레트로 fallback 허용.
폰트 (번들에 포함):
DM Sans(Regular 400, Medium 500, Light 300) — 본문 기본DM Mono(Regular 400, Medium 500, Light 300) — 코드·라벨·칩·탭라벨DM Serif Display(Regular + Italic) — 용어명 large title, 섹션 타이틀
Google Fonts에서 OFL 라이선스로 다운로드하여 Resources/Fonts/에 포함.
Info.plist의 UIAppFonts 배열에 파일명 등록.
사용은 .font(.custom("DMSans-Regular", size: 13, relativeTo: .body)) 패턴으로 Dynamic Type 연계.
간격/반경 기본값:
- 기본 패딩: 14–18px
- 카드 radius: 12–14px
- pill radius: 5–20px (뱃지: 5px, 칩: 20px)
뱃지 스타일:
- 카테고리 태그:
accent컬러 + 8% opacity 배경 + 20% opacity 보더, radius 5px, DM Mono 9px, uppercase - AI 생성 뱃지:
accentAI컬러 동일 구조 - 최근 검색 칩:
surface2배경 + border, radius 20px, DM Mono 10px
App/ContentView.swift
TabView {
SearchView()
.tabItem { Label("검색", systemImage: "magnifyingglass") }
BookmarkView()
.tabItem { Label("북마크", systemImage: "bookmark") }
HistoryView()
.tabItem { Label("히스토리", systemImage: "clock") }
SettingsView()
.tabItem { Label("설정", systemImage: "gearshape") }
}네비게이션 상태 관리:
- SearchView가
@State private var path = NavigationPath()를 소유 - NavigationStack(path: $path)으로 바인딩
- DetailView push:
path.append(keyword) - possibleTypo 재검색 시:
path.removeLast()후 새 keywordpath.append(suggestion)
검색 Task 관리:
- SearchViewModel은
private var currentSearchTask: Task<Void, Never>?프로퍼티를 보유 - 새로운 검색 시작 시 기존 Task를
currentSearchTask?.cancel()로 취소 후 새 Task 할당 - 연타/빠른 재검색 시 레이스 컨디션 방지
상태:
- 검색어 입력 → 엔터/검색 버튼 → DetailView push
- 검색창 하단 안내 문구: "영문 개발 용어를 입력해주세요 (예: mutex, JPA, deadlock)"
- 타이핑 중 자동완성: termService.autocomplete(prefix:) → 드롭다운 리스트
- 디바운싱: 300ms (Task.sleep 또는 Combine debounce)
- 최소 입력 길이: 1자 이상
- 최근 검색 칩: termService.recentSearches(limit: 5) 호출
- 칩 탭 → 해당 용어로 DetailView push
.onAppear에서 최근 검색 목록 갱신
검색 Task 관리:
- DetailViewModel도
currentSearchTask를 보유하여 fetch 중복 호출 방지 - View가 사라질 때(onDisappear) 진행 중인 Task 취소
TermResult별 표시:
.found(TermEntry):
- 용어명 (large title)
- 카테고리 태그 배지 (accent 컬러 pill — 예: "동시성 · Concurrency")
- AI 생성 뱃지 (source가 "ai"인 경우에만, 오렌지 계열 accent pill — 예: "✦ AI 생성")
- 번들 DB 결과의 경우엔 표시하지 않음
- ViewModel이 DetailView에 TermEntry와 함께 source 값(또는 isAIGenerated Bool)을 전달
- 한 줄 요약
- 어원 블록 (좌측 accent 보더)
- 작명 이유 본문 — ScrollView로 감싸서 긴 텍스트 대응
- 액션 행: 북마크 버튼 + 공유 버튼(ShareLink)
- 북마크: termService.toggleBookmark(for:) 호출
- 공유: ShareLink로
"{keyword}\n\n{summary}\n\n— DevEtym"형식 텍스트 공유
- 하단 고정: 오류 제보 버튼
.notDevTerm:
- "개발 용어를 검색해주세요" 안내 화면
- 검색으로 돌아가기 버튼
.possibleTypo(suggestion):
- "{suggestion}을(를) 찾으셨나요?" 안내
- 추천 용어 탭 시 → 같은 DetailView를 replace (NavigationStack path 교체)
로딩 상태:
- 번들 DB 히트: 로딩 없음
- AI 생성 중: ProgressView + "어원을 분석하는 중..." 텍스트
에러 처리 (ViewModel에서 catch → 상태 변수로 Alert 표시):
- TermResult에 에러 케이스를 추가하지 않음 — 에러는 throw → ViewModel catch 패턴 유지
- ViewModel은
@Published var errorMessage: String?로 에러 상태 관리 - ClaudeAPIError 타입별 분기:
- .invalidAPIKey → "API 키 설정이 필요합니다"
- .timeout → "요청 시간이 초과되었습니다. 다시 시도해주세요"
- .networkError(let error) → URLError.code로 세분화:
- .notConnectedToInternet → "인터넷 연결을 확인해주세요"
- 기타 → "네트워크 연결이 불안정합니다. 다시 시도해주세요"
- .invalidResponse → "응답을 처리할 수 없습니다. 다시 시도해주세요"
- 기타 → "오류가 발생했습니다" + 제보 유도
- Alert dismiss 후 검색 화면으로 돌아가기
위치: DetailView 하단 고정
버튼 텍스트: "이 설명이 잘못됐나요? 오류 제보하기"
mailto 구성:
수신: Constants.reportEmail
제목: [오류제보] {keyword}
본문:
■ 용어: {keyword}
■ 출처: {source}
■ 요약: {summary}
■ 어원: {etymology}
■ 작명이유: {namingReason}
─────────────
어떤 부분이 잘못되었나요?
→
- termService.bookmarkedTerms()로 목록 조회
.onAppear에서 목록 갱신- 빈 상태: 안내 문구
- 각 항목은 keyword + 한 줄 미리보기(summary 또는 aliases 첫 요소) 를 함께 표시
- 항목 탭 → DetailView push
- 스와이프 삭제 → termService.toggleBookmark(for:) 호출 → 직후 목록 다시 조회
- termService.recentSearches(limit:)로 목록 조회
.onAppear에서 목록 갱신- 각 항목의
searchedAt은 상대 시간("방금 전", "1시간 전", "어제", "3일 전")으로 표시RelativeDateTimeFormatter(단위 자동) 사용, 한국어 로케일
- 항목 탭 → DetailView push
- 스와이프 삭제 → termService.deleteSearchHistory(_:) → 직후 목록 다시 조회
- 상단 "전체 삭제" 버튼 → termService.clearAllSearchHistory() → 직후 목록 다시 조회
- 앱 첫 실행 시 1회만 표시 (@AppStorage("hasSeenOnboarding") 플래그)
- 표시 내용:
- 앱 소개 (1-2문장)
- "이 앱의 모든 설명은 AI가 생성합니다. 오류가 있을 수 있으니 제보해 주세요."
- 시작하기 버튼
Features/Settings/SettingsView.swift
ViewModel 불필요 (로직 없음, 순수 UI + 시스템 API 호출만).
화면 구성 (List + Section 패턴):
외관
├ 화면 모드: 시스템 / 라이트 / 다크 (Picker)
앱 정보
├ 앱 버전: Bundle.main 값 표시 (CFBundleShortVersionString)
├ 빌드 번호: (CFBundleVersion)
지원
├ 개발자에게 문의 → mailto (Constants.reportEmail)
├ 앱 평가하기 → StoreKit requestReview 또는 App Store URL
├ 오류 제보 → mailto (Constants.reportEmail)
법적 고지
├ 오픈소스 라이선스 (DM Fonts — OFL)
├ AI 생성 고지: "이 앱의 모든 어원 설명은 AI가 생성합니다"
├ 개인정보 처리방침 → 외부 URL (추후 등록)
화면 모드 구현:
@AppStorage("appearanceMode") var appearanceMode: Int = 0- 0: 시스템, 1: 라이트, 2: 다크
- 앱 루트(ContentView 또는 DevEtymApp)에서
.preferredColorScheme()적용- 0 → nil (시스템 따라감), 1 → .light, 2 → .dark
앱 평가하기:
- iOS 16+:
@Environment(\.requestReview)사용 - 또는 App Store URL:
https://apps.apple.com/app/id{APP_ID}?action=write-review - 앱 출시 전에는 requestReview만 사용 (URL은 APP_ID 필요)
#Preview:
#Preview {
SettingsView()
.preferredColorScheme(.dark)
}✅ Phase 3 완료 조건: 모든 탭 화면 렌더링, 검색 → 결과 플로우 동작, 에러 Alert 분기 동작, 오류 제보 mailto 동작, 설정 화면에서 외관 전환 동작
Phase 3-3에서 정의한 에러 분기가 통합 환경에서도 정상 동작하는지 확인 네트워크 오류 감지는 URLError.code 기반 (NWPathMonitor 미사용)
- 모든 이미지/아이콘에 accessibilityLabel 추가
- Dynamic Type 지원 확인 —
.font(.custom(...))사용 시relativeTo:파라미터로 연계 - 다크/라이트모드 전환 시 전 화면 렌더링 검증
- 초기 20개 → Claude API 배치 생성 스크립트로 200개로 확장
- 각 용어에
category필드 필수, 값은 6개 고정 집합 중 하나 - 기존 20개 용어를 반드시 포함 (keyword/aliases 변경 금지)
- 스크립트: Scripts/generate_db.py
- 각 용어에 aliases 포함 필수 (빈 배열 금지, 최소 1개)
- 생성 후 JSON 유효성 + aliases 존재 여부 검증
디자인 자산:
docs/design/icon/assets/v2/icon.svg(v2 최종판) 검토 자료:docs/design/icon/icon_candidate_v2.html색상: 딥 그린#2E5D3A/ 크림#F7E8D0앱 표시 이름: "개발 어원 사전" (CLAUDE.md 기준) — 아이콘 타이포와 일치
PNG 익스포트 (single-size 방식)
v2는 단일 SVG가 1024→28px 모든 사이즈에서 식별 가능하도록 설계되어, 사이즈별 최적화 없이 1024×1024 PNG 하나만 제작하고 Xcode가 자동으로 다운스케일하도록 위임한다.
rsvg-convert -w 1024 -h 1024 docs/design/icon/assets/v2/icon.svg \
-o DevEtym/DevEtym/Assets.xcassets/AppIcon.appiconset/icon.pngAssets.xcassets 등록
- 경로:
DevEtym/DevEtym/Assets.xcassets/AppIcon.appiconset/ Contents.jsonsingle-size 스키마:idiom: "universal",platform: "ios",size: "1024x1024",filename: "icon.png"- Info.plist의
CFBundleIcons자동 배선 (Xcode가 Asset Catalog 사용 시)
검증 체크리스트
- 시뮬레이터 홈스크린에서 60pt 실루엣 확인
- 시뮬레이터 알림센터에서 28px 가독성 확인 (한글이 흐려도 딥 그린+크림 덩어리로 식별)
- 실기기(라이트·다크) 홈스크린에서 대비 확인
- Settings / Spotlight 40pt에서 식별
- App Store 미리보기용 1024 PNG 준비 완료
금지 사항
- 아이콘에 투명 영역 금지 (iOS 규정: 사각형 풀블리드 필요, squircle은 OS가 자동 마스킹)
- 라이트/다크 듀얼 아이콘 시도 (iOS 18 기본 아이콘은 컬러 고정, 딥 그린의 저명도가 자동 대비 확보)
디자인 자산:
docs/design/icon/assets/v2/launch-logo.svg(투명 배경, 로고만) 배경 색상:#2E5D3A(Theme/brand) 목적: 앱 아이콘 → 런치 화면 → 첫 화면의 시각 연속성 확보. 흰 화면(Xcode 자동 생성 빈 dict) 방지.
구성 (UILaunchScreen 방식, iOS 14+ 권장)
- 배경: 딥 그린 풀블리드 (
Theme/brand컬러셋) - 중앙 이미지: "개발어원 사전" 크림 로고 (
LaunchLogo이미지셋, 2x/3x PNG) - LaunchScreen.storyboard 사용하지 않음
Info.plist 키
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key><string>Theme/brand</string>
<key>UIImageName</key><string>LaunchLogo</string>
<key>UIImageRespectsSafeAreaInsets</key><true/>
</dict>INFOPLIST_KEY_UILaunchScreen_Generation = YES(Xcode 자동 생성)는 빈 dict만 만들어 흰 화면이 됨. Info.plist에 명시한 키가 우선 적용되도록 위 dict 추가 필요.
자산 익스포트
rsvg-convert -w 480 -h 480 docs/design/icon/assets/v2/launch-logo.svg \
-o DevEtym/DevEtym/Assets.xcassets/LaunchLogo.imageset/launch-logo@2x.png
rsvg-convert -w 720 -h 720 docs/design/icon/assets/v2/launch-logo.svg \
-o DevEtym/DevEtym/Assets.xcassets/LaunchLogo.imageset/launch-logo@3x.png검증 체크리스트
- 시뮬레이터 첫 실행 시 흰 화면 대신 딥 그린 + 로고 노출
- 노치/Dynamic Island 영역 침범 없음 (UIImageRespectsSafeAreaInsets)
- 라이트·다크 시스템 모두 동일 색상 (런치는 컬러 고정)
- 런치 → SearchView 전환 시 깜빡임 없음
금지 사항
- 런치 화면에 "Loading..." 텍스트, 스피너, 애니메이션 (Apple HIG 위반)
- 라이트/다크 색상 분기 (런치는 단일 컬러)
- 정적이지 않은 콘텐츠 (날짜·시간·사용자 데이터)
✅ Phase 4 완료 조건: 모든 Phase 1-3 기능 통합 동작, 오류 처리 완비, 앱 아이콘 적용 및 가독성 검증 완료, 런치 스크린 적용 완료