| name | springboot-patterns |
| description | Spring Boot 아키텍처 패턴, REST API 설계, 계층형 서비스, 데이터 액세스, 캐싱, 비동기 처리 및 로깅 가이드입니다. Java Spring Boot 백엔드 작업 시 사용하세요. |
| origin | ECC |
Spring Boot 개발 패턴
확장 가능하고 프로덕션 수준의 서비스를 위한 Spring Boot 아키텍처 및 API 패턴입니다.
사용 시점
- Spring MVC 또는 WebFlux를 사용하여 REST API를 구축할 때
- 컨트롤러 → 서비스 → 레포지토리 계층을 구조화할 때
- Spring Data JPA, 캐싱 또는 비동기 처리를 구성할 때
- 유효성 검사, 예외 처리 또는 페이지네이션을 추가할 때
- 개발/스테이징/프로덕션 환경별 프로파일을 설정할 때
- Spring Events 또는 Kafka를 사용하여 이벤트 기반 패턴을 구현할 때
REST 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));
}
}
레포지토리 패턴 (Spring Data JPA)
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);
}
}
DTO 및 유효성 검사
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) {
return CompletableFuture.completedFuture(null);
}
}
로깅 (SLF4J)
@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);
장애 대응 외부 호출 (Retry)
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;
}
}
}
}
속도 제한 (Filter + Bucket4j)
보안 참고: X-Forwarded-For 헤더는 클라이언트가 변조할 수 있으므로 기본적으로 신뢰할 수 없습니다.
다음 조건이 충족될 때만 전달된 헤더를 사용하세요:
- 애플리케이션이 신뢰할 수 있는 리버스 프록시(nginx, AWS ALB 등) 뒤에 있음
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<>();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
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)와 통합하세요. 핸들러는 멱등성을 유지하고 관측 가능하게 유지하세요.
관측 가능성 (Observability)
- Logback 인코더를 통한 구조화된 로깅 (JSON)
- 메트릭: Micrometer + Prometheus/OTel
- 트레이싱: OpenTelemetry 또는 Brave 백엔드가 포함된 Micrometer Tracing
프로덕션 기본 사항
- 생성자 주입을 선호하고, 필드 주입은 피하세요.
- Spring Boot 3+에서는 RFC 7807 오류를 위해
spring.mvc.problemdetails.enabled=true를 활성화하세요.
- 워크로드에 맞게 HikariCP 풀 크기를 구성하고 타임아웃을 설정하세요.
- 쿼리에는
@Transactional(readOnly = true)를 사용하세요.
@NonNull 및 Optional을 적절히 사용하여 널 안전성을 강제하세요.
기억하세요: 컨트롤러는 얇게, 서비스는 집중되게, 레포지토리는 단순하게 유지하고, 예외 처리는 중앙에서 관리하세요. 유지보수성과 테스트 가능성에 최적화하세요.