hobokai 님의 블로그

Spring Boot 완전 가이드 Part 5: 운영과 모니터링 본문

Backend

Spring Boot 완전 가이드 Part 5: 운영과 모니터링

hobokai 2025. 8. 6. 10:39

시리즈 소개: 실무에서 바로 써먹는 Spring Boot 완전 가이드

Part 1: Spring Boot 기초 - 시작하기
Part 2: 설정과 자동구성
Part 3: 웹 개발과 REST API
Part 4: 데이터 액세스와 배치
Part 5: 운영과 모니터링 ← 현재


🔍 Spring Boot Actuator - 애플리케이션 모니터링

Actuator란?

Spring Boot Actuator는 애플리케이션의 모니터링, 관리, 감사 기능을 제공하는 도구입니다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

기본 엔드포인트 활성화

# application.yml
management:
  endpoints:
    web:
      exposure:
        # 모든 엔드포인트 노출 (개발환경만)
        include: "*"
        # 운영환경에서는 필요한 것만 노출
        # include: "health,info,metrics,prometheus"
  endpoint:
    health:
      show-details: always
    shutdown:
      enabled: true  # 주의: 운영환경에서는 비활성화

주요 Actuator 엔드포인트

엔드포인트용도URL

/actuator/health 애플리케이션 상태 GET
/actuator/info 애플리케이션 정보 GET
/actuator/metrics 메트릭 정보 GET
/actuator/env 환경 변수 GET
/actuator/loggers 로그 레벨 관리 GET/POST
/actuator/threaddump 스레드 덤프 GET
/actuator/heapdump 힙 덤프 GET
/actuator/prometheus Prometheus 메트릭 GET

커스텀 Health Indicator

@Component
public class DatabaseHealthIndicator implements HealthIndicator {
    
    private final DataSource dataSource;
    
    public DatabaseHealthIndicator(DataSource dataSource) {
        this.dataSource = dataSource;
    }
    
    @Override
    public Health health() {
        try (Connection connection = dataSource.getConnection()) {
            if (connection.isValid(1)) {
                return Health.up()
                        .withDetail("database", "Available")
                        .withDetail("validationTimeout", "1s")
                        .build();
            } else {
                return Health.down()
                        .withDetail("database", "Connection validation failed")
                        .build();
            }
        } catch (SQLException e) {
            return Health.down()
                    .withDetail("database", "Connection failed")
                    .withDetail("error", e.getMessage())
                    .build();
        }
    }
}

@Component
public class ExternalApiHealthIndicator implements HealthIndicator {
    
    private final RestTemplate restTemplate;
    private final String externalApiUrl;
    
    public ExternalApiHealthIndicator(RestTemplate restTemplate, 
                                     @Value("${app.external.api.url}") String externalApiUrl) {
        this.restTemplate = restTemplate;
        this.externalApiUrl = externalApiUrl;
    }
    
    @Override
    public Health health() {
        try {
            ResponseEntity<String> response = restTemplate.getForEntity(
                    externalApiUrl + "/health", String.class);
            
            if (response.getStatusCode().is2xxSuccessful()) {
                return Health.up()
                        .withDetail("external-api", "Available")
                        .withDetail("responseTime", "< 1s")
                        .build();
            } else {
                return Health.down()
                        .withDetail("external-api", "HTTP " + response.getStatusCode())
                        .build();
            }
        } catch (Exception e) {
            return Health.down()
                    .withDetail("external-api", "Connection failed")
                    .withDetail("error", e.getMessage())
                    .build();
        }
    }
}

애플리케이션 정보 설정

# application.yml
info:
  app:
    name: Demo Application
    description: Spring Boot 완전 가이드 데모
    version: 1.0.0
    author: Developer Team
  build:
    artifact: "@project.artifactId@"
    name: "@project.name@"
    time: "@maven.build.timestamp@"
    version: "@project.version@"
@Component
public class GitInfoContributor implements InfoContributor {
    
    @Override
    public void contribute(Info.Builder builder) {
        try {
            Properties gitProperties = new Properties();
            gitProperties.load(getClass().getResourceAsStream("/git.properties"));
            
            builder.withDetail("git", Map.of(
                    "branch", gitProperties.getProperty("git.branch"),
                    "commit", gitProperties.getProperty("git.commit.id.abbrev"),
                    "time", gitProperties.getProperty("git.commit.time")
            ));
        } catch (Exception e) {
            builder.withDetail("git", "정보를 불러올 수 없습니다");
        }
    }
}

📊 메트릭 수집과 모니터링

기본 메트릭 활용

@RestController
@RequiredArgsConstructor
public class OrderController {
    
    private final OrderService orderService;
    private final MeterRegistry meterRegistry;
    
    @GetMapping("/orders")
    public ResponseEntity<List<OrderDto>> getOrders() {
        Timer.Sample sample = Timer.start(meterRegistry);
        
        try {
            List<OrderDto> orders = orderService.findAllOrders();
            meterRegistry.counter("order.list.success").increment();
            return ResponseEntity.ok(orders);
        } catch (Exception e) {
            meterRegistry.counter("order.list.error").increment();
            throw e;
        } finally {
            sample.stop(Timer.builder("order.list.duration")
                    .description("Order list API duration")
                    .register(meterRegistry));
        }
    }
    
    @PostMapping("/orders")
    public ResponseEntity<OrderDto> createOrder(@Valid @RequestBody CreateOrderRequest request) {
        return Timer.Sample.start(meterRegistry)
                .stop(meterRegistry.timer("order.create.duration"))
                .recordCallable(() -> {
                    OrderDto order = orderService.createOrder(request);
                    meterRegistry.counter("order.create.success").increment();
                    return ResponseEntity.status(HttpStatus.CREATED).body(order);
                });
    }
}

커스텀 메트릭

@Component
@RequiredArgsConstructor
public class BusinessMetrics {
    
    private final MeterRegistry meterRegistry;
    private final Gauge activeUsersGauge;
    private final AtomicInteger activeUsersCount = new AtomicInteger(0);
    
    @EventListener
    public void handleUserLogin(UserLoginEvent event) {
        meterRegistry.counter("user.login", 
                "type", event.getLoginType(),
                "country", event.getCountry())
                .increment();
        
        activeUsersCount.incrementAndGet();
    }
    
    @EventListener
    public void handleUserLogout(UserLogoutEvent event) {
        activeUsersCount.decrementAndGet();
    }
    
    @PostConstruct
    public void initGauges() {
        Gauge.builder("user.active.count")
                .description("현재 활성 사용자 수")
                .register(meterRegistry, activeUsersCount, AtomicInteger::get);
    }
    
    // 비즈니스 메트릭 메서드들
    public void recordOrderAmount(BigDecimal amount) {
        meterRegistry.summary("order.amount")
                .record(amount.doubleValue());
    }
    
    public void recordPaymentProcessingTime(Duration duration) {
        meterRegistry.timer("payment.processing.time")
                .record(duration);
    }
}

Prometheus 연동

<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
# application.yml
management:
  endpoints:
    web:
      exposure:
        include: "health,info,metrics,prometheus"
  metrics:
    export:
      prometheus:
        enabled: true
    distribution:
      percentiles-histogram:
        http.server.requests: true
      percentiles:
        http.server.requests: 0.5,0.95,0.99
      slo:
        http.server.requests: 10ms,50ms,100ms,200ms,500ms

📋 로깅 전략

로깅 프레임워크 설정

# application.yml
logging:
  level:
    com.example: INFO
    org.springframework.web: DEBUG
    org.springframework.security: WARN
    org.hibernate.SQL: DEBUG
    org.hibernate.type.descriptor.sql.BasicBinder: TRACE
    
  pattern:
    console: "%clr(%d{ISO8601}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}"
    file: "%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n"
    
  file:
    name: logs/application.log
    max-size: 100MB
    max-history: 30
    total-size-cap: 1GB

구조화된 로깅 (Structured Logging)

@Slf4j
@Service
@RequiredArgsConstructor
public class OrderService {
    
    private final OrderRepository orderRepository;
    private final PaymentService paymentService;
    
    public OrderDto createOrder(CreateOrderRequest request) {
        String orderId = UUID.randomUUID().toString();
        
        // 구조화된 로그
        log.info("주문 생성 시작 - orderId: {}, userId: {}, amount: {}", 
                orderId, request.getUserId(), request.getAmount());
        
        try {
            // 주문 검증
            validateOrder(request);
            log.debug("주문 검증 완료 - orderId: {}", orderId);
            
            // 주문 생성
            Order order = Order.builder()
                    .id(orderId)
                    .userId(request.getUserId())
                    .amount(request.getAmount())
                    .status(OrderStatus.PENDING)
                    .build();
                    
            Order savedOrder = orderRepository.save(order);
            log.info("주문 저장 완료 - orderId: {}, status: {}", orderId, savedOrder.getStatus());
            
            // 결제 처리
            PaymentResult paymentResult = paymentService.processPayment(
                    savedOrder.getId(), 
                    savedOrder.getAmount()
            );
            
            if (paymentResult.isSuccess()) {
                savedOrder.setStatus(OrderStatus.COMPLETED);
                orderRepository.save(savedOrder);
                log.info("주문 완료 - orderId: {}, paymentId: {}", 
                        orderId, paymentResult.getPaymentId());
            } else {
                savedOrder.setStatus(OrderStatus.FAILED);
                orderRepository.save(savedOrder);
                log.warn("결제 실패 - orderId: {}, reason: {}", 
                        orderId, paymentResult.getFailureReason());
            }
            
            return OrderDto.from(savedOrder);
            
        } catch (Exception e) {
            log.error("주문 생성 실패 - orderId: {}, error: {}", orderId, e.getMessage(), e);
            throw new OrderCreationException("주문 생성 중 오류 발생", e);
        }
    }
    
    private void validateOrder(CreateOrderRequest request) {
        if (request.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
            throw new InvalidOrderException("주문 금액은 0보다 커야 합니다");
        }
        
        if (request.getItems().isEmpty()) {
            throw new InvalidOrderException("주문 상품이 없습니다");
        }
        
        log.debug("주문 검증 - userId: {}, itemCount: {}, totalAmount: {}", 
                request.getUserId(), request.getItems().size(), request.getAmount());
    }
}

로그 집계와 분석

# logback-spring.xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
    
    <!-- 콘솔 출력 -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${CONSOLE_LOG_PATTERN}</pattern>
        </encoder>
    </appender>
    
    <!-- 파일 출력 -->
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>logs/application.log</file>
        <encoder>
            <pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>logs/application.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
            <maxFileSize>100MB</maxFileSize>
            <maxHistory>30</maxHistory>
            <totalSizeCap>1GB</totalSizeCap>
        </rollingPolicy>
    </appender>
    
    <!-- JSON 형태 로그 (ELK Stack 연동용) -->
    <appender name="JSON" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>logs/application.json</file>
        <encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
            <providers>
                <timestamp/>
                <logLevel/>
                <loggerName/>
                <message/>
                <mdc/>
                <arguments/>
                <stackTrace/>
            </providers>
        </encoder>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>logs/application.%d{yyyy-MM-dd}.%i.json.gz</fileNamePattern>
            <maxFileSize>100MB</maxFileSize>
            <maxHistory>30</maxHistory>
        </rollingPolicy>
    </appender>
    
    <!-- 프로파일별 설정 -->
    <springProfile name="local,dev">
        <root level="INFO">
            <appender-ref ref="CONSOLE"/>
            <appender-ref ref="FILE"/>
        </root>
    </springProfile>
    
    <springProfile name="prod">
        <root level="WARN">
            <appender-ref ref="FILE"/>
            <appender-ref ref="JSON"/>
        </root>
    </springProfile>
</configuration>

🚀 성능 모니터링과 최적화

JVM 메트릭 모니터링

@Configuration
public class JvmMetricsConfig {
    
    @Bean
    public JvmGcMetrics jvmGcMetrics() {
        return new JvmGcMetrics();
    }
    
    @Bean
    public JvmMemoryMetrics jvmMemoryMetrics() {
        return new JvmMemoryMetrics();
    }
    
    @Bean
    public JvmThreadMetrics jvmThreadMetrics() {
        return new JvmThreadMetrics();
    }
    
    @Bean
    public ProcessorMetrics processorMetrics() {
        return new ProcessorMetrics();
    }
    
    @Bean
    public UptimeMetrics uptimeMetrics() {
        return new UptimeMetrics();
    }
}

애플리케이션 성능 메트릭

@Component
@RequiredArgsConstructor
public class PerformanceMonitor {
    
    private final MeterRegistry meterRegistry;
    
    @EventListener
    @Async
    public void handleSlowQuery(SlowQueryEvent event) {
        if (event.getDuration().toMillis() > 1000) {
            meterRegistry.counter("database.slow.query",
                    "query", event.getQueryType(),
                    "table", event.getTableName())
                    .increment();
            
            log.warn("Slow query detected - query: {}, duration: {}ms, table: {}", 
                    event.getQueryType(), event.getDuration().toMillis(), event.getTableName());
        }
    }
    
    @EventListener
    @Async
    public void handleHighMemoryUsage(HighMemoryUsageEvent event) {
        if (event.getUsagePercent() > 80) {
            meterRegistry.gauge("jvm.memory.usage.high", event.getUsagePercent());
            
            log.warn("High memory usage detected - usage: {}%", event.getUsagePercent());
            
            // 자동 힙 덤프 생성 (선택사항)
            if (event.getUsagePercent() > 90) {
                generateHeapDump();
            }
        }
    }
    
    private void generateHeapDump() {
        try {
            MBeanServer server = ManagementFactory.getPlatformMBeanServer();
            HotSpotDiagnosticMXBean mxBean = ManagementFactory.newPlatformMXBeanProxy(
                    server, "com.sun.management:type=HotSpotDiagnostic", HotSpotDiagnosticMXBean.class);
            
            String filename = "heap-dump-" + System.currentTimeMillis() + ".hprof";
            mxBean.dumpHeap(filename, true);
            
            log.info("Heap dump generated: {}", filename);
        } catch (Exception e) {
            log.error("Failed to generate heap dump", e);
        }
    }
}

데이터베이스 성능 모니터링

@Component
@RequiredArgsConstructor
public class DatabaseMetrics {
    
    private final MeterRegistry meterRegistry;
    private final DataSource dataSource;
    
    @Scheduled(fixedRate = 30000) // 30초마다 실행
    public void recordConnectionPoolMetrics() {
        if (dataSource instanceof HikariDataSource) {
            HikariDataSource hikariDataSource = (HikariDataSource) dataSource;
            HikariPoolMXBean poolBean = hikariDataSource.getHikariPoolMXBean();
            
            meterRegistry.gauge("hikari.connections.active", poolBean.getActiveConnections());
            meterRegistry.gauge("hikari.connections.idle", poolBean.getIdleConnections());
            meterRegistry.gauge("hikari.connections.total", poolBean.getTotalConnections());
            meterRegistry.gauge("hikari.connections.threads.awaiting", poolBean.getThreadsAwaitingConnection());
        }
    }
}

🐳 컨테이너화와 배포

Docker 최적화

# Multi-stage 빌드로 이미지 크기 최적화
FROM maven:3.8.4-openjdk-17 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B

COPY src ./src
RUN mvn clean package -DskipTests

# 실행 이미지
FROM openjdk:17-jdk-slim
WORKDIR /app

# 비 root 사용자로 실행
RUN addgroup --system spring && adduser --system spring --ingroup spring
USER spring:spring

# JAR 파일 복사
COPY --from=build --chown=spring:spring /app/target/*.jar app.jar

# 헬스체크
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --quiet --tries=1 --spider http://localhost:8080/actuator/health || exit 1

# 환경별 설정
ENV JAVA_OPTS="-Xms512m -Xmx1024m -XX:+UseG1GC -XX:+UseContainerSupport"
ENV SPRING_PROFILES_ACTIVE=prod

EXPOSE 8080

ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

Docker Compose로 전체 스택 관리

# docker-compose.yml
version: '3.8'

services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - DB_HOST=mysql
      - DB_USERNAME=myapp
      - DB_PASSWORD=secret123
      - REDIS_HOST=redis
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_started
    volumes:
      - ./logs:/app/logs
    restart: unless-stopped

  mysql:
    image: mysql:8.0
    environment:
      MYSQL_DATABASE: myapp
      MYSQL_USER: myapp
      MYSQL_PASSWORD: secret123
      MYSQL_ROOT_PASSWORD: rootpassword
    volumes:
      - mysql_data:/var/lib/mysql
      - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    volumes:
      - redis_data:/data
    restart: unless-stopped

  prometheus:
    image: prom/prometheus
    ports:
      - "9090:9090"
    volumes:
      - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
      - '--storage.tsdb.path=/prometheus'
    restart: unless-stopped

  grafana:
    image: grafana/grafana
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin
    volumes:
      - grafana_data:/var/lib/grafana
      - ./monitoring/grafana/dashboards:/etc/grafana/provisioning/dashboards
    restart: unless-stopped

volumes:
  mysql_data:
  redis_data:
  grafana_data:

Kubernetes 배포

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: spring-boot-app
  labels:
    app: spring-boot-app
spec:
  replicas: 3
  selector:
    matchLabels:
      app: spring-boot-app
  template:
    metadata:
      labels:
        app: spring-boot-app
    spec:
      containers:
      - name: app
        image: spring-boot-app:latest
        ports:
        - containerPort: 8080
        env:
        - name: SPRING_PROFILES_ACTIVE
          value: "k8s"
        - name: DB_HOST
          value: "mysql-service"
        - name: DB_USERNAME
          valueFrom:
            secretKeyRef:
              name: db-secret
              key: username
        - name: DB_PASSWORD
          valueFrom:
            secretKeyRef:
              name: db-secret
              key: password
        resources:
          requests:
            memory: "512Mi"
            cpu: "250m"
          limits:
            memory: "1Gi"
            cpu: "500m"
        livenessProbe:
          httpGet:
            path: /actuator/health/liveness
            port: 8080
          initialDelaySeconds: 30
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /actuator/health/readiness
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
  name: spring-boot-service
spec:
  selector:
    app: spring-boot-app
  ports:
  - protocol: TCP
    port: 80
    targetPort: 8080
  type: LoadBalancer

📈 모니터링 대시보드 구성

Prometheus 설정

# prometheus.yml
global:
  scrape_interval: 15s

scrape_configs:
  - job_name: 'spring-boot-app'
    metrics_path: '/actuator/prometheus'
    static_configs:
      - targets: ['app:8080']
    scrape_interval: 30s

  - job_name: 'mysql'
    static_configs:
      - targets: ['mysql-exporter:9104']

  - job_name: 'redis'
    static_configs:
      - targets: ['redis-exporter:9121']

rule_files:
  - "alert_rules.yml"

alerting:
  alertmanagers:
    - static_configs:
        - targets:
          - alertmanager:9093

Grafana 대시보드 JSON

{
  "dashboard": {
    "title": "Spring Boot Application Dashboard",
    "panels": [
      {
        "title": "Application Status",
        "type": "stat",
        "targets": [
          {
            "expr": "up{job=\"spring-boot-app\"}"
          }
        ]
      },
      {
        "title": "Request Rate",
        "type": "graph",
        "targets": [
          {
            "expr": "rate(http_server_requests_seconds_count{job=\"spring-boot-app\"}[5m])"
          }
        ]
      },
      {
        "title": "Response Time",
        "type": "graph", 
        "targets": [
          {
            "expr": "http_server_requests_seconds{job=\"spring-boot-app\",quantile=\"0.95\"}"
          }
        ]
      },
      {
        "title": "JVM Memory Usage",
        "type": "graph",
        "targets": [
          {
            "expr": "jvm_memory_used_bytes{job=\"spring-boot-app\"} / jvm_memory_max_bytes{job=\"spring-boot-app\"} * 100"
          }
        ]
      },
      {
        "title": "Database Connection Pool",
        "type": "graph",
        "targets": [
          {
            "expr": "hikari_connections_active{job=\"spring-boot-app\"}"
          }
        ]
      }
    ]
  }
}

알람 규칙 설정

# alert_rules.yml
groups:
  - name: spring-boot-alerts
    rules:
      - alert: ApplicationDown
        expr: up{job="spring-boot-app"} == 0
        for: 30s
        labels:
          severity: critical
        annotations:
          summary: "Spring Boot application is down"
          description: "Application {{ $labels.instance }} has been down for more than 30 seconds"

      - alert: HighResponseTime
        expr: http_server_requests_seconds{quantile="0.95"} > 1
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "High response time detected"
          description: "95th percentile response time is {{ $value }}s"

      - alert: HighMemoryUsage
        expr: (jvm_memory_used_bytes / jvm_memory_max_bytes) * 100 > 90
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "High JVM memory usage"
          description: "JVM memory usage is {{ $value }}%"

      - alert: DatabaseConnectionPoolExhausted
        expr: hikari_connections_active >= hikari_connections_max * 0.9
        for: 1m
        labels:
          severity: warning
        annotations:
          summary: "Database connection pool nearly exhausted"
          description: "Connection pool usage: {{ $value }}"

🔒 보안과 운영 고려사항

Actuator 보안 설정

@Configuration
@EnableWebSecurity
public class ActuatorSecurityConfig {
    
    @Bean
    public SecurityFilterChain actuatorSecurityFilterChain(HttpSecurity http) throws Exception {
        return http
                .requestMatcher(EndpointRequest.toAnyEndpoint())
                .authorizeHttpRequests(authz -> authz
                        .requestMatchers(EndpointRequest.to("health", "info")).permitAll()
                        .requestMatchers(EndpointRequest.to("prometheus")).hasRole("MONITOR")
                        .anyRequest().hasRole("ADMIN")
                )
                .httpBasic(Customizer.withDefaults())
                .build();
    }
}
# application-prod.yml
management:
  endpoints:
    web:
      exposure:
        # 운영환경에서는 최소한의 엔드포인트만 노출
        include: "health,info,metrics,prometheus"
      base-path: /internal/actuator  # 기본 경로 변경
  endpoint:
    health:
      show-details: when-authorized
    shutdown:
      enabled: false  # 운영환경에서는 비활성화
  security:
    enabled: true

spring:
  security:
    user:
      name: monitor
      password: ${ACTUATOR_PASSWORD}
      roles: MONITOR,ADMIN

민감한 정보 보호

# application.yml
management:
  endpoint:
    env:
      show-values: when-authorized  # 환경변수 값 숨김
    configprops:
      show-values: when-authorized  # 설정 프로퍼티 값 숨김

# 민감한 키 마스킹
management:
  endpoint:
    env:
      keys-to-sanitize: "password,secret,key,token,.*credentials.*,vcap_services"

🎯 Part 5 정리

핵심 포인트 요약

  1. 모니터링:
    • Spring Boot Actuator로 애플리케이션 상태 감시
    • 커스텀 Health Indicator와 메트릭 구현
    • Prometheus + Grafana 대시보드 구성
  2. 로깅:
    • 구조화된 로깅으로 분석 효율성 향상
    • 환경별 로그 레벨과 출력 방식 분리
    • ELK Stack 연동을 위한 JSON 로그 형식
  3. 성능 관리:
    • JVM 메트릭과 애플리케이션 성능 지표 수집
    • 데이터베이스 커넥션 풀 모니터링
    • 자동 알람 및 대응 체계 구축
  4. 배포와 운영:
    • Docker 컨테이너화와 최적화
    • Kubernetes 오케스트레이션
    • 보안을 고려한 Actuator 설정

실습 체크리스트

  •  Spring Boot Actuator 엔드포인트 활성화
  •  커스텀 Health Indicator 구현
  •  비즈니스 메트릭 수집 코드 작성
  •  구조화된 로깅 적용
  •  Prometheus 메트릭 노출 설정
  •  Grafana 대시보드 구성
  •  Docker 이미지 최적화
  •  Kubernetes 배포 설정
  •  알람 규칙 설정
  •  보안 설정 강화

시리즈 완주 축하! 🎉

Spring Boot 완전 가이드 5부작을 모두 완료하셨습니다!

학습한 내용 정리:

  • Part 1: Spring Boot 기초와 프로젝트 구조
  • Part 2: 자동 설정과 외부 설정 관리
  • Part 3: REST API 개발과 보안
  • Part 4: 데이터 액세스와 배치 처리
  • Part 5: 운영과 모니터링

이제 실무에서 Spring Boot로 안정적이고 확장 가능한 애플리케이션을 개발하고 운영할 수 있는 모든 지식을 갖추셨습니다!

다음 단계 추천

  1. 마이크로서비스 아키텍처: Spring Cloud를 활용한 분산 시스템
  2. 리액티브 프로그래밍: Spring WebFlux 심화
  3. 클라우드 네이티브: AWS/GCP Spring Boot 배포
  4. 테스트 전략: 통합 테스트와 성능 테스트
  5. 보안 심화: OAuth2, JWT 고급 활용

📚 참고 자료:

🏷️ 태그: #SpringBoot #Actuator #Prometheus #Grafana #Docker #Kubernetes #운영 #모니터링 #실무가이드