SOLID란 객체 지향 프로그래밍을 하면서 지켜야하는 5대 원칙이다.
각각 SRP(단일 책임 원칙), OCP(개방-폐쇄 원칙), LSP(리스코프 치환 원칙), ISP(인터페이스 분리 원칙), DIP(의존 역전 원칙)으로 이루어져 있다. SOLID를 지킨다면 프로그램이 복잡해져도 변경에 용이하고 유지보수와 확장이 쉬운 소프트웨어를 개발할 수 있을 것이다.
1. SRP(단일 책임 원칙, Single Responsibility Principle)
정의(what?)
하나의 클래스는 하나의 책임을 가져야한다는 뜻이다.
클래스가 제공하는 모든 서비스는 그 하나의 책임을 수행하기 위해 집중되어야 한다.
왜 써?(why?)
책임의 영역이 확실해지기 때문에 어떤 클래스의 책임이 변경되어야한다해도 다른 클래스의 책임에는 영향을 미치지 않을 수 있다.
즉 클래스 간 서로 종속되지 않음으로써 클래스를 만들었을 때 영향을 미치는 범위를 인식할 수 있어 유지보수에 용이하다.
ex. 맥가이버 칼 vs 가위,나이프
ex. 또다른 (축구) 예시로는, 미드필더 객체를 만드는 클래스에서 메서드로 펀칭, 골킥 등의 골키퍼 메서드도 함께 넣는 것.
public class Midfielder {
private String name;
private int age;
public Midfielder(String name, int age) {
this.name = name;
this.age = age;
}
public void passBall() {
// 공을 패스하는 로직(미드필더 로직)
System.out.println(name + "이(가) 공을 패스합니다.");
}
public void controllMidfieldLine() {
// 미드필더 라인을 컨트롤하는 로직(미드필더 로직)
System.out.println(name + "이(가) 미드필더 라인을 컨트롤합니다.");
}
public void catchBall() {
// 공을 잡는 로직(골키퍼 로직)
System.out.println(name + "이(가) 날라온 공을 잡습니다.");
}
public void KickGoalLineKick() {
// 골킥을 차는 로직(골키퍼 로직)
System.out.println(name + "이(가) 골라인에서 골킥을 찹니다.");
}
}
2. OCP(개방-폐쇄 원칙, Open Close principle)
정의(What?)
소프트웨어 구성요소는 확장에서는 열려있고, 수정에는 닫혀있어야 한다.
왜?(Why?)
변경을 위한 비용은 가능한 줄이고 확장을 위한 비용을 가능한 극대화하기 위함.
왜냐하면 요구사항의 변경이나 추가 사항이 발생할 때 기존 구성요소는 최대한 지키고 기존 구성요소에서 확장하여 재사용할 수 있도록 하기 위함.
ex. 축구선수 추상 클래스에서 각 포지션별로 클래스를 만드는 것
public abstract class SoccerPlayer {
private String name;
private int age;
public SoccerPlayer(String name, int age) {
this.name = name;
this.age = age;
}
public abstract void play();
}
public class Striker extends SoccerPlayer {
public Striker(String name, int age) {
super(name, age);
}
@Override
public void play() {
System.out.println(getName() + "이(가) 공격을 수행합니다.");
}
}
public class Defender extends SoccerPlayer {
public Defender(String name, int age) {
super(name, age);
}
@Override
public void play() {
System.out.println(getName() + "이(가) 수비를 수행합니다.");
}
}
OCP를 지키지 않은 예시는 아래와 같다.
아래 예시를 보면 if/else 문으로 position에 따라 play 메서드를 정의하고 있다. 다만 이런 경우 새로운 포지션이 추가되야할 때마다 Player 클래스 내 play 메서드에 새로운 코드를 추가하여 "수정" 해야한다.
이렇 경우 보다는 위처럼 클래스로 나누는 것이 기존 코드를 수정하지 않고 "확장" (새로운 포지션 추가 시 새로운 클래스 생성)할 수 있는 방법이다.
public abstract class SoccerPlayer {
private String name;
private int age;
private String position;
public SoccerPlayer(String name, int age, String position) {
this.name = name;
this.age = age;
this.position = position;
}
public abstract void play();
}
public class Player extends SoccerPlayer{
private String name;
private int age;
private String position;
public SoccerPlayer(String name, int age, String position) {
this.name = name;
this.age = age;
this.position = position;
}
public void play() {
if (position.equals("Forward")) {
System.out.println(name + "이(가) 공격을 수행합니다.");
} else if (position.equals("Midfielder")) {
System.out.println(name + "이(가) 중앙에서 플레이합니다.");
} else if (position.equals("Defender")) {
System.out.println(name + "이(가) 수비를 담당합니다.");
}
}
}
3. LSP(리스코프 치환 원칙, Liskov Substitution Principle)
정의(what?)
객체 상위 타입은 항상 해당 객체 하위 타입으로 대체할 수 있어야 한다.
이 말이 너무 어렵기 때문에 나는 아래와 같이 이해했다.
"상속되는 객체는 반드시 부모 객체를 완전히 대체할 수 있어야 한다"
즉, 특정 메서드가 상위 타입을 인자로 사용한다 할 때, 그 타입의 하위 타입도 문제없이 정상 작동해야한다는 뜻이며,
부모에서 정의된 메서드는 모두 자식 클래스에서도 정의되어 있어야한다는 뜻이다.
왜? (why?)
리스코프 치환 원칙을 준수함으로써 타입에 부모타입을 두고 여러 객체를 받을 수 있어 코드의 재사용성이 높아진다.
public class Striker extends SoccerPlayer {
public Striker(String name, int age) {
super(name, age);
}
@Override
public void play() {
System.out.println(getName() + "이(가) 공격을 수행합니다.");
}
}
public class Defender extends SoccerPlayer {
public Defender(String name, int age) {
super(name, age);
}
@Override
public void play() {
System.out.println(getName() + "이(가) 수비를 수행합니다.");
}
}
void main() {
SoccerPlayer player = new Striker();
train(player);
}
void train(SoccerPlayer player) {
}
4. ISP(인터페이스 분리 원칙, Interface Segregation Principle)
정의(what?)
자신이 사용하지 않는 인터페이스는 구현하지 말아야 하는 것.
즉, 가능한 최소한의 인터페이스만을 사용해야 하며, 이는 하나의 인터페이스에 모든 기능을 넣는 것보단, 여러 인터페이스로 나누는 것을 선호한다는 뜻
SRP가 클래스의 단일 책임을 강조한다면 ISP는 인터페이스의 단일 책임을 강조한다.
왜?(why?)
만약 인터페이스에 모든 메서드가 몰려있다면 인터페이스를 구현한 클래스를 하나 만들 때마다 해당 클래스에서 사용하지 않을 클래스도 억지로 구현해야만 한다. 만약 인터페이스의 특정 메서드가 변경된다면 이 메서드를 쓰진 않지만 이 인터페이스를 구현한 모든 클래스에서 메서드를 수정해줘야하므로 너무 번거롭기 때문.
// 공격수 인터페이스
public interface Forward {
void attack();
}
// 미드필더 인터페이스
public interface Midfielder {
void playInMidfield();
}
// 수비수 인터페이스
public interface Defender {
void defend();
}
// 공격수 클래스
public class Striker implements Forward {
@Override
public void attack() {
System.out.println("공격수가 공격을 수행합니다.");
}
}
// 미드필더 클래스
public class MidfieldPlayer implements Midfielder {
@Override
public void playInMidfield() {
System.out.println("미드필더가 중앙에서 플레이합니다.");
}
}
// 수비수 클래스
public class CenterBack implements Defender {
@Override
public void defend() {
System.out.println("수비수가 수비를 담당합니다.");
}
}
5. DIP(의존관계 역전 원칙, Dependency Inversion Principle)
정의(what?)
구체적인 클래스보다 인터페이스, 추상 클래스와 같은 변하지 않을 가능성이 높은 것과 관계를 맺어라.
추상화에 의존해야지 구체화에 의존하면 안된다.
왜(why?)
하위 모듈에 대한 종속성을 줄임으로써 하위 모듈이 변경되더라도 상위 모듈은 변경되지 않도록 해 유지보수를 보다 편하게 가져갈 수 있다.
아래 예시는 DIP를 위반한 예시다.
맨오브더매치(MOM) 클래스에서 공격수 객체를 직접 의존하고 있기 때문이다.
public class ManOfTheMatchPlayer {
private Striker player;
}
public class Striker {}
public class Midfielder {}
public class Defender {}
이를 리팩토링한다면 MOM 클래스가 공격수 객체를 직접 의존하는 것이 아닌, SoccerPlayer라는 추상화된 인터페이스에 의존하는 것으로 바꿀 수 있다.
이는 곧 MOM이 스트라이커만 될 수 있는게 아니라, 모든 사커플레이어가 될 수 있다는 것을 의미하기도 한다.
다만, 순수 자바에서는 DIP를 지키면서 개발하긴 어렵다. 이는 이후 스프링 컨테이너가 해결해준다.
interface SoccerPlayer
public class Striker implements SoccerPlayer {}
public class Midfielder implements SoccerPlayer {}
public class Defender implements SoccerPlayer {}
public class ManOfTheMatchPlayer {
private SoccerPlayer player;
}
'Development > Java' 카테고리의 다른 글
자바 컴파일 과정 (6) | 2025.03.16 |
---|---|
자바는 왜 객체지향 언어인가요? (4) | 2025.03.15 |
객체 지향 프로그래밍이란?? 객체 지향의 4가지 특징 Feat.캡상추다 (2) | 2024.02.26 |
boolean 바인딩 에러.. boolean과 Boolean의 차이 (2) | 2024.02.02 |
KakaoAPI를 활용해 위치기반 장소 검색 Java 애플리케이션을 개발해보자 (0) | 2023.09.04 |