🎯 목표 설정
1주차 과제에서의 목표는 기능 구현뿐만 아니라, 코드의 가독성과 유지보수를 고려한 효율적인 리팩토링을 실천하는 것이었다. 또한, 테스트 코드 작성 경험이 많지 않아서, 이번 과제에서는 테스트 코드에 중점을 두고 진행했다.
🔽 진행 계획
- 기능 구현 목록 정리
- 테스트 코드 작성
- 클린 코드 및 SOLID 원칙을 고려한 코드 리팩토링
- 배운 점 정리
💻 진행 과정
▶ 기능 구현 목록 정리
기능 구현 목록을 꼼꼼히 정리하기 위해 문서를 세심하게 읽으며 준비하는 데 하루를 투자했다.
▶ 테스트 코드 작성
테스트 케이스 작성에 많은 시간을 투자했고, 총 34개의 테스트 케이스를 작성했다.
class ApplicationTest extends NsTest {
@Test
void 공백_입력() {
assertSimpleTest(() -> {
run(" ");
assertThat(output()).contains("결과 : 0");
});
}
...
@Test
void 예외_커스텀_구분자_잘못된_위치() {
assertSimpleTest(() ->
assertThatThrownBy(() -> runException("//;\\n;1;2;3"))
.isInstanceOf(IllegalArgumentException.class)
);
}
@Override
public void runMain() {
Application.main(new String[]{});
}
}
▶ 기능 구현
처음에는 모든 로직을 Application 클래스에 몰아넣고, 기능 구현에만 집중했다.
🔨 기술 고도화 및 리팩토링
▶ 객체 지향적 접근
Application 클래스가 모든 로직을 포함하고 있어, 여러 객체지향 원칙을 위반하는 문제가 있었다.
- 단일 책임 원칙(SRP) 위반
- 재사용성과 확장성 저하
- 유지 보수성 저하
- 테스트 용이성 감소
이러한 문제를 해결하기 위해 계산 로직을 여러 객체로 분리하여 각 객체가 명확한 책임을 가지도록 설계했고, 이를 통해 유지 보수성과 코드의 가독성을 크게 향상했다.
- Application: 애플리케이션의 실행 로직을 담당하는 클래스, 입력을 받아 계산하고 결과를 출력하는 흐름을 정의한다.
- Calculator: 계산 로직을 담당하는 클래스, 여러 객체를 호출해 입력값을 파싱하고 숫자의 합을 계산하여 반환한다.
- Validator: 입력값의 유효성을 검증하는 클래스, 입력값이 비었거나 음수인지, 숫자인지 등을 확인한다.
- Delimiter: 구분자 파싱 로직을 처리하는 클래스, 기본 구분자(쉼표, 콜론)와 커스텀 구분자를 처리하여 문자열을 분리한다.
- Converter: 문자열 배열을 정수 리스트로 변환하는 클래스, 각 문자열을 정수로 변환하여 숫자 리스트를 생성한다.
- Adder: 숫자 리스트의 합을 계산하는 클래스, 숫자들이 담긴 리스트를 받아서 그 합계를 계산한다.
- Input: 입력을 담당하는 클래스, 사용자로부터 입력을 받는다.
- Output: 출력을 담당하는 클래스, 결과를 출력한다.
▶ 이스케이프 처리 로직
🔽 변경 전
// 파이프(|) 문자 처리
if ("|".equals(delimiter)) {
delimiter = "\\|";
}
이전에는 특정 구분자인 파이프(|)만 고려한 이스케이프 처리 로직을 사용했다. if 문으로 파이프 문자를 구분해 이스케이프 처리했지만, 이후 다른 특수 문자들을 처리하려니 모든 특수 문자를 각각 if 문으로 처리하는 것이 비효율적이라는 문제점이 있었다.
🔽 변경 후
/**
* 특수 문자 이스케이프 처리
*/
private String escapeCharacters(String delimiter) {
return delimiter.replaceAll("([\\Q^$|(){}[]*+?\\\\.\\E])", "\\\\$1");
}
변경된 로직은 정규식을 활용하여 모든 특수 문자를 이스케이프 처리한다. 이를 통해 새로운 특수 문자가 추가되더라도 별도의 if 문을 추가할 필요 없이 처리할 수 있게 되어 코드의 간결성과 확장성이 크게 향상되었다.
▶ Java의 split 함수
🔽 변경 전
/**
* 기본 구분자(쉼표, 콜론) 처리
*/
public String[] defaultDelimiter(String input) {
String[] commaSplit = input.split(",");
List<String> result = new ArrayList<>();
for (String part : commaSplit) {
String[] colonSplit = part.split(":");
result.addAll(Arrays.asList(colonSplit));
}
return result.toArray(new String[0]);
}
기존에는 쉼표(,)로 문자열을 먼저 나눈 후, 각 부분을 다시 콜론(:)으로 나누는 방식이었다. 이 방식은 split()을 두 번 호출하면서 불필요한 리스트 생성과 반복문을 사용하게 되어 코드가 복잡했다. 또한, 구분자 분리 작업이 두 단계에 걸쳐 이루어지다 보니 가독성이 떨어졌고, 성능 면에서도 비효율적이었다.
🔽 변경 후
/**
* 기본 구분자(쉼표, 콜론)로 분리
*/
public String[] defaultDelimiter(String input) {
return input.split("[,:]");
개선된 코드에서는 쉼표(,)와 콜론(:)을 하나의 정규식 패턴 [,:]으로 통합하여, split() 함수가 한 번에 두 구분자를 기준으로 문자열을 나눌 수 있도록 만들었다. 이를 통해 코드가 단순화되었고, 불필요한 ArrayList와 반복문이 제거되면서 가독성이 크게 향상되었다. 또한, 성능 면에서도 중복된 작업을 없애 효율적으로 문자열을 분리할 수 있게 개선되었다.
🔍 클린 코드 원칙 준수 점검
클린 코드 원칙 정리는 여기를 참고하면 좋을 것 같다.
▶ 한 단계의 들여쓰기
- Application: 모든 객체의 실행 흐름을 담당하며 한 단계의 들여쓰기를 유지했다.
- Calculator: 한 단계의 들여쓰기를 유지했다. 다른 객체에 역할을 위임해 들여쓰기 단계가 더 깊어지지 않았다.
- Validator: 간단한 조건문으로 한 단계의 들여쓰기를 유지했다.
- Delimiter: 구분자 처리 로직에서 한 단계의 들여쓰기를 유지했다.
- Converter: try-catch 블록을 포함한 들여쓰기도 한 단계로 유지했다.
- Adder: 합계를 계산하는 루프에서 한 단계의 들여쓰기를 유지했다.
- Input: 사용자 입력을 받아오는 메서드에서 한 단계의 들여쓰기를 유지했다.
- Output: 결과를 출력하는 메서드에서 한 단계의 들여쓰기를 유지했다.
▶ else 사용 제한
- Application: 여러 객체를 순차적으로 호출하는 구조에서 else 없이 명확하게 흐름을 유지했다.
- Calculator: 조건문에서 return을 사용하여 else 없이 early return 패턴을 잘 적용했다.
- Validator: 조건문에서 return을 사용하여 else 없이 early return 패턴을 잘 적용했다.
- Delimiter: 조건문에서 return을 사용하여 else 없이 early return 패턴을 잘 적용했다.
- Converter: else문이 필요하지 않았다.
- Adder: else문이 필요하지 않았다.
- Input: else문이 필요하지 않았다.
- Output: else문이 필요하지 않았다.
▶ 메서드 인자 수 제한
- Application: main 메서드는 String[] 인자를 받으므로 문제가 없다.
- Calculator: calculate 메서드는 단일 인자를 받고 있으며, 인자 수 제한 규칙을 잘 지키고 있다.
- Validator: 모든 메서드가 단일 인자를 받고 있으며, 인자 수 제한 규칙을 잘 지키고 있다.
- Delimiter: 구분자를 처리하는 메서드는 단일 인자를 받고 있으며, 인자 수 제한 규칙을 잘 지키고 있다.
- Converter: convertToNumbers 메서드는 하나의 배열 인자를 받고 있으며, 인자 수 제한 규칙을 잘 지키고 있다.
- Adder: sum 메서드는 하나의 List<Integer> 인자를 받고 있으며, 인자 수 제한 규칙을 잘 지키고 있다.
- Input:인자가 필요하지 않았다.
- Output: printResult 메서드는 단일 인자를 받고 있으며, 인자 수 제한 규칙을 잘 지키고 있다.
▶ getter/setter 사용 제한
모든 클래스에서 getter와 setter를 사용하지 않았으며, 객체지향 원칙에 따라 상태를 직접 노출하지 않고 필요한 기능만을 제공하도록 설계했다.
▶ 단일 책임 원칙 (SRP)
- Application: 각 객체를 실행하고 그 흐름을 관리하는 책임을 맡고 있어 SRP를 준수하고 있다.
- Calculator: 계산 로직을 분산해 SRP를 준수하고 있다. 입력 파싱, 변환, 합 계산, 예외 처리가 각각 다른 객체로 위임된다.
- Validator: 입력 유효성 검사에 대한 책임만을 담당하며 SRP를 준수하고 있다.
- Delimiter: 구분자 처리에 대한 책임만을 담당하며 SRP를 준수하고 있다.
- Converter: 문자열을 정수로 변환에 대한 책임만을 담당하며 SRP를 준수하고 있다.
- Adder: 합계 계산에 대한 책임만을 담당하며 SRP를 준수하고 있다.
- Input: 사용자로부터 입력을 받는 기능에 대한 책임만을 담당하며 SRP를 준수하고 있다.
- Output: 결과를 출력하는 기능에 대한 책임만을 담당하며 SRP를 준수하고 있다.
✏️ 배운 점 정리
▶ 정규 표현식
정규 표현식은 패턴 매칭을 통해 문자열을 효율적으로 처리할 수 있는 도구이다. 이번 프로젝트에서 구분자를 처리하는 부분에서 정규 표현식을 사용하여 쉼표(,)와 콜론(:)을 하나의 패턴으로 처리하고, 특수 문자를 이스케이프하는 로직에도 정규 표현식을 활용했다.
🔽 문자 클래스
문자 클래스는 특정 범위의 문자들이 올 수 있는 자리를 지정한다.
- [abc]: 'a', 'b', 'c' 중 하나와 매칭된다.
- [a-z]: 소문자 알파벳 중 하나와 매칭된다.
- [^a-z]: 소문자 알파벳을 제외한 모든 문자와 매칭된다.
🔽 메타 문자
메타 문자는 특별한 의미를 가진 문자이다.
- .: 임의의 한 문자와 매칭된다. (줄 바꿈 문자 제외)
- ^: 문자열의 시작을 의미한다.
- $: 문자열의 끝을 의미한다.
- \d: 숫자와 매칭된다. ([0-9]와 동일)
- \w: 알파벳 대소문자, 숫자, 언더바(_)와 매칭된다. ([a-zA-Z0-9_]와 동일)
- \s: 공백 문자와 매칭된다. ([ \t\n\x0B\f\r]와 동일)
🔽 수량자
수량자는 특정 패턴이 반복되는 횟수를 지정한다.
- *: 0번 이상 반복 (예시: a*는 'a'가 0번 이상 나오는 문자열에 매칭)
- +: 1번 이상 반복 (예시: a+는 'a'가 1번 이상 나오는 문자열에 매칭)
- ?: 0번 또는 1번 반복 (예시: a?는 'a'가 0번 또는 1번 나오는 문자열에 매칭)
- {n}: 정확히 n번 반복 (예시: a{3}은 'aaa'와 매칭)
- {n,}: 최소 n번 반복 (예시: a{2,}는 'aa', 'aaa' 등과 매칭)
- {n,m}: 최소 n번, 최대 m번 반복 (예시: a{2,4}는 'aa', 'aaa', 'aaaa'와 매칭)
🔽 그룹핑과 캡처
그룹을 사용하면 정규 표현식 내에서 특정 부분을 묶어 처리할 수 있다.
- (): 그룹을 형성한다. 이 그룹은 캡처되어 나중에 재사용되거나 참조될 수 있다.
- |: OR 연산을 나타낸다. (예시: a|b는 'a' 또는 'b'에 매칭)
▶ 다양한 자바의 메서드
🔽 subString()
substring() 메서드는 문자열의 특정 구간을 추출할 때 사용된다. 시작 인덱스와 종료 인덱스를 제공해 원하는 부분을 손쉽게 자를 수 있다. 구분자를 추출하거나 특정 부분의 값을 가져올 때 유용하다.
- 형태:
- substring(int beginIndex): 문자열에서 지정된 시작 인덱스부터 끝까지 부분 문자열을 반환한다.
- substring(int beginIndex, int endIndex): 문자열에서 지정된 시작 인덱스부터 끝 인덱스 전까지 부분 문자열을 반환한다.
- 활용 예시: input.substring(start, end)
- 주의: 잘못된 인덱스를 사용하면 IndexOutOfBoundsException이 발생한다.
🔽 split()
split() 메서드는 문자열을 특정 구분자 기준으로 나눌 때 사용된다. 특히, 정규 표현식과 함께 사용하면 복수의 구분자를 처리할 수 있다.
- 형태:
- split(String regex): 지정된 정규 표현식을 기준으로 문자열을 분리하여 배열로 반환한다.
- split(String regex, int limit): 지정된 정규 표현식을 기준으로 문자열을 분리하되, 최대 분리 개수를 제한하여 배열로 반환한다.
- 활용 예시: input.split("[,:]")
🔽 indexOf()
indexOf() 메서드는 문자열 내에서 특정 문자 또는 문자열이 처음으로 등장하는 위치를 반환한다. 문자열에서 구분자의 위치를 찾아내거나 특정 패턴을 탐색할 때 유용하다.
- 형태:
- indexOf(String str): 문자열 내에서 지정된 문자열의 첫 번째 위치를 반환한다.
- indexOf(int ch): 문자열 내에서 지정된 문자의 첫 번째 위치를 반환한다.
- indexOf(int ch, int fromIndex): 지정된 인덱스부터 검색하여 문자열 내에서 지정된 문자의 첫 번째 위치를 반환한다.
- indexOf(String str, int fromIndex): 지정된 인덱스부터 검색하여 문자열 내에서 지정된 문자열의 첫 번째 위치를 반환한다.
- 활용 예시: input.indexOf("//") + 2
- 주의: 찾는 문자열이 없을 경우 -1을 반환하므로, 이 값을 처리하는 로직이 필요하다.
🔽 lastIndexOf()
lastIndexOf() 메서드는 문자열 내에서 특정 문자나 문자열이 마지막으로 등장하는 위치를 반환한다. 구분자가 여러 번 등장할 때, 마지막 구분자를 기준으로 나누고 싶을 때 사용한다.
- 형태:
- lastIndexOf(String str): 문자열 내에서 지정된 문자열의 마지막 위치를 반환한다.
- lastIndexOf(int ch): 문자열 내에서 지정된 문자의 마지막 위치를 반환한다.
- lastIndexOf(int ch, int fromIndex): 지정된 인덱스부터 역방향으로 검색하여 문자열 내에서 지정된 문자의 마지막 위치를 반환한다.
- lastIndexOf(String str, int fromIndex): 지정된 인덱스부터 역방향으로 검색하여 문자열 내에서 지정된 문자열의 마지막 위치를 반환한다.
- 활용 예시: input.lastIndexOf("/")
🔽 isEmpty()
isEmpty() 메서드는 문자열의 길이가 0일 때 true를 반환한다.
- 형태:
- isEmpty(): 문자열이 비어있는지 여부를 반환한다. (문자열 길이가 0일 경우 true 반환)
- 활용 예시: input.isEmpty()
- 주의: 공백이 포함된 문자열에 대해서는 false를 반환하므로, 단순히 비어 있는지만 확인할 때 사용한다.
🔽 isBlank()
isBlank() 메서드는 문자열이 공백만으로 이루어졌는지 확인하는 메서드로, 공백, 탭, 엔터 등의 공백 문자를 모두 처리한다.
- 형태:
- isBlank(): 문자열이 비어있거나, 공백 문자로만 구성되어 있는지 여부를 반환한다.
- 활용 예시: input.isBlank()
- 차이점: isEmpty()는 길이가 0인 경우만 true를 반환하는 반면, isBlank()는 공백 문자로만 이루어진 문자열도 true를 반환한다.
▶ 객체지향
🔽 객체지향 프로그래밍의 4대 핵심 원칙
- 캡슐화: 캡슐화는 객체 내부 상태를 외부에서 직접 접근하지 못하게 하고, 객체의 메서드를 통해서만 데이터를 처리하게 만드는 원칙이다. 이를 통해 데이터 무결성을 보호하고, 외부에서 객체의 내부 동작을 임의로 변경하지 못하도록 한다.
- 상속: 상속은 상위 클래스의 속성이나 메서드를 하위 클래스가 물려받아 재사용하는 개념이다. 이를 통해 중복된 코드를 줄이고, 공통된 기능을 중앙에서 관리할 수 있어 유지보수가 용이해진다.
- 다형성: 다형성은 하나의 인터페이스나 메서드가 여러 다른 형태로 구현될 수 있는 성질을 의미한다. 이를 통해 코드의 유연성을 높이고, 상황에 따라 다른 동작을 수행할 수 있다.
- 추상화: 추상화는 객체의 중요한 속성과 동작만을 드러내어 복잡성을 줄이는 원칙이다. 불필요한 세부 구현을 감추고, 필요한 기능만을 외부에 제공할 수 있다.
🔽 객체지향의 주요 구성 요소
- 객체: 객체는 속성과 메서드를 가지며, 프로그램에서 실제로 동작하는 단위이다. 객체는 클래스의 인스턴스로, 데이터를 담고 있으며 기능을 수행하는 주체이다.
- 클래스: 클래스는 객체를 생성하기 위한 설계도이다. 객체가 가져야 할 속성과 메서드를 정의하며, 이 구조에 따라 객체가 생성된다.
- 메서드: 메서드는 객체가 수행할 동작을 정의하는 함수다. 객체는 메서드를 통해 데이터를 처리하고, 다른 객체와 상호작용한다.
- 속성: 속성은 객체의 상태를 나타내는 변수이다. 객체는 각 속성에 값을 저장하고, 이를 활용해 동작을 수행한다.
🔽 객체지향의 장점
- 각 클래스가 하나의 역할에 집중해 코드가 명확해진다.
- 클래스 간의 결합도가 낮아져 확장성과 유연성이 높아진다.
- 코드 수정 시, 다른 부분에 미치는 영향이 적어 유지보수가 용이해진다.
▶ 테스트 코드
테스트 코드는 프로그램의 안정성과 품질을 보장하는 데 중요한 역할을 한다. 코드가 의도한 대로 동작하는지 확인하고, 새로운 기능 추가 시 기존 기능이 정상적으로 유지되는지를 검증한다.
- 코드 품질 향상: 테스트 코드는 서비스의 품질을 향상할 수 있다. 이를 통해 발생할 수 있는 버그를 사전에 발견하고, 이를 방지할 수 있다. 특히, 개발자는 자신이 작성한 코드가 신뢰할 수 있는 코드인지 확인할 수 있어, 안정적인 개발 환경을 조성한다.
- 문서화: 테스트 코드는 코드의 예상 동작을 명확하게 보여주는 문서의 역할을 한다. 다른 개발자가 프로젝트에 참여했을 때, 테스트 코드를 통해 기능의 동작 방식을 빠르게 이해할 수 있어, 팀 간 커뮤니케이션을 원활하게 해 준다.
- 리팩토링: 기능 확장이나 코드 리팩토링이 필요할 때, 기존 테스트 코드는 안정성을 유지하는 데 필수적이다. 기능 변경 시 기존 기능이 정상적으로 동작하는지 테스트 코드로 확인함으로써, 리팩토링 작업을 안전하고 효율적으로 진행할 수 있다. 이는 유지보수 비용을 크게 줄이는 데 기여한다.
💡 고찰
▶ 객체지향
이전에는 객체 지향이라는 개념을 정확히 이해하지 못한 채, 단순히 인터페이스를 작성하고 객체를 설계하는 방식만을 따랐던 것 같다. 그 결과, 객체 지향의 핵심을 놓친 채로 코딩을 해왔던 것 같다. 하지만 이번 프로젝트를 통해 객체 지향이 무엇인지 명확히 배우고 실천할 수 있었다.
처음에 절차지향 방식으로 Application 클래스에 모든 로직을 작성한 후, 객체지향 방식으로 리팩토링을 진행했다. Calculator 클래스에 Delimiter, Converter, Adder, Validator 클래스를 의존성 주입하여 각각의 책임을 분리함으로써 단일 책임 원칙(SRP)을 준수했다. 이로 인해 유지보수가 훨씬 편리해졌다.
책임이 분리된 덕분에 특정 부분을 수정했을 때 다른 부분에 미치는 영향이 줄어들었다. 예를 들어, Application 클래스에서 입력과 출력을 처리하는 부분을 고정해 놓았기 때문에, 객체 내부의 코드를 리팩토링하더라도 외부 클래스에는 영향을 미치지 않았다.
이번 프로젝트를 통해 객체지향의 유지보수성 향상의 효과를 직접 경험할 수 있었다.
▶ 테스트 코드의 중요성
이전에는 테스트 코드를 작성해 본 경험이 많지 않아 그 중요성을 실감하지 못했다. 하지만 이번 프로젝트를 통해 테스트 코드가 얼마나 중요한지 깨달았다.
처음 기능 구현 목록을 정리하고, 그에 맞춰 필요한 테스트 코드만 작성함으로써 불필요한 개발을 줄일 수 있었다. 덕분에 주어진 요구사항에 집중해 개발을 진행할 수 있었고, 필요 이상의 코드를 작성하지 않게 되었다.
또한, 코드 리팩토링 과정에서도 테스트 코드의 중요성을 실감했다. 기존 기능을 최적화하는 작업을 할 때, 테스트 코드를 통해 리팩토링한 코드가 여전히 정상적으로 작동하는지 검증할 수 있었다.
이번 프로젝트를 통해 테스트 코드가 단순한 검증 도구를 넘어, 효율적인 개발과 유지보수를 위한 필수 요소임을 깨닫게 되었다.
▶ PR 리뷰와 공통 피드백에서 얻은 추가 고찰 (10.22 추가)
- 주석 사용: PR 리뷰와 공통 피드백에서 "무조건적으로 주석을 다는 것이 좋지 않다"는 지적을 받았다. 코드의 의도를 변수와 메서드 이름으로 명확하게 표현할 수 있다면, 주석은 불필요하다. 오히려 주석 없이도 이해할 수 있도록 이름을 통해 의도를 드러내는 것이 더 바람직하며, 꼭 필요한 경우에만 주석을 사용하는 습관을 들여야 한다. 1주차 코드에서는 모든 클래스와 메서드에 주석을 달았지만, 2주차부터는 클래스와 메서드 이름을 통해 의도를 더 명확하게 표현하도록 개선할 계획이다.
- 객체지향적 설계: 블로그를 작성할 당시만 해도 내가 작성한 코드가 객체지향적으로 잘 설계되었다고 생각했지만, PR 리뷰를 통해 그 판단이 다소 섣부른 것이었음을 알게 되었다. 처음에는 Calculator 클래스에 모든 책임을 부여하는 것이 유지보수에 유리하다고 판단했지만, Validator와 같은 역할을 Calculator와 분리하는 것이 더 나은 구조일 수 있다는 생각이 들었다. 앞으로는 객체지향적 설계에 대해 더 깊이 고민하고, 각 클래스가 책임을 명확히 분리하도록 설계하는 연습이 필요할 것 같다.
- 매직 넘버: 매직 넘버의 개념도 이번에 새롭게 알게 되었다. 코드에서 하드코딩된 숫자나 값들은 상수로 처리하는 것이 더 좋다는 점을 배웠으며, 1주차 코드에서 이를 적절히 처리하지 못한 부분이 많았다. 앞으로는 매직 넘버를 사용하지 않고, 상수로 정의하여 코드의 가독성과 유지보수성을 높이는 방향으로 개선할 생각이다.
📍 참고 자료
'우아한테크코스' 카테고리의 다른 글
[우아한테크코스] Java Enum 활용기 (4) | 2024.11.05 |
---|---|
[우아한테크코스] 프리코스 3주차 기록 (8) | 2024.11.05 |
[우아한테크코스] 프리코스 2주차 기록 (2) | 2024.10.29 |
[우아한테크코스] 우테코 코드 스타일 포매터 적용 (0) | 2024.10.15 |
[우아한테크코스] 프리코스 시작 전, 목표 설정 및 클린 코드 원칙 정리 (0) | 2024.10.07 |