2024. 1. 9. 22:30ㆍ백엔드/Spring Boot
# 디자인 패턴과 SOLID 원칙이 소프트웨어 개발에서 중요한 이유
소프트웨어 개발에서 핵심은 코드의 유지보수성, 가독성, 그리고 재사용성이다. 디자인 패턴과 SOLID 원칙의 적용은 일관된 스타일로 개발을 가능하게 하고, 이는 코드의 효율성을 향상시키고 전체 프로젝트 코드 품질을 높이는 데 기여한다.
특히, 여러 개발자들이 협업하거나 코드를 이해해야 할 때 표준화된 구조와 원칙을 따르면 개발 시간을 단축할 수 있고, 팀 간 커뮤니케이션도 원활하게 할 수 있다. 이러한 접근은 팀의 효율성을 향상시키는 중요한 역할을 한다.
따라서, 디자인 패턴과 SOLID 원칙은 소프트웨어 개발에서 코드 품질과 팀의 협업을 개선하기 위한 필수적인 가이드로 작용한다.
# SOLID 원칙
1. 단일 책임 원칙 (Single Responsibility Principle - SRP)
- 클래스(객체)는 단 하나의 책임(기능)만 가져야 한다.
- 클래스가 변경되어야 하는 이유는 오직 한 가지여야 한다.
[Before]
// BaseballPlayer 클래스는 선수의 정보와 선수에 관련된 특정 행동을 모두 포함
public class BaseballPlayer {
private String name;
private int jerseyNumber;
private int battingAverage;
public BaseballPlayer(String name, int jerseyNumber, int battingAverage) {
this.name = name;
this.jerseyNumber = jerseyNumber;
this.battingAverage = battingAverage;
}
public void printPlayerInfo() {
System.out.println("Name: " + name);
System.out.println("Jersey Number: " + jerseyNumber);
System.out.println("Batting Average: " + battingAverage);
}
public void hitHomeRun() {
System.out.println(name + " hit a home run!");
}
}
public class Main {
public static void main(String[] args) {
BaseballPlayer player = new BaseballPlayer("John Doe", 25, 0.320);
player.printPlayerInfo(); // 메소드 호출
player.hitHomeRun(); // 메소드 호출
}
}
[After]
// BaseballPlayer 클래스는 선수의 정보만을 관리
public class BaseballPlayer {
private String name;
private int jerseyNumber;
private int battingAverage;
public BaseballPlayer(String name, int jerseyNumber, int battingAverage) {
this.name = name;
this.jerseyNumber = jerseyNumber;
this.battingAverage = battingAverage;
}
public void printPlayerInfo() {
System.out.println("Name: " + name);
System.out.println("Jersey Number: " + jerseyNumber);
System.out.println("Batting Average: " + battingAverage);
}
}
// BaseballPlayerActions 클래스는 선수에 관련된 특정 행동을 담당
public class BaseballPlayerActions {
public void hitHomeRun(BaseballPlayer player) {
System.out.println(player.getName() + " hit a home run!");
}
}
public class Main {
public static void main(String[] args) {
BaseballPlayer player = new BaseballPlayer("John Doe", 25, 0.320);
player.printPlayerInfo(); // 메소드 호출
BaseballPlayerActions actions = new BaseballPlayerActions();
actions.hitHomeRun(player); // 메소드 호출
}
}
2. 개방/폐쇄 원칙 (Open/Closed Principle - OCP)
- 소프트웨어 엔티티(클래스, 모듈, 함수 등)는 확장에 열려 있어야 하고, 변경에는 닫혀있어야 한다.
- 새로운 기능을 추가하거나 변경할 때 기존의 코드를 수정하지 않고도 가능해야 한다.
[Before]
// BaseballPlayer 클래스에 새로운 특징을 추가하는 경우 기존 코드를 수정해야 함
public class BaseballPlayer {
private String name;
private int jerseyNumber;
private int battingAverage;
public BaseballPlayer(String name, int jerseyNumber, int battingAverage) {
this.name = name;
this.jerseyNumber = jerseyNumber;
this.battingAverage = battingAverage;
}
// 리팩토링 전 메서드
public void printPlayerInfo() {
System.out.println("Name: " + name);
System.out.println("Jersey Number: " + jerseyNumber);
System.out.println("Batting Average: " + battingAverage);
}
// 새로운 특징 추가에 대한 코드
public void hitHomeRun() {
System.out.println(name + " hit a home run!");
}
// 다른 새로운 특징 추가에 대한 코드
public void stealBase() {
System.out.println(name + " stole a base!");
}
}
public class Main {
public static void main(String[] args) {
BaseballPlayer player = new BaseballPlayer("John Doe", 25, 0.320);
player.printPlayerInfo(); // 메소드 호출
player.hitHomeRun(); // 메소드 호출
player.stealBase(); // 메소드 호출
}
}
[After - 신규 요구사항 추가]
// BaseballPlayer 인터페이스를 활용하여 확장 가능한 구조로 변경
public interface BaseballPlayer {
void printPlayerInfo();
}
// Hitter 클래스는 BaseballPlayer 인터페이스를 구현하고 새로운 특징을 추가
public class Hitter implements BaseballPlayer {
private String name;
private int jerseyNumber;
private int battingAverage;
public Hitter(String name, int jerseyNumber, int battingAverage) {
this.name = name;
this.jerseyNumber = jerseyNumber;
this.battingAverage = battingAverage;
}
@Override
public void printPlayerInfo() {
System.out.println("Name: " + name);
System.out.println("Jersey Number: " + jerseyNumber);
System.out.println("Batting Average: " + battingAverage);
}
public void hitHomeRun() {
System.out.println(name + " hit a home run!");
}
}
// Runner 클래스는 BaseballPlayer 인터페이스를 구현하고 다른 새로운 특징을 추가
public class Runner implements BaseballPlayer {
private String name;
private int jerseyNumber;
private int stolenBases;
public Runner(String name, int jerseyNumber, int stolenBases) {
this.name = name;
this.jerseyNumber = jerseyNumber;
this.stolenBases = stolenBases;
}
@Override
public void printPlayerInfo() {
System.out.println("Name: " + name);
System.out.println("Jersey Number: " + jerseyNumber);
System.out.println("Stolen Bases: " + stolenBases);
}
public void stealBase() {
System.out.println(name + " stole a base!");
}
}
public class Main {
public static void main(String[] args) {
BaseballPlayer hitter = new Hitter("John Doe", 25, 0.320);
hitter.printPlayerInfo(); // 메소드 호출
((Hitter) hitter).hitHomeRun(); // 메소드 호출
BaseballPlayer runner = new Runner("Jane Smith", 10, 15);
runner.printPlayerInfo(); // 메소드 호출
((Runner) runner).stealBase(); // 메소드 호출
}
}
3. 리스코프 치환 원칙 (Liskov Substitution Principle - LSP)
- 서브 타입은 언제나 기본 타입으로 교체할 수 있어야 한다.
- 상속 관계에서 하위(자식) 클래스가 상위(부모) 클래스를 완전히 대체할 수 있도록 하는것을 의미
[Before]
public class BaseballPlayer {
private String name;
private int jerseyNumber;
private double battingAverage;
public BaseballPlayer(String name, int jerseyNumber, double battingAverage) {
this.name = name;
this.jerseyNumber = jerseyNumber;
this.battingAverage = battingAverage;
}
public void printPlayerInfo() {
System.out.println("Name: " + name);
System.out.println("Jersey Number: " + jerseyNumber);
System.out.println("Batting Average: " + battingAverage);
}
}
public class Main {
public static void main(String[] args) {
BaseballPlayer player = new BaseballPlayer("John Doe", 25, 0.320);
player.printPlayerInfo(); // 메소드 호출
}
}
[After]
// BaseballPlayer 인터페이스를 도입하여 리스코프 치환 원칙을 준수하도록 변경
public interface BaseballPlayer {
void printPlayerInfo();
}
// Hitter 클래스는 BaseballPlayer 인터페이스를 구현하고 새로운 특징을 추가
public class Hitter implements BaseballPlayer {
private String name;
private int jerseyNumber;
private double battingAverage;
public Hitter(String name, int jerseyNumber, double battingAverage) {
this.name = name;
this.jerseyNumber = jerseyNumber;
this.battingAverage = battingAverage;
}
@Override
public void printPlayerInfo() {
System.out.println("Name: " + name);
System.out.println("Jersey Number: " + jerseyNumber);
System.out.println("Batting Average: " + battingAverage);
}
public void hitHomeRun() {
System.out.println(name + " hit a home run!");
}
}
// Pitcher 클래스는 BaseballPlayer 인터페이스를 구현하고 다른 새로운 특징을 추가
public class Pitcher implements BaseballPlayer {
private String name;
private int jerseyNumber;
private double earnedRunAverage;
public Pitcher(String name, int jerseyNumber, double earnedRunAverage) {
this.name = name;
this.jerseyNumber = jerseyNumber;
this.earnedRunAverage = earnedRunAverage;
}
@Override
public void printPlayerInfo() {
System.out.println("Name: " + name);
System.out.println("Jersey Number: " + jerseyNumber);
System.out.println("Earned Run Average: " + earnedRunAverage);
}
public void throwStrikeout() {
System.out.println(name + " threw a strikeout!");
}
}
public class Main {
public static void main(String[] args) {
BaseballPlayer hitter = new Hitter("John Doe", 25, 0.320);
hitter.printPlayerInfo(); // 메소드 호출
BaseballPlayer pitcher = new Pitcher("Jane Smith", 10, 2.75);
pitcher.printPlayerInfo(); // 메소드 호출
}
}
4. 인터페이스 분리 원칙 (Interface Segregation Principle - ISP)
- 클라이언트는 자신이 사용하지 않는 메서드에 의존 관계를 맺으면 안된다.
- 인터페이스를 여려개 작성하는 대신, 클라이언트가 사용하는 기능에만 종속되도록 인터페이스를 분리해야 한다.
[Before]
// BaseballPlayer 인터페이스에 모든 메소드를 포함
public interface BaseballPlayer {
void printPlayerInfo();
void hitHomeRun();
void stealBase();
}
// Hitter 클래스는 BaseballPlayer 인터페이스를 구현
public class Hitter implements BaseballPlayer {
private String name;
private int jerseyNumber;
private double battingAverage;
public Hitter(String name, int jerseyNumber, double battingAverage) {
this.name = name;
this.jerseyNumber = jerseyNumber;
this.battingAverage = battingAverage;
}
@Override
public void printPlayerInfo() {
System.out.println("Name: " + name);
System.out.println("Jersey Number: " + jerseyNumber);
System.out.println("Batting Average: " + battingAverage);
}
@Override
public void hitHomeRun() {
System.out.println(name + " hit a home run!");
}
@Override
public void stealBase() {
System.out.println(name + " stole a base!");
}
}
// Pitcher 클래스는 BaseballPlayer 인터페이스를 구현
public class Pitcher implements BaseballPlayer {
private String name;
private int jerseyNumber;
private double earnedRunAverage;
public Pitcher(String name, int jerseyNumber, double earnedRunAverage) {
this.name = name;
this.jerseyNumber = jerseyNumber;
this.earnedRunAverage = earnedRunAverage;
}
@Override
public void printPlayerInfo() {
System.out.println("Name: " + name);
System.out.println("Jersey Number: " + jerseyNumber);
System.out.println("Earned Run Average: " + earnedRunAverage);
}
@Override
public void hitHomeRun() {
System.out.println(name + " hit a home run!"); // 이 메소드는 Pitcher에게 적용되지 않는 메소드
}
@Override
public void stealBase() {
System.out.println(name + " stole a base!"); // 이 메소드는 Pitcher에게 적용되지 않는 메소드
}
}
public class Main {
public static void main(String[] args) {
BaseballPlayer hitter = new Hitter("John Doe", 25, 0.320);
hitter.printPlayerInfo();
hitter.hitHomeRun();
hitter.stealBase();
BaseballPlayer pitcher = new Pitcher("Jane Smith", 10, 2.75);
pitcher.printPlayerInfo();
pitcher.hitHomeRun(); // 이 메소드는 Pitcher에게 적용되지 않는 메소드
pitcher.stealBase(); // 이 메소드는 Pitcher에게 적용되지 않는 메소드
}
}
[After]
// 각각의 역할에 맞는 작은 인터페이스로 분리
public interface PlayerInfo {
void printPlayerInfo();
}
public interface HittingAction {
void hitHomeRun();
}
public interface BaseRunningAction {
void stealBase();
}
// Hitter 클래스는 두 개의 인터페이스를 구현
public class Hitter implements PlayerInfo, HittingAction {
private String name;
private int jerseyNumber;
private double battingAverage;
public Hitter(String name, int jerseyNumber, double battingAverage) {
this.name = name;
this.jerseyNumber = jerseyNumber;
this.battingAverage = battingAverage;
}
@Override
public void printPlayerInfo() {
System.out.println("Name: " + name);
System.out.println("Jersey Number: " + jerseyNumber);
System.out.println("Batting Average: " + battingAverage);
}
@Override
public void hitHomeRun() {
System.out.println(name + " hit a home run!");
}
}
// Pitcher 클래스는 두 개의 인터페이스를 구현
public class Pitcher implements PlayerInfo, BaseRunningAction {
private String name;
private int jerseyNumber;
private double earnedRunAverage;
public Pitcher(String name, int jerseyNumber, double earnedRunAverage) {
this.name = name;
this.jerseyNumber = jerseyNumber;
this.earnedRunAverage = earnedRunAverage;
}
@Override
public void printPlayerInfo() {
System.out.println("Name: " + name);
System.out.println("Jersey Number: " + jerseyNumber);
System.out.println("Earned Run Average: " + earnedRunAverage);
}
@Override
public void stealBase() {
System.out.println(name + " stole a base!");
}
}
public class Main {
public static void main(String[] args) {
PlayerInfo hitter = new Hitter("John Doe", 25, 0.320);
hitter.printPlayerInfo();
((HittingAction) hitter).hitHomeRun();
PlayerInfo pitcher = new Pitcher("Jane Smith", 10, 2.75);
pitcher.printPlayerInfo();
((BaseRunningAction) pitcher).stealBase();
}
}
5. 의존성 역전 원칙 (Dependency Inversion Principle - DIP)
- 고수준 모듈은 저수준 모듈에 의존해서는 안되며, 모두 추상화에 의존해야 한다.
- 추상화를 통해 구체적인 구현에 대한 의존성을 낮추어 시스템을 더 유현하게 만든다.
- 고수준 모듈 : 의미있는 단일 기능을 제공, 상위 수준의 정책을 구현
저수준 모듈 : 고수준 모듈의 기능을 구현하기 위해 필요한 하위 기능의 실제 구현
[Before]
// BaseballPlayer 클래스가 구체적인 선수 유형에 직접 의존
public class BaseballGame {
private BaseballPlayer player;
public BaseballGame(BaseballPlayer player) {
this.player = player;
}
public void playGame() {
player.printPlayerInfo();
player.hitHomeRun();
}
}
public class Hitter implements BaseballPlayer {
// Hitter에 대한 구현
}
public class Pitcher implements BaseballPlayer {
// Pitcher에 대한 구현
}
public class Main {
public static void main(String[] args) {
BaseballPlayer hitter = new Hitter();
BaseballGame game1 = new BaseballGame(hitter);
game1.playGame();
BaseballPlayer pitcher = new Pitcher();
BaseballGame game2 = new BaseballGame(pitcher);
game2.playGame();
}
}
[After]
// BaseballPlayer 인터페이스를 도입하여 추상화에 의존하도록 변경
public interface BaseballPlayer {
void printPlayerInfo();
void play();
}
// Hitter 클래스는 BaseballPlayer 인터페이스를 구현
public class Hitter implements BaseballPlayer {
// Hitter에 대한 구현
@Override
public void printPlayerInfo() {
System.out.println("Hitter's information");
}
@Override
public void play() {
System.out.println("Hitter hits a home run!");
}
}
// Pitcher 클래스는 BaseballPlayer 인터페이스를 구현
public class Pitcher implements BaseballPlayer {
// Pitcher에 대한 구현
@Override
public void printPlayerInfo() {
System.out.println("Pitcher's information");
}
@Override
public void play() {
System.out.println("Pitcher throws a strikeout!");
}
}
public class Main {
public static void main(String[] args) {
BaseballPlayer hitter = new Hitter();
hitter.printPlayerInfo();
hitter.play();
BaseballPlayer pitcher = new Pitcher();
pitcher.printPlayerInfo();
pitcher.play();
}
}
# Design Pattern
디자인 패턴은 객체 지향 프로그래밍 설계 시 발생하는 문제를 효과적으로 해결하고 의사소통 도구로 활용하는 패턴이다.
검증된 구조로 이미 테스트된 디자인 패턴은 설계 과정의 속도를 높이고 재사용성을 높일 수 있지만, 불필요한 상황에서 적용하면 코드의 복잡성이 증가할 수 있다.
코드의 간결성은 개발 중 가장 중요한 요소이며, 디자인 패턴을 과도하게 사용하면 코드가 복잡해지고 유지보수가 어려워지는 문제가 발생할 수 있다. 그렇기 때문에 디자인 패턴을 적용하는 경우에 대해서는 신중히 고려해야한다.
# 디자인 패턴의 분류 및 종류
디자인 패턴은 23개로 나뉘어져 있는데 이것들을 크게 3가지로 분류 할 수 있다. 이것을 GoF(Gang of Four) 패턴이라고 부른다.
1. 생성 패턴 (Creational Pattern)
- 객체 생성에 관련된 패턴으로, 객체의 생성과 조합을 캡슐화해 특정 객체가 생성되거나 변경되어도 프로그램 구조에 영향을 크게 받지 않도록 유연성을 제공한다.
[종류]
- 싱글턴 (Singleton) : 하나의 인스턴스만을 생성하고, 어디에서든 접근
- 빌더 (Builder) : 객체의 생성과 표현을 분리
- 추상 팩토리 (Abstract Factory) : 구체적인 클래스에 의존하지 않고, 서로 관련되거나 의존적인 객체들의 조합을 제공하는 인터페이스를 생성
- 팩토리 메서드 (Factory Method) : 객체 생성을 서브 클래스에서도 처리할 수 있도록 캡슐화
- 프로토 타입 (Prototype) : 기존 객체를 복사하여 새로운 객체를 생성
.
2. 구조 패턴 (Structural Pattern)
- 클래스나 객체를 조합해 더 큰 구조를 만드는 패턴으로 예를 들어 서로 다른 인터페이스를 지닌 2개의 객체를 묶어 단일 인터페이스를 제공하거나 객체들을 서로 묶어 새로운 기능을 제공하는 패턴이다.
[종류]
- 어댑터 (Adapter) : 한 클래스의 인터페이스를 클라이언트에서 사용하고자 하는 다른 인터페이스로 변환
- 브릿지 (Bridge) : 구현과 추상을 분리하여 각자 독립적으로 변형이 가능한 구조를 제공
- 컴포지트 (Composite) : 객체들을 트리 구조로 구성하여 개별 객체와 복합 개체를 클라이언트에서 구분 없이 사용
- 데코레이터 (Decorator) : 객체의 결합을 통해 기능을 동적으로 유연하게 확장
- 퍼사드 (Facade) : 복잡한 서브 시스템에 대해 시스템을 사용하기 편하도록 상위 수준의 인터페이스를 제공
- 플라이웨이트 (Flyweight) : 동일한 것을 공유하여 메모리 절약 및 클래스 경량화
- 프록시 (Proxy) : 실체 객체에 대한 대리 객체로 특정 객체로의 접근을 제어하기 위한 용도로 사용
3. 행위 패턴 (Behavioral Pattern)
- 객체나 클래스 사이의 알고리즘이나 책임 분배에 관련된 패턴으로, 한 객체가 혼자 수행할 수 없는 작업을 여러 개의 객체로 분배, 객체 사이의 결합도를 최소화하는 것에 중점을 둔다.
[종류]
- 책임 연쇄 (Chain of Responsibility) : 여러 객체에게 책임을 부여하고, 이를 연결하여 요청이 처리될 때까지 각 객체에게 전달
- 커맨드 (Command) : 사용자가 보낸 요청을 나중에 재사용 할 수 있도록 객체의 형태로 캡슐화하여 클래스를 설계 (매개변수화, 큐에 저장, 로깅 및 연산 지원)
- 인터프리터 (Interpreter) : 문법 규칙을 클래스로 표현
- 이터레이터 (Iterator) : 내부 표현부를 노출하지 않고 어떤 객체 집합에 속한 원소들을 순차적으로 접근할 수 있는 방법을 제공
- 미디에이터 (Mediator) : 중재자라는 객체안에서 서로 다른 객체들을 캡슐화하여 객체들이 직접적으로 상호작용하지않고 중재자를 통해서만 커뮤니케이션하도록 처리, 객체간의 의존성을 줄임
- 메멘토 (Memento) : 객체의 정보를 저장할 필요가 있을 때 적용, Undo 기능을 개발할 때 사용
- 옵저버 (Observer) : 객체의 상태 변화에 따라 다수의 객체가 자동으로 갱신
- 스테이트 (State) : 객체의 상태에 따라 객체의 행위 내용을 변경
- 스트레티지 (Strategy) : 알고리즘군을 정의하고 각각을 캡슐화하여 교체
- 템플릿 메소드(Template Meothods) : 특정 작업을 처리하는 일부분을 서브 클래스로 캡슐화하여 전체적인 구조는 바꾸지 않으면서 특정 단계에서 수행하는 내용을 변경
- 비지터(Visitor) : 각 클래스 데이터 구조로부터 처리 기능을 분리하여 별도의 클래스를 만들어 놓고 해당 클래스의 메서드가 각 클래스를 돌아다니며 특정 작업을 수행
Reference
- https://www.devkuma.com/docs/design-pattern/intro-basic/
- https://pingpongdev.tistory.com/4
- https://velog.io/@poiuyy0420/디자인-패턴-개념과-종류
- https://velog.io/@haero_kim/SOLID-원칙-어렵지-않다
- https://velog.io/@harinnnnn/OOP-객체지향-5대-원칙SOLID-인터페이스-분리-원칙-ISP
'백엔드 > Spring Boot' 카테고리의 다른 글
롬복 @AllArgsConstructor, @NoArgsConstructor, @RequiredArgsConstructor 어노테이션 알아보기 (1) | 2024.01.15 |
---|---|
SOLID 원칙 (2) | 2024.01.10 |
[Spring Security] 세션 제어하기 (0) | 2024.01.04 |
[Spring Security] AnonymousAuthenticationFilter (1) | 2024.01.04 |
[Spring Security] Remember Me 구현하기 (0) | 2024.01.04 |