전공 수업을 들으며 객체지향 프로그래밍, 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
}
이렇게 다형성을 활용하면 여러가지 장점이 있습니다
- 유연성 증가 : 상위 클래스 타입의 참조 변수로 다양한 하위 클래스 객체를 사용할 수 있음
- 코드 재사용성 : 상위 클래스의 메서드를 오버라이딩하여 하위 클래스에서 동작을 다르게 정의 가능
- 확장성 : 새로운 하위 클래스를 추가해도 기존 코드를 수정하지 않아도 됨
이러한 다형성은 평소에 자주 사용하는 `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. 과도한 설계
객체지향 프로그래밍의 특성상, 모든 것을 객체로 모델링하려는 경향이 있습니다. 간단한 문제를 과하게 설계하면 불필요한 클래스나 복잡한 계층 구조가 만들어질 수 있습니다. 이는 코드가 지나치게 복잡해지고, 유지보수가 어려워질 수 있습니다.
🍀 마무리
코드를 구현하며 객체지향을 입에 달고 살았지만 막상 정리해보니 머릿속에 뒤죽박죽이였던 개념들을 다시 정리할 수 있었던 것 같습니다. 프로젝트를 진행하며 의도하여 객체지향을 따라가려고 한 부분도 있었지만 구현하고 나니 객체지향을 저절로 따라가고 있던 부분들도 있었더라구요..! 사실 꼭 어떤 패턴을 의도해서 따라가기보단 구현하고 보면 해당 패턴을 따라가고 있으면 좋은 코드라고 하던데 잘 구현하고 있었던 걸까요..? ㅎㅎㅎ..
앞으로도 어떤 코드가 좋은 코드일지 고민해보고 좋은 코드를 구현할 수 있도록 많이 노력해야겠습니다 !
'Android' 카테고리의 다른 글
[Android] UI는 왜 Main Thread에서만 그려져야 할까? (0) | 2024.10.25 |
---|---|
[Android] UseCase 꼭 필요할까? (0) | 2024.10.23 |
[Android] 프로젝트에 Hilt로 의존성 주입하기 (다온길 마이그레이션) (0) | 2024.10.05 |
[Android] Dagger와 Hilt로 자동 의존성 주입 이해하기 (0) | 2024.10.03 |
[Android] 의존성 주입이란? (feat. 수동 의존성 주입) (0) | 2024.10.01 |