반응형

겁나 오래 살아 남아 있는 iOS 개발자가 RxSwift와 Combine 처음 봤을 때

iOS 개발을 2009년 부터 시작한 고인물 개발자 입니다. 너어어어무 고전 기술? 로만 앱을 만들고 유지보수 해봐서 그런지 RxSwift와 Combine을 처음 봤을 때 솔직히 당황했습니다.

"이게 뭐지? 왜 이렇게 복잡하게 하지?"

혹시 저처럼 느끼신 분들을 위해 정리해봤습니다.


먼저, 둘의 관계

RxSwift

  • 서드파티 라이브러리
  • iOS 8부터 사용 가능
  • 2015년부터 사용됨

Combine

  • Apple 공식 프레임워크
  • iOS 13 이상만 지원
  • 2019년 WWDC에서 발표

결론부터 말하면, 개념은 거의 똑같고 이름만 다릅니다.

RxSwift를 알면 Combine은 쉽고, Combine을 알면 RxSwift도 쉽습니다.


용어 비교표

개념 RxSwift Combine
데이터 발행 Observable Publisher
데이터 구독 Observer Subscriber
구독 해제 dispose() cancel()
메모리 관리 DisposeBag Set<AnyCancellable>
구독 메서드 subscribe sink
값 래퍼 BehaviorRelay @Published

기존 UIKit 방식 (우리가 익숙한 것)

버튼을 누르면 라벨 텍스트가 바뀌는 코드입니다.

button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside)

@objc func buttonTapped() {
    label.text = "눌렸다"
}

직관적이죠? 버튼 눌리면 → 함수 실행 → 라벨 변경

10년간 이렇게 해왔습니다.


RxSwift 방식

let disposeBag = DisposeBag()

button.rx.tap
    .subscribe(onNext: { _ in
        label.text = "눌렸다"
    })
    .disposed(by: disposeBag)

Combine 방식

var cancellables = Set<AnyCancellable>()

button.publisher(for: .touchUpInside)
    .sink { _ in
        label.text = "눌렸다"
    }
    .store(in: &cancellables)

나란히 비교하면 거의 똑같음

보시면 구조가 동일합니다.

단계 RxSwift Combine
1. 이벤트 소스 button.rx.tap button.publisher(for:)
2. 구독 .subscribe(onNext:) .sink { }
3. 메모리 관리 .disposed(by: disposeBag) .store(in: &cancellables)

핵심 개념 3가지 (둘 다 동일)

1. Publisher / Observable (발행자)

데이터를 내보내는 쪽입니다.

"나 값 바뀌면 알려줄게"라고 선언하는 겁니다.

Combine:

@Published var name: String = ""

RxSwift:

let name = BehaviorRelay<String>(value: "")

2. Subscriber / Observer (구독자)

데이터를 받는 쪽입니다.

Combine:

$name
    .sink { newName in
        print("이름 바뀜: \(newName)")
    }

RxSwift:

name
    .subscribe(onNext: { newName in
        print("이름 바뀜: \(newName)")
    })

3. Operator (연산자)

중간에서 데이터를 가공합니다. 검색창 예시로 보겠습니다.

Combine:

$searchText
    .debounce(for: 0.3, scheduler: RunLoop.main)
    .filter { $0.count >= 2 }
    .sink { text in
        self.search(text)
    }
    .store(in: &cancellables)

RxSwift:

searchText
    .debounce(.milliseconds(300), scheduler: MainScheduler.instance)
    .filter { $0.count >= 2 }
    .subscribe(onNext: { text in
        self.search(text)
    })
    .disposed(by: disposeBag)

거의 똑같죠? debounce, filter 같은 연산자 이름도 동일합니다.


메모리 관리 비교 (중요!)

이거 빼먹으면 둘 다 동작 안 합니다.

Combine:

var cancellables = Set<AnyCancellable>()

$name
    .sink { print($0) }
    .store(in: &cancellables)  // 필수!

RxSwift:

let disposeBag = DisposeBag()

name
    .subscribe(onNext: { print($0) })
    .disposed(by: disposeBag)  // 필수!

역할은 같습니다. 뷰컨트롤러가 해제될 때 구독도 같이 정리하는 거예요.


그래서 뭘 써야 하나요?

RxSwift를 써야 할 때:

  • iOS 13 미만 지원해야 할 때
  • 이미 RxSwift로 된 프로젝트 유지보수할 때
  • RxCocoa의 UI 바인딩이 필요할 때

Combine을 써야 할 때:

  • 새 프로젝트 시작할 때
  • iOS 13 이상만 지원해도 될 때
  • SwiftUI 쓸 때 (Combine 기반이라 궁합 좋음)
  • 외부 의존성 줄이고 싶을 때

솔직한 후기

처음엔 "왜 이렇게 복잡하게?" 싶었습니다.

근데 검색창처럼 "입력 → 딜레이 → 필터 → API 호출" 같은 흐름은 확실히 코드가 줄어듭니다.

UIKit으로 같은 걸 만들려면 Timer 쓰고, 플래그 변수 만들고, 취소 로직 넣고... 더 복잡해요.

아직 완벽히 익숙하진 않지만, 하나씩 적용해보는 중입니다.

하나만 익히면 다른 하나는 자동으로 따라옵니다. 용어만 다르니까요.


위 내용은 클로드에게 물어가며 적어 본 내용입니다.

반응형
Posted by onlyTheOne
,
반응형

claude로 어떻게 공부를 할 수 있을까 궁금해서 기존에 필터 앱을 업데이트 하고자 다음과 같이 요청 하였습니다. 

"새로운 질문을 하지 swift 언어로 iOS 15 이상에서 카메라앱을 만들거야 동영상 촬영 앱이고 기능은 다음과 같아 1. 동영상 촬영 버튼을 누르면 촬영이 되고 10초뒤에 촬영이 자동 종료 되 2. 동영상 필터는 2개가 제공될거야 3. 필터는 흑백하고 카툰 필터 4. 녹화된 영상은 앱내에 저장하는데 사진앱으로 내보내기 기능을 제공 5. 녹화사에는 전면 카메라 녹화나 후면 카메라 녹화 둘중 하나만 되게 할거야 6. 고급 카메라는 PIP 기능으로 영상이 녹화되게 할거야 7. 카메라 촬영중에는 화면에서 10초 타이머가 보여지면서 촬영된 영상이 실시간으로 보이게 할거야 여기까지 "

이렇게 요청 하니 CIFilter 기반 이 나왔다.....

CIFilter로는 퍼포먼스가 안나오는걸 알고 있기에... 다시 요청 했다.

"아 미안해... metal framework로 구현해줘 CIFilter는 퍼포먼스 안나와"

다음과 같이 안내해 주었다.

코드를 보니.... 분류가 안되어 있어 재차 요청 했다.

"파일명 별로 구분좀 해줄 수 있을까?"

 

위와 같이 프로젝트 구조도 알려주었다. 

 

그래서 각각의 소스코드는 다음과 같다. 

// MARK: - Models/ShaderTypes.swift
struct Vertex {
    var position: SIMD3<Float>
    var textureCoordinate: SIMD2<Float>
}

enum FilterType {
    case normal
    case grayscale
    case cartoon
}

// MARK: - Shaders/CameraShaders.metal
"""
#include <metal_stdlib>
using namespace metal;

struct VertexIn {
    float3 position [[attribute(0)]];
    float2 textureCoordinate [[attribute(1)]];
};

struct VertexOut {
    float4 position [[position]];
    float2 textureCoordinate;
};

vertex VertexOut vertexShader(uint vertexID [[vertex_id]],
                             constant VertexIn *vertices [[buffer(0)]]) {
    VertexOut out;
    out.position = float4(vertices[vertexID].position, 1.0);
    out.textureCoordinate = vertices[vertexID].textureCoordinate;
    return out;
}

fragment float4 fragmentShader(VertexOut in [[stage_in]],
                             texture2d<float> texture [[texture(0)]],
                             constant int &filterType [[buffer(1)]]) {
    constexpr sampler textureSampler(mag_filter::linear,
                                   min_filter::linear);
    
    float4 color = texture.sample(textureSampler, in.textureCoordinate);
    
    // Normal
    if (filterType == 0) {
        return color;
    }
    // Grayscale
    else if (filterType == 1) {
        float gray = dot(color.rgb, float3(0.299, 0.587, 0.114));
        return float4(gray, gray, gray, color.a);
    }
    // Cartoon
    else if (filterType == 2) {
        float3 original = color.rgb;
        
        float2 texelSize = float2(1.0 / texture.get_width(),
                                1.0 / texture.get_height());
        
        float3 edge = float3(0.0);
        for(int i = -1; i <= 1; i++) {
            for(int j = -1; j <= 1; j++) {
                float2 offset = float2(float(i), float(j)) * texelSize;
                float3 sample = texture.sample(textureSampler,
                                            in.textureCoordinate + offset).rgb;
                edge += abs(sample - original);
            }
        }
        
        float3 quantized = floor(original * 5.0) / 5.0;
        float edgeMask = 1.0 - smoothstep(0.2, 0.3, length(edge));
        return float4(quantized * edgeMask, color.a);
    }
    
    return color;
}
"""

// MARK: - Controllers/MetalCameraController.swift
class MetalCameraController: NSObject {
    private var captureSession: AVCaptureSession?
    private var videoOutput: AVCaptureMovieFileOutput?
    private var textureCache: CVMetalTextureCache?
    
    private let metalDevice: MTLDevice
    private let metalCommandQueue: MTLCommandQueue
    private let pipelineState: MTLRenderPipelineState
    private let vertexBuffer: MTLBuffer
    
    private var currentFilterType: FilterType = .normal
    
    private let vertices: [Vertex] = [
        Vertex(position: SIMD3<Float>(-1, -1, 0), textureCoordinate: SIMD2<Float>(0, 1)),
        Vertex(position: SIMD3<Float>(1, -1, 0), textureCoordinate: SIMD2<Float>(1, 1)),
        Vertex(position: SIMD3<Float>(-1, 1, 0), textureCoordinate: SIMD2<Float>(0, 0)),
        Vertex(position: SIMD3<Float>(1, 1, 0), textureCoordinate: SIMD2<Float>(1, 0))
    ]
    
    override init() {
        // ... (이전 코드와 동일)
    }
    
    private func setupTextureCache() {
        // ... (이전 코드와 동일)
    }
}

// MARK: - Views/Components/CameraView.swift
class CameraView: MTKView {
    var renderDelegate: CameraRenderDelegate?
    
    override init(frame frameRect: CGRect, device: MTLDevice?) {
        super.init(frame: frameRect, device: device)
        setupView()
    }
    
    required init(coder: NSCoder) {
        super.init(coder: coder)
        setupView()
    }
    
    private func setupView() {
        self.device = MTLCreateSystemDefaultDevice()
        self.backgroundColor = .black
        self.framebufferOnly = false
    }
}

// MARK: - Views/Components/CameraControls.swift
class CameraControls: UIView {
    var onRecordTap: (() -> Void)?
    var onFilterTap: (() -> Void)?
    
    private lazy var recordButton: UIButton = {
        let button = UIButton(type: .system)
        button.setTitle("녹화", for: .normal)
        button.backgroundColor = .red
        button.layer.cornerRadius = 25
        button.addTarget(self, action: #selector(recordButtonTapped), for: .touchUpInside)
        return button
    }()
    
    private lazy var filterButton: UIButton = {
        let button = UIButton(type: .system)
        button.setTitle("필터 변경", for: .normal)
        button.addTarget(self, action: #selector(filterButtonTapped), for: .touchUpInside)
        return button
    }()
    
    private lazy var timerLabel: UILabel = {
        let label = UILabel()
        label.textColor = .white
        label.font = .systemFont(ofSize: 24, weight: .bold)
        label.text = "10"
        label.textAlignment = .center
        return label
    }()
    
    @objc private func recordButtonTapped() {
        onRecordTap?()
    }
    
    @objc private func filterButtonTapped() {
        onFilterTap?()
    }
    
    // ... Layout 코드
}

// MARK: - ViewControllers/MetalCameraViewController.swift
class MetalCameraViewController: UIViewController {
    private var cameraView: CameraView!
    private var cameraControls: CameraControls!
    private var cameraController: MetalCameraController!
    
    private var currentFilter: FilterType = .normal
    private var recordingTimer: Timer?
    private var remainingTime: Int = 10
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupViews()
        setupCameraController()
        setupBindings()
    }
    
    private func setupViews() {
        cameraView = CameraView(frame: view.bounds, device: MTLCreateSystemDefaultDevice())
        cameraControls = CameraControls()
        
        view.addSubview(cameraView)
        view.addSubview(cameraControls)
        
        // Layout constraints
    }
    
    private func setupCameraController() {
        cameraController = MetalCameraController()
    }
    
    private func setupBindings() {
        cameraControls.onRecordTap = { [weak self] in
            self?.handleRecordTap()
        }
        
        cameraControls.onFilterTap = { [weak self] in
            self?.handleFilterTap()
        }
    }
    
    private func handleRecordTap() {
        if recordingTimer == nil {
            startRecording()
        } else {
            stopRecording()
        }
    }
    
    private func handleFilterTap() {
        switch currentFilter {
        case .normal: currentFilter = .grayscale
        case .grayscale: currentFilter = .cartoon
        case .cartoon: currentFilter = .normal
        }
        // Update filter in camera controller
    }
    
    // ... Recording and timer related methods
}

// MARK: - Utils/VideoExporter.swift
class VideoExporter {
    static func exportVideo(at url: URL, completion: @escaping (Result<URL, Error>) -> Void) {
        // Video export implementation
    }
}

// MARK: - Protocols/CameraRenderDelegate.swift
protocol CameraRenderDelegate: AnyObject {
    func renderFrame()
    func updateFilter(_ type: FilterType)
}

여기까지...  

직접 코드를 나눠어 넣었을때.... MetalCameraViewController.swift 파일에 먼가 이상함을 감지했다. 

to be continue...

반응형
Posted by onlyTheOne
,
반응형

최근 Claude 를 활용해서 필터 효과가 동영상 제작 앱을 하나 만들어 보았습니다. 

마무리 직압 중인데.... 무언가 남겨야 할 거 같아...

시작글을 올릴려고 합니다. 

이 동영상 촬영 앱은

Metal Framework를 사용한 소스코드이며 녹화와 화면에 실시간으로 보여지도록 되어 있습니다.

Swift 언어 기반에 SwiftUI는 사용하지 않았습니다. 

내일 부터 정리해서 올리도록 하겠습니다. 

반응형
Posted by onlyTheOne
,