이 프로젝트는 계층형(controller/service/repository를 최상위로 분리)이 아니라 도메인별 수직 분리를 택했다.
com.rfpmatch/
├── bid/ 공고 도메인 — 조회, 동기화 대상
├── requirement/ 요구사항 등록
├── product/ 제품 스펙 CRUD
├── match/
│ ├── engine/ 매칭 엔진 (2편 내용)
│ ├── service/ 매칭 실행·결과 조회
│ └── domain/ MatchResult, MatchStatus
├── sync/ 외부 API 클라이언트·동기화 서비스·스케줄러
└── common/ 공통 에러 포맷, CORS·OpenAPI 설정
각 도메인 패키지 안에 domain / dto / repository / service / controller가 다 들어있다. 도메인이 4~5개뿐인 규모에서는 이 구조가 "이 기능 관련 코드가 다 어디 있지?"를 찾을 때 훨씬 빠르다. 패키지 하나만 열면 그 도메인의 전체 흐름이 보인다.
Base URL은 /api/v1로 고정하고, 리소스 계층을 URL에 그대로 반영했다.
GET /bids?keyword=&status=OPEN&page=0&size=20
GET /bids/{bidId}
POST /bids/sync
POST /bids/{bidId}/requirements
GET /bids/{bidId}/requirements
DELETE /bids/{bidId}/requirements/{id}
POST /products
GET /products
GET /products/{id}
PUT /products/{id}
DELETE /products/{id}
POST /bids/{bidId}/match?productId=
GET /bids/{bidId}/match-result?productId=
요구사항이 bids/{bidId}/requirements처럼 공고에 종속된 리소스로 표현된 이유는 실제로도 요구사항이 특정 공고 없이는 존재 의미가 없기 때문이다. 반면 제품은 공고와 독립적으로 존재하므로 최상위 리소스 /products로 뒀다.
모든 에러 응답을 하나의 포맷으로 통일했다.
public record ErrorResponse(
OffsetDateTime timestamp,
int status,
String error,
String message,
String path) {
}
{
"timestamp": "2026-09-07T14:00:00+09:00",
"status": 400,
"error": "BAD_REQUEST",
"message": "requiredValue는 비어있을 수 없습니다.",
"path": "/api/v1/bids/1/requirements"
}
@RestControllerAdvice 하나로 예외를 상태 코드에 매핑했다.
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(NotFoundException e, HttpServletRequest request) {
return build(HttpStatus.NOT_FOUND, e.getMessage(), request);
}
@ExceptionHandler(ExternalApiException.class)
public ResponseEntity<ErrorResponse> handleExternalApi(ExternalApiException e, HttpServletRequest request) {
log.warn("외부 Open API 연동 실패: {}", e.getMessage());
return build(HttpStatus.BAD_GATEWAY, e.getMessage(), request);
}
| 코드 | 상황 |
|---|---|
| 400 | 입력값 검증 실패, 요구사항 없이 매칭 시도 |
| 404 | 공고·제품·요구사항 ID 없음, 매칭 결과 없음 |
| 502 | 외부 Open API 응답 실패 (다음 글에서 자세히) |
프런트는 이 포맷 하나만 믿고 error.response?.data?.message를 그대로 화면에 띄우면 된다. 백엔드가 어떤 예외를 던지든 프런트 쪽 에러 처리 코드는 한 줄로 끝난다.
이 프로젝트에서 가장 신경 쓴 데이터 정합성 문제는 이거였다.
요구사항을 수정했는데 예전 갭분석 결과가 화면에 그대로 남아있으면?
담당자가 낡은 충족률을 보고 입찰 여부를 판단하면 사고가 난다. 그래서 요구사항이나 제품 스펙이 바뀌는 시점에 관련 매칭 결과를 함께 지우는 규칙을 서비스 계층에 넣었다.
@Transactional
public RequirementCreateResponse register(Long bidId, RequirementCreateRequest request) {
requireBid(bidId);
List<RequirementItem> saved = requirementRepository.saveAll(...);
// 요구사항이 바뀌면 기존 갭분석표는 더 이상 맞지 않는다. 지우고 다시 매칭하게 한다.
matchResultRepository.deleteByBidId(bidId);
return new RequirementCreateResponse(bidId, saved.stream().map(RequirementItem::getId).toList());
}
@Transactional
public ProductResponse update(Long productId, ProductRequest request) {
ProductSpec product = getProduct(productId);
product.updateBasicInfo(...);
product.replaceSpecs(request.toSpecEntities());
// 스펙이 바뀌면 이 제품으로 돌린 갭분석 결과는 무효다.
matchResultRepository.deleteByProductId(productId);
return ProductResponse.from(product);
}
화면에도 "등록하면 기존 갭분석 결과는 초기화됩니다"라는 안내를 명시적으로 붙였다. 데이터 정합성은 백엔드에서 보장하고, UX 메시지로 사용자가 재실행이 필요하다는 걸 인지하게 만드는 이중 장치다.
공고 목록에 "요구사항 N건", "매칭완료/미매칭" 배지를 붙이려니 공고 개수만큼 집계 쿼리가 나가는 문제가 생겼다. 페이지에 담긴 공고 ID를 한 번에 모아서 두 번의 쿼리로 끝내도록 바꿨다.
// 목록 화면의 "요구사항 N건" 표기를 공고마다 조회하지 않고 한 번에 가져온다.
@Query("""
select r.bidId as bidId, count(r) as count
from RequirementItem r
where r.bidId in :bidIds
group by r.bidId
""")
List<BidRequirementCount> countByBidIds(@Param("bidIds") Collection<Long> bidIds);
목록 조회 한 번에 총 3개 쿼리(공고 목록 + 요구사항 건수 집계 + 매칭 여부 집계)로 끝난다. 제품 목록도 마찬가지로 left join fetch로 스펙까지 한 번에 가져온다.
@Query("select distinct p from ProductSpec p left join fetch p.specs order by p.id")
List<ProductSpec> findAllWithSpecs();
springdoc-openapi를 붙여서 Swagger UI를 자동 생성했다. 컨트롤러 메서드마다 @Operation으로 설명을 붙이면 별도 문서 없이도 API 스펙이 항상 코드와 동기화된다.
@Operation(summary = "매칭 실행",
description = "공고의 요구사항 전체를 지정한 제품 스펙과 대조해 갭분석표를 만든다. 재실행하면 이전 결과를 덮어쓴다.")
@PostMapping("/match")
public MatchResponse match(@PathVariable Long bidId, @RequestParam Long productId) {
return matchService.match(bidId, productId);
}
다음 글은 외부 Open API 연동이다. 응답 구조가 오퍼레이션마다 다른 문제, 인증키 오류를 어떻게 감지하는지, 부분 실패와 전체 실패를 어떻게 다르게 처리하는지를 다룬다.
이 시리즈는 개인 학습 프로젝트 개발 기록입니다. 코드에 등장하는 기관명·제품명은 전부 가상이며, 실제 연동 대상 서비스의 명칭·엔드포인트는 기술적 세부사항 보호를 위해 일반화해서 서술합니다.