공부/전자컴퓨터공학

Java 추상화(Abstraction) 란? 자바 기초 배우기

AhJustC 2024. 5. 28. 00:59
반응형
추상화

추상화는 객체의 공통적인 속성과 기능을 추출하여 정의하는 것을 의미한다. 상속과는 반대로 기존 클래스들의 공통적인 요소들을 뽑아 상위 클래스를 만들어내는 것이라고 할 수 있다.

위의 그림을 보면 자동차와 오토바이의 공통적인 분모들을 모아 이동수단이라는 클래스에 담았다. 이렇게 공통적인 속성과 기능을 모아 정의해주면 코드의 중복을 줄일 수 있고 보다 효과적으로 클래스 간의 관계를 설정할 수 있으며 유지/보수가 용이해진다. 자바에서는 주로 추상 클래스와 인터페이스라는 문법 요소를 사용해 추상화를 구현한다.

 

abstract 제어자

abstract 제어자란 주로 클래스와 메서드를 형용하는 키워드로 사용되는데 메서드 앞에 붙은 경우 추상메서드(abstract method), 클래스 앞에 붙은 경우를 추상 클래스(abstract class)라 각각 부른다. 어떤 클래스에 추상 메서드가 포함되어 있는 경우 해당 클래스는 자동으로 추상 클래스가 된다.

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

abstract의 가장 핵심적인 개념은 미완성 이라는 것이다. 추상 메서드는 메서드의 시그니처만 있고 바디가 없는 메서드를 의미하는데 abstract 키워드를 메서드 이름 앞에 붙여주어 해당 메서드가 추상 메서드임을 표시한다. 즉 추상 메서드는 충분히 구체화되지 않은 미완성 메서드이며, 미완성 메서드를 포함하는 클래스는 미완성 클래스를 의미하는 추상클래스가 된다.

AbstractExample abstractExample = new AbstractExample(); // 에러발생.

마지막으로 추상 클래스는 미완성 설계도이기 때문에 메서드 바디가 완성되기 전까지 이를 기반으로 객체 생성이 불가능하다.

추상 클래스

추상 클래스란 메서드 시그니처만 존재하고 바디가 선언되어있지 않은 추상 메서드를 포함하는 미원성 설계도 임을 알았다. 이를 기반으로 객체를 생성하는 것이 불가능 한 것도 알았다. 그럼 왜 미완성 클래스를 만드는 것일까? 크게 두 가지 이유가 있다.

첫 번째로, 추상 클래스는 상속 관계에 있어 새로운 클래스를 작성하는 데 매우 유용하다. 메서드의 내용이 상속을 받는 클래스에 따라서 종종 달라지기 때문에 상위 클래스에서는 선언부만을 작성하고, 실제 구체적인 내용은 상속을 받는 하위 클래스에서 구현하도록 비워둔다면 설계하는 상황이 변하더라도 보다 유연하게 변화에 대응할 수 있다.

abstract class Animal {
	public String kind;
	public abstract void sound();
}

class Dog extends Animal { // Animal 클래스로부터 상속
	public Dog() {
		this.kind = "포유류";
	}

	public void sound() { // 메서드 오버라이딩 -> 구현부 완성
		System.out.println("멍멍");
	}
}

class Cat extends Animal { // Animal 클래스로부터 상속
	public Cat() {
		this.kind = "포유류";
	}

	public void sound() { // 메서드 오버라이딩 -> 구현부 완성
		System.out.println("야옹");
	}
}

class DogExample {
    public static void main(String[] args) throws Exception {
       Animal dog = new Dog();
       dog.sound();

       Cat cat = new Cat();
       cat.sound();
    }
 }

// 출력값
멍멍
야옹

먼저 Animal 클래스 안에 abstract 키워드를 사용한 sound() 메서드가 추상 메서드로 선언되었고, 따라서 이를 포함하는 Animal 클래스 또한 abstract 키워드를 사용하여 추상 클래스로 만들어주었다. 그 이후 추상클래스 Animal을 상속받은 Dog클래스와 Cat 클래스 안에 추상메서드 sound()를 각각 오버라이딩하여 각 객체에 맞는 구현부를 완성해주었고, 마지막으로 이렇게 완성된 클래스를 기반으로 dog 인스턴스와 cat 인스턴스를 생성하여 sound() 메서드를 호출했다. 그 결과 출력값으로 각각 멍멍과 야옹이라는 값이 반환 되었다.

추상클래스를 사용하면 상속을 받는 하위 클래스에서 오버라이딩을 통해 각각 상황에 맞는 메서드 구현이 가능하다.

반응형

두 번째로, 추상클래스는 추상화를 구현하는데 핵심적인 역할을 수행한다. 추상화를 한마디로 정리하면 객체의 공통적인 속성과 기능을 추출하여 정의하는 것 이라 정리할 수 있다. 앞선 예시를 다시 보면 동물이 가지는 공통적인 특성을 모아 먼저 추상 클래스로 선언해주었고 이를 기반으로 각각의 상속된 하위 클래스에서 오버라이딩을 통해 클래스의 구체적인 내용을 결정해주었다. 상속 계층도의 상층부에 위치할 수록 추상화의 정도가 높고 그 아래로 내려갈수록 구체화 된다고 정리해 볼 수 있다. 다른 말로 상층부에 가까울 수록 더 공통적인 속성과 기능들이 정의되어 있다고 생각할 수 있다. 

 

final 키워드

final 키워드는 필드, 지역변수, 클래스 앞에 위치할 수 있으며 그 위치에 따라 의미가 조금씩 달라진다. 공통적으로 더 이상 변경이 불가능하거나 확장되지 않는 성질을 지니게 된다.

위치 의미
클래스 변경 또는 확장 불가능한 클래스, 상속 불가
메서드 오버라이딩 불가
변수 값 변경이 불가한 상수
final class FinalEx { // 확장/상속 불가능한 클래스
	final int x = 1; // 변경되지 않는 상수

	final int getNum() { // 오버라이딩 불가한 메서드
		final int localVar = x; // 상수
		return x;
	}
}

 

인터페이스

인터페이스(interface)란 서로 다른 두 시스템, 장치, 소프트웨어 따위를 서로 이어주는 부분, 또는 그런 접속장치 라고 정의할 수 있다. 대표적인 예로 GUI가 있다. GUI는 Graphic User Interface의 약자로 컴퓨터를 사용할 때 입출력 등을 좀 더 효율적이고 쉽게 조작할 수 있도록 아이콘 등으로 시각화한 사용자 인터페이스이다. GUI를 통해 명령어를 모르더라도 마우스 클릭만으로 작업 수행을 명령할 수 있다.

인터페이스도 추상 클래스처럼 자바에서 추상화를 구현하는데 활용된다는 점에서 동일하긴 하나, 추상 클래스에 비해 더 높은 추상성을 가진다는 차이점이 있다. 추상클래스를 설계가 모두 끝나지 않은 미완성 설계도에 비유할 수 있다면 인터페이스는 그보다 더 높은 추상성을 가지는 기초적인 밑그림에 빗대어 표현할 수 있다.

추상 클래스는 메서드 바디가 없는 추상 메서드를 하나 이상 포함한다는 점외에는 일반 클래스와 동일하다고 할 수 있으나, 인터페이스는 기본적으로 추상 메서드와 상수만을 멤버로 가질 수 있다는 점에서 차이가 있다. 인터페이스는 기본적으로 추상 메서드의 집합 이라고 기억해도 괜찮을 것 같다.

 

인터페이스의 기본 구조

인터페이스를 작성하는 것은 기본적으로 클래스의 작성과 유사하지만 class 키워드 대신 interface 키워드를 사용한다는 점에서 차이가 있다. 또한 일반 클래스와 다르게 내부의 모든 필드가 public static final로 정의되고, static과 default 메서드 이외의 모든 메서드가 public abstract 로 정의된다는 차이가 있다. 다만 모든 인터페이스의 필드와 메서드에는 위의 요소가 내포되어있어 명시하지 않아도 생략이 가능하다.

public interface InterfaceEx {
    public static final int rock =  1; // 인터페이스 인스턴스 변수 정의
    final int scissors = 2; // public static 생략
    static int paper = 3; // public & final 생략

    public abstract String getPlayingNum();
		void call() //public abstract 생략
}

인터페이스는 interface 키워드로 만들어지고 구현부가 완성되지 않은 추상 메서드와 상수만으로 구성되어있다. 인터페이스 안에서 상수를 정의하는 경우 반드시 public ststic final로, 메서드를 정의하는 경우에는 public abstract로 정의되어야 하지만 위에서 보는것처럼 일부분 또는 전부 생략이 가능하다. 생략된 부분은 컴파일러가 자동으로 추가해준다.

 

인터페이스의 구현

추상 클래스와 마찬가지로 인터페이스도 그 자체로 인스턴스를 생성할 수 없고 메서드 바디를 정의하는 클래스를 따로 작성해야한다. extends 키와드를 사용하는 상속과 기본적으로 동일하긴 하지만 implements 키워드를 사용한다.

class 클래스명 implements 인터페이스명 {
		... // 인터페이스에 정의된 모든 추상메서드 구현
}

특정 인터페이스를 구현한 클래스는 해당 인터페이스의 정의된 모든 추상메서드를 구현해야한다. 즉 어떤 클래스가 특정 인터페이스를 구현한다는 것은 그 클래스에게 인터페이스의 추상 메서드를 반드시 구현하도록 강제하는 것을 의미한다. 다른 말로, 어떤 클래스가 어떤 인터페이스를 구현한다는 것은 그 인터페이스가 가진 모든 추상 메서드들을 해당 클래스 내에서 오버라이딩하여 바디를 완성한다는 의미를 가진다.

 

인터페이스의 다중 구현

인터페이스는 다중적 구현이 가능하다.

class ExampleClass implements ExampleInterface1, ExampleInterface2, ExampleInterface3 {
				... 생략 ...
}
interface Animal { // 인터페이스 선언. public abstract 생략 가능.
	public abstract void cry();
}

interface Pet {
	void play();
}

class Dog implements Animal, Pet { // Animal과 Pet 인터페이스 다중 구현
    public void cry(){ // 메서드 오버라이딩
        System.out.println("멍멍!");
    }

    public void play(){ // 메서드 오버라이딩
        System.out.println("원반 던지기");
    }
}

class Cat implements Animal, Pet { // Animal과 Pet 인터페이스 다중 구현
    public void cry(){
        System.out.println("야옹~!");
    }

    public void play(){
        System.out.println("쥐 잡기");
    }
}

public class MultiInheritance {
    public static void main(String[] args) {
        Dog dog = new Dog();
        Cat cat = new Cat();

        dog.cry();
        dog.play();
        cat.cry();
        cat.play();
    }
}

// 출력값
멍멍!
원반 던지기
야옹~!
쥐 잡기

위의 예제에서 확인할 수 있듯이, Dog와 Cat 클래스는 각각 Animal과 Pet 인터페이스를 다중으로 구현하여 각각의 객체에 맞는 메서드를 오버라이딩하고 그 내용을 출력값으로 돌려주고 있다. 인터페이스는 미완성된 멤버를 가지고 있기 때문에 충돌이 발생하지 않고, 따라서 안전하게 다중 구현이 가능하다.

abstract class Animal { // 추상 클래스
	public abstract void cry();
}
interface Pet { // 인터페이스
	public abstract void play();
}

class Dog extends Animal implements Pet { // Animal 클래스 상속 & Pet 인터페이스 구현
    public void cry(){
        System.out.println("멍멍!");
    }

    public void play(){
        System.out.println("원반 던지기");
    }
}

class Cat extends Animal implements Pet { // Animal 클래스 상속 & Pet 인터페이스 구현
    public void cry(){
        System.out.println("야옹~!");
    }

    public void play(){
        System.out.println("쥐 잡기");
    }
}

public class MultiInheritance {
    public static void main(String[] args) {
        Dog dog = new Dog();
        Cat cat = new Cat();

        dog.cry();
        dog.play();
        cat.cry();
        cat.play();
    }
}

// 출력값
멍멍!
원반 던지기
야옹~!
쥐 잡기

위의 코드에서 기존의 Animal 인터페이스를 추상 클래스로 바꾸고 Animal 상위 클래스로부터 Dog와 Cat 클래스로 확장되는 것과 동시에 Pet 인터페이스를 구현하도록 하여 같은 결과물이 출력되게 하였다.

 

인터페이스의 장점
public class InterfaceExample {
    public static void main(String[] args) {
        User user = new User(); // User 클래스 객체 생성
        user.callProvider(new Provider()); // Provider 객체 생성 후에 매개변수로 전달
    }
}

class User { // User 클래스
    public void callProvider(Provider provider) { // Provider 객체를 매개변수로 받는 callProvider 메서드
        provider.call();
    }
}

class Provider { //Provider 클래스
    public void call() {
        System.out.println("무야호~");
    }
}

// 출력값
무야호~

위의 코드에서 User 클래스에 정의된 callProvider 메서드의 매개변수로 Provider 타입이 전달되어 호출되고 있는 것을 확인할 수 있다. 만약 이 코드에서 User 클래스가 의존하고 있는 Provider 클래스에서 변경사항이 발생해서 Provider 클래스가 아닌 Provider2 클래스로 교체해야 하는 상황이 발생한다면 어떻게 될까? 아래와 같은 코드로 변경해 볼 수 있을 것이다.

public class InterfaceExample {
    public static void main(String[] args) {
        User user = new User(); // User 클래스 객체 생성
        user.callProvider(new Provider2()); // Provider객체 생성 후에 매개변수로 전달
    }
}

class User { // User 클래스
    public void callProvider(Provider2 provider) { // Provider 객체를 매개변수로 받는 callProvider 메서드
        provider.call();
    }
}

class Provider2 { //Provider 클래스
    public void call() {
        System.out.println("야호~");
    }
}

// 출력값
야호~

변경된 내용을 살펴보면 원래 Provider 클래스에 의존했던 User 클래스의 의존 관계를 Provider2 클래스로 변경하기 위해 Provider2 객체를 새롭게 생성해주고, User 클래스의 callProvider 메서드가 동일한 타입의 매개변수를 받을 수 있도록 매개변수의 타입을 Provider2로 변경해주고 있다. 요약하자면 Provider 클래스에 의존하고 있는 User 클래스의 코드 변경이 불가피하다. 위의 수정은 간편하게 하였지만 코드가 수백, 수천 줄이 된다면 하나하나 수정하는게 불가능한 수준이다.

인터페이스의 가장 큰 장점 중에 하나는 역할과 구현을 분리시켜 사용자 입장에서는 복잡한 구현의 내용 또는 변경과 상관없이 해당 기능을 사용할 수 있다는 점이다.

interface Cover { // 인터페이스 정의
    public abstract void call();
}

public class Interface4 {
    public static void main(String[] args) {
        User user = new User();
//        Provider provider = new Provider();
//        user.callProvider(new Provider());
        user.callProvider(new Provider2());
    }
}

class User {
    public void callProvider(Cover cover) { // 매개변수의 다형성 활용
        cover.call();
    }
}

class Provider implements Cover {
    public void call() {
        System.out.println("무야호~");
    }
}

class Provider2 implements Cover {
    public void call() {
        System.out.println("야호~");
    }
}

//출력값
야호~

이전과 같은 결과를 출력하지만 인터페이스를 사용하여 구현한 코드이다 Cover라는 인터페이스를 정의한 후에 각각의 구현체에 implements 키워드를 사용하여 각각 기능을 구현하고 있다. 그리고 User 클래스에서는 매개변수의 다형성을 활용하여 구체적인 구현체가 아닌 인터페이스를 매개변수로 받도록 정의하였다. 이에 따라 Provider 클래스의 내용 변경 또는 교체가 발생하더라도 User 클래스는 더 이상 코드를 변경해주지 않아도 같은 결과를 출력해 낼 수 있다.

결론적으로 정리하면 인터페이스는 기능이 가지는 역할과 구현을 분리시켜 사용자로 복잡한 기능의 구현이나 교체/변경을 신경쓰지 않고도 코드 변경의 번거로움을 최소화하고 손쉽게 해당 기능을 사용할 수 있도록 한다. 반대로 기능을 구현하는 개발자의 입장에서도 선언과 구현을 분리시켜 개발시간을 단축할 수 있고, 독립적인 프로그래밍을 통해 한 클래스의 변경이 다른 클래스에 미치는 영향을 최소화할 수 있다는 큰 장점이 있다.

 

인터페이스 활용 예제

아래의 시나리오를 코드로 바꿔보자.

카페를 운영하는 사람이 있습니다.
단골손님들은 매일 마시는 음료가 정해져 있습니다.
단골손님A는 항상 아이스 아메리카노를 주문합니다.
단골손님B는 매일 아침 딸기라떼를 구매합니다.
//카페 손님
public class CafeCustomer {
  public String CafeCustomerName;

  public void setCafeCustomerName(String cafeCustomerName) {
    this.CafeCustomerName = cafeCustomerName;
  }
}

//CafeCustomer 클래스로부터 단골손님A와 단골손님B 상속
public class CafeCustomerA extends CafeCustomer {

}

public class CafeCustomerB extends CafeCustomer {

}

//카페 사장님
public class CafeOwner {
  public void giveItem(CafeCustomerB cafeCustomerB) {
    System.out.println("give a glass of strawberry latte to CafeCustomer B");
  }

  public void giveItem(CafeCustomerA cafeCustomerA) {
    System.out.println("give a glass of iced americano to CafeCustomer A");
  }
}

//메뉴 주문
public class OrderExample {
    public static void main(String[] args) throws Exception {
        CafeOwner cafeowner = new CafeOwner();
        CafeCustomerA a = new CafeCustomerA();
        CafeCustomerB b = new CafeCustomerB();

        cafeowner.giveItem(a);
        cafeowner.giveItem(b);
    }
}

// 출력값
give a glass of iced americano to CafeCustomer A
give a glass of strawberry latte to CafeCustomer B

위의 예시를 보면 단골손님 A와 단골손님 B는 CafeCustomer 클래스로부터 확장되었고 카페 주인은 CafeOwner 클래스로 정의했다. 그리고 단골손님 A와 B가 올 때 메서드 오버로딩을 사용하여 giveItem 메서드를 호출하고, OrderExample 클래스에서 객체를 생성하여 실행시키면 출력값과 같은 메세지가 반환되고 있다.

만약 손님이 늘어난다면 매번 CafeOwner는 오버로딩 메서드를 만들어야하니 매우 번거로울 것이다. 이때 인터페이스를 활용할 수 있다.

public interface Customer {
	// 상수
	// 추상 메서드
}

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

public class CafeCustomerA implements Customer {

}

public class CafeCustomerB implements Customer {

}

다음으로 implements 키워드를 사용하여 Customer 인터페이스를 각각 구현한 CafeCustomerA와 CafeCustomerB를 정의한다. Customer 타입을 매개변수로 전달함으로써 추가적인 손님이 등장할 때마다 매번 새롭게 메서드를 작성해야 하는 번거로움을 없앨 수 있다.

// 기존 코드
public class CafeOwner {
  public void giveItem(CafeCustomerB cafeCustomerB) {
    System.out.println("give a glass of strawberry latte to CafeCustomer B");
  }

  public void giveItem(CafeCustomerA cafeCustomerA) {
    System.out.println("give a glass of iced americano to CafeCustomer A");
  }
}

// 인터페이스를 활용하여 작성한 코드
public class CafeOwner {
  public void giveItem(Customer customer) {
    System.out.println("??????????");
  }
}

하지만 또 다는 문제점으로 현재 작성된 코드로는 각 단골 손님이 주문한 내용을 개별적으로 주문하기 어렵다는 것이다. 이 문제를 해결하기 위해 기존의 인터페이스에 getOrder 라는 추상 메서드를 인터페이스 Customer에 추가하고, 이를 활용하여 코드를 작성해보자.

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

public class CafeCustomerA implements Customer {
  public String getOrder(){
		return "a glass of iced americano";
	}
}

public class CafeCustomerB implements Customer {
  public String getOrder(){
		return "a glass of strawberry latte";
	}
}

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

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

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

public class OrderExample {
    public static void main(String[] args) throws Exception {
        CafeOwner cafeowner = new CafeOwner();
        Customer cafeCustomerA = new CafeCustomerA();
        Customer cafeCustomerB = new CafeCustomerB();

        cafeowner.giveItem(cafeCustomerA);
        cafeowner.giveItem(cafeCustomerB);
    }
}

// 출력값
Item : a glass of iced americano
Item : a glass of strawberry latte

 

모든 작업의 결과를 취합하면 아래와 같다.

interface Customer {
  String getOrder();
}

class CafeCustomerA implements Customer {
  public String getOrder(){
		return "a glass of iced americano";
	}
}

class CafeCustomerB implements Customer {
  public String getOrder(){
		return "a glass of strawberry latte";
	}
}

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

public class OrderExample {
    public static void main(String[] args) throws Exception {
        CafeOwner cafeowner = new CafeOwner();
        Customer cafeCustomerA = new CafeCustomerA();
        Customer cafeCustomerB = new CafeCustomerB();

        cafeowner.giveItem(cafeCustomerA);
        cafeowner.giveItem(cafeCustomerB);
    }
}

// 출력값
Item : a glass of iced americano
Item : a glass of strawberry latte

 

반응형