반응형

이전에 웹소켓을 사용해봤던 경험으로 이번에 사이드프로젝트로 프론트한분과 같이 채팅앱을 구현해보기로했다.

간단히 웹소켓을 만들고 채팅을 연결만 했던것에서 발전시켜서 채팅방을 만들고 채팅 기록을 남길수있도록 처리해주었다.

 

크게 만들어야할 기능들을 정리해보겠다.

1. 채팅기능 

2. 채팅방 

3. 채팅내용기록

4. 회원가입

 

이를 위해서 데이터베이스도 따로 erd로 정리를 해보았다.

 

 

회원가입 친구등록 그외 필요한 기능들을 위해서 db를 정의해 놓았다. 

 

라이브러리

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-websocket'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.mysql:mysql-connector-j'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
    implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
    implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
}

 

jpa, spring-security , validation , web, websocket , mysql을 사용하고있다.

 

이제 본격적으로 코드를 하나씩 작성해보자.

반응형
반응형

프론트에서 사용할채팅 기능을 위해서 찾아보다가 웹소켓이란것을 알게되었다. 

 

HTTP통신

 

보통은 클라이언트간에 데이터를 전송할때 1번 클라이언트가  2번에게 데이 보냈을때 2번이 이 데이터를 조회하기위해서는 새로고침을 눌러서 변경된데이터를 조회해야한다 이 방법은 3-way 핸드 쉐이크라고 하며 

 

연결

1번 => 2번 (내말들리니?) syn =>

1번 <= 2번 (잘 들려, 너도 들리니?) syn_send <= syn,ack

1번 => 2번 (ㅇㅇ 잘들려) ack => sync_rcvd

이렇게 3번 의과정을거쳐서 통신을 하게된다  

 

연결 종료

1번 => 2번 (할말 다 했으니까 이제 끊을게) close => 

1번 <= 2번 (알겠어 (끊을 준비를 하며)) closewait1 => closewait

1번 <= 2번 (이제 끊어도 돼) closewait2 <= close

1번 => 2번 (ㅇㅋ) ack =>

 

이런 연결과 종료 과정이 많고 http패킷에는 헤더와 바디 처럼 채팅 기능에는 필요없는 패킷을 가지고있어서 채팅 기능에는 주로 웹소켓이라는 전송기술을 사용하게 된다.

 

나는 스프링을 통해서 웹소켓을 구현했고 스프링에서 제공해주는 websocket라이브러리를 사용했다.

 

build.gradle

	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-websocket'

 

 

서버가 필요하기때문에 내장서버를 가진 starter-web과 websocket을 의존성으로 가져와야한다.

 

webSocketConfig

@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {

    private final WebSocketHandler webSocketHandler;
    private final SocketTextHandler socketTextHandler;
    private final HandshakeInterceptor ChattingHandshakeInterceptor;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        // endpoint 설정 : /api/v1/chat/{postId}
        // 이를 통해서 ws://localhost:8080/chat/rooms/{방번호} 으로 요청이 들어오면 websocket 통신을 진행한다.
        // setAllowedOrigins("*")는 모든 ip에서 접속 가능하도록 해줌
        registry.addHandler(socketTextHandler, "/chat/rooms/*")
                .addInterceptors(ChattingHandshakeInterceptor)
                //cors설정
                .setAllowedOrigins("*");
                //sockjs설정
//                .withSockJS();
    }
}

sockjs는 현재 cors에러가 생겨서 주석처리를 해놓았다.

 

setAllowedOrigins 는 cors메서드인데 여기서 허용하는 주소를 설정하면 해당하는주소만 접근할수있다고한다. 

현재는 " * " 로 모든 주소에서 접근이 가능하도록 처리해주었다. 

 

addInterceptors는  웹소켓에 클라이언트가 핸드셰이킹(연결)이 일어나기전에 필요한 처리를 해줄수있는 인터셉터를 추가해줄수있는 메서드이다. 

요청하는 주소에 방번호를 가지고있어서 해당 데이터를 처리해주기위해서 인터셉터를 추가해주었다.

 

ChattingHandShakeInterceptor

@Component
public class ChattingHandshakeInterceptor implements HandshakeInterceptor {
    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler,
                                   Map<String, Object> attributes) throws Exception {
        String path = request.getURI().getPath();
        String roomId = path.substring(path.lastIndexOf('/') + 1);
        //주소에서 방번호를 받아와 추가.
        attributes.put("roomId", roomId);
        return true;
    }

    @Override
    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler,
                               Exception exception) {

    }
}

 

 

여기서 넣은 roomId파라미터는 핸들러에서 session에 getAttributes().get("roomId") 로 받아올수있다.

 

 

이제 연결은 과정까지는 모두 만들어진것이다 이제 연결될때, 연결이 종료될때, 데이터를 보낼때 처리해줄 handler를 구현해야한다. configuration에서 파라미터로 들어가있는 socketTextHandler 코드이다. 

package com.example.chat_test.chat;

import com.example.chat_test.model.Room;
import com.example.chat_test.model.RoomRepository;
import java.util.HashSet;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

@Component
@RequiredArgsConstructor
@Slf4j
public class SocketTextHandler extends TextWebSocketHandler {
    private final RoomRepository roomRepository;

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        Long roomId = getRoomId(session);
        roomRepository.room(roomId).getSessions().add(session);
        log.info("새 클라이언트와 연결 되었습니다.");
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        Long roomId = getRoomId(session);
        Room currentRoom = roomRepository.room(roomId);

        log.info(message.getPayload());


        for (WebSocketSession connectedSession : currentRoom.getSessions()) {
            connectedSession.sendMessage(message);
        }
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        Long roomId = getRoomId(session);
        roomRepository.room(roomId).getSessions().remove(session);
        log.info("클라이언트와 연결이 해제 되었습니다.");
    }

    private Long getRoomId(WebSocketSession session) {
        return Long.parseLong(
                session.getAttributes()
                        .get("roomId")
                        .toString()
        );
    }
}

 

TextWebSocketHandler를 확장하게되면 필요한 메서드를 상속받아서 사용할수있게된다 . 

나는 현재 room , roomRepository를 통해서 방이있고 해당방에 들어가있는 세션들을 관리할수있게 처리 해두어서 

처음 세션이연결될때 해당하는 룸의 sessions컬렉션에 접속된 세션을 추가시켜주고 세션이 종료될때는 세션을 remove시키도록 해주었다. 

 

해당 방에만 메시지를 보내는 기능은 같은 방에있는 세션이 문자를 보낼때 해당 세션이가진 roomId를 받아와서 방을 조회하고 그 room이 가진 session에 모두 메시지를 send해주는 방법으로 처리해주었다. 

반응형
반응형

 

엑셀파일 저장기능 추가 - 추가 요구사항

 

이번에는 초과근무 계산한 결과를 엑셀의형태로 받을수있게 기능을 추가해주었다.

 

엑셀데이터를 다루기위해서 라이브러리를 추가해주었다. 

나는 apache poi라는 라이브러리를 사용해서 엑셀파일을 만들어주었다.

 

build.gradle - dependencies에 추가

// https://mvnrepository.com/artifact/org.apache.poi/poi-ooxml
implementation group: 'org.apache.poi', name: 'poi-ooxml', version: '5.2.0'

 

 

MemberController

 

@GetMapping("/overTimeExcel")
public void getExcel(Integer holidays, HttpServletResponse response) {
    excelService.getMemberExcel(holidays, response);
}

 

이전처럼 멤버컨트롤러에 엑셀기능으로 진입지점을만들어주었다. 

그리고 엑셀과 관련된 기능을 제공하기 위해서 엑셀서비스를 따로 만들어주었다. 

HttpServletResponse는 응답에 파일을 첨부해줘서 웹으로 다운받을수있도록 만들어주었다.

 

ExcelService

@Service
@RequiredArgsConstructor
public class ExcelService {
    private final MemberService memberService;

    public void getMemberExcel(Integer holidays,HttpServletResponse response){
        OverTimeRequest build = OverTimeRequest.builder()
                .yearMonth(getPreviousMonth())
                .holidays(holidays)
                .build();
        List<OverTimeResponse> overTime = memberService.getOverTime(build);
        getExcel(overTime,response);
    }

    private void getExcel(List object, HttpServletResponse response){

        try{
            String fileName = "OverTimeResult.xlsx";
            XSSFWorkbook workbook = new XSSFWorkbook();
            Sheet sheet = workbook.createSheet("초과 근무 계산결과");

            // HTTP 응답 헤더 설정
            response.setContentType("application/vnd.ms-excel");
            response.setHeader("Content-Disposition", "attachment; filename="+fileName);

            //헤더 생성
            Row headerRow = sheet.createRow(0);
            Field[] declaredFields = OverTimeResponse.class.getDeclaredFields();

            for(int i=0;i<declaredFields.length;i++){
                Cell headerCell = headerRow.createCell(i);
                headerCell.setCellValue(declaredFields[i].getName());
            }

            //데이터 생성
            for(int i=0;i<object.size();i++){
                Object o = object.get(i);
                Row row = sheet.createRow(i + 1);

                for(int j=0;j<declaredFields.length;j++){
                    Cell cell = row.createCell(j);
                    Field field = declaredFields[j];
                    field.setAccessible(true);
                    Object value;
                    try{
                        value = field.get(o); // 필드의 값을 가져옴
                        if (value != null) {
                            cell.setCellValue(value.toString());
                        }else{
                            cell.setCellValue(0);
                        }
                    }catch (IllegalAccessException e) {
                        e.printStackTrace();
                    }
                }
            }
            try(OutputStream out = response.getOutputStream()){
                workbook.write(out);
            }
            workbook.close();
        }
        catch (Exception e){
            e.printStackTrace();
        }

    }

    private YearMonth getPreviousMonth() {
        LocalDate previousMonth = LocalDate.now().minusMonths(1);
        return YearMonth.from(previousMonth);
    }
}

 

1. getMemberExcel에서 memberService의 getOverTime에 요청할 request를 생성해서 요청데이터를 보내고

응답으로 연장근무 데이터를 받아온다. 받아온 연장근무정보를 getExcel메서드에 파라미터로 넣어준다.

 

2. getExcel메서드에서는 다양한 타입을받아서 엑셀로만들기위해서 Object클래스를 파라미터 타입으로 설정해주었다.

 

첫번째 반복문에서는 클래스의 필드이름을받아서 반복문을 돌려서 엑셀의 첫번째행을 채워준다. 

id name overTimeResponse

 

2번째 반복문에서는 데이터를 순회하면서 행을만들고 그안에서 필드갯수로 중첩반복문을돌면서 셀을만들어서 데이터를 넣어주준다.

object1 첫번째 필드 object1 두 번째 필드 object1 세 번째 필드
object2 첫번째 필드  object2 두 번째 필드  object2 세 번째 필드

 

자바 스크립트에서 html테이블을 반복문으로 만드는것과 상당히 비슷하다.

 

엑셀 파일을만들어준후에 response 의 outputStream에 데이터를 실어주면 데이터를 다운받을수있다.

데이터를 엑셀로 변경

현재는 3월이고 저번달이 2월이라 근무정보가 없어서 연장근무가 없는걸로 나온다. 

필요한 응답데이터가있다면 처리해서 getExcel 메서드에 List타입으로만 넣어주면 어떤 데이터든 excel로 쉽게 출력할수있을것같다. 필요하다면 필드이름도 바꿔서 넣을수있을듯 하다.

반응형
반응형

 

 

요청할때 필요한 데이터는 연장근무를 찾아올 연도와 월 , 그리고 근무하지않는일수를 파라미터로 받도록 해주었다.

 

OverTimeRequest

@Getter
@Setter
@Builder
public class OverTimeRequest {
    @NotBlank
    @DateTimeFormat(pattern = "yyyy-MM")
    private YearMonth yearMonth;
    @NotNull
    private Integer holidays;
}

 

보통은 리퀘스트로 들어오는 클래스는 직접생성할일은 없기때문에 빌더를 붙이지는 않는데 이후에 추가될 기능때문에 

해당 클래스를 파라미터로 넣어줘야할 일이생겨서 빌더를 붙여주게되었다. 

 

이후에는 리팩토링을해서 request는 컨트롤러로 생성만되도록하고

추가적으로 사용하는메서드는 다른 파라미터를 사용하도록 변경해줘야한다.

 

MemberController

@PostMapping("/overTimeCalc")
public List<OverTimeResponse> getOverTime(@Validated @RequestBody OverTimeRequest overTimeRequest) {
    return service.getOverTime(overTimeRequest);
}

 

기존에 작성되어있던 MemberController에 추가해주었다.

직원의 연장근무정보를 조회하는 기능이기때문에 직원컨트롤러에서 접근하는게 좋겠다는 생각이었다.

 

MemberService

@Transactional
public List<OverTimeResponse> getOverTime(OverTimeRequest request) {
    YearMonth yearMonth = request.getYearMonth();
    Integer holidays = request.getHolidays();

    //근무규정의 일 근무시간을 조회
    CompanyPolicy policy = policyRepository.findByPolicyGubnAndPolicyName("workTime", "dailyWorkLimit")
            .orElseThrow(() -> new IllegalArgumentException("없는 정책입니다."));

    //해당하는 달의 총 평균근무시간을 계산 근무일 * 근무시간
    int standardWorkHour = calcStandardWorkDays(yearMonth, holidays) * Integer.parseInt(policy.getPolicyContent());

    List<Employee> employeeList = repository.findAll();
    OverTimeResponseList overTimeResponseList = new OverTimeResponseList(employeeList, yearMonth, standardWorkHour);
    return overTimeResponseList.getOverTimeResponseList();
}

 

1. 근무규정 테이블에서 표준근로시간을 조회

2. 해당달의 평균근무시간을 계산 (해당월의 총일수 - 휴무일) * 일 표준근로시간 = 월 근무시간

3. 직원정보를 모두 조회

4. OverTimeResponseList (overTimeResponse를 리스트로가진 래퍼클래스) 생성

5. List<OverTimeResponse>를 리턴

 

OverTimeResponseList (래퍼클래스)

@Getter
@AllArgsConstructor
public class OverTimeResponseList {
    private List<OverTimeResponse> overTimeResponseList;

    public OverTimeResponseList(List<Employee> employeeList,YearMonth yearMonth,Integer standardWorkHour) {
        this.overTimeResponseList = employeeList.stream().map(result -> {
            Integer employeeWorkHour = new EmployeeCommuteList(result).filterByYearMonth(yearMonth).calcEmployeeWorkHour();
            return OverTimeResponse.builder()
                    .id(Math.toIntExact(result.getId()))
                    .name(result.getName())
                    .overtimeMinutes(getOverTime(employeeWorkHour, standardWorkHour))
                    .build();
        }).collect(Collectors.toList());
    }

    private Integer getOverTime(Integer workTime, Integer standardWorkHour) {
        Integer standardWorkMinute = standardWorkHour * 60;
        if (workTime > standardWorkMinute) {
            return workTime - standardWorkMinute;
        } else {
            return 0;
        }
    }
}

 

직원의 근무시간정보는 직원이가지고있는 commuteHistory를

일급컬렉션인 EmployeeCommuteList에 넣고 거기서 연월로 필터링해준후

근무시간을 더해주는 메서드를 구현해서 구해주었다. 

id , name ,근무시간 정보를 overTimeResponse에 넣어준후 List로 반환 해주면 

OverTimeResponseList의 필드인 overTimeResponseList에 값이 세팅된다.

 

http://localhost:8080/member/overTimeCalc?yearMonth=2024-03&holidays=31

 

조회 결과

근무 일자를 0일로 만들기위해서 휴무일을 31일을 넣어주었다.

그러면 표준근로시간은 0시간이되고 직원의 근무시간이 나오게된다.

현재 가지고있는데이터에서는 1번아이디 직원외에는

1분을넘는 근무시간을 가진 직원이없기때문에 나머지는 0으로 잘나오고있는것같다.

반응형
반응형

연차 정보가 생기면서 추가된 요구사항.

 

직원이 연차를 신청할수있게되면서 직원의 근무시간을 조회하는 기능에 추가적인 요구사항이 생겼다. 

근무시간을 조회하면서  연차를 사용한날도 같이 조회해주어야한다. 

 

기존에 직원의 정보를 조회해오던 코드의 구조

Controller

@GetMapping("/worktime")
public CommuteTotalResponse checkTime(
        @Validated
        CommuteGetDto request) {
    return service.checkTime(request);
}

Service

@Transactional(readOnly = true)
public CommuteTotalResponse checkTime(CommuteGetDto request) {
	//직원아이디로 직원을 조회
    Employee employee = memberRepository.findById(request.getEmployeeId())
            .orElseThrow(()-> new IllegalArgumentException("직원 정보가없습니다."));
    //직원이 가진출퇴근 정보와 검색정보로 응답객체인 CommuteTimeResponseList를 생성
    CommuteTimeResponseList commuteTimeResponseList = new CommuteTimeResponseList(request, employee);

    return new CommuteTotalResponse(commuteTimeResponseList.getCommuteTimeResponseList());
}

 

CommuteTimeResponseList

@Getter
public class CommuteTimeResponseList {
    List<CommuteTimeResponse> commuteTimeResponseList;

    public CommuteTimeResponseList(CommuteGetDto request, Employee employee) {
        List<CommuteTimeResponse> collect = filterAndGroupCommuteHistories(request, employee);

        //연차 정보를 더해준다. //추가된 메서드
        addAnnualLeaveToCommuteTimeResponse(request, employee, collect);

        //정렬
        this.commuteTimeResponseList = sortCommuteTimeResponseListbyDate(collect);
    }

    private static List<CommuteTimeResponse> sortCommuteTimeResponseListbyDate(List<CommuteTimeResponse> collect) {
        List<CommuteTimeResponse> list = collect.stream()
                .sorted(Comparator.comparing(response -> LocalDate.parse(response.getDate())))
                .toList();
        return list;
    }

    private static void addAnnualLeaveToCommuteTimeResponse(CommuteGetDto request, Employee employee, List<CommuteTimeResponse> collect) {
        employee.getAnnualLeaveRegisters().stream()
                .filter(leave -> {
                    YearMonth startYearMonth = YearMonth.from(leave.getStartDate());
                    YearMonth endYearMonth = YearMonth.from(leave.getEndDate());
                    return !startYearMonth.isAfter(request.getSearchDate()) && !endYearMonth.isBefore(request.getSearchDate());

                })
                .forEach(leave -> {
                    LocalDate startDate = leave.getStartDate();
                    LocalDate endDate = leave.getEndDate();

                    // 검색 시작일과 휴가 시작일 중 늦은 날짜를 선택
                    LocalDate searchStart = request.getSearchDate().atDay(1);
                    LocalDate effectiveStartDate = startDate.isBefore(searchStart) ? searchStart : startDate;

                    // 검색 종료일과 휴가 종료일 중 이른 날짜를 선택
                    LocalDate searchEnd = request.getSearchDate().atEndOfMonth();
                    LocalDate effectiveEndDate = endDate.isAfter(searchEnd) ? searchEnd : endDate;

                    long daysBetween = ChronoUnit.DAYS.between(effectiveStartDate, effectiveEndDate);
                    for (long i = 0; i <= daysBetween; i++) {
                        LocalDate currentDate = leave.getStartDate().plusDays(i);
                        collect.add(new CommuteTimeResponse(currentDate));
                    }
                });
    }

    private List<CommuteTimeResponse> filterAndGroupCommuteHistories(CommuteGetDto request, Employee employee) {
        return employee.getCommuteHistories().stream()
                //연월 필터링
                .filter(result ->
                        result.isSameYearMonth(request.getSearchDate())
                                && !result.getIsWorking()
                )
                //그루핑
                .collect(Collectors.groupingBy(
                        result -> dateTimeToFormatString(result.getStartTime(), "yyyy-MM-dd"),
                        Collectors.summingInt(result -> diffMinute(result.getStartTime(), result.getEndTime()))
                ))
                .entrySet().stream()
                .map(result -> {
                    CommuteTimeResponse response = new CommuteTimeResponse();
                    response.setDate(result.getKey());
                    response.setWorkingMinutes(result.getValue());
                    return response;
                }).collect(Collectors.toList());
    }

    private String dateTimeToFormatString(LocalDateTime time, String format) {
        return time.format(DateTimeFormatter.ofPattern(format));
    }

    private Integer diffMinute(LocalDateTime start, LocalDateTime end) {
        return (int) Duration.between(start, end).toMinutes();
    }
}

 

코드를 이해하기쉽게 쓰려고 풀어서 쓰다보니까 상당히 길어졌는데.

기존에 날짜로 그룹화시켜서 리스트로매핑했던데이터에 휴가데이터를 추가해주는 메서드가 추가되었다.

휴가인 날은 usingDayOff 가 true인것이 고정이기때문에

생성시에 고정으로 근무시간은 0 DayOff는 true가 들어간 데이터가 리스트에 추가된다.

그후에 날짜값으로 데이터를 정렬해주게된다.

 

지금은 우선 기능을구현하려고 한 클래스에 넣어놨지만 코드가 너무길어져서 따로 클래스를 더 만들어야 할것같다.

나눈다면 아마 public에서 사용하고있는 3개의 메서드들을 따로 클래스로 만들어서 관리하게될것같다.

 


리팩토링

package Company.demo.commute.dto.firstCollection.commutetimeresponse;

import Company.demo.commute.dao.AnnualLeaveRegister;
import Company.demo.commute.dao.CommuteHistory;
import Company.demo.commute.dao.Employee;
import Company.demo.commute.dto.firstCollection.annualleavelist.AnnualLeaveToCommuteResponse;
import Company.demo.commute.dto.firstCollection.annualleavelist.FilterAnnualLeaveRegistered;
import Company.demo.commute.dto.firstCollection.commutehistorylist.FilterCommuteHistories;
import Company.demo.commute.dto.firstCollection.commutehistorylist.GroupCommuteHistories;
import Company.demo.commute.dto.request.CommuteGetDto;
import Company.demo.commute.dto.response.CommuteTimeResponse;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.Getter;

@Getter
public class CommuteTimeResponseList {
    List<CommuteTimeResponse> commuteTimeResponseList;

    public CommuteTimeResponseList(CommuteGetDto request, Employee employee) {
        List<CommuteHistory> filtered = new FilterCommuteHistories(employee.getCommuteHistories())
                .filterByDate(request.getSearchDate());
        Map<String, Integer> grouped = new GroupCommuteHistories(filtered).groupByDate();
        List<CommuteTimeResponse> commuteTimeResponsesByCommuteHistories = mapToCommuteTimeResponse(grouped);

        //연차 정보를 더해준다.
        List<CommuteTimeResponse> commuteTimeResponsesByAnnualLeave = AnnualLeaveToCommuteTimeResponse(request.getSearchDate(), employee);
        commuteTimeResponsesByCommuteHistories.addAll(commuteTimeResponsesByAnnualLeave);

        //정렬
        this.commuteTimeResponseList = new SortCommuteTimeResponse(commuteTimeResponsesByCommuteHistories).sort();
    }

    private static List<CommuteTimeResponse> AnnualLeaveToCommuteTimeResponse(YearMonth searchYearMonth, Employee employee) {
        List<AnnualLeaveRegister> filter = new FilterAnnualLeaveRegistered(employee.getAnnualLeaveRegisters()).filter(searchYearMonth);
        return new AnnualLeaveToCommuteResponse(filter).leaveToResponse(searchYearMonth);
    }

    private List<CommuteTimeResponse> mapToCommuteTimeResponse(Map<String, Integer> groupedCommuteHistory) {
        return groupedCommuteHistory.entrySet().stream()
                .map(result -> {
                    CommuteTimeResponse response = new CommuteTimeResponse();
                    response.setDate(result.getKey());
                    response.setWorkingMinutes(result.getValue());
                    return response;
                }).collect(Collectors.toList());
    }
}

 

각 메소드들을 기능별로 분리하고 정렬 그루핑 매핑 각각 일급컬렉션으로 분리해서 만들어줬다. 

반응형
반응형

연차신청 기능
연차 조회기능

 

현재의 데이터베이스는 직원의 연차에 대해 저장하는 컬럼이 아무데도 없으므로 데이터베이스 부터 수정해주었다.

서비스 테이블구조

직원테이블에 직원이 몇개의 연차를 가지고있는지를 저장하기위해서 연차 컬럼을 추가해주었고.

신청한연차를 기록하기위해서 연차기록테이블을 만들어주었다. 

올해입사한 직원은 11일 그외에는 15일 이라는 규정을 상수로 만들까하다가 변경이 생기는경우

데이터베이스에서 모두 처리해주기 위해서 정책테이블을 만들어주었다. 

연차에대한 규정은 annualLeave(정책구분) - newEmployee , experienced (정책이름) 으로 조회해서 정책내용으로 받아올수있다. 나름 트리 구조나 카테고리같은 구조를 생각하고 만들었는데 효용성이 있는지는 잘 모르겠다.

나중에 정책구분도 따로 테이블이 생긴다면 외래키로 조회해서

연차관련 정책을 한번에 받아온다던가 하는데에 쓰일것같아서 만들어봤다.

 

연차세팅

우선은 직원들의 연차를 초기화 시킬수있는 기능을 만들었다.

 

Controller

@RestController
@RequiredArgsConstructor
@RequestMapping("/annual-leave")
public class AnnualLeaveController {

    private final AnnualLeaveService service;

    @PutMapping("/set-annual-leave")
    public void setAnnualLeave(){
        service.setAnnualLeave();
    }
}

 

Service

@Service
@RequiredArgsConstructor
public class AnnualLeaveService {
    private final MemberRepository memberRepository;
    private final CompanyPolicyRepository policyRepository;

    @Transactional
    public void setAnnualLeave() {
        int newEmployee = getAnnualLeavePolicy("newEmployee");
        int experienced = getAnnualLeavePolicy("experienced");
        memberRepository.findAll().stream().forEach(employee -> {
            //올해
            int thisYear = LocalDate.now().getYear();
            //직원의 입사연도정보
            int employeeStartYear = employee.getStartDate().getYear();
            if(employeeStartYear == thisYear){
                employee.setAnnualLeave(newEmployee);
            }else{
                employee.setAnnualLeave(experienced);
            }
        });
    }

    private int getAnnualLeavePolicy(String employeeStatus){
        String policyGubn = "annualLeave";
        return Integer.parseInt(policyRepository.findByPolicyGubnAndPolicyName(policyGubn,employeeStatus)
                .orElseThrow(()->new IllegalArgumentException("없는 정책입니다."))
                .getPolicyContent());
    }
}

Repository

public interface MemberRepository extends JpaRepository<Employee,Long> {
    Employee findByName(String name);
}
public interface CompanyPolicyRepository extends JpaRepository<CompanyPolicy,Long> {
    Optional<CompanyPolicy> findByPolicyGubnAndPolicyName(String policyGubn, String policyName);
}

 

이미 만들어서사용했던 MemberRepository를 사용해서 직원데이터를 조회하는데 사용하고

정책 테이블이 새로생겨서 정책을 조회할수있는 CompanyPolicyRepository를 만들어주었다. 

그리고 정책내용을 정책구분과,정책이름으로 조회할수있게 추상메서드를 만들어줬다.

 

해당 컨트롤러로 요청을보내면 직원데이터의 입사일을 보고 신입은 11일 그외에는 15일로 세팅이된다.

 

연차신청 

Controller

@RestController
@RequiredArgsConstructor
@RequestMapping("/annual-leave")
public class AnnualLeaveController {

    private final AnnualLeaveService service;

    @PutMapping("/set-annual-leave")
    public void setAnnualLeave(){
        service.setAnnualLeave();
    }

    @PostMapping("/register")
    public void registerAL(@Valid @RequestBody ALRequest request) {
        service.registerAl(request);
    }
}

DTO

@Data
public class ALRequest {
    private Long employeeId;
    @FutureOrPresent // 현재거나이후의 시간이어야한다.
    private LocalDate startDate;
    @FutureOrPresent
    private LocalDate endDate;

    @AssertTrue(message = "endDate는 startDate보다 이후여야 합니다.") // endDate는 startDate의 이어야한다.
    public boolean isDateCheck(){
        if(startDate.isBefore(endDate)){
            return true;
        }
        return false;
    }
}

 

DTO에는 검증을하기위해서 어노테이션과 검증용 메서드를 만들었다.

이걸로 파라미터로 들어오는 값들을 간단하게 검증하고 해당조건을 만족하지못하는경우 예외를 발생시킨다.

Service

@Service
@RequiredArgsConstructor
public class AnnualLeaveService {
    private final MemberRepository memberRepository;
    private final AnnualLeaveRepository annualLeaveRepository;
    private final CompanyPolicyRepository policyRepository;

    @Transactional
    public void registerAl(ALRequest request) {
        LocalDate startDate = request.getStartDate();
        LocalDate endDate = request.getEndDate();
        Employee employee = memberRepository.findById(request.getEmployeeId())
                .orElseThrow(() -> new IllegalArgumentException("직원 정보가없습니다. "));
        //팀 휴가 사전보고일을 조회해서 사전보고일보다 늦게 보고시 에러처리
        Integer annualLeaveBefore = employee.getTeam().getAnnualLeaveBefore();
        long between = ChronoUnit.DAYS.between(LocalDate.now(), startDate);
        if (between < 0 || between < annualLeaveBefore) {
            throw new IllegalArgumentException(String.format("휴가 신청이 불가능합니다. %d 일 전에 팀에 보고해야 합니다.", annualLeaveBefore));
        }
        //연차를 신청한기간에 이미 연차등록이있으면 예외처리
        List<AnnualLeaveRegister> annualLeaveRegisters = employee.getAnnualLeaveRegisters();
        annualLeaveRegisters.stream().forEach(it -> {
            LocalDate empStartDate = it.getStartDate();
            LocalDate empEndDate = it.getEndDate();
            if (!(endDate.isBefore(empStartDate) || startDate.isAfter(empEndDate))) {
                throw new IllegalArgumentException("신청한 휴가 기간이 이미 신청된 휴가 기간과 겹칩니다.");
            }
        });
        //연차 등록정보를 등록
        annualLeaveRepository.save(new AnnualLeaveRegister(employee, startDate, endDate));
        //연차를사용한 직원의 연차를 차감.
        int annualLeaveDays = (int) ChronoUnit.DAYS.between(startDate, endDate) + 1;
        //연차 신청기간이 직원의 잔여 연차보다 크면 예외처리
        if (annualLeaveDays > employee.getAnnualLeave()) {
            throw new IllegalArgumentException(
                    String.format("휴가 신청이 불가능합니다. 남은 연차 (%d) 신청한연차기간 (%d)", employee.getAnnualLeave(),
                            annualLeaveDays));
        }
        employee.useAL(annualLeaveDays);
    }

    @Transactional
    public void setAnnualLeave() {
        int newEmployee = getAnnualLeavePolicy("newEmployee");
        int experienced = getAnnualLeavePolicy("experienced");
        memberRepository.findAll().stream().forEach(employee -> {
            //올해
            int thisYear = LocalDate.now().getYear();
            //직원의 입사연도정보
            int employeeStartYear = employee.getStartDate().getYear();
            if (employeeStartYear == thisYear) {
                employee.setAnnualLeave(newEmployee);
            } else {
                employee.setAnnualLeave(experienced);
            }
        });
    }

    private int getAnnualLeavePolicy(String employeeStatus) {
        String policyGubn = "annualLeave";
        return Integer.parseInt(policyRepository.findByPolicyGubnAndPolicyName(policyGubn, employeeStatus)
                .orElseThrow(() -> new IllegalArgumentException("없는 정책입니다."))
                .getPolicyContent());
    }
}

 

코드가 길어져서 분리할까 했는데 우선은 포스팅용으로 한번에 볼수있게 두었다.

정리하면 이렇게 될것같다.

@Transactional
public void registerAl(ALRequest request) {
    LocalDate startDate = request.getStartDate();
    LocalDate endDate = request.getEndDate();
    Employee employee = memberRepository.findById(request.getEmployeeId())
            .orElseThrow(() -> new IllegalArgumentException("직원 정보가없습니다. "));
    //팀 휴가 사전보고일을 조회해서 사전보고일보다 늦게 보고시 에러처리
    validateExceedAdvancedReportDate(employee, startDate);
    //연차를 신청한기간에 이미 연차등록이있으면 예외처리
    validateALRedundant(employee, endDate, startDate);
    //연차 등록정보를 등록
    annualLeaveRepository.save(new AnnualLeaveRegister(employee, startDate, endDate));
    int annualLeaveDays = (int) ChronoUnit.DAYS.between(startDate, endDate) + 1;
    //연차 신청기간이 직원의 잔여 연차보다 크면 예외처리
    validateEmployeeRemainAL(annualLeaveDays, employee);
    //연차를사용한 직원의 연차를 차감.
    employee.useAL(annualLeaveDays);
}

 

저런 검증들도 객체를 분리하고 최대한 파라미터를 통일해서 하나의 메서드에서 처리할수있도록 나중에는 수정해야지..

 

이렇게 해주면 예외로두었던 케이스들을 커버할수있다.

코딩 전 미리 작성한 readme

예외 발생시 리턴해주는 객체도 처리해주었던데로 잘 리턴된다 

스프링부트 예외처리

 

스프링부트 예외처리 - @RestControllerAdvice

@RestControllerAdvice 스프링부트에서 @RestController를 이용해서 api를 개발하는경우에 예외처리시 이용한다. 예외를 처리하고 응답객체를 리턴해줄수있다. api응답으로 에러객체를만들어서 보내줄수있

colazoa.tistory.com

 

연차조회

연차를 조회하는 기능은 간단하게 구현했다. 테이블을 수정하면서 직원객체가 휴가의 일수를 가지고있기때문에

직원을 조회해서 가지고있는 휴가를 리턴해주면된다.

 

//Controller
@GetMapping
public ResponseAnnualLeave getAnnualLeave(Integer employeeId) {
    return service.getAnnualLeave(employeeId);
}
//DTO
@Data
@AllArgsConstructor
public class ResponseAnnualLeave {
    private Integer annualLeave;
}

//Service
 @Transactional(readOnly = true)
 public ResponseAnnualLeave getAnnualLeave(Integer employeeId) {
     Employee employee = memberRepository.findById(Long.valueOf(employeeId))
             .orElseThrow(() -> new IllegalArgumentException("직원 정보가없습니다. "));
     return new ResponseAnnualLeave(employee.getAnnualLeave());
 }

응답을 그냥 Integer로 받아도 괜찮지만 나중에 요구사항이 추가되어서 추가로 필드가 더 필요해질수도있고 

응답이나 요청을 스네이크 케이스로 변경해달라고하거나 할때

@JsonNaming어노테이션으로 유연하게 대처할수있을것같아서 응답객체를 만들어서 사용해주었다.

 

연차를 신청하고 조회할수있는 기능을 api에 추가해주었다. 

연차정보가 생기면서 다른 기능의 요구사항에 변경이 생겼는데 이 기능은 다음 포스팅에서 정리해보겠다.

 

 

반응형
반응형

@RestControllerAdvice 

스프링부트에서 @RestController를 이용해서 api를 개발하는경우에 예외처리시 이용한다.

예외를 처리하고 응답객체를 리턴해줄수있다. api응답으로 에러객체를만들어서 보내줄수있다.

@Transactional(readOnly = true)
public CommuteTotalResponse checkTime(CommuteGetDto request) {
    Employee employee = memberRepository.findById(request.getEmployeeId())
            .orElseThrow(IllegalArgumentException::new);
    CommuteTimeResponseList commuteTimeResponseList = new CommuteTimeResponseList(request, employee);

    return new CommuteTotalResponse(commuteTimeResponseList.getCommuteTimeResponseList());
}

 

기존에는 이렇게 서비스계층에서 예외를 처리해서 에러를 내게되면 500에러가 나면서 

이런 모양의 에러객체를 리턴해주게된다.

{
    "timestamp": "2024-03-15T09:31:41.095+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "path": "/commute/worktime"
}

 

이 에러는 지금 내가요청하는 직원아이디를 없는아이디로 넣어서 나오는 에러인데

만약 이 API를 지금처럼 내가 직접만들고 해결한다면 백엔드코드를 보고 수정할수있을 것이다.

그러나 협업을 하게되고 클라이언트에서 요청했는데 이런 에러가나온다면

클라이언트측에서는 어떤 문제로 이런에러가 발생했는지 알기어려울 것이다.

이걸 해결하기 위해서 @RestControllerAdvice를 사용할수있다.

 

CommuteServiceExceptionHandler(에러 처리 객체)

@RestControllerAdvice
public class CommuteServiceExceptionHandler {
    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<String> illegalArgumentExceptionHandle(IllegalArgumentException e) {
        return ResponseEntity.badRequest().body("error : " + e.getMessage());
    }
}

 

이렇게 에러를 처리하는 객체를 만들고 @RestControllerAdvice를 달아주면

@RestController에서 에러가 발생하면 이 객체에서 전역적으로 처리해줄수있게된다. 

 

@ExceptionHandler(처리할예외.class)

여기에는 처리해줄 예외객체를 명시해주면 해당예외가발생시 해당하는 메서드를 실행시켜준다.

 

위의 코드는 IllegalArgumentException을 처리하는 메서드이다. 

service에서 아이디로검색한 직원이 없는경우에 IllegalArgumentException을 발생시키고

해당 예외가 이곳으로 와서 처리된다.

응답코드를 세팅하기위해서 리턴타입을 ResponseEntity로 해주었다.

ResponseEntity를 리턴해야 응답코드를 변경해서 보내줄수있으며

그냥 객체를 내보내게되면 해당 객체를 Json으로 보내주지만 응답객체는 200으로 나오게된다.

 

String으로 에러메시지를 리턴하는경우


 

ResponseEntity로 응답코드를 세팅해서 리턴하는경우

 

 

ResponseEntity<>로 특정객체를 감싸서 리턴해줄수도있다.

이번에는 api의 응답객체로 사용할 wrapper객체를 만들고 ResponseEntity로 이 객체를 감싸서 응답을 보내보자.

 

Api

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
public class Api<T> {

    private String resultCode;
    private String resultMessage;
    @Valid
    private T data;
    private Error error;

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    @Builder
    @JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
    public static class Error{
        private List<String> errorMessage;
    }
}

 

이 객체는 응답 결과데이터와 ,응답코드, 응답메시지,에러정보를 가지고있게된다 

만약 정상적인 요청을 보낸다면 

{
    "result_code": "200",
    "result_message": "OK",
    "data": {
        "name": "홍길동",
        "nick_name": "닉",
        "password": "1234",
        "email": "kim@gmail.com",
        "phone_number": "010-5491-2645",
        "register_at": "2024-03-16T09:58:14",
        "age": 26,
        "name_check": true
    },
    "error": null
}
 
이렇게 데이터에 응답객체를 가지고있고 응답코드,응답메시지도 표시해줄수있다. 
이 경우에 에러가 발생한다면.
 
 
{
    "result_code": "400",
    "result_message": "Bad Request",
    "data": null,
    "error": {
        "error_message": [
            "error :직원 정보가없습니다."
        ]
    }
}
 
이렇게 데이터는 빈값으로 주고 에러객체에 에러메시지값을 세팅해서 내보내줄수도있다. 
 

에러핸들러

@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Api<? extends Objects>> illegalArgumentExceptionHandle(IllegalArgumentException e) {
    String message = "error :" + e.getMessage();
    Error error = Error.builder()
            .errorMessage(Collections.singletonList(message))
            .build();

    Api errorResponse = Api.builder()
            .resultCode(String.valueOf(HttpStatus.BAD_REQUEST.value()))
            .resultMessage(HttpStatus.BAD_REQUEST.getReasonPhrase())
            .error(error)
            .build();

    return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
}

 

이 경우에 Error객체를 만들어주고 Api객체에 세팅해주어야해서 코드는 약간 길어지지만

정리해서 따로 메서드로 분리한다면 더 깔끔하게 정리할수있을것같다. 

 

일반 사용자의 입장에서는 에러가 기분나쁘고 고장난것처럼 느껴지지만

개발하는 입장에서는 에러가 명확하고 어떤부분에서 에러가 발생하는지 확실하게 알려주는편이 더 좋다고 생각한다.

그래야 클라이언트측에서 실수를 한건지 백엔드에서 예외처리를 못해준건지 명확해지고

요청정보가 잘못되었다면 빠르게 수정할수있다. 

반응형
반응형

Controller

@RestController
@RequestMapping("/commute")
public class CommuteController {
    private final CommuteService service;

    public CommuteController(CommuteService service) {
        this.service = service;
    }

    @PostMapping
    public void checkWork(@RequestBody Map<String, Long> json) {
        service.checkWork(json.get("employeeId"));
    }

    @PostMapping("/worktime")
    public CommuteTotalResponse checkTime(@RequestBody CommuteGetDto request) {
        return service.checkTime(request);
    }
}

CommuteGetDto ( requestDTO)

@Getter
public class CommuteGetDto {
    private Long employeeId;
    @DateTimeFormat(pattern = "yyyy-MM")
    private YearMonth searchDate;
}

 

@DateTimeFormat은 json에서 문자열로오는 데이터를 YearMonth타입으로 매칭시켜주기위해서 사용했다.

 

Service

@Service
@RequiredArgsConstructor
@Slf4j
public class CommuteService {

    private final CommuteHistoryRepository repository;
    private final MemberRepository memberRepository;

    @Transactional
    public void checkWork(Long employeeId) {
        //해당 아이디의 직원을 조회
        Employee employee = memberRepository.findById(employeeId).orElseThrow(()-> new IllegalArgumentException("직원 정보가없습니다."));
        //직원의 금일 근무정보가 있는지 조회
        EmployeeTodayCommuteList employeeTodayCommuteList = new EmployeeTodayCommuteList(employee);
        checkStartOrEndWorking(employeeTodayCommuteList, employee);
    }

    private void checkStartOrEndWorking(EmployeeTodayCommuteList employeeTodayCommuteList, Employee employee) {
        // 금일근무정보가 없거나 ,근무기록은 있지만 근무중이 아닌경우(이미퇴근) ,새로 출근기록 생성
        if (employeeTodayCommuteList.isWorkEnd()) {
            CommuteHistory save = repository.save(new CommuteHistory(employee));
            save.startWorking();
        } else {
            //출근 기록이있는경우 퇴근으로
            employeeTodayCommuteList.getWorkingCommuteHistory().endWorking();
        }
    }
    
    @Transactional(readOnly = true)
    public CommuteTotalResponse checkTime(CommuteGetDto request) {
        Employee employee = memberRepository.findById(request.getEmployeeId())
                .orElseThrow(IllegalArgumentException::new);
        CommuteTimeResponseList commuteTimeResponseList = new CommuteTimeResponseList(request, employee);

        return new CommuteTotalResponse(commuteTimeResponseList.getCommuteTimeResponseList());
    }

}

 

@Transactional(readOnly = true) 는 데이터를 조회하기만 할때 사용한다.

이렇게사용하면 조회용 트랜잭션이 생성되서 성능적인 측면에서 좋다고 한다.

CommuteTimeResponse(DTO)

@Getter
@Setter
@NoArgsConstructor
public class CommuteTimeResponse {
    private String date;
    private Integer workingMinutes;
    private boolean usingDayOff;
}

CommuteTimeResponseList(일급컬렉션)

@Getter
public class CommuteTimeResponseList {
    List<CommuteTimeResponse> commuteTimeResponseList;

    public CommuteTimeResponseList(CommuteGetDto request, Employee employee) {
        List<CommuteTimeResponse> collect = employee.getCommuteHistories().stream()
                //연월 필터링
                .filter(result ->
                        result.isSameYearMonth(request.getSearchDate())
                        && !result.getIsWorking()
                        )
                //그루핑
                .collect(Collectors.groupingBy(
                        result -> dateTimeToFormatString(result.getStartTime(), "yyyy-MM-dd"),
                        Collectors.summingInt(result -> diffMinute(result.getStartTime(), result.getEndTime()))
                ))
                .entrySet().stream()
                .map(result -> {
                    CommuteTimeResponse response = new CommuteTimeResponse();
                    response.setDate(result.getKey());
                    response.setWorkingMinutes(result.getValue());
                    return response;
                }).collect(Collectors.toList());
                
                //정렬
        	List<CommuteTimeResponse> list = collect.stream()
                	.sorted(Comparator.comparing(response -> LocalDate.parse(response.getDate())))
                	.toList();

        this.commuteTimeResponseList = list;
    }

    private String dateTimeToFormatString(LocalDateTime time, String format) {
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern(format);
        return time.format(formatter);
    }

    private Integer diffMinute(LocalDateTime start, LocalDateTime end) {
        Duration duration = Duration.between(start, end);
        return (int) duration.toMinutes();
    }
}

 

아직 리팩토링이 더필요한 부분이긴한데.. 서비스에서 출퇴근리스트를 필터링하고 그룹화해줘서 만들었던 메서드를 일급컬렉션으로 옮기고 응답을 해주는 객체인 CommuteTimeResponse로 매핑해주는 메서드 들이다. 이런 부분들을 jpql로 애초에 응답객체의 형식에맞춰서 데이터를 조회해올수도있지만 직원객체에서 출퇴근데이터를 이미 가지고있어서 나는 그냥 있는데이터를 처리해서 응답객체를 만들어주는 방법을 사용했다. 성능적으로 뭐가 더 좋은지는 잘 모르겠다. 

 

CommuteTotalResponse(DTO)

@Getter
@Setter
public class CommuteTotalResponse {
    private List<CommuteTimeResponse> detail = new ArrayList<>();
    private Integer sum;

    public CommuteTotalResponse(List<CommuteTimeResponse> detail) {
        this.detail = detail;
        this.sum = detail.stream().mapToInt(CommuteTimeResponse::getWorkingMinutes).sum();
    }
}

조회해서 매핑한데이터를 이클래스로 감싼다음 리턴해주어야 위에서봤던 Json의 응답형식에 맞출수있다.

{
    "detail": [
        {
            "date": "2024-03-01",
            "workingMinutes": 0,
            "usingDayOff": true
        },
        {
            "date": "2024-03-02",
            "workingMinutes": 0,
            "usingDayOff": true
        },
        {
            "date": "2024-03-03",
            "workingMinutes": 180,
            "usingDayOff": false
        },
        {
            "date": "2024-03-04",
            "workingMinutes": 120,
            "usingDayOff": false
        },
        {
            "date": "2024-03-05",
            "workingMinutes": 118,
            "usingDayOff": false
        },
        {
            "date": "2024-03-06",
            "workingMinutes": 0,
            "usingDayOff": true
        },
        {
            "date": "2024-03-07",
            "workingMinutes": 0,
            "usingDayOff": true
        },
        {
            "date": "2024-03-11",
            "workingMinutes": 0,
            "usingDayOff": false
        }
    ],
    "sum": 418
 

 

이렇게 직원정보와 검색날짜에 맞춰서 데이터가 잘 조회되는것을 볼수있다 usingDayOff부분은

이후에 진행하면서 추가된 기능의 부분이니 지금은 무시하자.


추가수정

CommuteController

package Company.demo.commute.controller;

import Company.demo.commute.dto.request.CommuteGetDto;
import Company.demo.commute.dto.response.CommuteTotalResponse;
import Company.demo.commute.service.CommuteService;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/commute")
public class CommuteController {
    private final CommuteService service;

    public CommuteController(CommuteService service) {
        this.service = service;
    }

    @PostMapping
    public void checkWork(@RequestBody Map<String, Long> json) {
        service.checkWork(json.get("employeeId"));
    }

	//PostMapping 이던 부분을 Get으로 변경 (조회하는 메서드에 알맞게 수정)
    @GetMapping("/worktime")
    public CommuteTotalResponse checkTime(CommuteGetDto request) {
        System.out.println("request.getSearchDate() = " + request.getSearchDate());
        System.out.println("request.getEmployeeId() = " + request.getEmployeeId());
        return service.checkTime(request);
    }
}

 

CommuteTimeResponseList

@Getter
public class CommuteTimeResponseList {
    List<CommuteTimeResponse> commuteTimeResponseList;

    public CommuteTimeResponseList(CommuteGetDto request, Employee employee) {
        List<CommuteTimeResponse> collect = filterAndGroupCommuteHistries(request, employee);
        //연차 정보를 더해준다.
        addAnnualLeaveToCommuteTimeResponse(request, employee, collect);
        //정렬
        this.commuteTimeResponseList = sortCommuteTimeResponseListbyDate(collect);
    }

    private static List<CommuteTimeResponse> sortCommuteTimeResponseListbyDate(List<CommuteTimeResponse> collect) {
        List<CommuteTimeResponse> list = collect.stream()
                .sorted(Comparator.comparing(response -> LocalDate.parse(response.getDate())))
                .toList();
        return list;
    }

    private static void addAnnualLeaveToCommuteTimeResponse(CommuteGetDto request, Employee employee, List<CommuteTimeResponse> collect) {
        employee.getAnnualLeaveRegisters().stream()
                .filter(leave -> {
                    YearMonth startYearMonth = YearMonth.from(leave.getStartDate());
                    YearMonth endYearMonth = YearMonth.from(leave.getEndDate());
                    return !startYearMonth.isAfter(request.getSearchDate()) && !endYearMonth.isBefore(request.getSearchDate());
                })
                .forEach(leave -> {
                    LocalDate startDate = leave.getStartDate();
                    LocalDate endDate = leave.getEndDate();

                    // 검색 시작일과 휴가 시작일 중 늦은 날짜를 선택
                    LocalDate searchStart = request.getSearchDate().atDay(1);
                    LocalDate effectiveStartDate = startDate.isBefore(searchStart) ? searchStart : startDate;

                    // 검색 종료일과 휴가 종료일 중 이른 날짜를 선택
                    LocalDate searchEnd = request.getSearchDate().atEndOfMonth();
                    LocalDate effectiveEndDate = endDate.isAfter(searchEnd) ? searchEnd : endDate;

                    long daysBetween = ChronoUnit.DAYS.between(effectiveStartDate, effectiveEndDate);
                    for (long i = 0; i <= daysBetween; i++) {
                        LocalDate currentDate = leave.getStartDate().plusDays(i);
                        collect.add(new CommuteTimeResponse(currentDate));
                    }
                });
    }

    private List<CommuteTimeResponse> filterAndGroupCommuteHistries(CommuteGetDto request, Employee employee) {
        List<CommuteTimeResponse> collect = employee.getCommuteHistories().stream()
                //연월 필터링
                .filter(result ->
                        result.isSameYearMonth(request.getSearchDate())
                                && !result.getIsWorking()
                )
                //그루핑
                .collect(Collectors.groupingBy(
                        result -> dateTimeToFormatString(result.getStartTime(), "yyyy-MM-dd"),
                        Collectors.summingInt(result -> diffMinute(result.getStartTime(), result.getEndTime()))
                ))
                .entrySet().stream()
                .map(result -> {
                    CommuteTimeResponse response = new CommuteTimeResponse();
                    response.setDate(result.getKey());
                    response.setWorkingMinutes(result.getValue());
                    return response;
                }).collect(Collectors.toList());
        return collect;
    }

    private String dateTimeToFormatString(LocalDateTime time, String format) {
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern(format);
        return time.format(formatter);
    }

    private Integer diffMinute(LocalDateTime start, LocalDateTime end) {
        Duration duration = Duration.between(start, end);
        return (int) duration.toMinutes();
    }
}

 

 

객체를 매핑해주는 코드를 수정해줬다

필터링하고 그룹을 지어주는 메서드

연차정보를 매핑해서 추가해주는 메서드

리스트를 날짜정보에의해 정렬해주는 메서드 , 이 세가지로 분리해서 리팩토링해주고 

 

연차정보를 추가하는 메서드는 필터조건과 추가하는 방법이 수정되었다.

이전의 방법은 시작일자의 연월데이터와 검색일자의 연월데이터를 비교해서 같은것들을 필터링해주었는데 

이런 필터링방법을 쓰니 내 데이터의 특성상 시작일자는 3월 이더라도 끝나는일자가 4월인 데이터들이 같이 추가되었다.

.filter(leave -> {
    YearMonth startYearMonth = YearMonth.from(leave.getStartDate());
    YearMonth endYearMonth = YearMonth.from(leave.getEndDate());
    return 
    //연차시작일자가 검색일자와 같거나 이전인 데이터 //검색일자보다 이후이면 false
    !startYearMonth.isAfter(request.getSearchDate()) 
    && 
    //연차 끝나는일자가 검색일자보다 같거나 이후인 데이터 //검색일자보다 이전이면 false
    !endYearMonth.isBefore(request.getSearchDate());
    //두조건이 and조건이므로 검색일자를 포함하는 데이터만남는다.
})

 

ex )

검색일자는 2024-03 

휴가시작일자는 2024-02

휴가끝나는일정이 2024-04 

 

이런경우에는 검색일자가 휴가일정에 포함되어있으므로 true가 나와야한다. 

그래서 검색일자와 같거나 이전인데이터(true) && 검색일자와 같거나 이후인데이터(true) 

이렇게 조건을 잡도록 수정해주었다. 이렇게 필터링을하니 2월 3월 4월 같은 검색날짜 이전과 이후의 데이터도 필터를 통과하는데.

.forEach(leave -> {
    LocalDate startDate = leave.getStartDate();
    LocalDate endDate = leave.getEndDate();

    // 검색 시작일과 휴가 시작일 중 늦은 날짜를 선택
    //검색월의 첫째날 if 2024-03이면 3월1일
    LocalDate searchStart = request.getSearchDate().atDay(1);
    //만약 휴가시작일이 2월10일 이라면 2월10일은 검색일인 3월보다이전이므로 true -searchStart를 리턴
    LocalDate effectiveStartDate = startDate.isBefore(searchStart) ? searchStart : startDate;

    // 검색 종료일과 휴가 종료일 중 이른 날짜를 선택
    // 검색월의 마지막날 ex) 3월31일
    LocalDate searchEnd = request.getSearchDate().atEndOfMonth();
    // 만약 휴가의 마지막날이 검색의 마지막날보다 이후라면 searchEnd 리턴
    LocalDate effectiveEndDate = endDate.isAfter(searchEnd) ? searchEnd : endDate;
    
    //2월부터 4월까지가 휴가라면 각 리턴된결과에따라 3월1일부터 3월 31일까지가 startDate,endDate가 된다.
    long daysBetween = ChronoUnit.DAYS.between(effectiveStartDate, effectiveEndDate);
    for (long i = 0; i <= daysBetween; i++) {
        LocalDate currentDate = leave.getStartDate().plusDays(i);
        collect.add(new CommuteTimeResponse(currentDate));
    }
});

 

 

이렇게 몇번 실행을하고 리팩토링을 하다보니 처음에는 생각하지못했던 문제들도 마주하게되었다. 

이런 케이스들을 여러번 경험하다보면 경험적으로 미리 이런부분들을 방지할수도 있게되겠지.

반응형

+ Recent posts