[OOP 11] 단일 책임 원칙 SRP

단일 책임 원칙(SRP, Single Responsibility Principle)은 "한 클래스는 한 가지 책임만 져야 한다"는 규칙이다. 여기서 책임이란 "변경의 이유"라고 이해하면 정확하다. 한 클래스가 데이터 보관도 하고, 데이터베이스 저장도 하고, 이메일 발송도 하고, 검증도 하면, 그 클래스는 바뀔 이유가 네 개다. 저장 방식이 바뀌어도, 메일 방식이 바뀌어도, 검증 규칙이 바뀌어도 전부 이 거대한 클래스를 건드려야 한다. 하나를 고치다 다른 셋을 실수로 망가뜨리기 쉽다.


이렇게 여러 책임을 혼자 떠안은 클래스를 흔히 갓 클래스(god class, 모든 것을 다 하는 만능 클래스)라 부른다. 만능처럼 보이지만 실은 손대기 무서운 지뢰밭이다. SRP는 이 지뢰밭을 책임별로 쪼개서, 각 클래스가 오직 한 가지 이유로만 바뀌게 만든다. before와 after를 나란히 본다.

11.1 네 가지 일을 하는 클래스

User 클래스가 사용자 데이터를 갖고, 데이터베이스에 저장하고, 이메일을 보내고, 검증까지 한다. 책임이 네 개다. before 코드다.


class UserGod:

def __init__(self, name, email):

self.name = name

self.email = email

def save_to_db(self):

return f"DB에 {self.name} 저장"

def send_email(self):

return f"{self.email}로 메일 발송"

def validate(self):

return "@" in self.email


u = UserGod("철수", "[email protected]")

print(u.save_to_db(), "/", u.send_email(), "/", u.validate())


실행 결과다.


DB에 철수 저장 / [email protected]로 메일 발송 / True


코드는 잘 돌아간다. 문제는 유지보수다. 데이터베이스를 다른 것으로 바꿔도, 메일 서버를 교체해도, 검증 규칙을 강화해도 전부 이 UserGod 하나를 열어야 한다. 변경의 이유가 여러 개라는 것이 곧 위험 신호다. 서로 무관한 이유들이 한 파일에서 뒤엉키면, 이메일 코드를 고치다 저장 로직을 건드리는 사고가 난다.

11.2 책임을 넷으로 쪼개기

UserGod를 책임 단위로 나눈다. 데이터는 User, 저장은 UserRepository, 발송은 EmailService, 검증은 UserValidator가 맡는다. after 코드다.


class User:

def __init__(self, name, email):

self.name = name

self.email = email

class UserRepository:

def save(self, user):

return f"DB에 {user.name} 저장"

class EmailService:

def send(self, user, msg):

return f"{user.email}로 '{msg}' 발송"

class UserValidator:

def is_valid(self, user):

return "@" in user.email


user = User("철수", "[email protected]")

print(UserRepository().save(user))

print(EmailService().send(user, "환영"))

print(UserValidator().is_valid(user))


실행 결과다.


DB에 철수 저장

[email protected]로 '환영' 발송

True


무엇이 좋아졌나. 각 클래스가 오직 한 가지 이유로만 바뀐다. 데이터베이스가 바뀌면 UserRepository만, 메일 서버가 바뀌면 EmailService만, 검증 규칙이 바뀌면 UserValidator만 손댄다. 이메일 코드를 고치다가 저장 로직을 망가뜨릴 길이 애초에 없다. 게다가 각 클래스를 따로 테스트할 수 있다. 검증만 시험하고 싶으면 UserValidator 하나만 만들어 확인하면 된다. User는 이제 순수하게 데이터를 담는 그릇으로 가벼워졌다.

11.3 포맷과 저장을 나누기

같은 냄새는 보고서 클래스에서도 흔하다. 보고서가 내용을 포맷하는 일과 파일로 저장하는 일을 둘 다 떠안는다. before 코드다.


class ReportGod:

def __init__(self, data):

self.data = data

def format(self):

return "보고서: " + ", ".join(self.data)

def save(self, text):

return f"파일에 저장: {text}"

rg = ReportGod(["매출", "이익"])

print(rg.save(rg.format()))


포맷과 저장은 서로 다른 이유로 바뀐다. 보고서 서식이 바뀌는 것과 저장 위치가 파일에서 클라우드로 바뀌는 것은 완전히 다른 사건이다. 둘을 갈라놓는다. after 코드다.


class ReportFormatter:

def format(self, data):

return "보고서: " + ", ".join(data)

class FileSaver:

def save(self, text):

return f"파일에 저장: {text}"

data = ["매출", "이익"]

text = ReportFormatter().format(data)

print(FileSaver().save(text))


두 코드 모두 같은 결과를 낸다.


파일에 저장: 보고서: 매출, 이익


무엇이 좋아졌나. 저장 위치를 파일에서 클라우드로 바꾸고 싶으면 FileSaver 대신 다른 저장기를 끼우면 그만이다. 포맷 로직은 손대지 않고 그대로 재사용한다. ReportFormatter는 저장 방식을 전혀 모르고, FileSaver는 포맷을 전혀 모른다. 서로 독립이라 한쪽 변경이 다른 쪽으로 번지지 않는다.

11.4 책임을 나누는 감각

정리한다. SRP의 핵심 질문은 "이 클래스가 바뀌는 이유가 몇 개인가"이다. 이유가 둘 이상이면 그 이유들을 각각 다른 클래스로 떼어낸다. 데이터를 담는 일, 저장하는 일, 보내는 일, 검증하는 일, 포맷하는 일은 모두 서로 다른 이유로 바뀌므로 서로 다른 클래스가 맡아야 한다.


책임이 뭉쳐 있는지 알아보는 쉬운 방법이 있다. 그 클래스가 하는 일을 한 문장으로 설명해 보는 것이다. "사용자 데이터를 담는다"처럼 끝나면 책임이 하나다. 그런데 "사용자 데이터를 담고, 저장하고, 메일을 보낸다"처럼 문장에 "그리고"가 끼면, 그 "그리고"의 개수만큼 책임이 뭉쳐 있다는 뜻이다. 클래스 이름을 지을 때도 마찬가지다. UserManager나 UserHandler처럼 두루뭉술한 이름은 대개 여러 책임을 감추고 있다. UserRepository, UserValidator처럼 이름만 봐도 하는 일이 하나로 좁혀지는 클래스가 SRP를 지키는 클래스다.


책임을 나누면 세 가지가 좋아진다. 첫째, 변경이 한 클래스 안에 갇혀 다른 곳으로 번지지 않는다. 둘째, 클래스마다 따로 테스트할 수 있다. 셋째, 각 클래스가 작고 이름이 명확해져 읽기 쉬워진다. 다만 지나친 분리도 경계한다. 아직 이유가 하나뿐인 것을 미리 여러 조각으로 쪼개면 오히려 복잡해진다. 변경의 이유가 실제로 갈라질 때 그 선을 따라 나누는 것, 그것이 SRP를 남용하지 않으면서 제대로 쓰는 감각이다.