작게 만들기
주니어 개발자로써 가장 큰 실수를 하는 것이 하나의 함수에 너무 많은 책임과 기능을 넣음으로써 해당 로직이 무슨 기능을 하는지 모르게 되는 실수를 많이 하게 된다. 저도 그런 실수를 종종하고 후회를 합니다.
왜 작게 만들면 좋을까? 책에서도 작게 만들면 무엇이 좋은지 증거나 자료를 제시해주지는 않는다. 하지만 좋은 코드들을 보게 되면 대부분 아주 작은 부분까지 분리되어 있다. 이렇게 많이 분리 시켜놓으면 더 관리하기 어렵다고 생각 할 수 있다. 하지만 직접 프로그래밍하면서 설계하는 개발자라면 개발하면서 나누는 이유와 그로 가져오는 이익들을 느낄 수 있다.
public class ValidationUtils {
public boolean idCheck(String id) {
String emailRegex = "^(.+)@(.+)$";
Pattern pattern = Pattern.compile(regx);
Matcher matcher = pattern.matcher(id);
return matcher.matches();
}
}
간단한 이메일 형식을 체크하는 메서드 입니다. 지금도 충분히 짧지만 더 짧게 구현해보도록 해보자.
public class ValidationUtils {
private final String emailRegex = "^(.+)@(.+)$";
public boolean emailCheck(String id) {
return regexPatternMatcher(emailRegex, id).matches();
}
private Matcher regexPatternMatcher(String regex, String targetString) {
Pattern pattern = Pattern.compile(regx);
return pattern.matcher(id);
}
}
코드가 더 길어졌지만 함수를 더 작게 쪼개어 구현을 해보았고. 우리는 한줄짜리 코드를 보고 아! 이건 이메일 정규식 패턴을 잘 매칭되어 있는지 확인하는 함수라는 것을 좀 더 쉽게 알 수 있었다. 작게 쪼개다 보면 함수의 역활이 명확해지고 중복코드가 줄면서 궁극적으로는 클린코드에 한발 더 다가갈 수 있다는 것을 느낄 수 있다.
※ 들여쓰기는 한번만: 책에서는 if문, for문, switch문 등 한번만 하도록 구현하는 것을 지향하도록 해야한다고 설명되어 있습니다. 개발하다보면 들여쓰기를 많이하면 할수록 코드의 길이가 길어지고 점점 거대해지기 때문에 한번 이상의 들여쓰는 것은 지양하도록 해야합니다.
한가지만 해라!
저는 티비를 보면서 휴대폰할 때 가장 많이 듣던 잔소리입니다.
좋은 소프트웨어, 유지보수하기 쉬운 소프트웨어, 테스트하기 쉬운 소프트웨어와 관련된 컨퍼러스나 세미나를 가게되면 가장 많이 나오는 이야기가 하나의 책임만 가질 수 있도록 구현해라, 하나의 메서드에는 하나의 기능만 구현하라고 합니다. 왜 이렇게 항상 이야기가 나오고 강조를 할까?
실제 비지니스로직을 짜고 구현하다 보면 한가지 역활만 하는 것은 매우 어렵다. 간단한 게시판만 보더라도 사진을 저장하고 파일서버에 올리고 게시글 내용은 데이터베이스에 저장하고 로그까지 찍게되면 점점 한가지의 역활을 가져가기 힘들게 된다.
요구사항
작성된 게시글을 데이터베이스에 저장합니다. 사진을 업로드 할 수 있으며 사진은 파일서버에 저장하도록합니다.
게시글이 저장이 성공적으로 저장했으면 게시물의 아이디를 로그로 남깁니다.
추상적인 설계
PostService
- save : 게시글 저장
ImageService
- upload : 이미지 업로드
PostLogAspect
- logging : 포스트 로깅
한개의 요구사항을 추상적으로 설계해보았다. 내부 로직을 구현하지 않았지만 해당 내용만 봤을 때 무슨 역활을 하는지 명확하게 알 수 있습니다. 책의 저자는 추상화 수준은 아주 낮아야 한다고 한다. 추상적인 의미가 섞이게 되면 깨진 유리 창문 처럼 우리는 이게 창문인지 유리 공예품인지 알 수 없게 됩니다.
※ TIP: 우리는 책을 읽을 때 위에서 아래로 보통 읽게 됩니다. 코드도 이처럼 위에서 아래로 흐르도록 설계해야 잘짜여진 이야기 처럼 이해하기 수월합니다.
Switch 문
같은 동작을 해야하지만 각 입력값의 유형마다 다른 로직으로 처리하게 되는 로직을 설계해야되는 경우가 자주 생깁니다. 우리는 결국 else if 문이나 switch를 통해 각각 다른 처리를 하도록 구현하게 됩니다.
예를 들어 게시물을 조회할 때 삭제된 게시물은 접근 불가 페이지를 보여주고 비활성화된 게시물은 빈페이지를 활성화된 게시물은 전부 보여줘야 하는 요구사항이 있을 때 switch를 사용하여 구현한다면 하면 아래와 같이 구현할 수 있다.
public PostPage get(PostType postType) throws InvalidPostTypeException
switch(postType.type) {
case DELETE:
return accessDeniedPage(postType.type);
case DISABLE:
return emptyPage(postType.type);
case ACTIVE:
return originalPage(postType.type);
default:
throw new InvalidPostTypeException(postType.type)
}
}
switch나 else if 문이 나올 수 밖에 없는 상황이 오면 매우 난감해진다. 책에서는 아래와 같은 점이 문제가 된다고 잘 설명 해주었습니다.
- 함수가 길다.
- 한가지 작업만 수행하지 않는다.
- SRP(단일 책임 원칙)를 지키지 못한다.
- OCP(확장에는 열려있고 변경에는 닫혀있다)를 위반한다.
유형이 추가 될 때마다 코드가 변경이 되서 정말 골치가 아프다. 가장 문제점은 해당 구조와 동일한 함수가 계속 생기는 것이다. 예를 들어 추가나 수정, 삭제, 관리자가 조회시 등 무한히 늘어나는 것이다.
이러한 문제를 해결하는 것은 바로 추상팩토리에 꼭꼭 숨겨서 적절한 다형성을 가진 클래스를 생성한다. 이 문장을 읽고 저도 잘 이해할 수 없었다. 코드를 보게 되면 좀 더 쉽게 이해할 수 있다.
public abstract class PostPage {
public abstract String getTitle();
public abstract String getContent();
}
--------------------------------------------------------------------
public interface PostPageFactory {
public PostPage make(PostType postType) throws InvalidPostTypeException
}
--------------------------------------------------------------------
public interface PostPageFactory implements PostPageFactoryImpl {
public PostPage make(PostType postType) throws InvalidPostTypeException {
switch(postType.type) {
case DELETE:
return new AccessDeniedPage(postType.type);
case DISABLE:
return new EmptyPage(postType.type);
case ACTIVE:
return new OriginalPage(postType.type);
default:
throw new InvalidPostTypeException(postType.type)
}
}
}
이코드를 보고 똑같이 Switch가 사용되어 지는데 어떻게 된거죠? 생각을 했었다 하지만 위에 있는 문제점을 확인해보면
- 함수가 길어진다.
한가지 작업만 수행하지 않는다.PostPage라는 클래스를 생성하는 작업만 한다.SRP(단일 책임 원칙)를 지키지 못한다.다형성을 통해 get이라는 함수가 추상메서드를 통해 다형성을 가진 클래스를 리턴함으로써 각각의 책임을 클래스로 분리되었기 때문에 하나의 책임만 가지게 되었다.OCP(확장에는 열려있고 변경에는 닫혀있다)를 위반한다.새로운 유형이 추가되면 다른 형태의 클래스를 선언하고 상속받게 사용하게 되면 확장에는 열려있고 변경에는 닫혀있는 구조로 되었다.
새로운 유형이 추가 될수록 함수가 길어지는 것은 어찌할 도리가 없다, 그래서 저자도 불가피한 상황이 생기기 마련이고 OOP 설계 원칙을 어긴 적이 있다고 한다. 하지만 알지 못한채로 못하는 것과 아는 상태로 못하는 것은 큰 차이가 있다고 생각한다. 소프트웨어는 여러 요소가 복합적으로 상호작용하여 동작하는 프로그램이기 때문에 모르는 만큼 불안정해지고 극단적으로는 유지보수할 수 없는 소프트웨어로 추락할 수도 있기 때문이다.
서술적인 이름 사용
이해할 수 있는 이름 짓기는 이전 챕터에도 나왔지만 이번 챕터에도 나왔다는 것은 그만큼 중요하다는 의미인 것 같다. 낮은 수준의 추상화를 추구하다 보면 getAll, save, get 등으로 명시되어 있지만 해당 이름과 맞지 않는 역활을 하는 경우가 많다. 그렇기 때문에 복잡한 함수는 서술하듯이 설명해야 한다고 한다. includSetupAndTeardownPages, includSetupPages 등 처럼 길게 네이밍을해도 상관 없는 것이다. 우리는 이름이 짧아지는 것보단 유추할 수 있는 이름을 짓는 것이 더 중요하기 때문이다.
함수와 인수
- 0개: 아주 이상적인 함수의 형태
- 1개: 사용해도 괜찮은 함수
- 2개: 이해하기 조금 어려운 함수
- 3개: 테스트하기 부담스러운 함수
- 4개 이상: 사용 X (함수만 보고 이해하기 힘들고 확장이 어렵다.)
단항 함수 (인수 1개)
가장 많이 쓰이는 구조이며 주로 3가지 역활을 하는데 사용한다.
- 질문을 던지는 경우
// 같은 객체인지 질문
Boolean isEqualTo(Object targetObject) {
}
// 파일이 존재하는 질문
Boolean fileExist(String path) {
}
- 인수를 변환해서 결과를 반환해야 할 때
// 페이징 하여 결과를 반환
Page<Content> getAllWithPage(Pageable pageable) {
}
// 파일 가져오기
FileStream fileOpen (String file) {
}
- 이벤트형 함수 (주의하여 사용할 것 시스템의 상태를 변경하는 함수이기 때문이다.)
Content save(Content content) {
}
String LocalDateTime ConvertDateTime(LocalDateTime dateTime) {
}
이와 같은 상황이 아니라면 가급적이면 피해야 한다. 혼란을 야기할 수 있기 때문이다. 그리고 플래그 인수는 사용하지 않는다. 파라미터로 boolean 값을 전달한다면 그에 따라 다른 역활을 하는 경우가 많기 때문이다.
이항 함수 (인수 2개)
자연스러운 인수의 순서가 있는 경우는 오히려 이항 함수를 사용하는 것이 좋다.
Point point = new Point(1, 2);
Image image = new Image("dog", "png")
그럴 경우가 아닌 경우 함수 한개보단 이해하기 힘들다. 인수가 두개를 가진 함수를 보게되면 핵심 인수를 제외하고는 무시하는 경우가 있다. 예를 들어 아래와 같은 함수를 봤을 때 자세히 보면 이해할 수 있지만 자연스럽지 않은 구조로 인해 한번에 이해하기는 어렵다.
String writeField(String Pattern, String name)
성질이 다른 인수이면 더더욱 이해하기가 어렵고 유지보수하기가 어려워진다. 그렇기 때문에 되도록이면 단항으로 바꿔 사용한다면 훨씬 이해하기가 편해져서 유지보수하기가 쉬워진다.
Field field = new Field("{ name : %s }");
file.writeFiled("Tom");
삼항 함수 (인수 2개)
삼항 함수를 사용하려면 훨씬 더 신중하게 생각해야한다. 그 순서가 자연스럽지 않다면 이해하는데 주춤거리고 코드를 읽는 시간이 점점 많아지기 때문이다. 성질이 다른 인수끼리 있다면 순서를 어떻게 해야되고 이름은 어떻게 지어야 할지 고민이 될 것이다.
잘사용한 예
Coordinate3D(String x, String y, String z);
Address(String contury, String city, String state);
그렇다면 3개의 인수를 넘겨줘야할 때 어떻게 해야 될까? 저자는 아래의 방법으로 사용하는 것을 추천했다.
인수 객체
결국 객체를 만들어서 눈속임 뿐이라고 생각할 순 있겠지만 새로운 개념을 사용함으로써 우리는 좀 더 이해하기 쉬운 함수를 만들고 사용할 수 있다. 새로운 개념을 통해 이해하기 쉬워진다면 유지보수를 하는데 있어서 훨씬 편해질 것이다.
Page<Content> search(String keyword, Integer page, Integer size);
Page<Content> search(String keyword, Pageable pageable);
인수 목록
아주 좋은 예가 String에서 format인데 가변인수를 사용하여 전부 동등한 취급을 한다면 좀 더 클린한 코드를 구현할 수 있을 것이다.
String.format("WorkedDay %s: $: %,d%", workDays, money)
동사와 키워드
아니면 함수이름에 동사와 키워드를 입력하여 좀 더 이해하기 쉽게 서술한다면 이해하기 사용할 수 있을 것이다.
Page<Content> searchWithPage(String keyword, Pageable pageable);
부수효과 금지
우리는 서비스를 구현하면서 부수효과가 생기는 것은 불가피 하다. 예를 들어 게시글에 댓글을 달았을 때 상대에게 알림을 보내는 것과 좋아요 몇개 이상이 달렸을 때 인기글에 등록해주는 것 등 실제 서비스는 매우 복잡하고 어렵게 요구사항을 요구한다. 하지만 우리는 그렇더라도 한개의 함수에는 하나의 역활만 하도록 해야한다.
postService.save(post);
위에 이름 처럼 사용해놓고 알림까지 보내는 기능을 넣게 되면 어떻게 될까? 알림을 보내는 서비스에서 장애가 생겨서 예외를 처리하게 된다면 게시글은 저장이 되었지만 API는 에러 처리된 결과를 받게된다. 그렇게 한번 더 시도하게 되고 중복된 게시글이 생기게 되는 것이다. 예상치 못한 혼란이 생겼을 때 원인을 알기도 어렵다. 그래도 사용해야 된다면 함수 이름에 명시해두도록 한다.
명령과 조회는 분리하자
함수는 뭔가 수행하거나 조회하거나 한가지 일에만 수행하도록 구현해야한다. 작가가 아니라 독자의 관점으로 보면 왜 그런지 이해하기 쉽다. 예를 들어 아래의 코드를 확인 해보자.
public Integer update (String name);
코드를 보는 사람 입장으로 어떤 숫자가 나올지 그 숫자는 무엇을 의미하는지 알 수 있을까? 전혀 이해할 수 없다. 우리는 이러한 코드를 짜는 것을 피해야하고 명령과 조회는 정확히 분리하여 사용함으로써 혼란함을 야기할 수 있는 것을 방지해야한다.
예외 >> 오류 코드
우리는 예외를 처리할 때 if문으로 작업할 때가 종종 있다. 하지만 이렇게 처리 한다면 우리의 코드는 if, else 지옥에 빠지게 될 것 이다. 예를 들어 아래의 코드로 예시를 보게 되면 알 수 있다.
void update(User user) {
User savedUser = userRepository.findById(user.getId()).orElse(null);
if(savedUser == null) {
log.error("not saved user");
} else {
if(user.status == "DELETE") {
log.error("deleted User");
}else {
userReposiotry.update(savedUser);
}
}
}
조건이 점점 붙을수록 확장하기는 어렵고 유지보수하기 어려운 방향으로 점점 가고 있는 것을 느낄 것이다. 그렇기 때문에 예외를 발생하도록 해야합니다. 물론 if 가 더 빠르지만 우리는 성능이라는 유혹에 빠져서 소프트웨어를 망가트릴 수 있는 길로 가지 않도록 해야한다.
void update(User user) {
User savedUser = userRepository.findById(user.getId())
.orElseThrow(() -> {
throws new InvalidUserException();
});
if(user.status == "DELETE")
throws new DeletedUserException();
userReposiotry.update(savedUser);
}
오류 처리에 대한 코드가 빠지면서 한층 더 깨끗해진 코드를 확인해볼 수 있다. 하지만 예외처리를 사용하면서 주의할 점이 있다. 아래의 나와있는 주의사항을 명심하고 사용하도록 하자!
주의사항
- try catch 블록도 if else와 마찬가지로 코드를 추하게 만듭니다. 그렇기 때문에 가장 상단의 함수에 처리하도록 명시하고 하단에 있는 함수들은 예외를 throw하여 넘겨주도록 합니다.
- 오류도 마찬가지로 한가지 역활만 하도록 구현해야 합니다. RunTimeException 이나 Exception으로 처리를 한다면 우리는 해당 오류가 무슨 오류인지 어떤 문제로 오류가 생긴 것인지 파악하기가 어려워 집니다.
- 오류도 재사용성이 높도록 구현하는 것이 좋습니다. 같은 맥락과 비슷한 에러 처리를 하는데도 계속 예외처리를 추가하게 된다면 우리는 관리할 수 없는 예외들을 보고 리팩토링을 하게 됩니다.
반복하지 말자
클린 코드, 클린 아키텍처, 성장가능한 소프트웨어 등 강의를 듣게되면 공통적으로 나오는 부분이 있다. 중복되는 부분의 코드를 모듈화 시켜서 재사용성을 높이게 되는 것이다. 단순히 반복안하는 것 만으로도 단순히 코드가 줄어드는 것 이상의 효과를 볼 수 있다. 아래의 코드를 통해 체감해보자.
public List<Order> getMyOrders(Long userId) {
List<Order> orders = orderRepository.findAllByUserId(Long userId);
if(orders == null || orders.isEmpty()) {
return orders;
}
order.foreach(order -> {
order.setPayment(paymentRepository.findOne(order.getCustomer());
})
return orders;
}
public List<Order> getMyCancleOrders(Long userId) {
List<Order> orders = cancleOrderRepository.findAllByUserId(Long userId);
if(orders == null || orders.isEmpty()) {
return orders;
}
order.foreach(order -> {
order.setPayment(paymentRepository.findOne(order.getCustomer());
})
return orders;
}
위에 있는 코드는 주문 목록을 가져오는 코드다. 반복되는 것은 가독성을 해칠 뿐만 아니라 알고리즘이 바뀌게 되면 중복되는 모든 코드를 모두 수정해야하는 작업을 해야한다. 하나라도 수정을 깜박하게 된다면 버그를 맞이하게 될 것이고 그에 따른 댓가로 우리는 시간을 몇 배 그 이상을 소모할 수도 있을 것이다.
public List<Order> getMyOrders(Long userId) {
return orderPaymentsUpdate(
orderRepository.findAllByUserId(Long userId)
);
}
public List<Order> getMyCancleOrders(Long userId) {
return orderPaymentsUpdate(
cancleOrderRepository.findAllByUserId(Long userId)
);
}
private void ordersPaymentsUpdate(List<Orders> orders) {
if(orders == null || orders.isEmpty()) {
return orders;
}
orders.foreach(order -> {
order.setPayment(paymentRepository.findOne(order.getCustomer());
})
return orders
}
구조적 프로그래밍
구조적 프로그래밍을 구현하기 위해서는 모든 함수는 하나의 입구와 출구를 가져야한다. return 문이 하나여야만 한다고 말이다. 하지만 작은 구조의 함수인 경우 때로는 단일 입구 원칙을 사용해서 표현 하는 것이 의도를 표현하기 좋을 때도 있다. 하지만 함수가 클 때는 단일 입구 원칙으로 하게 되면 구조적으로 큰 이익을 가져 올 수 있다.
함수를 짜는 방법
코드는 아주 긴 서사시이다. 작가가 책을 쓰는 것을 보면 주변에 꾸겨진 종이와 다 쓴 펜들로 가득한 것을 볼 수 있다. 마찬가지로 코드도 처음에는 즉흥적으로 짜고 중복코드가 있어도 좋고 코드가 길어도 좋다. 구현을 마치면 단위테스트를 작성하여 잘 작동하는지 확인하고 이제 천천히 다듬는 것이다. 중복 코드를 줄이고 메서드를 분리 시키고 순서를 바꾼다. 작업을 마치고 나면 단위테스트를 통과하는지 확인한다. 처음부터 완벽한 설계가 된 코드가 나올 수는 없다. 그렇기에 우리는 시행착오를 거치고 점점 완벽한 함수를 짜는 것이다.
이번 챕터 후기
이번 챕터는 정리해야될 내용도 많고 이해하기 위해 반복해서 본 부분도 꽤 많았다. 책에 내용을 정리하고 나에게 맞는 내용으로 작성하면서 책에 대한 내용을 이해하게 되었고 클린 코드의 길은 멀고도 험하다는 것을 느꼈다. 저자는 소프트웨어는 시스템이 일으키는 동작과 오류를 그 도메인에 특화된 프로그래밍 언어로 풀어 내야하는 이야기라고 한다. 이번 챕터는 나의 코드를 되돌아보고 반성하면서 피드백을 받을 수 있는 시간이였다.
요약
- 다른 역활을 못하도록 함수를 잘게 쪼개서 한가지 역활만 하도록 하자.
- 함수의 동작 순서도 중요하다. 다른 길로 빠지거나 뒤로 돌아가지 않도록 잘 설계해야 한다.
- 인수는 적을 수록 좋다. 어쩔 수 없는 상황이라면 다양한 방법으로 이해하기 쉽도록 함수를 설계해보자.
- 반복되는 부분은 모듈화하여 재사용하기 쉽게 함수를 구현하여 공통적으로 사용하자.
- 함수의 이름은 함수의 설계만큼 중요한 부분이다. 그렇기 때문에 좋은 네이밍을 하도록 해야한다.
'도서' 카테고리의 다른 글
클린코드 (주석) (0) | 2022.07.07 |
---|---|
클린 코드 (의미 있는 이름) (0) | 2022.06.26 |
클린 코드에 대하여 (0) | 2022.06.21 |
테스트 주도 개발 패턴 (2) (0) | 2022.05.29 |
테스트 주도 개발 패턴 (0) | 2022.05.15 |