우리는 평균적으로 코드를 작성하는 시간보다 코드를 읽고 해석하는데 많은 시간을 소비한다. 하물며, 자신이 직접 짠 코드에도 많은 분석시간이 필요하다. 그렇기 때문에 우리는 미래의 나를 믿지 말고 미래의 나도 쉽게 이해 시킬 수 있는 깨끗한 코드를 작성해야 한다. 그렇다면 깨끗한 코드는 무엇일까? 바로 "읽기가 쉬운 코드" 이다.
의미 있는 이름
소프트웨어에서 이름은 어디서나 쓰인다. 하지만 주의 깊게 이름을 붙이지 않는다.
우리는 변수에도 이름을 붙이고, 함수에도 이름을 붙이고, 인수, 클래스 그리고 패키지명에도 이름을 붙인다. 소스 파일을 저장하는 디렉토리에도 이름을 붙인다. 이렇듯 이름을 많이 사용하므로 잘 지으면 편하다. 그렇다면 어떻게 이름을 지어야 잘 지은 이름일까?
첫 번째 규칙 : 의도를 분명히 밝혀라.
"의도가 분명한 이름을 지어라". 이름을 짓기 위해 고민하는 시간이 많이 걸리지만 좋은 이름으로 절약하는 시간이 훨씬 더 크다. 이름에서 정확한 의도가 파악이 된다면 코드를 읽는 사람은 좀 더 행복해질 수 있다.
그리고 의도를 더욱 분명히 하고 싶을 때 클래스로 정의하는 것 또한 매우 좋다.
단순히 int kong = 100이라는 정의하는 것 보다, Vegitable kong = new Vegitable(100); 클래스로 정의하는 것이 더 의미를 분명하게 전달할 수 있다. 변수나 함수 그리고 클래스 이름은 아래와 같은 질문에 모두 답해야 한다.
- 변수(혹은 함수나 클래스)의 존재 이유는?
- 수행 기능은?
- 사용 방법은?
// 이름으로 의도가 파악되지 않는 이름
int d; // 경과 시간(단위: 날짜)
// 이름으로 의도가 올바르게 파악되는 이름
int elapsedTimeInDay;
int daysSinceCreation;
int daysSinceModification;
int fileAgeInDays;
다음 예제 코드만 보고 해당 변수들이 하는 일을 짐작하기 어렵다. 복잡한 문장도 아니고, 복잡한 구조(다형성)도 아니다.
문제는 코드의 단순성이 아니라 코드의 함축성에 있다. 코드 맥락이 코드 자체에 명시적으로 드러나 있지 않다. 위 코드의 저자는 독자가 다음과 같은 정보를 안다라는 가정에 작성했다.
- theList에 무엇이 들어 있는가?
- theList에서 0번째 값이 중요한 이유는 무엇인가?
- 4라는 값이 가지는 의미는?
- 함수가 반환하는 list1의 사용 용도는?
public class MeaninglessName {
private static final int[][] theList = new int[5][5];
@PostConstruct
public void init(){
log.info("After call constructor function, this function Run");
for (int i = 0; i < theList.length; i++) {
for (int j = 0; j < theList[i].length; j++) {
theList[i][j] = ((int) (Math.random() * 5));
}
}
}
public List<int[]> getThem() {
List<int[]> list1 = new ArrayList<>();
for (int[] x : theList) {
if (x[0] == 4)
list1.add(x);
}
return list1;
}
}
“지뢰찾기” 게임을 만드는 코드의 기반 지식이 있다고 가정해보면 theList는 게임판이라는 사실을 알 수 있다.
theList를 gameBoard로 바꿔본다. 그리고 배열에서 0번째 값은 칸 상태를 의미하고 4는 깃발이 꽂힌 상태를 가리킨다. 그러면 의미를 담고 있는 함축적인 이름으로 바꿔보겠다.
list1은 깃발이 꽂혀 있는 셀만 담고 있는 배열로 보여진다. 이것 또한 의도를 잘 표현하고 있는 이름으로 바꿔보겠다.
@Component
@Slf4j
public class MeaningfulName {
private static final int[][] gameBoard = new int[5][5];
private static final int STATUS_VALUE = 0;
private static final int FLAGGED = 4;
@PostConstruct
public void afterConstructorCallInit(){
log.info("After Constructor call, this function call");
for (int i = 0; i < gameBoard.length; i++) {
for (int j = 0; j < gameBoard[i].length; j++) {
gameBoard[i][j] = ((int) (Math.random() * 5));
}
}
}
public List<Cell> getFlaggedCell(){
List<Cell> flaggedCells = new ArrayList<>();
for(Cell cell : gameBoard){
if(cell[STATUS_VALUE] == FLAGGED)
flaggedCells.add(cell);
if(cell.isFlagged())
flaggedCells.add(cell);
}
return flaggedCells;
}
}
코드의 단순함은 여전한데, 각 개념에 이름만 붙였는데도 불구하고 코드가 상당히 나아졌음을 알 수 있다.
두 번째 규칙: 그릇된 정보를 피하라.
프로그래머는 코드에 그릇된 단서를 남겨서는 안된다. 그릇된 단서는 코드 의미를 흐린다.
- 나름대로 널리 쓰이는 의미가 있는 단어를 다른 의미로 사용해도 안된다. 예를 들어 "여러 계정을 묶을 때", 실제 List형이 아니라면, accountList라고 명명하면 안된다. 프로그래머에게 List라는 단어는 특수한 의미이기 때문이다.
String accountList = "송혜교, 전지현, 김태희, 고소영";
for(String account : accountList){
// 데이터 처리
...
}
그래서 "accountGroup", "bunchOfAccounts", "Accounts" 로 바꾸겠다.
String accountGroup = "송혜교, 전지현, 김태희, 고소영";
// or String accounts = "송혜교, 전지현, 김태희, 고소영";
// or String bunchOfAccounts = "송혜교, 전지현, 김태희, 고소영";
ArrayList<String> accountList = (ArrayList<String>) Arrays.stream(accountGroup.split(",")).toList();
for(String account : accountList){
// 데이터 처리
...
}
2. 서로 흡사한 이름을 사용하지 않도록 주의한다.
3. 유사한 개념은 유사한 표기법으로 사용한다.
일관성이 떨어지는 표기법은 그릇된 정보이다. 에디터를 사용하여 코드를 작성한다면, 우리는 자동 완성 기능을 사용한다. 이름을 몇 자만 입력한 후 핫키 조합을 누르면 가능한 후보 목록이 뜬다. 후보 목록에 유사한 개념이 알파벳 순으로 나온다면 그리고 각 개념 차이가 명백히 드러난다면 코드 자동 완성 기능은 굉장히 유용해진다.
4. 이름으로 그릇된 정보를 제공하지 말자. 끔찍한 예가 소문자 L이나 대문자O 변수다. 소문자 l은 1과 유사하고 대문자 O와 0(zero)는 매우 유사하다.
세 번째 규칙: 의미 있게 구분하라.
동일한 범위 안에서는 다른 두 개념에 같은 이름을 사용하지 못한다. 컴파일러를 통과할지라도 연속된 숫자를 덧붙이거나 불용어를 추가하는 방식은 적절하지 못하다. class라는 변수를 이미 사용하고 있어서 klass를 사용한다. 이러한 이름은 아무런 정보도 제공하지 못하기 때문에 코드를 읽는 사람에게 도움이 되지 못한다. 이름이 달라야 한다면 의미도 달라져야 한다.
예를 들어, Customer라는 클래스와 CustomerObject라는 클래스를 발견했다면 차이를 알 수 있는가? getActiveAccount( ); getActiveAccounts( ); getActiveAccountInfo( ); 이 세 개의 함수 차이를 알 수 있는가?
new Customer("Jinho");
new CustomerObject("Jinho");
getActiveAccount();
getActiveAccounts();
getActiveAccountInfo();
읽는 사람이 차이를 알도록 이름을 지어야한다.
네 번째 규칙: 발음하기 쉬운 이름을 사용하라.
소스를 읽을 때, 읽기가 쉽지 않고 줄임말로 인해서 해당 표현을 이해하기 힘들게 된다.
// 발음하기 어려운 명명 규칙을 사용한 소스
// 또한 약어로 인해 "그릇된 정보"를 줄 수 있다.
class DtaRcrd102 {
private Date genymdhms;
private Date modymdhms;
private final String pszqint = "102";
}
// 발음하기 쉬운 명명 규칙을 사용한 소스
class Customer {
private Date generationTimestamp;
private Date modificationTimestamp;
private final String recordId = "02"
}
다섯 번째 규칙: 검색하기 쉬운 이름을 사용하라.
문자 하나를 사용하는 이름과 상수는 텍스트 코드에서 쉽게 눈에 띄지 않는다는 문제점이 있다. MAX_CLASSES_PER_STUDENT는 grep으로 찾기가 쉽지만, 숫자 7은 은근히 까다롭고 검색 결과가 많이 나오기 때문에 찾기가 힘들다.
이름 길이는 범위(Scope) 크기에 비례해야한다. 변수나 상수를 코드 여러 곳에서 사용한다면 검색하기 쉬운 이름이 바람직하다.
여섯 번째 규칙: 인코딩을 피하라
명명 규칙에 데이터 타입을 인코딩할 필요는 없다. 이름 안에 데이터타입이 인코딩되면 의미가 혼탁해 질 수 있다.
String phoneNumberString = "010-1234-1234";
// 미래에 String -> PhoneNumber로 타입이 변경되도, 변수명은 바뀌지 않는다!!!
PhoneNumber phoneNumberString;
때로는 인코딩이 필요할 때 가 있다. 인터페이스 클래스와 구현 클래스의 경우, 인터페이스 클래스는 접두어를 붙이지 않고, 구체 클래스에 접두어를 붙이는 것이 좀 더 보기 좋다. ShapeFactory 인터페이스 클래스와 구현체인 ShapeFactoryImp가 보기 좋다.
클래스 이름
클래스 이름과 객체 이름은 명사나 명사구가 적합하다.
메서드 이름
동사나 동사구가 적합하다. 접근자, 변경자, 조건자는 javabean 표준에 따라 값 앞에 set, get, is 접두사를 붙인다.
일곱 번째 규칙: 한 개념에 한 단어를 사용하라.
추상적인 개념 하나에 단어 하나를 선택해 이를 고수한다. 예를 들어, 유사한 행동을 하는 메서드를 클래스마다 fetch, retrieve, get으로 제각각 부르면 혼란스럽다. 유사한 행동을 한다면 일관성 있는 어휘를 선택해서 이름을 붙이자.
// 일관성 없는 어휘 선택은 혼란을 줄 수 있음.
userService.getUserNameAndPasswrod();
boardService.fetchHotBoardList();
boardService.retrieveBookmakr();
// 일관성 있는 어휘를 선택하자.
userService.getUserNameAndPasswrod();
boardService.getHotBoardList();
boardService.getBookmakr();
과거에는 서비스 계층 별 어휘를 나눠서 결정한 케이스도 있다. Controller 계층에서는 fetch로 어휘를 통일했고 Service 계층에서는 get으로 어휘를 통일해서 계층 별로도 접두사로 사용되는 어휘를 구분하는 케이스도 있었다고 한다.
여덟 번째 규칙: 의미 있는 맥락을 추가하라.
스스로 의미가 분명한 이름이 없지 않다. 하지만 대다수 이름은 그렇지 못하다. 그래서 클래스, 함수, 이름 공간에 의미를 넣어 맥락을 부여한다. 의미 전달이 어렵다고 판단될 경우, 접두어를 붙어서 의미를 전달한다.
// 변수를 모두 훑어봐야 주소와 관련된 데이터임을 알 수 있다.
String firstName;
String lastName;
String zipCode;
String city;
String state;
// 맥락을 좀 더 분명하게 하기 위해서 addr라는 접두어 붙이면
// 단번에 알 수 있다.
String addrFirstName;
String addrLastName;
String zipCode;
String addrCity;
String addrState;
아홉 번째 규칙: 불필요한 맥락을 없애라.
일반적으로 짧은 이름이 긴 이름보다 좋다. 단, 의미가 분명한 경우에 한해서다. 이름에 불필요한 맥락을 추가하지 않도록 주의한다.
학교 졸업프로젝트에서 Application을 만든다고 가정해보자. 그래서 우리는 모든 클래스의 명칭에 SMWUData라는 접두어를 붙이는 것은 매우 바람직하지 못하다. 접두어가 의미가 있다면 붙이는 것이 바람직하겠지만, 그렇지 않기 때문에 접두어를 붙이는 건 매우 불필요하다.
변수명은 짧으면 짧을 수록 좋다. 단, 의미가 잘 전달된다는 조건이다. 만약, 의미가 제대로 전달되지 않는 짧은 변수명이라면 과감하게 의미를 명확하게 전달할 수 있는 변수명으로 바꾸자.
boolean isExisted;
boolean isExistTempDirectory
함수; The Small is Best!!
- 작게 만들어라!
함수를 만드는 첫째 규칙은 "작게!"다. 함수를 만드는 둘째 규칙은 "더 작게"다.
1.1 블록과 들여쓰기
if 문/else 문/ whie 문 등에 들어가는 블록은 한 줄이어야 한다.- 한 가지만 해라!
- 함수는 한 가지를 해야한다. 그 한 가지를 잘 해야 한다. 그렇다면 그 '한 가지가' 무엇인지 알기가 어렵다. 지정된 함수 이름 아래에서 추상화 수준이 하나인 단계만 수행한다면 그 함수는 한 가지 작업만 한다.
- 함수 당 추상화 수준은 하나로!
- 추상화 수준을 섞으면 코드를 읽는 사람이 헷갈린다. 특정 표현이 근본 개념인지 아니면 세부사항인지 구분하기 어려운 탓이다.
코드는 위에서 아래로 이야기처럼 읽혀야 좋다. 한 함수 다음에는 추상화 수준이 한 단계씩 낮은 함수가 온다. 즉, 위에서 아래로 프로그램을 읽으면 함수 추상화 수준이 한번에 한 단계씩 낮아진다.
예를 들어, "DB Table의 데이터를 읽는다." → "DB의 Connection 맺고 객체를 반환한다." → "Connection 객체를 이용해서 SQL문을 실행시켜 데이터를 가져온다." - Switch 문
switch문은 작게 만들기 힘들다. 본질적으로 switch문은 N가지를 처리한다. 각 switch문을 저차원 클래스에 숨기고 절대로 반복하지 않는 방법이 있다. 물론 다형성을 이용한다. 직원 유형에 따라 다른 값을 계산해 반환하는 함수다.
public Money calculatePay(Employee e)
throws InvalidEmployeeType {
swtich(e.type) {
case COMMISSONED:
return calculateCommissionedPay(e);
case HOURLY:
return calculateHourlyPay(e);
case SALARIED:
return calculateSalariedPay(e);
default :
throw new InvalidEmployeeType(e.type);
}
}
위 함수는 여러가지 문제를 가지고 있다.
- 첫번째, 함수가 길다.
- 두번째, '한 가지' 작업만 수행하지 않는다.
- 세번째, SRP(Single Responsibility Principle)를 위반한다. 단순히 급여계산만 하는게 아니라, 1)급여의 형태를 선택 후 2)지급해야 할 급여를 계산하고 있다.
- 네번째는 OCP(Open Close Principle)를 위반한다. 새 직원 유형을 추가할 때 마다 코드를 변경해야하기 때문이다.
이 문제를 해결할 수 있는 방법은 switch문을 추상 팩토리에 숨기는 것이다. 팩토리는 switch문을 사용해 적절한 Employee 파생 클래스의 인스턴스를 생성한다.
calculatePay, isPayday, deliverPay 등과 같은 함수는 Employee 인터페이스를 거쳐 호출된다. 그러면 다형성으로 인해 실제 파생 클래스의 함수가 실행된다.
public abstract class Employee {
public abstract boolean isPayday();
public abstract Money calculatePay();
public abstract void deliverPay();
}
public interface EmployeeFactory {
public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType;
}
@Configure
public class EmployeeFactoryImpl implements EmployeeFactory {
public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType {
switch(r.type){
case COMMISSION:
return new ComissionedEmployee(r);
case HOURLY:
return new HourlyEmployee(r);
case SALARIED:
return new SalariedEmployee(r);
}
}
}
@Service
public class EmployService{
private EmployeeFactoryImpl employeeFactory;
public Money getTotalAmount(EmployeeRecord r){
Employee e = employeeFactory.makeEmployee(r);
if(e.isPayday()){
return e.calculatePay();
else
return Money.ZERO;
}
3. 서술적인 이름을 사용해라
코드를 읽으면서 짐작했던 기능을 각 루틴이 그대로 수행한다면 깨끗한 코드이다. 그렇기 때문에 이름이 길다고 너무 걱정하지 말아라. 길고 서술적인 이름이 짧고 어려운 이름 보다 낫다.
4. 함수 인수 타입
- Output(or Result) 타입
우리는 함수에서 인수를 Input 값으로 인식한다. 그래서 함수는 Input 값을 사용해서 정의한 행동을 수행한다.
appendFooter(s)
위 예제 appendFoote 함수의 인수는 s는 Input일까? 함수의 Output(결과 값)일까?
이 코드를 보고 선언부를 찾으러 갈 수 밖에 없다. 여기서는 StringBuffer s에 바닥글을 이어서 붙이는 함수이다.
객체지향 세계에서는 출력 인수를 사용하지 않는다. 왜냐하면 우리 세계에는 this라는 키워드가 있기 때문이다. 그렇기 때문에 appendFooter(s); 보다는 report.appendFooter();가 더 보기 좋은 코드이다.
일반적으로 출력 인수는 피해야 한다. 함수에서 상태를 변경해야 한다면 함수가 속한 객체 상태를 변경하는 방식을 택하는 것이 좋다.
5. 부수 효과를 일으키지 마라!
6. 명령과 조회를 분리해라
HDFS 데이터저장소에 특정한 Directory의 존재 여부를 확인하고 없다면 디렉토리를 생성하는 코드이다.
public void getListDirectoryTest() {
String directoryPath = "jinho/x";
try {
boolean isExisted = Optional.ofNullable(webHdfsService.getListDirectory(directoryPath).execute().body())
.isPresent();
log.info("[RESULT] isExisted ::: {}", isExisted);
if(!isExisted){
Optional.ofNullable(webHdfsService.makeNewDirectory(directoryPath).execute().body())
.orElse(new HashMap<String, Boolean>())
.getOrDefault("boolean", false);
}
RespFileStatuses dirList = webHdfsService.getListDirectory(directoryPath).execute().body();
log.info("[RESULT] GetListDirectory ::: {}", dirList);
} catch (IOException e) {
log.error(e.getMessage());
}
}
현재 위 함수는 조회와 명령을 같이하고 있다. 이것을 명령과 조회로 분리하면 어떤 효과가 있을까
우리의 목표는 쉽게 읽을 수 있고 쉽게 예측 할 수 있는 코드를 작성하는 것이다. 기존 위의 코드보다 더 쉽게 읽을 수 있다.
@SpringBootTest
@Slf4j
public class HdfsServiceTest {
@Autowired
private WebHdfsService webHdfsService;
@Autowired
private HdfsService hdfsService;
@Test
public void getListDirectoryTest() {
String directoryPath = "jinho";
try {
if(!hdfsService.isExistedDirectory(directoryPath))
hdfsService.makeDirectory(directoryPath);
RespFileStatuses dirList = hdfsService.getListDirectory(directoryPath);
log.info("[RESULT] GetListDirectory ::: {}", dirList);
} catch (IOException e) {
log.error(e.getMessage());
}
}
}
위 코드를 명령과 조회를 구분해서 모듈화 시켰다. 함수가 하나의 일만 하니, 아주 짧고 읽기 편하고 예측이 가능하니 코드를 더 쉽게 읽을 수 있다.
@Service
@RequiredArgsConstructor
public class HdfsService {
private final WebHdfsService webHdfsService;
public boolean isExistedDirectory(String toPath) throws IOException {
Optional<RespFileStatuses> response = Optional.ofNullable(webHdfsService.getListDirectory(toPath).execute().body());
return response.isPresent();
}
public boolean makeDirectory(String toPath) throws IOException {
return Optional.ofNullable(webHdfsService.makeNewDirectory(toPath).execute().body())
.orElse(new HashMap<String, Boolean>())
.getOrDefault("boolean", false);
}
public RespFileStatuses getListDirectory(String toPath) throws IOException {
return webHdfsService.getListDirectory(toPath).execute().body();
}
}
7. 오류 코드 보다 예외를 사용하라. 명령 함수에서 오류 코드를 반환하는 방식은 명령/조회 분리 규칙을 미묘하게 위반한다. 그리고 만약 호출한 함수가 오류 코드를 반환 바로 처리해야하는 문제가 생긴다. 비즈니스 로직과 예외 처리하는 로직이 함께 뒤섞이게 된다.
if(deleteMember(memberDto) == E_OK){
if(userRespository.reRegister(memberDto) == E_OK){
// 비즈니스 로직 실행
if()
}else {
// Error 처리
}
}else {
// Error 처리
}
또한, ErrorCode라는 Enum 클래스를 정의하고 이 코드를 기반으로 분기를 처리하게 되면 오류 코드가 추가되면 코드 수정이 불가피하다.
그렇기 때문에 오류코드 기반보다는 Try-Catch문으로 예외처리를 하는 것이 좀 더 현명하다.
try {
deleteMember(memberDto);
userRespository.reRegister(memberDto);
String action = "delete";
userRespository.saveHistory(action, memberDto);
} catch(IOException | NullPointExcepiton e){
throw new Exception(e.getMessage());
}
8. 결론
처음부터 완성작을 작성하는 소설가는 세상 어디에도 없을 것이다. 초본을 작성하고 초본을 토대로 구조와 기틀을 세우고 고쳐 나간다. 그렇게 완성본이 탄생한다.
코드도 마찬가지다. 처음에는 이름도 의미가 전혀 없는 이름으로 선언하고 함수는 예외 처리 없이 단순히 실행만 되고 있는 상태이다. 우리가 코딩을 하면서 항상 첫 번째로 세우는 목표는 “실행이 되는 코드를 작성하자” 이다. 첫 번째 목표를 달성하면 두 번째 목표인 단위 테스트 모두 통과하는 코드로 코드 리팩토링을 하는 것이다. 그러니, 너무 처음부터 완벽한 코드를 작성하려고 하지 말아라. 그 누구도 처음부터 깨끗한 코드를 작성하는 것은 어려운 일이니 말이다.
'CS' 카테고리의 다른 글
디자인패턴 기반으로 알아보는 SOLID - (2) (6) | 2023.10.31 |
---|---|
디자인패턴 기반으로 알아보는 SOLID (2) | 2023.10.17 |
컴퓨터특강 2주차 : OOP (2) | 2023.10.03 |
[가상면접 사례로 배우는 대규모 시스템 설계 기초]사용자 수에 따른 규모 확장성사용자 수에 따른 규모 확장성 (0) | 2023.09.04 |