객체지향 설계에서는 코드의 변경과 확장에 유연하게 대응하기 위한 여러 원칙이 있다.
대표적인 것이 SOLID 원칙이다.
S → Single Responsibility Principle
O → Open-Closed Principle
L → Liskov Substitution Principle
I → Interface Segregation Principle
D → Dependency Inversion Principle
이번에는 그중 SRP와 OCP를 알아본다.
1. SRP - 단일 책임 원칙
SRP는 Single Responsibility Principle, 즉 단일 책임 원칙이다.
하나의 클래스는 하나의 책임만 가져야 한다.
여기서 책임은 단순히 메서드를 하나만 가져야 한다는 의미가 아니다.
조금 더 정확하게는
하나의 클래스는 하나의 이유로만 변경되어야 한다.
라고 이해하는 것이 좋다.
2. SRP가 필요한 이유
회원 관련 기능을 담당하는 클래스를 만든다고 해보자.
public class UserService {
public void register(User user) {
// 회원 저장
}
public void sendEmail(User user) {
// 가입 이메일 전송
}
public void createReport(User user) {
// 회원 통계 보고서 생성
}
}
현재 UserService는 여러 가지 일을 담당하고 있다.
UserService
├─ 회원 관리
├─ 이메일 전송
└─ 보고서 생성
이메일 전송 방식이 변경되면 UserService를 수정해야 한다.
보고서 형식이 변경되어도 UserService를 수정해야 한다.
회원 정책이 변경되어도 마찬가지다.
회원 정책 변경
→ UserService 수정
Email 정책 변경
→ UserService 수정
Report 정책 변경
→ UserService 수정
하나의 클래스가 여러 가지 이유로 변경되고 있다.
이는 SRP를 지키지 않은 구조이다.
3. SRP 적용
각 책임을 별도의 클래스로 분리할 수 있다.
public class UserService {
public void register(User user) {
// 회원 저장
}
}
public class EmailService {
public void sendWelcomeEmail(User user) {
// 가입 이메일 전송
}
}
public class UserReportService {
public void createReport(User user) {
// 회원 보고서 생성
}
}
이제 각각의 클래스가 서로 다른 책임을 담당한다.
UserService
→ 회원 관리
EmailService
→ 이메일 전송
UserReportService
→ 보고서 생성
이메일 전송 방식이 변경되면 EmailService만 수정하면 되고, 회원 관리 방식이 변경되면 UserService만 수정하면 된다.
즉 변경의 영향 범위를 줄일 수 있다.
SRP의 핵심은 다음과 같다.
하나의 클래스에 여러 역할을 넣지 않고
변경되는 이유를 기준으로
책임을 분리한다
4. OCP - 개방-폐쇄 원칙
OCP는 Open-Closed Principle, 즉 개방-폐쇄 원칙이다.
소프트웨어 요소는 확장에는 열려 있고 변경에는 닫혀 있어야 한다.
쉽게 말하면
새로운 기능 추가
→ 가능해야 함
기존 코드 수정
→ 최대한 하지 않아야 함
이라는 의미다.
5. OCP가 필요한 이유
결제 서비스를 만들어보자.
처음에는 카드 결제만 지원한다고 가정한다.
public class PaymentService {
public void pay(String type, int amount) {
if (type.equals("CARD")) {
System.out.println("카드 결제 " + amount);
}
}
}
여기에 카카오페이가 추가된다면 기존 코드를 수정해야 한다.
public void pay(String type, int amount) {
if (type.equals("CARD")) {
System.out.println("카드 결제 " + amount);
}
if (type.equals("KAKAO")) {
System.out.println("카카오페이 결제 " + amount);
}
}
네이버페이까지 추가된다면 또 수정해야 한다.
if (type.equals("NAVER")) {
System.out.println("네이버페이 결제 " + amount);
}
결제 방식이 늘어날 때마다 다음 작업이 반복된다.
새로운 결제 방식 추가
↓
PaymentService 수정
↓
if문 추가
즉 기능을 확장할 때마다 기존 핵심 코드를 계속 수정해야 한다.
6. OCP 적용
먼저 결제 방식이 따라야 할 공통 규칙을 만든다.
public interface Payment {
String getType();
void pay(int amount);
}
getType()은 이 구현체가 어떤 결제 방식을 담당하는지 알려주는 역할을 한다.
카드 결제를 구현한다.
@Component
public class CardPayment implements Payment {
@Override
public String getType() {
return "CARD";
}
@Override
public void pay(int amount) {
System.out.println("카드 결제 " + amount);
}
}
카카오페이도 같은 Payment를 구현한다.
@Component
public class KakaoPayment implements Payment {
@Override
public String getType() {
return "KAKAO";
}
@Override
public void pay(int amount) {
System.out.println("카카오페이 결제 " + amount);
}
}
현재 구조는 다음과 같다.
Payment
↑
┌──────┴──────┐
│ │
CardPayment KakaoPayment
CARD KAKAO
각 객체가 자신이 담당하는 결제 방식을 알고 있다.
7. PaymentService는 결제 방식을 어떻게 구분할까
Spring에서는 Payment를 구현한 객체들을 한꺼번에 주입받을 수 있다.
@Service
public class PaymentService {
private final Map<String, Payment> paymentMap;
public PaymentService(List<Payment> payments) {
this.paymentMap = payments.stream()
.collect(Collectors.toMap(
Payment::getType,
payment -> payment
));
}
public void pay(String type, int amount) {
Payment payment = paymentMap.get(type);
if (payment == null) {
throw new IllegalArgumentException("지원하지 않는 결제 방식");
}
payment.pay(amount);
}
}
Spring이 다음 두 객체를 발견했다고 해보자.
CardPayment
getType() → CARD
KakaoPayment
getType() → KAKAO
그러면 PaymentService 내부에서는 개념적으로 다음과 같은 Map이 만들어진다.
paymentMap
CARD → CardPayment
KAKAO → KakaoPayment
따라서
paymentService.pay("CARD", 10000);
이라는 요청이 들어오면
CARD
↓
paymentMap에서 CARD 검색
↓
CardPayment 선택
↓
CardPayment.pay()
↓
카드 결제 실행
이 된다.
반대로
paymentService.pay("KAKAO", 10000);
이라는 요청이 들어오면
KAKAO
↓
paymentMap에서 KAKAO 검색
↓
KakaoPayment 선택
↓
KakaoPayment.pay()
↓
카카오페이 결제 실행
이 된다.
즉 String type이 완전히 사라진 것이 아니다.
차이는 PaymentService가 직접
if CARD
if KAKAO
if NAVER
를 하나씩 판단하지 않는다는 것이다.
8. 새로운 결제 방식이 추가된다면
이제 네이버페이를 추가한다고 해보자.
새로운 구현체를 추가한다.
@Component
public class NaverPayment implements Payment {
@Override
public String getType() {
return "NAVER";
}
@Override
public void pay(int amount) {
System.out.println("네이버페이 결제 " + amount);
}
}
Spring은 이 객체도 Payment 구현체로 인식한다.
그러면 기존 PaymentService의 코드를 수정하지 않아도 다음과 같이 등록된다.
paymentMap
CARD → CardPayment
KAKAO → KakaoPayment
NAVER → NaverPayment
따라서
paymentService.pay("NAVER", 10000);
을 호출하면 NaverPayment가 선택된다.
전체 구조는 다음과 같다.
Payment
↑
┌───────────┼───────────┐
│ │ │
CardPayment KakaoPayment NaverPayment
│ │ │
CARD KAKAO NAVER
새로운 결제 방식이 추가되어도 기존 PaymentService의 결제 로직은 수정하지 않는다.
새로운 구현체를 추가하는 방식으로 기능을 확장할 수 있다.
이것이 OCP의 핵심이다.
확장에는 Open
새로운 Payment 구현체를
계속 추가할 수 있음
변경에는 Closed
기존 PaymentService의
핵심 로직은 수정하지 않음
9. 다형성은 어디에서 사용되는가
PaymentService에서 다음 코드를 보면
Payment payment = paymentMap.get(type);
payment.pay(amount);
변수의 타입은 Payment이다.
하지만 실제 들어있는 객체는 요청에 따라 달라진다.
CARD 요청
Payment payment
↓
CardPayment 객체
↓
CardPayment.pay()
KAKAO 요청
Payment payment
↓
KakaoPayment 객체
↓
KakaoPayment.pay()
같은
payment.pay(amount);
를 호출해도 실제 객체에 따라 다른 pay()가 실행된다.
이것이 객체지향의 다형성이다.
OCP는 이런 다형성과 추상화를 활용해 새로운 구현체를 추가하기 쉬운 구조를 만든다.
10. SRP와 OCP의 차이
SRP와 OCP는 서로 다른 문제에 집중한다.
SRP는
하나의 클래스가
너무 많은 일을 하고 있지 않은가
를 본다.
예를 들어
UserService
회원 저장
Email 전송
Report 생성
처럼 책임이 섞여 있다면
UserService
EmailService
ReportService
처럼 책임을 나눈다.
반면 OCP는
새로운 기능이 추가될 때마다
기존 코드를 계속 수정하고 있지 않은가
를 본다.
기존 구조가
if CARD
if KAKAO
if NAVER
if TOSS
처럼 계속 변경되어야 한다면
Payment
├─ CardPayment
├─ KakaoPayment
├─ NaverPayment
└─ TossPayment
처럼 구현체를 추가하는 방향으로 설계할 수 있다.
11. SRP와 OCP는 함께 사용될 수 있다
두 원칙은 서로 다른 원칙이지만 실제 설계에서는 함께 적용되는 경우가 많다.
예를 들어 PaymentService 하나에서
결제 처리
결제 이메일 전송
결제 로그 저장
결제 통계 생성
을 모두 처리한다면 SRP를 위반할 가능성이 있다.
이를
PaymentService
NotificationService
PaymentLogService
PaymentReportService
처럼 책임에 따라 분리할 수 있다.
그리고 결제 방식 자체는
Payment
├─ CardPayment
├─ KakaoPayment
└─ NaverPayment
처럼 새로운 구현체를 추가할 수 있도록 만들어 OCP를 적용할 수 있다.
12. 핵심 정리
| 원칙 | 핵심 |
|---|---|
| SRP | 하나의 클래스는 하나의 책임을 가져야 한다 |
| OCP | 기능 확장에는 열려 있고 기존 코드 변경에는 닫혀 있어야 한다 |
SRP는 다음 질문으로 생각하면 된다.
왜 이 클래스가 변경되는가
변경되는 이유가 여러 개라면
책임 분리를 고려한다
OCP는 다음 질문으로 생각하면 된다.
새로운 기능을 추가할 때마다
기존 코드를 수정하고 있는가
그렇다면 추상화와 다형성을 이용해
구현체를 추가하는 구조를 고려한다
한 문장으로 정리하면
SRP는 책임을 분리해 변경의 영향을 줄이는 원칙이고, OCP는 기존 핵심 코드를 최대한 수정하지 않으면서 새로운 기능을 확장할 수 있도록 설계하는 원칙이다.