← Back to list

[CS] Swift로 다시 보는 OOP와 SOLID (2)

안녕하세요! 이번 글에서는 지난 글에 이어서 SOLID에 대해 정리해보려고 합니다.

Seungjin Lee · 2026-06-17 08:56 · 0 claps · 10.3 min read
#swift #solid #ios #객체지향 #css
Open on Medium ↗
Wiki topics: 🌐 · Web Development 📱 · Mobile Development

[CS] Swift로 다시 보는 OOP와 SOLID (2)

안녕하세요! 이번 글에서는 지난 글에 이어서 SOLID에 대해 정리해보려고 합니다.

정리하면서 객체지향에서 중요한 것은 단순히 class를 만들거나 property와 method를 묶는 것이 아니라, 각 객체가 어떤 책임을 가지고 다른 객체와 어떻게 협력하는지 고민하는 것이라고 느꼈습니다.

프로젝트를 하다 보면 처음에는 View 안에서 API 호출, 상태 변경, 화면 이동까지 처리해도 큰 문제는 없지만, 하지만 기능이 많아질수록 하나의 객체가 너무 많은 책임을 가지게 되고, 작은 수정이 여러 곳에 영향을 주는 경우가 생깁니다.

이때 참고할 수 있는 설계 원칙이 SOLID라고 생각합니다.

SOLID란?

SOLID는 객체지향 설계를 더 유지보수하기 쉽게 만들기 위한 5가지 원칙입니다.

  • S: Single Responsibility Principle — 단일 책임 원칙
  • O: Open-Closed Principle — 개방-폐쇄 원칙
  • L: Liskov Substitution Principle — 리스코프 치환 원칙
  • I: Interface Segregation Principle — 인터페이스 분리 원칙
  • D: Dependency Inversion Principle — 의존성 역전 원칙

SRP: 단일 책임 원칙

SRP는 하나의 타입이 하나의 책임만 가져야 한다는 원칙입니다.

여기서 중요한 것은 “하나의 책임"을 단순히 기능 하나로만 보는 것이 아니라, 변경되는 이유가 하나여야 한다는 관점으로 보는 것입니다.

iOS 프로젝트에서 가장 쉽게 볼 수 있는 예시는 View가 너무 많은 일을 하는 경우입니다.

struct PlayerListView: View {
    @State private var players: [Player] = []
    @State private var isLoading = false
    @State private var errorMessage: String?

    var body: some View {
        List(players) { player in
            Text(player.name)
        }
        .task {
            isLoading = true

            do {
                let url = URL(string: "https://api.example.com/players")!
                let (data, _) = try await URLSession.shared.data(from: url)

                // 데이터 파싱
                // 상태 변경
                // 에러 처리
            } catch {
                errorMessage = error.localizedDescription
            }

            isLoading = false
        }
    }
}

위 코드에서는 View가 화면을 그리는 역할뿐만 아니라, 네트워크 요청, 데이터 파싱, 로딩 상태 관리, 에러 처리까지 담당하고 있습니다.

기능이 작은 예제에서는 괜찮아 보일 수 있지만, 기능이 늘어나면 View가 점점 무거워집니다.

이런 경우 View는 화면 표현에 집중하고, 상태 관리와 로직은 ViewModel로 분리할 수 있습니다.

@Observable
final class PlayerListViewModel {
    private(set) var players: [Player] = []
    private(set) var isLoading = false
    private(set) var errorMessage: String?

    func fetchPlayers() async {
        isLoading = true
        defer { isLoading = false }

        do {
            // 플레이어 목록 요청
            // 데이터 파싱
            // 상태 변경
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

View는 ViewModel의 상태를 보고 화면을 그리는 역할에 집중할 수 있습니다.

struct PlayerListView: View {
    @Bindable var viewModel: PlayerListViewModel

    var body: some View {
        List(viewModel.players) { player in
            Text(player.name)
        }
        .task {
            await viewModel.fetchPlayers()
        }
    }
}

이렇게 분리하면 View는 UI 표현이 바뀔 때 주로 수정되고, ViewModel은 화면 상태나 사용자 액션에 따른 흐름이 바뀔 때 수정됩니다.

즉, SRP는 단순히 파일을 많이 나누는 것이 아니라 변경 이유를 기준으로 책임을 나누는 것에 가깝다고 생각합니다.

OCP: 개방-폐쇄 원칙

OCP는 확장에는 열려있고, 수정에는 닫혀 있어야 한다는 원칙입니다.

처음에는 조건문으로 처리하는 것이 가장 빠를 수 있습니다.

func backgroundColor(for team: Team) -> Color {
    switch team {
    case .lg:
        return .red
    case .doosan:
        return .indigo
    case .samsung:
        return .blue
    }
}

팀이 몇 개 없을 때는 문제가 없어보입니다. 하지만 팀이 추가되거나, 색상 뿐만 아니라 배경 이미지, 로고, 폰트까지 한꺼번에 관리를 해야 한다면 이 함수는 계속 수정될 가능성이 높습니다.

이런 경우 테마를 제공하는 책임을 따로 분리할 수 있습니다.

struct TeamTheme {
    let primaryColor: Color
    let backgroundImageName: String
}

protocol TeamThemeProviding {
    func theme(for team: Team) -> TeamTheme
}
struct TeamHomeView: View {
    let theme: TeamTheme

    var body: some View {
        ZStack {
            Color(theme.primaryColor)
            Image(theme.backgroundImageName)
        }
    }
}

View가 팀별 조건을 직접 판단하지 않게 하는 것입니다.

즉, OCP는 모든 조건문을 없애야 한다는 의미가 아니라, 변경 자주 일어나는 부분을 한 곳으로 모으고 사용하는 쪽의 수정하는 범위를 줄이는 것이라고 생각합니다.

LSP: 리스코프 치환 원칙

LSP는 상위 타입을 사용하는 곳에서 하위 타입으로 바뀌어도 프로그램이 문제없이 동작해야 한다는 원칙입니다.

Swift에서는 protocol을 사용할 때 이 원칙을 생각해볼 수 있습니다.

protocol AudioPlayable {
    func play()
    func pause()
}

어떤 타입이 AudioPlayable을 채택한다면, 사용하는 쪽에서는 play()와 pause()가 기대하는 방식으로 동작한다고 생각하고 사용할 수 있어야 합니다.

final class StreamingAudioPlayer: AudioPlayable {
    func play() {
        // 스트리밍 재생
    }

    func pause() {
        // 스트리밍 일시정지
    }
}

final class LocalAudioPlayer: AudioPlayable {
    func play() {
        // 로컬 파일 재생
    }

    func pause() {
        // 로컬 파일 일시정지
    }
}

두 타입 모두 AudioPlayable을 따르기 때문에 ViewModel 입장에서는 같은 방식으로 사용할 수 있습니다.

즉, LSP는 protocol을 채택하는 것뿐만 아니라, 그 타입이 약속한 역할을 수행하는지까지 포함하는 원칙이라고 생각합니다.

ISP: 인터페이스 분리 원칙

ISP는 하나의 큰 인터페이스보다, 필요한 역할별로 작은 인터페이스를 나누는 것이 좋다는 원칙입니다.

protocol MediaService {
    func play()
    func pause()
    func download()
    func upload()
    func share()
}

이렇게 만들면 단순히 오디오 재생만 필요한 객체도 download(), upload(), share() 같은 기능을 모두 알아야 합니다.

이럴 때는 역할 별로 protocol을 나눌 수 있습니다.

protocol AudioPlayable {
    func play()
    func pause()
}

protocol MediaDownloadable {
    func download()
}

protocol MediaShareable {
    func share()
}

이렇게 나누면 오디오 재생만 필요한 객체는 AudioPlayable에만 의존하면 됩니다.

즉, ISP는 필요한 기능만 의존하게 만들어 객체 사이의 결합도를 낮추는 데 도움이 된다고 생각합니다.

DIP: 의존성 역전 원칙

DIP는 상위 모듈이 하위 모듈의 구체 구현에 직접 의존하지 않고, 추상화에 의존해야 한다는 원칙입니다.

처음에는 ViewModel 안에서 필요한 서비스를 직접 생성할 수 있습니다.

final class PlayerViewModel {
    private let audioService = AudioPlaybackService()

    func didTapPlay() {
        audioService.play()
    }
}

이 코드는 단순하지만, ViewModel이 AudioPlaybackService에 강하게 의존합니다. 나중에 오디오 재생 방식을 바꾸거나, 테스트용 객체를 넣고 싶을 때 ViewModel 코드 자체를 수정해야합니다.

이럴 때 protocol을 통해 의존성을 분리할 수 있습니다.

protocol AudioPlayable {
    func play()
    func pause()
}
final class PlayerViewModel {
    private let audioPlayer: AudioPlayable

    init(audioPlayer: AudioPlayable) {
        self.audioPlayer = audioPlayer
    }

    func didTapPlay() {
        audioPlayer.play()
    }
}

이제 ViewModel은 AudioPlaybackService를 모릅니다. AudioPlayable이라는 역할에만 의존합니다.

실제 앱에서는 실제 구현체를 넣고, 테스트에서는 Mock 객체를 넣을 수 있습니다.

let viewModel = PlayerViewModel(
    audioPlayer: AudioPlaybackService()
)

let testViewModel = PlayerViewModel(
    audioPlayer: MockAudioPlayer()
)

즉, DIP는 특정 구현체에 강하게 묶인 코드는 테스트하기 어렵지만, 추상화에 의존하는 코드는 필요한 구현체를 바꿔 사용하기 쉽게 해주는 것이라고 생각합니다.

마무리

이번 글에서는 SOLID 원칙을 Swift 코드와 함께 정리해보았습니다.

처음에는 SOLID를 단순히 객체지향 설계 원칙 중 하나라고만 생각했습니다. 하지만 프로젝트를 진행하면서 View가 너무 많은 책임을 가지거나, 특정 구현체에 강하게 의존하거나, 기능이 추가될 때마다 기존 코드를 계속 수정해야 하는 상황을 겪다 보니 자연스럽게 코드를 분리해야겠다는 생각이 들었습니다.

물론 모든 코드에 SOLID를 완벽하게 적용해야 한다고 생각하지는 않습니다. 작은 기능까지 과하게 분리하면 오히려 코드가 복잡해질 수 있기 때문입니다.

그래서 SOLID는 반드시 지켜야 하는 정답이라기보다, 현재 코드가 너무 많은 책임을 가지고 있지는 않은지, 변경이 생겼을 때 수정 범위가 지나치게 넓어지지는 않는지 점검하는 기준으로 사용하는 것이 더 적절하다고 느꼈습니다.

결국 중요한 것은 원칙의 이름을 외우는 것이 아니라, “이 객체는 어떤 책임을 가지고 있고, 어떤 이유로 변경될 수 있는가?”를 계속 고민하는 것이라고 생각합니다.

참고 자료

[embed]SOLID - Wikipedia In object-oriented programming and functional programming, SOLID is a mnemonic acronym for five principles intended to…en.wikipedia.org

[embed]SOLID Design Principles Explained: Building Better Software Architecture | DigitalOcean Understand SOLID design principles in object-oriented programming to write cleaner, scalable, and maintainable code. A…www.digitalocean.com

[embed]Documentation Copyright © 2014-2026 Apple Inc. and the Swift project authors. All rights reserved.docs.swift.org


메타데이터
post_id
66af9008cd74
slug
cs-swift로-다시-보는-oop와-solid-2-66af9008cd74
url
https://medium.com/@sjin3687/cs-swift%EB%A1%9C-%EB%8B%A4%EC%8B%9C-%EB%B3%B4%EB%8A%94-oop%EC%99%80-solid-2-66af9008cd74
canonical_url
https://medium.com/@sjin3687/cs-swift%EB%A1%9C-%EB%8B%A4%EC%8B%9C-%EB%B3%B4%EB%8A%94-oop%EC%99%80-solid-2-66af9008cd74
author_url
https://medium.com/@sjin3687
status
ok
fetched_at
2026-06-20 20:29:01