조건부 컴파일(Conditional Compilation)을 사용하면 컴파일러가 빌드 시점에 알려진 조건에 따라 소스 코드의 일부를 포함하거나 건너뛸 수 있습니다. The Swift Programming Language (2026)에 따르면, #if 지시문은 머신 코드 생성 전 AST 분석 중에 처리됩니다. 조건부 컴파일은 개발자가 중복 없이 여러 플랫폼과 구성에 대해 단일 코드베이스를 유지할 수 있는 능력을 제공합니다.
주요 포인트
조건부 컴파일은 컴파일러가 조건부 컴파일 지시문을 분석하고 조건이 충족되는 코드 블록만 출력 바이너리에 포함하는 메커니즘입니다. 이를 통해 다양한 대상 플랫폼과 구성에 적응하는 단일 코드베이스를 유지할 수 있습니다.
이 개념은 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는 네 가지 주요 지시문을 제공합니다: #if, #elseif, #else, #endif. C 전처리기와 달리 Swift는 모든 분기에서 코드의 구문적 정확성을 요구합니다 — 컴파일러는 모든 코드를 구문 분석하지만 활성 분기에 대해서만 머신 코드를 생성합니다.
Swift는 내장 검사 함수를 지원합니다: os(iOS), os(macOS), os(tvOS), os(watchOS), os(Linux), os(Windows). 이 함수들은 애플리케이션이 빌드되는 대상 플랫폼을 확인합니다. && 및 ||와의 조합을 통해 복잡한 조건을 만들 수 있습니다.
// 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의 매크로 등)은 이러한 검사로 보호될 수 있습니다.
// 하위 호환성
#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(ModuleName) 함수는 지정된 모듈이 현재 빌드 환경에서 사용 가능한지 확인합니다. 이것은 가장 유연한 메커니즘입니다: 특정 플랫폼에 묶여 있지 않습니다. 예를 들어, CoreHaptics를 사용하는 코드는 이 프레임워크를 사용할 수 있는 기기에서만 컴파일됩니다.
#if canImport(CoreHaptics)
import CoreHaptics
class HapticManager {
private var engine: CHHapticEngine?
func playTapFeedback() {
guard let engine else { return }
// 햅틱 피드백 구현
}
}
#endif
Kotlin 언어에는 전처리기 지시문이 없습니다. 대신 Android 생태계는 세 가지 대안을 제공합니다: BuildConfig 필드(런타임 검사), sourceSets(전체 파일 교체), expect/actual(Kotlin Multiplatform).
Gradle sourceSets를 사용하면 다양한 flavor 또는 빌드 유형에 대해 다른 클래스 구현을 가질 수 있습니다. src/debug/ 디렉토리에는 디버그 구현이, src/release/에는 릴리스 구현이 있습니다. 빌드 시 Gradle은 적절한 sourceSet을 선택하고 해당 파일만 컴파일합니다.
// 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
}
}
KMP는 expect 메커니즘(공통 코드에서 선언)과 actual(특정 플랫폼에 대한 구현)을 제공합니다. 이것은 컴파일 시간 메커니즘입니다: iOS의 경우 iOS sourceSet의 actual 구현이 컴파일되고, Android의 경우 Android sourceSet의 구현이 컴파일됩니다. 대상이 아닌 구현은 컴파일되지 않습니다.
// 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
Android NDK를 통해 네이티브 라이브러리를 개발할 때 #ifdef, #ifndef, #define 지시문과 함께 클래식 C/C++ 전처리기가 사용됩니다. Swift와 달리 C 전처리기는 텍스트 수준에서 작동하므로 비활성 분기의 코드가 구문적으로 올바르지 않을 수 있습니다.
NDK는 각 플랫폼에 대한 매크로를 정의합니다: __ANDROID__(Android), __APPLE__(iOS/macOS), __linux__(Linux). 아키텍처의 경우: __arm__, __aarch64__, __x86_64__. 이러한 매크로는 대상 플랫폼용 빌드 시 컴파일러에 의해 자동으로 설정됩니다.
// 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/Kotlin | BuildConfig.FLAVOR |
가장 위험한 안티패턴은 코드 전체에 지시문이 확산되는 것입니다. 두 파일마다 #if가 포함되어 있다면 아키텍처에 리팩토링이 필요하다는 신호입니다. 올바른 해결책은 플랫폼 코드를 프로토콜/인터페이스 뒤로 추출하고 의존성 주입을 사용하는 것입니다.
자주 묻는 질문
조건부 컴파일은 컴파일 시점에 작동합니다: 비활성 코드는 바이너리에 포함되지 않습니다. 런타임 검사(if / switch)는 항상 컴파일되며 조건은 실행 중에 확인됩니다. 전자가 더 안전하고 효율적이며, 후자가 더 유연합니다(재빌드 없이 변경 가능).
네, Swift는 함수 내에서 #if, 루프 내, 심지어 표현식 내에서도 지시문을 허용합니다. 이것은 Swift 초기 버전에 없었던 기능 중 하나입니다. 예: let x = #if DEBUG 1 #else 0 #endif — 유효한 코드입니다.
Kotlin 개발자들은 전처리기를 취약한 코드의 원천으로 간주하여 의도적으로 채택하지 않았습니다. 대신 expect/actual(컴파일 시간 안전성)과 Gradle sourceSets(파일 수준 격리)를 제공합니다. 두 접근 방식 모두 텍스트 대체보다 신뢰할 수 있습니다.
CI에서 다양한 플래그 조합으로 애플리케이션을 빌드하세요. Swift의 경우: 다른 Active Compilation Conditions로 별도의 Xcode 스킴을 구성하세요. Android의 경우: 별도의 Build Variants를 구성하고 각각에 대해 테스트를 실행하세요. 자동화는 필수입니다.
Swift에서 #if 조건은 컴파일러 지시문입니다. 조건 자체가 구문적으로 올바르지 않은 경우(예: os() 이름의 오타), 컴파일러가 컴파일 오류를 발생시킵니다. C/C++에서는 전처리기가 매크로를 찾지 못해 조건이 거짓이 됩니다.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.