개발/안드로이드

클린 아키텍처의 기본 개념 (1)

스몰스테핑 2024. 8. 7. 17:39

아키텍처 설계와 원칙

1. 아키텍처란(Architecture)?

소프트웨어에서 말하는 아키텍처란, 해당 소프트웨어를 구현한 시스템의 형태를 말하는 것이다. 그러한 형태는 개발, 배포, 운영, 유지보수를 쉽게 할 수 있도록 도와준다.

 

목표는 개발을 할때 투입되는 비용을 최소화하는 것이다.

 

아키텍쳐는 스파게티 코드처럼 코드가 엉망이 되어가는 것을 방지하는 규약이자 약속이다.

일부 개발자들은 지저분하더라도 우선 개발을 빨리 하고 아키텍쳐는 나중에 하자고 한다. 하지만 엉망이 된 코드를 다시 정리하는데 걸리는 시간은 처음부터 깔끔하게 만드는 것보다 배로 시간이 걸릴 것이다.

 

개발하는 사람 본인의 더 중요한 가치가 시간이라면 아키텍처가 더 나은 선택이 될 것이다.

 

2. 설계 원칙 (SOLID)

  • 단일 책임 원칙 (Single Responsibility Principle, SRP)
  • 개방-폐쇄 원칙 (Open-Closed Principle, OCP)
  • 리스코프 치환 원칙 (Liskov Substitution Principle, LSP)
  • 인터페이스 분리 원칙 (Interface Segregation Principle, ISP)
  • 의존성 역전 원칙 (Dependency Inversion Principle, DIP)

 

2-1. 단일 책임 원칙 (SRP)

하나의 클래스는 하나의 기능만 책임진다.

 

예를 들어, 맥가이버 칼은 여러 기능을 하나의 객체가 가지고 있다.

이 기능들을 따로 따로 하나씩만 가지고 있는게 칼, 가위, 코르크따개, 톱 등이라고 말할 수 있을 것이다.

 

실제로 맥가이버 칼로 전부 해당 도구들을 대체할 수는 없다.

개발쪽도 마찬가지인데 이유로는 유지보수나 확장성이 떨어지기 때문이다.

 

하나의 객체가 너무 많은 책임을 가지게 되면 코드가 한 곳에 집중되고, 그 하나의 코드가 다른 곳에도 영향을 줄 수 밖에 없어질 것이다. 이러한 책임을 분배해서 클래스이름을 정할때는 그 책임을 들어낼 수 있는 명확한 이름으로 지정하는 것이 좋다.

 

DRY 원칙이란, 한 프로젝트 내에서 동일한 코드를 반복해서 사용하지 말라는 내용이다.

 

  • 하나의 클래스는 하나의 기능만 책임지고 수행한다.
  • SRP 원칙을 따를 시, 하나의 책임 변경이 다른 책임의 변경으로 전파되지 않는다.
  • 책임을 적절히 분배함으로써 코드 가독성이 좋아지고, 유지보수하기 쉬워진다.
  • 클래스 이름은 책임을 명확하게 드러낼 수 있도록 작명한다.
  • 낮은 결합도와 높은 응집도를 고려한다.

 

2-2. 개방-폐쇄 원칙 (OCP)

어떠한 클래스가 확장에는 열려있고 변경에는 닫혀있다는 것을 의미한다.

여기서 확장이란, 새로운 기능 추가를 말한다.

 

예를 들어, 다음과 같은 예제 코드가 존재한다.

class Animal(val name: String)

class AnimalSpeaker {
    fun speak(animal: Animal) {
        when (animal.name) {
            "Cat" -> println("meow")
            "Dog" -> println("Woof")
        }
    }
}

 

이 코드는 문제가 없어보이지만, 하지만 Animal 클래스를 확장할 때마다, when 조건문 내부의 분기도 추가해줘야한다. 좋지 못한 유지보수와 확장성을 가진 코드가 된다.

 

abstract class Animal(val name: String) {
    abstract fun speak()
}

class Cat: Animal("Cat") {
    override fun speak() = println("Meow")
}

class Dog: Animal("Dog") {
    override fun speak() = println("Woof")
}

 

이렇게 한다면 Animal 클래스를 확장하더라도, Speak을 개별적으로 확장한 클래스에서 사용할 수 있다. 그렇게 Animal이라는 클래스를 내부 코드를 변경하지 않고도 확장할 수 있게 만들면서 개방-폐쇄 원칙을 지키게 만들었다고 할 수 있다.

 

  • 기존의 코드를 변경하지 않으면서 기능을 추가할 수 있어야 한다.
  • 변경사항이 발생했을 때 손쉽게 코드를 추가하여 확장할 수 있다.
  • OCP는 객체 지향 프로그래밍의 추상화를 의미한다.
  • abstract 또는 interface 키워드를 이용한다.
  • 의존 역전 원칙(DIP)의 설계 기반이 된다.

 

2-3. 리스코프 치환 원칙

자식 클래스는 부모 클래스로 대체 가능해야한다.

 

예를 들어, Collection - list - stack, arrayList, linkedList가 있다.

stack을 썼다면, list로 대체가 가능해야 하며, Collection으로도 대체가 가능하다는 이야기다.

stack은 list의 자식 클래스이고, Collection의 자식 클래스이기 때문에.

 

Collection<String> Collection = new ArrayList<>();
collection.add("A");
collection.add("B");
collection.add("C");
System.out.println(collection); // [A, B, C]

collection = new HashSet<>();
collection.add("A");
collection.add("B");
collection.add("C");
System.out.println(collection); // [A, B, C]

 

collection을 ArrayList로 초기화하고, 상위타입인 .add()를 써도 기대한 대로 아무 문제 없이 동작을 수행하는 것을 볼 수 있다. HashSet 예제도 마찬가지이다.

 

이렇게 부모타입에 치환되더라도, 기능동작에 전혀 문제 없는 경우를 리스코프 치환 원칙을 잘 준수했다고 할 수 있다.

 

abstract class Animal(val name: String) {
    abstract fun speak()
}

class Cat: Animal("Cat") {
    override fun speak() = println("Meow")
}

class Dog: Animal("Dog") {
    override fun speak() = println("Woof")
}

class Fish: Animal() {
    override fun speak() = throw Exception("Fish don't cry")
}

 

위에서 사용했던 코드를 다시 써보자.

Fish라는 물고기 클래스는 Animal 클래스가 요구하는 speak 메서드를 오버라이드 하지 못하고 예외를 던지고 있다.

이럴 경우 Fish를 Animal로 치환했을 때, speak 메서드를 호출하면 에러를 발생할 것이니, 치환 원칙을 준수하지 못했다고 할 수 있다.

 

그렇다고 Fish를 Animal 상속을 포기하자니 Fish도 Animal이긴 하다. 그렇다면 speak 메서드의 추상화가 잘못되었다고 판단할 수 있다. 모든 Animal이 울음소리를 내지는 않을 것이기 때문에.

 

  • 부모 클래스와 자식 클래스 사이의 행위가 일관성이 있어야 한다.
  • 부모 클래스 상속 시 정의한 원칙을 그대로 따라야 한다.
  • LSP는 협업 시 개발자 간의 약속을 지키는 원칙이기도 하다.
  • 객체 지향 프로그래밍의 다형성을 의미한다.

 

2-4. 인터페이스 분리 원칙

목적과 용도에 적합한 인터페이스만을 제공한다.

예를 들어, 자주 사용하는 스마트 폰의 인터페이스를 다음과 같이 만들었다고 가정하자.

interface SmartPhone {
    fun sendSMS()
    fun call()
    fun takePicture()
    fun doAirDrop()
    fun doSamsungPay()
}

 

인터페이스 분리 원칙에 따라 스마트폰의 기능을 추상화하고 분리해보면 문제가 발생한다.

 

class iPhone: SmartPhone {
    override fun sendSMS() = println("iPhone SMS send")
    override fun call() = println("iPhone call")
    override fun takePicture() = println("iPhone take Picture")
    override fun doAirDrop() = println("iPhone do AirDrop")
    override fun doSamsungPay() = throw Exception("This feature is not supported on this device.")
}

class Galaxy: SmartPhone {
    override fun sendSMS() = println("Galaxy SMS send")
    override fun call() = println("Galaxy call")
    override fun takePicture() = println("Galaxy take Picture")
    override fun doAirDrop() = throw Exception("This feature is not supported on this device.")
    override fun doSamsungPay() = println("SamsungPay")
}

 

아이폰에만 있는 AirDrop을 지원하지 못하는 Galaxy.

갤럭시에만 있는 SamsungPay를 지원하지 못하는 iPhone

이런 경우 인터페이스 분리원칙을 잘 지키지 못한 것이므로, SmartPhone 인터페이스에서 두 항목을 제거하고 각각 클래스에 각각 기능에 맞게 override가 아니라 메서드를 직접 추가해준다.

 

하지만 계속 그런식으로 추가하는 것이 올바를까?

 

interface SmartPhone {
    fun sendSMS()
    fun call()
    fun takePicture()
}

interface iPhoneInterface {
    fun doAirDrop()
}

class iPhone: SmartPhone {
    override fun sendSMS() = println("iPhone SMS send")
    override fun call() = println("iPhone call")
    override fun takePicture() = println("iPhone take Picture")
    override fun doAirDrop() = println("iPhone do AirDrop")
}

 

이런식으로 변경하게 된다면, 아이폰에서만 사용가능한 기능을 추가하고 추상화 시키는 것이 가능할 것이다.

 

  • 클라이언트의 목적과 용도에 적합한 인터페이스만을 제공.
  • 유연하게 클래스의 기능을 확장하거나 수정할 수 있다.
  • SRP는 클래스의 단일 책임을 강조하고, ISP는 인터페이스의 단일 책임을 강조한다.
  • 인터페이스 분리는 변경사항을 예측하고 설계해야 하는데, 이는 개발자의 경험과 역량이 중요하다.

 

2-5. 의존성 역전 원칙

세부적인 내용을 갖고 있지 않은 추상화 된 상위 수준에 의존해야 한다.

의존성 주입을 하기 위한 초기 단계라고 볼 수 있다.

 

호출자가 인터페이스에 의존한다. 이 인터페이스는 구현체가 없는 고수준의 모듈이다.

해당 인터페이스를 구현한 클래스 A라는 것이 존재하거나 B가 있을 수도 있다. 이런 클래스들은 저수준의 모듈이다.

 

이 호출자가 인터페이스를 요청하는 시점에 저수준에 있는 클래스 A나 B를 주입하게 된다.

그렇게 호출자가 클래스 A, B를 의존하지 않고, 호출자는 인터페이스만 보고, 하위의 구현체들이 상위에 꽂히게 되면서 의존성이 역전된다고 할 수 있다.

 

다음 예제 코드가 그러한 예시이다.

interface Engine

class GasolineEngine: Engine
class DieselEngine: Engine
class Car(val engine: Engine)

val engine = DieselEngine()
// val engine = GasolineEngine()
val car = Car(engine)

 

구현체가 없는 엔진 인터페이스와 그런 엔진을 호출하는 호출자인 자동차.

저수준 모듈인 가솔린엔진과 디젤엔진을 자동차에 넣음으로써 의존성이 역전된다.

 

  • 대상을 참조할 때는 추상화 된 요소(interface, abstract class)를 참조한다.
  • 추상화 된 요소를 구현한 내용이 저수준 클래스(모듈)이 된다.
  • 고수준 클래스는 변경이 적고, 저수준 모듈은 변경이 잦다.
  • 변경에 영향을 덜 받는 구조일수록 재사용성과 확장성 및 유닛테스트가 쉬워진다.