iOS Development Guide

retain cycle이 생기는 이유와 끊는 방법

가장 쉬운 말로 하면 retain cycle은 서로가 서로를 강하게 붙잡아서 아무도 메모리에서 내려가지 못하는 상태다. iOS에서는 ARC가 참조 개수를 기준으로 객체를 정리하므로, 강한 참조 고리가 생기면 `deinit`이 호출되지 않고 메모리 누수가 생길 수 있다.

ARC fundamentals Practical memory leak patterns Knowledge item #13-14
학습 날짜

2026-04-14

쉬운 설명

A가 B를 꼭 잡고 있고, B도 A를 꼭 잡고 있으면 둘 다 놓을 수 없다. 메모리도 비슷하다. 서로를 `strong`으로 잡고 있으면 ARC는 “아직 누가 쓰고 있네”라고 생각해서 둘 다 정리하지 못한다.

가장 먼저 알아야 할 것

  • retain cycle은 보통 `class` 사이에서 생긴다.
  • `struct`는 값 복사이므로 이런 종류의 순환 참조 중심 문제가 적다.
  • `weak`, `unowned`, 중간 proxy 객체로 고리를 끊는다.
  • 핵심은 “누가 누구를 strong으로 잡는가”를 그림처럼 보는 것이다.

왜 생기나

상황 왜 순환이 생기나
서로를 프로퍼티로 들고 있을 때 `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]`를 쓰면 클로저 생성 시점의 값을 따로 잡아 둔다.

참조 타입도 self 말고 캡쳐할 수 있다

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]`처럼 직접 의도를 적어 줘야 한다.

`WKWebView`에서 왜 자주 새나

`WKUserContentController`에 script message handler를 등록할 때, 그 handler를 내부적으로 강하게 잡는다. 그런데 보통 handler로 `self`인 뷰컨트롤러를 바로 넣고, 뷰컨트롤러는 다시 `webView`를 프로퍼티로 strong하게 갖고 있다. 그러면 아래와 같은 고리가 생긴다.

viewController
  -> webView
  -> configuration
  -> userContentController
  -> scriptMessageHandler(self)
  -> viewController
즉 문제의 핵심은 `userContentController.add(self, name: ...)`처럼 `self`를 바로 넘기는 순간, 외부 객체가 그 `self`를 strong으로 잡는 구조가 생길 수 있다는 점이다.

해결 예시: LeakAvoider 같은 중간 객체 두기

이런 경우에는 `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)` 구조가 되어 순환 고리가 끊긴다.

왜 이게 해결되나

  • `webView` 계열이 strong으로 잡는 것은 중간 객체다.
  • 중간 객체는 `self`를 weak으로만 본다.
  • 그래서 다시 `self`를 strong으로 되돌아오는 고리가 없다.
  • `self`가 사라지면 delegate는 `nil`이 되므로 메시지도 안전하게 무시된다.

질문

그럼 중간 객체도 누수되는 것 아닌가요?

중간 객체는 `userContentController`가 잡고 있을 수 있지만, 중요한 건 그 객체가 다시 뷰컨트롤러를 strong으로 붙잡지 않는다는 점이다. 즉 “작은 객체 하나가 남는 문제”가 아니라 “뷰컨트롤러 전체가 내려가지 못하는 문제”를 끊는 것이 핵심이다.

추가로 같이 해야 할 것

deinit {
    webView?.configuration.userContentController.removeScriptMessageHandler(forName: "socialEvent")
}

가능하면 `deinit`이나 정리 시점에 handler를 제거하는 것도 같이 해 주는 편이 좋다. proxy를 썼더라도 등록 자체를 해제하면 의도와 수명 관계가 더 분명해진다.

실무 체크리스트