객체지향 프로그래밍 개요
객체 지향 프로그래밍(Object-Oriented Programming, OOP)는 여러 독립적인 부품들의 조합이자 객체들의 유기적인 협력과 결합으로 파악하고자 하는 컴퓨터 프로그래밍의 패러다임을 의미합니다.
객체 지향은 설계에도 오래된 설계 5원칙과 특징 4가지가 존재합니다. 설계 5원칙은 SOLIO라는 이름으로 존재하며 특징은 추상화, 상속, 다형성, 캡슐화로 구성되어 있습니다. 오늘 이 글에서는 5원칙과 특징 4가지에 대해 다뤄보겠습니다. 이 내용들은 대부분의 객체지향 언어를 사용할 때 필수적으로 알아야 할 개념이며, 면접에서도 자주 등장하는 단골 면접 질문 중 하나입니다.
여러분들이 디자인 패턴에 대해서도 여기서 나온 SOLID와 특징 4가지를 기반으로 패턴들이 설계됩니다. 표준화 작업, 아키텍처 설계 등 프로그래밍 전반적으로 적용되기 때문에 제대로 원칙을 알고 있는 것이 좋습니다.
SOLID에 대한 간단한 소개
- SRP(Single Responsibility Principle) : 단일 책임 원칙
- OCP(Open Closed Priciple) : 개방 폐쇄 원칙
- LSP(Listov Substitution Priciple) : 리스코프 치환 원칙
- ISP(Interface Segregation Principle) : 인터페이스 분리 원칙
- DIP(Dependency Inversion Principle) : 의존 역전 원칙
총 5가지의 설계 원칙에서 앞에 글자를 딴 것이 SOLID 입니다. 쉽게 외우기 위해 만든 것이니 참고하시면 좋습니다. SOLID는 객체 지향 설계 원칙이라고 했는데 이것을 아야 하는 이유가 무엇일까요? 바로 코드를 확장하고 유지보수 관리가 쉬워지며 개발의 생산성을 높일 수 있기 때문입니다. 이러한 이유로 설계 원칙을 알아야하며, 코드를 작성할 때 당연히 적용해야 한다는 생각에 면접 단골 질문으로 나옵니다.
객체지향 프로그래밍 4가지 종류
- 추상화
- 상속
- 다형성
- 캡슐화
객체 지향 프로그래밍의 4가지 특징은 객체 지향적 설계의 이점들을 잘 살릴 수 있는 특징 입니다. 앞서 설명한 SOLID 원칙을 지킬 수 있도록 도와줍니다. 앞서 말한 SOLID처럼 코드 재사용성을 높이고, 코드를 최대한 간결하게 작성하도록 하는 특징입니다. 인간 친화적이고 직관적인 코드를 작성하도록 하는 특징이죠.
객체(object)란?
먼저 객체에 대해 이야기 하겠습니다. 객체는 객체 지향 프로그래밍의 가장 기본적인 단위 입니다. 기본 전제는 실제 세계는 객체로 구성되어 있다고 가정합니다. 또한 모든 행위, 사건들은 객체들 간의 상호작용으로 발생한다는 것에서 출발합니다.
그래서 객체는 무언인가??
public Class Study {
String name;
String title;
int count;
void startStudy() {
System.out.println("공부 시작합니다.");
}
void endStudy() {
System.out.println("공부를 종료합니다.");
}
}
객체 지향 프로그래밍에서는 위와 같이 객체를 추상화시켜 속성과 기능으로 분류한 후 이것을 다시 각 변수 및 함수로 정의하고 있습니다. 그러니 우리가 흔히 알고 있는 개념을 코드로 표현할 수 있다면 객체라고 정의 할 수 있습니다.
객체 지향 프로그래밍의 4가지 특징 소개
앞서 간단하게 소개한 객체 지향 프로그래밍의 4가지 특징을 자세히 알아보도록 하겠습니다. 앞서 객체에 대한 이야기를 했습니다. 또한, 객체 지향 프로그래밍의 특징이 왜 필요한 지 이야기 했습니다. 앞서 말한 내용을 토대로 특징을 알아보며, 이것들이 어떻게 연결되어 객체 지향 설계가 가능한 지 알아보겠습니다.
1. 추상화
추상이란 사물이나 표상을 어떤 성질, 공통성, 본질을 파악하여 그것을 추출하는 것 이라 정의할 수 있습니다. 우리가 추상화에서 사용할 것은 성질, 공통성, 본질을 추출 하는 것입니다.
쉽게 이야기하면 자동차에 대한 추상화는 무엇일까요? 바로 바퀴가 있다는 점입니다. 이런식으로 공통적인 특징이나 본질, 성질을 파악하고 추출하는 것을 추상화라고 합니다. 좀 더 덧붙여 이야기하자면 불필요한 세부 사항을 제거하고, 가장 본질적인 것과 공통적인 부분을 추출하는거죠.
예시를 들어보죠.
위 그림을 보면 다양한 자동차 회사들이 있습니다. 이 자동차 회사들을 추상화한다면 자동차를 만드는 회사라고 이야기 할 수 있죠. 객체 지향 프로그래밍에서 의미하는 추상화는 객체의 공통적인 속성과 기능을 추출하여 정의하는 것입니다. 우리는 위 예제를 통해 객체지향 프로그래밍을 알아보겠습니다.
위 예시를 보면 자동차라는 추상화된 정의와 그 밑에 속해 있는 자동차 회사들이 있습니다. 자동차는 이동 수단이며 시동을 걸고, 출발과 멈춤을 할 수 있는 기능을 제공합니다. 한번 코드로 표현해보겠습니다.
public interface Car {
public abstract void go()
public abstract void stop()
void moveForward();
void turnOffGear();
void turnOnGear();
void sideMirror();
}
자동차에는 공통 기능들이 있습니다. 앞으로 가는거, 시동을 걸거나 끄는 것, 사이드 미러를 조절하는 것 등 다양한 기능이 있습니다. 먼저 Car라는 인터페이스(interface)를 정의했습니다. 인터페이스는 추상화 된 객체에서 공통적으로 활용되는 기능들을 먼저 정의합니다. 그래서 인터페이스를 설계도라고 이야기 하곤 합니다.
컴퓨터 프로그래밍에서 인터페이스란 “서로 다른 두 시스템, 장치, 소프트웨어 따위를 서로 이어주는 부분 또는 그런 접속 장치” 라고 정의합니다. 이렇듯 인터페이스(interface)는 객체의 역할만을 정의하여 객체들 간의 관계를 연결하는 역할을 담당합니다.
이제 Car 인터페이스를 활용하여 실제 구현한 코드를 살펴보죠.
public class Benz implements Car {
@Override
public void go() {
System.out.println("Benz가 앞으로 갑니다.");
}
@Override
public void stop() {
System.out.println("Benz가 멈춥니다.");
}
@Override
public void moveForward() {
System.out.println("Benz 엑셀을 밟습니다.");
}
@Override
public void trunOffGear() {
System.out.println("Benz 기어를 중립으로 둡니다.");
}
@Override
public void trunOnGear() {
System.out.println("Benz 기어를 올립니다.");
}
@Override
public void sideMirror() {
System.out.println("Benz 사이드미러를 제어합니다.");
}
}
위에 보시면 앞서 인터페이스에서 정의한 역할을 클래스의 맥락에 맞게 구현되어 있습니다. Car가 가지고 있는 공통 기능을 전부 구현하고, 회사에 따라 어떤 자동차가 시동이 걸렸는지, 기어를 올렸는지 알려주도록 구현되어 있습니다. 다른 클래스로 구현했을 때, 해당 클래스에 맞는 기능을 구현할 수 있습니다. 각, 클래스별로 특별한 기능들이 있고, 동일한 기능이라도 각 회사별 특징이 다르기 때문에 클래스에서의 구현은 서로 다릅니다.
이것을 객체 지향 프로그래밍에서 역할과 구현의 분리라고 합니다. 이런 방식으로 코드를 작성하면 기능 구현이 유연해지고 변경이 용이한 프로그램을 설계하는데 큰 도움이 됩니다. 객체 지향 프로그래밍에서 보다 유연하고 변경에 열려있는 프로그램을 설계하기 위한 역할과 구현을 분리하는 것은 추상화를 통해 가능합니다.
2. 상속(Inheritance)
상속이란 기존의 클래스를 재활용하여 새로운 클래스를 작성하는 문법 요소입니다.
상속은 클래스 간 공유될 수 있는 속성과 기능들을 상위 클래스로 추상화 시켜 해당 상위 클래스로부터 확장된 여러 개의 하위 클래스들이 모두 상위 클래스의 속성과 기능들을 간편하게 사용할 수 있도록 합니다.
이를 통해 클래스들 간 공유하는 속성과 기능들을 재정의 할 필요 없고, 간편하게 재사용 할 수 있기 때문에 반복적인 코드를 최소화하고 공유하는 속성과 기능에 간편하게 접근하여 사용 할 수 있습니다. 간단한 예시를 통해 상속의 중요성을 알아보죠.
public class BMW implements Car {
String modelName;
String color;
int wheelCount;
String logo;
@Override
public void go() {
System.out.println("BMW가 앞으로 갑니다.");
}
@Override
public void stop() {
System.out.println("BMW가 멈춥니다.");
}
@Override
public void moveForward() {
System.out.println("BMW 엑셀을 밟습니다.");
}
}
public class KiaMotors implements Car {
String modelName;
String color;
int wheelCount;
String logo;
@Override
public void go() {
System.out.println("KiaMotors가 앞으로 갑니다.");
}
@Override
public void stop() {
System.out.println("KiaMotors가 멈춥니다.");
}
@Override
public void moveForward() {
System.out.println("KiaMotors 엑셀을 밟습니다.");
}
}
위 코드를 보면 modelName, color, wheelCount, logo가 반복되고 있고 go, stop, moveForward 기능이 반복되어 재정의 되어 있습니다. 해당 코드가 인터페이스에서 변경되거나 두 클래스가 동일하게 modelName, color, wheelCount, logo의 변수 명을 고친다고 하면 매우 귀찮은 작업이 되어 보입니다. 지금은 2개의 회사지만 회사가 늘어나면 추가 자원이 소모됩니다.
이것을 상속을 통해 코드를 깔끔하게 만들어보죠.
public class CommanCar implements Car {
String modelName;
String color;
int wheelCount;
String logo;
public void go(String name) {
System.out.println(name + " 앞으로 갑니다.");
}
public void stop(String name) {
System.out.println(name + " 멈춥니다.");
}
public void moveForward(String name) {
System.out.println(name + " 엑셀을 밟습니다.");
}
}
우리는 위 코드에 공통적인 기능을 옮겨 넣었습니다. 이걸 상속해보죠.
public class BMW extends extends CommanCar {
String design;
void getSoftware() {
System.out.println("BMW 소프트웨어를 실행합니다.");
}
}
public class KiaMotors extends extends CommandCar {
String nationality;
void repairLocation() {
System.out.println("KiaMotors는 전국에 수리점이 있습니다.");
}
}
위 코드를 보면 공통적인 속성과 기능을 가지고 있는 CommanCar를 extends 키워드를 통해 하위 클래스로 확장했습니다. 이렇게 확장을 통해 매번 반복적으로 정의해야 하는 번거로움이 제거되었습니다. 그리고 공통 코드의 변경이 필요할 경우 상위 클래스만 변경하면 원하는대로 기능을 빠르게 바꿀 수 있게 되었죠.
여기서 중요한 점은 공통 코드를 무조건 써야 하는 것이 아니라 메서드 오버라이딩을 통해 내용을 재정의도 가능합니다. 메서드 오버라이딩은 말 그대로 상위 클래스에 정의된 메소드를 그대로 재정의해서 자식 클래스에 맞는 기능으로 재구성하는 방법입니다.
상속은 추상화와 혼동할 수 있습니다. 상속의 경우 상위 클래스의 속성과 기능들을 하위 클래스에서 그대로 받아 사용하거나 오버라이딩을 통해 선택적으로 재정의하여 사용할 수 있는 반면, 인터페이스를 통한 구현은 반드시 인터페이스에 정의된 추상 메서드의 내용이 하위 클래스에서 정의되어야 합니다.
상속 관계는 재사용성이 매우 높아진다는 장점이 있지만 인터페이스를 사용하는 것 보단 추상화 정도가 낮다고 할 수 있습니다. 그렇기 때문에 상속은 재사용성이 우수하다에 초점을 맞추면 됩니다.
3. 다형성
객체 지향 프로그래밍의 가장 큰 장점은 다형성 때문이라고 할 수 있습니다. 다형성은 객체의 속성이나 기능이 상황에 따라 여러 가지 형태를 가질 수 있다는 성질입니다. 간단하게 이야기해서 나라는 사람은 집에서는 아들, 학교에서는 친구이자 학생, 동아리에서는 동아리원으로써 다양한 역할을 가지게 됩니다. 이것을 우린 다형성이라고 표현합니다.
객체 지향 프로그래밍에서 다형성도 이와 비슷합니다. 어떤 객체의 속성이나 기능이 그 맥락에 따라 다른 역할을 수행할 수 있는 객체 지향의 특성을 의미합니다. 대표적인 예로 메소드 오버라이딩과 메소드 오버로딩이 있습니다.
public interface Car {
public abstract void go()
public abstract void stop()
void moveForward();
void turnOffGear();
void turnOnGear();
}
public class Benz implements Car {
@Override
public void go() {
System.out.println("Benz가 앞으로 갑니다.");
}
@Override
public void stop() {
System.out.println("Benz가 멈춥니다.");
}
@Override
public void moveForward() {
System.out.println("Benz 엑셀을 밟습니다.");
}
@Override
public void trunOffGear() {
System.out.println("Benz 기어를 중립으로 둡니다.");
}
@Override
public void trunOnGear() {
System.out.println("Benz 기어를 올립니다.");
}
public void trunOnGear(String name) {
System.out.println(name + " 기어를 올립니다.");
}
}
위 코드는 앞서 보여드린 코드입니다. 추상화에서 봤었던 코드로 오버라이딩을 통해 매소드를 재정의 하고 있습니다. 이와 같이 같은 이름의 메소드가 상황에 따라 다른 역할을 수행하는 것을 다형성이라고 이야기 합니다.
또한 trunOnGear 메소드처럼 같은 이름의 메소드를 여러 개 중복하여 정의하는 것을 의미하는 오버로딩도 다형성이라고 할 수 있습니다.
객체 지향 프로그래밍에서 다형성이란 한 타입의 참조변수를 통해 여러 타입의 객체를 참조할 수 있도록 만든 것을 의미합니다. 좀 더 구체적으로, 상위 클래스 타입의 참조변수로 하위 클래스의 객체를 참조할 수 있도록 하는 것입니다.
위 글만 봤을 때는 내용이 어렵습니다. 이것을 현실 세계에 존재하는 예시를 보면 이해하기 쉽습니다.
우리가 음식을 먹는다고 가정합니다. 우리가 먹은 음식이 무엇일까요?
피자, 햄버거, 밥, 삼겹살, 소고기, 국밥 등등 너무나 많은 음식들이 나올 수 있습니다. 이것처럼 하나의 타입이 여러 개의 타입으로 사용 가능한 것을 다형성이라고 표현합니다. 음식이라는 Food 클래스는 Pizza, Rise, Pig 등등 다양한 클래스를 포함할 수 있습니다. 다형성이란 상위 클래스가 아래의 작은 개념들을 품을 수 있는 포괄적인 개념을 가지는 것을 의미합니다.
public class Main {
public static void main(String[] args) {
Car car = new Benz();
Car car2 = new KiaMotors();
// 구현
// ...
}
}
위 코드처럼 상위 클래스 타입의 참조변수로 하위클래스 객체를 참조하는 것을 이해할 수 있습니다. Car는 상위 클래스지만 하위 클래스인 Benz나 KiaMotros를 할당 받을 수 있습니다.
다형성이 왜 객체 지향 프로그래밍에서 중요할까요?
바로 코드 활용도가 엄청나게 올라가기 때문입니다. 하나씩 살펴보죠.
- 여러 종류의 객체를 배열로 다루는 일이 가능해진다.
- 하나의 타입만으로 여러 가지 타입의 객체를 참조할 수 있어 간편하고 유연한 코드를 작성한다.
- 역할과 구현을 구분하여 객체들 간의 직접적인 결합을 피하고, 느슨한 관계 설정을 통해 보다 유연하고 변경이 용이한 프로그램 설계가 가능하다.
- 각각의 클래스 내부의 변경이나 다른 객체가 새롭게 교체되는 것을 신경 쓰지 않아도 인터페이스에만 의존하여 수정이 있을 때 마다 코드 변경을 하지 않아도 되도록 코드 작성이 가능해집니다.
위와 같은 이유로 다형성이 중요합니다.
아래 코드를 통해 좀 더 확실히 와닿게 설명하겠습니다. 다형성이 없는 코드입니다.
public class BMW {
String modelName;
String color;
int wheelCount;
String logo;
public void go() {
System.out.println("BMW가 앞으로 갑니다.");
}
public void stop() {
System.out.println("BMW가 멈춥니다.");
}
public void moveForward() {
System.out.println("BMW 엑셀을 밟습니다.");
}
}
public class KiaMotors {
String modelName;
String color;
int wheelCount;
String logo;
public void go() {
System.out.println("KiaMotors가 앞으로 갑니다.");
}
public void stop() {
System.out.println("KiaMotors가 멈춥니다.");
}
public void moveForward() {
System.out.println("KiaMotors 엑셀을 밟습니다.");
}
}
위 코드는 자동차와 관련된 두 회사의 코드 입니다. 이걸 Driver라는 클래스와 결합하여 기능을 구현합니다.
public class Driver {
void drive(BMW bmw) {
bmw.go()
bmw.stop();
}
void drive(KiaMotors kiaMotors) {
kiaMotors.go();
kiaMotors.stop();
}
}
코드를 봤을 땐 큰 문제가 없어보입니다. 하지만 우리는 계속해서 회사들을 추가할 예정입니다. 그렇다면 위의 코드는 어떻게 될까요? 계속해서 회사가 추가될 때마다 코드를 집어 넣어야 합니다. 거기에 기능이 바뀌면 코드를 전부 변경하는 악재가 발생하죠. 자원이 많이 소모되며 시간을 많이 잡아 먹습니다. 이걸 막기 위해서 다형성이 추가된 코드로 변환해보겠습니다.
public class BMW implements Car {
String modelName;
String color;
int wheelCount;
String logo;
@Override
public void go() {
System.out.println("BMW가 앞으로 갑니다.");
}
@Override
public void stop() {
System.out.println("BMW가 멈춥니다.");
}
@Override
public void moveForward() {
System.out.println("BMW 엑셀을 밟습니다.");
}
}
public class KiaMotors implements Car {
String modelName;
String color;
int wheelCount;
String logo;
@Override
public void go() {
System.out.println("KiaMotors가 앞으로 갑니다.");
}
@Override
public void stop() {
System.out.println("KiaMotors가 멈춥니다.");
}
@Override
public void moveForward() {
System.out.println("KiaMotors 엑셀을 밟습니다.");
}
}
public class Driver {
void drive(Car car) {
car.go()
car.stop();
}
}
public class Main {
public static void main(String[] args) {
Car car = new Benz();
Car car2 = new KiaMotors();
Driver drive = new Driver();
drive.drive(car);
drive.drive(car2);
// Benz와 KiaMotors가 한번씩 출발 및 스탑을 호출합니다.
}
}
위와 같이 코드를 변경할 수 있습니다. 이 코드의 가장 큰 장점은 추후 회사가 추가되거나 코드를 변경할 때 쉽게 변경할 수 있다는 장점이 있습니다. 그리고 drive라는 메소드만 관리하면 되기 때문에 유지보수도 용이하게 됩니다. Driver 클래스는 공통적으로 묶인 하나의 Car 라는 인터페이스만 신경쓰면 되기 때문에 어떤 클래스가 추가 되더라도 동일한 기능을 보장할 수 있습니다.
4. 캡슐화(Encapsulation)
캡슐화는 클래스 안에 서로 연관있는 속성과 기능들을 하나의 캡슐로 만들어 데이터를 외부로부터 보호하는 것을 의미합니다. 즉, 외부 사용자가 클래스 내부에 있는 동작을 알 수 없도록 막는 것을 의미하며, 사용자는 함수명 및 인터페이스 명만 알고 기능을 사용하도록 합니다. 캡슐화의 가장 큰 이유는 외부에서 기존의 기능을 훼손하거나 망가지지 않도록 막기 위해 고안된 특징입니다.
캡슐화는 서로 관련 있는 데이터와 이를 처리할 수 있는 기능들을 한곳에 모아 관리하는 것입니다. 앞서 말씀드렸지만 객체 지향 프로그래밍에서 캡슐화를 하는 이유는 두 가지 입니다.
- 데이터 보호 – 외부로부터 클래스에 정의된 속성과 기능들을 보호한다.
- 데이터 은닉 – 내부의 동작을 감추고 외부에는 필요한 부분만 노출한다.
캡슐화에 목적은 클래스에 정의된 속성과 기능들을 보호하고, 필요한부분만 외부로 노출될 수 있도록 하여 각 객체 고유의 독립성과 책임 영역을 안전하게 지키고자 하는 목적입니다.
위와 같은 목적을 달성하기 위해서는 접근제어자를 활용할 수 있습니다. 접근 제어자는 클래스나 멤버들을 외부에서 접근하지 못하도록 접근을 제한하는 역할을 합니다. 자바에서는 총 4개의 접근제어자가 있습니다. public, default, protected, private 이렇게 말이죠. 각각의 특성을 표로 한번 살펴보겠습니다.
접근 제어자 | 클래스 내 사용 가능 여부 | 패키지 내 사용 가능 여부 | 다른 패키지의 하위 클래스 | 패지키 외 | 설명 |
private | O | X | X | X | 동일 클래스 내에서만 접근 가능합니다. |
default | O | O | X | X | 동일 패키지 내에서만 접근 가능합니다. |
protected | O | O | O | X | 동일 패키지 + 다른 패키지의 하위 클래스에서 접근 가능합니다. |
public | O | O | O | O | 접근 제한 없습니다. |
각자의 특성에 맞게 사용이 가능합니다.
가장 대표적인 예시로 객체간의 결합도를 낮출 때 사용합니다. 특히, 외부에서 특정 클래스의 동작을 파악하지 못하도록 기능을 제공할 때 사용하곤 합니다. 외부에서 파악하지 못해야 하는 이유는 기능 변경이나 삭제, 추가가 되었을 때 외부 사용자가 그 변화를 못느끼고, 기존에 사용했던 것과 동일한 경험을 제공해야 하기 때문입니다. 예시 코드를 보시죠.
public class Car {
private String model;
public Car(String model) {
this.model = model;
}
private void startEngin() {
System.out.println("엔진을 시작합니다.");
}
private void endEngin() {
System.out.println("엔진을 종료합니다.")
}
private void operate() {
startEngin();
endEngin();
}
}
public class Driver {
private Car car;
public Dirver(Car car) {
this.car = car;
}
public void drive() {
car.operate();
}
}
public class Main {
public static void main(String[] agrs) {
Car car = new Car();
Driver driver = new Dirver(car);
driver.drvie();
}
}
위 코드에서 Car에서 선언한 operate 메소드만 이용하여 Driver에서 drive 메소드에서 활용하는 모습을 볼 수 있습니다. 이렇게 작업하는 것에 대한 장점은 operate 메소드 안에서 기능이 변경되거나 수정하더라도 Driver 클래스에 drive 메소드는 독립적으로 코드를 작성할 수 있다는 점입니다. 즉, 객체간 결합도가 낮아졌습니다. Driver 클래스는 Car 클래스에 있는 operate 메소드에 대한 내용을 몰라도 됩니다. Car 클래스에서 제공하는 메소드를 사용만 하면 됩니다.
이로 인해 객체 내부의 동작이 외부로 노출되지 않고, 객체의 자율성을 높일 수 있습니다. 그리고 객체 간 결합도를 낮추면서 객체 지향의 핵심적인 이점을 살렸습니다. 위와 같은 상황 때문에 캡슐화도 중요한 객체 지향 프로그래밍 특징 중 하나가 되었습니다.
마무리
객체 지향 프로그래밍은 어떻게 활용하냐에 따라 좋은 코드를 만들어주는 패러다임 입니다. 함수형 프로그래밍이 개발자 사이에서 최고의 패러다임으로 받아들이고 있지만 객체 지향 프로그래밍이 가진 특징은 좋은 코드를 만드는데 필수적인 것들입니다. 함께 알고 작성하는 것이 좋은 코드를 만드는데 더욱 도움이 됩니다.