
OOP(Object Oriented Programming)은 프로그램을 수많은 객체(object)라는 기본 단위로 나누고 이들의 상호 작용으로 서술하는 방법론이다.
이 글에서는 OOP에 대한 전반적인 개념을 정리한다. 이를 위해 우선 객체에 대해 이해해야 한다.
현실 세계에서의 객체는 구체적인 사물 혹은 추상적인 개념을 뜻한다. 또한 객체는 state(상태)와 behaver(동작)으로 구성된다. 예를 들어 이주언 이라는 객체는 '서 있음', '앉아 있음' 과 같은 state, '서다', '앉다' 와 같은 behaver으로 구성되어 있다.
소프트웨어에서의 객체도 비슷한 원리를 지닌다. 단지 state가 field로, behaver가 method로 정의되어있을 뿐이다. field는 객체를 통하여 사용할 수 있는 변수이며, method는 객체를 통하여 호출할 수 있는 동작이다.
현실 세계의 객체는 개별적으로 사용할 수 있고, 다른 객체와 상호작용할 수도 있다. 예를 들어 이주언이 권재헌을 때리면 이주언에게는 쾌감이, 권재헌에게는 통증이 발생하듯이, 현실 세계에서 발생하는 여러 현상은 객체가 서로 상호작용하여 발생한다.
소프트웨어에서도 객체는 개별적으로 사용하거나 다른 객체와의 관계를 맺으며 동작할 수 있다. 대부분의 소프트웨어는 다수의 객체로 구성되며, 이들이 상호작용하여 문제를 해결한다. 이러한 문제 해결 방식을 취한 프로그램을 작성하는 것이 OOP라고 할 수 있다.
OOP에서는 현실 세계를 객체 단위로 프로그래밍한다. 소프트웨어에서 객체는 데이터, 즉 field와 그의 동작 method를 묶어 표현하는 구성 요소라고 정리할 수 있다.
제품을 만들기 위해 재료와 함께 설계도가 필요하다. 전통적으로 이를 붕어빵으로 예를 들어 설명한다. 붕어빵이라는 객체는 반죽, 앙금 등의 재료로 만들어지지만, 이것들을 붕어빵으로 만들기 위해서는 붕어빵 형틀(template)이 불가결적이다. OOP의 class가 이 동일한 객체를 생산하는 틀 혹은 설계도라고 할 수 있다.
객체는 field와 method로 구성되므로, class도 field와 method를 정의해놓아야 한다. OOP에서는 class의 field와 method를 정의한 후 이를 기반으로 필요한 객체를 생성한다.
Class라는 틀로 만든 객체가 해당 class의 instance이다. 예를 들어 붕어빵은 붕어빵 형틀의 instance가 된다. 이와 같이 class에서 객체를 생성하는 과정을 instantiation이라고 한다. Class는 추상적인 개념이기에 실제 작업을 수행할 수 없다. Instance는 이러한 class의 정의를 바탕으로 생성되어 작업을 수행하는 주체가 된다.
객체는 선언을 의미하는 반면 instance는 실체화를 의미한다. class에 의해 선언되었을 때 객체라고 하며, 그 객체가 메모리 상에 할당되어 실제로 사용될 때 instance라고 한다.
Procedural programming은 동작을 순서에 맞추어 단계적으로 실행되도록 명령어를 나열한다. 데이터의 정의보다 명령의 순서와 흐름에 중점을 둔다. 소규모 프로젝트의 경우 수행할 작업을 예측할 수 있어 직관적이고 프로그래밍하기 쉽다는 장점이 있다.
하지만 프로젝트 규모의 증대에 따라 이에 따른 한계가 부각될 것이다. 서로 복잡하게 얽힌 데이터를 그의 동작과 분리하여 사용하였기 때문에 추후 변경이나 확장에 난관을 겪을 수 밖에 없다.
현실 세계의 작업은 절차나 과정보다는 이와 관련한 많은 객체의 상호작용으로 표현하는 것이 효과적이다. 이러한 현실 세계의 특성을 고려하고 procedural programming의 한계를 극복한 것이 OOP이다.
변수와 메서드를 하나의 단위로 묶는 것을 의미하며, 이를 데이터의 캡슐화라고 할 수 있다. 자바에서는 class를 통해 이를 구현하고, 해당 class의 instance를 생성하여 class 안의 멤버 변수와 메서드에 쉽게 접근할 수 있다.
정보 은닉(information hiding)은 프로그램의 세부 구현을 외부로 드러나지 않도록 특정 모듈 내부로 감추는 것이다.
캡슐화의 의의는 이 정보 은닉에 있다. 컴퓨터 본체의 부품이 모두 노출된다면 실수로 쉽게 고장을 낼 수 있으므로 부품을 케이스에 담아 보호하는 것과 같은 목적이다. OO{에서도 외부로부터 보호하고 싶은 field나 method가 있다면 캡슐화함으로써 외부에서 접근할 수 없도록 사용 범위를 제한할 수 있다. 일반적으로 다음 네 종류의 접근 지정자(access modifier)가 사용된다.
public
Class의 외부에서 사용 가능하도록 노출시킨다. 한마디로 어디서든 접근할 수 있다.
protected
다른 class에게는 노출하지 않지만 동일 package 내의 class나, 상속받은 하위 class에게는 노출한다. 다른 package의 class에게는 노출되지 않는다.
default
protected와 동일하게 동일 package 내의 class에게 노출한다. 그러나 하위 class에게 노출하지 않는다.
private
class의 내부에서만 사용하며 외부로 노출하지 않는다.
컴퓨터의 RAM이 고장 났을 때 다른 RAM으로 교체하면 정상적으로 작동한다. 마찬가지로 class 내부의 캡슐화된 코드는 독립적으로 관리되어 동일한 기능이라면 다른 코드로 대체될 수 있다. 이를 통해 코드의 모듈화 편의성과 재사용성을 높일 수 있다.
상속은 하위 객체가 상위 객체의 특성과 기능을 물려받는 것을 뜻한다. 상속받은 하위 객체가 상위 객체의 method와 field를 사용할 수 있게 되며, 이는 재사용성을 높인다.
이때 상위 객체 class를 parent(부모) class, super class, base(기본) class 라고 하기도 하며, 하위 객체 class를 child(자식) class, subclass, derived(파생) class, extended(확장) class라고 하기도 한다.
Overriding은 하위 class가 상위 class에서 상속받은 method를 자신에게 맞게 재정의하는 것을 말한다. 예를 들어 human이라는 class를 상속받아 권재헌, 이주언, 황지훈 등 하위 class를 추가할 수 있다. 상위 class의 method를 그대로 사용하지 않고, 하위 class의 상황에 맞게 동작하도록 변경할 수 있다. overriding된 method는 하위 class에서 호출될 때, 상위 class의 method가 아닌 하위 class에서 재정의된 method가 실행된다.
다른 예시로, human class에 greet()라는 method가 있다고 가정하면, 이주언 class는 이 method를 overriding하여 "hello, i'm 이주언."라고 인사하도록 변경할 수 있다. 이때 권재헌 class는 "hello, i'm nigga."와 같이 다르게 overriding할 수 있다. 이러한 overriding을 통해 코드의 유연성과 확장성을 높일 수 있다.
다형성은 대입되는 객체에 따라서 method를 다르게 동작하도록 구현하는 것이다.
하나의 method가 상황에 따라 다른 의미로 해석될 수 있는 것을 일컫는다. 다른 객체에서도 동작이 비슷하면 다형성을 이용해 코드를 간결하게 작성할 수 있다. 권재헌과 이주언은 서로 다르게 인사한다. 따라서 human class의 greet() method를 하위 class에서 수정한다면 각 객체에 적합하게 이용할 수 있게 된다. greet()를 전달하는 것은 동일할지라도 사람마다 다르게 반응하도록 하는 것이다. 이렇듯 동일하게 명령해도 객체에 따라 다른 결과가 나타나도록 하는 것이 다형성이다.
Overloading은 하나의 class 안에서 같은 이름의 method를 여러 개 정의하는 것이다. 단 각 method의 매개변수의 개수나 자료형은 달라야 한다. 즉, 반환 값만 다른 method는 overloading 할 수 없다. 같은 기능알 하는 method를 하나의 이름으로 사용함으로써 이름을 절약할 수 있다는 장점이 있다.
OOP에서 지켜야 하는 5가지 원칙을 통틀어 객체지향 5원칙이라고 부른다. 각 원칙의 앞글자를 따서 SOLID라고도 한다.
객체는 오직 하나의 책임을 지녀야 한다.
하나의 객체는 오직 하나의 기능을 수행하여야 한다. 만일 하나의 객체가 여러 기능을 수행하게 된다면, 다른 기능을 수행하는 코드끼리 class 내부에서 결합하여 코드의 변경이 일어났을 때 그것이 영향을 끼치는 기능이 많아지게 된다.
따라서 책임이란 변경의 이유와 동일하다. 이 원칙을 따름으로써 각 class 주제에 적합한 알맞은 책임을 부여한다면, 한 책임의 변경으로부터 다른 책임의 변경이 일어나고 이것이 반복되는 연쇄 작용을 방지할 수 있다. 여기서 class명까지 기능에 맞게 잘 작성한다면 더 좋을 것이다.
이 원칙의 핵심은 class의 책임을 최대한 분산시키고 기능 변경 시의 파급 효과를 최소화하여 유지보수성을 증대시키는 것에 있다.
아래 코드는 유저 정보의 유효성을 검사하고 관리하는 기능을 수행한다. 이때 유저 정보의 유효성 검사 기능은 UserValidator class의 validateEmail method에서 수행하고 유저 정보의 저장과 관리 기능은 User class의 User method에서 수행한다.
class User {
private String name;
private String email;
public User(String name, String email) {
this.name = name;
this.email = email;
}
public String getName() {
return name;
}
public String getEmail() {
return email;
}
}
class UserValidator {
public boolean validateEmail(User user) {
String email = user.getEmail();
return email != null && email.contains("@");
}
}
public class Main {
public static void main(String[] args) {
User user = new User("haensol", "haensol@naver.com");
UserValidator validator = new UserValidator();
boolean isEmailValid = validator.validateEmail(user);
if (isEmailValid) {
System.out.println("ㅆㄱㄴ");
} else {
System.out.println("컷ㅋ");
}
}
}
이때 UserValidator class가 다음과 같이 변경된다고 가정한다.
class UserValidator {
public boolean validateEmail(User user) {
String email = user.getEmail();
return email != null && email.contains("@");
}
public void printUserName(User user) {
System.out.println(user.getName());
}
}
기존 class에서 printUserName method가 유저 이름의 출력이라는 전혀 다른 기능을 수행하고 있으므로, 이는 single responsibility principle를 위반한다고 할 수 있다.
객체는 확장에 대해서는 개방적이고 수정에 대해서는 폐쇄적이어야 한다.
기능이 변하거나 확장하는 것은 자유로워야 하지만, 그 과정에서 기존의 코드가 수정되는 것에서는 신중해야 한다는 원칙이다. 만약 하나의 객체를 수정해야 할 때 해당 객체에 의존하는 다른 객체들까지 연쇄적으로 수정하게 된다면, 이는 유지보수성이 좋은 설계라고 할 수 없기 때문이다. 따라서 객체간의 의존성을 최소화하여 코드 변경에 따른 영향력을 최소화하여야 한다.
한가지 예시로 라이브러리를 들 수 있다. 라이브러리를 사용하는 코드의 기능이 변경된다고 해서 라이브러리 내부의 코드가 변경되지는 않는다.
아래 코드에서는 Shape interface와 이를 구현한 Circle, Rectangle class를 통해 프로그램을 확장한다. 이 과정에서 기존의 AreaCalculator class 내부 코드를 변경하지 않았으므로 open closed principle를 지켰다고 할 수 있다.
interface Shape {
double calculateArea();
}
class Circle implements Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double calculateArea() {
return Math.PI * radius * radius;
}
}
class Rectangle implements Shape {
private double width;
private double height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double calculateArea() {
return width * height;
}
}
class AreaCalculator {
public double calculateTotalArea(Shape[] shapes) {
double totalArea = 0;
for (Shape shape : shapes) {
totalArea += shape.calculateArea();
}
return totalArea;
}
}
public class Main {
public static void main(String[] args) {
Shape[] shapes = new Shape[] {
new Circle(5),
new Rectangle(4, 6)
};
AreaCalculator calculator = new AreaCalculator();
double totalArea = calculator.calculateTotalArea(shapes);
System.out.println(totalArea);
}
}
하위 class는 언제나 자신의 상위 class를 대체할 수 있다.
상위 class가 들어갈 자리에 하위 class를 위치시켜도 단순히 컴파일이 성공하는 것을 넘어서 계획대로 작동해야 한다는 뜻이다. 이것을 상위 class와 하위 class간의 일관성이 있다고 한다.
OOP에서 상속이 일어나면, 하위 class는 상위 class의 특성을 가지며 그를 토대로 확장할 수 있지만 상위 class의 기능을 무시하거나 약화시키지 않아야 한다. liskov substitution principle는 올바른 상속을 위해 하위 객체의 확장이 부모 객체의 방향성을 완전히 따르도록 하는 것이다.
이 원칙을 준수하면 OOP의 요소 중 다형성이 증대되는 효과가 있다.
아래 코드에서 Bird class는 fly method를 가지며, 모든 새는 날 수 있다고 가정한다. Pigeon, Penguin class는 모두 Bird를 상속받았지만, Penguin은 날 수 없기 때문에 fly를 호출할 때 예외를 throw한다. 이때 하위 class인 Penguin이 상속받은 class의 기대 동작, fly를 충족하지 않기 때문에 liskov substitution principle를 위반한다.
class Bird {
public void fly() {
System.out.println("날아간다~");
}
}
class Pigeon extends Bird {
@Override
public void fly() {
System.out.println("비둘기, 난다.");
}
}
class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("펭귄, 못 난다.");
}
}
public class Main {
public static void main(String[] args) {
Bird Pigeon = new Pigeon();
Bird penguin = new Penguin();
Pigeon.fly();
penguin.fly();
}
}
클라이언트가 자신이 이용하지 않는 메서드에 의존하면 안된다
Interface는 동일 목적 하에 동일한 기능을 수행하도록 강제한다. 어떤 class가 특정한 interface를 사용하여 구현된다면, 그 class는 반드시 그 interface에 포함되어있는 method를 구현하도록 하는 것이다. 이는 다형성을 극대화하여 유지보수성을 높인다.
Interface segregation principle는 범용적인 interface보다 클라이언트, 즉 사용자가 실제로 사용하는 interface를 만들어야 한다는 의미로, interface를 그 기능에 맞게 분리해야 한다는 원칙이다.
만약 interface의 추상 method들을 범용적으로 구현한다면, 그 interface를 상속받은 class는 자신이 사용하지 않는 method마저 구현된다는 단점이 있다. 또한 사용하지 않는 interface의 추상 method가 변경된다면 class에서도 수정이 필요해진다. 이는 유지보수성을 크게 하락시킬 수 있다.
Interface segregation principle은 single responsibility principle와 유사한 면이 있다. 후자가 class의 단일 책임을 위한 원칙이라면, 전자는 interface의 단일 책임 원칙을 강조한다고 할 수 있다. 즉 이 원칙은 class가 아닌 interface의 분리를 통해 이루어진다.
아래 코드에서는 Robotics interface가 work와 eat method를 포함한다. 용빈 class는 두 method를 모두 구현할 수 있지만, 용빈이의로봇은 eat method를 구현할 필요가 없다. 하지만 interface를 위해 불필요한 eat을 구현해야만 한다. 이는 interface segregation principle를 위반한 것이다.
interface Robotics {
void work();
void eat();
}
class 용빈 implements Robotics {
@Override
public void work() {
System.out.println("용빈이가 훈련을 한다.");
}
@Override
public void eat() {
System.out.println("용빈이가 급식을 먹는다.");
}
}
class 용빈이의로봇 implements Robotics {
@Override
public void work() {
System.out.println("로봇이 훈련을 한다.");
}
@Override
public void eat() {
// 로봇은 먹을 수 없다!
}
}
아래 코드에서는 기존 Robotics interface를 기능에 따라 두 개의 작은 interface로 나누었다. 문제가 되었던 용빈이의로봇 class가 자신에게 필요한 work만 구현하여 불필요한 method 구현을 방지하였다.
interface Workable {
void work();
}
interface Eatable {
void eat();
}
class 용빈 implements Workable, Eatable {
@Override
public void work() {
System.out.println("용빈이가 훈련을 한다.");
}
@Override
public void eat() {
System.out.println("용빈이가 급식을 먹는다.");
}
}
class 용빈이의로봇 implements Workable {
@Override
public void work() {
System.out.println("로봇이 훈련을 한다.");
}
}
추상성이 높고 안정적인 고수준의 class는 구체적이고 불안정한 저수준의 class에 의존해서는 안 된다.
Dependency inversion principle이란 객체가 어떤 class를 참조해서 사용해야 한다면, 그 class를 직접 참조하는 것이 아닌 그의 상위 요소, 즉 추상 class 혹은 interface로 참조하라는 원칙이다.
의존 관계란, 한 class가 기능을 수행하려 할 때 다른 class의 기능이 필요한 관계를 뜻한다. 객체들이 서로 정보를 주고 받을 때 의존 관계가 형성된다.
클라이언트가 상속 관계로 이루어진 모듈을 사용할 때, 하위 모듈을 직접 사용하지 않아야 한다. 하위 모듈의 구체적인 내용에 의존하여 코드를 자주 수정하기보다 상위 interface의 추상적인 내용에 의존하여 코드를 보다 덜 수정하는 것이 유지보수성이 더 높다고 할 수 있다.
위 코드에서 Switch는 LightBulb에 직접 의존하고 있다. 만약 LightBulb를 다른 class로 교체하려면 Switch도 수정해야 한다.
class LightBulb {
public void turnOn() {
System.out.println("전구켜짐");
}
public void turnOff() {
System.out.println("전구꺼짐");
}
}
class Switch {
private LightBulb lightBulb;
public Switch(LightBulb lightBulb) {
this.lightBulb = lightBulb;
}
public void operate(String command) {
if (command.equalsIgnoreCase("on")) {
lightBulb.turnOn();
} else if (command.equalsIgnoreCase("off")) {
lightBulb.turnOff();
}
}
}
public class Main {
public static void main(String[] args) {
LightBulb lightBulb = new LightBulb();
Switch lightBulbSwitch = new Switch(lightBulb);
lightBulbSwitch.operate("on");
lightBulbSwitch.operate("off");
}
}
Switchable이라는 interface를 새로 만들고, LightBulb와 Fan이 이를 구현하도록 한다. Switch class는 이제 Switchable에 의존하므로, 저수준 모듈이 무엇이든 상관없이 작동할 수 있다. 이 방식으로 Switch는 저수준 구현에 의존하지 않으므로, dependency inversion principle를 준수하게 된다. 필요에 따라 새로운 class를 쉽게 추가할 수 있고, 이에 따라 Switch는 변경할 필요도 없습니다.
interface Switchable {
void turnOn();
void turnOff();
}
class LightBulb implements Switchable {
@Override
public void turnOn() {
System.out.println("전구켜짐");
}
@Override
public void turnOff() {
System.out.println("전구꺼짐");
}
}
class Fan implements Switchable {
@Override
public void turnOn() {
System.out.println("선풍기켜짐");
}
@Override
public void turnOff() {
System.out.println("선풍기꺼짐");
}
}
class Switch {
private Switchable device;
public Switch(Switchable device) {
this.device = device;
}
public void operate(String command) {
if (command.equalsIgnoreCase("on")) {
device.turnOn();
} else if (command.equalsIgnoreCase("off")) {
device.turnOff();
}
}
}
public class Main {
public static void main(String[] args) {
Switchable lightBulb = new LightBulb();
Switchable fan = new Fan();
Switch lightBulbSwitch = new Switch(lightBulb);
Switch fanSwitch = new Switch(fan);
lightBulbSwitch.operate("on");
lightBulbSwitch.operate("off");
fanSwitch.operate("on");
fanSwitch.operate("off");
}
}
우와 재밌네요!