객체 지향 설계를 위한 일반적인 원칙으로 단일책임 원칙, 개방/폐쇄 원칙, 리스코프 치환 원칙, 인터페이스 분리 원칙, 의존관계 역전 원칙으로 구성
OOP의 좋은 설계를 목적으로 할 때 코드의 재사용성, 유지보수의 편의성, 테스트의 용이성 등을 고려하고 개선하기 위하여 SOLID 원칙을 적용한다. 어떤 부분에서 개선이 이루어지는지 간단하게 알아보고 넘어가자.
이러한 이유들로 SOLID 원칙은 객체 지향 설계에 적용하면 소프트웨어의 품질을 향상시키고, 유지보수성, 확장성, 재사용성 등을 개선하는데 도움을 준다.
"하나의 객체 또는 메소드는 하나의 책임만을 가져야한다."
클래스 또는 메소드의 응집성을 높이고, 클래스 간의 결합도를 낮추는데 목적이 있다. 클래스가 하나의 책임에만 집중하게 되면, 해당 책임에 대한 변경이 필요할 때 다른 부분에 영향을 덜 주기 때문에 코드의 유지보수성과 확장성이 높아진다.
응집성이 높다는 것은 클래스나 모듈 내부의 구성 요소들이 서로 관련된 기능을 수행하고 있다는 것을 의미하고 서로 긴밀하게 연관되어 동일한 목적을 수행하고 있는 것을 의미한다.
즉, 응집성이 높다는 것을 단일책임 원칙을 준수하는 것이라고 할 수 있다.
예를 들어 면적을 계산하는 클래스가 있다고 가정하고, 해당 클래스가 구한 면적을 인치 또는 다른 단위로 변경하는 모습을 상상해보자.
public class AreaCalculrator{
//변환할 단위 상수
private static final double INCH_TERM = 0.0254d;
private final int width;
private final int height;
public AreaCalculrator(int width, int height){
this.width = width;
this.height = height;
}
//면적 구하기
public int getArea(){
return width * height;
}
//단위 변환(단일 책임원칙 위배)
public double metersToInches(int area){
return area / INCH_TERM;
}
}
위처럼 면적을 계산하는 클래스에서 단위변환까지 이루어질 경우 AreaCalculrator는 '면적 계산'이라는 기능 외에도 '면적 변환'이라는 기능까지 담당하게 되어 클래스 내에 보유하고 있는 구성요소들 간의 응집도가 낮아지게 된다. 일반적으로 '또한'이라는 말이 사용가능하다면 해당 클래스는 단일 책임 원칙을 위배하고 있는 것이다.
그럼 단일책임 원칙을 준수한 코드를 보도록 하자.
//AreaCalculrator
public class AreaCalculrator{
private final int width;
private final int height;
public AreaCalculrator(int width, int height){
this.width = width;
this.height = height;
}
//면적 구하기
public int getArea(){
return width * height;
}
}
//AreaConverter
public class AreaConverter{
//변환할 단위 상수들
private static final double INCH_TERM = 0.0245d;
private static final double FEET_TERM = 0.3048d;
//인치로 변환
public double metersToInches(int arae){
return area / INCH_TERM;
}
//피트로 변환
public double metersToFeet(int arae){
return area / FEET_TERM;
}
}
이렇게 개선해 준다면 클래스들의 구성요소들은 각각 '면적 구하기', '면적 변환'을 수행하기 위하여 서로 긴밀하게 동일한 목적을 달성할 것이다.
"확장에는 열려있고, 수정에는 닫혀있어야한다."
새로운 기능을 추가하는 경우 확장을 통해서만 새로운 기능이 적용가능토록 설계해야한다는 것이다. 새로운 기능 추가 시 기존의 코드가 수정이 이루어져야만 한다면 개방/폐쇄 원칙을 준수하지 않은 것이다.
개방/폐쇄 원칙은 소프트웨어의 확장성과 유연성을 향상시키는 것이 목적이다. 새로운 기능을 추가할 경우 코드를 수정하지 않아도 된다는 것은 곧 유지보수성이 높다는 것을 의미한다. 또한, 소프트웨어의 변경에 따른 리스크를 최소화 할 수 있기 때문에 준수해야한다.
예를 들어 Shape라는 인터페이스를 구현하는 도형 클래스들이 있다고 가정할 때, 각 도형들의 면적의 합을 구하는 기능을 구현하는 경우를 생각해보자.
//도형들이 구현할 interface
public interface Shape{
}
//도형1 선언
public class Rectangle implements Shape{
private final int width;
private final int height;
...생성자 + getter 생략
}
//도형2 선언
public class Circle implements Shape{
private final int radius;
...생성자 + getter 생략
}
//Calculator 선언
public class AreaCalculator{
private final List<Shape> shapes;
public AreaCalculator(List<Shape> shapes){
this.shapes = shapes;
}
//면적의 합 구하기
public double sum(){
int sum = 0;
for(Shape shape : shapes){
if(shape.getClass().equals(Rectangle.Class)){
sum += ((Retangle) shape).getWidth() * ((Retangle) shape).getHeight();
} else if(shape.getClass().equals(Circle.Class){
sum += Math.PI * Math.pow(((Circle) shape).getRadius(), 2);
}
}
}
}
위와 같이 개방/폐쇄 원칙을 준수하지 않은 경우 새로운 도형에 대한 면적의 합을 추가해야한다면 sum()내의 else if를 추가해야하며, AreaCalculator 클래스를 수정해야한다.
이 부분을 개선해보자.
//각 도형에 면적을 계산하는 메소드 추가를 위해 interface 개선
public interface Shape{
public double area();
}
//각 도형은 area() 오버라이딩
public class Rectangle implements Shape{
private final int width;
private final int height;
public Rectangle(int width, int height){
this.width = width;
this.height = height;
}
@Override
public double area(){
return width * height;
}
}
public class Circle implements Shape{
private final int radius;
public Rectangle(int radius){
this.radius = radius;
}
@Override
public double area(){
return Math.PI * Math.pow(radius, 2);
}
}
//AreaCalculator 개선
public class AreaCalculator{
private final List<Shape> shapes;
public AreaCalculator(List<Shape> shapes){
this.shapes = shapes;
}
//면적의 합 구하기
public double sum(){
int sum = 0;
for(Shape shape : shapes){
sum += shape.area();
}
}
}
위와 같이 개방/폐쇄 원칙을 준수할 경우 새로운 도형 클래스만을 선언해주면된다. 이러한 부분에서 장점은 확장만 했을 뿐 기존의 interface 또는 AreaCalculator 클래스의 코드 수정은 이루어지지 않는 것이다.
"서브 클래스의 객체는 슈펴 클래스의 객체와 반드시 같은 방식으로 동작해야한다."
상속 관계에 있는 클래스 간의 일관성과 호환성을 유지하는 것을 목적으로 한다. 슈퍼 클래스에서 정의된 인터페이스와 어떤 규약을 서브 클래스가 지켜야하는 것이다.
결국 서브 클래스의 객체를 슈퍼 클래스의 객체로 캐스팅하여도 프로그램의 동작에 영향을 주지 않아야 하는 원칙이다. 해당 원칙은 코드의 재사용성과 확장성을 향상시키는데 도움을 준다.
예를 들어 어떤 대회를 참가하고 주최해야하는 멤버들의 등급을 나누고 해당 기능들을 실행시킨다고 가정해보겠습니다. 여기서 '프리미엄'과 'VIP'는 주최를 할 수 있고, 'Free'는 참가만 가능하다고 가정하겠습니다.
//Member 선언
public abstract class Member{
private final String name;
public Member(String name){
this.name = name;
}
public void joinTournament(); //참가
public void organizeTournament(); //주최
}
//Member를 구현하는 등급별 회원 정의
public class PremiumMember extends Member{
public PremiumMember(String name){
super(name);
}
@Override
public void joinTournament(){
System.out.println("참가 가능");
}
@Override
public void organizeTournament(){
System.out.println("주최 가능");
}
}
//VIP는 Premium과 동일하여 생략
public class FreeMember extends Member{
public PremiumMember(String name){
super(name);
}
@Override
public void joinTournament(){
System.out.println("참가 가능");
}
@Override
public void organizeTournament(){
System.out.println("주최 불가");
}
}
위와 같이 선언한 경우 Member를 상속받은 서브 클래스들이 모두 동일한 동작을 하는지 확인해보자.
public static void main(String[] args){
List<Member> members = List.of(
new PremiumMember("kim");
new VIPMember("lee");
new FreeMember("hwang");
);
for(Member member : members){
member.organizeTournament();
}
}
//실행결과
"주최 가능"
"주최 가능"
"주최 불가"
FreeMember는 해당 권한이 없기 때문에 동일한 동작을 하는 것이 불가능하다. 이렇게 서브 클래스가 슈퍼 클래스의 역할을 온전히 수행할 수 없다면 리스코프 치환 원칙에 위배되는 것이다.
이 코드를 개선해보자.
//Member 추상 클래스 분리
public interface JoinTournament{
public void join();
}
public interface OrganizeTournament {
public void organize();
}
//등급별 회원 선언
public class PremiumMember implements JoinTournament, OrganizeTournament{
public PremiumMember(String name){
this.name = name;
}
@Override
public void join(){
System.out.println("참가 가능");
}
@Override
public void organize(){
System.out.println("주최 가능");
}
}
//VIP는 Premium과 동일하여 생략
public class FreeMember implements JoinTournament{
public PremiumMember(String name){
this.name = name;
}
@Override
public void join(){
System.out.println("참가 가능");
}
}
위와 같이 기존의 Member 추상클래스를 JoinTournament와 OrganizeTournament 인터페이스로 분리하여 선언해주고, 회원의 등급에 따라 해당 권한을 수행할 수 있는 인터페이스를 구현하면된다.
동작을 확인해보자.
public static void main(String[] args){
List<JoinTournament> members = List.of(
new PremiumMember("kim");
new VIPMember("lee");
new FreeMember("hwang");
);
for(Member member : members){
member.join();
}
}
//실행결과
"참가 가능"
"참가 가능"
"참가 가능"
이렇게 동일하게 동작하는 것을 알 수 있다.
"사용하지 않을 불필요한 메서드를 강제로 구현하게 해서는 안 된다."
인터페이스를 더 작은 단위로 분리함으로써 인터페이스의 응집성을 높이고, 클라이언트가 불필요한 메소드에 의존하는 것을 방지하여 코드의 결합도를 낮추는 것이 목적이다.
'1번', '2번' ,'3번' 기능을 가지고 있는 인터페이스를 구현하는 클라이언트 중 '1번' 기능만 사용하는 경우 '2번', '3번' 기능은 코드블럭을 비워둔 상태로 선언하게되는데 이렇게 되면 잘못 설계된 것이다. 이런 경우 해당 기능들을 3개의 인터페이스 각각 1개씩 선언하여 필요한 인터페이스만을 구현하도록 설계하여야한다.
해당 예제는 위의 리스코프 치환 원칙의 예제 중 추상 클래스를 2개의 인터페이스로 분리하여 각각 구현하게 한 것과 동일한 개념이므로 예제는 건너뛰겠다.
인터페이스 분리를 준수하지 않고, 코드블럭을 비워둔 상태로 선언할 경우 의도치 않은 예외가 발생하거나 클라이언트 입장에서 해당 메소드가 사용가능한 것으로 착각하는 상황들이 발생할 수 있으니 유의하도록 하자.
"구체화가 아닌 추상화에 의존해야한다."
상위 수준의 모듈이 하위 수준의 모듈에 직접 의존하는 것을 피하여, 모듈 간의 결합도를 낮추고 유연성과 확장성을 높이는 것이 목적이다.
인터페이스를 통해 추상화를 도입하여 구체적인 구현에 대한 의존성을 낮추고, 상위 수준의 모듈이 인터페이스에 의존함으로써 하위 수준의 모듈이 변경되어도 상위 수준의 모듈이 영향을 받지 않도록 해야한다.
예를 들어 JdbcUrl을 인수로 받는 ConnectToDatabase라는 클래스가 있다고 가정하고, Url의 유형이 변경되면 어떻게 되는지 알아보고, 의존관계 역전 원칙에 따라 개선해보자.
//oracle Url
public class OracleJdbcUrl{
private final String dbName;
public OracleJdbcUrl(String dbName){
this.dbName = dbName;
}
public String get(){
return "jdbc:oracle...." + dbName;
}
}
//ConnectToDatabase 선언
public class ConnectToDataBase{
public void connect(OracleJdbcUrl oracleJdbc){
System.out.println("Connecting to " + oracleJdbc.get());
}
}
위와 같이 인수로 OracleJdbcUrl을 구체적으로 넘겨준다면 Mysql 등 다른 형태의 Url을 받으려면 메소드를 오버로딩하거나 클래스 코드를 수정하여야한다.
이를 극복하기 위해 인터페이스를 통해 추상화를 진행해보자.
//인터페이스 선언
public interface JdbcUrl{
public String get();
}
//oracle Url
public class OracleJdbcUrl implements JdbcUrl{
private final String dbName;
public OracleJdbcUrl(String dbName){
this.dbName = dbName;
}
@Override
public String get(){
return "jdbc:oracle...." + dbName;
}
}
//ConnectToDatabase 인수 수정
public class ConnectToDataBase{
public void connect(JdbcUrl jdbc){
System.out.println("Connecting to " + jdbc .get());
}
}
위처럼 JdbcUrl을 구현하는 모든 클래스를 인수로 받아 동일하게 적용 시킬 수 있다. 이렇듯 상위 모듈이 하위 모듈에 대한 구체적인 정보를 가지는 것보다는 추상화된 정보를 가짐으로써 코드의 유연성 확보와 확장의 편의성이 증가하는 것이다.
이렇게해서 객체 지향 설계의 아주 중요한 원칙인 SOLID 원칙에 대하여 알아보았다.
소프트웨어의 유지보수성, 재사용성, 유연성, 확장성, 테스트의 용이성 등에 대하여 개선해주기 때문에 아주 중요한 개념이며 객체 지향 프로그래밍을 한다면 반드시 준수해야하는 사항들이니 다시 한번 꼼꼼히 읽어보길 바란다.
해당 개념에 대해 자세히 들어보지 않았더라도 실제 프로젝트를 수행하면서 본인도 모르게 SOLID 원칙을 준수한 사례들이 있을 수도 있다. 이 말은 프로그래밍에 필요한 부분들이라는 말과도 같다. 개발을 더욱 체계적으로 할 수 있으며 차후 유지보수를 위해 설계 단계부터 SOLID 원칙을 적용하는 습관을 가진다면 분명히 코드를 개선하고, 리팩토링하는 소요가 점차 줄어들 것이라고 생각한다.
그럼 이만.👊🏽