Skip to main content

[Five Lines Of Code] 3. 긴 코드 조각 내기

DRY(Don't Repeat Yourself, 똑같은 일은 두 번 하지 말 것) 그리고 KISS(Keep It Simple, Stupid, 단순함을 지킬 것) 지침을 따른 경우라도 코드는 쉽게 지저분해지고 혼란스러워 질 수 있습니다. 이 혼란의 주 원인은 다음과 같습니다.

  • 메서드가 여러 가지 다른 일을 수행한다.
  • 낮을 수준의 원시 연산(배열 조작, 산순 연산등)을 사용한다.
  • 주석과 적절한 메서드와 변수명 같이 사람이 읽을 수 있는 텍스트가 부족하다.

https://github.com/wikibook/five-lines/blob/section-3.1/index.ts

해당 코드를 기반하에 설명을 진행합니다.

3.1 첫 번째 규칙: 왜 다섯 줄인가?

어떤 메서드도 5줄 이상을 가질 수 없다는 간단한 규칙입니다.

3.1.1 규칙: 다섯 줄 제한

정의

메서드는 {}를 제외하고 5줄 이상이 되어서는 안 됩니다.

설명

문장이라고도 하는 코드 한 줄은 하나의 if, for, while 또는 세미콜론으로 끝나는 모든 것을 말합니다.

즉, 할당, 메서드 호출, return 같은 것입니다. 공백과 중괄호({})는 제외합니다.

모든 메서드를 이 규칙을 준순하도록 바꿀 수 있습니다. 20줄의 메서드가 있을 경우 첫 10줄과 나머지 10줄로 각각 도우미 메서드를 만듭니다. 원래의 메서드는 이제 두 줄입니다. 한 줄을 첫 번째 도우미를 호출하고 다른 한 줄은 두 번째 도우미를 호출합니다. 각 메서드가 두 줄이 될 때까지 이 절차를 반복합니다.

다음의 코드는 총 4줄이 됩니다.

fun isTrue(bool: Boolean): Boolean {
if (bool) { // 1
return true // 2
} else { // 3
return false //4
}
}

스멜

메서드가 길다는 것 자체가 스멜입니다. 그러면 한 번에 긴 메서드의 모든 논리를 머릿속에 담아야 해서 작업하기가 어렵습니다. 그렇다면 '길다는게 무슨 뜻일까?'라는 당연한 의문이 생깁니다.

이 질문에 답하기 위해 메서드는 한 가지 작업만 해야 한다는 다른 스멜을 생각해볼 수 있습니다. 특정 사례에 맞게 줄 수를 변경할 수는 있겠지만, 실제로 줄의 수는 5줄 정도로 끝나는 경우가 많습니다.

의도

각각 5줄의 코드가 있는 4개의 메서드가 20줄인 하나의 메서드보다 훨씬 빠르고 이해하기 쉽습니다. 각 메서드의 이름으로 코드의 의도를 전달할 수 있기 때문입니다. 기본적으로 메서드의 이름을 지정하는 것은 적어도 5줄마다 주석을 넣는 것과 같습니다.

3.2 함수 분해를 위한 리팩터링 패턴 소개

동일한 작업을 하는 데 필요한 줄의 그룹을 식별해봅시다. 그룹이라고 생각하는 경우 주석을 추가합니다.

const TILE_SIZE = 30;
const FPS = 30;
const SLEEP = 1000 / FPS;

enum Tile {
AIR,
FLUX,
UNBREAKABLE,
PLAYER,
STONE, FALLING_STONE,
BOX, FALLING_BOX,
KEY1, LOCK1,
KEY2, LOCK2
}

enum Input {
UP, DOWN, LEFT, RIGHT
}

let playerx = 1;
let playery = 1;
let map: Tile[][] = [
[2, 2, 2, 2, 2, 2, 2, 2],
[2, 3, 0, 1, 1, 2, 0, 2],
[2, 4, 2, 6, 1, 2, 0, 2],
[2, 8, 4, 1, 1, 2, 0, 2],
[2, 4, 1, 1, 1, 9, 0, 2],
[2, 2, 2, 2, 2, 2, 2, 2],
];

let inputs: Input[] = [];

function draw() {
let canvas = document.getElementById("GameCanvas") as HTMLCanvasElement;
let g = canvas.getContext("2d");

// Initialize map
g.clearRect(0, 0, canvas.width, canvas.height);

// Draw map
for (let y = 0; y < map.length; y++) {
for (let x = 0; x < map[y].length; x++) {
if (map[y][x] === Tile.FLUX)
g.fillStyle = "#ccffcc";
else if (map[y][x] === Tile.UNBREAKABLE)
g.fillStyle = "#999999";
else if (map[y][x] === Tile.STONE || map[y][x] === Tile.FALLING_STONE)
g.fillStyle = "#0000cc";
else if (map[y][x] === Tile.BOX || map[y][x] === Tile.FALLING_BOX)
g.fillStyle = "#8b4513";
else if (map[y][x] === Tile.KEY1 || map[y][x] === Tile.LOCK1)
g.fillStyle = "#ffcc00";
else if (map[y][x] === Tile.KEY2 || map[y][x] === Tile.LOCK2)
g.fillStyle = "#00ccff";

if (map[y][x] !== Tile.AIR && map[y][x] !== Tile.PLAYER)
g.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
}
}

// Draw player
g.fillStyle = "#ff0000";
g.fillRect(playerx * TILE_SIZE, playery * TILE_SIZE, TILE_SIZE, TILE_SIZE);
}

IDEA를 통해 drawMap()으로 메서드 추출을 합니다.

function draw() {
let canvas = document.getElementById("GameCanvas") as HTMLCanvasElement;
let g = canvas.getContext("2d");

// Initialize map
g.clearRect(0, 0, canvas.width, canvas.height);

drawMap(g)
drawPlayer(g);
}

function drawMap(g: CanvasRenderingContext2D) {
for (let y = 0; y < map.length; y++) {
for (let x = 0; x < map[y].length; x++) {
if (map[y][x] === Tile.FLUX)
g.fillStyle = "#ccffcc";
else if (map[y][x] === Tile.UNBREAKABLE)
g.fillStyle = "#999999";
else if (map[y][x] === Tile.STONE || map[y][x] === Tile.FALLING_STONE)
g.fillStyle = "#0000cc";
else if (map[y][x] === Tile.BOX || map[y][x] === Tile.FALLING_BOX)
g.fillStyle = "#8b4513";
else if (map[y][x] === Tile.KEY1 || map[y][x] === Tile.LOCK1)
g.fillStyle = "#ffcc00";
else if (map[y][x] === Tile.KEY2 || map[y][x] === Tile.LOCK2)
g.fillStyle = "#00ccff";

if (map[y][x] !== Tile.AIR && map[y][x] !== Tile.PLAYER)
g.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
}
}
}

function drawPlayer(g: CanvasRenderingContext2D) {
g.fillStyle = "#ff0000";
g.fillRect(playerx * TILE_SIZE, playery * TILE_SIZE, TILE_SIZE, TILE_SIZE);
}

3.3 추상화 수준을 맞추기 위한 함수 분해

3.3.1 규칙: 호출 또는 전달, 한 가지만 할 것

정의

함수 내에서는 객체에 있는 메서드를 호출하거나 객체를 인자로 전달할 수 있지만 둘을 섞어 사용해서는 안 됩니다.

설명

직접 조작하는 낮은 수준의 작업과 다른 함수에 인자로 전달하는 높은 수준의 호출이 공존하면 메서드 이름 사이의 불일치로 가독성이 떨어질 수 있습니다. 동일한 수준의 추상화를 유지하는 편이 코드를 읽기가 훨씬 더 쉽습니다.

배열의 평균을 구하는 함수를 생각해봅시다. 높은 수준의 추상화 sum(arr)과 낮은 수준의 arr.length를 모두 사용합니다.

fun sum(arr: IntArray): Int {
var total = 0
for (num in arr) {
total += num
}
return total
}

// 변경전: sum(arr), arr.size 보다 한 단계 추상화되어있다.
fun average(arr: IntArray): Double {
return (sum(arr) / arr.size).toDouble()
}

fun size(arr: IntArray): Int {
return arr.size
}

// 변경후
fun average(arr: IntArray): Double {
return (sum(arr) / size(arr)).toDouble()
}

스멜

함수의 내용은 동일한 추상화 수준에 있어야 한다는 말은 그 자체가 스멜일 정도로 강력합니다. 전달된 인자의 메서드가 어떻게 사용되었는지를 식별하는 것은 간단할 일인데, 인자로 전달된 변수 옆의 '.'(점)으로 쉽게 찾을 수 있습니다. 즉, 호출 또는 전달, 한 가지만 해야합니다.

draw 메서드를 살펴보면, 변수 g는 매개변수로 전달되기도 하고 거기에 메서드를 호출하기도 합니다.

3.3.2 규칙 적용

function draw() {
let canvas = document.getElementById("GameCanvas") as HTMLCanvasElement;
let g = canvas.getContext("2d");

// Initialize map
g.clearRect(0, 0, canvas.width, canvas.height); // 메서드를 호출

drawMap(g) // 매개변수로 전달
drawPlayer(g);
}

메서드 추출을 사용해서 이 규칙 위반을 수행해봅시다. 코드에서 빈줄로 구분된 g.clearRect줄을 추출하면, 결국 canvas를 인자로 전달하면서 canvas.getContext를 호출하게 돼서 다시 규칙을 위반합니다.

function temp(canvas: HTMLCanvasElement) {
let g = canvas.getContext("2d"); // 메서드를 호출

g.clearRect(0, 0, canvas.width, canvas.height); // 객체의 속성을 매개변수로 전달
}

3.4 좋은 함수 이름의 속성

좋은 이름이 가져야 할 몇 가지 속성은 다음과 같습니다.

  • 정직해야 합니다. 함수의 의도를 설명해야 합니다.
  • 완전해야 합니다. 함수가 하는 모든 것을 담아야 합니다.
  • 도메인에서 일하는 사람이 이해할 수 있어야 합니다. 작업 중인 도메인에서 사용하는 단어를 사용하십시오. 그렇게 하면 의사 소통이 더욱 효율적이게 되고 팀원 및 고객의 코드에 대해 더 쉽게 이야기할 수 있다는 장점이 있습니다.
function draw() {
const g = createGraphics();
drawMap(g)
drawPlayer(g);
}

function createGraphics() {
let canvas = document.getElementById("GameCanvas") as HTMLCanvasElement;
let graphic = canvas.getContext("2d");
graphic.clearRect(0, 0, canvas.width, canvas.height);
return graphic
}

이제 draw 함수를 끝내고 update로 넘어 가겠습니다.

function update() {
while (inputs.length > 0) {
let current = inputs.pop();
if (current === Input.LEFT)
moveHorizontal(-1);
else if (current === Input.RIGHT)
moveHorizontal(1);
else if (current === Input.UP)
moveVertical(-1);
else if (current === Input.DOWN)
moveVertical(1);
}

for (let y = map.length - 1; y >= 0; y--) {
for (let x = 0; x < map[y].length; x++) {
if ((map[y][x] === Tile.STONE || map[y][x] === Tile.FALLING_STONE)
&& map[y + 1][x] === Tile.AIR) {
map[y + 1][x] = Tile.FALLING_STONE;
map[y][x] = Tile.AIR;
} else if ((map[y][x] === Tile.BOX || map[y][x] === Tile.FALLING_BOX)
&& map[y + 1][x] === Tile.AIR) {
map[y + 1][x] = Tile.FALLING_BOX;
map[y][x] = Tile.AIR;
} else if (map[y][x] === Tile.FALLING_STONE) {
map[y][x] = Tile.STONE;
} else if (map[y][x] === Tile.FALLING_BOX) {
map[y][x] = Tile.BOX;
}
}
}
}

줄 바꿈을 기준으로 첫 번째 그룹에서 지배적인 단어가 input이고, 두 번째 그룹에서는 지배적인 단어가 map이라는 것을 알 수 있습니다. 우선 메서드를 추출합니다.

function update() {
handleInputs();
updateMap();
}

function handleInputs(inputs: Input[]) {
while (inputs.length > 0) {
let current = inputs.pop();
if (current === Input.LEFT)
moveHorizontal(-1);
else if (current === Input.RIGHT)
moveHorizontal(1);
else if (current === Input.UP)
moveVertical(-1);
else if (current === Input.DOWN)
moveVertical(1);
}
}

function updateMap() {
for (let y = map.length - 1; y >= 0; y--) {
for (let x = 0; x < map[y].length; x++) {
if ((map[y][x] === Tile.STONE || map[y][x] === Tile.FALLING_STONE)
&& map[y + 1][x] === Tile.AIR) {
map[y + 1][x] = Tile.FALLING_STONE;
map[y][x] = Tile.AIR;
} else if ((map[y][x] === Tile.BOX || map[y][x] === Tile.FALLING_BOX)
&& map[y + 1][x] === Tile.AIR) {
map[y + 1][x] = Tile.FALLING_BOX;
map[y][x] = Tile.AIR;
} else if (map[y][x] === Tile.FALLING_STONE) {
map[y][x] = Tile.STONE;
} else if (map[y][x] === Tile.FALLING_BOX) {
map[y][x] = Tile.BOX;
}
}
}
}

3.5 너무 많은 일을 하는 함수 분리하기

updateMap 에서는 단순히 if, else-if의 연속으로 단순한 메서드 추출로는 힘들 것으로 보입니다. 따라서 또 다른 규칙인 if 조건식은 함수의 시작에만 배치를 소개합니다.

3.5.1 규칙: if 조건식은 함수의 시작에만 배치

정의

if 문이 있는 경우 해당 if 문은 함수의 첫 번째 항목이어야 합니다.

설명

무언가를 확인하는 것은 한 가지 일입니다. 따라서 함수에 if가 있는 경우 함수의 첫 번째 항목이어야 합니다. 또한,그 후에 아무것도 해서는 안 된다는 의미에서 유일한 것이어야 합니다. if 문이 메서드가 하는 유일한 일이어야 한다는 말은 곧 그 본문을 추출할 필요가 없으며, 또한 else 문과 분리해서는 안 된다는 말입니다.

// 순회를 해서 소수를 report 하는 책임
// 소수이면 report 하는 책임
function reportPrimes(n: number) {
for (let i = 2; i < n; i++) {
if (isPrime(i)) {
console.log(`${i} is prime.`);
}
}
}

적어도 두 가지 분명한 작업이 존재합니다.

  • 숫자를 반복합니다.
  • 숫자가 소수인지를 확인합니다.
// 순회를 해서 소수를 report 하는 책임
function reportPrimes(n: number) {
for (let i = 2; i < n; i++) {
reportIfPrime(i);
}
}

// 소수이면 report 하는 책임
function reportIfPrime(n: number) {
if (isPrime(n)) {
console.log(`${n} is prime.`);
}
}

무언가를 확인하는 것이 하나의 작업이며, 하나의 함수에서 처리해야 합니다. 다만, 이를 분리할 때 else ifif문과 분리할 수 없는 원자 단위로 봅니다. 이것은 if 문이 else if와 함께 문맥을 형성할 때 메서드 추출로 수행할 수 있는 가장 작은 단위가 if문과 이어지는 else if까지 포함한다는 것을 의미합니다.

3.5.2 규칙 적용

function update() {
handleInputs();
updateMap();
}

function handleInputs(inputs: Input[]) {
while (inputs.length > 0) {
let current = inputs.pop();
handleInput(current)
}
}

function handleInput(input: Input) {
if (current === Input.LEFT)
moveHorizontal(-1);
else if (current === Input.RIGHT)
moveHorizontal(1);
else if (current === Input.UP)
moveVertical(-1);
else if (current === Input.DOWN)
moveVertical(1);
}

function updateMap() {
for (let x = 0; x < WIDTH; x++) {
for (let y = 0; y < HEIGHT; y++) {
updateTile(x, y);
}
}
}

function updateTile(x: number, y: number) {
if ((map[y][x] === Tile.STONE || map[y][x] === Tile.FALLING_STONE)
&& map[y + 1][x] === Tile.AIR) {
map[y + 1][x] = Tile.FALLING_STONE;
map[y][x] = Tile.AIR;
} else if ((map[y][x] === Tile.BOX || map[y][x] === Tile.FALLING_BOX)
&& map[y + 1][x] === Tile.AIR) {
map[y + 1][x] = Tile.FALLING_BOX;
map[y][x] = Tile.AIR;
} else if (map[y][x] === Tile.FALLING_STONE) {
map[y][x] = Tile.STONE;
} else if (map[y][x] === Tile.FALLING_BOX) {
map[y][x] = Tile.BOX;
}
}

handleInput은 이미 간결해서 다섯 줄 제한 규칙을 준수할 방법을 찾기 어렵습니다. 이에 대한 해결책은 추후 고민해봅시다.