From ecc
Spring Boot 아키텍처 패턴, REST API 설계, 계층형 서비스, 데이터 액세스, 캐싱, 비동기 처리 및 로깅 가이드입니다. Java Spring Boot 백엔드 작업 시 사용하세요.
npx claudepluginhub sam42-lab/everything-claude-code-krThis skill uses the workspace's default tool permissions.
확장 가능하고 프로덕션 수준의 서비스를 위한 Spring Boot 아키텍처 및 API 패턴입니다.
Mandates invoking relevant skills via tools before any response in coding sessions. Covers access, priorities, and adaptations for Claude Code, Copilot CLI, Gemini CLI.
Share bugs, ideas, or general feedback.
확장 가능하고 프로덕션 수준의 서비스를 위한 Spring Boot 아키텍처 및 API 패턴입니다.
@RestController
@RequestMapping("/api/markets")
@Validated
class MarketController {
private final MarketService marketService;
MarketController(MarketService marketService) {
this.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
public class MarketService {
private final MarketRepository repo;
public MarketService(MarketRepository repo) {
this.repo = 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
public class MarketCacheService {
private final MarketRepository repo;
public MarketCacheService(MarketRepository repo) {
this.repo = 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);
}
}
@Service
public class ReportService {
private static final Logger log = LoggerFactory.getLogger(ReportService.class);
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();
}
}
@Component
public class RequestLoggingFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class);
@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 헤더는 클라이언트가 변조할 수 있으므로 기본적으로 신뢰할 수 없습니다.
다음 조건이 충족될 때만 전달된 헤더를 사용하세요:
ForwardedHeaderFilter를 빈으로 등록함server.forward-headers-strategy=NATIVE 또는 FRAMEWORK를 구성함X-Forwarded-For 헤더를 덧붙이는 것이 아니라 덮어쓰도록 구성됨ForwardedHeaderFilter가 적절히 구성되면, request.getRemoteAddr()은 전달된 헤더에서 올바른 클라이언트 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이 전달된 헤더를 올바르게 처리하도록 구성해야 합니다:
*
* 1. application.properties/yaml에 server.forward-headers-strategy=NATIVE (클라우드 플랫폼용)
* 또는 FRAMEWORK 설정
* 2. FRAMEWORK 전략을 사용하는 경우, ForwardedHeaderFilter 등록:
*
* @Bean
* ForwardedHeaderFilter forwardedHeaderFilter() {
* return new ForwardedHeaderFilter();
* }
*
* 3. 프록시가 변조 방지를 위해 X-Forwarded-For 헤더를 (덧붙이지 않고) 덮어쓰는지 확인
* 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)와 통합하세요. 핸들러는 멱등성을 유지하고 관측 가능하게 유지하세요.
spring.mvc.problemdetails.enabled=true를 활성화하세요.@Transactional(readOnly = true)를 사용하세요.@NonNull 및 Optional을 적절히 사용하여 널 안전성을 강제하세요.기억하세요: 컨트롤러는 얇게, 서비스는 집중되게, 레포지토리는 단순하게 유지하고, 예외 처리는 중앙에서 관리하세요. 유지보수성과 테스트 가능성에 최적화하세요.