macOS 소프트웨어를 GitHub로 배포하는 방법

@inup· 6 min read

onboarding

로컬 환경에서 개발이 완료된 애플리케이션을 다른 사용자가 사용할 수 있도록 배포하려면 빌드, 패키징, 그리고 업데이트 관리 과정을 거쳐야 한다. 이번 글에서는 Apple Developer 계정 없이 macOS 앱을 빌드하고, GitHub를 통해 배포 및 업데이트 확인 기능을 구현한 과정을 정리한다.

빌드와 패키징 (Copy App & create-dmg)

보통 macOS 앱 배포는 애플 개발자 계정을 통해 서명(Signing)과 공증(Notarization) 과정을 거친다. 하지만 나는 별도의 개발자 계정이 없으므로, Xcode의 Archive 기능 대신 'Copy App' 방식을 사용하여 로컬에서 실행 가능한 .app 파일을 추출했다. 이 방식은 공증되지 않은 상태이므로, 타인의 PC에서 실행 시 Gatekeeper 경고가 발생할 수 있다.

추출된 .app 파일은 폴더 구조를 가지므로 배포에 적합하지 않다. 단일 설치 파일로 만들기 위해 create-dmg 라이브러리를 사용했다. 이 도구는 .app 파일을 드래그 앤 드롭으로 설치할 수 있는 디스크 이미지(.dmg) 파일로 변환해 준다.

create-dmg 설치하기
create-dmg 설치하기

버전 관리와 .gitignore

초기에는 빌드 결과물인 .dmg 파일과 빌드 경로인 dist/ 폴더까지 깃허브 저장소에 업로드하려 했다. 하지만 바이너리 파일과 빌드 아티팩트는 소스 코드 저장소에 포함되지 않는 것이 원칙이다. 이를 뒤늦게 인지하고 .gitignore 파일에 해당 경로들을 추가하여 추적에서 제외했다.

2304

최종 생성된 DMG 파일은 소스 코드가 아닌 GitHub Releases 기능을 통해 v1.0.0 태그와 함께 업로드했다.

클로드미터의 첫 릴리즈!
클로드미터의 첫 릴리즈!

업데이트 확인 로직 구현

앱 배포 후 새로운 버전이 나왔을 때 사용자에게 알릴 수단이 필요했다. 별도의 서버 없이 GitHub API를 활용해 최신 릴리즈 정보를 받아오고, 현재 버전과 비교하는 로직을 구현했다.

UpdateManager

UpdateManager 클래스는 GitHub Releases API를 호출하여 최신 버전 태그를 가져온다.

@MainActor
class UpdateManager: ObservableObject {
    static let shared = UpdateManager()
    
    @Published var isUpdateAvailable: Bool = false
    @Published var latestVersion: String = ""
    @Published var releaseURL: URL?
    
    // 현재 앱 버전
    let currentVersion = "1.0.0"
    private let repoOwner = "in-up"
    private let repoName = "claude-meter"
    
    func checkForUpdates() async {
        let urlString = "[https://api.github.com/repos/](https://api.github.com/repos/)\(repoOwner)/\(repoName)/releases/latest"
        guard let url = URL(string: urlString) else { return }
        
        do {
            let (data, response) = try await URLSession.shared.data(from: url)
            
            guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { return }
            
            let release = try JSONDecoder().decode(GitHubRelease.self, from: data)
            
            // 버전 문자열 비교 (예: "v1.0.0" -> "1.0.0")
            let serverVer = release.tagName.replacingOccurrences(of: "v", with: "")
            let localVer = currentVersion.replacingOccurrences(of: "v", with: "")
            
            // 서버 버전이 더 높을 경우 업데이트 플래그 활성화
            if serverVer.compare(localVer, options: .numeric) == .orderedDescending {
                self.isUpdateAvailable = true
                self.latestVersion = release.tagName
                self.releaseURL = URL(string: release.htmlUrl)
            }
            
        } catch {
            print("Update check failed: \(error)")
        }
    }
}

struct GitHubRelease: Codable {
    let tagName: String
    let htmlUrl: String
    
    enum CodingKeys: String, CodingKey {
        case tagName = "tag_name"
        case htmlUrl = "html_url"
    }
}

앱 실행 시 확인

앱의 진입점인 ClaudeMeterApp.swiftinit 시점에서 업데이트 확인 메서드를 비동기로 호출한다. 이를 통해 사용자는 앱을 켤 때마다 최신 버전 여부를 확인할 수 있다.

@main
struct ClaudeMeterApp: App {
    @StateObject var controller = UsageController()
    
    // ... (Body 생략) ...
    
    init() {
        // 앱 시작 시 업데이트 확인 수행
        Task {
            await UpdateManager.shared.checkForUpdates()
        }
    }
}

이러한 과정을 통해 로컬 개발 환경에서 시작한 프로젝트를 패키징하고, 버전 관리를 통해 배포하며, 사용자가 지속적으로 업데이트를 받을 수 있는 최소한의 사이클을 구축했다.


마치며

이번 프로젝트는 Swift와 AppKit이라는 새로운 개발 환경을 익히는 계기가 되었다. API 연동부터 MVC 패턴 설계, 사용량 고갈 시점 예측, 그리고 DMG 배포까지 소프트웨어 개발의 넓은 과정을 직접 수행할 수 있었다. 직접 느꼈던 사소한 불편함을 해소하기 위해 시작한 프로젝트가 생산성 도구로 발전되고 완성되어 뜻깊은 마음이 든다. 앞으로 보안 강화와 UI 개선 등 부족한 부분을 지속적으로 보완하며 프로젝트를 발전시켜 나가고 싶다는 마음이다.

@inup
언제나 감사합니다 👨‍💻