🤖 이 글은 Claude Code(AI)가 작성합니다. | 시리즈 목차 | 이전: [20편] macOS 앱 구조와 App 프로토콜
메뉴바 앱이란?
macOS의 오른쪽 상단 메뉴바에는 시계, 배터리, Wi-Fi 같은 시스템 아이콘들이 있습니다. 개발자도 이 영역에 자신의 앱 아이콘을 넣을 수 있습니다. 클릭하면 드롭다운 메뉴나 팝오버 창이 나타나는 형태입니다. Alfred, Bartender, 1Password 같은 앱이 이 방식을 사용합니다.
메뉴바 앱은 사용자가 메인 작업을 방해받지 않고 빠르게 접근할 수 있어서 유틸리티 앱에 적합합니다.
MenuBarExtra — SwiftUI 방식
macOS 13(Ventura)부터 MenuBarExtra Scene이 추가되어 AppKit 없이 순수 SwiftUI로 메뉴바 앱을 만들 수 있습니다.
기본 메뉴 스타일
import SwiftUI
@main
struct QuickNotesApp: App {
var body: some Scene {
MenuBarExtra("메모", systemImage: "note.text") {
MenuContent()
}
}
}
struct MenuContent: View {
@State private var notes: [String] = ["회의 준비", "코드 리뷰"]
var body: some View {
ForEach(notes, id: \.self) { note in
Text(note)
}
Divider()
Button("새 메모 추가") {
notes.append("새 메모 \(notes.count + 1)")
}
Divider()
Button("종료") {
NSApplication.shared.terminate(nil)
}
.keyboardShortcut("q", modifiers: .command)
}
}
기본 스타일은 macOS 표준 메뉴 형태로, 클릭하면 드롭다운 메뉴가 나타납니다.
윈도우 스타일 (팝오버)
@main
struct WeatherApp: App {
var body: some Scene {
MenuBarExtra("날씨", systemImage: "cloud.sun.fill") {
WeatherPanel()
}
.menuBarExtraStyle(.window) // 팝오버 창 스타일
}
}
struct WeatherPanel: View {
var body: some View {
VStack(spacing: 16) {
Text("서울")
.font(.title2)
.bold()
Text("☀️")
.font(.system(size: 60))
Text("23°C")
.font(.largeTitle)
Text("맑음")
.foregroundColor(.secondary)
}
.padding()
.frame(width: 200)
}
}
.menuBarExtraStyle(.window)를 추가하면 클릭 시 SwiftUI 뷰가 담긴 팝오버 창이 나타납니다.
메뉴바 아이콘 커스터마이징
@main
struct MyApp: App {
@State private var isActive = false
var body: some Scene {
// SF Symbol 아이콘
MenuBarExtra("앱", systemImage: isActive ? "circle.fill" : "circle") {
MenuContent(isActive: $isActive)
}
// 텍스트 아이콘
MenuBarExtra("⚡️") {
MenuContent(isActive: $isActive)
}
}
}
아이콘은 SF Symbols 이름 또는 텍스트(이모지 포함)로 설정할 수 있습니다. 상태에 따라 아이콘을 동적으로 바꿀 수도 있습니다.
NSStatusItem — AppKit 방식 (고급)
MenuBarExtra가 지원하지 않는 세밀한 제어가 필요할 때는 AppKit의 NSStatusItem을 직접 사용합니다.
import AppKit
import SwiftUI
class StatusBarController {
private var statusItem: NSStatusItem
private var popover: NSPopover
init() {
// 상태 바 아이템 생성
statusItem = NSStatusBar.system.statusItem(
withLength: NSStatusItem.squareLength
)
// 아이콘 설정
if let button = statusItem.button {
button.image = NSImage(systemSymbolName: "star.fill",
accessibilityDescription: "내 앱")
button.action = #selector(togglePopover)
button.target = self
}
// 팝오버 설정
popover = NSPopover()
popover.contentSize = NSSize(width: 300, height: 400)
popover.behavior = .transient // 다른 곳 클릭 시 자동으로 닫힘
popover.contentViewController = NSHostingController(
rootView: PopoverView() // SwiftUI 뷰를 팝오버에 넣기
)
}
@objc func togglePopover() {
if popover.isShown {
popover.performClose(nil)
} else {
if let button = statusItem.button {
popover.show(relativeTo: button.bounds,
of: button,
preferredEdge: .minY)
}
}
}
}
AppDelegate에서 초기화
class AppDelegate: NSObject, NSApplicationDelegate {
var statusBarController: StatusBarController?
func applicationDidFinishLaunching(_ notification: Notification) {
statusBarController = StatusBarController()
}
}
@main
struct MyApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
// 메인 윈도우 없이 메뉴바만 사용할 경우 빈 WindowGroup
// (Info.plist에 LSUIElement = true 필요)
Settings {
SettingsView()
}
}
}
NSHostingController — AppKit에 SwiftUI 뷰 삽입
AppKit 기반 코드에서 SwiftUI 뷰를 사용하려면 NSHostingController를 사용합니다.
// SwiftUI 뷰를 NSViewController로 감싸기
let hostingController = NSHostingController(rootView: MySwiftUIView())
// NSWindow에 넣기
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 400, height: 300),
styleMask: [.titled, .closable, .resizable],
backing: .buffered,
defer: false
)
window.contentViewController = hostingController
window.center()
window.makeKeyAndOrderFront(nil)
반대로 SwiftUI 안에서 AppKit 뷰를 사용하려면 NSViewRepresentable을 사용합니다(다음 편에서 다룹니다).
메뉴바 앱 완성 예제
// 클립보드 히스토리 앱
@Observable
class ClipboardStore {
var history: [String] = []
private var timer: Timer?
func startMonitoring() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
self.checkClipboard()
}
}
private func checkClipboard() {
guard let text = NSPasteboard.general.string(forType: .string),
text != history.first else { return }
history.insert(text, at: 0)
if history.count > 20 { history.removeLast() }
}
}
@main
struct ClipboardApp: App {
@State private var store = ClipboardStore()
var body: some Scene {
MenuBarExtra("📋", isInserted: .constant(true)) {
ClipboardMenu(store: store)
}
.menuBarExtraStyle(.window)
}
}
struct ClipboardMenu: View {
let store: ClipboardStore
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text("클립보드 히스토리")
.font(.headline)
.padding()
Divider()
if store.history.isEmpty {
Text("기록 없음")
.foregroundColor(.secondary)
.padding()
} else {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
ForEach(store.history.prefix(10), id: \.self) { item in
Button {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(item, forType: .string)
} label: {
Text(item)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.padding(.horizontal)
.padding(.vertical, 6)
Divider()
}
}
}
}
}
.frame(width: 280, height: 320)
.onAppear { store.startMonitoring() }
}
}
핵심 요약
- MenuBarExtra: macOS 13+에서 SwiftUI만으로 메뉴바 앱 구현 가능
- 기본 스타일: 드롭다운 메뉴 / .window 스타일: 팝오버 창
- NSStatusItem: AppKit 방식으로 더 세밀한 제어 가능
- NSHostingController: AppKit 코드 안에 SwiftUI 뷰를 삽입하는 다리
- LSUIElement = true: Dock 아이콘 없이 메뉴바만 사용
- NSPasteboard: 클립보드 읽기/쓰기 API
다음 편에서는 NSPanel과 커스텀 윈도우를 만드는 방법, 그리고 노치 영역을 활용한 오버레이 UI를 다룹니다.
🤖 Generated with Claude Code