[인프런 워밍업 클럽 0기 BE] - 세 번째 발걸음

[인프런 워밍업 클럽 0기 BE] - 세 번째 발걸음

미니 프로젝트 Step 02

구현 내용

* ①출근 기능

* 등록된 직원은 출근을 할 수 있어야 한다. 출근의 경우 이름은 동명이인이 있을 수 있으므로, DB에 등록된 ID를 기준으로 처리된다.

<br>

* ②퇴근 기능

* 출근한 직원은 퇴근을 할 수 있어야 한다. 퇴근 역시 DB에 등록된 ID르ㅜㄹ 기준으로 처리된다.

<br>

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

* 특정 직원 id2024-01과 같이 연/월을 받으면, 날짜별 근무 시간과 총 합을 반환해야 한다. 이때 근무 시간은 분단위로 계산된다.

* 예를 들어, 1번 id를 갖는 직원에 대해 2024-01을 기준으로 조회하면, 다음과 같은 응답이 반환되어야 한다.

{

"detail": [

{

"date": "2024-01-01",

"workingMinutes": 480

},

{

"date": "2024-01-02",

"workingMinutes": 490

},

... // 2024년 1월 31일까지 존재할 수 있다.

]

"sum": 10560

}

📌 edge-case

> - 등록되지 않은 직원이 출근 하려는 경우

> - 출근한 직원이 또 다시 출근하려는 경우

> - 퇴근하려는 직원이 출근하지 않았던 경우

> - 그 날, 출근했다 퇴근한 직원이 다시 출근하려는 경우

 

 

---

과정

* Table

> 💡 고민 1.

테이블을 어떻게 짤까?

CREATE TABLE commute

(

id bigint auto_increment,

start_of_work datetime,

end_of_work datetime,

attendance tinyInt,

member_id bigint,

primary key (id)

);

> 📌 JPA Auditing

저번 피드백에서 @EntityListeners(AuditingEntityListener.class) 어노테이션을 사용하는

BaseEntity 추상 클래스를 만들어 봤는데, BaseEntityCreatedAt, UpdatedAt 필드를 상속받고 <br> 출/퇴근시간을 자동으로 기록하는게 개인적인 목표입니다!

<br>

*Controller

@RestController

@RequiredArgsConstructor

public class CommuteController {

private final CommuteService commuteService;

@PostMapping("/start-of-work")

public void startOfWork(@Valid @RequestBody startOfWorkRequest request) {

commuteService.startOfWork(request);

}
@PostMapping("/end-of-work")

public void endOfWork(@Valid @RequestBody endOfWorkRequest request) {

commuteService.endOfWork(request);

}
@GetMapping("/commute")

public ResponseEntity<GetCommuteRecordResponse> GetCommuteRecord(@Valid GetCommuteRecordRequest request){

GetCommuteRecordResponse getCommuteRecordResponse = commuteService.GetCommuteRecord(request);

return ResponseEntity.ok().body(getCommuteRecordResponse);

}

}

 

> 📌 클래스를 매개변수로 사용하는 경우

클래스 형태의 객체를 매개변수로 받는 컨트롤러 메소드에서 별도의 어노테이션을 사용하지 않는 경우,

스프링은 기본적으로 쿼리 파라미터를 클래스의 프로퍼티와 매핑한다.

@RequestParam 어노테이션을 사용하면 매개변수가 쿼리 파라미터로 넘어오는 것이 아니라, 매개변수 자체가 요청의 특정 파라미터와 매핑되도록 기대한다.

따라서 클래스 타입의 객체를 @RequestParam으로 직접 받는다면 쿼리 파라미터 매핑이 자동으로 이뤄지지 않는다.

<br>

DTO(Request)

public record endOfWorkRequest(@NotNull long id) {
public record GetCommuteRecordRequest(@NotNull long id, @DateTimeFormat(pattern = "yyyy-MM") YearMonth yearMonth) {
}
public record startOfWorkRequest(@NotNull long id) {

public Commute toEntity(Member member){

return Commute.builder()

.member(member)

.build();

}

💡 저번 피드백에서 record 사용법을 배워서 record로 생성하였습니다.

<br>

* Domain

@Getter

@Entity

@NoArgsConstructor(access = AccessLevel.PROTECTED)

@AttributeOverrides({

@AttributeOverride(name= "createdAt", column = @Column(name= "start_of_work")),

@AttributeOverride(name= "updatedAt", column = @Column(name= "end_of_work"))

})

public class Commute extends BaseEntity { //BaseEntity를 상속받음

@Id

@GeneratedValue(strategy = GenerationType.IDENTITY)

private long id;

private boolean attendance = true; // 출근 상태

@ManyToOne(fetch= FetchType.LAZY)

private Member member;

public void endOfWork(){

this.attendance = false;

}
@Builder

public Commute(Member member){

this.member = member;

}
}

 

💡 저번 피드백에서 @EntityListeners(AuditingEntityListener.class) 어노테이션을 사용하는

추상 클래스 생성을 권유 받았는데, 만들고 나니 써먹고 싶어져서 추상 클래스를 상속받고,

attendance값의 변경을 통해 출/퇴근을 구현해보려고 합니다.

<br>

💡 처음에는 Member 도메인과 1 : N 양방향 연관 관계로 설계를 해뒀었는데,

블로그를 작성하면서 확인해보니, Member에 맺어둔 @OneToMany를 전혀 사용을 안했다는걸 깨닫고,

Commute@ManyToOne 단방향 연관관계만 살려두었습니다.

<br>

* Service

@Service

@Slf4j

@RequiredArgsConstructor

public class CommuteService {

private final CommuteRepository commuteRepository;

private final MemberRepository memberRepository;
@Transactional

public void startOfWork(startOfWorkRequest request) {

Member member = findMemberById(request.id());

Commute latestCommute = findLatestCommuteByMember(member);

if (latestCommute.isAttendance()) throw new AbsentCheckOutException(); //이전 기록 퇴근확인

boolean isAlreadyAttendance = LocalDate.now().equals(LocalDate.from(latestCommute.getCreatedAt()));

if (isAlreadyAttendance) throw new AlreadyAttendanceException(); //당일 출근기록 확인

commuteRepository.save(request.toEntity(member));

}
@Transactional

public void endOfWork(@RequestBody endOfWorkRequest request) {

Member member = findMemberById(request.id());

Commute latestCommute = findLatestCommuteByMember(member);

if (!latestCommute.isAttendance()) throw new AlreadyDepartureException();

latestCommute.endOfWork(); //변경감지 자동저장

}
@Transactional

public GetCommuteRecordResponse GetCommuteRecord(GetCommuteRecordRequest request) {

findMemberById(request.id());

List<GetCommuteDetail> commuteDetailList = findCommuteListByMemberIdAndStartOfWork(request);

Long sum = commuteDetailList.stream()

.map(GetCommuteDetail::workingMinutes)

.reduce(0L, Long::sum);

//commuteDetailList에서 workingMinutes를 조회, reduce로 합을 반환

return new GetCommuteRecordResponse(commuteDetailList, sum);

}

private Member findMemberById(long id) {

return memberRepository.findById(id).orElseThrow(MemberNotFoundException::new);

}

private Commute findLatestCommuteByMember(Member member) {

return commuteRepository.findLatestCommuteByMemberId(member.getId())

.orElseThrow(CommuteNotFoundException::new);

}

private List<GetCommuteDetail> findCommuteListByMemberIdAndStartOfWork(GetCommuteRecordRequest request) {

List<Commute> commuteList =

commuteRepository.findCommuteListByMemberIdAndStartOfWork(request.id(), request.yearMonth().getYear(), request.yearMonth().getMonth().getValue());

if (commuteList.isEmpty()) throw new CommuteNotFoundException(); //해당범위에 통근기록 존재 X

return commuteList.stream().map(GetCommuteDetail::from).toList(); //CommuteDetail으로 변환

}

}

 

💡**고민 2.**

현재 startOfWorkisAlreadyAttendance(전 기록 퇴근처리 확인) 기능이 과연 필요한가 고민중입니다.

야근 후, 12시가 넘어서 퇴근을 찍지않고 출근을 찍을 경우를 대비해서 넣어뒀는데,

퇴근을 찍지않으면 그 날의 CreatedAt(출근시간)UpdatedAt(퇴근시간) 이 동일하여

근무시간이 0으로 찍히기 떄문에 본인이 알아서 인사팀에 찾아가지 않을까요? 🤔

<br>

* DTO(Reponse)

@Builder

public record GetCommuteDetail(LocalDate date, long workingMinutes) {

public static GetCommuteDetail from(Commute commute){

Duration duration = Duration.between(commute.getCreatedAt(), commute.getUpdatedAt());

return GetCommuteDetail.builder()

.date(commute.getCreatedAt().toLocalDate())

.workingMinutes(duration.toMinutes())

.build();
}
}
public record GetCommuteRecordResponse(List<GetCommuteDetail> detail, long sum) {

}

💡 DTO 반환 과정에서 Duration을 활용해 생성시간과 수정시간의 차이를 분으로 바꿔줬습니다.

<br>

* Repository

public interface CommuteRepository extends JpaRepository<Commute, Long> {

@Query("SELECT latestcommute FROM Commute latestcommute WHERE latestcommute.member.id = :memberId AND latestcommute.createdAt = (SELECT MAX(commute.createdAt) FROM Commute commute WHERE commute.member.id = :memberId)")

Optional<Commute> findLatestCommuteByMemberId(Long memberId);

@Query("SELECT commute FROM Commute commute WHERE commute.member.id= :memberId AND FUNCTION('YEAR', commute.createdAt)= :year AND FUNCTION('MONTH', commute.createdAt)= :month")

List<Commute> findCommuteListByMemberIdAndStartOfWork(Long memberId, int year, int month);

}

 

💡 다른분들께 배운 JPQL 활용해보기

* findLatestCommuteByMemberId = SELECT MAX를 통해 가장 최근의 통근 기록을 조회한다.

* findCommuteListByMemberIdAndStartOfWork = GetCommuteRecordRequest

①`MemberId`, ②`year(request.yearMonth().getYear())`, ③`month(request.getMonth().getValue())`

값을 만족하는 모든 Commute를 조회한다.


 

# 구현 결과

  • ①출근 기능

  • 등록된 직원은 출근을 할 수 있어야 한다. 출근의 경우 이름은 동명이인이 있을 수 있으므로, DB에 등록된 ID를 기준으로 처리된다.

![](https://velog.velcdn.com/images/vosxja1/post/b9cc82f7-5bc9-4fa0-815f-11130f5d5a05/image.png)![](https://velog.velcdn.com/images/vosxja1/post/85ac2b6b-4e68-42c2-997b-81021840c187/image.png)

<br>

  • ②퇴근 기능

  • 출근한 직원은 퇴근을 할 수 있어야 한다. 퇴근 역시 DB에 등록된 ID를 기준으로 처리된다.

![](https://velog.velcdn.com/images/vosxja1/post/3dd809d4-5a5a-4942-be1e-af6d74c8c3a4/image.png)![](https://velog.velcdn.com/images/vosxja1/post/a9cf93c1-93ee-4e3b-bd17-70dc86d99f7e/image.png)

<br>

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

  • 특정 직원 id2024-01과 같이 연/월을 받으면, 날짜별 근무 시간과 총 합을 반환해야 한다. 이때 근무 시간은 분단위로 계산된다.

  • 예를 들어, 1번 id를 갖는 직원에 대해 2024-01을 기준으로 조회하면, 다음과 같은 응답이 반환되어야 한다.

![](https://velog.velcdn.com/images/vosxja1/post/01d2ef12-b8f1-42bf-b206-50e738bf0a29/image.png)![](https://velog.velcdn.com/images/vosxja1/post/8c8c536e-2f3f-4ebd-a869-19956daa1780/image.png)

 

 

 

코드 리뷰 Step 02

피드백 1.

@Query("SELECT latestcommute FROM Commute latestcommute

WHERE latestcommute.member.id = :memberId AND latestcommute.createdAt =

(SELECT MAX(commute.createdAt) FROM Commute commute

WHERE commute.member.id = :memberId)")

 

![](https://velog.velcdn.com/images/vosxja1/post/f204010b-c67e-45be-8702-48f3939136de/image.png)

📌 서브 쿼리를 이용해 가장 최근 기록을 가져오셨군요! 생각하지 못한 방법 또 하나 배우고 갑니다! 😊

추가로 질문을 드리자면 저 같은 경우는 서브쿼리는 성능이 걱정되어 불가피한 상황이 아닌 경우에는 지양하는 편인데 영훈님은 어떻게 생각하시는지 궁금합니다...!

<br>

해결 과정 1.

1. JPQL을 활용해 Join ORDER BY LIMIT 으로 변경

  • 우선 쿼리문을 수정했습니다.

@Query("SELECT latestcommute FROM Commute latestcommute

JOIN latestcommute.member member

WHERE member.id = :memberId

ORDER BY latestcommute.createdAt DESC")
  • 하지만 LIMIT를 활용하려 하니, JPQL 자체적으론 LIMIT를 지원하지 않는다는 사실을 알게 되었습니다.

@Query("SELECT latestcommute FROM Commute latestcommute

JOIN latestcommute.member member

WHERE member.id = :memberId

ORDER BY latestcommute.createdAt DESC")

List<Commute> findFirstByMemberId(long MemberId);
# 실제 쿼리 조회

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

join

member m1_0

on m1_0.id=c1_0.member_id

where

m1_0.id=?

order by

c1_0.start_of_work desc
  • 받아온 List를 Service 단에서 처리하려다, 모든 List를 받아오는것이 마음에 들지 않아서 JPA에 대해 조금 더 찾아보았습니다.

2. JPA 쿼리 메서드 활용

  • JPQL에서 LIMIT을 지원하지 않는데, 굳이 JPQL을 사용할 이유가 없었습니다.

Optional<Commute> findFirstByMemberIdOrderByCreatedAtDesc(Long memberId);
  • 해당 쿼리 메서드 조회시 실제 전송되는 쿼리문

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

left join

member m1_0

on m1_0.id=c1_0.member_id

where

m1_0.id=?

order by

c1_0.start_of_work desc

limit

?

💡 서브쿼리를 사용하지 않고, Left Join을 통해 최근순으로 정렬하고,

LIMIT을 통해 제일 처음(가장 최근)`Commute`을 반환합니다.


피드백 2.

public record startOfWorkRequest(@NotNull long id) {

public Commute toEntity(Member member){

return Commute.builder()

.member(member)

.build();
}
}

![](https://velog.velcdn.com/images/vosxja1/post/35fca9b4-8685-49ca-905e-58ddfdb26abd/image.png)

📌 요청값 검증 처리를 위해 @NotNull 등 어노테이션을 사용하고 계신데,

요청값에 대해 예외가 발생하면 어떤식으로 응답이 나가는지 알고 계신가요?

몇몇 커스텀 예외에 대해 핸들링 해서 일관된 응답 형식으로 응답이 나가고 있는데,

요청값 검증 예외는 다른 형식으로 응답이 나갈것 같습니다.

<br>

해결 과정 2.

Edge-Case의 Exception을 우선적으로 설정하느라 @Valid 로 값 검증을 하고 있으면서도

ExceptionHandler 에서 정작 ValidException 처리하는 메서드를 만들어 놓지 않았다는걸 깨달았습니다.

따로 ValidException 처리를 하지 않았기때문에, 요청값 검증 예외가 발생한다면,

전부 500 Internal-server-error로 처리 되었을 것입니다.

 

@ExceptionHandler(MethodArgumentNotValidException.class)
protected ResponseEntity<ErrorResponse> handle(MethodArgumentNotValidException e){
e.getStackTrace();
log.error("MethodArgumentNotValidException", e);
return createErrorResponse(ErrorCode.INVALID_INPUT_VALUE);
}

![](https://velog.velcdn.com/images/vosxja1/post/b44cfa4c-1c57-450f-9afa-50fc8de0eb9c/image.png)

💡 MethodArgumentNotValidException 예외가 발생한다면,

400 Bad Request와 올바르지 않은 입력값이라는 메세지가 출력되도록 했습니다.


미니 프로젝트 Step 03

구현 내용

  • 연차 신청

     

    • 이제부터 직원은 연차를 신청할 수 있습니다.

    • 연차는 무조건 하루 단위로만 사용이 가능합니다.

    • 올해 입사한 직원은 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은 무엇인지, 비즈니스 로직은 어디서 어떻게 처리할지가 고민이었습니다.

<br>

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 annual(id bigint auto_increment,annual_date_leave datetime,member_id bigint,primary key (id));

 

📌 기존 team 테이블 수정

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

<br>

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 메서드를

추가하였습니다.

<br>

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 어노테이션을 사용하여 연차를 과거로 떠나려는 시도를 막았습니다. 😊

<br>

  • 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를 수정하였습니다.

<br>

  • 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;

}

}

<br>

  • 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를 참조하도록 하였습니다.

<br>

  • 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일에 신청한 연차 기록이 날짜별 근무시간 조회로 반환될 것입니다.

뭔가 굉장히 어색하고 만약 내가 서비스 이용자였다면, 굉장히 유저 경험이 좋지 않았을듯 하여 수정하였습니다.

 


구현 결과

연차 신청

![](https://velog.velcdn.com/images/vosxja1/post/70105182-d948-4f79-be14-7982f027b647/image.png)

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

![](https://velog.velcdn.com/images/vosxja1/post/0e2366b3-f3fc-46ec-9be5-e827736d1e04/image.png)

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

<br>

연차 조회

![](https://velog.velcdn.com/images/vosxja1/post/ecde2bdb-201d-4ec2-b26c-1c21978f72a5/image.png)

📌 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)=?

📌 전송되는 쿼리문

![](https://velog.velcdn.com/images/vosxja1/post/9d9f29cb-41c5-4d8a-bbc0-6d106f643c16/image.png)![](https://velog.velcdn.com/images/vosxja1/post/c16e3777-97e7-4eaa-a7d1-9fee82f77695/image.png)

📌 사용한 연차 수가 5개이지만,

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

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

![](https://velog.velcdn.com/images/vosxja1/post/51f59cb9-021f-443f-81f5-fe004242a5d7/image.png)

📌 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

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

![](https://velog.velcdn.com/images/vosxja1/post/686b18de-60a0-4cbe-8ea5-eccc4a07cd6f/image.png) <br>

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

![](https://velog.velcdn.com/images/vosxja1/post/08615a7e-58fc-415a-afa8-16d0690c9212/image.png) <br>

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

![](https://velog.velcdn.com/images/vosxja1/post/268a6a7e-4395-443a-a256-c11577184bfc/image.png) <br>

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

![](https://velog.velcdn.com/images/vosxja1/post/2114d5ea-9c0a-4f9c-a1be-09b0f60d7238/image.png) <br>

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

![](https://velog.velcdn.com/images/vosxja1/post/4781e9db-70f5-4356-8b7e-7aff44fc1511/image.png)


코드 리뷰 Step 03

피드백 1.

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;

![](https://velog.velcdn.com/images/vosxja1/post/1861c4b0-1b0f-47b0-aabc-7318230fff52/image.png)

📌 매직 넘버를 상수로 처리하면 가독성이 더 좋아질 것 같은데, 어떻게 생각하시나요?

<br>

나의 답변 1.

![](https://velog.velcdn.com/images/vosxja1/post/864ad1d5-caa6-4462-969d-9ab836256c33/image.png)

<br>


해결 과정 1.

long maxAnnualLeave = ChronoUnit.YEARS.between(member.getCreatedAt(), LocalDateTime.now()) >= 1 ? 15L : 11L;

안그래도 이 부분의 15L : 11L 가 조금 명확하지 않다고 생각하여 (15L, 11L이 무슨 숫자인지 알 수가 없다.)

enum처리를 하려 합니다.

@RequiredArgsConstructor

public enum JoinDate {

OVER_ONE_YEAR(15L),

UNDER_ONE_YEAR(11L);

private final long maxAnnualLeaves;

public long getAnnualLeaves(){return maxAnnualLeaves;}

}
long maxAnnualLeave = ChronoUnit.YEARS

.between(member.getCreatedAt(), LocalDateTime.now()) >= 1 ?

JoinDate.OVER_ONE_YEAR.getAnnualLeaves() : JoinDate.UNDER_ONE_YEAR.getAnnualLeaves();

💡 JoinDateenum으로 생성하고, maxAnuualLeaveenum으로 처리하였습니다.


 

피드백 2.

//해당범위에 통근기록 존재 X? -> 통근기록없음 예외처리

List<GetCommuteDetail> commuteDetailList = commuteList.stream()

.map(GetCommuteDetail::from)

.collect(Collectors.toList()); //통근기록을 CommuteDetail으로 변환

![](https://velog.velcdn.com/images/vosxja1/post/2bce0c18-9dea-4306-a906-266e281e72e3/image.png)

📌`Collectors.toList()`와 Stream.toList()의 차이를 아시나요? Collectors.toList()를 사용한 이유가 궁금합니다!!

<br>

나의 답변 2.

![](https://velog.velcdn.com/images/vosxja1/post/4bd8049d-dd4e-4f01-a0cb-6be2b8038e30/image.png)

<br>


해결 과정 2.

안그래도 변환 가능한 리스트를 반환하는게 조금 신경 쓰였는데,

MergeAndSort 종료 후, Merge가 완료된 CommuteListCollections.unmodifiableList()를 통해 불변 List로

감싸서 반환하도록 처리하겠습니다.

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); // .addAll()을 통한 merge

return Collections.unmodifiableList(commuteDetailList); // 불변리스트로 변환 후 반환

💡 returncommuteDetailListCollections.unmodifiableList()로 감싸서 불변List로 만들어 주었습니다.


 

댓글을 작성해보세요.