객체지향에서 타입계층이 중요한 이유

WIZ·2023년 8월 18일
13

OOP

목록 보기
1/3

블로그에서 처음으로 객체지향에 대해서 이야기해보려고 한다.

그동안 객체지향에 대해서 포스팅을 쓰지 않았던 이유는 내가 함부로 다룰 수 없는 주제라고 생각했기 때문이었다. 오늘 인프콘 2023 에 참여했는데 거기서 "과거의 흔적을 보고 이불킥 한다는건 내가 그만큼 성장했다는 증거다" 라는 말을 들었고, 완벽하지 못할지도 모르는 객체지향 관련 포스팅을 작성해야겠다고 마음먹는 계기가 됐다.

가장 처음 이야기하고 싶은 내용의 핵심은 타입계층 이다.
들어가기전에 내가 이 포스팅을 통해서 전달하고 싶은 내용을 먼저 요약했다.

  1. 상속과 인터페이스는 타입계층을 만들어내기 위한 문법적 도구다.
  2. 타입계층이 중요한 이유는 중복코드제거, 코드의 재사용이 아니라 "행위에 대한 정보를 물려준다" 는 것이다.
  3. 이러한 타입계층은 다형성을 구현하는데 있어서 문법적 기반을 제공한다.
  4. 타입계층이 다형성의 구현에 어떻게 이용되는지 살펴보고, 다형성을 구현함으로써 얻어지는 것들에 대해서 살펴본다.

상속과 인터페이스


본격적인 타입계층에 대해서 설명하기 전에 상속에 대한 오해를 풀기 위해 간략한 설명을 하려고한다.

상속(Inheritance) 이란 기존 클래스에 기능을 추가하거나 재정의하여 새로운 클래스를 정의하는 것을 의미한다. 상속을 이용하면 기존에 작성된 클래스를 재활용 할 수 있다는 장점이 있다.

나는 개인적으로 이러한 내용이 객체지향을 이해하는데 있어서 중요하지 않다고 생각한다.

상속과 인터페이스의 가장 중요한 것은 이를 통해 타입계층을 만들어낼 수 있다는 것이다. 우리가 타입계층을 명확히 이해하고 객체지향을 바라봐야하는 이유는 객체지향의 꽃이라 불리는 다형성을 구현하는데 가장 중요한 문법적 기반이 되기 때문이다. 즉, 타입계층에 대한 개념이 제대로 정리가 되지 않았다면 다형성을 이해하는데 많은 허들이 존재하게된다.


타입계층이란?


타입계층이란 용어가 약간 생소할 수 있다.
내가 이 글에서 말하는 타입계층의 의미를 먼저 설명하려고 한다.

상속, 인터페이스 구현을 통해 타입간의 부모타입(혹은 기반타입), 자식타입(혹은 서브타입)이 만들어진다. 그러면 이 타입간의 관계에는 자연스럽게 계층이 존재하게 되는데, 이 계층을 나는 타입계층이라 표현하는 것이다. 계층이라는 표현을 쓴 것은 단순히 이 두 타입간의 관계만이라고 생각할 수 있지만 자식타입이 또 다른 자식타입을 가지게 되면 그 모양이 계층을 이루고 있다고 볼 수 있기 때문이다.

JAVA 의 Collection 이 가장 기본적으로 떠올릴 수 있는 타입계층인데, 타입계층의 예시로 위 그림을 떠올리면 될 것 같다. 이 부분은 사람에 따라 표현하는 방식이 다를 수 있으니 해당 포스팅에서 내가 생각하는 의도에 집중하면 좋을 것 같다.

본격적으로 내용으로 들어가보자!

open class People(
	val name: String,
	val age: Int
) {
	fun introduce() {
		println("My name is $name. I am $age years old!")
	}
}

class Student(
	name: String,
	age: Int,
	val subject: String
): People(name, age) {
		
}

Student 클래스는 People 클래스를 상속받는다.
우리가 상속을 이해했다면 StudentPeople 로부터 뭔가를 물려받고 있다는 사실을 알 것이다. 그렇다면 부모가 자식에게 물려주고 있는 것은 뭘까?

재사용 관점에서 봤을 때 introduce() 라는 메소드를 물려준다고 생각할 수 있다.
하지만 우리는 재사용 관점이 아니라 타입계층 관점에서 상속을 바라볼 필요가 있다.

타입계층 관점에서 위 코드를 바라봤을 때 부모가 자식에게 물려주는건 "어떤 행위를 할수 있는지" 에 대한 정보다.

이게 중요한 이유는 이러한 타입계층을 통해 문법적으로 부모타입의 행위를 자식타입이 반드시 할 수 있음을 보장해주기 때문이다. 이는 앞서 말했듯이 다형성을 구현하는데 가장 중요한 기반이된다.

JAVA 를 공부할 때 배웠던 UpCasting 을 떠올려보자.

val people: People = Student("박찬준", 29, "객체지향 프로그래밍")

이 코드는 굉장히 자연스러운 코드다.
이 코드의 자연스러움을 이해하기 위해서는 두 단계로 생각을 나눠볼 필요가 있다.

1. val people: People
2. people = Student("박찬준", 29, "객체지향 프로그래밍")

첫 번째 단계의 코드를 보고 무슨 생각이 드는지 말해보자.
Stack 메모리에 객체라는 공간이 생기고 그 공간에는 People 인스턴스의 주소가 들어갈 수 있다.

이 대답은 틀리진 않았지만 절반짜리 대답이다.

해당 공간에 들어갈 수 있는 주소는 People 인스턴스의 주소가 아니다.
People 역할을 할 수 있는 모든 인스턴스의 주소가 들어갈 수 있다는게 핵심이다.

실제로 2번 코드를 보면 어떤가?

People 인스턴스가 아니라 Student 인스턴스의 주소가 들어갔다.
즉, Student 인스턴스는 People 클래스의 정의된 행동을 모두 할 수 있다는 뜻이다. (다른 말로 StudentPeople 이 행위 호환성을 가진다고 할 수 있다)

그 사실을 문법적으로 보장해주는게 바로 타입계층인 것이다.
Student 인스턴스는 People 를 상속받았기 때문에 People 역할을 할 수 있음을 문법적으로 보장받고 있고, 그로인해 UpCasting 이 문법적으로 자연스러워지고, 자동 형변환이 가능해지는 것이다.

역할에 대해서 하고싶은 말이 많지만 이번 포스팅에서는 타입계층에 초점을 맞추기 위해서 다음 포스팅에서 깊이있게 다뤄볼 예정이다!


타입계층을 이용한 다형성 구현


다형성이 객체지향의 꽃이라 표현되는 이유는 다형성을 구현함으로써 변경에 유연한 프로그램을 만들 수 있기 때문이다.

우선 의존성에 대해서 설명하려고 한다.

객체 사이에는 의존성이 존재한다.
예를 들어 MemberService 에서 MemberRepository 의 특정 메소드를 호출하려고 하면, MemberServiceMemberRepository 객체를 의존하게 된다.

fun register(member: Member) {
	memberRepository.save(member)
}

위와 같이 특정 객체의 메소드 호출을 했다면 그 객체를 의존하고 있다고 볼 수 있다.
갑자기 이러한 의존성이야기를 하는 이유는 객체지향에서 의존성이 빠질 수 없지만 의존성을 타고 변경이 전파되기 때문이다. 우리가 다형성을 구현하는 이유는 이러한 변경의 전파를 최소화하기 위함이라고 이해하면 좋을 것 같다.

객체지향 프로그래밍에서 메소드를 호출하는 것을 요청을 보낸다고 표현을 하기도 한다. 즉, MemberService 가 잘 하지 못하는 일을 더 잘 할 수 있는 MemberRepository 에게 요청하는 것이다.

이러한 맥락에서 이 요청을 보내는 순간에는 MemberService 가 Client 객체가 된다

이러한 부분 때문에 SOLID 와 같은 설계원칙을 이해하는데 있어서도 Client 객체로의 변경 전파가 최소화됐는지에 대해서 굉장히 많이 초점을 맞춘다.

리스코프 치환 원칙(LSP) 를 예로들어 살펴보자.
리스코프 치환 원칙은 상속관계의 특징을 정의하기 위한 원칙으로, 서브타입의 객체가 기반타입의 객체로 교체될 수 있어야한다는 원칙이다. (여기서 서브타입과 기반타입은 우리가 지금까지 설명한 타입계층의 자식타입과 부모타입에 해당된다.)

즉, 서브타입과 기반타입의 행동호환성을 말하고 있는 것이다.
다른말로 기반타입이 서브타입에게 물려주는건 행동(What)에 대한 정보이고, 이를 통해서 기반타입과 서브타입의 행동호환성을 만들어야 한다는 것을 의미한다.

기반타입과 서브타입이 행동호환성을 가져야한다고 말하는데 단독으로 봤을 때 "그래서 뭐?" 라는 반응이 나오는게 당연하다. 하지만 그 객체를 의존하는 Client 객체가 등장하면 말이 달라진다.

Client 객체가 의존하고 있는데 기반타입과 서브타입이 행동호환성을 가지게 된다면, Client 객체로 변경의 전파가 발생하지 않고 기반타입과 서브타입을 바꿔치기할 수 있다. 뿐만아니라 서브타입을 또 다른 서브타입으로도 바꿔치기 할 수 있다. 여기서 중요한건 의존하고있는 Client 객체에 변경이 전파되지 않는다는 점이다.

그럼 타입계층을 이용해 다형성이 구현되는 과정을 살펴보자.

class Client(
	private val people: People
) {
	fun test() {
		people.introduce()
	}
}

Client 객체가 People 객체를 의존하고 있다.
그럼 과연 Client 객체가 의존하고 있는건 People 클래스로 생성된 인스턴스일까?

아니다!!

Client 객체가 의존하고 있는 것은 "People 이 할 수 있는 행위를 할 수 있는, 즉 People 과 행위호환성을 가지고 있는 인스턴스" 이다.
그 인스턴스가 꼭 People 일 필요가 없음을 기억해야한다.
People 타입을 대신할 수 있는 타입의 인스턴스라면 누구든 그 자리를 차지할 수 있다.

즉, 컴파일 시점에는 Client 객체가 의존하고 있는 것은 People 객체지만, 실제로 런타임 시점에 의존하게 되는 것은 People 객체가 아니라 People 의 역할을 할 수 있는 객체라면 누구든 될 수 있다는 것이다.

그렇다면 여기서 어떤 타입들이 People 을 대신할 수 있다는 사실을 JAVA/Kotlin 이 어떻게 알 수 있을까?
단순히 똑같은 이름의 메소드가 들어가있기만 하면되는건 아니다. (실제로 Duck Typing 을 지원하는 언어도 있긴하다)
이를 문법적으로 보장해주는 것이 바로 상속과 인터페이스를 통해 만들어낸 타입계층이다.

앞서 살펴봤듯이 타입계층에서 자식타입은 부모타입이 할 수 있는 행위에 대한 정보를 물려받는다. 이 말은 문법적으로 자식타입의 인스턴스가 부모타입에 들어가도 그 일을 모두 해낼 수 있음을 보장한다는 것이다.


한가지 더 중요한 사실은 Client 객체가 실제로 의존하고 있는 인스턴스를 컴파일 시점에 알 수 없다는 것이다.

위에 작성된 예시코드를 보고 Client 객체가 날린 요청 people.introduce() 를 처리하는 실제 인스턴스가 누군지 알 수 있을까?

없다.
없기 때문에 Client 객체와 실제 요청을 처리하는 인스턴스 사이가 낮은 결합도를 가지게 되는 것이고, 그로인해 변경의 유연한 코드가 되는 것이다.

다형성을 구현할수록 코드의 복잡성은 증가한다. 이는 중요한 Trade-off 포인트가 되는데, 다형성을 구현하는 것이 정답이 아님을 명심하자. 변경의 유연성과 코드의 단순함 중에 어떤 게 지금 내 상황에 더 중요한 가치인지 저울질할 필요가 있다.

예시를 한번 들어보자.

내가 레스토랑에 가서 스테이크를 시켰는데 이 스테이크가 어떻게 만들어지는지 관심이 없다고 해보자. 그저 나는 스테이크를 시켰고 내가 주문한 스테이크가 잘 나오기만 하면 된다.

실제로 그 스테이크를 굽는 셰프에 대해서 알 수 없음을 의미한다.
(위 코드와 비슷한 예시임을 이해하고 넘어가자)

그럼 스테이크를 굽는 셰프가 아파서 다른 셰프로 교체되거나, 셰프가 고기가 굽기 귀찮아져서 옆 스테이크 집에서 주문해서 가져다줘도 그러한 변경이 나한테 영향을 주진 않는다.

실제 그 요청을 처리하는 객체가 변경되거나 대체되어도 Client 객체에게 그 변경이 전파되지 않는다는 것을 말하는 것이다.


우리는 다형성이 어떻게 변경에 유연한 프로그램을 만들어 나가는지 살펴봤다.
그리고 이러한 다형성을 구현하기 위해서 타입계층이 왜 핵심적인 역할을 하는지도 살펴봤다.

이 글을 이해했다고 해서 다형성과 객체지향에 대해서 전부 이해했다고 볼 수 없다.
이번 포스팅을 통해서 이제 출입문 앞에 섰다고 생각하면 좋을 것 같다.
앞으로 더 많은 포스팅을 통해 내가 이해하고 있는 객체지향에 대해서 공유해보려고 한다.


타입계층으로 구현된 다형성의 사례


실제로 이러한 내용이 사용된 사례를 보면 이해하는데 더 도움이 될 것 같다.
다양한 사례가 있겠지만 나는 메모리에 데이터를 저장하는 코드에서 Spring Data JPA 를 사용하도록 변경하는 코드를 준비했다!

@Service
class MemberService(
	private val memberRepository: MemberRepository
) {
    fun register(req: MemberRegisterRequest): MemberResponse {
    	memberRepository.findByEmail(req.getEmail())?.let { 
        	throw RuntimeException("중복된 이메일입니다.")
        }
        return memberRepository.save(req.toEntity())
        	.let { MemberResponse.from(it) }
    }
}

Spring Boot & Spring Data JPA 를 이용해 개발해본 개발자라면 위 코드를 쉽게 이해할 수 있을 것이다.

여기서 기억해야할 부분은 MemberService 객체는 MemberRepository 를 의존하고 있고, MemberReopsitory 에게 findByEmail(), save() 요청을 보내고 있다는 것이다. 그리고 당연히 MemberService 가 Client 객체가 된다.

그럼 MemberRepository 가 어떻게 생겼는지 확인해보자.

interface MemberRepository {  // 우선 JPA 를 쓰지 않는다.
	fun findByEmail(email: String): Member?
	fun save(member: Member): Member
}

예상했겠지만 MemberRepository 는 인터페이스다.

의아한 점은 예상과 다르게 MemberRepository 가 Spring Data JPA 를 사용하지 않은 순수한 인터페이스라는 점이다.

즉, MemberService 코드만 봐서는 MemberRepository 가 어떻게 동작하는지 전혀 예상할 수 없음을 의미한다. 이러한 부분이 낮은 결합도로 이어지고 변경에 유연한 코드를 만드는 과정이다.

마지막엔 해당 MemberRepository 를 Spring Data JPA 가 반영된 인터페이스로 바꿔볼 것이다. 그 과정에서 눈여겨봐야 할 것은 그러한 변경이 Client 객체인 MemberService 로 전파되지 않는다는 점이다.

이 부분을 기억한 다음 이후 코드를 읽어나가보자.

어..? 그럼 MemberService 는 인터페이스에 요청을 보내고 있는걸까? 인터페이스에는 해당 요청을 어떻게(How) 처리할지에 대한 정보가 없는데 이 요청은 누가 처리하게 되는거지? 라는 의문이 드는게 정상이다. (아직까지 이 의문을 스스로 해소할 수 없다면 포스팅을 다시보는걸 추천한다!)

위 의문에 대한 답을 얘기해 보자면,
MemberService 가 의존하고 있는건 MemberRepository 의 역할을 할 수 있는 인스턴스이기 때문에 실제로 MemberRepository 의 행위를 물려받은 자식타입의 인스턴스가 있겠지!
라고 생각해야한다!

코드를 뒤져보니 이런 클래스가 있다.

@Repository
class MyMemberRepository: MemberRepository {
	
    private val members: List<Member> = emptyList()
    
    override fun findByEmail(email: String): Member? {
    	return members.filter { it.email == email }
        	.firstOrNull()
    }
   	
    override fun save(member: Member) {
    	members.add(member)
        member.id = members.size()
        return member
    }

}

해당 클래스는 실제 데이터베이스는 연동되지 않고 그냥 메모리에 사용자 정보를 저장하고 조회해주는 객체를 만들어낸다.

해당 클래스의 Bean 이 생성되어 Bean Factory 에 들어가있을테니 MemberService 에서 MemberRepository 역할을 할 수 있는 인스턴스를 주입해달라고 했을 때도 해당 클래스의 인스턴스가 주입될 것이다.

근데 이때 아래와 같은 변경이 생기게된다.
메모리에 저장하면 Spring Boot 를 재실행할 경우 회원정보가 다 날라가게 되니까 우리 데이터베이스를 연동해 Member 정보에 영속성을 부여합시다!!!

그래서 우린 기존에 사용하던 MyMemberRepository 를 제거하고 Spring Data JPA 를 사용하기로 결정했다. 방법은 매우 간단하다 그냥 MyMemberRepository 를 더 이상 Bean 으로 등록되지 않게 만들고 MemberRepository 를 아래와 같이 수정해주기만 하면된다.

interface MemberRepository: JpaRepository<Member, Long> {  // 이제 Spring Data JPA 를 사용!
	fun findByEmail(email: String): Member?
	fun save(member: Member): Member
}

아니, MemberRepsitory 역할을 하는 자식타입의 인스턴스를 Bean 으로 등록해줘야한다고 했는데 이게 다라고? 맞다! 그 과정을 Spring Data Repository 가 해주기 때문에 우린 더 이상 해줄게 없다.
Spring Data 가 해당 인터페이스를 구현(implements) 하는 인스턴스를 만들어 Bean 으로 등록해주게 된다.

자, 그럼 다시 Spring Boot 를 실행시키고 기능을 동작시켜보면 정상적으로 데이터베이스에 Member 정보가 저장되고, 잘 조회되는 것을 확인할 수 있다.

여기서 가장 핵심은 나는 이렇게 큰 변경을 하면서 MemberService 의 코드를 수정하지 않았다는 점이다.

MyMemberRepository 인스턴스와 Spring Data Repository 가 자동으로 생성해주는 인스턴스의 공통점은 모두 MemberRepository 로부터 행위에 대한 정의를 물려받았기 때문에 문법적으로 MemberRepository 의 역할을 할 수 있다고 보장받는다.

그렇기 때문에 MemberService 에 변경을 전파하지 않고 인스턴스가 대체될 수 있었던 것이다.
MemberService 입장에서는 단순한데 MyMemberRepository 인스턴스가 됐든 Spring Data Repository 가 생성해주는 인스턴스가 됐든 관심없이, 그냥 요청한 결과만 잘 오면된다는 것이다. 관심없다고 표현했지만 사실 MemberService 가 실제 요청을 처리하는 인스턴스에 대해서 컴파일 시점에는 알 수도 없다.

지금까지 다형성을 구현함으로써 변경의 전파를 최소화한 대표적인 예시를 살펴봤다.

전파를 최소화했다고 말하는건 메소드 이름이라 파라미터, 응답 자료형이 변경되면 그건 MemberService 로 전파된다. 즉, 아예 변경의 전파를 없앨 수는 없다.

단지 우린 자주 변경되는 것과 그렇지 않은 것을 분리함으로써 추상화 타입을 만들고 Client 객체가 자주 변경되지 않는 추상화 타입을 의존하도록 만드는 것이다.
이렇게 함으로써 변경의 전파를 최소화하게 된다.


마무리


가장 처음 썼던 내가 이 포스팅을 통해서 말하려고 하는 바를 다시 정리하면서 포스팅을 마무리 지으려고 한다.

  1. 상속과 인터페이스는 타입계층을 만들어내기 위한 문법적 도구다.
  2. 타입계층이 중요한 이유는 중복코드제거, 코드의 재사용이 아니라 "행위에 대한 정보를 물려준다" 는 것이다.
  3. 이러한 타입계층은 다형성을 구현하는데 있어서 문법적 기반을 제공한다.
  4. 타입계층이 다형성의 구현에 어떻게 이용되는지 살펴보고, 다형성을 구현함으로써 얻어지는 것들에 대해서 살펴본다.

이 맥락의 흐름이 이해가 안되는 부분이 있다면 포스팅을 다시 읽어보자.
그래도 이해가 안되는 부분이 있다면 코멘트를 달아주면 좋은 피드백이 될 것 같다!

5개의 댓글

comment-user-thumbnail
2023년 8월 18일

공감하며 읽었습니다. 좋은 글 감사드립니다.

답글 달기
comment-user-thumbnail
2023년 8월 22일

결국 유지/보수의 유연함 지향이 변경을 최소화하는 설계로 이어지게 된 것 같네요 ㅎㅎ 잘 읽었습니다.

답글 달기
comment-user-thumbnail
2023년 12월 8일

감사합니다!

답글 달기
comment-user-thumbnail
2023년 12월 19일

개인과제들을 해오면서 다형성과 코드의 간결성 사이가 와닿지 않았었는데 이 글을 보고 정리되는 느낌입니다. 감사합니다👍

답글 달기
comment-user-thumbnail
2023년 12월 20일

최근에 '객체지향의 사실과 오해' 라는 책을 읽고 있는데 '책임과 메세지'라는 파트에서 비슷한 주제를 봤던 것 같습니다! 실제 코드로 보면서 설명을 읽으니 더 좋네요!

답글 달기