이 글은 재사용가능한 코드를 작성하는 방법을 요약한 글로 대부분의 내용은 아래의 링크에서 가져왔습니다.
https://www.youtube.com/watch?v=60lLSe1phks&t=657s
더 디테일한 설명은 위의 영상을 통해 습득할 수 있고, 아래 설명에는 중간중간 저의 생각이나 의견이 추가되어있습니다.
두서 없이, 작성하는 글로 이해하는데 어려움이 있을 수 있습니다.
* 왜 클린코드여야 하는가?
- 동작하는 코드는 한번 작성되면, 최소 10번 이상 읽히기 때문에, 대충 돌아가게만 작성하면 안되고 읽기 편하도록 작성해야한다.
(보통의 개발자들은 새로 시작하는 프로젝트를 경험하기 보다는, 유지보수를 경험하게 될 확률이 높다)
- 인간이 이해할 수 있는 코드는 잘 훈련된 소프트웨어 엔지니어만이 작성할 수 있다.
* 왜 OOP 를 사용해야 하는가?
- 복잡하거나 요구사항 등 변화가 발생할 시스템은 객체지향적인 프로그래밍을 해야함
(데이터의 변경이 해당 객체로만 제한되어, 다른 객체에 영향을 미치지 않기(혹은 적은 영향을 미친다) 때문)
- 프로그램은 계속 사용하다보면 당연히 요구사항이 나오고 자주 바뀐다.
- 초기 진입은 절차지향이 쉬울지 모르나, 이후 시간이 지나면 수정하기 어려운 구조가 된다
* Object/Role/Responsibility 란?
- 객체지향 프로그래밍을 할 때는 위의 3단어를 많이 사용하게 되는데, 이를 간단하게 표현하면 결국은 SOLID 원칙을 지키며, 프로그래밍을 하자라는 의미이다.
- S : 클래스는 하나의 책임을 가져야한다
- O : 확장에는 열려있고(open), 수정에는 닫혀있어야한다(close).
- L : 하위타입으로 작성된 코드에 상위 타입을 넘기면 당연히 올바르게 작동해야한다.(업캐스팅을 지향한다)
- D : 비즈니스 로직을 담은 고수준의 로직(암호화 한다)은 저수준의 로직(AES, IV 를 활용해 디코딩한다)에 영향을 받아서는 안된다.
* 객체지향 설계 과정
1. 기능을 제공할 객체 후보 선별 (이 과정에서 클래스 다이어그램, 정적 설계(정적 모델링)를 하여, 객체 후보를 선별하게 되는데 보통 개발자는 이와 같은 방식으로 진행하지 않고, 기능을 구현하고 이후에 리팩토링을 통해 개선해나아가는 경우가 많다)
2. 객체 간 메세지 흐름 설정 및 연결 (이 과정에서 커뮤니케이션 다이어그램, 시퀸스 다이어그램, 동적 설계(erd) 를 이용해야한다)
3. 1, 2 를 반복하며 개선해 나간다.
e.g 파일 암호화
1. 기능을 제공할 객체 후보 선별
위의 사진에서 우리가 가장 많이 실수하는 부분은, 많은 개발자가 FileController 안에 fun readFile(), fun fileEncryption(), fun writeFile() 과 같은 방식으로 메서드를 정의하여 사용하는 것인데 이는 OOP 에는 어긋난다.
// 좋지 못한 예
class FileController {
fun readFile() {}
fun fileEncryption() {}
fun writeFile() {}
}
만약 이상태에서 읽어야할 File 타입이 늘어난다면 우리는 메서드를 늘리는 방식으로 처리할 수 밖에 없고, 이는 유지보수면에서 좋지 못하다.
// json, xml 타입 파일을 읽어오도록 추가
class FileController {
fun readFile() {}
fun fileEncryption() {}
fun writeFile() {}
fun jsonReadFile() {}
fun jsonFileEncryption() {}
fun jsonWriteFile() {}
fun xmlReadFile() {}
fun xmlFileEncryption() {}
fun xmlWriteFile() {}
}
영상에서 실제 코드는 나오지 않았지만, 구현한다면 이런 모습이 될 것 같다.
(상속을 이용하여 해결할 수 도 있지만, 이후에 자식 클래스가 많아지면, 부모 클래스에 함수를 수정하거나, 추가하면 자식 클래스들도 영향을 받기 때문에 건드릴 수 없을 수도 있어, 인터페이스를 활용하는 것이 좋다고 알고 있다.)
fun main() {
val normalFileController = FileController(FileReader(), Encryption(), FileWriter())
val jsonFileController = FileController(JsonFileReader(), JsonEncryption(), JsonFileWriter())
}
class FileController(val fileReader: FileReaderImp, val encryption: FileEncryptionImp, val fileWriter: FileWriterImp)
interface FileReaderImp {
fun read()
}
class FileReader : FileReaderImp {
override fun read() {}
}
class JsonFileReader : FileReaderImp {
override fun read() {}
}
interface FileEncryptionImp {
fun encryption()
}
class Encryption : FileEncryptionImp {
override fun encryption() {}
}
class JsonEncryption : FileEncryptionImp {
override fun encryption() {}
}
interface FileWriterImp {
fun write()
}
class FileWriter : FileWriterImp {
override fun write() {}
}
class JsonFileWriter : FileWriterImp {
override fun write() {}
}
2. 객체간 메세지 흐림 연결
FIleController 클래스 와 FileReader, Encrypter, FileWriter 클래스 간의 데이터 교환을 메서드로 정의해준다.
* Encapsulation
- 내부적인 구현을 감추고, 그 내부가 변경되어도 해당 객체를 사용하는 클라이언트에게는 영향이 없게 한다.
(코드 변경에 따른 비용을 최소화하며 객체지향의 기본이다)
e.g 절치지향 스톱워치 vs 객체지향 스톱워치
// 절치지향 milliseconds
class ProceduralStopWatch {
var startTime = Long.MIN_VALUE
var endTime = Long.MIN_VALUE
fun getElapsedTime(): Long {
return endTime - startTime
}
}
fun main() {
val stopWatch = ProceduralStopWatch()
stopWatch.startTime = System.currentTimeMillis()
// do something
stopWatch.endTime = System.currentTimeMillis()
println(stopWatch.getElapsedTime())
}
현재 위의 코드는 milliseconds 단위를 제공하는 스톱워치이고, nanoseconds 단위도 필요하다는 요구사항이 들어왔다.
class ProceduralStopWatch {
var startTime = Long.MIN_VALUE
var endTime = Long.MIN_VALUE
fun getElapsedTime(): Long {
return endTime - startTime
}
var nanoStartTime = Long.MIN_VALUE
var nanoEndTime = Long.MIN_VALUE
fun getNanoElapsedTime(): Long {
return nanoStartTime - nanoEndTime
}
}
fun main() {
val stopWatch = ProceduralStopWatch()
stopWatch.nanoStartTime = System.currentTimeMillis()
// do something
stopWatch.nanoEndTime = System.currentTimeMillis()
println(stopWatch.getNanoElapsedTime())
}
아마도 위와 같이 구현하게 될 것인데, 이 구조는 요구조건이 추가될 때마다 데이터 구조가 추가되어야하고, 심지어 사용하는 쪽의 코드도 수정이 필요하다. 만약 테스트 코드를 작성했다면, 테스트 코드도 수정이 필요할 것 이다.
이러한 절치지향의 한계를 극복하기 위해 객체지향적으로 변경시켜보자.
class Time(private val elapsedTime: Long) {
fun convertMillisTime(): Long {
return (elapsedTime / Math.pow(10.0, 6.0)).toLong()
}
fun convertNanoTime() = elapsedTime
}
class StopWatch {
private var startTime = Long.MIN_VALUE
private var endTime = Long.MIN_VALUE
fun start() {
startTime = System.nanoTime()
}
fun stop() {
endTime = System.nanoTime()
}
fun getElapsedTime(): Time {
return Time(endTime - startTime)
}
}
fun main() {
val stopWatch = StopWatch()
stopWatch.start()
// do something
stopWatch.stop()
val time = stopWatch.getElapsedTime()
// millis
println(time.convertMillisTime())
// nano
println(time.convertNanoTime())
}
짠 ~!
우리는 Time 이라는 클래스를 만들고, 시간 단위로 변환할 수 있는 메서드를 만들었다.
이제 단위가 추가 될 때는 그저 변환하는 메서드를 하나식 추가만 해주면된다.
또한 내부 구현에 변화가 있어도 클라이언트 코드는 영향을 받지 않는다.(수정할 필요가 없다)
* Tell. Don't Ask
- 멤버 변수, 프로퍼티 등에 직접 접근해서 변경하고, 저장하는 행위를 하지말고 그 것들을 하나의 기능으로 묶어서 실행하자
- 데이터를 잘 알고 있는 객체에게 기능을 수행하게 하자
e.g
// bad (Ask, Query)
if(member.getExpiredDate().getTime() < System.currentTime())
// good (Tell, Command)
if(member.isExpired())
만약 bad 와 같은 로직이 프로젝트 전체에 여럿 구현되어 있다면, 로직이 변경되면 모두 찾아 수정해야할 것 이다.
* The Law of Demeter
- 객체는 내부적으로 보유하고 있거나, 메세지를 통해 확보한 정보만 가지고 의사 결정을 해야한다.
*CQS (Command-Query Separate)
- Command(Tell) : 객체의 내부 상태를 변경시킬 수 있고 어떤 결과를 반환 할 수 있다.
- Query(Ask) : 객체의 상태에 대한 정보를 제공하고, 상태를 변경시키지 않는다
- 그렇기 때문에 Command 와 Query 는 서로 분리되어 있어야 한다.
- 객체가 외부에서 의사 결정에 사용되지 않을 때 객체의 순수한 상태를 얻을 수 있다.
- Tell. Don't Ask 를 더욱 잘 지킬 수 있게 해준다
*Polymorphism(다형성)
한 객체가 여러가지(Poly) 모습/타입(Morph)을 가질 수 있다.
보통 다형성을 구현하는 경우는 두가지가 있다
1. 구현 상속 : 부모 타입의 구현을 재사용한다
2. 인터페이스 상속 : 타입 정의만 상속하여, 객체에게 구현을 재사용할 수 있도록 한다
*Abstraction (추상화)
- 데이터/프로세스 등의 의미가 비슷한 개념/표현으로 정의하는 과정
- 상세한 구현으로부터 더 상위 개념을 도출하는 과정
타입 추상화 : 공통된 데이터나 프로세스를 제공하는 객체들을 하나의 타입(인터페이스)으로 추상화하는 것(기능만 정의)
위와 같이 구성하게 되면, 다른 로직을 변경하지 않고, 로그 수집 방법(FtgLog, DbLog 등)들의 변경이 가능하다.
핵심은 의존성 관리를 통해 High Level Logic(비즈니스 로직으로 여기서는 인터페이스인 수집(Collect), 반복(iterator) 을 의미한다) Low Level Detail(세부구현으로 Ftp 로그 수집 반복, DB 로그 수집, 반복) 로 부터 보호 하는 것 -> 즉 Low level Detail(DB, Ftp, Print, TV 등등)의 변경이나 추가 등이 발생해도, High Level Logic(Collect(), iterator()) 는 변경 없이 보호된다.
프로그래밍에서의 인터페이스 의미
- 클라이언트 코드는 항상 인터페이스에 대한 레퍼런스를 사용해야 한다는 의미
- 클라이언트는 구현 변경에 대해서 영향을 받지 않는다.
- 인터페이스 시그니처가 사용 가능한 모든 행위를 보여준다
- 추상화를 통해 유연함을 얻기위한 규칙
- 사용되는 이유 -> 런타임에 프로그램 행위를 변경하기 위함, 유지 보수 측면에서 보다 나은 프로그램을 작성할 수 있도록 도와줌(런타임 디스패치)
개발자가 추상화를 사용해야하는 이유
- 개발자들은 보통 상세한 구현에 빠지다 보면, 상위 수준의 설계를 놓치는 경우가 있음.
e.g
"디렉토리에서 파일을 읽어와 메모리에 저장하고" -> "라인 by 라인으로 정규 표현식으로 파싱하고" -> "결과를 DB 에 저장한다"
이런 구현에 들어가기 전에, "로그를 수집한다" -> "로그를 분석한다" -> "결과를 저장한다" 와 같은 추상화를 통해 상위 수준에서 설계를 하는데 도움을 얻을 수 있다.
* Composition over Inheritance
- 클래스를 상속하는 것이 아니라, 구성에 의해 다형성 동작 및 코드 재사용을 달성해야한다는 원칙이다.
상속을 통한 확장의 장단점
장점 : 자식 클래스는 부모 클래스의 기능을 재사용하기 때문에, 추가적인 기능 역시 제공하기 쉬움
단점 : 변경의 유연함 측면에서 치명적인 단점이 있는데,
1. 부모 클래스의 변경이 다수의 자식 클래스에 영향을 미친다.
2. 유사한 기능의 확장에서 클래스의 갯수가 불필요하게 증가할 수 있다.
3. 2개 이상 슈퍼 클래스의 기능이 필요한 경우, 다중상속이 불가하다(다이아몬드 문제를 해결할 수 없음) - 그렇기 때문에 한 클래스는 상속받고, 다른 하나는 인터페이스 등으로 구현하는 이상한(?) 일이 일어난다.
해결법 Composition(Delegation) 패턴을 이용한다
어떤 자주 변경이 일어날 부분을 클래스 내부가 아닌 외부에서 전달 받아 처리하는 방식(위임)으로 구현한다.
위임을 하게 되면, 구조 변경에 대해 유연성이 증대되고, 외부에 분리되기 때문에 유닛 테스트 할 때 Mocking 하기도 편해 TDD 에도 도움이 된다.
e.g
Public class Calculator {
private PriceStrategy strategy;
public Caculator(PriceStrategy strategy) {
this.strategy = strategy;
}
public void calculate(Price price) {
this.strategy.apply(price);
}
}
'Code Paradigm' 카테고리의 다른 글
클린 코더스 강의 요약(Architecture) - 5 (0) | 2020.11.03 |
---|---|
클린 코더스 강의 요약(Form) - 4 (0) | 2020.10.05 |
클린 코더스 강의 요약(Function Structure) - 3 (0) | 2020.07.21 |
클린 코더스 강의 요약(Function) - 2 (0) | 2020.07.20 |
코틀린으로 작성하는 Double Dispatch(더블 디스패치) (0) | 2020.03.22 |