<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>프로그래밍 이야기 &#8211; Naver Ending Study</title>
	<atom:link href="https://nangchang.nes.or.kr/category/pogramming/feed/" rel="self" type="application/rss+xml" />
	<link>https://nangchang.nes.or.kr</link>
	<description></description>
	<lastBuildDate>Tue, 16 Jun 2026 11:32:24 +0000</lastBuildDate>
	<language>ko-KR</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=6.8</generator>
	<item>
		<title>[Swift 입문] 31편 — 길이-접두사 프레이밍과 IPC 프로토콜 설계</title>
		<link>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-31%ed%8e%b8-%ea%b8%b8%ec%9d%b4-%ec%a0%91%eb%91%90%ec%82%ac-%ed%94%84%eb%a0%88%ec%9d%b4%eb%b0%8d%ea%b3%bc-ipc-%ed%94%84%eb%a1%9c%ed%86%a0%ec%bd%9c-%ec%84%a4/</link>
					<comments>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-31%ed%8e%b8-%ea%b8%b8%ec%9d%b4-%ec%a0%91%eb%91%90%ec%82%ac-%ed%94%84%eb%a0%88%ec%9d%b4%eb%b0%8d%ea%b3%bc-ipc-%ed%94%84%eb%a1%9c%ed%86%a0%ec%bd%9c-%ec%84%a4/#respond</comments>
		
		<dc:creator><![CDATA[낭창]]></dc:creator>
		<pubDate>Tue, 16 Jun 2026 11:32:24 +0000</pubDate>
				<category><![CDATA[프로그래밍 이야기]]></category>
		<guid isPermaLink="false">https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-31%ed%8e%b8-%ea%b8%b8%ec%9d%b4-%ec%a0%91%eb%91%90%ec%82%ac-%ed%94%84%eb%a0%88%ec%9d%b4%eb%b0%8d%ea%b3%bc-ipc-%ed%94%84%eb%a1%9c%ed%86%a0%ec%bd%9c-%ec%84%a4/</guid>

					<description><![CDATA[🤖 이 글은 Claude Code(AI)가 작성합니다. &#124; 시리즈 목차 &#124; 이전: [30편] Unix Domain Socket과 프로세스 간 통신 TCP 스트림의 근본 문제 앞 편에서 TCP가 &#8220;메시지 경계를 보장하지 않는 바이트 스트림&#8221;이라고 언급했습니다. 이것이 왜 문제인지 구체적으로 살펴봅니다. 보내는 쪽이 전송한 것: [메시지 A: "안녕하세요"] [메시지 B: "반갑습니다"] 받는 쪽이 수신할 수 있는 경우: 경우 1: [&#8230;]]]></description>
										<content:encoded><![CDATA[<blockquote>
<p><img src="https://s.w.org/images/core/emoji/15.1.0/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 이 글은 <a href="https://claude.ai/claude-code">Claude Code</a>(AI)가 작성합니다. | <a href="https://nangchang.nes.or.kr/?p=1085">시리즈 목차</a> | 이전: <a href="https://nangchang.nes.or.kr/?p=1205">[30편] Unix Domain Socket과 프로세스 간 통신</a></p>
</blockquote>
<h2>TCP 스트림의 근본 문제</h2>
<p>앞 편에서 TCP가 &#8220;메시지 경계를 보장하지 않는 바이트 스트림&#8221;이라고 언급했습니다. 이것이 왜 문제인지 구체적으로 살펴봅니다.</p>
<pre><code>보내는 쪽이 전송한 것:
  [메시지 A: "안녕하세요"] [메시지 B: "반갑습니다"]

받는 쪽이 수신할 수 있는 경우:
  경우 1: ["안녕하세요반갑습니다"]         (두 메시지가 합쳐짐)
  경우 2: ["안녕"] ["하세요반갑습니다"]    (첫 메시지가 쪼개짐)
  경우 3: ["안녕하세요"] ["반갑습니다"]    (정상 — 보장 안 됨)
</code></pre>
<p>이 문제를 해결해야 소켓 통신이 제대로 동작합니다.</p>
<hr/>
<h2>메시지 프레이밍(Framing) 방법들</h2>
<h3>1. 구분자(Delimiter) 방식</h3>
<pre><code>메시지1\n메시지2\n메시지3\n
</code></pre>
<p>줄바꿈(<code>\n</code>)이나 특수 문자로 메시지를 구분합니다. HTTP/1.1, SMTP, FTP 등이 이 방식을 씁니다.</p>
<ul>
<li>장점: 구현 단순, 사람이 읽기 쉬움</li>
<li>단점: 메시지 내용에 구분자가 포함될 수 없음 (이스케이프 필요)</li>
</ul>
<h3>2. 고정 길이 방식</h3>
<pre><code>모든 메시지가 정확히 256바이트
</code></pre>
<ul>
<li>장점: 구현 매우 단순</li>
<li>단점: 유연성 없음, 낭비 심함</li>
</ul>
<h3>3. 길이-접두사(Length-Prefix) 방식 ← 가장 범용적</h3>
<pre><code>[ 4바이트: 길이 ][ 본문 데이터 ][ 4바이트: 길이 ][ 본문 데이터 ] ...
</code></pre>
<p>메시지 앞에 본문의 크기를 먼저 보냅니다. 받는 쪽은 먼저 4바이트를 읽어 길이를 파악한 뒤, 정확히 그만큼만 읽습니다.</p>
<hr/>
<h2>길이-접두사 프레이밍 구현</h2>
<h3>인코딩 (메시지 감싸기)</h3>
<pre><code class="language-swift">import Foundation

enum FramingProtocol {
    // 메시지를 [4바이트 길이][본문] 형태로 감쌈
    static func encode(_ data: Data) -> Data {
        var frame = Data(capacity: 4 + data.count)

        // 길이를 빅엔디언(big-endian) 4바이트로 변환
        // 빅엔디언: 높은 자릿수 바이트가 먼저 오는 방식 (네트워크 표준)
        var length = UInt32(data.count).bigEndian
        frame.append(Data(bytes: &length, count: 4))
        frame.append(data)

        return frame
    }

    // JSON 객체를 바로 프레임으로 변환
    static func encode&lt;T: Encodable&gt;(_ value: T) throws -> Data {
        let json = try JSONEncoder().encode(value)
        return encode(json)
    }
}
</code></pre>
<h3>디코딩 (스트림에서 메시지 추출)</h3>
<pre><code class="language-swift">// 수신 버퍼 — 스트림 데이터를 누적하며 완전한 메시지를 추출
class FrameDecoder {
    private var buffer = Data()

    // 새 데이터를 버퍼에 추가하고 완성된 메시지 목록을 반환
    func feed(_ data: Data) -> [Data] {
        buffer.append(data)
        return extractFrames()
    }

    private func extractFrames() -> [Data] {
        var messages: [Data] = []

        while buffer.count >= 4 {
            // 앞 4바이트에서 길이 읽기
            let length = buffer.prefix(4).withUnsafeBytes { ptr in
                UInt32(bigEndian: ptr.load(as: UInt32.self))
            }

            let totalNeeded = 4 + Int(length)

            // 버퍼에 전체 메시지가 도착했는지 확인
            guard buffer.count >= totalNeeded else {
                break  // 아직 데이터가 더 필요함 — 다음 수신까지 대기
            }

            // 본문 추출
            let messageData = buffer[4..&lt;totalNeeded]
            messages.append(Data(messageData))

            // 처리한 데이터 제거
            buffer.removeFirst(totalNeeded)
        }

        return messages
    }
}
</code></pre>
<h3>실전 사용 예시</h3>
<pre><code class="language-swift">// 메시지 타입 정의
struct AppMessage: Codable {
    let type: MessageType
    let payload: String

    enum MessageType: String, Codable {
        case request = "request"
        case response = "response"
        case event = "event"
    }
}

// 서버에서 메시지 처리
class IPCServer {
    private let decoder = FrameDecoder()

    func onDataReceived(_ data: Data, connection: Connection) {
        // 수신 데이터를 디코더에 넣으면 완성된 메시지만 나옴
        let messages = decoder.feed(data)

        for messageData in messages {
            handleMessage(messageData, connection: connection)
        }
    }

    private func handleMessage(_ data: Data, connection: Connection) {
        guard let message = try? JSONDecoder().decode(AppMessage.self, from: data) else {
            print("파싱 실패")
            return
        }

        switch message.type {
        case .request:
            let response = AppMessage(type: .response, payload: "처리 완료: \(message.payload)")
            if let frame = try? FramingProtocol.encode(response) {
                connection.send(frame)
            }
        case .event:
            print("이벤트 수신: \(message.payload)")
        default:
            break
        }
    }
}
</code></pre>
<hr/>
<h2>엔디언(Endianness) 이해</h2>
<p>길이를 바이트로 표현할 때 <strong>바이트 순서</strong>가 중요합니다.</p>
<pre><code class="language-swift">let length: UInt32 = 1000  // = 0x000003E8

// 빅엔디언 (Big-Endian): [0x00, 0x00, 0x03, 0xE8]  ← 네트워크 표준
// 리틀엔디언 (Little-Endian): [0xE8, 0x03, 0x00, 0x00]  ← x86/ARM 기본

// 네트워크 통신에서는 항상 빅엔디언 사용 (네트워크 바이트 오더)
let bigEndian = length.bigEndian    // 전송 시
let original = UInt32(bigEndian: received)  // 수신 시
</code></pre>
<p>Swift의 <code>.bigEndian</code>은 값을 빅엔디언으로 변환하고, <code>UInt32(bigEndian:)</code>은 빅엔디언 값을 읽습니다. 양쪽이 같은 순서를 사용하면 언어나 플랫폼이 달라도 통신이 됩니다.</p>
<hr/>
<h2>완성된 IPC 프로토콜 — Python <img src="https://s.w.org/images/core/emoji/15.1.0/72x72/2194.png" alt="↔" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Swift</h2>
<h3>Python 쪽 (송신)</h3>
<pre><code class="language-python">import socket
import json
import struct

SOCKET_PATH = "/tmp/myapp.sock"

def send_request(action: str, data: dict) -> dict:
    payload = json.dumps({"type": "request", "action": action, "data": data})
    encoded = payload.encode("utf-8")

    # 길이-접두사 프레임
    frame = struct.pack(">I", len(encoded)) + encoded  # ">I" = 빅엔디언 uint32

    with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
        s.connect(SOCKET_PATH)
        s.sendall(frame)

        # 응답 수신 (4바이트 길이 먼저)
        length_bytes = s.recv(4)
        length = struct.unpack(">I", length_bytes)[0]
        response_bytes = s.recv(length)

        return json.loads(response_bytes)

# 사용
result = send_request("tool_call", {"tool": "Bash", "command": "ls"})
print(result)  # {"decision": "allow"}
</code></pre>
<h3>Swift 쪽 (수신 + 처리)</h3>
<pre><code class="language-swift">// 요청 타입
struct IPCRequest: Decodable {
    let type: String
    let action: String
    let data: [String: String]
}

// 응답 타입
struct IPCResponse: Encodable {
    let decision: String
}

// 연결 처리
func handleIPCConnection(_ fd: Int32) {
    var rawBuffer = Data()
    var buffer = [UInt8](repeating: 0, count: 4096)

    // 길이 헤더 읽기 (4바이트)
    var headerBytes = [UInt8](repeating: 0, count: 4)
    recv(fd, &headerBytes, 4, MSG_WAITALL)
    let length = UInt32(bigEndian: headerBytes.withUnsafeBytes {
        $0.load(as: UInt32.self)
    })

    // 본문 읽기
    var body = [UInt8](repeating: 0, count: Int(length))
    recv(fd, &body, Int(length), MSG_WAITALL)
    let bodyData = Data(body)

    // JSON 파싱
    guard let request = try? JSONDecoder().decode(IPCRequest.self, from: bodyData) else {
        close(fd); return
    }

    print("수신 요청: \(request.action)")

    // 응답 생성 및 전송
    let response = IPCResponse(decision: "allow")
    if let responseData = try? JSONEncoder().encode(response) {
        let frame = FramingProtocol.encode(responseData)
        frame.withUnsafeBytes { ptr in
            _ = send(fd, ptr.baseAddress!, frame.count, 0)
        }
    }

    close(fd)
}
</code></pre>
<p><code>MSG_WAITALL</code> 플래그는 &#8220;지정한 바이트 수가 모두 올 때까지 기다려라&#8221;는 의미입니다. 길이를 알고 있을 때 편리합니다.</p>
<hr/>
<h2>프로토콜 설계 체크리스트</h2>
<ul>
<li><img src="https://s.w.org/images/core/emoji/15.1.0/72x72/2705.png" alt="✅" class="wp-smiley" style="height: 1em; max-height: 1em;" /> <strong>메시지 경계</strong>: 길이-접두사 또는 구분자로 프레임 구분</li>
<li><img src="https://s.w.org/images/core/emoji/15.1.0/72x72/2705.png" alt="✅" class="wp-smiley" style="height: 1em; max-height: 1em;" /> <strong>엔디언 통일</strong>: 빅엔디언(네트워크 표준) 사용</li>
<li><img src="https://s.w.org/images/core/emoji/15.1.0/72x72/2705.png" alt="✅" class="wp-smiley" style="height: 1em; max-height: 1em;" /> <strong>타입 필드</strong>: 메시지 종류를 구분하는 필드 포함</li>
<li><img src="https://s.w.org/images/core/emoji/15.1.0/72x72/2705.png" alt="✅" class="wp-smiley" style="height: 1em; max-height: 1em;" /> <strong>에러 처리</strong>: 연결 끊김, 파싱 실패, 타임아웃 처리</li>
<li><img src="https://s.w.org/images/core/emoji/15.1.0/72x72/2705.png" alt="✅" class="wp-smiley" style="height: 1em; max-height: 1em;" /> <strong>버전 필드</strong>: 양쪽 프로토콜 버전이 다를 때 대비</li>
</ul>
<hr/>
<h2>핵심 요약</h2>
<ul>
<li>TCP/UDS는 바이트 스트림 — <strong>메시지 경계가 없음</strong></li>
<li><strong>길이-접두사 프레이밍</strong>: 앞 4바이트에 본문 길이를 넣어 경계를 명시</li>
<li><strong>FrameDecoder</strong>: 수신 버퍼에 데이터를 누적하며 완성된 메시지만 추출</li>
<li><strong>빅엔디언</strong>: 네트워크 통신의 표준 바이트 순서. Swift의 <code>.bigEndian</code> 사용</li>
<li>Python의 <code>struct.pack(">I", n)</code> <img src="https://s.w.org/images/core/emoji/15.1.0/72x72/2194.png" alt="↔" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Swift의 <code>UInt32(n).bigEndian</code>은 동등</li>
<li><code>MSG_WAITALL</code>: 지정 바이트 수가 도착할 때까지 블록</li>
</ul>
<p>6부 네트워킹과 IPC가 완결됩니다. 다음 편부터는 7부 시스템 프로그래밍으로, 셸 스크립트, PTY, LaunchAgent, AVFoundation 사운드 재생을 다룹니다.</p>
<blockquote>
<p><img src="https://s.w.org/images/core/emoji/15.1.0/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Generated with <a href="https://claude.ai/claude-code">Claude Code</a></p>
</blockquote>
]]></content:encoded>
					
					<wfw:commentRss>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-31%ed%8e%b8-%ea%b8%b8%ec%9d%b4-%ec%a0%91%eb%91%90%ec%82%ac-%ed%94%84%eb%a0%88%ec%9d%b4%eb%b0%8d%ea%b3%bc-ipc-%ed%94%84%eb%a1%9c%ed%86%a0%ec%bd%9c-%ec%84%a4/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>[Swift 입문] 30편 — Unix Domain Socket과 프로세스 간 통신</title>
		<link>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-30%ed%8e%b8-unix-domain-socket%ea%b3%bc-%ed%94%84%eb%a1%9c%ec%84%b8%ec%8a%a4-%ea%b0%84-%ed%86%b5%ec%8b%a0/</link>
					<comments>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-30%ed%8e%b8-unix-domain-socket%ea%b3%bc-%ed%94%84%eb%a1%9c%ec%84%b8%ec%8a%a4-%ea%b0%84-%ed%86%b5%ec%8b%a0/#respond</comments>
		
		<dc:creator><![CDATA[낭창]]></dc:creator>
		<pubDate>Tue, 16 Jun 2026 11:31:10 +0000</pubDate>
				<category><![CDATA[프로그래밍 이야기]]></category>
		<guid isPermaLink="false">https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-30%ed%8e%b8-unix-domain-socket%ea%b3%bc-%ed%94%84%eb%a1%9c%ec%84%b8%ec%8a%a4-%ea%b0%84-%ed%86%b5%ec%8b%a0/</guid>

					<description><![CDATA[🤖 이 글은 Claude Code(AI)가 작성합니다. &#124; 시리즈 목차 &#124; 이전: [29편] TCP 소켓과 네트워크 통신 프로세스 간 통신(IPC)이란? 하나의 앱이 모든 일을 하는 경우는 드뭅니다. 메인 앱과 도우미 프로세스, 앱과 셸 스크립트, 또는 두 개의 독립 앱이 서로 데이터를 주고받아야 할 때가 있습니다. 이것을 IPC(Inter-Process Communication, 프로세스 간 통신)라고 합니다. macOS에서 IPC를 구현하는 방법은 [&#8230;]]]></description>
										<content:encoded><![CDATA[<blockquote>
<p><img src="https://s.w.org/images/core/emoji/15.1.0/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 이 글은 <a href="https://claude.ai/claude-code">Claude Code</a>(AI)가 작성합니다. | <a href="https://nangchang.nes.or.kr/?p=1085">시리즈 목차</a> | 이전: <a href="https://nangchang.nes.or.kr/?p=1204">[29편] TCP 소켓과 네트워크 통신</a></p>
</blockquote>
<h2>프로세스 간 통신(IPC)이란?</h2>
<p>하나의 앱이 모든 일을 하는 경우는 드뭅니다. 메인 앱과 도우미 프로세스, 앱과 셸 스크립트, 또는 두 개의 독립 앱이 서로 데이터를 주고받아야 할 때가 있습니다. 이것을 <strong>IPC(Inter-Process Communication, 프로세스 간 통신)</strong>라고 합니다.</p>
<p>macOS에서 IPC를 구현하는 방법은 여러 가지입니다.</p>
<table>
<thead>
<tr>
<th>방법</th>
<th>특징</th>
<th>적합한 상황</th>
</tr>
</thead>
<tbody>
<tr>
<td>TCP 소켓</td>
<td>네트워크를 통해 통신</td>
<td>원격 서버, 다른 기기</td>
</tr>
<tr>
<td>Unix Domain Socket</td>
<td>파일 시스템을 통해 통신 (로컬 전용)</td>
<td>같은 컴퓨터 내 프로세스</td>
</tr>
<tr>
<td>XPC</td>
<td>Apple 전용 고수준 IPC</td>
<td>앱 확장, 특권 분리</td>
</tr>
<tr>
<td>Distributed Objects</td>
<td>구식, 잘 사용 안 함</td>
<td>—</td>
</tr>
<tr>
<td>표준 입출력(stdin/stdout)</td>
<td>부모<img src="https://s.w.org/images/core/emoji/15.1.0/72x72/2194.png" alt="↔" class="wp-smiley" style="height: 1em; max-height: 1em;" />자식 프로세스 간 파이프</td>
<td>셸 스크립트와 통신</td>
</tr>
</tbody>
</table>
<hr/>
<h2>Unix Domain Socket이란?</h2>
<p>Unix Domain Socket(UDS)은 TCP 소켓과 같은 API를 사용하지만, 인터넷을 거치지 않고 <strong>파일 시스템 경로</strong>를 주소로 사용합니다.</p>
<pre><code>TCP 소켓: 192.168.1.10:8080  (IP 주소 + 포트)
Unix Domain Socket: /tmp/myapp.sock  (파일 경로)
</code></pre>
<h3>TCP 소켓 대비 장점</h3>
<ul>
<li><strong>더 빠름</strong>: 네트워크 스택을 거치지 않아 로컬 통신 속도가 TCP보다 빠릅니다</li>
<li><strong>파일 권한으로 보안</strong>: 소켓 파일의 읽기/쓰기 권한으로 접근 제어</li>
<li><strong>포트 충돌 없음</strong>: 포트 번호 대신 파일 경로를 사용하므로 충돌 위험이 없음</li>
</ul>
<p>Docker, PostgreSQL, Redis 같은 서버 소프트웨어도 로컬 IPC에 UDS를 사용합니다.</p>
<hr/>
<h2>Swift에서 Unix Domain Socket 서버</h2>
<pre><code class="language-swift">import Foundation
import Network

class UnixSocketServer {
    private var listener: NWListener?
    private let socketPath: String
    private let queue = DispatchQueue(label: "uds.server")

    init(socketPath: String) {
        self.socketPath = socketPath
    }

    func start() throws {
        // 기존 소켓 파일 제거 (재시작 시 충돌 방지)
        try? FileManager.default.removeItem(atPath: socketPath)

        // Unix Domain Socket 엔드포인트
        let endpoint = NWEndpoint.unix(path: socketPath)
        let parameters = NWParameters()
        parameters.defaultProtocolStack.transportProtocol =
            NWProtocolTCP.Options()

        // NWListener는 UDS도 지원
        listener = try NWListener(using: .tcp, on: .any)

        // 실제로는 POSIX API를 사용하는 것이 더 일반적
        // (아래 POSIX 방식 참고)
    }
}
</code></pre>
<p>Apple의 <code>Network</code> 프레임워크는 Unix Domain Socket을 완전히 지원하지 않아, macOS에서는 POSIX C API를 직접 사용하는 경우가 많습니다.</p>
<h3>POSIX API로 UDS 서버</h3>
<pre><code class="language-swift">import Foundation

class PosixUnixSocketServer {
    private let socketPath: String
    private var serverFd: Int32 = -1
    private var running = false

    init(socketPath: String) {
        self.socketPath = socketPath
    }

    func start() {
        // 기존 소켓 파일 삭제
        unlink(socketPath)

        // 소켓 생성 (AF_UNIX = Unix Domain, SOCK_STREAM = TCP 유사)
        serverFd = socket(AF_UNIX, SOCK_STREAM, 0)
        guard serverFd >= 0 else {
            print("소켓 생성 실패")
            return
        }

        // 주소 구조체 설정
        var addr = sockaddr_un()
        addr.sun_family = sa_family_t(AF_UNIX)
        withUnsafeMutablePointer(to: &addr.sun_path) { ptr in
            ptr.withMemoryRebound(to: CChar.self, capacity: 108) { cPtr in
                _ = socketPath.withCString { strncpy(cPtr, $0, 107) }
            }
        }

        // 바인드 (소켓을 경로에 연결)
        let bindResult = withUnsafePointer(to: &addr) {
            $0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
                bind(serverFd, $0, socklen_t(MemoryLayout&lt;sockaddr_un&gt;.size))
            }
        }
        guard bindResult == 0 else { print("bind 실패"); return }

        // 리슨 (연결 대기)
        listen(serverFd, 5)
        print("서버 시작: \(socketPath)")

        running = true
        acceptLoop()
    }

    private func acceptLoop() {
        DispatchQueue.global().async { [weak self] in
            guard let self = self else { return }
            while self.running {
                let clientFd = accept(self.serverFd, nil, nil)
                if clientFd >= 0 {
                    self.handleClient(clientFd)
                }
            }
        }
    }

    private func handleClient(_ fd: Int32) {
        DispatchQueue.global().async {
            var buffer = [UInt8](repeating: 0, count: 4096)
            while true {
                let bytesRead = recv(fd, &buffer, buffer.count, 0)
                if bytesRead <= 0 { break }

                let data = Data(buffer[0..&lt;bytesRead])
                if let text = String(data: data, encoding: .utf8) {
                    print("수신: \(text)")

                    // 에코 응답
                    data.withUnsafeBytes { ptr in
                        _ = send(fd, ptr.baseAddress!, data.count, 0)
                    }
                }
            }
            close(fd)
        }
    }

    func stop() {
        running = false
        close(serverFd)
        unlink(socketPath)
    }
}
</code></pre>
<hr/>
<h2>Python 스크립트와 Swift 앱 연결</h2>
<p>실제 앱에서 자주 쓰이는 패턴은 Swift 앱과 Python 스크립트가 Unix Domain Socket으로 대화하는 것입니다.</p>
<h3>Python 클라이언트 (스크립트 쪽)</h3>
<pre><code class="language-python">import socket
import json

SOCKET_PATH = "/tmp/myapp.sock"

def send_message(data: dict) -> dict:
    with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
        s.connect(SOCKET_PATH)

        # JSON으로 직렬화해서 전송
        payload = json.dumps(data).encode("utf-8")
        s.sendall(payload)
        s.shutdown(socket.SHUT_WR)  # 전송 완료 신호

        # 응답 수신
        response_data = b""
        while chunk := s.recv(4096):
            response_data += chunk

        return json.loads(response_data)

# 사용
response = send_message({"action": "approve", "toolName": "Bash"})
print(response)  # {"result": "allowed"}
</code></pre>
<h3>Swift 서버 (앱 쪽) — JSON 메시지 처리</h3>
<pre><code class="language-swift">// 메시지 타입 정의
struct HookRequest: Decodable {
    let action: String
    let toolName: String
}

struct HookResponse: Encodable {
    let result: String
}

// 클라이언트로부터 수신한 데이터를 JSON으로 처리
private func handleClient(_ fd: Int32) {
    DispatchQueue.global().async {
        var accumulated = Data()
        var buffer = [UInt8](repeating: 0, count: 4096)

        // 전체 메시지 수신 (클라이언트가 SHUT_WR 전송 시까지)
        while true {
            let n = recv(fd, &buffer, buffer.count, 0)
            if n <= 0 { break }
            accumulated.append(contentsOf: buffer[0..&lt;n])
        }

        // JSON 파싱
        if let request = try? JSONDecoder().decode(HookRequest.self, from: accumulated) {
            print("요청: \(request.action) - \(request.toolName)")

            // 응답 생성
            let response = HookResponse(result: "allowed")
            if let responseData = try? JSONEncoder().encode(response) {
                responseData.withUnsafeBytes { ptr in
                    _ = send(fd, ptr.baseAddress!, responseData.count, 0)
                }
            }
        }

        close(fd)
    }
}
</code></pre>
<hr/>
<h2>표준 입출력(stdin/stdout) — 가장 단순한 IPC</h2>
<p>부모 프로세스가 자식 프로세스를 실행할 때 stdin/stdout으로 통신하는 방법입니다.</p>
<pre><code class="language-swift">import Foundation

// 자식 프로세스 실행 + stdin/stdout 통신
func runScript(input: String) async throws -> String {
    let process = Process()
    process.executableURL = URL(fileURLWithPath: "/usr/bin/python3")
    process.arguments = ["/path/to/script.py"]

    let stdinPipe = Pipe()
    let stdoutPipe = Pipe()
    let stderrPipe = Pipe()

    process.standardInput = stdinPipe
    process.standardOutput = stdoutPipe
    process.standardError = stderrPipe

    try process.run()

    // stdin으로 데이터 전달
    if let data = input.data(using: .utf8) {
        stdinPipe.fileHandleForWriting.write(data)
        stdinPipe.fileHandleForWriting.closeFile()
    }

    // stdout 읽기
    let outputData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
    process.waitUntilExit()

    guard let output = String(data: outputData, encoding: .utf8) else {
        throw NSError(domain: "ProcessError", code: -1)
    }
    return output.trimmingCharacters(in: .whitespacesAndNewlines)
}

// 사용
let result = try await runScript(input: "Hello from Swift")
print(result)
</code></pre>
<hr/>
<h2>어떤 IPC 방식을 선택할까?</h2>
<pre><code>빠른 로컬 통신이 필요하고 동시 연결이 많음  →  Unix Domain Socket
단순한 단방향 데이터 전달                     →  stdin/stdout (Pipe)
Apple 플랫폼 전용, 권한 분리 필요             →  XPC
원격 통신 또는 HTTP 기반                      →  TCP + URLSession
</code></pre>
<hr/>
<h2>핵심 요약</h2>
<ul>
<li><strong>Unix Domain Socket</strong>: 파일 경로를 주소로 쓰는 로컬 전용 소켓. TCP보다 빠르고 권한 제어 용이</li>
<li><code>socket(AF_UNIX, SOCK_STREAM, 0)</code> → <code>bind</code> → <code>listen</code> → <code>accept</code> 순서로 서버 구성</li>
<li>Python 스크립트 <img src="https://s.w.org/images/core/emoji/15.1.0/72x72/2194.png" alt="↔" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Swift 앱 IPC 패턴: JSON 메시지 + Unix Domain Socket</li>
<li><strong>Process + Pipe</strong>: 자식 프로세스 실행 후 stdin/stdout으로 단순 통신</li>
<li>TCP 소켓과 API가 거의 동일해 TCP를 알면 UDS도 쉽게 사용 가능</li>
</ul>
<p>다음 편에서는 스트림 기반 소켓의 핵심 문제인 <strong>메시지 경계</strong>를 해결하는 길이-접두사 프레이밍과 실전 IPC 프로토콜 설계를 다룹니다.</p>
<blockquote>
<p><img src="https://s.w.org/images/core/emoji/15.1.0/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Generated with <a href="https://claude.ai/claude-code">Claude Code</a></p>
</blockquote>
]]></content:encoded>
					
					<wfw:commentRss>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-30%ed%8e%b8-unix-domain-socket%ea%b3%bc-%ed%94%84%eb%a1%9c%ec%84%b8%ec%8a%a4-%ea%b0%84-%ed%86%b5%ec%8b%a0/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>[Swift 입문] 29편 — TCP 소켓과 네트워크 통신</title>
		<link>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-29%ed%8e%b8-tcp-%ec%86%8c%ec%bc%93%ea%b3%bc-%eb%84%a4%ed%8a%b8%ec%9b%8c%ed%81%ac-%ed%86%b5%ec%8b%a0/</link>
					<comments>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-29%ed%8e%b8-tcp-%ec%86%8c%ec%bc%93%ea%b3%bc-%eb%84%a4%ed%8a%b8%ec%9b%8c%ed%81%ac-%ed%86%b5%ec%8b%a0/#respond</comments>
		
		<dc:creator><![CDATA[낭창]]></dc:creator>
		<pubDate>Tue, 16 Jun 2026 11:29:52 +0000</pubDate>
				<category><![CDATA[프로그래밍 이야기]]></category>
		<guid isPermaLink="false">https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-29%ed%8e%b8-tcp-%ec%86%8c%ec%bc%93%ea%b3%bc-%eb%84%a4%ed%8a%b8%ec%9b%8c%ed%81%ac-%ed%86%b5%ec%8b%a0/</guid>

					<description><![CDATA[🤖 이 글은 Claude Code(AI)가 작성합니다. &#124; 시리즈 목차 &#124; 이전: [28편] SQLite와 데이터베이스 네트워크 통신의 기초 — TCP/IP 앱이 인터넷을 통해 서버와 대화하거나, 같은 컴퓨터 안에서 두 프로세스가 대화할 때 사용하는 가장 기본적인 메커니즘이 소켓(Socket)입니다. 소켓은 두 프로그램이 데이터를 주고받기 위해 여는 &#8220;연결 창구&#8221;입니다. 편지함에 비유하면, 소켓은 주소가 붙은 우편함이고, 데이터는 그 안에 넣는 [&#8230;]]]></description>
										<content:encoded><![CDATA[<blockquote>
<p><img src="https://s.w.org/images/core/emoji/15.1.0/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 이 글은 <a href="https://claude.ai/claude-code">Claude Code</a>(AI)가 작성합니다. | <a href="https://nangchang.nes.or.kr/?p=1085">시리즈 목차</a> | 이전: <a href="https://nangchang.nes.or.kr/?p=1203">[28편] SQLite와 데이터베이스</a></p>
</blockquote>
<h2>네트워크 통신의 기초 — TCP/IP</h2>
<p>앱이 인터넷을 통해 서버와 대화하거나, 같은 컴퓨터 안에서 두 프로세스가 대화할 때 사용하는 가장 기본적인 메커니즘이 <strong>소켓(Socket)</strong>입니다.</p>
<p>소켓은 두 프로그램이 데이터를 주고받기 위해 여는 &#8220;연결 창구&#8221;입니다. 편지함에 비유하면, 소켓은 주소가 붙은 우편함이고, 데이터는 그 안에 넣는 편지입니다.</p>
<p><strong>TCP(Transmission Control Protocol)</strong>는 가장 널리 쓰이는 소켓 유형입니다.</p>
<ul>
<li><strong>신뢰성</strong>: 데이터가 반드시 도착하고, 순서도 보장됩니다</li>
<li><strong>스트림 기반</strong>: 바이트 흐름으로 통신합니다</li>
<li><strong>연결 지향</strong>: 통신 전 연결(핸드셰이크)이 필요합니다</li>
</ul>
<hr/>
<h2>URLSession — HTTP 통신</h2>
<p>대부분의 앱은 REST API를 통해 서버와 통신합니다. Swift에서는 <code>URLSession</code>이 이를 담당합니다.</p>
<pre><code class="language-swift">import Foundation

// GET 요청
func fetchPosts() async throws -> [Post] {
    let url = URL(string: "https://jsonplaceholder.typicode.com/posts")!
    let (data, response) = try await URLSession.shared.data(from: url)

    // HTTP 상태 코드 확인
    guard let httpResponse = response as? HTTPURLResponse,
          (200...299).contains(httpResponse.statusCode) else {
        throw URLError(.badServerResponse)
    }

    return try JSONDecoder().decode([Post].self, from: data)
}

// POST 요청
func createPost(title: String, body: String) async throws -> Post {
    let url = URL(string: "https://jsonplaceholder.typicode.com/posts")!

    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")

    let payload = ["title": title, "body": body, "userId": 1] as [String: Any]
    request.httpBody = try JSONSerialization.data(withJSONObject: payload)

    let (data, _) = try await URLSession.shared.data(for: request)
    return try JSONDecoder().decode(Post.self, from: data)
}
</code></pre>
<hr/>
<h2>Network 프레임워크 — TCP 소켓 직접 사용</h2>
<p>HTTP가 아닌 커스텀 프로토콜이나 낮은 레벨의 TCP 통신이 필요할 때는 Apple의 <code>Network</code> 프레임워크를 사용합니다.</p>
<h3>TCP 클라이언트</h3>
<pre><code class="language-swift">import Network

class TCPClient {
    private var connection: NWConnection?
    private let queue = DispatchQueue(label: "tcp.client")

    func connect(host: String, port: UInt16) {
        let endpoint = NWEndpoint.hostPort(
            host: NWEndpoint.Host(host),
            port: NWEndpoint.Port(rawValue: port)!
        )

        connection = NWConnection(to: endpoint, using: .tcp)

        connection?.stateUpdateHandler = { state in
            switch state {
            case .ready:
                print("연결됨")
                self.receive()
            case .failed(let error):
                print("연결 실패: \(error)")
            case .cancelled:
                print("연결 취소")
            default:
                break
            }
        }

        connection?.start(queue: queue)
    }

    // 데이터 송신
    func send(_ text: String) {
        guard let data = text.data(using: .utf8) else { return }

        connection?.send(content: data, completion: .contentProcessed { error in
            if let error = error {
                print("전송 실패: \(error)")
            }
        })
    }

    // 데이터 수신 (반복 호출)
    private func receive() {
        connection?.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, isComplete, error in
            if let data = data, !data.isEmpty {
                let text = String(data: data, encoding: .utf8) ?? "(바이너리)"
                print("수신: \(text)")
            }

            if isComplete {
                print("연결 종료")
            } else if error == nil {
                self.receive()  // 다음 데이터 대기
            }
        }
    }

    func disconnect() {
        connection?.cancel()
    }
}
</code></pre>
<h3>TCP 서버</h3>
<pre><code class="language-swift">class TCPServer {
    private var listener: NWListener?
    private let queue = DispatchQueue(label: "tcp.server")
    private var connections: [NWConnection] = []

    func start(port: UInt16) throws {
        let parameters = NWParameters.tcp
        listener = try NWListener(using: parameters,
                                   on: NWEndpoint.Port(rawValue: port)!)

        listener?.stateUpdateHandler = { state in
            switch state {
            case .ready:
                print("서버 시작됨 — 포트 \(port)")
            case .failed(let error):
                print("서버 오류: \(error)")
            default:
                break
            }
        }

        // 새 클라이언트 연결 수락
        listener?.newConnectionHandler = { [weak self] connection in
            print("새 클라이언트 연결")
            self?.connections.append(connection)
            self?.handleConnection(connection)
        }

        listener?.start(queue: queue)
    }

    private func handleConnection(_ connection: NWConnection) {
        connection.stateUpdateHandler = { state in
            if case .ready = state {
                self.receiveLoop(connection)
            }
        }
        connection.start(queue: queue)
    }

    private func receiveLoop(_ connection: NWConnection) {
        connection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { data, _, isComplete, error in
            if let data = data, let text = String(data: data, encoding: .utf8) {
                print("클라이언트로부터 수신: \(text)")

                // 에코 응답
                connection.send(content: data, completion: .idempotent)
            }

            if !isComplete && error == nil {
                self.receiveLoop(connection)
            }
        }
    }

    func stop() {
        listener?.cancel()
        connections.forEach { $0.cancel() }
    }
}
</code></pre>
<hr/>
<h2>TCP 스트림의 핵심 문제 — 메시지 경계</h2>
<p>TCP는 <strong>바이트 스트림</strong>입니다. &#8220;1000바이트 메시지를 보냈다&#8221;고 해서 받는 쪽이 한 번에 1000바이트를 받는다는 보장이 없습니다. 200바이트씩 5번에 나눠서 올 수도 있고, 다른 메시지와 합쳐서 올 수도 있습니다.</p>
<p>이 문제를 해결하는 방법이 <strong>메시지 프레이밍(Framing)</strong>입니다. 다음 편에서 자세히 다룹니다.</p>
<pre><code class="language-swift">// 잘못된 방식 — TCP는 경계를 보장하지 않음
connection.send(content: messageData, ...)  // 보낸 쪽
connection.receive(...) { data in
    // 받는 쪽: data가 전체 메시지라는 보장이 없음!
}

// 올바른 방식 — 길이 접두사로 경계 표시 (다음 편에서 상세히)
var frame = Data()
var length = UInt32(messageData.count).bigEndian
frame.append(Data(bytes: &length, count: 4))  // 앞 4바이트: 길이
frame.append(messageData)                      // 나머지: 실제 데이터
</code></pre>
<hr/>
<h2>async/await와 Network 프레임워크</h2>
<pre><code class="language-swift">// Swift Concurrency와 함께 사용하는 래퍼
actor TCPClientActor {
    private var connection: NWConnection?
    private let queue = DispatchQueue(label: "tcp.actor")

    func connect(host: String, port: UInt16) async throws {
        let endpoint = NWEndpoint.hostPort(
            host: NWEndpoint.Host(host),
            port: NWEndpoint.Port(rawValue: port)!
        )
        let conn = NWConnection(to: endpoint, using: .tcp)

        // continuation으로 콜백 → async 변환
        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation&lt;Void, Error&gt;) in
            conn.stateUpdateHandler = { state in
                switch state {
                case .ready:
                    continuation.resume()
                case .failed(let error):
                    continuation.resume(throwing: error)
                default:
                    break
                }
            }
            conn.start(queue: self.queue)
        }

        self.connection = conn
    }

    func send(_ data: Data) async throws {
        guard let connection else { throw URLError(.notConnectedToInternet) }

        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation&lt;Void, Error&gt;) in
            connection.send(content: data, completion: .contentProcessed { error in
                if let error = error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume()
                }
            })
        }
    }
}
</code></pre>
<hr/>
<h2>다른 언어와 비교</h2>
<table>
<thead>
<tr>
<th>언어</th>
<th>TCP 소켓 API</th>
</tr>
</thead>
<tbody>
<tr>
<td>Swift (macOS)</td>
<td>Network 프레임워크 (NWConnection)</td>
</tr>
<tr>
<td>Python</td>
<td>socket 모듈 (BSD 소켓 래핑)</td>
</tr>
<tr>
<td>Node.js</td>
<td>net 모듈</td>
</tr>
<tr>
<td>Java/Kotlin</td>
<td>java.net.Socket</td>
</tr>
</tbody>
</table>
<p>Python 예시와 비교:</p>
<pre><code class="language-python"># Python TCP 클라이언트
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("localhost", 8080))
s.send(b"Hello")
data = s.recv(1024)
s.close()
</code></pre>
<p>Swift의 <code>Network</code> 프레임워크는 Python의 <code>socket</code>보다 추상화 수준이 높고, TLS/연결 상태 관리가 더 편리하게 통합되어 있습니다.</p>
<hr/>
<h2>핵심 요약</h2>
<ul>
<li><strong>TCP</strong>: 신뢰성 있는 바이트 스트림 프로토콜. 연결 후 양방향 통신</li>
<li><strong>URLSession</strong>: HTTP/HTTPS 통신에 적합한 고수준 API</li>
<li><strong>Network 프레임워크</strong>: TCP/UDP 소켓 직접 제어. <code>NWConnection</code>, <code>NWListener</code></li>
<li>TCP는 <strong>메시지 경계를 보장하지 않음</strong> — 프레이밍이 필요</li>
<li><code>withCheckedThrowingContinuation</code>으로 콜백 기반 API를 async/await로 변환</li>
</ul>
<p>다음 편에서는 같은 컴퓨터 내 프로세스 간 통신에 더 적합한 <strong>Unix Domain Socket</strong>과 메시지 경계 문제를 해결하는 <strong>길이-접두사 프레이밍</strong>을 배웁니다.</p>
<blockquote>
<p><img src="https://s.w.org/images/core/emoji/15.1.0/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Generated with <a href="https://claude.ai/claude-code">Claude Code</a></p>
</blockquote>
]]></content:encoded>
					
					<wfw:commentRss>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-29%ed%8e%b8-tcp-%ec%86%8c%ec%bc%93%ea%b3%bc-%eb%84%a4%ed%8a%b8%ec%9b%8c%ed%81%ac-%ed%86%b5%ec%8b%a0/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>[Swift 입문] 28편 — SQLite와 데이터베이스</title>
		<link>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-28%ed%8e%b8-sqlite%ec%99%80-%eb%8d%b0%ec%9d%b4%ed%84%b0%eb%b2%a0%ec%9d%b4%ec%8a%a4/</link>
					<comments>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-28%ed%8e%b8-sqlite%ec%99%80-%eb%8d%b0%ec%9d%b4%ed%84%b0%eb%b2%a0%ec%9d%b4%ec%8a%a4/#respond</comments>
		
		<dc:creator><![CDATA[낭창]]></dc:creator>
		<pubDate>Tue, 16 Jun 2026 11:28:20 +0000</pubDate>
				<category><![CDATA[프로그래밍 이야기]]></category>
		<guid isPermaLink="false">https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-28%ed%8e%b8-sqlite%ec%99%80-%eb%8d%b0%ec%9d%b4%ed%84%b0%eb%b2%a0%ec%9d%b4%ec%8a%a4/</guid>

					<description><![CDATA[🤖 이 글은 Claude Code(AI)가 작성합니다. &#124; 시리즈 목차 &#124; 이전: [27편] FileManager와 파일 시스템 파일 저장의 한계와 데이터베이스 JSON 파일로 데이터를 저장하면 간단하지만, 데이터가 많아질수록 문제가 생깁니다. 10,000개의 메모를 불러오려면 전체 JSON 파일을 읽어야 합니다 &#8220;2024년에 작성한 메모만 검색&#8221;하려면 모든 데이터를 메모리에 올려야 합니다 여러 스레드에서 동시에 파일에 쓰면 데이터가 깨질 수 있습니다 이런 [&#8230;]]]></description>
										<content:encoded><![CDATA[<blockquote>
<p><img src="https://s.w.org/images/core/emoji/15.1.0/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 이 글은 <a href="https://claude.ai/claude-code">Claude Code</a>(AI)가 작성합니다. | <a href="https://nangchang.nes.or.kr/?p=1085">시리즈 목차</a> | 이전: <a href="https://nangchang.nes.or.kr/?p=1202">[27편] FileManager와 파일 시스템</a></p>
</blockquote>
<h2>파일 저장의 한계와 데이터베이스</h2>
<p>JSON 파일로 데이터를 저장하면 간단하지만, 데이터가 많아질수록 문제가 생깁니다.</p>
<ul>
<li>10,000개의 메모를 불러오려면 전체 JSON 파일을 읽어야 합니다</li>
<li>&#8220;2024년에 작성한 메모만 검색&#8221;하려면 모든 데이터를 메모리에 올려야 합니다</li>
<li>여러 스레드에서 동시에 파일에 쓰면 데이터가 깨질 수 있습니다</li>
</ul>
<p>이런 문제를 해결하는 것이 <strong>데이터베이스</strong>이고, macOS/iOS 앱에서 가장 널리 쓰이는 내장 데이터베이스가 <strong>SQLite</strong>입니다.</p>
<hr/>
<h2>SQLite란?</h2>
<p>SQLite는 서버가 필요 없는 <strong>파일 기반 관계형 데이터베이스</strong>입니다. 전 세계에서 가장 많이 배포된 데이터베이스로, 스마트폰 앱, 브라우저, 항공기 소프트웨어까지 다양한 곳에서 사용됩니다.</p>
<ul>
<li>단일 <code>.db</code> 파일에 모든 데이터 저장</li>
<li>SQL 쿼리로 빠른 검색·정렬·필터링</li>
<li>트랜잭션으로 데이터 일관성 보장</li>
<li>macOS/iOS에 <code>libsqlite3</code>로 기본 내장</li>
</ul>
<hr/>
<h2>libsqlite3 — C API 직접 사용</h2>
<p>Swift에서 SQLite를 사용하는 가장 기본적인 방법은 시스템에 내장된 C 라이브러리를 직접 호출하는 것입니다.</p>
<pre><code class="language-swift">import Foundation
import SQLite3  // 또는 import CSQLite

class Database {
    private var db: OpaquePointer?

    init(path: String) throws {
        // 데이터베이스 열기 (없으면 생성)
        let result = sqlite3_open(path, &db)
        if result != SQLITE_OK {
            throw DatabaseError.openFailed(sqlite3_errmsg(db).map(String.init) ?? "알 수 없는 오류")
        }
    }

    deinit {
        sqlite3_close(db)
    }
}

enum DatabaseError: Error {
    case openFailed(String)
    case queryFailed(String)
}
</code></pre>
<h3>테이블 생성</h3>
<pre><code class="language-swift">extension Database {
    func createTables() throws {
        let sql = """
            CREATE TABLE IF NOT EXISTS notes (
                id          TEXT PRIMARY KEY,
                title       TEXT NOT NULL,
                body        TEXT NOT NULL DEFAULT '',
                created_at  REAL NOT NULL,
                updated_at  REAL NOT NULL
            );
            CREATE INDEX IF NOT EXISTS notes_created_at ON notes(created_at DESC);
        """

        var errorMsg: UnsafeMutablePointer&lt;CChar&gt;?
        let result = sqlite3_exec(db, sql, nil, nil, &errorMsg)

        if result != SQLITE_OK {
            let msg = errorMsg.map(String.init) ?? "알 수 없는 오류"
            sqlite3_free(errorMsg)
            throw DatabaseError.queryFailed(msg)
        }
    }
}
</code></pre>
<h3>데이터 삽입 — Prepared Statement</h3>
<p>SQL 인젝션을 막고 성능을 높이기 위해 항상 prepared statement를 사용합니다.</p>
<pre><code class="language-swift">struct Note {
    let id: String
    var title: String
    var body: String
    var createdAt: Date
    var updatedAt: Date
}

extension Database {
    func insert(_ note: Note) throws {
        let sql = """
            INSERT OR REPLACE INTO notes (id, title, body, created_at, updated_at)
            VALUES (?, ?, ?, ?, ?);
        """

        var stmt: OpaquePointer?
        defer { sqlite3_finalize(stmt) }  // 항상 해제

        guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {
            throw DatabaseError.queryFailed("prepare 실패")
        }

        // ? 자리에 값 바인딩 (1-indexed)
        sqlite3_bind_text(stmt, 1, note.id, -1, SQLITE_TRANSIENT)
        sqlite3_bind_text(stmt, 2, note.title, -1, SQLITE_TRANSIENT)
        sqlite3_bind_text(stmt, 3, note.body, -1, SQLITE_TRANSIENT)
        sqlite3_bind_double(stmt, 4, note.createdAt.timeIntervalSince1970)
        sqlite3_bind_double(stmt, 5, note.updatedAt.timeIntervalSince1970)

        guard sqlite3_step(stmt) == SQLITE_DONE else {
            throw DatabaseError.queryFailed("insert 실패")
        }
    }
}
</code></pre>
<h3>데이터 조회</h3>
<pre><code class="language-swift">extension Database {
    func fetchAll() throws -> [Note] {
        let sql = "SELECT id, title, body, created_at, updated_at FROM notes ORDER BY created_at DESC;"
        var stmt: OpaquePointer?
        defer { sqlite3_finalize(stmt) }

        guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {
            throw DatabaseError.queryFailed("prepare 실패")
        }

        var notes: [Note] = []
        while sqlite3_step(stmt) == SQLITE_ROW {
            let id    = String(cString: sqlite3_column_text(stmt, 0))
            let title = String(cString: sqlite3_column_text(stmt, 1))
            let body  = String(cString: sqlite3_column_text(stmt, 2))
            let createdAt = Date(timeIntervalSince1970: sqlite3_column_double(stmt, 3))
            let updatedAt = Date(timeIntervalSince1970: sqlite3_column_double(stmt, 4))

            notes.append(Note(id: id, title: title, body: body,
                              createdAt: createdAt, updatedAt: updatedAt))
        }
        return notes
    }

    func search(query: String) throws -> [Note] {
        let sql = "SELECT id, title, body, created_at, updated_at FROM notes WHERE title LIKE ? OR body LIKE ? ORDER BY updated_at DESC LIMIT 50;"
        var stmt: OpaquePointer?
        defer { sqlite3_finalize(stmt) }

        sqlite3_prepare_v2(db, sql, -1, &stmt, nil)

        let pattern = "%\(query)%"
        sqlite3_bind_text(stmt, 1, pattern, -1, SQLITE_TRANSIENT)
        sqlite3_bind_text(stmt, 2, pattern, -1, SQLITE_TRANSIENT)

        var results: [Note] = []
        while sqlite3_step(stmt) == SQLITE_ROW {
            // 위와 동일하게 행 읽기...
            let id    = String(cString: sqlite3_column_text(stmt, 0))
            let title = String(cString: sqlite3_column_text(stmt, 1))
            let body  = String(cString: sqlite3_column_text(stmt, 2))
            let createdAt = Date(timeIntervalSince1970: sqlite3_column_double(stmt, 3))
            let updatedAt = Date(timeIntervalSince1970: sqlite3_column_double(stmt, 4))
            results.append(Note(id: id, title: title, body: body,
                                createdAt: createdAt, updatedAt: updatedAt))
        }
        return results
    }
}
</code></pre>
<hr/>
<h2>트랜잭션</h2>
<p>여러 작업을 하나의 원자적 단위로 처리할 때 트랜잭션을 사용합니다. 중간에 실패하면 전부 취소(롤백)됩니다.</p>
<pre><code class="language-swift">extension Database {
    func transaction(_ block: () throws -> Void) throws {
        sqlite3_exec(db, "BEGIN TRANSACTION;", nil, nil, nil)
        do {
            try block()
            sqlite3_exec(db, "COMMIT;", nil, nil, nil)
        } catch {
            sqlite3_exec(db, "ROLLBACK;", nil, nil, nil)
            throw error
        }
    }
}

// 사용
try db.transaction {
    try db.insert(note1)
    try db.insert(note2)
    try db.insert(note3)
    // 셋 중 하나라도 실패하면 전부 취소
}
</code></pre>
<hr/>
<h2>WAL 모드 — 성능 향상</h2>
<p>SQLite의 기본 저널 모드 대신 WAL(Write-Ahead Logging) 모드를 사용하면 읽기와 쓰기를 동시에 할 수 있어 성능이 크게 향상됩니다.</p>
<pre><code class="language-swift">// 데이터베이스 열 때 한 번만 설정
sqlite3_exec(db, "PRAGMA journal_mode=WAL;", nil, nil, nil)
sqlite3_exec(db, "PRAGMA synchronous=NORMAL;", nil, nil, nil)
sqlite3_exec(db, "PRAGMA cache_size=-4000;", nil, nil, nil)  // 4MB 캐시
</code></pre>
<hr/>
<h2>SwiftData (iOS 17 / macOS 14+)</h2>
<p>Apple이 2023년 발표한 <strong>SwiftData</strong>는 SQLite 위에 구축된 고수준 프레임워크로, Core Data를 대체합니다. 복잡한 SQL 없이 Swift 코드로 데이터를 모델링하고 저장할 수 있습니다.</p>
<pre><code class="language-swift">import SwiftData

@Model  // SwiftData 모델 선언
class Note {
    var id: UUID
    var title: String
    var body: String
    var createdAt: Date

    init(title: String, body: String) {
        self.id = UUID()
        self.title = title
        self.body = body
        self.createdAt = .now
    }
}

// SwiftUI와 통합
struct NoteApp: App {
    var body: some Scene {
        WindowGroup {
            NoteListView()
        }
        .modelContainer(for: Note.self)  // 컨테이너 설정 한 줄로 끝
    }
}

struct NoteListView: View {
    @Query(sort: \Note.createdAt, order: .reverse) var notes: [Note]
    @Environment(\.modelContext) var context

    var body: some View {
        List(notes) { note in
            Text(note.title)
        }
        .toolbar {
            Button("추가") {
                context.insert(Note(title: "새 메모", body: ""))
            }
        }
    }
}
</code></pre>
<p>최신 macOS/iOS 앱을 새로 시작한다면 SwiftData를 사용하는 것이 권장됩니다. 구버전 지원이 필요하거나 성능을 세밀하게 제어해야 한다면 SQLite를 직접 사용합니다.</p>
<hr/>
<h2>데이터 저장 방법 비교</h2>
<table>
<thead>
<tr>
<th>방법</th>
<th>적합한 용도</th>
<th>용량</th>
</tr>
</thead>
<tbody>
<tr>
<td>UserDefaults</td>
<td>앱 설정, 간단한 값</td>
<td>소량</td>
</tr>
<tr>
<td>Codable + JSON 파일</td>
<td>중소규모 데이터, 내보내기</td>
<td>중간</td>
</tr>
<tr>
<td>SQLite</td>
<td>대량 데이터, 검색·정렬 필요</td>
<td>대용량</td>
</tr>
<tr>
<td>SwiftData</td>
<td>관계형 모델, Swift-native API</td>
<td>대용량</td>
</tr>
<tr>
<td>Core Data</td>
<td>레거시 코드, 구버전 지원</td>
<td>대용량</td>
</tr>
</tbody>
</table>
<hr/>
<h2>핵심 요약</h2>
<ul>
<li><strong>SQLite</strong>: 서버 불필요한 파일 기반 관계형 DB. macOS/iOS에 기본 내장</li>
<li><strong>sqlite3_open</strong>: DB 열기/생성. <strong>sqlite3_close</strong>: 반드시 닫기</li>
<li><strong>Prepared statement</strong>: SQL 인젝션 방지 + 성능 향상. <code>?</code>로 바인딩</li>
<li><strong>SQLITE_ROW / SQLITE_DONE</strong>: sqlite3_step 결과 코드</li>
<li><strong>트랜잭션</strong>: BEGIN → 작업 → COMMIT/ROLLBACK으로 원자성 보장</li>
<li><strong>WAL 모드</strong>: 읽기·쓰기 동시 처리로 성능 향상</li>
<li><strong>SwiftData</strong>: iOS 17+/macOS 14+ 신규 앱에 권장되는 고수준 API</li>
</ul>
<p>5부 Foundation과 데이터 저장이 완결됩니다. 다음 편부터는 6부 네트워킹과 IPC로, TCP 소켓, Unix Domain Socket, 프로세스 간 통신을 다룹니다.</p>
<blockquote>
<p><img src="https://s.w.org/images/core/emoji/15.1.0/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Generated with <a href="https://claude.ai/claude-code">Claude Code</a></p>
</blockquote>
]]></content:encoded>
					
					<wfw:commentRss>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-28%ed%8e%b8-sqlite%ec%99%80-%eb%8d%b0%ec%9d%b4%ed%84%b0%eb%b2%a0%ec%9d%b4%ec%8a%a4/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>[Swift 입문] 27편 — FileManager와 파일 시스템</title>
		<link>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-27%ed%8e%b8-filemanager%ec%99%80-%ed%8c%8c%ec%9d%bc-%ec%8b%9c%ec%8a%a4%ed%85%9c/</link>
					<comments>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-27%ed%8e%b8-filemanager%ec%99%80-%ed%8c%8c%ec%9d%bc-%ec%8b%9c%ec%8a%a4%ed%85%9c/#respond</comments>
		
		<dc:creator><![CDATA[낭창]]></dc:creator>
		<pubDate>Tue, 16 Jun 2026 11:27:09 +0000</pubDate>
				<category><![CDATA[프로그래밍 이야기]]></category>
		<guid isPermaLink="false">https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-27%ed%8e%b8-filemanager%ec%99%80-%ed%8c%8c%ec%9d%bc-%ec%8b%9c%ec%8a%a4%ed%85%9c/</guid>

					<description><![CDATA[🤖 이 글은 Claude Code(AI)가 작성합니다. &#124; 시리즈 목차 &#124; 이전: [26편] UserDefaults와 앱 설정 저장 파일로 데이터를 저장해야 할 때 UserDefaults는 소규모 설정값에 적합하지만, 다음 상황에서는 파일이 필요합니다. 수백 개 이상의 메모·문서·로그 저장 이미지, 오디오, 바이너리 데이터 내보내기/가져오기가 필요한 데이터 사용자가 접근해야 하는 문서 Swift에서 파일 시스템을 다루는 핵심 클래스는 FileManager입니다. 앱이 사용하는 디렉토리 [&#8230;]]]></description>
										<content:encoded><![CDATA[<blockquote>
<p><img src="https://s.w.org/images/core/emoji/15.1.0/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 이 글은 <a href="https://claude.ai/claude-code">Claude Code</a>(AI)가 작성합니다. | <a href="https://nangchang.nes.or.kr/?p=1085">시리즈 목차</a> | 이전: <a href="https://nangchang.nes.or.kr/?p=1201">[26편] UserDefaults와 앱 설정 저장</a></p>
</blockquote>
<h2>파일로 데이터를 저장해야 할 때</h2>
<p>UserDefaults는 소규모 설정값에 적합하지만, 다음 상황에서는 파일이 필요합니다.</p>
<ul>
<li>수백 개 이상의 메모·문서·로그 저장</li>
<li>이미지, 오디오, 바이너리 데이터</li>
<li>내보내기/가져오기가 필요한 데이터</li>
<li>사용자가 접근해야 하는 문서</li>
</ul>
<p>Swift에서 파일 시스템을 다루는 핵심 클래스는 <code>FileManager</code>입니다.</p>
<hr/>
<h2>앱이 사용하는 디렉토리</h2>
<p>macOS/iOS 앱은 보안 샌드박스 안에서 동작합니다. 아무 경로에나 파일을 쓸 수 없고, 정해진 디렉토리를 사용해야 합니다.</p>
<pre><code class="language-swift">import Foundation

let fm = FileManager.default

// 1. Documents — 사용자 문서. iTunes/Finder로 접근 가능, iCloud 백업 대상
let docs = fm.urls(for: .documentDirectory, in: .userDomainMask).first!
print(docs)  // /Users/hoin/Library/Containers/com.myapp/Data/Documents/

// 2. Application Support — 앱 데이터. 사용자에게 노출 안 됨
let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!

// 3. Caches — 캐시. 시스템이 필요 시 삭제 가능
let caches = fm.urls(for: .cachesDirectory, in: .userDomainMask).first!

// 4. 임시 파일
let temp = fm.temporaryDirectory
print(temp)  // /var/folders/.../T/

// macOS — 사용자 홈 디렉토리 (샌드박스 없는 앱)
let home = fm.homeDirectoryForCurrentUser
</code></pre>
<p>일반적으로:</p>
<ul>
<li><strong>Documents</strong>: 사용자가 직접 관리하는 파일 (노트, 내보내기 파일)</li>
<li><strong>Application Support</strong>: 앱 내부 데이터베이스, 설정 파일</li>
<li><strong>Caches</strong>: 다운로드 캐시, 썸네일 (삭제되어도 재생성 가능한 것)</li>
</ul>
<hr/>
<h2>파일 읽기와 쓰기</h2>
<h3>텍스트 파일</h3>
<pre><code class="language-swift">let docsURL = FileManager.default.urls(for: .documentDirectory,
                                        in: .userDomainMask).first!
let fileURL = docsURL.appendingPathComponent("note.txt")

// 쓰기
let content = "안녕하세요!\n이것은 파일에 저장된 텍스트입니다."
try content.write(to: fileURL, atomically: true, encoding: .utf8)

// 읽기
let loaded = try String(contentsOf: fileURL, encoding: .utf8)
print(loaded)

// atomically: true → 임시 파일에 먼저 쓴 뒤 교체 (중간에 앱 충돌 시 데이터 보호)
</code></pre>
<h3>Data (바이너리) 파일</h3>
<pre><code class="language-swift">let imageURL = docsURL.appendingPathComponent("photo.png")

// 쓰기
let imageData: Data = // ... 이미지 데이터
try imageData.write(to: imageURL, options: .atomic)

// 읽기
let loadedData = try Data(contentsOf: imageURL)
</code></pre>
<h3>Codable 객체를 파일로 저장</h3>
<pre><code class="language-swift">struct Note: Codable, Identifiable {
    let id: UUID
    var title: String
    var body: String
    var createdAt: Date
}

class NoteStorage {
    private let fileURL: URL

    init() {
        let docs = FileManager.default.urls(for: .documentDirectory,
                                             in: .userDomainMask).first!
        fileURL = docs.appendingPathComponent("notes.json")
    }

    func save(_ notes: [Note]) throws {
        let data = try JSONEncoder().encode(notes)
        try data.write(to: fileURL, options: .atomic)
    }

    func load() throws -> [Note] {
        guard FileManager.default.fileExists(atPath: fileURL.path) else {
            return []  // 파일이 없으면 빈 배열
        }
        let data = try Data(contentsOf: fileURL)
        return try JSONDecoder().decode([Note].self, from: data)
    }
}
</code></pre>
<hr/>
<h2>파일과 디렉토리 관리</h2>
<pre><code class="language-swift">let fm = FileManager.default
let baseURL = fm.urls(for: .documentDirectory, in: .userDomainMask).first!

// 디렉토리 생성
let notesDir = baseURL.appendingPathComponent("Notes")
try fm.createDirectory(at: notesDir, withIntermediateDirectories: true)
// withIntermediateDirectories: true → 중간 경로도 자동 생성

// 파일 존재 확인
if fm.fileExists(atPath: notesDir.path) {
    print("디렉토리 있음")
}

// 파일 목록 조회
let files = try fm.contentsOfDirectory(at: notesDir,
                                        includingPropertiesForKeys: [.fileSizeKey, .creationDateKey],
                                        options: .skipsHiddenFiles)
for file in files {
    print(file.lastPathComponent)
}

// 복사
let srcURL = notesDir.appendingPathComponent("note1.txt")
let dstURL = notesDir.appendingPathComponent("note1_backup.txt")
try fm.copyItem(at: srcURL, to: dstURL)

// 이동/이름 변경
let newURL = notesDir.appendingPathComponent("renamed_note.txt")
try fm.moveItem(at: srcURL, to: newURL)

// 삭제
try fm.removeItem(at: dstURL)
</code></pre>
<hr/>
<h2>파일 속성 읽기</h2>
<pre><code class="language-swift">let fileURL = baseURL.appendingPathComponent("data.json")

// 리소스 값 (성능 최적화됨)
let resourceValues = try fileURL.resourceValues(forKeys: [
    .fileSizeKey,
    .creationDateKey,
    .contentModificationDateKey,
    .isDirectoryKey
])

let fileSize = resourceValues.fileSize ?? 0
let created = resourceValues.creationDate
let modified = resourceValues.contentModificationDate
let isDir = resourceValues.isDirectory ?? false

print("크기: \(fileSize) bytes")
print("생성일: \(created?.description ?? "알 수 없음")")

// 사람이 읽기 좋은 파일 크기
let bcf = ByteCountFormatter()
bcf.allowedUnits = [.useKB, .useMB]
bcf.countStyle = .file
print("크기: \(bcf.string(fromByteCount: Int64(fileSize)))")  // "4.2 KB"
</code></pre>
<hr/>
<h2>비동기 파일 처리</h2>
<p>큰 파일을 읽고 쓸 때는 메인 스레드를 블록하면 안 됩니다.</p>
<pre><code class="language-swift">// async/await로 파일 작업 비동기 처리
actor FileStore {
    private let fileURL: URL

    init(url: URL) {
        self.fileURL = url
    }

    func save(_ data: Data) async throws {
        // Task.detached로 백그라운드 스레드에서 실행
        try await Task.detached(priority: .background) {
            try data.write(to: self.fileURL, options: .atomic)
        }.value
    }

    func load() async throws -> Data {
        try await Task.detached(priority: .background) {
            try Data(contentsOf: self.fileURL)
        }.value
    }
}
</code></pre>
<p><code>actor</code>를 사용해 동시 접근을 막고, <code>Task.detached</code>로 파일 IO를 백그라운드에서 처리합니다.</p>
<hr/>
<h2>앱 그룹 — 앱 간 파일 공유</h2>
<p>macOS에서 여러 앱(예: 메인 앱 + 확장 앱)이 같은 파일에 접근하려면 App Group을 사용합니다.</p>
<pre><code class="language-swift">// App Group 컨테이너 URL
let groupURL = FileManager.default.containerURL(
    forSecurityApplicationGroupIdentifier: "group.com.mycompany.myapp"
)

let sharedDB = groupURL?.appendingPathComponent("shared.db")
</code></pre>
<p>App Group은 Xcode에서 Signing &amp; Capabilities에 추가합니다.</p>
<hr/>
<h2>핵심 요약</h2>
<ul>
<li><strong>FileManager.default</strong>: 파일 시스템 작업의 진입점</li>
<li>앱 샌드박스 내 주요 디렉토리: <strong>Documents, Application Support, Caches, Temp</strong></li>
<li><code>String.write(to:atomically:encoding:)</code>: 텍스트 파일 저장</li>
<li><code>Data.write(to:options:)</code>: 바이너리 파일 저장. <code>.atomic</code>으로 안전하게</li>
<li><code>createDirectory</code>, <code>copyItem</code>, <code>moveItem</code>, <code>removeItem</code>으로 파일 관리</li>
<li>대용량 파일은 <strong>백그라운드 스레드</strong>에서 처리</li>
</ul>
<p>다음 편에서는 대량의 구조화된 데이터를 효율적으로 저장하는 <strong>SQLite</strong>를 배웁니다.</p>
<blockquote>
<p><img src="https://s.w.org/images/core/emoji/15.1.0/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Generated with <a href="https://claude.ai/claude-code">Claude Code</a></p>
</blockquote>
]]></content:encoded>
					
					<wfw:commentRss>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-27%ed%8e%b8-filemanager%ec%99%80-%ed%8c%8c%ec%9d%bc-%ec%8b%9c%ec%8a%a4%ed%85%9c/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>[Swift 입문] 26편 — UserDefaults와 앱 설정 저장</title>
		<link>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-26%ed%8e%b8-userdefaults%ec%99%80-%ec%95%b1-%ec%84%a4%ec%a0%95-%ec%a0%80%ec%9e%a5/</link>
					<comments>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-26%ed%8e%b8-userdefaults%ec%99%80-%ec%95%b1-%ec%84%a4%ec%a0%95-%ec%a0%80%ec%9e%a5/#respond</comments>
		
		<dc:creator><![CDATA[낭창]]></dc:creator>
		<pubDate>Tue, 16 Jun 2026 11:26:09 +0000</pubDate>
				<category><![CDATA[프로그래밍 이야기]]></category>
		<guid isPermaLink="false">https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-26%ed%8e%b8-userdefaults%ec%99%80-%ec%95%b1-%ec%84%a4%ec%a0%95-%ec%a0%80%ec%9e%a5/</guid>

					<description><![CDATA[🤖 이 글은 Claude Code(AI)가 작성합니다. &#124; 시리즈 목차 &#124; 이전: [25편] Codable과 JSON 설정값을 어디에 저장할까? 앱을 종료하고 다시 열어도 사용자 설정이 유지되어야 합니다. &#8220;다크 모드 켜기&#8221;, &#8220;알림 허용&#8221;, &#8220;마지막으로 선택한 탭&#8221; 같은 간단한 값들을 저장하는 가장 쉬운 방법이 UserDefaults입니다. UserDefaults는 macOS/iOS가 제공하는 키-값 저장소입니다. 앱이 종료되어도 데이터가 유지되며, 같은 앱 내 어디서든 접근할 [&#8230;]]]></description>
										<content:encoded><![CDATA[<blockquote>
<p><img src="https://s.w.org/images/core/emoji/15.1.0/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 이 글은 <a href="https://claude.ai/claude-code">Claude Code</a>(AI)가 작성합니다. | <a href="https://nangchang.nes.or.kr/?p=1085">시리즈 목차</a> | 이전: <a href="https://nangchang.nes.or.kr/?p=1200">[25편] Codable과 JSON</a></p>
</blockquote>
<h2>설정값을 어디에 저장할까?</h2>
<p>앱을 종료하고 다시 열어도 사용자 설정이 유지되어야 합니다. &#8220;다크 모드 켜기&#8221;, &#8220;알림 허용&#8221;, &#8220;마지막으로 선택한 탭&#8221; 같은 간단한 값들을 저장하는 가장 쉬운 방법이 <strong>UserDefaults</strong>입니다.</p>
<p>UserDefaults는 macOS/iOS가 제공하는 키-값 저장소입니다. 앱이 종료되어도 데이터가 유지되며, 같은 앱 내 어디서든 접근할 수 있습니다.</p>
<hr/>
<h2>기본 사용법</h2>
<pre><code class="language-swift">import Foundation

// 저장
UserDefaults.standard.set(true, forKey: "isDarkMode")
UserDefaults.standard.set(16.0, forKey: "fontSize")
UserDefaults.standard.set("홍길동", forKey: "username")
UserDefaults.standard.set(42, forKey: "loginCount")

// 읽기
let isDarkMode = UserDefaults.standard.bool(forKey: "isDarkMode")      // false (없으면 기본값)
let fontSize = UserDefaults.standard.double(forKey: "fontSize")         // 16.0
let username = UserDefaults.standard.string(forKey: "username")         // Optional("홍길동")
let loginCount = UserDefaults.standard.integer(forKey: "loginCount")    // 42

// 삭제
UserDefaults.standard.removeObject(forKey: "username")
</code></pre>
<h3>저장 가능한 타입</h3>
<ul>
<li><code>Bool</code>, <code>Int</code>, <code>Float</code>, <code>Double</code></li>
<li><code>String</code>, <code>URL</code></li>
<li><code>Data</code> (바이너리 데이터)</li>
<li><code>Array</code>, <code>Dictionary</code> (위 타입으로 이루어진)</li>
<li><code>Date</code></li>
</ul>
<p>커스텀 객체는 직접 저장할 수 없습니다 — <code>Data</code>로 변환(Codable 사용)해야 합니다.</p>
<hr/>
<h2>키 상수화 — 오타 방지</h2>
<p>문자열 키를 직접 쓰면 오타가 생기기 쉽습니다. 상수로 관리하는 것이 좋습니다.</p>
<pre><code class="language-swift">extension UserDefaults {
    enum Key: String {
        case isDarkMode = "isDarkMode"
        case fontSize = "fontSize"
        case username = "username"
        case lastOpenedTab = "lastOpenedTab"
    }

    // 편의 메서드
    func bool(forKey key: Key) -> Bool {
        return bool(forKey: key.rawValue)
    }

    func set(_ value: Bool, forKey key: Key) {
        set(value, forKey: key.rawValue)
    }

    func string(forKey key: Key) -> String? {
        return string(forKey: key.rawValue)
    }

    func set(_ value: String?, forKey key: Key) {
        set(value, forKey: key.rawValue)
    }
}

// 사용 — 문자열 대신 타입 안전한 키 사용
UserDefaults.standard.set(true, forKey: .isDarkMode)
let isDark = UserDefaults.standard.bool(forKey: .isDarkMode)
</code></pre>
<hr/>
<h2>Codable 객체 저장</h2>
<pre><code class="language-swift">struct AppTheme: Codable {
    var accentColor: String
    var fontSize: Double
    var showSidebar: Bool
}

extension UserDefaults {
    // Codable 객체 저장
    func save&lt;T: Encodable&gt;(_ value: T, forKey key: String) {
        let data = try? JSONEncoder().encode(value)
        set(data, forKey: key)
    }

    // Codable 객체 불러오기
    func load&lt;T: Decodable&gt;(_ type: T.Type, forKey key: String) -> T? {
        guard let data = data(forKey: key) else { return nil }
        return try? JSONDecoder().decode(type, from: data)
    }
}

// 사용
let theme = AppTheme(accentColor: "blue", fontSize: 14.0, showSidebar: true)
UserDefaults.standard.save(theme, forKey: "appTheme")

if let savedTheme = UserDefaults.standard.load(AppTheme.self, forKey: "appTheme") {
    print(savedTheme.accentColor)  // "blue"
}
</code></pre>
<hr/>
<h2>SwiftUI에서 UserDefaults — @AppStorage</h2>
<p>SwiftUI에서는 <code>@AppStorage</code>로 UserDefaults를 훨씬 간편하게 사용할 수 있습니다.</p>
<pre><code class="language-swift">struct SettingsView: View {
    @AppStorage("isDarkMode") private var isDarkMode = false
    @AppStorage("fontSize") private var fontSize = 14.0
    @AppStorage("username") private var username = ""

    var body: some View {
        Form {
            Section("외관") {
                Toggle("다크 모드", isOn: $isDarkMode)

                VStack(alignment: .leading) {
                    Text("글꼴 크기: \(Int(fontSize))")
                    Slider(value: $fontSize, in: 10...24, step: 1)
                }
            }

            Section("계정") {
                TextField("사용자 이름", text: $username)
                    .textFieldStyle(.roundedBorder)
            }
        }
        .padding()
        .preferredColorScheme(isDarkMode ? .dark : .light)
    }
}
</code></pre>
<p><code>@AppStorage("키이름")</code>은 내부적으로 UserDefaults를 사용합니다. 값이 바뀌면 자동으로 뷰가 업데이트되고, UserDefaults에도 즉시 저장됩니다.</p>
<hr/>
<h2>설정 스토어 패턴</h2>
<p>여러 설정을 하나의 <code>@Observable</code> 클래스로 관리하면 앱 전체에서 사용하기 편리합니다.</p>
<pre><code class="language-swift">@Observable
class SettingsStore {
    // UserDefaults와 동기화되는 속성들
    var isDarkMode: Bool {
        get { UserDefaults.standard.bool(forKey: "isDarkMode") }
        set { UserDefaults.standard.set(newValue, forKey: "isDarkMode") }
    }

    var fontSize: Double {
        get { UserDefaults.standard.double(forKey: "fontSize").nonZero ?? 14.0 }
        set { UserDefaults.standard.set(newValue, forKey: "fontSize") }
    }

    var notificationsEnabled: Bool {
        get { UserDefaults.standard.bool(forKey: "notificationsEnabled") }
        set { UserDefaults.standard.set(newValue, forKey: "notificationsEnabled") }
    }

    // 복잡한 객체는 Codable로
    var recentSearches: [String] {
        get { UserDefaults.standard.stringArray(forKey: "recentSearches") ?? [] }
        set { UserDefaults.standard.set(newValue, forKey: "recentSearches") }
    }

    func reset() {
        isDarkMode = false
        fontSize = 14.0
        notificationsEnabled = true
        recentSearches = []
    }
}

// 앱 전체에 환경으로 공유
@main
struct MyApp: App {
    @State private var settings = SettingsStore()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(settings)
        }
    }
}
</code></pre>
<hr/>
<h2>기본값 등록</h2>
<p>앱이 처음 실행될 때 기본값을 설정하려면:</p>
<pre><code class="language-swift">// AppDelegate 또는 앱 시작 시점에 한 번만 실행
func registerDefaults() {
    UserDefaults.standard.register(defaults: [
        "isDarkMode": false,
        "fontSize": 14.0,
        "notificationsEnabled": true,
        "launchCount": 0
    ])
}
// register(defaults:)는 이미 값이 있으면 덮어쓰지 않음
// → 앱 업데이트로 새 키가 추가될 때 유용
</code></pre>
<hr/>
<h2>UserDefaults 주의사항</h2>
<ol>
<li><strong>민감한 정보 저장 금지</strong>: 비밀번호, 토큰, 개인 식별 정보는 <strong>Keychain</strong>에 저장해야 합니다. UserDefaults는 암호화되지 않습니다.</li>
<li><strong>대용량 데이터 부적합</strong>: 작은 설정값에만 사용. 큰 데이터는 파일이나 SQLite에 저장합니다.</li>
<li><strong>동기화 타이밍</strong>: 저장은 즉시 되지만 플러시(디스크 반영)는 시스템이 결정합니다. 앱 충돌 시 마지막 변경이 유실될 수 있습니다.</li>
</ol>
<hr/>
<h2>다른 플랫폼과 비교</h2>
<table>
<thead>
<tr>
<th>플랫폼</th>
<th>간단한 설정 저장</th>
<th>특징</th>
</tr>
</thead>
<tbody>
<tr>
<td>macOS/iOS</td>
<td>UserDefaults</td>
<td>시스템 통합, plist 파일로 저장</td>
</tr>
<tr>
<td>Android</td>
<td>SharedPreferences</td>
<td>XML 파일로 저장</td>
</tr>
<tr>
<td>웹 브라우저</td>
<td>localStorage</td>
<td>도메인별 키-값 저장</td>
</tr>
<tr>
<td>Python</td>
<td>configparser, json 파일</td>
<td>파일 직접 관리</td>
</tr>
</tbody>
</table>
<hr/>
<h2>핵심 요약</h2>
<ul>
<li><strong>UserDefaults</strong>: 앱 재시작 후에도 유지되는 간단한 키-값 저장소</li>
<li><code>set(_:forKey:)</code>로 저장, <code>bool/string/integer(forKey:)</code>로 읽기</li>
<li><strong>@AppStorage</strong>: SwiftUI에서 UserDefaults를 선언적으로 사용하는 방법</li>
<li>커스텀 타입은 <strong>Codable + Data</strong>로 변환해서 저장</li>
<li><strong>register(defaults:)</strong>로 앱 첫 실행 시 기본값 설정</li>
<li>민감한 정보는 반드시 <strong>Keychain</strong> 사용</li>
</ul>
<p>다음 편에서는 파일을 직접 읽고 쓰는 <code>FileManager</code>를 배웁니다.</p>
<blockquote>
<p><img src="https://s.w.org/images/core/emoji/15.1.0/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Generated with <a href="https://claude.ai/claude-code">Claude Code</a></p>
</blockquote>
]]></content:encoded>
					
					<wfw:commentRss>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-26%ed%8e%b8-userdefaults%ec%99%80-%ec%95%b1-%ec%84%a4%ec%a0%95-%ec%a0%80%ec%9e%a5/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>[Swift 입문] 25편 — Codable과 JSON</title>
		<link>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-25%ed%8e%b8-codable%ea%b3%bc-json/</link>
					<comments>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-25%ed%8e%b8-codable%ea%b3%bc-json/#respond</comments>
		
		<dc:creator><![CDATA[낭창]]></dc:creator>
		<pubDate>Tue, 16 Jun 2026 11:24:55 +0000</pubDate>
				<category><![CDATA[프로그래밍 이야기]]></category>
		<guid isPermaLink="false">https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-25%ed%8e%b8-codable%ea%b3%bc-json/</guid>

					<description><![CDATA[🤖 이 글은 Claude Code(AI)가 작성합니다. &#124; 시리즈 목차 &#124; 이전: [24편] Accessibility API와 전역 단축키 데이터를 주고받는 표준 형식 — JSON 앱은 대부분 외부와 데이터를 주고받습니다. 서버에서 사용자 목록을 받아오거나, 설정을 파일로 저장하거나, 다른 앱과 데이터를 교환할 때 가장 널리 쓰이는 형식이 JSON(JavaScript Object Notation)입니다. { "id": 42, "name": "홍길동", "email": "hong@example.com", "isPremium": true, [&#8230;]]]></description>
										<content:encoded><![CDATA[<blockquote>
<p><img src="https://s.w.org/images/core/emoji/15.1.0/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 이 글은 <a href="https://claude.ai/claude-code">Claude Code</a>(AI)가 작성합니다. | <a href="https://nangchang.nes.or.kr/?p=1085">시리즈 목차</a> | 이전: <a href="https://nangchang.nes.or.kr/?p=1194">[24편] Accessibility API와 전역 단축키</a></p>
</blockquote>
<h2>데이터를 주고받는 표준 형식 — JSON</h2>
<p>앱은 대부분 외부와 데이터를 주고받습니다. 서버에서 사용자 목록을 받아오거나, 설정을 파일로 저장하거나, 다른 앱과 데이터를 교환할 때 가장 널리 쓰이는 형식이 <strong>JSON(JavaScript Object Notation)</strong>입니다.</p>
<pre><code class="language-json">{
  "id": 42,
  "name": "홍길동",
  "email": "hong@example.com",
  "isPremium": true,
  "tags": ["swift", "ios", "macos"]
}
</code></pre>
<p>Swift에서는 <code>Codable</code> 프로토콜로 Swift 타입과 JSON을 쉽게 변환합니다.</p>
<hr/>
<h2>Codable이란?</h2>
<p><code>Codable</code>은 <code>Encodable</code>과 <code>Decodable</code>을 합친 타입 별칭입니다.</p>
<ul>
<li><strong>Encodable</strong>: Swift 타입 → JSON (직렬화)</li>
<li><strong>Decodable</strong>: JSON → Swift 타입 (역직렬화)</li>
<li><strong>Codable</strong>: 양방향 모두</li>
</ul>
<pre><code class="language-swift">struct User: Codable {
    let id: Int
    let name: String
    let email: String
    var isPremium: Bool
    var tags: [String]
}
</code></pre>
<p>구조체나 클래스에 <code>Codable</code>만 붙이면 끝입니다. 모든 속성이 <code>Codable</code>을 지원하는 타입이면 컴파일러가 자동으로 변환 코드를 생성합니다.</p>
<hr/>
<h2>JSON → Swift (Decoding)</h2>
<pre><code class="language-swift">import Foundation

let jsonString = """
{
  "id": 42,
  "name": "홍길동",
  "email": "hong@example.com",
  "isPremium": true,
  "tags": ["swift", "ios"]
}
"""

let jsonData = jsonString.data(using: .utf8)!

do {
    let decoder = JSONDecoder()
    let user = try decoder.decode(User.self, from: jsonData)
    print(user.name)    // 홍길동
    print(user.tags)    // ["swift", "ios"]
} catch {
    print("디코딩 실패: \(error)")
}
</code></pre>
<h3>배열 디코딩</h3>
<pre><code class="language-swift">let jsonArray = """
[
  {"id": 1, "name": "홍길동", "email": "hong@example.com", "isPremium": true, "tags": []},
  {"id": 2, "name": "이순신", "email": "lee@example.com", "isPremium": false, "tags": ["swift"]}
]
"""

let data = jsonArray.data(using: .utf8)!
let users = try JSONDecoder().decode([User].self, from: data)
print(users.count)  // 2
</code></pre>
<hr/>
<h2>Swift → JSON (Encoding)</h2>
<pre><code class="language-swift">let user = User(id: 1, name: "김철수", email: "kim@example.com",
                isPremium: false, tags: ["macos"])

do {
    let encoder = JSONEncoder()
    encoder.outputFormatting = [.prettyPrinted, .sortedKeys]  // 읽기 좋게 출력

    let data = try encoder.encode(user)
    let jsonString = String(data: data, encoding: .utf8)!
    print(jsonString)
} catch {
    print("인코딩 실패: \(error)")
}
</code></pre>
<p>출력 결과:</p>
<pre><code class="language-json">{
  "email" : "kim@example.com",
  "id" : 1,
  "isPremium" : false,
  "name" : "김철수",
  "tags" : [
    "macos"
  ]
}
</code></pre>
<hr/>
<h2>키 이름 변환</h2>
<p>서버 API는 보통 <code>snake_case</code>를 쓰고, Swift는 <code>camelCase</code>를 씁니다. 자동으로 변환하려면:</p>
<pre><code class="language-swift">struct Post: Codable {
    let postId: Int         // JSON: "post_id"
    let createdAt: Date     // JSON: "created_at"
    let authorName: String  // JSON: "author_name"
}

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase  // snake_case → camelCase 자동 변환
decoder.dateDecodingStrategy = .iso8601               // "2024-01-15T10:30:00Z" 형식 자동 파싱

let post = try decoder.decode(Post.self, from: data)
</code></pre>
<h3>CodingKeys로 수동 매핑</h3>
<pre><code class="language-swift">struct Product: Codable {
    let id: Int
    let productName: String  // JSON 키가 다를 때
    let price: Double

    enum CodingKeys: String, CodingKey {
        case id
        case productName = "product_title"  // JSON의 "product_title"을 productName으로
        case price = "cost"                  // JSON의 "cost"를 price로
    }
}
</code></pre>
<hr/>
<h2>중첩 구조 처리</h2>
<pre><code class="language-swift">struct Order: Codable {
    let orderId: String
    let customer: Customer
    let items: [OrderItem]
    let total: Double

    struct Customer: Codable {
        let name: String
        let address: Address
    }

    struct Address: Codable {
        let city: String
        let zipCode: String
    }

    struct OrderItem: Codable {
        let productId: Int
        let quantity: Int
        let price: Double
    }
}

// JSON
let json = """
{
  "orderId": "ORD-001",
  "customer": {
    "name": "홍길동",
    "address": { "city": "서울", "zipCode": "04524" }
  },
  "items": [
    {"productId": 1, "quantity": 2, "price": 9900.0}
  ],
  "total": 19800.0
}
"""

let order = try JSONDecoder().decode(Order.self, from: json.data(using: .utf8)!)
print(order.customer.address.city)  // 서울
</code></pre>
<hr/>
<h2>옵셔널 필드와 기본값</h2>
<pre><code class="language-swift">struct Profile: Codable {
    let username: String
    let bio: String?          // JSON에 없으면 nil
    let followerCount: Int    // JSON에 없으면 디코딩 실패!

    // 기본값이 필요하면 커스텀 init 사용
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        username = try container.decode(String.self, forKey: .username)
        bio = try container.decodeIfPresent(String.self, forKey: .bio)
        followerCount = try container.decodeIfPresent(Int.self, forKey: .followerCount) ?? 0
    }
}
</code></pre>
<p><code>decodeIfPresent</code>는 키가 없을 때 <code>nil</code>을 반환합니다. 기본값을 주려면 <code>?? 0</code>처럼 nil 병합 연산자를 쓰면 됩니다.</p>
<hr/>
<h2>날짜 처리</h2>
<pre><code class="language-swift">struct Event: Codable {
    let title: String
    let date: Date
}

let decoder = JSONDecoder()

// ISO 8601 형식 ("2024-06-15T14:30:00Z")
decoder.dateDecodingStrategy = .iso8601

// Unix 타임스탬프 (1718459400)
decoder.dateDecodingStrategy = .secondsSince1970

// 커스텀 형식 ("2024-06-15")
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
decoder.dateDecodingStrategy = .formatted(formatter)
</code></pre>
<hr/>
<h2>실전 — API 응답 처리</h2>
<pre><code class="language-swift">// 서버 응답 공통 래퍼
struct APIResponse&lt;T: Decodable&gt;: Decodable {
    let success: Bool
    let data: T?
    let error: String?
}

struct GitHubUser: Decodable {
    let login: String
    let name: String?
    let publicRepos: Int

    enum CodingKeys: String, CodingKey {
        case login, name
        case publicRepos = "public_repos"
    }
}

// async/await로 API 호출 + 디코딩
func fetchUser(username: String) async throws -> GitHubUser {
    let url = URL(string: "https://api.github.com/users/\(username)")!
    let (data, _) = try await URLSession.shared.data(from: url)
    return try JSONDecoder().decode(GitHubUser.self, from: data)
}

// 사용
Task {
    let user = try await fetchUser(username: "apple")
    print("\(user.login): \(user.publicRepos)개 저장소")
}
</code></pre>
<hr/>
<h2>다른 언어와 비교</h2>
<table>
<thead>
<tr>
<th>언어</th>
<th>JSON 직렬화</th>
<th>특징</th>
</tr>
</thead>
<tbody>
<tr>
<td>Swift</td>
<td>Codable + JSONDecoder</td>
<td>컴파일 타임 타입 안전성</td>
</tr>
<tr>
<td>Python</td>
<td>json.loads() / json.dumps()</td>
<td>딕셔너리로 바로 변환, 타입 없음</td>
</tr>
<tr>
<td>TypeScript</td>
<td>JSON.parse() + 타입 캐스팅</td>
<td>런타임에 타입 보장 없음</td>
</tr>
<tr>
<td>Kotlin</td>
<td>kotlinx.serialization / Gson</td>
<td>@Serializable 어노테이션</td>
</tr>
</tbody>
</table>
<p>Swift의 Codable은 타입 불일치를 <strong>컴파일 시점</strong>에 잡아주므로 런타임 크래시 위험이 크게 줄어듭니다.</p>
<hr/>
<h2>핵심 요약</h2>
<ul>
<li><strong>Codable</strong> = Encodable + Decodable. 구조체/클래스에 붙이면 JSON 변환 자동 지원</li>
<li><strong>JSONDecoder</strong>: JSON Data → Swift 타입</li>
<li><strong>JSONEncoder</strong>: Swift 타입 → JSON Data</li>
<li><strong>keyDecodingStrategy = .convertFromSnakeCase</strong>: snake_case <img src="https://s.w.org/images/core/emoji/15.1.0/72x72/2194.png" alt="↔" class="wp-smiley" style="height: 1em; max-height: 1em;" /> camelCase 자동 변환</li>
<li><strong>CodingKeys</strong>: JSON 키 이름과 Swift 속성 이름이 다를 때 매핑</li>
<li><strong>decodeIfPresent</strong>: 옵셔널 필드 처리 (없으면 nil)</li>
<li>중첩 구조체도 모두 Codable이면 자동으로 처리됨</li>
</ul>
<p>다음 편에서는 앱 설정을 기기에 저장하는 <code>UserDefaults</code>를 배웁니다.</p>
<blockquote>
<p><img src="https://s.w.org/images/core/emoji/15.1.0/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Generated with <a href="https://claude.ai/claude-code">Claude Code</a></p>
</blockquote>
]]></content:encoded>
					
					<wfw:commentRss>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-25%ed%8e%b8-codable%ea%b3%bc-json/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>[Swift 입문] 24편 — Accessibility API와 전역 단축키</title>
		<link>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-24%ed%8e%b8-accessibility-api%ec%99%80-%ec%a0%84%ec%97%ad-%eb%8b%a8%ec%b6%95%ed%82%a4/</link>
					<comments>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-24%ed%8e%b8-accessibility-api%ec%99%80-%ec%a0%84%ec%97%ad-%eb%8b%a8%ec%b6%95%ed%82%a4/#respond</comments>
		
		<dc:creator><![CDATA[낭창]]></dc:creator>
		<pubDate>Sun, 14 Jun 2026 07:29:01 +0000</pubDate>
				<category><![CDATA[프로그래밍 이야기]]></category>
		<guid isPermaLink="false">https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-24%ed%8e%b8-accessibility-api%ec%99%80-%ec%a0%84%ec%97%ad-%eb%8b%a8%ec%b6%95%ed%82%a4/</guid>

					<description><![CDATA[🤖 이 글은 Claude Code(AI)가 작성합니다. &#124; 시리즈 목차 &#124; 이전: [23편] AppKit과 SwiftUI 통합 Accessibility API란? macOS Accessibility API(접근성 API)는 원래 시각 장애인을 위한 스크린 리더(VoiceOver) 같은 보조 기술을 위해 만들어졌습니다. 그런데 이 API는 단순히 접근성 지원 이상의 능력을 갖고 있습니다. 다른 앱의 UI 요소 읽기: 다른 앱에 있는 버튼 텍스트, 입력창 내용 등을 [&#8230;]]]></description>
										<content:encoded><![CDATA[<blockquote>
<p><img src="https://s.w.org/images/core/emoji/15.1.0/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 이 글은 <a href="https://claude.ai/claude-code">Claude Code</a>(AI)가 작성합니다. | <a href="https://nangchang.nes.or.kr/?p=1085">시리즈 목차</a> | 이전: <a href="https://nangchang.nes.or.kr/?p=1193">[23편] AppKit과 SwiftUI 통합</a></p>
</blockquote>
<h2>Accessibility API란?</h2>
<p>macOS Accessibility API(접근성 API)는 원래 시각 장애인을 위한 스크린 리더(VoiceOver) 같은 보조 기술을 위해 만들어졌습니다. 그런데 이 API는 단순히 접근성 지원 이상의 능력을 갖고 있습니다.</p>
<ul>
<li><strong>다른 앱의 UI 요소 읽기</strong>: 다른 앱에 있는 버튼 텍스트, 입력창 내용 등을 코드로 읽을 수 있습니다.</li>
<li><strong>다른 앱의 UI 조작</strong>: 버튼 클릭, 텍스트 입력 등을 코드로 수행할 수 있습니다.</li>
<li><strong>포커스된 앱 감지</strong>: 현재 어떤 앱이 활성화되어 있는지, 어떤 요소가 선택됐는지 알 수 있습니다.</li>
</ul>
<p>이런 이유로 자동화 도구, 생산성 앱, AI 어시스턴트 같은 앱들이 Accessibility API를 활용합니다.</p>
<hr/>
<h2>권한 요청</h2>
<p>Accessibility API를 사용하려면 반드시 사용자에게 권한을 받아야 합니다. 권한 없이 사용하면 빈 데이터가 반환됩니다.</p>
<pre><code class="language-swift">import AppKit

// 현재 접근성 권한 확인
func checkAccessibilityPermission() -> Bool {
    let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true]
    return AXIsProcessTrustedWithOptions(options as CFDictionary)
}

// 호출하면 "시스템 설정 > 개인 정보 보호 > 손쉬운 사용"으로 유도
if !checkAccessibilityPermission() {
    print("접근성 권한이 필요합니다. 시스템 설정을 확인하세요.")
}
</code></pre>
<p><code>kAXTrustedCheckOptionPrompt: true</code>를 넣으면 권한이 없을 때 자동으로 시스템 설정 다이얼로그가 뜹니다.</p>
<hr/>
<h2>AXUIElement — UI 요소 탐색</h2>
<pre><code class="language-swift">import AppKit

// 현재 포커스된 앱의 접근성 요소 가져오기
func getFocusedAppElement() -> AXUIElement? {
    guard let frontApp = NSWorkspace.shared.frontmostApplication else { return nil }
    let pid = frontApp.processIdentifier
    return AXUIElementCreateApplication(pid)
}

// 특정 속성 읽기
func getAttribute(_ element: AXUIElement, _ attribute: String) -> AnyObject? {
    var value: AnyObject?
    AXUIElementCopyAttributeValue(element, attribute as CFString, &value)
    return value
}

// 사용 예시
if let appElement = getFocusedAppElement() {
    // 앱 이름
    let appName = getAttribute(appElement, kAXTitleAttribute as String) as? String

    // 포커스된 창
    let focusedWindow = getAttribute(appElement, kAXFocusedWindowAttribute as String)

    // 포커스된 UI 요소 (현재 선택된 버튼, 입력창 등)
    let focusedElement = getAttribute(appElement, kAXFocusedUIElementAttribute as String)

    print("앱: \(appName ?? "알 수 없음")")
}
</code></pre>
<h3>주요 AX 속성</h3>
<table>
<thead>
<tr>
<th>상수</th>
<th>의미</th>
</tr>
</thead>
<tbody>
<tr>
<td>kAXTitleAttribute</td>
<td>요소의 제목/이름</td>
</tr>
<tr>
<td>kAXValueAttribute</td>
<td>현재 값 (입력창의 텍스트 등)</td>
</tr>
<tr>
<td>kAXRoleAttribute</td>
<td>요소 역할 (button, textField 등)</td>
</tr>
<tr>
<td>kAXChildrenAttribute</td>
<td>자식 요소 목록</td>
</tr>
<tr>
<td>kAXFocusedWindowAttribute</td>
<td>포커스된 창</td>
</tr>
<tr>
<td>kAXFocusedUIElementAttribute</td>
<td>포커스된 UI 요소</td>
</tr>
<tr>
<td>kAXSelectedTextAttribute</td>
<td>현재 선택된 텍스트</td>
</tr>
<tr>
<td>kAXPositionAttribute</td>
<td>화면상 위치</td>
</tr>
</tbody>
</table>
<h3>선택된 텍스트 읽기</h3>
<pre><code class="language-swift">func getSelectedText() -> String? {
    guard let appElement = getFocusedAppElement() else { return nil }

    // 포커스된 UI 요소
    var focusedElement: AnyObject?
    AXUIElementCopyAttributeValue(
        appElement,
        kAXFocusedUIElementAttribute as CFString,
        &focusedElement
    )

    guard let element = focusedElement else { return nil }

    // 선택된 텍스트
    var selectedText: AnyObject?
    AXUIElementCopyAttributeValue(
        element as! AXUIElement,
        kAXSelectedTextAttribute as CFString,
        &selectedText
    )

    return selectedText as? String
}
</code></pre>
<hr/>
<h2>전역 키보드 단축키</h2>
<p>앱이 포커스되지 않아도 동작하는 전역 단축키는 유틸리티 앱의 핵심 기능입니다. macOS에서는 두 가지 방법이 있습니다.</p>
<h3>1. NSEvent 글로벌 모니터 (앞 편에서 소개)</h3>
<pre><code class="language-swift">class GlobalHotkeyMonitor {
    private var monitor: Any?

    func start(handler: @escaping () -> Void) {
        monitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { event in
            // Cmd + Shift + Space
            let isCmdShiftSpace = event.modifierFlags.contains([.command, .shift])
                && event.keyCode == 49  // 스페이스바 키코드

            if isCmdShiftSpace {
                DispatchQueue.main.async { handler() }
            }
        }
    }

    func stop() {
        if let monitor = monitor {
            NSEvent.removeMonitor(monitor)
        }
    }
}
</code></pre>
<p>단, <code>addGlobalMonitorForEvents</code>는 Accessibility 권한 없이도 키코드를 받을 수 있지만, 일부 보안 환경에서는 제한될 수 있습니다.</p>
<h3>2. Carbon HotKey API (더 안정적)</h3>
<pre><code class="language-swift">import Carbon

class HotKeyManager {
    private var hotKeyRef: EventHotKeyRef?
    private var eventHandler: EventHandlerRef?

    func register(keyCode: UInt32, modifiers: UInt32, handler: @escaping () -> Void) {
        // 핫키 ID
        let hotKeyID = EventHotKeyID(signature: OSType("htky".utf8.reduce(0) { ($0 << 8) | UInt32($1) }),
                                     id: 1)

        // 이벤트 핸들러 설치
        var eventSpec = EventTypeSpec(eventClass: UInt32(kEventClassKeyboard),
                                      eventKind: UInt32(kEventHotKeyPressed))

        InstallEventHandler(
            GetApplicationEventTarget(),
            { (_, event, _) -> OSStatus in
                NotificationCenter.default.post(name: .globalHotkeyPressed, object: nil)
                return noErr
            },
            1, &eventSpec, nil, &eventHandler
        )

        // 핫키 등록 (Cmd+Shift+Space 예시)
        RegisterEventHotKey(keyCode, modifiers, hotKeyID, GetApplicationEventTarget(), 0, &hotKeyRef)

        // 알림 수신
        NotificationCenter.default.addObserver(forName: .globalHotkeyPressed, object: nil, queue: .main) { _ in
            handler()
        }
    }

    func unregister() {
        if let hotKeyRef = hotKeyRef {
            UnregisterEventHotKey(hotKeyRef)
        }
    }
}

extension Notification.Name {
    static let globalHotkeyPressed = Notification.Name("globalHotkeyPressed")
}
</code></pre>
<hr/>
<h2>NSWorkspace — 실행 중인 앱 감지</h2>
<pre><code class="language-swift">import AppKit

class AppWatcher {
    private var observers: [NSObjectProtocol] = []

    func startWatching() {
        // 앱 활성화 감지 (다른 앱으로 전환할 때)
        observers.append(
            NSWorkspace.shared.notificationCenter.addObserver(
                forName: NSWorkspace.didActivateApplicationNotification,
                object: nil,
                queue: .main
            ) { notification in
                let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication
                print("활성화된 앱: \(app?.localizedName ?? "알 수 없음")")
                print("번들 ID: \(app?.bundleIdentifier ?? "")")
            }
        )

        // 앱 실행 감지
        observers.append(
            NSWorkspace.shared.notificationCenter.addObserver(
                forName: NSWorkspace.didLaunchApplicationNotification,
                object: nil,
                queue: .main
            ) { notification in
                let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication
                print("새로 실행된 앱: \(app?.localizedName ?? "")")
            }
        )
    }

    func stopWatching() {
        observers.forEach { NSWorkspace.shared.notificationCenter.removeObserver($0) }
    }
}
</code></pre>
<h3>특정 앱이 터미널인지 확인</h3>
<pre><code class="language-swift">let terminalBundleIDs = [
    "com.apple.Terminal",
    "com.googlecode.iterm2",
    "dev.warp.Warp-Stable",
    "com.github.wez.wezterm"
]

func isFrontmostAppTerminal() -> Bool {
    guard let frontApp = NSWorkspace.shared.frontmostApplication,
          let bundleID = frontApp.bundleIdentifier else { return false }
    return terminalBundleIDs.contains(bundleID)
}
</code></pre>
<hr/>
<h2>SwiftUI에서 Accessibility 지원</h2>
<p>내 앱의 접근성을 높이는 것도 중요합니다.</p>
<pre><code class="language-swift">struct AccessibleButton: View {
    var body: some View {
        Button(action: doSomething) {
            Image(systemName: "trash")
        }
        // VoiceOver가 읽을 레이블
        .accessibilityLabel("삭제")
        .accessibilityHint("선택한 항목을 삭제합니다")

        // 접근성 요소 숨기기 (장식용 이미지)
        Image(systemName: "star.fill")
            .accessibilityHidden(true)

        // 슬라이더에 값 설명 추가
        Slider(value: $volume, in: 0...100)
            .accessibilityValue("\(Int(volume))%")
    }

    func doSomething() { }
    @State private var volume = 50.0
}
</code></pre>
<hr/>
<h2>핵심 요약</h2>
<ul>
<li><strong>Accessibility API</strong>: 다른 앱의 UI 요소를 읽고 조작하는 macOS 시스템 API</li>
<li><strong>AXIsProcessTrustedWithOptions</strong>: 접근성 권한 확인 및 요청</li>
<li><strong>AXUIElement</strong>: UI 계층 탐색의 기본 단위. kAX* 상수로 속성 읽기</li>
<li><strong>NSEvent.addGlobalMonitorForEvents</strong>: 전역 키보드 이벤트 감지</li>
<li><strong>Carbon HotKey API</strong>: 더 안정적인 전역 단축키 등록</li>
<li><strong>NSWorkspace</strong>: 실행 중인 앱 목록, 앱 활성화 이벤트 감지</li>
<li>SwiftUI의 <code>.accessibilityLabel</code>, <code>.accessibilityHint</code>로 내 앱의 접근성도 챙길 것</li>
</ul>
<p>4부 macOS 앱 개발이 완결됩니다. 다음 편부터는 5부 Foundation과 데이터 저장으로, Codable/JSON, UserDefaults, FileManager, SQLite를 다룹니다.</p>
<blockquote>
<p><img src="https://s.w.org/images/core/emoji/15.1.0/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Generated with <a href="https://claude.ai/claude-code">Claude Code</a></p>
</blockquote>
]]></content:encoded>
					
					<wfw:commentRss>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-24%ed%8e%b8-accessibility-api%ec%99%80-%ec%a0%84%ec%97%ad-%eb%8b%a8%ec%b6%95%ed%82%a4/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>[Swift 입문] 23편 — AppKit과 SwiftUI 통합</title>
		<link>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-23%ed%8e%b8-appkit%ea%b3%bc-swiftui-%ed%86%b5%ed%95%a9/</link>
					<comments>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-23%ed%8e%b8-appkit%ea%b3%bc-swiftui-%ed%86%b5%ed%95%a9/#respond</comments>
		
		<dc:creator><![CDATA[낭창]]></dc:creator>
		<pubDate>Sun, 14 Jun 2026 07:27:42 +0000</pubDate>
				<category><![CDATA[프로그래밍 이야기]]></category>
		<guid isPermaLink="false">https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-23%ed%8e%b8-appkit%ea%b3%bc-swiftui-%ed%86%b5%ed%95%a9/</guid>

					<description><![CDATA[🤖 이 글은 Claude Code(AI)가 작성합니다. &#124; 시리즈 목차 &#124; 이전: [22편] NSPanel과 커스텀 윈도우 왜 AppKit과 혼합해야 하나? SwiftUI는 강력하지만 아직 AppKit의 모든 기능을 커버하지는 못합니다. macOS 앱을 만들다 보면 다음 상황에서 AppKit이 필요합니다. SwiftUI에 없는 컴포넌트: NSTextView(풍부한 텍스트 편집기), WKWebView(웹뷰), NSTableView(고성능 테이블) 세밀한 이벤트 제어: 마우스 오버, 드래그, 키보드 이벤트 기존 AppKit 코드와의 [&#8230;]]]></description>
										<content:encoded><![CDATA[<blockquote>
<p><img src="https://s.w.org/images/core/emoji/15.1.0/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 이 글은 <a href="https://claude.ai/claude-code">Claude Code</a>(AI)가 작성합니다. | <a href="https://nangchang.nes.or.kr/?p=1085">시리즈 목차</a> | 이전: <a href="https://nangchang.nes.or.kr/?p=1192">[22편] NSPanel과 커스텀 윈도우</a></p>
</blockquote>
<h2>왜 AppKit과 혼합해야 하나?</h2>
<p>SwiftUI는 강력하지만 아직 AppKit의 모든 기능을 커버하지는 못합니다. macOS 앱을 만들다 보면 다음 상황에서 AppKit이 필요합니다.</p>
<ul>
<li>SwiftUI에 없는 컴포넌트: <code>NSTextView</code>(풍부한 텍스트 편집기), <code>WKWebView</code>(웹뷰), <code>NSTableView</code>(고성능 테이블)</li>
<li>세밀한 이벤트 제어: 마우스 오버, 드래그, 키보드 이벤트</li>
<li>기존 AppKit 코드와의 통합</li>
</ul>
<p>다행히 SwiftUI는 AppKit 뷰와 양방향으로 통합할 수 있는 브릿지를 제공합니다.</p>
<hr/>
<h2>NSViewRepresentable — AppKit 뷰를 SwiftUI에서 사용</h2>
<p><code>NSViewRepresentable</code>은 <code>NSView</code>를 SwiftUI 뷰처럼 사용할 수 있게 해주는 프로토콜입니다.</p>
<h3>기본 구조</h3>
<pre><code class="language-swift">import SwiftUI
import AppKit

struct MyNSViewWrapper: NSViewRepresentable {
    // 1. 어떤 NSView 타입을 감쌀지 선언
    typealias NSViewType = SomeNSView

    // 2. 뷰 생성 (한 번만 호출)
    func makeNSView(context: Context) -> SomeNSView {
        let view = SomeNSView()
        // 초기 설정
        return view
    }

    // 3. 뷰 업데이트 (SwiftUI 상태 변화 시마다 호출)
    func updateNSView(_ nsView: SomeNSView, context: Context) {
        // 상태 변화를 뷰에 반영
    }
}
</code></pre>
<h3>실전 예제 — WKWebView 래핑</h3>
<pre><code class="language-swift">import SwiftUI
import WebKit

struct WebView: NSViewRepresentable {
    let url: URL

    func makeNSView(context: Context) -> WKWebView {
        let webView = WKWebView()
        webView.load(URLRequest(url: url))
        return webView
    }

    func updateNSView(_ nsView: WKWebView, context: Context) {
        // URL이 바뀌면 새로 로드
        if nsView.url != url {
            nsView.load(URLRequest(url: url))
        }
    }
}

// 사용
struct ContentView: View {
    var body: some View {
        WebView(url: URL(string: "https://www.apple.com")!)
            .frame(minWidth: 800, minHeight: 600)
    }
}
</code></pre>
<h3>Coordinator — AppKit 이벤트를 SwiftUI로 전달</h3>
<p>AppKit 뷰의 델리게이트 이벤트를 SwiftUI의 상태로 연결하려면 <code>Coordinator</code>가 필요합니다.</p>
<pre><code class="language-swift">struct WebViewWithProgress: NSViewRepresentable {
    @Binding var isLoading: Bool
    @Binding var progress: Double
    let url: URL

    // Coordinator: AppKit 델리게이트 이벤트를 받는 중간자
    class Coordinator: NSObject, WKNavigationDelegate {
        var parent: WebViewWithProgress

        init(parent: WebViewWithProgress) {
            self.parent = parent
        }

        func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
            parent.isLoading = true
        }

        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            parent.isLoading = false
        }
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(parent: self)
    }

    func makeNSView(context: Context) -> WKWebView {
        let webView = WKWebView()
        webView.navigationDelegate = context.coordinator  // Coordinator를 델리게이트로 설정
        webView.load(URLRequest(url: url))

        // KVO로 progress 관찰
        webView.addObserver(context.coordinator,
                           forKeyPath: "estimatedProgress",
                           options: .new,
                           context: nil)
        return webView
    }

    func updateNSView(_ nsView: WKWebView, context: Context) { }
}
</code></pre>
<p>데이터 흐름을 정리하면:</p>
<pre><code>AppKit 이벤트 → Coordinator → @Binding 업데이트 → SwiftUI 뷰 재렌더링
</code></pre>
<hr/>
<h2>NSViewControllerRepresentable</h2>
<p>뷰 대신 뷰 컨트롤러를 감쌀 때 사용합니다. 사용 방법은 <code>NSViewRepresentable</code>과 동일합니다.</p>
<pre><code class="language-swift">struct SplitViewController: NSViewControllerRepresentable {
    func makeNSViewController(context: Context) -> NSSplitViewController {
        let splitVC = NSSplitViewController()

        let sidebarVC = NSHostingController(rootView: SidebarView())
        let contentVC = NSHostingController(rootView: ContentAreaView())

        splitVC.addSplitViewItem(NSSplitViewItem(sidebarWithViewController: sidebarVC))
        splitVC.addSplitViewItem(NSSplitViewItem(viewController: contentVC))

        return splitVC
    }

    func updateNSViewController(_ vc: NSSplitViewController, context: Context) { }
}
</code></pre>
<hr/>
<h2>NSHostingController — SwiftUI를 AppKit에 삽입</h2>
<p>반대 방향 — AppKit 코드 안에 SwiftUI 뷰를 넣을 때는 <code>NSHostingController</code>를 사용합니다. 이미 앞 편에서 봤지만, 더 자세히 살펴봅니다.</p>
<pre><code class="language-swift">class MainViewController: NSViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        // SwiftUI 뷰를 자식 뷰 컨트롤러로 추가
        let swiftUIView = MySwiftUIView()
        let hostingVC = NSHostingController(rootView: swiftUIView)

        addChild(hostingVC)
        hostingVC.view.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(hostingVC.view)

        NSLayoutConstraint.activate([
            hostingVC.view.topAnchor.constraint(equalTo: view.topAnchor),
            hostingVC.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            hostingVC.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            hostingVC.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
    }
}
</code></pre>
<hr/>
<h2>NSEvent — 전역 마우스·키보드 이벤트</h2>
<p>AppKit을 사용하면 앱 외부의 이벤트도 감지할 수 있습니다.</p>
<pre><code class="language-swift">class GlobalEventMonitor {
    private var monitor: Any?

    // 앱 외부 이벤트 감지 (다른 앱 사용 중에도 동작)
    func startMonitoring() {
        monitor = NSEvent.addGlobalMonitorForEvents(
            matching: [.leftMouseDown, .rightMouseDown]
        ) { event in
            print("전역 클릭 감지: \(event.locationInWindow)")
        }
    }

    // 앱 내부 이벤트 감지
    func startLocalMonitoring() {
        monitor = NSEvent.addLocalMonitorForEvents(
            matching: .keyDown
        ) { event in
            print("키 입력: \(event.keyCode)")
            return event  // nil 반환 시 이벤트 소비 (다른 뷰로 전달 안 함)
        }
    }

    func stopMonitoring() {
        if let monitor = monitor {
            NSEvent.removeMonitor(monitor)
        }
    }
}
</code></pre>
<p>전역 이벤트 모니터는 앱 포커스 없이도 동작하므로, 전역 단축키나 포커스 외 클릭 감지에 사용됩니다. 단, <code>Accessibility</code> 권한이 필요할 수 있습니다.</p>
<hr/>
<h2>NotificationCenter — AppKit 알림</h2>
<pre><code class="language-swift">class AppEventObserver {
    private var observers: [NSObjectProtocol] = []

    func startObserving() {
        // 화면 해상도 변경
        observers.append(
            NotificationCenter.default.addObserver(
                forName: NSApplication.didChangeScreenParametersNotification,
                object: nil,
                queue: .main
            ) { _ in
                print("화면 설정 변경됨")
            }
        )

        // 시스템 다크모드 변경 감지
        observers.append(
            DistributedNotificationCenter.default().addObserver(
                forName: NSNotification.Name("AppleInterfaceThemeChangedNotification"),
                object: nil,
                queue: .main
            ) { _ in
                print("다크모드 변경됨")
            }
        )
    }

    deinit {
        observers.forEach { NotificationCenter.default.removeObserver($0) }
    }
}
</code></pre>
<hr/>
<h2>핵심 요약</h2>
<ul>
<li><strong>NSViewRepresentable</strong>: AppKit NSView를 SwiftUI 뷰로 감싸는 브릿지</li>
<li><strong>makeNSView</strong>: 뷰 생성 (한 번), <strong>updateNSView</strong>: 상태 변화 시 호출</li>
<li><strong>Coordinator</strong>: AppKit 델리게이트 이벤트 → SwiftUI 상태 변환 중간자</li>
<li><strong>NSHostingController</strong>: SwiftUI 뷰를 AppKit에 삽입</li>
<li><strong>NSEvent.addGlobalMonitorForEvents</strong>: 전역 마우스·키보드 이벤트 감지</li>
<li>두 프레임워크를 혼합할 때는 <strong>SwiftUI를 최대한 활용하고 AppKit은 필요한 곳에만</strong> 사용하는 것이 원칙</li>
</ul>
<p>다음 편에서는 macOS의 Accessibility API와 전역 키보드 단축키를 다룹니다.</p>
<blockquote>
<p><img src="https://s.w.org/images/core/emoji/15.1.0/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Generated with <a href="https://claude.ai/claude-code">Claude Code</a></p>
</blockquote>
]]></content:encoded>
					
					<wfw:commentRss>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-23%ed%8e%b8-appkit%ea%b3%bc-swiftui-%ed%86%b5%ed%95%a9/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>[Swift 입문] 22편 — NSPanel과 커스텀 윈도우</title>
		<link>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-22%ed%8e%b8-nspanel%ea%b3%bc-%ec%bb%a4%ec%8a%a4%ed%85%80-%ec%9c%88%eb%8f%84%ec%9a%b0/</link>
					<comments>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-22%ed%8e%b8-nspanel%ea%b3%bc-%ec%bb%a4%ec%8a%a4%ed%85%80-%ec%9c%88%eb%8f%84%ec%9a%b0/#respond</comments>
		
		<dc:creator><![CDATA[낭창]]></dc:creator>
		<pubDate>Sun, 14 Jun 2026 07:26:47 +0000</pubDate>
				<category><![CDATA[프로그래밍 이야기]]></category>
		<guid isPermaLink="false">https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-22%ed%8e%b8-nspanel%ea%b3%bc-%ec%bb%a4%ec%8a%a4%ed%85%80-%ec%9c%88%eb%8f%84%ec%9a%b0/</guid>

					<description><![CDATA[🤖 이 글은 Claude Code(AI)가 작성합니다. &#124; 시리즈 목차 &#124; 이전: [21편] MenuBarExtra와 메뉴바 앱 NSWindow와 NSPanel의 차이 macOS에는 두 가지 기본 윈도우 타입이 있습니다. NSWindow: 일반 앱 윈도우. 타이틀바, 닫기/최소화/최대화 버튼이 있고, 다른 앱으로 전환해도 화면에 유지됩니다. NSPanel: 보조 윈도우. 주 윈도우 앞에 띄우는 팔레트, 인스펙터, 알림창 등에 사용됩니다. NSWindow의 서브클래스입니다. 메뉴바 앱의 팝업창, [&#8230;]]]></description>
										<content:encoded><![CDATA[<blockquote>
<p><img src="https://s.w.org/images/core/emoji/15.1.0/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 이 글은 <a href="https://claude.ai/claude-code">Claude Code</a>(AI)가 작성합니다. | <a href="https://nangchang.nes.or.kr/?p=1085">시리즈 목차</a> | 이전: <a href="https://nangchang.nes.or.kr/?p=1191">[21편] MenuBarExtra와 메뉴바 앱</a></p>
</blockquote>
<h2>NSWindow와 NSPanel의 차이</h2>
<p>macOS에는 두 가지 기본 윈도우 타입이 있습니다.</p>
<ul>
<li><strong>NSWindow</strong>: 일반 앱 윈도우. 타이틀바, 닫기/최소화/최대화 버튼이 있고, 다른 앱으로 전환해도 화면에 유지됩니다.</li>
<li><strong>NSPanel</strong>: 보조 윈도우. 주 윈도우 앞에 띄우는 팔레트, 인스펙터, 알림창 등에 사용됩니다. <code>NSWindow</code>의 서브클래스입니다.</li>
</ul>
<p>메뉴바 앱의 팝업창, 노치 오버레이 같은 특수 UI는 대부분 <code>NSPanel</code>을 커스터마이징해서 만듭니다.</p>
<hr/>
<h2>NSPanel 기본 사용법</h2>
<pre><code class="language-swift">import AppKit
import SwiftUI

class FloatingPanel: NSPanel {
    init(contentView: some View) {
        super.init(
            contentRect: NSRect(x: 0, y: 0, width: 320, height: 200),
            styleMask: [
                .nonactivatingPanel,  // 패널이 앱을 활성화하지 않음
                .titled,
                .closable,
                .fullSizeContentView  // 콘텐츠가 타이틀바 영역까지 확장
            ],
            backing: .buffered,
            defer: false
        )

        // SwiftUI 뷰 연결
        self.contentViewController = NSHostingController(rootView: contentView)

        // 항상 다른 윈도우 위에 표시
        self.level = .floating

        // 화면 중앙에 위치
        self.center()

        // 타이틀바를 투명하게
        self.titlebarAppearsTransparent = true
        self.titleVisibility = .hidden

        // 그림자 활성화
        self.hasShadow = true

        // 배경 투명화 (SwiftUI로 커스텀 배경 그리기)
        self.isOpaque = false
        self.backgroundColor = .clear
    }
}
</code></pre>
<h3>패널 표시와 숨기기</h3>
<pre><code class="language-swift">class PanelController {
    private var panel: FloatingPanel?

    func show() {
        if panel == nil {
            panel = FloatingPanel(contentView: MyPanelView())
        }
        panel?.makeKeyAndOrderFront(nil)
        NSApp.activate(ignoringOtherApps: true)
    }

    func hide() {
        panel?.orderOut(nil)
    }

    func toggle() {
        if panel?.isVisible == true {
            hide()
        } else {
            show()
        }
    }
}
</code></pre>
<hr/>
<h2>윈도우 레벨(Window Level)</h2>
<p>macOS에서 윈도우는 레벨(Z-order)에 따라 어느 윈도우 위에 표시될지 결정됩니다.</p>
<pre><code class="language-swift">// 일반 윈도우 레벨 (기본값)
window.level = .normal

// 도크 위에 표시
window.level = .floating

// 스크린 세이버 위에 표시
window.level = .screenSaver

// 상태바(메뉴바) 위에 표시
window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.statusWindow)) + 1)

// 모든 윈도우 위 (주의해서 사용)
window.level = .popUpMenu
</code></pre>
<p>메뉴바 오버레이 UI처럼 항상 화면 최상단에 표시하려면 적절한 레벨을 선택해야 합니다.</p>
<hr/>
<h2>커스텀 모양 — 둥근 모서리와 그림자</h2>
<pre><code class="language-swift">struct RoundedPanelView: View {
    var body: some View {
        VStack(spacing: 12) {
            Text("알림")
                .font(.headline)
            Text("작업이 완료되었습니다.")
                .foregroundColor(.secondary)
            Button("확인") { }
                .buttonStyle(.borderedProminent)
        }
        .padding(20)
        .background {
            RoundedRectangle(cornerRadius: 16)
                .fill(.regularMaterial)  // 반투명 배경
                .shadow(radius: 20)
        }
        .padding()  // 그림자가 잘리지 않도록 여백
    }
}

// 패널을 배경 투명으로 설정해야 모양이 보임
// panel.isOpaque = false
// panel.backgroundColor = .clear
</code></pre>
<h3>Visual Effect (반투명 배경)</h3>
<pre><code class="language-swift">// SwiftUI .regularMaterial 계층
struct BlurredBackground: View {
    var body: some View {
        ZStack {
            // 시스템 블러 배경
            Rectangle()
                .fill(.ultraThinMaterial)

            // 콘텐츠
            VStack {
                Text("투명 배경 패널")
                    .font(.headline)
            }
            .padding()
        }
        .clipShape(RoundedRectangle(cornerRadius: 16))
    }
}
</code></pre>
<p><code>.ultraThinMaterial</code>, <code>.thinMaterial</code>, <code>.regularMaterial</code>, <code>.thickMaterial</code> 등 다양한 두께의 블러 재질을 사용할 수 있습니다. macOS의 Control Center나 Spotlight 같은 시스템 UI에서 볼 수 있는 효과입니다.</p>
<hr/>
<h2>포커스 잃으면 닫히는 패널</h2>
<p>메뉴바 팝업처럼 다른 곳을 클릭하면 자동으로 닫히게 하려면:</p>
<pre><code class="language-swift">class AutoDismissPanel: NSPanel {
    // 포커스를 잃으면 닫힘
    override var canBecomeKey: Bool { true }

    init(contentView: some View) {
        super.init(
            contentRect: .zero,
            styleMask: [.nonactivatingPanel, .fullSizeContentView],
            backing: .buffered,
            defer: false
        )

        self.contentViewController = NSHostingController(rootView: contentView)
        self.level = .floating
        self.isOpaque = false
        self.backgroundColor = .clear
        self.hasShadow = true

        // 포커스 잃으면 자동으로 닫힘
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(windowDidResignKey),
            name: NSWindow.didResignKeyNotification,
            object: self
        )
    }

    @objc func windowDidResignKey() {
        self.orderOut(nil)
    }
}
</code></pre>
<hr/>
<h2>화면 좌표계 이해</h2>
<p>macOS와 iOS의 좌표계는 반대입니다.</p>
<pre><code class="language-swift">// macOS: 좌하단이 원점 (0,0)
// iOS/SwiftUI: 좌상단이 원점 (0,0)

// 화면 크기 가져오기
let screenFrame = NSScreen.main?.frame ?? .zero
let screenWidth = screenFrame.width
let screenHeight = screenFrame.height

// 화면 중앙에 윈도우 배치
let windowSize = CGSize(width: 400, height: 300)
let x = (screenWidth - windowSize.width) / 2
let y = (screenHeight - windowSize.height) / 2  // 하단 기준
window.setFrameOrigin(NSPoint(x: x, y: y))

// 또는 간단하게
window.center()
</code></pre>
<h3>메뉴바 아래에 패널 표시</h3>
<pre><code class="language-swift">func showBelowMenuBar(panel: NSPanel, button: NSStatusBarButton) {
    // 버튼의 화면 위치 계산
    guard let buttonWindow = button.window else { return }
    let buttonFrame = buttonWindow.convertToScreen(button.frame)

    // 패널 크기
    let panelSize = panel.frame.size

    // 메뉴바 아이콘 바로 아래에 배치
    let x = buttonFrame.midX - panelSize.width / 2
    let y = buttonFrame.minY - panelSize.height

    panel.setFrameOrigin(NSPoint(x: x, y: y))
    panel.makeKeyAndOrderFront(nil)
}
</code></pre>
<hr/>
<h2>노치 영역 활용 (Mac 노치 모델)</h2>
<p>MacBook Pro 노치 모델(2021~)에서는 메뉴바가 노치 양옆으로 나뉩니다. 노치 영역 자체는 앱이 직접 사용할 수 없지만, 노치 바로 아래 영역에 패널을 표시하는 앱들이 있습니다.</p>
<pre><code class="language-swift">// 노치 영역 크기 확인 (safeAreaInsets 활용)
if let screen = NSScreen.main {
    // 메뉴바 높이
    let menuBarHeight = NSApp.mainMenu?.menuBarHeight ?? 24

    // 노치가 있는 경우 추가 safeArea
    if #available(macOS 12.0, *) {
        let safeArea = screen.safeAreaInsets
        let notchHeight = safeArea.top
        print("노치 높이: \(notchHeight)")
    }
}
</code></pre>
<p>노치 바로 아래를 가리는 형태의 UI는 macOS에서 독특한 UX를 제공할 수 있습니다. 단, 다른 메뉴바 아이콘을 가리지 않도록 주의해야 합니다.</p>
<hr/>
<h2>핵심 요약</h2>
<ul>
<li><strong>NSPanel</strong>: 보조 윈도우. NSWindow의 서브클래스로 팝업·팔레트·오버레이에 적합</li>
<li><strong>.nonactivatingPanel</strong>: 패널 표시 시 현재 앱 포커스를 빼앗지 않음</li>
<li><strong>window.level</strong>: 윈도우 Z-order. .floating으로 항상 위에 표시</li>
<li><strong>isOpaque = false + backgroundColor = .clear</strong>: 투명 배경으로 커스텀 모양 구현</li>
<li><strong>.regularMaterial / .ultraThinMaterial</strong>: 시스템 블러 반투명 배경</li>
<li><strong>NSWindow.didResignKeyNotification</strong>: 포커스를 잃으면 자동 닫힘 구현</li>
<li>macOS 좌표계는 <strong>좌하단이 원점</strong> (iOS와 반대)</li>
</ul>
<p>다음 편에서는 AppKit과 SwiftUI를 혼합해서 사용하는 방법인 <code>NSViewRepresentable</code>과 <code>NSViewControllerRepresentable</code>을 배웁니다.</p>
<blockquote>
<p><img src="https://s.w.org/images/core/emoji/15.1.0/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Generated with <a href="https://claude.ai/claude-code">Claude Code</a></p>
</blockquote>
]]></content:encoded>
					
					<wfw:commentRss>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-22%ed%8e%b8-nspanel%ea%b3%bc-%ec%bb%a4%ec%8a%a4%ed%85%80-%ec%9c%88%eb%8f%84%ec%9a%b0/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>
