Project/AutoSchedule

6일차 - Task/CalendarEvent API 검증 & 캘린더 통합

sowon02 2025. 11. 7. 17:18

오늘은 Task / CalendarEvent API에 검증 로직을 추가하고,

프론트엔드에서 작업(Task)을 캘린더에 표시하는 기능까지 통합했다.

팀별 색상과 우선순위에 따른 색 진하기로 가독성도 개선했다.

 

추가한 기능

  • 작업 추가하기 (모달 + API 연동)
  • 우선순위 지정(1~5) - (지금은 사용자 지정, 정책 고민 예정)
  • 팀별 색상 지정 / 우선순위 별 색 진하기
  • 캘린더에 작업 표시(마감일 기준)
  • 데이터 무결성 검증 강화(DTO + Service)

1) 백엔드 - 검증 로직(DTO + Service)

컨트롤러 이전 단계에서 "형식"을 걸러내고, 서비스에서 "의미/도메인 규칙"을 보장해 무결성과 가독성을 동시에 확보하기 위해

두 단계를 수정했다.

 

1-1. DTO 레벨 검증 (Jakarta Validation) - 형식/존재성 체크 

컨트롤러 진입 초기에 반복되는 형식 검증(필수/범위/양수)을 선언적으로 차단하면, 컨트롤러/서비스 코드가 짧고 명확해지며, 

에러 메시지의 일관성이 생긴다. 

// TaskCreateRequest.java
// 핵심: 필수/선택 필드 명확화, 범위/형식 검증 메시지 지정
@NotNull(message = "팀 ID는 필수입니다")
private Long teamId;

@NotBlank(message = "제목은 필수입니다")
private String title;

@NotNull(message = "소요 시간은 필수입니다")
@Positive(message = "소요 시간은 양수여야 합니다")
private Integer durationMin;

@Min(value = 1, message = "우선순위는 1 이상이어야 합니다")
@Max(value = 5, message = "우선순위는 5 이하여야 합니다")
private Integer priority; // 기본값 3

 

 

이렇게해두면, 컨트롤러 단에서 @Vaild만 붙여도 DTO는 기본적인 형식을 보장되고,

클라이언트는 정확한 메시지를 즉시 받아 재시도 포인트를 빠르게 파악한다. 

 

1-2. 서비스 레벨 비즈니스 검증 (의미/도메인 규칙)

DTO 검증은 "모양"만 맞춘 단계다. 실제로 팀/사용자 존재 여부, 시간 순서, 과거/미래 같은 도메인 규칙은 서비스에서 검증해야

데이터 무결성을 지킬 수 있다

// TaskService.java - createTask
@Transactional
public TaskResponse createTask(TaskCreateRequest request) {
    // 우선순위 기본값 + 범위 검증
    Integer priority = request.getPriority() != null ? request.getPriority() : 3;
    if (priority < 1 || priority > 5) {
        throw new IllegalArgumentException("우선순위는 1부터 5 사이의 값이어야 합니다. 입력값: " + priority);
    }

    // 마감일은 현재 이후만 허용
    if (request.getDueAt() != null && request.getDueAt().isBefore(OffsetDateTime.now())) {
        throw new IllegalArgumentException("마감일은 현재 시간 이후여야 합니다. 입력값: " + request.getDueAt());
    }

    // 팀/담당자 존재 검증
    Team team = teamRepository.findById(request.getTeamId())
        .orElseThrow(() -> new IllegalArgumentException("팀을 찾을 수 없습니다: " + request.getTeamId()));

    User assignee = null;
    if (request.getAssigneeId() != null) {
        assignee = userRepository.findById(request.getAssigneeId())
            .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다: " + request.getAssigneeId()));
    }

    // 엔티티 매핑 + 기본값 처리
    Task task = new Task();
    task.setTeam(team);
    task.setAssignee(assignee);
    task.setTitle(request.getTitle());
    task.setDurationMin(request.getDurationMin());
    task.setPriority(priority);
    task.setDueAt(request.getDueAt());
    task.setSplittable(request.getSplittable() == null || request.getSplittable());
    task.setTags(request.getTags());
    task.setCreatedAt(OffsetDateTime.now());
    task.setUpdatedAt(OffsetDateTime.now());

    return toResponse(taskRepository.save(task));
}
// CalendarEventService - createEvent
@Transactional
public CalendarEventResponse createEvent(CalendarEventCreateRequest request) {
    // 필수/순서 검증
    if (request.getStartsAt() == null) throw new IllegalArgumentException("시작 시간은 필수입니다");
    if (request.getEndsAt() == null) throw new IllegalArgumentException("종료 시간은 필수입니다");
    if (!request.getEndsAt().isAfter(request.getStartsAt())) {
        throw new IllegalArgumentException("종료 시간은 시작 시간 이후여야 합니다. 시작: "
            + request.getStartsAt() + ", 종료: " + request.getEndsAt());
    }

    Team team = teamRepository.findById(request.getTeamId())
        .orElseThrow(() -> new IllegalArgumentException("팀을 찾을 수 없습니다: " + request.getTeamId()));

    User owner = null;
    if (request.getOwnerId() != null) {
        owner = userRepository.findById(request.getOwnerId())
            .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다: " + request.getOwnerId()));
    }

    CalendarEvent e = new CalendarEvent();
    e.setTeam(team);
    e.setOwner(owner);
    e.setTitle(request.getTitle());
    e.setLocation(request.getLocation());
    e.setStartsAt(request.getStartsAt());
    e.setEndsAt(request.getEndsAt());
    e.setFixed(Boolean.TRUE.equals(request.getFixed()));
    e.setAttendees(request.getAttendees());
    e.setNotes(request.getNotes());
    e.setCreatedAt(OffsetDateTime.now());
    e.setUpdatedAt(OffsetDateTime.now());

    return toResponse(calendarEventRepository.save(e));
}

 

이렇게하면, 

DTO(형식) + Service(의미) 이중 검증으로 빈틈을 최소화 할 수 있으며,

orElseThrow()로 예외 지점이 명확해 유지보수 & 디버깅이 쉽다.

@Transactional로 여러 저장/조회가 한번에 원자적으로 처리된다. 

 

 

1-3. Controller는 얇게 (단일 책임)

@PostMapping
public ResponseEntity<TaskResponse> createTask(@Valid @RequestBody TaskCreateRequest request) {
    return ResponseEntity.status(HttpStatus.CREATED).body(taskService.createTask(request));
}

 

컨트롤러는 입력 검증 (@Valid) + 서비스 위임 + 상태 코드만 담당.

비즈니스 로직은 서비스로 몰아 관심사 분리. 


2) 업데이트 - 부분 업데이트(PATCH) & "null-merge"패턴

운영에서는 "일부 필드만 수정"이 흔하다. 해당 필드가 null이면 스킵, 값이 있으면 선택적으로 반영한다.

다만 업데이트 시에도 비즈니스 규칙은 반드시 재검증 해야한다. 

// TaskService - updateTask
@Transactional
public TaskResponse updateTask(Long id, TaskUpdateRequest request) {
    Task task = taskRepository.findById(id)
        .orElseThrow(() -> new IllegalArgumentException("작업을 찾을 수 없습니다: " + id));

    if (request.getTitle() != null) task.setTitle(request.getTitle());

    if (request.getAssigneeId() != null) {
        User assignee = userRepository.findById(request.getAssigneeId())
            .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다: " + request.getAssigneeId()));
        task.setAssignee(assignee);
    }

    if (request.getDurationMin() != null) task.setDurationMin(request.getDurationMin());

    if (request.getPriority() != null) {
        if (request.getPriority() < 1 || request.getPriority() > 5) {
            throw new IllegalArgumentException("우선순위는 1부터 5 사이의 값이어야 합니다. 입력값: " + request.getPriority());
        }
        task.setPriority(request.getPriority());
    }

    if (request.getDueAt() != null) {
        if (request.getDueAt().isBefore(OffsetDateTime.now())) {
            throw new IllegalArgumentException("마감일은 현재 시간 이후여야 합니다. 입력값: " + request.getDueAt());
        }
        task.setDueAt(request.getDueAt());
    }

    if (request.getSplittable() != null) task.setSplittable(request.getSplittable());
    if (request.getTags() != null) task.setTags(request.getTags());

    task.setUpdatedAt(OffsetDateTime.now());
    return toResponse(taskRepository.save(task));
}
// CalendarEventService - updateEvent (기존값과 병합 후 검증함)
@Transactional
public CalendarEventResponse updateEvent(Long id, CalendarEventUpdateRequest request) {
    CalendarEvent e = calendarEventRepository.findById(id)
        .orElseThrow(() -> new IllegalArgumentException("이벤트를 찾을 수 없습니다: " + id));

    OffsetDateTime startsAt = request.getStartsAt() != null ? request.getStartsAt() : e.getStartsAt();
    OffsetDateTime endsAt   = request.getEndsAt()   != null ? request.getEndsAt()   : e.getEndsAt();

    if (startsAt != null && endsAt != null && !endsAt.isAfter(startsAt)) {
        throw new IllegalArgumentException("종료 시간은 시작 시간 이후여야 합니다. 시작: " + startsAt + ", 종료: " + endsAt);
    }

    if (request.getTitle() != null) e.setTitle(request.getTitle());
    if (request.getLocation() != null) e.setLocation(request.getLocation());
    if (request.getStartsAt() != null) e.setStartsAt(request.getStartsAt());
    if (request.getEndsAt() != null) e.setEndsAt(request.getEndsAt());
    if (request.getFixed() != null) e.setFixed(request.getFixed());
    if (request.getAttendees() != null) e.setAttendees(request.getAttendees());
    if (request.getNotes() != null) e.setNotes(request.getNotes());

    e.setUpdatedAt(OffsetDateTime.now());
    return toResponse(calendarEventRepository.save(e));
}

 

클라이언트가 필요한 필드만 보내도 안전하게 처리할 수 있으며,

업데이트 시에도 시간/범위 규칙이 보장돼 데이터 무결성이 유진된다. 

 


3) 조회/변환 - Stream API로 가독성과 재사용성 확보

Entity → DTO 변환을 단일 메서드로 모두 캡슐화하면, 조회 로직은 .map(this::toResponse)로 짧고 읽기 쉽다.

@Transactional(readOnly = true)
public List<TaskResponse> findByTeamId(Long teamId) {
    return taskRepository.findByTeam_Id(teamId).stream()
        .map(this::toResponse)
        .collect(Collectors.toList());
}

private TaskResponse toResponse(Task t) {
    TaskResponse r = new TaskResponse();
    r.setId(t.getId());
    r.setTitle(t.getTitle());
    r.setDurationMin(t.getDurationMin());
    r.setTeamId(t.getTeam() != null ? t.getTeam().getId() : null);
    r.setAssigneeId(t.getAssignee() != null ? t.getAssignee().getId() : null);
    r.setPriority(t.getPriority());
    r.setDueAt(t.getDueAt());
    r.setSplittable(t.isSplittable());
    r.setTags(t.getTags());
    r.setCreatedAt(t.getCreatedAt());
    r.setUpdatedAt(t.getUpdatedAt());
    return r;
}

API 엔드포인트

 

  • POST /api/tasks — 작업 생성
  • GET /api/tasks/team/{teamId} — 팀별 작업 조회
  • GET /api/tasks/assignee/{assigneeId} — 담당자별 작업 조회
  • POST /api/events — 일정 생성
  • GET /api/events/team/{teamId} — 팀별 일정 조회
  • GET /api/teams/user/{userId} — 사용자가 속한 팀 목록
  • 샘플 요청/응답 (POST /api/task, 201)

샘플 요청
샘플 응답(성공 201)

 


프론트엔드

같은 타임라인에서 Task와 Event를 함께 보되, 팀/중요도가 한눈에 들어오도록 표시하였다.

마감일 기준으로 표시 하였고, 팀별 색상과 우선순위 진하기로 색상이 정해진다. 

프론트엔드 화면