들어가며
객체지향 5원칙을 실제 예시를 통해 알아봅니다.
- S: 단일 책임 원칙 (Single Responsibility Principle, SRP)
- O: 개방-폐쇄 원칙 (Open-Closed Principle, OCP)
- L: 리스코프 치환 원칙 (Liskov Substitution Principle, LSP)
- I: 인터페이스 분리 원칙 (Interface Segregatio Principle, ISP)
- D: 의존관계 역전 원칙 (Dependency Inversio Principle, DIP)
단일 책임 원칙 (Single Responsibility Principle, SRP)
요점
- 하나의 객체가 하나의 책임만 가져야 한다.
- 클래스는 단 한 가지 목표만 가지고 작성해야 한다.
- 애플리케이션 모듈 전반에서 높은 유지보수성과 가시성 제어 기능을 유지하는 원칙이다.
예시
사각형 면적계산기를 예로 들겠습니다.
SRP Bad Case (x)
public class RectangleCalculator {
private static final double IN애_TERM = 0.0254d;
private final int width;
private final int height;
public RectangleCalculator(int width, int height) {
this.width = width;
this.height = height;
}
public int area() {
return width * height;
}
public double metersToInches(int area) {
return area / INCH_TERM;
}
}
면적계산기에서 단위변환 일까지 하고 있습니다. 면적계산이라는 클래스의 목적과 부합하지 않아 유지보수성이 떨어지므로 분리해 봅니다.
SRP Good Case (o)
public class RectangleCalculator {
private final int width;
private final int height;
public RectangleCalculator(int width, int height) {
this.width = width;
this.height = height;
}
public int area() {
return width * height;
}
}
public class AreaConverter {
private static final double INCH_TERM = 0.0254d;
private static final double FEET_TERM = 0.3048d;
public double metersToInches(int area) {
return area / INCH_TERM;
}
public double metersToFeet(int area) {
return area / FEET_TERM;
}
}
각 클래스가 맡은 하나의 일만 하기 때문에 단일 책임 원칙을 따릅니다.
SRP를 지켜 코드를 작성하면, 각 클래스가 어떤 일을 할지 예측가능합니다. 예측가능한 코드를 작성하는 것은 동료 개발자, 미래의 본인의 인지적 부하를 줄이고 유지보수성에 용이하기 때문에 중요하다고 생각합니다.
개방-폐쇄 원칙 (Open-Closed Principle, OCP)
요점
- 소프트웨어 컴포넌트는 확장에 관해 열려있고, 수정에 관해서는 닫혀 있어야 한다.
- 다른 개발자가 클래스를 확장하기만 해도 원하는 작업을 할 수 있도록 해야 한다.
예시
도형 인터페이스, 도형의 면적계산기를 예로 들겠습니다.
OCP Bad Case (x)
public interface Shape {
}
public class Rectangle implements Shape {
private final int width;
private final int height;
// constructor, getter, setter ..
}
public class ShapeAreaCalculator {
public List<Shape> shapes;
public ShapeAreaCalculator(List<Shape> shapes) {
this.shapes = shapes;
}
public double sumOfShapeAreas() {
int sum = 0;
if (shapes.getClass().equals(Rectangle.class)) {
sum += ((Rectangle) shape).getHeight()
* ((Rectangle) shape).getWidth();
}
return sum;
}
}
계산기 내부에서 각각을 계산한다고 가정합니다. 그렇다면 다른 개발자가 `Circle` 클래스를 새롭게 구현하고자 하면 어떨까요? 그럼 면적 계산기의 `sumOfShapeAreas()`메서드에 원의 면적계산로직을 끼워 넣어야 하는 수정이 발생합니다.
//...
public double sumOfShapeAreas() {
int sum = 0;
if (shapes.getClass().equals(Rectangle.class)) {
sum += ((Rectangle) shape).getHeight()
* ((Rectangle) shape).getWidth();
} else if (shapes.getClass().equals(Circle.class)) { // 수정이 발생하는 부분
// ...
}
return sum;
}
//...
OCP Good Case (o)
각 객체 면적의 계산은 객체가 처리하도록 수정합니다.
public interface Shape {
double area();
}
원과 사각형 클래스 내부에 `area()`를 구현합니다.
public class Rectangle {
public double area() {
// ...
}
}
public class Circle {
public double area() {
// ...
}
}
public class ShapeAreaCalculator {
public List<Shape> shapes;
public ShapeAreaCalculator(List<Shape> shapes) {
this.shapes = shapes;
}
public double sumOfShapeAreas() {
int sum = 0;
for (Shape shape : shapes) {
sum += shape.area();
}
return sum;
}
}
이렇게 각 객체에게 작업을 할당하면 면적계산기에서는 단지 클래스의 `area()`메서드만을 이용하면 됩니다. 다른 개발자가 삼각형을 구현해도 `Calculator`의 수정은 발생하지 않으므로 OCP원칙을 지킵니다.
리스코프 치환 원칙 (Liskov Substitution Principle, LSP)
요점
- 서브클래스의 객체는 슈퍼클래스의 객체와 반드시 같은 방식으로 동작해야 한다.
- 파생 타입은 반드시 기본 타입을 완벽히 대체할 수 있어야 한다.
예시
프리미엄, VIP, 무료회원과 바둑동호회를 예로 들겠습니다.
LSP Bad Case (x)
`Member`클래스는 바둑동호회 구성원 클래스입니다.
public abstract class Member {
private final String name;
public abstract void joinTournament();
public abstract void organizeTournament();
// constructor, getter, setter
}
프리미엄 멤버와 VIP 멤버는 대회를 주최하거나, 참여할 수 있습니다. 반면 무료회원은 대회에 오직 참여만 가능합니다. 주최는 할 수 없습니다.
public class PremiumMember extends Member {
public PremiumMember(String name) {
super(name);
}
@Override
public void joinTournament() {
System.out.println("Premium member joins tournament..");
}
@Override
public void organizeTournament() {
System.out.println("Premium member organize tournament..");
}
}
public class FreeMember extends Member {
public FreeMember(String name) {
super(name);
}
@Override
public void joinTournament() {
System.out.println("Free member joins tournament..");
}
@Override
public void organizeTournament() {
throw new IllegalStateException("Free member can't organize tournament");
}
}
이 멤버를 사용하는 곳에서 아래와 같이 코드를 작성하면 어떻게 될까요?
public static void main(String[] args) {
List<Member> members = List.of(new PremiumMember("김프리미엄"),
new VipMember("홍브압"),
new FreeMember("박무료"));
for (Member member : members) {
member.organizeTournament();
}
}
반복문의 마지막 `박무료`의 메서드를 호출하려 할 때 에러가 발생합니다. `FreeMember`가 `Member`를 대체할 수 없기 때문입니다. 이것은 리스코프 치환 원칙에 어긋납니다.
LSP Good Case (o)
대회에 참여, 대회를 주최하는 두 가지 일을 분리하는 것으로 시작합니다.
public interface TournamentJoiner {
void joinTournament();
}
public interface TournamentOrganizer {
void organizeTournament();
}
public abstract class Member implements TournamentJoiner, TournamentOrganizer {
private final String name;
// constructor
}
public class PremiumMember extends Member {
}
public class VipMember extends Member {
}
public class FreeMember implements TournamentJoiner { // TournamentJoiner만을 구현한 새로운 FreeMember
}
public static void main(String[] args) {
List<TournamentJoiner> members = List.of(new PremiumMember("김프리미엄"),
new VipMember("홍브압"),
new FreeMember("박무료"));
List<TournamentOrganizer> members = List.of(new PremiumMember("김프리미엄"),
new VipMember("홍브압"));
// FreeMember는 TournamentOrganizer를 구현하지 않기 리스트에 포함될 수 없음
}
리스트를 반복하며 메서드를 실행하면 기대한 방식대로 동작하고, 리스코프 치환 원칙도 준수하는 것을 확인할 수 있습니다.
인터페이스 분리 원칙 (Interface Segregatio Principle, ISP)
요점
- 클라이언트가 사용하지 않을 불필요한 메서드를 강제로 구현하게 해서는 안된다.
- 메서드를 강제로 구현하는 일이 없을 때까지 하나의 인터페이스를 2개 이상의 인터페이스로 분할한다.
예시
Connection인터페이스를 예로 들겠습니다.
ISP Bad Case (x)
public interface Connection {
void socket();
void http();
void connect();
}
`Connection`인터페이스를 구현하는 `WwwPingConnection`이 있다고 가정합니다.
public class WwwPingConnection implements Connection {
@Override
public void http() {
// do http..
}
@Override
public void connect() {
// do connect..
}
@Override
public void socket() {
// do nothing
}
}
`socket()`메서드를 사용하지 않지만 강제로 구현해야 합니다. 또한 `WwwPingConnection`를 사용하는 클라이언트는 `socket()`이 아무것도 하지 않는 메서드인지 모릅니다. 이는 ISP원칙에 위배됩니다.
ISP Good Case (o)
public interface Connection {
void connect();
}
public interface HttpConnection extends Connection {
void http();
}
public interface SocketConnection extends Connection {
void socket();
}
인터페이스를 분리한 후 `WwwPingConnection`을 다시 구현합니다.
public class WwwPingConnection implements HttpConnection {
@Override
public void http() {
// do http..
}
@Override
public void connect() {
// do connect..
}
}
`WwwPingConnection`은 필요한 메서드만 구현합니다. 이는 ISP 원칙을 준수합니다.
의존관계 역전 원칙 (Dependency Inversio Principle, DIP)
요점
- 구체화가 아닌 추상화에 의존해야 한다.
- 다른 구상 모듈에 의존하는 구상 모듈 대신, 구상 모듈을 결합하기 위한 추상 계층을 사용한다.
예시
데이터베이스 JDBC URL클래스를 예로 들겠습니다.
DIP Bad Case (x)
public class PostgresSQLJdbcUrl {
private final String dbName;
// constructor
public String get() {
return "jdbc:postgresql://.." + dbName;
}
}
public class ConnectToDatabase {
public void connect(PostgresSQLJdbcUrl postgresql) {
System.out.println("Connecting to " + postgresql.get());
}
}
`MySQLJdbcUrl`과 같이 다른 JDBC URL 타입을 생성하는 경우엔 `ConnectToDatabase.connect()`를 사용할 수 없습니다. 이럴 때 구체화가 아닌 추상화에 대한 의존관계를 만들어야 합니다.
DIP Good Case (o)
public interface JdbcUrl {
String get();
}
public class PostgresSQLJdbcUrl implements JdbcUrl {
private final String dbName;
// constructor
@Override
public String get() {
return "jdbc:postgresql://.." + dbName;
}
}
public class MySQLJdbcUrl implements JdbcUrl {
private final String dbName;
// constructor
@Override
public String get() {
return "jdbc:mysql://.." + dbName;
}
}
public class ConnectToDatabase {
public void connect(JdbcUrl jdbcUrl) {
System.out.println("Connecting to " + jdbcUrl.get());
}
}
여러 벤더사에 대한 추상화를 통해 JdbcUrl 인터페이스를 생성합니다. `ConnectToDatabase`에서 추상화 한 JdbcUrl 인터페이스에 의존합니다. 이후 JdbcUrl을 구현한 클래스라면 `ConnectToDatabase`에서 사용할 수 있습니다. 이제 DIP 원칙을 만족합니다.
마치며
SOLID에 대한 개념뿐만 아니라 구체화된 예시를 보며 이해도를 높일 수 있었습니다.
읽어주셔서 감사합니다.
reference
자바 코딩 인터뷰 완벽 가이드 - 안겔 레오나르도
'Programming' 카테고리의 다른 글
도커로 MySQL Master Slave Replication 만들기 (0) | 2024.06.03 |
---|