F-lab Java 1주차 / Phase 3 / Unit 3.1 본격 학습 자료
9-섹션 마스터 프롬프트 형식으로 깊이 파헤친다.선수 지식: Phase 2 (다형성, 클래스 설계)
다음 Unit: 3.2 — OCP (개방-폐쇄 원칙)이 Unit의 의미: SOLID의 첫 글자. Phase 2의 모든 학습 (메서드, 클래스, 다형성) 이 모이는 출발점.
가장 단순해 보이지만 가장 어려운 원칙. 시니어 개발자의 차별화 영역.
평범한 직장인 김씨를 상상해보세요. 그의 직업은 "개발자" 입니다.
그런데 어느 날 사장이 김씨에게 말합니다:
"김씨, 오늘부터 개발도 하고, 회계도 하고, 마케팅도 하고, 청소도 해줘."
문제:
결과:
→ 이게 SRP를 위반한 클래스의 모습.
같은 회사를 다시 설계:
효과:
→ 이게 SRP의 정신. 각 클래스는 한 가지 책임만.
일반 리모컨:
각 리모컨은 한 가지에 집중 → 단순, 명확.
만능 리모컨 (호텔에서 자주 보는 그것):
→ 이게 SRP를 위반한 God Class의 모습.
| 비유 요소 | SRP 적용 |
|---|---|
| 김씨 (직업 1개) | 책임 1개의 클래스 |
| 김씨 (직업 4개) | God Class (안티패턴) |
| 회계 규정 변화 | 변경 이유 |
| 그만둘 때 영향 | 변경의 파급 |
SRP (Single Responsibility Principle) — Uncle Bob이 1990년대에 정립.
원래 정의:
"A class should have one, and only one, reason to change."
("클래스는 변경되어야 할 이유가 단 하나여야 한다.")
→ "책임" 보다는 "변경의 이유" 가 더 정확한 표현.
1980-90년대, 객체지향 프로그래밍이 본격화되면서 흔한 안티패턴:
// God Class — 모든 걸 다 함
public class OrderManager {
public void createOrder(...) { ... } // 주문 생성
public void calculatePrice(...) { ... } // 가격 계산
public void applyDiscount(...) { ... } // 할인
public void calculateTax(...) { ... } // 세금
public void saveToDatabase(...) { ... } // DB 저장
public void sendEmail(...) { ... } // 이메일
public void sendSms(...) { ... } // SMS
public void generateInvoice(...) { ... } // 청구서
public void logActivity(...) { ... } // 로그
public void backupData(...) { ... } // 백업
public void exportToExcel(...) { ... } // 엑셀 내보내기
// ... 100개 메서드 ❌
}
경험적 문제:
→ "변경되어야 할 이유가 너무 많다" 는 본질을 Uncle Bob이 포착.
Uncle Bob의 개정된 정의 (Clean Architecture, 2017):
"A module should be responsible to one, and only one, actor."
("모듈은 단 하나의 행위자에게만 책임을 져야 한다.")
Actor = "변경을 요청하는 주체"
예시:
만약 한 클래스에 세금/할인/DB가 모두 있다면 → 3개 팀이 같은 클래스를 만짐:
→ 각 actor 별로 클래스 분리 가 SRP의 본질.
"SRP는 '하나의 일' 보다 '하나의 변경 이유' 가 핵심이다."
클래스가 작은 게 SRP가 아니다. 누가, 왜 이 클래스를 바꾸려 하는가 를 봐야 한다. 같은 이유로 함께 바뀌는 것은 묶고, 다른 이유로 바뀌는 것은 분리한다.
SRP를 위반했을 때의 구체적 문제를 ILIC 시나리오로 보겠습니다.
운임 등록 시 다음 작업이 필요:
1. 데이터 검증
2. 운임 계산 (할인, 세금)
3. DB 저장
4. 이메일 발송
5. SMS 발송
6. 통계 업데이트
7. 로그 기록
public class FareService {
private final Connection dbConnection;
private final EmailClient emailClient;
private final SmsClient smsClient;
public void registerFare(FareRequest request) {
// 1. 검증
if (request.getAmount() < 0) {
throw new IllegalArgumentException("음수 불가");
}
if (request.getCustomerId() == null) {
throw new IllegalArgumentException("고객 필수");
}
if (!isValidCurrency(request.getCurrency())) {
throw new IllegalArgumentException("유효하지 않은 통화");
}
// 2. 운임 계산
int baseAmount = request.getAmount();
int discount = 0;
if (request.getCustomerLevel().equals("VIP")) {
discount = baseAmount * 20 / 100;
} else if (request.getCustomerLevel().equals("PARTNER")) {
discount = baseAmount * 30 / 100;
}
int discountedAmount = baseAmount - discount;
int tax = discountedAmount * 10 / 100;
int totalAmount = discountedAmount + tax;
// 3. DB 저장
try {
PreparedStatement stmt = dbConnection.prepareStatement(
"INSERT INTO fares (customer_id, amount, total) VALUES (?, ?, ?)"
);
stmt.setLong(1, request.getCustomerId());
stmt.setInt(2, baseAmount);
stmt.setInt(3, totalAmount);
stmt.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException("DB 저장 실패", e);
}
// 4. 이메일 발송
String emailContent = "안녕하세요,\n\n운임이 등록되었습니다.\n금액: " + totalAmount;
emailClient.send(request.getCustomerEmail(), "운임 등록 완료", emailContent);
// 5. SMS 발송
String smsContent = "운임 " + totalAmount + "원 등록 완료";
smsClient.send(request.getCustomerPhone(), smsContent);
// 6. 통계 업데이트
updateMonthlyStats(totalAmount);
// 7. 로그
System.out.println("[INFO] 운임 등록: " + request.getCustomerId() +
", 총액: " + totalAmount);
}
private boolean isValidCurrency(String currency) { ... }
private void updateMonthlyStats(int amount) { ... }
}
이 클래스는 다음 경우 모두 변경이 필요:
→ 7명 이상이 같은 클래스를 만짐 ⚠️
이메일 라이브러리 업그레이드 → FareService 수정 → 테스트 시 운임 계산까지 다 검증해야 ❌
@Test
void 운임_등록_테스트() {
// 테스트하려면 필요한 것:
// - DB 연결 (mock)
// - 이메일 클라이언트 (mock)
// - SMS 클라이언트 (mock)
// - 모든 검증 로직
// - 모든 계산 로직
// → 테스트가 너무 복잡
}
→ 단위 테스트 불가능 → 통합 테스트만 가능 → 느림 + 깨지기 쉬움
운임 계산 로직을 다른 곳에서 사용 하고 싶다면?
FareService 전체를 가져와야 함 (DB, 이메일, SMS 모두)200줄짜리 메서드 안에 모든 로직이 섞여있음.
여러 개발자가 동시에 작업 시:
운임 등록에 버그가 났다 → 어디서?
// 1. 검증 책임
@Component
public class FareValidator {
public void validate(FareRequest request) { ... }
}
// 2. 운임 계산 책임
@Component
public class FareCalculator {
public int calculate(FareRequest request) { ... }
}
// 3. 저장 책임
@Repository
public class FareRepository {
public void save(Fare fare) { ... }
}
// 4. 알림 책임
@Component
public class NotificationService {
public void notify(Fare fare, Customer customer) { ... }
}
// 5. 통계 책임
@Component
public class FareStatistics {
public void update(Fare fare) { ... }
}
// 6. 흐름 조율 (얇아진 Service)
@Service
@RequiredArgsConstructor
public class FareService {
private final FareValidator validator;
private final FareCalculator calculator;
private final FareRepository repository;
private final NotificationService notification;
private final FareStatistics statistics;
public void registerFare(FareRequest request) {
validator.validate(request);
int total = calculator.calculate(request);
Fare fare = repository.save(new Fare(request, total));
notification.notify(fare, request.getCustomer());
statistics.update(fare);
}
}
| 문제 | 해결 |
|---|---|
| 변경 이유 7개 | 각 클래스가 1개씩만 |
| 한 변경이 전체 영향 | 한 클래스만 수정 |
| 테스트 불가능 | 각 클래스 독립 테스트 |
| 재사용 불가 | 각 클래스를 재사용 가능 |
| 가독성 0 | 각 클래스가 단순 |
| 동시 작업 불가 | 다른 파일이라 충돌 X |
| 책임 추적 불가 | 책임이 명확히 분리 |
→ 이게 SRP의 진짜 가치.
SRP 적용 단계:
public class Order {
// 변경 이유 식별
// [재무팀] 가격 계산 규칙 변경 시
public int calculateTotal() { ... }
// [영업팀] 할인 정책 변경 시
public int applyDiscount(int amount) { ... }
// [DBA] DB 스키마 변경 시
public void saveToDatabase() { ... }
// [마케팅팀] 이메일 템플릿 변경 시
public void sendEmailNotification() { ... }
}
→ 변경 이유가 4개 actor = SRP 위반.
// 도메인 객체 — 핵심 비즈니스
public class Order {
private int amount;
private List<OrderLine> lines;
public int calculateTotal() { ... } // 핵심 비즈니스 로직만
}
// 가격 계산 — 재무 정책
@Component
public class PricingService {
public int applyDiscount(Order order, Customer customer) { ... }
}
// 영속성 — 데이터 저장
@Repository
public class OrderRepository {
public void save(Order order) { ... }
}
// 알림 — 고객 커뮤니케이션
@Component
public class OrderNotificationService {
public void notifyOrderCreated(Order order) { ... }
}
@Service
@RequiredArgsConstructor
public class OrderService {
private final PricingService pricing;
private final OrderRepository repository;
private final OrderNotificationService notification;
public void createOrder(OrderRequest request, Customer customer) {
Order order = new Order(request);
int total = pricing.applyDiscount(order, customer);
order.setTotal(total);
repository.save(order);
notification.notifyOrderCreated(order);
}
}
→ OrderService 는 흐름 조율자 역할만.
나쁜 이름:
OrderManager (뭐 하는지 모호)OrderHelperOrderUtil좋은 이름:
OrderRepository (저장)OrderValidator (검증)OrderPricingService (가격 계산)OrderNotificationService (알림)→ 이름에 책임이 드러나야 SRP를 따르는 클래스.
SRP 따름:
"FareValidator는 운임 요청의 유효성을 검증한다."
SRP 위반 ("그리고" 가 들어감):
"FareService는 검증하고, 계산하고, 저장하고, 알림 보낸다."
→ "그리고" 가 등장하면 책임이 여러 개라는 신호.
// ⚠️ import가 30개 이상
import javax.sql.DataSource;
import javax.mail.Message;
import com.aws.SnsClient;
import com.kafka.Producer;
import org.elasticsearch.Client;
// ... 30개
public class FareService { ... }
→ 이 클래스는 너무 많은 일을 함.
SRP는 메서드에도 적용:
나쁜 예:
public void processOrder() {
// 1. 검증 (50줄)
// 2. 계산 (50줄)
// 3. 저장 (30줄)
// 4. 알림 (40줄)
// → 한 메서드 200줄
}
좋은 예:
public void processOrder() {
validate();
int total = calculate();
save(total);
notify();
}
private void validate() { ... }
private int calculate() { ... }
private void save(int total) { ... }
private void notify() { ... }
→ 각 메서드가 한 가지 일만. 메서드명만 봐도 흐름이 보임.
"응집도가 높다" = 한 클래스의 멤버들이 밀접하게 관련됨
높은 응집도 (SRP 따름):
public class FareCalculator {
private DiscountPolicy discountPolicy;
private TaxPolicy taxPolicy;
public int calculate(Fare fare) { ... }
public int applyDiscount(int amount) { ... }
public int applyTax(int amount) { ... }
// → 모두 "운임 계산" 이라는 한 주제
}
낮은 응집도 (SRP 위반):
public class Util {
public String formatDate(Date d) { ... }
public int calculateFare(Fare f) { ... }
public void sendEmail(String to) { ... }
// → 관련 없는 메서드들의 모음
}
→ 응집도 ↑ = SRP 따름 = 좋은 설계.
SRP는 문법이 아닌 설계 원칙 이라 내부 동작 원리는 없습니다. 대신 SRP를 따르는 코드의 구조적 특징 을 봅니다.
SRP 위반 — 하나의 거대 클래스:
[FareService]
↓ (모든 의존성)
[Database] [Email] [SMS] [Stats] [Log]
→ 변경의 파급 효과 큼.
SRP 적용 — 분산된 작은 클래스들:
[FareService]
↓ 조율
[FareValidator] [FareCalculator] [FareRepository]
[NotificationService] [FareStatistics]
↓ ↓ ↓
[검증 규칙] [정책] [DB]
→ 각 의존성이 분리됨, 변경 영향 최소화.
시나리오: 이메일 발송 라이브러리 변경
SRP 위반 시:
[FareService 수정] ← 이 안에 이메일 코드가 있음
↓ 영향
[운임 등록 테스트 다 깨질 수 있음]
[운임 계산 코드도 모두 재검증 필요]
SRP 적용 시:
[NotificationService 수정만]
↓ 영향
[알림 테스트만 영향]
[FareService는 손도 안 댐]
→ 변경 비용의 차이가 극명.
SRP 위반 시 테스트:
@Test
void 운임_등록_테스트() {
// mock 필요한 것:
DataSource ds = mock(DataSource.class);
Connection conn = mock(Connection.class);
PreparedStatement stmt = mock(PreparedStatement.class);
EmailClient email = mock(EmailClient.class);
SmsClient sms = mock(SmsClient.class);
StatsService stats = mock(StatsService.class);
when(ds.getConnection()).thenReturn(conn);
when(conn.prepareStatement(any())).thenReturn(stmt);
// ... 수십 줄의 mock 설정 ❌
FareService service = new FareService(ds, email, sms, stats);
service.registerFare(request);
// 검증도 복잡
verify(stmt).executeUpdate();
verify(email).send(any(), any(), any());
verify(sms).send(any(), any());
verify(stats).update(any());
}
SRP 적용 시 테스트:
@Test
void 운임_검증_테스트() {
// 단순 — Validator만 테스트
FareValidator validator = new FareValidator();
assertThatThrownBy(() -> validator.validate(invalidRequest))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void 운임_계산_테스트() {
// 단순 — Calculator만 테스트
FareCalculator calc = new FareCalculator();
assertThat(calc.calculate(request)).isEqualTo(50000);
}
@Test
void 통합_테스트() {
// 협력 검증
FareService service = new FareService(validator, calculator, repo, notif, stats);
service.registerFare(request);
verify(repo).save(any());
}
→ 각 클래스 단위 테스트 + 협력 검증 테스트. 명확하고 빠름.
좋은 설계의 두 가지 지표:
| 응집도 (Cohesion) | 결합도 (Coupling) | |
|---|---|---|
| 의미 | 한 클래스 내부의 관련성 | 클래스 간 의존도 |
| 목표 | 높게 ↑ | 낮게 ↓ |
| SRP 영향 | 응집도 ↑ | 결합도 ↓ |
SRP가 두 지표 모두 개선:
→ SRP는 좋은 설계의 토대.
박승제님이 이전에 학습한 빈혈 도메인 모델 의 SRP 관점:
Before — 빈혈 + God Service:
@Entity
public class Fare {
private int amount;
private FareStatus status;
// getter/setter만
}
@Service
public class FareService {
// 모든 비즈니스 로직 + 인프라 코드 ❌
public void changeAmount(Fare fare, int newAmount) { ... }
public void submit(Fare fare) { ... }
public void pay(Fare fare) { ... }
public void cancel(Fare fare) { ... }
public boolean canCancel(Fare fare) { ... }
public void save(Fare fare) { ... }
public void notifyCustomer(Fare fare) { ... }
public void updateStatistics(Fare fare) { ... }
// ... 50개 메서드
}
After — Rich Domain + 책임 분리:
// 1. Fare — 자기 행동 책임
@Entity
public class Fare {
private int amount;
private FareStatus status;
public void changeAmount(int newAmount) {
if (status != FareStatus.DRAFT) throw new IllegalStateException();
if (newAmount < 0) throw new IllegalArgumentException();
this.amount = newAmount;
}
public void submit() { ... }
public void pay() { ... }
public void cancel() { ... }
public boolean canCancel() { ... }
}
// 2. FareRepository — 저장 책임
@Repository
public interface FareRepository extends JpaRepository<Fare, Long> { }
// 3. NotificationService — 알림 책임
@Component
public class NotificationService {
public void notifyFareChanged(Fare fare, Customer customer) { ... }
}
// 4. FareStatistics — 통계 책임
@Component
public class FareStatistics {
public void recordFareEvent(Fare fare) { ... }
}
// 5. FareService — 흐름 조율
@Service
@RequiredArgsConstructor
public class FareService {
private final FareRepository repository;
private final NotificationService notification;
private final FareStatistics statistics;
@Transactional
public void changeFareAmount(Long fareId, int newAmount) {
Fare fare = repository.findById(fareId).orElseThrow();
fare.changeAmount(newAmount); // ← 도메인 객체에 위임
notification.notifyFareChanged(fare, fare.getCustomer());
statistics.recordFareEvent(fare);
}
}
효과:
Fare 자체가 자기 책임 (상태 무결성)FareService 는 조율 만// Before — 한 클래스가 모든 정책
public class FareCalculator {
public int calculate(Fare fare, Customer customer) {
int amount = fare.getAmount();
// 할인 정책
int discount = 0;
if (customer.getLevel().equals("VIP")) discount = amount * 20 / 100;
else if (customer.getLevel().equals("PARTNER")) discount = amount * 30 / 100;
int discounted = amount - discount;
// 세금 정책
int tax = discounted * 10 / 100;
if (customer.isOverseas()) tax = 0; // 해외는 면세
// 환율 적용
double rate = 1.0;
if (customer.getCurrency().equals("USD")) rate = 1300.0;
else if (customer.getCurrency().equals("EUR")) rate = 1400.0;
return (int)((discounted + tax) * rate);
}
}
→ 변경 이유 3개 (할인, 세금, 환율) = SRP 위반.
After — 책임 분리:
// 1. 할인 정책 (영업팀 책임)
@Component
public class DiscountPolicy {
public int calculate(int amount, Customer customer) {
return switch (customer.getLevel()) {
case VIP -> amount * 20 / 100;
case PARTNER -> amount * 30 / 100;
default -> 0;
};
}
}
// 2. 세금 정책 (회계팀 책임)
@Component
public class TaxPolicy {
public int calculate(int amount, Customer customer) {
if (customer.isOverseas()) return 0;
return amount * 10 / 100;
}
}
// 3. 환율 정책 (재무팀 책임)
@Component
public class ExchangeRatePolicy {
public double getRate(String currency) {
return switch (currency) {
case "USD" -> 1300.0;
case "EUR" -> 1400.0;
default -> 1.0;
};
}
}
// 4. 종합 계산 (각 정책 조합)
@Component
@RequiredArgsConstructor
public class FareCalculator {
private final DiscountPolicy discountPolicy;
private final TaxPolicy taxPolicy;
private final ExchangeRatePolicy exchangeRatePolicy;
public int calculate(Fare fare, Customer customer) {
int amount = fare.getAmount();
int discount = discountPolicy.calculate(amount, customer);
int discounted = amount - discount;
int tax = taxPolicy.calculate(discounted, customer);
double rate = exchangeRatePolicy.getRate(customer.getCurrency());
return (int)((discounted + tax) * rate);
}
}
효과:
DiscountPolicy 만 수정TaxPolicy 만 수정com.ilic.fare/
├── domain/
│ ├── Fare.java # 도메인 객체 (자기 책임)
│ └── FareStatus.java
│
├── policy/
│ ├── DiscountPolicy.java # 할인 정책
│ ├── TaxPolicy.java # 세금 정책
│ └── ExchangeRatePolicy.java # 환율 정책
│
├── repository/
│ └── FareRepository.java # 영속성
│
├── service/
│ ├── FareService.java # 흐름 조율
│ ├── FareCalculator.java # 계산 조합
│ └── FareValidator.java # 검증
│
├── notification/
│ └── FareNotificationService.java
│
├── statistics/
│ └── FareStatistics.java
│
└── controller/
└── FareController.java # HTTP 처리
→ 각 패키지 = 각 책임. 한눈에 구조 파악.
// Before — 한 메서드가 너무 많은 일
public Fare createFare(FareRequest request) {
// 검증 (20줄)
if (request == null) throw ...;
if (request.getAmount() < 0) throw ...;
if (request.getCustomerId() == null) throw ...;
// 도메인 객체 생성 (10줄)
Fare fare = new Fare();
fare.setAmount(request.getAmount());
fare.setCustomerId(request.getCustomerId());
fare.setStatus(FareStatus.DRAFT);
fare.setCreatedAt(LocalDateTime.now());
// 저장 (5줄)
Fare saved = fareRepository.save(fare);
// 이벤트 발행 (10줄)
FareCreatedEvent event = new FareCreatedEvent();
event.setFareId(saved.getId());
event.setTimestamp(LocalDateTime.now());
eventPublisher.publish(event);
return saved;
}
After — 메서드 분리:
public Fare createFare(FareRequest request) {
validateRequest(request);
Fare fare = buildFare(request);
Fare saved = fareRepository.save(fare);
publishCreatedEvent(saved);
return saved;
}
private void validateRequest(FareRequest request) { ... }
private Fare buildFare(FareRequest request) { ... }
private void publishCreatedEvent(Fare fare) { ... }
→ 메인 메서드만 봐도 흐름이 보임. 각 단계는 별도 메서드에서.
// ❌ 책임 없는 잡동사니
public class IlicUtil {
public static String formatDate(LocalDate d) { ... }
public static String formatMoney(int amount) { ... }
public static int calculateFare(Fare f) { ... }
public static String generateReceiptNumber() { ... }
public static boolean isValidEmail(String email) { ... }
public static String maskPhoneNumber(String phone) { ... }
// ... 50개 메서드, 모두 다른 영역
}
해결:
// 영역별 분리
public class DateFormatter { ... }
public class MoneyFormatter { ... }
public class FareCalculator { ... }
public class ReceiptNumberGenerator { ... }
public class EmailValidator { ... }
public class PhoneMaskingService { ... }
→ Util 같은 모호한 이름은 SRP 위반의 신호.
// ❌ 너무 잘게 쪼갬
public class Customer {
public String getName() { return name; }
}
public class CustomerEmailGetter {
public String getEmail(Customer c) { ... }
}
public class CustomerAgeCalculator {
public int calculateAge(Customer c) { ... }
}
// → 50개 클래스로 폭발 ❌
올바른 이해:
"SRP는 한 메서드 가 아니라 한 변경 이유"
Customer 의 여러 정보 조회 는 같은 책임 (사용자 정보 관리).
// ✅ 적절한 분리
public class Customer {
public String getName() { ... }
public String getEmail() { ... }
public int calculateAge() { ... }
// → "사용자 정보" 라는 한 책임
}
// ❌ 너무 작아서 의미 없음
public class CustomerNameValidator {
public void validate(String name) {
if (name == null) throw ...;
}
}
public class CustomerEmailValidator {
public void validate(String email) {
if (!email.contains("@")) throw ...;
}
}
public class CustomerAgeValidator {
public void validate(int age) {
if (age < 0) throw ...;
}
}
// → 클래스 폭발, 흐름 추적 어려움
올바른 분리:
public class CustomerValidator {
public void validate(Customer customer) {
validateName(customer.getName());
validateEmail(customer.getEmail());
validateAge(customer.getAge());
}
private void validateName(String name) { ... }
private void validateEmail(String email) { ... }
private void validateAge(int age) { ... }
}
→ "고객 검증" 이라는 한 책임 안에서 메서드로 분리.
// ❌ 너무 추상적
public class OrderService {
// "주문 관리" — 그런데 주문 관리에 뭐가 들어가나?
// 결국 모든 게 들어감
}
올바른 정의:
public class OrderCreationService { ... } // 주문 생성
public class OrderCancellationService { ... } // 주문 취소
public class OrderRefundService { ... } // 환불
→ 이름이 구체적이어야 책임도 명확.
// ❌ DTO에 비즈니스 로직 추가
public class FareDTO {
private int amount;
private String customerEmail;
// DTO인데 비즈니스 로직 ❌
public boolean isVipDiscount() { ... }
public int calculateTax() { ... }
}
올바른 분리:
// DTO — 데이터 전송만
public class FareDTO {
private int amount;
private String customerEmail;
// getter/setter 만
}
// 비즈니스 로직 — 별도 클래스
@Component
public class FareCalculator {
public boolean isVipDiscount(Fare fare) { ... }
public int calculateTax(Fare fare) { ... }
}
→ DTO는 데이터, 도메인 객체는 비즈니스, 서비스는 조율.
// ❌ 단순한 시스템에 과도한 분리
@Component public class CustomerNameValidator { ... }
@Component public class CustomerEmailValidator { ... }
@Component public class CustomerAgeValidator { ... }
@Component public class CustomerAddressValidator { ... }
@Component public class CustomerPhoneValidator { ... }
@Component public class CustomerOrchestrator { ... }
// → 단순 등록 기능에 6개 클래스 ❌
원칙 ⭐ :
"처음에는 한 클래스로 시작, 변경 이유가 실제로 분리되면 그때 분리"
YAGNI (You Aren't Gonna Need It) 원칙과 균형.
// ⚠️ 모호한 큰 책임
public class FareManagement { ... } // 운임 관리?
public class CustomerSystem { ... } // 고객 시스템?
→ "관리", "시스템", "처리" 같은 단어는 SRP 위반의 신호.
더 구체적:
FareCreation, FareModification, FareCancellationCustomerRegistration, CustomerVerification, CustomerProfile@Service
public class OrderService {
@Transactional
public void createOrder(...) {
// 1. Order 저장
// 2. Inventory 차감
// 3. Payment 처리
// → 한 트랜잭션 안에 다 해야 함
// → SRP 위반?
}
}
해결 — 흐름 조율은 한 책임:
@Service
public class OrderService {
private final OrderCreator creator;
private final InventoryService inventory;
private final PaymentService payment;
@Transactional
public void createOrder(...) {
Order order = creator.create(...);
inventory.deduct(...);
payment.process(...);
}
}
// → "주문 흐름 조율" 이 OrderService의 책임
// → 각 작업은 다른 클래스에 위임
→ 흐름 조율 vs 실제 작업 도 다른 책임.
[SRP] ← 지금 여기
↓ (SRP를 지키면 가능)
[OCP] — 다음 Unit
↓
[LSP] [ISP] [DIP]
→ SRP가 SOLID의 출발점. 책임이 분리되어야 다른 원칙도 따를 수 있음.
| Phase 2 학습 | SRP 적용 |
|---|---|
| 클래스 작성 | 한 책임의 클래스 |
| 메서드 구조 | 한 일만 하는 메서드 |
| 빈혈 모델 회피 | 도메인 객체에 행동 부여 |
| 상속 남용 회피 | 책임 분리 후 합성 |
| 다형성 | 책임마다 다른 구현 |
→ Phase 2의 모든 학습이 SRP에서 통합.
3주차 (스트림/람다):
5주차 (Spring DI):
7주차 (트랜잭션):
8-9주차 (AOP):
11-12주차 (JPA):
17주차 (MSA):
→ SRP는 모든 자바 학습의 토대.
높은 응집도 ←→ 낮은 결합도
↑ ↑
└──── SRP ─────┘
SRP의 효과:
| 질문 | 이 Unit에서의 답 |
|---|---|
| "SRP가 뭔가요?" | 한 클래스는 한 변경 이유만 가져야 함 |
| "왜 SRP를 지켜야?" | 변경 영향 최소화, 테스트 용이, 협업 가능 |
| "God Class란?" | 너무 많은 책임을 가진 클래스 (SRP 위반 안티패턴) |
| "응집도와 결합도?" | SRP 적용 시 응집도 ↑, 결합도 ↓ |
| "SRP를 너무 적용하면?" | 클래스 폭발, 추적 어려움 — 균형 필요 |
1️⃣ SRP는 "한 클래스, 한 변경 이유" 의 원칙이다.
Uncle Bob의 정의: "A class should have only one reason to change." 핵심은 "한 가지 일" 이 아닌 "한 가지 변경 이유" — 같은 actor (회계팀, 영업팀 등) 가 변경 요청하는 것은 묶고, 다른 actor 가 변경하는 것은 분리한다.
2️⃣ SRP를 위반한 God Class는 7가지 문제를 만든다.
변경 영향 폭발, 테스트 불가, 재사용 불가, 가독성 0, 협업 충돌, 책임 추적 불가, 한 변경이 전체에 영향. "이 클래스가 뭐 하나?" 에 "그리고" 가 들어가면 SRP 위반의 신호.
Util,Manager,Service같은 모호한 이름도 위험 신호.3️⃣ SRP는 모든 SOLID와 좋은 설계의 출발점이다.
SRP 없이는 OCP, LSP, ISP, DIP 모두 적용 불가. 응집도 ↑, 결합도 ↓ 라는 두 가지 좋은 설계 지표를 동시에 개선. 단, 너무 일찍 / 너무 잘게 적용하면 클래스 폭발 — "지금 분리할 가치가 있는가" 의 균형이 시니어의 판단력.
박승제님의 ILIC 코드를 점검해보세요:
SRP 위반 신호 ⚠️:
Util, Manager, Helper 라는 이름이 있다3개 이상 해당 = SRP 위반 가능성 높음.