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

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

구현 내용

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

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

  • ③특정 직원의 날짜별 근무시간을 조회하는 기능
    • 특정 직원 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 필드를 상속받고
출/퇴근시간을 자동으로 기록하는게 개인적인 목표입니다!


  • 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으로 직접 받는다면 쿼리 파라미터 매핑이 자동으로 이뤄지지 않는다.


  • 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로 생성하였습니다.


  • 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값의 변경을 통해 출/퇴근을 구현해보려고 합니다.


💡 처음에는 Member 도메인과 1 : N 양방향 연관 관계로 설계를 해뒀었는데,
블로그를 작성하면서 확인해보니, Member에 맺어둔 @OneToMany를 전혀 사용을 안했다는걸 깨닫고,
Commute@ManyToOne 단방향 연관관계만 살려두었습니다.


  • 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으로 찍히기 떄문에 본인이 알아서 인사팀에 찾아가지 않을까요? 🤔


  • 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을 활용해 생성시간과 수정시간의 차이를 분으로 바꿔줬습니다.


  • 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를 기준으로 처리된다.


  • ②퇴근 기능

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


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

    • 특정 직원 id2024-01과 같이 연/월을 받으면, 날짜별 근무 시간과 총 합을 반환해야 한다. 이때 근무 시간은 분단위로 계산된다.
    • 예를 들어, 1번 id를 갖는 직원에 대해 2024-01을 기준으로 조회하면, 다음과 같은 응답이 반환되어야 한다.
  • ④edge-case

    • 등록되지 않은 직원이 출근 하려는 경우
    • 출근한 직원이 또 다시 출근하려는 경우

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

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


0개의 댓글