🎉 1차 합격
나한테 프리코스는 아주 좋은 배움의 장이었다.
백엔드로 시작하고 나서 그동안 '구현'자체에만 집중해 왔다. 자바의 기본기나 클린코드, 테스트 코드는 늘 후순위였다.
사실 자바를 어느 정도만 익히고 바로 스프링과 DB 등을 공부하고 "일단 구현부터"라는 생각으로 공부를 해왔기 때문이다.
나는 실제로 무언가를 만들어봐야 큰 그림이 보이고, 그 과정에서 부족한 점을 하나둘씩 보완하는 스타일이다. 그래서 처음 백엔드 공부할 때도 내 학습 방식에 맞춰 직접 구현하는 데 더 집중했다. 하지만 그렇게 만들다 보니 자바 기본기와 클린 코드, 테스트 코드가 상대적으로 약해져서 늘 갈증을 느꼈다.
그러던 중 우테코 모집 공고를 보고 프리코스를 시작했는데, 그동안의 갈증을 조금씩이나마 해소할 수 있었다. 이전에 만들어둔 서비스에는 클린 코드나 테스트 코드가 거의 없어 에지 케이스를 처리하거나 유지보수를 할 때 매우 오랜 시간이 걸렸다. 사실 유지보수할 때마다 ‘돌아가는 쓰레기’라는 생각이 들 정도로 스트레스를 받기도 했다.
하지만 프리코스 1~4주차를 진행하면서 클린 코드를 작성하는 원칙과 테스트 코드를 효율적으로 작성하는 방법을 익히게 되었다. 객체지향 개념도 훨씬 구체적으로 알게 되어, “이걸 어떻게 기존 서비스에 적용해 볼까”라는 고민을 자주 하게 되었다.
무엇보다 큰 도움이 되었던 건 코드 리뷰와 커뮤니티였다. 나는 전공자도 아니고 교육기관에서 체계적으로 배운것도 아니었고 개발을 잘하는 친구도 없었다. 기존에 스터디나 프로젝트를 했지만 혼자 한다는 느낌을 종종 받았데 여기는 아니었다.
나보다 훨씬 잘하는 분들이 많았고, 그분들에게 리뷰를 받거나 반대로 피드백해주면서 왜 이렇게 코드를 작성해야 하는지, 왜 이렇게 하면 안 되는지 등의 이유를 상세히 배울 수 있었다. 커뮤니티 역시 큰 자극이 되었는데, 토론을 통해 기존에 알고 있던 지식을 더 깊이 있게 공부하고, 공유를 통해 몰랐던 내용은 새로 익히는 기회가 되었다. 그렇게 함께 배우며 하루하루 한 걸음씩 나아가다 보니, 운이 좋게도 1차에 합격할 수 있었다.
📚 최종 코테 준비
사실 최종 코테 준비는 4주차 끝나고 일주일 뒤부터 시작했다.
4주간 미뤄온 여러가지 일들을 끝내야 했었다.
누군가는 1차합격한 뒤에 해도 되겠지만 나는 많이 부족하기 때문에 혹시라도 합격이 된다면 그때부터 준비하기에는 늦기 때문에 별생각 안 하고 최대한 빨리 준비하고자 했다. (코드 리뷰를 하다 보면 고수분들이 넘쳐나기 때문에 그분들에게서 배울 점들이 많기에 나는 남들보다 조금 더 열심히 해야 했다. )
최종 코테 준비를 위해 프리코스 1~4주차를 회고해 보니 몇 가지 문제점이 눈에 보였다.
문제점
1️⃣ 종종 지금 써야할 메서드가 바로 생각나지 않거나 매개변수가 무엇인지 까먹음
기존에 자바스크립트로 코테도 많이 풀어봤기에 특정 기능을 구현하기 위해 저버 문법만 알면 구현할 수 있었다.
그렇기에 큰 문제가 없었고 인텔리제이 2024.1 버전에서 추가된 기능인 전체 줄 코드 완성이 꽤 도와도 줬다.
하지만 최종 코테에서는 AI 도구를 전혀 사용할 수 없고 시간이 정말 중요하다. 만약 최종 코테를 볼 때 어떤 메서드인지 헷갈리거나 생각이 나지 않으면 그걸 찾는데 불필요한 시간이 낭비된다.
프리코스를 하면서 헷갈리는 개념들을 정리 했지만 사실 자주 사용하지 않으면 금방 까먹고 자주 사용하는 것만 사용하다 보니 현재 내가 확실히 아는 개념이 무엇인지 체크할 필요가 있었다.
그래서 기초적인 문제를 빠르게 여러가지를 풀면서 헷갈리거나 바로바로 생각나지 않는 개념들을 정리했다.
예를 들어 해당 메서드를 떠올렸을 때 어떨 때 어떤 에러가 나고 특정 인수가 들어갈 때 리턴값이 무엇인지 생각나지 않으면 바로 정리 대상이었다.
2️⃣ 빠르게 객체지향적으로 구조 설계를 하지 못함
이는 정말 큰일이었다. 문제를 보고 대강 객체지향적으로 설계를 할 줄 알아야 하는데 사실 제대로 객체지향적으로 설계를 시작한 게 프리코스를 시작하고 난 뒤부터였다. 기존 프리코스 문제는 스파게티 코드로 짜고 몇 시간씩 고민해 가면서 계속 리팩터링이 하면서 객체지향적으로 코드를 수정했지만 최종 코테에서는 5시간 안에 모든 걸 해야 했다.
일단 기존 프리코스 문제를 복습하면서 카카오 코테 level.1 수준의 문제를 객체 지향적으로 푸는 연습을 했다.
일단 테스트를 통과하는 코드를 만들고 리팩터링하면서 객체지향적으로 바꾸었다.
거의 한 문제를 하루동안 풀었다. "어떻게 더 객체지향적으로 할 수 있을까?", "어떻게 get으로 꺼내지 않고 메시지를 보내서 해결할 수 있을까?", "어떻게 하면 강결합을 하지 않을 수 있을까?", "어떻게 하면 더 메서드를 쪼갤 수 있을까" 등등을 생각하면서 말이다.
어느 정도 감을 잡고 나서 이전 기수 문제들을 반복적으로 풀기 시작했다.
풀었던 문제를 전부 push 하지 않아서 몇 개가 누락되었지만 같은 문제를 여러 번 조금씩 다른 방식으로 즉 과거에 풀었던 기억을 끄집어내기보다는 의식적으로 조금씩 다르게 풀려고 노력했다. (5기 4주 차/최종 | 6기 4주 차/최종 | 7시 4주 차)
그 결과 최종 코테가 가까워질 때쯤 어느 정도 문제를 보았을 때 구조를 빠르게 설계할 수 있었다.
이런 식으로 반복적으로 연습하다 보니 몇 가지 구현 스타일이 보였다.
1. 스파게티 코드로 짠 후 리팩터링
2. 핵심적인 부분을 제대로 구현하고 비즈니스 로직 스파게티 코드
3. 핵심적인 부분을 제대로 구현하면서 테스트 코드 병행
1. 스파게티 코드로 짠 후 리팩터링
스파게티코드로 자다 보면 나중에 몇 가지 요구사항을 빼먹었을 때 추가 하는 과정에서 더 코드가 꼬이고 에러가 이곳저곳에서 터져서 리스크가 컸다. 물론 운이 좋아 에러가 뜨지 않는다면 좋겠지만 한 번 꼬이면 디버깅하는 데 엄청난 시간이 소모되었다.
그리고 문제가 어려워질수록 코드가 심각하게 복잡해진다는 단점이 있다.
하지만 운이 좋게 내가 생각한 대로 코드가 동작하면 시간은 제일 빨랐다.
2. 핵심적인 부분을 제대로 구현하고 비즈니스 로직 스파게티 코드
시간은 스파게티 코드보다 더 걸리지만 초반에 설계할 때 시간이 조금 걸리고 구현하는 속도도 느리지만 무언가 빼먹은 요구사항이 있어도 추가가 쉬웠고 디버깅도 훨씬 쉬웠다.
마치 이렇게 잘 정리해서 레고를 조립하는 느낌이었다.
3. 핵심적인 부분을 제대로 구현하면서 테스트 코드 병행
사실 제일 안정적인 방법이지만 내 실력으로는 시간이 너무나 오래 걸렸다.
결굴 2번 방법으로 연습하기 시작했고 의식적으로 최소한의 클린코드를 작성하면서 문제를 푸는 연습을 했다.
코드 리뷰를 통해 배운 내용들은 모두 적용하고 싶었지만 욕심이라고 생각했고 요구 사항과 1,2,3주 차 공통 피드백만이라도 지키면서 하기 위해 연습했다.
🚌 시험장 가기
잠을 푹 못자서 지하철에서도 걷는 중에도 계속 하품을 해서 시험장옆에 있는 잠실 스타벅스에서 아메리카노 그란데를 주문했다.
그렇게 커피를 들고 바로 시험장(루터회관 14층)에 왔는데 아무도 없었다.
순간 내가 잘못 왔나 했다.
그리고 다시 네이버지도와 층수를 확인하고 맞는 걸 확인했고 시간이 조금 흐른 뒤 사람들이 하나 둘 도착하기 시작했다.
🪑 착석
도착해서 할 일들을 수행했다.
그리고 와이파이 연결 및 생성형 AI 쓰는 게 있는지 다시 한번 확인했다.
시험 보는 곳은 듣던 대로 비좁았지만 다행히 스타벅스 작은 원형 테이블보다는 넓었다.
(이날을 위해 스타벅스 작은 테이블에서 하는 것을 계속 연습했다!!)
📝 시험시작
문제를 딱 봤는데 쉽지는 않아 보였다.
바로 요구사항부터 분석했다.
중간에 놓칠 수 있는 부분들이 많았다.
- 교육시간이 월요일만 다르다는 것
- 어떤 곳은 초과 어떤곳은 이상을 사용한다는 것
- 교육시간과 캠퍼스 운영시간이 다르다는 점
- 주말 및 공휴일에는 출석을 받지 않는다는 점
- 전날까지의 출석 기록으로 파악
- 에러마다 메시지가 다르다는 점
- 출석 기록이 없으면 채워 넣은 뒤 시간은 표기를 안 한다는 점
- 지각 3회는 결석 1회로 간주한다는 점
문제를 분석하고 기능목록을 작성하는 데 40분 정도 소요가 되었고 바로 기능 구현을 시작했다.
문제가 어렵기에 나는 연습했던 핵심적인 부분을 제대로 구현하고 비즈니스 로직 스파게티 코드 전략으로 시작했다.
제일 먼저 구현한 것은 파일에 있는 내용을 전부 가져오는 것부터였다.
1️⃣ 파일 읽고 초기값 세팅하기
파일이 아래와 같이 생겼었는데
쿠키,2024-12-13 10:08
빙봉,2024-12-13 10:07
빙티,2024-12-13 10:07
이든,2024-12-13 10:07
빙봉,2024-12-12 11:11
이든,2024-12-12 10:06
...
여기서 했던 생각은 한 사람이 날짜별로 여러 개의 출석 기록을 가지고 있어야겠다고 생각했다.
그래서 유저 안에 여러 개의 출석 기록을 가지고 있도록 구현했다.
public class User {
private final String nickname;
private final AttendanceRegisters attendanceRegisters;
// 구현 코드
}
public class Users {
private final List<User> users;
public class AttendanceRegister {
private final LocalDateTime datetime;
private final State state;
// 구현 코드...
}
public class AttendanceRegisters {
private final List<AttendanceRegister> attendanceRegisters;
여기서 출석/지각/결석을 보고 이건 바로 enum을 써야겠다고 생각했다.
그래야 나중에 출석/지각/결석을 몇 회를 했는지 파악하고 그에 맞는 처벌을 결정할 때 훨씬 쉬울 것 같았다.
아래와 같은 방식으로 작성했다. (변수명은 1초 이상 생각하지 않아서 별로...)
public enum State {
ATTENDANCE("출석", LocalTime.of(13, 6), LocalTime.of(10, 6)),
LATENESS("지각", LocalTime.of(13, 5), LocalTime.of(10, 5)),
ABSENCE("결석", LocalTime.of(13, 30), LocalTime.of(10, 30));
private final String category;
private final LocalTime mondayLateness;
private final LocalTime weekdayLateness;
이렇게 까지 구현 디버깅으로 제대로 만들어지는지 확인해 보았는데
예를 들면 `빙티`라는 유저는 10개의 `AttendanceRegister` 객체가 생성되어야 하는데 저는 7개밖에 생성되지 않았다.
자세히 보니 결석을 표기해줘야 했다. (분명 분석할 때 인지했는데 놓쳤다.)
그래서 로직을 추가했습니다. (여기는 스파게티)
public static void checkAbsence(List<AttendanceRegister> attendanceRegisters) {
final int beforeDay = DateTimes.now().getDayOfMonth() - 1;
final List<Integer> recordedAttendance = attendanceRegisters.stream()
.map(attendanceRegister -> attendanceRegister.getDatetime().getDayOfMonth())
.toList();
// get get 하는것보다 attendanceRegister에서 메시지를 던져서 값을 받는게 더 good but 시간 부족
IntStream.rangeClosed(2, beforeDay)
.filter(number -> !recordedAttendance.contains(number))
.filter(WeekdayChecker::isWeekday)
.forEach(number -> attendanceRegisters.add(
new AttendanceRegister(createDateTime(number), State.ABSENCE)));
}
12월 한 달 중에 이미 파일에 있는 날짜를 제외하고 평일만 가져와서 오늘 하루 전까지 결석 처리
그렇게 출력을 했는데?
응? csv파일에 기록된 출석 시간과 예시 값이 맞지 않았다. 하지만 초반 일부는 맞았다. 그래서 더 헷갈렸다. 일부는 맞고 일부는 틀리다니 "뭔가 로직이 있어야 하나?", "요구 사항을 잘 못 파악했나?" 싶었다. 그렇게 다시 문제를 꼼꼼하게 읽어보았으나 놓친 것은 없었다. 여기서 시간을 많이 소비했다. 그냥 어디에서 수정했겠지 생각하고 넘어갔으면 좋았을 텐데 왜그렇게 붙잡고있었는지 모르겠다.
그렇게 시간을 날린뒤에 꺼림작한 상태로 일단 다음에 무엇을 구현할지 생각했다.
3번 기능이 어차피 조회기능이라 기존 세팅한 출석 기록과 출석/지각/결석 횟수 및 그에 따른 처벌을 출력하면 되기에 3번 기능 먼저 구현했다.
이때도 처벌은 enum으로 해주면 딱이겠다 생각했다.
public enum Punishment {
WEEDING("제적", 5),
INTERVIEW("면담", 2),
WARNING("경고", 1),
NONE("없음", -1);
private final String punishmentName;
private final int absenceCount;
// 구현 코드 ..
}
(왜 NONE를 0으로 안 하고 -1로 했지?)
이외에 비즈니스로직을 스파게티 코드로 만들고 3번 기능을 완성함으로써 모든 골조공사가 끝났다.
여기서 바로 1번을 구현하면 좋았을 텐데 3번 포맷팅과 자잘한 버그를 잡는데 시간을 사용해서 15분도 남지 않게되었다.
그래서 결국 1번을 빠르게 구현하는데 문제가 발생한다.
대망의 no line found에러
바로 이 부분을 놓쳤기 때문에 또 시간낭비를 하게되어 1번 기능을 구현하다가 끝나게 되었고 결국 5개의 테스트 코드중 1개만 통과하고 끝났다.
⁉️ 무엇이 부족했을까?
1. LocalDateTime
사실 localDateTime에 대해 공부를 했다. 하지만 자주 사용하는 메서드만 알고 있었고 더 많은 메서드에 대해 알지 못했다. 그 결과 파일에서 출석기록을 가져오는 기능을 만들때 몇가지 메서드를 구글링 하느라 시간이 더 지체됐다.
2. 요구사항 꼼곰하게 보지 못한 점
중요 사항을 몇 개 놓쳤다.
1) 지각 3회는 결석 1회로 간주
2) 에러 발생 시 바로 종료
사실 지각 3회 = 결석 1회 간주는 캐치했으나 잊어먹었고 에러 발생시 바로 종료는 캐치하지도 못했고 기능 목록에는 재입력하라고 적어두었기 때문에 더 발견하기 어려웠다.
3. 테스트 코드에서 핵심을 파악하지 못한 점
테스트코드 5개중 4개는 1번기능을 구현하면 통과할 수 있는 것들이다. 하지만 나는 테스트 코드를 먼저 보았지만 이 점을 놓쳤다.
그냥 예외처리를 꼼꼼하게 해야겠다는 생각만 했다.
다시 천천히 풀어보았을 때
4번 기능은 정렬 일부 개념을 놓쳤기에 풀 수 없었지만 1,2번 기능은 충분히 풀 수 있다고 생각이 들었기 때문에 너무 아쉬웠다.
오히려 "1,2번부터 풀었으면 어땠을까?"라는 아쉬움이 남긴 했다.
https://github.com/Ryan-Dia/java-attendance-7-java-Retry
✅ 다시 풀면서 느낀 부족했던 점
1) 다중 정렬
4번기능을 구현할 때 여러가지 조건에 맞게 정렬을 해야한다.
1. 제적/면담/경고 대상자 순으로 정렬
2. 항목이 같을 때는 지각을 결석으로 간주하여 내림차순 정렬
3. 출석 상태가 같으면 닉네임으로 오름차순 정렬
처음에는 아래와 같이 정렬을 했다.
public List<User> confirmationOfPersonsAtRiskOfExpulsion() {
return users.stream()
.filter(user -> user.getPunishment() != Punishment.NONE) // NONE 제외
.sorted(Comparator
// 1. 제적/면담/경고 대상자 순으로 정렬
.comparingInt((User user) -> user.getPunishment().getSeverity())
// 2. 항목이 같을 때는 지각을 결석으로 간주하여 내림차순 정렬
.thenComparingInt(user -> {
int latenessCount = user.getAttendanceRegisters()
.calculateAttendanceWithoutConsidered()
.getOrDefault(State.LATENESS, 0);
int absenceCount = user.getAttendanceRegisters()
.calculateAttendanceWithoutConsidered()
.getOrDefault(State.ABSENCE, 0);
return absenceCount + latenessCount / ABSENCE_STANDARD_COUNT;
}).reversed()
// 3. 출석 상태가 같으면 닉네임으로 오름차순 정렬
.thenComparing(User::getNickname))
.toList();
}
하지만 이렇게 정렬하면 문제가 생긴다.
`reversed()`를 체인 끝에 호출하면 지금까지 체이닝된 전체 비교 결과가 뒤집혀 버린다. 즉, 첫 번째 비교 조건을 적용한 뒤 두 번째 비교 조건을 적용하고, 그 마지막에 `reversed()`를 붙이면 전체 정렬 순서가 최종적으로 다시 뒤집히게 되기 때문에 의도한 대로 정렬이 되지 않는다.
그래서 지금은 아래와 같이 수정했다.
public List<User> confirmationOfPersonsAtRiskOfExpulsion() {
return users.stream()
.filter(user -> user.getPunishment() != Punishment.NONE)
.sorted(
punishmentOrder
.thenComparing(absenceCountWithLateness)
.thenComparing(User::getNickname)
)
.toList();
}
private final Comparator<User> punishmentOrder = Comparator
.comparingInt((User user) -> user.getPunishment().getAbsenceCount())
.reversed();
private final Comparator<User> absenceCountWithLateness = Comparator
.comparingInt((User user) -> {
final Integer latenessCount = user.getAttendanceRegisters()
.calculateAttendanceWithoutConsidered()
.getOrDefault(State.LATENESS, 0);
final Integer absenceCount = user.getAttendanceRegisters()
.calculateAttendanceWithoutConsidered()
.getOrDefault(State.ABSENCE, 0);
return absenceCount + latenessCount / ABSENCE_STANDARD_COUNT;
})
.reversed();
이렇게 하면 의도한대로 정렬할 수 있다.
🎬 마무리
시험이 끝나고 프리코스를 하면서 알게 된 고수분들과 밥을 먹으며 최종 코테와 앞으로의 미래에 대해 5시간 넘게 이야기를 나눴는데, 정말 재미있었다.
최종 코테에서 다 보여주지 못했다고 생각하지만, 그간의 과정을 다 보실 것 같기에 너무 큰 의미를 두지 않으려고 노력하고 있다.
사실 아쉬움이 정말 많다. 누구나 다 그렇겠지만, “조금만 더 시간이 있었다면 좋았을 텐데”를 속으로 수십, 수백 번은 외치는 것 같다.
그래도 이번 시험을 통해 부족한 점들을 알았으니 앞으로 채워 나가면 된다.
결과가 나오기까지는 아무도 모르기 때문에, 섣불리 기뻐하거나 슬퍼하기는 싫었다.
그냥 묵묵히 그동안 해왔던 것처럼 꾸준하게 공부해나가는 오늘이다.
이번 프리코스를 경험하면서 확실히 느낀게 있다.
비록 온라인이지만 함께 할 때 더 빠르게 성장한다는 것이다.
서로 피드백을 주고 받고 특정 주제에 대해 토론하고 종종 음성방에서하는 잡담등 이 모든 것이 시간의 밀도를 높이고 짧은 시간안에 더 많은 것들에 대해 배우고 성장하는 데 엄청난 역할을 했다.