Java

객체지향 생활 체조 원칙

onddd 2023. 10. 31. 18:16
728x90

규칙 1. 한 메서드에 오직 한 단계의 들여쓰기(indent)만 한다.

“One level of indentation per method” 원칙은 객체 지향 프로그래밍과 코드 디자인에서 깨끗하고 가독성 있는 코드를 작성하기 위한 지침 중 하나입니다.
이 원칙은 잘 구조화된 메서드에서 들여쓰기 수준을 하나로 유지하도록 권장하는 규칙으로 이를 준수할 경우 메서드를 짧게 유지하고, 하나의 작업에 집중할 수 있는 환경을 구성하게됩니다.

메서드에 들여쓰기가 하나만 있는 경우, 일반적으로 메서드가 한가지 목적을 수행하게되면 그만큼 코드의 길이가 짧아지고 이해하기 쉬운 코드가 되기 때문에 다른 개발자 혹은 미래에 다시 코드를 들여다 보게 될 나에게 더 확실한 정보를 전달할 수 있습니다.
하나의 목적에 충실한 메서드는 유지 및 수정에 용이하고, 변경을 수행하기 위해 중첩된 함수를 훑어 다니지 않아도 됩니다.

단일 책임을 갖는 메서드는 코드베이스의 다른 부분에서 재사용 가능성이 높아지고 이는 곧 DRY(Don’t Repeat Yourself) 원칙을 장려하며 동일한 작업을 다시 반복하지 않게 해주고 메서드에 대한 단위 테스트 역시 작성하기 쉽습니다. 해당 메서드의 기능이 구체적이기 때문에 그만큼 올바른 테스트를 작성하고, 동작을 확인할 수 있습니다.

물론 “One level of indentation per method” 원칙을 준수하려면 코드를 작게 유지하는 연습이 필요한데, 만약 내가 작성한 코드의 들여쓰기 수준이 높다면 리팩터링이 필요한 시점이라고 생각할 수 있습니다.

예를 들어 아래와 같이 CarNames 클래스가 존재할 때, extractCarNames() 메서드는 이름 분할, 유효성 검사, 좌우 공백 제거까지 총 세 가지 역할을 하고있고, 들여쓰기 레벨 역시 준수하지 않고 있습니다.

public class CarNames {  
    private final String value;  
  
    public CarNames(String carName) {  
        this.value = carName;  
    }
      
	public List<String> extractCarNames() { 
		String[] names = value.split(COMMA_DELIMITER); 
		List<String> carNames = new ArrayList<>(); 
		
		for (String name : names) {
			if(name == null || name.isEmpty()){
				throw new IllegalArgumentException("");
			}
			carNames.add(name.trim()); 
		} 
		return carNames; 
	}
}

이 클래스를 규칙 1에 맞춰 개선하면 다음과 같이 변경할 수 있습니다.

public class CarNames {  
    private static final String COMMA_DELIMITER = ",";  
    private static final String EXCEPTION_MESSAGE = "유효하지않은 입력입니다.";  
  
    private final String value;  
  
    public CarNames(String carName) {  
        hasNotNUllEmpty(carName);  
        this.value = carName;  
    }  
  
    public List<String> extractCarNames() {  
        String[] names = splitCarNames();  
        return trimCarNames(names);  
    }  
  
    private String[] splitCarNames() {  
        return value.split(COMMA_DELIMITER);  
    }  
  
    private List<String> trimCarNames(final String[] names) {  
        return Arrays.stream(names)  
                .map(String::trim)  
                .collect(Collectors.toList());  
    }  
  
    private void hasNotNUllEmpty(String value) {  
        if (value == null || value.isEmpty()) {  
            throw new IllegalArgumentException(EXCEPTION_MESSAGE);  
        }  
    }  
}

규칙 2. else 예약어를 사용하지 않는다.

“else 사용하지 않기” 원칙은 객체 지향 프로그래밍에서 코드를 더 읽기 쉽고 모듈화하며 이해하기 쉽도록 작성하는데 중점을 둡니다. else 문이 적은 코드는 일반적으로 더 읽기 쉽고, 일련의 조건 검사와 특정 작업으로 구성되어 코드의 동작이 더 명시적으로 다가오고 복잡한 로직을 피해 더 작고 집중된 메서드를 작성할 수 있는 환경을 조성할 수 있습니다.

이에 따라 각 메서드는 조건 논리의 특정 부분에 대해 책임을 가지게 되고 이는 단일 책임 원칙으로 이어집니다.
추가로 논리 로직에 따라 컨디션이 변경되지 않기 때문에 더 간단한 제어 흐름을 갖게 되므로 버그 발생 가능성이 줄어들고 프로그램 동작에 대한 이해가 쉬워지기 때문에 직관적인 코드 구성을 통해 효과적인 단위 테스트를 수행할 수 있습니다.

이 원칙을 준수하기 위해선 early return, 다형성, 또는 패턴 매칭과 같은 기술을 사용하여 else 문의 사용을 제거하거나 줄이는 방법을 고려할 수 있습니다.

public int processData(String data) {
	if(data == null){
		return -1; // else 사용 불필요
	}
	// 데이터 로직 처리 ..
	int result = validProcessing(data);
	return result;
}

규칙 3. 모든 원시값과 문자열을 포장한다.

이 원칙은 원시 데이터 유형 (int, double, char) 및 문자열을 객체로 래핑하는걸 권장하는 원칙으로 객체 지향 프로그래밍은 객체의 상호 작용으로 프로그램을 모델링하는 것을 강조하는데, 원시 데이터 유형은 객체가 아니므로 객체 지향 설계의 일관성을 높이기 위해 이러한 값을 객체로 랩핑하길 권장하고있습니다.

객체로 값을 랩핑하면 이 값에 대한 더 많은 메타데이터와 동작을 추가할 수 있습니다. 예를 들어, int 값 대신 Integer 객체를 사용하면 값의 범위를 검증하거나 다양한 수치 연산을 수행할 수 있고, null 값 처리 역시 명확해지며 예외를 방지할 수 있습니다.

다음과 같이 외부 입력으로 값이 들어오는 상황에서 원시 타입으로 곧 바로 입력을 받는다면, 유효하지 않은 값일 경우 예외가 발생할 가능성이 존재합니다.

int playRounds = Console.readLine();

이는 프로그램이 정상적인 동작을 할 수 없게 만들고, 외부 입력에 의존하기 때문에 다음과 같은 형태로 포장하는것이 바람직합니다.

PlayRound playRounds = new PlayRound(Console.readLine());
public class PlayRound {  
    private static final String NUMBER_PATTERN = "[0-9]+";  
    private static final String EXCEPTION_MESSAGE = "유효하지않은 입력입니다.";  
  
    private final String value;  
  
    public PlayRound(String value) {  
        validateInputRange(value);  
        this.value = value;  
    }  
  
    public Integer extractPlayRound() {  
        return Integer.valueOf(this.value);  
    }  
  
    private void validateInputRange(final String playRounds) {  
        if (playRounds.equals("0") || !playRounds.matches(NUMBER_PATTERN)) {  
            throw new IllegalArgumentException(EXCEPTION_MESSAGE);  
        }  
    }  
}

규칙 4. 한 줄에 점을 하나만 찍는다.

디미터(Demeter)의 법칙 “친구하고만 대화하라” 는 코드에서 메서드 연쇄를 피하고, 한 줄에 하나의 작업 또는 호출만 포함하도록 권장하고 있습니다.
만약 코드에서 연쇄가 발생한다면, 다른 객체에 지나치게 관여하게 되고, 이는 캡슐화가 깨질 가능성이 생기기 때문에 지양해야합니다. (강한 결합도 형성)

물론 디미터 법칙은 Fluent Interface를 만들때는 적용되지 않으며, Builder 패턴과 같은 메서드 체인 패턴을 구현하는 경우는 적용되지 않습니다.
이 방법 역시 코드 가독성과 디버깅 및 유지 보수 향상을 기대할 수 있습니다.

규칙 5. 줄여쓰지 않는다.(축약 금지)

코드를 작성할 때 명확하고 의미 있는 변수, 메서드, 클래스 및 주석을 사용한다면 코드의 가독성은 향상되고 유지 보수 역시 수월하게 진행할 수 있습니다.
의미 있는 이름이란 간결하면서도 명확해야하며 이름을 통해 역할을 유추할 수 있어야합니다.

  1. 변수 및 메서드 이름: 변수 및 메서드의 이름은 간결하면서도 명확해야 합니다. 약어 또는 축약어를 사용하지 말고 이름을 충분히 설명적으로 지정하세요.

  2. 클래스 이름: 클래스의 이름은 해당 클래스가 어떤 역할을 하는지 명확하게 나타내야 합니다. 클래스 이름을 축약하지 말고 명확한 용어를 사용하세요.

  3. 주석: 주석은 코드의 이해를 돕는 중요한 도구입니다. 주석을 사용할 때도 축약어를 피하고 명확하고 간결한 언어를 사용하세요.

# 의미 없는 이름과 주석으로 전혀 스윙하고있지않습니다.
int x = 5;
// Loop through arr
for (int i = 0; i < arr.length; i++) {
    // Calc total
    total += arr[i];
}
# 어떤 역할을 하는지, 수행하는 작업은 무엇인지 한 눈에 알아볼 수 있습니다.
int counter = 5;
// Iterate through the array
for (int index = 0; index < array.length; index++) {
    // Calculate the total
    total += array[index];
}

규칙 6. 모든 엔티티는 작게 유지한다.

객체가 작을수록 이해, 유지 관리 및 디버깅이 더 쉽습니다.
이 원칙에서 말하는 엔티티는 클래스, 패키지를 통틀어 업무적 구분을 갖는 단위를 의미하며 작은 엔티티를 나누는 대략적인 기준은 다음과 같습니다.

- 50줄 이하의 클래스
- 10개 이하의 파일을 갖는 패키지

물론 이 기준을 엄격하게 준수하며 프로그램을 짜는건 쉬운일이 아니며, 반드시 지켜야할 규정이라기 보단 지향해야하는 기준으로 두는게 바람직합니다.

중요한 것은 각 엔티티가 명확하고 구체적인 목적이 있어야하고 이와 동시에 단일 책임 원칙을 준수해야한다는 점으로 만약 엔티티가 기준 이상으로 지나치게 비대해진다면 리팩터링을 고민해야할 시점으로 여길 수 있습니다.

규칙 7. 3개 이상의 인스턴스 변수를 가진 클래스를 쓰지 않는다.

클래스의 인스턴스 변수 제한에 관한 지침으로 3개 이상(혹은 2개 이상)의 원시 타입 또는 컬렉션과 같은 형태의 변수를 의미하고 인스턴스 변수는 곧 클래스의 상태와 연결됩니다. 이 상태의 종류가 많다는 것은 클래스가 여러 종류의 정체성을 가지고 설계 되었을 가능성을 내포하고 있기 때문에 가능한 인스턴스 변수 수를 제한하여 도메인적으로 상태를 관리하길 권장합니다.

이 원칙을 준수하여 클래스 구조를 설계하게 될 경우 높은 응집도를 유지할 수 있게 됩니다.

규칙 8. 일급 컬렉션을 사용한다.

일급 컬렉션은 프로그래밍 언어에서 컬렉션(배열, 맵 …)을 일급 시민으로 취급하는 것으로 형태는 다음과 같이 Collection을 Wrapping하면서 Collection 외 다른 멤버 변수가 없는 상태를 가지게 됩니다.

public class OrderItem {  
    private final Map<String,Integer> item;  
  
    private OrderItem(Map<String,Integer> data) {  
        this.item = data;  
    }  
  
    public static OrderItem of(String item) {  
        validFormat(item);  
        Map<String, Integer> parseData = Parser.parseString(item);  
        validateOrderData(parseData);  
        return new OrderItem(parseData);  
    }  
  
    public int getCount(String itemName) {  
        return item.getOrDefault(itemName, ZERO);  
    }  
  
    public Integer totalPrice() {  
        return item.entrySet().stream()  
                .mapToInt(entry -> Menu.getMenuByName(entry.getKey()).getPrice() * entry.getValue())  
                .sum();  
    }  
}

언뜻 보기에 사용 이유에 대한 의문을 품을 수 있지만 해당 클래스 내부의 메서드를 외부에서 호출 할 경우 다음과 같이 호출할 수 있습니다.

orderItem.getCount();
orderItem.totalPrice();

이는 본래 해쉬 맵의 기능을 랩핑한 것으로도 볼 수 있는데, 이를 통해 해 당 컬렉션(여기선 Map)에 OrderItem 이라는 이름이 생긴것과 동시에
검증이 이루어질 수 있게 되었고 메서드 역시 명확한 의도를 전달할 수 있는 하나의 단위가 되는것과 동시에 사용하지 않는 불필요한 기본 메서드를 제한하는 역할까지 함께 수행합니다.

규칙 9. 게터/세터/프로퍼티를 쓰지 않는다.

개인적인 생각으로 객체를 객체스럽게 사용하고자 할 때 가장 첫 번째로 지켜야 할 항목 중 하나라고 생각합니다.
객체는 그 자체로 의미있고 유효한 상태이므로, 외부에서 행위가 이루어지는것이 아닌 객체에 메시지를 전달하는 것으로 행동이 이루어져야 한다는 것이 주된 의미입니다.


public class Name {
	private String name;
	
	public String getName(){
		return this.name;
	}
	public void setName(String name){
		this.name = name;
	}
}

위와 같이 이름을 관리하는 클래스가 존재할 때, getter 혹은 setter를 통해 객체의 값을 반환하거나 변경하게 된다면 외부 접근을 막기 위해 private 접근 제한자를 사용한 것이 무의미 해지게 됩니다.
만약 이름을 통해 해야할 작업이 있다면, Name 클래스 내부에 관련 메서드를 추가하여 객체 자체에서 해결할 수 있도록 유도하고, 웹 개발과 같이 도메인간 상호 작용이 활발할 경우 Mapper, DTO 같은 형태로 값을 전달하여, 원본에 대한 불변성을 보장해주는것이 좋습니다.


public class Name {
	private final String name;
	
	public String someName(String name){
		...
	}
}