객체지향의 특성 중 상속에 대해 기록해보려한다. 상속은 두 객체 사이에서 정의되는 관계다. 두 객체는 다음과 같다.
- 상속을 해주는 객체: 상위, 부모, 혹은 super 객체
- 상속을 받는 객체: 하위, 자식, (this) 객체
상속을 하는 객체는 하위 객체에게 필드 또는 메서드를 전달할 수 있다. 하위 객체가 상위 객체의 접근 제한에 따라 상위 객체의 필드 또는 메서드에 접근하고 사용할 수 있는 것이 객체지향의 특성인 상속이다.
부모 객체, 그리고 자식 객체는 다른 말로 추상 객체, 그리고 구체 객체라고 할 수 있다. 상속에 관해선 바로 이 추상과 구체의 개념적인 요소들을 기록해보려한다.
추상 객체와 구체 객체
흔히들 부모 또는 상위, 그리고 자식 또는 하위 객체라고 하지만, 상속의 관계에 놓여진 두 객체를 가장 잘 정의하는 것은 추상과 구체라는 키워드라고 생각한다.
부모 객체는 추상 객체다. 하위 객체인 자식 객체에 비해서 상대적으로 추상적이기에, 추상 객체라고 한다. 자식 객체는 구체 객체다. 상대적으로 추상적인 부모 객체를 더욱 구체적으로 구현된 객체이기 때문에 구체 객체라고 한다.
이렇게 추상 객체, 구체 객체라는 생각과 멀어지면 오해가 생기기 쉽다.
상속에 대한 오해
공통된 기능을 여러 하위 객체에게 전달하고 싶을 때 상속을 접근하는 경우도 있다. 이해가 쉽도록 자바 코드를 준비해봤다.
class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User getUserById(Long id) {
return userRepository.findById(id)
.orElseThrow(EntityNotFound::new);
}
public void checkDuplicateById(Long id) {
if (!Objects.nonNull(getUserById(id))) {
throw new DuplicateKeyException();
}
}
}
class TicketService {
private final TicketRepository ticketRepository;
private final UserRepository userRepository;
public TicketService(TicketRepository ticketRepository, UserRepository userRepositroy) {
this.ticketRepository = ticketRepository;
this.userRepository = userRepository;
}
public User getUserById(Long id) {
return userRepository.findById(id)
.orElseThrow(EntityNotFound::new);
}
public Ticket getTicketOfUser(Long userId) {
User user = getUserById(id);
return ticketRepository.findByUser(user);
}
}
사용자와 사용자의 티켓을 찾는 서비스를 작성했다. 사용자 서비스와 티켓 서비스 모두 사용자를 찾는 getUserById라는 기능을 사용하기에, 이를 공통 코드로 빼고 중복 코드를 줄이기 위해 새로운 상위 클래스를 만들어 상속해보겠다.
class UserReadService {
protected final UserRepository userRepository;
public UserReadService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User getUserById(Long id) {
return userRepository.findById(id)
.orElseThrow(EntityNotFound::new);
}
}
class UserService extends UserReadService {
public UserService(UserRepository userRepository) {
super(userRepository);
}
public void checkDuplicateById(Long id) {
if (!Objects.nonNull(getUserById(id))) {
throw new DuplicateKeyException();
}
}
}
class TicketService extends UserReadService {
private final TicketRepository ticketRepository;
public TicketService(TicketRepository ticketRepository, UserRepository userRepositroy) {
super(userRepository);
this.ticketRepository = ticketRepository;
}
public Ticket getTicketOfUser(Long userId) {
User user = getUserById(id);
return ticketRepository.findByUser(user);
}
}
이렇게 공통적인 기능을 기준으로 상속을 진행했다. 중복된 코드도 깔끔하게 제거한 듯 하고 나쁘지 않아보인다. 하지만 이렇게 기능으로 상속을 사용하는 것이 바로 상속에 대한 오해다.
상속의 기준 - 추상화
상속의 기준은 상위 객체가 하위 객체에게 비해 더 일반적이고 추상적인 개념이어야 한다.
짱구로 예를 들어보겠다.
짱구라는 객체가 있다고 가정하자. 짱구보다 추상적인 객체는 무엇이 있을까? 일반적인 범위를 확대했을 때 짱구는 남자다. 남자라는 개념은 짱구보다 더 일반적이고 추상적이다. 짱구는 남자로 분류될 수 있는, 구체적인 남자다. 즉, 짱구는 남자의 구체적인 부분집합이다.
비슷하게 남자에서 그 범위를 확대했을 때 더 일반적인 것은 사람이 있겠다. 사람보다 더 추상적인 것은 포유류다. 짱구와 남자, 사람, 그리고 포유류의 관계를 그림으로 나타내면 아래와 같다.
포유류는 사람으로 상속할 수 있고, 사람은 남자로 상속할 수 있으며, 남자는 짱구로 상속할 수 있다. 이와 같이 상속의 관계에서 부모와 자식 관계는 자식 객체가 부모 객체에서 더욱 구체적이어야 하는 관계, 개념적으로 추상과 구체의 관계에서 상속의 관계를 맺는 것이 객체지향에서의 올바른 상속 관계다.
추상과 구체의 관계도 개념적인 관계이기 때문에 애매모호한 경우가 있다. 짱구는 남자어린이다. 남자어린이는 남자이기에, 짱구와 남자와의 상속 관계 사이에 남자 어린이가 들어가면 어떨까? 이 또한 올바른 상속 관계라고 여겨질 수도 있고, 아닐 수도 있다.
남자어린이의 기준은 명확하지 않다. 누군가는 초등학생까지 어린이라 할 것이고 누군가는 중학생이라고 할 것이다. 일반적으로 그 기준이 애매모호하지 않다면, 만약 애매하더라도 공통적으로 합의된 절대적인 기준이 프로그램 상에 존재한다면 올바른 상속 관계를 만들 수 있다.
상속은 OOP의 다른 특성들과 연관이 깊다. 접근 제어를 잘못하면 상위 객체의 정보 은닉과 캡슐화가 망가질 수 있고, 다형성 또한 바람직한 동작에서 멀어질 수 있다. 이를 잘 정의한 것이 SOLID 원칙 중 L에 해당하는 LSP(Liskov Substitution Princple), 리스코프 치환 원칙이다. 이에 관해선 다음에 상술해보겠다.
References
프로그래머스 백엔드 데브코스
'객체지향 프로그래밍' 카테고리의 다른 글
객체지향의 특성 - 다형성 (0) | 2024.05.24 |
---|---|
객체지향의 특성 - 추상화 (0) | 2024.05.22 |
객체지향의 특성 - 캡슐화 (정보은닉) (0) | 2024.05.21 |
객체지향 프로그래밍(OOP) 이야기 (1) | 2024.05.20 |
단일 책임 원칙 이야기 (2) - 책임의 범주 (1) | 2024.01.21 |