SIL (Swift Intermediate Language)

Tabber·2026년 2월 1일

Swift

목록 보기
10/15
post-thumbnail

SIL은 Swift 컴파일 과정에서 가장 독특하면서도 중요한 부분이에요. 일반적인 컴파일러 (C, C++ 등) 가 소스코드에서 바로 LLVM IR로 넘어가는 것과는 달리, Swift는 그 사이에서 SIL이라는 단계를 하나 더 두었어요.

왜 굳이 일을 두 번 할까요? 그 이유는 “Swift는 너무 똑똑하고 안전한 언어라서” 그래요.

왜 SIL이 필요한가? (The Motivation)

LLVM IR은 하드웨어에 가깝기 때문에, Swift가 가진 고수준의 언어적 특징들을 다 이해하지 못해요. 만약 SIL 없이 바로 LLVM IR로 간다면 다음과 같은 문제가 발생해요.

  • 정보의 손실 : Swift의 제네릭, 프로토콜, ARC 와 같은 개념이 LLVM IR로 변환되는 순간 단순한 포인터와 메모리 덩어리로 쪼개져 버려요. 최적화할 기회를 놓치는 거죠.
  • 안전성 검사 누락 : 변수가 초기화 되었는지, 배열의 인덱스가 범위를 벗어났는지 등을 LLVM IR 수준에서 검사하기는 매우 어려워요.

즉, SIL은 Swift의 언어적 의미를 유지하면서 최적화를 수행하기 위한 중간 기착지에요.

SIL의 두 가지 상태

SIL은 컴파일 과정 중에 두 가지 모습으로 변해요.

  1. Raw SIL (가공되지 않은 SIL)

Swift 문법을 막 분석해서 나온 상태에요. 여기서는 “데이터 흐름 분석(Data Flow Analysis)”을 통해 다음을 검사해요.

  • Definite Initialization : 모든 변수가 사용되기 전에 확실히 값이 들어가있는가?
  • Unreachable Code : 절대로 실행될 리 없는 코드가 있는가?
  • Return Check : 함수가 모든 경로에서 값을 변환하는가?
  1. Canonical SIL (표준화된 SIL)

검사가 끝나면 Swift 특유의 성능 최적화를 진행해요.

  • Generic Specialization : 제네릭 함수를 실제 타입(Int, String 등)을 사용하는 구체적인 함수로 복제하여 실행 속도를 높혀요.
  • ARC Optimization : 불필요한 retain / release 호출을 찾아내서 제거해요. (메모리 관리 효율화의 작업이에요.)
  • Devirtualization : 프로토콜이나 클래스 상속 때문에 발생하는 동적 호출(Dynamic Dispatch)을 분석하여, 가능한 경우 직접 호출 (Static Dispatch)로 바꿔버려요.

실제 코드로 보는 SIL

이해를 돕기 위해 아주 간단한 Swift 코드가 SIL에서 어떻게 보이는지 확인해봐요.

// Swift Code
func add(a: Int, b: Int) -> Int {
    return a + b
}

이 코드가 SIL로 변하면 대략 이런 식이 돼요. (실제보다 단순화한 버전이에요.)

// SIL Code (의사 코드)
sil hidden @add_function : $@convention(thin) (Int, Int) -> Int {
bb0(%0 : $Int, %1 : $Int):
  %2 = struct_extract %0 : $Int, #Int._value  // 정수 구조체에서 실제 값을 추출
  %3 = struct_extract %1 : $Int, #Int._value
  %4 = builtin "add_Int64"(%2 : $Builtin.Int64, %3 : $Builtin.Int64) // 내장 덧셈 수행
  %5 = struct $Int (%4 : $Builtin.Int64)      // 다시 Int 구조체로 포장
  return %5 : $Int                            // 반환
}
  • bb0 (Basic Block): 실행의 기본 단위에요.
  • $Int : Swift의 Int 가 단순한 숫자가 아니라 struct 임을 인지하고 있어요.
  • 강력한 타입 : 모든 단계에 타입 정보가 붙어 있어 안전합니다.

요약

SIL이 주는 이점

  1. 더 빠른 실행 속도 : 제네릭 최적화와 직접 호출 전환을 통해 성능을 극대화해요.
  2. 더 안전한 코드 : 컴파일 타임에 초기화되지 않은 변수나 논리적 오류를 잡아내요.
  3. 효울적인 메모리 : ARC 비용을 최소화해요.

실제 코드로 확인해보기

직접 확인해보고 싶어서 터미널에서 swiftc -emit-sil 파일명.swift 를 입력하여 SIL로 변환을 해보았어요.

import Foundation

public enum ViewType {
    case signIn
    case myTamin
}

변환을 시도한 코드에요. enum으로 화면의 타입을 지정해놓은 코드에요.

그런데, 변환을 시도하니 다음과 같이 나왔어요.
실제 코드는 너무 길어서 엄청 축약을 했습니다.

import Builtin
import Swift
import SwiftShims

import Foundation

public enum ViewType {
  case signIn
  case myTamin
  @_implements(Equatable, ==(_:_:)) static func __derived_enum_equals(_ a: ViewType, _ b: ViewType) -> Bool
  func hash(into hasher: inout Hasher)
  var hashValue: Int { get }
}

// main
// Isolation: unspecified
sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
  %2 = integer_literal $Builtin.Int32, 0          // user: %3
  %3 = struct $Int32 (%2)                         // user: %4
  return %3                                       // id: %4
} // end sil function 'main'

// static ViewType.__derived_enum_equals(_:_:)
// Isolation: unspecified
sil @$s8ViewTypeAAO21__derived_enum_equalsySbAB_ABtFZ : $@convention(method) (ViewType, ViewType, @thin ViewType.Type) -> Bool {
// %0 "a"                                         // users: %7, %3
// %1 "b"                                         // users: %22, %4
// %2 "self"                                      // user: %5
bb0(%0 : $ViewType, %1 : $ViewType, %2 : $@thin ViewType.Type):
  debug_value %0, let, name "a", argno 1          // id: %3
  debug_value %1, let, name "b", argno 2          // id: %4
  debug_value %2, let, name "self", argno 3       // id: %5
  %6 = alloc_stack [var_decl] $Int                // users: %16, %10, %41
  switch_enum %0, case #ViewType.signIn!enumelt: bb1, case #ViewType.myTamin!enumelt: bb2 // id: %7

bb1:                                              // Preds: bb0
  %8 = integer_literal $Builtin.Int64, 0          // user: %9
  %9 = struct $Int (%8)                           // users: %11, %13
  %10 = begin_access [modify] [static] %6         // users: %11, %12
  store %9 to %10                                 // id: %11
  end_access %10                                  // id: %12
  br bb3(%9)                                      // id: %13

bb2:                                              // Preds: bb0
  %14 = integer_literal $Builtin.Int64, 1         // user: %15
  %15 = struct $Int (%14)                         // users: %17, %19
  %16 = begin_access [modify] [static] %6         // users: %17, %18
  store %15 to %16                                // id: %17
  end_access %16                                  // id: %18
  br bb3(%15)                                     // id: %19

// %20                                            // user: %36
bb3(%20 : $Int):                                  // Preds: bb1 bb2
  %21 = alloc_stack [var_decl] $Int               // users: %31, %25, %40
  switch_enum %1, case #ViewType.signIn!enumelt: bb4, case #ViewType.myTamin!enumelt: bb5 // id: %22

// ...

// Mappings from '#fileID' to '#filePath':
//   'ViewType/ViewType.swift' => '/Users/tabber/Desktop/github/MiTamin/MiTamin/Source/Extension/ViewType.swift'

소스코드는 단 4줄짜리 enum 인데, SIL은 수백 줄에 달했어요.

그리고 이 현상이 “Swift가 왜 안전하고 강력한 언어인가”를 보여주는 증거에요.

왜 그런지에 대해 더 알아보았어요.

“공바닥에서 건물 짓기” : 암시적 구현 (Derived Conformance)

제가 만든 코드인 ViewType 파일은 단순해 보이지만, Swift 컴파일러는 이 enum 이 나중에 Equatable 이나 Hashable 처럼 독장할 수 있도록 기본적인 뼈대 코드를 자동으로 생성해요.

  • __derived_enum_equals : enum 의 케이스들을 서로 비교하기 위한 로직이에요. SIL 코드를 보면 swift_enum 을 통해 signIn 인지 myTamin 인지 일일히 대조하는 복잡한 분기문이 생성된 것을 볼 수 있어요.
  • hash(into:) & hashValue : 이 enum 이 딕셔너리의 키로 쓰이거나 Set에 담길 때 필요한 해시 계산 로직이에요.

개발자가 작성하지 않아도 컴파일러가 “너 나중에 이거 쓸 거지? 그럼 내가 미리 안전하게 만들어둘게”라며 정교한 코드를 덧붙이는 과정이에요.

“이름표 붙이기” : Name Mangling

SIL 코드에서 @$s8ViewTypeAAO21__derived_enum_equalsySbAB_ABtFZ 같은 괴상한 이름들을 봤을거에요.
이를 Name Mangling 이라고 해요.

Swift는 오버로딩(이름은 같지만 파라미터가 다른 함수)을 지원하기 때문에, 컴파일러 레벨에서는 함수 이름 + 파라미터 타입 + 리턴 타입 + 모듈 이름을 모두 섞어서 세상에 단 하나뿐인 고유한 ID를 만들어요. 나중에 링크 단계에서 정확한 함수를 찾아가기 위한 주소표라고 생각하시면 돼요.

“철저한 관리” : 메모리의 제어 흐름 (Basic Blocks)

SIL의 구조를 다시 보면 bb0, bb1 , bb2 같은 Basic Blocks 로 나뉘어 있어요.

  • alloc_stack / dealloc_stack : Swift는 메모리 안정성을 위해 스택 메모리 할당과 해제를 명확하게 기록해요.
  • begin_access / end_access : 독점적 메모리 접근 (Exclusive Access to Memory) 을 검사하는 코드에요. 여러 곳에서 동시에 값을 수정하려 할 때 런타임 에러를 내주는 그 안전 장치가 SIL 레벨에서 이미 삽입돼요.

눈여겨봐야 할 핵심 포인트

출력된 SIL 중에서 가장 중요한 부분은 하단의 sil_witness_table 이에요.

이 Witness Table은 Swift 다형성의 핵심이에요.

“ViewType이라는 애는 Equatable 프로토콜을 만족하려면 이 함수 주소로 가면 돼!” 라는 매핑 테이블이에요. 여기서 PWT라는 개념이 나오는데, Protocol Witness Table (PWT)에 대해서는 클릭하시면 자세히 정리가 되어있어요.

결론 : SIL은 “명세서”이다.

우리가 Swift로 “이거 해줘” 라고 한마디 하면 SIL은 그걸 하기 위해서 “메모리는 어떻게 잡고, 타입은 어떻게 하고, 에러는 어떻게 처리할지”를 아주 상세하게 적어놓은 실행 명세서와 같아요.


나의 추가 질문

해당 내용을 공부하면서 든 궁금증을 다시 공부해보기로 했어요.

1. Swift의 타입 안정성과 SIL의 역할

질문 내용은 다음과 같았어요.

아까 "강력한 타입 : 모든 단계에 타입 정보가 붙어 있어 안전합니다." 이 구문에 대한 질문인데, 이 모든 타입이 붙어있다는 점 때문에 Swift은 타입 추론에 강점이 있고 안전한 언어라고 하는거야?

내용이 얼추 정확 합니다. 하지만 ‘타입 추론’‘타입 안전’을 나누어서 이해하면 더 명확해져요.

  • 타입 추론 (Type Inference) : 이건 주로 AST(Abstract Syntax Tree) 단계, 즉 SIL로 오기 전인 Semantic Analysis 단계에서 결정돼요. 우리가 let x = 10 이라고만 써도 컴파일러가 “아, 이건 Int 구나”라고 판단하는 과정이죠.
  • SIL에서의 타입 보존 : SIL은 이 추론된 결과를 바탕으로 모든 명령어에 타입을 명시해요.
    • 왜?
      LLVM IR 로 내려가기 전에 “논리적 무결성” 을 최종적으로 확인하기 위해서에요.
      C언어 같은 경우, 나중에 포인터로 강제 형변환을 하면 컴파일러가 놓치는 경우가 많아요. 하지만 SIL은 모든 데이터 흐름에 타입 정보를 꼬리표처럼 붙여두기 때문에, 최적화 과정에서 타입이 어긋나는 코드가 생성되는 것을 원천적으로 차단해요.

결론적으로 타입 추론 덕분에 코딩은 편해지고, SIL에서 타입을 엄격히 고정하기 때문에 런타임에 타입으로 인한 사고가 안 터지는 ‘안전한 언어’가 되는 것이에요.

2. Exclusive Access (독점적 접근)와 EXC_BAD_ACCESS

질문 내용은 다음과 같았어요.

begin_access / end_access 독점적 메모리 접근을 설명할 때 "런타임 에러를 내주는 그 안전장치가 SIL 레벨에서 이미 삽입"된다고 했잖아. 이게 소위 크래시 상황 중 BAD_ACCESS 상황이야? 그리고 이걸 어떻게 도출되게 하는지도 궁금해

이건 제가 혼동한 부분입니다. 아주 중요한 차이가 있어요.

begin_access 와 Law of Exclusivity

Swift는 “한 변수를 동시에 수정하거나, 수정하는 도중에 읽는 행위”를 엄격히 금지해요.

  • 컴파일 타임 : 정적 분석을 통해 문제를 찾아내요.
  • 런타임(begin_access / end_access ) : 정적으로 판단이 안되는 경우 (ex: 글로벌 변수, 클래스 프로퍼티 등) SIL 단계에서 검사 코드를 끼워 넣어요.
  • 이 검사에 걸리게 되면 "Simultaneous accesses to ..., but modification requires exclusive access” 라는 메시지와 함께 정지해요.

EXC_BAD_ACCESS와의 차이

  • begin_access 실패 : Swift 언어 차원에서 “야, 너 지금 데이터 레이스 날 수 있어! 위험해!” 라고 의도적으로 앱을 죽이는 안전한 크래시에요.
  • EXC_BAD_ACCESS : 이건 보통 메모리 주소 자체가 잘못되었을 때 발생해요. 이미 해제된 메모리 (댕글링 포인터) 에 접근하거나, 아예 권한이 없는 메모리 번지를 찌를 때 OS가 던지는 에러에요. Swift는 ARC와 강력한 포인터 제어를 통해 이 EXC_BAD_ACCESS 를 최대한 예방하려 노력해요.
profile
iOS 정복중인 Tabber 입니다.

0개의 댓글