[Java] Java 기초 - 객체지향 프로그래밍

Hyunjun Kim·2025년 3월 31일
0

Data_Engineering

목록 보기
18/153

9. 객체 지향 프로그래밍(Object-Oriented Programming)

이번 장에서는 Java 에서 객체지향 프로그래밍을 위해서 지원하는 문법과 이를 이용한 프로그래밍 방법을 알아보자.
객체 지향 프로그래밍은 일상생활에서 실제로 사람과 사물이 관계맺는 방식을 표현하기 위한 방법으로 고안된 방법이다.

9.1. 클래스(Class)

9.1.1. 클래스의 속성

클래스는 표현하고자 하는 대상의 공통 속성을 한 군데에 정의해 놓은 것이라고 할 수 있다. 즉, 클래스는 객체의 속성을 정의해 놓은 것.
클래스는 멤버 변수(또는 인스턴스 변수), 메소드(함수)를 속성으로 가질 수 있다.

  • 멤버 변수는 앞에서 배운 변수와 특성은 같다. 클래스에 속한 변수라고 보면 됨.

9.1.2 인스턴스(instance)

클래스와 인스턴스의 관계

붕어빵을 만드는 틀(붕어빵의 공통된 특성)은 클래스,
붕어빵 틀로부터 만들어진 붕어빵(실제 만들어진 객체)은 인스턴스!

인스턴스(Instance)란?

어떠한 클래스로부터 만들어진 객체를 그 클래스의 인스턴스라고 함

인스턴스 예제

public class Main {
	static class Person {
		String name;
		String country;
		int age;
	}
	public static void main(String[] args) {
		Person minsoo = new Person();
		minsoo.name = "민수";
		minsoo.country = "대한민국";
		minsoo.age = 10;
		
        Person paul = new Person();
		paul.name = "Paul";
		paul.country = "America";
		paul.age = 10;

		Person[] persons = { minsoo, paul };
		for(Person cur : persons) {
			System.out.println("<자기소개>");
			System.out.println("안녕하세요. " + cur.name + " 입니다.");
			System.out.println("저는 "+ cur.country + "에서 태어났습니다.");
			System.out.println("현재 "+ cur.age + "살 입니다.");
		}
	}
}
  • Person이라는 클래스에는 사람의 이름, 나이, 국적에 대한 정보가 담겨져 있다. 이를 활용하여 name, country, age라는 같은 속성을 가진 minsoo, paul으로 각기 다른 인스턴스를 만들었다.

인스턴스의 멤버변수에 접근할 때는 [생성된 인스턴스.멤버변수] 의 형식을 사용하면 된다.

9.2. 메소드(method)

메소드는 어떠한 작업을 수행하는 코드를 하나로 묶어 놓은 것.

9.2.1 메소드가 필요한 이유

  1. 재사용성
    같은 동작을 하는 코드를 메소드로 만들어 반복적으로 재사용이 가능하다. 메소드를 이용해 중복된 코드를 줄이고, 같은 동작에 대해서 메소드 하나만 수정해서 메소드를 사용하는 여러 부분에 적용할 수 있다.
  2. 프로그램 구조화
    구조화에 대해서는 아래 예시를 보면서 이해를 할 수 있다.
int[] heights = new int[5]; // 키가 들어가 있는 배열

initHeight(heights); // 1. 키에 대한 초기화
sortHeight(heights); // 2. 키를 오름차순으로 정렬
printHeight(heights); // 3. 정렬된 키를 출력
  • 보다시피 코드가 어떠한 작업을 하느냐에 따라 구분이 되어 구조화가 된 것을 확인할 수 있다. 엄청나게 긴 코드를 작성할 때 이러한 방식을 통해 보다 쉽게 수정 및 관리를 할 수 있다.

9.2.2 메소드 이름 규칙

메소드를 만들 때, 메소드 안에서 동작하는 내용을 잘 표현할 수 있도록 이름을 잘 지어주면, 메소드 안을 들여다 보지 않고도 한 눈에 코드를 읽어내려갈 수 있어서 좋다. 이것을 readability가 좋다고 표현한다.
이 readability의 기본 품질을 위해서 Java로 메소드를 만들 때 지켜줘야 하는 기본 약속은 다음 두 가지가 있다. 이 약속은 사실상의 표준(defacto - standard)이다.

  1. 동사로 시작해야한다.
  2. camel case로 작성해야한다. (첫 단어는 소문자로, 이후 단어의 구분에 따라서 첫 글자만 대문자인 단어가 이어진다. 중간에 띄어쓰기나 특수문자는 들어가지 않는다.)

9.2.3 메소드 선언과 구현

접근제어자 반환타입 메소드이름 (타입 변수명, 타입 변수명, ...){
	수행되어야 할 코드
}
  • 반환타입: 메소드는 return 문을 통해 수행의 결과를 반환하게 된다. 이때, 결과의 자료형을 결정하는 부분이 반환 타입이다.
int add(int x, int y) {
	int result = x + y;
	return result;
}
  • 메소드의 반환타입은 int이며 이는 반환되어지는 변수인 result와 일치해야 한다. (반환타입중 void 는 '아무 것도 없음'을 의미)

예제: [CalculationTest.java]

class Calculation {
	int add(int x, int y) {
		int result = x + y;
		return result;
	}// 두 값을 더한 결과

	int subtract(int x, int y) {
		int result = x - y;
		return result;
	}// 두 값을 뺀 결과
}

public class Main {
	public static void main(String[] args) {
		Calculation calculation = new Calculation();

		int addResult = calculation.add(100, 90);
		int subResult = calculation.subtract(90, 70);
		System.out.println("두 개를 더한 값은 " + addResult);
		System.out.println("두 개를 뺀 값은 " + subResult);
	}
}
  • add 메소드와 subtract 메소드 모두 x와 y변수가 중복되어 사용된 것을 확인할 수 있다. 하지만, 메소드내의 변수는 지역변수로써 메소드 내부에서만 사용할 수 있는 변수다. 즉, 서로 다른 메소드라면 같은 이름의 지역변수를 선언하여 사용해도 됨.
  • 지역변수는 메소드의 실행이 끝나면(return 된 이후) 메모리에서 삭제된다.

9.2.4 정적 메소드(static method)

정적 메소드는 클래스의 인스턴스 없이도 사용할 수 있는 함수를 말한다.

다음과 같이 선언할 수 있다.

접근제어자 static 반환타입 메소드이름 (타입 변수명,타입 변수명, ...){
수행되어야 할 코드
}

클래스이름.메소드이름(파라미터) 의 형식으로 사용할 수 있다.

정적메소드는 객체의 상태에 독립적이어야 한다. (context-free, stateless) 즉, 파라미터로 받은 값 또는 객체와, 메소드 내에서 사용되는 값 또는 객체, 그리고 다른 정적(static) 리소스만을 이용해서 구현되어야 한다. 해당 클래스의 멤버 변수(인스턴스 변수)와 인스턴스 메소드는 사용할 수 없다.(컴파일 에러)

9.2.5 인스턴스 메소드(instance method)

인스턴스 메소드는 클래스의 객체가 생성되어야 호출될 수 있는 함수를 말한다.

다음과 같이 선언할 수 있습니다.

접근제어자 반환타입 메소드이름 (타입 변수명,타입 변수명, ...){
수행되어야 할 코드
}

객체변수이름.메소드이름(파라미터) 의 형식으로 사용할 수 있다.
인스턴스 메소드는 객체의 리소스에 접근할 수 있습니다. 클래스에 선언된 해당 인스턴스의 멤버 변수의 값을 가져오거나 값을 변경할 수 있고, 인스턴스 메소드를 호출할 수 있다.

9.3 생성자

생성자는 인스턴스가 생성될 때 사용되는 '인스턴스 초기화 메소드'다. 즉 new 와 같은 키워드로 해당 클래스의 인스턴스가 새로 생성될 때, 자동으로 호출되는 메소드이다. 이 생성자를 이용해서 인스턴스가 생성될 때 수행할 동작을 코드로 짤 수 있는데, 대표적으로 인스턴스 변수를 초기화 하는 용도로 사용한다.

9.3.1 생성자의 형식

클래스이름 (타입 변수명, 타입 변수명, ...){
인스턴스 생성 될 때에 수행하여할 코드
변수의 초기화 코드
}

생성자에게도 생성자만의 조건이 있다.
1. 생성자의 이름은 클래스명과 같아야 한다.
2. 생성자는 리턴 값이 없다.

9.3.2 생성자 예제

public class Main {
	static class Person {
		String name;
		String country;
		int age;

		public Person(String name, String country, int age) {
			this.name = name;
			this.country = country;
			this.age = age;
		}
	}
    
	public static void main(String[] args) {
		Person minsoo = new Person("민수", "대한민국", 10);

		Person paul = new Person("Paul", "America",10);

		Person[] persons = { minsoo, paul };
		for(Person cur : persons) {
			System.out.println("<자기소개>");
			System.out.println("안녕하세요. " + cur.name + " 입니다.");
			System.out.println("저는 "+ cur.country + "에서 태어났습니다.");
			System.out.println("현재 "+ cur.age + "살 입니다.");
		}
	}
}

💡 생성자에서 사용된 this 는 생성된 객체 자신을 가리키며 생성자의 매개변수의 값을 객체의 해당하는 데이터에 넣어주게 된다.

9.3.3 기본 생성자

모든 클래스에는 반드시 하나 이상의 생성자가 있어야 한다!
클래스에 생성자가 1개도 작성이 되어있지 않을 경우, 자바 컴파일러가 기본 생성자를 추가해주기 때문에 우리는 기본 생성자를 작성하지 않고도 편리하게 사용할 수 있다. (기본 생성자는 매개변수와 내용이 없는 생성자)

9.3.4 인스턴스(멤버) 변수의 기본값

앞의 <자료형> 챕터에서 배웠던 자료형들처럼 class 에 선언된 변수는 instance 가 생성될 때 값이 초기화(initialize)된다.
이 때, 변수의 선언부나 생성자를 통해서 초기화를 해주지 않는다면, 기본값(default value)를 가진다. 각 자료형마다 기본값이 다르다.

인스턴스 변수의 기본값 - Main

class DefaultValueTest {
	byte byteDefaultValue;
	int intDefaultValue;
    short shortDefaultValue;
    long longDefaultValue;
    float floatDefaultValue;
    double doubleDefaultValue;
    boolean booleanDefaultValue;
    String referenceDefaultValue;
}

public class Main {
	public static void main(String[] args) {
		DefaultValueTest defaultValueTest = new DefaultValueTest();
		System.out.println("byte default: " + defaultValueTest.byteDefaultValue);
		System.out.println("short default: " + defaultValueTest.shortDefaultValue);
		System.out.println("int default: " + defaultValueTest.intDefaultValue);
		System.out.println("long default: " + defaultValueTest.longDefaultValue);
		System.out.println("float default: " + defaultValueTest.floatDefaultValue);
		System.out.println("double default: " + defaultValueTest.doubleDefaultValue);
		System.out.println("boolean default: " + defaultValueTest.booleanDefaultValue);
		System.out.println("reference default: " + defaultValueTest.referenceDefaultValue);
	}
}

실행 결과

byte default: 0 // 1byte 를 구성하는 8개의 bit가 모두 0이라는 뜻.
short default: 0
int default: 0
long default: 0
float default: 0.0
double default: 0.0
reference default: null

9.4 상속(inheritance)

9.4.1 상속의 특징


이미지 출처 : https://medium.com/@smagid_allThings/uml-class-diagrams-tutorial-step-by-step-520fd83b300b

상속을 보여주는 UML Class Diagram이다. 동물의 하위 계층으로 오리, 물고기, 얼룩말이 존재한다. 이렇게 계층적 구조를 만들어 보자!

UML(Unified Modeling Language) 이란?

  • 소프트웨어 시스템을 시각적으로 표현하기 위한 표준화된 모델링 언어
  • 객체지향 설계를 기반으로 시스템의 구조 및 동작을 문서화하는 데 사용됨
  • 소프트웨어 아키텍처, 비즈니스 프로세스, 데이터 모델링 등 다양한 분야에서 활용

UML 클래스 다이어그램 (UML Class Diagram)

  • 클래스 간의 관계를 시각적으로 표현하는 UML 다이어그램 중 하나
  • 객체지향 프로그래밍에서 클래스 구조를 설계하고, 속성(attribute) 및 메서드(method)를 정의
  • 상속, 연관, 집합, 포함 관계 등을 나타낼 수 있음

1) 상속이란?

상속이란 기존의 클래스를 재사용하는 방식 중의 하나다.

  • 한 번 작성한 코드가 재사용이 필요하다면, 변경사항만 코드로 작성하므로 상대적으로 적은 양의 코드를 작성할 수 있게 된다. 이렇게 코드를 재사용하면, 코드와 클래스가 많아질수록 관리가 용이하다는 장점이 있다.
  • 상속을 통해 클래스간의 계층구조를 만들게 된다.

2) 상속의 특징

  1. 부모 클래스로에서 정의된 필드와 메소드를 물려 받는다. (접근제어자 들어가면 조금 다를 수 있음)
  2. 새로운 필드와 메소드를 추가할 수 있다.
  3. 부모 클래스스에서 물려받은 메소드를 수정(덮어쓰기)을 할 수 있다.

9.4.2. 상속의 형식

상속은 extends를 이용하여 사용할 수 있다.

class Animal{}
class Duck extends Animal{}
class Fish extends Animal{}
class Zebra extends Animal{}

위 그림에서 Animal 는 부모 클래스, 조상 클래스(Parent Class)라고 부른다. Duck,Fish,Zebra 클래스는 자식 클래스, 자손 클래스(Child Class)라고 부른다.

9.4.3. 상속 예제

package com.fastcampus.de.java.clip11_4;

public class Main {
	static class Animal {
		String name;
		
        public void cry() {
			System.out.println(name + " is crying.");
		}
	}

	static class Dog extends Animal {
		Dog(String name) {
			this.name = name;
		}
		
        public void bark() {
			System.out.println(name + "(" + breed + ") is barking.");
		}
	}
    
	public static void main(String[] args) {
		Dog dog = new Dog("코코", "허스키");
		dog.cry();
		dog.bark();
		
        Animal animal = dog;
		animal.cry();
		// animal.bark(); 실제 객체는 dog와 같더라도, Animal 타입으로 선언한 이상, Dog 타입에 있는 함수는 호출할 수 없습니다.
	}
}
  • 자식(Dog) 객체는 자식(Dog) 타입으로 선언된 변수에도 할당할 수 있고, 부모(Animal) 타입으로 선언된 변수에도 할당할 수 있다. 단, 부모(Animal) 타입의 변수로 사용할 때는, 실제 객체를 만들(new) 때 사용한 자식(Dog) 타입에 있는 함수 (여기서는 swim())를 호출할 수 없다. 컴파일 에러다.
  • 참고로, 상속을 받을 때 여러 클래스를 상속받을 수는 없다. 오직 하나의 클래스만을 상속받을 수 있다.

9.4.4 super() 메소드

부모 클래스에 있는 필드나 메소드, 생성자를 자식 클래스에서 참조하여 사용하고 싶을 때 사용하는 키워드다.

Super 에 대한 추가 설명 : https://www.tcpschool.com/java/java_inheritance_super

public class Main {
	static class Animal {
		String name;

		Animal(String name) {
			this.name = name;
		}

		public void cry() {
			System.out.println(name + " is crying.");
		}
	}

	static class Dog extends Animal {

		String breed;
		Dog(String name, String breed) {
			super(name);
			this.breed = breed;
		}
		
        public void bark() {
			System.out.println(name + "(" + breed + ") is barking.");
		}
	}

	public static void main(String[] args) {
		Dog dog = new Dog("코코", "허스키");
		dog.cry();
		dog.bark();

		Animal animal = dog;
		animal.cry();
		// animal.bark(); 실제 객체는 dog와 같더라도, Animal 타입으로 선언한 이상, Dog 타입에 있는 함수는 호출할 수 없습니다.
	}
}

부모 클래스인 Animal에 name을 받는 생성자가 있다.
Animal을 상속받는 Dog 클래스에 기본 생성자를 만들면 부모 클래스의 생성자와 맞지 않아 오류가 발생한다.
따라서 최소한 부모와 같은 파라미터를 받는 생성자를 만들어야 하고, 추가로 더 많은 값을 받는 생성자도 정의할 수 있다.
super는 부모 클래스를 의미하며, super(name)은 부모 클래스의 생성자를 호출하는 역할을 한다.
부모 클래스에 기본 생성자 외에 다른 생성자가 있다면, 자식 클래스에는 최소한 그에 맞는 생성자가 하나 있어야 한다.

9.4.5 오버로딩(overloading)

한 클래스 내에 동일한 이름의 메소드를 여러개 정의하는 것

1) 오버로딩의 조건

  1. 메소드 이름이 동일해야 함.
  2. 매개변수의 개수 혹은 타입이 달라야 함.

2) 오버로딩 예제

int add(int x, int y, int z) {
	int result = x + y + z;
	return result;
}

long add(int a, int b, long c) {
	long result = a + b + c;
	return result;
}

int add(int a, int b) {
	int result = a + b;
	return result;
}
//오버로딩의 조건에 부합하는 예제다.

오버로딩이 아닌 경우는
리턴 타입만 다르고 입력 파라미터의 타입이 같은 경우다.
같은 이름과 동일한 파라미터 리스트를 가진 함수가 두 개 존재하면 컴파일 오류가 발생한다

함수를 호출할 때는 객체를 생성한 후, 함수 이름과 전달하는 파라미터에 따라 어떤 함수가 실행될지 결정된다.
리턴 타입은 함수 실행 후에야 알 수 있기 때문에, 리턴 타입이 다르다고 해서 오버로딩이 성립하지 않는다.

또한, int와 long처럼 서로 자동 변환(Auto Casting)이 가능한 타입의 경우, 같은 int 결과를 long 변수에 저장할 수 있다.
자바에서는 박싱(Boxing), 언박싱(Unboxing) 등의 개념이 적용되어 특정 타입 간 변환이 자동으로 이루어지기 때문에, 리턴 타입만 다른 메소드는 서로 명확히 구분되지 않는다.

결국 오버로딩의 조건은 메소드 이름이 같더라도, 파라미터의 타입이나 개수가 달라야 한다는 점이다

3) 오버로딩이 아닌 예제

int add(int x, int y, int z) {
	int result = x + y + z;
	return result;
}

long add(int a, int b, int c) {
	long result = a + b + c;
	return result;
}
// 반환타입은 다르지만 매개변수의 자료형과 개수는 같기에 오버로딩이 아니다.

9.4.6 오버라이딩(overriding)

부모 클래스로부터 상속받은 메소드를 재정의 하는 것.
상속받은 메소드를 그대로 사용하기도 하지만, 필요에 의해 변경해야할 경우 오버라이딩함.

1) 오버라이딩의 조건

  1. 부모 클래스의 메소드와 이름이 같아야 한다
  2. 부모 클래스의 메소드와 매개변수가 같아야 한다
  3. 부모 클래스의 메소드와 반환타입이 같아야 한다

그러니까 완전히 똑같은 메소드를 재정의한다. 고 보면 됨.

2) 오버라이딩 예제

public class Main {
	static class Animal {
		String name;

		Animal(String name) {
			this.name = name;
		}

		public void cry() {
			System.out.println(name + " is crying.");
		}
	}

	static class Giraffe extends Animal {
		Giraffe(String name) {
			super(name);
		}

		@Override
		public void cry() {
			System.out.println(name + " cannot cry.");
		}
	}

	public static void main(String[] args) {
		Animal giraffe = new Giraffe("기린이");
		giraffe.cry();
	}
}

Overriding을 하더라도 super 키워드를 이용해 부모 클래스에 선언된 메소드를 그대로 호출할 수 있다.

public class Main {
	static class Animal {
		String name;
		Animal(String name) {
			this.name = name;
		}

		public void cry() {
			System.out.println(name + " is crying.");
		}
	}

	static class Giraffe extends Animal {
		Giraffe(String name) {
			super(name);
		}

		@Override
		public void cry() {
			super.cry();
			System.out.println(name + " cannot cry.");
		}
	}

	public static void main(String[] args) {
		Animal giraffe = new Giraffe("기린이");
		giraffe.cry();
	}
}

super.cry()를 호출하면 부모 클래스 Animal의 cry() 메소드가 실행되어 "기린이 is crying."이 출력된다.
이후 오버라이딩된 cry() 메소드의 "기린이 cannot cry."가 출력된다.

@Override 어노테이션은 없어도 코드가 실행되지만, 추가하는 것이 좋다.
이걸 보면 해당 메소드가 부모의 메소드를 재정의한 것임을 한눈에 알 수 있기 때문이다. 즉, 부모 클래스의 동작과 다를 수 있으니 주의해야 한다는 점을 명확히 해준다.

객체지향의 캡슐화 개념에서는 객체의 상태가 외부 변경에 취약하지 않도록 보호해야 한다.
하지만 Override는 부모 클래스의 동작을 변경하는 것이므로, 캡슐화를 어느 정도 깨뜨린다고 볼 수 있다.
그럼에도 불구하고 자바에서는 상속을 통해 Override를 허용하고 있다.

9.4.7 오버로딩 vs 오버라이딩 비교

오버로딩 : 기존에 없는 새로운 메소드를 정의하는 것
오버라이딩 : 부모 클래스에 있는 메소드를 재정의하는 것

9.5 접근 제어자 (access modifier)

접근 제어자는 멤버 변수/함수 혹은 클래스에 사용되며 외부에서의 접근을 제한하는 역할을 한다.

(intelliJ에서 코드를 짤 때, 접근제어자가 없음면 기본이 package private라고 해서, 같은 패키지 안에 있는 친구들은 클래스의 객체를 가지고 있다면 해당 함수에 접근할 수 있다.)

9.5.1 접근 제어자의 종류

  1. private : 같은 클래스 내에서만 접근이 가능
  2. default(nothing) : 같은 패키지 내에서만 접근이 가능
  3. protected : 같은 패키지 내에서, 그리고 다른 패키지의 자손클래스에서 접근이 가능
  4. public : 접근 제한이 전혀 없음

private → default → protected → public
의 순서대로 접근 범위가 넓어짐

9.5.2. 접근 제어자 예제

접근제어자 예제를 위한 첫번째 패키지를 만든다.

public class AccessModifierTest {
	private void messageInside() {
		System.out.println("This is private modifier");
	}

	void messageDefault() {
		messageInside();
		System.out.println("This is default(package-private) modifier");
	}

	protected void messageProtected() {
		messageInside();
		System.out.println("This is protected modifier");
	}

	public void messageOutside() {
		messageInside();
		System.out.println("This is public modifier");
	}
}

첫 번째 패키지 내부에 Anonymous 클래스를 만든다. AccessModifierTest 클래스의 객체를 만들고 각 함수를 호출가능한지 확인한다.

public class Anonymous {
	public void call() {
		AccessModifierTest accessModifierTest = new AccessModifierTest();
		// accessModifierTest.messageInside(); compile error
		accessModifierTest.messageDefault();
		accessModifierTest.messageProtected();
		accessModifierTest.messageOutside();
	}

	public static void main(String[] args) {
		Anonymous anonymous = new Anonymous();
		anonymous.call();
	}
}

두 번째 패키지를 만든다.
두 번째 패키지 내부에 Anonymous 클래스를 만든다. AccessModifierTest 클래스의 객체를 만들고 각 함수를 호출가능한지 확인한다.

import com.fastcampus.de.java.clip11_5.AccessModifierTest;

public class Anonymous {
	public void call() {
		AccessModifierTest accessModifierTest = new AccessModifierTest();
		// accessModifierTest.messageInside(); compile error
		// accessModifierTest.messageDefault();
		// accessModifierTest.messageProtected();
		accessModifierTest.messageOutside();
	}
}

두 번째 패키지 내부에 AccessModifierTest를 상속( extends ) 받은 Child 클래스를 만든다. Child 클래스의 함수 내에서 this 키워드를 이용해서 AccessModifierTest에 선언한 각 함수를 호출 가능한지 확인한다.

import com.fastcampus.de.java.clip11_5.AccessModifierTest;

public class Child extends AccessModifierTest{
	public void call() {
		// this.messageInside();
		// this.messageDefault();
		this.messageProtected();
		this.messageOutside();
	}
	
    public static void main(String[] args) {
		Child child = new Child();
		child.call();
	}
}

왜 접근 제어자를 사용하는가?

  • 객체지향 프로그래밍에서는 객체 간의 상호작용을 정의해야 하며, 이를 위해 접근 권한을 제한할 필요가 있음.
  • 클래스 내부 데이터의 부적절한 사용을 방지하고, 데이터를 보호하기 위해 사용됨.
  • 이를 캡슐화(encapsulation)라고 하며, 접근 제어자는 캡슐화가 가능하게 돕는 도구다.

9.6 추상클래스(abstract class)

9.6.1 추상클래스의 정의

1) 추상클래스

추상클래스는 추상메소드를 선언할 수 있는 클래스를 의미한다. 또한 추상클래스는 클래스와는 다르게 상속받는 클래스 없이 그 자체로 인스턴스를 생성할 수는 없다.
대신 타입으로서는 쓸 수 있다.

2) 추상메소드

추상메소드는 메소드 시그니처(method signature)의 설계만 되어있으며 수행되는 코드 ({})에 대해서는 작성이 안된 메소드다.

  • 미완성으로 남겨두는 이유는 상속받는 클래스 마다 반드시 동작이 달라지는 경우에 상속받는 클래스 작성자가 반드시 작성하도록하기 위함이다.

3) 추상 메소드 형식

abstract 리턴타입 메소드이름();

9.6.2 추상클래스 예제

abstract class Bird {
	private int x, y, z;
	void fly(int x, int y, int z) {
		printLocation();
		System.out.println("이동합니다.");

		this.x = x;
		this.y = y;
		if (flyable(z)) {
			this.z = z;
		} else {
			System.out.println("그 높이로는 날 수 없습니다");
		}

		printLocation();
	}

	abstract boolean flyable(int z);
	public void printLocation() {
		System.out.println("현재 위치 (" + x + ", " + y + ", " + z + ")");
	}
}

class Pigeon extends Bird {
	@Override
	boolean flyable(int z) {
		return z < 10000;
	}
}

class Kiwi extends Bird {
	@Override
	boolean flyable(int z) {
		return false;
	}
}

public class Main {
	public static void main(String[] args) {
		Bird pigeon = new Pigeon();
		Bird kiwi = new Kiwi();
		System.out.println("-- 비둘기 --");
		pigeon.fly(1, 1, 3);
		System.out.println("-- 키위새 --");
		kiwi.fly(1, 1, 3);
		System.out.println("-- 비둘기 --");
		pigeon.fly(3, 3, 30000);
	}
}
  • fly(x, y, z) 함수는 Bird 를 상속받는 모든 클래스에서 동일한 동작을 한다. 다만, 그 안에서 호출된 flyable(z) 의 동작만 그것을 구현하는 자식 클래스에서 구현한대로 동작하는 것이다.

  • 키위새(kiwi)는 새이지만 전혀 날 수가 없다. 그래서 키위새의 flyable() 은 항상 false 를 리턴해서 언제나 x,y 좌표로만 움직인다. 반면에, 비둘기(pigeon)는 일정 높이까지는 날아갈 수 있기 때문에 그 기준(여기서는 10000)이 되기 전까지는 z좌표로도 움직일 수 있다. 이것을 새의 종류마다 중복코드 없이 구현하려면 추상클래스와 추상메소드를 이용해서 이렇게 구현할 수 있다.
    이렇게 코드를 짜면, 중복코드가 없으면서도 새의 종류마다 주어진 위치까지 날 수 있는지를 판단할 수 있는 유연성을 허용하며 구현할 수 있다.

Tip: interface의 메소드 또는 abstract class 의 abstract method 처럼 구현하는 클래스에서 직접 구현해야하는 경우 IntelliJ IDEA에서 command + N (window는 alt + insert )을 눌러서 implement methods 를 선택하면 자동으로 코드완성이 된다. 혹은 class 선언 부분에 빨간줄이 그어진다면 alt + enter 로도 추천이 가능하다.

9.7 인터페이스(Interface)

9.7.1 인터페이스의 정의

인터페이스는 객체가 가져야 할 행동(메소드)의 형식만 정의하고 실제 구현(동작)은 구현(implements)한 클래스에게 맡기는 구조다.

1) 인터페이스 특징

  • 인터페이스는 함수의 형식(method signature)인 리턴타입, 메소드 이름만을 정의한다.
  • 함수의 내용은 없다.
  • 인터페이스를 구현(implements)하는 클래스는 인터페이스에 존재하는 메소드의 내용( {} 중괄호 안의 내용)을 반드시 구현해야 한다
  • 인터페이스를 구현하겠다고 선언한 클래스가 인터페이스에 선언된 메소드를 구현하지 않으면 컴파일 에러가 발생.
  • 인터페이스는 default 키워드로 기본 구현을 정의할 수 있다.
  • 인터페이스는 정적 메소드(static method)를 정의하고 구현할 수 있다.
  • 인터페이스는 추상 메소드(abstract method)를 정의할 수 있다. (가능은 하지만, 인터페이스의 메소드 선언과 기능이 동일하기 때문에 추상메소드는 굳이 선언하지 않는 것을 추천)
  • 인터페이스의 변수는 항상 static final(정적 상수) 형태로 선언됨.
  • 인터페이스의 메소드는 구현하는 클래스에서 자동으로 public 접근 제어자를 가짐

2) 인터페이스 형식

interface 인터페이스{
	리턴타입 메소드이름();
	default 리턴타입;
}

class 클래스 implements 인터페이스 {
	@Override
	public 리턴타입 메소드이름() {
		// TODO implements
	}
}

9.7.2 인터페이스 예제

public interface Bird {
	String STATIC_VARIABLE = "STATIC";
	void fly(int x, int y, int z);
	default void printBreed() {
		System.out.println("나는 새 중에 " + getBreed() + " 입니다.");
	}

	String getBreed();
	static void staticMethod() {
		System.out.println("This is static method");
	}
	
    abstract void abstractMethod();
}

public interface Pet {
	String getHome();
}

public class Pigeon implements Bird, Pet{
	private int x, y, z;
	@Override
	public void fly(int x, int y, int z) {
		System.out.println("이동합니다.");
		this.x = x;
		this.y = y;
		this.z = z;
		printLocation();
	}

	@Override
	public String getBreed() {
		return "비둘기";
	}

	@Override
	public void abstractMethod() {
		System.out.println("this is abtract method implemented from Pigeon");
	}

	public void printLocation() {
		System.out.println("현재 위치 (" + x + ", " + y + ", " + z + ")");
	}

	@Override
	public String getHome() {
		return "도곡동";
	}
}

public class Main {
	public static void main(String[] args) {
		Bird bird = new Pigeon();
		bird.fly(1, 2, 3);
		// bird.printLocation(); // compile error
		bird.printBreed();
		bird.abstractMethod();
		Bird.staticMethod();
		System.out.println(Bird.STATIC_VARIABLE);
	}
}
  • interface인 Bird 타입으로 선언한 bird 변수는 실제로 Pigeon 객체이지만, interface인 Bird 에 선언되지 않은 printLocation() 이라는 함수는 호출할 수 없다. interface type 으로 선언되어있는 부분에서는 실제 객체가 무엇이든지, interface에 정의된 행동만 할 수 있다.

@Override
앞에서 배웠던 메소드 오버라이딩의 개념이 abstract method, interface의 함수를 구현하는 데에도 사용됨.

9.7.3 인터페이스 vs 추상클래스

1) 인터페이스

  1. 구현하려는 객체의 동작의 명세
  2. 다중 구현 가능
  3. implements를 이용하여 구현
  4. 메소드 시그니처(이름, 파라미터, 리턴 타입)에 대한 선언만 가능

2) 추상클래스

  1. 클래스를 상속받아 이용 및 확장을 위함
  2. 다중 상속 불가능 , 단일 상속
  3. extends를 이용하여 구현
  4. 추상메소드에 대한 구현 가능
  5. 캡슐화(encapsulation)를 깰 가능성이 있다.

3) 상속이 왜 캡슐화를 깨는가?

참고 링크 : https://en.wikipedia.org/wiki/Composition_over_inheritance

상속(Inheritance)이 캡슐화를 깨뜨리는 이유는 부모 클래스의 구현이 자식 클래스에 강하게 결합되기 때문이다.

  1. 부모 클래스의 변경이 자식 클래스에 영향을 줌
  • 부모 클래스가 변경되면, 이를 상속한 모든 자식 클래스에 영향을 미쳐 예상치 못한 오류가 발생할 수 있음.
  1. 불필요한 기능 상속 가능성
  • 자식 클래스는 부모 클래스의 모든 public/protected 메소드와 필드를 물려받는데, 반드시 필요한 기능만 상속하는 것이 아니므로 불필요한 의존성이 생길 수 있음.
  1. 캡슐화 원칙 위배
  • 캡슐화는 내부 구현을 감추고 외부에는 필요한 기능만 제공하는 것인데, 상속을 사용하면 부모 클래스의 내부 구현이 자식 클래스에서 직접 접근될 수 있어 캡슐화가 깨질 가능성이 높음.

그래서 "Composition over Inheritance"를 권장
상속(Inheritance) 대신 합성(Composition)을 사용하면 불필요한 결합도를 낮추고 유연한 설계가 가능함.
즉, 객체를 상속하는 대신 필요한 기능을 별도 클래스로 분리하고 이를 객체로 포함(참조)하는 방식이 더 바람직하다.

profile
Data Analytics Engineer 가 되

0개의 댓글