[미니 프로젝트] _ Step 3.

김영훈·2024년 3월 7일
post-thumbnail

구현 내용

  • 연차 신청
    • 이제부터 직원은 연차를 신청할 수 있습니다.
    • 연차는 무조건 하루 단위로만 사용이 가능합니다.
    • 올해 입사한 직원은 11개의 연차를, 그 외 직원은 15개의 연차를 사용할 수 있습니다.
      연차를 사용하기 위해서는 연차 사용일을 기준으로 며칠전 연차 등록을 해야 합니다.
      연차를 등록하기만 하면, 매니저의 허가 없이 연차가 바로 적용됩니다.
      • 단, 며칠 전에 연차를 등록해야 하는지는 팀 마다 다르게 적용됩니다.
        예를 들어 A팀은 하루 전에만 등록하면 연차를 사용할 수 있지만, B팀은 7일 전에 등록해야
        연차를 사용할 수 있습니다.
  • 연차 조회
    • 각 직원은 id를 이용해 올해 사용하지 않고 남은 연차를 확인할 수 있습니다.
  • 특정 직원의 날짜별 근무시간을 조회하는 기능 Version02
    • 연차를 신청할 수 있게되며, project_Step02 에서 개발했던 기능도 조금 변경되어야 합니다.
    • 만약 연차를 사용했다면, UsingDayOff : true가 반환되어야 합니다.
{
	"detail": [
	{
		"date": "2024-01-01",
		"workingMinutes": 480,
        "usingDayOff": false // 연차를 사용하지 않았으니, false가 반환
	},
	{
		"date": "2024-01-02",
		"workingMinutes": 0,
        "usingDayOff": true // 연차를 사용한 날은 true가 반환
	},
	... // 2024년 1월 31일까지 존재할 수 있다.
	]
	"sum": 10560
}

📌 edge-case

  • 연차를 사용한 직원이 출근하려는 경우
  • 각 팀별 설정 연차 등록일 이전에 연차를 사용하려하는 경우
  • 해당일에 이미 연차를 등록한 경우
  • 과거로 연차를 사용하려 하는 경우
  • 올해의 연차를 모두 사용한 경우

과정

💡 고민 1.
연차 신청과 연차 조회는 쉽게 만들 수 있을거 같은데..
특정 직원의 날짜별 근무시간을 조회하는 기능은 어떻게 처리할지, 만약 그 방법으로 처리한다면
필요한 Column은 무엇인지, 비즈니스 로직은 어디서 어떻게 처리할지가 고민이었습니다.


  • Table

CREATE TABLE annual
(
    id                bigint auto_increment,
    annual_date_leave datetime,
    member_id         bigint,
    primary key (id)
);

📌 어떻게 구현할지 생각해보기

  • Step02에서 만들었던 특정 직원의 날짜별 근무시간을 조회하는 기능을 처리할 때,
    해당 연월에 연차를 사용했는지 체크하고, 연차 사용기록이 존재한다면
    연차를 사용한 요일 : {date}, 일한 시간 : 0, usingDayOff : true 로 반환해주려 합니다.
  • 올해 사용하지 않고 남은 연차 조회 기능은 간단합니다.
    LocalDate.now() 로 구한 현재 년도MemberId 로 사용한 연차의 갯수를 구하고,
    ChronoUnit.Years.between을 활용해 입사년도LocalDate.now() 의 차이가 1보다 크거나 같다면
    15, 그렇지 않다면 11 에서 위에서 구한 사용한 연차의 갯수를 빼주면 됩니다.
    만약 연차의 개수가 0보다 작거나 같다면, CustomException으로 예외를 던져주겠습니다.
CREATE TABLE team
(
    id                bigint auto_increment,
    name              varchar(20),
    manager           varchar(20),
    day_before_annual int,
    primary key (id)
);

📌 기존 team 테이블 수정
팀별 연차 등록일을 설정해주기 위해 team 테이블을 수정하였습니다


  • Controller

@RestController
@RequiredArgsConstructor
public class AnnualLeaveController {
    private final AnnualLeaveService annualLeaveService;

    @PostMapping("/annual")
    public ResponseEntity<Void> registerAnnualLeave(@RequestBody @Valid RegisterAnnualLeaveRequest request) {
        annualLeaveService.registerAnnualLeave(request);
        return ResponseEntity.status(HttpStatus.CREATED).build();
    }

    @GetMapping("/annual")
    public ResponseEntity<GetRemainAnnualLeavesResponse> getRemainAnnualLeaves(@Valid 
    GetRemainAnnualLeavesRequest request) {
        long remainAnnualLeaves = annualLeaveService.getRemainAnnualLeaves(request);
        GetRemainAnnualLeavesResponse response = new GetRemainAnnualLeavesResponse(remainAnnualLeaves);
        return ResponseEntity.ok(response);
    }
}
@RestController
@RequiredArgsConstructor
public class TeamController {

    private final TeamService teamService;

    @PostMapping("/team")
    public ResponseEntity<Void> createTeam(@RequestBody CreateTeamRequest request) {
        teamService.createTeam(request);
        return ResponseEntity.status(HttpStatus.CREATED).build();
    }

    @GetMapping("/team")
    public ResponseEntity<List<GetAllTeamsResponse>> getAllTeams() {
        List<GetAllTeamsResponse> allTeamsList = teamService.getAllTeams();
        return ResponseEntity.ok().body(allTeamsList);
    }

    @PutMapping("/team/day-before-annual")
    public void updateDayBeforeAnnual(@RequestBody @Valid UpdateDayBeforeAnnualRequest request){
        teamService.updateDayBeforeAnnual(request);
    }
}

💡 각 팀별 연차 등록일 설정을 위해 TeamControllerupdateDayBeforeAnnual 메서드를
추가하였습니다.


  • DTO

    • AnnualLeave

      public record GetRemainAnnualLeavesRequest(@NotNull long id) {
      }
      public record RegisterAnnualLeaveRequest(@NotNull long id, @Future LocalDate date) {
         public AnnualLeave toEntity(Member member){
             return AnnualLeave.builder()
                     .annualDateLeave(date)
                     .member(member)
                     .build();
         }
      }
      public record GetRemainAnnualLeavesResponse(long remainAnnualLeaves) {
      }

      💡 @Future 어노테이션을 사용하여 연차를 과거로 떠나려는 시도를 막았습니다. 😊


    • Commute

      public record GetCommuteRecordRequest(@NotNull long id, @DateTimeFormat(pattern = "yyyy-MM") YearMonth yearMonth) {
        public int getYear(){ return this.yearMonth.getYear();}
        public int getMonth(){ return this.yearMonth.getMonth().getValue(); }
      }
      @Builder
      public record GetCommuteDetail(LocalDate date, long workingMinutes, boolean usingDayOff) {
        public static GetCommuteDetail from(Commute commute){
            Duration duration = Duration.between(commute.getCreatedAt(), commute.getUpdatedAt());
      
            return GetCommuteDetail.builder()
                    .date(commute.getCreatedAt().toLocalDate())
                    .workingMinutes(duration.toMinutes())
                    .usingDayOff(false)
                    .build();
        }
        public static GetCommuteDetail from(AnnualLeave annualLeave){
            return GetCommuteDetail.builder()
                    .date(annualLeave.getAnnualDateLeave())
                    .workingMinutes(0)
                    .usingDayOff(true)
                    .build();
        }
      }

      💡 특정 직원의 날짜별 근무시간 조회시, 범위 내 연차 사용기록이 있으면 CommuteResponseDTO
      변환하기위해 GetCommuteDetail를 수정하였고,
      좀더 쉽게 연도와 월 값을 구하기 위해 requestDTO에서 요청하는 날의 값을 가져올 수 있게
      GetCommuteRecordRequest를 수정하였습니다.


  • Domain

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class AnnualLeave {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

    private LocalDate annualDateLeave;

    @ManyToOne(fetch = FetchType.LAZY)
    private Member member;

    @Builder
    public AnnualLeave(LocalDate annualDateLeave, Member member) {
        this.annualDateLeave = annualDateLeave;
        this.member = member;
    }
}

  • Service

@Service
@Slf4j
@RequiredArgsConstructor
public class AnnualLeaveService {
    private final AnnualLeaveRepository annualLeaveRepository;
    private final MemberService memberService;

    @Transactional
    public void registerAnnualLeave(RegisterAnnualLeaveRequest request){
        Member member = memberService.findMemberById(request.id());
        if(isAcceptTeamPolicy(member, request)) throw new AcceptTeamPolicyException();
        if(isAlreadyUsingAnnualLeaves(member, request.date())) throw new AlreadyRegisteredException();
        if(isRemainAnnualLeaves(member)) throw new RemainAnnualLeavesException();

        annualLeaveRepository.save(request.toEntity(member));
    }
    @Transactional(readOnly = true)
    public long getRemainAnnualLeaves(GetRemainAnnualLeavesRequest request){
        Member member = memberService.findMemberById(request.id());
        return remainAnnualLeaves(member);
    }

    private boolean isAcceptTeamPolicy(Member member, RegisterAnnualLeaveRequest request){
        return
        ChronoUnit.DAYS.between(LocalDate.now(), request.date()) < member.getTeam().getDayBeforeAnnual();
    }
    private long remainAnnualLeaves(Member member){ // 남은 연차 계산 & 연차 조회시 반환
        long maxAnnualLeave = ChronoUnit.YEARS.between(member.getCreatedAt(), LocalDateTime.now()) >= 1 ? 15L : 11L;
        long usedThisYear = annualLeaveRepository.countByMemberId(member.getId(), YearMonth.now().getYear());
        return maxAnnualLeave - usedThisYear;
    }
    private boolean isRemainAnnualLeaves(Member member){
        return remainAnnualLeaves(member) <= 0;
    }

    public boolean isAlreadyUsingAnnualLeaves(Member member, LocalDate date){
        return annualLeaveRepository.existsByMemberIdAndAnnualDateLeaveEquals(member.getId(), date);
    }
    public List<AnnualLeave> findAnnualLeavesByMemberIdAndYearMonth(long memberId, YearMonth request){
        int year = request.getYear();
        int month = request.getMonth().getValue();
        //
        return annualLeaveRepository.findAllAnnualLeavesByMemberIdAndYearMonth(memberId, year, month);
    }
}
@Service
@Slf4j
@RequiredArgsConstructor
public class CommuteService {

// @@생략

    @Transactional(readOnly = true)
    public GetCommuteRecordResponse GetCommuteRecord(GetCommuteRecordRequest request) {
        memberService.findMemberById(request.id());

        List<GetCommuteDetail> commuteDetailList = findCommuteListByMemberIdAndStartOfWork(request);
        long sum = commuteDetailList.stream()
                .mapToLong(GetCommuteDetail::workingMinutes)
                .sum();
        //commuteDetailList에서 workingMinutes를 조회, reduce로 합을 반환
        return new GetCommuteRecordResponse(commuteDetailList, sum);
    }


    private List<GetCommuteDetail> findCommuteListByMemberIdAndStartOfWork(GetCommuteRecordRequest request) {
        List<Commute> commuteList = commuteRepository
                .findCommuteListByMemberIdAndStartOfWork(request.id(), request.getYear(), request.getMonth());
        if (commuteList.isEmpty()) throw new CommuteNotFoundException();
        //해당범위에 통근기록 존재 X? -> 통근기록없음 예외처리
        List<GetCommuteDetail> commuteDetailList = commuteList.stream()
                .map(GetCommuteDetail::from)
                .collect(Collectors.toList()); //통근기록을 CommuteDetail으로 변환

        List<AnnualLeave> annualLeaveLeavesList = annualLeaveService 
                .findAnnualLeavesByMemberIdAndYearMonth(request.id(), request.yearMonth()); 
	// 연차기록찾기 (오늘보다 미래의 연차기록은 가져오지않음)
        mergeAndSort(commuteDetailList, annualLeaveLeavesList); //Merge하고 sort함
        return commuteDetailList;
    }
    
    private void mergeAndSort(List<GetCommuteDetail> commuteDetailList, List<AnnualLeave> annualLeaveLeavesList) {
        if (annualLeaveLeavesList != null) { //해당범위 연차기록이 있으면 Merge
            List<GetCommuteDetail> annualLeavesToDetails = annualLeaveLeavesList.stream()
                    .map(GetCommuteDetail::from)
                    .toList();
            commuteDetailList.addAll(annualLeavesToDetails);
        }
        commuteDetailList.sort(Comparator.comparing(GetCommuteDetail::date)); //있던없던 sort는 함
    }
}

💡 특정 직원의 날짜별 근무시간 조회시 연차목록을 조회하기 위해 CommuteService에서 AnnualLeaveList를 참조하도록 하였습니다.


  • Repository

public interface AnnualLeaveRepository extends JpaRepository<AnnualLeave, Long> {
    boolean existsByMemberIdAndAnnualDateLeaveEquals(long memberId, LocalDate annualDate);
    @Query("SELECT COUNT(*) FROM AnnualLeave annual " +
            "WHERE annual.member.id = :memberId " +
            "AND FUNCTION('YEAR', annual.annualDateLeave) = :year")
    long countByMemberId(long memberId, int year);

    @Query("SELECT annual FROM AnnualLeave annual " +
            "WHERE annual.member.id = :memberId " +
            "AND FUNCTION('YEAR', annual.annualDateLeave) = :year " +
            "AND FUNCTION('MONTH', annual.annualDateLeave) = :month " +
            "AND annual.annualDateLeave <= CURRENT_DATE()")
    List<AnnualLeave> findAllAnnualLeavesByMemberIdAndYearMonth(long memberId, int year, int month);
}
  • countByMemberId는 남은 연차를 계산할때 사용합니다. 기본적으로 memberId현재년도
    조회하여, 올해 사용한 연차의 갯수를 반환합니다.

  • findAllAnnualLeavesByMemberIdAndYearMonthmemberId, 요청년도, 요청월,로 조회하며,
    현재 날짜 이전의 연차 사용기록 리스트를 반환합니다.
    현재 날짜 이전으로 설정하지 않는다면, 03월 08일2024-03월 근무기록 조회시,
    03월 15일에 신청한 연차 기록이 날짜별 근무시간 조회로 반환될 것입니다.
    뭔가 굉장히 어색하고 만약 내가 서비스 이용자였다면, 굉장히 유저 경험이 좋지 않았을듯 하여 수정하였습니다.

구현 결과

  • 연차 신청

    📌 MemberId : 2Member2024-12-12연차 신청

    📌 2024-12-12에 연차등록 완료


  • 연차 조회

    📌 Id : 6member의 남은 연차 조회

    Hibernate: 
       select
           m1_0.id,
           m1_0.birthday,
           m1_0.work_start_date,
           m1_0.name,
           m1_0.role,
           m1_0.team_id 
       from
           member m1_0 
       where
           m1_0.id=?
    Hibernate: 
       select
           count(*) 
       from
           annual_leave al1_0 
       where
           al1_0.member_id=? 
           and year(al1_0.annual_date_leave)=?

    📌 전송되는 쿼리문

    📌 사용한 연차 수가 5개이지만,
    입사한지 1년이 지나지 않았기 때문에 남은 연차 : 6 이 반환된걸 확인할 수 있습니다.

  • 특정 직원의 날짜별 근무시간을 조회하는 기능 Version02

    📌 2024-03월 근무기록 조회

    Hibernate: #멤버 검색
       select
           m1_0.id,
           m1_0.birthday,
           m1_0.work_start_date,
           m1_0.name,
           m1_0.role,
           m1_0.team_id 
       from
           member m1_0 
       where
           m1_0.id=?
    Hibernate: #요청 년,월 근무기록 조회
       select
           c1_0.id,
           c1_0.attendance,
           c1_0.start_of_work,
           c1_0.member_id,
           c1_0.end_of_work 
       from
           commute c1_0 
       where
           c1_0.member_id=? 
           and year(c1_0.start_of_work)=? 
           and month(c1_0.start_of_work)=?
    Hibernate: #요청 년, 월 연차기록 조회(미래의 연차기록은 조회X)
       select
           al1_0.id,
           al1_0.annual_date_leave,
           al1_0.member_id 
       from
           annual_leave al1_0 
       where
           al1_0.member_id=? 
           and year(al1_0.annual_date_leave)=? 
           and month(al1_0.annual_date_leave)=? 
           and al1_0.annual_date_leave<=current_date

    📌 전송되는 쿼리문

    {
       "detail": [
           {
               "date": "2024-03-01",
               "workingMinutes": 867,
               "usingDayOff": false
           },
           {
               "date": "2024-03-02",
               "workingMinutes": 0,
               "usingDayOff": true
           },
           {
               "date": "2024-03-03",
               "workingMinutes": 0,
               "usingDayOff": true
           },
           {
               "date": "2024-03-04",
               "workingMinutes": 0,
               "usingDayOff": true
           },
           {
               "date": "2024-03-05",
               "workingMinutes": 0,
               "usingDayOff": true
           },
           {
               "date": "2024-03-06",
               "workingMinutes": 619,
               "usingDayOff": false
           },
           {
               "date": "2024-03-07",
               "workingMinutes": 685,
               "usingDayOff": false
           },
           {
               "date": "2024-03-08",
               "workingMinutes": 0,
               "usingDayOff": true
           }
       ],
       "sum": 2171
    }

    📌 연차를 사용했을 경우, usingDayOff : true 반환


📌 edge-case

  • 연차를 사용한 직원이 출근하려는 경우


  • 각 팀별 설정 연차 등록일 이전에 연차를 사용하려하는 경우


  • 해당일에 이미 연차를 등록한 경우


  • 과거로 연차를 사용하려 하는 경우


  • 올해의 연차를 모두 사용한 경우


0개의 댓글