업데이트:

카테고리:

/

태그: , , ,

개념적 유사성 : 어떤 코드는 서로 끌어당긴다.

  • 친화도 높을수록 코드를 가까이 배치
  • 친화도가 높은 상황
    • 직접적 종속성 : 한 함수가 다른 함수를 호출
    • 비슷한 동작을 수행 → 개념적인 친화도가 높다
  • 종속적인 관계가 없어도 가까이 배치할 수 있다.

가장 중요한 개념은 상단에 세세한 설명은 뒤에 작성

가로형식

긴 행보다는 짧은 행을 선호

가로 공백 : **밀접한 개념과 느슨한 개념

totalChars += lineSize

위와 같이 서술했을 때 연산자를 기준으로 공백을 주면 양쪽에 요소가 확실히 분리되어 보인다.

class Example() {
	fun add(a: Int, b: Int): Int {
		return a + b
	}
}

함수를 작성할 때는 괄호와 함수명 사이를 붙여놓는다.

한눈에 보았을 때 한 개념이라는 사실을 보이기 위해서

우선순위를 기준으로 공백을 줄 수 있다.

b*b - a*4*c 식으로 곱셉&나눗셈 의 우선순위가 높으므로 같이 붙여놓고 우선순위가 낮은 뺄셈&덧셈을 떨어트려 놓는다.

가로정렬 - 각 항목의 스페이스바 간격을 맞추겠다고 진행하는 건 대단히 바보같은 짓

들여쓰기

선언문과 실행문을 해석하는 범위 → 파일의 구조가 한눈에 들어오게 된다.

이때 간단한 if문, 짧은 while문 의 경우 한줄에 표현가능하여 들여쓰기를 무시하고 작성하고 싶어도 들여쓰기로 구분

→ 함수는 몰라도 if문은 조건이 길어지지 않는 한 코드 가독성이 올라가지 않을까?

팀규칙

팀에서 일한다면 정한 컨벤션은 최우선순위로 두고 진행하자

5. 객체와 자료구조

변수를 pirvate하게 만드는 이유 → 남들이 변수에 의존하고 싶지않아서

자료 추상화

// 구체적인 클래스
class Point {
	val x: Double
	val y: Double
}

// 추상적인 클래스
interface Point {
	fun getX(): Double
	fun getY(): Double
	fun setCartesian(x: Double, y: Double)
	fun getR(): Double
	fun getTheta(): Double
	fun setPolar(r: Double, theta: Double)
}

interface로 구현하여 interface 내부에 데이터를 가져오고 싶으면 메서드를 이용하여 설정하고 요청해야된다.

자료의 핵심은 숨긴채 유저가 구현을 몰라도 조작할 수 있어야 된다.

// 구체적인 클래스
interface Vehicle {
	fun getFuelTankCapacityInGallons(): Double
	fun getGallonsOfGalsoline(): Double
}

// 추상적인 클래스
interface Vehicle {
	fun getPercentFuelRemaining(): Double
}

위는 자동차의 연료 상태를 구체적으로 알려주지만, 아래는 연료상태를 퍼센트라는 추상적인 개념으로 알려준다.

인터페이스를 제작하고, 조회/설정 함수를 만든다고 해서 그것이 추상화가 이뤄지는 것은 아니다.

자료/객체 비대칭

// 절차적
class Square {
	val topLeft: Point
	val sode: Double
}

class Rentangle {
	val topLeft: Point
	val height: Double
	val width: Double
}

class Circle {
	val center: Point
	val radius: Double
}

class Geometry {
	val PI: Double = 3.1415...

	fun area(shape: Object): Double: {
		if (shape instanceOf Square) {
			val s: Sqaure = shape
			return s.side * s.side
		} else if (shape instanceOf Rectangle) {
			val r: Rectangle = shape
			return r.height * r.width
		} else if (shape instanceOf Circle) {
			val c: Circle = shape
			return c.radius * c.radius * PI
		}
	}
}

Geometry 클래스에 새로운 기능을 하는 함수를 추가하고 싶다면, 추가하면 된다. 도형 클래스는 아무 영향도 받지 않는다.

// 다형적
class Square: Shape() {
	private val topLeft: Point
	private val side: Double

	fun area(): Double {
		return side*side
	}
} 

class Rectangle: Shape() {
	private val topLeft: Point
	private val height: Double
	private val width: Double

	fun area(): Double {
		return height*width
	}
} 

class Circle: Shape() {
	private val center: Point
	private val radius: Double
	private const val PI: Double = 3.1415...

	fun area(): Double {
		return PI*radius*radius
	}
} 

새도형을 추가한다면 기존에 있는 객체는 아무런 영향이 없지만, 새로 둘레를 구하는 함수를 추가한다면 기존에 작성한 모든 클래스를 수정해야된다.

결론

  • 절차적: 기존 자료 구조를 변경하지 않으면서 새 함수를 추가하기 쉽다.
  • 객체 지향: 기존 함수를 변경하지 않으면서 새 클래스를 추가하기 쉽다.

디미터 법칙

모듈은 자신이 조작하는 객체의 자세한 사항까지 알 필요는 없다.

  • 클래스 C의 메서드는 f는 다음과 같은 객체의 메서드만 호출
    • 클래스 C
    • 메서드 f 가 생성한 객체
    • 메서드 f 의 인수로 넘어온 객체
    • 클래스 C 인스턴스 변수에 저장된 객체

기차충돌: 여러객체가 한줄로 이어짐

val outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath()

val opts = ctxt.getOptions()
val scratchDir = opts.getScratchDir()
val outputDir = scratchDir.getAbsolutePath()

만약 객체가 아닌 자료 구조의 형태라면 이런식으로 줄줄히 소세지를 만들필요가 없지만 그게 아니라면 분해하여 설정하는 것이 맞다.

아니면 오히려 ctxt 객체에게 임시파일을 만들라는 함수를 작성하면 절대경로를 가져올 필요없이 내부에서 사용하여 새로운 임시파일 객체를 던져주면 된다.

6. 오류처리

오류를 깔끔하게 처리하는 것도 Clean Code로 나아가기 위한 발걸음

예외를 사용

try-catch문 으로 수행과정 중 어떤 오류가 발생하면 catch 문의 로직으로 들어가니 코드가 깔끔해진다.

그렇기 때문에 예외가 발생할 코드에는 try-catch-finally문 으로 시작하는 것이 코드를 깔끔하게 짜는 데 도움이 된다.

이것은 단위테스트에서도 도움이 되는데, 예외가 없는 코드는 테스트에서 잘못된 파일을 넘겨도 예외를 던지지 않기에 테스트가 실패한다.

예외를 던지는 코드는 단위 테스트에서 성공한다.

테스트 코드를 작성할 때 예외를 일으키는 코드를 작성하고 예외에 맞춰 코드를 작성하는 것이 트랜잭션의 범위를 구현하게 되므로 범위 내에서 트랜잭션 본질을 유지하기 쉽다.

미확인 예외 사용

과거에는 확인된 예외를 사용하여 예외처리를 하였다. → 비용이 많이 발생할 수 있다.

메서드에서 확인된 예외를 던질 때, catch 블록이 세 단계 위에 있다면 그 사이 로직에 모두 catch문을 작성해야된다. 즉, 하위 단계의 코드를 변경하면 상위단계 코드도 전부 고쳐야 한다.

예외에 의미 제공

예외를 던질 때는 전후 사항을 충분히 설명할 것 → 오류메세지에 정보를 담아 예외에 던지기

로깅할 때 어느 부분에서 문제가 발생했는지 알기 쉽다.

호출자를 고려해 예외 정의

예외 상황을 하나로 합쳐 출력하자

null을 반환하지 마!

null을 반환하게 되면 null을 체크해야되는 코드가 수두룩해진다.

만약 list를 반환하는 코드라면 null이 아닌 emptyList를 반환하거나, 빈문자열… null이 아닌 빈 값을 보내도록하자, 이렇게하면 NullPointerException 이 발생할 가능성이 줄어든다!

null 전달 금지

메서드에서 null을 전달하는 코드는 최대한 지양해야된다.

만약 Non-null 한 값에 null이 들어온다면 에러가 발생할 것이고 이것을 해결할 방법은 메서드 내부에 인자들이 null한 값인지 확인하여 로직을 수행하는 수 밖에 없다.

그렇기 때문에 애초에 함수 인자에 넘겨줄 때 null한 값이 넘어오지 않도록 수정하는 것이 에러 발생확률을 줄이는 방법이다.

7. 경계

우리는 내부 코드 로직으로만 돌아가는 프로그램을 짜기보다는 외부 라이브러리나 오픈소스를 활용하여 작성하는 경우가 많다.

외부 코드 사용

외부 코드로 Map을 사용한다고 생각해보자

val sensors = HashMap()
val s = sensors.get(sensorId)

map이 사용할 수 있는 메서드는 무궁무진하게 많고 그 중에서 clear() , put(), remove() 등 데이터를 조작할 수 있는 메서드를 누구가 사용할 수 있다.

이후 시간이 지나면서 map을 여기저기에서 호출하게 되고 사용하다보면 map 의 구조가 바뀔 수 있을 것이고 그러면 map 사용된 모든 코드를 변경해야 될 수 있다.

경계 클래스 제작

class Sensors {
	private sensors: Map = HashMap()
	
	fun getById(id: String): Sensors {
		return sensors.get(id)
	}
}

경계 인터페이스를 Sensor 내부에 숨김으로서 인터페이스가 변해도 나머지 프로그램에는 영향을 끼치지 않아 문제가 일어날 확률이 줄어든다.

외부에서 가져온 코드를 내부에서 알맞게 변환해서 사용할 공간이 필요!

경계 살피고 익히기

외부 라이브러리를 사용할 때는 무턱대고 사용하는 프로젝트에 연결시키는 것이 아니라 문서를 읽고 사용법을 익히고 이후 테스트 코드에서 라이브러리를 실행해보면서 정상적으로 동작할 때 까지 시도한 뒤에 프로젝트에 사용해야된다.

8. 단위 테스트

TDD의 법칙

  1. 실패하는 단위 테스트를 작성할 때까지 실제 코드 작성 x
  2. 컴파일은 실패하지 않으면서 실행이 실패하는 정도의 단위 테스트를 작성
  3. 현재 실패하는 테스트를 통과할 정도로만 실제 코드 작성

기능이 분리되지 않은 상태에서 작성한 테스트 코드를 나중에 로직이 바뀔 때 흐름을 따라가지 못하고 없느니만 못한 상태가 되어버림 → 테스트 코드에 유지 보수하는 비용이 늘어남

단위 테스트 → 유연성, 유지보수성, 재사용성

test coverage가 높을수록 변경되었을 때 버그가 없는 방향으로 테스트가 가능

테스트 코드의 중요한 점 : 가독성

테스트 케이스를 제작할 때 함수를 중복호출하는 경우가 많은데, 계산기로 예를 들면 숫자입력이나 연산자 입력에 대해서 하나마다 호출이 들어감, 이것으로 인해 코드의 가독성이 떨어짐

중복된 함수는 하나로 묶어서 처리하도록 하자

테스트 코드는, 자료 제작, 자료 조작, 결과 판단, 3부분으로 나누어서 작성하는 것이 테스트 구조에 적합하다. → 함수의 형태는 assert문 으로 작성(결과는 boolean 으로 나오고, 에러가 발생할 시 exception을 던짐)

테스트 당 개념 하나

여러 개념을 연속으로 테스트하는 긴 함수는 지양할 것

동일한 테스트를 여러개 진행해도 개념 하나마다 분리해서 테스트 케이스로 만들어야 옳다

깨끗한 테스트의 5가지 법칙

  1. Fast(빠르게): 테스트 코드를 빨리 돌아야 함, 자주 돌려야되기 때문에 시간이 적게 소모되어야 함
  2. Independent(독립적으로): 각 테스트 케이스가 의존적이면 안됨, 한 테스트 코드가 다른 테스트 코드의 환경을 준비해서는 안됨, 오류가 생겼을 때 컴파일하기가 어려워짐
  3. Repeatable(반복가능하게): 어떤 환경에서도 반복 가능
  4. Self-Validting(자가검증): 테스트는 bool값으로 결과를 반환, 통과 여부를 확인하기 위해 로그 파일을 읽도록 해선 안된다.
  5. Timely(적시에): 테스트 코드는 실제 테스트 코드 작성 전에 작성한다.

9. 클래스

클래스 체계

  • 변수 목록
    • 공개 변수
    • 비공개 변수
  • 함수
    • 공개 함수
    • 비공개 함수

웬만하면 공개하지 않는 편이 좋지만, 굳이 숨겨야된다는 규칙도 없기에 protected 를 사용하여 접근은 가능해도 수정은 할 수 없도록 한다. → 공개 여부 결정은 클래스가 완성되고 마지막에 정할 사항이다.

클래스는 작아야 함

클래스의 책임이 많으면 안됨 → 책임이 많을수록 클래스의 크기도 커짐

클래스 이름에 Processor, Manager, Super로 명명된 객체는 그만큼 많은 책음을 떠앉았다는 것

단일 책임 원칙

클래스나 모듈을 변경할 이유가 하나 뿐이어야 한다

클래스를 세세히 분리해놓아야 큰 프로젝트를 진행할 때 코드를 찾기가 더 쉬워진다.

응집도

인스턴스 변수가 적어야 함

메서드가 변수를 많이 사용할수록 메서드와 클래스 사이 응집도가 높아진다

⇒ 클래스의 변수와 메서드가 서로 의존하여 논리적인 단위로 묶임

class Stack {
	private val topOfStack = 0
	val elements = lisfOf<Int>()
	
	fun size(): Int {
		return topOfStack	
	}
	
	// 이 두개의 함수는 클래스의 인스턴스 변수를 사용하고 있음
	fun push(element: Int) {
		topOfStack++
		elements.add(topOfStack)
	}

	fun pop(): Int {
		if (topOfStack == 0) {
			throw PoppedWhenEmpty()
		}
		val element = elements.get(--topOfStack)
		elements.remove(topOfStack)
		return element
	}
}

위의 클래스 처럼 메서드가 인스턴스 변수를 모두 사용하면 문제가 없지만, 일부 함수가 일부 변수만 사용한다면 다른 클래스로 분리해야될 필요성이 있다.

변경하기 쉬운 클래스

class Sql {
	fun SQL(table: String, columns: List<Column>)
	fun create(): String
	...
	private fun columnList(columns: List<Column>): String
	...
}

위의 클래스는 update라는 새로운 메서드를 추가해야 할 때 Sql 클래스에 손을 대서 고쳐야한다.

이경우 클래스는 SRP를 위반한다.

그래서 Sql 를 추상클래스로 변경하고 남은 모든 메서드는 Sql 에서 파생되는 클래스로 교체하였다.

abstract class Sql {
	fun Sql(table: String, columns: List<Column>)
	abstract fun generate(): String
}

class CreateSql: Sql() {
	fun createSql(table: String, columns: List<Column>)
	override fun generate(): String
}

...

class ColumnList {
	fun ColumnList(columns: List<Column>)
	fun generate(): String
}

클래스 하나마다 메서드를 하나씩 가져 새로운 기능을 추가할 때도 기존에 있던 클래스를 건들일 필요없이 추가하면된다.

이렇게 제작된 클래스는 OCP도 지원해, 새로운 기능 추가에는 열려있지만, 기존 기능 수정에는 폐쇄적이다. ⇒ 이런 구조가 매우 바람직하다.

interface stockExchange {
	fun currentPrice(symbol: String): Money
}

class Portfolio {
	private lateinit var exchange: stockExchange
	
	fun Portfolio(exchange: stockExchange) {
		this.exchange = exchange
	}
}

// 테스트 코드
class PortfolioTest {
	private lateinit var exchange: FixedStockExchangeStub
	private lateinit var portfolio: Portfolio

	@Before
	protected fun setUp() {
		exchange = FixedStockExchangeStub()
		exchange.fix("MSFT", 100)
		portfolio = Portfolio(exchange)
	}

	@Test
	fun GivenFiveMSRTTotalShouldBe500 {
		portfolio.add(5, "MSFT")
		Assert.assertEquals(500, portfolio.value())
	}
}

테스트가 가능할 정도로 결합도를 낮추면, 유연성과 재사용성이 높아짐

그리고 결합도가 높으면 portfolio의 예상 결과가 궁금하다면 환율정보부터 정의하고 예상 결과를 출력해야 하는데 분리되어 있으니 미리 지정하고 테스트에 필요한 코드만 간결하게 작성할 수 있어 위에서 언급했던, 테스트를 작게 만들 수 있다.

10. 시스템 - 이해안됨

시스템 제작과 시스템 사용을 분리

시작은 관심사 분리, 보통의 애플리케이션은 시작 단계의 관심사를 분리하지 않아 준비과정 코드와 런타임 로직이 섞여있다.

fun getService(): Service {
	if (service == null) {
		service == MyServiceImpl()
	}
	return service
}

위의 코드는 MyServiceImpl 에 의존한다. 만약 service가 이미 정의되어 있어 MyServiceImpl 를 사용하지 않더라도 의존성을 해결하지 않으면 컴파일이 진행되지 않는다.

이는 테스트할 때도 문제가 되는데 MyServiceImpl 가 무거운 객체라면 getService() 를 호출하기 전에 미리 할당해야된다. 그리고 런타임 로직에 객체 생성을 섞어놓아서, 모든 실행 경로를 모두 테스트 해봐야한다.

체계적이고 탄탄한 시스템을 만들기 위해서는 설정논리와 일반 실행 논리를 분리 해야 모듈성이 높아져 원하는 시스템을 만들 수 있다.

  1. Main 분리

    생성과 관련한 코드는 모두 main이나 main이 호출하는 모듈로 옮기고, 나머지 시스템은 모든 객체가 생성되었고 모든 의존성이 연결되었다고 가정한다.

    애플리케이션은 객체가 생성되는 과정을 모름

  2. 팩토리

    때로는 객체가 생성되는 시점을 애플리케이션이 결정해야 함

    main에서 팩토리를 사용하여 초기화를 할 때, Android의 경우에는 Acitivty의 값을 넘겨 Factory에서 의존성 주입과, 복잡한 생성로직을 담당해 ViewModel을 반환한다.

  3. 의존성 주입

    객체가 가지고 있는 보조 책임을 새로운 객체로 떠넘긴다 → 단일 책임 원칙도 지키게 된다.

    하지만 객체는 의존성을 인스턴스로 만드는 책임을 지지 않으므로 이 책임을 질 전담 매커니즘으로 main 이나 컨테이너를 활용한다. (android에서는 Hilt가 해당 부분을 맡고있다.)

    DI 컨테이너가 필요한 객체의 인스턴스를 생성 후, 인수나 설정자 메서드를 사용해 의존성을 설정한다.

확장

처음 부터 도시를 설계할 때 6차선 도로를 짓는 마을은 없을 것이다. 그만한 비용을 정당화할 논리도 없고 필요도 없으니까

대신에 그날 그날에 맞춰 시스템을 구현하고, 내일은 내일에 맞는 새로운 스토리에 시스템을 조정하고 확장한다.

11. 창발성

설계 규칙

  1. 모든 테스트를 실행

    설계는 의도한 대로 돌아가는 시스템을 내놓아야 한다.

    테스트를 거쳐 모든 테스트를 항상 통과하는 테스트가 가능한 시스템을 만들어야한다.

    이런 설계를 추구하면 크기가 작고 목적은 하나인 클래스가 나오며, 그 덕에 테스트가 더욱 쉬워진다.

    결합도가 높으면 테스트 케이스를 작성하기 힘들기 때문에 결합도를 낮추기 위해 의존성 주입, 인터페이스, 추상화와 같은 방법을 사용하고 설계 품질은 더욱 높아진다.

  2. 리팩터링

    테스트 코드를 작성 후 코드와 클래스를 정리한다

    1. 중복을 제거

       fun size(): Int {}
       fun isEmpty(): Boolean {}
      

      각 메서드를 따로 구현하는 방법도 있지만 아래와 같이 한번에 구현하는 방법도 있다.

       fun isEmpty(): Boolean {
       	return 0 == size()
       }
      

      각 코드에서 공통으로 사용하는 부분은 따로 함수로 빼서 작성하여 중복을 제거하는 방법이 있다. 이러면 가독성도 높아지고, 시스템 복잡도도 낮아진다.

    2. 프로그래머의 의도를 표현

      프로그램의 규모가 커짐에 따라 유지보수를 위한 코드 분석에 시간이 오래 걸리고 코드를 오해할 가능성도 커진다.

      개발자는 의도를 명확하게 표현하여, 이해하기 쉽고 오해가 없는 코드를 작성해야 유지보수 비용이 적게 든다.

      → 좋은 이름, 함수와 클래스의 크기를 최소, 표준명칭 사용, 단위 테스트 케이스 작성

    3. 클래스와 메서드를 최소로 줄이기

12. 동시성

스레드가 하나인 프로그램은 한 곳에서 모든 로직을 돌려야하니 무엇언제가 밀접 → 호출스택을 부르면 상태가 바로 드러남

이 무엇과 언제를 분리하면 구조과 효율이 매우 좋아진다. 프로그램이 작은 협력 프로그램으로 보이기에 구조 파악이 쉬워진다.

예로 웹의 서블릿 모델은 요청이 들어올 때 마다 서블릿을 실행한다. 각 서블릿 스레드는 다른 스레드와 무관하게 돌아간다.

이런 구조적 개선을 위해서 동시성을 사용할 뿐만 아니라, 응답 시간작업 처리량 개선을 위해서 사용하는 경우도 있다.

class Example {
	private var lastIdUsed = 0
	
	fun getNextId(): Int {
		return ++lastIdUsed
	}
}

lastIdUsed 를 42로 설정하고 getNextId를 호출하면 다음의 경우 중 한가지의 결과가 나온다.

  • A스레드는 43을 받고, B 스레드는 44를 받는다. lastIdUsed 는 44가 된다.
  • A스레드는 44을 받고, B 스레드는 43를 받는다. lastIdUsed 는 44가 된다.
  • A스레드는 43을 받고, B 스레드는 43를 받는다. lastIdUsed 는 43가 된다.

마지막의 경우 가능성은 매우 희박하지만 해당 결과가 나올 가능성이 있다.

동시성 방어 원칙

  1. 단일 책임 원칙(Single Responsibility Principle)

    주어진 메서드/ 클래스/ 컴포넌트를 변경할 이유가 하나여야 한다는 원칙

    동시성과 다른 코드는 분리해야됨

  2. 자료 범위 제한

    객체 하나를 공유하고서 동일 필드를 수정할 때 스레드 간의 간섭으로 예상치 못한 결과가 나온다. 공유하는 객체를 사용하는 코드 내에서 임계영역을 sychronized 로 보호할 것

    자료를 캡슐화 하고 공유하는 변수를 줄여라

  3. 자료 사본을 사용

    공유 자료를 줄이는 방법은 자료 사본을 사용하는 것, 객체를 복사하여 읽기 전용으로 사용하는 방법이 있다. 하지만 이 방법은 시간과 성능에 부하가 걸릴 수 있기에, 한번 실제 구동해서 확인하는 것이 중요

  4. 스레드는 독립적으로 구현

    다른 스레드와 자료를 공유하지 말 것, 각 스레드는 요청 하나를 처리한다.

    서블릿 코드가 로컬 변수만 사용하면 동기화 문제를 일으킬 수가 없다.

코드에 보조 코드를 넣어서 강제로 오류를 일으킬 것

  • 어디에 보조 코드를 삽입할 것인가?
  • 어떤 함수를 호출할 것인가
  • 실제 배포된 버전은 해당 코드가 있으면 성능이 떨어진다.
  • 무작위적이기에 드러내지 않을 확률이 크다.

13. 점진적인 개선

필요한 유틸리티(함수)가 없으면 만든다.