Home 잘못 이해하고 있었던 단일 책임의 원칙
Post
Cancel

잘못 이해하고 있었던 단일 책임의 원칙

글을 작성하게 된 계기


SOLID 원칙 중, 단일 책임 원칙에 대해 잘못 이해하고 있던 내용 을 정리하기 위해 글을 작성하게 되었습니다.





1. 어떻게 잘못 이해하고 있었을까?


이때까지 단일 책임의 원칙을 클래스는 하나의 책임만 가져야 한다 라고 이해하고 있었습니다. 즉, 아래와 같은 코드는 단일 책임 원칙을 위반한 코드라고 생각했죠. 저장, 업데이트, 삭제 등 다양한 책임을 가지고 있으니까요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Service
class MerchantSaveService(
    private val merchantRepository: MerchantRepository
) {

    @Transactional
    fun save(merchant: Merchant) { /* 저장 로직 */ }

    @Transactional
    fun update(merchantId: Long, merchantUpdateCommand: MerchantUpdateCommand) { /* 업데이트 로직 */ }

    @Transactional
    fun delete(merchantId: Long) { /* 삭제 로직 */ }
}




그런데 제가 잘못 이해한 대로 단일 책임의 원칙을 지키는 코드를 작성하면 다음과 같습니다. 각 클래스는 하나의 메서드만 가져야 하죠. 이렇게 이상하게 단일 책임의 원칙을 이해하고 말도 안되는 이론이라고 생각했죠. 책도 딱 한 권만 읽은 놈이 제일 무섭다 고, 방귀뀐 놈이 성내고 있었습니다. 👀

1
2
3
4
5
@Service
class MerchantSaveService {
    @Transactional
    fun save(merchant: Merchant) { /* 저장 로직 */ }
}
1
2
3
4
5
@Service
class MerchantUpdateService {
    @Transactional
    fun update(merchantId: Long, merchantUpdateCommand: MerchantUpdateCommand) { /* 업데이트 로직 */ }
}
1
2
3
4
5
@Service
class MerchantDeleteService {
    @Transactional
    fun delete(merchantId: Long) { /* 삭제 로직 */ }
}







2. 단일 책임 원칙의 올바른 이해


단일 책임의 원칙의 정의는 다음과 같습니다. 모듈은 하나의 액터에 대해서만 책임을 가져야 한다. 책임이란 어떤 변경이 일어났을 때 그 변경의 이유가 오직 하나 여야 한다는 의미입니다. 그런데 여기서 말하는 변경의 이유액터(Actor) 란 무엇일까요?

The single-responsibility principle(SRP) is a computer programming principle that states that “A module should be responsible to one, and only one, actor. The term actor refers to a group (consisting of one or more stakeholders or Merchants) that requires a change in the module.




먼저 액터를 살펴보겠습니다. 액터는 시스템에서 변경을 일으키는 사람, 집단, 외부 시스템 등 변경의 주체 를 의미합니다. 예를 들어, 주소, 사업자 등록번호 등 가맹점의 기본 정보는 보통 운영팀, 관리자 등 내부 직원이 변경합니다. 이 경우, 내부 직원이 바로 해당 모듈의 액터가 됩니다.

image




반면, 가맹점 평점이나 리뷰와 같은 정보는 외부 사용자가 직접 입력하거나 수정하는데요, 이때는 외부 고객이 액터가 됩니다. 물론 운영팀, 관리자 등 내부 직원도 액터구요.

image




즉, 액터란 변경을 요청하는 주체 로, 변경의 이유 를 만들어내는 집단 입니다. 이때의 주체는 단순히 특정 인물이나 부서를 넘어서, 시스템의 다양한 이해관계자(Stakeholder) 전체를 의미할 수 있습니다. 예를 들어, 서비스 운영팀, 외부 고객, 심지어 자동화된 외부 시스템 등도 모두 각각의 액터가 될 수 있습니다.

각 액터는 서로 다른 요구사항과 관심사를 가지고 있고, 이로 인해 동일한 코드나 기능에 대해서도 서로 다른 변경 요청이 발생할 수 있습니다.




따라서 변경의 주체가 다를 때는, 각 주체별로 별도의 모듈이나 클래스로 분리하는 것이 바람직합니다. 이를 통해 서로 다른 요구나 변경이 독립적으로 관리될 수 있고, 한쪽의 변경이 다른 쪽에 불필요하게 영향을 미치는 일을 방지할 수 있으니까요. 이게 단일 책임의 원칙이 말하는 변경의 이유가 오직 하나 라는 의미입니다. 즉, 각 액터가 변경을 요청할 때, 그 변경이 해당 액터에만 국한되고, 다른 액터에는 영향을 주지 않도록 하는 것이죠.

  • 서로 다른 요구나 변경이 독립적으로 관리
  • 한쪽의 변경이 다른 쪽에 불필요하게 영향을 미치는 일 방지







3. 왜 변경의 단위가 액터일까?


액터를 기준으로 책임을 분리하는 이유는 뻔한 이야기지만 코드의 안정성유지보수성 을 높이기 위해서입니다. 만약 한 클래스에 여러 이해관계자의 요구사항이 섞여 있다면, 즉, 여러 액터의 요구사항이 한 클래스에 있으면 우발적 결합(Accidental Coupling) 문제가 발생합니다.


예를 들어, 하나의 클래스에서 가맹점의 기본 정보와 가맹점의 평점, 리뷰를 모두 함께 관리한다고 해보겠습니다. 여기서 가맹점 기본 정보는 내부 직원 이 변경하고, 평점이나 리뷰는 외부 사용자 가 직접 입력하거나 수정할 수 있습니다. 이 경우, 내부 직원용 코드를 수정하다가, 관련 없는 외부 사용자 로직이 영향을 받을 수 있죠. 이처럼 서로 다른 액터를 위한 코드가 하나의 클래스에 섞여 있으면, 한쪽의 변경이 다른 쪽에 우발적으로 결합되어, 예기치 않은 오류가 발생할 위험이 커집니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Service
class MerchantUpdateService {
    
    @Transactional
    fun update(
        merchantId: Long,
        command: MerchantUpdateCommand
    ) {
        // 내부 직원용 정보 업데이트
        // ...
        
        // 외부 고객 평점도 같이 처리 (책임이 섞임)
        // ...
    }
}




이 경우 별도의 클래스로 나누는게 맞겠죠.

1
2
3
4
5
6
7
8
9
10
11
@Service
class MerchantUpdateService {
    
    @Transactional
    fun updateInfo(
        merchantId: Long,
        command: MerchantUpdateCommand
    ) {
        // 내부 직원이 사용하는 정보 수정 로직
    }
}
1
2
3
4
5
6
7
8
9
10
11
@Service
class MerchantRatingService {
    
    @Transactional
    fun addRating(
        merchantId: Long,
        command: MerchantRatingUpdateCommand
    ) {
        // 외부 고객이 사용하는 평점 등록 로직
    }
}







4. 인터페이스로 추상화하면 안될까?


액터가 다르지만 같은 인터페이스를 공유 하는 경우, 즉, 하나의 인터페이스에 별도의 구현체를 둘 경우, 관심사가 다른 코드가 섞여서 발생하는 문제는 사라집니다. 하지만 이 경우, 인터페이스를 수정할 때, 관련 없는 구현체에 영향 이 갈 수 있습니다. 수정하지 않아도 되는 구현체에도요.

1
2
3
interface MerchantUseCase {
    fun update(merchantId: Long, merchantUpdateCommand: MerchantUpdateCommand)
}
1
2
3
4
5
6
7
8
9
10
11
@Service
class MerchantUpdateService : MerchantUseCase {
    
    @Transactional
    fun update(
        merchantId: Long,
        command: MerchantUpdateCommand
    ) {
        // 내부 직원이 사용하는 정보 수정 로직
    }
}
1
2
3
4
5
6
7
8
9
10
11
@Service
class MerchantRatingService : MerchantUseCase {
    
    @Transactional
    fun update(
        merchantId: Long,
        command: MerchantUpdateCommand
    ) {
        // 외부 고객이 사용하는 평점 등록 로직
    }
}




서로 다른 액터를 위한 기능이 동일 인터페이스로 추상화 되었기 때문에 인터페이스의 변경 이 발생하면 서로 관련 없는 액터의 서비스가 함께 영향 을 받기 때문입니다.

1
2
3
4
interface MerchantUseCase {
    // userId가 추가
    fun update(userId: Long, merchantId: Long, merchantUpdateCommand: MerchantUpdateCommand)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
class MerchantUpdateService : MerchantUseCase {

    /* 내부 직원용 구현체는 userId가 필요 없더라도 
        인터페이스를 공유하기 때문에 인자로 받을 수 밖에 없음. 
    */
    @Transactional
    fun update(
        userId: Long,
        merchantId: Long,
        command: MerchantUpdateCommand
    ) {}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
class MerchantRatingService : MerchantUseCase {

    /* 외부 사용자용 구현체는 추가 요구사항 구현을 위해 
       userId가 반드시 필요. 
    */
    @Transactional
    fun update(
        userId: Long,
        merchantId: Long,
        command: MerchantUpdateCommand
    ) {}
}







5. 메서드만 분리하면 되지 않을까?


여기서 메서드만 분리하면 되지 않을까? 라는 생각을 할 수도 있는데요, 맞습니다. 사실 각 액터가 사용하는 메서드를 나누면 문제는 해결 됩니다. 그런데 개발하다보면 괴물 클래스 를 한 번쯤은 보셨을 텐데요, 처음에는 이 코드 한 줄은 괜찮겠지? 라는 생각으로 메서드를 추가했을 겁니다. 이러다가 불필요한 의존성 이 생기고, 한 클래스가 너무 많은 책임 을 가지게 되죠.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
@Service
class MerchantService(
    private val merchantRepository: MerchantRepository,
    private val reviewService: ReviewRepository,
    private val notificationService: NotificationService,
    private val settlementService: SettlementService,
    private val promotionService: PromotionService,
) {

    // 내부 직원/운영팀용
    @Transactional
    fun updateInfo(merchantId: Long, command: MerchantUpdateCommand) { /* 기본 정보 수정 */ }

    @Transactional
    fun approveMerchant(merchantId: Long) { /* 가맹점 승인 */ }

    @Transactional
    fun rejectMerchant(merchantId: Long, reason: String) { /* 가맹점 승인 거절 */ }

    @Transactional
    fun suspendMerchant(merchantId: Long) { /* 영업정지 */ }

    @Transactional
    fun resumeMerchant(merchantId: Long) { /* 영업정지 해제 */ }

    @Transactional
    fun changeBusinessType(merchantId: Long, businessType: String) { /* 업종 변경 */ }

    @Transactional
    fun updateSettlementAccount(merchantId: Long, account: String) { /* 정산 계좌 변경 */ }

    @Transactional
    fun issueWarning(merchantId: Long, reason: String) { /* 경고장 발송 */ }
    
    @Transactional
    fun updateOperatingHours(merchantId: Long, open: String, close: String) { /* 영업시간 변경 */ }

    @Transactional
    fun updateManagerContact(merchantId: Long, contact: String) { /* 담당자 연락처 변경 */ }

    @Transactional
    fun addTag(merchantId: Long, tag: String) { /* 태그 추가 */ }

    @Transactional
    fun removeTag(merchantId: Long, tag: String) { /* 태그 삭제 */ }

    @Transactional
    fun updatePromotion(merchantId: Long, promotionId: Long) { /* 프로모션 정보 수정 */ }

    @Transactional
    fun deleteMerchant(merchantId: Long) { /* 가맹점 삭제 */ }

    @Transactional
    fun resetPassword(merchantId: Long) { /* 관리자 비밀번호 초기화 */ }

    @Transactional
    fun assignCategory(merchantId: Long, category: String) { /* 카테고리 할당 */ }
    
    // 외부 고객/사용자용
    @Transactional
    fun addRating(merchantId: Long, rating: Int) { /* 평점 등록 */ }

    @Transactional
    fun addReview(merchantId: Long, review: String) { /* 리뷰 등록 */ }

    @Transactional
    fun updateReview(merchantId: Long, reviewId: Long, content: String) { /* 리뷰 수정 */ }

    @Transactional
    fun deleteReview(merchantId: Long, reviewId: Long) { /* 리뷰 삭제 */ }

    @Transactional
    fun reportReview(merchantId: Long, reviewId: Long) { /* 리뷰 신고 */ }

    @Transactional
    fun requestCoupon(merchantId: Long) { /* 쿠폰 발급 신청 */ }

    @Transactional
    fun useCoupon(merchantId: Long, couponCode: String) { /* 쿠폰 사용 처리 */ }

    @Transactional
    fun registerFavorite(merchantId: Long, userId: Long) { /* 즐겨찾기 등록 */ }

    @Transactional
    fun removeFavorite(merchantId: Long, userId: Long) { /* 즐겨찾기 해제 */ }
    
    // 내부/외부 공통 또는 자동화
    @Transactional
    fun archiveMerchant(merchantId: Long) { /* 휴면 가맹점 처리 */ }

    @Transactional
    fun blacklistMerchant(merchantId: Long, reason: String) { /* 블랙리스트 등록 */ }

    @Transactional
    fun removeBlacklist(merchantId: Long) { /* 블랙리스트 해제 */ }

    @Transactional
    fun requestRefund(merchantId: Long, orderId: Long) { /* 환불 요청 */ }

    @Transactional
    fun approveRefund(merchantId: Long, orderId: Long) { /* 환불 승인 */ }
}




모든 시스템에는 역할과 책임 이 있어야 하며, 경계 가 확실해야 합니다. 이를 지키지 않는다고 해서 시스템이 고장나진 않지만, 시스템이 커지고 복잡해질수록 책임과 역할이 명확하지 않은 코드 는 유지보수하기 어려워지니까요. 어디서 버그가 발생했는지 찾기도 힘들고, 찾더라도 수정하기가 힘들어 시간이 한참 걸리고요. 가급적 단방향 의존성으로, 각자의 역할과 책임을 명확히 하는 것이 좋습니다.

image





6. 정리


단일 책임의 원칙에 대해 잘못 이해하고 있던 부분을 정리해 보았습니다. 단일 책임의 원칙은 클래스가 하나의 액터에 대해서만 책임을 가져야 한다 는 의미로, 액터란 변경을 요청하는 주체를 의미합니다. 이를 통해 서로 다른 액터의 요구사항이 섞이지 않도록 하고, 우발적 결합 문제를 방지할 수 있습니다. 3년 전 처음 알았던 단일 책임의 원칙인데, 몇 년을 돌고 돌아 이제서야 제대로 이해가 됐네요. 요즘 들어 이렇게 과거에 이해하지 못했던 개념이 갑자기 이해되는 경우가 많은데, 좋은 징조이길. 🚀


This post is licensed under CC BY 4.0 by the author.

레디스 복제 동기화: PSYNC

컴퓨터의 수 표현 방식과 Decimal을 사용하는 이유