본문 바로가기

JAVA/JAVA 이론

[JAVA 이론] 추상화(Abstraction)

 

[JAVA 심화]

1. 상속(Inheritance)

2. 캡슐화(encapsulation)

3. 다형성(Polymorphism)

4. 추상화(Abstraction)


개요

자바 객체지향 프로그래밍의 네 가지 주요 특성 중 마지막 특성인 추상화에 대해 공부해 보도록 하겠습니다.


학습목표

  • 추상화의 핵심 개념과 목적을 이해하고 설명할 수 있다.
  • abstract 제어자가 내포하고 있는 의미를 이해하고, 어떻게 사용되는지 설명할 수 있다.
  • 추상 클래스의 핵심 개념과 기본 문법을 이해할 수 있다.
  • final 키워드를 이해하고 설명할 수 있다.
  • 자바 추상화에서 핵심적인 역할을 수행하는 인터페이스의 핵심 내용과 그 활용을 이해할 수 있다.
  • 추상 클래스와 인터페이스의 차이를 설명할 수 있다.

추상화

"추상" 이라는 용어의 사전적 의미를 보면 "사물이나 표상을 어떤 성질, 공통성, 본질에 착안하여 그것을 추출하여 파악하는 것"이라고 정의합니다.

여기서 핵심적인 개념은 공통성, 본질을 파악 및 추출하는 것입니다.

즉, 추상화는 기존의 클래스들의 공통적인 속성과 기능을 정의하여 하위 클래스들을 생성하는 것과 반대로 하위 클래스들의 공통성을 모아 상위 클래스를 정의할 수 있는 것을 말합니다.

위의 그림을 보면 computer와 microwave의 공통적인 요소를 모아 electronic product라는 클래스에 담았습니다.

반대로 electronic product가 가지는 공통적인 특징을 computer와 microwave에 내려줬다고 생각해도 공식은 유효합니다.

위처럼 공통적인 속성과 기능을 모아서 정의함으로써 코드의 중복을 줄이고, 효과적으로 클래스 간의 관계를 설정할 수 있으며, 유지/보수가 용이해집니다.

자바 프로그래밍에서는 주로 추상 클래스와 인터페이스라는 문법 요소를 사용해서 추상화를 구현합니다.


abstract 제어자

abstract의 사전적 의미는 "추상적인"이라는 뜻입니다. 자바의 맥락에서 abstract가 내포하는 의미는 "미완성"이라 정의할 수 있습니다.

abstract는 주로 클래스와 메서드를 형용하는 키워드로 사용되는데, 메서드 앞에 붙은 경우를 '추상 메서드(abstract method)', 클래스 앞에 붙은 경우를 '추상 클래스(abstract class)'라 부릅니다.

만약 클래스에 추상 메서드가 포함되어 있는 경우 해당 클래스는 자동으로 추상 클래스가 됩니다.

아래 예제 코드를 보겠습니다.

abstract class AbstractExample {	// 추상 메서드가 최소 하나 이상 포함돼있는 추상 클래스
	abstract void start();	// 메서드 바디가 없는 추상 메서드
}

abstract의 가장 핵심적인 개념은 앞서 언급한 '미완성'에 있습니다.

 

추상 메서드는 위 예제 코드와 같이 바디가 없는 메서드를 의미하는데, abstract 키워드를 메서드 이름 앞에 붙여주어 해당 메서드가 추상 메서드임을 표시합니다.

추상 클래스는 앞서 말한 대로 '미완성'이기에 메서드 바디가 완성이 될 때까지 이를 기반으로 객체 생성이 불가합니다.


추상 클래스

추상 클래스란, 메서드 시그니처만 존재하고 바디가 선언되어있지 않은 추상 메서드를 포함하는 클래스입니다.

그렇기에 이를 기반으로 인스턴스를 생성하는 것은 불가능합니다.

그렇다면 인스턴스도 생성하지 못하는 미완성 클래스를 만드는 이유는 무엇일까요?

이는 크게 두 가지로 알 수 있습니다.

  1. 추상 클래스는 상속 관계에 있어 새로운 클래스를 작성하는데 매우 유용하다.
  2. 추상 클래스는 자바 객체지향 프로그래밍의 마지막 주요 특성인 추상화를 구현하는데 핵심적인 역할을 수행한다.

이 두 가지를 자세히 알아보도록 하겠습니다.

1. 추상 클래스는 상속 관계에 있어 새로운 클래스를 작성하는데 매우 유용하다.

하위 클래스는 상속받는 상위 클래스에 따라서 메서드의 내용이 종종 달라지기 때문에 상위 클래스에서는 선언부만을 작성하고, 실제 구체적인 내용은 상속을 받는 하위 클래스에서 구현하도록 비워둔다면 편리하게 메서드를 작성할 수 있습니다.

이때 우리가 사용하게 되는 것이 '오버라이딩'입니다.

오버 라이딩에 대한 설명은 아래의 링크에서 확인할 수 있습니다.

 

[JAVA 심화] 상속

[JAVA 심화] 1. 상속(Inheritance) 2. 캡슐화(encapsulation) 3. 다형성(Polymorphism) 4. 추상화(Abstraction) 개요 자바 심화에서는 자바의 4가지 주요 원칙인 상속성, 캡슐화, 다형성, 추상화에 대해 공부하고자 합

developingman.tistory.com

오버라이딩을 통해 추상 클래스로부터 상속받은 추상 메서드의 내용을 구현하여 메서드를 완성시킬 수 있고, 이렇게 완성된 클래스를 기반으로 해당 객체를 생성할 수 있습니다.

예제 코드와 함께 공부해 봅시다.

abstract class Person {
	public String job;
    public abstract void talent();
}

class programmer extends Person{ // Inheritance by Person class
	public programmer(){
    	this.job = "프로그래머";
    }
    
    public void talent(){		// method overiding -> Creating a method body
    	System.out.println("coding");
    }
}
class Singer extends Person{	// Inheritance by Person class
	public singer(){
    	this.job = "가수";
    }
    
    public void talent(){		// method overiding -> Creating a method body
    	System.out.println("singing");	
    }
}
class Test{
	public static void main(String[] args) throws Exception {
    	Person programer = new Programmer();
        programmer.talent();
        
        Singer singer = new Singer();
        singer.talent();
    }
}
coding
singing

위의 코드 예제를 보면 상속을 받는 하위 클래스는 오버라이딩을 통해 각각 상황에 맞는 메서드 구현이 가능하다는 장점이 있습니다.

2. 추상 클래스는 자바 객체지향 프로그래밍의 마지막 주요 특성인 추상화를 구현하는데 핵심적인 역할을 수행한다

추상화를 한마디로 정리하면 "객체의 공통적인 속성과 기능을 추출하여 정의하는 것"이라 정리하였습니다.

앞선 예제 코드를 다시 보면 사람이 가지는 공통적인 특성을 모아 먼저 추상 클래스로 선언을 해주었고, 이를 기반으로 각각의 상속된 하위 클래스에서 오버라이딩을 통해 클래스의 구체적인 속성과 기능을 정의해 주었습니다.

이러한 방법을 통해 공통된 속성과 기능임에도 불구하고 각각 다른 변수와 메서드로 정의돼서 발생되는 오류를 미연에 방지할 수 있습니다.

이는 즉, 상속계층도의 상층부에 위치할수록 추상화의 정도가 높고 그 아래로 내려갈수록 구체화된다고 정리해 볼 수 있습니다. (상층부에 가까울수록 더 공통적인 속성과 기능들이 정의)


final 키워드

final 키워드는 필드, 지역 변수, 클래스 앞에 위치할 수 있으며 그 위치에 따라 그 의미가 조금씩 달라지게 됩니다.

위치 의미
클래스 변경 또는 확장 불가능한 클래스, 상속 불가
메서드 오버라이딩 불가
변수 값 변경이 불가한 상수

위의 표를 확인해 보면, 각각 조금의 차이점은 있지만 결국 공통적으로 변경이 불가능하고 확장할 수 없게 한다는 점에서 유사할 수 있습니다.

final class FinalEX {	// 확장/상속 불가능한 클래스
	final int x = 1;	// 변경되지 않는 상수
    
    final int getNum(){	// 오버라이딩 불가한 메서드
    	final int localVar = x;	// 상수
        return x;
    }
}

각각의 클래스, 메서드, 변수 앞에 final 제어자가 추가되면 해당 대상은 더 이상 변경이 불가하거나 확장되지 않는 성질을 지니게 됩니다.


인터페이스

인터페이스(interface)는 "-간/사이"를 뜻하는 inter와 "얼굴/면"을 의미하는 face의 결합으로 구성된 단어로, 두 개의 다른 대상 사이를 연결한다는 의미를 가지고 있습니다.

자바에서의 인터페이스는 추상 클래스처럼 자바에서 추상화를 구현하는 데 활용된다는 점에서 동일하지만, 추상클래스에 비해 더 높은 추상성을 가진다는 점에서 큰 차이가 있습니다.

추상 클래스는 추상 메서드를 하나 이상 포함한다는 점 외에는 일반 클래스와 동일하다고 할 수 있습니다.

반면 인터페이스는 추상 메서드와 상수만을 멤버로 가질 수 있다는 점에서 추상 클래스에 비해 추상화 정도가 더 높다고 할 수 있습니다.

인터페이스의 기본 구조

인터페이스를 작성하는 것은 기본적으로 클래스를 작성하는 것과 유사하지만, class 키워드 대신 interface 키워드를 사용한다는 점에서 차이가 있습니다.

또한 일반 클래스와는 다르게, 내부의 모든 필드가 public static final로 정의되고, static과 default 메서드 이외의 모든 메서드가 public abstract로 정의된다는 차이가 존재합니다.

 

다만 모든 인터페이스의 필드와 메서드에는 위의 요소가 내포되어 있기 때문에 따로 명시하지 않아도 생략이 가능합니다.

예제 코드를 함께 확인해 보도록 하겠습니다.

public interface InterfaceEx{
	pulbic static final int rock = 1;	//  interface instance variable definition Variables
    final int scissors = 2;				// omitting public static
    static int paper = 3;				// omitting public & final
    
    public abstract String getPlayingNum();
    	void call()		// omitting public abstract
}

위의 예제 코드를 보면 인터페이스는 interface 키워드를 이용하여 만들어지고 구현부가 완성되지 않은 추상 메서드와 상수만으로 구성되어 있습니다.

인터페이스 안에서 상수를 정의하는 경우 반드시 public static final로, 메서드를 정의하는 경우에는 public abstract로 정의되어야 하지만 위에서 보시는 것처럼 일부분 또는 전부 생략이 가능합니다.

인터페이스의 구현

추상클래스와 마찬가지로 인터페이스도 그 자체로 인스턴스를 생성할 수 없고, 메서드 바디를 정의하는 클래스를 따로 작성해야 합니다.

과정은 상속과 기본적으로 동일하지만 인터페이스는 extends가 아닌 implements 키워드를 사용한다는 점에서 차이가 있습니다.

class className implements interfaceName {
	.... // abstract method body shall be implemented
}

특정 인터페이스를 구현한 클래스는 해당 인터페이스에 정의된 모든 추상메서드를 구현해야 합니다.

즉, 인터페이스를 구현한다는 것은 인터페이스의 추상 메서드를 반드시 구현하여야 한다를 의미합니다.

다른 말로, 인터페이스를 구현하려면 모든 추상메서드들을 해당 클래스 내에서 오버라이딩하여 바디를 완성하여야 한다는 것을 의미합니다.

 

인터페이스의 다중 구현

클래스 간의 상속에서는 다중 상속은 허용되지 않습니다.

즉 하위 클래스는 단 하나의 상위 클래스만 상속받을 수 있습니다.

반면 인터페이스는 다중적 구현이 가능합니다.

즉, 하나의 클래스가 여러 개의 인터페이스를 구현할 수 있습니다. 다만 인터페이스는 인터페이스로부터만 상속이 가능하고, 클래스와 달리 Object 클래스와 같은 최고 조상이 존재하지 않습니다.

class ExampleClass implements Interface1, Interface2, Interface3 {
	...
}

 

다음의 예제코드를 통해 확인해 보겠습니다

interface Person {		// interface declaraton and public abstract can be omitted
	public abstract void greeting();
}

interface Do{
	void behavior();
}

class Nom implements Person, Do { // Multiple implemetations of Person and Do interface
	public void greeting(){ // method overiding
    	System.out.println("안녕하시렵니까");
    }
    public void behavior(){
    	System.out.println("코딩합니다");
    }
}

public class MultiInterface {
	public static void main(String[] args){
    	Nom nom = new Nom();
        nom.greating();
        nom.behavior();
    }
}

// 출력 결과
안녕하시렵니까
코딩합니다

위의 예제를 보면 Nom 클래스는 Person과 Do 인터페이스를 다중으로 구현하여 각각 객체에 맞는 메서드를 오버라이딩하고 그 내용을 출력값으로 돌려주고 있습니다.

 

그렇다면 왜 인터페이스는 클래스와 달리 다중 구현이 가능할까요?

 

클래스에서 다중 상속이 불가능했었던 이유는 만약 부모 클래스에서 동일한 이름의 field 또는 method가 존재하는 경우 에러가 발생하기 때문이었습니다.

반면 인터페이스는 애초에 미완성된 멤버를 가지고 있기 때문에 충돌이 발생할 여지가 없고, 따라서 안전하게 다중 구현이 가능합니다.

마지막으로, 특정 클래스는 다른 클래스로부터의 상속을 받으면서 동시에 인터페이스를 구현할 수 있습니다

 

아래의 예제 코드를 함께 보겠습니다.

abstract class Person { // abstract class
	public abstract void greating();
}
interface Job {
	public abstract void do();
}

class Nom extends Person implements Job { // this class inherits the Person class. It is also implemented with a Job interface
	public void greating(){ 	//Overiding the method of the Person class
    	System.out.println("hello");
    }
    public void do(){
    	System.out.println("i code all the time");
    }
}

public class MultiInheritance {
	public static void main(String[] args) {
    	Nom nom = new Nom();
        
        nom.greating();
        nom.do();
    }
}

// 출력 결과
hello
i code all the time

위의 코드 예제에서는 기존의 Person 인터페이스를 추상 클래스로 바꾸고 Nom 클래스가 Person 클래스를 상속받아 확장함과 동시에 Job 인터페이스를 구현하도록 하여 위와 같은 결과가 출력되도록 하였습니다.

 


인터페이스 활용 예제

코드 예제를 통해 인터페이스를 왜 사용하고, 인터페이스가 가지는 장점이 무엇인지에 대한 내용들을 살펴보도록 하겠습니다.

먼저 인터페이스를 사용하지 않는 경우에 발생할 수 있는 어려움을 가상의 시나리오를 통해서 알아보고, 인터페이스가 이를 어떻게 보완할 수 있는지에 대해서 설명하도록 하겠습니다.

 

먼저 시나리오입니다.

놈은 편의점을 운영합니다
단골손님들은 매일 사는 물품이 정해져 있습니다.
단골손님A는 항상 바나나우유를 구매합니다.
단골손님B는 항상 커피를 구매합니다.

위의 내용을 바탕으로 예제 코드를 작성해 봅니다.

 

//costomer
public class Customer {
	public String customerName;
    
    public void setCustomerName(Stirng customerName){
    	this.customerName = customerName;
    }
}

public class CA = extends Customer{
}

public class CB = extends Customer{
}

// convenience store owner
public class Nom {
	public void giveItem(CA ca){
    	System.out.println("give a banana milk to customerA");
    }
    	public void giveItem(CB cb){
    	System.out.println("give a coffee to customerB");
    }
}

// order for goods
public class Order{
	public static void main(String[] args){
    	Nom n = new Nom();
        CA a = new CA();
		CB b = new CB();        
    }
}

// 출력 결과
give a banana milk to customerA
give a coffee to customerB

위 예제를 보면 손님 A와 B가 올 때 메서드 오버로딩을 사용하여 giveItem 메서드를 호출하고 Order 클래스에서 객체를 생성하여 실행시키면 위와 같은 출력 결과를 확인할 수 있습니다.

 

그런데 만약 손님이 두 명이 아니라 더 늘어난다면 어떨까요?

이 경우 Nom 클래스에 오버로딩한 메서드를 만들어줘야 합니다. 

이럴 때 인터페이스를 활용하여 예제 코드를 작성해 보도록 하겠습니다.

먼저 class 키워드 대신 interface 키워드를 사용하여 Customer 인터페이스를 생성합니다.

public interface Customer {
	// constant
    // abstract method
}

다음으로 손님 A, B 클래스를 인터페이스 Customer를 이용하여 정의합니다.

public class CA implements Customer {
}

public class CB implements Customer {
}

위의 인터페이스 없이 작성한 예제 코드에서는 손님의 수가 늘어날수록 구현 클래스를 계속 추가하여 만들어줘야 하지만, 인터페이스를 이용한다면 추가적인 손님이 등장할 때마다 매번 새롭게 메서드를 작성해야 하는 번거로움을 없앨 수 있습니다.

하지만, 현재 작성된 코드로는 각 단골손님이 좋아하는 물건이 무엇인지 알기 어렵습니다.

이 문제를 해결하기 위해 기존의 인터페이스에 getOrder라는 추상 메서드를 인터페이스 Customer에 추가하고 이를 활용하여 코드를 재작성해 봅시다.

public interface Customer {
	public abstract String getOrder();
}

public class CA implements Customer{
	public String getOrder(){
    	return "banana milk";
    }
}

public class CB implements Customer{
	public String getOrder(){
    	return "coffee";
    }
}

그리고 Nom 클래스를 재정의하여 매개변수로 Customer 타입이 입력될 수 있게끔 만들어주면, 매개변수의 다형성에 의해 Customer를 통해 구현된 객체 모두가 들어올 수 있습니다.

public class Nom{
	public void giveItem(Customer customer){
    	System.out.println("Item : " customer.getOrder());
    }
}

다시 Order 클래스를 통해 테스트를 해보면 다음과 같은 결과를 얻을 수 있습니다.

모든 코드를 보고 확인해 보도록 하겠습니다.

interface Customer {
	String getOrder(); //ommiting public abstract
}

class CA implements Customer {	
	public String getOrder(){	//method overiding
    	return "banana milk";
    }
}

class CB implements Customer {
	public String getOrder(){ //method overiding
    	return "coffee";
    }
}

class Nom{
	public void giveItem(Customer customer){
    	System.out.println("Item: "+ customer.getOrder());
    }
}

public class Order{
	public static void main(String[] args){
    	Nom nom = new Nom();
        Customer cA = new CA();
        Customer cB = new CB();
        
        cafeowner.giveItem(cA);
        cafeowner.giveItem(cB);
    }
}

// 출력 결과
Item: banana milk
Item: coffee

최초에 인터페이스를 사용하지 않았을 때 손님 수만큼 giveItem() 메서드가 필요했던 Nom 클래스가 Customer 인터페이스 사용 후에 단 한 개의 giveItem 메서드로 구현이 가능해졌습니다.

여기서 중요한 부분은 메서드의 개수가 줄었다는 점보다는 Nom 클래스가 더 이상 손님에게 의존적인 클래스가 아닌 독립적인 기능을 수행하는 클래스가 되었다는 점입니다.


이번 글을 마지막으로 자바 객체지향 프로그래밍의 네 가지 주요 특성을 모두 공부하였습니다.

앞으로 실습을 통하여 지금까지 배운 캡슐화, 추상화, 상속성, 다형성을 익숙해지는 연습을 해볼까 합니다.

감사합니다.