오늘은 못다한 상속과 추상화에 대해 배워보는 시간을 가져보았다.
super 키워드로 자식 클래스에서 부모를 소환할 수 있다.
주로 자식에서 내부적으로 부모의 속성이나 메서드를 사용하고 싶을 때 소환한다.
Student 클래스의 greeting 메서드에서 부모의 greeting을 활용해보라.
class Person {
void greeting() {
System.out.println("안녕하세요");
}
}
class Student extends Person {
void greeting() { //부모의 greeting 호출 (기본 로직)
super.greeting();
System.out.println("저는 학생입니다"); // 추가로직
}
}
public class Main {
public static void main(String[] args) {
Student student = new Student();
student.greeting();
}
}
Student 클래스에서 부모의 greeting() 메서드를 오버라이딩 하였다.
그리고 super.greeting();을 사용해 부모 클래스의 메서드를 먼저 실행하도록 만들었다.
print("저는 학생입니다")를 추가하여 자식 클래스만의 기능도 함께 실행되도록 작성하였다.
즉 부모의 기능은 그대로 사용하면서 새로운 기능을 추가한 코드이다.
업캐스팅 (Upcasting)
부모 타입에 자식의 인스턴스를 맞추는 것을 의미한다.
다형성, 추상화를 구현하기 위해 활용한다.
다운캐스팅 (Downcasting)
부모 타입으로 생성한 자식 인스턴스를 본인 타입으로 돌려놓는 것을 의미한다.
자식의 고유한 메서드를 사용하고 싶을 때 활용한다.
Student의 인스턴스를 부모인 Person 타입으로 생성하라.
class Person {
void greeting() {
System.out.println("안녕하세요");
}
}
class Student extends Person {
void greeting() {
System.out.println("저는 학생입니다");
}
}
public class Main {
public static void main(String[] args) {
// 업캐스팅: 부모 타입으로 자식의 인스턴스 생성
Person person = new Student();
// 오버라이딩한 메서드가 호출된다
person.greeting();
}
}
먼저 Student 클래스는 Person 클래스를 상속받고 void greeting()메서드를 오버라이딩하였다.
그래서 부모의 "안녕하세요" 대신 저는 학생입니다 를 출력하도록 변경하였다.
main 함수에서는 Person person = new Student();를 사용하였다.
이처럼 부모 타입으로 자식 객체를 저장하는 것을 업캐스팅이라고 한다.
즉 변수 타입은 Person이지만 실제 객체는 Student인 상태이다.
그리고 person.greeting();를 실행하면 부모 메서드가 아니라 자식 클래스에서 오버라이딩한 메서드가 호출된다.
따라서 결과로 저는 학생입니다 가 출력이 된다.
Person 타입으로 생성된 Student의 인스턴스 person을 다운캐스팅해서 student 객체를 생성하라.
// 부모
class Person {
void greeting() {
System.out.println("안녕하세요");
}
}
// 자식
class Student extends Person {
void goSchool() {
System.out.println("학교 가는중");
}
}
public class Main {
public static void main(String[] args) {
// 업캐스팅
Person person = new Student();
// 업캐스팅된 객체는 자식의 고유한 메서드 사용 불가
// person.goSchool();
// 다운 캐스팅
if (person instanceof Student) { // 객체가 자식(student)의 인스턴스인지 확인
// 다운캐스팅: 부모 타입의 person을 자식(student)에 맞춥니다
Student student = (Student) person;
// 고유한 메서드 사용 가능
student.goSchool();
}
}
}
먼저 Person person = new Student();를 사용해 부모 타입으로 자식 객체를 저장하였다.
이걸 업캐스팅이라고하지만 업캐스팅된 상태에서는 person.goSchool();처럼 자식 클래스만의 메서드는 사용할 수 없다.
그래서 instanceof를 사용해 현재 객체가 Student 타입인지 먼저 검사하였다.
그 다음 Student student = (Student) person;를 사용해 자식 타입으로 다시 변환하였다.
이걸 다운 캐스팅이라고 한다.
다운캐스팅 이후 student.goSchool();처럼 자식 클래스의 고유한 메서드도 사용할 수 있게 된다.
상호 작용하는 객체들 사이에 단순한 인터페이스만 노출시키는 기법이다.
추상화를 통해 객체 간의 결합도를 낮출 수 있다.
추상화 구현 방법
추상 클래스는 추상 메서드 (abstract method)와 일반 메서드를 모두 가질 수 있다.
추상 메서드란 몸체가 없는 함수를 의미한다.
추상 클래스는 상속받은 클래스가 구현해야 한다.
또한 추상 메서드는 추상화의 도구일 뿐 인스턴스를 생성할 수 없다.
다음을 만족하는 추상 클래스를 정의하라
Animal
eat(): void
cry(): void
// 추상 클래스
abstract class Animal {
// 일반 메서드
void eat() {
System.out.println("냠냠");
}
// 추상 메서드- 자식 클래스가 구현해야 한다
abstract void cry();
}
public class Main {
}
abstract class Animal을 사용해 Animal 클래스를 추상클래스로 만들었다.
추상 클래스는 객체를 직접 생성할 수 없고, 주로 상속을 위해 사용된다.
안에서는 일반 메서드인 void eat()을 만들어 "냠냠"을 출력하도록 작성하였다.
그리고 abstract void cry();를 사용해 추상 메서드도 선언하였다.
추상 메서드는 내용이 없는 메서드이며 자식 클래스에서 반드시 구현해야 한다는 의미를 가진다.
추상 클래스를 상속받은 클래스는 부모의 추상 메서드를 구현해야 한다.
만약 구현하지 않으면 자식 또한 추상 클래스가 된다
다음은 추상 클래스를 상속받는 방법이다.
추상 클래스 Animal을 상속받아 각각 Dog, Cat, Duck 클래스를 정의하고 cry() 메서드를 구현하라.
// 추상 클래스 (부모)
abstract class Animal{
abstract void cry();
}
class Dog extends Animal {
void cry() {
System.out.println("멍멍");
}
}
class Cat extends Animal {
void cry() {
System.out.println("야옹");
}
}
class Duck extends Animal {
void cry() {
System.out.println("꽥꽥");
}
}
public class Main {
public static void main(String[] args) {
Animal dog = new Dog();
dog.cry();
Animal cat = new Cat();
cat.cry();
Animal duck = new Duck();
duck.cry();
}
}
abstract class Animal을 사용해 Animal 추상 클래스를 만들었다.
그리고 abstract void cry();라는 추상 메서드를 선언하였다.
추상 메서드는 내용이 없는 메서드이며, 자식 클래스에서 반드시 구현해야 한다.
그 다음 Dog, Cay, Duck 클래스가 Animal 클래스를 상속받고 각각 cry() 메서드를 오버라이딩하였다.
Dog은 멍멍, Cat은 야옹, Duck은 꽥꽥 처럼 각 동물마다 다른 소리를 출력하도록 작성하였다.
Main 함수에서는 Animal dog = new Dog();처럼 부모 타입으로 자식 객체를 저장하였다.
그리고 dog.cry();를 실행하면 실제 객체 타입에 맞는 메서드가 출력이 된다.
즉 같은 cry() 메서드라해도 객체에 따라 다른 결과가 출력된다.
인터페이스는 추상 클래스보다 높은 수준의 추상화 구현 도구로 추상 메서드만 가질 수 있다.
인터페이스에 필드를 선언하면 기본값으로 static final, 메서드는 public abstract 이다.
다음을 만족하는 인터페이스를 정의하라
interface
Animal
cry(): void
// 인터페이스: 기준, 규격
interface Animal {
// pulblic abstract
void cry();
}
public class Main {
}
interface를 사용해 Animal 인터페이스를 만들었다.
인터페이스는 클래스들이 따라야 하는 규칙이나 기능의 기준 역할을 한다.
안에는 void cry(); 메서드를 선언하였다.
인터페이스 안의 메서드는 기본적으로 public abstract 가 자동으로 붙는다.
즉 실제 기능은 작동하지 않고 동물이라면 cry() 기능을 반드시 구현해야 한다. 라는 규칙만 정해둔 것이다.
아래의 인터페이스를 코드로 구현하라
interface
Chessman
move():void
interface Chessman {
void move();
}
public class Main {
}
1번 예제와 동일하게 작성하였다.
인터페이스를 구현하는 클래스를 정의하는 방법이다.
class 클래스 implements 인터페이스
인터페이스 Animal을 구현해 각각 Dog, Cat, Duck 클래스를 정의하고 cry() 메서드를 구현하라.
interface Animal {
void cry();
}
class Dog implements Animal {
public void cry() {
System.out.println("멍멍");
}
}
class Cat implements Animal {
public void cry() {
System.out.println("야옹");
}
}
class Duck implements Animal {
public void cry() {
System.out.println("꽥꽥");
}
}
public class Main {
public static void main(String[] args) {
Animal dog = new Dog();
Animal cat = new Cat();
Animal duck = new Duck();
dog.cry();
cat.cry();
duck.cry();
}
}
interface를 사용해 Animal 인터페이스를 만들고 cry(); 메서드를 선언하였다.
그 다음 Dog, Cay, Duck 클래스에서 implements Animal을 사용해 인터페이스를 구현하였다.
그리고 각각의 클래스에서 cry() 메서드를 오버라이딩하여 Dog은 멍멍, Cat은 야옹, Duck은 꽥꽥이 출력되도록 작성하였다.
Main 함수에는 Animal dog = new Dog();처럼 인터페이스 타입으로 객체를 저장하였다.
그리고 dog.cry()를 실행하면 실제 객체 타입에 맞는 메서드가 호출된다.
즉 같은 cry의 메서드라도 객체에 따라 다른 결과가 출력되는 다형성 구조의 코드인 것이다.
Chessman 인터페이스를 따르는 Queen, Knight, Bishop 클래스를 정의하고 move 메서드를 구현하라.
interface Chessman {
void move();
}
class Queen implements Chessman {
public void move() {
System.out.println("모든 곳으로 이동 가능");
}
}
class Knight implements Chessman {
public void move() {
System.out.println("L자 이동 가능");
}
}
class Bishop implements Chessman {
public void move() {
System.out.println("대각선으로 이동 가능");
}
}
public class Main {
public static void main(String[] args) {
Chessman queen = new Queen();
Chessman knight = new Knight();
Chessman bishop = new Bishop();
queen.move();
knight.move();
bishop.move();
}
}
1번 예제와 동일하게 작성하였다.
인터페이스 간에도 상속이 가능하다.
다층 상속(Multi-level), 다중 상속(Multiple)을 모두 지원한다.
interface 자식 인터페이스 extends 부모 인터페이스1, 부모 인터페이스2
이는 인터페이스 상속 방법이다.
YouTube(부모)와 YouTube Premium(자식) 인터페이스가 다음과 같을 때 코드로 구현하라
Youtube YouTube Premium
playVideo(): void removeAds(): void
// 부모 인터페이스
interface Youtube {
void playVideo();
}
// 자식 인터페이스
interface YoutubePremium extends Youtube {
void removeadds();
}
public class Main {
}
interface를 사용해 부모 인터페이스(Youtube)를 만들고 void playVideo(); 메서드를 선언하였다.
그 다음 interface YoutubePremium extends Youtube를 사용해 YoutubePremium 인터페이스가 Youtube 인터페이스를 상속받도록 만들었다.
인터페이스도 클래스처럼 extends를 사용해 상속할 수 있다.
그리고 void removeadds(); 메서드를 추가해 프리미엄 기능인 광고 제거 기능도 선언하였다.
즉 YoutubePremium은 playVideo()와 removeadds() 두 기능을 모두 가지게 된다.
다음과 같이 세 인터페이스가 있을 때, Smartphone이 Phonen, Camera를 상속받은 코드를 구현하라.

interface Phone {
void call();
}
interface Camera {
void takePhoto();
}
interface SmartPhone extends Phone, Camera {
void playApp();
}
// 스마트폰 인터페이스를 구현하는 클래스
class iPhone implements SmartPhone {
public void takePhoto() {
System.out.println("사진 찍는중");
}
public void call() {
System.out.println("전화 거는중");
}
public void playApp() {
System.out.println("앱 실행중");
}
}
public class Main {
public static void main(String[] args) {
SmartPhone iPhone = new iPhone(); {
iPhone.takePhoto();
iPhone.call();
iPhone.playApp();
}
}
}
Phone 인터페이스를 만들고 전화기능인 call 메서드를 선언하였다.
그리고 Carmera 인터페이스에서는 사진촬영 기능인 takePhoto 메서드를 선언하였다.
그 다음 extends를 통해 SmartPhone 인터페이스가 Phone과 Camera 인터페이스를 동시에 상속받도록 만들었다.
그리고 void playApp(); 메서드를 추가해 앱 실행 기능도 선언하였다.
iPhone 클래스에서는 implements SmartPhone을 사용해 스마트폰 인터페이스를 구현하였다.
그래서 takePhoto(), call(), playApp() 메서드를 모두 오버라이딩하였다.
main 함수에서는 SmartPhone iPhone = new iPhone();을 사용해 객체를 생성하였다.
그리고 사진 촬영, 전화, 앱실행 기능을 각각 호출하도록 작성하였다.
위치 정보를 저장하고 그 위치를 기반으로 추천 장소를 출력하는 지도 서비스 코드를 작성하라.
// 지도 서비스
class MapService {
// 외부에서 접근 불가
private int lat; // 위도 (lattitude)
private int lng; // 경도 (longitude)
MapService(int lat, int lng) {
this.lat = lat;
this.lng = lng;
}
// 장소 추천해주는 기능
void recommendPlace() {
System.out.printf
("위치 [%d, %d] 에 기반한 추천 장소들입니다", lat, lng);
}
}
public class Main {
public static void main(String[] args) {
MapService mapService = new MapService(100, 200);
mapService.recommendPlace();
}
}
먼저 MapService 클래스를 만들고 private을 사용해 위도와 경도 값을 선언하였다.
여기서 private은 외부에서 직접 접근하지 못하도록 데이터를 보호하는 역할을 한다.
그 다음 생성자인 MapService(int lat, int lng)을 만들고 this.lat = lat; this.lng = lng;을 사용해 전달받은 값을 객체 안에 저장하도록 작성하였다.
그리고 recommendPlace() 메서드를 만들어 현재 위치를 기반으로 추천 장소를 출력하도록 작성하였다.
안에서는 printf를 사용해 위도와 경도를 문자열 안에 함께 출력하였다.
main 함수에서는 new MapService(100, 200)를 사용해 객체를 생성하고 위치 정보를 전달하였다.
마지막으로 mapService.recommendPlace(); 를 호출해 위치 기반 추천 장소 분구가 출력되도록 작성하였다.
사람(Person)과 반려동물(Pet) 객체가 서로 협력하는 구조를 객체지향 방식으로 표현하라
class Pet {
String type;
String name;
Pet(String name, String type) {
this.name = name;
this.type = type;
}
void roll() {
System.out.println("구르는 중!");
}
}
class Person {
String name;
// Pet에 의존합니다
Pet pet;
Person(String name, Pet pet) {
this.name = name;
this.pet = pet;
}
void play() {
System.out.println("굴러" + pet.name + "!");
pet.roll();
}
}
public class Main {
public static void main(String[] args) {
Pet pet = new Pet("퐁고", "강아지");
// 의존성 주입: Person에게 Pet을 주입한다
Person person = new Person("John Doe", pet);
person.play();
}
}
먼저 Pet 클래스를 만들고 String을 사용해 동물 종류와 이름을 저장하도록 작성하였다.
그리고 생성자를 통해 값을 전달받아 객체 안에 저장하였다.
또 roll 메서드를 만들어 "구르는 중!"을 출력하도록 작성하였다.
그 다음 Person 클래스에서는 Pet pet을 선언하였다.
즉 Person 클래스가 Pet 객체를 사용하는 구조이며, 이것을 객체 의존이라고 한다.
생성자에서는 Person(String name, Pet pet)을 사용해 외부에서 Pet 객체를 전달받도록 만들었다.
이처럼 객체를 직접 생성하지 않고 외부에서 전달받는 것을 의존성 주입(DI)이라고 한다.
play() 메서드에서는 pet.roll();을 호출해 반려동물의 기능을 실행하였다.
즉 Person 객체가 pet 객체의 기능을 사용하는 구조다.
main 함수에서는 Pet pet = new Pet("퐁고", "강아지");를 사용해 반려동물 객체를 만들고, Person person = new Person("John Doe", pet);을 통해 사람 객체에게 반려동물을 전달하였다.
마지막으로 person.play(); 를 실행하면 "굴러 퐁고!" "구르는 중!"이 출력된다.
나쁜 예시
// 애플페이 처리기
class ApplePayProcessor {
void processPay() {
System.out.println("애플페이 결제요청 처리중");
}
}
// 카카오페이 처리기
class KakaoPayProcessor {
void processPay() {
System.out.println("카카오페이 결제요청 처리중");
}
}
// 네이버페이 처리기
class NaverPayProcessor {
void processPay() {
System.out.println("네이버페이 결제요청 처리중");
}
}
// 주문 처리 시스템
class OrderService {
// 주문 처리 로직
void processOrder(String payRequest) {
// payRequest: 요청받은 결제 방법(카카오페이, 애플페이, 네이버페이 등)
System.out.println("주문이 접수되었습니다"); // 주문 시작
// 결제 처리- 결제 처리기 활용
// 문제 1. OCP 위반: 새 결제수단이 추가되면 OrderService를 수정해야 함
// 문제 2. 추상화 없음: OrderService가 여러가지 결제 처리기를 알아야 함
// OrderService: 주문과 관련된 핵심로직만 가지는 게 좋다
// 문제 3. 다형성 없음: 결제 요청에 따라 각자 다른 결제 처리기를 사용해야 함
if ("apple-pay".equals(payRequest)) {
ApplePayProcessor applePayProcessor = new ApplePayProcessor();
applePayProcessor.processPay();
} else if ("kakao-pay".equals(payRequest)) {
KakaoPayProcessor kakaoPayProcessor = new KakaoPayProcessor();
kakaoPayProcessor.processPay();
} else if ("naver-pay".equals(payRequest)) {
NaverPayProcessor naverPayProcessor = new NaverPayProcessor();
naverPayProcessor.processPay();
} else {
System.out.println("알 수 없는 결제 요청");
return;
}
System.out.println("주문이 완료되었습니다"); // 주문처리 완료
}
}
public class Main {
public static void main(String[] args) {
OrderService orderService = new OrderService();
// 카카오페이로 주문이 들어옴
orderService.processOrder("kakao-pay");
}
}
이 코드가 나쁜 예시라고 하는 이유는 OrderService가 너무 많은 것을 알고있기 때문이다.
원래 OrderService는 주문 처리만 담당하는 클래스가 되어야 한다.
그런데 지금은 애플페이, 카카오페이, 네이버페이 결제 방식까지 전부 직접 처리하고 있다.
if ("apple-pay".equals(payRequest)) 예를들어 여기처럼 조건문이 계속 늘어나고 있다.
만약 새로운 결제수단이 추가가 된다면 토스페이 추가 삼성페이 추가 페이코 추가... 계속 if-else를 수정해야 한다.
즉 기능을 추가할 때마다 기존 코드를 수정해야 한다라는 문제가 생긴다.
이걸 OCP 위반이라고 한다.
그리고 또 ApplePayProcessor, KakaoPayProcessor, NaverPayProcessor를 OrderService가 전부 알고 있다라는 점이다.
즉 주문 클래스가 결제 클래스들에 너무 강하게 묶여있다.
이걸 결합도가 높다라고 표현한다.
또 다형성도 제대로 사용하지 못하고 있다.
현재는 if else if else 로 직접 분기하고 있지만 인터페이스를 사용한다면 PayProcessor payProcessor 하나로 다양한 결제 방식을 처리할 수 있다.
즉 결제 방식이 바뀌어도 주문 로직은 그대로 유지가 가능하다라는 것이다.
그렇다면 어떻게 바꿔야 할까?
좋은 예시
// 결제 처리기 인터페이스
// ISP 준수: 결제 처리기는 결제처리만 담당한다
// ISP (인터페이스 분리 원칙)
interface PayProcessor {
void processPay();
}
// 애플페이 처리기
class ApplePayProcessor implements PayProcessor {
// LSP: 부모가 시킨 일을 처리한다.
// Lsp: (리스코프 치환 원칙)
public void processPay() {
System.out.println("애플페이 결제요청 처리중");
}
}
// 카카오페이페이 처리기
class KakaoPayProcessor implements PayProcessor {
public void processPay() {
System.out.println("카카오페이 결제요청 처리중");
}
}
// 네이버페이 처리기
class NaverPayProcessor implements PayProcessor {
public void processPay() {
System.out.println("네이버페이 결제요청 처리중");
}
}
// 삼성페이 추가
// class SamsungPayProcessor implements PayProcessor {}
// OCP: 여기에 새 결제수단을 추가할 수 있다.
// OCP (개방-폐쇄 원칙)
// 주문 처리기
class OrderService{
// 결제처리기를 주입받습니다
// DIP: 구체적인 결제처리가 아니라 '결제처리기' 인터페이스에 의존
// DIP (의존 역전 원칙)
PayProcessor payProcessor;
OrderService(PayProcessor payProcessor) {
this.payProcessor = payProcessor;
}
// 주문 처리 로직
void processOrder() {
System.out.println("주문이 접수되었습니다");
// 결제 처리
// 추상화 달성: OderService가 결제 수단에 대해 자세히 알 필요가 없다
// 다형성 달성: payProcessor가 모든 결제 요청을 처리할 수 있다
payProcessor.processPay();
// 주문 완료
System.out.println("주문 처리가 완료되었습니다");
}
}
public class Main {
public static void main(String[] args) {
// 요청받은 결제 수단
String payRequest = "kakao-pay";
PayProcessor payProcessor;
// 결제 수단에 따라 다른 인스턴스
if ("apple-pay".equals(payRequest)) {
payProcessor = new ApplePayProcessor();
} else if ("kakao-pay".equals(payRequest)) {
payProcessor = new KakaoPayProcessor();
} else if ("naver-pay".equals(payRequest)) {
payProcessor = new NaverPayProcessor();
} else {
System.out.println("알 수 없는 결제 요청");
return;
}
OrderService orderService = new OrderService(payProcessor);
// 주문 처리 로직을 호출합니다
orderService.processOrder();
}
}
이 코드의 가장 큰 장점은 결제 방식이 바뀌어도 주문 로직을 거의 수정하지 않아도 된다라는 점이다.
먼저 interface PayProcessor를 사용해 결제 처리 규칙을 만들었다.
그래서 애플페이, 카카오페이, 네이버페이 모두 같은 방식으로 동작할 수 있게 되었다.
이 덕분에 payProcessor.processPay(); 한 줄로 모든 결제 처리가 가능해졌다.
이것이 다형성이다.
그리고 새로운 결제수단이 추가되어도 class SamsungPayProcessor implements PayProcessor 처럼 클래스만 새로 만들면 된다.
기존 OrderService 코드는 수정하지 않아도 된다.
즉 기능 확장에는 열려있고 기존 코드 수정에는 닫혀있는 구조가 되었고, 이것을 OCP 라고 한다.
또 PayProcessor payProcessor; 처럼 인터페이스에 의존하도록 만들었다.
즉 OrderService는 애플페이인지 카카오페이인지 네이버페이인지 자세히 알 필요가 없다.
그냥 결제 처리기면 된다 라고만 생각한다.
이것이 DIP(의존 역전 원칙)이다.
그리고 각 결제 클래스는 processPay() 기능만 담당한다.
즉 역할이 명확하게 분리되어 있다.
이것을 ISP (인터페이스 분리원칙) 이라고 한다.
결국 이 코드는 다형성을 사용하고 유지보수가 쉬우며 새기능 추가가 편하다.
또한 역할이 명확하게 분리되어있고 결합도가 낮아졌으며 객체지향 설계를 잘 따른 구조의 코드라는 것이다.
오늘은 인터페이스, 추상 클래스, 다형성, 의존성 주입 같은 객체지향 개념들을 많이 배워보았다.
처음에는 용어도 어렵고 코드 흐름도 복잡하게 느껴졌지만, 직접 코드를 따라 작성해보면서 왜 이런 구조를 사용하는지 조금씩 이해가 되기 시작했다.
특히 인터페이스를 사용하면 여러 기능을 같은 방식으로 처리할 수 있다는 점과, 다형성을 통해 코드가 훨씬 유연해진다는 점이 신기했다.
또 좋은 코드와 나쁜 코드를 비교해보면서 유지보수와 확장성을 고려하는 이유도 조금 알게 되었다.
아직은 헷갈리는 부분도 많고 혼자 응용하는 건 어렵지만, 계속 반복해서 연습하면 점점 익숙해질 것 같다는 생각이 들었다.