2024. 3. 15. 20:59ㆍ백엔드/자바
자바 - 객체 지향 프로그래밍의 4가지 특징(추상화, 상속, 다형성, 캡슐화)
까비: 오늘은 꼬비가 클래스를 알려준다고 했는데... 클래스는 얼마나 재밌을까?
해가 쨍한 아침부터 까비는 길을 나섰다. 꼬비네 집으로 가는 길은 숲을 지나야 했다. 초록색 푸르름이 울창하게 깔려 있고, 매일 아침마다 새들이 바쁘게 지저귀는 숲이었다.
까비: 오늘도 숲 속 새 친구들은 수다스럽네. 응 저게 뭐지?
까비는 멀리 보이는 나무 밑에 무언가 반짝이는 것을 보았다. 호기심이 생긴 까비는 땅땅한 몸뚱이로 반짝이는 것을 향해 폴짝폴짝 뛰어갔다.
까비: 응 이게 뭐지 작고 예쁜 돌멩이네?
까비는 처음 보는 물건을 넋이 나간 듯 바라보았다. 그 순간
까비: 어어! 으아아아아악!
갑자기 까비의 발 밑이 푹 꺼지고 도깨비 한 명이 충분히 들어갈만한 구멍이 생겼다. 까비는 미처 피할 새도 없이 구멍으로 떨어져 버렸다.
객체 지향 프로그래밍(OOP, Object-Oriented Programming)
객체 지향 프로그래밍에서 모든 데이터를 객체(object)로 취급하며, 이러한 객체가 프로그래밍의 중심이 된다. 객체(object)란 간단히 이야기하자면 실생활에서 우리가 인식할 수 있는 사물 혹은 현상으로 설명할 수 있다. 이러한 객체의 상태(state)와 행동(behavior)을 구체화하는 형태의 프로그래밍이 바로 객체 지향 프로그래밍이다.
객체 지향 프로그래밍의 장단점
장점
- 1. 코드 재사용이 용이
- 2. 유지보수가 쉬움
- 3. 대형프로젝트에 적합
단점
- 1. 처리속도가 상대적으로 느림
- 2. 객체가 많으면 용량이 커질 수 있음
- 3. 설계 시 많은 시간과 노력이 필요
으 머리야...
객체 지향 프로그래밍 키워드 4가지
- 추상화
- 상속
- 다형성
- 캡슐화
뭐가 어떻게 된 거지?
???: 넌 누구지?
까비: 으악! 누... 누구세요?
???: 건방진 꼬마로군... 나는 두 번 말하는 것을 아주 싫어한다 꼬마야.
까비: 저... 저는 까비예요...
???: 그래 감히 내 땅에 발을 들인 이유가 있을 테지?
까비: 아... 아뇨 저는 그냥 꼬비한테 클래스를 배우러 가던 중이었는데... 갑자기 땅이 푹 꺼지고 정신을 차려보니 이곳이었어요...
???: 꼬비라면 자기가 코딩을 잘한다고 착각하는 그 애송이 꼬마겠군. 그 애송이가 울면서 달아나는 꼴이 얼마나 우스웠는지... 클래스를 배운다고? 혹시 JVM 녀석의 칠칠맞은 클래스를 말하는 것이냐.
까비: 맞아요. 하지만 JVM 할아버지는 멋진 분이세요! 그렇게 말할 분이 아니라구요!
???: 과연 그럴까? 허울뿐인 모습에 현혹되어 본질을 보지 못하는구나. 네가 그렇게 좋아하는 자바를 잔뜩 먹여줄 테니 어디 한 번 실컷 즐겨보거라. 단, 의문이 들지 않는다면 넌 그곳에 갇혀 영영 빠져나오지 못할 것이다.
까비: 그게 무슨 말이...
순간 까비의 눈앞이 다시 깜깜해졌다. 그리고 까비는 극심한 어지러움과 함께 어디론가 빨려 들어가는 느낌이 들어 정신을 잃었다.
추상화(Abstraction)
추상이라는 용어의 사전적 의미를 보면 "사물이나 표상을 어떤 성질, 공통성, 본질에 착안하여 그것을 추출하여 파악하는 것"이라 정의한다. 여기서 핵심이 되는 개념은 "공통성과 본질을 모아 추출 "한다는 것이다. 예를 들어, 서울의 지하철 노선도는 서울의 지리를 추상화시켜서 보여주는 대표적인 예라고 할 수 있다. 중요한 부분을 강조하기 위해 불필요한 세부사항들은 제거하고 가장 본질적이고 공통적인 부분만을 추출하여 표현하는 것과 관련이 있다.

같은 맥락에서, 객체 지향 프로그래밍에서 의미하는 추상화는 객체의 공통적인 속성과 기능을 추출하여 정의하는 것을 의미한다.

자동차와 오토바이는 모두 이동 수단이며 모든 이동 수단은 전진과 후진을 할 수 있다는 공통점을 가진다. 이것을 자바 문법 요소를 사용하여 표현하면, 자동차와 오토바이라는 하위 클래스(sub-class)들의 공통적인 기능(전진과 후진)을 추출하여 이동 수단이라는 상위 클래스(super class)에 정의했다. 위의 예제에선 편의상 공통적인 기능(메서드)만 추출했지만, 공통적인 속성(변수)도 추출하여 선언하는 것이 가능하다.
위에서 살펴본 내용을 코드로 표현하면 다음과 같다. 자바에서 추상화를 구현할 수 있는 문법 요소로는 추상 클래스(abstract class)와 인터페이스(interface)가 있는데, 이번 예제에선 OOP에서 더 많이 쓰이는 인터페이스를 사용해 본다.
Vechicle 인터페이스
public interface Vehicle {
public abstract void start()
void moveForward(); //public abstract 키워드 생략 가능
void moveBackward();
}
가장 먼저 자동차와 오토바이의 공통적인 기능을 추출하여 이동 Vechicle 인터페이스에 정의한다. 컴퓨터 프로그래밍에서 인터페이스란 "서로 다른 두 시스템, 장치, 소프트웨어 따위를 서로 이어주는 부분 또는 그런 접속 장치"이라 정의할 수 있다. 객체 지향적 설계에서 인터페이스는 어떤 객체의 역할만을 정의하여 객체들 간의 관계를 보다 유연하게 연결하는 역할을 담당한다.
다른 말로 표현하면, 인터페이스에는 추상 메서드나 상수를 통해서 어떤 객체가 수행해야 하는 핵심적인 역할만을 규정해 두고, 실제적인 구현은 해당 인터페이스를 구현하는 각각의 객체들에서 하도록 프로그램을 설계하는 것을 의미한다.
다음은 Vehicle 인터페이스를 실제로 구현한 코드 예제이다.
Car 클래스
public class Car implements Vehicle{ // 이동수단을 구체화한 자동차 클래스
@Override
public void moveForward() {
System.out.println("자동차가 전진한다");
}
@Override
public void moveBackward() {
System.out.println("자동차가 후진한다");
}
}
MotorBike 클래스
public class MotorBike implements Vehicle{ // 이동수단을 구체화한 오토바이 클래스
@Override
public void moveForward() {
System.out.println("오토바이가 전진한다");
}
@Override
public void moveBackward() {
System.out.println("오토바이가 후진한다");
}
}
위에서 확인할 수 있는 것처럼, Vehicle 인터페이스를 구현한 구현체, Car와 MotorBike 클래스에서 앞서 인터페이스에 정의한 역할을 각각의 클래스의 맥락에 맞게 구현하고 있다. 즉, 각각 클래스 모두 전진과 후진의 기능을 공통적으로 가지지만, 차는 차의 시동을 걸어야 하고, 오토바이는 오토바이의 시동을 걸어야 하기 때문에 그 구현은 각 클래스에 따라 달라져야 한다.
이것을 객체 지향 프로그래밍에서는 역할과 구현의 분리라고 하며, 이 부분이 아래에서 살펴볼 다형성과 함께 유연하고 변경이 용이한 프로그램을 설계하는 데 가장 핵심적인 부분이 된다. 정리하면, 객체 지향 프로그래밍에서는 보다 유연하고 변경에 열려있는 프로그램을 설계하기 위해 역할과 구현을 분리하는데, 여기서 역할에 해당하는 부분이 인터페이스를 통해 추상화될 수 있다.
상속(Inheritance)
상속이란 기존의 클래스를 재활용하여 새로운 클래스를 작성하는 자바의 문법 요소를 의미한다.
앞서 봤었던 추상화의 연장선에서, 상속은 클래스 간 공유될 수 있는 속성과 기능들을 상위 클래스로 추상화시켜 해당 상위 클래스로부터 확장된 여러 개의 하위 클래스들이 모두 상위 클래스의 속성과 기능들을 간편하게 사용할 수 있도록 한다.
즉, 클래스들 간 공유하는 속성과 기능들을 반복적으로 정의할 필요 없이 딱 한 번만 정의해 두고 간편하게 재사용할 수 있어 반복적인 코드를 최소화하고 공유하는 속성과 기능에 간편하게 접근하여 사용할 수 있도록 한다.
앞서 봤던 예제를 약간 변형해 상속의 맥락에서 살펴보자.

위의 그림을 보면 자동차와 오토바이가 있고, 각각의 기능과 속성들이 명시되어 있다. 이 중에서 빨간색으로 표시된 속성과 기능들은 자동차와 오토바이의 공통적인 부분들이고, 푸른색으로 표시된 부분들은 그렇지 못한 부분이다.
Car 클래스
public class Car {
String model;
String color;
int wheels;
//Car 클래스의 고유 속성
boolean isConvertible;
void moveForward() {
System.out.println("앞으로 전진한다");
}
void moveBackwoard() {
System.out.println("뒤로 후진한다");
}
//Car 클래스의 고유 기능
void openWindow() {
System.out.println("모든 창문을 연다")
}
}
MotorBike 클래스
public class MotorBike {
String model;
String color;
int wheels;
//MotorBike 클래스의 고유 속성
boolean isRaceable;
void moveForward() {
System.out.println("앞으로 전진한다");
}
void moveBackwoard() {
System.out.println("뒤로 후진한다");
}
//MotorBike 클래스의 고유 기능
public void stunt() {
System.out.println("앞바퀴를 든다")
}
}
위의 코드 예제를 보면, 딱 봐도 같은 코드가 반복적으로 정의되고 있다는 사실을 알 수 있다. 좀 더 구체적으로 보면, 각각의 클래스마다 속성으로 `model`, `color`, `wheels` 기능으로 `moveForward()`와 `moveBackward`가 완전히 동일한 코드임에도 불구하고 계속 반복되고 있다는 점을 확인할 수 있다. 또한 하나의 코드에서 변경 사항이 일어나면, 해당 코드의 변경 사항을 다른 클래스에서도 일일이 수정해야 한다는 어려움이 있다.
문제를 해결해 주기 위해 앞서 설명한 추상화와 상속을 활용하여 코드를 재정의한다. 아래 그림을 참고하며 코드를 확인하자.

Vehicle 클래스
public class Vehicle { // 추상화를 통한 상위클래스 정의
String model;
String color;
int wheels;
void moveForward() {
System.out.println("앞으로 전진한다");
}
void moveBackwoard() {
System.out.println("뒤로 후진한다");
}
}
Car 클래스
public class Car extends Vehicle {
boolean is Convertible;
void openWindow() {
System.out.println("모든 창문을 연다");
}
}
MotorBike 클래스
public class MotorBike extends Vehicle {
booleab is Raceable;
//메서드 오버라이딩 -> 기능 재정의
@Override
void moveForward() {
System.out.println("오토바이가 앞으로 전진한다");
}
public void stunt() {
System.out.println("앞바퀴를 든다.")
}
}
Main 실행 클래스
public class Main {
public static void main(String[] args) {
//객체 생성
Car car = new Car();
MotorBike motorBike = new MotorBike();
//car 객체의 속성 정의
car.model = "테슬라";
car.color = "빨강색";
System.out.println("나의 자동차는 " + car.color + " " + car.model + "입니다" );
// 객체들의 기능 실행
car.moveForward();
motorBike.moveForward();
motorBike.moveBackward();
}
}
//출력값
나의 자동차는 빨강색 테슬라입니다
앞으로 전진한다.
오토바이가 앞으로 전진한다.
뒤로 후진한다.
위의 코드 예제를 보면, Car 와 MotorBike 클래스의 공통적인 속성과 기능들을 추출(추상화)하여 Vehicle클래스(상위 클래스)에 정의하였고, extends 키워드를 통해 각각의 하위 클래스로 확장하여 해당 기능과 속성들을 매번 반복적으로 정의해야 하는 번거로움을 제거했다. 또한, 공통적인 코드의 변경이 있는 경우 상위 클래스에서 단 한 번의 수정으로 모든 클래스의 변경 사항이 반영될 수 있도록 만들었다.
참고로, MotorBike 클래스에서 확인할 수 있듯이, 상위 클래스의 기능과 속성들을 그대로 사용할 수도 있지만, 각각의 클래스의 맥락에 맞게 메서드 오버라이딩(method overridin)을 사용하여 내용을 재정의할 수도 있다.
사실 이 부분이 앞서 추상화에서 봤었던 인터페이스를 통한 구현과 상속을 구분하는 핵심적인 차이 중에 하나이다. 즉, 양자 모두 상위 클래스 - 하위 클래스의 관계를 전제하면서 공통적인 속성과 기능을 공유할 수 있지만, 상속의 경우 상위 클래스의 속성과 기능들을 하위 클래스에서 그래도 받아 사용하거나 오버라이딩을 통해 선택적으로 재정의하여 사용할 수 있는 반면, 인터페이스를 통한 구현은 반드시 인터페이스에 정의된 추상 메서드의 내용이 하위 클래스에서 정의되어야 한다.
결론적으로, 상속 관계의 경우 인터페이스를 사용하는 구현에 비해 추상화의 정도가 낮다고 할 수 있다. 인터페이스가 역할에 해당하는 껍데기만 정의해 두고, 하위 클래스에서 구체적인 구현을 하도록 강제하는 것에 비해, 상속 관계의 경우 상황에 따라 모든 구체적인 내용들을 정의해 두고 하위 클래스에서는 그것을 단순히 가져다가 재사용할 수 있다.
다형성
객체 지향 프로그래밍의 꽃이라 할 수 있는 다형성에 대해 간략히 살펴보자. 다형성(多形性)이란 한자 이름 그대로 어떤 객체의 속성이나 기능이 상황에 따라 여러 가지 형태를 가질 수 있는 성질을 의미한다.
비유적으로 표현하자면, 어떤 중년의 남성이 있다고 했을 때 그 남자의 역할이 아내에게는 남편, 자식에게는 아버지, 부모님에게는 자식, 회사에서는 회사원, 동아리에선 총무 등 상황과 환경에 따라서 달라지는 것과 비슷하다.

객체 지향에서의 다형성도 이와 비슷한. 즉, 어떤 객체의 속성이나 기능이 그 맥락에 따라 다른 역할을 수행할 수 있는 객체 지향의 특성을 의미한다. 대표적인 예로 우리가 앞서 본 메서드 오버라이딩과 메서드 오버로딩(method overloading)이 있다.
Vehicle 인터페이스
public interface Vehicle {
public abstract void start() {
void moveForward(); //public abstract 키워드 생략 가능
void moveBackward();
}
}
Car 클래스
public class Car implements Vehicle{ // 이동수단을 구체화한 자동차 클래스
@Override
public void moveForward() {
System.out.println("자동차가 앞으로 전진한다.");
}
@Override
public void moveBackward() {
System.out.println("자동차가 뒤로 후진한다.");
}
}
앞서 추상화에서 본 것처럼 메서드 오버라이딩을 사용하면 같은 이름의 `moveForward()`와 moveBackward() 를 각각의 클래스의 맥락에 맞게 재정의하여 사용할 수 있다. 죽, 같은 이름의 메서드가 상황에 따라 다른 역할을 수행하는 것이다. 또한, 하나의 클래스 내에서 같은 이름의 메서드를 여러 개 중복하여 정의하는 것을 의미하는 메서드 오버로딩도 이와 같은 맥락이라 할 수 있다.
사실 앞서 언급한 메서드 오버라이딩과 메서드 오버로딩도 다형성의 중요한 예시지만, 객체 지향의 맥락에서 이것보다 더 중요한 다형성의 정의는 이것이다.
객체 지향 프로그래밍에서 다형성이란 한 타입의 참조변수를 통해 여러 타입의 객체를 참조할 수 있도록 만든 것을 의미한다. 좀 더 구체적으로, 상위 클래스 타입의 참조 변수로 하위 클래스의 객체를 참조할 수 있도록 하는 것이다.
위의 정의만 봐서는 단번에 이해하기가 어렵다. 이해를 돕기 위해 예를 한 가지 들어보자.
사람이 음식을 먹는다.
여기서 `음식`은 상황에 따라서 피자가 될 수도, 치킨이 될 수도, 라면이 될 수도, 탕수육이 될 수도 있다. 한편, `사람`은 옆집의 철수가 될 수도, 영희가 될 수도, 선생님이 될 수도, 내 동생이 될 수도 있다. 정리하면, 앞서 쭉 설명한 다형성의 맥락과 마찬가지로 `음식`과 `사람`은 그 상황과 맥락에 따라서 모습을 바꿀 수 있다.
지금까지 쭉 사용해 왔던 `이동 수단`을 다시 한번 더 예시로 들어보면, 이동 수단은 `자동차`가 될 수도, `오토바이`가 될 수도 있다. 다르게 표현해 보면, `자동차는 자동차이다.`라는 명제와 `자동차는 이동수단이다.`라는 명제는 모두 참이다. 오토바이의 경우도 마찬가지이다. 이동수단이라는 범위 안에 자동차와 오토바이를 하나로 묶을 수 있게 된다. 즉, 이동수단은 작은 개념들을 품을 수 있는 포괄적인 개념이라는 의미이다.
다시 위의 정의로 돌아가서, 객체 지향 프로그래밍에서 다형성이란 앞서 설명한 `이동수단`과 같은 넓은 범위의 타입, 즉 상위 클래스 타입의 참조 변수로 그것과 관계있는 하위 클래스들을 참조할 수 있는 능력이다. 이제 코드를 통해 좀 더 구체적으로 그 의미를 알아보자.
앞서 상속 파트에서 우리가 상위 클래스와 하위 클래스를 상속 관계로 만들고, 객체를 생성하여 어떤 속성과 기능을 사용했을 때 우리는 아래와 같이 객체를 생성한 바가 있다.
Main 실행 클래스
public class Main {
public static void main(String[] args) {
//원래 사용했던 객체 생성 방식
Car car = new Car();
MotorBike motorBike = new MortorBike();
--- 이하 생략 ---
}
}
이제 방금 설명한 다형성 개념을 활용하여 객체를 생성하는 방법을 추가해 보도록 하자.
Main 실행 클래스
public class Main {
public static void main(String[] args) {
//원래 사용했던 객체 생성 방식
Car car = new Car();
MotorBike motorBike = new MortorBike();
// 다형성을 활용한 객체 생성 방식
Vehicle car2 = new Car();
--- 이하 생략 ---
}
}
위의 코드를 확인해 보면, `상위클래스 타입의 참조변수로 하위클래스 객체를 참조하는 것`의 의미를 조금 더 구체적으로 이해할 수 있다. 원래 우리가 사용했던 방식은 하위 클래스의 객체를 생성하여 하위 클래스 타입의 참조 변수에 할당해 주었지만, 다형성을 활용한 객체 생성 방식에서는 하위 클래스의 객체를 생성하여 상위 클래스 타입의 참조변수 `car2`에 할당해주고 있다.
그렇다면 왜 이렇게 다형성을 활용한 방식이 유용할까?
지금부터 코드로 하나씩 살펴보도록 하자. 먼저는 다형성을 활용하면 여러 종류의 객체를 배열로 다루는 일이 가능해진다. 아래 코드를 보자.
public class Main {
public static void main(String[] args) {
// 상위 클래스 타입의 객체 배열 생성
Vehicle vehicles[] = new Vehicle[2];
vehicles[0] = new Car();
vehicles[1] = new MotorBike();
for (Vehicle vehicle : vehicles) {
System.out.println(vehicle.getClass()); // 각각의 클래스를 호출해주는 메서드
}
}
}
//출력값
class Car
class MotorBike
앞서 논의했던 것처럼, 상위 클래스 `Vehicle`타입의 객체 배열을 생성해 주면, 이제 해당 타입의 참조 변수는 `Vehicle` 클래스와 상속 관계에 있는 모든 하위 클래스들을 그 안에 담아줄 수 있다. 원래 자바에서 배열의 개념이 하나의 같은 타입으로 이뤄져 있는 자료구조라는 사실을 기억할 때, 이렇게 다형성을 활용하면 하나의 타입만으로 여러 가지 타입의 객체를 참조할 수 있어 보다 간편하고 유연하게 코드를 작성하는 것이 가능해진다.
또 다른 예제를 살펴보기 위해 새로운 `Driver` 클래스를 아래와 같이 정의해 보자.
Driver 클래스
public class Driver {
void drive(Car car) {
car.moveForward();
car.moveBackward();
}
void drive(MotorBike motorBike) {
motorBike.moveForward();
motorBike.moveBackward();
}
}
Main 클래스
public class Main {
public static void main(String[] args) {
Car car = new Car();
MotorBike motorBike = new MortorBike();
Driver driver = new Driver();
driver.drive(car);
driver.drive(motorBike);
}
}
//출력값
앞으로 전진한다
뒤로 후진한다
오토바이가 앞으로 전진한다
뒤로 후진한다
위의 예제에서 확인할 수 있듯이, `Driver` 클래스의 코드는 매우 간단하다. 즉, 매개변수로 자동차나 오토바이 객체를 전달받아 운전하는 것이다. 이렇게 하나의 객체가 다른 객체의 속성과 기능에 접근하여 어떤 기능을 사용할 때, 우리는 "A클래스는 B클래스에 의존한다."라고 표현한다.
같은 맥락에서, 위의 코드 예제를 도식을 사용하여 표현하면 아래와 같이 그려볼 수 있고, 우리는 `Driver 클래스가 `Car` 클래스와 `MotorBike` 클래스에 의존하고 있다고 설명할 수 있다. 즉 `Driver` 클래스가 `Car`클래스와 `MotorBike` 클래스에 의존하고 있다고 설명할 수 있다. 즉 `Driver` 클래스와 다른 두 개의 클래스가 서로 직접적인 관계를 가지고 있는데, 이러한 상황을 "객체들 간의 결합도가 높다"라고 표현한다.

하지만 이렇게 결합도가 높은 상태는 객체 지향적인 설계를 하는데 매우 불리하다.
만약 지금처럼 이동 수단이 자동차와 오토바이 단 2개가 아니라 수 십, 수 백개라면 어떨까? 아마 아래와 같이 똑같은 코드를 수 십, 수 백 번 작성해야 할 것이다.
public class Driver {
void drive(Car car) {
car.moveForward();
car.moveBackward();
}
void drive(MotorBike motorBike) {
motorBike.moveForward();
motorBike.moveBackward();
}
void drive(Bus bus) {
bus.moveForward();
bus.moveBackward();
}
void drive(Train train) {
train.moveForward();
train.moveBackward();
}
--- 이하 생략---
}
생각만 해도 끔찍하다.
또 만약에 어떤 새로운 상황이 발생해서, `MotorBike` 클래스가 다른 클래스 `MotorCycle` 클래스로 변경되어야 하는 경우는 어떨까?
public class Driver {
void drive(Car car) {
car.moveForward();
car.moveBackward();
}
void drive(MotorCycle motorCycle) {
motorCycle.moveForward();
motorCycle.moveBackward();
}
}
마찬가지로, `Driver`안에 매개변수로 전달되는 참조변수의 타입과 참조변수를 수정할 수밖에 없는 상황이 발생한다. 그리고 코드가 많아질수록 이 작업은 아주 고되고 힘들어진다. 이것은 마치 운전자가 운전을 배웠는데, 이동 수단이 바뀔 때마다 새롭게 운전을 배워야 하는 상황과 같다고 할 수 있다.
이런 맥락에서, 객체 지향 프로그래밍은 지금까지 학습한 추상화, 상속, 그리고 다형성의 특성을 활용하여 프로그래밍을 설계할 때 역할과 구현을 구분하여 객체들 간의 직접적인 결합을 피하고, 느슨한 관계 설정을 통해 보다 유연하고 변경이 용이한 프로그램 설계를 가능하게 만든다. 이 부분이 객체 지향 프로그래밍의 하이라이트이자 핵심이라 할 수 있다.
코드를 통해 확인해 보자.
Vehicle 인터페이스
public interface Vehicle { //이동수단의 역할 정의
void moveForward();
void moveBackward();
}
Car 클래스
public class Car implements Vehicle{ // 이동수단 인터페이스를 구현
@Override
public void moveForward() {
System.out.println("자동차가 앞으로 전진한다.");
}
@Override
public void moveBackward() {
System.out.println("자동차가 뒤로 후진한다.");
}
}
MotorBike 클래스
public class MotorBike implements Vehicle{ // 이동수단 인터페이스를 구현
@Override
public void moveForward() {
System.out.println("오토바이가 앞으로 전진한다.");
}
@Override
public void moveBackward() {
System.out.println("오토바이가 뒤로 후진한다.");
}
}
Driver 클래스
public class Driver {
void drive(Vehicle vehicle) { //매개변수로 인터페이스 타입의 참조변수를 전달
vehicle.moveForward();
vehicle.moveBackward();
}
}
Main 실행 클래스
public class Main {
public static void main(String[] args) {
Car car = new Car();
MotorBike motorBike = new MortorBike();
Driver driver = new Driver();
driver.drive(car);
driver.drive(motorBike);
}
}
//출력값
앞으로 전진한다
뒤로 후진한다
오토바이가 앞으로 전진한다
뒤로 후진한다
먼저 앞서 추상화에서 봤었던 것처럼 `Vehicle` 인터페이스를 통해 이동 수단의 역할을 추상화하고, 각각 `Car` 클래스와 `MotorBike` 클래스에서 기능들을 구현하고 있다. 여기까지는 앞서 추상화 파트에서 봤던 것과 동일하다.
위에서 눈여겨봐야 할 코드는 바로 `Driver` 클래스이다. 비교를 위해 인터페이스를 작성하지 않았던 코드를 함께 가져와서 살펴보자.
Vehicle 인터페이스 적용 전
public class Driver {
void drive(Car car) {
car.moveForward();
car.moveBackward();
}
void drive(MotorBike motorBike) {
motorBike.moveForward();
motorBike.moveBackward();
}
void drive(Bus bus) {
bus.moveForward();
bus.moveBackward();
}
void drive(Train train) {
train.moveForward();
train.moveBackward();
}
--- 이하 생략---
}
Vehicle 인터페이스 적용 후
public class Driver {
void drive(Vehicle vehicle) { //매개변수로 인터페이스 타입의 참조변수를 전달
vehicle.moveForward();
vehicle.moveBackward();
}
}
한눈에 봐도 코드의 중복이 사라지고, 코드가 훨씬 간결해졌다는 사실을 알 수 있다. 핵심은 `drive()` 메서드로 전달되는 매개변수의 타입을 상위 클래스인 인터페이스 타입 `Vehicle`로 변경한 것이다. 이제 다형성의 세례를 받은 `drive()` 메서드의 매개변수로 인터페이스를 구현한 객체라면 무엇이든 전달할 수 있게 되었다. 메서드가 많아지고 코드의 라인이 길어질수록 이 효과는 더욱 강력해진다.
이해를 돕기 위해, 앞서 본 코드를 다시 도식으로 나타내보자.

앞서 봤었던 도식에서는 `Driver` 클래스가 `Car`와 `MotorBike` 클래스 각각과 직접적으로 연결되어 강한 결합도를 보였지만, 이제는 `Vehicle` 인터페이스를 통해 간접적으로 연결되어 결합도가 낮아졌다. 따라서, 이제 `Driver` 클래스는 더 이상 각각의 클래스 내부의 변경이나 다른 객체가 새롭게 교체되는 것을 신경 쓰지 않아도 인터페이스에만 의존하여 수정이 있을 때마다 코드 변경을 하지 않아도 된다.

아까의 비유로 표현하면, 운전자가 운전하는 법을 매번 새롭게 배우지 않아도 변경된 이동 수단을 이용하는데 아무런 문제가 발생하지 않는다. 이것이 바로 지금까지 학습한 추상화와 다형성의 특성을 활용한 역할과 구현의 구분이자, 보다 유연하고 변경이 용이한 소프트웨어 설계를 가능하게 하는 객체 지향 프로그래밍의 꽃이라 할 수 있다.
(그럼 1종 대형, 보통, 소형의 관계처럼 대형을 몰면 보통과 소형을 모두 몰 수 있는 반면, 소형은 소형만 몰 수 있게 설계하려면 어떻게 해야 할까?)
물론 아직 모든 문제가 해결된 것은 아니다.
public class Main {
public static void main(String[] args) {
Vehicle car = new Car(); //높은 결합도
Vehicle motorBike = new MotorBike(); //높은 결합도
Driver driver = new Driver();
driver.drive(car);
driver.drive(motorBike);
}
}
//출력값
앞으로 전진한다
뒤로 후진한다
오토바이가 앞으로 전진한다
뒤로 후진한다
여전히 실행 클래스의 코드에서 객체를 생성할 때, `new Car()`와 `new MotorBike()` 처럼 객체에 직접적으로 의존하고 있어서, 해당 객체를 다른 객채로 변경할 시 코드의 변경이 불가피하다. 죽, 다시 객체 간 높은 결합도를 보이는 상황이 초래되었다.
이 문제를 해결하기 위해 등장한 것이 바로 의존관계 주입(Dependency injection)이다. 의존관계 주입에 대한 내용은 추후 스프링을 포스팅할 때 자세히 설명하고자 한다.
다시 돌아가서 정리하면, 객체 지향 프로그래밍은 객체 간 관계와 협력을 설계하는 것인데, 다형성은 그 관계를 보다 유연하고 확장이 용이한 설계가 가능하도록 하는데 핵심적인 역할을 한다는 사실이 중요하다. 또한, 다형성을 제대로 활용하기 위해서 앞서 배웠던 추상화와 상속에 대한 내용들이 함께 존재해야 한다는 사실도 기억해야 한다. 즉, 추상화가 있어야 각 객체들의 역할 정의가 가능하고, 인터페이스는 상위 클래스 - 하위 클래스를 전제하기 때문에 상속에서 배웠던 개념들이 필요하다.
캡슐화(Encapsulation)
이제 객체 지향의 마지막 특징인 캡슐화에 대해 살펴보자. 캡슐화란 클래스 안에 서로 연관 있는 속성과 기능들을 하나의 캡슐(capsule)로 만들어 데이터를 외부로부터 보호하는 것을 말한다.
아래의 그림은 캡슐화의 기본 내용을 잘 표현한다. 즉 서로 관련 있는 데이터와 이를 처리할 수 있는 기능들을 한 곳에 모아 관리하는 것이다. 자바 객체 지향 프로그래밍에서 이렇게 캡슐화를 하는 이유로는 크게 두 가지를 언급할 수 있다.
- 데이터 보호(data protection) - 외부로부터 클래스에 정의된 속성과 기능들을 보호
- 데이터 은닉(data hiding) - 내부의 동작을 감추고 외부에는 필요한 부분만 노출
\

우리가 아플 때 한 번씩 먹게 되는 캡슐약을 떠올려보면, 우리는 캡슐 안에 어떤 색의 내용물이 있는지, 또 어떤 성분의 약이 들어있는지 알 수 없다. 또한, 그 안의 내용물은 캡슐을 통해서 외부로부터 오염되지 않고 안전하게 보호될 수 있다.
자바의 캡슐화도 이와 같다. 즉, 외부로부터 클래스에 정의된 속성과 기능들을 보호하고, 필요한 부분만 노출될 수 있도록 하여 각 객체 고유의 독립성과 책임 영역을 안전하게 지키고자 하는 목적이 있다.
그렇다면 어떻게 이러한 목적을 달성할 수 있을까?
자바 객체 지향 프로그래밍에서 캡슐화를 구현하기 위한 방법은 크게 두 가지가 있다. 먼저 접근 제어자(access modigires)를 활용하는 것이다. 접근 제어자는 클래스 또는 클래스의 내부의 멤버들에 사용되어 해당 클래스나 멤버들을 외부에서 접근하지 못하도록 접근을 제한하는 역할을 한다. 실생활의 예시를 보자.

위의 그림은 접근 제어자를 우리가 실생활에서 쉽게 접할 수 있는 화장실을 예시로 표현한 것이다. 모두에게 열려있는 공중화장실, 특정한 멤버십을 가진 사람들에게만 열려있는 호텔 화장실, 그리고 오직 나만 사용할 수 있는 우리 집 개인 화장실은 각각 다른 접근 범위를 가진다.
접근 제어자도 같은 개념이라고 생각하면 쉽다. 자바에는 `public`, `default`, `protected`, `private` 총 네 가지 종류의 접근 제어자가 있는데 위 화장실의 예제처럼 오른쪽으로 갈수록 더 좁은 접근 범위를 가진다. 따라서, 어떤 소프트웨어 프로그램을 설계할 때 위의 접근 제어자를 활용하여 어떤 클래스나 그 멤버에 대한 접근 범위를 설정하여 데이터를 효과적으로 보호할 수 있다.
자바 접근 제어자의 접근 범위를 표로 나타내면 다음과 같다.

위의 표에서 확인할 수 있는 것처럼, 접근 제어자의 접근 범위가 각각 클래스 내, 패키지 내, 다른 패키지의 하위 클래스, 그리고 패키지 외까지 각각 다른 것을 확인할 수 있다. 위 내용을 코드로 살펴보자.
SuperClass 클래스
package package1; //패키지명 package1
class Test { //Test 클래스의 접근 제어자는 default
public static void main(String[] args) {
SuperClass superClasss = new SuperClass();
// System.out.println(parent.a); //동일 클래스가 아니기 때문에 에러 발생
System.out.println(superClass.b);
System.out.println(superClass.c);
System.out.println(superClass.d);
}
}
public class SuperClass { // SuperClass 클래스의 접근 제어자는 public
private int a = 1; // a, b, c,d에 각각 다른 접근 제어자 정의
int b = 2;
protected int c = 3;
public int d = 4;
public void printEach() {
System.out.println(a); // 동일 클래스이기 때문에 에러 발생 x
System.out.println(b);
System.out.println(c);
System.out.println(d);
}
}
// 출력값
2
3
4
다른 패키지의 Test2 클래스
package package2; //패키지명 package2
//파일명 Test2.java
import package1.SuperClass
class SubClass extends SuperClass { // package1로부터 SuperClass 클래스 상속
public void printEach() {
//System.out.println(a); //에러 발생!
//System.outl.println(b);
System.out.println(c); // 다른 패키지의 하위 클래스
System.out.println(d);
}
}
public class Test2 { //Test 클래스의 접근 제어자는 default
public static void main(String[] args) {
SuperClass parent = new SuperClass();
// System.out.println(parent.a); //public을 제외한 모든 호출 에러!
// System.out.println(parent.b);
// System.out.println(parent.c);
System.out.println(parent.d);
}
}
자바의 캡슐화를 구현하기 위한 두 번째 방법으로는 `getter/setter` 메서드가 있다.
Car 클래스
public class Car {
private String model;
private String color;
private int wheels;
public String getModel() {
return model;
}
public void setModel(String model) {
this.model = model;
}
public String getColor() {
return color;
}
public int getWheels() {
return wheels;
}
public void setWheels(int wheels) {
this.wheels = wheels;
}
}
위의 코드를 보면, 모든 속성값들이 `private` 접근 제어자로 선언되어 있고, `getter/setter` 메서드의 접근 제어자만이 `public`으로 열려있다. 따라서 선택적으로 외부에 접근할 속성과 그렇지 않은 속성을 `getter/setter을 통해 정해줄 수 있다.
Car 클래스
public class Car {
private String model;
private String color;
public Car(String model, String color) {
this.model = model;
this.color = color;
}
public void startEngine() {
System.out.println("시동을 겁니다.");
}
public void moveForward() {
System.out.println("자동차가 앞으로 전진한다");
}
public void startEngine() {
System.out.println("모든 창문을 연다");
}
}
Driver 클래스
public class Driver {
private String name;
private Car car;
public Driver(String name, Car car)
this.name = name;
this.car = car ;
puvlic void drive() {
car.startEngine();
car.moveForward();
car.openWindow();
}
}
Main 실행 클래스
public class Main {
public static void main(String[] args) {
Car car = new Car("테슬라 모델x", "레드");
Driver driver new Driver("김코딩", car);
driver.drive();
)
}
//출력값
시동을 겁니다
자동차가 앞으로 전진한다.
모든 창문은 연다
위의 코드는 아무런 문제 없이 잘 작동하는 코드처럼 보이지만, 치명적인 약점을 가지고 있다. `Driver` 클래스의 `drive()` 메서도의 바디를 살펴보면, 해당 메서드가 호출되었을 때 Car 클래스의 메서드들이 순차적으로 실행되고 있는 것을 확인할 수 있다.
겉으로 봤을 때는 아무런 문제가 없어 보이는 데 이게 왜 치명적인 약점이 될까?
만약에 Car 클래스의 3가지 메서드들에 어떤 변경이 생겼다고 가정해 보자. 그러면 해당 메서드들을 사용하고 있는 `Driver` 클래스의 drive() 메서드의 수정이 불가피하다. 다른 말로, Driver 클래스가 Car 클래스의 세부적인 내부 로직을 너무 속속들이 알고 있고, 이것은 앞서 우리가 계속 피하고자 했던 객체 간의 결합도가 높은 상태를 의미한다.
이럴 때 우리는 캡슐화를 활용하여 객체의 자율성, 즉 하나의 객체가 해당 객체의 속성과 기능에 대한 독점적인 책임을 담당하도록 만들고, 이를 통해 객체 간의 결합도를 낮게 유지할 수 있다. 코드로 살펴보자.
Car 클래스
public class Car {
private String model;
private String color;
public Car(String model, String color) {
this.model = model;
this.color = color;
}
private void startEngine() {
System.out.println("시동을 겁니다.");
}
private void moveForward() {
System.out.println("자동차가 앞으로 전진한다");
}
private void startEngine() {
System.out.println("모든 창문을 연다");
}
public void operate() { //앞서 Driver 클래스에 정의된 메서드를 이동하여 메서드 추출
startEngine();
moveForward();
openWindow();
}
}
Driver 클래스
public class Driver {
private String name;
private Car car;
public Driver(String name, Car car) {
this.name = name;
this.car = car;
}
public Strin getName() {
return name;
}
public void drive() {
car.operate(); // Car 클래스에 있는 메서드를 단순하게 호출
}
}
Main 실행 클래스
public class Main {
public static void main(String[] args) {
Car car = new Car("테슬라 모델x", "레드");
Driver driver new Driver("김코딩", car);
driver.drive();
)
}
//출력값
시동을 겁니다
자동차가 앞으로 전진한다.
모든 창문은 연다
아까와 출력값은 동일하지만, 기존의 `Driver` 클래스가 하나하나 호출해 줬던 메서드들은 모두 `operate()` 메서드로 묶어 Car 클래스로 옮겨두었고, Driver 클래스에서는 내부 동작을 전혀 신경 쓰지 않고 단순히 `operate()` 메서드를 호출하여 사용한다.
또한, `operate()` 메서드 내부의 메서드들은 외부에서 호출되어 사용할 일이 없으므로 접근 제어자를 모두 `private`으로 변경해 주었다. 정리하면, Car 클래스와 관련된 기능들은 온전히 Car에서만 관리되도록 하였고, 불필요한 내부 동작의 노출을 최소화하였다. 이제 Driver 클래스의 입장에서는 더 이상 Car 클래스의 내부 로직을 알지 못하고, 알 필요도 없어졌다.
이렇게 캡슐화를 활용하면, 객체 내부의 동작을 외부로의 노출을 최소화하여 각 객체의 자율성을 높이고, 이를 통해 객체 간 결합도를 낮추어 앞서 설명한 객체 지향의 핵심적인 이점을 잘 살리는 방법으로 프로그램을 설계하는 일이 가능하다.
원본 게시물
객체 지향 프로그래밍의 4가지 특징ㅣ추상화, 상속, 다형성, 캡슐화 -
객체 지향 프로그래밍은 객체의 유기적인 협력과 결합으로 파악하고자 하는 컴퓨터 프로그래밍의 패러다임을 의미합니다. 객체 지향 프로그래밍의 기본적인 개념과 그 설계를 바르게 하기 위
www.codestates.com
'백엔드 > 자바' 카테고리의 다른 글
| 자바 - 상속(Inheritance) (0) | 2024.03.20 |
|---|---|
| 자바 - 클래스(인스턴스, 필드, 메서드, 생성자) (0) | 2024.03.19 |
| 자바 - 래퍼 클래스(Wrapper Class) (0) | 2024.03.14 |
| 자바 - 자바 변수의 자료형(Data Type) (0) | 2024.03.13 |
| 자바 - 코드의 구조 (0) | 2024.03.13 |