SwiftUI: iOS를 위한 모던 인터페이스 구축 가이드
SwiftUI로 모던 UI를 구축하는 방법을 알아봅니다. 선언적 구문, 컴포넌트, 애니메이션, iOS 18 모범 사례를 종합적으로 다룹니다.

SwiftUI는 Apple 플랫폼의 인터페이스 개발 방식을 근본적으로 바꾸었습니다. 선언적 구문과 네이티브 통합을 통해 이전보다 훨씬 적은 코드로 세련된 애플리케이션을 구축할 수 있습니다. iOS 18에서는 성능의 대폭적인 향상과 새로운 기능이 도입되었습니다.
SwiftUI는 iOS 18에서 완전한 성숙기에 접어들었습니다. 렌더링 최적화, 메모리 관리 개선, UIKit 통합 간소화가 이루어졌으며, 신규 iOS 애플리케이션 개발의 표준으로 자리 잡았습니다.
선언적 패러다임 이해하기
코드를 작성하기 전에 SwiftUI의 본질적인 특징을 파악해야 합니다. UIKit(기존 프레임워크)에서는 인터페이스를 단계별로 구축하도록 지시해야 했습니다. "레이블을 생성하고, 여기에 배치하고, 사용자가 클릭하면 색상을 변경하라"는 식입니다. 이것이 명령형 패러다임입니다.
SwiftUI는 다른 접근 방식을 취합니다. 표시하고자 하는 내용을 기술하면 프레임워크가 나머지를 처리합니다. 내비게이션에서 경로를 하나씩 안내하는 것(명령형)과 목적지만 말하는 것(선언형)의 차이와 유사합니다.
첫 번째 View 만들기
SwiftUI에서 모든 인터페이스 요소는 View입니다. View는 화면에 표시될 내용을 기술하는 struct입니다. 제목, 부제목, 버튼이 포함된 첫 번째 화면을 만들어 보겠습니다.
import SwiftUI
// Each screen is a struct that implements the View protocol
struct ContentView: View {
// The "body" property describes what the view displays
// "some View" means "a type of View, but Swift figures it out automatically"
var body: some View {
// VStack = "Vertical Stack": stacks elements vertically
// spacing: 20 = 20 points of space between each element
VStack(spacing: 20) {
// A simple text with style modifiers
Text("Welcome to SharpSkill")
.font(.largeTitle) // Large title font
.fontWeight(.bold) // Bold text
Text("Prepare for your iOS interviews")
.font(.subheadline) // Smaller font
.foregroundColor(.secondary) // Gray secondary color
// Button with an action (closure) and a label
Button("Get Started") {
print("Button tapped!")
}
.buttonStyle(.borderedProminent) // Filled blue button style
}
.padding() // Adds margins around the VStack
}
}이 구조를 살펴보면, 표시할 요소(텍스트, 버튼)를 선언하고, 세로로 배치하고(VStack), 모디파이어(.font(), .padding())로 스타일을 적용하고 있습니다.
핵심 포인트: SwiftUI에서는 UI 객체를 "생성"하는 것이 아니라, 원하는 인터페이스를 기술합니다. 실제 요소의 생성, 갱신, 소멸은 SwiftUI가 담당합니다.
모디파이어: View 변환하기
모디파이어는 View 뒤에 체이닝하여 변환을 적용하는 메서드입니다. Instagram 필터를 순서대로 적용하는 것과 비슷하다고 생각하면 됩니다. 순서가 중요합니다. 각 모디파이어가 이전 View를 감싸는 새로운 View를 생성하기 때문입니다.
순서가 왜 중요한지 보여주는 예제를 살펴보겠습니다.
// Example 1: padding THEN background
Text("SwiftUI")
.padding() // 1. Adds 16pt of space around the text
.background(.blue) // 2. Blue background covers text + padding
.foregroundColor(.white)
// Result: white text on blue rectangle with margins
// Example 2: background THEN padding (reversed order!)
Text("SwiftUI")
.background(.blue) // 1. Tight blue background around text only
.padding() // 2. Transparent padding around blue background
.foregroundColor(.white)
// Result: white text on small blue rectangle, surrounded by empty space차이점은 명확합니다. 첫 번째 예제에서는 padding이 배경의 "안쪽"에 있고, 두 번째에서는 "바깥쪽"에 있습니다. 미묘한 차이이지만 레이아웃을 정확하게 제어하는 데 필수적인 지식입니다.
Stack을 활용한 인터페이스 구성
SwiftUI는 View를 구성하기 위한 세 가지 주요 컨테이너를 제공합니다. 콘텐츠를 서로 다른 방식으로 배치하는 상자라고 생각하면 됩니다.
VStack, HStack, ZStack
- VStack (Vertical Stack): 요소를 위에서 아래로 쌓습니다
- HStack (Horizontal Stack): 요소를 왼쪽에서 오른쪽으로 나열합니다
- ZStack (Z축 Stack): 요소를 겹쳐서 배치합니다
세 가지 Stack을 모두 조합하여 사용자 프로필 카드를 구축해 보겠습니다. 사진 위에 인증 배지를 겹치고, 옆에 사용자 이름과 직책을 표시하는 것이 목표입니다.
struct ProfileCard: View {
var body: some View {
// Main HStack: photo on left, info on right
HStack(spacing: 16) {
// ZStack to overlay the badge on the photo
ZStack(alignment: .bottomTrailing) {
// Profile image (circle)
Image("avatar")
.resizable()
.frame(width: 60, height: 60)
.clipShape(Circle())
// Green "verified" badge at bottom right
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
.background(Circle().fill(.white)) // White circle behind
}
// VStack to stack name and role vertically
VStack(alignment: .leading, spacing: 4) {
Text("Marie Dupont")
.font(.headline)
Text("iOS Developer")
.font(.subheadline)
.foregroundColor(.secondary)
}
// Spacer pushes everything to the left
Spacer()
// Chevron on right indicates it's tappable
Image(systemName: "chevron.right")
.foregroundColor(.gray)
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(12)
}
}여기서 핵심은 Spacer()입니다. 사용 가능한 모든 공간을 차지하는 보이지 않는 요소입니다. Spacer가 없으면 요소들이 중앙에 배치되지만, Spacer를 사용하면 요소들이 왼쪽으로 밀려나고 셰브론이 오른쪽 끝에 고정됩니다.
Xcode에서 임의의 View를 ⌘ + 클릭하면 비주얼 인스펙터에 접근할 수 있습니다. 코드를 직접 입력하지 않고도 모디파이어를 추가할 수 있습니다.
@State와 @Binding을 활용한 상태 관리
상태 관리는 SwiftUI의 핵심입니다. 상태란 변경될 수 있으며 인터페이스를 갱신해야 하는 데이터를 의미합니다. 상태가 변경되면 SwiftUI는 영향을 받는 View를 자동으로 재계산합니다.
@State: View의 로컬 상태
@State는 프로퍼티 래퍼로, SwiftUI에 "이 변수를 감시하고, 변경되면 View를 갱신하십시오"라고 전달합니다. 단일 View의 로컬 상태에 적합합니다.
대화형 카운터를 만들어 이 메커니즘을 이해해 보겠습니다.
struct CounterView: View {
// @State creates a "source of truth" for this view
// private because state shouldn't be modified from outside
@State private var count = 0
var body: some View {
VStack(spacing: 30) {
// This Text updates automatically when count changes
Text("\(count)")
.font(.system(size: 72, weight: .bold))
HStack(spacing: 40) {
// Decrement button
Button(action: {
count -= 1 // Modifies state → view refreshes
}) {
Image(systemName: "minus.circle.fill")
.font(.largeTitle)
}
// Increment button
Button(action: {
count += 1
}) {
Image(systemName: "plus.circle.fill")
.font(.largeTitle)
}
}
}
}
}버튼을 탭하면 count가 변경됩니다. SwiftUI는 이 변경을 감지하고 body를 다시 실행하여 화면을 갱신합니다. 레이블을 수동으로 업데이트할 필요가 없습니다. 모든 것이 자동으로 처리됩니다.
@Binding: View 간 상태 공유하기
자식 View가 부모 View의 상태를 변경해야 하는 경우가 있습니다. 이때 @Binding이 필요합니다. 기존 @State에 대한 양방향 연결을 생성합니다.
구체적인 예제로, 실시간 유효성 검사가 포함된 사용자 이름 입력 필드를 살펴보겠습니다.
// Parent view: owns the state
struct SignupForm: View {
@State private var username = "" // Source of truth
@State private var isValid = false // Validation state
var body: some View {
VStack(spacing: 20) {
// We pass BINDINGS (with $) to the child view
UsernameField(username: $username, isValid: $isValid)
Button("Create Account") {
// Submit the form
}
.disabled(!isValid) // Disabled if invalid
.buttonStyle(.borderedProminent)
}
.padding()
}
}자식 View는 바인딩을 전달받아 변경할 수 있습니다.
// Child view: receives and modifies state via @Binding
struct UsernameField: View {
@Binding var username: String // Connection to parent's @State
@Binding var isValid: Bool
var body: some View {
VStack(alignment: .leading, spacing: 8) {
TextField("Username", text: $username)
.textFieldStyle(.roundedBorder)
.onChange(of: username) { oldValue, newValue in
// Validation: at least 3 characters
isValid = newValue.count >= 3
}
// Visual feedback
HStack {
Image(systemName: isValid ? "checkmark.circle" : "xmark.circle")
Text("Minimum 3 characters")
}
.font(.caption)
.foregroundColor(isValid ? .green : .red)
}
}
}사용자가 TextField에 입력하면 username이 바인딩을 통해 변경됩니다. 부모 View는 이 변경 사항을 인식하고 활용할 수 있습니다. 깔끔한 양방향 통신이 구현된 것입니다.
@State는 단순한 로컬 상태에만 사용해야 합니다. 여러 화면에 걸친 데이터나 복잡한 로직에는 @Observable(iOS 17 이상) 또는 MVVM 패턴을 사용하는 것을 권장합니다.
iOS 면접 준비가 되셨나요?
인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.
동적 리스트 표시하기
리스트는 모바일 앱 어디에나 존재합니다. SwiftUI는 네이티브 iOS 스타일링(구분선, 스와이프 액션 등)을 갖춘 데이터 컬렉션 표시를 위해 List를 제공합니다.
간단한 리스트 만들기
리스트를 표시하려면 데이터와 그것을 식별하는 방법 두 가지가 필요합니다. Identifiable 프로토콜을 통해 SwiftUI는 어떤 View가 어떤 데이터에 대응하는지 파악할 수 있습니다.
먼저 데이터 모델을 정의합니다.
// Identifiable lets SwiftUI track each element
struct Interview: Identifiable {
let id = UUID() // Auto-generated unique identifier
let technology: String
let difficulty: String
let questionCount: Int
}이제 리스트를 생성합니다. ForEach로 데이터를 반복 처리하고 각 요소에 대한 행을 생성하는 방식입니다.
struct InterviewListView: View {
// Data to display (in reality, this would come from an API)
@State private var interviews = [
Interview(technology: "iOS", difficulty: "Intermediate", questionCount: 25),
Interview(technology: "Android", difficulty: "Advanced", questionCount: 30),
Interview(technology: "React", difficulty: "Beginner", questionCount: 20)
]
var body: some View {
// NavigationStack enables the navigation bar
NavigationStack {
List {
// ForEach iterates over each interview
// Thanks to Identifiable, no need to specify id:
ForEach(interviews) { interview in
InterviewRow(interview: interview)
}
}
.navigationTitle("My Interviews")
}
}
}각 행의 View를 독립된 컴포넌트로 분리합니다. 가독성과 재사용성이 향상됩니다.
struct InterviewRow: View {
let interview: Interview
var body: some View {
HStack {
// Left column: title and subtitle
VStack(alignment: .leading, spacing: 4) {
Text(interview.technology)
.font(.headline)
Text(interview.difficulty)
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
// Badge with question count
Text("\(interview.questionCount) Q")
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.blue.opacity(0.1))
.cornerRadius(8)
}
.padding(.vertical, 4)
}
}InterviewRow를 독립된 struct로 분리함으로써 코드의 가독성과 재사용성이 크게 향상됩니다. 이것은 SwiftUI의 모범 사례입니다.
액션 추가: 삭제와 재정렬
iOS 리스트는 삭제(왼쪽 스와이프)와 재정렬(드래그 앤 드롭)을 기본적으로 지원합니다. SwiftUI에서는 .onDelete와 .onMove로 간단하게 구현할 수 있습니다.
struct InterviewListView: View {
@State private var interviews = [/* ... data ... */]
var body: some View {
NavigationStack {
List {
ForEach(interviews) { interview in
InterviewRow(interview: interview)
}
// Swipe to delete
.onDelete(perform: deleteInterview)
// Drag and drop to reorder
.onMove(perform: moveInterview)
}
.navigationTitle("My Interviews")
.toolbar {
// "Edit" button that activates edit mode
EditButton()
}
}
}
// Deletes elements at specified indices
private func deleteInterview(at offsets: IndexSet) {
interviews.remove(atOffsets: offsets)
}
// Moves elements from one position to another
private func moveInterview(from source: IndexSet, to destination: Int) {
interviews.move(fromOffsets: source, toOffset: destination)
}
}단 4줄의 추가 코드(.onDelete, .onMove, EditButton, 두 개의 함수)만으로 완전한 인터랙티브 리스트가 완성됩니다. SwiftUI의 진가가 여기서 드러납니다.
인터페이스 애니메이션
SwiftUI는 애니메이션에서 탁월한 성능을 발휘합니다. UIKit에서 대량의 코드가 필요했던 애니메이션도 여기서는 모두 선언적으로 기술할 수 있습니다. 최종 상태를 기술하면 SwiftUI가 전환을 처리합니다.
withAnimation을 활용한 암시적 애니메이션
애니메이션을 적용하는 가장 간단한 방법은 상태 변경을 withAnimation으로 감싸는 것입니다. SwiftUI가 변경 사항을 감지하고 영향을 받는 시각적 속성을 자동으로 애니메이션 처리합니다.
struct AnimatedCard: View {
@State private var isExpanded = false
var body: some View {
VStack {
RoundedRectangle(cornerRadius: 20)
.fill(.blue)
// Dimensions change based on state
.frame(
width: isExpanded ? 300 : 150,
height: isExpanded ? 200 : 100
)
Button(isExpanded ? "Collapse" : "Expand") {
// withAnimation animates ALL visual changes
// resulting from this state change
withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) {
isExpanded.toggle()
}
}
.padding(.top)
}
}
}버튼을 탭하면 isExpanded가 토글됩니다. withAnimation 덕분에 사각형의 크기 변경이 스프링 효과로 애니메이션 처리됩니다. 무엇을 애니메이션할지 지정할 필요가 없습니다. SwiftUI가 자동으로 판단합니다.
트랜지션: 표시와 사라짐의 애니메이션
트랜지션은 View가 어떻게 나타나고 사라지는지를 정의합니다. 기본값은 페이드(opacity)이지만 맞춤 설정이 가능합니다.
struct TransitionDemo: View {
@State private var showDetails = false
var body: some View {
VStack(spacing: 20) {
Button("Show Details") {
withAnimation(.easeInOut(duration: 0.3)) {
showDetails.toggle()
}
}
// This view appears/disappears with a transition
if showDetails {
DetailCard()
// Asymmetric transition: different for entry and exit
.transition(
.asymmetric(
insertion: .scale.combined(with: .opacity), // Entry: zoom + fade
removal: .slide // Exit: slide
)
)
}
}
}
}
struct DetailCard: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Interview Details")
.font(.headline)
Text("25 questions • 45 minutes")
.foregroundColor(.secondary)
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(16)
}
}이 예제에서 카드는 중앙에서 확대(scale + opacity)되며 나타나고, 옆으로 슬라이드(slide)하며 사라집니다. 이러한 마이크로 애니메이션이 인터페이스에 생동감과 전문적인 느낌을 부여합니다.
SwiftUI는 애니메이션을 자동으로 최적화합니다. 자연스러운 동작을 위해 .spring()을 사용하고, 사용자에게 불편을 주는 과도하게 긴 애니메이션(0.5초 이상)은 피해야 합니다.
비동기 데이터 로딩
실제 애플리케이션에서 데이터는 대부분 API에서 가져옵니다. Swift Concurrency(async/await)는 .task 모디파이어를 통해 SwiftUI와 원활하게 통합됩니다.
Loading / Error / Success 패턴
API 데이터를 표시하기 위한 표준 패턴을 소개합니다. 로딩 중, 오류, 성공의 세 가지 상태를 처리합니다.
struct AsyncDataView: View {
@State private var questions: [Question] = []
@State private var isLoading = true
@State private var errorMessage: String?
var body: some View {
Group {
if isLoading {
// State: loading in progress
ProgressView("Loading...")
} else if let error = errorMessage {
// State: error
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle")
.font(.largeTitle)
.foregroundColor(.orange)
Text(error)
Button("Retry") {
Task { await loadQuestions() }
}
}
} else {
// State: success, display data
List(questions) { question in
Text(question.title)
}
}
}
// .task runs automatically when the view appears
.task {
await loadQuestions()
}
// Pull-to-refresh
.refreshable {
await loadQuestions()
}
}
private func loadQuestions() async {
isLoading = true
errorMessage = nil
do {
// Async API call
questions = try await QuestionService.shared.fetchQuestions()
} catch {
errorMessage = "Unable to load questions"
}
isLoading = false
}
}.task 모디파이어는 필수적입니다. View가 표시될 때 비동기 태스크를 실행하고, View가 사라지면 자동으로 취소합니다. 메모리 누수의 위험이 없습니다.
결론
SwiftUI는 모던 iOS 개발에서 필수적인 존재가 되었습니다. iOS 18을 통해 프로덕션 환경의 애플리케이션에 충분한 성숙도에 도달했습니다.
핵심 요약
- 선언적 패러다임: 구축 방법이 아닌, 표시하고 싶은 내용을 기술한다
- @State와 @Binding: 상태를 리액티브하게 관리하고 View 간에 전달한다
- Stack: VStack, HStack, ZStack을 조합하여 유연한 레이아웃을 구축한다
- List: 최소한의 코드로 네이티브 인터랙션이 포함된 컬렉션을 표시한다
- 애니메이션:
withAnimation으로 부드러운 자동 전환을 구현한다 - Async/await:
.task로 데이터를 로딩하고 로딩/에러 상태를 처리한다
체크리스트
- 명령형(UIKit)과 선언형(SwiftUI)의 차이를 이해하고 있다
- 모디파이어와 적용 순서를 숙달하고 있다
- @State, @Binding, @Observable의 사용 구분을 파악하고 있다
- Stack을 활용한 레이아웃 구축이 가능하다
- 액션이 포함된 리스트(삭제, 이동)를 구현할 수 있다
- withAnimation을 활용한 상태 변경 애니메이션을 적용할 수 있다
연습을 시작하세요!
면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.
SwiftUI는 Apple 생태계 전반에 걸쳐 세련된 애플리케이션을 구축할 수 있는 길을 열어줍니다. 가장 좋은 학습 방법은 실천입니다. 작은 개인 프로젝트를 만들고 이 글에서 소개한 각 개념을 직접 시험해 보시기 바랍니다.

작성자
Anthony Fillion-Maillet풀스택 개발자, SharpSkill 창업자
10년 이상 풀스택 개발을 해왔습니다. SharpSkill을 운영하며 이곳에 게시되는 모든 내용에 책임을 집니다.
2026년 4월 2일 업데이트
태그
공유
관련 기사

SwiftUI 성능: LazyVStack과 복잡한 리스트 최적화
LazyVStack과 SwiftUI 리스트를 위한 최적화 기법. 메모리 소비를 줄이고 스크롤 성능을 향상시키며 흔한 함정을 피합니다.

SwiftUI 커스텀 ViewModifier: 디자인 시스템을 위한 재사용 가능한 패턴
일관된 디자인 시스템을 위해 SwiftUI에서 커스텀 ViewModifier를 구축합니다. iOS 뷰를 효율적으로 스타일링하기 위한 패턴, 베스트 프랙티스, 실용적인 예시를 다룹니다.

SwiftUI @Observable vs @State: 2026년에 무엇을 언제 사용할까
SwiftUI에서 @Observable과 @State의 차이를 마스터하고 iOS 앱에 적합한 상태 관리 도구를 선택해보세요.