1. SRP ( 단일 책임 원칙 )
SRP 단일 책임 원칙 (Single Responsibility Principle)의 약자로, SOLID 원칙 중 하나입니다. 이 원칙은 클래스가 단 하나의 이유만으로 변경이 되어야 한다는 원칙을 의미합니다.
여기서 단 하나의 책임 이 말이 추상적이여서 아래 내용을 통해 구분해 보겠습니다.
기능적 책임 : 클래스가 수행해야 할 주요 동작이나 기능입니다. 주문 시스템에서 OrderService 클래스의 책임은 주문 생성, 추가, 조회등을 담당할 것 입니다.
변경의 원인 : 하나의 책임은 변경의 이유가 하나라는 것입니다. 예를 들어 데이터베이스가 변경 되었다고 Service 클래스가 영향을 받아서는 안됩니다.
예시로 주문 시스템을 생각해볼께요. 여기에는 주문과 주문내역을 인쇄하는 두 기능을 담당하는 클래스가 있다고 합시다.
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
public void addOrder(Order order) {
orderRepository.save(order);
}
public String generateOrderReceipt(Long orderId) {
Order order = orderRepository.findById(orderId).orElse(null);
return "Order Receipt for " + order.getDetails(); // 간단하게 주문 상세를 반환합니다.
}
}
위의 예에서 "OrderService" 는 주문을 관리하는 기능과 영수증을 관리하는 기능 모두를 담당하고 있습니다.
이렇게 되면 주문 기능이 변경될때 영수증 기능까지 영향을 받게 됩니다.
아래 코드는 SRP를 준수한 예시 입니다.
Entity
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String details;
// getters, setters, constructors ...
}
Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
}
OrderService
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
public Order addOrder(Order order) {
return orderRepository.save(order);
}
public Optional<Order> getOrder(Long id) {
return orderRepository.findById(id);
}
// 다른 비즈니스 로직들 ...
}
ReceiptService
@Service
public class ReceiptService {
@Autowired
private OrderRepository orderRepository;
public String generateReceipt(Long orderId) {
Optional<Order> optionalOrder = orderRepository.findById(orderId);
if (!optionalOrder.isPresent()) {
throw new IllegalArgumentException("Order not found!");
}
Order order = optionalOrder.get();
// 여기에서 실제 인쇄 로직이 들어갈 수 있습니다. 예시로는 문자열을 반환하겠습니다.
return "Receipt for Order ID: " + order.getId() + "\nDetails: " + order.getDetails();
}
}
이렇게 Spring Data JPA를 사용하면 데이터베이스 액세스 코드를 최소화하면서도 유연하게 데이터베이스 연산을 수행할 수 있습니다. 또한, 데이터베이스 스키마가 변경되더라도 OrderRepository 인터페이스와 연관된 부분만 확인하면 되기 때문에, OrderService와 같은 서비스 로직은 그 영향에서 상대적으로 자유롭습니다.
Controller
@RestController
@RequestMapping("/orders")
public class OrderController {
@Autowired
private OrderService orderService;
@Autowired
private ReceiptService receiptService;
@PostMapping
public Order createOrder(@RequestBody Order order) {
return orderService.addOrder(order);
}
@GetMapping("/{orderId}/receipt")
public String generateReceipt(@PathVariable Long orderId) {
return receiptService.generateReceipt(orderId);
}
}
Controller 에서 OrderService, ReceiptService를 모두 주입 받는 것이 SRP 위반한 것 처럼 보일 수 있는데요. 그러나 SRP 의 주요 관점은 "클래스가 변경되어야 하는 이유가 하나여야 한다" 입니다.
Controller의 주된 책임은 HTTP 요청을 적절한 서비스 로직으로 라우팅 하는 것 입니다. 따라서 OrderController 의 변경이유 ( 책임) 은 아래 사항에 해당 됩니다.
1. 새로운 주문 관련 HTTP endpoint가 필요할 때
2. 기존 주문 관련 HTTP endpoint 요청/응답 형식이 변경될 때
OrderService 나 ReceiptService의 내부 로직이 변경되어도, 컨트롤러에는 영향이 없습니다. 컨트롤러는 그저 서비스의 메서드를 호출할 뿐입니다.
그러므로, OrderController에서 여러 서비스를 주입받는 것은 SRP를 위반하지 않습니다. 이는 컨트롤러가 여러 서비스에 대한 요청을 라우팅하는 것이 그의 주된 책임이기 때문입니다.
다만, 만약 컨트롤러가 너무 많은 서비스를 주입받아서 여러 가지 다른 책임을 수행하기 시작한다면, 그때는 SRP 위반의 신호로 볼 수 있습니다. 이럴 때는 컨트롤러를 적절하게 분리하거나 리팩토링해야 할 필요가 있습니다.
2. OCP ( 개방 폐쇄 원칙)
OCP (Open Closed Priciple) 는 SOLID 원칙 중 하나 입니다. 간단히 설명해서 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다는 원칙입니다.
- 확장에 열려 있다 : 새로운 기능의 요구사항이나 변경 사항이 생길 경우, 기존 코드의 변경 없이 새로운 코드를 추가하여 기능을 확장 할 수 있어여 합니다.
- 변경에 닫혀 있다 : 기존 코드는 수정(변경) 하지 않아야 합니다. 즉, 새로운 기능이나 요구사항을 추가할 때, 기존 코드의 변경 없이 기존 코드가 수용할 수 있도록 설계 되어야 합니다.
아래 클래스 다이어그램은 개방폐쇄의 원칙을 준수합니다.
위 예시에는 인터페이스를 활용하여 OCP를 준수하였습니다. 만약 클라이언트의 요구 사항으로 다른 활인 정책이 들어와서 OrderService 소스는 변경이 되지 않고 확장이 가능합니다.
* 인터페이스만 OCP를 준수할 수 있는 것은 아닙니다. ENUM(enum 글참조) 다형성을 이용하여 변경에 닫혀 있게끔 만들수 있으며, 다른 디자인 패턴을 이용하여도 됩니다.
3. LSP (리스코프 치환 원칙)
LSP는 Liskov Substitution Principle(리스코프 치환 원칙)의 약자로, SOLID 원칙 중 하나입니다. LSP는 "프로그램의 정확성을 깨뜨리지 않으면서 하위 클래스의 객체를 상위 클래스의 객체로 치환할 수 있어야 한다"는 원칙을 의미합니다. 이는 다형성의 중요한 부분이기도 합니다.
다시 말해서, 부모클래스가 할 수 있는 행동은 자식 클래스도할 수 있어야 한다는 것입니다.
예를 들어, Animal 클래스가 있습니다.
public abstract class Animal {
public abstract String makeSound();
}
자식클래스로 cat 클래스가 있구요
public class Cat extends Animal {
@Override
public String makeSound() {
return "Meow!";
}
}
위 예제는 부모 클래스의 핵심 기능을 동물은 소리를 낸다 입니다. 만약, 자식 클래스가 특정 상황에서 소리를 내지 않는다라는 조건을 넣으면 LSP 위반이 됩니다.
아래처럼 말이죠, 위반 하지 않으려면 Cate에 있는 isMute 조건이 부모 클래스의 기능으로 들어가야 합니다.
엄격한 기준(계약 또는 규칙)은 부모클래스나 인터페이스에서 정의 하고, 그 계약을 상속 받거나 구현하는 클래스는 그 계약을 깨뜨리면 안됩니다. (계약 위반으로 벌금을 낼수도 있습니다)
public class Cat extends Animal {
private boolean isMute;
public Cat(boolean isMute) {
this.isMute = isMute;
}
@Override
public String makeSound() {
if (isMute) {
return ""; // 소리를 내지 않음
} else {
return "Woof!";
}
}
}
도형으로 다시 한번 더 예시를 만들어 볼께요
public abstract class Shape {
// 모든 도형은 면적을 계산할 수 있어야 한다.
public abstract double area();
}
public class Rectangle extends Shape {
private double width;
private double height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double area() {
return width * height;
}
}
public class Circle extends Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double area() {
return Math.PI * radius * radius;
}
}
* 상속이나 인터페이스 등은 그 상황에 맞게 사용해야 합니다. 예를 들어 공통의 구현이 필요한 경우에는 abstract class 를 사용하는 것이 좋습니다. 또한, 추상클래스의 경우와 interface (default 메서드로 바디 구현 가능)의 경우 비슷한 점이 많이만 문법상으로 틀립니다.
아래와 같은 경우 추상클래스로 작업하면 효과적입니다.
1. 인스턴스 변수(필드) 가 필요한 경우
2. 생성자가 필요한 경우
3. Object 클래스의 메소드 오버라이딩 하고 싶은경우
4. ISP (인터페이스 분리 원칙)
ISP는 "Interface Segregation Principle"의 약자로, "클래스는 자신이 사용하지 않는 인터페이스는 구현하지 않아야 한다."
즉, 너무 많은 기능이 포함된 인터페이스 보다는 특정 목적에 맞게 분리된 여러 개의 인터페이스가 좋다는 의미 입니다.
결론적으로, ISP는 인터페이스를 작고 특정한 목적에 맞게 잘게 분리하여 클래스가 불필요한 기능을 강제로 구현하지 않게 하는 원칙 입니다.
ISP를 위반하는 코드
public interface Printer {
void print(String message);
void scan(String message);
void copy(String message);
}
public class AllInOnePrinter implements Printer {
@Override
public void print(String message) {
// 인쇄기능
}
@Override
public void scan(String message) {
// 스캔기능
}
@Override
public void copy(String message) {
// 복사기능
}
}
public class SimplePrinter implements Printer {
@Override
public void print(String message) {
// 인쇄기능
}
@Override
public void scan(String message) {
// 이 프린터는 스캔 기능이 없습니다.
throw new UnsupportedOperationException("scan function is not supported");
}
@Override
public void copy(String message) {
// 이 프린터는 복사 기능이 없습니다.
throw new UnsupportedOperationException("copy function is not supported");
}
}
ISP 준수 코드
public interface Printer {
void print(String message);
}
public interface Scan {
void scan(String message);
}
public interface Copy {
void copy(String message);
}
public class AllInOnePrinter implements Printer, Scan, Copy {
@Override
public void print(String message) {
// 인쇄기능
}
@Override
public void scan(String message) {
// 스캔기능
}
@Override
public void copy(String message) {
// 복사기능
}
}
public class SimplePrinter implements Printer {
@Override
public void print(String message) {
// 인쇄기능
}
}
ISP를 따르는 예에서는 각 기능을 독립된 인터페이스로 분리하였습니다. SimplePrinter 클래스는 Printer 인터페이스만 구현 하면 되므로 불필요한 메서드를 강제 구현 하지 않아도 됩니다.
5. DIP (의존 역전 원칙)
해당 내용은 이미 의존성에서 다루었는데요. 개방폐쇠의 원칙과 상당히 비슷하다고 생각이 들수 있습니다.
하지만 개방폐쇄는 확정에는 열려 있어야 되고 변경에는 닫혀 있어야 된다는 개념입니다. 구현 클래스에서는 어떤 변경도 이루어 지지 않는거죠 , DIP는 고수준 컴포넌트는 저수준 컴포넌트를 의존하지 않는다는 개념입니다. 둘다 인터페이스를 활용하여 다룬다는 점에서는 상당히 유사하지만 다릅니다.
이미 DIP는 앞에서 내용에 대해 언급 했으니, 간접적으로 의존 역전이 깨지는 상황을 만들어 해결 방안을 공부 할께요
public interface Repository {
void save(String message);
}
public class DataBaseRepository implements Repository {
@Override
public void save(String message) {
if (message == null) {
throw new RecordNotFoundException("레코드를 찾을 수 없습니다.");
}
}
}
public class FileRepository implements Repository {
@Override
public void save(String message) {
// 파일에 저장하는 기능
if (message == null) {
throw new RecordNotFoundException("레코드를 찾을 수 없습니다.");
}
}
}
public class RecordNotFoundException extends RuntimeException {
public RecordNotFoundException(String message) {
super(message);
}
}
public class TextNotFoundException extends RuntimeException {
public TextNotFoundException(String message) {
super(message);
}
}
public class DataService {
private final Repository repository;
public DataService(Repository repository) {
this.repository = repository;
}
public void save(String message) {
try {
repository.save(message);
} catch (RecordNotFoundException recordNotFoundException) {
System.out.println(recordNotFoundException.getMessage());
} catch (TextNotFoundException textNotFoundException) {
System.out.println(textNotFoundException.getMessage());
}
}
}
위 코드를 보시면 uncheckException을 발생 시키고 있습니다. 개방폐쇠의 원칙이나 DIP 원칙을 대입하여 생각하면 DataService에서는 이미 소스의 수정이 발생합니다. 예를 들어 MemoryRepository가 새로 생기고 예외를 MemoryNotFoundException 을 던진다면 말이죠. DataService는 이미 Repository 에서 어떤 것을 하는지 너무 잘 아는 상태가 되어 버린겁니다.
일반화된 예외 클래스 사용 : 특정 클래스에 종속적이지 않은 예외를 만들어 사용합니다.
public interface Repository {
void save(String message) throws DataException;
}
public class DataException extends RuntimeException {
public DataException(String message) {
super(message);
}
}
public class DataBaseRepository implements Repository {
@Override
public void save(String message) throws DataException {
if (message == null) {
throw new DataException("레코드를 찾을 수 없습니다.");
}
// DB 저장 로직
}
}
public class FileRepository implements Repository {
@Override
public void save(String message) throws DataException {
if (message == null) {
throw new DataException("레코드를 찾을 수 없습니다.");
}
// 파일 저장 로직
}
}
public class DataService {
private final Repository repository;
public DataService(Repository repository) {
this.repository = repository;
}
public void save(String message) {
try {
repository.save(message);
} catch (DataException e) {
System.out.println(e.getMessage());
}
}
}
위의 수정을 통해, 새로운 저장소를 추가하더라도 DataService는 변경할 필요가 없게 되었습니다. 모든 저장소는 일반화된 DataException을 사용하여 예외를 던지기 때문에 DataService는 이 예외만 처리하면 됩니다.
'자바' 카테고리의 다른 글
슬기롭게 주석 사용하기 (0) | 2023.12.12 |
---|---|
순회 하면서 컬렉션 수정하지 않기 (0) | 2023.12.12 |
의존성 ( dependency ) (0) | 2023.12.12 |
if문 제거하기 (0) | 2023.12.12 |
Java Optional (0) | 2023.12.12 |