[인프런 워밍업 클럽 0기 BE] - 세 번째 발걸음
미니 프로젝트 Step 02
구현 내용
* ①출근 기능
* 등록된 직원은 출근을 할 수 있어야 한다. 출근의 경우 이름은 동명이인이 있을 수 있으므로, DB에 등록된 ID
를 기준으로 처리된다.
<br>
* ②퇴근 기능
* 출근한 직원은 퇴근을 할 수 있어야 한다. 퇴근 역시 DB에 등록된 ID
르ㅜㄹ 기준으로 처리된다.
<br>
* ③특정 직원의 날짜별 근무시간을 조회하는 기능
* 특정 직원 id
와 2024-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
추상 클래스를 만들어 봤는데, BaseEntity
의 CreatedAt
, 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.**
현재
startOfWork
의isAlreadyAttendance
(전 기록 퇴근처리 확인) 기능이 과연 필요한가 고민중입니다.야근 후, 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
를 기준으로 처리된다.
<br>
②퇴근 기능
출근한 직원은 퇴근을 할 수 있어야 한다. 퇴근 역시 DB에 등록된
ID
를 기준으로 처리된다.
<br>
③특정 직원의 날짜별 근무시간을 조회하는 기능
특정 직원
id
와2024-01
과 같이 연/월을 받으면, 날짜별 근무 시간과 총 합을 반환해야 한다. 이때 근무 시간은 분단위로 계산된다.예를 들어,
1번
id를 갖는 직원에 대해2024-01
을 기준으로 조회하면, 다음과 같은 응답이 반환되어야 한다.
④edge-case
등록되지 않은 직원이 출근 하려는 경우![](https://velog.velcdn.com/images/vosxja1/post/b47982b1-bfb6-4f34-981d-46e816e2ffe1/image.png)
출근한 직원이 또 다시 출근하려는 경우![](https://velog.velcdn.com/images/vosxja1/post/16e45880-37e3-4d5d-bc03-97a3e2b69365/image.png)
퇴근하려는 직원이 출근하지 않았던 경우![](https://velog.velcdn.com/images/vosxja1/post/808de891-11e2-41dc-ab8c-c3747d2e3d59/image.png)
그 날, 출근했다 퇴근한 직원이 다시 출근하려는 경우![](https://velog.velcdn.com/images/vosxja1/post/2ba0fb2b-3b3b-4a55-9ad7-7168c6f811aa/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);
}
}
💡 각 팀별 연차 등록일 설정을 위해
TeamController
에updateDayBeforeAnnual
메서드를추가하였습니다.
<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
와현재년도
로조회하여, 올해 사용한 연차의 갯수를 반환합니다.
findAllAnnualLeavesByMemberIdAndYearMonth
는memberId
,요청년도
,요청월
,로 조회하며,
현재 날짜 이전
의 연차 사용기록 리스트를 반환합니다.현재 날짜 이전으로 설정하지 않는다면,
03월 08일
에2024-03
월 근무기록 조회시,
03월 15일
에 신청한 연차 기록이 날짜별 근무시간 조회로 반환될 것입니다.뭔가 굉장히 어색하고 만약 내가 서비스 이용자였다면, 굉장히 유저 경험이 좋지 않았을듯 하여 수정하였습니다.
구현 결과
연차 신청
![](https://velog.velcdn.com/images/vosxja1/post/70105182-d948-4f79-be14-7982f027b647/image.png)
📌
MemberId : 2
인Member
의2024-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 : 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
![](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();
💡
JoinDate
를enum
으로 생성하고,maxAnuualLeave
를enum
으로 처리하였습니다.
피드백 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
가 완료된 CommuteList
를 Collections.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); // 불변리스트로 변환 후 반환
💡
return
시commuteDetailList
를Collections.unmodifiableList()
로 감싸서 불변List로 만들어 주었습니다.
댓글을 작성해보세요.