강의 출처
프로젝트 2단계
DB 설계
- work 테이블
- Long id
- Long employee_id
- LocalDate work_date (출근 날짜)
- Time start_time (출근 시간)
- Time end_time (퇴근 시간)
- Long working_minutes (총 근무시간)
Domain
@Entity @Getter
@AllArgsConstructor
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Work {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private LocalDate workDate;
private LocalTime startTime;
private LocalTime endTime;
private Long workingMinutes;
@ManyToOne
@JoinColumn(nullable = false, name = "employee_id")
private Employee employee;
}
API 스펙 정하기
출근하기
- HTTP Method > POST
- HTTP Path > /startWork
- HTTP Param > HTTP Body
{
"employeeId" : Long id,
"workDate" : LocalDate
}
- 반환 타입 > 200 OK
퇴근하기
- HTTP Method > PUT
- HTTP Path > /endWork
- HTTP Param > HTTP Body
{
"employeeId" : Long id,
"workDate" : LocalDate
}
- 반환 타입 JSON
{
"employeeName" : String,
"teamName" : String,
"workDate" : LocalDate,
"workingMinutes" : Long
}
월별 조회
- HTTP Method > GET
- HTTP Path > /getWorkListOfMonth
- HTTP Param > HTTP Body (JSON)
{
"employeeId" : Long id,
"workMonth" : LocalDate
}
- HTTP 반환 (JSON)
출근하기 기능 개발
출근하기의 경우 요청 값으로 workDate를 굳이 받지 않아도 되지만 테스트를 위해 추가하기로했다. workDate가 없으면 현재 날짜로 DB에 저장하고 workDate가 있으면 workDate로 저장한다. 추후 개발 완료 시 workDate를 받지 않도록 수정할 예정
Controller
@PostMapping("/startWork")
public void startWork(@RequestBody WorkStartRequest request) {
workService.startWork(request);
}
Dto
@Getter
@AllArgsConstructor
public class WorkStartRequest {
private Long employeeId;
private LocalDate workDate;
}
Service
/**
* 직원의 출근 정보를 저장한다.
* @param request
*/
@Transactional
public void startWork(WorkStartRequest request) {
Employee findEmployee = employeeRepository.findById(request.getEmployeeId())
.orElseThrow(IllegalArgumentException::new);
Work newWork = new Work(request);
newWork.addEmployee(findEmployee);
workRepository.save(newWork);
}
Work domain
@Entity @Getter
@AllArgsConstructor
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Work {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private LocalDate workDate;
private String startTime;
private String endTime;
private Long workingMinutes;
@ManyToOne
@JoinColumn(nullable = false, name = "employee_id")
private Employee employee;
public Work(WorkStartRequest request) {
if(request.getWorkDate() == null) {
this.workDate = LocalDate.now();
}
else {
this.workDate = request.getWorkDate();
}
LocalTime nowTime = LocalTime.now();
this.startTime = nowTime.format(DateTimeFormatter.ofPattern("HH:mm"));
}
public void addEmployee(Employee employee) {
this.employee = employee;
}
}
도메인 파일에서 많이 수정되었다. startTime과 endTime은 내가 DB에 저장되길 원하는 포맷은 HH:mm이었는데 LocalTime으로는 원하는 포맷으로 저장할 수 없고 String으로 변환해야한다고 봐서 포맷을 변환하기 위해 필드 타입을 String으로 변경했다.
그리고 생성자로는 WorkStartRequest가 넘어오면 일단 workDate의 값이 있는지 확인 후 있으면 해당 값을 저장하고, 없으면 현재 날짜를 가져와서 저장하게 했다. 원래는 workDate 값을 받지 않고 그냥 현재 날짜를 now()로 해서 저장하면 되는데 테스트가 필요해 일단 추가했다. 추후 개발이 완료되면 지울 예정이다.
그리고 startTime의 경우 현재 시간을 가져와서 HH:mm 포맷으로 변경해준 뒤에 저장해줬다.
그리고 addEmployee() 같은 경우는 연관관계를 엮어주기 위해 작성했다.
WorkRepository
public interface WorkRepository extends JpaRepository<Work, Long> {
}
- 포스트맨 테스트
1~4번까지는 디폴트 포맷으로 저장되어 있고, 포맷터를 적용시켜주니 5~6번과 같이 내가 원하는 포맷으로 시간이 잘 정해져 있다.
음 근데 데이터 저장된 걸 보다 보니 working_minutes가 기본값이 null이 아닌 0이면 나중에 월 총 근무시간을 계산하는데 더 수월할 것 같아 domain에 prePersist() 메서드를 추가했다.
// insert 되기 전(persist 되기 전) 실행되는 메서드
@PrePersist
public void prePersist() {
this.workingMinutes = (this.workingMinutes == null) ? 0L : this.workingMinutes;
}
메서드 추가 후 테스트해보면 디폴트 값이 잘 들어간 걸 확인할 수 있다.
예외 사항 추가 (한 걸음 더!)
등록되지 않은 직원이 출근 하려는 경우
WorkService에 이미 구현이 되어있는데 에러 메세지가 너무 영어만 나와서 메세지를 추가해줬다.
Employee findEmployee = employeeRepository.findById(request.getEmployeeId()).orElseThrow(()
-> new IllegalArgumentException(String.format("등록되지 않은 직원 id(%d) 입니다.", request.getEmployeeId())));
- 포스트맨 테스트
출근한 직원이 또 다시 출근하려는 경우
정상 로직이 무엇일까 생각해봤는데 출근 체크 이후에 또 출근이 찍히면 잘못 누른 것으로 인식하고 최초 출근 시간을 그대로 유지해야하는게 맞다고 생각했다. 어쨌든 첫 번째 출근 버튼을 눌렀을때가 해당 날짜에 최초 출근 시간이니까!
생각한 로직은
1. 최초 출근 체크를 한다.
2. DB에 출근 시간을 저장한다.
3. 똑같은 id의 직원이 출근 체크를 또 한다.
4. DB에서 해당 일의 해당 id를 가진 직원이 출근 체크를했는지 여부를 확인한다.
5. 출근 체크가 되어있으면 "출근 처리가 이미 완료되었습니다." 메세지를 반환하고, 아니면 출근 처리를 한다.
WorkService
/**
* 직원의 출근 정보를 저장한다.
* @param request
*/
@Transactional
public void startWork(WorkStartRequest request) {
Employee findEmployee = employeeRepository.findById(request.getEmployeeId()).orElseThrow(()
-> new IllegalArgumentException(String.format("등록되지 않은 직원 id(%d) 입니다.", request.getEmployeeId())));
Work newWork = new Work(request);
if(workRepository.existsByWorkDateAndEmployeeId(newWork.getWorkDate(), request.getEmployeeId()))
{
throw new IllegalArgumentException(String.format("[%s] 해당 날짜에 출근 처리가 이미 완료되었습니다.", newWork.getWorkDate()));
}
newWork.addEmployee(findEmployee);
workRepository.save(newWork);
}
WorkRepository
public interface WorkRepository extends JpaRepository<Work, Long> {
boolean existsByWorkDateAndEmployeeId(LocalDate date, Long employeeId);
}
- 포스트맨 테스트
7번 직원으로 출첵을 시도한다.
2024-03-10일에 출근 체크가 되었다.
7번 직원으로 출첵을 한번 더 시도한다.
퇴근하기 기능 개발
Dto
@Getter
@AllArgsConstructor
public class WorkEndRequest {
private Long employeeId;
private LocalDate workDate;
}
Controller
@PutMapping("/endWork")
public void endWork(@RequestBody WorkEndRequest request) {
workService.endDate(request);
}
Domain
기능 개발을 위해 생성자를 추가해주었다.
public Work(WorkEndRequest request) {
if(request.getWorkDate() == null) {
this.workDate = LocalDate.now();
}
else {
this.workDate = request.getWorkDate();
}
LocalTime nowTime = LocalTime.now();
this.endTime = nowTime.format(DateTimeFormatter.ofPattern("HH:mm"));
}
생성자를 추가해보니 출근 기능에서 사용하는 생성자와 로직이 똑같아 따로 메서드로 뺐다.
그래서 setWorkDate(), getNowTime() 메서드가 새로 생기게 되었다.
public Work(WorkStartRequest request) {
setWorkDate(request.getWorkDate());
this.startTime = getNowTime();
}
public Work(WorkEndRequest request) {
setWorkDate(request.getWorkDate());
this.endTime = getNowTime();
}
// .... 생략
// workDate 값 저장
private void setWorkDate(LocalDate date) {
if(date == null) {
this.workDate = LocalDate.now();
}
else {
this.workDate = date;
}
}
// 현재 시간을 HH:mm 형식으로 반환
private String getNowTime() {
return LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm"));
}
Service
퇴근하기 기능을 개발하면서 도메인의 startWork, endWork 필드의 데이터 타입이 String에서 LocalTime으로 변경되었다. String으로 저장하니 깔끔은했지만 시간 계산을 할 때마다 LocalTime 타입으로 변경해줘야하는 번거로움이 생겨서 아래와 같이 도메인 코드를 변경했다.
private LocalTime startTime;
private LocalTime endTime;
// 생략
// 현재 시간을 HH:mm 형식으로 반환
private LocalTime getNowTime() {
return LocalTime.parse(LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm")));
}
이 상태에서 저장해보면 HH:mm의 정보까지만 저장되고 초 부분은 00으로 저장되어서 계산하기 편할 거 같아 이 방식을 선택하기로 했다.
그리고 출근하기 기능과 똑같이 직원 정보를 먼저 조회해 해당 직원이 존재하는 직원인지를 확인했다. 그러다보니 중복 코드가 발생해 따로 메서드를 뺐다.
/**
* 파라미터로 넘어온 직원의 id로 직원이 존재하는지 조회
* @param employeeId
* @return
*/
public Employee existsEmployeeCheck(Long employeeId) {
return employeeRepository.findById(employeeId).orElseThrow(()
-> new IllegalArgumentException(String.format("등록되지 않은 직원 id(%d) 입니다.", employeeId)));
}
그리고 퇴근 체크하는 날짜와 직원 아이디를 가져와 해당 날짜에 출근 처리가 되어 있는지 체크했다. newWork를 굳이 생성한 이유는 Work 생성자에 workDate 값을 세팅해주기 때문이다. 이 부분은 리팩토링이 좀 필요할 거 같다.
Work newWork = new Work(request);
if(!workRepository.existsByWorkDateAndEmployeeId(newWork.getWorkDate(), request.getEmployeeId())) {
throw new IllegalArgumentException
(String.format("[%s] 해당 날짜에 출근 처리가 되지 않아 퇴근 처리가 불가능합니다.", newWork.getWorkDate()));
}
그리고 workDate와 EmployeeId로 work 데이터를 찾아왔다.
Work findWork = workRepository.findByWorkDateAndEmployeeId(newWork.getWorkDate(), request.getEmployeeId());
findWork.setEndData();
근데 보다보니까 위에 exists~ 메서드에서 직원 정보를 조회하는거나, findByWorkDate~메서드에서 work를 조회해오는 로직이 비슷해보여 그냥 findByWorkDate~ 메서드를 Optional로 반환받고, 객체가 null일 경우에 출근 데이터가 없는 것으로 간주하여 예외를 터트려줬다.
Work findWork = workRepository.findByWorkDateAndEmployeeId(newWork.getWorkDate(), request.getEmployeeId())
.orElseThrow(()
-> new IllegalArgumentException(String.format("[%s] 해당 날짜에 출근 처리가 되지 않아 퇴근 처리가 불가능합니다.",newWork.getWorkDate())));
그리고 근무 시간을 저장해줬다.
// 근무 시간 저장
findWork.CalculateWorkingMinutes();
Work 도메인에 메서드 추가 개발
// 근무 시간 (startTime ~ endTime)을 저장한다.
public void CalculateWorkingMinutes() {
Duration diff = Duration.between(this.startTime, this.endTime);
this.workingMinutes = diff.toMinutes();
}
- 포스트맨 테스트
테스트를 하다가 문득 퇴근 시간이 24시가 넘어 버리면 어떡하지? 라는 생각이 스쳐지나갔다. 왜냐면 내가 그렇게 퇴근한 적이 몇 번 있었기 때문이다;; 엄청난 노예! 이런 노예가 나 뿐만 아니라서 다들 24시를 넘어서 퇴근할 경우도 있을텐데 그럼 지금 저장되는 데이터로는 계산할 수가 없다.. 그래서 start_time과 end_time의 필드를 또 LocalTime에서 LocalDateTime으로 변경했다. 와 이래서 꼼꼼한 설계가 중요하구나,,!
리팩토링
- Work 도메인
private LocalDateTime startTime;
private LocalDateTime endTime;
// 생략
// 현재 날짜와 시간을 yyyy-MM-dd HH:mm 형식으로 반환
private LocalDateTime getNowTime() {
return LocalDateTime.parse(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")));
}
대차게 오류 발생! 구글링 해보니까 LocalDateTime의 경우 포맷을 유지하면서 LocalDateTime 타입으로 저장이 안되는 것 같았다. 그래서 다들 LocalDateTime 타입을 쓰되 필드는 String으로해서 지정한 포맷이 적용돼서 저장되게 하는 것 같았다. 오우 그래서 다시 startTime과 endTime의 필드 타입을 String으로 변경해주었다.
private String startTime;
private String endTime;
// 생략...
// 현재 날짜와 시간을 yyyy-MM-dd HH:mm 형식의 String으로 반환
private String getNowTime() {
return LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"));
}
이렇게 코드를 변경하면 CalculateWorkingMinutes()에서 에러가 발생한다. startTime, endTime 둘 다 String 형식이기 때문에 계산을 할 수 없어서다. 그래서 아래와 같이 코드를 변경해주었다.
// 일 근무 시간을 저장한다. (분단위)
public void CalculateWorkingMinutes() {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
LocalDateTime start = LocalDateTime.parse(this.startTime, formatter);
LocalDateTime end = LocalDateTime.parse(this.endTime, formatter);
Duration diff = Duration.between(start, end);
this.workingMinutes = diff.toMinutes();
}
- 포스트맨 테스트
일단 LocalDateTime > String 필드 타입을 변경해서 저장까지 되게 수정해놨다. 여러 출퇴근 케이스를 생각해보고 그에 맞게 개발하면 될 것 같다.
- 전날 퇴근 기록이 없는데 출근 체크를 한 경우
> 전날 퇴근 시간은 일반적인 직장인 퇴근 시간인 18:00로 기록된다.
> 출근 체크 한 시간이 해당 날짜의 출근 시간으로 저장된다. - 전날 퇴근 기록이 있는데 출근 체크를 한 경우
> 출근 체크 한 시간이 해당 날짜의 출근 시간으로 저장된다. - 당일 출근 기록은 없는데 퇴근 체크를 할 경우
> 출근 기록이 없다는 메시지를 던진다. - 출근을 하루에 여러 번 체크했을 경우
> 해당 일자의 최초 출근 일자를 저장하며, 사용자에겐 이미 출근처리 되었다고 메시지를 던진다. - 출근했다가 최근 후, 다시 출근하는 경우
> 해당일의 경우 퇴근 시간을 없앤다. 그 후 다시 퇴근 처리하는 경우 최초 출근일 ~ 퇴근 체크 시간까지의 근무 시간을 다시 계산하여 저장함
Reference
- https://dotoridev.tistory.com/6