4주간 우아한 테크코스 백엔드 프리코스 과정을 수행하면서 제 코드의 문제점을 알게 되었고, 이를 개선하는 시간을 보내게 되었습니다. 그 시간 동안 제가 배운 좋은 코드를 위한 방법에 대해 공유하고자 글을 쓰게 되었습니다.
당연한 말처럼 들리지만 읽는 사람들이 해당 변수나 메서드, 도메인 명이 어떤 일을 하는 것인지 충분히 예측 가능하도록 이름을 짓는 것은 충분한 고민이 필요한 문제라고 생각합니다.
하나의 예시를 들어보도록 하겠습니다.
check
라는 메서드명은 확인한다는 의미이기 때문에 이를 읽은 사람들은 이 메서드가 어떤 여부에 대해 확인하는 메서드라고 예측할 것 입니다. 그런데 만약 이 메서드에서 데이터가 업데이트되는 일이 생긴다면 사람들은 예측할 수 없는 코드가 될 것이므로 만약 데이터가 바뀌는 로직이라면 calculate
와 match
와 같이 메서드 명이나 calculator
같은 도메인 명으로 수정하여 데이터가 수정될 수 있다는 의미를 담아야 합니다.
따라서 check
라는 메서드명은 반환값이 boolean일 때만 사용하도록 하고 데이터 수정이 되는 로직이라면 그 의미가 충분히 내포될 수 있도록 지어주는 것이 적절합니다.
2주차 미션인 로또에서 계산 로직의 일부 입니다.
로또와 당첨번호를 비교해 비교 결과에 따라 결과를 보상을 업데이트하는 로직입니다. 이 경우, check
보다 match
를 사용해 데이터가 업데이트 될 것이라는 것을 예측할 수 있도록 하였습니다.
//check를 사용하였을 경우
public GameResultResponse check(List<Integer> luckyNumbers, int bonusNumber, int payment){
int income = 0;
EnumMap<Reward, Integer> rewards = setUp();
for (Lotto lotto : lottos) {
Reward reward = lotto.check(luckyNumbers, bonusNumber);
income += reward.getMoney();
rewards.put(reward, rewards.get(reward) + 1);
}
return new GameResultResponse(rewards, computeProfit(income, payment));
}
//match를 사용하였을 경우
public GameResultResponse match(List<Integer> luckyNumbers, int bonusNumber, int payment){
int income = 0;
EnumMap<Reward, Integer> rewards = setUp();
for (Lotto lotto : lottos) {
Reward reward = lotto.match(luckyNumbers, bonusNumber);
income += reward.getMoney();
rewards.put(reward, rewards.get(reward) + 1);
}
return new GameResultResponse(rewards, computeProfit(income, payment));
}
객체지향 생활체조 원칙(Object Calisthenics)은 코드 가독성과 유지보수성 향상을 위해 소트웍스 앤솔러지가 만든 코드 가이드라인입니다. 객체지향 생활체조 원칙 중 getter 사용을 지양하는 원칙이 있습니다.
객체의 내부 값을 외부에서 가져가 값을 함부로 변경하는 것은 정보 은닉과 불변성을 측면에서 적절하지 않으므로 객체 내부 값은 될 수 있으면 내부에서 처리하여 내보내는 방식을 지향하고 있습니다. 이를 위해 getter가 필요한 로직을 도메인 안으로 가져와 수행하는 방식으로 getter를 삭제할 수 있습니다. 물론 무조건 getter를 없애는 방식이 정답은 아니지만 getter가 필요한 상황에서 도메인 안에서 일을 수행할 수는 없는지 한 번 고민해보는 것이 필요합니다. 이렇게 한다면 도메인이 자신이 가진 데이터에 적절한 책임을 다하는지 확인할 수 있습니다.
2주차 미션인 자동차 경주에서 도메인이 자신이 가진 값에 대한 역할을 수행하도록 작성한 코드 입니다.
Game이라는 도메인 안에 cars와 raceProgress라는 데이터 가지고 있기 때문에 raceProgress가 쓰이는 move(이동)
이라는 로직과 cars와 raceProgress가 쓰이는 selectWinner(우승자 선정)
이라는 로직이 필요합니다.
public class Game {
private List<String> cars;
private List<Integer> raceProgress;
...
public RaceProgressResponse move(List<Integer> randomNumbers){
for (int i = 0; i < randomNumbers.size(); i++) {
if (randomNumbers.get(i) >= RaceConstant.MIN_MOVEMENT_VALUE) {
int updatedProgress = raceProgress.get(i) + RaceConstant.PROGRESS_DISTANCE;
raceProgress.set(i, updatedProgress);
}
}
return new RaceProgressResponse(cars, raceProgress);
}
public WinnerResponse selectWinner() {
int maxDistance = getMaxDistance(raceProgress);
List<String> winner = IntStream.range(0, raceProgress.size())
.filter(k -> raceProgress.get(k).equals(maxDistance))
.mapToObj(cars::get)
.collect(Collectors.toList());
return new WinnerResponse(winner);
}
private int getMaxDistance(List<Integer> finalResult) {
return finalResult.stream()
.mapToInt(Integer::intValue)
.max()
.orElse(0);
}
}
객체지향 생활체조 원칙 중 else를 사용하지 않는 원칙이 있습니다. 연속하여 else를 반복하는 것은 코드 가독성을 떨어뜨리는 일이기 때문에 else 대신 사용할 수 있는 방법이 early return입니다.
기존에 있는 else를 early return으로 처리하고 한 함수가 하나의 역할만을 하는지 확인합니다. 만약 하나의 함수가 2개 이상을 할당한다면 이를 메서드 분리하면 더 좋은 코드를 만들어 낼 수 있습니다.
1주차 미션인 숫자야구에서 계산 로직 코드의 일부 입니다.
else 를 사용하여 스트라이크와 볼 계산을 한번에 하는 것보다 early return 패턴을 사용해 두 로직을 나누고 메서드 분리까지 하는 것이 역할을 명확히 하고, 코드 가독성을 높여줍니다.
//early return 적용하지 않았을 경우
public void calculate(List<Integer> randomNumbers, List<Integer> playerNumbers){
for (int tempSize = 0; tempSize < BaseballConstant.NUMBER_SIZE; tempSize ++) {
int randomNumber = randomNumbers.get(tempSize);
int playerNumber = playerNumbers.get(tempSize);
if (randomNumber == playerNumber) {
strike++;
} else if (randomNumbers.contains(playerNumber)) {
ball++;
}
}
if (strike == 0 && ball == 0) {
nothing++;
}
}
//early return 적용하였을 경우
public void calculate(List<Integer> randomNumbers, List<Integer> playerNumbers){
for (int tempSize = 0; tempSize < BaseballConstant.NUMBER_SIZE; tempSize ++) {
int randomNumber = randomNumbers.get(tempSize);
int playerNumber = playerNumbers.get(tempSize);
calculateStrike(randomNumber, playerNumber);
calculateBall(randomNumbers, randomNumber, playerNumber);
}
calculateNothing();
}
private void calculateNothing() {
if (strike == 0 && ball == 0) {
nothing++;
}
}
private void calculateBall(List<Integer> randomNumbers, int randomNumber, int playerNumber) {
if (randomNumbers.contains(playerNumber) && randomNumber != playerNumber) {
ball++;
}
}
private void calculateStrike(int randomNumber, int playerNumber) {
if (randomNumber == playerNumber) {
strike++;
}
}
stream은 자바에 적용되는 방법입니다. 정적 타입 언어라는 자바의 특성상, 변수 생성 당시에 데이터 타입을 지정해주어야 하고, 자료형과 데이터 타입이 맞지 않으면 컴파일 에러가 발생합니다. 이 특성은 코드 중간중간에 데이터 타입 변환 코드가 필요하기 때문에 동적 타입 언어인 파이썬과 자바스크립트에 비해 유연성이 떨어지고 코드가 길어집니다. 이 문제를 해결하기 위한 하나의 방법으로 자바 8에서는 lambda와 double colon, stream을 제공합니다. 그래서 자바에서는 stream을 잘 사용하여 코드의 길이를 줄여주는 것도 코드 가독성을 위해 필요한 방법입니다.
저의 경우는 문법의 간단한 이론을 익힌 후, 망나니 개발자님이 제공해주시는 stream 예제 문제를 클론 받아 풀면서 stream에 익숙해지기 위한 노력을 했습니다.
4주차 미션인 크리스마스 프로모션에서 stream을 적용한 코드 입니다.
menu별 주문 갯수와 가격을 곱하여 더한 합산으로 지불금액을 계산하였습니다.
private static int getPayment(EnumMap<Menu, Integer> menu) {
return menu.entrySet().stream()
.mapToInt(entry -> entry.getKey().getPrice() * entry.getValue())
.sum();
}
좋은 코드를 위한 고민을 많이 하면서 좋은 코드에 대한 이해가 늘게 되었습니다. 전까지는 좋은 코드를 위한 설계보다는 구현에 더 초점을 두었는데, 앞으로 가독성 있는 코드를 위한 연습을 더하여 좋은 코드를 작성하고 싶다는 생각을 하게 된 시간이었습니다.