회원가입을 하면 원하는 문장을
저장할 수 있어요!
다음
AWS 이용 중이라면 최대 700만 원 지원받으세요
개발자를 준비하는 많은 분들이 자기 PR 목적으로 코드를 공유합니다. 깃헙이나 블로그에 직접 작성한 코드를 올려놓으면, 본인에 대한 더 많은 정보를 제공할 수 있습니다. 그러나 가독성이 좋지 않은 코드를 공유한다면, 오히려 역효과가 날 수 있습니다.
회원가입을 하면 원하는 문장을
저장할 수 있어요!
다음
회원가입을 하면
성장에 도움이 되는 콘텐츠를
스크랩할 수 있어요!
확인
개발자를 준비하는 많은 분들이 자기 PR 목적으로 코드를 공유합니다. 깃헙이나 블로그에 직접 작성한 코드를 올려놓으면, 본인에 대한 더 많은 정보를 제공할 수 있습니다. 그러나 가독성이 좋지 않은 코드를 공유한다면, 오히려 역효과가 날 수 있습니다.
생각보다 흔하게 일어나는 일인데요. 그 이유 중 하나는 바로 코드 내용에만 집중하기 때문입니다. 여러분은 어떤 기능을 구현하기 위해 가장 효율적인 방법은 무엇인지, 어떻게 하면 확장성 있게 구현할 수 있을까에 대해 고민할 것입니다. 이러한 것들을 고민하다 보면 점점 구조가 복잡해지고, 읽기 어려운 코드가 됩니다.
코드를 통해 여러분이 고민한 내용을 온전히 전달하기 위해서는 가독성을 높이는 것이 중요합니다. 앞서 요즘IT에 코드 스타일의 중요성에 관한 글을 발행한 적이 있습니다. 인덴트, 변수와 함수 이름과 같은 코드 스타일은 코드의 첫인상으로, 이를 제대로 지키지 않으면 아예 코드를 읽지 않을 수도 있습니다.
코드 스타일은 가독성을 높이는 첫 단계입니다. 이번 글에서는 코드 스타일 외에 가독성 높은 코드를 작성할 수 있는 몇 가지 방법을 알아보겠습니다. 저 또한 개인 블로그에 코드를 공유할 때 항상 신경 쓰는 내용인 만큼, 이번 글을 통해 앞으로 코드를 공유할 때 한 번씩 적용해 보면서 점점 더 좋은 코드를 작성할 수 있으면 좋겠습니다.
Early Out은 메서드의 가독성을 높이는 대표적인 방법입니다. 예시로 사용자의 주문을 처리하는 다음의 코드를 살펴봅시다.
public void processOrder(Order order) {
if (order != null) {
if (order.isValid()) {
try {
if (order.getPaymentStatus() == PaymentStatus.PAID) {
if (order.getShippingAddress() != null) {
if (order.getItems().size() > 0) {
// 주문 처리 로직
// ...
} else {
throw new OrderException("주문 상품이 없습니다.");
}
} else {
throw new OrderException("배송 주소가 없습니다.");
}
} else {
throw new OrderException("결제가 완료되지 않았습니다.");
}
} catch (OrderException e) {
// 주문 예외 처리 로직
// ...
} catch (Exception e) {
// 기타 예외 처리 로직
// ...
}
} else {
throw new OrderException("주문 정보가 유효하지 않습니다.");
}
} else {
throw new OrderException("주문 정보가 없습니다.");
}
}
}
이 코드는 주문이 정상적인지를 확인하고, 정상적일 경우 처리합니다. 로직을 생각해 보면 주문 정보가 있고, 유효하고, 결제가 완료되었고, 배송 주소가 있고, 주문한 상품이 있을 때 주문을 처리해야 하니 틀린 로직이 아닙니다. 하지만 가독성이 매우 떨어집니다.
가장 먼저 등장하는 조건인 order != null에 대한 처리가 메서드 가장 아래에 등장합니다. 마찬가지로 다른 조건들 또한 위에 있는 조건일수록, 아래쪽에서 대응하고 있습니다. 이처럼 조건문과 그에 대한 처리가 멀리 떨어져 있는 경우, 가독성을 심하게 해칠 수 있습니다.
이런 문제는 조건을 반전시킴으로써 해결할 수 있습니다. 다음과 같이 예외 경우에 대한 처리를 우선 검사하고, 일찍 메서드를 종료하는 것입니다.
public void processOrder(Order order) {
if (order == null) {
throw new OrderException("주문 정보가 없습니다.");
}
if (!order.isValid()) {
throw new OrderException("주문 정보가 유효하지 않습니다.");
}
if (order.getPaymentStatus() != PaymentStatus.PAID) {
throw new OrderException("결제가 완료되지 않았습니다.");
}
if (order.getShippingAddress() == null) {
throw new OrderException("배송 주소가 없습니다.");
}
if (order.getItems().isEmpty()) {
throw new OrderException("주문 상품이 없습니다.");
}
// 주문 처리 로직
// ...
}
이렇게 코드를 수정함으로써 같은 로직을 유지하면서, 예외 처리에 대한 가독성을 높일 수 있습니다. 기존에 있던 인덴트 지옥이 해결되는 것도 큰 장점입니다.
자바는 for-each문을 통한 반복을 지원합니다. for-each는 인덱스를 관리하며 원소를 순회하는 일반적인 for문과 달리 배열이나 컬렉션의 각 원소에 직접 접근하여 안전하게 작업을 수행할 수 있습니다.
예를 들어, 장바구니에 담은 상품들의 총 가격을 구하는 예시 코드를 살펴봅시다.
List<Product> products = /* 장바구니의 상품 리스트 */;
int totalPrice = 0;
for (Product product : product) {
totalPrice += product.getQuantity() * product.getPrice();
}
이처럼 for-each문을 사용하면 모든 원소들에 대해 원하는 작업을 직관적으로 수행할 수 있게 됩니다. 인덱스를 직접 관리하지 않는 것에는 여러 장점이 있습니다.
우선 코드가 간결해집니다. 일반적인 for문은 다음 코드와 같이 인덱스를 관리하고, 다시 이 인덱스를 통해 원소에 접근하는 과정이 필요합니다. 인덱스와 관련된 부분을 생략함으로써 코드가 깔끔해지고, 가독성이 좋아집니다.
List<Product> products = /* 장바구니의 상품 리스트 */;
int totalPrice = 0;
for (int i = 0; i < products.size(); i++) {
totalPrice += products.get(i).getQuantity() * products.get(i).getPrice();
}
또한 off-by-one 오류를 방지할 수 있습니다. 이 오류는 배열의 인덱스를 직접 다룰 때 자주 발생하는 논리 오류 중 하나로, 경곗값을 잘못 처리하여 의도한 값보다 1만큼 크거나 작은 값을 사용하게 되는 오류입니다.
for (int i = 0; i <= products.size(); i++) { … }
for-each문은 실제로 존재하는 원소들에 대해서 순회하기 때문에 이러한 오류로부터 안전합니다.
물론 모든 반복을 for-each로 대체할 수 있는 것은 아닙니다. 인덱스의 정보가 필요할 때, 짝수 번째와 같이 모든 원소가 아닌 특정 원소에 대해서만 작업을 수행하고 싶을 때, 역순으로 순회하고자 할 때 등 for-each로는 해결하기 힘든 반복 작업이 있을 수 있습니다. 이러한 특수한 상황에서는 ranged based for문을 쓰되, 순방향으로 모든 원소에 대해 작업을 반복할 때는 for-each문을 사용해 보세요.
옛날 버전의 C언어를 사용하는 분들은 변수를 함수 가장 위에 몰아서 선언하는 경우가 있습니다. 자바는 변수를 메서드 가장 앞에 몰아서 선언할 필요가 없습니다. 변수를 몰아서 선언하는 것은 변수가 어떻게 사용될지, 컨텍스트가 없는 상황에서 선언해 놓는 것이기 때문에 오히려 가독성을 저하시킵니다.
// 좋지 않은 예시
int result;
// ... 많은 코드 ...
result = calculateSomething();
// 좋은 예시
// ... 많은 코드 ...
int result = calculateSomething();
이처럼 변수를 실제로 사용하는 곳 근처에 선언하는 것은 코드를 읽을 때 위아래로 훑어봐야 하는 번거로움을 줄이고, 변수의 목적을 쉽게 파악할 수 있게 해줍니다.
현대의 많은 언어들은 null-safety를 강조합니다. 자바에서는 null을 안전하고 명시적으로 관리하여, NullPointerException을 방지하기 위한 안전한 방법이 마땅치 않습니다. Optional은 이를 어느 정도 해결할 수 있는 방법이 될 수 있습니다.
예를 들어, 다음과 같은 코드가 있습니다.
int price = 0;
Product product = getProduct(id);
if (product != null) {
price = product.getPrice();
}
Optional의 사용이 표준화되어 있지 않은 코드라면, getProduct()가 반환한 값이 null인지 여부를 체크해 주어야 합니다. Optional을 사용할 경우, 해당 객체의 값이 없을 수도 있다는 것을 명시적으로 표현할 수 있습니다.
int price = getProduct(id).map(Product::getPrice).orElse(0);
/*
아래와 동일한 코드
Optional<Product> product = getProduct(id);
int price = product.map(Product::getPrice).orElse(0);
*/
Optional은 다음과 같은 상황에서 사용하면 유용합니다.
위 경우 Optional을 활용하면 NullPointerException을 예방할 수 있습니다. 또한 Optional 클래스가 제공하는 map(), filter(), orElse()와 같은 메서드를 통해 함수형 스타일로 코드를 더욱 직관적으로 작성할 수 있습니다.
자바에서 제공하는 많은 컬렉션들은 구현체와 인터페이스로 구분되어 있습니다. 예를 들어, List 인터페이스는 이를 구현하는 ArrayList, LinkedList 등의 클래스가 있고, Set 인터페이스는 HashSet, TreeSet 등이 구현합니다.
많은 경우, 구체적인 클래스 대신 인터페이스 자료형을 사용하면 코드의 가독성을 높일 수 있으며, 코드를 더욱 유연하고 확장성 있게 만들어 줍니다.
예를 들어, 전체 상품 리스트를 카테고리별로 분류하는 메서드 categorize()가 다음과 같이 정의되어 있습니다.
HashMap<Category, ArrayList<Product>> categorize(ArrayList<Product> products) {
HashMap<Category, ArrayList<Product>> result = new HashMap<>();
for (Product product : products) {
if (!result.containsKey(product.category)) {
result.put(product.category, new ArrayList<>());
}
result.get(product.category).add(product);
}
return result;
}
위 코드는 HashMap과 ArrayList 등 인터페이스를 구현하는 클래스에 의존합니다. 이렇게 작성한 코드는 TreeMap, LinkedList처럼 같은 인터페이스를 다른 형태로 구현한 클래스에 대해서는 작업을 수행할 수 없습니다.
메서드 내용은 Map과 List 인터페이스에서 제공하는 작업들로 충분하므로, 굳이 구체적인 클래스를 명시하여 역할을 제한할 필요가 없습니다. 이를 반영하여 수정한 코드는 다음과 같습니다.
Map<Category, List<Product>> categorize(List<Product> products) {
Map<Category, List<Product>> result = new HashMap<>();
for (Product product : products) {
if (!result.containsKey(product.category)) {
result.put(product.category, new ArrayList<>());
}
result.get(product.category).add(product);
}
return result;
}
실제 객체를 생성할 때는 구현체가 있어야 하므로 클래스를 이용해 생성하지만, 변수나 메서드의 반환형은 인터페이스를 사용함을 확인할 수 있습니다.
다만 인터페이스를 사용할 땐 주의할 점이 있습니다. 만약 구현체별로 성능이나 동작 차이가 발생하고, 이것이 메서드를 수행하는 데에 있어서 중요한 요소라면 인터페이스보다는 클래스를 사용하는 것이 더 나은 선택일 수 있습니다.
리스트에 대해 버블 소트를 수행하는 다음의 메서드를 살펴봅시다.
public static void bubbleSort(List<Integer> list) {
int n = list.size();
for (int i = 0; i < n - 1; i++) {
for (int j = 0; j < n - i - 1; j++) {
if (list.get(j) > list.get(j + 1)) {
int temp = list.get(j);
list.set(j, list.get(j + 1));
list.set(j + 1, temp);
}
}
}
}
위 버블 정렬은 List 인터페이스에서 제공하는 get() 메서드를 이용해 작성되었습니다. List에서 제공하는 것이니 인터페이스를 사용하는 것이 맞을까요?
List를 구현하는 가장 대표적인 두 클래스인 ArrayList와 LinkedList를 비교해 보면, random-access 동작을 수행하는 get() 메서드가 소요하는 시간에서 큰 차이가 발생합니다. 배열 기반인 ArrayList는 상수 시간인 O(1)만에 수행되는 데 반해, 링크드 리스트 기반인 LinkedList는 O(n)의 시간 복잡도를 가지게 됩니다.
따라서 위 bubbleSort() 메서드는 입력으로 ArrayList를 넘겨주면 O(n²)의 시간 복잡도를, LinkedList를 넘겨주면 O(n³)의 시간 복잡도를 가지게 됩니다.
메서드를 호출할 때는 메서드의 구체적인 동작 방식을 모르므로, 이와 같은 사실을 알기 힘듭니다. 이처럼 메서드 외부에서 전달해 주는 구현체에 따라 메서드의 동작이 달라질 경우, 구현체를 명시하여 원하는 동작만 수행하도록 제한하는 것도 고려할 만한 방법 중 하나입니다.
public static void bubbleSort(ArrayList<Integer> list) {
int n = list.size();
for (int i = 0; i < n - 1; i++) {
for (int j = 0; j < n - i - 1; j++) {
if (list.get(j) > list.get(j + 1)) {
int temp = list.get(j);
list.set(j, list.get(j + 1));
list.set(j + 1, temp);
}
}
}
}
지금까지 자바 가독성을 높이는 5가지 팁을 살펴봤습니다. 사실 좋은 코드를 작성하는 것은 하루아침에 되는 것이 아닙니다. 코드를 한 줄 한 줄 적을 때마다 기능을 구현할 수 있는 여러 방법을 생각하고, 그중 하나를 충분한 근거를 통해 선택하는 과정을 반복함으로써 차근차근 쌓아갈 수 있습니다.
이번 글에서 소개한 가독성 높이는 팁이 사소할 수 있지만, 그만큼 몇 번만 신경 써서 코드를 작성하면 충분히 습관 들일 수 있는 내용입니다. 이렇게 작은 내용부터 시작해 체득한다면, 점점 더 가독성을 높여 좋은 코드를 작성할 수 있을 것이라 생각합니다.
요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.