HOME INFO PROJECT BLOG ESSAY
Article Projects Lab
한국어 English
article |

React와 비교하며 알아보는 SwiftUI 렌더링

SwiftUI와 React를 비교하며 알아보는 렌더링

개요

요즘 iOS 애플리케이션을 만들다보니 SwiftUI를 사용하고 있습니다.

쓰다보니 재밌고(중요) 괜찮은 언어여서 LLM으로 굳어버린 뇌에 신선한 자극을 주고자 어떻게 SwiftUI에서 어떻게 동작하는지 정리해봤습니다.

명령형, 선언형 패러다임

그 전에 명령형과 선언형 패러다임을 짚고 가겠습니다.

먼저 명령형 은 “컴퓨터에게 수행할 명령들의 순서를 직접 기술하는 방식”입니다.

예를 들어서 순수 js로만 todo list를 만든다고 가정해본다면:

const li = document.createElement("li");
li.textContent = "새 항목";
list.appendChild(li);

직접 변수를 생성하고 명령하여 만들고자 하는 결과에 필요한 스텝을 하나씩 명시해야합니다. 현재 상태에서 다음 상태로 넘어가려면 element에 어떤 것을 추가해야한다는 정보를 정의하고 순서대로 실행하는 것이죠.

반면 선언형은 상태를 정의하고, 최종적으로 ‘output이 ~ 해야 한다’라는 결과을 ‘선언’합니다.

React로 예시를 든다면:

function TodoList() {
  const [items, setItems] = useState([
    { id: 1, text: "새 항목" }
  ]);

  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{item.text}</li>
      ))}
    </ul>
  );
}

appendChild같은 명령없이 “현재 상태의 items가 이 화면으로 렌더링되어야 한다”는 최종 결과만 설명하는 방식이죠.

React로 좀 더 자세하게 알아보는 선언형

React에서는 결과만 선언했는데 어떻게 상태를 알아서 추적할까요?

사실 내부에서는 명령형 패러다임으로 동작하고 있습니다. 개발자들이 편하라고 추상화되어있을 뿐이죠.

선언형에서 뭐가 바뀌었는지 감지하려면 어떤 작업이 필요할지 예상해봅시다.

todo list에 항목을 하나 추가하는 상황을 가정해보면:

// before
<ul>
  <li>빨래하기</li>
  <li>장보기</li>
</ul>

// after
<ul>
  <li>이메일 보내기</li>
  <li>빨래하기</li>
  <li>장보기</li>
</ul>

저희가 눈으로 봤을 때는 “이메일 보내기”를 맨 앞에 추가했다는 걸 알고 있을테지만 React 입장에서는 두 개의 완성된 결과물만 받을 뿐, 무슨 요소가 추가되고 뭐가 그대로인지 알 방법이 없습니다.

순서대로 비교하면 “빨래하기”가 “이메일 보내기”로 바뀐 것처럼 보이고, “장보기”가 “빨래하기”로 바뀐 것처럼 보입니다. 각 항목이 “같은 항목인지 다른 항목인지” 판별할 기준이 없기 때문입니다.

어떻게 식별하는지 알아봅시다.

// before
<ul>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

// after — 앞에 Connecticut 추가
<ul>
  <li>Connecticut</li>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

먼저 key가 없으면 React는 앞에서부터 순서대로 비교합니다.

첫 번째 <li>의 내용이 DukeConnecticut으로 달라졌으니 갱신하고, 두 번째도 VillanovaDuke로 달라졌으니 갱신하고, 세 번째 Villanova는 새로 추가합니다. 실제로는 하나만 추가하면 되는데, 세 개 모두를 건드리게 됩니다.

그래서 React는 개발자에게 key를 요구합니다. key를 붙이면 React는 위치가 아닌 key로 항목을 대조합니다.

// before
<ul>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

// after
<ul>
  <li key="2014">Connecticut</li>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

React는 이전 key 목록 ["2015", "2016"]과 새 key 목록 ["2014", "2015", "2016"]을 대조합니다.

"2015""2016"은 이전에도 있었으므로 기존 DOM 노드를 그대로 재사용하고, "2014"는 이전에 없었으므로 새로 생성해서 삽입합니다. DOM 조작이 삽입 한 번으로 끝납니다.

그러면 React는 이 비교를 실제로 어떤 과정으로 수행할까요?

공식 문서에서는 상태 변경부터 화면 갱신까지를 세 단계로 설명합니다.

1. TriggersetState를 호출하면 렌더링이 예약됩니다.

setItems((prev) => [...prev, { id: 2, text: "item 2" }]);

2. Render — React가 컴포넌트 함수를 다시 호출합니다. 이때 실제 DOM은 아직 건드리지 않습니다.

function TodoList() {
  const [items, setItems] = useState([...]);
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{item.text}</li>
      ))}
    </ul>
  );
}

이 시점에서 React는 이전 렌더링 결과방금 새로 만든 결과, 두 개의 스냅샷을 갖게 됩니다. 선언형 코드에는 “무엇이 바뀌었는지”라는 정보가 없지만, 함수를 다시 호출해서 완전한 새 결과물을 만들었기 때문에 이전 결과물과 비교할 수 있습니다.

3. Commit — 두 트리를 비교해서 실제 DOM에 차이점만 반영합니다.

// React 내부: 이전 트리와 새 트리의 diff 결과
// → <li>item 2</li> 하나만 추가하면 된다
// → parentNode.appendChild(newLi)

여기서 3단계 중 2번(Render)과 3번(Commit) 사이에서 일어나는 비교 작업을 Reconciliation(재조정) 이라고 합니다.

상태가 바뀔 때마다 React는 새로운 트리를 만들고, 이전 트리와 비교해서 실제 DOM에 반영할 최소한의 변경을 계산합니다.

비교(diff)가 끝나면 React는 각 노드에 남긴 플래그를 보고 실제 DOM API를 호출합니다.

// ReactFiberCommitWork.js (단순화)
if (flags & Placement) {
  parentNode.insertBefore(childNode, referenceNode);
}
if (flags & ChildDeletion) {
  parentNode.removeChild(childNode);
}
if (flags & Update) {
  element.setAttribute(name, value);
}

글 흐름상 필요한 내용만 적다보니 과정이 몇 개 생략되었지만, 중요한 것은 선언형으로 쓰여진 코드는 내부적으로 명령형으로 이런 작업을 하고 있다는 것을 기억하면 좋을 것 같습니다.

SwiftUI의 경우

SwiftUI도 내부적인 동작의 디테일한 부분은 미묘하게 다르지만 상태를 다루는 메커니즘은 유사합니다.

화면이 좀 이상하지만..

“items 배열의 각 요소가 Text로 나열되고, 그 아래 ‘추가’ 버튼이 있는 VStack이 존재해야 한다”를 선언합니다.

다만 React는 최종적으로 브라우저 DOM에 반영하지만, SwiftUI는 최종적으로 UIKit 혹은 AppKit 같은 네이티브 UI 시스템과 연결됩니다. 같은 SwiftUI 코드라도 iOS에서는 UIKit 쪽으로, macOS에서는 AppKit 쪽으로 해석되는 것이죠.

그래서 Button 하나를 써도 플랫폼에 따라 실제 보이는 버튼과 상호작용 방식이 달라집니다. 일종의 추상화 계층이 하나 더 있다고 보면 될 것 같습니다. (RN과도 유사해보임)

SwiftUI 내부는 어떤일이

React에서는 Reconciliation으로 두 스냅샷을 비교하고, key로 항목의 정체성을 추적했습니다. SwiftUI도 “무엇이 같은 뷰이고 무엇이 다른 뷰인가”를 판단해야 하지만, 접근 방식이 다릅니다.

SwiftUI는 뷰를 추적하는 방식을 Identity, Lifetime, Dependencies 세 축으로 설명합니다. 이 중 Identity가 가장 기본입니다.

Structural Identity

SwiftUI는 뷰의 타입계층 구조 내 위치로 정체성을 자동 부여합니다. 개발자가 명시적으로 식별자를 주지 않아도 됩니다.

VStack {
    Text("Hello")   // 타입: Text, 위치: VStack의 첫 번째 자식
    Text("World")   // 타입: Text, 위치: VStack의 두 번째 자식
}

if-else 분기가 있으면, 각 분기는 서로 다른 정체성을 갖습니다.

if dayTime {
    Text("Good Dog")    // true 분기의 뷰
} else {
    Text("Bad Dog")     // false 분기의 뷰 — 다른 정체성
}

여기서 분기가 바뀌면 이전 뷰는 사라지고 새 뷰가 생성된다는 것입니다. 즉 @State도 초기화됩니다.

@State var title = "Theseus"

if dayTime {
    TextField("Name", text: $title)   // true 분기
} else {
    TextField("Name", text: $title)   // false 분기 — 같은 코드지만 다른 정체성
}
// dayTime이 바뀌면 title이 초기값으로 리셋됨

React에서 key가 바뀌면 컴포넌트가 언마운트/리마운트되는 것과 같은 원리입니다. 이를 피하려면 분기를 없애고 modifier로 처리합니다.

// 정체성이 유지되므로 @State도 유지됨
TextField("Name", text: $title)
    .foregroundColor(dayTime ? .green : .red)

Explicit Identity

리스트처럼 동적으로 생성되는 뷰는 구조만으로 정체성을 추적할 수 없습니다. 이때 Identifiable 프로토콜이나 .id() modifier로 명시적 정체성을 부여합니다. React의 key와 같은 역할입니다.

// Identifiable — ForEach가 각 항목을 id로 추적
struct NamedFont: Identifiable {
    let name: String
    let font: Font
    var id: String { name }
}

ForEach(namedFonts) { namedFont in
    Text(namedFont.name)
        .font(namedFont.font)
}
// .id() modifier — 특정 뷰에 명시적 정체성 부여
Text("Header").id("header")

공식 문서에 따르면, .id() 값이 바뀌면 해당 뷰의 정체성이 리셋되어 @State, @FocusState, @GestureState모든 상태가 초기화됩니다.

정리하면 SwiftUI에서 Identity는 다음을 결정하는 것입니다.

  • 상태 유지 — 같은 정체성이면 @State가 유지되고, 정체성이 바뀌면 초기화
  • 애니메이션 — 같은 정체성의 뷰는 부드럽게 전환되고, 다른 정체성의 뷰는 fade in/out
  • 업데이트 범위 — SwiftUI는 정체성을 기준으로 어떤 뷰를 다시 그릴지 결정

CSS가 없고 modifier가 있다

Tailwind 같은 CSS 프레임워크를 쓰지 않는 이상 웹에서는 보통 DOM 과 CSS 스타일이 분리되어 있지만, SwiftUI는 구조와 스타일이 한 코드 흐름 안에 같이 있습니다.

겉으로 보면 “하나의 뷰 객체를 계속 수정하는 것”처럼 보이지만, modifier는 기존 뷰를 제자리에서 바꾸는 명령이라기보다 새로운 뷰 값을 만들어 내는 변환에 가까워보입니다.

체이닝된 순서대로 새로운 뷰를 생성하며 적용되므로, 순서에 따라 결과가 완전히 달라집니다.

그래서 이런 코드도 가능합니다.

React로 따진다면:

<div style={{ padding: 20, backgroundColor: 'blue' }}>
    <div style={{ padding: 20, backgroundColor: 'yellow' }}>
      <p>Hello</p>
    </div>
  </div>

이런 코드일 것 같은데 웹의 단일 DOM 노드 하나에 배경색을 두 번 주는 식으로 생각하면 이상하지만, SwiftUI에서는 modifier가 내부적으로 감싸는 새 뷰를 만들 수 있습니다.

View는 객체가 아니라 프로토콜

SwiftUI의 View의 공식 정의를 보면:

public protocol View {
    associatedtype Body: View
    @ViewBuilder var body: Self.Body { get }
}

View는 프로토콜이고, 이를 채택하는 타입은 대부분 struct입니다. 객체가 아니라 값(value)입니다.

modifier 체이닝에서 이 차이가 드러납니다. 공식 문서의 modifier(_:) 시그니처를 보면:

func modifier<T>(_ modifier: T) -> ModifiedContent<Self, T>

반환 타입이 ModifiedContent<Self, T>입니다. modifier를 붙일 때마다 새로운 타입의 새로운 값이 만들어집니다.

Text("Hello")            // Text
    .padding(20)          // ModifiedContent<Text, ...>
    .background(.yellow)  // ModifiedContent<ModifiedContent<Text, ...>, ...>

즉 modifier 체이닝은 “하나의 뷰를 수정하는 것”이 아니라, 타입이 계속 감싸지는 구조입니다. React로 비유하면 <div>로 중첩하는 것과 비슷하지만, SwiftUI에서는 이게 컴파일 타임 타입 시스템 레벨에서 일어납니다.

개발자가 작성한 View struct는 화면에 그려지는 실체가 아니라 렌더링을 위한 설계도이고, SwiftUI가 이 설계도를 해석해서 실제 화면을 구성하게되죠.

@ViewBuilder와 DSL

VStack {
    Image(systemName: "globe")
        .imageScale(.large)

    Text("Hello, world!")
        .font(.body)
        .padding(20)

    text // 변수 참조만으로도 렌더링됨
}

Image(...)Text(...)는 그냥 나열만 했을 뿐인데, 어떻게 뷰로 인식될까요? return도 없고, 배열에 넣은 것도 아닙니다. 심지어 let text = Text("Hello")로 만든 변수를 그냥 text라고만 써도 화면에 나타납니다.

이것이 가능한 이유는 SwiftUI가 Swift의 @resultBuilder 라는 메타프로그래밍 기능으로 만든 DSL(Domain Specific Language) 이기 때문입니다.

@resultBuilder가 하는 일

VStack의 실제 이니셜라이저를 보면 이렇게 생겼습니다.

struct VStack<Content: View>: View {
    init(@ViewBuilder content: () -> Content) { ... }
}

@ViewBuilder가 클로저에 붙어 있습니다. Apple 공식 문서에 따르면 ViewBuilder는 이렇게 선언되어 있습니다.

// Apple Developer Documentation — SwiftUI > ViewBuilder
@resultBuilder
struct ViewBuilder

@resultBuilder는 Swift 언어 자체의 메타프로그래밍 기능입니다. 이 attribute가 붙은 타입은 클로저 안의 표현식들을 자동으로 수집하는 DSL을 정의할 수 있습니다.

ViewBuilder가 제공하는 핵심 메서드들을 보면 구조가 보입니다.

// 각 표현식을 View로 인식 — 단순 통과
static func buildExpression<Content>(_ content: Content) -> Content
    where Content: View

// 여러 표현식을 하나로 합침
static func buildBlock<Content>(_ content: Content) -> Content
    where Content: View

// body 프로퍼티 자체에도 @ViewBuilder가 붙어 있음
@ViewBuilder @MainActor @preconcurrency
var body: Self.Body { get }

컴파일러는 @ViewBuilder가 붙은 클로저 안의 표현식들을 모아서 buildExpressionbuildBlock 호출로 변환합니다.

// 개발자가 작성하는 코드
VStack {
    Image(systemName: "globe")
    Text("Hello, world!")
}

// 컴파일러가 실제로 변환하는 코드
VStack {
    let v0 = ViewBuilder.buildExpression(Image(systemName: "globe"))
    let v1 = ViewBuilder.buildExpression(Text("Hello, world!"))
    return ViewBuilder.buildBlock(v0, v1)
}

클로저 안에 나열된 각 표현식이 buildBlock의 인자로 수집됩니다. 변수를 선언하든, 직접 생성자를 호출하든, View 프로토콜을 만족하는 표현식이기만 하면 자동으로 뷰 트리에 포함됩니다.

조건문도 DSL이 처리한다

@resultBuilder가 단순히 표현식을 모으는 것에서 끝나지 않고 if/else 같은 조건문도 컴파일러가 변환합니다.

// 개발자가 작성하는 코드
myView.contextMenu {
    Text("Cut")
    Text("Copy")
    Text("Paste")
    if isSymbol {
        Text("Jump to Definition")
    }
}

if문은 컴파일러에 의해 buildOptional 호출로 변환됩니다. if/else의 경우 buildEither가 사용됩니다.

// if/else가 있는 코드
VStack {
    if isLoggedIn {
        Text("Welcome!")    // → buildEither(first:)
    } else {
        Text("Please log in") // → buildEither(second:)
    }
}

// 컴파일러가 변환하는 코드
VStack {
    if isLoggedIn {
        let v = ViewBuilder.buildExpression(Text("Welcome!"))
        ViewBuilder.buildEither(first: v)
    } else {
        let v = ViewBuilder.buildExpression(Text("Please log in"))
        ViewBuilder.buildEither(second: v)
    }
}

맺음

전혀 다른 패러다임을 가진 언어를 공부해보니 시야가 넓어지는 기분입니다. LLM으로 굳어진 뇌를 이렇게라도 깨워봐야겠습니다.

잘못된 내용이 있을수도 있어 iOS, React 개발자님들의 피드백은 언제나 달게 받겠습니다.

출처

Comments