프리랜서 업무에서 실제로 자주 쓰이는 계약/인보이스/정산 관리를 MVP로 구현했습니다.
로그인(JWT) → 고객/프로젝트/계약 → 인보이스/결제 → 대시보드(월별 발행/수금) 플로우를 모두 포함하고,
사용자별 데이터 스코핑과 Admin 읽기 전용 모니터링까지 반영했습니다.
subtotal/tax/withholding/total + 상태 전이(DRAFT → SENT → OVERDUE/PAID)INV-YYYYMM-000001 (소유자+월별 시퀀스, 유니크 / 불변)owner를 JPA Auditing으로 자동 채움 → 리포지토리/서비스/컨트롤러에서 일관된 필터.number는 updatable=false.

3) 고객/프로젝트/계약 생성

4) 인보이스 생성/발송

com.devcraft.freelance
├─ auth (JWT 로그인/리프레시/회원관리)
├─ common (ApiResponse, GlobalException, OwnedAuditable, Authz)
├─ client (entity/dto/repo/web)
├─ project (entity/dto/repo/web)
├─ contract (entity/dto/repo/web)
├─ invoice
│ ├─ entity (Invoice/InvoiceLine/Payment/InvoiceStatus/PayMethod/InvoiceNumberSeq)
│ ├─ repo (InvoiceRepository/InvoiceLineRepository/PaymentRepository/InvoiceNumberSeqRepository)
│ ├─ service (InvoiceService/InvoiceNumberingService)
│ └─ web (InvoiceController/PaymentController)
└─ config (CurrentAuditor)
// JPA Auditing + Swagger + Validation 등은 일반적인 Spring Boot 설정
@EnableJpaAuditing
@SpringBootApplication
public class FreelanceApplication { ... }
// 소유자 자동 기록
@Component
public class CurrentAuditor implements AuditorAware<String> {
@Override public Optional<String> getCurrentAuditor(){
var a = SecurityContextHolder.getContext().getAuthentication();
return Optional.ofNullable(a!=null ? a.getName() : null);
}
}
// 모든 도메인 엔티티는 OwnedAuditable 상속(owner/createdAt/updatedAt)
@MappedSuperclass @EntityListeners(AuditingEntityListener.class)
public abstract class OwnedAuditable {
@CreatedBy @Column(updatable=false) private String owner;
@CreatedDate @Column(updatable=false) private Instant createdAt;
@LastModifiedDate private Instant updatedAt;
}
@Service
public class InvoiceNumberingService {
@Transactional
public String nextNumber(String owner, LocalDate issueDate){
String yyyymm = (issueDate!=null? issueDate: LocalDate.now()).format(DateTimeFormatter.ofPattern("yyyyMM"));
var seq = repo.findForUpdate(owner, yyyymm)
.orElseGet(() -> repo.save(InvoiceNumberSeq.builder().owner(owner).prefix(yyyymm).nextValue(1L).build()));
long v = seq.getNextValue(); seq.setNextValue(v+1);
return "INV-" + yyyymm + "-" + String.format("%06d", v);
}
}
// Invoice 엔티티의 번호 컬럼은 불변 & 유니크
@Table(
uniqueConstraints=@UniqueConstraint(name="uk_invoice_owner_number", columnNames={"owner","number"}),
indexes={ @Index(name="idx_invoice_owner", columnList="owner"),
@Index(name="idx_invoice_issueDate", columnList="issueDate"),
@Index(name="idx_invoice_status", columnList="status") }
)
public class Invoice extends OwnedAuditable {
@Column(nullable=false, updatable=false, length=32)
private String number;
// ... subtotal/tax/withholding/total, status, lines 등
}
// 예: ClientController
@PostMapping
public ApiResponse<ClientResponse> create(@Valid @RequestBody ClientRequest req, Authentication auth){
if (Authz.isAdmin(auth)) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Admin is read-only");
// ... 사용자 소유 검증 후 생성
}
src/
├─ api/axios.ts # baseURL, 401→리프레시, 타입 안전 인터셉터
├─ auth/{AuthProvider.tsx,useAuth.ts}
├─ components/Navbar.tsx
├─ pages/
│ ├─ auth/{LoginPage.tsx,RegisterPage.tsx}
│ ├─ Dashboard.tsx
│ ├─ Clients.tsx / Projects.tsx / Contracts.tsx / Invoices.tsx
├─ App.tsx (보호 라우팅, Admin UI 숨김)
└─ index.css (심플 스타일)
// axios.d.ts에서 InternalAxiosRequestConfig<D = any>에 _retry 보강
// eslint는 d.ts 전용으로 no-explicit-any off
import axios from "axios";
import type { AxiosError, AxiosRequestHeaders, InternalAxiosRequestConfig } from "axios";
const api = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL });
api.interceptors.request.use((cfg) => {
const tokens = JSON.parse(localStorage.getItem("tokens")||"null");
const t = tokens?.accessToken; if (t){
cfg.headers = (cfg.headers||{}) as AxiosRequestHeaders;
(cfg.headers as AxiosRequestHeaders).Authorization = `Bearer ${t}`;
}
return cfg;
});
// 401 → refresh → 대기중 요청 재시도
api.interceptors.response.use(
(r)=>r,
async (error: AxiosError) => { /* ...생략(본문에 구현) */ }
);
export default api;
const { user } = useAuth();
const isAdmin = user?.role === "ADMIN";
// 헤더/셀 모두 조건부 렌더링
{!isAdmin && <th/>}
{!isAdmin ? <td><button onClick={()=>send(i.id)}>Send</button></td> : null}
# 1) 로그인
curl -X POST http://localhost:8080/api/auth/login -H 'Content-Type: application/json' \
-d '{"username":"admin@example.com","password":"admin1234"}'
# 2) 고객 생성 (USER 계정으로)
curl -X POST http://localhost:8080/api/clients -H "Authorization: Bearer <access>" -H 'Content-Type: application/json' \
-d '{"name":"테스트고객","bizNo":"222-33-44444","email":"test@client.com","phone":"010-9999-0000"}'
# 3) 프로젝트/계약/인보이스/결제는 Swagger에서 Authorize 후 순서대로 테스트
amount = qty * unitPrice, total = subtotal + tax - withholdingqty ≥ 0, unitPrice ≥ 0, payment.amount > 0, contract.rate ≥ 0paid ≥ total → PAID, dueDate < today && paid < total → OVERDUE, 취소는 최우선verbatimModuleSyntax 환경에서 타입 전용 import 필요 (import type { ... })_retry 속성: 모듈 보강(augmentation)으로 타입 에러 해결no-explicit-any: d.ts에 한해 override (원본 제네릭이 any이므로 동일 유지해야 TS 충돌 없음)./gradlew bootRun → http://localhost:8080/swagger-ui.htmlnpm i && npm run dev → http://localhost:5173admin@example.com/admin1234) 또는 직접 회원가입 후 로그인prod에선 Swagger 비활성화, JPA
ddl-auto=validate, JWT Secret 교체 권장
이번 MVP는 실사용 워크플로(로그인 → 계약/인보이스 → 정산)과 데이터 안전장치(소유자 스코핑, Admin 읽기 전용, 불변 번호)를 중심으로 만들었습니다.
필요 기능(PDF/메일/리포트/배포)이 붙어도 핵심 아키텍처는 그대로 재사용 가능하도록 설계했습니다.