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

MongoDB와 RDB를 비교해보자

개요

MongoDB를 프로젝트에 사용하면서 NoSQL이라는 사실만 알고 있었는데, 그나마(?) 저에게 익숙한 개념인 RDB(관계형 데이터베이스)와 비교하여 정리해봤습니다.

이 글에서는 MongoDB를 “테이블 대신 컬렉션을 쓰는 DB” 정도로만 보지 않고, RDB와 사고방식이 얼마나 다른지 위주로 비교하겠습니다.

등장 배경

MongoDB를 왜 쓰는지에 대해 알아보기 전에 등장 배경에 대해 알아보겠습니다.

MongoDB의 공동 창립자인 드와이트 메리먼, 엘리엇 호로위츠, 케빈 라이언은 온라인 광고 회사 DoubleClick에서 대규모 트래픽을 다뤘던 경험이 있었습니다. MongoDB 공식 소개에서도 이들이 초당 40만 건 이상의 광고를 처리하는 규모에서 관계형 데이터베이스의 한계를 경험했습니다.

문제는 당시 RDB가 그 수준의 트래픽을 수평으로 확장하기 쉽게 설계되지 않았고, 그들은 직접 해결책을 만들기로 하고 2007년에 10gen을 창업했습니다.

그런데 그들의 처음 목표는 데이터베이스가 아닌 클라우드 기반 PaaS 플랫폼이었습니다.

Google App Engine과 유사한 플랫폼을 만들려 했고, 데이터베이스는 그 안의 부품 중 하나였습니다.

그런데 당시 존재하던 데이터베이스가 원하는 클라우드 아키텍처 요건을 충족하지 못했습니다. 결국 팀은 데이터베이스까지 직접 만들기 시작했죠.

흥미로운 점은 개발자들이 플랫폼보다 그 안의 데이터베이스에 더 큰 관심을 보였다는 것입니다.

결국 10gen은 PaaS 전체를 내려놓고, 데이터베이스 하나에 집중하기로 결정합니다. 이 후 2009년, MongoDB가 오픈소스로 공개되었습니다.

당시에는 인터넷 서비스가 커지고, 장치와 사용자 로그가 폭발적으로 늘어나면서 전통적인 RDB로 다루기 까다로운 데이터가 많아졌습니다. 완전히 정해진 테이블 구조에 넣기 어려운 데이터, 빠르게 바뀌는 요구사항, 큰 트래픽을 처리하기 위한 수평 확장이 중요한 문제가 됐습니다.

MongoDB는 이 문제를 “관계형 모델을 더 잘 흉내내는 방식”이 아니라, 도큐먼트 모델로 풀었습니다.

RDB와 MongoDB 비교하기

테이블(table) vs 도큐먼트(document)

CREATE TABLE reservations (
  id BIGINT PRIMARY KEY,
  property_id BIGINT NOT NULL,
  guest_name VARCHAR(255) NOT NULL,
  check_in DATE NOT NULL,
  check_out DATE NOT NULL
);

먼저 RDB의 기초적인 데이터 집합인 테이블은 행과 열로 구성되고, 테이블 간 관계는 보통 Primary Key와 Foreign Key로 표현합니다.

{
  "_id": "reservation-1",
  "propertyId": "property-1",
  "guestName": "홍길동",
  "dateRange": {
    "checkIn": "2026-05-01",
    "checkOut": "2026-05-03"
  }
}

MongoDB는 데이터를 컬렉션 안의 도큐먼트로 저장하며, BSON(Binary JSON)이라는 데이터 형식으로 저장합니다.

일반 JSON과 큰 차이가 없어 보이는데 무슨 특성을 가지고 있을까요?

{
  "_id": "507f1f77bcf86cd799439011",
  "createdAt": "2026-04-12T12:00:00Z",
  "price": 1000
}

BSON은 JSON 형태에 날짜, ObjectId 같은 타입을 더한 이진 표현입니다.

일반 JSON 파일에서는 _id는 문자열이고, createdAt도 문자열입니다.

반면 BSON은 필드마다 타입 정보를 함께 저장합니다.

[문서 전체 길이]
[필드 타입][필드명][값]
[필드 타입][필드명][값]
...
[문서 끝]

그래서 MongoDB는 createdAt을 문자열이 아니라 BSON의 Date 타입으로 저장할 수 있고, _id도 단순 문자열이 아니라 ObjectId 타입으로 저장할 수 있습니다.

바이너리 데이터도 base64 문자열로 우회하지 않고 Binary 타입으로 저장할 수 있습니다.

Binary로 다뤄서 얻는 이점은 크게 세 가지인데:

  1. Date, ObjectId, Binary, Decimal128, int32, int64 같은 타입을 MongoDB 내부 타입으로 다룰 수 있으며

  2. 텍스트를 매번 파싱하지 않아도 됩니다. BSON은 타입과 길이 정보를 갖고 있기 때문에 MongoDB가 값을 읽을 때 어디까지가 해당 값인지 더 빠르게 알 수 있고

  3. 숫자와 바이너리 데이터를 문자열로 바꾸지 않고, 숫자는 숫자 타입으로, 바이너리 데이터는 바이트 배열로 저장할 수 있습니다.

BSON은 필드 타입과 길이 정보 같은 메타데이터를 추가로 저장하는 특징이 있는데, 예를 들어 JSON의 1은 한 글자지만, BSON의 int32 값은 값 자체만 4바이트를 사용하게 됩니다.

이로써 MongoDB가 문서를 타입 정보와 함께 빠르게 다룰 수 있습니다.

스키마(Schema)를 다루는 법

처음 공식 문서를 보고 의아했던 것은 schema라는 표현을 계속 사용하는데, RDB에서 말하는 스키마와 MongoDB 문서에서 말하는 스키마는 느낌이 조금 달랐던 것입니다.

CREATE TABLE reservations (
  id BIGINT PRIMARY KEY,
  guest_name VARCHAR(255) NOT NULL,
  status VARCHAR(20) NOT NULL,
  check_in DATE NOT NULL,
  check_out DATE NOT NULL
);

RDBMS 시스템에서는 스키마를 강제합니다. guest_name이 없거나, check_in에 날짜로 해석할 수 없는 값이 들어오면 DB가 거부하고, 컬럼을 추가하거나 타입을 바꾸려면 ALTER TABLE 같은 DDL을 실행해야 합니다.

즉 RDB에서의 스키마는 대체로 테이블 구조 + 컬럼 타입 + 제약 조건입니다.

하지만 여기서 비교하는 것은 namespace로서의 스키마가 아니라, 테이블이 어떤 컬럼과 제약을 갖는지에 대한 테이블 스키마입니다.

반면 MongoDB에서 말하는 schema는 기본적으로 컬렉션 안의 도큐먼트들이 대체로 어떤 구조를 갖는가에 가깝습니다.

MongoDB는 기본적으로 같은 컬렉션 안에서도 도큐먼트마다 필드가 다를 수 있습니다.

db.reservations.insertMany([
  {
    _id: "reservation-1",
    guestName: "홍길동",
    status: "CONFIRMED"
  },
  {
    _id: "reservation-2",
    guest: {
      name: "김철수"
    },
    memo: "늦은 체크인 요청"
  }
])

둘 다 같은 reservations 컬렉션에 들어갈 수 있는데:

첫 번째 도큐먼트는 guestName을 문자열로 갖고 있고, 두 번째 도큐먼트는 guest라는 중첩 객체를 갖고 있습니다.

이게 MongoDB 공식 문서에서 말하는 flexible schema입니다. 문서마다 반드시 같은 필드 집합을 가질 필요가 없고, 같은 필드라도 도큐먼트마다 타입이 달라질 수 있습니다.

하지만 이 말이 “스키마가 없다”는 뜻은 아니고, 애플리케이션이 기대하는 구조는 여전히 존재합니다.

예를 들어 예약을 처리하는 코드가 guestNamestatus를 기대한다면, 이 구조가 사실상의 application 스키마가 됩니다.

차이는 그 스키마가 처음부터 DB에 의해 강제되는지, 아니면 애플리케이션과 규칙으로 먼저 존재하는지입니다.

필요하다면 schema validation을 걸 수 있습니다.

db.createCollection("reservations", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["guestName", "status"],
      properties: {
        guestName: { bsonType: "string" },
        status: {
          enum: ["PENDING", "CONFIRMED", "CANCELLED"]
        }
      }
    }
  }
})

이렇게 하면 guestNamestatus 같은 필드를 검증할 수 있습니다. MongoDB의 validation rule은 필요한 부분에만 적용할 수 있고, 모든 필드를 빠짐없이 정의해야 하는 것도 아닙니다.

그래서 MongoDB를 schema-less가 아니라 schema를 미리 강하게 고정하지 않는 flexible schema 모델에 가깝다고 볼 수 있습니다.

레퍼런스(reference)와 임베드(embed)

MongoDB에서 컬렉션은 도큐먼트를 묶는 단위입니다. RDB의 테이블과 비슷한 위치에 있지만, table처럼 모든 row가 같은 column 구조를 가져야 하는 것은 아닙니다.

관계가 있는 데이터를 저장할 때 MongoDB 공식 문서는 크게 두 가지 모델을 이야기합니다.

  • 레퍼런스(reference): 다른 도큐먼트의 id를 저장하고 필요할 때 따라가는 방식
  • 임베드(embed): 관련 데이터를 현재 도큐먼트 안에 중첩 객체나 배열로 함께 넣는 방식

예를 들어 예약과 숙소가 있다고 해보겠습니다.

{
  "_id": "reservation-1",
  "guestName": "홍길동",
  "propertyId": "property-1"
}

이건 레퍼런스 방식입니다. 예약 도큐먼트는 숙소 이름과 주소를 직접 갖고 있지 않고, propertyId만 갖고 있습니다.

{
  "_id": "property-1",
  "name": "강릉 오션뷰 스테이",
  "address": "강원도 강릉시 ..."
}

숙소 정보의 원본은 properties 컬렉션의 도큐먼트에 있습니다.

반대로 같은 데이터를 임베드 방식으로 설계한다면:

{
  "_id": "reservation-1",
  "guestName": "홍길동",
  "property": {
    "_id": "property-1",
    "name": "강릉 오션뷰 스테이",
    "address": "강원도 강릉시 ..."
  }
}

관련 데이터를 별도 도큐먼트로 분리하지 않고, 현재 도큐먼트 안에 함께 저장하는 방식입니다.

위 예시에서는 예약 도큐먼트 안에 숙소 정보 일부를 중첩 객체로 넣었습니다.

reservation document
  └─ property
      ├─ _id
      ├─ name
      └─ address

이 경우에는 예약 하나를 조회할 때 숙소 이름과 주소까지 같이 읽을 수 있습니다.

db.reservations.findOne({ _id: "reservation-1" })

여기서 “조회 한 번”이라는 말은 reservations 컬렉션에서 예약 도큐먼트 하나를 읽었는데, 그 도큐먼트 안에 화면에 필요한 property.name, property.address가 이미 들어 있다는 의미입니다.

대신 같은 숙소 정보가 여러 예약 도큐먼트에 반복 저장될 수 있습니다.

{
  "_id": "reservation-2",
  "guestName": "파랑 피크민",
  "property": {
    "_id": "property-1",
    "name": "강릉 오션뷰 스테이",
    "address": "강원도 강릉시 ..."
  }
}

이 경우 reservation-1reservation-2는 서로 다른 예약 도큐먼트입니다. 따라서 도큐먼트 자체가 중복된 것은 아닙니다.

다만 두 도큐먼트 안에 같은 property-1의 이름과 주소가 복사되어 있습니다. MongoDB에서 말하는 비정규화나 중복 허용은 보통 이런 상황을 말합니다.

RDB와 MongoDB에서의 관계(Relation)

RDB에서는 관계를 보통 Foreign Key와 join으로 다룹니다.

reservations
  id
  property_id
  guest_name

properties
  id
  name
  address

여기서 reservations.property_idproperties.id를 가리키는 참조 값이며, join은 그 참조 값을 이용해 조회 시점에 두 테이블의 데이터를 합치는 연산입니다.

SELECT r.id, r.guest_name, p.name, p.address
FROM reservations r
JOIN properties p ON r.property_id = p.id
WHERE r.id = 1;

MongoDB에서도 레퍼런스를 저장하고 나중에 따라갈 수 있습니다.

{
  "_id": "reservation-1",
  "guestName": "홍길동",
  "propertyId": "property-1"
}

이 방식에서는 숙소 정보가 필요할 때 애플리케이션에서 properties 컬렉션을 한 번 더 조회할 수 있습니다.

db.properties.findOne({ _id: "property-1" })

또는 aggregation의 $lookup을 사용해 컬렉션 간 데이터를 합칠 수 있습니다.

db.reservations.aggregate([
  {
    $lookup: {
      from: "properties",
      localField: "propertyId",
      foreignField: "_id",
      as: "property"
    }
  }
])

다만 MongoDB에서는 RDB처럼 join을 설계의 기본 전제로 두기보다, 애플리케이션이 데이터를 읽는 모양에 맞춰 reference와 embed 중 하나를 선택합니다.

레퍼런스는 데이터를 정규화하는 쪽에 가깝고, 임베드는 데이터를 비정규화하는 쪽에 가깝습니다.

수평 확장과 샤딩(sharding)

전통적인 RDB에서는 샤딩을 애플리케이션이나 운영 설계에서 직접 감당해야 하는 경우가 많습니다.

예를 들어 예약 데이터를 property_id 기준으로 나눈다고 해보겠습니다.

reservations_property_1_1000   -> shard A
reservations_property_1001_2000 -> shard B
reservations_property_2001_3000 -> shard C

이렇게 나누면 애플리케이션은 어떤 예약이 어느 shard에 있는지 알아야 합니다.

property_id = 1200이면 shard B로 보내고, property_id = 300이면 shard A로 보내야 합니다.

즉 RDB에서 샤딩은 결국

  • shard 간 join이 어려워지고
  • 특정 shard에 트래픽이 몰릴 수 있으며
  • 데이터가 커지면 다시 쪼개고 옮기는 작업이 필요하고
  • transaction과 consistency 경계가 복잡해져서

애플리케이션과 운영 복잡도가 함께 커지는 방식에 가깝습니다.

MongoDB는 이 문제를 데이터베이스 기능으로 더 직접적으로 다루며 sharded cluster를 다음과 같이 구성합니다:

애플리케이션은 보통 mongos라는 라우터에 요청을 보냅니다. mongos는 shard key를 보고 어떤 shard로 요청을 보낼지 결정합니다.

예를 들어 reservations 컬렉션을 propertyId 기준으로 shard한다고 하면, MongoDB는 shard key 값을 기준으로 데이터를 여러 shard에 나눠 저장합니다.

sh.shardCollection("pms.reservations", { propertyId: 1 })

이때 중요한 설계 포인트는 shard key입니다.

shard key를 잘 고르면 특정 숙소의 예약을 찾는 쿼리를 필요한 shard로 보낼 수 있습니다.

db.reservations.find({ propertyId: "property-1" })

반대로 shard key를 잘못 고르면 특정 shard에만 데이터나 요청이 몰릴 수 있습니다.

MongoDB의 설계 철학은 “정규화된 데이터를 조인해서 조립한다”보다, 애플리케이션의 접근 패턴에 맞춰 document와 shard key를 설계한다에 가깝습니다.

어떤 필드로 데이터를 나눌지, 어떤 쿼리가 자주 들어오는지, 한 도큐먼트 안에 어떤 데이터를 함께 둘지 같이 설계해야 합니다.

정리

사실 이것 외에도 굉장히 내용이 방대해서 두 번째 파트로 나누려고 합니다.

이후에는 mongoDB에서 트랜잭션을 어떻게 다루는지, replica set과 같은 특징을 이어서 다뤄보겠습니다.

출처

article |

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

개요

요즘 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 개발자님들의 피드백은 언제나 달게 받겠습니다.

출처