์ด์ ๋
Project Timeline์ ๋ง๋ค์ด์
ํ๋ก์ ํธ ๋จ์์ ํ๋ฆ์ ๋ณผ ์ ์๊ฒ ๋ง๋ค์๋ค.
์ด์ ๋ ํ๋ฆ์ด ๋ณด์ธ๋ค.
๊ทผ๋ฐ ์ฌ๊ธฐ์ ๋ ํ๋ ๋ถ์กฑํ ๊ฒ ์์๋ค.
โ ํ๋ฆ์ ๋ณด์ด๋๋ฐ, ํด์์ ์๋๋ค
์ง๊ธ์ ์ด๋ฒคํธ๋ฅผ ๋์ดํ๋ ๋จ๊ณ๋ค.
๊ทธ๋์ ์ค๋ ๋ชฉํ๋ ์ด๊ฑฐ์๋ค.
โ ํ๋ฆ์ ์์ฝํด์ ๋ณด์ฌ์ฃผ์
์๋ฅผ ๋ค์ด,
ํ๋ก์ ํธ๋ฅผ ์งํํ๋ค ๋ณด๋ฉด
์์
์ ๊ณ์ ์ถ๊ฐ๋๊ณ ์ํ๋ ๋ฐ๋๋๋ฐ,
๋ง์ ์ง๊ธ ์ด ํ๋ก์ ํธ๊ฐ ์ด๋ ๋จ๊ณ์ ์๋์ง
ํ ๋ฒ์ ํ์
ํ๊ธฐ๋ ์ด๋ ต๋ค.
์ด๊ฑธ ํ์ธํ๋ ค๋ฉด
์ฌ๋ฌ Task๋ฅผ ํ๋์ฉ ๋ณด๊ฑฐ๋,
Timeline์ ์ง์ ํด์ํด์ผ ํ๋ค.
๊ทธ๋์
โ ์ด๋ฒคํธ๋ฅผ ๊ทธ๋๋ก ๋ณด์ฌ์ฃผ๋ ๊ฒ ์๋๋ผ
โ ํ๋ฆ์ ํด์ํด์ ๋ณด์ฌ์ฃผ๋ ๊ตฌ์กฐ๊ฐ ํ์ํ๋ค.
โ ๊ฒฐ๊ตญ, ๋ฐ์ดํฐ๋ฅผ ๋ณด๋ ๊ฒ์ด ์๋๋ผ
โ ์ํ๋ฅผ โํ๋จโํ ์ ์๋ ํํ๋ก ๋ฐ๊พธ๋ ๊ฒ์ด์๋ค.
์ด๋ฏธ ์ด๋ฐ API๊ฐ ์๋ค.
GET /api/projects/{id}/timeline
์ด๊ฑด ํ๋ก์ ํธ ์์์ ๋ฐ์ํ ์ด๋ฒคํธ๋ฅผ
์๊ฐ์์ผ๋ก ๋์ดํด์ค๋ค.
ํ์ง๋ง ์ค์ ๋ก ๊ถ๊ธํ ๊ฑด ์ด๊ฑฐ๋ค.
โ ์ง๊ธ ์ด ํ๋ก์ ํธ๋ ์ด๋ ์ ๋ ์งํ๋๋๊ฐ?
โ ์์
์ ๋ช ๊ฐ๊ณ , ์ํ๋ ์ด๋ป๊ฒ ๋ถํฌ๋์ด ์๋๊ฐ?
โ ์ต๊ทผ์ ์ธ์ ํ๋์ด ์์๋๊ฐ?
๋จ์ ๋์ด๋ง์ผ๋ก๋
์ด๊ฑธ ๋ฐ๋ก ์๊ธฐ ์ด๋ ต๋ค.
๊ทธ๋์ ํ์ํ ๊ฑด
โ ํ๋ฆ์ ํด์ํ ๊ฒฐ๊ณผ
๋จ์ํ ์ด๋ฒคํธ๋ฅผ ๋ณด๋ ๊ฒ์ด ์๋๋ผ
์ด ํ๋ก์ ํธ๊ฐ ์ง๊ธ ๊ฑด๊ฐํ๊ฒ ์งํ๋๊ณ ์๋์ง ํ๋จํ ์ ์์ด์ผ ํ๋ค.
์ฆ,
โ ํ๋ฆ์ ๋์ดํ๋ ๊ฒ์ด ์๋๋ผ
โ ํ๋ฆ์ ๊ธฐ๋ฐ์ผ๋ก ์ํ๋ฅผ ํด์ํด์ผ ํ๋ค.
์ด๊ฑธ ํด๊ฒฐํ๊ธฐ ์ํด ๋ง๋ API๊ฐ ์ด๊ฑฐ๋ค.
GET /api/projects/{id}/analysis
์ด API๋
ํ๋ก์ ํธ์ ์ํ Task๋ฅผ ์กฐํํ๊ณ
TaskEvent๋ฅผ ๊ธฐ๋ฐ์ผ๋ก
์ํ ๋ถํฌ์ ํ๋ฆ ์ ๋ณด๋ฅผ ๊ณ์ฐํด์ ๋ฐํํ๋ค

์ด์ ๋
โ ๋ฌด์จ ์ผ์ด ์์๋์ง๊ฐ ์๋๋ผ
โ ์ง๊ธ ์ด๋ค ์ํ์ธ์ง๊ฐ ๋ณด์ธ๋ค
์ด API๋ ๋จ์ ์กฐํ๊ฐ ์๋๋ผ,
โ ์ฌ๋ฌ ์ด๋ฒคํธ๋ฅผ ์ข
ํฉํด์
โ ํ๋ก์ ํธ์ ํ์ฌ ์ํ๋ฅผ ํ ๋ฒ์ ํ๋จํ ์ ์๋๋ก ๋ง๋ API๋ค.
๊ตฌ์กฐ๋ ๊ทธ๋๋ก ์ ์งํ๋ค.
Project โ Task โ TaskEvent
์ฝ๋ ํ๋ฆ์ ์ด๋ ๋ค.
List<Task> tasks = taskRepository.findByProject_IdAndStatusNot(projectId, TaskStatus.DELETED);
List<Long> taskIds = tasks.stream()
.map(Task::getId)
.toList();
List<TaskEvent> events = taskIds.isEmpty()
? List.of()
: taskEventRepository.findByTaskIdInOrderByCreatedAtAsc(taskIds);
ํต์ฌ์ ์ด๊ฑฐ๋ค.
โ Task๋ ํ์ฌ ์ํ
โ TaskEvent๋ ์ํ ๋ณํ ์ด๋ ฅ
์ด ๋์ ํฉ์ณ์ผ
โ ํ๋ฆ ํด์์ด ๊ฐ๋ฅํ๋ค
๋จ์ ์กฐํ๋ฅผ ๋์ด์
์ํ๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ํต๊ณ๋ฅผ ๋ง๋ ๋ค.
int todoCount = countByStatus(tasks, TaskStatus.TODO);
int inProgressCount = countByStatus(tasks, TaskStatus.IN_PROGRESS);
int blockedCount = countByStatus(tasks, TaskStatus.BLOCKED);
int doneCount = countByStatus(tasks, TaskStatus.DONE);

โ TODO / IN_PROGRESS / DONE ๋น์จ ํ์ธ
์๋ฅผ ๋ค์ด,
IN_PROGRESS๊ฐ ๋ง๊ณ DONE์ด ์ ๋ค๋ฉด
โ ์์
์ด ์์์ ๋์์ง๋ง ์๋ฃ๋์ง ์๊ณ ์์ด๊ณ ์๋ ์ํ๋ค.
์ฆ,
๋ณ๋ชฉ ๊ตฌ๊ฐ์ ํ๋์ ํ์ ํ ์ ์๋ค
โ ๋จ์ ๊ฐ์๊ฐ ์๋๋ผ
โ ์ํ ํ๋ฆ์ ๋ฌธ์ ๋ฅผ ๋๋ฌ๋ด๋ ์งํ๊ฐ ๋๋ค
LocalDateTime lastEventAt = events.isEmpty()
? null
: events.get(events.size() - 1).getCreatedAt();

โ ๊ฐ์ฅ ์ต๊ทผ ์ด๋ฒคํธ ์๊ฐ
โ ์ด ํ๋ก์ ํธ๊ฐ ๋ง์ง๋ง์ผ๋ก ์์ง์ธ ์๊ฐ
์ต๊ทผ ํ๋ ์์ ์ด ์ค๋๋๋ค๋ฉด
โ ์ด ํ๋ก์ ํธ๋ ์ฌ์ค์ ๋ฉ์ถฐ์๋ ์ํ๋ค.
์ฆ, ์ต๊ทผ ์ด๋ฒคํธ ์๊ฐ ํ๋๋ก
ํ๋ก์ ํธ์ โํ์ฑ๋โ๋ฅผ ํ๋จํ ์ ์๋ค
๊ธฐ์กด์๋
POST /tasks
void ์ํ์๋ค.
์ด๊ฑธ ๋ฐ๊ฟจ๋ค.
โ ์์ฑํ๋ฉด ๋ฐ๋ก ๊ฒฐ๊ณผ ๋ฐํ

โ ์์ฑ ํ id ํฌํจ๋ JSON ๋ฐํ
์ด์ ๋
์์ฑ โ ๋ฐ๋ก ์กฐํ/์ฌ์ฉ ๊ฐ๋ฅ
๊ธฐ์กด์๋
โ ์์ธ๋ฅผ ๋์ง๊ธฐ๋ง ํ๋ค
์ด๊ฑธ ๋ฐ๊ฟจ๋ค.
โ JSON ํํ๋ก ํต์ผ


์ด์ ๋
โ ์ฑ๊ณต/์คํจ ๋ชจ๋ ๊ฐ์ ๊ตฌ์กฐ
๋ถ์ API๋ ๊ณ์ฐ ๋น์ฉ์ด ์๋ค.
ํ๋ก์ ํธ์ ์ํ Task๋ฅผ ์กฐํํ๊ณ
๊ฐ Task์ ์ด๋ฒคํธ๋ฅผ ๋ค์ ์กฐํํ ๋ค
์ํ๋ณ ๊ฐ์์ ๋ง์ง๋ง ์ด๋ฒคํธ ์์ ์ ๊ณ์ฐํด์ผ ํ๋ค.
๊ทธ๋์ ๋ถ์ API์ ์บ์ฑ์ ๋ถ์๋ค.
@Cacheable(value = "projectAnalysis", key = "#projectId")
๊ทธ๋ฆฌ๊ณ Task ์ํ๊ฐ ๋ณ๊ฒฝ๋๋ฉด
๋ถ์ ๊ฒฐ๊ณผ๋ ๋ฐ๋์ด์ผ ํ๋ฏ๋ก ์บ์๋ฅผ ์ ๊ฑฐํ๋๋ก ํ๋ค.
@CacheEvict(value = "projectAnalysis", key = "#result.projectId")

์ฒซ ์์ฒญ์ 1751ms๊ฐ ๊ฑธ๋ ธ๋ค.
์ด ์์ฒญ์์๋ ์ค์ ๋ก ๋ถ์ ๋ก์ง์ด ์คํ๋๊ณ ,
Task ์กฐํ์ Event ์กฐํ, ์ํ๋ณ ์ง๊ณ๊ฐ ์ํ๋๋ค.

๊ฐ์ ์์ฒญ์ ๋ค์ ๋ณด๋ด์ ์๋ต ์๊ฐ์ด 95ms๋ก ์ค์๋ค.
์ฆ,
โ ์ฒซ ์์ฒญ: 1751ms
โ ๋ ๋ฒ์งธ ์์ฒญ: 95ms
์ฝ 18๋ฐฐ ์ ๋ ๋นจ๋ผ์ง ๊ฒ์ ํ์ธํ ์ ์์๋ค.
๋จ์ํ ๋ก์ง์ ๋น ๋ฅด๊ฒ ๋ง๋ ๊ฒ์ด ์๋๋ผ,
๋์ผํ ์์ฒญ์ ๋ํด์๋ ๋ถ์ ๋ก์ง ์์ฒด๋ฅผ ๋ค์ ์คํํ์ง ์๊ณ
์บ์์ ์ ์ฅ๋ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ๋ก ๋ฐํํ ๊ฒ์ด๋ค.
์บ์ฑ์ ์ด๋
ธํ
์ด์
๋ง ๋ถ์ด๋ฉด ๋๋๋ ์ค ์์๋๋ฐ,
์ค์ ๋ก๋ ๋ฌด์์ ์ ์ฅํ ์ ์๋๊ฐ๊น์ง ๊ฐ์ด ๊ณ ๋ คํด์ผ ํ๋ค๋ ๊ฑธ ์๊ฒ ๋๋ค.
๊ทผ๋ฐ ์ฌ๊ธฐ์ ๋ฌธ์ ๊ฐ ๋ฐ์ํ๋ค.
GET http://localhost:8080/api/projects/1/analysis
HTTP/1.1 400
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Fri, 01 May 2026 12:47:22 GMT
Connection: close
{
"code": "BAD_REQUEST",
"message": "DefaultSerializer requires a Serializable payload but received an object of type [com.ahyeon.flowbit.domain.project.dto.ProjectAnalysisResponse]",
"timestamp": "2026-05-01T21:47:22.185627213"
}
์๋ต ํ์ผ์ด ์ ์ฅ๋์์ต๋๋ค.
> 2026-05-01T214722.400.json
Response code: 400; Time: 87ms (87 ms); Content length: 223 bytes (223 B)
์บ์์ ์ ์ฅํ๋ ค๋ ๊ฐ์ฒด๊ฐ
์ง๋ ฌํ๊ฐ ์ ๋์ด ์์ด์ ์๋ฌ๊ฐ ํฐ์ก๋ค.
๋ฌธ์ ์์ธ:
โ ์บ์์ ์ ์ฅํ๋ ค๋ DTO๊ฐ ์ง๋ ฌํ ๋ถ๊ฐ
ํด๊ฒฐ:
public class ProjectAnalysisResponse implements Serializable

โ ์บ์ฑ ์ ์ ๋์
์ด๊ฑธ ํตํด ์๊ฒ ๋ ์ :
์บ์ฑ์ ๋จ์ ์ด๋
ธํ
์ด์
์ด ์๋๋ผ
์ ์ฅ ๋ฐฉ์๊น์ง ๊ณ ๋ คํด์ผ ํ๋ค
๊ธฐ์กด:
/projects
/tasks
๋ณ๊ฒฝ:
/api/projects
/api/tasks


โ ํ๋ก ํธ / ๋ฐฑ์๋ ๋ช ํํ ๋ถ๋ฆฌ
์ค๋ ํ ๊ฑธ ํ ์ค๋ก ์ ๋ฆฌํ๋ฉด ์ด๊ฑฐ๋ค.
โ ํ๋ฆ์ ๋ณด๋ ๋จ๊ณ์์
โ ํ๋ฆ์ ํด์ํ๊ณ ํ๋จํ๋ ๋จ๊ณ๋ก ๋์ด๊ฐ๋ค
์ด์ ๋ ๋จ์ํ ๋ฐ์ดํฐ๋ฅผ ๋์ดํ๋ ๊ฒ์ด ์๋๋ผ
โ ํ๋ก์ ํธ์ ์ํ๋ฅผ ํด์ํ๊ณ
โ ๋ฌธ์ ๊ฐ ๋ฐ์ํ๋ ์ง์ ์ ํ์
ํ ์ ์๋ ๊ตฌ์กฐ๊ฐ ๋์๋ค
๋ํ ๋ถ์ API์ ์บ์ฑ์ ์ ์ฉํ๋ฉด์
โ ์ฑ๋ฅ ๊ฐ์ ๊ณผ ์ง๋ ฌํ ๋ฌธ์ ๊น์ง ํจ๊ป ๊ฒฝํํ๋ค.
Project โ๏ธ
Task โ๏ธ
TaskEvent โ๏ธ
Timeline โ๏ธ
Analysis โ๏ธ
Exception Handling โ๏ธ
Caching โ๏ธ
API ๊ตฌ์กฐ โ๏ธ
์ด์ ๋
โ ๋จ์ CRUD๊ฐ ์๋๋ผ
โ ํ๋ฆ ๊ธฐ๋ฐ ์์คํ
์ด์ ๋ฐฑ์๋๋ ์ถฉ๋ถํ ์ค๋น๋๋ค.
๋ค์ ๋จ๊ณ๋
โ React๋ก ํ๋ก ํธ ๋ถ์ด๊ธฐ
Timeline + Analysis๋ฅผ
ํ๋ฉด์ผ๋ก ๋ณด์ฌ์ฃผ๋ ๋จ๊ณ
๊ทธ๋ฆฌ๊ณ Redis๋
โ ๊ตฌ์กฐ๋ง ์ก์๋๊ณ
โ ์ค์ ์ ์ฉ์ ์ดํ ๋จ๊ณ์์ ์งํํ ์์