본문 바로가기

Android

[Android] 객체지향 프로그래밍이란? (객체지향 프로그래밍 != SOLID 원칙)

전공 수업을 들으며 객체지향 프로그래밍, SOLID 원칙 이라는 단어들이 참 많이 나왔었는데 직접 프로젝트를 진행하다보니 객체지향 프로그래밍의 중요성을 몸소 깨닫게 되었습니다

 

그런데 가끔 저도 모르게 객체지향 프로그래밍 == SOLID 원칙이라고 생각을 할 때가 있더라구요... 물론 둘다 객체지향 프로그래밍과 관련된 개념은 맞지만 서로 다른 정의를 가지고 있으니 이 참에 다시 정리를 해보려고 합니다

 

 

오늘은 우선 객체지향 프로그래밍에 대해서 정리해보겠습니다 !

객체지향 프로그래밍 (Object Oriented Programming)은 프로그래밍에서 필요한 데이터를 추상화시켜 상태와 행위를 가진 객체를 만들고, 그 객체들 간의 상호작용을 통해 로직을 구성하는 프로그래밍 방법 입니다

 

그리고 SOLID 원칙은 이 객체지향 프로그래밍을 더 체계적으로 만들기 위한 가이드 라인이 되는 것입니다

 

 

그런데 객체지향 프로그래밍을 구현하기 이전에 알아야 할 개념이 한가지 있는데요, 바로 클래스와 인스턴스(객체)입니다

 

 

 

💡 클래스와 인스턴스 (객체)

1. 클래스

클래스는 어떤 문제를 해결하기 위한 데이터를 만들기 위해 추상화를 거쳐 집단에 속하는 속성과 행위를 변수와 메서드로 정의한 것입니다

 

이때 속성과 행위는?

  • 속성 : 객체가 가진 특징이나 상태를 나타냄, 객체의 "무엇"을 설명함
  • 행위 : 객체가 수행할 수 있는 기능이나 작업, 객체의 "무엇을 할 수 있는지"를 설명함

 

2. 인스턴스 (객체)

클래스에 정의만 해두면 아무 의미가 없습니다. 클래스에서 정의한 것을 토대로 실제 메모리에 할당해 사용되는 데이터가 바로 인스턴스(객체)가 됩니다

 

// 클래스
class Car {
    // 속성 (변수)
    var brand: String = "Unknown"
    var color: String = "White"
    var speed: Int = 0

    // 행위 (메서드)
    fun accelerate() {
        speed += 10
        println("$brand가 가속하여 현재 속도는 $speed km/h입니다.")
    }

    fun brake() {
        if (speed > 0) {
            speed -= 10
            println("$brand가 감속하여 현재 속도는 $speed km/h입니다.")
        } else {
            println("$brand가 이미 멈춰 있습니다.")
        }
    }
}

 

이렇게 브랜드 이름, 차 색상, 속도를 나타내는 변수와 악셀, 브레이크를 행위로 가지는 Car 클래스를 정의하였습니다. 그럼 이 클래스를 사용하기 위해선 메모리 상에서 인스턴스를 생성해 사용해야겠죠?

 

fun main() {
    // 첫 번째 인스턴스 생성
    val car1 = Car() // car1은 Car 클래스의 인스턴스
    car1.brand = "Hyundai"
    car1.color = "Blue"
    car1.speed = 50

    // 두 번째 인스턴스 생성
    val car2 = Car() // car2는 Car 클래스의 또 다른 인스턴스
    car2.brand = "Kia"
    car2.color = "Red"
    car2.speed = 70

    // 각 인스턴스의 메서드 호출
    car1.accelerate() // 출력: Hyundai가 가속하여 현재 속도는 60 km/h입니다.
    car2.brake()      // 출력: Kia가 감속하여 현재 속도는 60 km/h입니다.

    // 각 인스턴스의 속성 확인
    println("${car1.brand} 자동차의 색상은 ${car1.color}입니다.") // 출력: Hyundai 자동차의 색상은 Blue입니다.
    println("${car2.brand} 자동차의 색상은 ${car2.color}입니다.") // 출력: Kia 자동차의 색상은 Red입니다.
}

 

이렇게 Car() 클래스를 활용해 car1과 car2 인스턴스를 생성해 메모리 위에서 직접 사용할 수 있습니다

 

예시를 통해 확인하면 클래스는 "자동차"라는 개념을 정의하는 설계도가 되고, 인스턴스는 "현대 자동차(파란색)" 또는 "기아 자동차(빨간색)"처럼 클래스를 기반으로 만들어진 구체적인 객체를 나타내는 것을 볼 수 있습니다

 

객체지향 프로그래밍은 이러한 클래스와 인스턴스를 통해 현실 세계의 객체를 모델링하고 관리하므로, 이 둘은 객체지향 프로그래밍의 기초가 됩니다

 

그럼 이제 객체지향 프로그래밍에 대해 알아볼까요?

 

 

 

🌟 객체지향 키워드 4가지 (객체지향이란 무엇인가)

1. 캡슐화 (Encapsulation)

연관성이 있는 멤버 변수와 메서드를 하나의 클래스로 묶고, 데이터를 외부에서 함부로 수정하지 못하도록 보호하는 개념입니다

캡슐화의 가장 큰 목적은 데이터 보호입니다. 접근 제한자(private, public 등)와 getter/setter를 이용해 중요한 데이터나 로직을 외부에서 직접 접근하거나 수정하지 못하게 막습니다

 

class BankAccount {
    // private: 외부에서 직접 접근 불가
    private var balance: Int = 0

    // Getter: 잔액 조회
    fun getBalance(): Int {
        return balance
    }

    // Setter: 입금 기능
    fun deposit(amount: Int) {
        if (amount > 0) {
            balance += amount
            println("입금 완료: $amount원, 현재 잔액: $balance원")
        } else {
            println("입금액은 0보다 커야 합니다.")
        }
    }

    // Setter: 출금 기능
    fun withdraw(amount: Int) {
        if (amount > 0 && amount <= balance) {
            balance -= amount
            println("출금 완료: $amount원, 현재 잔액: $balance원")
        } else {
            println("출금 실패: 출금액이 잔액보다 많거나 유효하지 않습니다.")
        }
    }
}

 

위 코드를 보면 balance는 private으로 지정되어 있어 외부에서 직접 balance를 변경할 수 없습니다. 대신 `getBalance()` , `deposit()` , `withdraw()` 를 통해 잔액을 확인하고, 입/출금을 가능하게 해줍니다. 이렇게 사용하면 입금 시 유효성을 검사한 후 잔액을 수정하고, 출금 가능 여부를 검사한 후 잔액을 수정해 데이터의 무결성을 보장해줍니다.

 

 

 

위에서 이야기하긴 했지만, 저도 처음에 객체지향 개념을 배울 때 "왜 굳이 Private으로 설정하고 다시 getter/setter를 통해 값을 설정해주지?" 라는 의문점을 가진 적이 있었는데요. 이렇게 굳이 getter/setter를 이용하는 이유는 다음과 같습니다

 

1. 데이터 무결성 보장 (직접 접근하면 위험하다!)

Setter를 사용하면 유효성 검사를 거쳐 데이터를 변경하기 때문에 잘못된 값이 들어가는 것을 막을 수 있습니다

예를 들어 나이, 급여, 가격 같은 데이터에 유효성 검사를 적용할 수 있습니다

 

2. 내부 동작을 숨길 수 있음

외부에서는 객체가 어떻게 동작하는지는 알 필요가 없습니다. 그렇기 때문에 Getter/Setter로만 접근 가능하도록 하면 내부 구현을 변경해도 외부 코드를 수정할 필요가 없게 되겠죠 !

 

결론은 직접 속성 조작을 못하게 하고, 데이터를 안전하게 다룰 수 있는 "통제권"을 가지는 것이 객체지향 프로그래밍의 기본 철학이기 때문입니다

 

 

프로젝트를 진행하며 ViewModel에서 MutableLiveData는 private으로 설정하여 외부에서 변경을 불가능하게 하고, LiveData는 public으로 설정하여 외부에서 읽기만 가능하도록 구현한 적이 있는데 이 경우도 캡슐화에 해당하네요!

 

 

 

2. 상속 (Inheritance)

기존 클래스의 속성(변수)과 동작(함수)을 새로운 클래스에서 재사용하고 확장하는 방법입니다. 상속을 통해 기존 클래스를 기반으로 새로운 클래스를 만들 수 있습니다

 

// 부모 클래스
open class Animal(val name: String) { // open 키워드로 상속 가능하게 만듦
    fun eat() {
        println("$name is eating.")
    }

    fun sleep() {
        println("$name is sleeping.")
    }
}

// 자식 클래스
class Dog(name: String) : Animal(name) { // 부모 클래스를 상속
    fun bark() {
        println("$name is barking.")
    }
}

fun main() {
    val dog = Dog("Buddy")
    dog.eat()    // 부모 클래스의 메서드 사용
    dog.sleep()  // 부모 클래스의 메서드 사용
    dog.bark()   // 자식 클래스에서 추가한 메서드 사용
}

 

상속의 특징으로는 크게 3가지가 있습니다

 

  • 부모 클래스(상위 클래스)에서 정의된 속성이나 메서드를 자식 클래스(하위 클래스)에서 상속받아 재사용할 수 있습니다.
  • 자식 클래스는 부모 클래스의 기능을 재사용(상속)하거나 오버라이드(override)하여 자신만의 동작을 정의할 수 있습니다.
  • 상속을 통해 계층적 구조로 클래스를 구성할 수 있습니다.

 

위 코드에서는 Animal 클래스를 open 클래스로 정의하여 상속이 가능하도록 선언합니다. 이에 Dog 클래스는 Animal 클래스를 상속받아 `bark()` 함수를 추가로 정의하여 사용하였습니다.

 

그런데 이때 ! 코틀린에서는 기본적으로 모든 클래스, 메서드, 그리고 프로퍼티는 final로 선언됩니다. 즉, 명시적으로 open 키워드를 사용하지 않으면 클래스는 상속할 수 없고, 메서드는 오버라이드할 수 없습니다.

 

 

이때, 부모 클래스의 함수를 오버라이드하여 새로운 동작을 정의할 수도 있는데요

// 부모 클래스
open class Animal(val name: String) {
    open fun sound() { // open 키워드로 오버라이드가 가능하게 만든다.
        println("$name makes a sound")
    }
}

// 자식 클래스
class Dog(name: String) : Animal(name) {
    override fun sound() { // 부모 클래스의 메서드를 오버라이드
    	super.sound() // 부모 클래스의 sound() 호출
        println("$name barks")
    }
}

class Cat(name: String) : Animal(name) {
    override fun sound() { // 부모 클래스의 메서드를 오버라이드
        println("$name meows")
    }
}

fun main() {
    val dog = Dog("Buddy")
    val cat = Cat("Whiskers")

    dog.sound()  
    cat.sound()
    /* 출력:
    Buddy makes a sound
    Buddy barks
    Whiskers meows
    */
}

 

부모 클래스의 `sound()` 함수를 open으로 선언하여 `Dog` 와 `Cat` 자식 클래스에서 `sound()` 함수를 오버라이딩하여 사용하였습니다. 이 오버라이딩은 많은 장점이 존재합니다.

 

오버라이딩을 하게 되면 객체지향의 또 다른 키워드인 "다형성"을 실현하면서도 코드 재사용성이 높아집니다. 부모 클래스에서 기본적인 기능을 제공하고, 자식 클래스에서 필요에 맞게 메서드를 오버라이드할 수 있으므로 코드를 중복해서 작성할 필요 없이 재사용할 수 있습니다. 또한, 부모 클래스에서 제공하는 기본 동작을 자식 클래스에서 쉽게 확장하거나 변경할 수 있도록 해줍니다.

 

그리고 특히 인터페이스를 구현할 때 매우 중요한 역할을 합니다. 인터페이스에 정의된 메서드를 구현하는 과정에서 구체적은 동작을 오버라이드 할 수 있습니다. 이는 인터페이스가 정의하는 동작을 자식 클래스에서 어떻게 구현할지 유연하게 결정할 수 있도록 해줍니다.

 

또한, 자식 클래스에서 부모 클래스의 메서드를 호출하고 싶을 때 super 키워드를 사용하여 부모 클래스의 메서드를 호출할 수 있습니다

 

 

 

3. 다형성 (Polymorphism)

하나의 변수명, 함수명 등이 다양한 의미로 해석될 수 있음을 뜻합니다

이는 주로 오버라이딩(Overriding)과 오버로딩(Overloading)을 통해 구현됩니다

 

  • 오버라이딩(Overriding) : 부모클래스의 메서드와 같은 이름, 자식 클래스에서 본인의 입맛대로 재정의하여 사용
  • 오버로딩(Overloading) : 같은 이름의 함수를 여러개 정의하고, 매개변수의 타입과 개수를 다르게 하여 매개변수에 따라 다르게 호출할 수 있게 하는 것

 

오버라이딩

// 상위 클래스
open class Animal {
    open fun sound() {
        println("Some generic animal sound")
    }
}

// 하위 클래스
class Dog : Animal() {
    override fun sound() {
        println("Woof! Woof!")
    }
}

fun main() {
    // 상위 클래스 타입의 참조 변수에 하위 클래스 객체를 할당
    val animal: Animal = Dog()
    animal.sound()  // 출력: Woof! Woof!
}

 

오버로딩

class Calculator {
    // 같은 이름의 메서드, 서로 다른 매개변수
    fun add(a: Int, b: Int): Int {
        return a + b
    }

    fun add(a: Int, b: Int, c: Int): Int {
        return a + b + c
    }
}

fun main() {
    val calculator = Calculator()
    println(calculator.add(3, 4))       // 출력: 7
    println(calculator.add(1, 2, 3))   // 출력: 6
}

 

이렇게 다형성을 활용하면 여러가지 장점이 있습니다

  1. 유연성 증가 : 상위 클래스 타입의 참조 변수로 다양한 하위 클래스 객체를 사용할 수 있음
  2. 코드 재사용성 : 상위 클래스의 메서드를 오버라이딩하여 하위 클래스에서 동작을 다르게 정의 가능
  3. 확장성 : 새로운 하위 클래스를 추가해도 기존 코드를 수정하지 않아도 됨

 

이러한 다형성은 평소에 자주 사용하는 `onClickListener` , `onLongClickListener` 가 대표적인 오버라이딩 예시로 볼 수 있습니다

 

 

 

4. 추상화 (Abstraction)

불필요한 세부정보는 숨기고 필요한 기능만 공개하는 개념입니다. 추상화를 통해 복잡한 시스템을 단순화하고, 객체가 "무엇을 해야 하는지"에 집중하게 합니다. "어떻게 동작하는지"는 숨기고 "어떤 동작을 할 수 있는지"를 명확히 정의합니다.

 

추상화의 핵심은 상위 클래스나 인터페이스의 공통된 동작만을 정의하고, 세부 구현은 하위 클래스에서 정의하는 것입니다

 

인터페이스 사용

// 인터페이스
interface Flyable {
    fun fly()  // 추상 메서드
}

interface Swimable {
    fun swim()  // 추상 메서드
}

// 클래스가 여러 인터페이스를 구현
class Bird : Flyable, Swimable {
    override fun fly() {
        println("The bird is flying!")
    }

    override fun swim() {
        println("The bird is swimming!")
    }
}

fun main() {
    val bird = Bird()
    bird.fly()   // 출력: The bird is flying!
    bird.swim()  // 출력: The bird is swimming!
}

 

`Flyable` 과 `Swimable` 은 행동의 규약만 정의하며, `Bird` 클래스에서 이를 구현합니다

 

이렇게 추상화를 통해 새로운 동작이나 객체를 쉽게 추가할 수 있고, 다형성을 활용해 다양한 객체를 동일한 방식으로 다룰 수 있다는 장점이 존재합니다.

 

저는 추상화와 캡슐화가 어떻게 다른지 꽤나 헷갈렸었는데요, 이런 차이점이 있습니다

  • 추상화: "무엇을 한다"에 초점. 객체가 제공해야 할 기능만 노출하고, 구현 세부사항은 숨김
  • 캡슐화: "어떻게 한다"에 초점. 데이터와 메서드를 하나의 단위로 묶고, 접근 제한자를 통해 외부로부터 숨김

 

가장 많이 사용되는 추상화이자 프로젝트에서 유용하게 사용했던 추상화로 Repository를 구현할 때 구현체와 인터페이스를 나눠서 구현했던 기억이 있습니다

 

 

그렇다면 마지막으로 이러한 객체지향 프로그래밍을 왜 따라야 할까요?! 간단하게 알아보겠습니다 :)

 

 

 

😮 객체지향 프로그래밍 장점과 단점

장점

1. 코드 재사용이 용이

매 키워드마다 나왔던 이야기인 것 같은데 아무래도 부모 클래스 등 남이 만든 클래스를 가져와서 이용할 수 있고 상송을 통해 확장이 가능하기 때문에 코드 재사용이 용이합니다

 

2. 유지보수가 쉬움

절차 지향은 코드를 수정할 때 일일이 찾아야 하지만, 객체지향은 수정할 부분이 클래스 내부에 멤버 변수 혹은 메서드로 모여서 존재하기 때문에 해당 부분만 수정하면 됩니다

 

3. 대형 프로젝트에 적합

클래스 단위로 모듈화시켜서 개발할 수 있으므로 대형 프로젝트처럼 여러 명, 여러 회사에서 프로젝트를 개발할 때 업무 분담이 쉽습니다

 

 

단점

1. 메모리 소비

객체를 생성하면 메모리 할당이 필요하므로, 대규모 애플리케이션에서 많은 객체를 관리할 경우 메모리 소비가 많아질 수 있습니다. 객체 지향 시스템은 객체와 객체 간의 관계를 관리해야 하기 때문에 더 많은 메모리 공간을 사용할 수 있습니다.

 

2. 과도한 설계

객체지향 프로그래밍의 특성상, 모든 것을 객체로 모델링하려는 경향이 있습니다. 간단한 문제를 과하게 설계하면 불필요한 클래스나 복잡한 계층 구조가 만들어질 수 있습니다. 이는 코드가 지나치게 복잡해지고, 유지보수가 어려워질 수 있습니다.

 

 

 

 

 

🍀 마무리

코드를 구현하며 객체지향을 입에 달고 살았지만 막상 정리해보니 머릿속에 뒤죽박죽이였던 개념들을 다시 정리할 수 있었던 것 같습니다. 프로젝트를 진행하며 의도하여 객체지향을 따라가려고 한 부분도 있었지만 구현하고 나니 객체지향을 저절로 따라가고 있던 부분들도 있었더라구요..! 사실 꼭 어떤 패턴을 의도해서 따라가기보단 구현하고 보면 해당 패턴을 따라가고 있으면 좋은 코드라고 하던데 잘 구현하고 있었던 걸까요..? ㅎㅎㅎ.. 

 

앞으로도 어떤 코드가 좋은 코드일지 고민해보고 좋은 코드를 구현할 수 있도록 많이 노력해야겠습니다 !