springboot-patterns
Spring Boot 아키텍처 패턴, REST API 설계, 계층형 서비스, 데이터 접근, 캐싱, 비동기 처리, 로깅을 다룬다. Java Spring Boot 백엔드 작업에 사용한다.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
메뉴
Spring Boot 아키텍처 패턴, REST API 설계, 계층형 서비스, 데이터 접근, 캐싱, 비동기 처리, 로깅을 다룬다. Java Spring Boot 백엔드 작업에 사용한다.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
SOC 직업 분류 기준
새 Spring Boot 서비스를 api-gateway + auth-api 에코시스템에 연결한다. auth-api 클라이언트 등록 → Gateway 라우팅 추가 → 서비스에 econo-passport 연동 → 동작 확인까지 한 번에 처리. 다음 상황에서 반드시 이 스킬을 사용한다: - "새 서비스 Gateway에 연결해줘", "새 서비스 auth 연동" - "서비스 등록해줘", "Gateway 뒤에 붙여줘" - "/register-service" 직접 호출 - 새 Spring Boot 서비스가 추가되고 인증이 필요할 때 ARGUMENTS: 서비스명, 서비스 경로(선택), 업스트림 URL(선택) 예: "EEOS-BE /Users/mando/study/eeos/EEOS-BE/eeos"
이 스킬은 사용자가 "PR 생성해줘", "PR 만들어줘", "pull request 생성", "pr 올려줘", "/git-pr" 등을 요청할 때 호출된다. 현재 브랜치의 커밋을 원격으로 push하고, `.github/PULL_REQUEST_TEMPLATE.md`를 채워 GitHub Pull Request를 생성한다.
신규 기능을 개발할 때 정책 문서 → 코드 → 테스트 순서로 작성한다. ADR은 기술적 결정에만 사용하고, 기능 정책은 docs/features/ 에 작성한다. 다음 상황에서 반드시 이 스킬을 사용한다: - "기능 추가해줘", "기획부터 해봐", "설계해봐" - 새로운 API 엔드포인트 또는 도메인 규칙이 생길 때 - "/new-feature" 직접 호출 ARGUMENTS: 기능 이름 또는 요구사항 (없으면 대화에서 추출)
Architecture Decision Record(ADR)를 작성한다. 기술적 결정사항, 설계 선택, 트레이드오프를 문서화하여 나중에 "왜 이렇게 했지?"를 알 수 있게 한다. 다음 상황에서 반드시 이 스킬을 사용한다: - "ADR 써줘", "결정사항 문서화해줘", "이 결정 기록해줘" - 기술 방향 선택 후 ("A 대신 B 쓰기로 했어") - 설계 논의가 끝났을 때 - 나중에 이 결정이 왜 내려졌는지 설명이 필요할 것 같을 때 - "/adr" 직접 호출 ARGUMENTS: 결정 내용 또는 결정 번호 (없으면 대화에서 추출)
AI 에이전트의 액션 스페이스, 도구 정의, 관측(Observation) 포맷을 설계·최적화해 작업 완수율을 높일 때 사용한다.
로컬 개발, 컨테이너 보안, 네트워킹, 볼륨 전략, 멀티 서비스 오케스트레이션을 위한 Docker 및 Docker Compose 패턴.
| name | springboot-patterns |
| description | Spring Boot 아키텍처 패턴, REST API 설계, 계층형 서비스, 데이터 접근, 캐싱, 비동기 처리, 로깅을 다룬다. Java Spring Boot 백엔드 작업에 사용한다. |
| origin | ECC |
확장 가능하고 운영 환경 수준의 서비스를 위한 Spring Boot 아키텍처 및 API 패턴.
@RestController
@RequestMapping("/api/markets")
@Validated
@RequiredArgsConstructor
class MarketController {
private final MarketService marketService;
@GetMapping
ResponseEntity<Page<MarketResponse>> list(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Page<Market> markets = marketService.list(PageRequest.of(page, size));
return ResponseEntity.ok(markets.map(MarketResponse::from));
}
@PostMapping
ResponseEntity<MarketResponse> create(@Valid @RequestBody CreateMarketRequest request) {
Market market = marketService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(MarketResponse.from(market));
}
}
public interface MarketRepository extends JpaRepository<MarketEntity, Long> {
@Query("select m from MarketEntity m where m.status = :status order by m.volume desc")
List<MarketEntity> findActive(@Param("status") MarketStatus status, Pageable pageable);
}
@Service
@RequiredArgsConstructor
public class MarketService {
private final MarketRepository repo;
@Transactional
public Market create(CreateMarketRequest request) {
MarketEntity entity = MarketEntity.from(request);
MarketEntity saved = repo.save(entity);
return Market.from(saved);
}
}
public record CreateMarketRequest(
@NotBlank @Size(max = 200) String name,
@NotBlank @Size(max = 2000) String description,
@NotNull @FutureOrPresent Instant endDate,
@NotEmpty List<@NotBlank String> categories) {}
public record MarketResponse(Long id, String name, MarketStatus status) {
static MarketResponse from(Market market) {
return new MarketResponse(market.id(), market.name(), market.status());
}
}
@ControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<ApiError> handleValidation(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage())
.collect(Collectors.joining(", "));
return ResponseEntity.badRequest().body(ApiError.validation(message));
}
@ExceptionHandler(AccessDeniedException.class)
ResponseEntity<ApiError> handleAccessDenied() {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiError.of("Forbidden"));
}
@ExceptionHandler(Exception.class)
ResponseEntity<ApiError> handleGeneric(Exception ex) {
// 예상치 못한 오류는 스택 트레이스와 함께 로깅한다
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiError.of("Internal server error"));
}
}
설정 클래스에 @EnableCaching이 필요하다.
@Service
@RequiredArgsConstructor
public class MarketCacheService {
private final MarketRepository repo;
@Cacheable(value = "market", key = "#id")
public Market getById(Long id) {
return repo.findById(id)
.map(Market::from)
.orElseThrow(() -> new EntityNotFoundException("Market not found"));
}
@CacheEvict(value = "market", key = "#id")
public void evict(Long id) {}
}
설정 클래스에 @EnableAsync가 필요하다.
@Service
public class NotificationService {
@Async
public CompletableFuture<Void> sendAsync(Notification notification) {
// 이메일/SMS 발송
return CompletableFuture.completedFuture(null);
}
}
@Slf4j
@Service
@RequiredArgsConstructor
public class ReportService {
public Report generate(Long marketId) {
log.info("generate_report marketId={}", marketId);
try {
// 로직
} catch (Exception ex) {
log.error("generate_report_failed marketId={}", marketId, ex);
throw ex;
}
return new Report();
}
}
@Slf4j
@Component
public class RequestLoggingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
long start = System.currentTimeMillis();
try {
filterChain.doFilter(request, response);
} finally {
long duration = System.currentTimeMillis() - start;
log.info("req method={} uri={} status={} durationMs={}",
request.getMethod(), request.getRequestURI(), response.getStatus(), duration);
}
}
}
PageRequest page = PageRequest.of(pageNumber, pageSize, Sort.by("createdAt").descending());
Page<Market> results = marketService.list(page);
public <T> T withRetry(Supplier<T> supplier, int maxRetries) {
int attempts = 0;
while (true) {
try {
return supplier.get();
} catch (Exception ex) {
attempts++;
if (attempts >= maxRetries) {
throw ex;
}
try {
Thread.sleep((long) Math.pow(2, attempts) * 100L);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw ex;
}
}
}
}
보안 주의: X-Forwarded-For 헤더는 클라이언트가 위조할 수 있으므로 기본적으로 신뢰할 수 없다.
forwarded 헤더는 다음 조건이 모두 충족될 때만 사용한다:
ForwardedHeaderFilter를 빈으로 등록했다server.forward-headers-strategy=NATIVE 또는 FRAMEWORK를 설정했다X-Forwarded-For 헤더를 덮어쓰도록(append하지 않도록) 구성되어 있다ForwardedHeaderFilter가 올바르게 구성되면 request.getRemoteAddr()가 forwarded 헤더에서
실제 클라이언트 IP를 자동으로 반환한다. 이러한 구성이 없다면 request.getRemoteAddr()를 직접 사용한다.
이는 직접 연결된 IP를 반환하며, 신뢰할 수 있는 유일한 값이다.
@Component
public class RateLimitFilter extends OncePerRequestFilter {
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
/*
* 보안: 이 필터는 속도 제한을 위해 클라이언트를 식별할 때 request.getRemoteAddr()를 사용한다.
*
* 애플리케이션이 리버스 프록시(nginx, AWS ALB 등) 뒤에 있다면, 정확한 클라이언트 IP 감지를 위해
* Spring이 forwarded 헤더를 올바르게 처리하도록 반드시 구성해야 한다:
*
* 1. application.properties/yaml에 server.forward-headers-strategy=NATIVE (클라우드 플랫폼) 또는
* FRAMEWORK 를 설정한다
* 2. FRAMEWORK 전략을 사용하는 경우 ForwardedHeaderFilter를 등록한다:
*
* @Bean
* ForwardedHeaderFilter forwardedHeaderFilter() {
* return new ForwardedHeaderFilter();
* }
*
* 3. 위조 방지를 위해 프록시가 X-Forwarded-For 헤더를 append가 아닌 덮어쓰도록 구성한다
* 4. 컨테이너에 맞춰 server.tomcat.remoteip.trusted-proxies 또는 동등한 설정을 구성한다
*
* 이 구성이 없다면 request.getRemoteAddr()는 클라이언트 IP가 아닌 프록시 IP를 반환한다.
* X-Forwarded-For를 직접 읽지 않는다 — 신뢰할 수 있는 프록시 처리 없이는 손쉽게 위조될 수 있다.
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// ForwardedHeaderFilter가 구성되어 있으면 올바른 클라이언트 IP를, 그렇지 않으면 직접 연결된 IP를
// 반환하는 getRemoteAddr()를 사용한다. 적절한 프록시 구성 없이 X-Forwarded-For 헤더를
// 직접 신뢰하지 않는다.
String clientIp = request.getRemoteAddr();
Bucket bucket = buckets.computeIfAbsent(clientIp,
k -> Bucket.builder()
.addLimit(Bandwidth.classic(100, Refill.greedy(100, Duration.ofMinutes(1))))
.build());
if (bucket.tryConsume(1)) {
filterChain.doFilter(request, response);
} else {
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
}
}
}
Spring의 @Scheduled를 사용하거나 큐(Kafka, SQS, RabbitMQ 등)와 통합한다. 핸들러는 멱등성을 유지하고 관측 가능하게 만든다.
@RequiredArgsConstructor로 생성자 주입을 사용한다spring.mvc.problemdetails.enabled=true를 활성화한다 (Spring Boot 3+)@Transactional(readOnly = true)를 사용한다@NonNull과 Optional로 null 안전성을 강제한다기억할 점: 컨트롤러는 얇게, 서비스는 책임을 명확하게, 리포지토리는 단순하게 유지하고 오류는 중앙에서 처리한다. 유지보수성과 테스트 용이성을 우선 최적화한다.