Swift

[Swift] 추상화

pearhyunjin 2024. 9. 15. 16:33

 

추상화(Abstraction)는 객체 지향 프로그래밍의 중요한 개념 중 하나이다. 구체적인 구현 세부 사항은 숨기고, 사용자에게는 필요한 기능만을 제공하는 방식으로 설계되어 시스템의 복잡성을 줄이고 본질에 집중할 수 있게 해준다.

 

목적

추상화는 시스템의 복잡성을 숨겨 단순화시켜 사용자가 이해하기 쉽게 만드는 것이다. 사용자는 내부 구현을 알 필요 없이 상위 개념만을 이용할 수 있다. 즉, 구체적인 기능을 숨기고 상위 계층에서 쉽게 사용할 수 있게 하는 것이다.

추상화를 활용하게되면 아래와 같은 효과를 얻을 수 있다.

  • 코드 재사용성 향상 : 추상화를 통해 공통 로직을 묶어 여러 클래스에서 재사용할 수 있다.
  • 유지보수성 향상 : 추상화된 코드는 개별 구현부와 독립적이므로, 특정 구현부를 수정해도 다른 부분에 영향을 주지 않는다.
  • 유연성 증대 : 추상화된 인터페이스를 이용하면 구체적인 구현을 자유롭게 교체할 수 있어 시스템의 확장과 변경이 용이해진다.
  • 복잡성 감소 : 구체적인 구현 세부 사항을 숨기기 때문에, 상위 계층에서 시스템의 복잡성을 감추고 더 간결하게 유지할 수 있다.

 

추상화 방법

1. 프로토콜 (Protocols)

프로토콜은 객체의 인터페이스를 정의하는 추상적인 개념이다. 상속과 달리 프로토콜은 클래스, 구조체, 열거형에서 모두 채택 가능하며 프로토콜을 사용하면 객체들이 반드시 구현해야 할 메서드나 속성을 정의할 수 있다. 이때, 구체적인 구현은 각 클래스나 구조체에서 따로 진행된다. 예를 들어, Shape라는 프로토콜을 정의하여 모든 도형들이 공통적으로 가져야 할 기능을 강제할 수 있다.

 

protocol Shape {
    var area: Double { get }
    func draw()
}

 

위의 프로토콜을 이용하여 다양한 도형 클래스들이 Shape 프로토콜을 채택하고, 각각의 도형에 맞는 구체적인 구현을 할 수 있다.

class Circle: Shape {
    var radius: Double
    
    var area: Double {
        return Double.pi * radius * radius
    }
    
    init(radius: Double) {
        self.radius = radius
    }
    
    func draw() {
        print("Drawing a circle with radius \(radius)")
    }
}

class Rectangle: Shape {
    var width: Double
    var height: Double
    
    var area: Double {
        return width * height
    }
    
    init(width: Double, height: Double) {
        self.width = width
        self.height = height
    }
    
    func draw() {
        print("Drawing a rectangle with width \(width) and height \(height)")
    }
}
 

위 예제에서 Shape 프로토콜을 사용하여 Circle과 Rectangle 클래스가 각각 자신만의 draw() 메서드를 구현하고, 면적을 계산하는 area 속성도 각 도형에 맞게 구현되었다.

 

+)

  • 유연한 추상화
    프로토콜은 구현을 강제하지 않고, 객체가 어떤 기능을 가져야 하는지에만 집중한다. 따라서 여러 객체들이 서로 다른 구현을 갖더라도 동일한 프로토콜을 따름으로써 하나의 인터페이스를 공유할 수 있다. 이를 통해 다형성을 극대화하고, 유연한 설계를 할 수 있다.
  • 역할 기반 설계
    프로토콜은 "어떤 역할을 수행해야 하는가"에 집중하게 만들어 준다. 예를 들어, Drawable 프로토콜을 통해 도형을 그릴 수 있는 객체를 추상화하면, Rectangle이든 Photo든, 이 프로토콜을 채택하는 모든 객체가 draw() 메서드를 구현해야 하는 구조를 만들 수 있다. 이런 방식은 상속보다 구체적인 구현의 강제성이 낮기 때문에 더 가볍고 유연한 추상화가 가능하다.

-)

  • 명시적 구현 요구
    프로토콜은 구체적인 구현을 제공하지 않으므로, 이를 채택하는 모든 객체에서 필요한 메서드와 속성을 구현해야 한다. 이는 특정 메서드의 기본 구현이 필요한 경우 추가적인 코드 작성이 필요하게 만들어, 상속보다 코드 중복이 더 발생할 수 있다.
  • 공통된 기능의 재사용이 어려움
    상속에서는 부모 클래스에서 공통된 기능을 정의하고 이를 재사용할 수 있지만, 프로토콜은 기능의 "선언"만 제공하고 "구현"은 각 타입에서 해야 하기 때문에 동일한 기능을 구현할 때 코드가 중복될 수 있다. 추상화 관점에서 보면, 같은 로직을 여러 곳에서 반복해야 한다는 점이 프로토콜의 단점이 될 수 있다.

 

2. 클래스 상속

추상화는 클래스와 구조체에서도 사용할 수 있다. 클래스는 일반적으로 복잡한 로직을 구현할 때 사용되며, 상속을 통해 부모 클래스의 공통 기능을 자식 클래스에서 재사용하고, 필요시 오버라이딩하여 구체화할 수 있다.

class Animal {
    var name: String
    
    init(name: String) {
        self.name = name
    }
    
    func makeSound() {
        print("\(name) is making a sound")
    }
}

class Dog: Animal {
    override func makeSound() {
        print("\(name) is barking")
    }
}

class Cat: Animal {
    override func makeSound() {
        print("\(name) is meowing")
    }
}
 

위 코드는 공통 부분에 대해 Animal 클래스를 만들고 Dog와 Cat 클래스가 각각 자신만의 소리를 내도록 추상화하고 있다. 이 방식으로 코드의 중복을 줄이고, 필요한 곳에서만 구체적인 행동을 정의할 수 있다.

 

+)

  • 일반화와 코드 재사용
    상속을 사용하면 부모 클래스에서 공통된 기능을 정의해 놓고, 자식 클래스에서 이를 상속받아 사용할 수 있다. 예를 들어, 여러 종류의 도형(Rectangle, Circle)이 있을 때, 공통된 메서드를 부모 클래스 Shape에 정의함으로써 중복 코드를 줄일 수 있다. 이처럼 상속을 통해 여러 클래스에 걸쳐 중복되는 코드를 일반화할 수 있다.
  • 다형성(Polymorphism)
    부모 클래스 타입으로 자식 클래스 객체를 다룰 수 있으므로, 동일한 인터페이스를 통해 다른 타입의 객체를 처리할 수 있다. 예를 들어 Shape 클래스를 상속한 여러 자식 클래스들은 Shape 타입의 배열로 관리할 수 있어, 코드를 더욱 추상화하고 유연하게 만들 수 있다.

-)

  • 구체적인 구현 강제
    상속 구조에서 부모 클래스가 세부 구현을 포함할 경우, 자식 클래스는 그 구현을 반드시 상속받게 된다. 이는 특정 구현을 상속받고 싶지 않을 때 불필요한 종속성이 생기는 문제를 초래할 수 있다. 즉, 상속을 통해 추상화하는 경우에도 자식 클래스가 부모 클래스의 구체적인 메서드에 얽매일 수 있다.
  • 깊은 상속 계층의 복잡성
    상속 계층이 깊어지면 부모 클래스와 자식 클래스 간의 의존도가 커지며, 유지보수나 확장이 어려워질 수 있다. 특히 추상화를 위해 상속을 과하게 사용하면, 시스템의 복잡도가 증가하여 가독성과 확장성 측면에서 문제가 될 수 있다.

 

3. 제네릭 (Generics)

제네릭은 타입에 구애받지 않고 공통 기능을 재사용할 수 있게 해준다.

예를 들어, 여러 타입의 배열에서 공통적으로 사용할 수 있는 함수를 정의할 때 제네릭을 사용할 수 있다.

func swapValues<T>(a: inout T, b: inout T) {
    let temp = a
    a = b
    b = temp
}

// 아래처럼 타입 상관없이 사용 가능
var num1 = 10
var num2 = 20
swapValues(a: &num1, b: &num2)
print("num1: \(num1), num2: \(num2)")  // num1: 20, num2: 10

var str1 = "Hello"
var str2 = "World"
swapValues(a: &str1, b: &str2)
print("str1: \(str1), str2: \(str2)")  // str1: World, str2: Hello
 

위의 swapValues 함수는 어떤 타입이든 상관없이 두 값을 교환할 수 있다. 이처럼 제네릭은 여러 타입에 대한 공통 로직을 추상화하는 데 유용하다.

 

+)

  • 타입에 독립적인 추상화
    제네릭을 사용하면 여러 타입에 대해 동작하는 코드를 작성할 수 있다. 이를 통해 특정 타입에 구애받지 않고, 다양한 데이터 타입에서 공통으로 작동하는 함수나 클래스를 정의할 수 있다. 예를 들어, 배열 내의 요소를 교환하는 함수를 제네릭으로 구현하면, 이 함수는 Int, String, Double 등 다양한 타입에 대해 사용될 수 있다. 이처럼 제네릭은 타입에 의존하지 않는 추상화를 가능하게 해준다.
  • 코드 중복 최소화
    특정 기능을 여러 타입에 대해 반복해서 구현할 필요가 없어진다. 제네릭을 사용하면 동일한 동작을 여러 타입에서 추상적으로 처리할 수 있어, 코드 재사용성 측면에서 큰 장점을 가진다.

-)

  • 타입 제약 복잡성
    제네릭을 사용할 때, 모든 타입에 대해 동작할 수 있도록 제약을 걸어야 할 경우가 많다. 예를 들어, 어떤 제네릭 함수가 특정 프로토콜을 따르는 타입에만 동작하도록 할 때 제약 조건을 추가해야 하는데, 이러한 제약이 많아지면 코드가 복잡해질 수 있다.
  • 런타임 타입 정보 부족
    제네릭은 컴파일 타임에 타입을 결정하기 때문에, 런타임에는 타입 정보를 알 수 없는 경우가 있다. 이는 추상화를 통해 여러 타입을 다룰 때 예상치 못한 문제를 일으킬 수 있고 런타임에 타입에 따라 동작을 달리해야 하는 상황에서는 제네릭의 장점을 활용하기 어려울 수 있다.

 

 

 

Swift에서 추상화는 코드의 복잡성을 줄이고 재사용성과 유연성을 높이는 중요한 설계 기법이다. 프로토콜, 제네릭, 클래스와 같은 다양한 도구를 활용하여 효율적이고 유지보수하기 쉬운 코드를 작성할 수 있다. 각 방식에 대한 이점을 생각해보며 적합한 방식을 적용하면 더 효율적인 추상화가 가능할 것이다. 제너릭을 사용한 추상화는 경험이 없는데, 추가적으로 사용해보고 이점을 활용해봐야 할 것 같다.

 

 


* https://zeddios.tistory.com/1331

* https://fomaios.tistory.com/entry/WWDC-2022-%EC%A0%9C%EB%84%88%EB%A6%AD%EA%B3%BC-%ED%94%84%EB%A1%9C%ED%86%A0%EC%BD%9C%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%B4-%EC%BD%94%EB%93%9C-%EC%B6%94%EC%83%81%ED%99%94%ED%95%98%EA%B8%B0-feat-someany-Embrace-Swift-generics

* https://green1229.tistory.com/429