<인프런 워밍업 스터디 클럽 0기> - BE 발자국 3주차
이번 3주차에는 3단계 까지 수행한 내용을 적어보고 프로젝트를 수행하면서 든 생각 등을 적어보겠습니다.
전체 코드는 아래의 깃허브 주소에서 확인하실 수 있습니다.
문제 1. 팀 등록 기능 / 직원 등록 기능 / 팀 조회 기능
문제 1번은 그다지 어렵지 않았습니다. 아래 팀과 직원이라는 1:N 관계를 모델링해주고 기능을 작성하면 됩니다. 팀 등록 기능의 경우엔 동일한 팀이름이 존재할 수는 없으므로 서비스 로직에 다음과 같은 로직만 추가해주었습니다.
직원 등록 기능의 경우엔 한 가지 예외만 처리해주었습니다. 보통 팀(혹은 부서)에는 Manager의 역할을 하는 팀장이 1명입니다. 물론 예외도 있습니다만 여기서는 팀에 팀장은 한 명만 존재한다고 가정하고 다음과 같이 로직을 완성했습니다.
우선 등록하려는 팀이 있는 팀인지 확인한다. 존재하지 않으면 관련 예외를 던진다.
팀이 존재한다면, 역할이 Manager로 정해지지 않았는지 확인한다. 이미 해당 팀에 매니저가 있는 경우, 이미 팀장이 존재한다는 예외를 던진다.
위 두 가지 케이스를 통과했다면 팀을 할당하고 저장
팀 정보 조회는 아래와 같이 작성했습니다. 그저 조회 메소드이니 특별한 것은 없습니다.
다만, 응답 DTO에 필드가 늘어난다면 map()에 파라미터로 전달되는 부분을 축소할 필요가 있어보이는데 이 부분은 리팩토링 해야할 부분으로 남겨두었습니다.
아래는 각 기능을 수행했을 때 정상적으로 작동하는 것을 보여주는 스크린샷입니다.
문제 2. 출근 기능 / 퇴근 기능 / 특정 직원의 날짜별 근무 기간을 조회하는 기능
이 기능들은 다음과 같은 엣지 케이스가 있을 수 있습니다.
등록되지 않은 직원이 출근하려는 경우
출근한 직원이 또 다시 출근하려는 경우
퇴근하려는 직원이 출근하지 않았던 경우
그 날, 출근했다 퇴근한 직원이 다시 출근하려는 경우
저는 이 출근, 퇴근이라는 기능에서 날짜를 정하는 부분을 내부적으로 LocalDate.now()로 잡았는데요. 두 번째, 세 번째 케이스의 경우 오늘 날짜에 출근을 이미했거나 퇴근을 시도할 때 '오늘' 출근한 기록이 없으면 퇴근이 기록될 수 없게 했습니다. 물론 출근 기능에 문제가 생길 수 있으나, 논리적으로 출근을 하지 않았으니 당연히 퇴근 기록이 남는 것도 말이 안된다고 봤습니다. 또한 저는 오늘 근무한 시간을 퇴근을 기록하면서 출근시간과 퇴근시간의 차이를 구해 기록하는 식으로 남겼기 때문에 퇴근 날짜만 남겼을 경우 엉뚱한 값이 기록될 수 있어 이렇게 정했습니다.
가장 고민이 되었던 것은 4번째였는데요. 저는 이미 당일날 출근을 했으면 다시 출근이 기록될 수 없게 했기 때문에 만약 하루에 출-퇴근을 2번 찍었다면 2개의 행이 들어갈 수가 없게되었습니다. 그래서 우선은 임시방편으로 퇴근시간을 null로 바꾸었는데요. 이렇게 되면 하루 총 근무 시간이 초과 근무 시간을 넘게되는 일이 발생할 수 있는데 이 문제를 해결할 다른 정책?을 고민해봐야겠습니다. 문제 2번의 전체 코드는 아래와 같습니다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AttendanceService {
private final AttendanceRepository attendanceRepository;
private final EmployeeRepository employeeRepository;
private final DayOffRepository dayOffRepository;
@Transactional
public void recordGoToWorkTime(Long employeeId) {
// 등록되지 않은 직원이 출근할 수 없다.
Employee employee = employeeRepository.findById(employeeId).orElseThrow(EmployeeDoesNotExistException::new);
LocalDate today = LocalDate.now();
Optional<Attendance> attendedEmployee = attendanceRepository.findAttendedEmployee(employee.getId(), today);
// 연차를 이미 사용한 날짜엔 출근을 기록할 수 없다.
if (dayOffRepository.existsByEmployeeIdAndDayOffDate(employee.getId(), today)) {
throw new AttendanceTodayIsDayOffException();
}
if (attendedEmployee.isPresent()) {
Attendance attendance = attendedEmployee.get();
// 당일 출근과 퇴근이 모두 기록되었다면 퇴근 시간을 null로 업데이트
if (attendance.getGetOffWorkTime() != null) {
attendance.recordGetOffWorkTime(null);
return;
}
// 이미 당일날 출근을 등록한 경우 예외 발생
throw new AttendanceAlreadyArrivedException();
}
attendanceRepository.save(new Attendance(employee));
}
@Transactional
public void recordGetOffWorkTime(Long employeeId) {
// 퇴근하려는 직원이 당일 출근하지 않았을 경우엔 ERR
Attendance attendance = attendanceRepository.findAttendedEmployee(
employeeId, LocalDate.now()).orElseThrow(AttendanceGetOffNotAvailableException::new);
attendance.recordGetOffWorkTime(LocalDateTime.now());
attendance.recordWorkingMinutes();
}
public ResponseAttendanceInfoByEmployee getAttendanceInfoByEmployee(Long employeeId, YearMonth date) {
Employee findEmployee = employeeRepository.findById(employeeId).orElseThrow(EmployeeDoesNotExistException::new);
LocalDate startOfMonth = date.atDay(1);
LocalDate endOfMonth = date.atEndOfMonth();
List<Attendance> attendanceInfo = attendanceRepository.findAttendanceByEmployeeId(findEmployee.getId(),
startOfMonth,
endOfMonth);
List<DayOff> dayOffTaken = dayOffRepository.findDayOffTakenByEmployeeAndMonth(
findEmployee.getId(), startOfMonth, endOfMonth);
List<AttendanceDetail> details = attendanceInfo.stream()
.map(attendance -> new AttendanceDetail(attendance.getGetOffWorkTime().toLocalDate(),
attendance.getWorkingMinutes(), false))
.collect(Collectors.toList());
// 연차 정보도 AttendanceDetail 형태로 변환하여 details 리스트에 추가
List<AttendanceDetail> dayOffDetails = dayOffTaken.stream()
.map(dayOff -> new AttendanceDetail(dayOff.getDayOffDate(), 0L, true))
.toList();
details.addAll(dayOffDetails);
details.sort(Comparator.comparing(AttendanceDetail::date));
Long sum = details.stream().mapToLong(AttendanceDetail::workingMinutes)
.sum();
return new ResponseAttendanceInfoByEmployee(details, sum);
}
}
문제 3. 연차 신청 / 연차 조회 / 특정 직원의 날짜별 근무 시간 조회
여기서는 연차와 관련된 정보를 따로 관리하는 엔티티를 만들었는데요. 지금 생각해보면 조금 아쉬웠던? 결정이었습니다. 왜냐하면 근태관리 시스템에선 보통 연차/반차/조퇴/초과근무/정상출퇴근을 함께 관리하고 있는데 Attedance에 이를 구분하는 코드를 삽입하는 속성을 추가해서 함께 관리했으면 연차를 위한 엔티티를 따로 만들 필요가 없었던 것입니다. 근태라는것의 유형을 분류해서 로직을 구성했으면 오히려 좀 더 응집도가 있었을 수 있었는데 저는 그렇게 하지 못했습니다. 그래서 위에 보시면 직원의 근태정보를 가져오는 부분에서 레포지토리를 세개나 주입을 받고 있습니다. 이 부분은 추후에 한 번 개선을 해볼만한 지점입니다. 연차를 신청할 때는 다음의 예외를 다뤄줘야 했는데요.
연차 사용 횟수가 없는 경우엔 연차를 신청할 수가 없다.
연차 사용일 - 연차 신청일이 팀에서 정한 기한보다 작은 경우엔 연차를 신청할 수 없다.
이미 연차를 신청한 날짜에 또 다시 연차를 신청할 수 없다.
연차를 사용한 날에는 출근이 기록될 수 없도록 출근 기능에서도 예외 핸들링을 해줘야 한다.
따라서 연차 신청은 다음과 같은 코드로 수행했습니다.
여러 케이스를 다루다보니, 코드가 좀 길어졌는데 따로 연차 신청이 유효한지 판단하는 private 메서드를 만들어서 빼주도록 리팩토링하는 시간을 가져봐야겠습니다.
남은 연차를 조회하는 경우에는 이 Employee에 dayOffRemains라는 정수 필드를 추가했습니다. 그래서 연차 사용이 정상적으로 마무리 되었으면 subtractDayOffRemain()
이라는 함수를 호출해서 연차 사용 횟수를 1회 깎아주는 방식을 채택했습니다.
연차 정보까지 포함한 근태정보를 가져오는 로직에서는 고민을 좀 많이 했는데요. 로직을 보시면
보시면 로직이 굉장히 깁니다. 정상 출퇴근 정보를 갖고 있는 Attedance 테이블에서 조회를 해오고, 연차정보를 조회해오고, 이를 다시 날짜순으로 정렬을 해주고, 근무 시간의 총합을 구하는 스트림을 돌고 있습니다. 연산을 굉장히 많이하게 되는데요. 사실 더 좋은 방법이 없을까..? 쿼리로 한 번에 가져올 수는 없을까? 생각을 했는데 처음에 든 생각은,
'음... 겨우 한 달짜리만 가져오는 거잖아?"
네 사실 조회하는 데이터나... 연산의 대상이되는 데이터의 크기가 크지 않습니다. 근데 또... 기간이 6개월이라면? 1년이라면...? 이라는 생각이 들기는 하는데 그래도 많은 양인가? 엄청난 통계성 데이터일만큼? 이라는 생각이 들어서 로직을 분리하는 것외에는 더이상의 리팩토링을 할 필요가 있을까 싶기도 하고... 또 로직이 단순해지려면 위에서 말했던 것처럼 그냥 연차를 Attendance에서 한번에 관리하는 것이 더 좋겠다는 생각이 들었습니다.
시간 관계상 프로젝트는 3단계까지 했지만 4단계까지 쭉 해볼 생각입니다. 이번 프로젝트를 하면서 변경에 유연한 설계를 할 수 있는지에 대한 시험대에 오른 것 같다는 생각이 들었습니다. 계속해서 리팩토링 해보고 더 많은 기능을 추가해보면서 실력을 갈고 닦아야겠습니다. 이런 좋은 과제를 준비해주신 태현 코치님에게 감사드립니다.
댓글을 작성해보세요.