지난 글에 이어서 클래스와 모듈에 대한 이야기를 이어가 보자. 우선 믹스인(Mixin)에 대해 좀 더 자세히 살펴 보자.
믹스인은 다중 상속의 문제점을 없애면서도 다수의 모듈로부터 기능을 동시에 물려 받을 수 있도록 해준다.
특정 클래스에서 다수의 모듈을 동시에 믹스인하더라도 ancestors 메서드를 이용하여 상속 계층을 확인해 보면 바로 상위 부모는 하나가 되는데 이것이 다중 상속의 문제점을 없애는 해결책인 것이다.
즉 다수의 모듈을 동시에 믹스인하더라도 실제 믹스인되는 순서에 따라 상속 계층에서 해당 모듈의 순서가 정해진다.
아래 그림처럼 include를 한 번만 사용하여 M1, M2 모듈을 믹스인하면 include 에 전달한 순서 그대로 상속 계층 순서에 반영되지만 M1, M2 모듈을 따로따로 include 하여 믹스인하면 나중에 믹스인된 모듈이 상속 계층 순서에서 먼저 믹스인된 모듈 보다 앞에 위치하게 된다.
?> module M1
?> def foo
?> puts "모듈 M1에서 정의한 'foo' 메서드입니다"
?> end
>> end
=> :foo
?> module M2
?> def foo
?> puts "모듈 M2에서 정의한 'foo' 메서드입니다"
?> end
>> end
=> :foo
>>
?> class C1
?> include M1, M2
>> end
=> C1
>>
>> C1.ancestors
=> [C1, M1, M2, Object, Kernel, BasicObject]
>> C1.new.foo
모듈 M1에서 정의한 'foo' 메서드입니다
=> nil
>>
?> class C2
?> include M1
?> include M2
>> end
=> C2
>>
>> C2.ancestors
=> [C2, M2, M1, Object, Kernel, BasicObject]
>> C2.new.foo
모듈 M2에서 정의한 'foo' 메서드입니다
=> nil
상속 계층에서의 순서는 중요한 의미를 갖는데 이 순서가 결국은 메서드를 찾는 순서를 결정하기 때문이다.
위의 예에서 C1 과 C2 클래스에는 foo 인스턴스 메서드를 직접 정의하지 않았으므로 foo 메서드를 호출했을 때 상속 계층을 따라 foo 메서드를 찾는 과정을 거치게 된다.
C1 클래스의 상속 계층 목록에서 C1 클래스 바로 앞에는 M1 모듈이 있고 C2 클래스의 상속 계층 목록에서 C2 클래스 바로 앞에는 M2 모듈이 있다.
그래서 C1 클래스의 인스턴스에 대해 호출한 foo 메서드는 M1 모듈에서 정의한 'foo' 메서드를 호출한 게 되고 C2 클래스의 인스턴스에 대해 호출한 foo 메서드는 M2 모듈에서 정의한 'foo' 메서드를 호출한 게 된다.
엄밀히 말하면 클래스와 클래스 사이에서만 상속이 허용되므로 ancestors 메서드가 보여주는 결과는 상속 계층이라기 보다는 메서드 탐색 순서(경로)라고 보는 것이 맞다.
아래 두 그림을 보면 C2 클래스는 C1 클래스를 상속받았고 'C2.ancestors' 결과에서도 당연히 C1 클래스가 C2 클래스 보다 앞에 나오지만 M1 과 M2 모듈은 C1 클래스와 C2 클래스가 각각 어떤 모듈을 믹스인했느냐에 따라 순서가 다르게 나온다.
?> module M1
>> end
=> nil
?> module M2
>> end
=> nil
?> class C1
>> end
=> nil
?> class C2 < C1
>> end
=> nil
>> C1.include M1
=> C1
>> C2.include M2
=> C2
>> C2.ancestors
=> [C2, M2, C1, M1, Object, Kernel, BasicObject]
?> module M1
>> end
=> nil
?> module M2
>> end
=> nil
?> class C1
>> end
=> nil
?> class C2 < C1
>> end
=> nil
>> C1.include M2
=> C1
>> C2.include M1
=> C2
>> C2.ancestors
=> [C2, M1, C1, M2, Object, Kernel, BasicObject]
조금 더 복잡한 예를 보자. 아래처럼 M2 모듈이 M1 모듈을 믹스인하고 C2 클래스가 C1 클래스를 상속함과 동시에 M1 모듈을 믹스인한 후 C2.ancestors 결과를 보면 자연스러워 보인다. 그러나 만약 C1 클래스가 M2 모듈을 믹스인하게 되면 C2.ancestors 결과를 단순히 '상속' 계층이라 보기는 어려울 것이다.
왜냐하면 M1 모듈이 M2 모듈의 조상이면서 후손이 되기 때문이다.
그래서 ancestors 메서드의 결과 목록은 메서드 탐색 순서(경로)로 보면되고 그 안에 있는 클래스들만이 실제 상속 관계를 맺는다고 이해하자.
다시 정리하자면 클래스 간 '상속'과 모듈의 '믹스인' 을 통해 코드(속성과 메서드)를 재활용할 수 있는데 어떤 클래스를 상속하느냐 그리고 어떤 모듈을 어디에서 어떤 순서로 어떻게 믹스인 하느냐에 따라 실제 메서드를 탐색하는 경로가 달라지게 된다.
?> module M1
>> end
=> nil
?> module M2
?> include M1
>> end
=> M2
>>
>> M2.ancestors
=> [M2, M1]
>>
?> class C1
>> end
=> nil
?> class C2 < C1
?> include M1
>> end
=> C2
>>
>> C2.ancestors
=> [C2, M1, C1, Object, Kernel, BasicObject]
>> C1.include M2
=> C1
>> C2.ancestors
=> [C2, M1, C1, M2, M1, Object, Kernel, BasicObject]
어떤 클래스에 어떤 모듈을 믹스인하게 되면 해당 클래스의 인스턴스에 대해서 믹스인한 모듈이 정의한 메서드를 호출할 수 있게 된다는 걸 알게 되었다.
그런데 그 클래스가 이미 동일한 메서드를 정의하고 있다면 어떻게 될까? 'C.ancestors' 를 통해 알 수 있듯이 메서드 탐색 시 가장 먼저 C 클래스에서 찾게 된다.
즉 믹스인한 모듈이 갖고 있는 메서드들 중에서 원하는 어떤 메서드를 클래스에서 재정의할 수 있는 것이다.
그리고 super 를 통해 다시 메서드 탐색 경로 상에서 자신의 앞에서부터 같은 이름의 메서드를 찾아 호출할 수도 있다.
?> module M
?> def foo
?> puts "모듈 M에서 정의한 'foo' 메서드입니다"
?> end
>> end
=> :foo
?> class C
?> include M
?> def foo
?> puts "클래스 C에서 정의한 'foo' 메서드입니다"
?> super
?> end
>> end
=> :foo
>>
>> C.ancestors
=> [C, M, Object, Kernel, BasicObject]
>> C.new.foo
클래스 C에서 정의한 'foo' 메서드입니다
모듈 M에서 정의한 'foo' 메서드입니다
=> nil
그렇다면 반대로 어떤 클래스에 이미 정의되어 있는 메서드와 동일한 메서드를 갖는 모듈을 믹스인하면서 그 모듈의 메서드가 먼저 호출되도록 할 수는 없을까?
아래 예를 보자. C 클래스가 prepend 메서드를 이용하여 M 모듈을 믹스인했더니 메서드 탐색 경로 상에 C 클래스 보다 M 모듈이 먼저 나오게 되었다.
그리고 원하는 대로 C 클래스의 인스턴스에 대해 호출한 foo 메서드는 모듈 M 이 정의한 foo 메서드를 호출하게 되었다.
?> module M
?> def foo
?> puts "모듈 M에서 정의한 'foo' 메서드입니다"
?> end
>> end
=> :foo
?> class C
?> def foo
?> puts "클래스 C에서 정의한 'foo' 메서드입니다"
?> end
>> end
=> :foo
>>
>> C.prepend M
=> C
>> C.ancestors
=> [M, C, Object, Kernel, BasicObject]
>> C.new.foo
모듈 M에서 정의한 'foo' 메서드입니다
=> nil
믹스인과 관련하여 끝으로 extend 라는 게 있는데 이것은 믹스인이 되는 모듈의 인스턴스 메서드를 믹스인을 하는 클래스 또는 모듈에서 클래스 메서드로 호출할 수 있도록 해준다. 아래 그림을 보면 C 클래스 안에서 extend 메서드를 통해 모듈 M을 믹스인했고 C 클래스는 모듈 M 에서 정의한 'foo' 메서드를 클래스 메서드로서 호출할 수 있게 되었다.
그리고 'C.ancestors' 가 보여주는 메서드 탐색 경로에는 모듈 M 이 포함되지 않는 것을 볼 수 있다. 이것은 클래스 C의 인스턴스 메서드 호출에 대한 탐색 경로이고 모듈 M의 인스턴스 메서드가 클래스 C의 인스턴스 메서드로 믹스인된 게 아니라 클래스 C의 클래스 메서드로 믹스인되었기 때문이다.
그런데 C 클래스도 Class 클래스의 인스턴스이기 때문에 따지고 보면 클래스 메서드라고 하는 것도 결국 인스턴스 메서드이기도 하지 않은가? 그렇다면 클래스 메서드 역시 어떤 다른 메서드 탐색 경로를 따라 메서드 호출이 결정될 것이고 그 경로 상에서는 모듈 M 이 있을 것도 같다. 이 부분에 대해서는 다음 글에서 파헤쳐보기로 하자.
?> module M
?> def foo
?> puts "모듈 M에서 정의한 'foo' 메서드입니다"
?> end
>> end
=> :foo
?> class C
?> extend M
>> end
=> C
>>
>> C.foo
모듈 M에서 정의한 'foo' 메서드입니다
=> nil
>> C.ancestors
=> [C, Object, Kernel, BasicObject]
오늘은 모듈이 갖는 중요한 기능 중 하나인 믹스인(Mixin)에 대해 자세히 알아보았다. 다음 글에서는 메서드 탐색 경로에 대해 조금 더 깊이 살펴본 후 모듈이 필요한 또 다른 이유인 '네임스페이스' 에 대해서도 알아보겠다.
See you again~~