티스토리 뷰
비동기 작업을 큐에 넣은 다음엔 뭘 봐야 할까? 작업 상태 추적, Job Status API, 폴링 구조 정리 — FastAPI · Spring Boot · Node.js
octo54 2026. 5. 29. 11:43비동기 작업을 큐에 넣은 다음엔 뭘 봐야 할까? 작업 상태 추적, Job Status API, 폴링 구조 정리 — FastAPI · Spring Boot · Node.js
한 줄 요약
비동기 작업과 큐를 도입했다면, 다음 단계는 **“작업이 지금 어디까지 갔는지 추적할 수 있게 만드는 것”**입니다. 사용자는 요청이 접수됐는지, 처리 중인지, 실패했는지 알고 싶고, 운영자는 어떤 작업이 자주 실패하는지 봐야 합니다. 그래서 보통 job_id, status, progress, result, error_message를 포함한 작업 상태 모델과, 이를 조회하는 Job Status API를 같이 설계합니다.
이 글에서 다루는 내용
- 비동기 작업 다음에 왜 상태 추적이 필요한지
- QUEUED, PROCESSING, SUCCEEDED, FAILED 같은 상태 모델은 어떻게 잡는지
- 폴링과 웹훅 중 무엇을 먼저 써야 하는지
- FastAPI, Spring Boot, Node.js에서 최소 구현 예제
- 검색에 잘 걸리도록 질문형 제목, 첫 문단 정답 요약, 정의 문장, FAQ, 핵심 요약 구조를 반영한 글 구성 원칙
Job Status API란?
Job Status API는 비동기 작업의 현재 상태를 조회하는 API입니다.
예를 들어 사용자가 이런 요청을 보냈다고 해볼게요.
- PDF 요약 생성
- 이미지 썸네일 생성
- 대량 메일 발송
- AI 문서 임베딩 생성
- 영상 변환
이런 작업은 보통 몇 초에서 몇 분까지 걸릴 수 있습니다.
즉 요청-응답 한 번으로 끝내기 어렵고, 큐에 넣고 나중에 처리하는 구조가 자연스럽습니다. 그런데 이렇게 되면 바로 다음 질문이 생깁니다.
“그래서 지금 이 작업이 끝났어요?”
이걸 알려주는 게 Job Status API입니다.
핵심만 말하면, Job Status API는 비동기 작업을 조회 가능한 상태 데이터로 바꿔주는 인터페이스입니다.
왜 큐만 있으면 끝이 아닐까
지난 글에서 큐를 도입한 이유는 이랬죠.
- 요청 응답을 빠르게 하기 위해
- 느린 후처리를 워커로 넘기기 위해
- 재시도와 실패 처리를 안정적으로 하기 위해
그런데 큐만 넣으면 새로운 문제가 생깁니다.
- 사용자는 작업이 접수됐는지 모름
- 프론트는 언제 결과를 다시 가져와야 할지 모름
- 운영자는 어떤 작업이 실패했는지 파악하기 어려움
- 재처리 버튼을 만들기도 애매함
즉 큐를 넣는 순간부터는
“처리”뿐 아니라 “추적”도 시스템 기능이 됩니다.
그래서 보통 비동기 작업 구조는 두 축으로 같이 갑니다.
- 작업 실행 구조
- 작업 상태 추적 구조
작업 상태는 보통 어떻게 나눌까
가장 무난한 시작점은 이 정도입니다.
- QUEUED
- PROCESSING
- SUCCEEDED
- FAILED
이 네 개만 있어도 꽤 많은 걸 설명할 수 있어요.
QUEUED
작업이 등록됐고, 아직 워커가 가져가지 않은 상태
PROCESSING
워커가 가져가서 실제 처리 중인 상태
SUCCEEDED
성공적으로 끝난 상태
FAILED
실패했고, 더 이상 진행되지 않는 상태
여기에 조금 더 붙이면 이렇게 확장할 수 있습니다.
- RETRYING
- CANCELLED
- PARTIAL_SUCCESS
- EXPIRED
하지만 초반에는 너무 복잡하게 가지 않는 게 좋아요.
검색에 잘 걸리는 개발 글도 “글 하나에 검색 의도 하나만 잡아라”는 식으로 범위를 명확히 잡는 게 좋다고 정리되어 있고, 구조도 단순할수록 AI가 답변 재료로 쓰기 쉽습니다.
작업 상태 모델에 어떤 필드가 있으면 좋을까
저는 최소한 이 정도를 추천합니다.
- job_id
- job_type
- status
- progress
- created_at
- updated_at
- result
- error_message
그리고 있으면 좋은 건 이렇습니다.
- owner_id
- retry_count
- started_at
- finished_at
- correlation_id
이 필드들이 왜 중요하냐면,
- 사용자에게는 status, progress
- 운영자에게는 error_message, retry_count
- 서비스 로깅에는 job_id, correlation_id
가 주로 쓰이기 때문입니다.
progress는 꼭 필요할까
꼭 그런 건 아닙니다.
작업 종류에 따라 달라요.
progress가 있으면 좋은 작업
- 파일 변환
- 대량 발송
- 문서 인덱싱
- 여러 단계 파이프라인 작업
progress가 없어도 되는 작업
- 메일 1건 발송
- 결제 영수증 생성
- 단일 웹훅 후처리
- 짧은 AI 후처리
초반에는 progress 없이 status만으로 시작해도 충분합니다.
그 대신 결과가 긴 작업이라면 최소한 QUEUED → PROCESSING → SUCCEEDED/FAILED는 보여주는 편이 좋습니다.
폴링이 좋을까, 웹훅이나 실시간 업데이트가 좋을까
이 질문도 진짜 많이 나옵니다.
저는 초반엔 거의 항상 폴링부터 추천합니다.
폴링
클라이언트가 일정 주기로 /jobs/{job_id}를 조회
장점:
- 단순함
- 구현이 쉬움
- 브라우저/앱 모두 적용 쉬움
단점:
- 요청이 반복됨
- 실시간감은 덜함
웹훅
작업이 끝나면 서버가 다른 서버에 알림
장점:
- 서버 간 통합에 좋음
- 클라이언트가 계속 조회 안 해도 됨
단점:
- URL 관리, 재시도, 보안이 필요
- 일반 웹 프론트에는 바로 안 맞는 경우 많음
SSE / WebSocket
작업 상태를 실시간으로 push
장점:
- UX 좋음
- 진행률 보여주기 좋음
단점:
- 구현 복잡도 증가
- 연결 유지 비용 있음
초반 서비스에서 제일 현실적인 선택은 보통 이거예요.
먼저 Job Status API + 폴링
그다음 정말 필요하면 SSE/WebSocket
이번 글에서 맞출 공통 예제
이번에는 “AI 문서 요약 작업”을 예로 들겠습니다.
흐름은 이렇게 통일할게요.
- /api/jobs/summarize 요청
- 서버가 작업을 큐에 등록
- job_id 반환
- 클라이언트가 /api/jobs/{job_id} 폴링
- 완료되면 result.summary 확인
즉 오늘 글의 핵심은 이겁니다.
비동기 작업은 job_id를 반환하는 순간 끝나는 게 아니라, status를 조회할 수 있을 때 비로소 제품 기능이 된다.
1) FastAPI에서 Job Status API 만들기
추천 구조
fastapi-backend/
├── app/
│ ├── api/
│ │ └── job.py
│ ├── repositories/
│ │ └── job_repository.py
│ ├── schemas/
│ │ └── job.py
│ ├── services/
│ │ └── job_service.py
│ └── main.py
app/schemas/job.py
from pydantic import BaseModel
from typing import Any, Optional
from datetime import datetime
class CreateSummaryJobRequest(BaseModel):
document_id: str
class JobRecord(BaseModel):
job_id: str
job_type: str
status: str
progress: int
owner_id: str
created_at: datetime
updated_at: datetime
result: Optional[dict[str, Any]] = None
error_message: Optional[str] = None
app/repositories/job_repository.py
from datetime import datetime
from typing import Optional
from app.schemas.job import JobRecord
class JobRepository:
def __init__(self) -> None:
self._jobs: dict[str, JobRecord] = {}
def save(self, job: JobRecord) -> JobRecord:
self._jobs[job.job_id] = job
return job
def find_by_id(self, job_id: str) -> Optional[JobRecord]:
return self._jobs.get(job_id)
def update_status(
self,
job_id: str,
status: str,
progress: int,
result: dict | None = None,
error_message: str | None = None,
) -> Optional[JobRecord]:
job = self._jobs.get(job_id)
if not job:
return None
job.status = status
job.progress = progress
job.updated_at = datetime.utcnow()
job.result = result
job.error_message = error_message
return job
app/services/job_service.py
import uuid
from datetime import datetime
from app.repositories.job_repository import JobRepository
from app.schemas.job import CreateSummaryJobRequest, JobRecord
class JobService:
def __init__(self, job_repository: JobRepository) -> None:
self.job_repository = job_repository
def create_summary_job(
self,
request: CreateSummaryJobRequest,
owner_id: str,
) -> JobRecord:
job = JobRecord(
job_id=str(uuid.uuid4()),
job_type="DOCUMENT_SUMMARY",
status="QUEUED",
progress=0,
owner_id=owner_id,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
return self.job_repository.save(job)
def get_job(self, job_id: str) -> JobRecord:
job = self.job_repository.find_by_id(job_id)
if job is None:
raise ValueError("작업을 찾을 수 없습니다.")
return job
# 예제용: 실제로는 워커가 처리
def simulate_worker_progress(self, job_id: str) -> None:
self.job_repository.update_status(job_id, "PROCESSING", 50)
self.job_repository.update_status(
job_id,
"SUCCEEDED",
100,
result={"summary": "문서 요약 결과입니다."},
)
app/api/job.py
from fastapi import APIRouter, HTTPException, status
from app.repositories.job_repository import JobRepository
from app.schemas.job import CreateSummaryJobRequest, JobRecord
from app.services.job_service import JobService
router = APIRouter(prefix="/api/jobs", tags=["jobs"])
job_repository = JobRepository()
job_service = JobService(job_repository)
@router.post("/summarize", response_model=JobRecord, status_code=status.HTTP_202_ACCEPTED)
def create_summary_job(request: CreateSummaryJobRequest) -> JobRecord:
job = job_service.create_summary_job(request, owner_id="demo-user-1")
return job
@router.get("/{job_id}", response_model=JobRecord)
def get_job(job_id: str) -> JobRecord:
try:
return job_service.get_job(job_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.post("/{job_id}/simulate-complete")
def simulate_complete(job_id: str):
job_service.simulate_worker_progress(job_id)
return {"message": "작업 상태가 완료로 변경되었습니다."}
app/main.py
from fastapi import FastAPI
from app.api.job import router as job_router
app = FastAPI()
app.include_router(job_router)
FastAPI에서 핵심 감각
FastAPI에서는 Job Status API를 일반 REST API처럼 깔끔하게 만들 수 있습니다.
중요한 건 “작업 생성 API”와 “작업 상태 조회 API”를 분리하는 거예요.
- POST /api/jobs/summarize
- GET /api/jobs/{job_id}
이 구조가 있어야 프론트도 안정적으로 폴링할 수 있습니다.
2) Spring Boot에서 작업 상태 모델 만들기
Spring 쪽은 이 구조가 특히 익숙합니다.
일반 도메인 모델처럼 job을 저장하고, 상태를 갱신하고, 조회하면 됩니다.
추천 구조
springboot-backend/
├── src/main/java/com/example/backend/
│ ├── controller/
│ │ └── JobController.java
│ ├── dto/
│ │ └── CreateSummaryJobRequest.java
│ ├── model/
│ │ └── JobRecord.java
│ ├── repository/
│ │ └── JobRepository.java
│ └── service/
│ └── JobService.java
dto/CreateSummaryJobRequest.java
package com.example.backend.dto;
public record CreateSummaryJobRequest(
String documentId
) {
}
model/JobRecord.java
package com.example.backend.model;
import java.time.Instant;
import java.util.Map;
public record JobRecord(
String jobId,
String jobType,
String status,
int progress,
String ownerId,
Instant createdAt,
Instant updatedAt,
Map<String, Object> result,
String errorMessage
) {
}
repository/JobRepository.java
package com.example.backend.repository;
import com.example.backend.model.JobRecord;
import org.springframework.stereotype.Repository;
import java.time.Instant;
import java.util.*;
@Repository
public class JobRepository {
private final Map<String, JobRecord> jobs = new HashMap<>();
public JobRecord save(JobRecord job) {
jobs.put(job.jobId(), job);
return job;
}
public Optional<JobRecord> findById(String jobId) {
return Optional.ofNullable(jobs.get(jobId));
}
public void updateStatus(
String jobId,
String status,
int progress,
Map<String, Object> result,
String errorMessage
) {
JobRecord current = jobs.get(jobId);
if (current == null) return;
jobs.put(jobId, new JobRecord(
current.jobId(),
current.jobType(),
status,
progress,
current.ownerId(),
current.createdAt(),
Instant.now(),
result,
errorMessage
));
}
}
service/JobService.java
package com.example.backend.service;
import com.example.backend.dto.CreateSummaryJobRequest;
import com.example.backend.model.JobRecord;
import com.example.backend.repository.JobRepository;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
@Service
public class JobService {
private final JobRepository jobRepository;
public JobService(JobRepository jobRepository) {
this.jobRepository = jobRepository;
}
public JobRecord createSummaryJob(CreateSummaryJobRequest request, String ownerId) {
JobRecord job = new JobRecord(
UUID.randomUUID().toString(),
"DOCUMENT_SUMMARY",
"QUEUED",
0,
ownerId,
Instant.now(),
Instant.now(),
null,
null
);
return jobRepository.save(job);
}
public JobRecord getJob(String jobId) {
return jobRepository.findById(jobId)
.orElseThrow(() -> new IllegalArgumentException("작업을 찾을 수 없습니다."));
}
public void simulateWorkerProgress(String jobId) {
jobRepository.updateStatus(jobId, "PROCESSING", 50, null, null);
jobRepository.updateStatus(
jobId,
"SUCCEEDED",
100,
Map.of("summary", "문서 요약 결과입니다."),
null
);
}
}
controller/JobController.java
package com.example.backend.controller;
import com.example.backend.dto.CreateSummaryJobRequest;
import com.example.backend.model.JobRecord;
import com.example.backend.service.JobService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/jobs")
public class JobController {
private final JobService jobService;
public JobController(JobService jobService) {
this.jobService = jobService;
}
@PostMapping("/summarize")
@ResponseStatus(HttpStatus.ACCEPTED)
public JobRecord createSummaryJob(@RequestBody CreateSummaryJobRequest request) {
return jobService.createSummaryJob(request, "demo-user-1");
}
@GetMapping("/{jobId}")
public JobRecord getJob(@PathVariable String jobId) {
return jobService.getJob(jobId);
}
@PostMapping("/{jobId}/simulate-complete")
public Object simulateComplete(@PathVariable String jobId) {
jobService.simulateWorkerProgress(jobId);
return java.util.Map.of("message", "작업 상태가 완료로 변경되었습니다.");
}
}
Spring Boot에서 핵심 감각
Spring에서는 Job 상태도 그냥 하나의 도메인처럼 보면 됩니다.
- 생성
- 조회
- 상태 전이
- 완료 결과 저장
나중에 이 구조를 JPA 엔티티와 JpaRepository로 옮기기도 쉽습니다.
즉 큐가 들어왔더라도 결국 상태 추적은 일반 백엔드 모델링 문제로 돌아옵니다.
3) Node.js에서 Job Status API 만들기
Node.js는 이 구조를 분리하지 않으면 라우터가 금방 지저분해집니다.
그래서 job model도 repository/service로 나누는 편이 좋습니다.
추천 구조
node-backend/
├── src/
│ ├── repositories/
│ │ └── job.repository.js
│ ├── routes/
│ │ └── job.route.js
│ └── services/
│ └── job.service.js
src/repositories/job.repository.js
export class JobRepository {
constructor() {
this.jobs = new Map();
}
save(job) {
this.jobs.set(job.jobId, job);
return job;
}
findById(jobId) {
return this.jobs.get(jobId) ?? null;
}
updateStatus(jobId, { status, progress, result = null, errorMessage = null }) {
const current = this.jobs.get(jobId);
if (!current) return null;
const updated = {
...current,
status,
progress,
updatedAt: new Date().toISOString(),
result,
errorMessage,
};
this.jobs.set(jobId, updated);
return updated;
}
}
src/services/job.service.js
import crypto from "node:crypto";
export class JobService {
constructor(jobRepository) {
this.jobRepository = jobRepository;
}
createSummaryJob({ documentId, ownerId }) {
const job = {
jobId: crypto.randomUUID(),
jobType: "DOCUMENT_SUMMARY",
status: "QUEUED",
progress: 0,
ownerId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
result: null,
errorMessage: null,
};
return this.jobRepository.save(job);
}
getJob(jobId) {
const job = this.jobRepository.findById(jobId);
if (!job) {
throw new Error("작업을 찾을 수 없습니다.");
}
return job;
}
simulateWorkerProgress(jobId) {
this.jobRepository.updateStatus(jobId, {
status: "PROCESSING",
progress: 50,
});
this.jobRepository.updateStatus(jobId, {
status: "SUCCEEDED",
progress: 100,
result: {
summary: "문서 요약 결과입니다.",
},
});
}
}
src/routes/job.route.js
import { Router } from "express";
import { JobRepository } from "../repositories/job.repository.js";
import { JobService } from "../services/job.service.js";
const router = Router();
const jobRepository = new JobRepository();
const jobService = new JobService(jobRepository);
router.post("/summarize", (req, res) => {
const { documentId } = req.body;
if (!documentId) {
return res.status(400).json({ message: "documentId가 필요합니다." });
}
const job = jobService.createSummaryJob({
documentId,
ownerId: "demo-user-1",
});
return res.status(202).json(job);
});
router.get("/:jobId", (req, res) => {
try {
const job = jobService.getJob(req.params.jobId);
return res.json(job);
} catch (error) {
return res.status(404).json({ message: error.message });
}
});
router.post("/:jobId/simulate-complete", (req, res) => {
jobService.simulateWorkerProgress(req.params.jobId);
return res.json({ message: "작업 상태가 완료로 변경되었습니다." });
});
export default router;
src/server.js
import express from "express";
import jobRouter from "./routes/job.route.js";
const app = express();
const PORT = 3000;
app.use(express.json());
app.use("/api/jobs", jobRouter);
app.listen(PORT, () => {
console.log(`server running on http://localhost:${PORT}`);
});
Node.js에서 핵심 감각
Node.js에서는 특히 “큐는 BullMQ, 상태는 DB”처럼 역할을 분리해서 생각하는 게 좋습니다.
즉 BullMQ는 작업 실행을 맡고,
우리 애플리케이션은 그 작업 상태를 사용자에게 보여주는 API를 맡는 거죠.
이 구조가 잡혀야 프론트도 상태 화면을 만들 수 있습니다.
폴링은 어떻게 하면 될까
프론트에서는 보통 이렇게 갑니다.
- 작업 생성
- job_id 받기
- 1초~3초 간격으로 상태 조회
- SUCCEEDED면 결과 표시
- FAILED면 에러 표시
- timeout 또는 최대 횟수 초과 시 중단
초반엔 이 정도면 충분합니다.
예를 들면:
const interval = setInterval(async () => {
const response = await fetch(`/api/jobs/${jobId}`);
const job = await response.json();
if (job.status === "SUCCEEDED") {
clearInterval(interval);
console.log(job.result.summary);
}
if (job.status === "FAILED") {
clearInterval(interval);
console.error(job.errorMessage);
}
}, 2000);
이 단순한 폴링 구조가 생각보다 오래 갑니다.
실시간이 꼭 필요한 게 아니라면, 먼저 이걸로 충분한 경우가 많아요.
작업 상태 추적에서 꼭 같이 봐야 할 것
1. job_id는 외부에 노출 가능한 식별자로 설계하세요
숫자 증가형보다 UUID가 더 무난한 경우가 많습니다.
2. owner_id를 꼭 두는 편이 좋습니다
그래야 “내 작업만 조회” 같은 제어가 쉬워집니다.
3. result는 너무 크게 만들지 마세요
결과가 크면 object key나 별도 결과 레코드 참조를 두는 편이 낫습니다.
4. 실패 원인은 사용자용과 운영자용을 나누세요
사용자에겐 “처리에 실패했습니다”
운영자 로그엔 stack trace나 상세 원인
5. 상태 전이는 제한적으로 관리하세요
예:
- QUEUED -> PROCESSING
- PROCESSING -> SUCCEEDED
- PROCESSING -> FAILED
이렇게 정해두는 게 좋습니다.
실무에서 자주 하는 실수
1. 큐는 넣었는데 상태 저장을 안 함
이러면 사용자도 운영자도 아무것도 모릅니다.
2. 성공/실패만 저장하고 진행 중 상태가 없음
프론트 UX가 굉장히 애매해집니다.
3. worker 로그만 믿고 상태 API는 없음
로그는 운영자가 보지, 사용자가 보는 게 아닙니다.
4. 실패 시 에러 메시지를 그대로 노출
내부 정보가 새어나갈 수 있습니다.
5. job 상태와 실제 결과 저장 위치가 분리 안 됨
결과가 커질수록 구조가 금방 꼬입니다.
FAQ
Q. Job Status API는 꼭 필요할까요?
비동기 작업이 사용자에게 보이는 기능이라면 거의 필요합니다. 큐만 넣고 상태 조회가 없으면 프론트도 사용자도 “지금 뭐가 됐는지” 알기 어렵습니다.
Q. 폴링과 웹소켓 중 뭐가 더 좋나요?
초반엔 폴링이 더 단순하고 현실적입니다. 작업 수가 많고 진행률을 실시간으로 보여줘야 할 때 SSE나 WebSocket을 붙이는 편이 보통 더 낫습니다.
Q. Job 결과를 DB에 다 저장해야 하나요?
작은 결과는 상태 레코드 안에 넣어도 되지만, 결과가 크면 별도 테이블이나 object storage key를 참조하는 구조가 더 낫습니다.
Q. 실패한 작업은 어떻게 다시 처리하나요?
보통은 FAILED 상태를 보고 운영자 재시도 API를 따로 두거나, 큐 시스템의 retry 기능을 같이 씁니다. 다만 멱등성은 꼭 같이 봐야 합니다.
Q. 진행률(progress)은 꼭 숫자여야 하나요?
꼭 그렇진 않습니다. 하지만 초반엔 0~100 정수로 두는 게 프론트와 운영 화면에서 가장 다루기 쉽습니다.
핵심 요약
- 비동기 작업을 도입했다면 다음 단계는 작업 상태 추적입니다.
- 최소 상태는 QUEUED, PROCESSING, SUCCEEDED, FAILED 정도로 시작하면 충분합니다.
- job_id, status, progress, result, error_message 정도는 보통 필요합니다.
- 초반엔 Job Status API + 폴링이 가장 현실적입니다.
- 큐 시스템과 별개로, 상태를 사용자에게 보여주는 API를 따로 설계해야 합니다.
- 검색에 잘 걸리는 개발 글은 질문형 제목, 초반 정답 요약, 정의 문장, FAQ, 구조화된 소제목이 강합니다.
출처
- FastAPI 공식 문서 — Background Tasks. (fastapi.tiangolo.com)
- Spring Framework 공식 문서 — Task Execution and Scheduling / @Async. (docs.spring.io)
- BullMQ 공식 문서 — Queues, Workers, Queue guide. (docs.bullmq.io)
- Celery 공식 문서 — Tasks, retry, worker 개념. (docs.celeryq.dev)
- 글 구성 참고: 업로드하신 “AI 검색에 잘 걸리는 글” 가이드.
백엔드개발, FastAPI, SpringBoot, Nodejs, Express, 비동기작업, JobStatusAPI, 작업상태추적, 폴링구조, 백엔드시리즈
'study > 백엔드' 카테고리의 다른 글
- Total
- Today
- Yesterday
- Prisma
- fastapi
- 개발블로그
- node.js
- CI/CD
- JWT
- rag
- Express
- 딥러닝
- seo 최적화 10개
- nodejs
- 주니어개발자
- SEO최적화
- Next.js
- 백엔드개발
- 쿠버네티스
- PostgreSQL
- NestJS
- 웹개발
- llm
- REACT
- LangChain
- flax
- 생성형AI
- Python
- JAX
- kotlin
- SpringBoot
- DevOps
- nextJS
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |

