개방 폐쇄 원칙은 단일 책임 원칙에 비해 더 이해하기 쉬울 수 있다. 하지만 객체지향 프로그래밍을 작성할 때 더욱 체감되는 원칙이 될 것이다. 책임은 개념적인 요소지만, 개방 폐쇄 원칙은 소프트웨어의 유지를 위한 설계에 더욱 직접적임을 시간이 갈 수록 느끼고 있다.
개방 폐쇄 원칙이란
모든 시스템은 그 생명주기에서 변하기 마련이다. 프로그램을 만들고 배포를 첫 배포를 하게 되면 해당 프로그램의 첫 버전이 완성된다. 만약 우리가 첫 버전의 프로그램을 개정해 개정된 버전을 만들었다면, 개정된 버전의 프로그램이 첫 버전의 프로그램보다 더 오래 유지될 수 있도록 신경쓰고 싶을 것이고, 그러기 위해선 변화를 마주했을 때 더욱 안정적이어야 할 것이다. 변화에 안정적인 프로그램 설계를 하기 위해 1988년 버트란드 마이어(Bertrand Meyer)는 Open-Closed Principle, 개방 폐쇄의 원칙을 제시했다.
개방 폐쇄 원칙은 말 그대로 모듈의 개방과 폐쇄에 관한 원칙이다. 각각의 모듈이나 객체는 확장에는 열려있고 변경에는 닫혀야 한다는 간단한 원칙을 의미한다.
이렇게 단순한 의미를 보고있자면 OCP가 내포하고 있는 것은 확장과 변경, 두 가지처럼 보인다. 하지만 OCP는 그 중 확장에만 관한 원칙이다. OCP가 말하는 변경은 확장할 때의 변경이다. 즉, 모듈이나 객체가 열려있고 닫혀있는지의 의미를 열림과 닫힘 두 가지의 개별적인 속성을 말하는 것이 아니라, Open-Closed, 열림과 닫힘을 세트로 하나의 속성으로 생각해야한다.
Open for extension: 확장
OCP의 Open, 확장 부분은 굉장히 명확하다. 요구사항이 새롭게 생기거나 변경되게 된다면 프로그램은 새로운 기능을 동작할 수 있게끔 추가적인 개발이 필요하다. OCP의 확장은 단순히 그런 의미다. 프로그램이나 모듈이 확장할 수 있는지, 다르게 말하면 새로운 요구사항이 생겼을 때 요구사항을 충족시키기 위해 새로운 행동을 추가할 수 있는지에 대한 아주 단순한 가능성을 의미한다.
도형 그리기 프로그램을 예를 들어보겠다. 처음 요구사항으로 주어진 원과 정사각형을 그리는 도형 그리기 프로그램을 작성했다고 가정해보자.
public enum Shape {
CIRCLE,
SQUARE,
}
public class Circle {
private final Point center;
private final double radius;
public Circle(Point center, double radius) {
this.center = center;
this.radius = radius;
}
public void draw() {...}
}
public class Square {
private final Point topLeft;
private final double side;
public Square(Point topLeft, double side) {
this.topLeft = topLeft;
this.side = side;
}
public void draw() {...}
}
public class Drawer {
...
public draw(Shape shape, Point point, double value) {
switch (shape) {
case CIRCLE:
Circle circle = new Circle(point, value);
circle.draw();
break;
case SQUARE:
Square square = new Square(point, value);
square.draw();
break;
}
}
}
public class Main {
public static void main(String[] args) {
Drawer drawer = new Drawer();
drawer.draw(CIRCLE, 10, new Point(10, 10));
drawer.draw(SQUARE, 10, new Point(10, 10));
}
}
모양이 주어지면 모양에 맞춰 도형을 생성해 도형을 그리는 간단한 프로그램이다. 지금까지는 원과 정사각형만 그리는 기능을 지원하는데, 이 프로그램을 확장시켜 정삼각형도 그리는 기능을 추가하고싶다. 이렇게 새로운 요구사항이 생겼을 때 이를 충족시킬 수 있도록 추가 개발이 가능할까?
public class Triangle {
private final Point bottomLeft;
private final double side;
public Triangle(Point bottomLeft, double side) {
this.bottomLeft = bottomLeft;
this.side = side;
}
public void draw() {...}
}
Circle과 Square와 비슷하게 Triangle 클래스를 추가해주었다. 이제 추가된 요구사항을 충족시키기 위해 기존 코드를 수정해보겠다.
public enum Shape {
CIRCLE,
SQUARE,
TRIANGLE,
}
public class Drawer {
...
public draw(Shape shape, Point point, double value) {
switch (shape) {
case CIRCLE:
Circle circle = new Circle(point, value);
circle.draw();
break;
case SQUARE:
Square square = new Square(point, value);
square.draw();
break;
case TRIANGLE:
Triangle triangle = new Triangle(point, value);
triangle.draw();
break;
}
}
}
public class Main {
public static void main(String[] args) {
Drawer drawer = new Drawer();
drawer.draw(CIRCLE, 10, new Point(0, 0));
drawer.draw(SQUARE, 10, new Point(10, 0));
drawer.draw(TRIANGLE, 10, new Point(10, 0));
}
}
이렇게 식별 가능한 모양을 추가해주고, 정삼각형을 그리는 기능을 기존 코드의 변경을 통해 추가했다. 기능적으로 잘 동작한다고 가정하고, 이 프로그램은 이와 같이 확장을 할 수 있었으니 확장엔 열려있는 프로그램인 것이다.
Closed for modification: 변경
OCP에서 말하는 변경은, 확장이 아닐 때의 변경이 아니라 확장을 할 때의 변경이다. 새로운 요구사항이 생긴 것이 아니라 기존의 요구사항을 모두 충족했던 기존 프로그램이 알고보니 버그가 있다면, 단일 책임 원칙에 나왔던 변경의 이유를 찾아 변경을 진행할 수 있다. 그러한 변경은 OCP에서 말하는 변경이 아니다. OCP가 의미하는 변경이란, 프로그램이나 모듈에 새로운 행동이 추가되며 확장할 때 새로운 소스가 아닌 기존 소스의 변경을 의미한다. 그렇다면 변경에 닫혀있지 않은 구조란 무엇일까?
위 확장의 예시에서 나온 코드가 변경에 닫혀있지 않는 OCP를 위반하는 구조의 예이다. 삼각형을 그리는 기능을 추가하기 위해 우리는 기존의 Shape와 Drawer의 draw 메서드가 변경이 되었다. 실행하기 위한 Main 클래스를 제외하고도 말이다.
이처럼 프로그램이나 모듈의 새로운 행동을 추가하면서 기존 코드의 연쇄적인 변경을 유발한다면, 이는 OCP를 위배해 바람직하지 못한 구조의 프로그램이다.
Open-Closed: 개방과 폐쇄
다시 정리하자면, OCP는 기존 모듈이나 프로그램이 새로운 기능을 추가를 할 수 있는 가능성, 즉, 확장을 열어두고, 확장 시에 기존 소스코드나 의존하고 있는 다른 모듈의 변경을 피할 수 있는 의미인 변경에는 닫혀있어야 한다는 원칙이다. 개방과 폐쇄를 따로 보는 것이 아니라 개방-폐쇄를 확장할 때의 하나의 속성으로 생각해야 한다.
쉽게 말하자면, 기능 추가를 할 때 기존 코드를 수정하지 않도록 하는 구조를 만드는 것이 개방 폐쇄 원칙이다.
추상화: OCP의 핵심 솔루션
OCP는 왜 중요한 것일까? 왜 OCP를 위반하지 않는 구조를 지향해야 하며, OCP를 달성함으로써 얻을 수 있는 이점은 무엇일까? 위에 예시로 들었던 코드의 문제는 무엇이라고 할 수 있을까?
public enum Shape {
CIRCLE,
SQUARE,
TRIANGLE,
}
public class Circle {
private final Point center;
private final double radius;
public Circle(Point center, double radius) {
this.center = center;
this.radius = radius;
}
public void draw() {...}
}
public class Square {
private final Point topLeft;
private final double side;
public Square(Point topLeft, double side) {
this.topLeft = topLeft;
this.side = side;
}
public void draw() {...}
}
public class Triangle {
private final Point bottomLeft;
private final double side;
public Triangle(Point bottomLeft, double side) {
this.bottomLeft = bottomLeft;
this.side = side;
}
public void draw() {...}
}
public class Drawer {
...
public draw(Shape shape, Point point, double value) {
switch (shape) {
case CIRCLE:
Circle circle = new Circle(point, value);
circle.draw();
break;
case SQUARE:
Square square = new Square(point, value);
square.draw();
break;
case TRIANGLE:
Triangle triangle = new Triangle(point, value);
triangle.draw();
break;
}
}
}
public class Main {
public static void main(String[] args) {
Drawer drawer = new Drawer();
drawer.draw(CIRCLE, 10, new Point(10, 0));
drawer.draw(SQUARE, 10, new Point(10, 0));
drawer.draw(TRIANGLE, 10, new Point(10, 0));
}
}
위 예시의 문제는 세 가지 정도로 말할 수 있다.
Rigidity: 뻣뻣함
정삼각형의 기능을 추가는 Shape, Drawer등의 다른 클래스의 수정 또한 발생시켰다. 새롭게 추가되는 Triangle과 사용을 결정할 Main 클래스 말고도 새롭게 변경되는 클래스들의 컴파일이 다시 필요하다. 확장이 발생할 때마다 다른 여러 클래스 파일들을 새롭게 컴파일하게 만드는 점에 있어서, 위 예시 코드는 유연하지 못한 뻣뻣한 구조를 지니고 있다.
Fragility: 부실함
위 예시는 견고하지 못 하다. 새로운 확장이 발생할 때 마다, switch/case에서 처리해야 하는 case가 늘어나게 된다. 혹은, if-else 조건문의 연쇄적인 사용이 필요하고, 이 또한 새로운 확장마다 분기가 늘어나게 된다. 이 같은 연쇄적이고 긴 switch/case나 if-else 조건문은 가독성을 해치고, 개발자가 찾고자 하는 포인트가 명확히 보이지 않는다.
Immobile: 부동
다른 개발자가 이 프로젝트에 참여한다면, Drawer의 draw 같은 메서드를 재사용하기가 어렵다. 왜냐하면 Shape나 구체적인 Circle, Triangle, Square등의 불필요한 존재 또한 모두 한 곳에 엮여있기 때문이다. draw는 매개변수로 모양과 반지름이나 변의 길이로 들어갈 value, point 등의 존재도 모두 받는다. 만약, 직사각형처럼 변 길이가 두 개 필요한 경우나 필요한 포인트가 두 개 이상인 도형의 그리기 기능 확장이 필요하다면, 그 도형들을 위해 이 draw함수는 모든 매개변수를 알아야 한다. 기존의 원이나 정사각형 등을 그리기 위해선 필요가 없는데 말이다.
OCP를 위반한다면 위의 세 가지와 같은 문제가 생길 수 있다. 유연하고 오래 유지할 수 있는 소프트웨어를 만들기 위해 추상화를 이용해서 OCP를 지킬 수 있다.
변경 없는 확장
Circle, Square, Triangle은 모두 공통적으로 도형이다. 게다가 모두 draw라는 공통적인 메서드를 가지고 있다. 이를 힌트로 삼아, 추상화를 적용해 OCP를 지키는 코드를 만들어보자.
public interface Shape {
void draw();
}
public class Circle implements Shape {
private final double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public void draw() {...}
}
public class Square implements Shape {
private final Point topLeft;
private final double side;
public Square(Point topLeft, double side) {
this.topLeft = topLeft;
this.side = side;
}
@Override
public void draw() {...}
}
public class Triangle implements Shape {
private final Point bottomLeft;
private final double side;
public Triangle(Point bottomLeft, double side) {
this.bottomLeft = bottomLeft;
this.side = side;
}
@Override
public void draw() {...}
}
우선 Shape라는 추상체를 인터페이스로 만들어 draw() 메서드를 정의한 후, 만들어진 도형 클래스들이 Shape를 구현하도록 했다.
이와 같은 추상화는 이런 객체들이 사용되는 곳에서 큰 변화를 만들 수 있다.
public class Drawer {
...
public draw(Shape shape) {
shape.draw();
}
}
그리기를 수행할 Drawer 객체는 복잡한 switch/case가 아니라 매개변수로 받는 도형을 그리기만 하면 된다. 내부의 동작은 도형마다 정의가 되어 있을테니 말이다.
어떤 도형을 그릴지는 호스트가 결정해서 알려주기만 하면 된다.
public class Main {
public static void main(String[] args) {
Drawer drawer = new Drawer();
Point point = new Point(10, 10);
Shape circle = new Circle(point, 10);
Shape square = new Square(point, 10);
Shape triangle = new Triangle(point, 10);
List<Shape> shapes = List.of(circle, square, triangle);
for (Shape shape : shapes) {
drawer.draw(shape);
}
}
}
이렇게 객체를 결정하고 실행시킬 호스트와 확장되는 객체를 제외한 나머지 객체들이 변경되지 않을, OCP를 지키는 코드로 보완했다.
직사각형을 추가해보자.
public class Rectangle implements Shape {
private final Point topLeft;
private final double width;
private final double height;
public Rectangle(Point topLeft, double width, double height) {
this.topLeft = topLeft;
this.width = width;
this.height = height;
}
@Override
public void draw() {...}
}
public class Main {
public static void main(String[] args) {
Drawer drawer = new Drawer();
Point point = new Point(10, 10);
Shape circle = new Circle(point, 10);
Shape square = new Square(point, 10);
Shape triangle = new Triangle(point, 10);
Shape rectangle = new Rectangle(point, 10, 5);
List<Shape> shapes = List.of(circle, square, triangle);
for (Shape shape : shapes) {
drawer.draw(shape);
}
}
}
역시 새롭게 확장된 직사각형 객체와 호스트 코드인 Main 클래스만을 제외하면 변경이 없는, OCP를 위반하지 않는 구조를 만들었다.
이번엔 직사각형 기능을 새롭게 확장하면서 위에 언급되었던 세 가지 문제가 보이지 않는다. Main을 제외하면 다른 객체들은 컴파일이 다시 필요하지 않고, 복잡한 switch/case가 없어 부실하지 않으며, Shape만을 알면 되는 Drawer의 draw 메서드는 다른 모양에 같은 기능을 적용하고 싶어하는 누군가에 의해 재사용되기도 쉬워지고 불필요한 정보는 몰라도 되는 유동성이 생겼다.
이처럼 OCP를 준수함으로써 유연한 구조를 만들어 프로그램이나 모듈의 유지보수성을 증가시키고 재사용성을 증가시킬 수 있다.
하지만 이 같은 예시는 너무 간단한 프로그램이다. 객체지향을 사용하는 이유인 대규모 프로젝트 같은 경우, 확장 시 모든 변경이 닫히기 어렵다.
트레이드 오프: OCP 준수는 어렵다
프로그램이나 모듈을 확장할 때, 가능한 많은 객체들의 변경을 방지하는 것은 중요하다. 그렇다고 모든 변경을 방지하는 것은 매우 어렵다.
OCP는 변경에는 닫혀있는 것이 확장할 때의 고려사항인 원칙인데, 취약해보이더라도 절대 변경할 일이 없는 코드가 있다면 OCP 준수를 위해 그것을 수정해야 할까? 절대 일어나지 않을 변경이라면 변경이라고 할 수 있을까? 절대라는 것은 존재하지 않는다고 믿는다면, 변경될 가능성이 거의 없는 코드를 위해 OCP를 준수하는 것은 시간이나 인력 등의 리소스만을 소모 시킬 뿐이다. 변경이 일어날 것 같은 코드라고 해서 유연한 구조로 만들었지만 변경이 거의 없을 수도 있고, 예상치 못한 곳에서 변경이 일어나 유연하지 못한 구조 속에서 헤맬 수도 있다. 예상 가능한 변경점을 찾아 OCP를 준수하는 것이 좋지만, 아이러니하게 예상 가능한 변경점을 예상하는 것은 매우 어렵다.
OCP 준수의 값은 비싸다. 추상화를 통해 만들어지는 유연한 구조는 개발자들의 이해도가 충분해야하며, 자칫보면 더 복잡한 구조이기 때문에 올바른 추상화를 위해 시간과 노력이 필요하다. 추상화가 늘어날 수록 소프트웨어 구조의 복잡성도 올라가지만, 구현을 위한 시간은 대개 한정적이다.
결국 또 트레이드 오프다. 경험과 상식을 기반으로 충분히 고려해봤다면, 더 깊은 고민은 낭비인 것 같다. 변화를 예상하기 힘들 땐, 변화가 실제로 생길 때까지 기다리는 것이 방법이다!
References
R. C. Martin, Agile Software Development: Principles, Patterns, and Practices. Harlow, Essex: Pearson Education Limited, 2014.
'객체지향 프로그래밍' 카테고리의 다른 글
UML - Class Diagram (0) | 2024.05.25 |
---|---|
객체지향의 특성 - 다형성 (0) | 2024.05.24 |
객체지향의 특성 - 추상화 (0) | 2024.05.22 |
객체지향의 특성 - 상속 (0) | 2024.05.21 |
객체지향의 특성 - 캡슐화 (정보은닉) (0) | 2024.05.21 |