본문 바로가기

카테고리 없음

[OOP] SOLID (1) 단일 책임 원칙 (Single Responsibility Principle)

SOLID?

OOP의 5대 원칙으로 SRP(단일 책임 원칙), OCP(개방-폐쇄 원칙), LSP(리스코프 치환 원칙), DIP(의존 역전 원칙), ISP(인터페이스 분리 원칙)을 말하며, 프로그래머가 시간이 지나도 유지 보수와 확장이 쉬운 소프트웨어를 만드는 데 적절한 방향성을 제공한다. 

 

소프트웨어 개발을 하다 보면 2가지의 갈림길에 서 있는 경우가 있다. 바로 성능유지보수 중 어디에 중점을 둬야 할까? 성능에 눈이 쏠리면 최적화가 일어나고, 반대로 유지보수는 코드가 돌아간다. 개인마다 성향이 다르겠지만 나의 생각으론 유지보수에 중점을 둔 소프트웨어가 웃지 않을까 싶다.

 

이유는 간단하다. 변화나 장애에 마추질 때, 야근을 원하는 개발자가 있을까?

단일 책임 원칙이란

객체는 단 하나의 책임만 가져야 한다는 원칙을 말한다. 이어 한 가지의 변경 사항이 발생되면 프로젝트 내 해당 책임을 가지는 객체만이 수정돼야 한다는 의미로도 이어진다.

 

그럼 여기서 말하는 "책임"이란 어떤 것일까? 하나의 기능? 하나의 주제? 그건 개발자가 정하기 나름이다.

 

잠깐 OSI 7계층을 떠올려본다. 7계층 또한 각각의 레이어마다 맡고 있는 책임이 있다. 이와 같은 맥락으로 단일 책임 원칙을 바라보면 쉬울 듯하다.

 

이렇게 되면 단일 책임 원칙을 준수해야 하는 이유 중 한 가지가 벌써 보인다. OSI 7계층으로 나눈 이유는 계층을 통신이 일어나는 과정이 단계별로 파악할 수 있기 때문이다. 흐름을 한눈에 알아보기 쉽고, 사람들이 이해하기 쉽고, 7단계 중 특정한 곳에 이상이 생기면 다른 단계의 장비 및 소프트웨어를 건드리지 않고도 이상이 생긴 단계만 고칠 수 있기 때문이다.

 

OOP에서 또한 마찬가지이다. 어떤 장애가 발생했을 때 해당 장애와 관련된 클래스가 1개라면 대응하기에 수월해지고, 야근 확률이 줄어든다.

단일 책임 원칙을 준수하지 않는다면

단일 책임 원칙이란 단어답게, 다양하게 생각할 수 있다.

1. 클래스가 2개의 책임을 지니고 있을 때

class GpsManager {
    fun getCurrentLocation(): Location {
        val location = Location()
        // 로직
        return location
    }
    
    fun getAroundToilet(): Toilet {
        val currentLocation = getCurrentLocation()
        val toilet = Toilet()
        // 로직
        return toilet
    }
}

위는 클래스 이름만 봐도 알 수 있듯이 위치에 대한 기능을 담당하는 클래스다. 현재 위치를 기반으로 주변 화장실을 가져오는 기능을 개발한다 했을 때, 위처럼 GpsManager안에 getAroundToilet이라는 성질이 다른듯한 메서드를 추가로 생성할 가능성이 적어도 나 같은 신입 개발자는 다분하다. 

 

일단 책임이라는 추상화된 개념에 옳고 그름을 따지긴 어렵지만 적어도 GpsManager라는 클래스안에는 들어가기 부적합해 보인다. 이는 어떤 문제가 발생할까?

 

사실 단일 책임을 위반했다는 사실 외에 문제될 것이 없어 보인다. 하지만 이는 습관으로 이어질 가능성이 높다. 과연  getAroundToilet 와 같은 기능을 하는 메서드가 다른 파일엔 없을까?

 

추후에 주변 화장실을 가져오는 기능에서 변화가 생겨 주변 범위를 넓혔다고 치면, 변경사항은 한 가지인데 여기저기 흩어져 있을 getAroundToilet에 대한 수정이 진행되어야 할 것이다. 또한 어디는 getNearRestroom이라는 이름으로 존재하고 있다면 찾기도 힘들 것이다.

 

2. 메서드가 2개 이상의 기능을 할 때

관련 정보를 찾아 보면 주로 클래스에 대한 이야기로 설명한다. 나는 단일 책임 원칙은 클래스뿐 아니라 메서드에도 적용되면 충분히 좋다고 생각한다.

class ViewModel {
    fun getTodayDiary() {
        // 1. 오늘 날짜 계산
        // 2. 계산한 날짜로 서버에 요청
        // 3. 뷰에 알맞게 mapping 작업
        return
    }
}

오늘 작성했던 일기를 가져오는 메서드이다. 언뜻 보면 누가 이렇게 코드 짜나 싶지만 적어도 나는 이런 식으로 코드를 자주 작성했었다.

 

일단 위 코드는 하나의 메서드가 가지고 있는 책임이 3개일 뿐더러 코드의 양 또한 길어질게 뻔해 보여 가독성도 낮아질 예정이다. 그보다 원론적인 문제는 오늘이 아닌 어제, 엊그제와 같이 다른 날짜의 일기도 필요한 상황이 충분히 올만하다. 또한 mapping된 데이터가 아닌 row한 데이터가 필요한 상황도 올 것이다.

 

이를 방지하려면 3개의 기능은 각각 다른 메서드로 분리할 필요가 있어 보인다.

class ViewModel {
	fun getTimestamp(when: String): Long {
    	val timestamp = ~
    	// 로직
        return timestamp
    }
    fun getDiary(timestamp: Long): Diary {
        val diary = ~
        // 로직
        return diary
    }
    fun mapDiary(): DiaryDto {
    	val dto = ~
        // 로직
        return dto
    }
}

이제 하나의 책임을 가지고 있다. 그리고 이를 사용하는 클래스 쪽에서 전략 패턴과 같이 퍼즐 맞추는 방식으로 코드가 흘러가야 모듈화의 이점을 가져올 수 있다.

 

UseCase

단일 책임 원칙에 가장 좋은 예가 UseCase 아닐까 싶다. 흔히 Android 진영의 클린 아키텍처 예제를 살펴보면 ~UsaCase라는 클래스들이 보인다.

 

처음엔 뭐 하러 클래스를 저렇게 낭비하면서 메서드처럼 사용하는 거지 싶었는데, 사용하다 보니 가독성이 올라가면서 단일 책임 원칙에 매우 부합하지 않나 싶었다.

class GetDiaryUseCase {
	operator fun invoke(timestamp: Long) {
    	// 로직
    }
}

위처럼 클래스의 이름이 마치 메서드처럼 이뤄졌다. 이어서 invoke를 정의하여 특별한 호출자 없이 invoke만 시켜도 로직이 수행되는 흐름이다. 

 

이는 해당 클래스에서 다른 기능을 추가하는 일을 매우 방지시켜 준다. 평소 같았으면 GetDiaryUseCase에 Diary에 관한 다른 메서드를 추가하고도 남았을 텐데 위 invoke라는 메서드와 클래스 이름이 주는 압박감이 있다 보니 허튼짓을 안 하게 된다. 그러면서 자연스레 "나 단일 책임 원칙 잘 지킵니다!"를 외칠 수 있다.