여러가지 활동/프리온보딩 프론트엔드 챌린지

객체지향 프로그래밍과 디자인 패턴

홍수성찬 2024. 7. 18. 22:10

2024년 7월 원티드 프리온보딩 백엔드 챌린지 간단 요약

 

소프트웨어 가치

소프트웨어 개발자가 할 수 있는 일은 새로운 가치를 제공할 수 있고, 변화하는 요구사항에 적응할 수 있는 소프트웨어를 만드는 일입니다.

 

변화에 적응하는 소프트웨어의 특징은 '유연성', '확장성', '유지 보수성'이 있습니다.

 

의존

의존은 어떠한 일을 자신의 힘으로 하지 못하고 다른 어떤 것의 도움을 받아 의지한다는 의미를 갖고 있습니다.

(A → B: A가 B의 의존한다)

 

코드에서는 '객체 참조에 의한 연관 관계', '메서드 리턴타입이나 파라미터로서의 의존 관계', '상속에 의한 의존 관계', '구현에 의한 의존 관계'로 표현할 수 있습니다.

 

객체 참조에 의한 연관 관계

class ClassA {
	var b: ClassB = ClassB()
    
    fun someMethod() {
    	b.someMethod();
    }
    
    class ClassB {
    	fun someMethod() {
        	...
        }
    }
}

 

메서드 리턴 타입이나 파라미터로서의 의존 관계

class ClassA {
    fun methodB(b: ClassB): ClassC {
    	return b.someMethod()
    }
}

class ClassB {
	fun someMethod(): ClassC {
		return ClassC()
	}
}
    
class ClassC {
	...
}

 

상속에 의한 의존 관계

open class SuperClass {
	open fun functionInSuper() {
    	...
    }
}

class subClass: SuperClass {
	override fun functionInSuper() {
    	...변경 사항
    }
}

 

구현에 의한 의존 관계

interface InterfaceA {
	fun functionInInterfaceA()
}

class ClassB: InterfaceA {
	override fun functionInInterfaceA() {
    	...인터페이스 메서드 구현
    }
}

 

의존이 가지고 있는 진짜 의미는 '변경 전파 가능성'입니다. 쉽게 말해, 필요한 의존성만 유지하고, 의존성은 최소화 하자는 말입니다.

 

절차지향 vs 객체지향

절차지향은 프로시저에 중점을 둡니다. 프로그램은 일련의 절차적 단계로 구성되고, 데이터와 프로시저가 별도로 존재합니다.

 

객체지향은 데이터와 기능을 하나의 객체로 묶습니다.

이 두개를 의존으로 본다면 어떻게 해석할 수 있을까? (예시 코드는 강의 자료로 공개 불가)

 

상황을 예시로 들자면,

  1. 순차적으로 입금, 출금, 잔액 출력의 절차를 진행한다.
  2. BankAccount 클래스로 객체화한다.
  3. 이 객체는 잔액 상태와 입금, 출금, 잔액 출력이라는 기능을 갖는다.
  4. 요구사항을 변경한다. (VIP 고객은 이체 수수료 면제)

 

객체지향 설계를 통해 의존을 다룰 수 있습니다. 이는 변경이 전파되는 것을 제한하도록 돕습니다. 이것이 어떻게 가능할까요?

객체는 자체 상태와 행동을 갖기 때문입니다.

 

왜 가능할까요?

하나의 객체(내부)가 변경되더라도, 외부에서는 알 수 없기 때문입니다.

 

그래서 객체지향 설계가 의존을 다루는 핵심은 무엇일까요?

 

객체지향 핵심

앨런 케이의 객체지향 핵심은 'Message Passing', 'Encapsulation', 'Dynamic Binding' 입니다.

 

Message Passing

클라이언트는 요청만 하면, 서버는 내부적으로 무엇을 하는지 알 수 없지만 응답을 줍니다.

 

클라이언트는 자신이 원하는 목적을 달성할 수 있는 서버의 API는 알고 있습니다. 서버는 API를 통해 받은 요청을 서버가 할 수 있는 방법으로 처리합니다.

Client ↔ Server

 

예를 들어, 고객이 원하는 것은 식재료 판매업체에서 산 불량식품을 교체하는 것이고, 고객이 불량식품 환불을 요청하는 방법은 식재료 판매업체에 환불을 요청하는 것 입니다. 즉, 고객이 원하는 결과는 '환불'입니다.

고객 ↔ 식재료 판매업체 ↔ 고객이 관심없는 영역(고객 환불 요청으로 식재료 판매업체가 해야할 일)

 

객체 또한 마찬가지 입니다.

객체 ↔ 객체 ↔ 요청한 객체는 관심없는 영역 (요청받은 객체가 해야할 일)

 

코드로 본다면 아마 아래와 같을 것입니다.

커피 주문 → Customer → 커피 만들어라 → Barista → 주문한 아메리카노 → Customer
customer.order() → Customer → barista.makeCoffee() → Barista → Coffee → Customer

 

여기서 메시지는 'makeCoffee()'이며 이는 '오퍼레이션 + 인자'입니다.

메시지를 전송하는 전송자는 어떤 메시지를 전송해야 하는지만 알면 되며, 그저 내가 원하는 것만 얻으면 됩니다. 즉, 어떤 API를 호출하면 되는지만 알면 됩니다.

 

메시지를 수신하는 수신자도 누가 전송했는지 중요하지 않으며, 내가 가진 것 안에서 내가 결정하면 됩니다.

 

Encapsulation

캡슐화는 객체의 내부 상태와 동작을 외부로부터 숨기는 방법입니다. 이는 어떤 의미가 있을까요?

이를 통해 얻을 수 있는 것은 '1 결합도를 낮추는 것`이며, 이는 변경을 더 쉽게 할 수 있습니다.

 

높은 결합도는 인스턴스를 직접 참고하는 반면, 낮은 결합도는 인스턴스를 직접 참조하지 않고 data만 처리하게 됩니다.

 

그리고 자율적인 객체는 즉, 소통은 인터페이스로 구현은 내 마음대로 바꿀 수 있게 됩니다.

예를 들어, Car 객체와 소통하려는 다른 객체는 오직 노출된 인터페이스(메소드)만을 통해서 소통해야 합니다. 차가 브레이크를 밟을 때마다 로그를 남긴다는 요구사항이 들어오면, Car 내부에 로깅할 수 있도록 수정만 하면 됩니다. 즉, 내부는 변경되어도 인터페이스는 변경되지 않습니다.

 

Dynamic Binding

동적 바인딩은 런타임 시점에 참조 변수와 실제 객체 타입을 확인하여 함수를 호출하는 방식입니다.

 

다형성은 하나의 참조 변수로 여러 개의 객체를 참조할 수 있는 특성을 말합니다.

 

동적 바인딩은 다형성이 적용된 코드에서 발생하는 하나의 현상입니다.

객체지향에서 다형성을 해석하면, 다른 객체에서 보내는 메시지가 실제로 어떤 메서드를 호출할지 런타임에서 결정된다는 의미입니다.

 

협력

현실에서 협력은 '무엇을 해야하는지', '무엇을 누가 잘하고 잘 아는지', '무엇을 누군가에게 요청할 지'이며, 한 사람이 상황에 따라 하는 일이 다르다는 것을 알아야 합니다.

 

협력이 왜 필요할까요? 객체지향 프로그래밍에서 기능을 구현하는 유일한 방법이기 때문입니다.

 

예들 들면,

객체는 Message Passing을 통해 요청하고 협력합니다. customer는 barista에게 커피를 만드는 것을 요청하고, customer는 barista가 커피를 만들도록 합니다. (barista가 커피를 만들도록 하는 것 = customer가 아는 것, 만들도록 하는 것 = customer가 하는 것)

 

여기서 customer의 책임은 'barista가 무엇을 하는지 아는 것', 'barista가 할 줄 아는 것을 시키는 것' 입니다.

barista의 책임은 '커피를 만드는 방법을 아는 것', '커피를 만드는 것' 입니다.

 

여기서 책임은 객체가 무엇을 할 수 있는지, 객체가 무엇을 아는지를 말합니다.

 

customer가 makeCoffee라는 메시지를 barista에게 책임 할당합니다. 그 이유는 barista가 커피 만드는 방법을 알고 있기 때문입니다. 여기서 책임 할당은 할 줄 아는 사람한테 책임을 할당하는 것을 말하며, 단, 어떻게 할 줄 아는지 모르며, 할 줄 아는 것만 알고 있습니다.

 

여기서 커피를 만들라는 메시지의 적절한 객체는 'coffeeMachine/HandDrip'이 해당하며, 이를 추상화하고 역할을 줍니다. 여기서 역할은 구체적인 객체를 바꿔끼울 수 있는 슬롯입니다.

 

객체의 협력, 책임, 역할 이해하기

마트 계산 시스템이 있습니다. 여기서 계산 프로세스를 바탕으로 설계를 한다면, '협력에 필요한 메시지를 찾고', '메시지에 적절한 객체를 찾아야' 합니다.

 

여기서 메시지는 무엇일까요?

메시지는 '상품을 카트에 담기', '자불 정보를 만들기', '결제하기' 입니다.

 

여기서 책임은 누구에게 있을까요?

바로 '고객', '마트오너' 입니다.

상품 카트에 담기 → Customer → 지불정보 생성/계산 → MartOwner

 

여기서 고객이 가진 책임은 다음과 같습니다.

 

  • 아는 것
    • 고객 ID
    • 계산 수단(Card)
    • 카트가 물건을 담고 있다는 것
    • 마트오너가 지불정보를 생성한다는 것
  • 하는 것
    • 상품을 카트에 담는 것
    • 지불정보 생성을 마트오너에게 요청하는 것
    • 지불정보를 바탕으로 마트오너에게 계산을 요청하는 것

 

마트오너가 가진 책임은 다음과 같습니다.

  • 아는 것
    • 지불정보 박스
  • 하는 것
    • 지불정보를 생성하는 것
    • 지불정보를 계산하는 것

 

전체 프로세스로 나타내자면 다음과 같을 것 입니다.

 

학습

학습(배움)은 본능적인 변화인 성숙과 달리, 직/간접적인 경험이나 훈련에 의해 지속적으로 자각하고, 인지하며, 변화시키는 행동 변화입니다.

 

무엇을 훈련시킬 것인가? (내가 부족한 것은 무엇인가?), 변화되었다는 것을 어떻게 측정할 수 있을까? (피드백)을 알아야 합니다.

 

내가 무엇이 필요한 지에 대해 무엇을 모르는지 알아야 하며, 언제 필요한 것인지도 알고 있어야 합니다.

무엇을 모르는 지 아는 것은 모르는 대상을 아는 것만으로도 무언가를 찾을 수 있는 기회를 얻게 됩니다.

 

우리는 확실히 아는 것(무언가 관계를 맺어 임팩트 내는 것)과 들어본 것(넓히다보면 무언가 필요할 때 도움되는 것) 그리고 그냥 모르는 것(무지)을 분류할 줄 알아야 합니다.

 

먼 미래를 준비하는 것은 비효율적이며, 근미래를 준비하는 것이 효율적입니다. 물론 필요할 때는 장기적인 준비가 필요하기도 합니다.

 

나를 잘 이해하는 방법은 '메타인지' 입니다. 메타인지를 올릴 수 있는 방법은 너무 많고, 경험을 정리해 지표로 활용해야 합니다. 나는 지금까지 어떻게 자라왔는지, 어떤 영향을 받았고, 선택을 했는지처럼 말입니다.

그리고 내가 언제 효율적인지 생각해야 합니다. 시간적으로 효율적인 것과 능력적으로 효율적인 부분에 대해서 말입니다. 그리고 꾸준히 시도해야 합니다. 하지 않고 추측하는 것과 해보고 피드백하는 것은 매우 큰 차이가 존재합니다. 여기서 피드백 방법은 다양합니다. 방법을 모르겠다면 뛰어난 사람들을 따라해보는 것도 좋습니다.

 

그렇다면, 피드백은 어떻게 해야 할까요?

빠른 피드백이 중요합니다. 테스트 코드처럼 피드백을 주는 장치도 중요합니다. 그리고 피드백 루프를 구성해야 하는데 '자동화된 피드백'과 '주기적인 피드백'이 있습니다.

 

자동화된 피드백은 서비스를 통해서 알 수 있고, 주기적인 피드백은 일기를 통해 알 수 있습니다.

습관과 루틴은 힘이 되며, 이를 실행가능하게 하는 것은 쉬운 환경을 만드는 것 입니다. 습관을 체이닝하세요.

 

현실적인 내용으로는 나의 레벨을 찾아야 합니다. 연차가 아닌 지표로 찾고, 도서, JD, 선배들과 이야기 해보세요.지속적으로 스터디를 하고 싶다면 혼자보다는 같이 해나가는 게 좋습니다. 피드백을 얻기 위해서는 선배 개발자가 있다면 큰 스프린트가 끝났을 때 함께 피드백을 하고, 없다면 함께한 동료들과 피드백을 해야 합니다. 아무도 없다면 혼자서라도 해야 합니다. (ChatGPT를 이용해도 좋습니다.)

 

고민을 자주하는 것은 효율이 떨어질 수 있습니다. 프레임워크와 파이프라인은 효율적으로 만들어주고 개선하는 것도 쉽습니다. (잘못된 점을 고치는 일처럼)

 

SOLID

'SOLID(객체지향 설계 원칙)'은 가독성, 확장성, 재사용성, 유지 보수성을 향상시키기 위해서 알고 있어야 합니다.

 

  1. 단일 책임 원칙: 클래스가 하나의 책임이나 기능만을 담당
    • 기본적으로 작은 단위와 단일 기능을 가진 클래스 설계
      • 클래스에 속성, 메서드가 많아 가독성, 유지보수가 어려울 때
      • 많은 수의 클래스에 의존할 때
      • 클래스의 이름을 비즈니스적으로 정확하게 명명할 수 없을 때
      • 응집도가 낮아 메서드들이 클래스의 적은 수의 속성만 사용할 때
  2. 개방 폐쇄 원칙
    • 확장할 때는 개방, 수정할 때는 폐쇄
    • 새로운 기능 추가 시, 기존 모듈, 클래스, 함수를 수정하기 보다 기존 코드를 기반으로 모듈, 클래스, 함수 등을 추가하는 방식으로 크드 확장
      • 새로운 기능을 추가하면 인터페이스를 구현하는 새로운 클래스를 생성해 확장 가능
      • 개방 폐쇄 원칙을 달성하기 위해서, 확장 포인트가 어디인지 알기 위해서 비즈니스에 대한 정확한 이해가 필요
      • 추후 요구될 가능성이 거의 없는 사항들까지 미리 준비하는 것은 과도한 설계
      • 실제 필요할 때, 리팩토링 추천
  3. 리스코프 치환 원칙
    • 상위 타입의 객체를 하위 타입의 객체로 치환해도 상위 타입을 사용하는 프로그램은 정상적으로 동작 필수 (대표: Circle Ellipse Problem)
      • 계약에 따른 설계
      • 상위 클래스에서 선언한대로 기능 동작
      • 입력, 출력, 예외 모두 상위 클래스 팔로우
      • 상위 클래스의 특별 지침 모두 준수
      • 계약이고 지침이기 때문에 LSP를 코드로 제어 불가
      • DELETE 요청 시, 동일한 인터페이스만 가지고 있을 뿐 동일한 행위를 하는지는 개발자 주관적 판단
    • 다형성과의 차이점은 리스코프 치환 원칙은 상속 관계에서 하위 클래스의 설계 방식을 설명하는 설계 원칙이고, 다형성은 코드를 구현하는 방식
  4. 인터페이스 분리 원칙
    • 클라이언트는 자신이 사용하는 메서드에만 의존 필수
      • 클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않는 것이 중요
      • 하나의 일반적인 인터페이스보다 여러 개의 구체적인 인터페이스가 효율적
  5. 의존 역전 원칙
    • 고수준 모듈저수준 모듈의 구현에 의존 불필요
    • 저수준 모듈이 고수준 모듈에서 정의한 추상 타입에 의존 필요
      • 고수준 모듈: 의미있는 단일 기능을 제공하는 모듈 (예. 자동차는 속도가 높아지고 낮아짐)
      • 저수준 모듈: 고수준 모듈의 기능을 구현하기 위해 필요한 하위 기능의 실제 구현 (예. 자동차 엔전은 동력 생성을 담당해 자동차 움직임 제공)
    • 고수준 모듈이 저수준 모듈에 직접 의존하게 되면 문제 발생
      • 유연성 부족
      • 확장성 문제
      • 유지보수 어려움

 

디자인 패턴

객체지향 설계는 코드 변경을 최소화하면서 요구사항을 수용합니다. 이는, 디자인 패턴이 필요한 이유입니다.

반복적으로 하다보면 일정한 패턴을 갖게 되며, 이러한 패턴은 특정 상황에 맞는 해결책을 빠르게 찾도록 도와줍니다.

 

  • 필더 패턴
  • 싱글턴 패턴
  • 팩토리 패턴
    • 단순 팩터리 패턴
    • 팩터리 메서드 패턴
    • 추상 팩터리 패턴

 

단순 팩터리 패턴

어떤 클래스의 인스턴스를 생성하는 데 사용되는 데이터와 코드가 여러 클래스에 퍼져 있는 경우, 생성 지식을 하나의 팩터리 클래스로 옮깁니다.

 

장점은 '생성을 위한 지식을 한 곳으로 모을 수 있으며', '생성 로직을 클라이언트로부터 분리 가능'합니다.

그러나, 단점으로는 '직접 생성으로 충분한 경우 설계를 복잡'하게 만들 수 있습니다.

 

팩터리 메서드 패턴

한 상속 구조 내의 클래스들이 어떤 메서드를 각자 구현하는 데 객체 생성 단계만 제외하고 나머지가 서로 유사한 경우에 해당 메서드를 수퍼클래스로 옮기고 객체 생성은 팩터리 메서드에 맡기도록 합니다. 팩토리와 구체 클래스의 강한 결합을 느슨하게 만들어 줍니다.

 

이는 각 ConcreteFactory가 개별 객체 생성에 대한 지식을 갖으며, 클라이언트는 개별 객체 생성에 대한 지식을 생략할 수 있습니다.

해당 객체를 얻는 인터페이스에 대한 지식만 갖으며, 내부에서 무엇을 해도 클라이언트는 이를 모릅니다.

// Before
interface Vehicle {
	fun drive()
}

class Car: Vehicle {
	override fun drive() {
    	printin("Driving a Car")
    }
}

class Truck: Vehicle {
	override fun drive() {
    	printin("Driving a Truck")
    }
}

class VehicleFactory {
	fun getVehicle(vehicleType: string): Vehicle {
    	return when (vehicleType) {
        	"car" -> Car()
            "truck" -> Truck()
            else -> throw IllegalArgumentException("Unknown")
        }
    }
}

fun main() {
	val factory = VehicleFactory()
    val car = factory.getVehicle("car")
    car.drive()
    
    val truck = factory.getVehicle("truck")
    truck.drive()
}
// After
interface Vehicle {
	fun drive()
}

class Car: Vehicle {
	override fun drive() {
    	printin("Driving a Car")
    }
}

class Truck: Vehicle {
	override fun drive() {
    	printin("Driving a Truck")
    }
}

abstract class VehicleFactory {
	abstract fun getVehicle(): Vehicle
}

class TruckFactory: VehicleFactory() {
	override fun getVehicle(): Vehicle {
    	return Truck()
    }
}

class CarFactory: VehicleFactory() {
	override fun getVehicle(): Vehicle {
    	return Car()
    }
}

fun main() {
    val car = CarFactory.getVehicle()
    val truck = TruckFactory.getVehicle()
}

 

만약, 새로운 Vehicle이 늘어나면 Factory와 Vehicle을 추가하면 변경을 최소화할 수 있습니다.

 

클라이언트는 개별 객체 생성에 대한 지식을 생략할 수 있습니다.

복잡한 객체 생성은 어려운 일이며, 협력을 이루었다는 것은 서로에 대해 잘 알고 있다는 의미입니다. A를 만들어야 할 때, B에 의존하고 있고, B를 만들어야 할 때, C와 F에 의존하고 있다면 이는 매우 복잡하고 어려운 상황에 놓일 것 입니다.

 

단순 팩터리 패턴과 팩터리 메서드 패턴 중 생성자가 단순한 경우에는 단순 팩터리 패턴이 좋습니다. 패턴이 들어가면 설계가 복잡해지기 때문입니다. (Trade-Off)

 

생성을 다루는 디자인 패턴이 중요한 이유는 생성자는 생성하려는 행위를 추상화할 수 없기 때문입니다.

객체지향에서 추상화는 객체의 내부 구현을 감추고 외부에 필요한 인터페이스만을 제공하는 것을 의미합니다. 그러나, 생성자는 객체의 생성 과정을 구체적으로 정의하기 때문에 추상화가 어렵습니다.

 

생성을 다루는 디자인 패턴은 생성하려는 행위를 추상화할 수 있습니다. 클라이언트 코드는 구체 클래스를 몰라도 되기 때문에, 유연하게 객체를 생성할 수 있습니다. 이는 '캡슐화의 이점'을 누릴 수 있다는 의미입니다.

 

구조를 다루는 디자인 패턴

구조를 다룬다는 것은 객체가 여러 의존성을 가지고 있을 때, 의존성을 어떻게 분리할 것인가를 생각하면 됩니다.

 

퍼사드 패턴

개방폐쇄 원칙

추상 인터페이스만 알게 하고, 구상 객체를 외부에서 가져오게끔 책임을 미룹니다. 어딘가에는 구상 객체를 다 알고 있는 게 존재하고 있습니다. (main이 다 알고 있음) 책임을 미룬다면 계속 미룰 수 있지만, 레이어의 경계에서 의존성 전파를 절단하는 용도가 '퍼사드'입니다. 의존성 전파를 퍼사드에서 멈추는 것을 말합니다.

 

즉, 퍼사드 패턴의존성의 집합체이면서 인터페이스의 집합체입니다.

 

무슨 말인지 어려울 수 있습니다. 서비스 레이어를 왜 만들까요?

컨트롤러에서 레포지토리를 직접 사용하면 컨트롤러가 더러워지기 때문입니다. (= 모듈화) 컨트롤러 입장에서 서비스의 메서드만 알면 됩니다. 어떤 의존성이 있는지는 모릅니다. 여기서 서비스는 '간이 퍼사드'입니다.

 

컴포지트 패턴

동일한 타입의 단일 인스턴스와 동일한 방식으로 처리되는 객체 그룹을 말합니다.

 

`obj` 대신 `composite`도 가능합니다.

 

핵심은 그룹과 오브젝트가 같은 인터페이스를 제공함으로써 루트에서 시작된 함수가 모든 오브젝트로 퍼지는 구조인 것입니다. 그룹을 오브젝트를 같은 타입으로 바라보는 것이 핵심입니다.

 

보는 눈이 없다면, 사용하기 어려운 패턴입니다.

 

행동을 다루는 디자인 패턴

복잡한 메서드는 어떻게 분산시킬 수 있을까요?

 

템플릿 메서드 패턴

실행 과정은 동일하지만, '일부 구현'이 다른 경우에 사용할 수 있는 패턴입니다. 상위 클래스에서 실행 과정을 구현한 메서드를 제공합니다. 일부 단계는 추상 메서드를 호출하는 방식입니다.

 

패턴은 너무 많습니다. 자주 사용되는 것은 따로 있습니다. 그리고 아는 것과 사용할 줄 아는 것은 다릅니다. 이를 위해서 우리는 연습을 꾸준히 해야 합니다.

 

일 잘하기

개인적인 정의도 중요하지만, 일을 잘 한다는 평가는 타인에 의해서 정해집니다.

그렇기 때문에, 타인이 필요로 하는 것을 만족시키면, '일을 잘 한다는 평가'를 받을 수 있게 될 것입니다.

 

만약, 신입 또는 주니어라면 주로 맡겨진 일을 잘 마무리하는 것(기한 안에 잘 마무리)에 집중하세요.

추가적으로 주변에 관심을 갖고, 동료가 필요로 하는 것에 대해 고민하고, 본인이 할 수 있는 일인지, 도움을 줄 수 있는 방법이 무엇인지 생각해 보세요.

 

개발자가 개발 외에 잘 할 수 있는 것은 제한없이 도전하는 것, 그리고 틀을 깨고 나오는 것일 것입니다.

그리고 개발자가 프로젝트에 적응하기 위해서는 프로젝트를 파악하고, 함께 하는 동료들을 알아가는 게 중요합니다.

 

프로젝트 파악

비즈니스 파악

핵심은 비즈니스를 파악하는 것 입니다. 이는 도메인의 전체 맥락을 파악하는 것과 같습니다.

 

아키텍처 파악

비즈니스를 이해해야 아키텍처도 이해할 수 있습니다. 즉, 비즈니스를 이해해야 합니다.

 

비즈니스 파악하는 방법

시스템으로 파악하거나, 문서로 파악할 수 있습니다.

시스템으로 파악하는 방법은 테이블을 직접 그리고, 테이블 간의 관계를 매핑합니다. 그리고 테이블 내의 중요 정보를 표시하고, 핵심 데이터들을 살펴봅니다. 마지막으로 도메인 내 핵심 로직들의 흐름을 그려봅니다.

 

문서로 파악하는 방법은 비즈니스를 파악하기 위한 문서인 '정책서', '스토리보드'를 확인합니다. 이런 것들이 프로젝트 모든 인원에게 열려 있지 않고 시스템을 직접 사용하면서 파악해야 하는 경우도 있으니 유의해야 합니다.

문서가 잘 안 남는 이유는 정리된 문서를 보기 어렵거나, 의도와 달리 관리하기 어렵거나, 팀/파트 차원에서 어떻게 관리할 지 정책이 있어야 주시하게 되기 때문입니다.

 

개인이 할 수 있는 것은 본인이 한 영역에서 열심히 관리하는 것이 좋습니다.

 

의존

개발자가 코드를 다루는 경우의 수는 다음과 같습니다.

 

  • 기능 추가: 구조 변경
  • 버그 수정: 구조 변경, 기능 변경
  • 리팩토링: 구조 변경, 기능 변경
  • 최적화: 리소스 사용량 변경

생각보다 변경의 범위는 좁습니다. 변경 대상 외 다른 것은 변경되면 안 됩니다. 결국 이는 '의존'하게 됩니다.

 

우리는 왜 의존을 다룰까요?

변경을 일으킬 때, '다른 것에 영향을 주지 않는다는 것'을 보장해야 하기 때문입니다. 이는 분명히 어렵지만, 어떻게 영향을 최소화할 수 있을까 고민해야 합니다.

 

의존이 야기하는 문제

  • 어려운 변경
  • 유지보수 비용 증가
  • 어려운 테스트
  • 재사용성 저하
  • 확장성 제한
  • 순환 의존성
  • 어려운 의존성 관리

 

의존 관리 코드 설계

  • 약속과 규칙을 통한 의존 관리
  • 레이어 관리
  • 적절한 책임 분리
  • 테스트 코드 작성

 

레이어 관리

일반적으로 함께 개발하는 것은, 모든 사람들이 해당 아키텍처에 대해 동일하게 이해해야 합니다. (레이어드 아키텍처, 헥사고날 아키텍처, 클린 아키텍처) 그리고 이는 비용이 큽니다.

 

언젠가 본인이 아닌 다른 누군가가 해야 할 일이 될 수 있습니다. 받을 사람도 동일하게 이해할 수 있도록 해야 합니다. 이는 랜딩에 비용이 큽니다.

 

즉, 모두 비용 문제가 존재합니다.

 

레이어드 아키텍처

Controller > Service > Repository

 

'Controller'는 외부 인터페이스를 정의합니다.

'Service'는 비즈니스 로직을 정의합니다.

'Repository'는 데이터 접근 역할을 합니다.

 

문제는 책임이 불분명한 ~Service 지옥이 나타날 수 있습니다. 그리고 응집도가 떨어지는 의존으로 인한 테스트로 어려움이 존재할 수 있습니다. 또한, 순환 의존성이 존재할 수 있습니다.

 

이는, 클래스의 이름을 비즈니스적으로 정확하게 명명할 수 없는 경우(UserService, OrderService ...)에 해당하며, 도메인과 관련된 모든 기능이 담겨버린 괴물이 탄생할 수 있습니다.

또한, ~Service가 가진 의존성이 매우 늘어날 수 있으며, 이는 테스트 코드 작성 시 매우 불편할 수 있습니다.

프로젝트 초기에는 문제가 없는 것처럼 보일 수 있습니다만, 실제로 작은 비즈니스만 담으면 문제가 없습니다. 그러나, 비즈니스가 복잡해질 경우 순환 의존성이 발생할 가능성이 커집니다.

 

규칙은 의존 방향을 아래로 흐르게 만듭니다. 의존 시 계층을 건너뛰지 않게 하며, 'UseCase'는 상호 참조를 불가능하게 합니다. 그리고 'Implementation'은 상호 참조를 가능하게 하며, 실용성을 고려하여 'Entity(Model)'은 'Data Access 계층'을 벗어나 UseCase와 Implementation에서 사용이 가능합니다.

 

그리고 적절한 채임이 주어져야 합니다. ({Domain}{행동}UseCase) 이는 책임이 많아지면 나눌 수 있습니다.

적절한 책임으로 ~Service 보다 높은 응집도를 가지도록 할 수 있으며, 순환 의존이 제거할 수 있습니다.

 

이 방식은 정석이 아닌 개인과 팀의 정의에 따라 달라질 수 있습니다.

 

적절히 책임이 분리되고, 필요한 것만 의존하도록 하면 괜찮습니다.

의존을 많이 한다면 테스트를 하기 어렵습니다. 이런 코드는 테스트 코드가 없는 프로젝트가 될 가능성이 커집니다.

테스트 코드가 있다면, 코드 수정에 자신감이 붙을 수 있으니 테스트 코드 작성은 필요합니다.

 

멀티 모듈

큰 프로젝트를 여러 개의 모듈로 나누어 관리하는 방식입니다. 모듈 간 의존성 관리가 매우 중요합니다.

 

장점은 복잡성을 낮출 수 있으며, 유지보수를 용이하게 만들어 줍니다. 또한, 재사용성을 높일 수 있고, 각 팀이 독립적으로 모듈 개발을 가능하게 만들어 줍니다.

 

반대로, 단점은 의존성 관리와 규칙이 있어야 하며, 초기 설정이 어렵습니다.

 

만약, 코드 재활용만을 목표로 한다면 문제가 있습니다. `Common`만 커져버리면 의존성이 높아지기 때문입니다.

 

전체 프로젝트를 멀티 모듈로 할 수 없습니다. 하나의 프로젝트에서 전시, 주문, 빌링, 광고를 하나의 소스를 멀티 모듈로 구성하기 보다, 전시 / 주문 / 빌링 / 광고처럼 도메인 중심으로 프로젝트를 나눈 것 안에서 멀티 모듈을 구성하는 게 낫습니다.

전시 / 주문 / 빌링 / 광고
ㄴ api
ㄴ batch
ㄴ admin

 

모듈은 모듈입니다.

 

모듈

프로그램을 구성하는 시스템을 독립적으로 기능 단위로 분리한 형태입니다. 모듈의 장점은 원하는대로 합치고 나누는 관리의 용이성을 제공합니다.

 

멀티 모듈을 어떻게 구성할 지는 자유입니다. 그래서 다양한 의견이 나올 수 있습니다.

 

멀티 모듈에 대한 멘토 의견

  • 싱글 모듈이 이상한 것은 아닙니다.
  • 그러나, 강제성을 부여하기는 어렵습니다.
    • ArchUnit을 통해 강제성 부여가 가능합니다.
    • ArchUnit는 보조 수단이라고 생각합니다.
    • 의존성 관리를 통한 강제성이 더 낫다고 생각합니다.
  • 필요에 의해 하면 됩니다.

 

유지보수와 테스트

코드를 다루기 전에 변경의 대상이 무엇인지, 변경이 이루어졌는지 확인할 방법을 찾고, 다른 것에 영향을 주지 않았는지 확인할 방법을 찾아야 합니다. (→ 피드백을 활용할 것)

 

테스트 루틴, 피드백을 받을 수 있는 방법은 무엇일까요?

우선, 테스트 루틴은 테스트 코드와 다릅니다. 그리고 단위 테스트와 통합 테스트 중 무엇이 나을까요?

 

좋은 테스트 루틴의 조건은 빠른 실행 피드백을 줄 수 있고, 오류 위치 파악에 도움이 됩니다.

 

그렇다면, 통합 테스트는 좋은 테스트 루틴일까요?

통합 테스트는 오류의 위치를 파악하기 어려워 테스트의 대상에서 멀어질수록 테스트 실패가 의미하는 바를 찾기 어렵습니다.

API 호출을 활용해 테스트
오류 발생 > 찾기 > 수정 > 다른 오류 발생 > 찾기 > 수정

 

통합 테스트는 실행 시간이 길어질 가능성이 높습니다. 실행하는 데 오래 걸린다면, 피드백을 받는 시간도 오래 걸릴 것입니다. 물론, 통합 테스트 자체로 의미가 있습니다. 그러나, 그 전에 작은 단위로써 단위 테스트가 선행되어야 합니다.

 

코드 변경 발생은 일상입니다. 다만, 변경을 일으킬 때 다른 것에 영향을 주면 안 됩니다. 빠른 피드백을 주는 테스트 루틴으로 코드 변경의 영향을 감지할 수 있습니다.

이 때, 영향을 감지하기 위한 테스트 루틴의 적절한 위치를 찾아야 합니다. 시작은 변경 대상을 확인하는 것부터 입니다.

 

  1. 변경 대상 확인
    • 요구사항을 소화하기 위해 코드 변경 대상을 식별합니다.
  2. 테스트 루틴 작성 위치 찾기
    • 코드 변경 대상이 변경됨에 따라 영향받는 위치를 머리 속으로 생각하고 정리합니다.
  3. 테스트 루틴 작성
  4. 변경 및 리팩토링 수행

 

핵심 아이디어가 있다면, '시각화(영양 스케치)' 합니다.

영향이 전파되는 케이스는 '호출 코드가 사용되는 반환 값', '매개 변수로 전달되는 객체를 변경', '정적 또는 전역 변수를 변경' 입니다.

 

주의사항

영양 스케치가 너무 복잡한 경우에는 한 번에 너무 많은 변경을 시도하는 것은 아닌지 검토해야 합니다. 이 때, 변경 지점으로 다시 돌아가보는 게 좋습니다. 가급적으로 개별 변경 지점의 근처에 테스트 루틴을 작성해보려고 노력해야 합니다. 그리고 요구사항을 더 잘게 보는 습관을 들여야 합니다.

 

  • X: 영향이 집중된 곳, 모든 영향을 검출할 수 있는 최적의 위치, 조임 지점
  • 의존이 많은 경우: 많은 책임, 책임 분배 필요

 

"테스트를 수행한다"는 것의 의미는 무엇일까요?

일반적으로 버그를 찾기 위한 테스트를 의미합니다. "기존 동작이 잘 동작하는가?"의 관점에서의 행위라고 볼 수 있습니다. 만약, 잘 동작하지 않는 기존 동작은 버그로 취급됩니다. 이것만으로 충분한 가치가 있습니다.

 

애초에 버그를 만들지 않는 것은 사실상 어려운 일입니다.

그렇기 때문에, 버그를 만들지 않도록 도움을 주는 환경을 구성하는 관점에서 테스트 코드를 작성합니다.

 

문서화

문서화에서 중요한 것은 '문서를 읽는 사람', '알고 싶어 하는 것', '순서' 등이 있습니다.

 

문서화 테스트

"기존 동작은 무엇일까?" + "기존 동작은 잘 동작할까?"라고 생각하면 됩니다.

즉, 시스템의 현재 동작을 그대로 문서화하는 테스트입니다.

 

기존 동작 + 새롭게 기대되는 동작 → 변경 작업

● IDE(손)로 추적 + 새롭게 기대되는 동작 → 변경 작업 => 추적이 익숙해질 수 있지만, 반복된다면 낮은 효율
● 문서화 테스트 + 새롭게 기대되는 동작 → 변경 작업 => 코드 동작에 관한 지식을 전달하여 높은 효율

 

클래스 문서화

많은 부분에서 클래스를 사용합니다. 클래스를 잘 설명합시다.

  • 테스트 코드 배열: 클래스의 주된 의도 > 특징

 

InvoiceProcessor를 사용할 때 알아야 하는 것은 무엇일까요?

바로, '목적과 기능' 그리고 '메소드 역할과 매개변수' 입니다.

 

문서를 작성하는 이유는 무엇일까요? '생각 및 자료 정리', '공유용' 입니다.

구분하지 말고, 개인의 문서도 공유용처럼 작성합시다.

 

어떻게 문서를 잘 작성할 수 있을까?

기준이라고 할 수 있는 것은 '나무 위키'를 추천합니다. 사용자가 많아 가장 일반화되어 있는 포맷을 사용할 것입니다.

잘 읽히는 기술 문서는 'Azure'가 있습니다.

 

코드를 리팩토링하는 것처럼 문서를 작성합시다.

  • 코드는 다음과 같은 흐름으로 작성되고 리팩토링이 됩니다.
    1. 코드를 작성할 때, 함수를 먼저 작성합니다.
    2. 큰 함수가 되면 작은 함수로 나눕니다.
    3. 개념(도메인)이 성숙해지면 개념을 중심으로 분리합니다.
  • 문서도 코드처럼 작성합시다.
    1. 처음부터 정리한다는 생각으로 분리하지 말고, 일단 작성합니다.
    2. 규모가 커져서 정리가 필요하다고 느껴지면 나눕니다.

 

사람이 글을 읽는 방식을 떠올리며 작성합시다.

  • 글 전체를 파악할 수 있어야 합니다.
    • 글 전체를 파악할 수 있도록 목차를 제공합니다.
    • 표 1x1 {목차}를 작성합니다.
    • 너무 깊은 목차까지 제공하는 것은 도움이 되지 않습니다.
  • 사람은 긴 글보다 짧은 글을, 짧은 글보다 몇 단어를 좋아합니다.

 

Azure에서 CQRS를 설명하는 일부분은 아래와 같습니다.

 

사람이 글을 읽는 방식을 떠올리며 문서를 작성합시다.

글보다는 그림을 좋아하는 경우가 많아 적절한 위치에 그림을 넣으면 좋습니다. 그림은 시각적 이해를 제공하고, 관심을 유도하게 만들어 줍니다. 결과적으로 글을 더 이해하기 쉽게 만들어 줍니다.

 

작업 관리 문서

  1. 유사 ADR
  2. 작업 검토 배경
  3. 검토 방향 및 대상
  4. 해결 방향
  5. 결론 (TODO)

 

도메인 문서

  • 도메인 라이프 사이클
  • 도메인 관계 (DB 기준 x)
  • 핵심 비즈니스 행동 및 데이터 변화 DFD

프로젝트 문서

  • 업무
    • 요구사항
    • 구현 방향
    • 자료조사
    • 미팅노트
    • 전체 일정 및 프로젝트 관리
    • 기타
  • 개발
    • 개발 가이드
    • 아키텍처
    • 핵심 기능
    • 캐시 관리 현황
  • 인프라
    • 인프라 구성 및 인프라 서비스 접근 링크
    • 각 스펙
  • 이슈
    • 이슈 히스토리
    • 이슈 해결방안