OOP와 SOLID 원칙 - Before You Study Spring (0)

강지혁·2022년 8월 11일
2

Before You Study Spring

목록 보기
1/3
post-thumbnail

학과 동아리에서 스프링 MVC 기초 세미나를 맡게 되었다.
스프링은 객체 지향 프레임워크의 대표 격이고, 나는 이것을 알고 있지만..
누군가에게 설명을 하라고 하면, 바로 알아먹게 할 수 있을까? 싶은 생각이 들었다.

그렇다면 잘 알고 있는 것이 아닐 수도 있고

그래서,

  1. 스프링을 하려면 왜 객체 지향 패러다임을 알고 있어야 하는지
  2. 스프링은 어떤 문제를 해결하기 위해 나온 건지
  3. 스프링의 IoC, DI, PSA를 어떻게 보여줄 수 있는지

정리해보고자 한다.

그 전에 일단 OOP부터 정리하자.
천리길도 한걸음부터!


객체 지향 프로그래밍

❗️OOP는 프로그래밍 패러다임이다❗️

프로그래밍 패러다임이 뭔지부터 간단히 짚고 넘어가야 한다.
프로그래밍이란 것은 대단히 실무적인 일이다.
세상의 문제를 해결하기 위해, 세상에서 일어나는 일들을 코드로 옮기고, 답을 도출해내는 일련의 과정이다.

따라서 프로그래밍에서 가장 중요한 것은, 세상을 어떻게 바라보고 코드로써 "잘" 문서화 하는가? 라고 할 수 있겠다.

이것은 세상을 잘 설명할 수 있는가? 와는 엄연히 다른 질문이다.
현실의 문제 및 클라이언트의 요구사항은 매번 변하며, 문제를 해결하는 동료도 수시로 변한다.
그렇기 때문에, 협업과 유지보수는 프로그래밍을 잘 하는데 있어 아주 중요한 고려사항이다.

  1. 세상의 문제를 적절하게 코드로 옮기면서
  2. 협업 및 유지보수가 쉬운 코드를 만드는 것

이 두 가지 목표를 잘 충족시켜줄 방법에 대해 수많은 고민이 있었을 것이다.
OOP는 그러한 방법론 가운데 가장 흥한(?) 케이스라고 보면 된다.

OOP를 통해 세상을 기술하면,
(처음보는 사이끼리도) 좀 더 일관된 방식의 코드를 주고받을 수 있고,
좀 더 수월한 코드 유지&보수가 가능하다.


⭐️ OOP가 어떻길래 그런게 가능한가요? ⭐️

OOP가 어떤 식인지 간단히 요약하면, 아래처럼 말할 수 있을 것 같다.

세상만사는 모든 객체 (Object) 간의 협력이다.
객체는 서로 의존하고, 협력하는 살아있는 존재다.
이들 객체 간의 의존 및 협력을 효과적으로 제어하는 것이 곧 OOP를 잘 치는(?) 것이다.

조금 더 구구절절 설명해보자면,,

객체 간의 협력

OOP는 세계를 바라보는 다소 서구적인 시각에서 출발한다.
세상은 저마다의 "객체(Object)"로 구성되어 있으며, 각 객체는 몇 가지 속성을 보유하고 있다.
객체는 주변 환경에 영향을 받는 존재보다는, 영향을 끼치는 존재로 묘사된다.

(생각의 지도어떤 개발자 분의 진지한 고찰와 같은 글들을 참고하시면 무슨 말을 하려는지 감이 올 것 같다.)

우리가 버스를 타는 행위를 코드로 옮긴다고 생각해보자.

  1. 사람들은 정해진 시간에 정류장에서 버스에 탄다.
  2. 버스에 탄 사람은 지갑에서 돈을 꺼내 요금을 지불한다.
  3. 버스의 저금통에는 잔고가 들어간다.
// 정류장이 있다.
// 정류장에는 대기중인 버스들이 있다.
// 정류장에는 대기중인 승객들도 있다.
class Station(
	val waitingBuses: List<Bus>,
    val passengers: List<Passenger>,
)

// 버스가 있고
// 버스 이용료는 1000원이다.
class Bus(
	val time: LocalDateTime,
	val balance: Long,
    val passengers: List<Passenger>,
    val fee: Long = 1000L,
)

// 승객이 있다.
class Passenger(
	val time: LocalDateTime,
	val wallet = Wallet(10000L),
)

// 승객에게는 지갑도 있다.
data class Wallet(val balance: Long)

정류장에서 버스에 승객이 타는 행위는, 이제 앞서 정의한 네 Object 끼리의 상호작용으로 정의할 수 있다.

class Station(
	val waitingBuses: List<Bus>,
    val passengers: MutableList<Passenger>,
) { 
	fun rideBus() { 
    	val leftPassengers = mutableListOf()
    	// 0. 모든 승객들은
    	for (p in passengers) { 
        	// 1. 시간이 맞는 버스를 찾고
        	val targetBus = waitingBuses.find { p.time == it.time }
            // 2. 시간이 맞는 버스에게 돈을 지불한다
            val busFee = targetBus.fee
            if (busFee < p.wallet.balance) {     
                passengers.wallet.balance -= busFee
                targetBus.balance += busFee
            	// 3. 돈을 지불한 승객들은 버스에 탄다.
                targetBus.passengers.add(p)
                leftPassengers.add(p)
            } else { 
            	// 4. 돈을 지불하지 못하면 버스에 타지 못한다.
                continue
            }
        }
        // 5. 버스에 탄 승객들은 정류장을 떠난다.
        passengers.removeAll(leftPassengers)
    }
}

위 예시는 현실에서 일어나는 일을 코드로 옮기는 데 성공했다.
그런데 '잘 옮기는 것'에는 실패했다.

왜일까?

  1. 각 객체가 하는 짓이 자연스럽지 못하다.
    • 정류장에서, 승객의 지갑에서 돈을 빼서, 버스에 넣어주고 있다. (?!)
    • 승객은 가만히 있다가, 정류장이 맞는 버스를 찾아주면, 가서 탄다. (응애 승객)
  2. 변경사항에 취약하다.
    • 승객이 지갑이 아니라 가방을 들고 다니기 시작한다면? 승객 중에 무료승차권이 있는 사람이 생기면?
      - 정류장은 사실 알 바가 아니지만
      • 그러나 지금 구현은, 그런 변경사항이 생길 때마다, 정류장의 코드를 건드려야만 한다.

위 코드는 이러한 문제점들을 가지고 있다.
1번은 '책임의 분리'가 원활히 이루어지지 않았음을 의미하며,
2번은 말 그대로 '변경사항에 취약'함을 의미한다.

이러한 코드는, 현실의 문제를 코드로 옮기긴 했으나,
앞서 이야기한 OOP의 본질 관점에서 보면, 반쪽짜리 코드라고 보아야 한다.

1, 2번에 부합하는 코드를 어떻게 하면 만들어볼 수 있을까?
아래처럼 해볼 수 있겠다. (방법은 많겠지..)

class Station(
	val waitingBuses: List<Bus>,
    val passengers: MutableList<Passenger>,
) { 
	fun rideBus() {
    	// 0. 승객들은 이제 타야 할 버스를 알아서 찾아간다.
    	val gonePassengers = passengers
        	.filter { p -> findAndRide(waitingBuses) == true }
            
        // 1. 정류장은 이제 떠나간 승객들만 정리해준다.
        passengers.removeAll(gonePassengers)
    }
}

class Passenger(
	val time: LocalDateTime,
    // 이제 지갑을 누구에게도 보여줄 필요가 없어졌다.
	private val wallet = Wallet(10000L),
) {
	fun findAndRide(buses: List<Bus>): Boolean {
    	val targetBus = buses.find { it.time == this.time }
        return bus.addPassenger(p)
    }
    
    fun canPay(fee: Long): Boolean {
    	if (fee < wallet.balance) { 
            return true
        } else {
        	return false
        }
    }
    
    fun minusBalance(fee: Long) {
    	// 여기서도 지갑과의 결합도를 느슨하게 만들 수 있다.
    	this.wallet.balance -= fee
	}
}

class Bus(
	val time: LocalDateTime,
    val fee: Long = 1000L,
    // 여기도 감춰도 되는 것들이 늘어났다.
    // 아래 두 프로퍼티는 외부에서 쓰이지 않는다.
    // 따라서 변경사항에 대응하기가 훨씬 수월해졌다.
	private val balance: Long,
    private val passengers: List<Passenger>,
) {
	fun addPassenger(p: Passenger): Boolean {
    	if (p.canPay(this.fee)) {
        	// ❗️ 주목 !! ❗️
        	p.minusBalance(fee)
        	this.balance += fee
    		this.passengers.add(p)
            return true
        }
        
        return false
    }
}

위는 기존 구현에 비해 각 객체의 책임이 디테일하게 분리되어있고,
구현 세부 사항 (각 객체의 프로퍼티 상태, 타입 등)을 감춤으로써 변경사항에 좀 덜 민감한 코드로 개선되었다.

이제 객체는, 앞서 필자가 정의했던 바 처럼, 서로 의존 및 협력을 통해 원하는 바를 이루어내고 있다.

그리고 또, 앞서 말했던 대로, 이는 세상에 대한 우리의 직관과 100% 일치하지 않을 수도 있다.
위 구현에서 ❗️주목❗️이라고 써둔 곳을 봐달라.

버스 객체에서 직접 유저의 잔고를 빼내고 있다.
유저가 돈을 지불하는 것이 직관 상으로는 비교적 더 자연스러운 행동이고, 코드를 그렇게 개선할 수도 있다.
(그나마 버스 객체는 승객에게 지갑이 있는지 가방이 있는지 까지는 모른다)

이 버스 <=> 승객 이슈는 간단하지만 생각해볼 것이 많은 포인트다.
지금처럼 간단한 문제를 해결하는 것만 보더라도, 문제 해결에 참여하는 객체 간의 의존과 협력 방식을 구성하는 데에는 선택지가 다양하게 존재한다.

상황에 따라서, 또는 요구 사항에 따라서, 또는 개발자의 취향에 따라서,
우리는 각 객체에게 어떤 주도권을 주고, 또 주지 않을 지 결정할 수 있다.

대체로 좋은 코드를 만드는 것은 (객체지향적인 코드를 잘 작성한다는 것은),
적절한 객체들에게 자율성이 주어지고, 변경에 유연하도록(과하게 결합되어있지 않도록) 의존성을 잘 제어하는 것을 의미한다.

코드의 의인화 (Anthropomorphism)

OOP에서 우리는 각 객체를 자율적이고, 서로 협력하는 살아있는 존재로 바라본다.
현실의 버스는 자율적으로 움직일 수 없다. 정거장도 마찬가지다.
그러나 객체지향의 세계에서는, 모든 객체들에게 자율적인 행동이 가능하다.
앞선 코드 예시에서 버스가 취하는 자율적인 행동들을 기억하자.


살아있는 버스 ㅋㅋ


SOLID 원칙

앞서 코드 예시와 함께 살펴본 책임 분리 & 변경사항은 객체 지향의 중요한 고려사항 가운데 하나다.
이처럼 좋은 객체 지향 프로그래밍을 위한 5대 원리가 존재한다.

이 원칙은 클린 코드, 클린 아키텍처 등으로 유명한 로버트 마틴(밥아저씨)으로 부터 나온 것이라고 한다.
(유래에 대해서 별 관심 없었는데, 이것도 밥아저씨 출처였네..)

SOLID 원칙은 기억해두면 좋다. 앞선 예시로부터, 실제로 코드가 어떻게 나아졌는지 대충이라도 경험해보았듯이..!

1. SRP (단일 책임의 원칙)

: Single-Responsibility-Principle

There should be never be more than on reason for a class to change.

앞서 언급했던, '책임의 분리'가 첫번째 원칙이다.
책임을 적절히 분리하는 것은

  • 코드 응집성을 높이고
  • 가독성 향상, 유지보수에 도움이 되면
  • 변경에 최소한으로 대응 가능해지는

등의 효과를 가지고 있을 뿐만 아니라, 다른 S'OLID' 원칙의 기반을 잘 잡도록 도와준다.
책임의 분리는 의미가 단순한 만큼, 추상적이기도 하다.

여기서 '책임'의 범위와 단위를 어떻게 정할 것인가? 판단하는 데에는 개발자의 경험치가 중요한 것 같다.

2. OCP (개방-폐쇄의 원칙)

: Open Close Principle

YOU SHOUD BE ABLE TO EXTEND A CLASS BEHAVIOUR, WITHOUT MODIFYING IT

객체는 확장에는 열려 있고, 변경에는 닫혀 있어야 한다.
이 말인 즉슨, 요구사항이 변경되거나 추가되었을 때, 기존 객체를 수정하는 일은 최소화하고, 쉽게 확장해서 쓸 수 있어야 한다 는 뜻이다.

이것은 Interface와 아주 아주 큰 관련이 있다.

interface Bird { fun fly() }

class BirdOne(): Bird { fun fly() }

// Good
fun sky(bird: Bird) { 
	// birds are flying.
    bird.fly()
}

// Bad
fun sky(bird: BirdOne) { 
	// birds are flying.
    bird.fly()
}

// 이게 추가되면, 위 두 메소드 중 하나에는 수정이 필요하겠죠?
class BirdTwo(): Bird { fun fly() }

아주 간단해서 비교적 단적인 예시긴 하다.
다만 인터페이스를 정의함으로써, 객체들이 서로의 구체적인 구현에 의존하지 않게 두고,
세부 구현 및 수정 & 추가를 좀 더 원활히 가져갈 수 있게끔 할 수 있다.

실무에서는 어디까지 추상화를 할까, 미리 해둘까 말까 같은 문제로 참 고민을 많이 한다..
실제로 각을 잘 재는 작업이 필요한 것 같다.
뭐든 안 그렇겠냐마는 판단은 유연하게 가져가는게 최고다

3. LSP (리스코브 치환의 원칙)

: Liskov Substitution Principle

FUNCTIONS THAT USE POINTERS OR REFERENCES TO BASE CLASSES MUST BE ABLE TO USE OBJECTS OF DERIVED CLASSES WITHOUT KNOWING IT.

이름이 왜 이럴까? 3번째 원칙은 대략 이런 의미다.
어떤 타입을 상속하거나, 사용하는 하위 타입은, 자기 부모 타입의 책임을 100% 수행할 수 있어야 한다.

즉, 상위 타입 대신에 하위 타입 객체를 넣어도 코드가 깨지면 안된다는 뜻이다. (다소 당연해 보이기도 한다)

이는 컴파일 타임에 대한 규약을 넘어서, 기존에 작성된 메소드에 담겨 있는 기능 및 명세에 대한 책임을 포함한다.
즉 어떤 타입을 상속받을 거면, 진짜로 모든 의미에서 하위 타입일 때에만 상속으로 구현하라는 뜻이다.
LSP에 대한 설명이 잘 담겨있는 다른 글을 참고로 걸어두고 넘어가고자 한다.

4. ISP (인터페이스 분리의 원칙)

: Interface Segregation Principle

CLIENTS SHOULD NOT BE FORCED TO DEPEND UPON INTERFACES THEY DO NOT USE.

ISP 원칙은, 인터페이스의 범위는 좁을수록 좋다는 의미로 받아들일 수 있다.

// 집에서 잘 수 있긴 하지만,
interface House { fun cook(); fun sleep() }

// 아래 클라이언트는 집의 자는 부분만을 사용한다.
fun seeYouTomorrow(house: House) {
	val p = person()
    p.wash()
    house.sleep()
}

// 만약 나중에 아래와 같은 인터페이스가 추가된다고 치자.
interface Hotel { fun sleep(); fun roomService() }
interface Tent { fun sleep(); }

// 잠을 자는 seeYouTomorrow에서 House 대신 sleep만 가진 PlaceToSleep 인터페이스를 부르게 리팩토링할 수 있다.

interface PlaceToSleep { fun sleep(); }
interface House: PlaceToSleep { fun cook(); }

5. DIP (의존관계 역전의 원칙)

: Dependency Inversion Principle

Abstractions Should Not Depend Upon Details. Details Should Depend Upon Abstractions.

의존관계 역전의 법칙은, A => B 의존관계를 A => B* <= B와 같은 꼴로 역전시키라는 뜻을 담고 있다.
이 분야의 고전 예시가 타이어라고 한다.

// 차는 타이어를 가지고 다닌다.
// 타이어는 차보다 변경이 잦은, detail한 구현체다.
class Car(val tire: HankookTire)

// 어느 날 눈이 와서 하루만 스노우타이어를 낀다거나,
// 넥센타이어로 바꾸는 날에는,, 구현이 바뀌어야 한다.
// 타이어 때문에 차 코드를 건드는 상황은 적절치 못하다.

class Car(val tire: Tire)

interface Tire
class HankookTire: Tire
class NexenTire: Tire
// ...

스프링을 쓰다 보면 DIP use-case는 정말 많이 볼 수 있다.

XXXCustomRepository & CustomRepositoryImpl
각종 Mocking & Stubbing 등등..

구현 세부 사항을 숨김고, 유연한 사용이 가능해지면 정말 이점이 크다.


마무리

오늘은 OOP와 SOLID 원칙에 대해 알아보았다.

OOP는 결국 '프로그래밍을 잘 하기 위한 약속'이다.
객체 간의 협력을 통해 읽기 쉽고 & 유지 보수가 쉬운 코드를 만들어내는 것이 OOP의 목표다.

그리고 그러한 목표를 잘 지키기 위한 원칙이 바로 SOLID 원칙이다.
SOLID 원칙을 잘 이해하고 있으면, 객체지향적인 코드를 더욱 짜임새 있게 만들어낼 수 있다.

OOP가 뭔지는 사실 제대로 보지 않아도, 이름 만으로 유추가 가능하다.
그러나 그게 왜 필요한 지, 어떻게 도움이 되는지는 지나치기 쉬운 것 같다.

잘 쓰기는 더더욱 어렵고..
그래도 스프링을 잘 이해하기 위한 기본 중의 기본이니
한번쯤 머릿속에 확실히 저장해두는 것은 나쁘지 않은 것 같다 👍


다음으로는 스프링 프레임워크 소개를 간단히 하고,
그 다음으로는 DI 직접 구현해보기, @Transactional로 보는 PSA, IoC 컨테이너 뜯어보기 등등 할 것 같다.
할 거 참 많다 😂

1개의 댓글

comment-user-thumbnail
2022년 8월 12일

핵심도 잘 정리되어 있고 위트도 넘치는 글, 재미있게 잘 읽었습니다~^^

답글 달기