모바일 앱의 조건부 컴파일 — 본질, 지시문 및 작동 원리

저자: IT Sectr 게시일: 2026-06-01 읽는 시간: 9 분

조건부 컴파일(Conditional Compilation)을 사용하면 컴파일러가 빌드 시점에 알려진 조건에 따라 소스 코드의 일부를 포함하거나 건너뛸 수 있습니다. The Swift Programming Language (2026)에 따르면, #if 지시문은 머신 코드 생성 전 AST 분석 중에 처리됩니다. 조건부 컴파일은 개발자가 중복 없이 여러 플랫폼과 구성에 대해 단일 코드베이스를 유지할 수 있는 능력을 제공합니다.

주요 포인트

  • 조건부 컴파일 — 플랫폼, 구성 또는 언어 버전 조건에 따라 코드를 선택적으로 컴파일하는 기술.
  • 지시문 #if, #elseif, #else, #endif — Swift, C, C++, Objective-C의 주요 조건부 컴파일 구조.
  • Kotlin에는 전처리기 지시문이 없습니다 — 대신 BuildConfig, expect/actual 및 sourceSets가 사용됩니다.
  • 장점 — 부적합한 플랫폼의 코드가 컴파일되지 않아 바이너리 크기가 줄어들고 오류가 제거됩니다.
  • iOS/macOS 공유 코드 — 조건부 컴파일은 Apple의 크로스 플랫폼 프레임워크 개발의 기초입니다.

조건부 컴파일이란

조건부 컴파일은 컴파일러가 조건부 컴파일 지시문을 분석하고 조건이 충족되는 코드 블록만 출력 바이너리에 포함하는 메커니즘입니다. 이를 통해 다양한 대상 플랫폼과 구성에 적응하는 단일 코드베이스를 유지할 수 있습니다.

이 개념은 C/C++의 전처리기 지시문 #ifdef, #ifndef, #endif에서 비롯되었습니다. 현대 언어(Swift, Rust, Go)에서는 이 메커니즘이 별도의 전처리기 없이 컴파일러 수준에서 작동하여 안전성이 향상됩니다. 조건부 블록은 컴파일되지 않더라도 구문적으로 올바른 상태여야 합니다.

Apple WWDC 세션 “Embrace Swift”(2025)에 따르면, 약 40%의 Swift 프로젝트가 단일 대상에서 iOS와 macOS를 지원하기 위해 조건부 컴파일을 사용합니다. UIKit 및 SwiftUI를 사용하는 프로젝트의 경우 UI 코드는 #if os(iOS)#if os(macOS) 지시문으로 분할되어 비즈니스 로직 재사용이 가능합니다.

주요 장점은 컴파일 시간 안전성입니다. 부적합한 플랫폼의 코드는 실행되지 않을 뿐만 아니라 컴파일되지도 않습니다. 즉, iOS 특정 코드의 오류는 macOS용 빌드 시 나타나지 않으며 그 반대도 마찬가지입니다. 런타임 검사는 이러한 보장을 제공하지 않습니다.

Swift의 조건부 컴파일

Swift는 네 가지 주요 지시문을 제공합니다: #if, #elseif, #else, #endif. C 전처리기와 달리 Swift는 모든 분기에서 코드의 구문적 정확성을 요구합니다 — 컴파일러는 모든 코드를 구문 분석하지만 활성 분기에 대해서만 머신 코드를 생성합니다.

플랫폼 검사 os()

Swift는 내장 검사 함수를 지원합니다: os(iOS), os(macOS), os(tvOS), os(watchOS), os(Linux), os(Windows). 이 함수들은 애플리케이션이 빌드되는 대상 플랫폼을 확인합니다. &&||와의 조합을 통해 복잡한 조건을 만들 수 있습니다.

swift
// iOS, macOS 및 tvOS용 통합 코드
import Foundation

class PlatformService {
    func getSystemVersion() -> String {
        #if os(iOS) || os(tvOS)
            return UIDevice.current.systemVersion
        #elseif os(macOS)
            let vers = ProcessInfo.processInfo.operatingSystemVersion
            return "\(vers.majorVersion).\(vers.minorVersion)"
        #else
            return "unknown"
        #endif
    }
}

컴파일러 버전 검사

Swift는 컴파일러 버전 검사를 지원합니다: #if swift(>=5.9). 이는 여러 Swift 버전을 지원하는 라이브러리 및 프레임워크에 유용합니다. 새로운 언어 기능(Swift 5.9의 매크로 등)은 이러한 검사로 보호될 수 있습니다.

swift
// 하위 호환성
#if swift(>=5.9)
    @MainActor
    struct ModernView: View {
        var body: some View {
            Text("Modern SwiftUI")
        }
    }
#else
    struct ModernView: View {
        var body: some View {
            Text("Legacy SwiftUI")
        }
    }
#endif

모듈 가용성 검사 canImport()

canImport(ModuleName) 함수는 지정된 모듈이 현재 빌드 환경에서 사용 가능한지 확인합니다. 이것은 가장 유연한 메커니즘입니다: 특정 플랫폼에 묶여 있지 않습니다. 예를 들어, CoreHaptics를 사용하는 코드는 이 프레임워크를 사용할 수 있는 기기에서만 컴파일됩니다.

swift
#if canImport(CoreHaptics)
    import CoreHaptics

    class HapticManager {
        private var engine: CHHapticEngine?

        func playTapFeedback() {
            guard let engine else { return }
            // 햅틱 피드백 구현
        }
    }
#endif

Kotlin 및 Android의 대안

Kotlin 언어에는 전처리기 지시문이 없습니다. 대신 Android 생태계는 세 가지 대안을 제공합니다: BuildConfig 필드(런타임 검사), sourceSets(전체 파일 교체), expect/actual(Kotlin Multiplatform).

Gradle의 Source Sets

Gradle sourceSets를 사용하면 다양한 flavor 또는 빌드 유형에 대해 다른 클래스 구현을 가질 수 있습니다. src/debug/ 디렉토리에는 디버그 구현이, src/release/에는 릴리스 구현이 있습니다. 빌드 시 Gradle은 적절한 sourceSet을 선택하고 해당 파일만 컴파일합니다.

kotlin
// src/debug/kotlin/com/example/Logger.kt
object Logger {
    fun log(tag: String, message: String) {
        Log.d(tag, message)
    }
}

// src/release/kotlin/com/example/Logger.kt
object Logger {
    fun log(tag: String, message: String) {
        // 릴리스에서 No-op
    }
}

Kotlin Multiplatform의 Expect/Actual

KMP는 expect 메커니즘(공통 코드에서 선언)과 actual(특정 플랫폼에 대한 구현)을 제공합니다. 이것은 컴파일 시간 메커니즘입니다: iOS의 경우 iOS sourceSet의 actual 구현이 컴파일되고, Android의 경우 Android sourceSet의 구현이 컴파일됩니다. 대상이 아닌 구현은 컴파일되지 않습니다.

kotlin
// commonMain — expect 선언
expect fun getPlatformName(): String

// androidMain — Android용 actual
actual fun getPlatformName(): String =
    "Android \${Build.VERSION.SDK_INT}"

// iosMain — iOS용 actual
actual fun getPlatformName(): String =
    UIDevice.current.systemName() + " " + UIDevice.current.systemVersion

C/C++ 전처리기 및 NDK

Android NDK를 통해 네이티브 라이브러리를 개발할 때 #ifdef, #ifndef, #define 지시문과 함께 클래식 C/C++ 전처리기가 사용됩니다. Swift와 달리 C 전처리기는 텍스트 수준에서 작동하므로 비활성 분기의 코드가 구문적으로 올바르지 않을 수 있습니다.

NDK 플랫폼 플래그

NDK는 각 플랫폼에 대한 매크로를 정의합니다: __ANDROID__(Android), __APPLE__(iOS/macOS), __linux__(Linux). 아키텍처의 경우: __arm__, __aarch64__, __x86_64__. 이러한 매크로는 대상 플랫폼용 빌드 시 컴파일러에 의해 자동으로 설정됩니다.

cpp
// Android 및 iOS용 네이티브 코드
#include <cstdint>

#ifdef __ANDROID__
    int32_t getJniEnv(JNIEnv* env) {
        return env->GetVersion();
    }
#elif defined(__APPLE__)
    #include <TargetConditionals.h>
    int32_t getOsVersion() {
        #if TARGET_OS_IOS
            return "iOS";
        #elif TARGET_OS_OSX
            return "macOS";
        #endif
    }
#endif

NDK로 작업할 때 C/C++ 전처리기는 텍스트 대체라는 점을 기억하는 것이 중요합니다. 비활성 분기에 구문 오류가 있어도 컴파일러가 이를 인식하지 못하지만, 잘못된 #define이 활성 분기를 손상시키면 오류가 나타납니다. #define 체인을 최소화하고 constexpr 상수를 사용하는 것이 좋습니다.

UniFFI 및 Mozilla Application Services를 통해 모바일 개발에도 사용되는 Rust에는 자체 메커니즘인 Cargo.toml의 기능 플래그가 있습니다. #[cfg(target_os = “android”)]와 같은 Rust의 플래그는 Swift 지시문과 유사하게 작동합니다: 검사는 전처리기가 아닌 컴파일러 수준에서 수행됩니다. 이는 단일 코드베이스에서 Android와 iOS용으로 컴파일해야 하는 네이티브 라이브러리에 대해 Rust를 매력적인 선택으로 만듭니다.

실용적인 시나리오 및 안티패턴

조건부 컴파일은 엄격하게 정의된 시나리오에서 효과적입니다. 잘못 사용하면 테스트 및 유지 관리가 어려운 코드 스멜이 생성됩니다. 올바른 시나리오와 일반적인 실수를 살펴보겠습니다.

올바른 시나리오

첫 번째 시나리오는 플랫폼 추상화입니다: 내부에서 조건부 컴파일이 플랫폼 구현을 선택하는 단일 퍼사드. 두 번째는 디버깅 및 프로파일링: 릴리스에 포함되어서는 안 되는 개발자 도구. 세 번째는 하위 호환성: 최소 버전이 업데이트될 때까지 이전 OS 버전 지원.

시나리오언어조건
플랫폼 추상화Swift#if os(iOS)
디버깅Swift/ObjC#if DEBUG
하위 호환성Swift#if swift(>=5.7)
네이티브 라이브러리C/C++#ifdef __ANDROID__
A/B 테스트Java/KotlinBuildConfig.FLAVOR

안티패턴

가장 위험한 안티패턴은 코드 전체에 지시문이 확산되는 것입니다. 두 파일마다 #if가 포함되어 있다면 아키텍처에 리팩토링이 필요하다는 신호입니다. 올바른 해결책은 플랫폼 코드를 프로토콜/인터페이스 뒤로 추출하고 의존성 주입을 사용하는 것입니다.

  • 모든 파일에 #if — 아키텍처 안티패턴입니다. 플랫폼 코드는 프로토콜 뒤로 격리되어야 합니다.
  • 중첩된 #if — 빠르게 읽기 어려워집니다. 중첩 깊이는 2레벨을 초과해서는 안 됩니다.
  • 전체 함수 중복 — 함수가 #if와 #else에 완전히 복사된 경우 공통 부분으로 추출해야 합니다.
  • 테스트 — 비활성 분기 내의 코드는 테스트되지 않습니다. 가능한 모든 조합의 CI 빌드가 필요합니다.
  • 매직 플래그 — 새 개발 팀이 알지 못하는 문서화되지 않은 플래그입니다.

자주 묻는 질문

조건부 컴파일과 런타임 검사의 차이점은 무엇인가요?

조건부 컴파일은 컴파일 시점에 작동합니다: 비활성 코드는 바이너리에 포함되지 않습니다. 런타임 검사(if / switch)는 항상 컴파일되며 조건은 실행 중에 확인됩니다. 전자가 더 안전하고 효율적이며, 후자가 더 유연합니다(재빌드 없이 변경 가능).

Swift의 함수 내에서 #if를 사용할 수 있나요?

네, Swift는 함수 내에서 #if, 루프 내, 심지어 표현식 내에서도 지시문을 허용합니다. 이것은 Swift 초기 버전에 없었던 기능 중 하나입니다. 예: let x = #if DEBUG 1 #else 0 #endif — 유효한 코드입니다.

Kotlin이 전처리기를 추가하지 않은 이유는 무엇인가요?

Kotlin 개발자들은 전처리기를 취약한 코드의 원천으로 간주하여 의도적으로 채택하지 않았습니다. 대신 expect/actual(컴파일 시간 안전성)과 Gradle sourceSets(파일 수준 격리)를 제공합니다. 두 접근 방식 모두 텍스트 대체보다 신뢰할 수 있습니다.

비활성 #if 분기 내의 코드를 테스트하는 방법은?

CI에서 다양한 플래그 조합으로 애플리케이션을 빌드하세요. Swift의 경우: 다른 Active Compilation Conditions로 별도의 Xcode 스킴을 구성하세요. Android의 경우: 별도의 Build Variants를 구성하고 각각에 대해 테스트를 실행하세요. 자동화는 필수입니다.

#if 조건에 구문 오류가 있으면 어떻게 되나요?

Swift에서 #if 조건은 컴파일러 지시문입니다. 조건 자체가 구문적으로 올바르지 않은 경우(예: os() 이름의 오타), 컴파일러가 컴파일 오류를 발생시킵니다. C/C++에서는 전처리기가 매크로를 찾지 못해 조건이 거짓이 됩니다.

요약

  • 조건부 컴파일 — 런타임 검사와 달리 빌드 시점에 비대상 코드를 제외하는 컴파일 기술.
  • Swift는 os(), canImport(), swift()와 함께 #if를 지원 — 모든 분기의 구문적 정확성을 요구하는 컴파일 시간 안전 지시문.
  • Kotlin은 전처리기 대신 expect/actual 및 Gradle sourceSets를 사용 — 더 신뢰할 수 있지만 덜 유연한 접근 방식.
  • NDK의 C/C++는 플랫폼 매크로 __ANDROID__, __APPLE__와 함께 클래식 텍스트 전처리기 #ifdef / #ifndef를 사용.
  • 올바른 사용 — 플랫폼 추상화, 디버깅, 하위 호환성. 잘못된 사용 — 모든 파일에 #if, 깊은 중첩, 매직 플래그.
  • CI는 필수 — 모든 플래그 조합이 자동으로 빌드되고 테스트되어야 합니다. 그렇지 않으면 비활성 분기의 코드가 데드 코드가 됩니다.

턴키 방식의 모바일 애플리케이션을 개발해 드립니다

IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.

프로젝트 논의

더 읽어보기