SRP (Single Responsibility Principle, 단일 책임 원칙)

“A module should be responsible to one, and only one, actor.” 하나의 모듈은 오직 하나의 액터(변경을 요구하는 사람/그룹)에 대해서만 책임을 져야 한다. Robert C. Martin, The Single Responsibility Principle (2014)

상위 개념: SOLID원칙 관련: OCP (개방 폐쇄 원칙), SOLID_ O와 D의 차이


1. 정의 다시 보기: “책임”의 오해

SRP를 “하나의 클래스는 하나의 일만 해야 한다”로 외우고 있는 사람이 많다. 이건 엄밀히 틀렸다. Uncle Bob이 2014년 블로그 글에서 직접 정정한 내용이 있다.

잘못된 이해정정된 이해
”한 가지 일(function)만 해라""한 가지 변경 이유(reason to change) 만 가져라"
"메서드를 잘게 쪼개라""변경을 요구하는 사람(actor) 기준으로 묶어라”
기능 단위로 자르기이해관계자(stakeholder) 단위로 자르기

한마디 요약: SRP는 “기능”이 아니라 “누구 때문에 수정되는가”로 자른다.

왜 ‘사람’ 기준인가?

Martin 원문: “This principle is about people. When you write a software module, you want to make sure that when changes are requested, those changes can only originate from a single person, or rather, a single tightly coupled group of people representing a single narrowly defined business function.”

코드가 바뀌는 원인은 결국 누군가가 요구했기 때문이다. 그 요구자가 여러 명이면, 한 명의 요구를 반영하다가 다른 명의 요구사항이 깨진다. SRP는 이런 “요구자 간 간섭” 을 막는 원칙이다.


2. 일상 비유: 식당 홀 매니저

한 사람이 주방 요리 + 홀 서빙 + 계산대 + 식자재 발주까지 다 하는 식당을 상상해보자.

요구자(actor)요구 내용충돌
셰프”플레이팅 시간 줄여라”홀 서빙 루틴과 충돌
사장”원가 5% 줄여라”식자재 발주 루틴 수정 필요
단골 손님”카카오페이 결제 추가해달라”계산대 코드 수정 필요

한 사람이 네 영역을 겸하면, 셰프 요구를 반영하다가 손님 결제가 터진다. 사람마다 담당을 나누는 것이 SRP의 본질이다.


3. 위반 신호 5가지

자기가 짠 클래스에 아래 증상이 있으면 SRP 위반을 의심해야 한다.

  • 클래스 이름에 “and”, “Manager”, “Helper”, “Util” 같은 애매한 단어가 들어간다
  • 한 파일의 import가 DB, HTTP, 로깅, LLM SDK, 템플릿 엔진을 전부 포함한다
  • git log <파일>을 찍었을 때 커밋 메시지가 “fix: DB migration”, “refactor: LLM switch”, “feat: Slack notify”처럼 도메인이 섞여 있다
  • 단위 테스트를 짜려면 mock이 5개 이상 필요하다
  • 한 메서드를 수정했는데 관련 없어 보이는 테스트가 깨진다

직접 확인하기

# 최근 20개 커밋의 수정 파일별 빈도 (Windows Git Bash 기준)
git log -n 20 --name-only --pretty=format: | sort | uniq -c | sort -rn | head

한 파일이 비정상적으로 자주 수정되고, 커밋 메시지가 제각각이면 그 파일은 SRP 위반 후보이다.


4. AI Agent 개발에서의 SRP

에이전트 코드는 특히 책임이 뭉치기 쉽다. “LLM 한 번 부르는” 흐름에 자연스럽게 여러 영역이 딸려오기 때문이다.

전형적 위반: 거대한 ChatAgent

# ❌ 하나의 클래스, 다섯 명의 actor
class ChatAgent:
    def __init__(self):
        self.openai = openai.OpenAI(api_key=os.getenv("OPENAI_KEY"))
        self.db = psycopg2.connect(...)
        self.slack = SlackClient(...)
 
    def handle(self, user_msg: str, user_id: str) -> str:
        # 1) 프롬프트 조립
        system_prompt = "너는 친절한 상담원이다..."
        messages = [{"role": "system", "content": system_prompt},
                    {"role": "user", "content": user_msg}]
 
        # 2) LLM 호출
        resp = self.openai.chat.completions.create(
            model="gpt-4o", messages=messages
        )
        answer = resp.choices[0].message.content
 
        # 3) 비용 계산
        cost = resp.usage.total_tokens * 0.00001
 
        # 4) DB 저장
        cur = self.db.cursor()
        cur.execute(
            "INSERT INTO conversations (user_id, msg, resp, cost) VALUES (%s,%s,%s,%s)",
            (user_id, user_msg, answer, cost),
        )
        self.db.commit()
 
        # 5) Slack 알림
        if cost > 0.1:
            self.slack.post(f"⚠️ 고비용 대화: ${cost}")
 
        # 6) 응답 포맷팅
        return f"[답변]\n{answer}\n\n(사용 토큰: {resp.usage.total_tokens})"

이 클래스의 actor 목록

Actor (변경 요구자)바뀌면 건드려야 할 부분
Prompt 엔지니어system_prompt 문구
ML 엔지니어모델명, temperature, 호출 방식
재무팀/FinOps비용 단가, 알림 임계치
데이터 엔지니어DB 스키마, INSERT 쿼리
운영팀Slack 채널, 알림 포맷
프론트엔드응답 포맷팅 문자열

6개의 액터가 한 파일을 수정한다. merge conflict가 쏟아지고, 테스트 하나 짜려고 DB/Slack/OpenAI를 전부 mock해야 한다.

리팩토링: 책임을 actor별로 자른다

# ✅ 책임 분리
# prompt_builder.py  (Prompt 엔지니어 담당)
class PromptBuilder:
    def build(self, user_msg: str) -> list[dict]:
        return [
            {"role": "system", "content": "너는 친절한 상담원이다..."},
            {"role": "user", "content": user_msg},
        ]
 
# llm_client.py  (ML 엔지니어 담당)
class LLMClient:
    def __init__(self, client, model: str):
        self.client = client
        self.model = model
 
    def complete(self, messages: list[dict]) -> LLMResponse:
        resp = self.client.chat.completions.create(
            model=self.model, messages=messages
        )
        return LLMResponse(
            text=resp.choices[0].message.content,
            tokens=resp.usage.total_tokens,
        )
 
# cost_tracker.py  (FinOps 담당)
class CostTracker:
    RATE = 0.00001
    THRESHOLD = 0.1
 
    def calc(self, tokens: int) -> float:
        return tokens * self.RATE
 
    def is_expensive(self, cost: float) -> bool:
        return cost > self.THRESHOLD
 
# conversation_repo.py  (데이터 엔지니어 담당)
class ConversationRepository:
    def __init__(self, db):
        self.db = db
 
    def save(self, user_id: str, msg: str, resp: str, cost: float) -> None:
        cur = self.db.cursor()
        cur.execute(
            "INSERT INTO conversations (user_id, msg, resp, cost) VALUES (%s,%s,%s,%s)",
            (user_id, msg, resp, cost),
        )
        self.db.commit()
 
# alerter.py  (운영팀 담당)
class SlackAlerter:
    def __init__(self, client, channel: str):
        self.client = client
        self.channel = channel
 
    def alert_high_cost(self, cost: float) -> None:
        self.client.post(self.channel, f"⚠️ 고비용 대화: ${cost}")
 
# formatter.py  (프론트엔드 담당)
class ResponseFormatter:
    def format(self, answer: str, tokens: int) -> str:
        return f"[답변]\n{answer}\n\n(사용 토큰: {tokens})"
 
# chat_agent.py  (조립만 담당)
class ChatAgent:
    def __init__(self, prompt, llm, cost, repo, alerter, formatter):
        self.prompt = prompt
        self.llm = llm
        self.cost = cost
        self.repo = repo
        self.alerter = alerter
        self.formatter = formatter
 
    def handle(self, user_msg: str, user_id: str) -> str:
        messages = self.prompt.build(user_msg)
        resp = self.llm.complete(messages)
        cost = self.cost.calc(resp.tokens)
        self.repo.save(user_id, user_msg, resp.text, cost)
        if self.cost.is_expensive(cost):
            self.alerter.alert_high_cost(cost)
        return self.formatter.format(resp.text, resp.tokens)

리팩토링 후 얻은 것

변경 요구수정할 파일
”system prompt 문구 바꿔달라”prompt_builder.py 한 곳
”GPT-4o 대신 Claude 써보자”llm_client.py 한 곳 (또는 새 LLMClient 구현체 추가)
“Slack 대신 Discord로 알림”alerter.py 한 곳
”비용 임계치 0.1 → 0.05”cost_tracker.py 한 곳

각 actor의 수정이 다른 actor의 코드를 건드리지 않는다. merge conflict도 줄고, 테스트도 각자 고립된 상태로 짤 수 있다.


5. AI Agent 특화 가이드라인

5.1 LLM 호출 컴포넌트는 반드시 단독 분리

LLM 호출은 가장 자주 바뀌는 영역이다. 모델명 변경, SDK 버전업, 온도·토큰 파라미터 튜닝, 재시도·타임아웃 정책 등 actor가 많다. 반드시 단독 클래스(예: LLMClient, ChatModel)로 분리하고, 나머지 로직과 섞지 마라.

5.2 Prompt는 코드가 아니라 데이터로 취급

Prompt 문자열을 코드 중간에 박아두면 “프롬프트 엔지니어 actor”의 수정이 LLM 호출 로직과 엉킨다.

# ❌ prompt가 코드에 박힘
def summarize(text):
    resp = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {"role": "system", "content": "너는 요약가이다. 3문장 이내로..."},
            {"role": "user", "content": text},
        ],
    )
 
# ✅ prompt를 별도 파일/클래스로
# prompts/summarize.md 또는 PromptRegistry.get("summarize")

5.3 Tool은 하나의 Tool = 하나의 클래스

Agent에 Tool(검색, 계산, 코드 실행 등)을 붙일 때, 하나의 Tool 클래스가 “검색 + 파싱 + 캐싱 + 로깅”을 다 하면 SRP 위반이다. SearchTool은 검색만 하고, 캐싱은 CachedTool 데코레이터로 감싸는 식이 깔끔하다.

5.4 Memory/Retrieval은 “읽기”와 “쓰기”가 다른 actor

벡터 DB에서 검색(읽기)과 저장(쓰기)은 다른 팀의 요구로 바뀐다.

  • 읽기 쪽은 “검색 품질” actor(리콜/정확도 튜닝)
  • 쓰기 쪽은 “파이프라인” actor(청킹 전략, 임베딩 모델 변경)

VectorStore 인터페이스는 유지하되, 내부 구현은 VectorReaderVectorWriter로 나눠두면 CQRS(Command Query Responsibility Segregation) 패턴과 자연스럽게 맞물린다.


6. 다른 SOLID 원칙과의 포함 관계

  • SRP → OCP: 책임이 분리돼 있어야 새 책임을 별도 클래스로 “확장”할 수 있다. 덩어리진 클래스에서는 확장이 곧 수정이 된다.
  • SRP → ISP: 인터페이스를 작게 쪼개려면 먼저 구현 클래스 수준에서 책임이 나뉘어 있어야 한다.
  • SRP ← DIP: 추상화에 의존하려면 구체 클래스가 단일 책임을 가져야 추상 인터페이스도 단순해진다.
    SRP (책임 분리)
      │
      ├─→ ISP (인터페이스 작게)
      │
      └─→ OCP (확장 가능)

SRP는 모든 SOLID 원칙의 출발점이다. SRP가 안 지켜지면 다른 네 가지도 실현 불가능하다.


7. 실전 체크리스트

AI Agent 코드를 짜면서 스스로 던질 질문.

  • 이 클래스가 바뀔 이유를 3개 이상 말할 수 있는가? (있다면 쪼개라)
  • 이 클래스 테스트하려고 mock이 몇 개 필요한가? (4개 이상이면 쪼개라)
  • 클래스 이름이 “뭐 하는 놈”인지 한 단어로 말할 수 있는가? (못 하면 쪼개라)
  • Prompt 엔지니어가 프롬프트만 바꾸고 싶을 때, 이 파일을 건드려야 하는가? (그렇다면 쪼개라)
  • LLM Provider를 교체할 때, Agent 클래스를 수정해야 하는가? (그렇다면 쪼개라)

8. 흔한 오해와 함정

오해 1: “클래스를 최대한 잘게 쪼개는 게 SRP다”

아니다. 너무 쪼개면 오히려 “산탄총 수술(shotgun surgery)” 반대 방향의 안티패턴, “기능 파편화(feature envy)” 가 생긴다. 한 번의 논리적 변경이 10개 클래스를 순회하게 되면 그것도 문제.

기준: 같은 actor가 같은 이유로 바꿀 코드는 한 곳에 모으고, 다른 actor의 코드는 다른 곳에 둔다. actor 수만큼 나눠라.

오해 2: “한 클래스 = 한 메서드”

SRP는 메서드 수와 무관하다. LLMClientcomplete(), stream(), embed()를 다 갖고 있어도, 전부 “LLM 공급자 actor”가 바꾸는 영역이면 한 클래스에 두는 게 옳다.

오해 3: “SRP는 작은 프로젝트에선 오버엔지니어링”

소규모 스크립트에는 맞는 말이다. 하지만 AI Agent 프로젝트는 거의 항상 LLM + DB + Vector Store + Tool + 외부 API 조합이라 actor가 기본 5명 이상이다. 처음부터 SRP로 설계하는 편이 결국 빠르다.


9. 한마디 요약

“책임은 ‘기능’이 아니라 ‘누가 수정을 요구하느냐’로 나눈다. Actor가 겹치지 않으면 한 클래스, 겹치면 여러 클래스.”

관련 문서:

참고 원문: