🎯1주차 Unit 3.1 — SRP (단일 책임 원칙)

Psj·2026년 5월 7일

F-lab

목록 보기
30/142

🎯 Unit 3.1 — SRP (단일 책임 원칙)

F-lab Java 1주차 / Phase 3 / Unit 3.1 본격 학습 자료
9-섹션 마스터 프롬프트 형식으로 깊이 파헤친다.

선수 지식: Phase 2 (다형성, 클래스 설계)
다음 Unit: 3.2 — OCP (개방-폐쇄 원칙)

이 Unit의 의미: SOLID의 첫 글자. Phase 2의 모든 학습 (메서드, 클래스, 다형성) 이 모이는 출발점.
가장 단순해 보이지만 가장 어려운 원칙. 시니어 개발자의 차별화 영역.


🌍 1. 세상 속 비유

SRP = "한 사람, 한 가지 직업"

평범한 직장인 김씨를 상상해보세요. 그의 직업은 "개발자" 입니다.

  • 코드를 작성한다
  • 버그를 수정한다
  • 코드 리뷰를 한다

그런데 어느 날 사장이 김씨에게 말합니다:

"김씨, 오늘부터 개발도 하고, 회계도 하고, 마케팅도 하고, 청소도 해줘."

문제:

  • 회계 규정이 바뀌면 → 김씨가 바뀌어야 함
  • 마케팅 트렌드가 바뀌면 → 김씨가 바뀌어야 함
  • 청소 도구가 바뀌면 → 김씨가 바뀌어야 함
  • 김씨가 변경되어야 할 이유가 너무 많음 ⚠️

결과:

  • 한 영역 변경 → 다른 영역에 영향
  • 김씨가 그만두면 → 4개 업무 마비
  • 새로 들어온 직원이 김씨 일을 인수받기 거의 불가능

→ 이게 SRP를 위반한 클래스의 모습.


좋은 회사 = "각자 맡은 일에 집중"

같은 회사를 다시 설계:

  • 개발자: 코드만 신경 씀
  • 회계사: 회계만 신경 씀
  • 마케터: 마케팅만 신경 씀
  • 청소부: 청소만 신경 씀

효과:

  • 회계 규정 바뀜 → 회계사만 영향
  • 한 명이 그만둬도 다른 영역 영향 X
  • 새 사람 채용 시 그 영역만 가르치면 됨

이게 SRP의 정신. 각 클래스는 한 가지 책임만.


더 직관적인 비유 — "리모컨 vs 만능 리모컨"

일반 리모컨:

  • TV 리모컨: TV만 조작
  • 에어컨 리모컨: 에어컨만 조작
  • 조명 리모컨: 조명만 조작

각 리모컨은 한 가지에 집중 → 단순, 명확.


만능 리모컨 (호텔에서 자주 보는 그것):

  • 한 리모컨에 100개 버튼
  • TV, 에어컨, 조명, 환풍기, 커튼...
  • 어떤 버튼이 어떤 기기인지 헷갈림
  • 한 기기 고장나면 다른 기기까지 안 됨

이게 SRP를 위반한 God Class의 모습.


비유의 핵심

비유 요소SRP 적용
김씨 (직업 1개)책임 1개의 클래스
김씨 (직업 4개)God Class (안티패턴)
회계 규정 변화변경 이유
그만둘 때 영향변경의 파급

🔥 2. 탄생 배경

Robert C. Martin (Uncle Bob) 의 정의 — 1990년대

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개 메서드 ❌
}

경험적 문제:

  • DB 변경 → 이 클래스 수정
  • 이메일 라이브러리 변경 → 이 클래스 수정
  • 가격 정책 변경 → 이 클래스 수정
  • 한 클래스에 너무 많은 변경 사유

"변경되어야 할 이유가 너무 많다" 는 본질을 Uncle Bob이 포착.


변경의 이유 = "Actor"

Uncle Bob의 개정된 정의 (Clean Architecture, 2017):

"A module should be responsible to one, and only one, actor."
("모듈은 단 하나의 행위자에게만 책임을 져야 한다.")

Actor = "변경을 요청하는 주체"

예시:

  • 회계팀 → 세금 계산 로직 변경 요청
  • 영업팀 → 할인 정책 변경 요청
  • 인프라팀 → DB 변경 요청

만약 한 클래스에 세금/할인/DB가 모두 있다면 → 3개 팀이 같은 클래스를 만짐:

  • 충돌 발생
  • 한 팀의 변경이 다른 팀에 영향
  • 책임 소재 불명

각 actor 별로 클래스 분리 가 SRP의 본질.


핵심 통찰

"SRP는 '하나의 일' 보다 '하나의 변경 이유' 가 핵심이다."

클래스가 작은 게 SRP가 아니다. 누가, 왜 이 클래스를 바꾸려 하는가 를 봐야 한다. 같은 이유로 함께 바뀌는 것은 묶고, 다른 이유로 바뀌는 것은 분리한다.


💣 3. 없으면 생기는 문제

SRP를 위반했을 때의 구체적 문제를 ILIC 시나리오로 보겠습니다.

시나리오: ILIC 운임 등록 처리

운임 등록 시 다음 작업이 필요:
1. 데이터 검증
2. 운임 계산 (할인, 세금)
3. DB 저장
4. 이메일 발송
5. SMS 발송
6. 통계 업데이트
7. 로그 기록


SRP 위반 — God Class

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가지 심각한 문제 ⚠️

1. 변경 이유가 7개

이 클래스는 다음 경우 모두 변경이 필요:

  • 검증 규칙 변경 (회계팀)
  • 할인 정책 변경 (영업팀)
  • 세금 정책 변경 (회계팀)
  • DB 스키마 변경 (DBA)
  • 이메일 템플릿 변경 (마케팅)
  • SMS 발송 방식 변경 (인프라)
  • 통계 산출 방식 변경 (분석팀)
  • 로그 형식 변경 (운영)

7명 이상이 같은 클래스를 만짐 ⚠️


2. 한 변경이 전체에 영향

이메일 라이브러리 업그레이드 → FareService 수정 → 테스트 시 운임 계산까지 다 검증해야


3. 테스트 불가능

@Test
void 운임_등록_테스트() {
    // 테스트하려면 필요한 것:
    // - DB 연결 (mock)
    // - 이메일 클라이언트 (mock)
    // - SMS 클라이언트 (mock)
    // - 모든 검증 로직
    // - 모든 계산 로직
    // → 테스트가 너무 복잡
}

단위 테스트 불가능 → 통합 테스트만 가능 → 느림 + 깨지기 쉬움


4. 재사용 불가

운임 계산 로직을 다른 곳에서 사용 하고 싶다면?

  • FareService 전체를 가져와야 함 (DB, 이메일, SMS 모두)
  • 또는 코드 복사 → 중복

5. 가독성 0

200줄짜리 메서드 안에 모든 로직이 섞여있음.

  • 이 메서드가 무엇을 하는지 한눈에 파악 불가
  • 신입 개발자: "어디서부터 봐야 하지?"

6. 동시 작업 불가

여러 개발자가 동시에 작업 시:

  • A: 검증 로직 수정
  • B: 할인 정책 수정
  • C: 이메일 템플릿 수정
  • 같은 파일에서 머지 충돌

7. 책임 추적 불가

운임 등록에 버그가 났다 → 어디서?

  • 검증? 계산? DB? 이메일? SMS? 통계?
  • 모두 한 클래스에 → 추적이 지옥

SRP 적용 — 책임 분리

// 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가지 문제가 어떻게 해결됐나?

문제해결
변경 이유 7개각 클래스가 1개씩만
한 변경이 전체 영향한 클래스만 수정
테스트 불가능각 클래스 독립 테스트
재사용 불가각 클래스를 재사용 가능
가독성 0각 클래스가 단순
동시 작업 불가다른 파일이라 충돌 X
책임 추적 불가책임이 명확히 분리

이게 SRP의 진짜 가치.


✅ 4. 해결책 — SRP를 적용하는 방법

핵심 원칙 — "한 클래스, 한 변경 이유"

SRP 적용 단계:

  1. 변경 이유 식별
  2. 각 변경 이유별로 클래스 분리
  3. 분리된 클래스를 협력시킴

단계 1: 변경 이유 식별 — "누가 왜 변경하는가?"

public class Order {
    // 변경 이유 식별
    
    // [재무팀] 가격 계산 규칙 변경 시
    public int calculateTotal() { ... }
    
    // [영업팀] 할인 정책 변경 시
    public int applyDiscount(int amount) { ... }
    
    // [DBA] DB 스키마 변경 시
    public void saveToDatabase() { ... }
    
    // [마케팅팀] 이메일 템플릿 변경 시
    public void sendEmailNotification() { ... }
}

→ 변경 이유가 4개 actor = SRP 위반.


단계 2: 책임별 분리

// 도메인 객체 — 핵심 비즈니스
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) { ... }
}

단계 3: 협력 (Composition)

@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흐름 조율자 역할만.


SRP 적용의 입문 가이드 ⭐

1. 클래스 이름이 정의를 표현해야 함

나쁜 이름:

  • OrderManager (뭐 하는지 모호)
  • OrderHelper
  • OrderUtil

좋은 이름:

  • OrderRepository (저장)
  • OrderValidator (검증)
  • OrderPricingService (가격 계산)
  • OrderNotificationService (알림)

이름에 책임이 드러나야 SRP를 따르는 클래스.


2. "이 클래스가 무엇을 하나?" 한 문장 답변

SRP 따름:

"FareValidator는 운임 요청의 유효성을 검증한다."

SRP 위반 ("그리고" 가 들어감):

"FareService는 검증하고, 계산하고, 저장하고, 알림 보낸다."

"그리고" 가 등장하면 책임이 여러 개라는 신호.


3. import 문이 너무 많으면 의심

// ⚠️ 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

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() { ... }

각 메서드가 한 가지 일만. 메서드명만 봐도 흐름이 보임.


응집도 (Cohesion) ⭐

"응집도가 높다" = 한 클래스의 멤버들이 밀접하게 관련됨

높은 응집도 (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 따름 = 좋은 설계.


🏗️ 5. 내부 동작 원리

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());
}

각 클래스 단위 테스트 + 협력 검증 테스트. 명확하고 빠름.


SRP와 응집도/결합도의 관계 ⭐

좋은 설계의 두 가지 지표:

응집도 (Cohesion)결합도 (Coupling)
의미한 클래스 내부의 관련성클래스 간 의존도
목표높게낮게
SRP 영향응집도 ↑결합도 ↓

SRP가 두 지표 모두 개선:

  • 한 클래스가 한 책임만 → 응집도 ↑
  • 책임 분리 → 다른 책임에 의존 X → 결합도 ↓

SRP는 좋은 설계의 토대.


💻 6. 실전 코드 예시

예시 1: 빈혈 모델에서 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조율
  • 각 컴포넌트 독립 테스트 가능

예시 2: 운임 계산 로직 분리

// 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 만 수정
  • 각 정책 독립 테스트 가능
  • 새 정책 (이벤트 할인) 추가 쉬움

예시 3: SRP를 따르는 패키지 구조

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 처리

각 패키지 = 각 책임. 한눈에 구조 파악.


예시 4: 메서드 레벨 SRP

// 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) { ... }

메인 메서드만 봐도 흐름이 보임. 각 단계는 별도 메서드에서.


예시 5: 안 좋은 패턴 — Util 클래스

// ❌ 책임 없는 잡동사니
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 위반의 신호.


⚠️ 7. 주의사항 & 흔한 실수

실수 1: 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() { ... }
    // → "사용자 정보" 라는 한 책임
}

실수 2: SRP 광신 — 과도한 분리

// ❌ 너무 작아서 의미 없음
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) { ... }
}

"고객 검증" 이라는 한 책임 안에서 메서드로 분리.


실수 3: 책임을 너무 추상적으로 정의

// ❌ 너무 추상적
public class OrderService {
    // "주문 관리" — 그런데 주문 관리에 뭐가 들어가나?
    // 결국 모든 게 들어감
}

올바른 정의:

public class OrderCreationService { ... }   // 주문 생성
public class OrderCancellationService { ... }  // 주문 취소
public class OrderRefundService { ... }     // 환불

이름이 구체적이어야 책임도 명확.


실수 4: 데이터 클래스의 SRP 위반

// ❌ 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는 데이터, 도메인 객체는 비즈니스, 서비스는 조율.


실수 5: SRP를 너무 일찍 적용

// ❌ 단순한 시스템에 과도한 분리
@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) 원칙과 균형.


실수 6: 너무 큰 책임을 한 번에 정의

// ⚠️ 모호한 큰 책임
public class FareManagement { ... }  // 운임 관리?

public class CustomerSystem { ... }   // 고객 시스템?

"관리", "시스템", "처리" 같은 단어는 SRP 위반의 신호.

더 구체적:

  • FareCreation, FareModification, FareCancellation
  • CustomerRegistration, CustomerVerification, CustomerProfile

실수 7: 트랜잭션 단위와 SRP의 충돌

@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 실제 작업 도 다른 책임.


🔗 8. 연관 개념 맵

Phase 3 (SOLID) 내 흐름

[SRP]  ← 지금 여기
  ↓ (SRP를 지키면 가능)
[OCP] — 다음 Unit
  ↓
[LSP] [ISP] [DIP]

SRP가 SOLID의 출발점. 책임이 분리되어야 다른 원칙도 따를 수 있음.


Phase 2 (OOP) 와의 연결

Phase 2 학습SRP 적용
클래스 작성한 책임의 클래스
메서드 구조한 일만 하는 메서드
빈혈 모델 회피도메인 객체에 행동 부여
상속 남용 회피책임 분리 후 합성
다형성책임마다 다른 구현

Phase 2의 모든 학습이 SRP에서 통합.


미래 주차와의 연결

3주차 (스트림/람다):

  • 스트림 연산 = "한 단계, 한 책임"

5주차 (Spring DI):

  • 빈마다 한 책임 → 자연스러운 SRP

7주차 (트랜잭션):

  • 트랜잭션 단위 = 책임 단위

8-9주차 (AOP):

  • 횡단 관심사 분리 = SRP의 자연스러운 결과

11-12주차 (JPA):

  • Repository / Service / Domain 분리

17주차 (MSA):

  • Bounded Context = 거대한 SRP

SRP는 모든 자바 학습의 토대.


응집도와 결합도

높은 응집도 ←→ 낮은 결합도
    ↑              ↑
    └──── SRP ─────┘

SRP의 효과:

  • 응집도 ↑ (한 클래스 안 멤버들이 밀접)
  • 결합도 ↓ (다른 책임에 의존 X)
  • 유지보수성 ↑, 테스트 용이성 ↑

면접 단골 질문 매핑

질문이 Unit에서의 답
"SRP가 뭔가요?"한 클래스는 한 변경 이유만 가져야 함
"왜 SRP를 지켜야?"변경 영향 최소화, 테스트 용이, 협업 가능
"God Class란?"너무 많은 책임을 가진 클래스 (SRP 위반 안티패턴)
"응집도와 결합도?"SRP 적용 시 응집도 ↑, 결합도 ↓
"SRP를 너무 적용하면?"클래스 폭발, 추적 어려움 — 균형 필요

📝 9. 핵심 요약 — 3줄 정리

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 모두 적용 불가. 응집도 ↑, 결합도 ↓ 라는 두 가지 좋은 설계 지표를 동시에 개선. 단, 너무 일찍 / 너무 잘게 적용하면 클래스 폭발 — "지금 분리할 가치가 있는가" 의 균형이 시니어의 판단력.


🎓 학습 자기 점검

기본 이해

  • SRP의 정의를 한 문장으로 설명할 수 있다
  • "변경 이유" 와 "actor" 의 의미를 안다
  • God Class의 7가지 문제를 나열할 수 있다
  • 응집도와 결합도와 SRP의 관계를 안다

실전 적용

  • ILIC 코드의 God Class를 식별할 수 있다
  • 한 클래스의 변경 이유를 정확히 분석할 수 있다
  • SRP에 따라 클래스를 분리할 수 있다
  • "이 클래스가 뭐 하나?" 에 한 문장으로 답할 수 있다

면접 대비 (3-5분 답변)

  • "SRP가 뭔가요?" 답변 가능
  • "ILIC에서 SRP를 어떻게 적용하셨나요?" 답변 가능
  • "SRP의 어려움은?" 답변 가능 (과도한 분리 위험)

자기 점검 — ILIC 적용

박승제님의 ILIC 코드를 점검해보세요:

SRP 위반 신호 ⚠️:

  • Service 클래스가 1000줄 이상이다
  • 한 클래스에 import가 30개 이상이다
  • "이 클래스가 뭐 하나?" 에 "그리고" 가 들어간다
  • Util, Manager, Helper 라는 이름이 있다
  • 한 메서드가 200줄 이상이다
  • 빈혈 도메인 모델이다 (Entity는 getter/setter만)

3개 이상 해당 = SRP 위반 가능성 높음.


다음 Unit으로

  • OCP (개방-폐쇄 원칙) 을 학습할 준비 완료
  • "확장에는 열려있고, 변경에는 닫혀있다" 가 궁금하다
  • 다형성이 OCP의 직접적 구현임을 만날 준비 완료
profile
Software Developer

0개의 댓글