[iOS] 플러그인 패턴이요...? 내가아는 그 플러그인인가요?

Youth·2024년 12월 24일
1

고찰 및 분석

목록 보기
19/21

안녕하세요 킴스캐슬입니다
오늘 날짜는 12월 24일 크리스마스 이브입니다:)

모두 메리크리스마스 하시고 오늘은 플러그인패턴이라는 주제로 글을 써보려고 합니다

저번달에 Let'swift에 가서 직접 듣지는 못했고 같이 갔던 동료분이 들었던 내용을 요약한 글을 봤는데 살짝만 들어봐도 오...괜찮은데...?라는 생각이 들었습니다

다만, 쉽게 접근했다가 생각보다 래퍼런스도 많이 없고 제 수준에서는 이해하기 어려운 내용이 많아서 혹~~~시나 플러그인 패턴에대해 궁금해 하시는 분들에게 약간이라도 도움이 되고싶다는 마음으로 준비해봤습니다

그럼 플러그인패턴에대해 알아보러 가시죠!

Plugin Pattern

특정 기능을 하는 객체를 만들 때 가장 어려운건 그 객체가 담당할 기능의 범위를 결정하는 일입니다

한마디로, 어느 역할까지 이 객체가 해야할까?

너무 좁은 범위의 기능을 수행하는 객체를 만들면 비슷하지만 약간의 다른 기능이 추가된 경우 대응이 어려울 수도 있고 너무 넓은 범위를 수행하는 객체로 만들면 당연히 너무 많은 책임을 맡게되고 내부에서 분기처리에대한 로직때문에 복잡해지고 작은 수정이 많은 수정을 야기할 수 있기에 관리하기가 어려워질 수 있습니다

개인적인 생각이지만 OCP를 지키기 어려운이유가 이 때문이 아닐까 싶습니다

텍스트로만보면 당연한듯하지만 헷갈릴 수 있기 때문에 한가지 예시를 들어보겠습니다

플러그인 패턴이 왜 필요할까요?

만약 image를 load하는 객체를 따로 만들어서 관리한다고 하면 최소기능은 url을 받아서 image를 반환해주는 기능일겁니다

class ImageLoader {
    typealias Handler = (Result<UIImage, Error>) -> Void

    private let networking: Networking

    init(networking: Networking) {
        self.networking = networking
    }

    func loadImage(from url: URL,
                   then handler: @escaping Handler) {
        let request = Request(url: url, method: .get)

        networking.perform(request) { result in
            switch result {
            case .success(let data):
                guard let image = UIImage(data: data) else {
                    handler(.failure(ImageError.invalidData))
                    return
                }

                handler(.success(image))
            case .failure(let error):
                handler(.failure(error))
            }
        }
    }
}

초기 서비스의 규모가 매우작거나 특별한 기능이 없다면 해당 객체만으로도 충분하겠죠

해당 경우는 하나의 객체가 아주간단한 기능에대한 책임만 갖게만드는 경우로 서비스의 복잡도가 낮고 규모가 작다면 유효한 방법이고 좋은 방법이라고 생각합니다

만약 앱의 규모가 커지는 과정에서 단순 이미지가 아니라 이미지에 워터마크를 표시해야하는 뷰가 생기고 특정 뷰에서는 이미지로드에 실패할 가능성이 높은 로직이 있어서 이미지 로드 실패시 placeholder이미지를 보여줘야하는 기능이 필요하다고 가정해보겠습니다

두가지 해결방법이 존재할것같습니다

  1. ImageLoader에게 받은 plain이미지를 view가 받아서 따로 작업하는방법
    • view가 imageloader에게 이미지를 받는데 성공하면 이미지 위에 워터마크를 그려줌
    • view가 imageloader에게 이미지를 받는데 실패하면 placeholder이미지를 넣어줌
  2. ImageLoader 내부에서 placeholder를 넣거나 워터마크를 넣어주는 case도 처리할 수 있게끔 imageloader의 책임을 추가

다만 여기서 1번 방법을 선택한다는것은 객체의 역할을 최소로두고 나머지 객체에게 책임을 넘긴다는건데 imageloader가 맡은 책임이 너무 좁아서 큰 영향력을 발휘하기 어려워집니다. 그렇다고 워터마크를 만드는 객체나 placeholder를 만드는 객체를 만들어 객체간의 상호작용을 바라기에는 객체의 수가 너무 많아지고 복잡도가 증가해 유지보수가 더 어려워질 수 있습니다

2번 방법을 통해서 imageloader라는 객체가 더 큰 범위의 책임과 역할을 맡게끔해야하는데 기존코드에서 어떤뷰는 워터마크만 어떤뷰는 실패시placeholder만 어떤뷰는 워터마크와 placeholder둘다요구한다면 아래와같은 분기처리를 위해 paramter를 따로 받는 방법을 사용해야할수도있습니다

기본 lmageloader에서 두가지 분기처리를 해보면 아래와같이 코드를 작성할 수 있을겁니다

class ImageLoader {
    ...생략...
    func loadImage(from url: URL, 
				   hasWatermark: Bool,
				   hasPlaceholder: Bool
                   then handler: @escaping Handler) {
        let request = Request(url: url, method: .get)

        networking.perform(request) { result in
            switch result {
            case .success(let data):
                guard let image = UIImage(data: data) else {
                    handler(.failure(ImageError.invalidData))
                    return
                }
				if hasWatermark {
					handler(.success(워터마크추가한이미지))
					return
				}
                handler(.success(image))
            case .failure(let error):
		        if hasPlaceholder {
			        handler(.success(플레이스홀더이미지))
		        }
                handler(.failure(error))
            }
        }
    }
}

단순히 두개의 case만 추가가 되어서 그렇게까지 불편해보이진 않을 수 있지만 계속 기능이 추가된다면 분기가 계속해서 늘어나고 코드를 읽기도 어렵고 보기도 어렵고 당연히 유지보수하기도 어려워질겁니다

ImageLoader가 하나의 프로그램이라고 가정해보겠습니다. 만약에 각각의 view가 imageloader객체를 들고있는데 그 imageloader에 필요한 plugin이 설치된 imageloader라고 상상해보면 어떨까요?

각View들이 imageloader에게 구체적인 명령을 하지 않아도 필요한 요소가 설치된 imageloader에게 이미지달라는 요청만 해도 원하는 이미지를 받아올 수 있습니다

A뷰입장에서는 placeholder관련 plugin이 설치된 Imageloader에게 이미지를 요청했으니 받은 이미지는 실패시 placeholder를 받을 수 있을거고

B뷰입장에서는 watermark관련 plugin이 설치된 imageloader에게 이미지를 요청했으니 받은 이미지는 워터마크가 그려진 이미지를 받을 수 있을겁니다

plugin을 통해 imageloader는 기본적인 이미지로드 라는 기능 이외에 다양하고 많은 확장을 할 수있고 imageloader를 사용하는 쪽에서는 구체적인 명령을 할 필요도 없어집니다

즉, 자기의 입맛대로 원하는 결과물을 만들어낼 수 있습니다(vscode에서 다양한 플러그인을 가지고 본인에게 맞는 개발환경을 세팅할수있는것처럼 말이죠)

플러그인 적용하기

💡플러그인 패턴을 쓰면 어떤점이 좋은지에대한건 알겠는데 대체 어떻게 쓰는걸까?

우선 plugin이란걸 정의해보겠습니다

위 예시에서 플러그인이란 받은 image를 워터마크를 추가하던 placeholder이미지로 반환하던 결국 이미지를 받아서 이미지를 반환하는 역할을 수행해줍니다. 간단하게 제네릭을 첨가해보면 아래와같은 typealias로 만들 수 있습니다

typealias Plugin<T> = (T) -> T

만약 이미지에 watermark를 추가할 수있는 메서드와 placeholder이미지를 반환하는 메서드가 아래와같이 extension으로 선언되었다고 가정해보겠습니다

extension UIImage {
    static func makePlaceholder(size: CGSize = CGSize(width: 100, height: 100), backgroundColor: UIColor = .lightGray, text: String = "?") -> UIImage {
        let renderer = UIGraphicsImageRenderer(size: size)
        return renderer.image { context in
            ...생략...
        }
    }
}

extension UIImage {
    static func makeWatermark(_ image: UIImage) -> UIImage {
		    ...생략...
        return placeholderImage
    }
}

그렇다면 ImageLoader가 사용할 수 있는 플러그인은 두개의 플러그인일테니까 아래와같이 묶어서 선언할 수 있습니다

enum ImageLoaderPlugin {
    static var addWaterMark: Plugin<Result<UIImage, Error>> = { result in
        switch result {
        case .success(let image):
            return .success(.makeWatermark(image))
        case .failure(let error):
            return .failure(error)
        }
    }
    
    static var addPlaceHolder: Plugin<Result<UIImage, Error>> = { result in
        switch result {
        case .success:
            return result
        case .failure:
            return .success(.makePlaceholder())
        }
    }
}

이렇게 ImageLoader에 설치할 Plugin들을 선언했습니다

그러면 이제는 ImageLoader에 Plugin을 설치해야하는 코드가 필요한데 해당동작을 ImageLoader가 필요한 plugin을 가지고 있다가 image를 실제로 load할때 설치된 plugin을 사용한다라고 생각하면 ImageLoader가 플러그인들을 가지고 있어야하기때문에 Plugin을 담을 객체와 그 객체에 플러그인을 담고 차례로 설치된(=저장된)플러그인을 실행시킬 수있는 객체를 선언해주면 됩니다

이때 a플러그인 b플러그인 두개가 설치되어있다면 a플러그인을 적용하고 나온 결과에 b플러그인을 적용해야하는 프로세스이기때문에 reduce를 사용해서 변경된 결과의 객체를 계속 넘겨주면 됩니다

따라서 플러그인을 관리하는 객체를 아래같이 선언할 수 있습니다

struct PluginCollection<Value> {
    private var plugins = [Plugin<Value>]()

    mutating func add(_ plugin: @escaping Plugin<Value>) {
        plugins.append(plugin)
    }

    func apply(to value: Value) -> Value {
        plugins.reduce(value) { value, plugin in
            plugin(value)
        }
    }
}

이렇게 만든 플러그인저장객체를 imageloader가 가지고 있고 이미지로더의 메서드를 통해 원하는 플러그인을 계속 플러그인저장객체에 넣어주면 image를 load하는 메서드 내부에서 apply메서드를 통해 원하는 이미지를 만들어서 반환해주기때문에 사용하는 입장에서도 코드구현부에서도 복잡한 분기처리를 알고있지도 직접 구현할필요도 없어집니다

최종적으로 imageloader는 아래와같은 구조를 가지게됩니다

final class ImageLoader {

    private var postProcessingPlugins = PluginCollection<Result<UIImage, Error>>()
    
    typealias Handler = (Result<UIImage, Error>) -> Void

    private let networking: Networking

    init(networking: Networking) {
        self.networking = networking
    }
    
    func addPlugin(_ plugin: @escaping Plugin<Result<UIImage, Error>>) {
        postProcessingPlugins.add(plugin)
    }

    func loadImage(from url: URL, then handler: @escaping Handler) {
        
        let request = Request(url: url, method: .get)

        let handler: Handler = { [postProcessingPlugins] result in
            handler(postProcessingPlugins.apply(to: result))
        }

        networking.perform(request) { result in
            switch result {
            case .success(let data):
                guard let image = UIImage(data: data) else {
                    handler(.failure(ImageError.invalidData))
                    return
                }
                handler(.success(image))
            case .failure(let error):
                handler(.failure(error))
            }
        }
    }
}

사용하는 뷰입장에선 내가 지금 어떤 플러그인이 필요한지를 판단해서 addPlugin을 통해 필요한 플러그인을 설치해주고 loadImage를 실행해주면 설치된 플러그인에 따른 결과 이미지를 반환받기만 하면됩니다

워터마크만필요한 뷰는 워터마크플러그인만 addPlugin해주면되고 플레이스홀더만 필요한 뷰는 플레이스홀더플러그인만 addPlugin해주면됩니다. 둘다필요하면 둘다 넣어주면되겠죠

struct ContentView: View {
    let imageLoader = ImageLoader(networking: URLSessionNetworking())
    @State var mainImage: UIImage? = nil

    var body: some View {
        ...생략...
        .onAppear {
            Task {
                let imageURL = URL(string: "https://picsu")!
                /// 필요한 플러그인 설치
                imageLoader.addPlugin(ImageLoaderPlugin.addPlaceHolder)
                imageLoader.addPlugin(ImageLoaderPlugin.addWaterMark)
                
                /// 이미지 반환
                /// 설치된 플러그인에따라 반환되는 이미지가 달라짐
                imageLoader.loadImage(from: imageURL) { result in
                    switch result {
                    case .success(let image):
                        DispatchQueue.main.async {
                            self.mainImage = image
                        }
                    case .failure(let error):
                        print("이미지 로드 실패: \(error)")
                    }
                }
            }
        }
    }
}

큰 흐름에 따른 코드만 봤기때문에 상세한 동작원리에 대해서는 헷갈릴 수 있을거같아서 설치된 플러그인이 어떻게 동작하는지를 알기위해 loadImage메서드의 동작을 살펴보겠습니다

func loadImage(from url: URL, then handler1: @escaping Handler) {
    
    let request = Request(url: url, method: .get)

    let handler2: Handler = { [postProcessingPlugins] result in
        handler1(postProcessingPlugins.apply(to: result))
    }

    networking.perform(request) { result in
        switch result {
        case .success(let data):
            guard let image = UIImage(data: data) else {
                handler2(.failure(ImageError.invalidData))
                return
            }
            handler2(.success(image))
        case .failure(let error):
            handler2(.failure(error))
        }
    }
}

handler가 두개라서 조금 헷갈릴 수도있을거같아서 handler를 1과 2로 표현해봤습니다

우선 network의 perform의 후행 클로저내에서는 결과를 handler2에 인자로 넣고 실행시킵니다. 그렇게 인자로 들어간 result타입은 apply함수의 파라미터로 전달되고 그 결과가 loadimage를 호출할때 선언했던 후행클로저에 들어갑니다

결론적으로는 네트워킹통신을 통해 받아온 result타입의 결과를 플러그인을 통해 호출부에게 전달될 객체로 reduce를 통해서 변경되고 그렇게 완성된 최종 데이터가 비로소 호출부의 handler의 인자로들어가게됩니다

호출부는 자세한 작동은 플러그인에 위임하고 원하는 이미지가 돌아올것을 기대할 수있습니다. 이미지load관련 관심사의 분리가 가능해지기때문에 유지보수에도 용이해집니다

언제 유용할까?

만약에 기능이 추가되고 제거된다고 가정해보겠습니다

플러그인패턴이 아니라 loadimage메서드 내부에서 인자로 분기처리를 했다면 우선 추가 제거시 파라미터를 제거하고나 추가해야하기때문에 관련 메서드를 사용한 모든 곳에서 파라미터 변경 및 제거가 필요합니다

파라미터를 변경하고나면 imageload부분에 분기로직을 바꿔줘야합니다. 다만 이미 복잡한 코드였다면 분기 코드를 건드리는것만으로 어떤 side-effect가 발생할지 모르는 일이기도 합니다…

플러그인 패턴을 쓴다면 우선 플러그인을 정의해줍니다. 그리고 해당 플러그인이 추가되야할 호출부에가서 addPlugin으로 플러그인을 추가해주면됩니다. 기존 코드를 수정하거나 때로는 제거할 필요가 없어집니다. 특정호출분에서 제거된 기능이있다면 addPlugin을 하나 지워주면 됩니다. side-effect를 걱정할 필요도 영향받는 다른 코드에대한 고려도 하지 않아도 됩니다

말그대로 기존의 코드를 변경하지 않으면서, 기능을 추가할 수 있도록 설계 할 수 있습니다. 제거도 마찬가지겠죠

다만, 객체의 추가적인 생성 및 추가에서의 반복적인 코드등의 문제는 있기때문에 상황에 맞게 해당 방식을 사용할지 말지를 고민하는것도 중요한 포인트라고 생각합니다

플러그인패턴을 통해 확장성있는 코드를 구현해보자하는 블로그의 개발자분들도 아래와같은 마무리멘트를 적어놓으셨습니다(변역 : chatpGPT)

💡 플러그인이 항상 적절한 것은 아니며, 플러그인 자체의 트레이드오프도 있습니다. 플러그인 중심 접근 방식을 사용하는 데에는 한 가지 위험이 있는데, 전체 시스템이 너무 단편화되고 분산되어 특정 문제를 디버깅하기 어렵게 만들거나 시스템 개요를 파악하는 데 더 많은 시간이 소요될 수 있다는 것입니다. 이러한 트레이드오프가 가치가 있는지 여부는 항상 그렇듯이 우리가 구축하려는 시스템의 종류에 따라 달라집니다.


마무리

Let’swift에서 처음 알게된 플러그인 패턴을 처음 알게되었는데요. 당시에는 너무 어렵다,,,라고 생각했다가 어떤 이점이 있을지가 갑자기 궁금해져서 알아보고 정리해봤던것같습니다

솔직한 후기는 객체 설계의 책임 분리와 확장성을 동시에 만족시키는 강력한 도구라는 생각이 들었습니다. 위 예제처럼 ImageLoader와 같은 객체에 플러그인 패턴을 도입하면 새로운 요구사항이 생기더라도 기존 코드를 수정하지 않고 기능을 유연하게 추가하거나 제거할 수 있으니까요. 이는 OCP(Open-Closed Principle, 개방-폐쇄 원칙)를 준수하는 데 매우 유용하다는 느낌이 들었습니다(늘 개발을 하면서 OCP를 많이 생각하는 편이어서 더 그랬던것도 있는것같습니다)

하지만 위 예제가 단순한 예제이기도 하고 어쩌면 플러그인패턴으로 만들수있는 가장 fit한 예제였기 때문에 좋아보일수도있겠다는 느낌이 들었습니다. 플러그인 패턴도 만능은 아닐겁니다. 플러그인의 추가적인 관리 비용, 시스템의 단편화 가능성, 디버깅의 복잡도 증가와 같은 트레이드오프가 존재할겁니다. 모든 디자인패턴이 그렇듯이 말이죠 ㅎㅎ…

최근에 느끼는거지만 좋다라는 기준은 주관적이고 결국 중요한 것은 유연성과 복잡성 사이의 균형을 찾는 것입니다. 어떤걸 주고 어떤걸 얻을것이냐의 적정선을 정하는부분이 어려운거겠죠

다만 고민하는 과정이 중요하다고 생각합니다. 그렇게 생각의 깊이를 만들어가면 언젠간 도움이 되지않을까요?ㅎㅎ

오늘은 여기서 마무리해보겠습니다!

그럼 20000!

profile
AppleDeveloperAcademy@POSTECH 1기 수료, SOPT 32기 iOS파트 수료

0개의 댓글