개요
리팩터링 스터디 중 궁금한 점이 생겼는데요, 의문이 생긴 맥락으로 글을 시작합니다.
‘코드에서 나는 악취’ 챕터의 기능 편애(Feature Envy)라는 목차에서 다음과 같이 설명합니다:
프로그램을 모듈화할 때는 코드를 여러 영역으로 나눈 뒤 영역 안에서 이뤄지는 상호작용은 최대한 늘리고, 영역 사이에서 이뤄지는 상호작용은 최소로 줄이는 데 주력한다. ‘기능 편애’는 흔히 어떤 함수가 자기가 속한 모듈의 함수나 데이터보다 다른 모듈의 함수나 데이터와 상호작용 할 일이 더 많을 때 풍기는 냄새다
책에서 말하는 이 기능 편애를 해결하기 위해서 나온 방법으로, 함수가 데이터와 가까워하는 의중이 있다면 함수 옮기기를, 함수 일부에서만 기능을 편애할 경우 그 부분만 독립 함수로 빼내는 함수 추출, 그 함수를 원하는 모듈로 옮기는 함수 옮기기라는 기법을 제시하는데, 여기서 위 문단에서 말한 방법(함수 옮기기, 함수 추출..)들을 거스르는 패턴도 언급하며 전략 패턴(Strategy pattern) 과 방문자 패턴(Visitor pattern) 을 소개합니다.
방문자 패턴이 언급되면서 스터디에서 ‘더블 디스패치’가 필요할 경우에 다형성이 양방향으로 적용이 되어야 되는 케이스가 있고, 이를 방문자 패턴같은 구조를 이용하면 멀티플 디스패치를 언어에서 지원되지 않더라도 구현할 수 있다는 말이 오갔는데요, 여기서 궁금한 점이 생겨 의문을 해결하는 과정을 글로 작성해봤습니다.
궁금증이 생긴 포인트이자 글에서 다룰 내용은 다음과 같습니다.
- 정적 디스패치와 동적 디스패치
- 싱글 디스패치와 가상 메서드
- 멀티플 디스패치
- Java에서 멀티플 디스패치를 구현하는 방법
이어서 글을 읽기 위한 사전 지식은 다음과 같습니다.
- 언어의 다형성에 대한 이해가 있으신 분.
- 오버로딩과 오버라이딩의 차이를 알고있으신 분.
디스패치(Dispatch)는 무엇인가?
디스패치의 사전적 의미는 ‘보내다’, ‘파견하다’인데요, ‘컴퓨터 과학’에서 디스패치는 메서드 호출 시 실행할 메서드를 결정하는 과정을 의미합니다.
디스패치는 정적 디스패치와 동적 디스패치로 나뉘는데 코드와 함께 알아보겠습니다.
정적 디스패치(Static Dispatch)
정적 디스패치는 컴파일 타임에 메서드 호출이 결정되는 메커니즘을 말합니다.
Service 객체에 이름이 같은 두 메서드가 있지만(메서드 오버로딩), 어떤 메서드를 호출할지 컴파일러가 메서드의 시그니처(매개변수 타입, 개수)를 기준으로 결정합니다.
컴파일 시점에 컴파일러도 알고 있으며, 컴파일된 바이트 코드에도 그 정보들이 그대로 남아 있어 실제 실행되는 런타임 시점이 되지 않아도 어느 메서드 호출이 일어날 것인가를 결정하게 되죠.
동적 디스패치(Dynamic Dispatch)
반면 동적 디스패치는 컴파일러가 컴파일 타임에는 호출할 메서드를 알 수 없지만, 프로그램이 실행되는 런타임 시점에 실제 객체의 타입에 따라 호출할 메서드가 결정되는 것을 말합니다.
코드를 보면 svc라는 변수의 타입은 그냥 Service이며 이 추상 클래스의 메서드를 그냥 호출하는 것처럼 되어 있지만, 컴파일 시점에서는 무엇을 선택할지 결정하지 못하고 있습니다.
하지만 실제 프로그램을 실행을 해보면(런타임) ‘MyService1’이라는 구체적인 클래스를 정의했기 때문에 클래스 안에 있는 메서드가 실행됩니다.
여기서 MyService1과 MyService2를 리시버 패러미터(receiver parameter)라 합니다.
싱글 디스패치(Multiple Dispatch)와 가상 메서드
싱글 디스패치
싱글 디스패치는 메서드 실행 시 수신 객체(호출된 객체)의 실제 타입만을 기준으로 호출할 메서드를 결정하는 방법입니다. 여기서 메서드의 인자 타입은 고려되지 않습니다.
Java는 기본적으로 싱글 디스패치만을 지원하는 언어입니다. 이는 Java의 메서드 호출 메커니즘과 관련있습니다.
일반 객체 포인터(Ordinary Object Pointer, OOP)
Java의 모든 객체는 OOP라는 객체로 표현되는데요, 객체의 클래스 정보, 해시코드, GC 관련 메타 데이터를 담고 있습니다.
이 OOP를 생성할 때 JVM이 확보한 메모리 영역에서 생성하여 새 메모리 할당을 위한 시스템 콜을 호출할 필요가 없습니다. (오버헤드가 줄겠죠?)
OOP는 instance OOP
와 Klass OOP
가 있습니다. 깊게 파고들어나면 주제에 벗어나기 때문에 내용 이해에 필요한 부분만 설명하겠습니다.
Instance OOP
Instance OOP는 Mark Word와 Klass Word를 포함합니다.
Mark Word는 인스턴스의 메타 데이터를 가리키는 포인터로 해시코드를 포함하며, Klass Word는 클래스의 메타데이터를 가리키는 포인터로, 클래스의 메타 정보에 대한 참조를 저장해둔 데이터 단위로 C++로 작성된 포인터입니다.
Klass OOP와 가상 메서드
가상 메서드는 상속하는 클래스 내에서 같은 메서드 시그니처(Signature)의 메서드로 오버라이딩(Override)될 수 있는 메서드입니다. (가상 함수와 메서드를 혼용되어 사용하지만, Java를 기준으로 설명하기 때문에 가상 메서드로 통일합니다.)
여기서 Java의 메서드 시그니처는 메서드 명과 패러미터 타입을 말합니다. 메서드 시그니처 만으로 메서드를 구분지을 수 있는 근거가 되는 것이죠.
klass word는 내부적으로 메서드에 대한 참조 값을 저장해둔 배열인 가상 메서드 테이블(Virtual Method Table, vtable) 을 가집니다.
가상 메서드 테이블에는 위 그림처럼 객체가 실제로 바라보는 메서드에 대한 참조 정보가 들어있습니다.
Java는 메서드 호출 시 점연산자(오프셋 연산자)를 사용하는데요, JVM은 메모리에 로드된 메서드에 대한 참조 정보를 가상 메서드 테이블의 특정 오프셋(혹은 인덱스)에 저장합니다.
예를 들어 4번 오프셋(배열의 인덱스)에 toString() 이라는 메서드에 대한 참조 정보를 저장하는 것이죠.
또한 메서드는 오버라이드될 수 있는데요, 앞서 언급한 toString() 메서드로 예시를 들어보겠습니다.
Java의 모든 클래스는 Object 클래스를 상속 받습니다.
여기서 슈퍼 클래스(부모 클래스)의 toString() 메서드를 ‘A 참조’라고 부르고 서브 클래스(자식 클래스)의 toString() 메서드를 ‘B 참조’라고 부르겠습니다.
Object 인스턴스가 4번 인덱스에 ‘A 참조’를 저장했다고 가정하면 부모 클래스의 인스턴스와, 자식 클래스의 인스턴스 모두 4번 인덱스에 참조(A,B)를 저장합니다. 이렇게 같은 인덱스에 오버라이드 된 메서드 정보를 저장하기 때문에 Java의 “상속” 구조를 구현할 수 있는 것 입니다.
이렇게 인스턴스가 바라보는 하나의 정보만 저장하기 때문에 서브 클래스의 인스턴스 입장에서는 ‘A 참조’와 ‘B 참조’ 중 어떤 것을 실제로 호출할지 고민할 필요가 없습니다.
이렇게 하나의 배열공간에 같은 오프셋에 저장되는 메커니즘 때문에 오버라이드 된 메서드는 단일 상속밖에 지원하지 않습니다. 참조인 인덱스를 가상 메서드 테이블에 덮어쓰는 방법으로 상속을 구현했기 때문이죠.
여기서 생기는 궁금증
가상 함수를 사용함으로써 얻는 메리트는 무엇일까?
Java의 모든 인스턴스가 메서드 세부사항을 저장하고 있는 것은 매우 메모리 효율상 비효율적일 것입니다.
각 객체가 메서드 구현을 저장하는 대신, 클래스당 하나의 가상 메서드 테이블만 유지하여 인스턴스가 호출할 수 있는 메서드는 모두 동일하게 동작하기 때문에 공통된 장소에 메서드를 올려둔다면(공유) 메모리를 절약할 수 있겠네요.
(JVM의 JIT 컴파일러도 가상함수 테이블을 활용해 메소드 인라이닝, 탈가상화(devirtualization) 같은 최적화를 수행한다고 합니다.)
멀티플 디스패치, 메서드(Multiple Dispatch, Multi method)
멀티플 디스패치는 패러미터가 몇 개든 상관 없이 패러미터의 동적 타입에 따라 가상 함수처럼 동작하여 메서드 호출을 결정하는 메커니즘을 말합니다.
설명은 멀티플 디스패치를 지원하는 Julia
라는 언어로 특징을 알아보겠습니다.
Julia도 Java와 마찬가지로 내부적으로 “메서드 테이블”을 사용해 각 함수에 대해 정의된 모든 메서드의 목록을 저장하는 데요
- 함수 이름으로 메서드 테이블을 찾고
- 인자의 타입을 확인
- 타입에 가장 잘 맞는 메서드를 선택
- 선택된 메서드 실행
위 과정을 거쳐 메서드 호출을 결정하게 됩니다.
Integer 타입의 인자를 받는 함수 process는 Int64나 Int32 타입의 인자도 처리할 수 있습니다.
결과적으로 두 함수 모두 “Processing an integer”가 출력됩니다.
또한 필요한 경우 암시적 형변환을 수행합니다. 여기서 정수 5는 자동으로 Float64로 변환되어 연산이 이루어집니다.
코드를 보면 기존에 정의된 distance 함수를 수정하지 않고도 새로운 타입 조합(Point와 Circle)에 대한 기능을 확장했습니다.
또한 다른 객체지향 언어에서는 보통 상속이나 인터페이스를 통해 다형성을 구현해야 하지만 Julia는 단순히 같은 함수 이름에 다른 타입 시그니처의 메소드를 추가하는 것만으로 다형성을 구현할 수 있습니다.
이처럼 함수를 호출할 때 함수의 이름과 첫 번째 인자(보통 객체)의 타입만을 고려하는 ‘싱글 디스패치’와 달리, ‘멀티플 디스패치’ 방식은 모든 인자의 타입을 고려해서 적합한 함수를 선택하는 특징때문에 엄청난 유연성을 가집니다.
Java에서 멀티플 디스패치를 구현하는 방법
Java는 싱글 디스패치만을 지원하는 언어이기 때문에 ‘멀티플 디스패치’처럼 작동하게 만드려면 디스패치를 2번하게 만드는 더블 디스패치 방식으로 구현해야 합니다.
상황과 예시를 들어보겠습니다.
상황
SNS 플랫폼에 알맞은 포스팅을 만들어주는 서비스를 개발하며 다음과 같은 비즈니스 로직을 가정하고 시작합니다.
- SNS라는 도메인과 Post라는 서비스가 있으며
- SNS의 구현체로는 현재로써는 ‘페이스북’과 ‘트위터’가 있고
- Post는 SNS 객체를 받아서 포스트를 만들어낸다.
- Post의 구현체로 Text와 Picture가 있다.
코드로 나타내면 다음과 같습니다.
이제 비즈니스 로직에서 SNS 플랫폼 별로 포스팅을 해보겠습니다.
코드에서는 getClass() 메서드로 각 SNS의 클래스를 출력하는 동일한 로직이 사용되었습니다.
여기서 SNS 종류 마다 다른 기능을 추가한다고 했을 때 어떻게 변경해야 할까요?
if 분기문과 객체 타입을 확인하는 연산자인 instanceof
로 타입을 분류하여 타입에 맞는 기능을 실행하도록 변경했습니다. 코드 역시 예상대로 작동합니다.
이 상황에서 다른 SNS 플랫폼인 Instagram을 추가해달라는 요구가 들어온다면 어떻게 해야될까요? SNS 인터페이스의 구현체로 Instagram을 추가하면 됩니다.
하지만 코드를 실행하면 컴파일 에러가 발생하게 됩니다. 왜냐면 Picture에는 Instagram의 기능을 추가하는 것을 잊어버렸습니다..
수동으로 Instagram의 기능을 추가하면 코드는 실행되겠지만, SNS 플랫폼이 100개가 넘어간다고 하면 일일이 그걸 다 수정할 수 있을까요?
그래서 if 분기문을 제거하고 기능을 분리하도록 해보겠습니다.
if 문과 instanceof 를 제거하고 메서드 오버로딩을 이용해 기능을 분리한 코드입니다.
하지만 여전히 새로운 SNS가 추가되는 경우에도 Post와 Text, Picture를 모두 수정해야하며 main()는 여전히 컴파일 에러를 뱉습니다.
Post의 postOn() 메서드에 Facebook과 Twitter 객체를 패러미터로 받는 메서드를 정의하고 객체도 넘겼는데, 왜 컴파일 에러가 발생할까요?
그 이유는 메서드 오버로딩은 정적 디스패치를 하기 때문입니다.
오버로딩된 메서드는 컴파일 시점에서 타입 체크를 하고 어떤 메서드를 실행할지 알아야 하는데, main() 메서드의 forEach문의 매개변수로 Facebook이나 Twitter와 같은 구현체의 타입이 아니라 SNS 객체를 넘겨주고 있어, 어떤 메서드를 실행할지 결정할 수 없는 상황입니다.
이를 해결하기 위해 더블 디스패치를 적용해보겠습니다.
더블 디스패치 기법 적용하기
위에서 살펴본 문제점은 ‘오버로딩’을 사용한 정적 디스패치 방식을 사용했기 때문에 SNS 객체를 넘길 수 없다는 것이었죠.
그래서 SNS 타입을 받아 실행할 수 있도록 동적 디스패치를 사용하도록 변경해야 합니다.
먼저 postOn() 메서드를 SNS 타입을 받도록 변경하고, Post의 구현체인 Facebook과 Twitter에 postOn을 오버라이딩 변경했습니다.
이어서 SNS 인터페이스에 post 메서드를 위치시킵니다.
그리고 구현체인 Facebook, Twitter, Instagram에 text와 picture를 패러미터로 받았을 때 해야하는 기능을 추가합니다.
그리고 Text와 Picture에는 패러미터로 전달받은 SNS 객체의 post() 메서드를 호출하고 자기 자신(this)을 패러미터로 넘깁니다.
자 이제 main() 메서드를 실행해보겠습니다.
의도대로 동작하며 기존 코드를 수정하지 않은 채, SNS의 새 구현체를 정의할 수 있습니다. 그림으로 살펴보면:
Post 클래스 구현체중 어떤 클래스의 postOn() 메서드를 사용할지 결정(Dynamic Dispatch 1회)하고
postOn에 인자로 선택된 SNS를 구현한 클래스에서 어떤 post() 메서드를 사용할지 결정(Dynamic Dispatch 2회)하여 동적 디스패치를 두 번 하게 됩니다.
여기서 SNS의 구현체인 FaceBook, Twitter, Instagram 클래스가 비즈니스 로직을 직접 구현하기 때문에 추후에 새로운 구현체가 추가되더라도 Post 클래스는 변경이 없습니다.
즉 확장에 대해 열려 있어야 하고, 변경에는 닫혀 있어야 하는 객체 지향의 개방-폐쇄 원칙을 지킬수 있게 되죠!
맺음
Java에 대해 공부를 꽤 했다고 생각했는데 아직도 모르는 부분이 이렇게 많다는 것을 보면 정말 배움의 길은 끝이 없다는 것을 느꼈습니다.
글을 작성하기 전에는 언어에서 다형성을 지원한다는 것과 상속 방식의 이점에 대해 두루뭉술하게 알고 있었다면, 이번 공부를 통해 내부를 들여다보면서 객체 지향 언어 패러다임의 철학과 Java라는 언어의 한계점을 알 수 있게된 의미있는 시간이 되었습니다.
읽어 주셔서 감사하고 틀린 내용이나 피드백이 있다면 언제든 달게 받겠습니다.