쉬운 설명
A가 B를 꼭 잡고 있고, B도 A를 꼭 잡고 있으면 둘 다 놓을 수 없다. 메모리도 비슷하다. 서로를 `strong`으로 잡고 있으면 ARC는 “아직 누가 쓰고 있네”라고 생각해서 둘 다 정리하지 못한다.
가장 쉬운 말로 하면 retain cycle은 서로가 서로를 강하게 붙잡아서 아무도 메모리에서 내려가지 못하는 상태다. iOS에서는 ARC가 참조 개수를 기준으로 객체를 정리하므로, 강한 참조 고리가 생기면 `deinit`이 호출되지 않고 메모리 누수가 생길 수 있다.
2026-04-14
A가 B를 꼭 잡고 있고, B도 A를 꼭 잡고 있으면 둘 다 놓을 수 없다. 메모리도 비슷하다. 서로를 `strong`으로 잡고 있으면 ARC는 “아직 누가 쓰고 있네”라고 생각해서 둘 다 정리하지 못한다.
| 상황 | 왜 순환이 생기나 |
|---|---|
| 서로를 프로퍼티로 들고 있을 때 | `A -> B`, `B -> A`가 모두 strong이면 둘 다 해제되지 못한다. |
| 클로저가 self를 잡고, self가 그 클로저를 들고 있을 때 | `self -> closure`, `closure -> self`가 된다. |
| delegate / handler / observer를 외부 객체가 strong으로 잡을 때 | delegate가 다시 원래 owner를 strong으로 잡으면 고리가 된다. |
| `WKWebView` 관련 스크립트 메시지 핸들러 | `webView -> configuration -> userContentController -> handler -> viewController` 구조로 고리가 생기기 쉽다. |
final class LoginViewController: UIViewController {
var onComplete: (() -> Void)?
func bind() {
onComplete = { [weak self] in
self?.dismiss(animated: true)
}
}
}여기서는 `onComplete`를 `self`가 들고 있으므로, 클로저 안에서 `self`를 strong으로 바로 쓰면 순환 참조가 생길 수 있다. 그래서 `[weak self]`를 쓰는 패턴이 흔하다.
왜 항상 weak self를 쓰지 않나요?
무조건은 아니다. 순환 구조가 실제로 생기는 경우에만 필요하다. 예를 들어 일회성 클로저, self가 클로저를 저장하지 않는 구조, 수명 관계가 더 강하게 보장되는 경우는 다르게 판단할 수 있다.
캡쳐는 클로저가 바깥 스코프의 값이나 참조를 기억해 두는 것이다. 클로저는 지금 바로 실행되지 않을 수 있으므로, 나중에 실행될 때도 필요한 값을 계속 쓸 수 있도록 문맥을 붙잡아 둔다.
| 캡쳐 대상 | 실무에서 중요하게 볼 점 |
|---|---|
| 값 타입 `struct`, `Int`, `String` | 어느 시점의 값이 들어가는지가 중요하다. |
| 참조 타입 `class` | 기본이 strong capture라서 메모리 고리와 수명을 같이 봐야 한다. |
| `self` | `self -> closure -> self` 고리가 생길 수 있으므로 가장 자주 점검한다. |
struct Counter {
var value: Int
}
var counter = Counter(value: 0)
let latestValueClosure = {
print(counter.value)
}
let fixedValueClosure = { [counter] in
print(counter.value)
}
counter.value = 10
latestValueClosure() // 10
fixedValueClosure() // 0그냥 `counter`를 쓰면 클로저가 바깥 문맥의 변수를 본다고 이해하면 된다. 반대로 `[counter]`를 쓰면 클로저 생성 시점의 값을 따로 잡아 둔다.
final class Service {
var value: Int
init(value: Int) {
self.value = value
}
func fetch() {
print(value)
}
}
var service = Service(value: 1)
let latestServiceClosure = {
service.fetch()
}
let fixedReferenceClosure = { [service] in
service.fetch()
}
service = Service(value: 2)
latestServiceClosure() // 2
fixedReferenceClosure() // 1여기서 `[service]`는 객체 내부 상태를 얼리는 것이 아니라, "그 시점에 가리키던 객체 참조"를 고정한다. 그래서 나중에 바깥 `service` 변수가 다른 객체를 가리켜도, `fixedReferenceClosure`는 예전 객체를 계속 본다.
final class Box {
var value: Int
init(value: Int) {
self.value = value
}
}
let box = Box(value: 1)
let closure = { [box] in
print(box.value)
}
box.value = 10
closure() // 10이 코드는 `[box]`로 캡쳐했는데도 `10`이 출력된다. 이유는 참조 타입에서 고정되는 것은 "참조"이기 때문이다. 같은 객체를 계속 보고 있으므로 그 객체 내부 프로퍼티가 바뀌면 바뀐 값이 보인다.
| 질문 | 답 |
|---|---|
| struct에서 retain cycle이 생기나 | struct 자체는 값 타입이라 retain cycle의 주체가 아니다. 다만 struct 안에 든 class나, struct가 보관한 클로저가 class를 strong capture하면 간접적으로 순환 고리에 참여할 수 있다. |
| struct를 `nil`로 하면 안의 객체도 항상 해제되나 | struct 값 자체는 사라진다. 하지만 안에 있던 class 객체끼리 strong 고리가 있으면 그 객체들은 남을 수 있다. |
| class 안에서도 값 타입을 캡쳐할 수 있나 | 가능하다. 클로저는 class 안이든 struct 안이든 바깥 스코프의 값 타입과 참조 타입을 모두 캡쳐할 수 있다. |
| self 말고 다른 class 인스턴스도 캡쳐할 수 있나 | 가능하다. `service`, `manager`, `cache` 같은 다른 참조 타입도 클로저가 strong으로 캡쳐할 수 있다. |
| `{ service.fetch() }`와 `{ [service] in service.fetch() }`는 같은가 | 단순한 `let service` 상황에서는 비슷하게 보일 수 있다. 하지만 `[service]`는 클로저 생성 시점의 참조를 명시적으로 고정한다는 점이 다르다. 바깥 변수가 `var`라면 차이가 분명히 드러난다. |
| `[]`를 쓰면 값 타입과 참조 타입 모두 생성 시점 값을 캡쳐하나 | 크게는 맞다. 다만 참조 타입은 객체 전체가 복사되는 것이 아니라 생성 시점의 객체 참조를 고정하는 것이다. |
| 그럼 참조 타입은 내부 변경도 반영되나 | 그렇다. 같은 객체를 계속 보고 있기 때문이다. 바깥 변수가 다른 객체를 가리키도록 바뀌는 것과, 이미 잡아 둔 객체 내부 프로퍼티가 바뀌는 것은 다른 문제다. |
| 왜 클로저가 클래스를 강하게 붙잡나 | 나중에 실행될 때도 그 객체를 써야 하기 때문이다. Swift는 기본 캡쳐를 strong으로 두어 필요한 대상을 확실히 살려 둔다. 그래서 메모리 고리가 생길 수 있는 곳에서는 `[weak self]`, `[weak service]`처럼 직접 의도를 적어 줘야 한다. |
`WKUserContentController`에 script message handler를 등록할 때, 그 handler를 내부적으로 강하게 잡는다. 그런데 보통 handler로 `self`인 뷰컨트롤러를 바로 넣고, 뷰컨트롤러는 다시 `webView`를 프로퍼티로 strong하게 갖고 있다. 그러면 아래와 같은 고리가 생긴다.
viewController
-> webView
-> configuration
-> userContentController
-> scriptMessageHandler(self)
-> viewController이런 경우에는 `self`를 직접 message handler로 등록하지 않고, 중간 proxy 객체를 하나 두고 그 안에서 `delegate`를 `weak`으로 들게 하면 된다. 질문에 적은 `SocialUILeakAvoider(delegate: self)` 패턴이 바로 이 방식이다.
import WebKit
import UIKit
final class ScriptMessageLeakAvoider: NSObject, WKScriptMessageHandler {
weak var delegate: WKScriptMessageHandler?
init(delegate: WKScriptMessageHandler) {
self.delegate = delegate
}
func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
delegate?.userContentController(userContentController, didReceive: message)
}
}
final class SocialViewController: UIViewController, WKScriptMessageHandler {
private var webView: WKWebView!
override func viewDidLoad() {
super.viewDidLoad()
let contentController = WKUserContentController()
contentController.add(
ScriptMessageLeakAvoider(delegate: self),
name: "socialEvent"
)
let configuration = WKWebViewConfiguration()
configuration.userContentController = contentController
webView = WKWebView(frame: .zero, configuration: configuration)
}
func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
print(message.body)
}
}여기서는 `WKUserContentController`가 직접 잡는 대상이 `SocialViewController`가 아니라 `ScriptMessageLeakAvoider`다. 그리고 `ScriptMessageLeakAvoider`는 내부에서 `delegate`를 `weak`으로만 잡는다. 그래서 `userContentController -> leakAvoider -> weak delegate(self)` 구조가 되어 순환 고리가 끊긴다.
그럼 중간 객체도 누수되는 것 아닌가요?
중간 객체는 `userContentController`가 잡고 있을 수 있지만, 중요한 건 그 객체가 다시 뷰컨트롤러를 strong으로 붙잡지 않는다는 점이다. 즉 “작은 객체 하나가 남는 문제”가 아니라 “뷰컨트롤러 전체가 내려가지 못하는 문제”를 끊는 것이 핵심이다.
deinit {
webView?.configuration.userContentController.removeScriptMessageHandler(forName: "socialEvent")
}가능하면 `deinit`이나 정리 시점에 handler를 제거하는 것도 같이 해 주는 편이 좋다. proxy를 썼더라도 등록 자체를 해제하면 의도와 수명 관계가 더 분명해진다.