
연차 등록을 해야 합니다.id를 이용해 올해 사용하지 않고 남은 연차를 확인할 수 있습니다.Version02project_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은 무엇인지, 비즈니스 로직은 어디서 어떻게 처리할지가 고민이었습니다.
TableCREATE 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);
}
}
💡 각 팀별 연차 등록일 설정을 위해
TeamController에updateDayBeforeAnnual메서드를
추가하였습니다.
DTOAnnualLeavepublic 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어노테이션을 사용하여 연차를 과거로 떠나려는 시도를 막았습니다. 😊
Commutepublic 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를 참조하도록 하였습니다.
Repositorypublic 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와현재년도로
조회하여, 올해 사용한 연차의 갯수를 반환합니다.
findAllAnnualLeavesByMemberIdAndYearMonth는memberId,요청년도,요청월,로 조회하며,
현재 날짜 이전의 연차 사용기록 리스트를 반환합니다.
현재 날짜 이전으로 설정하지 않는다면,03월 08일에2024-03월 근무기록 조회시,
03월 15일에 신청한 연차 기록이 날짜별 근무시간 조회로 반환될 것입니다.
뭔가 굉장히 어색하고 만약 내가 서비스 이용자였다면, 굉장히 유저 경험이 좋지 않았을듯 하여 수정하였습니다.

📌
MemberId : 2인Member의2024-12-12연차 신청

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

📌
Id : 6인member의 남은 연차 조회
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
연차를 사용한 직원이 출근하려는 경우
![]()
각 팀별 설정 연차 등록일 이전에 연차를 사용하려하는 경우
![]()
해당일에 이미 연차를 등록한 경우
![]()
과거로 연차를 사용하려 하는 경우
![]()
올해의 연차를 모두 사용한 경우