객체 지향의 특징 '상속'
상속은 객체 지향 특징 4개 중 하나로, 자식 클래스가 부모 클래스를 상속받아 사용하는 것을 의미한다. 자식 클래스는 부모 클래스가 가지고 있는 멤버나 메서드에 접근할 수 있고, 추가 연산이 가능하다. 자식 클래스만 사용할 수 있는 개인적인 메서드도 새로 생성할 수 있고, 부모 클래스의 메서드를 오버라이딩하여 재정의할 수 있다.
객체 지향 프로그래밍을 사용하면서 상속은 위와 같은 이유로 많이 적용된다. 상속은 인터페이스와도 깊은 연관이 있다. 인터페이스는 실제로 동작하는 코드를 가지고 있지 않고, 인터페이스를 사용하기 위해서는 다른 클래스에서 인터페이스를 상속받아 사용해야 한다.
인터페이스를 사용하기 위해서는 implements 키워드를 이용하여 무조건 상속이 적용되는 것이다.
인터페이스를 상속하는 것에 대한 의미
인터페이스 구현은 상속이 꼭 필요하다. 상속은 인터페이스가 아닌 일반 클래스와 클래스 사이에서도 가능하다. 상속은 인터페이스라는 정의가 꼭 필요하지 않지만, 인터페이스를 사용하기 위해서는 상속이 꼭 필요한 것이다.
그러나 좋은 인터페이스를 구현하고 객체 지향 원칙을 지키기 위해서는 반대로 생각해야 한다.
상속 개념을 다시 되돌아보면서 인터페이스를 떠올려보자.
외부 객체 시점에서 인터페이스 상속
인터페이스를 상속받은 자식은 그 부모를 포함하고 있다. 자식은 부모에 포함된 정보를 모두 사용할 수 있다. 외부에서 볼 때는 자식을 부모와 동일한 타입으로 간주한다. List
객체를 생성할 때 List의 자식인 ArrayList
로 인스턴스 할 수 있는 것을 생각해보자. 우리는 인터페이스인 List에 접근할 수 없지만 그 자식을 통해서 List에 접근할 수 있다. 사용자 입장에서는 List와 ArrayList가 동일한 타입으로 보기 때문에 List<> list = new ArrayList<>();
가 가능하다.
List<Integer> list = new ArrayList<>();
System.out.println(list.getClass());
// class java.util.ArrayList
우리는 List의 add()
를 사용하기 위해 메시지를 전달한다. 그러나 우리가 인스턴스한 클래스는 ArrayList이다. ArrayList에 있는 overriding
된 add()
를 호출한다. 그냥 add()라는 연산을 실행시키기만 하면 되며, 어떤 클래스의 인스턴스인지 우리는 신경쓰지 않는다.
이렇게 사용자가 부모와 자식을 함께 사용해도 오류가 나지 않는 건, 컴파일러가 부모가 나오는 모든 곳에 자식 클래스 사용을 허용하고 있기 때문이다.
위 예시 코드처럼 자식 클래스가 부모 클래스를 대신하는 것을 업캐스팅(upcasting)이라고 한다.
클래스 다이어그램을 그릴 때 부모가 자식보다 위에 위치하고, 화살표로 이어주는 모습에서 더 up한다고 한다.
메시지 전달과 메서드 사용
메시지는 메서드와 다르다. 우리는 add()를 사용할 때 부모에게 메시지를 전달한다.

ArrayList로 인스턴스한 list1과 LinkedList로 인스턴스한 list2가 있다. 둘 모두 List 인터페이스를 상속받고 있다.
두 변수에 add()를 실행시켰고, 해당 add()에 마우스를 올리면 java.util.List<E>
가 뜨고 있다. 지정한 리스트 끝에 요소를 추가하는 것으로, list1에서도 list2에서도 모두 동작한다.
여기서 주의해서 봐야할 점은 메시지와 메서드 사용이다. 우리는 부모인 List에게 add()를 실행할거라는 메시지를 전달했다. 그리고 실제 동작은 인스턴스된 객체 클래스에서 진행된다. ArrayList.add()와 LinkedList.add()는 서로 다른 클래스에서 동작하는 서로 다른 메서드다. 하지만 둘은 동일한 메시지를 전달하고 있다.
같은 동작과 같은 메시지여도 그 메서드를 실행하는 것은 다른 클래스에 있고, 이 과정이 가능한 것은 같은 부모인 List를 상속하고 있기 때문이다. 객체 지향 프로그래밍의 특징 중 하나인 다형성이 여기서 적용되는 것이다.
객체 지향의 특징 '다형성'
다형성은 하나의 객체가 여러 타입을 가질 수 있는 것을 의미한다. 우리는 방금 인터페이스 상속에 대해 들어가다가 다형성을 맞이했다. 다형성은 상속과 인터페이스 개념을 기반으로 나오는 특징인 것이다.
컴파일 시간 의존성 vs 실행 시간 의존성
다형성 적용은 컴파일 시간 의존성과 실행 시간 의존성이 다르다는 특징이 있다. 위에서 작성한 코드를 다시 가져와 이 의존성에 대입하면 아래 표와 같다.
컴파일 시간 의존성 | 상속받는 추상 클래스에 의존하여 컴파일에 결정 | List |
실행 시간 의존성 | 실제 구현된 클래스에 의존하여 실행될 메서드를 런타임에 결정 | ArrayList, LinkedList |
다형성의 개념을 천천히 대입하면 이해할 수 있다. list1와 list2는 add()를 호출했을 때 객체에 따라서 다르게 응답한다. 하지만 이 두 객체 모두 List라는 인터페이스를 상속받고 있어 같은 메시지를 이해한다. 같은 List에서 ArrayList, LinkedList라는 두 개의 타입으로 나타나는 것이 다형성이다.
add()를 실행하면 구현된 클래스(인스턴스한 클래스)에서 메서드를 실행한다. 런타임 시점에 실행될 메서드를 결정하고 있다. 컴파일되는 시점에서는 상속받는 추상 클래스에 의존하지만, 메서드를 실행할 때는 런타임 시점에서 구현 클래스에 의존한다.
지연 바인딩 lazy binding, 동적 바인딩 dynamic binding
메서드 실행 시점에 바인딩 되는 것
초기 바인딩 early binding, 정적 바인딩 static binding
컴파일 시점에 실행될 함수나 프로시저가 결정되는 것
상속을 하는 목적과 종류
1. 구현 상속 implementation inheritance (서브 클래싱 subclassing)
구현을 목적으로 하는 상속을 구현 상속이라고 한다. 단순히 코드를 재사용하기 위해 상속을 사용하는 것.
2. 인터페이스 상속 interface inheritance (서브 타이핑 subtyping)
다형성 적용을 위해 인터페이스를 공유하는 상속을 인터페이스 상속이라고 한다. 상속받은 클래스가 인터페이스를 공유하여 인터페이스 재사용을 하는 것으로, '코드'가 아닌 '인터페이스'임에 주목해야 한다.
Java의 interface는 구현에 대한 고려 없이 다형적인 협력에 참여하기 위해 제공하는 인터페이스 상속을 목적으로 한다.
좋은 인터페이스를 설계하는 방법
'캡슐화'를 적용하여 '추상화'
객체 지향의 특징 나머지 두 개가 여기서 등장한다. 캡슐화를 통해 추상화를 한다.
캡슐화는 속성과 행위를 하나로 묶어, 내부 구현은 감추어 은닉하는 것이다. 추상화는 핵심적인 요소만 추출하여 단순하게 만드는 것을 의미한다. 캡슐화를 통해서 추상적인 인터페이스를 구현하는 것이 좋은 인터페이스 구현의 핵심이다.
아까 List를 사용한 예시 코드에서 우리는 add()가 어떻게 동작하는 그 내용을 보지 않고 사용할 수 있었다. 사용자는 하고 싶은 동작이 '어떻게 수행되는 지' 알고 있지 않아도 동작을 수행할 수 있어야 한다. 그저 사용할 '목적'만 생각하고 그 목적에 맞는 동작을 선택한다. 자세히 알지 않아도 그저 '목적'만 가지고 사용하는 것, 이것이 바로 추상화이다.
추상화를 지키면서 내부 구현을 사용자에게 알리지 않게 해야 한다. 사용자 관점에서 '어떻게 동작되는 가'에 대한 관심은 필요 없다. 우리는 동작이 실행되는 내부 구현은 외부가 알지 못하게 감추어야 한다. 내부 동작이 외부에 노출되는 순간 정보 은닉에도 실패한 것이다.
이 캡슐화와 추상화를 지키며 인터페이스를 설계하는 것이 큰 핵심이고, 좋은 인터페이스를 구현하는 길이다. 만약 이 두가지가 지켜지지 않으면 어떤 일이 발생하게 되고, 어떻게 해결해야 할까?
디미터 법칙 Law of Demeter
인터페이스 설계를 위해 제안된 법칙 중 가장 대표적인 것은 디미터 법칙이다. 디미터 법칙은 캡슐화를 위해 고안된 것으로, 낮은 결합도를 만들어 나간다. 디미터 법칙은 객체 내부가 강하게 결합되지 않도록 협력 경로를 제한한다.
객체 내부라고 하면 하나의 모듈 안에 있는 응집도가 아닌가? 라고 생각할 수 있다. 여기서 말하는 객체 내부는 객체와 다른 객체에서 서로 각자의 내부가 결합되지 않도록 하는 것으로, 모듈과 모듈 사이의 결합도가 맞다.
서로 협력하는 경로를 제한하면 결합도를 낮출 수 있다고 말한다. 특정 조건을 만족하는 대상에만 메시지를 전송하는 것이다. 객체가 메시지를 선택하는 것이 아닌 메시지가 사용할 객체를 선택한다. (책임 주도 설계 방법)
메시지 수신자 내부 구조는 전송자에게 노출시키지 않고, 메시지 전송자는 수신자의 내부 구현에 결합되지 않아야 한다. 조금 더 쉽게 풀어보면 호출되는 객체는 메서드의 내부 구조를 알리지 않는다. 객체를 호출할 때는 그 메서드 내부 구조에 따라서 움직이지 않아야 한다.
dot연산자를 사용해 메서드를 호출할 때, instance.getmethod().startmethod()
처럼 기차가 줄로 연결되어 이어가는 것과 같은 형태는 자제해야 하는 것이다. 단순히 start를 하고 싶으면 우리는 그냥 start()만 사용해야 한다. 객체에서 get을 통해 다른 것을 가져오고, start를 하는 방식은 이미 우리가 그 구조를 알고 있기 때문에 연결해서 사용하는 것이다. 캡슐화가 이루어지지 않고 있다.
호출하는 사용자 입장에서는 객체 상태에 대해서 묻지 않아도 사용할 수 있어야 한다. 상태를 기반으로 결정하고, 그 상태를 변경하는 건 내부 로직을 알고 있는 캡슐화에 위반된 것이다. 객체의 정보와 행동은 클래스 내부에 위치하고 있어야 하며, 사용자는 그저 상태를 묻기 보다는 행동을 요청만 해야 한다.
구현과 관련된 모든 정보는 캡슐화하고, 인터페이스에는 사용과 관련된 것만 표현하여 추상화를 시켜야 한다.
기능은 최소로 구성
꼭 필요한 기능만 포함하고 있어야 한다. 비슷한 기능을 하거나, 중첩된 기능을 하는 건 필요 없다.
꼭 필요한 기능이 서로 겹치지 않게 구성되어야 한다.
외부에 대한 일관성 유지
동작을 구현할 때 다른 것과 비슷하게 작동하도록 구현해야 한다. 어디서나 같은 일을 같은 방식으로 처리해야 하는 것이다. 꼭 필요한 기능을 최소로 구성하라고 했지만, 우리가 사용하는 c언어나 c++, java에서도 같은 결과를 보여주는 기능이 중복된다.
Queue<Integer> queue = new LinkedList<>();
queue.add(1);
queue.add(2);
// [1, 2]
queue.poll();
// [2]
queue.remove();
// []
위 코드는 자바에서 Queue
를 사용하는 것으로 poll()
과 remove()
를 비교하는 코드이다. Queue는 먼저 들어간 데이터가 먼저 삭제되는 FIFO 형태의 자료구조로 자바에서는 Queue의 자식인 LinkedList
를 통해서 구현한다. add()를 이용해 Queue에 값 두개를 넣어주었고, 각각 poll()과 remove()를 이용하면 같은 결과를 볼 수 있다.
remove()와 poll()의 위치를 바꿔서 실행시켜도 같은 결과가 나온다. 이 두개의 메서드는 Queue안에서 요소를 삭제하는 기능을 하고 있다. Queue가 비어있어 빼야 하는 값이 없을 때, remove()는 예외를 발생하고, poll()은 null을 반환하는 것으로 완전 같은 기능을 하는 것은 아니다. 하지만 단순히 '삭제'를 위해서 사용하고 싶을 때는 이 두 가지 중 무엇을 사용해도 상관 없다.
poll()도 remove()도 삭제가 주된 기능이며, 인자로 아무것도 받지 않는다. 비슷하게 작동하는 것이 한 눈에 보인다.
인터페이스에서 예외 처리는?
1. 에러는 저수준에서, 처리는 고수준에서
호출자가 에러 처리를 결정한다. 호출이 되는 곳에서는 에러를 잡아서 넘기기만 하면 된다.
그 에러에 대한 처리는 호출하는 곳에서 결정한다.
2. 정말 예외적인 상황에만 사용
예외는 제어 흐름을 건드리는 방식이기 때문에 정말 예외적인 경우가 아니라면 남용을 자제해야 한다.
예외가 발생할 부분에 모두 예외 처리를 해주면 그만큼 에러가 덜 생기지만, 단순한 제어문으로 위험을 피할 수 있거나 처리할 수 있다면 꼭 예외처리를 사용하지 않아도 된다. 정말 예외적인 경우에 사용하도록 하자.
3. 예외를 하나로 묶는 것보다 따로 구분
예외를 하나씩 구분하는 것이 귀찮아서 Exception 하나로 퉁치는 경우가 있다.
하지만 좀 더 나은 객체 지향 프로그래밍을 설계하기 위해서는 예외를 명확히 구분해야 한다.
사용자에게 더 명확한 예외를 알려 안전하게 동작하도록 해야 한다.
reference
조영호. 「오브젝트: 코드로 이해하는 객체지향 설계」. 위키북스(2019).
Brian W. Kernighan, Rob Pike. 「프로그래밍 수련법」. 김정민, 장혜식, 신성국. 인사이트(2008).
'Language > JAVA' 카테고리의 다른 글
객체 지향 5대 원칙 - SOLID 이해하기 (0) | 2025.01.10 |
---|---|
객체 지향 프로그래밍(Object-Oriented-Programming) 자세히 이해하기 (0) | 2025.01.08 |
[자바/JAVA] 자바로 그래프(Graph) 직접 구현해보기 -인접 행렬, 인접 리스트 (1) | 2025.01.03 |
[자바/JAVA] HashSet을 사용해서 정렬이 되는 이유 찾아보기 (2) | 2025.01.03 |
[JAVA] 깊은 복사와 얕은 복사 코드로 직접 확인하기 -python과 비교 (1) | 2024.12.31 |