[우아한테크코스 7기 프리코스] 4주차 회고록

프리코스가 끝이 났다. 길고도 짧은 여정이었다. 배운 부분도 많고, 아쉬운 부분도 많다. 이 시간들을 잊지 말고 잘 기억해야지 싶다.

 

1. 3주차 피드백 정리

공통 피드백

  • 함수의 길이는 15 라인을 넘지 말자. 🔼
  • 비즈니스 로직과 UI 로직은 분리하자. 데이터가 필요하다면 getter 메서드를 통해 View로 전달하자. 🔼
  • 연관성이 있는 상수는 static final 대신 enum을 활용하자. ✅
  • final 키워드를 사용해 값의 변경을 막자. ✅
  • 객체의 상태 접근을 제한(캡슐화) 하자. 🔼
  • 객체는 객체답게 사용하자. 객체가 자신의 데이터를 스스로 처리하도록 메시지를 던지게 하자. 🔼
  • 필드의 수를 최소화하자. 🔼
  • 성공하는 케이스 뿐만이 아닌 예외 케이스도 테스트하자. ✅
  • 파라미터화된 테스트를 구현하자. ✅
  • 테스트 코드는 구현 코드에서 분리하자. 테스트를 위해 접근 제어자를 바꾸거나, 테스트 코드에서만 사용되는 메서드는 유의하자. ✅
  • 메서드 시그니처를 수정하여 테스트하기 좋은 메서드를 만들자. 🔼
  • private 함수를 테스트 하고싶다면 클래스 분리를 고려하자. ✅

 

코드 리뷰

  • 깃허브 내에서 탭 문자를 사용한 것처럼 보여지는 원인 찾기 🔼
  • 동일한 구조가 여러 곳에서 중복될 경우, 함수형 인터페이스 사용해보기
  • 객체 간의 결합도를 낮추기(메시지 던지기)
  • allMatch()보다 anyMatch() 사용하기 
  • findAny()보다 findFirst() 사용하기 
  • orElse(null)보다 orElse(NONE) 사용하기
  • 에러 메시지 상수화하기 
  • 매직넘버 상수화하기 🔼
  • 패키지 분리 생각해보기 ✅
  • 가변인자 사용해보기 ✅
  • String.format에 의존적이지 않기 위한 방향성 생각해보기 🔼
  • 출력 메시지를 무조건 상수화하는 것이 맞는지 다시 생각해보기 🔼
  • 유연하게 설계 및 구현하기(ex - 로또에서 2등의 숫자 일치 개수 가변적으로 다룰 경우, 다른 등수들이 true 값을 가지게 될 경우)
  • 상수명 네이밍 더 신경쓰기(공통적으로 사용되는 의미로) 🔼
  • 재사용되지 않는 객체를 생성하는 것이 맞는가 의구심 가져보기 ✅
  • try-catch문은 Controller에서 행하기(InputView의 역할 분리하기) ✅
  • OutputView의 역할 분리하기 🔼
  • 테스트코드의 컨벤션 만들기 🔼
  • @ParameterizedTest 적극 활용하기 ✅

 

 3주차 때는 11명의 리뷰어분들과 118개의 Conversation을 주고 받았다. 3주차 목표치는 15분에게 코드 리뷰를 받는 것이었는데, 생각보다 쉽지 않았다. 하지만 모두 양질의 리뷰를 남겨주셔서 부족한 점을 깨달을 수 있었다.

 

 


 

2. 4주차 목표

  • TDD 도입하기
  • 내가 구현한 부분도 한번 더 의구심을 가지고 살펴보기
  • 더 깊게 고민하기
  • 더 나은 방향성, 내가 성장할 수 있는 방향성에 대해 끊임없이 고민해보기

 


 

3. 고민한 과정

3-1. FileInputStream() vs FileReader(), BufferedReader()

요구사항을 구현하기에 앞서 md 파일로부터 데이터를 읽어오는 과정을 어떻게 구현할 것인가에 대한 고민이 필요했다. 그 중 FileInputStream()과 FileReader(), BufferedReader()를 사용한 코드를 각각 비교해보기로 결정했다.

 

  • FileInputStream() + byte 배열
try {
	FileInputStream products = new FileInputStream("src/main/resources/products.md");
	FileInputStream promotions = new FileInputStream("src/main/resources/promotions.md");

	int data1 = 0;
	byte[] buf1 = new byte[products.available()];

	while ((data1 = products.read(buf1, 0, buf1.length)) != -1) {
		System.out.println(new String(buf1, 0, data1));
	}

	int data2 = 0;
	byte[] buf2 = new byte[promotions.available()];

	while ((data2 = promotions.read(buf2, 0, buf2.length)) != -1) {
		System.out.println(new String(buf2, 0, data2));
	}
} catch (Exception e) {
		System.out.println(e.getMessage());
}

 

장점

  • FileInputStream()은 available()로 파일 크기를 가져와 배열 크기를 동적으로 설정하기 때문에 파일 크기가 작을 때는 효율적이다.

단점

  • available()로 파일 전체 크기만큼의 배열(byte[])을 생성하기 때문에 파일이 큰 경우 메모리를 많이 사용하게 될 수 있다.
  • 너무 큰 파일(수백 MB 이상)을 처리하면 배열 크기와 관련된 문제(메모리 부족 또는 Java 힙 메모리 초과)가 발생할 수 있다.

 

  • FileReader(), BufferedReader()
try {
	FileReader fr1 = new FileReader("src/main/resources/products.md");
	BufferedReader br1 = new BufferedReader(fr1);

	String line = "";

	while ((line = br1.readLine()) != null) {
		System.out.println(line);
	}

	System.out.println();

	FileReader fr2 = new FileReader("src/main/resources/promotions.md");
	BufferedReader br2 = new BufferedReader(fr2);

	String line2 = "";

	while ((line2 = br2.readLine()) != null) {
		System.out.println(line2);
	}
} catch (Exception e) {
		System.out.println(e.getMessage());
}

 

장점

  • BufferedReader()는 내부적으로 버퍼(기본값 8192 바이트)를 사용하여 데이터를 읽기 때문에 메모리 사용량이 상대적으로 적다. 파일 크기와 무관하게 효율적이다.
  • 텍스트 파일의 데이터를 처리할 경우 적합하고, 줄 단위로 데이터를 처리할 수 있다.

단점

  • 바이너리 파일(이미지, 오디오 등)을 읽는 데 적합하지 않다.
  • 줄 단위가 아닌 특정 바이트 단위로 데이터를 처리해야 하는 경우에는 부적합할 수 있다.

 

실제 두 코드의 실행 속도는 약 200ms로 큰 차이가 없었다. 따라서 FileReader()와 BufferedReader()를 사용하는 것이 텍스트 파일을 읽는데 적합하다고 판단하였다.

 

3-2. TDD 도입

3주차 공통 피드백에서 "이번 영상은 TDD 방식으로 진행되어, TDD에 관심 있는 분들께 더 많은 도움이 될 것 같은데요. TDD가 처음이라면 미션에서 직접 도전해보는 것도 재미있는 경험이 될 것입니다." 라는 말을 듣고 용기를 내 TDD에 도전했다. 하지만 결과는 처참했다,, 다음 기능을 구현할 때마다 기존 비즈니스 로직이 계속 수정되었고, 테스트코드도 고쳐야 했다. 점점 테스트 코드가 아닌 로직을 수정하는데 급급해졌다.  4주차 미션을 시작하기 직전부터 미션이 어려운데 TDD를 도입할 수 있을까 라는 걱정이 현실이 되었다. 아래는 그나마 TDD를 구현할 수 있었던 파일 읽기 관련 코드이다.

 

  • FileReaderTest
public class FileReaderTest {

	@Test
	@DisplayName("products.md 파일을 읽어온다.")
	void 상품_파일을_읽어온다() {
		File file = new File("src/main/resources/products.md");

		List<String> lines = FileReader.readFile(file);

		assertEquals(17, lines.size());
		assertEquals("name,price,quantity,promotion", lines.get(0));
		assertEquals("콜라,1000,10,탄산2+1", lines.get(1));
		assertEquals("콜라,1000,10,null", lines.get(2));
	}

	@Test
	@DisplayName("promotions.md 파일을 읽어온다.")
	void 프로모션_파일을_읽어온다() {
		File file = new File("src/main/resources/promotions.md");

		List<String> lines = FileReader.readFile(file);

		assertEquals(4, lines.size());
		assertEquals("name,buy,get,start_date,end_date", lines.get(0));
		assertEquals("탄산2+1,2,1,2024-01-01,2024-12-31", lines.get(1));
		assertEquals("MD추천상품,1,1,2024-01-01,2024-12-31", lines.get(2));
	}

	@Test
	@DisplayName("존재하지 않는 파일일 경우 예외가 발생한다.")
	void 존재하지_않는_파일이다() {
		File nonExistingFile = new File("non_existing_file.md");

		assertThatThrownBy(() -> FileReader.readFile(nonExistingFile))
			.isInstanceOf(IllegalStateException.class)
			.hasMessage(NON_EXIST_FILE.getMessage());
	}
}

 

  • FileReader
public class FileReader {
	public static List<String> readFile(File file) {
		List<String> fileLines = new ArrayList<>();
		try (BufferedReader br = new BufferedReader(new java.io.FileReader(file))) {
			String fileLine;
			while ((fileLine = br.readLine()) != null) {
				fileLines.add(fileLine);
			}
		} catch (IOException e) {
			throw new IllegalArgumentException(NON_EXIST_FILE.getMessage());
		}
		return fileLines;
	}
}

 

TDD를 구현하고자 하는 목표를 못 이루었기에 스터디원분께서 공유해주신 TDD 팁을 참고해 미션을 재구현하면서 반드시 적용해보고 싶다. 처음부터 너무 복잡하게 설계를 하기보다는 주요 기능에 맞는 테스트코드를 독립적으로 구현하는 것이 중요한 것 같다. TDD를 왜 해야 하는지 구현하면서 뼈저리게 느껴보고 싶다.🥲

 

3-3. 4주차 미션 예제 테스트 3/4 원인

4주차 미션 제출 당일 마지막 커밋과 리팩토링을 하고, 로컬 내 테스트가 정상적으로 실행되는 것을 확인했다. 이후 리포지토리 링크를 제출해 지원폼 내 테스트를 실행했는데 3/4가 떴다,, 모든 테스트가 정상적으로 통과하지 않았다는 의미이다. 청천벽력 같았다. 하지만 마감 시간이 다 되어 원인을 찾지 못한 채 4주차 미션을 종료하였다. 이후 스터디원분이 관련 블로그를 남겨주셔서 원인을 파악할 수 있었다.

"두 파일 모두 내용의 형식을 유지한다면 값은 수정할 수 있다."라는 요구사항을 잘못 이해한 것이 원인이었다. 제시된 출력 요구사항에서 상품 목록은 18줄이었기에 md 파일에도 "오렌지주스,1800,0,null", "탄산수,1200,0,null"를 추가해 똑같이 맞추려고 했다. 지금 생각해보면 참 바보같은 일이다.

 

  • 수정 전
name,price,quantity,promotion
콜라,1000,10,탄산2+1
콜라,1000,10,null
사이다,1000,8,탄산2+1
사이다,1000,7,null
오렌지주스,1800,9,MD추천상품
탄산수,1200,5,탄산2+1
물,500,10,null
비타민워터,1500,6,null
감자칩,1500,5,반짝할인
감자칩,1500,5,null
초코바,1200,5,MD추천상품
초코바,1200,5,null
에너지바,2000,5,null
정식도시락,6400,8,null
컵라면,1700,1,MD추천상품
컵라면,1700,10,null

 

  • 수정 후
name,price,quantity,promotion
콜라,1000,10,탄산2+1
콜라,1000,10,null
사이다,1000,8,탄산2+1
사이다,1000,7,null
오렌지주스,1800,9,MD추천상품
오렌지주스,1800,0,null /* 추가한 부분 */
탄산수,1200,5,탄산2+1
탄산수,1200,0,null /* 추가한 부분 */
물,500,10,null
비타민워터,1500,6,null
감자칩,1500,5,반짝할인
감자칩,1500,5,null
초코바,1200,5,MD추천상품
초코바,1200,5,null
에너지바,2000,5,null
정식도시락,6400,8,null
컵라면,1700,1,MD추천상품
컵라면,1700,10,null

 

해결법이 작성되어 있는 블로그를 살펴보니 해결 방법은 아래와 같았다.

 public List<Product> loadFileProducts() {
    return new ArrayList<>(load(FILE_PATH));
}

public List<Product> loadProducts() {
    List<Product> products = loadFileProducts();
    List<Product> promotionProducts = products.stream().filter(Product::promotionNotNull).toList();

    promotionProducts.stream()
            .filter(each -> products.stream().noneMatch(all -> isPromotionExistsAndNormalDont(each, all)))
            .map(each -> new Product(each.getName(), each.getPrice(), 0, null))
            .forEach(products::add);

    return List.copyOf(products);
 }
    
private static boolean isPromotionExistsAndNormalDont(Product each, Product all) {
    return all.getName().equals(each.getName()) && !all.promotionNotNull();
}

 

위 코드는 FilePath에 있는 데이터를 Product 인스턴스로 만들어서 초기화 하는 코드이다. promotion 이 있는 재고를 먼저 추출한 뒤, "프로모션이 있지만 일반 상품이 존재하지 않는" 상품을 리스트화 해서 새로운 일반 상품으로 만들어야 하는 것이었다. 원인과 해결 방법을 파악하고 나니 이 부분 또한 많이 아쉬웠다. 해결 방법을 공유해주신 분께 정말 감사하다🥹

 

https://velog.io/@calaf/%ED%94%84%EB%A6%AC%EC%BD%94%EC%8A%A4-%EC%98%88%EC%A0%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8-34-%ED%95%B4%EA%B2%B0%EB%B2%95

 

프리코스 예제 테스트 3/4 해결법

3/4 문제로 시달린 모든 분들을 위해...

velog.io

 

3-4. 깃허브 내에서 탭 문자를 사용한 것처럼 보여지는 원인

코드리뷰에서 지속적으로 탭 문자를 사용하는 것 같다는 피드백을 받았다. 의식적으로 탭 문자를 사용하지 않아도, 인텔리제이 내에서는 일정했던 포맷이 깃허브에만 올라가면 다르게 변해있었다. 4주차가 종료되고 나서야 원인을 발견하였다. 인텔리제이 내 설정에서 'Use tab character'을 비활성화 해야된다는 것이다,, 더 빨리 원인을 파악하려고 노력했다면 하는 아쉬움이 많이 남았다. 호오오옥시나 같은 문제를 겪고 있을 사람을 위해 설정 방법을 남겨본다.

 

  1. 인텔리제이에서 Settings로 들어간다.
  2. Editor > Code Style > Java로 이동한다.
  3. Tab and Indents 섹션에서 'Use tab character' 옵션을 비활성화한다.

 


 

4. 프리코스를 마치며

4주간의 프리코스가 막을 내렸다. 포수타(포비와의 수다 타임)에서 1차 결과까지 앞으로 어떤 걸 할 것인가에 대한 질문이 나왔다. 이에 대한 포비의 답변은 아래와 같다.

 

  • 미션 재풀이를 해보자. 예를 들어 4주차가 너무 어려워서 실패 했었으면 어땠을까? 실패에 대한 원인을 분석해보았으면. 만약 소화해내기 힘들었으면 "일부만 구현해보자."라는 메타인지가 필요한 것은 아닌지. 누누히 강조하는게 동작안하는 아름다운 코드보다, 동작하는 쓰레기 코드가 더 중요하다.
  • 세뇌해라. 1차 합격해서 최종 코테보러 갈거야. 최종 코테 기간 전까지 복습하고 역량을 쌓으라는 마음을 쌓아라.
  • 처음부터 끝까지 전부 구현해보자. 4개의 미션 중 더 끌리고 다시 해봐야지라는 마음이 드는 미션을 더 여유로운 마음을 가지고 해보자. 미션을 많이 구현해본다고 실력이 올라가지는 않는다, 하나의 미션을 여러 번 다양하게 구현해보거나 함께 스터디 만드는 것도 좋다. 더 추천하는 것은 스터디에서 짝 프로그래밍을 해보는 것이다.

 

4주차가 종료되고 테스트케이스를 3/4로 통과하지 못했기에 망연자실했다. 기능 요구사항 중 몇 가지를 구현하지 못했기에 사실 최종 코딩테스트를 준비하는 것이 맞을까 라는 의문이 들었다. 하지만 포비의 말을 듣고 마음을 고쳐먹었다. 내가 구현한 코드의 실패와 결과는 내가 제일 잘 알고있다. 그렇다면 그 코드를 한 번 바꿔봐야 하지 않겠는가? 4주차 미션을 소화해내기 힘들었던 나에게 메타인지가 필요한 것 같다. 프리코스를 시작하기 전 메타인지를 했던 것처럼, 또 다른 프리코스가 시작된 것이다. 프리코스를 하며 자바, 클린 코드와 관련된 서적을 꾸준히 읽는 학습 방법을 얻은 것 같다. 이 부분은 앞으로도, 평생동안 꾸준히 적용하고 싶다.

프리코스는 종료되었지만 스터디는 약 2달 이상 함께 하기로 결정했다. 함께의 가치가 크기에 스터디를 조금 더 잘 활용하고 싶다. 다른 사람들의 견해에 대해 생각해보고, 내가 하고 있는 방향이 맞는지 의구심이 들기에 메타인지가 더 잘 되는 것 같다. 물론 그만큼 배울 수 있는 부분들도 많다. 그래서 감사하다. 나중에 현업에서 스터디원분들을 만나게 된다면 감회가 새로울 것 같다.

 


 

5. 앞으로의 방향

1주차를 마치며 프리코스 커뮤니티 토론하기에 "어떤 색깔을 가진 개발자가 되고 싶은가?"라는 글을 올린 적이 있다.

 

 

우아한테크코스 7기 지원서를 작성하며 나 자신에게 끊임없이 계속해서 던진 질문이였다. "난 어떤 개발자가 되고 싶은걸까? 나의 색깔은 뭘까? 아니 내 색깔은 어떻게 찾는거지?" 늘 그랬듯 구글에 '나만의 색깔을 가진 개발자'를 검색하니 아래 블로그가 제일 먼저 눈에 들어왔다.

 

https://evan-moon.github.io/2021/09/10/developer-direction-of-effort/

 

모티베이션의 원천은 취업이나 이직과 같은 외부 조건이 아니라,
성장이나 흥미와 같은 내재적인 부분에서 비롯되는 것이 훨씬 건강하다.
- 블로그 글 인용 -

 

위 글을 읽는 순간, 한 대 얻어맞은 기분이 들었다. 이제껏 나는 취업이라는 목표를 향해 앞만 보고 달렸던 것 같다.(사실 아직도 많이 부족하다는 생각이 든다) 가끔은 신발끈이 풀리진 않았는지, 밑창이 닳진 않았는지, 옳은 방향으로 가고있는지 나를 점검해보는 시간이 필요했던 것이다. 그저 달리기만 하는 것보다는 명확한 방향으로 가야 한다. 그래야 내가 덜 힘들고, 즐겁게 할 수 있다. 다른 사람들이 다 한다고 해서 하는 것이 아닌 나의 성장이나 흥미를 위해서 프로그래밍을 한다는 것은, 사실 말처럼 쉽지만은 않다. 현실과의 타협도 제법 필요하다. 하지만 진정으로 프로그래밍이 재미있고, 이 길이 맞다는 확신이 든다면 더 재미있게 즐겨야 하지 않겠는가? 한 번뿐인 인생이지 않은가? 이에 대한 다른 지원자분들의 견해가 궁금해 토론해보고 싶었다.

 

 

꽤 많은 분들이 이모지를 남겨주셨지만 애당케도 답변은 한 분만이 남겨주셨다. 나도 알고있다. 어려운 질문이다. 호기롭게 글을 올리긴 했지만, 개발자의 길을 걷겠다고 선택한 나에게도 아마 평생의 숙제이지 않을까 싶다.

 

 

답변을 남겨주신 분의 글도 인상깊었다(개인 정보 보호를 위해 닉네임은 가렸다,,!!)

'최대한 코드를 수정하지 않고 문제를 해결하는 개발자, 무혈입성하고자 하는 개발자'라는 답변을 남겨주셨다. 다른 지원자분의 견해를 알 수 있어서 참 기뻤다. 스터디분들께도 해당 질문을 해보았었는데 어렵다는 답변을 받았었다. 나에게도 참 어려운 질문이다. 먼 훗날 다시 만나 해당 질문에 대해 커피챗을 하며 이야기를 나누면 정말 재미있을 것 같다. 포비도 답변을 남겨주셨는데 순간 울컥했다. "나의 색깔은 무엇일까?"라는 고민을 가지고 살아간다면 언젠가 찾을 수 있을 것이라는 말이 위로와 응원이 되었기 때문이었을까?

 

4주간의 프리코스동안 열정적으로 코드리뷰도 나누고, 스터디에서 라이브로 자신이 짠 코드를 리뷰하면서 참으로도 즐거웠다. 100% 확실한건 함께해서 더 즐거웠던 것 같다. 스터디는 정말 후회없는 최고의 선택이였다. 우아한테크코스 선발 과정은 그 자체만으로도 프로그래밍에 대한 즐거움을 한층 더 높여주는 교육 과정이라는 생각이 든다. 지원서부터 프리코스까지 나 자신을 되돌아보고 메타인지를 할 수 있었다. 이 경험들을 토대로 의식적인 연습, 명확한 학습 방향은 앞으로 나의 삶에 있어 필수적인 요소인 것 같다는 생각이 들었다. 늘 그렇듯이 명확한 방향으로 꾸준히 학습하다보면 나만의 색깔이 뚜렷해지지 않을까? 그렇게 하다보면 업은 자연스레 따라오지 않을까?(물론 매순간 최선을 다해야 한다.) 프로그래밍을 할 수 있는 이 순간을 즐기자. 이게 내 결론이다.