[워밍업 클럽 2기 - 백엔드 클린 코드, 테스트 코드] 1주차 발자국
본 글은 인프런에서 진행하는
워밍업 클럽 2기 - 클린코드&테스트반
박우빈 - Readable Code : 읽기 좋은 코드를 작성하는 사고법
강의를 회고하면서 작성했습니다.
강의에서 보고 배우는 것들이 정말 중요하다는 것은 항상 알고 있었지만,
개인 프로젝트든, 팀 프로젝트든 강의에서 배우는 것들을 제대로 적용해본적은 없었다.
그래서 이참에 강의 내용을 회고하고 복습하며 몸에 습관으로 남기고자 했다.
크게 섹션별로 나눠 내가 배운 것들을 정리해보겠다.
분량이 좀 많습니다.
섹션 2 - 추상
이 섹션에 배운, 추상화의 원칙을 적용해서 지뢰게임 시스템을 일부 리펙터링해 볼 것이다.
추상화라는 것은 클린 코드를 관통하는 하나의 개념이라고 할 수 있다.
쉽게 말하면, 중요한 정보는 가려내어 남기고, 덜 중요한 정보는 생략해 버리는 것이다.
추상에 반대 개념인 구체에서 정보를 함축하고 제거하면 추상이된다.
구체와 추상은 이분법적으로 나뉘지 않는다.
추상화도 단계적으로 나뉘며, 상대적인 개념이라 할 수 있다.
예를 들어, 운영체제는 하드웨어에 비해 추상화되었고
애플리케이션은 운영체제에 비해 추상화되었다.
추상화는 구체를 유추할 수 있도록 해야한다.
추상화 과정에서 중요한 정보를 부각시키지 못하거나,
해석자들이 동일하게 공유하는 문맥에서 벗어난다면 추상으로부터
어떤 구체에서 파생된 것인지 분석할 수 없다.
이 추상을 가장 쉽게 접할 수 있는 방법은 바로 '이름 짓기'이다.
추상적 사고는 표현하고자 하는 구체에서 정말 중요한 핵심 개념만을
추출하여 잘 드러낸 표현을 뜻한다.
이때, 도메인의 문맥 안에서 이해될 수 있는 일반적인 용어여야한다.
개념적인 말이 길었는데, 이 '이름 짓기'라는 추상화 작업을 언급한 이유는
우리가 정말 친숙하게 사용하고 있던 추상화 작업 중 하나이기 때문이다.
우리가 프로젝트를 개발할 때, main 메서드에 모든 로직을 때려박지 않고
기계적으로 특정 작업들은 이름을 부여해 따로 분리했을 것이다.
이때, 이름을 부여해 메서드로 분리해낸 것이 바로 추상화 작업이라 할 수 있다.
특정 로직에 이름을 부여해줌으로써, 단순히 프로그래밍 언어 몇줄로 작성되어 있던
코드 덩어리가 의미가 생기고 여러 방면에서 활용될 수 있게되었다.
이때, 이 메서드를 만들 때 몇가지 유의할 점이 있다.
1. 메서드가 수행하는 '구체'적인 작업을 메서드명이 잘 추상화하여 표현하고 있는지
2. 메서드가 2가지 이상의 일을 수행하는지는 않는지
3. 충분히 포괄적인 의미를 담아 이름을 지었는지
모든 로직이 때려박혀 있던 기존 main 메서드에
갑자기 추상화된 특정 메서드가 생긴다는 것은,
추상화 작업을 통해 새로운 경계가 생겨난 것을 의미한다.
메서드로 분리된 곳을 내부 세계라고 한다면, 이 내부 세계는 추상화 레벨이 낮다고
할 수 있다. 외부 세계에 비해 구체적인 특정 작업을 수행하기 때문이다.
반대로 기존 여러 로직들이 엉켜있던 외부 세계는 추상화 레벨이 높다고 할 수 있다.
이제 본격적인 추상화 작업에 들어라려고 한다.
오늘은 추상화 원칙에 맞춰, 여러 복잡한 로직들로 엉켜있는 기존 코드들에서
특정 작업들을 추상화하여 새로운 메서드들로 만들어 내는 작업에 의의를 두겠다.
public class MinesweeperGame {
private static String[][] board = new String[8][10];
private static Integer[][] landMineCounts = new Integer[8][10];
<중략>
public static void main(String[] args) {
System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");
System.out.println("지뢰찾기 게임 시작!");
System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");
Scanner scanner = new Scanner(System.in);
for (int i = 0; i < 8; i++) {
for (int j = 0; j < 10; j++) {
board[i][j] = "□";
}
}
<중략>
for (int i = 0; i < 8; i++) {
for (int j = 0; j < 10; j++) {
int count = 0;
if (!landMines[i][j]) {
if (i - 1 >= 0 && j - 1 >= 0 && landMines[i - 1][j - 1]) {
count++;
}
if (i - 1 >= 0 && landMines[i - 1][j]) {
count++;
}
<중략>
}
landMineCounts[i][j] = count;
continue;
}
landMineCounts[i][j] = 0;
}
}
while (true) {
System.out.println(" a b c d e f g h i j");
for (int i = 0; i < 8; i++) {
System.out.printf("%d ", i + 1);
for (int j = 0; j < 10; j++) {
System.out.print(board[i][j] + " ");
}
System.out.println();
}
<중략>
char r = input.charAt(1);
int col;
switch (c) {
case 'a':
col = 0;
break;
case 'b':
col = 1;
break;
<중략>
여기 누가봐도 복잡한 코드 구성이 보인다.
우선, 이 지뢰찾기 게임에서 각 독립적인 기능들을 파악하고
그 기능에 대한 작업은 메서드로 추상화하여 프로그램의
가독성과 유지보수성을 높여보자.
System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");
System.out.println("지뢰찾기 게임 시작!");
System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");
그저 게임 인트로 부분을 담당하는 이 세줄의 코드를 이름을 부여해서
따로 빼주도록 하자.
원하는 부분을 드래그 한 뒤, option + cmd + M 하면 메서드로 분리할 수 있다.
이렇게 분리함으로써, main 메서드와 showGameStartComments 메서드간의
경계가 생겼다. 지뢰찾기 게임 시작이라는 인트로 문구를 출력하는 특정 작업만을
수행하고, 메서드 명을 통해 이 점이 구체적으로 드러나는
showGameStartComments메서드는 main메서드보다
추상화 단계가 낮다고 할 수 있다.
가독성이 높아졌을 뿐만 아니라, 추후 서비스 기획의 변경으로 인해
인트로 문구가 변경되어야 할 때 복잡한 main 메서드 안을 뒤지지 않고
바로 showGameStartComments의 몸체 부분을 수정해 줄 수 있을 것이다.
유지보수성이 향상된 것이다.
또한, 게임의 다른 상황 혹은 다른 게임 내에서 이 메서드의 출력 부분이 필요하다면
메서드 호출을 통해서 간편하게 재사용할 수 있다. 동일한 코드를 반복해서 작성할 일을 방지하므로, 코드의 재사용성도 향상되었다.
아직까지는 사실 큰 변화는 없다. 계속해서 리펙터링해보자.
for (int i = 0; i < 10; i++) {
int col = new Random().nextInt(10);
int row = new Random().nextInt(8);
landMines[row][col] = true;
}
for (int i = 0; i < 8; i++) {
for (int j = 0; j < 10; j++) {
int count = 0;
if (!landMines[i][j]) {
if (i - 1 >= 0 && j - 1 >= 0 && landMines[i - 1][j - 1]) {
count++;
}
if (i - 1 >= 0 && landMines[i - 1][j]) {
count++;
}
if (i - 1 >= 0 && j + 1 < 10 && landMines[i - 1][j + 1]) {
count++;
}
<중략>
count++;
}
landMineCounts[i][j] = count;
continue;
}
landMineCounts[i][j] = 0;
}
}
지뢰게임 초기 세팅을 위한 초기화 부분이다. 작업의 역할이 구체적이고 독립적이므로
특정 메서드로 추상화하는 것이 좋을 것 같다.
여기까지만 왔는데도, 많은 개선이 되었다.
해당 작업에 initializeGame라는 이름을 붙여 추상화하였고,
구체적인 작업 사항은 initializeGame 메서드 몸체 부분에 있고
main 메서드에서는 추상화된 이름으로만 명시되어 있다.
if (gameStatus == 1) {
System.out.println("지뢰를 모두 찾았습니다. GAME CLEAR!");
break;
}
if (gameStatus == -1) {
System.out.println("지뢰를 밟았습니다. GAME OVER!");
break;
}
이 부분을 잘 살펴보자. main메서드에
추상화된 작업명들만 잘 남고 있다가 뜬금없이
gameStatus를 그저 숫자값만 비교하며 게임의 흐름을 제어하는 부분이 보인다.
어떤 작업인지 유추할 수 없을 뿐 아니라, 잘 유지해가고 있던 추상화 레벨이 깨졌다.
추상화 원칙에서 정말 중요한 사항은, 하나의 계층 안에서는 추상화 레벨이 동등해야한다는
것이다. 갑자기 낮은 추상화 레벨의 이 로직 부분을 추상화하고, 구체적인 작업은
분리된 메서드 몸체 내부에 구현되도록하자.
이제 main 메서드에서 추상화 레벨이 잘 유지되었다.
추상화된 이름만 보아더 어떤 작업인지 유추할 수 있다.
구체적인 구현 내부는 main 메서드 입장에서 관심없다. 메서드 명대로만
잘 작동하면 상관없다.
System.out.println("선택할 좌표를 입력하세요. (예: a1)");
String input = scanner.nextLine();
System.out.println("선택한 셀에 대한 행위를 선택하세요. (1: 오픈, 2: 깃발 꽂기)");
String input2 = scanner.nextLine();
char c = input.charAt(0);
char r = input.charAt(1);
int col;
switch (c) {
case 'a':
col = 0;
break;
case 'b':
col = 1;
break;
case 'c':
col = 2;
break;
<중략>
default:
col = -1;
break;
}
int row = Character.getNumericValue(r) - 1;
벌써 숨이 턱 막힌다.
유저로부터 입력받은 문자열을 파싱하고, 로직에서 사용할 데이터 타입으로 변경하는
부분이 너무 장황하고, 너무 구체적이다.
String cellInput = getCellInputFromUser("선택할 좌표를 입력하세요. (예: a1)", scanner);
int selectedColIndex = getSelectedColIndex(cellInput);
int selectedRowIndex = getSelectedRowIndex(cellInput);
우리가 얻어야 하는 것은 유저가 입력한 문자가 결국 지뢰 배열에서
어떤 행, 열 값인지이다.
이를 메서드로 분리한다.
private static String getCellInputFromUser(String x, Scanner scanner) {
System.out.println(x);
String cellInput = scanner.nextLine();
return cellInput;
}
private static int getSelectedColIndex(String cellInput) {
char cellInputCol = cellInput.charAt(0);
return convertColFrom(cellInputCol);
}
여기서 getSelectedColIndex 메서드에 주목하자. 뎁스가 한 번 더 생겼다.
getSelectedColIndex은 문자열 파싱만 담당하고, 파싱한 알파벳을
행, 열에 쓰일 int값으로 변환해주는 작업은 다시 convertColFrom메서드에게 맡겼다.
private static int convertColFrom(char cellInputCol) {
switch (cellInputCol) {
case 'a':
return 0;
case 'b':
return 1;
case 'c':
return 2;
<중략>
default:
return -1;
}
}
이렇게 되면 추상화 레벨이 상대적으로 더 나뉘었다고 할 수 있다.
getSelectedColIndex은 main 메서드보다 구체적이며
convertColFrom은 getSelectedColIndex 메서드보다 구체적이다.
역도 성립한다.
String cellInput = getCellInputFromUser("선택할 좌표를 입력하세요. (예: a1)", scanner);
String userActionInput = getCellInputFromUser("선택한 셀에 대한 행위를 선택하세요. (1: 오픈, 2: 깃발 꽂기)", scanner);
int selectedColIndex = getSelectedColIndex(cellInput);
int selectedRowIndex = getSelectedRowIndex(cellInput);
코드가 굉장히 깔끔해졌다. 구현부분들이 모두 사라지고
추상화된 메서드명들을만 남았지만, 이름을 통해 그 흐름이 유추가 된다.
지금 같은 흐름으로 나머지 코드들도 똑같이 작업해준다.
이제, 매직 넘버와 매직 스트링 개념을 설명하면서
특정 값들을 상수로 추출하는 작업을 설명하고자 한다.
매직 넘버, 매직 스트링은 의미를 갖고 있으나 아직 상수로 추출되지 않은
숫자나 문자열 등을 말한다.
이런 값들을 상수로 추출하고 이름을 지어 의미를 부여하면
가독성과 유지보수성이 향상되게 된다.
현재 시스템에서 코드에서 상수로 뺄 수 있는 것들을 살표보자.
private static String[][] board = new String[8][10];
private static Integer[][] landMineCounts = new Integer[8][10];
지뢰게임의 판 크기였던 세로 8, 가로 10은 변하지않으며
중복해서 여기저기서 쓰인다. 상수로 딱 빼기 좋은 예시이다.
단축기 option + cmd + c를 통해 상수로 추출한다.
게임의 인터페이스 부분을 차지하던 깃발, 땅, 지뢰 기호들도
빼줄 수 있다.
public static final String FLAG_SIGN = "⚑";
public static final String LAND_MINE_SIGN = "☼";
public static final String CLOSED_CELL_SIGN = "□";
public static final String OPENED_CELL_SIGN = "■";
물론 여기서도 추상화의 원칙에 따라, 이름을 보고 해당 그림을 유추할 수 있어야한다.
이렇게 특정 값들을 상수로 추출하면,
해당 값들이 중요하게 쓰인다는 것을 나타낼 수 있고
더불어 나중에 해당 값들을 변경해야할 때 전역에서 사용되고 있는 부분 하나하나를
수정하지 않고 선언부에서만 수정하면되므로, 유지보수성을 향상시킬 수 있다.
public class MinesweeperGame {
<중략>
private static int gameStatus = 0; // 0: 게임 중, 1: 승리, -1: 패배
public static void main(String[] args) {
showGameStartComments();
Scanner scanner = new Scanner(System.in);
initializeGame();
while (true) {
showBoard();
<중략>
System.out.println("잘못된 번호를 선택하셨습니다.");
}
}
}
private static boolean isLandMineCell(int selectedRowIndex, int selectedColIndex) {
return LAND_MINES[selectedRowIndex][selectedColIndex];
}
private static boolean doesUserChooseToOpenCell(String userActionInput) {
return userActionInput.equals("1");
}
<중략>
private static int convertColFrom(char cellInputCol) {
switch (cellInputCol) {
case 'a':
return 0;
case 'b':
return 1;
case 'c':
return 2;
case 'd':
return 3;
<중략>
}
}
private static void showBoard() {
System.out.println(" a b c d e f g h i j");
for (int row = 0; row < BOARD_ROW_SIZE; row++) {
System.out.printf("%d ", row + 1);
for (int col
이렇게 추상화 원칙을 적용한 지뢰찾기 프로그램 리펙터링 1차를 마쳤다.
생각보다 1줄짜리 코드도 메서드로 빼거나 단 두곳 정도에서만 공통으로 쓰인
값도 상수로 빼는 경우도 있었다. 추상화는 코드의 양이 중요한 것이 아니다.
의미를 부여할 수 있다면 코드 수는 중요하지 않다.
섹션 3 - 논리 사고의 흐름
인지적 경제성이란,
인지 및 지각은 처리과정에서 사용되는 에너지(비용)와 이에 따른 경제적 결과(효율)을
고려한다는 이론이다.
갑자기 뜬구름같은 말일 수도 있겠지만, 우리가 코드를 해석할 때
이 인지적 경제성은 굉장히 중요한 요소가 된다.
클린하지 않은 코드는 읽어 내려갈 때,
이전 내용의 코드를 계속해서 기억해야하는 경우가 많다.
클린코드로 리펙터링하기 1편에서 특정 복잡한 로직들을
추상화하여 메서드로 분리하지않았다면
게임에 대한 전체적인 코드가 담긴 main메서드를 읽어내려갈 때,
중간중간 자잘한 데이터타입 변환, 값 계산 등의 부분들이 등장하면
현재가 게임 내 어떤 플로우에 위치해 있는지, 이전 중요한 플로우는 무엇이었는지
계속해서 기억을 쌓아가면서 읽어내야한다.
하지만, 굳이 구체할 필요 없는 부분들은 메서드 몸체 내부에 구현해주고,
메서드명만으로 해당 작업이 어떤 작업인지 추상함으로써
main 메서드를 읽는 사람으로 하여금 전체적인 플로우를 단숨에 파악하고,
그 중 한 부분만 집중해서 보고 싶다면 메서드 몸체 부분을 분석하면되도록 하였다.
우리가 어플리케이션을 개발할 때, 사용하지 않는 데이터들은 바로바로 메모리에서 비워주고
이를 가능케 하도록 객체들간의 의존성을 최대한 줄이는 것도
이 인지적 경제성과 밀접한 관계가 있다고 볼 수 있다.
우리 뇌도 기억하고 있어야할 요소가 적을수록, 인지해야할 구체적인 부분들은 숨겨져 있어
추상적인 부분들만 파악하면 될 때, 더욱 적은 자원을 사용한다.
오늘은 이 인지적 경제성 원리에 맞춰, 최소한의 인지만으로도 코드를 해석할 수 있도록
지뢰찾기 게임 시스템을 리펙터링해고자 한다.
인지적 경제성을 향상시킬 수 있는 전략들을 알아보면서, 동시에 리펙터링을 이어나가자보자.
Early return
여러 조건들이 나열된 if문에서 return과 같이 끊어주는 구간이 없으면
이전 조건들의 정보들을 계속 기억에 유지하면서 (뇌 메모리 공간에 올려 놓은 상태로)
아래 코드들을 읽게 된다. 이때, 이 조건문 부분을 메서드로 분리한 다음
각 조건에 return으로 탈출 구간을 만들어주면, 코드를 읽을 때
return으로 끝난 구현부는 기억에 유지하지않으면서 다음 부분들을 읽어내려갈 수 있게된다.
큰 차이가 없어 보인다고 느낄 수 없지만, 조건 분기가 복잡해질수록
이 전략은 굉장한 효과를 보인다.
특히, else는 지양하는 편이 좋다. 상황에 따라 다르겠지만 대부분 상황에서
else는 이전 조건들을 모두 기억하고 이 조건들에 해당하지 않는 경우들을
분석해내야하는 과정을 만들어 내기 때문이다.
같은 원리로 switch/case문도 지양하는 편을 추천한다.
String cellInput = getCellInputFromUser("선택할 좌표를 입력하세요. (예: a1)", scanner);
String userActionInput = getUserActionInputFromUser("선택한 셀에 대한 행위를 선택하세요. (1: 오픈, 2: 깃발 꽂기)", scanner);
int selectedColIndex = getSelectedColIndex(cellInput);
int selectedRowIndex = getSelectedRowIndex(cellInput);
if (doesUserChooseToPlantFlag(userActionInput)) {
board[selectedRowIndex][selectedColIndex] = FLAG_SIGN;
checkIfGameIsOver();
} else if (doesUserChooseToOpenCell(userActionInput)) {
if (landMines[selectedRowIndex][selectedColIndex]) {
board[selectedRowIndex][selectedColIndex] = LAND_MINE_SIGN;
changeGameStatusToLose();
continue;
} else {
open(selectedRowIndex, selectedColIndex);
}
checkIfGameIsOver();
} else {
System.out.println("잘못된 번호를 선택하셨습니다.");
}
}
}
지뢰찾기에서 이런 부분이 있었다. 조건문 분기가 한 눈에 들어오지않을 뿐더러,
여러 조건들을 비교하면서 각 상황의 차이가 어떻게 다른지,
또 조건문내에서도 depth가 나뉘면서 안쪽에 조건문은 바깥쪽의 조건에 대한 기억을
유지한 상태로 읽게된다.
해당 조건문들의 나열은 크게봤을 때
결국 유저가 지뢰 탐색을 선택했는지, 깃발 꽂기를 선택했는지에 대한
분기이다.
해당 작업을 메서드로 분리한다음
이 두가지 선택사항이 명확하게 분리되고,
각 상황을 살펴볼 때 반대쪽 상황의 코드는 잊어도되도록 구현해보자.
String cellInput = getCellInputFromUser("선택할 좌표를 입력하세요. (예: a1)", scanner);
String userActionInput = getUserActionInputFromUser("선택한 셀에 대한 행위를 선택하세요. (1: 오픈, 2: 깃발 꽂기)", scanner);
actOnCell(cellInput, userActionInput);
}
}
private static void actOnCell(String cellInput, String userActionInput) {
int selectedColIndex = getSelectedColIndex(cellInput);
int selectedRowIndex = getSelectedRowIndex(cellInput);
if (doesUserChooseToPlantFlag(userActionInput)) {
board[selectedRowIndex][selectedColIndex] = FLAG_SIGN;
checkIfGameIsOver();
return;
}
if (doesUserChooseToOpenCell(userActionInput)) {
if (landMines[selectedRowIndex][selectedColIndex]) {
board[selectedRowIndex][selectedColIndex] = LAND_MINE_SIGN;
changeGameStatusToLose();
return;
}
open(selectedRowIndex, selectedColIndex);
checkIfGameIsOver();
return;
}
System.out.println("잘못된 번호를 선택하셨습니다.");
}
이렇게 함으로써, 지뢰 탐색과 깃발 꽂기의 두 경우가 나뉘고,
return을 통해 머리 속에 기억해야할 정보들의 끊김 포인트를 정해주어
코드를 읽어내림에 있어 부담을 줄여줬다.
사고의 Depth 줄이기
위에서도 잠깐 언급된 전략이기도 한데,
분기문이나 반복문에서 중첩되어 있는 부분은 머리에 연속적인 기억을 남게한다.
이를 최대한 분리하고 추상화함으로써 코드의 가독성을 향상시킬 수 있다.
private static boolean isAllCellIsOpened() {
boolean isAllOpened = true;
for (int row = 0; row < BOARD_ROW_SIZE; row++) {
for (int col = 0; col < BOARD_COL_SIZE; col++) {
if (BOARD[row][col].equals(CLOSED_CELL_SIGN)) {
isAllOpened = false;
}
}
}
return isAllOpened;
}
기존 코드에 게임 승리조건을 확인하기 위한 메서드가 있었다.
2차원 배열을 중첩 반복문을 통해 탐색하는 로직인데,
현재는 매우 간단한 상황이라 큰 상관은 없겠지만 어쨌든
불필요한 Depth가 나뉘고 있다.
private static boolean isAllCellIsOpened() {
return Arrays.stream(BOARD) //Stream<String[]>
.flatMap(Arrays::stream) //Stream<String>
.noneMatch(cell -> CLOSED_CELL_SIGN.equals(cell));
}
Stream을 활용하여 간단하게 작성해준다.
중첩반복문이 직관적으로 쉽게 해석할 수는 있으나, Stream문법만 안다면
똑같이 쉽게 이해할 수 있으므로, 보다 가독성이 좋은 위에 방식을 사용했다.
위 예시는 간단한 로직이라 전과 후 큰 차이가 없어 보인다.
다음 예시를 보자
private static void initializeGame() {
for (int row = 0; row< BOARD_ROW_SIZE; row++) {
for (int col = 0; col < BOARD_COL_SIZE; col++) {
BOARD[row][col] = CLOSED_CELL_SIGN;
}
}
for (int i = 0; i < BOARD_COL_SIZE; i++) {
int col = new Random().nextInt(BOARD_COL_SIZE);
int row = new Random().nextInt(BOARD_ROW_SIZE);
LAND_MINES[row][col] = true;
}
for (int row = 0; row < BOARD_ROW_SIZE; row++) {
for (int col = 0; col < BOARD_COL_SIZE; col++) {
int count = 0;
if (!isLandMineCell
(row, col)) {
if (row - 1 >= 0 && col - 1 >= 0 && isLandMineCell(row - 1, col - 1)) {
count++;
}
<중략>
LAND_MINE_COUNTS[row][col] = count;
continue;
}
LAND_MINE_COUNTS[row][col] = 0;
}
}
}
처음 게임이 로드될 때, 각 타일마다 주변 지뢰 개수를 세서 기록하는 부분이다.
중첩 반복문에 중첩 조건문까지 Depth가 굉장히 많이 나뉜 모습이다.
추상화하여 메서드로 분리할 수 있는 부분이 있는지 살펴보자.
if (row - 1 >= 0 && col - 1 >= 0 && isLandMineCell(row - 1, col - 1)) {
count++;
}
if (row - 1 >= 0 && isLandMineCell(row - 1, col)) {
count++;
}
if (row - 1 >= 0 && col + 1 < BOARD_COL_SIZE && isLandMineCell(row - 1, col + 1)) {
count++;
<중략>
}
if (row + 1 < BOARD_ROW_SIZE && col + 1 < BOARD_COL_SIZE && isLandMineCell(row + 1, col + 1)) {
count++;
}
LAND_MINE_COUNTS[row][col] = count;
해당 작업은 지뢰가 아닌 타일의 주변 타일에서 지뢰의 개수가 몇개인지 세는 구체적인
작업을 표현한다. '근처의 지뢰를 세는 작업'으로 추상화하여 메서드로 분리해준다.
for (int row = 0; row < BOARD_ROW_SIZE; row++) {
for (int col = 0; col < BOARD_COL_SIZE; col++) {
int count = 0;
if (!isLandMineCell
(row, col)) {
count = countNearbyMines(row, col, count);
LAND_MINE_COUNTS[row][col] = count;
continue;
}
LAND_MINE_COUNTS[row][col] = 0;
}
}
}
외부 메서드와의 경계가 생긴대신 기존 코드 부분은 굉장히 간결해졌다.
private static int countNearbyMines(int row, int col, int count) {
if (row - 1 >= 0 && col - 1 >= 0 && isLandMineCell(row - 1, col - 1)) {
count++;
}
if (row - 1 >= 0 && isLandMineCell(row - 1, col)) {
count++;
}
if (row - 1 >= 0 && col + 1 < BOARD_COL_SIZE && isLandMineCell(row - 1, col + 1)) {
count++;
}
<중략>
}
if (row + 1 < BOARD_ROW_SIZE && col + 1 < BOARD_COL_SIZE && isLandMineCell(row + 1, col + 1)) {
count++;
}
return count;
}
추상화된 메서드명에 적합한 구체적인 작업이 메서드 몸체 부분에 구현되어있다.
공백 라인 활용
모든 사람들이 무의식적으로 가장 많이 사용하고 있는 전략일 것이다.
중첩 조건문을 메서드로 분리한 뒤 중간중간 return을 달아주었던 이유와 동일하게
복잡하게 늘어져 있는 코드 중간중간에 공백을 통해 관심사를 분리함으로써
각 코드 덩어리 부분이 어떤 흐름의 차이점을 보이는지 명시하는 것이다.
코드를 쭉 읽어내려가다가 이전 정보들을 잠시 기억에서 내려놓을
브레이크 지점을 주는 것이다.
public static void main(String[] args) {
showGameStartComments();
initializeGame();
while (true) {
try {
showBoard();
if (doesUserWinTheGame()) {
System.out.println("지뢰를 모두 찾았습니다. GAME CLEAR!");
break;
}
if (doesUserLoseTheGame()) {
System.out.println("지뢰를 밟았습니다. GAME OVER!");
break;
.
.
.
(중략)
}
}
기존 main 메서드이다. 각 역할을 관심사로 분리된 작업들이
메서드로 분리되고 추상화된 메서드명만 남아서 굉장히 깔끔해진 상태이다.
여기서 추가로 구분되는 작업들에 공백을 준다.
예를 들어
showGameStartComments();
initializeGame();
이 두 작업은 게임이 로드될 때 초기 세팅이 되는 부분이고,
while문은 유저의 행동에 따라 게임이 계속해서 진행되는 부분이다.
공백을 통해
가볍게 분리해준다.
이 전략은 너무 흔하고 모두 의식하지않아도 코드를 깔끔하게 정리할 때 사용했을
것 같으므로 여기까지만 설명한다.
부정어 지양하기
평소에 코드를 작성할 때 '!' 연산을 굉장히 애용했다.
코테의 영향일지는 몰라도, 단어 하나만으로 조건문과 같은 상황에서
조건을 쉽고 편하게 작성할 수 있다는 장점이 있어서 자주 사용했다.
하지만 이러한 부정연산은 !이 붙은 값이나 메서드를 먼저 이해하고 나서야 기존 조건인
반대 상황을 떠올릴 수 있다는 인지적 경세성을 해치는 과정이 필요하며,
한 글자라 로직에 끼치는 영향력에 큰 것에 비해 인지는
쉽게 되지않는다는 단점이 존재한다.
때문에 ! 표현은 최대한 지양하고, 부정해야하는 대상이 값이었다면
해당 값의 반대 의미를 가진 변수를 만들어 사용하거나
메서드였다면 반대의 논리형 값을 반환하는 메서드를 만들어 사용하는 편이
더 좋은 방향일 것이다.
for (int row = 0; row < BOARD_ROW_SIZE; row++) {
for (int col = 0; col < BOARD_COL_SIZE; col++) {
int count = 0;
if (!isLandMineCell
(row, col)) {
count = countNearbyMines(row, col, count);
LAND_MINE_COUNTS[row][col] = count;
continue;
}
LAND_MINE_COUNTS[row][col] = 0;
}
}
기존 initializeGame메서드에 이 ! 연산이 쓰이는 곳이 있었다.
지뢰가 있는 타일이 아니라면 주변 지뢰의 개수를 세는 메서드를 호출한다.
이 코드를 읽을 때에는 먼저 이 메서드 명을 읽고 '아 이건 현재 조회하는
셀리 지뢰인지를 확인하는 메서드이구나'를 인지한 뒤
반대 상황인 '현재 조회 타일이 지뢰인 상황'을 떠올려야한다.
이 과정이 쉽다고는 해도 !연산은 눈에 잘 안 보인다는 단점도 존재했다.
어떻게 개선할 수 있을까?
if문 분기 상황을 역전시켜서 isLandMineCell메서드가 참인 경우를 사용하도록
해보자.
for (int row = 0; row < BOARD_ROW_SIZE; row++) {
for (int col = 0; col < BOARD_COL_SIZE; col++) {
if (isLandMineCell(row, col)) { //if문 분기를 조건을 역전시켜서 부정연산자를 사용하지않는 방향으로 바꿔줌
LAND_MINE_COUNTS[row][col] = 0;
continue;
}
int count = countNearbyMines(row, col);
LAND_MINE_COUNTS[row][col] = count;
}
}
이렇게 하면, 메서드명만 보고 해당 조건문 아래 내용이 어떤 상황에 적용되는지,
이 조건문 밖에 상황은 어떤 상황인지 명확하게 구분될 뿐 아니라 가독성이
더 좋아졌다.
물론 이 !연산도 무조건적으로 사용하지말라는 것은 아니고,
현재 부정표현이 꼭 이 방법밖에 없는 방향인지,
보다 더 가독성이나 재사용성을 높일 수 있는 방법은 없는지 고민해보는 과정이
중요하다.
마지막으로 오늘 배웠던 것들을 복습하기 위해
public boolean validateOrder(Order order) {
if (order.getItems().size() == 0) {
log.info("주문 항목이 없습니다.");
return false;
} else {
if (order.getTotalPrice() > 0) {
if (!order.hasCustomerInfo()) {
log.info("사용자 정보가 없습니다.");
return false;
} else {
return true;
}
} else if (!(order.getTotalPrice() > 0)) {
log.info("올바르지 않은 총 가격입니다.");
return false;
}
}
return true;
}
이 코드를 리펙터링해보자.
if (order.getItems().size() == 0) {
log.info("주문 항목이 없습니다.");
return false;
}
주문의 아이템 목록이 존재하는지 검사한다. 역할이 명확하고
'아이템 존재 여부 판단'이라는 메서드명으로 추상화할 수 있으므로 메서드로 분리해준다.
public boolean isEmptyItemIn (Order order) {
if (order.getItems().size() == 0) {
log.info("주문 항목이 없습니다.");
return true;
}
return false;
}
이렇게 분리할 수 있을 것이다. 메서드명과 파라미터가 이어져서 메서드의 구체 작업을
보다 쉽게 유추할 수 있도록 하였다.
public boolean validateOrder(Order order) {
if (isEmptyItemIn(order)) return false;
if (order.getTotalPrice() > 0) {
if (!order.hasCustomerInfo()) {
log.info("사용자 정보가 없습니다.");
return false;
} else {
return true;
}
} else if (!(order.getTotalPrice() > 0)) {
log.info("올바르지 않은 총 가격입니다.");
return false;
}
return true;
}
반복문 분기를 한번 끊어주게 되면서, 인지적 경제성을 저해하는 else문이
하나 사라지고 조건문의 depth도 한 단계 줄이는 효과를 보였다.
만약, validateOrder메서드에서 아이템 목록 존재 여부에 따른
작업 분기만 관심 있는 사람이 현재 코드를 읽는다면 이전보다 굉장히 향상된
인지적 경제성을 가지게 될 것이다.
현재 코드에서 가장 바깥쪽 경계인 조건문 분기인 if와
else if를 살펴봤을 때, 주문의 가격이 유효한지 유효하지 않은 지 나눠주는 부분이 있다.
유효한 경우에는 또 다시 조건문 분기가 일어나므로, 우선적으로
유요하지않았을 때 false만 반환하는 부분을 메서드로리하여 가독성을 높여보자.
이때, '!'연산을 지양하고자 올바르지 않은 가격임을 확인하는 메소드로 구현한다.
public boolean validateOrder(Order order) {
if (isEmptyItemIn(order)) return false;
if(hasInvalidTotalPriceIn(Order order) return false;
if (!order.hasCustomerInfo()) {
log.info("사용자 정보가 없습니다.");
return false;
} else {
return true;
}
return true;
}
public boolean isEmptyItemIn (Order order) {
if (order.getItems().size() == 0) {
log.info("주문 항목이 없습니다.");
return true;
}
return false;
}
public boolean hasInvalidTotalPriceIn (Order order) {
if(order.getTotalPrice() < 0) {
log.info("올바르지 않은 총 가격입니다.");
return true;
}
return false;
}
!연산을 하나 줄이고, 조건문의 depth도 줄였다.
마지막 조건문 분기를 확인해보면
사용자 정보의 유효성을 검사한다.
역시 메서드로 분리하면서 !연산도 없애보자.
public boolean validateOrder(Order order) {
if (isItemEmptyOf(order))
return false;
if (hasInvalidTotalPriceIn(order))
return false;
if (hasMissingCustomerInfoIn(order))
return false;
return true;
}
<중략>
public boolean hasMissingCustomerInfoIn (Order order) {
if (order.hasCustomerInfo()) return false;
log.info("사용자 정보가 없습니다.");
return true;
}
이렇게 개선될 수 있다.
마지막으로 로그와 공백 줄맞춤 등을 정리해보자
public boolean validateOrder(Order order) {
if (isItemEmptyOf(order)) {
log.info("주문 항목이 없습니다.");
return false;
}
if (hasInvalidTotalPriceIn(order)) {
log.info("올바르지 않은 총 가격입니다.");
return false;
}
if (hasMissingCustomerInfoIn(order)) {
log.info("사용자 정보가 없습니다.");
return false;
}
return true;
}
public boolean isItemEmptyOf(Order order) {
return order.getItems().isEmpty();
}
public boolean hasInvalidTotalPriceIn(Order order) {
return order.getTotalPrice() < 0;
}
public boolean hasMissingCustomerInfoIn(Order order) {
return !order.hasCustomerInfo();
}
추상화하여 분리된 메서드들 중 hasMissingCustomerInfoIn 메서드는
위에서 권장한 것과 다르게 '!'연산을 사용했는데,
public boolean hasMissingCustomerInfoIn(Order order) {
if (order.hasCustomerInfo()) return false;
return true;
}
이렇게 작성하면 다른 메서드들과의 통일성이 좀 깨지기도 하고
'!'연산이 쓰이는 곳이 구체적인 메서드 몸체 내부이기 때문에
부정 연산자를 사용하는 방향으로 했다.
가장 추상화 단계가 높은 validateOrder메서드의
유효성 검사 방식과 플로우가 보기 좋게 작성되었다.
총 3가지의 검사를 거치고 모두 통과했을 때에야 true를 반환한다.
각 검사는 메서드 명과 파라미터의 조합으로 어떤 작업을 하는지
명확하게 추상화되었다.
클린 코드에는 적절한 예외 처리도 굉장히 중요하다.
무분별한 예외 처리는 가독성을 저해하고,
명확하고 직관적으로 작성한 예외 처리는 각 코드들이
어떤 동작을 기대하고, 어떤 상황을 기획적인 측면에서 방지하고자 하는지
알 수 있기 때문이다.
'해피 케이스'는 소프트웨어 개발에서 모든 조건이 정상적으로
충족되고 시스템이 의도한대로 작동하는 이상적인 상황을 의미한다.
이 해피 케이스는 시스템의 기대 동작을 정의하며,
기본적인 흐름을 중심으로 설계하고 개발하는 출발점이 된다.
하지만 정말 완벽하게 설계했다고 생각한 시스템에서도,
실제 운영 환경에서는 이 해피 케이스에 반하는 상황들이 발생하기 때문에
예외 처리가 필요해진다.
결국 예외 처리도, 이 해피 케이스를 기반으로 이 케이스들에서 벗어나는
행위들을 정의함으로써 헨들링이 시작된다.
지뢰게임 시스템으로 살펴보자
while (true) {
showBoard();
//화면에 보드를 띄우고 게임이 진행되니 여기 공백으로 구분해주기
if (doesUserWinTheGame()) {
System.out.println("지뢰를 모두 찾았습니다. GAME CLEAR!");
break;
}
if (doesUserLoseTheGame()) {
System.out.println("지뢰를 밟았습니다. GAME OVER!");
break;
}
String cellInput = getCellInputFromUser();
String userActionInput = getUserActionInputFromUser();
actOnCell(cellInput, userActionInput);
}
기존 시스템에서 이 while문 안이 실질적으로 유저의 입력을 받아 게임을
이어나가는 곳이다.
컴파일 과정이 아닌 런타임 내에 에러가 난 다면 이 반복문에서 발생할
확률이 클 것이다. 이 부분에 적절한 예외처리를 적용해보자.
매 게임이 진행될때마다 getCellInputFromUser와 getUserActionInputFromUser을
통해 어느 행 어느 열 위치 cell에서 지뢰 탐색을 할지 입력을 받는다.
이 상황에서 해피케이스는 유저가 게임판 행열 크기 내에 cell만 입력해주는 것이다.
public static final int BOARD_ROW_SIZE = 8;
public static final int BOARD_COL_SIZE = 10;
그렇다면 이 범위 내에 값을 입력하지 않거나 지정된 값 타입을 작성하지 않는다면
기획적인 측면의 장애 발생이다.
조회하는 배열의 범위를 벗어나므로, 에러가 터지겠지만
해당 에러는 시스템적인 장애라기 보다는 내 서비스 기획내에서 벗어난 행위이며
프로그램이 중단되는 일을 방지해야 하기 때문에,
적절한 예외처리를 하여 유저로부터 입력형태가
잘못됐음을 인터페이스로 응답해주어야한다.
이처럼 서비스 설계와 위반되는 유저의 행위로 인해 발생하는 에러와
개발자가 예상치 못한 시스템적인 장애를 구분하기 위해
서비스에러는 RuntimeException을 상속받아 따로 구현해준다.
//개발자가 의도한, 예상하는 에러
public class AppException extends RuntimeException {
public AppException(String message) {
super(message);
}
}
그렇다면 이제 유저의 입력을 받는 부분에서
private static int convertRowFrom(char cellInputRow) {
int rowIndex = Character.getNumericValue(cellInputRow) -1;
if(rowIndex >= BOARD_ROW_SIZE) {
throw new AppException("잘못된 입력입니다");
}
return rowIndex;
}
이렇게 해피 케이스인 rowIndex >= BOARD_ROW_SIZE에 위반됐을 때
해당 에러를 발생시키도록한다.
while (true) {
try {
showBoard();
//화면에 보드를 띄우고 게임이 진행되니 여기 공백으로 구분해주기
if (doesUserWinTheGame()) {
System.out.println("지뢰를 모두 찾았습니다. GAME CLEAR!");
break;
}
if (doesUserLoseTheGame()) {
System.out.println("지뢰를 밟았습니다. GAME OVER!");
break;
}
//Scanner scanner = new Scanner(System.in); //사용하는 쪽과 가깝게 두기 근데 이러면 반복문 돌 때 마다 새로 다시 생성하게됨. 상수로 빼주기
//게임 종료 조건 체크 후 셀을 어떻게 할지 결정하므로 여기 공백 라인으로 구분
String cellInput = getCellInputFromUser();
String userActionInput = getUserActionInputFromUser();
actOnCell(cellInput, userActionInput);
} catch (AppException e) {
System.out.println(e.getMessage());
}
}
}
그럼 반복문은 이렇게 바뀌게 될 것이다.
일반적인 서비스라면 특정 에러 코드를 웹서버로 내려주고
웹서버는 해당 코드에 맞는 경고창과 같은 인터페이스 변화를 유저에게 보여줄 것이다.
이렇게 최대한 예상될 수 있는 에러들을 생각해내서 헨들링할 수 있어야한다.
QA와 테스트 코드가 중요한 이유이기도 하다. 사전에 헨들링할 수 있는
에러들을 모두 명시하고 예외 처리하여 속된말로 서버가 터지는(WAS가 꺼지는)
일이 없도록 해야되기 때문이다.
하지만 개발자가 모든 예상을 할 수 있는 것은 아니기에,
이러한 에러를 잡을 수도 있도록 한다.
while (true) {
try {
showBoard();
//화면에 보드를 띄우고 게임이 진행되니 여기 공백으로 구분해주기
if (doesUserWinTheGame()) {
System.out.println("지뢰를 모두 찾았습니다. GAME CLEAR!");
break;
}
if (doesUserLoseTheGame()) {
System.out.println("지뢰를 밟았습니다. GAME OVER!");
break;
}
//Scanner scanner = new Scanner(System.in); //사용하는 쪽과 가깝게 두기 근데 이러면 반복문 돌 때 마다 새로 다시 생성하게됨. 상수로 빼주기
//게임 종료 조건 체크 후 셀을 어떻게 할지 결정하므로 여기 공백 라인으로 구분
String cellInput = getCellInputFromUser();
String userActionInput = getUserActionInputFromUser();
actOnCell(cellInput, userActionInput);
} catch (AppException e) {
System.out.println(e.getMessage());
} catch (Exception e) {
System.out.println("예상치 못한 프로그램의 문제가 발생했습니다"); //개발자가 의도치않은 시스템 장애 발생1
}
}
}
이렇게 예상할 수 없는 에러가 발생했을 때에 대한 대응도
코드단에서 대응할 수 있어야한다.
AppException이 잘 작동하는지 확인해보자
잘 작동하는 모습을 보인다.
이번엔 일부러 시스템 장애를 일으켜 Exception에 대한 예외처리가
잘 작동하는지 확인해보자.
역시 잘 작동한다.
현재는 간단한 게임 서비스라 복잡하게 생각할 것이 없지만,
예외 처리는 서비스가 복잡해질수록 가장 어려우면서 동시에
중요한 과정이기도하다.
섹션 4 - 객체 지향 패러다임
체지향 프로그래밍 설계에서 정말 중요한 SOLID 원칙에 대해서 알아보겠다.
기술면접으로 자주 나오는 용어이기도 하고, 전공 수업에서도 다루는 중요한 개념이다 보니
다들 뭔지는 알지만 사실 본인 프로젝트에 적용하기란 쉽지 않다.
개념을 확실하게 익혀 기존 프로젝트를 리펙터링하거나 추후 프로젝트에서는
설계 단계에서부터 이 원칙을 잘 적용해보고자 개념을 정리해본다.
또한, 간단한 코드로 각 원칙을 적용해 구현해보면서 활용법을 익혀본다.
SOLID 원칙은 객체 지향 설계의 기초를 이루며, 이를 잘 적용하면 소프트웨어의 유지보수성과 확장성을 크게 향상시킬 수 있다.
SOLID는 5개의 원칙의 각 앞글자를 따서 만든 용어이다.
SRP (Single Responsibility Principle)
단일 책임 원리
단일 책임 원칙이다.
단일 책임 원칙은 클래스는 단 하나의 책임만을 가져야 하며,
그 책임을 완전히 캡슐화해야 한다는 원칙이다.
즉, 클래스는 변경의 이유가 단 하나뿐이어야 한다.
이렇게 함으로써 클래스의 응집도가 높아지고,
클래스 간의 의존도는 낮아지도록 할 수 있다.
클래스 간의 영향 범위가 줄어들수록(한 곳의 변경에 따른 다른 곳의 변화가 적을수록)
유지보수는 쉬워진다.
우선, 이런 원칙을 위반한 상황을 살펴보자.
// User.java
public class User {
private String name;
private String email;
// 생성자, getter, setter 생략
public void save() {
// 데이터베이스에 사용자 정보 저장
}
public void sendEmail(String message) {
// 이메일 전송 로직
}
}
이 클래스는 이메일 전송과 데이터 저장이라는 두가지 역할을 하고 있다.
이렇게 한다면, 추후 데이터 저장 방식이 서비스 측면에서 변경되었을 때
이 User 클래스를 변경해야하고, 같은 모듈에 존재하는 이메일 전송 기능에
어떤 영향을 주게될지 알 수 없다.
그리고 무엇보다 User라는 이름으로 추상화된 클래스에게 데이터 저장과
이메일 전송이라는 구체 기능은 논리적으로 어울리지 않는다
만약,
// User.java
public class User {
private String name;
private String email;
// 생성자, getter, setter 생략
}
// UserRepository.java
public class UserRepository {
public void save(User user) {
// 데이터베이스에 사용자 정보 저장
}
}
// EmailService.java
public class EmailService {
public void sendEmail(String email, String message) {
// 이메일 전송 로직
}
}
이렇게 세가지의 객체로 나눈다면,
각 클래스명은 각자 가진 구체적인 기능에 대한 추상을 적절한 이름으로 보이게 되고,
각 클래스가 본인 이름에 맞는 하나의 책임만을 가지게된다.
각 클래스는 서로가 서로의 추상화된 모습만 알게되었다.
이렇게 함으로싸 각 기능의 변경이 다른 부분에 미치는 영향을 최소화되었다.
이메일 전송 방식을 변경하고 싶으면 개발자는 EmailService에만 접근하고,
데이터 저장 방식을 변경하고 싶으면 UserRepository에,
서비스 User의 정의를 다시 하고 싶으면 User 클래스에만 접근하면 된다.
OCP (Open-Closed Principle)
개방 폐쇄의 원칙
개방/폐쇄 원칙은 소프트웨어 엔티티(클래스, 모듈, 함수 등)는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다는 원칙이다.
즉, 기존 코드를 수정하지 않고도 시스템의 기능을 확장 할 수 있어야 한다는 의미이다.
// Shape.java
public class Shape {
public String type;
public double width;
public double height;
public double radius;
}
// AreaCalculator.java
public class AreaCalculator {
public double calculateArea(Shape shape) {
switch (shape.type) {
case "Rectangle":
return shape.width * shape.height;
case "Circle":
return Math.PI * shape.radius * shape.radius;
default:
throw new IllegalArgumentException("Unknown shape type");
}
}
}
위 상황에서는 새로운 도형을 추가 할 때마다 AreaCalculator 클래스를 수정해야하는
문제점이 발생한다.
// Shape.java
public interface Shape {
double calculateArea();
}
// Circle.java
public class Circle implements Shape {
private double radius;
// 생성자, getter, setter 생략
@Override
public double calculateArea() {
return Math.PI * radius * radius;
}
}
// AreaCalculator.java
public class AreaCalculator {
public double calculateArea(Shape shape) {
return shape.calculateArea();
}
}
하지만 위와 같이 객체 지향의 추상화와 다형성의 장점을 살려 설계한다면,
기존 코드의 변경 없이 도형이 추가되는 시스템의 기능 확장을
유연하게 대처할 수 있게된다.
객체간의 협력은 추상화된 공개 메서드를 통해 소통이 이뤄지도록 해야
추후 구체에 해당하는 로직 변경이 순조로워지는 것이다.
LSP : Liskov Substitution Principle
리스코프 치환 원칙이라고 한다.
부모 클래스의 인스턴스를 자식 클래스의 인스턴스로 치환할 수 있어야한다는 원칙이다.
즉, 프로그램의 정확성을 유지하면서 부모 타입의 객체를 자식 타입의 객체로 대체할 수 있어야 한다는 것을 의미한다. 이를 통해 상속 관계에서의 올바른 다형성을 보장한다.
// Bird.java
public class Bird {
public void fly() {
// 날기 로직
}
}
// Penguin.java
public class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("펭귄은 날 수 없습니다.");
}
}
이렇게 Bird를 상속했지만 리스코프 치환 원칙을 위배하여, 기존 부모 클래스의
역할을 수행하지 못하는 자식 클래스 Penguin가 있다면
Bird의 의존을 받는 곳에서 기존 Bird의 타입을 Penguin으로 바꿨을 때,
fly()가 호출되는 지점에서 에러가 발생하게 될 것이다.
// Bird.java
public abstract class Bird {
// 공통된 기능
}
// FlyingBird.java
public interface FlyingBird {
void fly();
}
// Sparrow.java
public class Sparrow extends Bird implements FlyingBird {
@Override
public void fly() {
// 참새 날기 로직
}
}
// Penguin.java
public class Penguin extends Bird {
// 펭귄은 날지 않으므로 FlyingBird를 구현하지 않음
}
이렇게 한다면, FlyingBird 인터페이스를 구현한 클래스만
fly 메서드를 가지므로, FlyingBird의 구현체가 역할하고 있엇던 곳은
동일하게 FlyingBird의 다른 구현체로만 변경되도록하고
부모 클래스인 Bird가 쓰이는 곳은 이 부모 클래스를 상속받는
자식 클래스라면 어떤 타입이라도 변경될 수 있도록 한다.
ISP : Interface Segregation Principle
인터페이스 분리 원칙
인터페이스 분리 원칙은 클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 한다는 원칙이다.
즉, 하나의 일반적인 인터페이스보다는 여러 개의 구체적인 인터페이스가
더 낫다는 의미한다.
이를 통해 인터페이스가 클라이언트의 필요에 맞게 세분화되어,
불필요한 의존성을 줄일 수 있다.
// Worker.java
public interface Worker {
void work();
void eat();
}
// HumanWorker.java
public class HumanWorker implements Worker {
@Override
public void work() {
// 일하기 로직
}
@Override
public void eat() {
// 식사하기 로직
}
}
// RobotWorker.java
public class RobotWorker implements Worker {
@Override
public void work() {
// 일하기 로직
}
@Override
public void eat() {
throw new UnsupportedOperationException("로봇은 식사할 필요가 없습니다.");
}
}
위와 같은 상황이 있다고 하자.
Worker 인터페이스가 여러 역할을 하나로 묶고 있다.
하지만 Worker의 구현체들 중에 Worker의 메서드가 필요없는 클래스가 보인다
// Workable.java
public interface Workable {
void work();
}
// Eatable.java
public interface Eatable {
void eat();
}
// HumanWorker.java
public class HumanWorker implements Workable, Eatable {
@Override
public void work() {
// 일하기 로직
}
@Override
public void eat() {
// 식사하기 로직
}
}
// RobotWorker.java
public class RobotWorker implements Workable {
@Override
public void work() {
// 일하기 로직
}
}
위와 같이 인터페이스를 더욱 세분화함으로써,
불필요한 의존성들을 제거해준다.
DIP : Dependency Inversion Principle
의존성 역전 원칙
존성 역전 원칙은 고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 한다는 원칙이다.
또한, 추상화는 세부 사항에 의존해서는 안 되고, 세부 사항이 추상화에 의존해야 한다는 의미도 포함한다.
이를 통해 모듈 간의 결합도를 낮추고, 유연성과 재사용성을 높일 수 있다.
// LightBulb.java
public class LightBulb {
public void turnOn() {
// 전등 켜기 로직
}
public void turnOff() {
// 전등 끄기 로직
}
}
// Switch.java
public class Switch {
private LightBulb lightBulb;
public Switch(LightBulb lightBulb) {
this.lightBulb = lightBulb;
}
public void operate() {
// 스위치 작동 시 전등 켜기 또는 끄기
lightBulb.turnOn();
}
}
Switch 클래스가 LightBulb에 직접적으로 의존하고 있어, 다른
전등으로 변경 시 Switch 클래스를 수정해야한다.
// Switchable.java
public interface Switchable {
void turnOn();
void turnOff();
}
// LightBulb.java
public class LightBulb implements Switchable {
@Override
public void turnOn() {
// 전등 켜기 로직
}
@Override
public void turnOff() {
// 전등 끄기 로직
}
}
// Fan.java
public class Fan implements Switchable {
@Override
public void turnOn() {
// 팬 켜기 로직
}
@Override
public void turnOff() {
// 팬 끄기 로직
}
}
// Switch.java
public class Switch {
private Switchable device;
public Switch(Switchable device) {
this.device = device;
}
public void operate() {
// 스위치 작동 시 장치 켜기 또는 끄기
device.turnOn();
}
}
이제 Switch 클래스는 Switchable 인터페이스에 의존하므로, 새로운 장치가 추가되더라도 Switch 클래스를 수정할 필요가 없다. Switchable의 구현체라면,
새로운 장치로 변경된다 해도 기존 시스템(보다 추상레벨이 높은 로직 위치)은
문제없이 작동할 것이다.
각 원칙의 개념을 명확하게 이해하는 것도 중요하지만,
이 이해를 바탕으로 프로젝트의 설계를 어떻게 진행해나가는지에 대한
실무 역량도 중요한 것 같다.
섹션 5 - 객체 지향 적용하기
해당 섹션은 아직 충분한 회고와 복습이 이뤄지지않아, 강의 내용에서 나온 개념들 중에 추가로 공부한 내용들을 정리하려고 한다.
하루이틀 정도 지뢰게임 프로젝트를 통해 다시 복습해보면서 익혀야할 것 같다. 슬슬 이제 내가 모르는 개념들이 수업에 많이 나오는 것 같다.
상속과 조합, 값 객체(Value Object), 일급 시민, Enum 활용, 그리고 다형성을 통해 반복되는 조건문을 제거하며 OCP를 지키는 방법 등을 다룰 것이다. 각 개념을 이해하고 코드 예제를 통해 어떻게 적용할 수 있는지 살펴보자.
상속과 조합
상속은 코드 재사용 측면에서 유용하지만, 구조가 시멘트처럼 굳어져 수정이 어려워진다는 단점이 있다. 특히 부모와 자식 클래스 간의 결합도가 높아지면 유지보수에 큰 문제를 일으킬 수 있다. 즉, 부모 클래스가 변경되면 자식 클래스에도 영향을 미치게 되는 것이다.
이렇게 부모-자식의 관계는 한번 맺어지면 종속적인 형태를 띄기 때문에, 초기 관계를 맺을 때도 타당성을 꼭 점검해봐야한다.
조합과 인터페이스를 활용하면 유연한 구조를 만들 수 있다. 상속을 통한 코드의 중복 제거가 주는 이점보다, 중복이 생기더라도 유연한 구조를 설계하는 것이 장기적으로 훨씬 더 좋다. 코드로 한번 이해해보자
// 상속을 사용하는 경우
class Animal {
void sound() {
System.out.println("Animal makes a sound");
}
}
class Dog extends Animal {
@Override
void sound() {
System.out.println("Bark");
}
}
// 조합과 인터페이스를 사용하는 경우
interface SoundBehavior {
void makeSound();
}
class BarkSound implements SoundBehavior {
public void makeSound() {
System.out.println("Bark");
}
}
class Animal {
private SoundBehavior soundBehavior;
public Animal(SoundBehavior soundBehavior) {
this.soundBehavior = soundBehavior;
}
void performSound() {
soundBehavior.makeSound();
}
}
class Dog extends Animal {
public Dog() {
super(new BarkSound());
}
}
위 예시에서 상속을 사용한 방식은 Dog 클래스가 Animal 클래스에 강하게 의존하게 되어 있다. 하지만 조합을 사용하면 SoundBehavior 인터페이스를 통해 더 많은 유연성을 확보할 수 있다.
Value Object: 도메인을 추상화한 값 객체
값 객체(Value Object)는 도메인의 개념을 값으로 추상화하여 표현하는 객체이다. 중요한 점은 값 객체는 불변성, 동등성, 유효성 검증 등을 보장해야 한다는 것이다. 이를 통해 값 객체는 한 번 생성되면 변경되지 않고, 내부의 값이 같으면 같은 객체로 취급된다.
• 불변성: 값을 안전하게 유지하기 위해 불변 객체로 설계하는 것이 중요하다. 예를 들어, 필드는 final로 선언하고 setter 메서드는 제공하지 않아야 한다.
• 동등성: 서로 다른 인스턴스라도 내부의 값이 같다면 같은 값 객체로 취급한다. 이를 위해 equals()와 hashCode() 메서드를 재정의해야 한다.
• 유효성 검증: 객체 생성 시점에 값이 유효한지 확인해야 한다. 이렇게 하면 객체의 상태를 신뢰할 수 있게 된다.
VO vs. Entity
값 객체와 엔티티(Entity)를 비교할 때 가장 큰 차이점은 식별자의 유무이다.
• Entity는 식별자가 있어야 하며, 식별자가 동일하면 동일한 객체로 취급된다.
• 반면 VO는 식별자가 없고, 내부의 모든 값이 동일해야만 동등한 객체로 취급된다.
// Entity 예시
public class User {
private Long id;
private String name;
// getters, setters, equals, hashCode 등
}
// Value Object 예시
public final class Address {
private final String street;
private final String city;
public Address(String street, String city) {
this.street = street;
this.city = city;
}
// getters, equals, hashCode 등
}
보통 두개의 개념을 혼동해서 말하는 사람들을 자주 봤는데, 구분하는 기준은 생각보다 명확하다.
VO은 값만 같으면 같은 객체로 인식한다는 점에서, 정말 데이터 그 자체를 담는 용도로 생각하면 쉽다.
생성 후 그 속성이 변하지 않기에 VO는 불변성의 특징을 가진다.
엔티티는 DTO로서, 계층 간 데이터를 전송하는데 활용될 수 있다.
해당 내용에 있어서는 기술블로그에 정리한 적이 있다. -> [데이터베이스] DTO, DAO, VO의 개념 (+ DTO와 VO의 차이)
일급 시민: 일급 컬렉션을 활용하자
일급 시민이란 다른 요소들이 가능한 모든 연산을 지원하는 개념이다. 예를 들어, 함수형 프로그래밍 언어에서는 함수가 일급 시민으로 취급되는데, 이는 함수를 변수에 할당하거나 파라미터로 전달할 수 있고, 함수의 결과로 반환될 수도 있음을 의미한다.
아직 개념이 잘 안잡힌 것 같아서, 더 공부해야할 내용인 것 같다.
일급 컬렉션: 컬렉션을 일급으로 다루기
일급 컬렉션은 컬렉션만을 유일한 필드로 가지는 객체이다. 이는 컬렉션을 다른 객체처럼 의미 있게 다룰 수 있게 해주고, 컬렉션을 가공하는 로직을 캡슐화하여 보다 깨끗한 코드와 더 나은 테스트 가능성을 제공한다.
public class OrderList {
private final List<Order> orders;
public OrderList(List<Order> orders) {
this.orders = new ArrayList<>(orders);
}
public List<Order> getOrders() {
return new ArrayList<>(orders);
}
public void addOrder(Order order) {
orders.add(order);
}
}
Enum의 특성과 활용
Enum은 상수의 집합이며, 상수와 관련된 로직을 담을 수 있는 공간이다. 상태와 행위를 한 곳에서 관리할 수 있어 코드의 가독성과 유지보수성을 크게 향상시킬 수 있다. 도메인 개념에 대한 종류와 기능을 명시적으로 표현할 수 있으며, 만약 변경이 잦은 개념이라면 Enum보다는 DB로 관리하는 것이 나을 수 있다. 항상 거의 변하지않는 데이터들이 우리 서비스 내에 있을 때, Enum으로 관리할지 DB로 관리할지 고민하게 되는 것 같다. 그 양이 너무 많을 때에는, 프로젝트 메모리에 올려두는 것이 좋지 않을 것 같아 DB에 넣을 때도 있고, 불빌요한 쿼리문을 쏘지 않고자 Enum으로 관리할 때도 있기 때문이다. 더 많은 경험을 쌓아서 기준을 만들어야 될 것 같다.
다형성: if문을 제거하고 OCP 지키기
반복되는 조건문을 다형성으로 제거하여 OCP(Open/Closed Principle)를 지킬 수 있다. OCP는 “확장은 열려 있고, 수정은 닫혀 있다”는 원칙으로, 새로운 기능 추가 시 기존 코드를 변경하지 않고 확장할 수 있게 한다. 이를 위해 조건에 따라 다른 동작을 수행해야 할 때, 다형성을 활용해 조건과 행위를 분리할 수 있다.
숨겨져 있는 도메인 개념 도출하기
지뢰게임 시스템로 리펙토링 중에, 서비스 기획과 의도를 재파악 한 뒤, 설계를 변경하게 되는 순간들이 많았었다.
이처럼 도메인 지식은 만드는 것이 아니라, 발견하는 것이다. 시스템 초기 설계에서는 이 발견을 미리 최대한 예측하여 확장 가능하도록 구현하고, 후에 확장 단계에서 재정립해가는 것이다.
그러므로 개발 초기에 확장 가능한, 객체 지향의 핵심
Loose Coupling(느슨한 결합)과 High Cohesion(높은 응집)에 기반하여 개발해야할 것 같다.
여기까지 백엔드 클린 코드, 테스트 코드강의의 1주차 내용 정리글을 작성해보았다.
초반에는 그래도 수업이 좀 따라갈만 했는데, 슬슬 어려운 내용들이 많이 나오는 것 같아 시간을 좀 더 써야할 것 같다는 생각이 들었다.
그리고 지금은 헷갈리는 개념이 나올 때마다 예제 코드를 만들어보거나 참고하는데, 이 방법이 가장 빠르게 이해할 수 있는 방법인 것 같다. 아쉬웠던 점은, 마지막 섹션은 수업 내용을 너무 따라가려고만 했는데, 끝나고 나니 남는게 좀 적었다. 이해 못한 부분이 있으면 복습한 뒤, 다음 차트로 넘어가도 좋을 것 같다.
댓글을 작성해보세요.