Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6577a0c
refactor: ScheduleRepositoryCustom 인터페이스 메서드 이름 수정
GoGradually Jan 23, 2026
edc0ebc
refactor: ScheduleRepositoryCustom 인터페이스 메서드 이름 수정
GoGradually Jan 23, 2026
b2cd3c1
test: TaskRepository에 대한 단위 테스트 추가
GoGradually Jan 23, 2026
9741ab9
refactor: TaskRepository에서 마감일을 LocalDate로 변경
GoGradually Jan 23, 2026
72c5975
feat: LocalDate를 기준으로 하루의 시작 시간을 반환하는 메서드 추가
GoGradually Jan 23, 2026
ed063d6
feat: ZonedDateAttribute에서 ZonedDateTime 변환 메서드 개선
GoGradually Jan 23, 2026
cb56658
feat: Task의 마감일을 LocalDate와 ZoneOffset으로 변경
GoGradually Jan 23, 2026
19cb40f
test: TemporalConstraint의 equals 메서드 테스트 개선
GoGradually Jan 23, 2026
db6862a
feat: TaskService에서 커서 기반 작업 조회 메서드 개선 및 LocalDate로 마감일 처리
GoGradually Jan 23, 2026
cfbf39f
feat: 작업 마감일을 자정 기준으로 변경 및 관련 테스트 수정
GoGradually Jan 23, 2026
3b20b90
feat: TaskCreateRequestV2 클래스 추가 및 마감일 처리 로직 구현
GoGradually Jan 23, 2026
28b00bf
feat: TaskCursorPageResponseV2 클래스 추가 및 페이지네이션 처리 로직 구현
GoGradually Jan 23, 2026
bafee00
feat: TaskResponseV2 클래스 추가 및 작업 응답 처리 로직 구현
GoGradually Jan 23, 2026
a212e0c
feat: TaskResponse의 마감 기한 설명을 자정 기준으로 수정
GoGradually Jan 23, 2026
5f8b95b
feat: DateWithOffset 클래스 추가 및 ZonedDateTime 변환 로직 구현
GoGradually Jan 23, 2026
fbbb6f7
test: 마감일 비교 로직을 날짜 기준으로 수정
GoGradually Jan 23, 2026
28256fa
feat: TaskUpdateRequestV2 클래스 추가 및 작업 업데이트 요청 처리 로직 구현
GoGradually Jan 23, 2026
52449cd
feat: TaskControllerV2 클래스 추가 및 작업 관리 API 구현
GoGradually Jan 23, 2026
3fd9b3a
docs: 작업 마감일 데이터 마이그레이션을 위한 SQL 스크립트 추가
GoGradually Jan 23, 2026
d5e490d
refactor: DTO 패키지 구조 변경 및 관련 import 수정
GoGradually Jan 23, 2026
60ba23d
feat: 회원 및 일정 관련 API v2 클래스 추가 및 v1 클래스 deprecated 처리
GoGradually Jan 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# TemporalConstraint `deadline` ZonedDateAttribute 적용 체크리스트

## 사전 이해

- [x] 기존 `deadline`이 `ZonedDateTimeAttribute`로 `LocalDateTime`+`ZoneId`를 저장하며 컬럼이 `deadline_time`, `deadline_zone_id` 임을
확인.
- [x] 새 `ZonedDateAttribute`는 날짜(`LocalDate`)와 `ZoneOffset`만 저장하므로 **타임존(Region) 정보 손실**과 **시간(시:분:초) 00:00 고정** 부작용을
문서화.
- [x] API/도메인/리포지터리 로직이 시간 단위 정밀도나 지역 타임존을 기대하는 부분이 있는지 사용처를 전수 조사.

## 도메인 코드 변경

- [x] `TemporalConstraint.deadline` 타입을 `ZonedDateAttribute`로 교체하고 컬럼명을 `deadline_date`, `deadline_offset_id` 등으로 명시.
- [x] 생성자·변경자에서 `ZonedDateAttribute.from`을 사용하도록 수정하고 null 방어/불변성 점검.
- [x] `getDeadline()`이 자정(`00:00`)으로 복원됨을 호출부에 주석 또는 메서드 네이밍으로 명확히 전달.

## 조회·정렬·커서 로직

- [x] `TaskRepository.findNextByCursor` JPQL의 `deadline.dateTime` 참조를 `deadline.date` 기반으로 변경하고 커서 타입을 `LocalDate`로 맞춤.
- [x] `TaskService.encode/decodeCursor` 직렬화 포맷을 날짜 기준으로 업데이트해 클라이언트·링크 공유·페이징 호환성 영향도를 평가.
- [x] 동일 날짜 내 정렬 기준(시간→ID) 변경 시 업무 규칙을 재확인하여 순서 역전 위험이 없는지 검증.

## DTO·API 계약

- [x] API 계약 변경이 필요하면 기존 V1을 보존하고 **새 V2 엔드포인트**를 추가해 전환(문서/라우팅/버전 네고 포함).
- [x] 응답 DTO `DateTimeWithZone`를 유지할지, 날짜 전용 DTO를 도입할지 결정하고 스웨거 스펙/예제를 갱신.
- [ ] 요청·응답에서 시간이 아닌 “마감 날짜” 의미임을 프런트/모바일 팀과 합의하고 배포 노트에 명시.
- [x] `ZoneId` → `ZoneOffset` 변경 시 클라이언트 파싱 호환성(예: `+09:00` 고정 포맷 사용, `Asia/Seoul` 미지원)을 명시하고 샘플 페이로드를 제공.

## 데이터 마이그레이션

- [x] 새 컬럼 DDL 작성 (`deadline_date` DATE, `deadline_offset_id` VARCHAR 등) 및 기존 컬럼 유지/삭제 전략 확정.
- [x] 기존 `deadline_time`, `deadline_zone_id` 값을 날짜와 오프셋으로 변환하는 DML 준비 (`deadline_time::date`, `deadline_zone_id`를 해당 날짜의
오프셋으로 계산).
- [x] 지역 타임존이 사라지므로 DST 전환일의 오프셋 결정 방식(표준 vs 써머타임)을 명문화하고 샘플 케이스로 검증.
- [x] 롤백 스크립트, 데이터 백업 시점, 장애 시 복구 절차를 마련.

## 테스트

- [x] 도메인 단위 테스트: 생성/equals/patch/커서 비교가 날짜 단위로 동작하는지 추가.
- [x] 리포지터리 통합 테스트: JPQL 정렬·커서 조회가 기대대로 수행되는지 확인.
- [x] API 컨트롤러/스냅샷 테스트: 직렬화 포맷 변경으로 응답이 깨지지 않는지 검증.
- [ ] 마이그레이션 스크립트(Flyway/Liquibase) 실행 테스트 포함.

## 배포 체크

- [ ] 배포 전에 클라이언트·배치·알림 시스템에 “시간 → 날짜” 계약 변경을 공지.
- [ ] 릴리스 노트에 클라이언트는 `+09:00` 같은 `ZoneOffset` 문자열로 파싱해야 함을 명시(V2 API 포함).
- [ ] 배포 순서: DB 마이그레이션 → 애플리케이션 배포 → 캐시/큐/스케줄러 재기동 필요 여부 확인.
- [ ] 문제 발생 시 스키마/애플리케이션 롤백 플랜과 모니터링 포인트를 준비.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
-- Task.deadline migration: ZonedDateTimeAttribute -> ZonedDateAttribute (date + offset)
-- Assumptions:
-- 1) Existing columns: deadline_time (DATETIME), deadline_zone_id (Region zone id).
-- 2) Offset is calculated at local midnight of deadline_date in the original region zone.
-- 3) MySQL timezone tables are loaded so CONVERT_TZ supports region zones.

-- 1. Add new columns (keep nullable during backfill).
ALTER TABLE task
ADD COLUMN deadline_date DATE NULL AFTER deadline_zone_id,
ADD COLUMN deadline_offset_id VARCHAR(10) NULL AFTER deadline_date;

-- 2. Backfill from legacy columns (local midnight offset).
UPDATE task
SET deadline_date = DATE(deadline_time),
deadline_offset_id = CONCAT(
SUBSTR(DATE_FORMAT(CONVERT_TZ(CONCAT(DATE(deadline_time), ' 00:00:00'), deadline_zone_id, '+00:00'), '%z'),
1, 3),
':',
SUBSTR(DATE_FORMAT(CONVERT_TZ(CONCAT(DATE(deadline_time), ' 00:00:00'), deadline_zone_id, '+00:00'), '%z'),
4, 2)
);

-- 3. Enforce NOT NULL after verifying backfill.
ALTER TABLE task
MODIFY deadline_date DATE NOT NULL,
MODIFY deadline_offset_id VARCHAR (10) NOT NULL;

-- 4. (Optional) Drop legacy columns after application rollout and data validation.
-- ALTER TABLE task DROP COLUMN deadline_time, DROP COLUMN deadline_zone_id;

-- Rollback plan:
-- - If needed, repopulate deadline_time from deadline_date using stored offset (time fixed at 00:00:00):
-- UPDATE task SET deadline_time = CONVERT_TZ(CONCAT(deadline_date, ' 00:00:00'), '+00:00', deadline_offset_id);
-- - Restore NOT NULL/column definitions accordingly.
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,10 @@ public ZonedDateTime toZonedDateTime(LocalDateTime localDateTime, ZoneId zoneId)
Objects.requireNonNull(zoneId, "zoneId must not be null");
return ZonedDateTime.of(localDateTime, zoneId);
}

public ZonedDateTime toStartOfDay(LocalDate date, ZoneOffset offset) {
Objects.requireNonNull(date, "date must not be null");
Objects.requireNonNull(offset, "offset must not be null");
return date.atStartOfDay(offset);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public List<Schedule> getScheduleList(Long memberId, ZonedDateTime dateTime) {
Instant startOfDay = dateTime.toLocalDate().atStartOfDay(memberZoneById).toInstant();
Instant endExclusive = dateTime.toLocalDate().plusDays(1).atStartOfDay(memberZoneById).toInstant();

return scheduleRepository.findAllByOwnerIdAndDesignatedStartTimeInstantBetween(
return scheduleRepository.findAllByOwnerIdAndDesignatedStartTimeBetween(
memberId,
startOfDay,
endExclusive
Expand All @@ -58,7 +58,7 @@ public List<Schedule> getScheduleListForWeek(Long memberId, ZonedDateTime now) {
ZoneOffset zoneOffsetOfMember = memberService.findZoneOffsetOfMember(memberId);
Instant start = dateTimeUtils.lastMondayStart(now, zoneOffsetOfMember).toInstant();
Instant end = dateTimeUtils.lastMondayStart(now, zoneOffsetOfMember).plusDays(7).toInstant();
return scheduleRepository.findAllByOwnerIdAndDesignatedStartTimeInstantBetween(
return scheduleRepository.findAllByOwnerIdAndDesignatedStartTimeBetween(
memberId,
start,
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,19 @@
import me.gg.pinit.pinittask.domain.task.model.Task;
import me.gg.pinit.pinittask.domain.task.patch.TaskPatch;
import me.gg.pinit.pinittask.domain.task.repository.TaskRepository;
import me.gg.pinit.pinittask.interfaces.dto.TaskCursorPageResponse;
import me.gg.pinit.pinittask.interfaces.dto.TaskResponse;
import me.gg.pinit.pinittask.interfaces.task.dto.TaskCursorPageResponse;
import me.gg.pinit.pinittask.interfaces.task.dto.TaskResponse;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Deque;
import java.util.List;
import java.util.Optional;
Expand All @@ -29,6 +32,8 @@
@RequiredArgsConstructor
public class TaskService {
private static final String CURSOR_DELIMITER = "|";
private static final DateTimeFormatter CURSOR_DEADLINE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");

private final TaskRepository taskRepository;
private final DependencyService dependencyService;
private final ScheduleService scheduleService;
Expand All @@ -50,23 +55,20 @@ public Page<Task> getTasks(Long ownerId, Pageable pageable, boolean readyOnly) {
return taskRepository.findAllByOwnerId(ownerId, pageable);
}

@Deprecated
@Transactional(readOnly = true)
public TaskCursorPageResponse getTasksByCursor(Long ownerId, int size, String cursor, boolean readyOnly) {
Cursor decoded = decodeCursor(cursor);
List<Task> tasks = taskRepository.findNextByCursor(
ownerId,
readyOnly,
decoded.deadline(),
decoded.id(),
PageRequest.of(0, size)
);
boolean hasNext = tasks.size() == size;
String nextCursor = hasNext ? encodeCursor(tasks.getLast()) : null;
var dependencyInfoMap = dependencyService.getDependencyInfoForTasks(ownerId, tasks.stream().map(Task::getId).toList());
List<TaskResponse> data = tasks.stream()
CursorPage cursorPage = loadTasksByCursor(ownerId, size, cursor, readyOnly);
var dependencyInfoMap = dependencyService.getDependencyInfoForTasks(ownerId, cursorPage.tasks().stream().map(Task::getId).toList());
List<TaskResponse> data = cursorPage.tasks().stream()
.map(task -> TaskResponse.from(task, dependencyInfoMap.get(task.getId())))
.toList();
return TaskCursorPageResponse.of(data, nextCursor, hasNext);
return TaskCursorPageResponse.of(data, cursorPage.nextCursor(), cursorPage.hasNext());
}

@Transactional(readOnly = true)
public CursorPage getTasksByCursorPage(Long ownerId, int size, String cursor, boolean readyOnly) {
return loadTasksByCursor(ownerId, size, cursor, readyOnly);
}

@Transactional(readOnly = true)
Expand All @@ -76,6 +78,7 @@ public List<Task> findTasksByIds(Long ownerId, List<Long> taskIds) {
.toList();
}


@Transactional
public Task createTask(Task task) {
return taskRepository.save(task);
Expand Down Expand Up @@ -119,6 +122,20 @@ public void markIncomplete(Long ownerId, Long taskId) {
publishEvents();
}

private CursorPage loadTasksByCursor(Long ownerId, int size, String cursor, boolean readyOnly) {
Cursor decoded = decodeCursor(cursor);
List<Task> tasks = taskRepository.findNextByCursor(
ownerId,
readyOnly,
decoded.deadline(),
decoded.id(),
PageRequest.of(0, size)
);
boolean hasNext = tasks.size() == size;
String nextCursor = hasNext ? encodeCursor(tasks.getLast()) : null;
return new CursorPage(tasks, nextCursor, hasNext);
}

private void validateOwner(Long ownerId, Task task) {
if (!task.getOwnerId().equals(ownerId)) {
throw new IllegalArgumentException("Member does not own the task");
Expand Down Expand Up @@ -148,27 +165,50 @@ private void publishEvents() {
}

private String encodeCursor(Task task) {
LocalDateTime deadline = task.getTemporalConstraint().getDeadline().toLocalDateTime();
return deadline + CURSOR_DELIMITER + task.getId();
LocalDate deadline = task.getTemporalConstraint().getDeadlineDate();
return deadline.atStartOfDay().format(CURSOR_DEADLINE_FORMAT) + CURSOR_DELIMITER + task.getId();
}

private Cursor decodeCursor(String cursor) {
if (cursor == null || cursor.isBlank()) {
return new Cursor(LocalDateTime.MIN, 0L);
return new Cursor(LocalDate.MIN, 0L);
}
String[] parts = cursor.split("\\|");
if (parts.length != 2) {
throw new IllegalArgumentException("잘못된 커서 형식입니다.");
}
try {
LocalDateTime deadline = LocalDateTime.parse(parts[0]);
LocalDate deadline = parseDeadline(parts[0]);
Long id = Long.parseLong(parts[1]);
return new Cursor(deadline, id);
} catch (Exception e) {
throw new IllegalArgumentException("잘못된 커서 값입니다.", e);
}
}

private record Cursor(LocalDateTime deadline, Long id) {
private LocalDate parseDeadline(String raw) {
try {
return LocalDate.parse(raw);
} catch (DateTimeParseException ignored) {
}
try {
return LocalDateTime.parse(raw).toLocalDate();
} catch (DateTimeParseException e) {
return LocalDateTime.parse(appendMissingSeconds(raw)).toLocalDate();
}
}

private String appendMissingSeconds(String raw) {
if (raw.matches("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}$")) {
return raw + ":00";
}
throw new IllegalArgumentException("잘못된 커서 값입니다.");
}


private record Cursor(LocalDate deadline, Long id) {
}

public record CursorPage(List<Task> tasks, String nextCursor, boolean hasNext) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,29 @@ private ZonedDateAttribute(LocalDate date, String offsetId) {

public static ZonedDateAttribute from(ZonedDateTime zonedDateTime) {
Objects.requireNonNull(zonedDateTime, "zonedDateTime must not be null");
return new ZonedDateAttribute(zonedDateTime.toLocalDate(), zonedDateTime.getOffset().getId());
return of(zonedDateTime.toLocalDate(), zonedDateTime.getOffset());
}

public static ZonedDateAttribute of(LocalDate date, ZoneOffset offset) {
Objects.requireNonNull(date, "date must not be null");
Objects.requireNonNull(offset, "offset must not be null");
return new ZonedDateAttribute(date, offset.getId());
}

public ZonedDateTime toZonedDateTime() {
Objects.requireNonNull(date, "dateTime must not be null");
Objects.requireNonNull(offsetId, "offsetId must not be null");

ZoneOffset to = ZoneOffset.of(offsetId);
ZoneOffset to = getOffset();

return date.atStartOfDay(to);
}

public ZoneOffset getOffset() {
Objects.requireNonNull(offsetId, "offsetId must not be null");
return ZoneOffset.of(offsetId);
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@
import java.util.List;

public interface ScheduleRepositoryCustom {
List<Schedule> findAllByOwnerIdAndDesignatedStartTimeInstantBetween(Long ownerId, Instant start, Instant end);
List<Schedule> findAllByOwnerIdAndDesignatedStartTimeBetween(Long ownerId, Instant start, Instant end);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.time.LocalDateTime;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;

Expand All @@ -31,14 +31,14 @@ public interface TaskRepository extends JpaRepository<Task, Long> {
WHERE t.ownerId = :ownerId
AND (:readyOnly = false OR (t.inboundDependencyCount = 0 AND t.completed = false))
AND (
t.temporalConstraint.deadline.dateTime > :cursorTime
OR (t.temporalConstraint.deadline.dateTime = :cursorTime AND t.id > :cursorId)
t.temporalConstraint.deadline.date > :cursorDate
OR (t.temporalConstraint.deadline.date = :cursorDate AND t.id > :cursorId)
)
ORDER BY t.temporalConstraint.deadline.dateTime ASC, t.id ASC
ORDER BY t.temporalConstraint.deadline.date ASC, t.id ASC
""")
List<Task> findNextByCursor(@Param("ownerId") Long ownerId,
@Param("readyOnly") boolean readyOnly,
@Param("cursorTime") LocalDateTime cursorTime,
@Param("cursorDate") LocalDate cursorDate,
@Param("cursorId") Long cursorId,
Pageable pageable);
}
Loading
Loading