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 안전성을 강제한다기억할 점: 컨트롤러는 얇게, 서비스는 책임을 명확하게, 리포지토리는 단순하게 유지하고 오류는 중앙에서 처리한다. 유지보수성과 테스트 용이성을 우선 최적화한다.