목차
1. 소프트웨어 아키텍쳐 설계 가이드라인 & 원칙
2. 객체 지향(OO) 설계 원칙
- SOLID OO 설계 원칙
- GRASP OO 설계 원칙
소프트웨어 아키텍쳐 설계 가이드라인
소프트웨어 아키텍쳐를 설계(Design)할때 놓치기 쉬운 부분은 Analysis 먼저하고 아키텍쳐 Design 해야된다는 것입니다.
아키텍쳐 분석 -> 추상적 아키텍처 설계 -> 아키텍쳐 설계
위와 같이 분석과 추상 아키텍쳐 설계를 우선 거치고 설계해야됩니다.
1. 아키텍쳐 분석
어떻게 할지 생각하기 전에 무엇을 할지 생각해보는게 중요합니다.
아키텍쳐와 상세적인 설계전에 기능적 요구사항(FR)과 비기능적 요구사항(NFR)이 정의되고 확인(verify), 인증(Validated)되어야합니다.
(요구사항의 verification은 SRS 명세서와 일치하는지, validation은 고객등 이해관계자(stakeholder)의 요구사항과 일치하는것을 말합니다)
2. 추상적 아키텍처 설계
구체적인 설계를 생각해보기전에 추상적인 설계를 생각해야합니다.
항상 컴포넌트의 인터페이스와 추상적 데이터 타입을 명세하는 추상적 설계에서 시작해야됩니다.
설계하기전에 비기능적 요구사항을 먼저 생각해야합니다.
아키텍쳐 설계에 기능적 요구사항을 적용한다면 비기능적 요구사항도 역시 고려해야합니다.
3. 재사용과 변경 고려
소프트웨어 재사용성과 확장성을 최대한 고려해야됩니다.
기존에 존재하는 소프트웨어 컴포넌트를 재사용해서 신뢰성과 비용 효과성을 최대화 하려고 해야됩니다.
각 컴포넌트의 높은 응집도와 다른 컴포넌트간의 낮은 연결성을 유지하기 위해서 노력해야됩니다.
(High Cohesion, Loose Coupling)
4. 프로토타입 정제하며, 모호/과도한 설계를 지양해야됩니다
디자인의 정제를 허용해야됩니다
디자인의 정제를 허용하는 것은 소프트웨어 설계 과정에서 초기 설계가 완벽하지 않음을 인정하고, 필요에 따라 지속적으로 설계를 개선하고 수정할 수 있는 유연한 태도를 의미합니다. 이를 통해 더 나은 설계와 구현을 도출할 수 있으며, 시스템의 유연성과 유지보수성을 향상시킬 수 있습니다.
모호한 디자인과 과도한 세부 설계를 피하세요.
디자인의 정제를 효과적으로 수행하기 위해서는 모호한 디자인과 과도한 세부 설계를 피하는 것이 중요합니다
아키텍쳐 설계 원칙
SOLID 원칙
SOLID는 객체 지향 설계를 더욱 이해하기 쉽고, 유연하며, 유지보수 가능하게 만들기 위한 다섯 가지 설계 원칙을 뜻합니다. 각각의 원칙은 소프트웨어 디자인에서 발생할 수 있는 문제를 해결하고, 더 나은 구조를 갖추기 위해 고안되었습니다.
SIP 제외하고는 전부 인터페이스 관련 원칙입니다.
단일 책임 원칙 (Single Responsibility Principle, SRP):
설명: 클래스는 단 하나의 책임만 가져야 하며, 하나의 기능만을 수행해야 합니다. 즉, 클래스가 변경되는 이유가 오직 하나여야 합니다.
목표: 클래스가 다양한 기능을 담당하면 변경할 때마다 서로 다른 이유로 수정이 필요해질 수 있습니다. SRP를 지키면 유지보수가 쉬워집니다.
예시) Employee 클래스가 정보 출력까지 책임이 있으면 SIP 위반이므로 출력 기능을 다른 클래스로 책임을 넘겨줘서 지킨 예입니다.
// SRP: Employee 클래스는 직원 정보만 담당
public class Employee {
private String name;
private String position;
public Employee(String name, String position) {
this.name = name;
this.position = position;
}
public String getName() {
return name;
}
public String getPosition() {
return position;
}
}
// 책임이 다른 클래스를 생성하여 출력을 담당
public class EmployeePrinter {
public void print(Employee employee) {
System.out.println("Name: " + employee.getName() + ", Position: " + employee.getPosition());
}
}
개방-폐쇄 원칙 (Open Closed Principle, OCP):
설명: 소프트웨어 객체는 확장에는 열려 있어야 하고, 수정에는 닫혀 있어야 합니다. 즉, 새로운 기능을 추가할 때 기존 코드를 수정하지 않고도 확장이 가능해야 합니다.
목표: 새로운 기능을 쉽게 추가하면서도 기존 코드의 변경을 최소화하여 안정성을 유지할 수 있습니다.
예시) Execute 인터페이스를 이용해서 Drawer, Door등으로 자유롭게 확장에 열러있지만, 수정은 구현체 객체만 수정하면되므로 인터페이스의 수정에는 닫혀있습니다.
// OCP: Execute 인터페이스를 통해 새로운 기능 추가 시 기존 코드를 수정하지 않음
public interface Executable {
void execute();
}
// Hand 클래스가 다양한 객체를 실행할 수 있도록 함
public class Hand {
public void performAction(Executable executable) {
executable.execute();
}
}
// Refrigerator 클래스
public class Refrigerator implements Executable {
@Override
public void execute() {
System.out.println("Opening the refrigerator.");
// 냉장고 열기 관련 로직 추가
}
}
// Drawer 클래스
public class Drawer implements Executable {
@Override
public void execute() {
System.out.println("Opening the drawer.");
// 서랍 열기 관련 로직 추가
}
}
// Door 클래스
public class Door implements Executable {
@Override
public void execute() {
System.out.println("Opening the door.");
// 문 열기 관련 로직 추가
}
}
리스코프 치환 원칙 (Liskov Substitution Principle, LSP):
설명: 자식 클래스는 언제나 부모 클래스를 대체할 수 있어야 합니다. 즉, 부모 클래스 타입으로 자식 클래스를 사용해도 프로그램의 동작에 문제가 없어야 합니다.
목표: 상속을 제대로 사용해 부모 클래스와 자식 클래스 간의 일관성을 유지하고, 코드의 재사용성과 확장성을 높입니다.
예) 새 추상클래스를 만들고 새 종류별로 자식 클래스를 만들었습니다. 부모클래스인 새에 다른 자식클래스를 넣어도 코드가 원활히 돌아가는걸 확인할 수 있습니다.
public class Main {
public static void main(String[] args) {
Bird mySparrow = new Sparrow();
Bird myOstrich = new Ostrich();
// Bird 타입으로 Sparrow와 Ostrich를 다룰 수 있음
moveBird(mySparrow);
moveBird(myOstrich);
}
public static void moveBird(Bird bird) {
bird.move(); // bird 객체에 따라 적절한 move 메서드 호출
}
}
// LSP: Subclass가 Parent Class의 행동을 유지
public class Bird {
public void fly() {
System.out.println("Flying...");
}
}
// LSP를 따르기 위해 Bird를 추상 클래스로 변경
public abstract class Bird {
public abstract void move();
}
public class Sparrow extends Bird {
@Override
public void move() {
System.out.println("Sparrow flying...");
}
}
public class Ostrich extends Bird {
@Override
public void move() {
System.out.println("Ostrich running...");
}
}
인터페이스 분리 원칙 (Interface Segregation Principle, ISP):
설명: 클래스는 자신이 사용하지 않는 인터페이스에 의존하지 않아야 합니다. 즉, 하나의 큰 인터페이스보다는 여러 개의 작은 인터페이스로 나누는 것이 좋습니다.
목표: 인터페이스가 너무 크면 불필요한 의존성이 생기기 때문에, 작고 특화된 인터페이스를 사용하여 의존성을 줄이고 유지보수를 용이하게 합니다.
예) 클래스들이 자신이 사용하지 않는 기능을 담은 인터페이스를 의존 받지 않기 위해서 인터페이스를 기능별로 더 쪼개서(Segregate) 사용합니다.
// Animal 인터페이스를 분리
public interface Eatable {
void eat();
}
public interface Flyable {
void fly();
}
// Bird 클래스
public class Bird implements Eatable, Flyable {
@Override
public void eat() {
System.out.println("Bird is eating.");
}
@Override
public void fly() {
System.out.println("Bird is flying.");
}
}
// Dog 클래스
public class Dog implements Eatable {
@Override
public void eat() {
System.out.println("Dog is eating.");
}
}
// 사용 예시
public class Main {
public static void main(String[] args) {
Eatable myDog = new Dog();
myDog.eat(); // Dog is eating.
Eatable myBird = new Bird();
myBird.eat(); // Bird is eating.
}
}
의존성 역전 원칙 (Dependency Inversion Principle, DIP):
설명: 고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 둘 다 추상화된 인터페이스에 의존해야 합니다. 즉, 구체적인 구현이 아닌, 추상화된 인터페이스나 상위 수준의 개념에 의존해야 합니다.
목표: 모듈 간의 의존성을 낮추고, 변경이 발생해도 시스템의 다른 부분에 미치는 영향을 최소화합니다.
예) NotificationService는 EmailSender에 직접 의존합니다. 이로 인해 다른 메시지 전송 방법을 추가하기 어렵습니다.
DIP를 적용하여 MessageSender 인터페이스를 생성하고, NotificationService가 이 인터페이스에 의존하도록 변경했습니다. 이제 EmailSender와 SmsSender를 쉽게 추가할 수 있습니다.
// 메시지 전송 인터페이스
public interface MessageSender {
void send(String message);
}
// EmailSender 클래스
public class EmailSender implements MessageSender {
@Override
public void send(String message) {
System.out.println("Sending email: " + message);
}
}
// NotificationService 클래스
public class NotificationService {
private MessageSender messageSender;
public NotificationService(MessageSender messageSender) {
this.messageSender = messageSender;
}
public void notify(String message) {
messageSender.send(message);
}
}
// 사용 예시
public class Main {
public static void main(String[] args) {
MessageSender sender = new EmailSender();
NotificationService service = new NotificationService(sender);
service.notify("Hello!"); // Sending email: Hello!
}
}
다른 예시 : 프록시 객체
DIP: Main 클래스는 Image 인터페이스에 의존하며, RealImage와 ProxyImage는 인터페이스에 대한 의존성을 통해 구현됩니다. 이 구조는 코드의 유연성과 확장성을 높이며, 실제 객체와 프록시 객체 간의 의존성을 잘 관리합니다.
// 실제 객체의 인터페이스
public interface Image {
void display();
}
// 실제 객체
public class RealImage implements Image {
private String filename;
public RealImage(String filename) {
this.filename = filename;
loadImageFromDisk();
}
private void loadImageFromDisk() {
System.out.println("Loading " + filename);
}
@Override
public void display() {
System.out.println("Displaying " + filename);
}
}
// 프록시 객체
public class ProxyImage implements Image {
private RealImage realImage;
private String filename;
public ProxyImage(String filename) {
this.filename = filename;
}
@Override
public void display() {
if (realImage == null) {
realImage = new RealImage(filename); // 필요할 때만 실제 객체 생성
}
realImage.display();
}
}
// 사용 예시
public class Main {
public static void main(String[] args) {
Image image1 = new ProxyImage("image1.jpg");
image1.display(); // 처음 호출 시 이미지 로드
image1.display(); // 이미 로드된 이미지 표시
}
}
DI(의존성 주입) 컨테이너 예시
- 객체 생성: DI 컨테이너는 클래스의 인스턴스를 자동으로 생성합니다. 개발자는 객체 생성에 대한 코드를 작성할 필요가 없습니다.
- 의존성 관리: DI 컨테이너는 각 객체가 필요로 하는 의존성을 자동으로 주입합니다. 이를 통해 객체 간의 의존 관계를 명확하게 정의할 수 있습니다.
- 생명 주기 관리: DI 컨테이너는 객체의 생명 주기를 관리합니다. 예를 들어, 싱글턴(Singleton)이나 프로토타입(Prototype) 등 객체의 생명 주기를 설정할 수 있습니다.
// 사용 예시
public class Main {
public static void main(String[] args) {
Service service = DIContainer.getService(); // DI 컨테이너를 통해 서비스 인스턴스 가져오기
Client client = new Client(service); // 서비스 주입
client.doSomething(); // Real Service 실행
}
}
GRASP 원칙
GRASP(General Responsibility Assignment Software Patterns) 디자인 원칙은 소프트웨어 설계 시 책임 할당 및 클래스 간의 관계를 정의하는 데 도움을 주는 원칙들입니다. 각 원칙은 소프트웨어의 품질을 높이고 유지보수성을 향상시키는 데 기여합니다. 아래에서 각 원칙에 대해 설명하겠습니다.
1. Low Coupling (낮은 결합도)
- 정의: 클래스 간의 결합도를 낮추어 서로 독립적으로 변경할 수 있도록 하는 원칙입니다.
- 설명: 결합도가 낮으면 한 클래스의 변경이 다른 클래스에 미치는 영향을 최소화할 수 있습니다. 이를 통해 시스템의 유지보수성과 재사용성을 높일 수 있습니다. 낮은 결합도를 유지하기 위해서는 인터페이스를 사용하거나 의존성 주입을 활용하는 것이 좋습니다.
아래는 중간에 인터페이스를 이용해서 Printer와 User간의 결합을 끊어낸 예시입니다.
// 인터페이스 정의
public interface Printer {
void print(String message);
}
// 구현 클래스
public class ConsolePrinter implements Printer {
@Override
public void print(String message) {
System.out.println(message);
}
}
// 사용자 클래스
public class User {
private Printer printer;
public User(Printer printer) {
this.printer = printer; // 의존성 주입
}
public void printMessage(String message) {
printer.print(message); // 낮은 결합도
}
}
// 사용 예시
public class Main {
public static void main(String[] args) {
Printer printer = new ConsolePrinter();
User user = new User(printer);
user.printMessage("Hello, Low Coupling!");
}
}
2. High Cohesion (높은 응집도)
- 정의: 클래스 내의 메서드와 속성이 밀접하게 관련되어 있어 하나의 명확한 책임을 갖는 원칙입니다.
- 설명: 높은 응집도를 가진 클래스는 특정한 역할이나 기능에 집중하므로 이해하기 쉽고 유지보수가 용이합니다. 응집도를 높이기 위해 관련된 기능을 동일한 클래스에 모아 두는 것이 좋습니다.
아래는 높은 응집도를 위해서 비슷한 기능(메서드)가 한 클래스에 모여있는것을 확인할 수 있습니다.
public class Calculator {
// 여러 관련된 메서드가 하나의 클래스에 모여 있음
public int add(int a, int b) {
return a + b;
}
public int subtract(int a, int b) {
return a - b;
}
public int multiply(int a, int b) {
return a * b;
}
public int divide(int a, int b) {
return a / b;
}
}
// 사용 예시
public class Main {
public static void main(String[] args) {
Calculator calculator = new Calculator();
System.out.println(calculator.add(5, 3)); // 8
}
}
3. Expert (정보 전문가)
- 정의: 특정 작업을 수행하는 데 필요한 정보를 가장 잘 알고 있는 클래스를 그 작업의 책임을 할당하는 원칙입니다.
- 설명: 정보 전문가 원칙에 따라 책임을 할당하면 데이터와 행동이 함께 묶여 코드의 응집도를 높일 수 있습니다. 예를 들어, 특정 데이터와 관련된 비즈니스 로직이 해당 데이터를 관리하는 클래스에 위치하게 합니다.
Order가 왜 무조건 주문 가격을 계산하는 책임이 있어야하는지 의문을 품을 수 있지만, Order 객체는 필요한 정보를 가장 잘 알고있는 Expert 객체 이기 때문에 Expert 객체인 Order에 계산하는 책임을 넘겨주는것이 올바른 예시입니다.
public class Order {
private double price;
public Order(double price) {
this.price = price;
}
// 주문의 가격을 계산하는 책임을 Order 클래스에 할당
public double calculateTotal(double taxRate) {
return price + (price * taxRate);
}
}
// 사용 예시
public class Main {
public static void main(String[] args) {
Order order = new Order(100);
double total = order.calculateTotal(0.1); // 110.0
System.out.println(total);
}
}
4. Creator (생성자)
- 정의: 객체의 생성 책임을 적절한 클래스로 할당하는 원칙입니다. (포함관계를 지키는게 핵심입니다)
- 설명: 생성자 원칙은 특정 객체를 생성할 책임을 관련된 객체에게 할당하는 것입니다. 예를 들어, 클래스 A가 클래스 B를 포함하고 있다면 A가 B를 생성하는 것이 바람직합니다. 이렇게 함으로써 책임이 명확해지고 코드의 이해도가 높아집니다.
객체를 생성하는 책임을 어떠한 객체를 가질지에 관한 내용인데, 포함관계에 맞춰서 상위 객체가 자신에게 포함된 객체를 생성해줘야합니다.
아래 경우에는 차가 엔진을 포함하고 있는 관계이기 때문에 엔진이 차를 생성해서 의존하는 것이 아닌,
차가 엔진을 생성하고 의존하고 있는 예시입니다.
public class Car {
private Engine engine;
// Engine을 생성하는 책임을 Car 클래스에 할당
public Car() {
this.engine = new Engine();
}
}
class Engine {
public Engine() {
System.out.println("Engine created!");
}
}
// 사용 예시
public class Main {
public static void main(String[] args) {
Car car = new Car(); // "Engine created!" 출력
}
}
5. Controller (제어자)
- 정의: 시스템의 주요 흐름을 제어하는 역할을 하는 클래스를 정의하는 원칙입니다.
- 설명: 제어자는 사용자의 입력을 받고, 적절한 비즈니스 로직을 수행한 후 결과를 반환하는 역할을 합니다. 이를 통해 비즈니스 로직과 사용자 인터페이스의 결합을 줄이고, 시스템의 구조를 명확히 할 수 있습니다.
Main과 UserService간의 비즈니즈 로직이 일어날때 컨트롤러를 거쳐서 로직을 구현한 예시입니다.
public class UserController {
private UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
public void createUser(String name) {
userService.saveUser(name); // 비즈니스 로직을 호출
}
}
class UserService {
public void saveUser(String name) {
System.out.println("User " + name + " saved!");
}
}
// 사용 예시
public class Main {
public static void main(String[] args) {
UserService userService = new UserService();
UserController userController = new UserController(userService);
userController.createUser("Alice"); // "User Alice saved!" 출력
}
}
추가) Don’t Talk to Strangers (낯선 사람과 대화하지 말 것)
- 정의: 클래스는 자신의 직접적인 관계에 있는 객체와만 상호작용하도록 해야 한다는 원칙입니다.
- 설명: 이 원칙은 객체 간의 관계를 명확하게 하고, 서로 다른 객체가 간접적으로 상호작용하는 것을 피함으로써 결합도를 낮추고 코드의 가독성을 높이는 데 기여합니다. 클래스가 직접적으로 알지 못하는 객체와 상호작용할 필요가 있을 경우, 해당 객체를 참조할 수 있는 중재자(예: 인터페이스)를 사용하는 것이 좋습니다.
사람(Main)이 자동차 엔진을 시동걸고 싶을때, 사람과 엔진은 Strainger이기 때문에 엔진에게 바로 시동거는 메세지를 보내는 것이 아닌, 차에게 메세지를 보내서 차가 대리로 시동거는 메소드를 호출하고 있는 모습입니다.
class Engine {
public void start() {
System.out.println("Engine started!");
}
}
class Car {
private Engine engine; // 직접적인 의존성을 가짐
public Car() {
this.engine = new Engine();
}
public void startCar() {
engine.start(); // Engine과 직접 상호작용
}
}
// 사용 예시
public class Main {
public static void main(String[] args) {
Car car = new Car();
car.startCar(); // "Engine started!" 출력
}
}
'CS 지식 > 소프트웨어 공학&아키텍쳐' 카테고리의 다른 글
시스템 모델링(System Modeling)과 관점에 따른 모델 종류 (0) | 2024.10.16 |
---|---|
요구사항 공학(Requirements Engineering) 프로세스 (0) | 2024.10.11 |
요구사항 공학(Requirements Engineering) 특성과 유형 (0) | 2024.10.08 |
애자일 소프트웨어 개발 (Agile Software Development) 방법론(2) - PM, 스크럼(Scrum) (0) | 2024.10.08 |
애자일 소프트웨어 개발 (Agile Software Development) 방법론(1) - 기법 (0) | 2024.10.08 |
댓글