일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 |
- ribs
- Subscribe
- ios
- KeyPath
- swift 5.9
- Tuist Swift
- ios swiftdata
- xcode
- Firebase Analytics
- SeSAC
- Subject
- swift
- swiftdata
- RxSwift
- realm
- ios database
- Combine
- Tuist
- Firebase
- JSON
- arc
- GCD
- swift 6
- observable
- Swift Tuist
- SwiftUI
- swift db
- 카카오뱅크 ios
- 네트워크 통신
- swift database
- Today
- Total
천원의 개발
Swift는 객체 지향 프로그래밍 본문
안녕하세요. 천원입니다.
이번에 카카오뱅크에서 진행한 '퇴근길 기술 한 잔' 이라는 밋업 행사에 참여했는데요. 확실히 새로운 기술의 학습도 중요하지만 조금 더 근본적인 개념들을 탄탄하게 쌓아야겠다고 생각이 들어서 먼저 Swift 언어의 패러타임들(객체 지향, 명령형, 함수형, 프로토콜)을 복습하고 정리해 보려고 합니다.
객체 지향 프로그래밍
Swift는 객체 지향 언어입니다. 객체 지향 언어는 현실 세계의 객체를 소프트웨어 객체로 설계하여 객체들의 상호작용으로 프로그래밍하는 기법입니다. 간단한 예를 들자면 컴퓨터가 동작하기 위해서는 CPU, 메인보드, SSD, 렘 등 다양한 부품들의 상호작용으로 동작을 하는 것처럼 우리는 프로그램으로 CPU, 메인보드 등등을 구현하고 이들을 상호 작용 시켜 프로그램을 구현하는 겁니다. 이렇게 구현을 했을 때의 장점이라면 CPU가 고장이 났다면 우리는 다른 부품을 교체할 필요 없이 CPU만 교체하면 될 겁니다. 코드로 본다면 각각의 독립적인 역할을 가지고 있으므로 코드의 변경을 최소화하고 유지보수를 하는데 유리할 겁니다.
객체 지향 프로그래밍의 특징 4가지
객체지향에는 딱 4가지의 특징이 있다! 이런 느낌으로 외우지 말고 한번 하나하나 천천히 이해해 보겠습니다. 가장 먼저 추상화입니다.
객체의 공통점인 속성과 기능을 추출하여 정의하는 것입니다. 예시를 들자면 '아이폰'과 '갤럭시'가 있다면 우리는 둘의 공통점을 '스마트폰'으로 뽑을 수 있겠죠. Swift 코드로 한번 작성해 보겠습니다.
protocol SmartPhone {
func kakaoTalk()
func instagram()
}
class iPhone: SmartPhone {
func kakaoTalk() {
print("카카오톡")
}
func instagram() {
print("인스타그램")
}
}
class Galaxy: SmartPhone {
func kakaoTalk() {
print("카카오톡")
}
func instagram() {
print("인스타그램")
}
}
우리는 아이폰과 갤럭시의 공통적인 기능을 추출하여 스마트폰이라는 프로토콜을 만들었습니다. 음.. 작성을 하고 보니까 굳이 코드가 길어지는데 프로토콜을 작성할 필요가 있을까 하는 의문이 안 드시나요? 그러면 프로토콜로 추상화 했을 때의 이점을 한번 생각해보겠습니다.
먼저 인터페이스의 통일입니다. 우리가 혹시 추가로 다른 스마트폰을 객체로 만들 상황에서 SmartPhone 프로토콜을 채택하게 되면 손쉽게 통일된 인터페이스를 구현할 수 있겠죠. 두 번째는 타입으로써의 활용입니다. Swift의 큰 특징인 프로토콜을 타입으로 사용이 가능하니까 우리가 phone이라는 변수에 SmartPhone 타입을 선언해두면 iPhone과 Galxy 어느 쪽의 인스턴스도 넣을 수 있겠죠. 다음으로는 유지보수성의 편의입니다. 만약 인스타그램이 함수의 명이 메타로 변경이 된다면 우리는 프로토콜 내부의 함수명을 수정하기만 하면 되겠죠.
여기까지 추상화의 개념에 대해 설명을 해봤는데 우리는 프로토콜이라는 강력한 도구를 사용해서 추상화를 구현했으니 왜 Swift가 프로토콜 지향 프로그래밍으로 불리는지 감이 잡히는 느낌입니다.
Swift --> 객체 지향 --> 추상화 --> 프로토콜 --> 프로토콜 지향 프로그래밍!
다음은 상속입니다. 기존의 클래스를 재활용해서 새로운 클래스를 작성하는 것입니다. iOS 개발자라면 우리는 상당히 익숙한 개념이져 우리는 애플에서 만들어준 UiKit 같은 프레임워크 들이 모두 클래스로 작성되어있고 UIViewContoller를 상속받아서 사용하고 있죠.
class SmartPhone {
func kakaoTalk() {
print("카카오톡")
}
func instagram() {
print("인스타그램")
}
}
class iPhone: SmartPhone {
override func kakaoTalk() {
print("카카오톡")
}
override func instagram() {
print("인스타그램")
}
}
class Galaxy: SmartPhone {
override func kakaoTalk() {
print("카카오톡")
}
override func instagram() {
print("인스타그램")
}
}
이렇게 코드로 보면 추상화 코드랑 거의 동일하죠. 코드는 동일하지만 개념적인 측면에 차이가 있는데요. 추상화는 기존에 작성된 클래스들의 공통점을 추출해서 동일한 인터페이스를 작성해서 위에 정리한 추상화에 장점을 얻는 개념이고, 상속은 상위 클래스의 상속을 통해서 반복적인 코드를 줄일 수 있는 장점을 가지고 있죠.
다음은 다형성입니다. 문자 그대로 같은 슈퍼클래스를 상속한 아이폰이랑 갤럭시는 동일한 클래스는 아니라는 겁니다. 동일한 kakaoTalk 함수를 가졌어도 우리는 override 키워드를 통해서 함수의 재정의가 가능하니까요.
마지막으로 캡슐화입니다. 캡슐화는 하나의 클래스를 캡슐로 만들어서 데이터를 외부로부터 보호하는 것을 말합니다. 예제부터 한번 작성하겠습니다.
class Car {
private var fuelLevel: Int = 100 // 내부 상태, 연료 레벨
func startEngine() {
if fuelLevel > 0 {
print("엔진이 시작되었습니다.")
} else {
print("연료가 부족하여 엔진을 시작할 수 없습니다.")
}
}
func refuel(amount: Int) {
if amount > 0 {
fuelLevel += amount
print("\(amount)만큼 연료가 보충되었습니다. 현재 연료 레벨: \(fuelLevel)")
} else {
print("유효하지 않은 연료 양입니다.")
}
}
}
예제 코드를 보시면 우리는 접근 제어 자를 활용하여 클래스 내부의 캡슐화를 적용한 모습입니다. 덕분에 fuelLevel에 직접 접근이 불가능하고 클래스 내부에 작성된 refuel 함수를 통해서 간접적으로 변경을 하게 될겁니다. 그렇다면 이렇게 캡슐화를 구성했을 때는 어떤 장점이 있을까요? 먼저 외부에서 접근이 불가능하니 fuelLevel 프로퍼티의 무결성을 유지할 수 있겠죠. 다음으로 모듈성이 있겠는데요. 만약 우리가 외부에서도 fuelLevel를 접근이 가능하도록 했다면 fuelLevel 변수명이 변경되었을 때 사용한 모든 부분을 찾아서 수정을 해주어야겠죠. 그러나 지금 같이 캡슐화를 적용하게 되면 모듈 내부의 fuelLevel 코드만 수정하게 된다면 간단히 변경이 가능할 겁니다.
객체 지향 프로그래밍의 원칙 (SOLID)
앞에서 우리는 객체 지향 프로그래밍의 특징에 대해서 알아봤는데 이번에는 객체 지향 설계를 위해 지켜야할 원칙에 대해서 학습해 보겠습니다. 이번에도 SOLID!!! 하면서 딱딱하게 외우지 말고 하나하나 이해해보면서 진행하겠습니다. 먼저 단일 책임 원칙입니다. 말 그대로 하나의 클래스는 하나의 책임만 가져야 한다는 뜻인데요. 예제를 작성해 보겠습니다.
class BankAccount {
private var balance: Double = 0
func createAccount(initialBalance: Double) {
// 계좌 생성 로직
}
func deposit(amount: Double) {
// 입금 로직
}
func withdraw(amount: Double) {
// 출금 로직
}
func getBalance() -> Double {
// 잔액 조회 로직
return balance
}
}
위에 코드는 '계좌관리'라는 하나의 책임을 가지고 동작을 하는 코드입니다. 만약 여기서 월간 실적을 출력하는 메서드가 추가가 된다면 단일 책임 원칙을 위반하는 경우가 되겠죠.
다음으로 개방 폐쇄의 원칙입니다. 모듈은 확장은 자유롭게 가능하지만 변경에는 닫혀있어야 한다는 뜻으로 이번에도 이해하기 쉬운 코드와 함께 보겠습니다.
enum Animal {
case dog
case cat
case chicken
var noise: String {
switch self {
case .dog: return "멍멍"
case .cat: return "야옹"
case .chicken: return "꼬끼오"
}
}
var legs: Int {
switch self {
case .dog: return 4
case .cat: return 4
case .chicken: return 2
}
}
}
위에 코드를 보시면 enum을 활용해서 동물들을 정리한 모습입니다. 이러한 코드에서 우리가 추가로 cow를 추가하고 싶으면 어떻게 해야할까요? case에 cow를 추가해주고 각각의 noise와 legs 프로퍼티에 case또한 추가를 해줘야 하겠죠. 여기서 우리가 앞서 정리한 모듈의 코드가 변경됨으로 개방 폐쇄 원칙에 위배되는 코드입니다. 그렇다면 위에 코드를 프로토콜을 활용하여 개방 폐쇄 원칙에 위반하지 않도록 수정해 보겠습니다.
protocol AnimalType {
var noise: String { get }
var legs: Int { get }
}
struct Dog: AnimalType {
var noise: String = "멍멍"
var legs: Int = 4
}
struct Cat: AnimalType {
var noise: String = "야옹"
var legs: Int = 4
}
struct Chicken: AnimalType {
var noise: String = "꼬끼오"
var legs: Int = 2
}
위와 같이 구현을 하게 된다면 우리는 cow를 추가할 때 기존의 코드를 수정하는게 아니라 새로운 코드로 확장을 할 수 있겠죠. 오호 그렇다면 추상화를 달성하기 위해 우리는 프로토콜을 사용한 것처럼 이번에는 개방 폐쇄 원칙을 준수하기 위해서 프로토콜을 사용했네요. 점점 Swift에서 프로토콜이 왜 중요한지 알 것 같은 느낌입니다.
그러면 enum은 언제 사용해야 좋을까요. enum은 유연성을 요구하지 않는 시나리오에서 유용합니다. 예를 들자면 일주일이라는 enum 이 있다면 월, 화, 수, 목, 금, 토, 일 이렇게 구현할 수 있겠죠.
다음으로 리스코프 치환 원칙 입니다. 상위 타입의 객체를 하위 타입 객체로 변경해도 정상적으로 동작해야 한다는 법칙입니다. 간단한 예를 들자면
class Bird {
func fly() {
print("난다")
}
}
class Swallow: Bird {
}
class Chicken: Bird {
override func fly {
print("못난다")
}
}
Swallow.fly()에서 Swallow를 Bird로 변경해도 정상적으로 코드는 동작하지만 반대로 Chicken을 Bird로 변경하면 정상적으로 동작하지 못하겠죠. 엥.. 그렇다면 상속받고 override 한다면 리스코프 치환 원칙을 준수하지 못하는 걸까요? 아닙니다. 지금 예제를 든 코드는 상속 자체를 잘못한 예제인데요. 우리는 새는 난다는 특성을 추상화하여 Bird 클래스를 작성했는데 Chicken은 날지 못하죠 그러니 Bird를 상속 받은 자체가 잘못인겁니다. 그렇다면 올바른 상속관계를 가지고 override 하는 코드를 작성해 보겠습니다.
class Animal {
func fly() {
print("날거나 못나면 정상적인 행동입니다.")
}
}
class Bird: Animal {
override func fly() {
print("난다")
}
}
class Swallow: Bird {
}
class Chicken: Animal {
override func fly {
print("못난다")
}
}
Animal 이라는 상위의 클래스를 통해서 fly 함수를 작성해 리스코프 치환 원칙을 준수한 모습입니다.
다음으로는 인터페이스 분리의 원칙 입니다. 특정 class에 인터페이스를 구현할 때 사용하지 않는 메서드가 존재하는 인터페이스 의존시키면 안됩니다. 간단하게 말하자면 Bird Protocol을 채택한 Dog Class가 있다고 가정했을 때 Brid Protocol 내부에 fly() 함수를 채택은 했으니까 Dog Class에 구현은 하겠지만 실제로 사용할 일은 없겠죠. 이럴 때 인터페이스 분리의 원칙이 지켜지지 않은 겁니다.
마지막으로 객체 지향의 꽃이라고 불리는 의존성 역전의 원칙 입니다. 객체 지향의 큰 장점은 의존성을 어디에든 역전 시킬 수 있다는 점입니다. 자자 그렇다면 우리는 어디에 의존성을 두는게 좋을까요. 바로 변경의 적은 곳일 겁니다. 상위 클래스, 프로토콜에 의존성을 두게 된다면 좋겠죠. 코드와 함께 보겠습니다.
class TemperatureSensor {
func readTemperature() -> Double {
return 25.0
}
}
class TemperatureDisplay {
let sensor = TemperatureSensor()
func displayTemperature() {
let temperature = sensor.readTemperature()
print("현재 온도: \(temperature)도")
}
}
우리는 TemperatureSensor 를 직접적으로 Display에서 의존해서 코드를 작성 했습니다. 이는 변경에 취약하고 모듈간의 결합도를 높일 수 있기 때문에 우리는 프로토콜을 사용해서 의존성을 역전 시켜 보겠습니다.
protocol Sensor {
func read() -> Double
}
class TemperatureSensor: Sensor {
func read() -> Double {
return 25.0
}
}
class TemperatureDisplay {
let sensor: Sensor
init(sensor: Sensor) {
self.sensor = sensor
}
func displayTemperature() {
let temperature = sensor.read()
print("현재 온도: \(temperature)도")
}
}
TemperatureDiplay에서 직접 TeaperatureSensor를 의존시키는 게 아니라 Sensor를 의존시킴으로써 모듈간의 결합도를 떨어뜨리고 코드 수정에 용이한 코드가 완성되었네요.
여기까지 객체 지향 프로그래밍에 대하여 정리해 보았는데요. Swift가 왜 프로토콜 지향 프로그래밍인지 깨닫게 되는 시간이었습니다. 이제 코드를 작성 시에 프로토콜을 활용해서 결합도를 줄이는 연습을 해보아야겠습니다.
'컴퓨터과학' 카테고리의 다른 글
TDD(Test-Driven-Development) 와 BDD(Behavior-Driven-Development) (0) | 2023.10.06 |
---|