𝝅번째 알파카의 개발 낙서장

screen

[OOP] 객체지향의 특징 - 상속(Inheritance)

posts

CS

시리즈 톺아보기

객체지향

객체지향
count

상속 (Inheritance) 🔗

나른한 주말, 느긋하게 영화를 보고 있는 A씨. 영화에선 천대받으며 살던 주인공 소녀가 어느날 누군가로부터 거액의 유산을 받았다. 알고보니 그는 어렸을 적 실종된 재벌집 가문의 손녀딸이였던 것! 이후 그녀는 받은 유산을 통해 고마웠던 주변인에게 은혜를 갚고, 무시하던 이들에게 통쾌한 복수를 돌려준다는 내용이였다.

나름 통쾌했던 A씨지만, 어차피 현실에선 일어날 수 없는 일이라는 걸 이내 떠올린 A씨. 우리는 이걸 상속이라 부른다. 이처럼 상속이라는 개념은 영화나 드라마와 같은 창작물에서나 볼 수 있었다. 사전에서나 찾아볼 수 있었던 허구의 개념인 셈이다.

평범하게 살던 내가 콤퓨타 이세카이에선 상속자???!!?!?!

하지만 객체지향 언어에서는 누구나 필요에 의해 쉽게 상속받을 수 있다!

객체지향 역시 동일한 개념이 존재한다. 객체지향에서의 상속이란 객체가 다른 객체를 상속받아 상속받은 객체의 요소를 사용하는 것을 의미한다.

이 때 객체를 상속받은 객체는 자식, 상속된 객체는 부모라 칭한다.

자식 객체는 상속된 부모 객체의 은닉화 구성에 따라 정해진 변수, 메소드에 접근할 수 있다. 또한 부모 객체가 추상 객체일 경우 추상 메소드오버라이딩(Overriding)을 통해 부모 객체의 메소드를 구현하거나 다룰 수 있다.

추상 객체 🔗

추상 객체는 하나 이상의 추상 메소드를 포함하는 객체다.

JAVA

0abstract public class Main
1{
2 // 메소드
3}

JAVA로 표현한 추상 클래스는 위와 같으며, 클래스의 맨 앞에 abstract 키워드를 적어 해당 객체가 추상 객체임을 표현할 수 있다.

추상 메소드 🔗

추상 메소드는 자식 객체에서 구현해야하는 메소드다.

JAVA

0abstract public class Main
1{
2 public void normalMethod()
3 {
4 System.out.println("일반 메소드");
5 }
6
7 abstract public void abstractMethod();
8}

위는 JAVA로 표현한 추상 객체다. normalMethod()은 일반적인 메소드고, abstractMethod()는 추상 메소드다. 추상 메소드는 일반적인 메소드와 큰 차이가 있는데, 메소드의 동작이 기술되어있지 않다.

추상 메소드의 구현은 자식 객체가 담당하며, 아래 단계에서 이루어진다.

  • 추상 객체의 인스턴스 생성 시
  • 추상 객체를 상속받을 시

일반적인 메소드는 자신의 객체에서 선언되어있다. 하지만 추상 메소드의 경우, 추상 객체를 할당받으려는 객체에서 선언이 이루어진다. 이 경우 어떤 메리트가 있을까?

예를 들어, 부모 객체 Main과 이를 상속받은 자식 객체 Sub가 있다고 가정하자. 만약 동작 구조 상 abstractMethod()에서 자식 객체의 변수나 메소드를 사용해야만 한다면?

normalMethod() 처럼 동작이 이미 부모 객체에 선언되는 경우 자식 객체의 요소를 반영하기가 매우 어렵다. 인스턴스를 생성하는 방법도 있겠지만 어떤 객체를 상속받을 지 알 수 없는 경우, 예상되는 객체의 인스턴스를 전부 할당받아놓는 게 아니라면 불가능에 가깝다. 그리고 이 방법의 경우 메모리 낭비가 너무 심해진다.

반면 abstractMethod() 같은 추상 메소드의 경우 자식 객체에서 구현되기 때문에 자식 객체의 변수나 메소드에 직접적으로 접근할 수 있다. 때문에 자식 객체의 요소를 활용해서 동작을 구현해야 할 경우, 해당 메소드를 추상으로 정의하면 자식 객체의 특성에 맞게 구현하기 용이하다.

추상 메소드 구현 - 인스턴스 생성 시 🔗

JAVA를 통해 Main의 인스턴스를 Sub에서 생성해보자.

JAVA

0public class Sub
1{
2 public void run()
3 {
4 Main main = new Main()
5 {
6 @Override
7 public void abstractMethod()
8 {
9 System.out.println(text());
10 }
11 }
12 }
13
14 private String text()
15 {
16 return "Sub 객체의 요소";
17 }
18}

원래대로라면 abstractMethod() 메소드는 Sub 객체의 text()에 접근할 수 없다. text()private 접근제어자를 가지기 때문이다.

하지만 추상 메소드의 경우 구현이 Sub에서 이루어지기 때문에 Sub의 모든 요소에 직접적으로 접근할 수 있다. 즉, private 같은 내부 메소드까지 전부 접근 가능하다.

추상 메소드 구현 - 상속 시 🔗

JAVA를 통해 MainSub에 상속시켜보자.

JAVA

0public class Sub extends Main
1{
2 @Override
3 public void abstractMethod()
4 {
5 System.out.println(text());
6 }
7
8 private String text()
9 {
10 return "자식 객체 Sub의 요소";
11 }
12}

부모 객체에 추상 메소드가 있을 경우, 자식 객체는 이를 반드시 오버라이딩해야한다. 그러지 않을 경우 컴파일 오류를 일으킨다.

마찬가지로 메소드의 구현이 자식 객체에서 이루어지므로, 자식 객체의 모든 요소에 접근할 수 있다.

추상 메소드는 이처럼 구현의 주체를 자식 객체에게 전가함으로써, 자식 객체의 요소에 제한없이 접근할 수 있다. 원래라면 public 등으로 열어줬어야 함에도 자식 객체 내부에서 구현이 이루어지기 때문에 접근제어자를 변경할 필요가 없다.

상속의 예제 🔗

JAVA를 통해 객체의 상속이 어떤식으로 이루어지고, 어떤식으로 사용되는지 알아보자.

JAVA

0import java.util.Date;
1
2/**
3 * 컴퓨터 추상 클래스
4 *
5 * @author RWB
6 * @since 2021.08.06 Fri 21:19:19
7 */
8abstract public class Computer
9{
10 private final String OS;
11
12 /**
13 * Computer 생성자 함수
14 *
15 * @param os: [String] OS 이름
16 */
17 public Computer(String os)
18 {
19 this.OS = os;
20 }
21
22 /**
23 * 시작 함수
24 */
25 public void startup()
26 {
27 System.out.println(new StringBuilder().append(OS).append(" - started at ").append(new Date().toString()));
28 }
29
30 /**
31 * 종료 함수
32 */
33 public void shutdown()
34 {
35 System.out.println(new StringBuilder().append(OS).append(" - shutdown at ").append(new Date().toString()));
36 }
37
38 /**
39 * 동작 추상 함수
40 */
41 abstract public void run();
42}

여기 Computer라는 추상 객체가 존재한다. 이 객체는 OS라는 상태와 startup, shutdown, run이라는 동작을 가진다.

이 중 run은 좀 특별한데, 동작은 적혀있으나, 어떤식으로 동작하는지에 대한 명세는 정해져있지 않다.

이는 추상 객체의 특징 중 하나로, 추상 객체는 하나 이상의 추상 메서드를 포함할 수 있다. 추상 메서드는 구현되지 않은 메서드로, 동작의 개념 정도로만 이해하면 된다. 추상 메서드의 구현은 해당 객체를 상속받은 자식 객체에서 이루어진다. 즉, run 추상 메소드는 자식마다 제각각으로 구현된 동작을 수행한다.

아래의 두 클래스 AsusDellComputer 추상 클래스를 상속받은 자식 클래스다.

JAVA

0/**
1 * ASUS 컴퓨터 클래스
2 *
3 * @author RWB
4 * @since 2021.08.06 Fri 21:24:50
5 */
6public class Asus extends Computer
7{
8 /**
9 * Asus 생성자 함수
10 *
11 * @param os: [String] OS 이름
12 */
13 public Asus(String os)
14 {
15 super(os);
16 }
17
18 /**
19 * 동작 함수
20 */
21 @Override
22 public void run()
23 {
24 System.out.println("ASUS 작업 수행");
25 }
26}

JAVA

0/**
1 * DELL 컴퓨터 클래스
2 *
3 * @author RWB
4 * @since 2021.08.06 Fri 21:26:46
5 */
6public class Dell extends Computer
7{
8 /**
9 * Dell 생성자 함수
10 *
11 * @param os: [String] OS 이름
12 */
13 public Dell(String os)
14 {
15 super(os);
16 }
17
18 /**
19 * 시작 함수
20 */
21 @Override
22 public void startup()
23 {
24 super.startup();
25
26 System.out.println("시스템 안정화 수행");
27 }
28
29 /**
30 * 종료 함수
31 */
32 @Override
33 public void shutdown()
34 {
35 System.out.println("시스템 프로세스 정리 수행");
36
37 super.shutdown();
38 }
39
40 /**
41 * 동작 함수
42 */
43 @Override
44 public void run()
45 {
46 System.out.println("DELL 작업 수행");
47 }
48}

AsusDell 모두 Computer를 상속받았음을 확인할 수 있다. 또한 모두 run 함수가 제각각 구현된 것 역시 확인할 수 있다.

그런데 Asus와 달리 Dell은 부팅 시와 종료 시 각각 시스템의 안정성을 위한 사전/후 작업이 추가됐다.

이러한 사전/후 작업을 구현하기 위해 startup, shutdown을 오버라이딩한다. 이 과정을 통해 시작과 종료 함수에 각각 원하는 동작을 추가한다.

super?
자식 클래스에서 부모 클래스를 호출할 때 super 키워드를 이용해 호출한다. Dell의 오버라이딩 메소드 동작에서 활용됨을 알 수 있다. super.shutdown()은 부모 클래스 Computer의 메소드인 shutdown()을 호출한다.

JAVA

0/**
1 * 메인 클래스
2 *
3 * @author RWB
4 * @since 2021.06.14 Mon 00:06:32
5 */
6public class Main
7{
8 /**
9 * 메인 함수
10 *
11 * @param args: [String[]] 매개변수
12 */
13 public static void main(String[] args)
14 {
15 Dell dell = new Dell("Windows 10 Pro");
16 Asus asus = new Asus("Ubuntu 21.04");
17
18 dell.startup();
19 dell.run();
20 dell.shutdown();
21
22 System.out.println();
23
24 asus.startup();
25 asus.run();
26 asus.shutdown();
27 }
28}

OUTPUT

0Windows 10 Pro - started at Fri Aug 06 22:54:39 KST 2021
1시스템 안정화 수행
2DELL 작업 수행
3시스템 프로세스 정리 수행
4Windows 10 Pro - shutdown at Fri Aug 06 22:54:39 KST 2021
5
6Ubuntu 21.04 - started at Fri Aug 06 22:54:39 KST 2021
7ASUS 작업 수행
8Ubuntu 21.04 - shutdown at Fri Aug 06 22:54:39 KST 2021

AsusDell의 메소드를 순서대로 수행하면 위와 같은 결과가 나온다. Dell의 시작, 종료 간 시스템 동작이 수행됨을 확인할 수 있다.

정리 🔗

객체지향은 모든 객체의 모듈화를 추구한다. 좋은 모듈화는 캡슐화, 은닉화가 적절히 구현되고 유지되는 것을 지향한다.

하지만 포장이 견고하면 뜯기 어렵듯이, 탄탄한 모듈화는 모듈이 경직된다. 재사용의 범위가 제한되는 것 뿐만 아니라, 이를 이용한 확장 또한 어려울 것이다. 만약 객체지향에 이 두 개념만 있었다면 개발자는 재사용성과 모듈화를 적절히 타협하며 객체를 구현했을 것이다.

하지만 상속이라는 개념의 존재로 인해 객체에 지정된 모듈화를 전혀 해치지 않으면서 재사용성, 확장성을 보장받을 수 있다. 객체지향의 모듈화로 인한 딜레마를 상쇄하는 키치한 개념이 아닐 수 없다. 개인적으로는 객체지향의 특징 중 가장 중요한 특징이라고 생각한다. 물론 객체지향 중에서도 매우 어려운 개념이지만, 이를 잘 이해하면 조금 더 객체지향다운 코드를 짤 수 있을 것이다.