[실습] 시작하기
🎮 직접 코드 구현하기
💾 개요에서 확인한 요청서와 참고 사항을 확인하고 알맞은 프로그램을 제작하여 봅시다.
LoL_char1
package com.java.seb.lol_program;
import java.util.Scanner;
/**
* @LoL_char() : 게임정보를 담는 클래스
* @name : 유닛의 이름
* @ad : 유닛의 공격력
* @def : 유닛의 방어력
* @hp : 유닛의 체력
* */
class LoL_char1 {
//TODO:
// 사용자의 입력을 받기위한 객체 생성을 진행합니다.
Scanner sc = new Scanner(System.in);
// 게임에 필요한 변수 타입을 사전 정의합니다.
String name;
String ad;
String def;
String hp;
/**
* @user_info : 생성된 유닛의 정보를 담는 메서드
* @this : 해당 메서드가 입력받은 변수들은 this 를
*/
void user_info(String name, String ad, String def, String hp) {
this.name = name;
this.ad = ad;
this.def = def;
this.hp = hp;
}
/**
* @user_create() : 유닛을 생성하는 메서드이며, 입력된 값은 user_info() 메서드에게 전달 및 반환 처리합니다.
* 반환된 값은 시스템 정보창에 출력되도록 합니다.
*/
String[] user_create() {
//TODO:
//입력 메시지와 함께, 게임에 필요한 유닛의 정보를 입력받습니다.
System.out.println("[시스템] 유닛 [이름]을 입력해 주세요 : ");
String name = sc.nextLine();
System.out.println("[시스템] 유닛 [공격력]을 입력해 주세요 : ");
String ad = sc.nextLine();
System.out.println("[시스템] 유닛 [방어력]을 입력해 주세요 : ");
String def = sc.nextLine();
System.out.println("[시스템] 유닛 [체력]을 입력해 주세요 : ");
String hp = sc.nextLine();
// 입력된 값은 user_info()에 전달하여 줍니다.
user_info(name, ad, def, hp);
// 유저마다의 객체 생성을 위해 반환 과정 또한 정의하여 줍니다.
return new String[]{name, ad, def, hp};
}
/**
* @user_print() : 입력된 배열을 통해 유닛 정보 출력
*/
void user_print(String[] user) {
//TODO:
System.out.println("[안내] 생성된 유닛 정보는 다음과 같습니다.");
System.out.printf("[안내] %s 유닛이 게임에 잠여하였습니다.%n", this.name);
System.out.printf("[공격력] : %s%n", this.ad);
System.out.printf("[방어력] : %s%n", this.ad);
System.out.printf("[체력] : %s%n", this.hp);
System.out.println("=".repeat(40));
}
/**
* @user_info_int() : 공격력과 방어력 등을 고려해 체력 감소를 위해 문자열을 정수형태로 전환합니다.
* Integer.parseInt() : 정수형태로 전환합니다.
*/
int[] user_info_int(String[] info) {
//TODO:
// 같은 형식의 반환을 위해 배열 선언
// int[] info_int;
// 배열의 값을 하나씩 꺼내 정수형태로 전환합니다.
// int ad_int = Integer.parseInt(info[1]);
// int def_int = Integer.parseInt(info[2]);
// int hp_int = Integer.parseInt(info[3]);
//
// return new int[]{ad_int, def_int, hp_int};
int[] info_int = new int[3];
for (int i = 1; i < info.length; i++) {
info_int[i - 1] = Integer.parseInt(info[i]);
}
return info_int;
}
/**
* @attack() : 공격을 수행하는 메서드
* 아군 유닛 정보와 상대의 정보를 입력받습니다.
*/
void attack(int[] me_info_int, int[] enemy) {
// 조건 1. 적군의 체력이 0 이하면 [유닛 제거] 가 됩니다.
// 조건 2. 적군 체력이 0 이하가 아니라면 공격을 성공적으로 수행합니다.
// 공격 정책 수식 : 적군 체력 -= 아군 유닛 공격력 / 적군 유닛 방어력
//TODO:
System.out.println("-".repeat(40));
System.out.printf("[안내] [%s]유닛이 [공격]하였습니다%n", this.name);
enemy[2] = enemy[2] - (me_info_int[0] / enemy[1]);
if (enemy[2] <= 0) {
System.out.println("[안내] 상대 유닛이 사망했습니다.");
} else {
System.out.printf("[안내] 상대 유닛의 남은 체력은 %d입니다.%n", enemy[2]);
}
}
}
LOL_Program1
package com.java.seb.lol_program;
import java.util.Scanner;
public class LOL_Program1 {
public static void main(String[] args) {
//TODO:
System.out.println("[안내] TRG 스타크래프트 시작합니다.");
System.out.println("[안내] 자신의 유닛 정보를 입력해 주세요.");
LoL_char1 myUser = new LoL_char1();
String[] myUserStringArray = myUser.user_create();
myUser.user_print(myUserStringArray);
int[] myUserIntegerArray = myUser.user_info_int(myUserStringArray);
System.out.println("[안내] 상대의 유닛 정보를 입력해 주세요.");
LoL_char1 anotherUser = new LoL_char1();
String[] anotherUserStringArray = anotherUser.user_create();
anotherUser.user_print(anotherUserStringArray);
int[] anotherUserIntegerArray = anotherUser.user_info_int(anotherUserStringArray);
Scanner sc = new Scanner(System.in);
System.out.println("몇 번 공격하실 건가요?");
int count = 0;
count = sc.nextInt();
sc.nextLine();
for (int i = 0; i < count; i++) {
if (anotherUserIntegerArray[2] <= 0) {
myUser.attack(myUserIntegerArray, anotherUserIntegerArray);
}
}
}
}
⚙️ 리팩토링(Refactoring)
💡 ‘리팩토링’은 결과 변경 없이 코드의 구조를 재조정하는 행위를 의미합니다. 주로 가독성을 높이고 유지보수를 편하게 만들기 위해 수행합니다. 버그를 없애거나 새로운 기능을 추가하는 행위가 아니며, 사용자가 보는 외부 화면은 그대로 두면서 내부 논리나 구조를 바꾸고 개선하는 유지 보수행위입니다.
객체 지향 설계 예시
Player
package com.java.seb.lol_solution;
public class Player {
private String name;
private int attackPower;
private int defensePower;
private int healthPower;
public Player(String name, int attackPower, int defensePower, int healthPower) {
this.name = name;
this.attackPower = attackPower;
this.defensePower = defensePower;
this.healthPower = healthPower;
}
public String getName() {
return name;
}
public int getAttackPower() {
return attackPower;
}
public int getDefensePower() {
return defensePower;
}
public int getHealthPower() {
return healthPower;
}
public void setHealthPower(int healthPower) {
this.healthPower = healthPower;
}
}
TextRpgGame
package com.java.seb.lol_solution;
public class TextRpgGame {
private final Player player1;
private final Player player2;
public TextRpgGame(Player player1, Player player2) {
this.player1 = player1;
this.player2 = player2;
}
public void PlayGame() {
// 조건 1. 적군의 체력이 0 이하면 [유닛 제거] 가 됩니다.
// 조건 2. 적군 체력이 0 이하가 아니라면 공격을 성공적으로 수행합니다.
// 공격 정책 수식 : 적군 체력 -= 아군 유닛 공격력 / 적군 유닛 방어력
while (player2.getHealthPower() > 0) {
int healthOfPlayer2 = player2.getHealthPower();
int attackPowerOfPlayer1 = player1.getAttackPower();
int defensePowerOfPlayer2 = player2.getDefensePower();
System.out.println("-".repeat(40));
System.out.printf("[안내] [%s]유닛이 [공격] 하였습니다.%n", player1.getName());
player2.setHealthPower(healthOfPlayer2 - getAttackPoint(attackPowerOfPlayer1, defensePowerOfPlayer2));
if(player2.getHealthPower() > 0) {
System.out.printf("[안내] 상대 유닛의 남은 [체력]은 %d입니다.%n", player2.getAttackPower());
}
}
System.out.println("-".repeat(40));
System.out.println("[안내] 더 이상 공격할 수 없습니다.");
System.out.println("[안내] 상대 유닛이 사망했습니다.");
}
private int getAttackPoint(int attackPowerPlayer1, int defensePowerPlayer2) {
return attackPowerPlayer1 / defensePowerPlayer2;
}
}
TextRpgGameApplication
package com.java.seb.lol_solution;
import java.util.Scanner;
public class TextRpgGameApplication {
private static Scanner scanner = new Scanner(System.in);
private final static String ME_PLAYER_INPUT_GUIDE_MESSEAGE = "[안내] 자신의 유닛 정보를 입력해 주세요.";
private final static String ANOTHER_PLAYER_INPUT_GUIDE_MESSEAGE = "[안내] 상대의 유닛 정보를 입력해 주세요.";
public static void main(String[] args) {
displayWelcomeMessage();
// 플레이어1 정보 입력 및 생성
Player player1 = createPlayer(ME_PLAYER_INPUT_GUIDE_MESSEAGE);
// 플레이어1 정보 출력
displayPlayerInformation(player1);
// 플레이어2 정보 입력 및 생성
Player player2 = createPlayer(ANOTHER_PLAYER_INPUT_GUIDE_MESSEAGE);
// 플레이어2 정보 출력
displayPlayerInformation(player2);
// 게임 시작
playGame(player1, player2);
}
private static void playGame(Player player1, Player player2) {
TextRpgGame textRpgGame = new TextRpgGame(player1, player2);
textRpgGame.PlayGame();
}
private static void displayWelcomeMessage() {
System.out.println("[안내] TRPG 스타크래프트 게임을 시작합니다.");
}
private static Player createPlayer(String str) {
System.out.println(str);
System.out.printf("[시스템] 유닛 [이름]을 입력해 주세요: ");
String name = scanner.nextLine().trim();
System.out.printf("[시스템] 유닛 [공격력]을 입력해 주세요: ");
int attackPower = Integer.parseInt(scanner.nextLine().trim());
System.out.printf("[시스템] 유닛 [방어력]을 입력해 주세요: ");
int defensePower = Integer.parseInt(scanner.nextLine().trim());
System.out.printf("[시스템] 유닛 [체력]을 입력해 주세요: ");
int healthPower = Integer.parseInt(scanner.nextLine().trim());
return new Player(name, attackPower, defensePower, healthPower);
}
private static void displayPlayerInformation(Player player) {
System.out.println("[안내] 생성된 유닛의 정보는 다음과 같습니다.");
System.out.printf("[안내] %s유닛이 게임에 참여하였습니다.%n", player.getName());
System.out.printf("[공격력] : %d%n", player.getAttackPower());
System.out.printf("[방어력] : %d%n", player.getDefensePower());
System.out.printf("[체력] : %d%n", player.getHealthPower());
System.out.println("=".repeat(40));
}
}
상대방도 공격을 할 때로 코드 리펙토링 (서로 공격)
TextRpgGame
package com.java.seb.lol_solution;
public class TextRpgGame {
private final Player player1;
private final Player player2;
public TextRpgGame(Player player1, Player player2) {
this.player1 = player1;
this.player2 = player2;
}
public void PlayGame() {
// 조건 1. 적군의 체력이 0 이하면 [유닛 제거] 가 됩니다.
// 조건 2. 적군 체력이 0 이하가 아니라면 공격을 성공적으로 수행합니다.
// 공격 정책 수식 : 적군 체력 -= 아군 유닛 공격력 / 적군 유닛 방어력
// 개선 포인트
// 조건 1. 아군 or 적군의 체력이 0 이하라면 [유닛 제거]가 됩니다.
// 조건 2. 아군 or 적군의 체력이 0 이하가 아니라면 공격을 수행합니다.
//조건 2-1. 아군이 공격하고, 적국도 공격해야 합니다.
// 공격 정책 수식 : 적군 체력 -= 아군 유닛 공격력 / 적군 유닛 방어력
while (player1.getHealthPower() > 0 && player2.getHealthPower() > 0) {
int healthOfPlayer1 = player1.getHealthPower();
int healthOfPlayer2 = player2.getHealthPower();
int attackPowerOfPlayer1 = player1.getAttackPower();
int attackPowerOfPlayer2 = player2.getAttackPower();
int defensePowerOfPlayer1 = player1.getDefensePower();
int defensePowerOfPlayer2 = player2.getDefensePower();
System.out.println("-".repeat(40));
System.out.printf("[안내] [%s]유닛이 [%s]유닛을 [공격] 하였습니다.%n", player1.getName(), player2.getName());
player2.setHealthPower(healthOfPlayer2 - getAttackPoint(attackPowerOfPlayer1, defensePowerOfPlayer2));
if(player2.getHealthPower() > 0) {
System.out.printf("[안내] %s 유닛의 남은 [체력]은 %d입니다.%n", player2.getName(), player2.getAttackPower());
}
System.out.println("-".repeat(40));
System.out.printf("[안내] [%s]유닛이 [%s]유닛을 [공격] 하였습니다.%n", player2.getName(), player1.getName());
player1.setHealthPower(healthOfPlayer1 - getAttackPoint(attackPowerOfPlayer2, defensePowerOfPlayer1));
if(player1.getHealthPower() > 0) {
System.out.printf("[안내] %s 유닛의 남은 [체력]은 %d입니다.%n", player1.getName(), player2.getAttackPower());
}
}
String currentDeathPlayerName = player1.getHealthPower() <= 0 ? player2.getName() : player1.getName();
System.out.println("-".repeat(40));
System.out.println("[안내] 더 이상 공격할 수 없습니다.");
System.out.printf("[안내] %s 유닛이 사망했습니다.", currentDeathPlayerName);
}
private int getAttackPoint(int attackPowerPlayer1, int defensePowerPlayer2) {
return attackPowerPlayer1 / defensePowerPlayer2;
}
}
OOP(Object-Oriented Programming) Advanced
앞선 객체지향 프로그래밍의 기초 파트를 통해 우리는 자바 언어의 맥락에서 객체지향 프로그래밍의 기본적인 개념을 학습했습니다.
먼저 객체지향 프로그래밍의 근간이 되는 클래스와 객체를 배웠고, 이를 바르게 정의하기 위해 필요한 변수와 메서드, 그리고 변수 초기화를 위한 생성자에 대한 내용을 이해할 수 있었습니다.
앞서 배운 내용의 연장선상에서 이번 유닛에서는 객체지향 프로그래밍 설계를 지탱하는 핵심적인 4가지 기둥에 대한 내용을 중심으로 학습을 이어가도록 하겠습니다. 그 4가지 기둥은 각각 상속성, 캡슐화, 다형성, 추상화입니다.
사실 이 4가지 핵심 원리는 자바에 국한되는 것이 아니라 객체지향 프로그래밍 설계를 지향하는 모든 언어 전반에 공통적으로 적용되는 객체지향의 핵심 중추이자 기둥이라고 할 수 있습니다.
이번 유닛에서는 자바 언어의 맥락에서 객체지향 프로그래밍의 핵심 원리들이 어떻게 적용되고 작동하는 지를 학습할 예정입니다.
상속(Inheritance)
자바의 객체지향 프로그래밍의 핵심 기둥 네 가지 중 우리가 첫 번째로 배우게 될 내용은 상속입니다.
다음의 학습 목표를 통해 이번 챕터의 학습 내용을 잠시 먼저 확인해 봅시다.
학습 목표
- 상위 클래스-하위 클래스의 상속 관계의 핵심을 이해하고, 그 장점을 설명할 수 있다.
- extends 키워드를 사용하여 두 개 이상의 클래스 간 상속 관계를 정의할 수 있다.
- 포함관계와 상속 관계의 차이를 설명할 수 있다.
- 상속 관계에서 사용할 수 있는 메서드 오버라이딩의 정의, 성립 조건, 장점을 이해하고 이를 활용할 수 있다.
- super와 super()의 차이를 설명할 수 있다.
- Object 클래스가 자바 클래스의 상속계층도 상에서 최상단에 위치한다는 사실을 이해할 수 있다.
상속
자바 언어에서 상속이란 기존의 클래스를 재활용하여 새로운 클래스를 작성하는 자바의 문법 요소를 의미합니다.
가장 단순한 형태를 생각해 보면, 두 클래스를 상위 클래스와 하위 클래스로 나누어 상위 클래스의 멤버(필드, 메서드, 내부 클래스)를 하위 클래스와 공유하는 것을 의미합니다.
여기서 우리는 이 두 클래스를 서로 상속 관계에 있다고 하며, 하위 클래스는 상위 클래스가 가진 모든 멤버를 상속받게 됩니다.
따라서 하위 클래스의 멤버 개수는 언제나 상위 클래스의 그것과 비교했을 때 같거나 많습니다. 경우에 따라 이러한 상위 클래스-하위 클래스의 관계를 조상-자손 관계로 표현하기도 하는데, 상속의 실제적인 내용을 생각했을 때 조상-자손 관계보다는 상위-하위 클래스로 표현하는 것이 보다 바람직한 표현 방식입니다.
또한, "~클래스로부터 상속받았다"라는 표현보다는 **"~클래스로부터 확장되었다"**는 표현이 그 역할과 기능을 생각했을 때 더 적절한 표현입니다. 뒤에서 더 자세히 배우겠지만, 두 클래스 간 상속 관계를 설정할 때 사용하는 extends 키워드 자체가 "확장하다"라는 의미를 가지고 있다는 점에 유의할 필요가 있습니다.
따라서 우리는 이번 유닛의 내용을 학습하면서, 가급적으로 후자의 표현을 사용할 예정입니다.
상속에 대한 이해를 좀 더 돕기 위해 다음의 간단한 예시를 하나 보겠습니다.
위의 그림에는 총 4개의 클래스가 정의되어 있습니다.
먼저 트리구조에서 아래에 위치한 3개의 클래스를 살펴볼까요?
각각 Programmer , Dancer , 그리고 Singer 클래스에 대한 속성과 기능이 정의되어 있는 것을 확인하실 수 있습니다.
- 먼저 Programmer 클래스를 보면 속성으로 이름, 나이, 회사 이름이 있고, 기능으로 배우기, 걷기, 먹기, 코딩하기가 정의되어 있습니다.
- 다음으로 Dancer 클래스에는 속성으로 이름, 나이, 그룹명이 있고, 기능으로 배우기, 걷기, 먹기, 춤추기가 있습니다.
- 마지막으로 Singer 클래스에는 속성으로 이름, 나이, 밴드명이 있고, 기능으로 배우기, 걷기, 먹기, 노래하기가 있습니다.
조금만 주의를 기울여서 살펴보면 어렵지 않게 세 개의 클래스에 공통적인 속성과 기능이 정의되어 있다는 것을 알 수 있습니다.
즉 사람이라면 누구나 이름과 나이가 있고, 무언가를 배우고 먹고 걷는 일을 한다는 공통적인 특성이 있습니다.
결국 아래의 세 개의 클래스는 사람이 공통적으로 가지는 속성과 기능에 각각 직업에 맞는 어떤 일(코딩하기, 춤추기, 노래하기)이 추가된 것이라 할 수 있습니다.
결과적으로 이 내용을 상속의 맥락에서 설명하면 사람 클래스가 상위 클래스, 그리고 프로그래머, 댄서, 가수 클래스가 상위 클래스로부터 특정한 속성과 기능을 내려받는(또는 확장된) 하위 클래스가 됩니다.
그렇다면 왜 상속을 사용할까요?
결론적으로 말하자면, 상속을 통해 클래스를 작성하면 앞선 예제에서 확인할 수 있었던 것처럼 코드를 재사용하여 보다 적은 양의 코드로 새로운 클래스를 작성할 수 있어 코드의 중복을 제거할 수 있습니다.
또한, 상속은 다형적 표현이 가능하다는 장점이 있습니다.
다시 위의 예시로 예를 들면, ‘프로그래머는 프로그래머이다'라는 문장은 참입니다. 그와 동시에 ‘프로그래머는 사람이다' 또한 참입니다. 즉 하나의 객체가 여러 모양으로 표현될 수 있다는 것을 우리는 다형성이라 말합니다.
사실 객체의 다형성은 객체지향형 프로그래밍에서 가장 핵심적이고 중요한 개념 중에 하나라 할 수 있습니다. 이것과 관련한 내용은 이후 추상 클래스와 인터페이스를 학습하면서 좀 더 자세히 다루도록 하겠습니다.
지금은 상속의 장점 중의 하나가 다형성의 표현이며, 이 다형성이 객체지향적 설계를 수행하는 데 있어 매우 중요한 개념이라는 점을 기억하는 것만으로도 충분합니다.
코드 예제
이제 코드를 통해서 앞서 설명한 상속의 개념에 대해 더 알아봅시다.
자바에서 상속을 구현하는 방법은 아주 간단합니다. 클래스를 상속할 때는 extends 키워드를 사용하며, 클래스명 다음에 extends 상위 클래스명을 사용하여 정의합니다.
다음의 코드 예제를 한번 볼까요?
이전과 마찬가지로, 코드 예제는 반드시 자신의 인텔리제이에서 입력해 보고 값이 바르게 출력되는지 직접 확인해 보시길 바랍니다.
class Person {
String name;
int age;
void learn(){
System.out.println("공부를 합니다.");
};
void walk(){
System.out.println("걷습니다.");
};
void eat(){
System.out.println("밥을 먹습니다.");
};
}
class Programmer extends Person { // Person 클래스로부터 상속. extends 키워드 사용
String companyName;
void coding(){
System.out.println("코딩을 합니다.");
};
}
class Dancer extends Person { // Person 클래스로부터 상속
String groupName;
void dancing(){
System.out.println("춤을 춥니다.");
};
}
class Singer extends Person { // Person 클래스로부터 상속
String bandName;
void singing(){
System.out.println("노래합니다.");
};
void playGuitar(){
System.out.println("기타를 칩니다.");
};
}
public class HelloJava {
public static void main(String[] args){
//Person 객체 생성
Person p = new Person();
p.name = "김코딩";
p.age = 24;
p.learn();
p.eat();
p.walk();
System.out.println(p.name);
//Programmer 객체 생성
Programmer pg = new Programmer();
pg.name = "박해커";
pg.age = 26;
pg.learn(); // Persons 클래스에서 상속받아 사용 가능
pg.coding(); // Programmer의 개별 기능
System.out.println(pg.name);
}
}
//출력값
공부를 합니다.
밥을 먹습니다.
걷습니다.
김코딩
공부를 합니다.
코딩을 합니다.
박해커
위의 코드는 앞서 상속 개념을 설명하면서 들었던 예시를 코드로 표현한 것입니다.
보시는 것처럼 Person 클래스로부터 Programmer, Dancer, Singer 클래스가 확장되어 Person 클래스에 있는 속성과 기능들을 사용할 수 있는 것을 확인할 수 있습니다.
또한 각각의 클래스의 개별적인 속성과 기능들은 객체 생성 이후 개별적으로 정의해주고 있습니다.
만약 상속이 없었더라면 객체 하나하나 속성과 기능들을 모두 선언해주어야 했을 것이고, 그 경우 계속해서 같은 코드를 중복해야 하는 번거로운 상황을 초래할 것입니다.
이제 객체지향의 상속이 가지는 유용성이 조금 와닿나요?
마지막으로 언급하고 싶은 내용은 자바의 객체지향 프로그래밍에서는 단일 상속(single inheritance)만을 허용한다는 것입니다. 다른 말로, 다중 상속은 허용되지 않습니다.
이는 C++ 등 다른 객체지향언어와 구분되는 자바 객체지향 프로그래밍의 한 특징이기도 합니다. 다만 자바에서도 앞서 언급했던 인터페이스(interface)라는 문법 요소를 통해 다중 상속과 비슷한 효과를 낼 수 있는 방법이 존재합니다.
인터페이스와 다중상속에 대한 보다 자세한 내용은 이후의 챕터에서 자세히 다뤄보도록 하겠습니다.
포함 관계
앞서 우리는 상속을 통한 클래스 간의 관계 설정에 대한 내용을 학습했습니다.
이번 챕터에서는 상속과 함께 알아두면 유용한 포함 관계에 대한 내용을 살펴볼 것입니다.
**포함(composite)**은 상속처럼 클래스를 재사용할 수 있는 방법으로, 클래스의 멤버로 다른 클래스 타입의 참조변수를 선언하는 것을 의미합니다.
다음의 예시를 통해 확인해 보겠습니다.
public class Employee {
int id;
String name;
Address address;
public Employee(int id, String name, Address address) {
this.id = id;
this.name = name;
this.address = address;
}
void showInfo() {
System.out.println(id + " " + name);
System.out.println(address.city+ " " + address.country);
}
public static void main(String[] args) {
Address address1 = new Address("서울", "한국");
Address address2 = new Address("도쿄", "일본");
Employee e = new Employee(1, "김코딩", address1);
Employee e2 = new Employee(2, "박해커", address2);
e.showInfo();
e2.showInfo();
}
}
class Address {
String city, country;
public Address(String city, String country) {
this.city = city;
this.country = country;
}
}
// 출력값
1 김코딩
서울 한국
2 박해커
도쿄 일본
위 예시를 보면, 한 회사의 근로자(Employee)를 표현하기 위한 Employee 클래스의 멤버 변수로 근로자가 사는 개략적인 주소를 나타내는 Address 클래스가 정의되어 있습니다.
원래라면 Address 클래스에 포함되어 있는 인스턴스 변수 city와 country를 각각 Employee 클래스의 변수로 정의해주어야 하지만, Address 클래스로 해당 변수들을 묶어준 다음 Employee 클래스 안에 참조변수를 선언하는 방법으로 코드의 중복을 없애고 포함관계로 재사용하고 있습니다.
사실 객체지향 프로그래밍에서 상속보다는 포함 관계를 사용하는 경우가 더 많고 대다수라 할 수 있습니다.
그렇다면 클래스 간의 관계를 설정하는 데 있어서 상속 관계를 맺어 줄 것 인지 포함 관계를 맺어 줄 것인지를 어떤 기준으로 판별할 수 있을까요?
가장 손쉬운 방법은 클래스 간의 관계가 ‘~은 ~이다(IS-A)’ 관계인지 ~은 ~을 가지고 있다(HAS-A) 관계인지 문장을 만들어 생각해 보는 것입니다.
위의 예시로 예를 들어보면, Employee는 Address이다.라는 문장은 성립하지 않는 반면, Employee는 Address를 가지고 있다.는 어색하지 않은 올바른 문장임을 알 수 있습니다. 따라서 이 경우에는 상속보다는 포함관계가 적합합니다.
반면 Car 클래스와 SportCar라는 클래스가 있다고 할 때, SportsCars는 Car를 가지고 있다.라는 문장보다 SportsCar는 Car이다.라는 문장이 훨씬 더 자연스럽습니다. 따라서 이 경우에는 Car를 상위클래스로 하는 상속 관계를 맺어주는 것이 더 적합하다고 할 수 있습니다.
메서드 오버라이딩
**메서드 오버라이딩(Method Overriding)**은 상위 클래스로부터 상속받은 메서드와 동일한 이름의 메서드를 재정의하는 것을 의미합니다.
영단어 Override의 사전적 의미가 "~위에 덮어쓰다"를 의미한다는 것을 생각해 보면서 우리가 컴퓨터를 사용할 때 동일한 위치에 동일한 파일을 저장하고자 할 때 주로 사용하는 덮어쓰기를 연상해 보면 좀 더 쉽게 이해할 수 있습니다.
간단한 예시를 통해 살펴보도록 하겠습니다.
public class Main {
public static void main(String[] args) {
Bike bike = new Bike();
Car car = new Car();
MotorBike motorBike = new MotorBike();
bike.run();
car.run();
motorBike.run();
}
}
class Vehicle {
void run() {
System.out.println("Vehicle is running");
}
}
class Bike extends Vehicle {
void run() {
System.out.println("Bike is running");
}
}
class Car extends Vehicle {
void run() {
System.out.println("Car is running");
}
}
class MotorBike extends Vehicle {
void run() {
System.out.println("MotorBike is running");
}
}
// 출력값
Bike is running
Car is running
MotorBike is running
위 예시에서, Vehicle 클래스에 run() 메서드가 정의되어 있으며, Bike, Car, MotorBike 클래스에서 run() 메서드를 재정의함으로써 Vehicle 클래스의 run() 메서드를 오버라이딩하고 있습니다.
따라서 Bike, Car, MotorBike의 인스턴스를 통해 run() 메서드를 호출하면 Vehicle의 run()이 아닌, Bike, Car, MotorBike의 run()이 호출됩니다.
이처럼 메서드 오버라이딩은 상위 클래스에 정의된 메서드를 하위 클래스에서 메서드의 동작을 하위 클래스에 맞게 변경하고자 할 때 사용합니다.
상위 클래스의 메서드를 오버라이딩하려면 다음의 세 가지 조건을 반드시 만족시켜야 합니다.
이 조건들 중 2번의 접근제어자와 3번의 예외에 대한 부분은 아직 배우지 않았으니, 지금은 일단 가볍게 읽어주세요.
1. 메서드의 선언부(메서드 이름, 매개 변수, 반환 타입)가 상위클래스의 그것과 완전히 일치해야 한다.
2. 접근 제어자의 범위가 상위 클래스의 메서드보다 같거나 넓어야 한다.
3. 예외는 상위 클래스의 메서드보다 많이 선언할 수 없다.
super 키워드와 super()
앞서 우리는 객체지향 프로그래밍 기초 유닛에서 this 키워드와 this() 메서드를 학습했습니다. 그리고 이 둘의 생김새는 유사하지만 매우 다른 용도로 사용된다는 점을 이해할 수 있었습니다.
배웠던 내용을 한 번 더 정리해 보면, this는 자기 객체를 가리키는 참조 변수명으로, 메서드 내에서 멤버 변수와 지역 변수의 이름이 같을 때 구분하기 위한 용도로 사용되며, 생략 시 컴파일러가 자동으로 추가해준 다고 했습니다.
반면 this() 메서드는 같은 클래스의 다른 생성자를 호출하는 데 사용되며, 생성자 내에서만 사용 가능하고, 항상 첫 줄에 위치해야 한다고 배웠습니다.
한마디로 정리하면 this는 자신의 객체, this() 메서드는 자신의 생성자 호출을 의미합니다.
이번 챕터에서 배울 super 키워드와 super() 메서드도 이와 비슷하다고 할 수 있습니다. super 키워드는 상위 클래스의 객체, super()는 상위 클래스의 생성자를 호출하는 것을 의미합니다.
이 둘은 공통적으로 상위 클래스의 존재를 상정하며 상속 관계를 전제로 합니다. 그럼 이제 하나씩 살펴보도록 하겠습니다.
먼저 super 키워드를 아래의 예시를 통해 알아보도록 합시다.
public class Example {
public static void main(String[] args) {
SubClass subClassInstance = new SubClass();
subClassInstance.callNum();
}
}
class SuperClass {
int count = 20; // super.count
}
class SubClass extends SuperClass {
int count = 15; // this.count
void callNum() {
System.out.println("count = " + count);
System.out.println("this.count = " + this.count);
System.out.println("super.count = " + super.count);
}
}
// 출력값
count = 15
count = 15
count = 20
위에 예제에서 SubClass는 SuperClass로부터 변수 count를 상속받는데, 공교롭게도 자신의 인스턴스 변수 count와 이름이 같아 둘을 구분할 방법이 필요합니다.
이런 경우, 두 개의 같은 이름의 변수를 구분하기 위한 방법이 바로 super 키워드입니다.
만약 super 키워드를 붙이지 않는다면, 자바 컴파일러는 해당 객체는 자신이 속한 인스턴스 객체의 멤버를 먼저 참조합니다.
반면 경우에 따라서 상위 클래스의 변수를 참조해야 할 경우가 종종 있는데 그 경우 super 키워드를 사용하면 부모의 객체의 멤버 값을 참고할 수 있습니다.
즉, 상위 클래스의 멤버와 자신의 멤버를 구별하는 데 사용된다는 점을 제외한다면 this와 super는 기본적으로 같은 것이라 말할 수 있습니다.
위의 예시에서 첫 번째 count는 자기에게서 가장 가까운 count 변수, 즉 15를 가리킵니다.
두 번째 카운트 또한 자신이 호출된 객체의 인스턴스 변수를 가리키기 때문에 15를 출력합니다.
마지막으로 super 키워드를 사용하여 호출한 count는 앞서 설명드렸던 것처럼 상위 클래스의 변수를 참조하여 숫자 20을 출력합니다.
다음으로 super() 메서드를 살펴볼까요?
앞서 간략하게 설명한 것처럼, super()도 this() 메서드처럼 생성자를 호출할 때 사용합니다.
단, this()는 같은 클래스의 다른 생성자를 호출하는 데 사용되지만, super()는 상위 클래스의 생성자를 호출하는 데 사용됩니다.
다음의 예시를 통해 좀 더 알아보겠습니다.
public class Test {
public static void main(String[] args) {
Student s = new Student();
}
}
class Human {
Human() {
System.out.println("휴먼 클래스 생성자");
}
}
class Student extends Human { // Human 클래스로부터 상속
Student() {
super(); // Human 클래스의 생성자 호출
System.out.println("학생 클래스 생성자");
}
}
// 출력값
휴먼 클래스 생성자
학생 클래스 생성자
Human 클래스를 확장하여 Student 클래스를 생성하고, Student 생성자를 통해 상위 클래스 Human 클래스의 생성자를 호출하고 있습니다.
super() 메서드 또한 this()와 마찬가지로 생성자 안에서만 사용 가능하고, 반드시 첫 줄에 와야 합니다.
예제에서 super() 메서드는 Student 클래스 내부에서 호출되고 있고 상위 클래스 Human의 생성자를 호출하고 있습니다. 그리고 출력값으로 “휴먼 클래스 생성자”와 "학생 클래스 생성자"가 순차적으로 출력됩니다.
여기서 기억해야 하는 가장 중요한 사실은 모든 생성자의 첫 줄에는 반드시 this() 또는 super()가 선언되어야 한다는 것입니다.
만약 super()가 없는 경우에는 컴파일러가 생성자의 첫 줄에 자동으로 super()를 삽입합니다.
이때 상위 클래스에 기본생성자가 없으면 에러가 발생하게 됩니다.
따라서 클래스를 만들 때는 자동으로 기본 생성자를 생성하는 것을 습관화하는 것이 좋습니다.
클래스의 정점, Object 클래스
Object 클래스는 자바의 클래스 상속계층도에서 최상위에 위치한 상위클래스입니다. 따라서 자바의 모든 클래스는 Object 클래스로부터 확장된다는 명제는 항상 참입니다.
실제로 자바 컴파일러는 컴파일 과정에서 다른 클래스로부터 아무런 상속을 받지 않는 클래스에 자동으로 extends Object를 추가하여 Object 클래스를 상속받도록 합니다.
class ParentEx { // 컴파일러가 "extends Object" 자동 추가
}
class ChildEx extends ParentEx {
}
위의 예시에서, ParentEx 클래스를 상속받아 ChildEx 클래스를 만들었을 때 상위클래스 ParentEx는 아무것도 상속하고 있지 않기에 컴파일러는 extends Object를 삽입하는 것입니다.
앞서 설명한 것처럼 Object 클래스는 자바 클래스의 상속계층도에 가장 위에 위치하기 때문에 Object 클래스의 멤버들을 자동으로 상속받아 사용할 수 있습니다.
Object 클래스에서 확장되어 사용 메서드들은 매우 많지만, 대표적인 메서드 몇 가지만 소개하겠습니다.
지금 당장 각 메서드들의 역할과 기능을 이해하지 못해도 괜찮습니다. 지금은 클래스 계층도의 최상위에 Object 클래스가 있다는 것, 그리고 이에 따라 아래와 같은 메서드들을 따로 정의하지 않고도 사용 가능하다는 점만 기억해 주세요.
더 추가적인 내용들은 이후 학습을 진행하시면서 차차 자연스럽게 이해할 수 있게 될 것입니다.
아래에 Object 클래스의 대표적인 메서드를 나열해 두었습니다. 해당 메서드들을 여러분이 지금 당장 외워야 하는 것은 아니니, "이런 것들이 있구나" 정도로만 훑어보시고 넘어가시기 바랍니다.
캡슐화(Encapsulation)
이번 챕터에서는 자바 객체지향 프로그래밍의 핵심 기둥 중 두 번째로 캡슐화에 대한 내용을 학습할 예정입니다.
다음의 학습 목표를 통해 이번 챕터의 학습 내용을 먼저 확인해 봅시다.
학습 목표
- 캡슐화의 핵심 개념과 목적을 이해하고 설명할 수 있다.
- 패키지의 개념과 import 문이 어떻게 사용되는지 이해할 수 있다.
- 자바에서 캡슐화를 달성하기 위한 핵심적인 수단인 접근제어자 네 가지를 이해하고, 각각의 접근 가능 범위를 설명할 수 있다.
- 데이터를 효과적으로 보호하기 위한 수단인 getter/setter 메서드를 이해하고 사용할 수 있다.
캡슐화
캡슐화란 특정 객체 안에 관련된 속성과 기능을 하나의 캡슐(capsule)로 만들어 데이터를 외부로부터 보호하는 것을 말합니다.
이렇게 캡슐화를 해야 하는 이유로 크게 두 가지 목적이 있습니다. 첫째는 데이터 보호의 목적이고, 둘째로 내부적으로만 사용되는 데이터에 대한 불필요한 외부 노출을 방지하기 위함입니다.
정리하면, 캡슐화의 가장 큰 장점은 **정보 은닉(data hiding)**에 있다고 정리할 수 있습니다.
즉, 외부로부터 객체의 속성과 기능이 함부로 변경되지 못하게 막고, 데이터가 변경되더라도 다른 객체에 영향을 주지 않기에 독립성을 확보할 수 있습니다.
더 나아가 유지보수와 코드 확장 시에도 오류의 범위를 최소화할 수 있어서 효과적으로 코드를 유지보수하기에 용이합니다.
이번 챕터에서는 자바에서 캡슐화를 수행하기 위한 핵심적인 수단으로 접근제어자(Access Modifier)와 getter와 setter 메서드를 중심으로 학습을 진행합니다.
먼저 접근제어자를 이해하기 위해 선행되어야 하는 개념인 자바 패키지(package)에 대한 내용을 배우고, 접근제어자와 getter/setter 메서드 챕터를 통해 어떻게 자바 언어의 맥락에서 캡슐화를 달성할 수 있는지 함께 살펴보도록 하겠습니다.
패키지
**패키지(package)**란 특정한 목적을 공유하는 클래스와 인터페이스의 묶음을 의미합니다.
인터페이스에 대한 내용은 곧 다가오는 챕터에서 학습하기 때문에, 지금은 패키지가 비슷한 목적을 가진 클래스와 인터페이스의 묶음으로 이뤄져 있다는 사실만 기억하고 넘어가 주세요.
클래스를 정의할 때 관련 있는 속성과 기능을 묶어 데이터들을 효율적으로 관리할 수 있었듯, 패키지는 클래스들을 그룹 단위로 묶어 효과적으로 관리하기 위한 목적을 가지고 있습니다.
우리가 컴퓨터를 사용할 때 폴더를 만들어 그 폴더와 관련된 파일들을 관리하는 것과 유사하다고 할 수 있습니다.
자바에서 패키지는 물리적인 하나의 디렉토리(directory)이고, 하나의 패키지에 속한 클래스나 인터페이스 파일은 모두 해당 패키지에 속해있습니다.
더 나아가, 이 디렉토리는 하나의 계층 구조를 가지고 있는데, **계층 구조 간 구분은 점(.)**으로 표현됩니다.
마지막으로, 패키지가 있는 경우 소스 코드의 첫 번째 줄에 반드시 package 패키지 명이 표시되어야 하고, 만약 패키지 선언이 없으면 이름 없는 패키지에 속하게 됩니다.
아래의 예시를 한번 확인해 봅시다.
// 패키지를 생성했을 때
package practicepack.test; // 패키지 구문 포함. 패키지가 없다면 구문 필요없음
public class PackageEx {
}
자바에 기본적으로 포함되어 있는 대표적인 패키지로 자바의 기본 클래스들을 모아 놓은 java.lang, 확장 클래스를 묶어 놓은 java.util, 자바의 입출력과 관련된 클래스를 묶어놓은 java.io와 java.nio 등이 있습니다.
예를 들면, 우리가 주로 사용하는 String 클래스의 실제 이름은 java.lang.String인데, 여기서 java.lang은 패키지 명을 나타내고 점(.)을 사용하여 디렉토리 계층 구조를 나타내고 있습니다.
이렇게 패키지로 클래스를 묶는 것의 또 하나의 장점은 클래스의 충돌을 방지해 주는 기능에 있습니다. 예를 들면, 같은 이름의 클래스를 가지고 있더라고 각각 다른 패키지에 소속되어 있다면 이름명으로 인한 충돌이 발생하지 않습니다.
규모가 큰 프로젝트에서 협업 시 클래스명 중복으로 인한 충돌이 종종 발생할 수 있는데 패키지를 설정하면 이러한 클래스 간의 충돌을 효과적으로 방지할 수 있습니다.
추가로, 곧 학습하게 될 접근제어자의 접근 제어 범위를 설정하는 데 있어서도 패키지에 대한 기본적인 이해가 바탕이 되어야 합니다.
Import 문
import 문은 다른 패키지 내의 클래스를 사용하기 위해 사용하며, 일반적으로 패키지 구문과 클래스 문 사이에 작성합니다.
예를 들면, import 문 없이 다른 패키지의 클래스를 사용하기 위해서는 아래와 같이 매번 패키지 명을 붙여 주어야 하는데, import 문을 사용하면 사전에 컴파일러에게 소스 파일에 사용된 클래스에 대한 정보를 제공하여 이러한 번거로움을 덜어줍니다.
아래의 예시를 통해서 좀 더 알아보겠습니다.
package practicepack.test;
public class ExampleImport {
public int a = 10;
public void print() {
System.out.println("Import 문 테스트");
}
}
package practicepack.test2; // import 문을 사용하지 않는 경우, 다른 패키지 클래스 사용방법
public class PackageImp {
public static void main(String[] args) {
practicepack.test.ExampleImport example = new practicepack.test.ExampleImport();
}
}
위의 예시를 보면, import 문을 사용하지 않고 다른 패키지의 클래스를 사용하기 위해서 패키지 명을 모두 포함시켜서 클래스의 패키지에 대한 정보를 제공해야 한다는 사실을 알 수 있습니다.
그렇다면 import 문을 사용하면 어떨까요?
import 문은 다음과 같이 작성할 수 있습니다.
import 패키지 명.클래스 명; 또는 import 패키지 명.*;
먼저 import 키워드를 써주고 패키지 명과 패키지 명을 생략하고자 하는 클래스명을 함께 써주면 됩니다.
만약 같은 패키지에서 여러 클래스가 사용될 때는 import 문을 여러 번 사용하기보다는 위에 작성된 것처럼 import 패키지 명.* 으로 작성하면 해당 패키지의 모든 클래스를 패키지 명 없이 사용할 수 있습니다.
그럼 이제 이 방법을 사용해서 위의 예제에 적용해 볼까요?
package practicepack.test;
public class ExampleImp {
public int a = 10;
public void print() {
System.out.println("Import 문 테스트");
}
}
package practicepack.test2; // import 문을 사용하는 경우
import practicepack.test.ExampleImp // import 문 작성
public class PackageImp {
public static void main(String[] args) {
ExampleImp x = new ExampleImp(); // 이제 패키지 명을 생략 가능
}
}
어떤가요? 위에 작성한 코드보다 훨씬 깔끔하고 번거롭지 않은 코드작성이 가능해졌습니다.
참고로 import 문은 컴파일 시에 처리되므로 프로그램의 성능에는 영향을 주지 않습니다.
접근 제어자
제어자(Modifier)
자바 프로그래밍에서 제어자는 클래스, 필드, 메서드, 생성자 등에 부가적인 의미를 부여하는 키워드를 의미합니다.
‘ 파란 하늘', ‘ 붉은 노을'에서 ‘파란'과 ‘붉은'처럼 명사를 꾸며주는 형용사의 역할과 같다고 할 수 있습니다.
자바에서 제어자는 크게 접근 제어자와 기타 제어자로 구분할 수 있습니다.
앞서 언급했듯, 제어자는 클래스, 필드, 메서드, 생성자 등에 주로 사용되며 ‘ 맛있는 빨간 사과'에서 사과를 수식하기 위해 ‘맛있는'과 ‘빨간'이라는 형용사가 두 번 사용된 것처럼 하나의 대상에 대해서 여러 제어자를 사용할 수 있습니다.
하지만, 각 대상에 대해서 접근 제어자는 단 한 번만 사용할 수 있습니다.
기타 제어자와 관련해서는 static, final, abstract 키워드가 주로 사용되기 때문에 이 세 가지를 중심으로 학습을 먼저 진행하고, 나머지는 차차 필요에 따라 학습해 가는 것을 권장합니다.
접근 제어자(Access Modifier)
이제 자바 객체지향 프로그래밍의 캡슐화를 구현하기 위한 핵심적인 방법으로 접근 제어자를 학습해 봅시다.
앞서 설명한 대로, 접근 제어자를 사용하면 클래스 외부로의 불필요한 데이터 노출을 방지(data hiding)할 수 있고, 외부로부터 데이터가 임의로 변경되지 않도록 막을 수 있습니다. 이것은 데이터 보호의 측면에서 매우 중요하다고 할 수 있습니다.
자바 접근 제어자로 다음의 4가지가 있습니다.
위의 내용을 접근 제한 범위에 따라서 표현하면, public(접근 제한 없음) > protected(동일 패키지 + 하위클래스) > default(동일 패키지) > private(동일 클래스) 순으로 정리할 수 있습니다.
이중 default의 경우는 아무런 접근 제어자를 붙이지 않는 경우 기본적인 설정을 의미합니다. 즉 변수명 앞에 아무런 접근 제어자가 없는 경우에는 자동으로 해당 변수의 접근 제어자는 default가 됩니다.
이제 예시를 통해서 좀 더 자세히 알아보도록 합니다.
package package1; // 패키지명 package1
//파일명: Parent.java
class Test { // Test 클래스의 접근 제어자는 default
public static void main(String[] args) {
Parent p = new Parent();
// System.out.println(p.a); // 동일 클래스가 아니기 때문에 에러발생!
System.out.println(p.b);
System.out.println(p.c);
System.out.println(p.d);
}
}
public class Parent { // Parent 클래스의 접근 제어자는 public
private int a = 1; // a,b,c,d에 각각 private, default, protected, public 접근 제어자 지정
int b = 2;
protected int c = 3;
public int d = 4;
public void printEach() { // 동일 클래스이기 때문에 에러발생하지 않음
System.out.println(a);
System.out.println(b);
System.out.println(c);
System.out.println(d);
}
}
// 출력값
2
3
4
먼저 동일한 패키지에 속한 경우를 살펴보도록 하겠습니다.
아래 Parent 클래스를 먼저 살펴볼까요?
a, b, c, d 모두 에러 없이 정상적으로 접근 가능함을 확인하실 수 있습니다.
동일한 패키지의 동일한 클래스 내에 있기 때문에 가장 접근 제한이 엄격한 private 변수도 접근이 가능합니다.
반면 위의 Test 클래스에서 객체를 생성하여 접근을 시도했을 때는 private 접근 제어자가 있는 a에는 접근이 불가하여 에러가 발생하는 모습을 확인할 수 있습니다.
package package2; // package2
//파일명 Test2.java
import package1.Parent;
class Child extends package1.Parent { // package1으로부터 Parent 클래스를 상속
public void printEach() {
// System.out.println(a); // 에러 발생!
// System.out.println(b);
System.out.println(c); // 다른 패키지의 하위 클래스
System.out.println(d);
}
}
public class Test2 {
public static void main(String[] args) {
Parent p = new Parent();
// System.out.println(p.a); // public을 제외한 모든 호출 에러!
// System.out.println(p.b);
// System.out.println(p.c);
System.out.println(p.d);
}
}
그렇다면 다른 패키지에 있는 경우는 어떨까요?
먼저 package1의 Parent 클래스로부터 상속받아 만들어진 Child 클래스를 살펴보면, 같은 클래스와 같은 패키지 안에 있는 private(a)와 default(b) 접근 제어자를 사용하는 멤버에는 접근이 불가능한 반면, 다른 패키지의 하위 클래스에 접근 가능한 protected(c)와 어디서나 접근이 가능한 public(d)에는 접근이 가능하다는 사실을 확인할 수 있습니다.
마지막으로 Test2 클래스는 상속받은 클래스가 아니기 때문에 다시 protected(c)에는 접근이 불가능하고 public(d)에만 접근이 가능합니다.
결론적으로 다시 정리하면, 우리는 접근 제어자를 통해 외부로부터 데이터를 보호하고, 불필요하게 데이터가 노출되는 것을 방지할 수 있습니다.
getter와 setter 메서드
앞서 우리는 자바에서 데이터 보호와 은닉을 위한 효과적인 방법으로 접근 제어자를 사용한다는 것을 학습했습니다.
그렇다면 객체지향의 캡슐화의 목적을 달성하면서도 데이터의 변경이 필요한 경우는 어떻게 할 수 있을까요?
대표적으로 private 접근 제어자가 포함되어 있는 객체의 변수의 데이터 값을 추가하거나 수정하고 싶을 때를 생각해 볼 수 있습니다.
이런 경우 우리는 getter와 setter 메서드를 사용할 수 있습니다.
마찬가지로 간단한 예시를 통해 알아보도록 하겠습니다.
public class GetterSetterTest {
public static void main(String[] args) {
Worker w = new Worker();
w.setName("김코딩");
w.setAge(30);
w.setId(5);
String name = w.getName();
System.out.println("근로자의 이름은 " + name);
int age = w.getAge();
System.out.println("근로자의 나이는 " + age);
int id = w.getId();
System.out.println("근로자의 ID는 " + id);
}
}
class Worker {
private String name; // 변수의 은닉화. 외부로부터 접근 불가
private int age;
private int id;
public String getName() { // 멤버변수의 값
return name;
}
public void setName(String name) { // 멤버변수의 값 변경
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
if(age < 1) return;
this.age = age;
}
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
}
// 출력값
근로자의 이름은 김코딩
근로자의 나이는 30
근로자의 ID는 5
위의 예제는 자바 객체지향 프로그래밍에서 캡슐화를 통해 데이터를 보호하면서도 어떻게 데이터를 변경할 수 있는지 잘 보여주고 있습니다.
먼저 setter 메서드는 외부에서 메서드에 접근하여 조건에 맞을 경우 데이터 값을 변경할 수 있게 해 주고 일반적으로 메서드명에 set-을 붙여서 정의합니다.
예시를 보면 이름을 변경하기 위해 setName()이라는 메서드를 사용하고 있음을 확인할 수 있습니다.
한편 getter 메서드는 이렇게 설정한 변수값을 읽어오는 데 사용하는 메서드입니다.
경우에 따라 객체 외부에서 필드 값을 사용하기에 부적절한 경우가 발생할 수 있는데 이런 경우에 그 값을 가공한 이후에 외부로 전달하는 역할을 하게 됩니다.
위의 예시에서 볼 수 있듯이 get-을 메서드명 앞에 붙여서 사용합니다.
예시를 좀 더 자세히 살펴보면, 먼저 Worker 클래스를 기반으로 객체 인스턴스를 생성해 주고 같은 타입을 가지고 있는 참조변수 w에 담았습니다.
다음으로 w의 setter 메서드를 사용하여 이름, 나이, 아이디에 대한 데이터값을 저장하고, getter 메서드를 통해 해당 데이터 값을 불러와 변수에 담아 출력해 주고 있습니다.
이렇게 setter와 getter 메서드를 활용하면 데이터를 효과적으로 보호하면서도 의도하는 값으로 값을 변경하여 캡슐화를 보다 효과적으로 달성할 수 있습니다.
추상화(Abstraction)
여기까지 학습 진행하느라 정말 수고 많으셨습니다.
이제 벌써 객체지향 프로그래밍의 네 번째이자 마지막 기둥에 해당하는 ‘추상화’에 대한 내용을 배워보도록 하겠습니다.
마찬가지로 학습 목표를 통해 이번 챕터의 학습 내용을 먼저 확인해 보겠습니다.
학습 목표
- 추상화의 핵심 개념과 목적을 이해하고 설명할 수 있다.
- abstract 제어자가 내포하고 있는 의미를 이해하고, 어떻게 사용되는지 설명할 수 있다.
- 추상 클래스의 핵심 개념과 기본 문법을 이해할 수 있다.
- final 키워드를 이해하고 설명할 수 있다.
- 추상화에서 핵심적인 역할을 수행하는 인터페이스의 개념과 활용 방법을 이해할 수 있다.
- 추상 클래스와 인터페이스의 차이를 설명할 수 있다.
추상화
먼저 **“추상”**이라는 용어의 사전적 의미를 보면 **“사물이나 표상을 어떤 성질, 공통성, 본질에 착안하여 그것을 추출하여 파악하는 것"**이라고 정의합니다.
여기서 핵심적인 개념은 공통성과 본질을 모아 추출하는 것입니다.
같은 맥락에서, 자바에서의 추상화는 객체의 공통적인 속성과 기능을 추출하여 정의하는 것을 의미합니다.
앞서 학습했던 상속이 하위 클래스를 정의하는데 상위 클래스를 사용하는 것이라고 한다면 추상화는 반대로 기존 클래스들의 공통적인 요소들을 뽑아서 상위 클래스를 만들어 내는 것이라고 할 수 있습니다.
사실 방법에 있어서는 상향식과 하향식 설계 모두 크게 상관이 없습니다.
즉, 공통적인 속성과 기능을 정의하고 하위 클래스들을 생성할 수도 있고, 반대로 하위 클래스들의 공통성을 모아 상위 클래스를 정의할 수 있습니다. 아래 그림을 잠시 살펴봅시다.
위의 그림은 추상화를 잘 보여줍니다.
그림을 보면 자동차와 오토바이의 공통적인 분모들을 모아 이동 수단이라는 클래스에 담았습니다.
반대로 이동 수단이 가지는 공통적인 특징을 자동차와 오토바이에 내려줬다고 생각해도 공식은 유효합니다.
이렇게 공통적인 속성과 기능을 모아서 정의해 주면 코드의 중복을 줄일 수 있고, 보다 효과적으로 클래스 간의 관계를 설정할 수 있으며, 유지/보수가 용이해집니다.
자바에서는 주로 추상 클래스와 인터페이스라는 문법 요소를 사용해서 추상화를 구현합니다.
이번 챕터를 통해 추상 클래스와 인터페이스에 대한 내용들을 하나씩 학습해 보도록 하겠습니다.
abstract 제어자
자바 객체지향 프로그래밍과 추상화에 대한 핵심 개념을 살펴보기 위해 먼저 알아야 할 개념이 abstract 제어자입니다.
앞서 우리는 자바의 제어자는 크게 접근 제어자와 기타 제어자로 구분될 수 있고 기타 제어자 중에 가장 빈번하게 사용하는 제어자 중에 하나로 abstract 제어자가 있다는 점을 배웠습니다.
그렇다면 abstract 제어자란 무엇일까요?
영단어 abstract의 사전적 의미는 **‘추상적인'**이라는 뜻을 가지고 있는데, 자바의 맥락에서 abstract라는 단어가 내포하는 의미는 **‘미완성'**이라 정리할 수 있습니다.
abstract는 주로 클래스와 메서드를 형용하는 키워드로 사용되는데, **메서드 앞에 붙은 경우를 ‘추상 메서드(abstract method)’, 클래스 앞에 붙은 경우를 ‘추상 클래스(abstract class)’**라 각각 부릅니다.
어떤 클래스에 추상 메서드가 포함되어 있는 경우 해당 클래스는 자동으로 추상 클래스가 됩니다.
아래 예시를 통해 한번 확인해 보겠습니다.
abstract class AbstractExample { // 추상 메서드가 최소 하나 이상 포함돼있는 추상 클래스
abstract void start(); // 메서드 바디가 없는 추상메서드
}
abstract의 가장 핵심적인 개념은 앞서 언급한 ‘미완성'에 있습니다.
추상 메서드는 메서드의 시그니처만 있고 바디가 없는 메서드를 의미하는데, abstract 키워드를 메서드 이름 앞에 붙여주어 해당 메서드가 추상 메서드임을 표시합니다.
‘구체적인'의 반대 의미로서 ‘추상적인'이라는 형용사가 가지는 의미를 생각해 보시면 이해하는 데 도움이 될 수 있습니다. '추상적이다'라는 것의 의미는 다른 말로 표현하면 '충분히 구체적이지 않다'는 의미이기도 합니다.
즉, 추상 메서드는 충분히 구체화되지 않은 **‘미완성 메서드’**이며, 미완성 메서드를 포함하는 클래스는 **‘미완성 클래스'**를 의미하는 추상 클래스가 됩니다.
AbstractExample abstractExample = new AbstractExample(); // 에러발생.
마지막으로 추상 클래스는 앞서 설명한 대로 미완성 설계도이기 때문에 메서드 바디가 완성되기 전까지 이를 기반으로 객체 생성이 불가합니다.
그렇다면 추상 클래스는 왜 사용하는 것이며, 사용하면 어떤 장점이 있을까요?
이어지는 챕터에서 추상 클래스에 대해 좀 더 자세히 알아보겠습니다.
추상 클래스
앞서 우리는 abstract 제어자를 학습하면서 추상 클래스란, **메서드 시그니처만 존재하고 바디가 선언되어있지 않은 추상 메서드를 포함하는 ‘미완성 설계도’**임을 학습했습니다.
또한 미완성된 구조를 가지고 있기에 이를 기반으로 객체를 생성하는 것이 불가능하다고 배웠습니다.
그렇다면 왜 객체도 생성하지 못하는 미완성 클래스를 만드는 걸까요?
크게 두 가지를 언급할 수 있습니다.
먼저 추상 클래스는 상속 관계에 있어 새로운 클래스를 작성하는 데 매우 유용합니다.
메서드의 내용이 상속을 받는 클래스에 따라서 종종 달라지기 때문에 상위 클래스에서는 선언부만을 작성하고, 실제 구체적인 내용은 상속을 받는 하위 클래스에서 구현하도록 비워둔다면 설계하는 상황이 변하더라도 보다 유연하게 변화에 대응할 수 있습니다.
이때 우리가 사용하게 되는 것이 앞서 상속 파트에서 학습한 ‘오버라이딩'입니다.
오버라이딩을 통해 추상 클래스로부터 상속받은 추상 메서드의 내용을 구현하여 메서드를 완성시킬 수 있고, 이렇게 완성된 클래스를 기반으로 해당 객체를 생성할 수 있습니다.
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 키워드는 필드, 지역 변수, 클래스 앞에 위치할 수 있으며 그 위치에 따라 그 의미가 조금씩 달라지게 됩니다.
위의 표를 확인해 보면, 각각 조금의 차이점이 있지만 결국 공통적으로 변경이 불가능하고 확장할 수 없다는 점에서 유사하다고 할 수 있습니다.
final class FinalEx { // 확장/상속 불가능한 클래스
final int x = 1; // 변경되지 않는 상수
final int getNum() { // 오버라이딩 불가한 메서드
final int localVar = x; // 상수
return x;
}
}
위에서 설명한 대로 각각의 클래스, 메서드, 그리고 변수 앞에 final 제어자가 추가되면 이제 해당 대상은 더 이상 변경이 불가하거나 확장되지 않는 성질을 지니게 됩니다.
인터페이스
이제 추상 클래스와 함께 자바 객체지향 프로그래밍 추상화에서 가장 핵심적인 역할을 담당하는 **인터페이스(interface)**에 대해 알아보도록 하겠습니다.
영어에서 인터페이스는 “-간/사이"를 뜻하는 **inter**와 “얼굴/면"을 의미하는 **face**의 결합으로 구성된 단어로, 두 개의 다른 대상 사이를 연결한다는 의미를 가지고 있습니다.
비슷한 맥락에서, 컴퓨터 프로그래밍에서 사용하는 인터페이스도 **“서로 다른 두 시스템, 장치, 소프트웨어 따위를 서로 이어주는 부분 또는 그런 접속 장치"**라 정의할 수 있습니다.
일상적으로 우리가 사용하는 인터페이스의 대표적인 예로 GUI가 있습니다.
GUI는 Graphic User Interface의 약자로 컴퓨터를 사용할 때 입출력 등을 좀 더 효율적이고 쉽게 조작할 수 있도록 아이콘 등으로 시각화한 사용자 인터페이스입니다.
GUI를 통해서 사용자는 컴퓨터 내부의 어려운 컴퓨터 명령어나 복잡한 작동 원리를 깊이 이해하지 않더라도 몇 번의 마우스 클릭만으로 컴퓨터에게 원하는 작업을 수행하도록 명령할 수 있습니다.
우리에게 익숙한 마이크로소프트사의 Windows와 애플의 Mac OS 운영체제가 대표적이라 할 수 있습니다.
자바에서 인터페이스도 이와 유사한 기능을 가지고 있다고 할 수 있습니다.
인터페이스의 유용성과 장점을 이해하기에 앞서 먼저 간단하게 인터페이스가 무엇이며, 어떻게 사용될 수 있는지 살펴보겠습니다.
종종 인터페이스를 이해하기 위해 앞서 학습한 추상 클래스와 비교합니다.
기본적으로 인터페이스도 추상 클래스처럼 자바에서 추상화를 구현하는 데 활용된다는 점에서 동일하지만, 추상 클래스에 비해 더 높은 추상성을 가진다는 점에서 큰 차이가 있습니다.
추상 클래스를 설계가 모두 끝나지 않은 “미완성 설계도"에 비유할 수 있다면, 인터페이스는 그보다 더 높은 추상성을 가지는 가장 **기초적인 “밑그림"**에 빗대어 표현할 수 있습니다.
추상 클래스는 메서드 바디가 없는 추상 메서드를 하나 이상 포함한다는 점 외에는 기본적으로 일반 클래스와 동일하다고 할 수 있습니다.
반면 인터페이스는 기본적으로 추상 메서드와 상수만을 멤버로 가질 수 있다는 점에서 추상 클래스에 비해 추상화 정도가 더 높다고 할 수 있습니다.
참고로 새로운 메서드 추가의 어려움 등 기존 인터페이스가 가지는 한계를 보완하기 위해 자바 8 이후에 default/static 메서드를 인터페이스에 사용할 수 있도록 업데이트가 되었지만, 이 부분은 인터페이스의 핵심 역할을 고려했을 때 부수적인 부분에 해당하기 때문에 여기서는 인터페이스는 기본적으로 **“추상 메서드의 집합"**으로 이뤄져 있다는 사실만 기억하고 넘어가도 좋습니다.
인터페이스의 기본 구조
그러면 좀 더 구체적으로 인터페이스의 기본 구조에 대해서 알아보도록 하겠습니다.
인터페이스를 작성하는 것은 기본적으로 클래스를 작성하는 것과 유사하지만, 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 static final로, 메서드를 정의하는 경우에는 public abstract로 정의되어야 하지만 위에서 보시는 것처럼 일부분 또는 전부 생략이 가능합니다.
여기서 생략된 부분은 컴파일러가 자동으로 추가해 주게 됩니다.
인터페이스의 구현
위에서 설명한 방법으로 인터페이스를 정의하였다면, 이제 인터페이스의 구현에 대해 살펴보겠습니다.
추상 클래스와 마찬가지로 인터페이스도 그 자체로 인스턴스를 생성할 수 없고, 메서드 바디를 정의하는 클래스를 따로 작성해야 합니다.
이 과정은 앞서 배운 extends 키워드를 사용하는 클래스의 상속과 기본적으로 동일하지만, “구현하다"라는 의미를 가진 implements 키워드를 사용한다는 점에서 차이가 있다고 할 수 있습니다.
class 클래스명 implements 인터페이스명 {
... // 인터페이스에 정의된 모든 추상메서드 구현
}
특정 인터페이스를 구현한 클래스는 해당 인터페이스에 정의된 모든 추상메서드를 구현해야 합니다.
즉, 어떤 클래스가 특정 인터페이스를 구현한다는 것은 그 클래스에게 인터페이스의 추상 메서드를 반드시 구현하도록 강제하는 것을 의미합니다.
다른 말로, 어떤 클래스가 어떤 인터페이스를 구현한다는 것은 그 인터페이스가 가진 모든 추상 메서드들을 해당 클래스 내에서 오버라이딩하여 바디를 완성한다는 의미를 가집니다.
인터페이스의 다중 구현
앞서 배웠던 것처럼, 클래스 간의 상속에서 다중 상속은 허용되지 않습니다.
즉 하위 클래스는 단 하나의 상위 클래스만 상속받을 수 있습니다.
반면 인터페이스는 다중적 구현이 가능합니다.
다시 말해, 하나의 클래스가 여러 개의 인터페이스를 구현할 수 있습니다. 다만 인터페이스는 인터페이스로부터만 상속이 가능하고, 클래스와 달리 Object 클래스와 같은 최고 조상이 존재하지 않습니다.
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 인터페이스를 구현하도록 하여 같은 결과물이 출력되게 했습니다.
인터페이스의 장점
그러면 이제 간단한 코드 예제를 통해 인터페이스가 왜 유용하고, 어떤 장점을 가지고 있는지 한번 살펴보도록 하겠습니다.
여기 Provider 클래스에 의존하고 있는 User 클래스가 있습니다.
여기서 “의존한다"라는 말의 의미가 조금 어렵게 느껴질 수 있는데, 쉽게 표현하면 User 클래스에서 Provider에 정의된 특정 속성 또는 기능을 가져와 사용하고 있다는 의미입니다.
아래의 코드를 살펴보겠습니다.
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 클래스의 코드의 변경이 불가피합니다.
사실 위의 코드 예제만 보면 별다른 큰 차이가 없어 보입니다.
간단한 수정만으로 우리가 원하는 결과를 도출할 수 있었습니다.
하지만 만약 변경해야 하는 코드가 위의 예제처럼 몇 줄로 이뤄져 있지 않고, 수백, 수천 줄이 된다면 어떨까요?
아마 같은 결과를 만들어내기 위해서 수백, 수천줄에 이르는 코드 라인을 하나하나 수정해야 할 것입니다. 그마저도 제대로 수정하지 않는다면 원하는 결과를 얻어내기 어렵습니다.
인터페이스의 가장 큰 장점 중에 하나는 앞서 봤었던 일반적인 인터페이스의 기능처럼 역할과 구현을 분리시켜 사용자 입장에서는 복잡한 구현의 내용 또는 변경과 상관없이 해당 기능을 사용할 수 있다는 점입니다.
따라서 앞선 예제에서 봤었던 것과 같이 하나하나 코드를 일일이 변경해주어야 할 필요도 없어집니다.
그럼, 이제 위의 예제에 인터페이스를 적용해 보도록 하겠습니다.
코드를 적용하기 전에 인터페이스를 적용한 모습을 도식화해 보면 위의 그림처럼 나타낼 수 있습니다.
기존의 Provider 클래스에 인터페이스라는 껍데기를 씌운 형태입니다.
이제 User 클래스는 더 이상 Provider의 교체 또는 내용의 변경에 상관없이 인터페이스와의 상호작용을 통해서 의도한 목적을 달성할 수 있습니다.
위의 그림을 다시 코드로 적어보겠습니다.
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 클래스는 더 이상 코드를 변경해주지 않아도 같은 결과를 출력해 낼 수 있습니다.
위의 코드 예제에서는 설명을 위한 편의상 코드를 단순화시켰기 때문에 아직 인터페이스의 장점이 크게 와닿지 않을 수 있습니다.
만약 User 클래스에서 변경되어야 하는 코드가 한 줄이 아니라, 수백, 수천 줄이 되는 경우를 상상해 볼까요?
일일이 그 많은 코드를 변경해야 하는 번거로움은 분명 개발자에게 썩 달가운 일은 아닐 것입니다.
혹시 이해가 당장 이해가 되지 않더라도 괜찮습니다. 다음의 활용 예제를 통해 좀 더 구체적인 내용을 확인하고, 계속 반복적으로 등장하는 인터페이스의 용례를 보면서 점차 자연스럽게 인터페이스에 익숙해질 수 있습니다.
결론적으로 정리하면, 인터페이스는 기능이 가지는 역할과 구현을 분리시켜 사용자로 복잡한 기능의 구현이나 교체/변경을 신경 쓰지 않고도 코드 변경의 번거로움을 최소화하고 손쉽게 해당 기능을 사용할 수 있도록 합니다.
반대로 기능을 구현하는 개발자의 입장에서도 선언과 구현을 분리시켜 개발시간을 단축할 수 있고, 독립적인 프로그래밍을 통해 한 클래스의 변경이 다른 클래스에 미치는 영향을 최소화할 수 있다는 큰 장점이 있습니다.