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) {
// 이메일/SMS 전송
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);
오류에 강한 외부 호출
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;
}
}
}
}
속도 제한 (필터 + Bucket4j)
보안 참고: X-Forwarded-For 헤더는 클라이언트가 위조할 수 있으므로 기본적으로 신뢰할 수 없습니다.
전달된 헤더는 다음 조건에서만 사용하세요:
- 애플리케이션이 신뢰할 수 있는 리버스 프록시(nginx, AWS ALB 등) 뒤에 있는 경우
ForwardedHeaderFilter를 빈으로 등록한 경우- application.properties에서
server.forward-headers-strategy=NATIVE또는FRAMEWORK를 구성한 경우 - 프록시가
X-Forwarded-For헤더를 추가(append)하지 않고 덮어쓰도록(overwrite) 구성된 경우
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)와 통합하세요. 핸들러는 멱등성(idempotent)과 관찰 가능성(observable)을 유지하세요.
관찰 가능성
- 구조화된 로깅(JSON): Logback 인코더 사용
- 메트릭: Micrometer + Prometheus/OTel
- 추적: OpenTelemetry 또는 Brave 백엔드와 함께 Micrometer Tracing 사용
프로덕션 기본값
- 필드 주입보다는 생성자 주입 선호
- Spring Boot 3+에서 RFC 7807 오류를 위해
spring.mvc.problemdetails.enabled=true활성화 - 워크로드에 맞게 HikariCP 풀 크기 구성, 타임아웃 설정
- 쿼리에는
@Transactional(readOnly = true)사용 - 적절한 곳에서
@NonNull과Optional을 통해 null 안전성 강제
기억하세요: 컨트롤러는 얇게, 서비스는 집중적으로, 리포지토리는 단순하게, 오류는 중앙에서 처리하세요. 유지보수성과 테스트 용이성을 최적화하세요.