효율적인 코드 작성을 위한 리팩터링 기법
들어가기 전에
여러분, 코드 작성하면서 “이렇게 하면 유지보수가 쉬울까?” 고민해본 적 있으시죠?
"파이브 라인즈 오브 코드"의 4장은 이런 고민을 해결할 수 있는 팁들을 가득 담고 있어요.
제가 경험했던 코드 문제들과 함께 책에서 제시한 해결책을 나눠볼게요.
4.1 간단한 if 문 리팩터링
if 문에서 else를 사용하지 말 것
여러분도 if-else
문을 작성하다가 코드가 너무 중첩되어서 복잡해진 경험 있으신가요?
저도 그런 경험이 많아요. 예를 들어 나이 검사를 할 때:
if age >= 18 {
print("Welcome!")
} else {
print("You are not allowed.")
}
이런 코드가 많아지면 가독성이 떨어지고 버그가 발생할 가능성이 높아집니다.
이때, 책에서는 guard
문을 사용하는 방법을 추천합니다.
func checkAge(_ age: Int) {
guard age >= 18 else {
print("You are not allowed.")
return
}
print("Welcome!")
}
이렇게 하면, 코드가 훨씬 깔끔해지고, 실패 조건을 먼저 처리하므로 성공 케이스에 더 집중할 수 있습니다.
규칙 적용
사용자 입력을 검증할 때, 유효하지 않은 경우를 먼저 처리하면 코드가 더 명확해집니다.
func validateUserInput(input: String?) {
guard let input = input, !input.isEmpty else {
print("Invalid input.")
return
}
print("Valid input: \(input)")
}
이렇게 하면 실패 조건을 먼저 처리하여, 주 로직의 가독성을 높일 수 있습니다.
리팩터링 패턴: 클래스로 타입 코드 대체
enum
을 사용해서 타입을 정의하다 보면 확장성이 떨어져서 불편했던 적이 있나요?
예를 들어, AnimalType
을 enum
으로 정의하고 각 타입별로 다른 동작을 구현할 때 말이죠.
enum AnimalType {
case dog
case cat
case bird
}
class Animal {
var type: AnimalType
init(type: AnimalType) {
self.type = type
}
func makeSound() {
switch type {
case .dog:
print("Bark")
case .cat:
print("Meow")
case .bird:
print("Chirp")
}
}
}
let myDog = Animal(type: .dog)
myDog.makeSound() // 출력: Bark
이 코드를 클래스로 바꿔보면 훨씬 유연해집니다.
protocol SoundBehavior {
func makeSound()
}
class BarkSound: SoundBehavior {
func makeSound() {
print("Bark")
}
}
class MeowSound: SoundBehavior {
func makeSound() {
print("Meow")
}
}
class ChirpSound: SoundBehavior {
func makeSound() {
print("Chirp")
}
}
class Animal {
private var soundBehavior: SoundBehavior
init(soundBehavior: SoundBehavior) {
self.soundBehavior = soundBehavior
}
func performSound() {
soundBehavior.makeSound()
}
}
let dog = Animal(soundBehavior: BarkSound())
dog.performSound() // 출력: Bark
이제 Animal
클래스는 더 유연해졌고, 새로운 동물을 추가하기도 쉬워졌죠.
새로운 동물 소리를 추가하려면 SoundBehavior
를 구현하는 새로운 클래스를 만들기만 하면 됩니다.
4.2 긴 if 문의 리팩터링
일반성 제거
긴 if 문을 작성하다 보면 코드가 너무 복잡해져서 가독성이 떨어지는 경우가 많죠?
예를 들어, 주문 타입에 따라 다른 처리를 해야 할 때:
func processOrder(type: String) {
if type == "Online" {
print("Processing online order")
} else if type == "InStore" {
print("Processing in-store order")
} else {
print("Unknown order type")
}
}
이런 코드를 리팩터링하는 방법을 소개합니다.
메서드 전문화
func processOnlineOrder() {
print("Processing online order")
}
func processInStoreOrder() {
print("Processing in-store order")
}
func processOrder(type: String) {
switch type {
case "Online":
processOnlineOrder()
case "InStore":
processInStoreOrder()
default:
print("Unknown order type")
}
}
이렇게 하면 각 메서드가 독립적으로 동작하여 가독성과 유지보수성이 높아집니다.
switch를 사용하지 말 것
switch
문 대신 다형성을 사용하면 조건문을 더 깔끔하게 대체할 수 있습니다.
protocol Order {
func process()
}
class OnlineOrder: Order {
func process() {
print("Processing online order")
}
}
class InStoreOrder: Order {
func process() {
print("Processing in-store order")
}
}
let orders: [Order] = [OnlineOrder(), InStoreOrder()]
orders.forEach { $0.process() }
이렇게 하면 새로운 주문 타입을 추가할 때
기존 코드를 수정할 필요 없이 간단하게 클래스를 추가할 수 있습니다.
4.3 코드 중복 처리
인터페이스와 추상 클래스: 그 차이점과 사용법
여러분, 코드 작성하면서 인터페이스와 추상 클래스 중 어떤 것을 사용해야 할지 고민해본 적 있으신가요?
4.3.1 인터페이스 대신 추상 클래스를 사용할 수는 없을까?
인터페이스와 추상 클래스의 차이점
인터페이스와 추상 클래스는 객체 지향 프로그래밍에서 중요한 개념이죠.
두 개념 모두 객체들이 공통으로 따라야 하는 규약을 정의하는 데 사용되지만,
각기 다른 특징과 장단점을 가지고 있습니다.
인터페이스
장점:
다중 상속:
Swift에서는 한 클래스가 여러 인터페이스(프로토콜)를 구현할 수 있습니다.
이는 클래스가 다양한 동작을 혼합하여 사용할 수 있게 해줍니다.
구현 강제:
인터페이스는 메서드의 구현을 강제합니다.
이는 모든 구현 클래스가 동일한 메서드를 가지게 하여 일관성을 유지하게 합니다.
유연성:
인터페이스는 특정한 구현을 강제하지 않기 때문에 더 유연한 설계를 가능하게 합니다.
단점:
코드 중복:
인터페이스는 구현을 포함하지 않기 때문에,
공통 동작을 여러 클래스에서 반복해서 구현해야 할 수 있습니다.
추상 클래스
장점:
공통 구현 제공:
추상 클래스는 공통 동작을 미리 구현할 수 있기 때문에,
이를 상속받는 클래스는 공통 동작을 재사용할 수 있습니다.
코드 중복 최소화:
공통 동작을 추상 클래스에 구현해 두면,
이를 상속받는 클래스는 중복 코드를 작성하지 않아도 됩니다.
단점:
단일 상속 제한:
Swift에서는 클래스가 하나의 부모 클래스만을 상속받을 수 있기 때문에,
추상 클래스를 사용하면 다중 상속의 유연성을 잃게 됩니다.
유연성 부족:
특정 구현이 포함되어 있어, 인터페이스만큼 유연하지 않을 수 있습니다.
코드 스멜:
추상 클래스의 메서드가 오버라이드되지 않아도 컴파일러가 에러를 발생시키지 않기 때문에,
의도하지 않은 동작을 초래할 수 있는 코드 스멜을 유발할 수 있습니다.
추상 클래스 예제:
// Swift는 추상 클래스를 명시적으로 지원하지 않지만, 이를 시뮬레이션 할 수 있습니다.
class Vehicle {
func start() {
// 공통 동작
print("Vehicle started")
}
// 추상 메서드처럼 동작하도록 구현을 강제할 수 있습니다.
func drive() {
fatalError("This method must be overridden")
}
}
class Car: Vehicle {
override func drive() {
print("Driving a car")
}
}
class Truck: Vehicle {
override func drive() {
print("Driving a truck")
}
}
let vehicles: [Vehicle] = [Car(), Truck()]
vehicles.forEach {
$0.start()
$0.drive()
}
위 예제에서는 Vehicle
클래스를 추상 클래스로 사용하여 공통 동작을 정의하고,
Car
와 Truck
클래스에서 이를 상속받아 구현했습니다.
4.3.2 규칙: 인터페이스에서만 상속받을 것
인터페이스를 사용하면 코드의 유연성과 확장성을 극대화할 수 있습니다.
이를 통해 다양한 구현 클래스가 같은 인터페이스를 공유하면서도 각자의 독립적인 동작을 유지할 수 있습니다.
인터페이스(프로토콜) 예제:
protocol Drivable {
func drive()
}
class Car: Drivable {
func drive() {
print("Driving a car")
}
}
class Truck: Drivable {
func drive() {
print("Driving a truck")
}
}
let vehicles: [Drivable] = [Car(), Truck()]
vehicles.forEach { $0.drive() }
위 예제에서는 Drivable
프로토콜을 사용하여 Car
와 Truck
클래스에서 각각의 동작을 정의했습니다.
이를 통해 코드의 유연성과 확장성을 높였습니다.
4.4 복잡한 if 체인 구문 리팩터링
여러분, 코드 작성하면서 “if-else가 점점 많아지면 나중에 어떻게 유지보수하지?”라는 고민 해보신 적 있나요?
저도 그랬어요. 기본적인 if-else
체인을 사용한 결제 처리 코드를 한 번 볼까요?
기본 코드
class ShoppingCart {
var paymentType: String?
func checkout(amount: Double) {
if paymentType == "CreditCard" {
print("Paid \(amount) using Credit Card")
} else if paymentType == "PayPal" {
print("Paid \(amount) using PayPal")
} else {
print("Unknown payment type")
}
}
}
let cart = ShoppingCart()
cart.paymentType = "CreditCard"
cart.checkout(amount: 100.0)
cart.paymentType = "PayPal"
cart.checkout(amount: 200.0)
이런 식으로 코드를 작성하다 보면,
나중에 결제 방식이 추가되거나 변경될 때마다 if-else
문을 수정해야 하죠.
그럼 지금부터 이 복잡한 if
체인을 전략 패턴으로 리팩터링해볼까요?
전략 패턴 사용
protocol PaymentStrategy {
func pay(amount: Double)
}
class CreditCardPayment: PaymentStrategy {
func pay(amount: Double) {
print("Paid \(amount) using Credit Card")
}
}
class PayPalPayment: PaymentStrategy {
func pay(amount: Double) {
print("Paid \(amount) using PayPal")
}
}
class ShoppingCart {
var paymentStrategy: PaymentStrategy?
func checkout(amount: Double) {
paymentStrategy?.pay(amount: amount)
}
}
let cart = ShoppingCart()
cart.paymentStrategy = CreditCardPayment()
cart.checkout(amount: 100.0)
cart.paymentStrategy = PayPalPayment()
cart.checkout(amount: 200.0)
이제 새로운 결제 방식을 추가할 때는 기존 코드를 수정할 필요가 없어요.
전략 패턴 덕분에 결제 방식이 추가되거나 변경되더라도 간단하게 새로운 클래스를 추가하고
PaymentStrategy
를 구현하기만 하면 됩니다.
이렇게 하면 코드 유지보수도 훨씬 수월해지고, 코드의 유연성도 극대화됩니다.
마무리
"파이브 라인즈 오브 코드" 4장을 통해 여러분도 타입 코드를 어떻게 더 깔끔하게 만들 수 있는지 배웠을 거예요.
코드의 가독성과 유지보수성을 높이는 다양한 리팩터링 기법을 실제 프로젝트에 적용해보세요.