예외 처리가 중요한 이유
•
프로그램의 비정상 종료 예방
•
문제의 원인 파악
•
보안
•
사용자 경험(UX)
예외를 구분해서 처리해야 하는 이유
•
문제의 원인이 다르기 때문
•
HTTP 상태코드의 매핑
•
비즈니스 로직에 따라 예외 메세지와 후처리가 달라짐
체크예외 (Checked Exception)
•
반드시 직접 처리해야 함
•
try-catch, throws로 무조건 예외 처리해야 함
•
예외처리 하지 않으면 컴파일 에러가 발생하여 아예 실행이 되지않음
언체크 예외 (Unchecked Exception)
•
컴파일러가 신경쓰지 않는 예외
•
try-catch 없어도 컴파일 됨
•
해당 요청만 실패로 처리하고 다음 요청 진행
•
예외 처리를 하지 않아도 서버가 죽지 않음
예외 코드 작성
GlobalExceptionHandler
package org.example.backendproject.exception;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.client.HttpClientErrorException.Conflict;
import org.springframework.web.client.HttpClientErrorException.MethodNotAllowed;
import org.springframework.web.client.HttpClientErrorException.NotFound;
import org.springframework.web.client.HttpClientErrorException.TooManyRequests;
import org.springframework.web.client.HttpClientErrorException.UnprocessableEntity;
import org.springframework.web.client.HttpClientErrorException.UnsupportedMediaType;
import org.springframework.web.client.HttpServerErrorException.BadGateway;
import org.springframework.web.client.HttpServerErrorException.GatewayTimeout;
import org.springframework.web.client.HttpServerErrorException.ServiceUnavailable;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
@RestControllerAdvice // 스프링에서 모든 컨트롤러의 예외를 한 곳에서 처리하기 위한 어노테이션
@Slf4j
public class GlobalExceptionHandler {
// 400: 파라미터 타입 오류, JSON 파싱 오류 등
@ExceptionHandler({
MethodArgumentTypeMismatchException.class,
HttpMessageNotReadableException.class,
MissingServletRequestParameterException.class
})
public ResponseEntity<?> handleBadRequest(Exception e) {
log.warn("[BAD_REQUEST] {}", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("잘못된 요청이라요: " + e.getMessage());
}
// 컨트롤러에서 RuntimeException 에러가 발생했을 때 이 메서드가 대신 처리하도록 매핑
@ExceptionHandler(value = RuntimeException.class)
public ResponseEntity<ErrorResponse> handleRuntimeException(RuntimeException e) {
ErrorResponse errorResponse = new ErrorResponse(
400,
"내가 전달하는 메세지",
e.getMessage()
);
log.error(errorResponse.toString());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
}
// 400: DTO validation(@Valid) 실패
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<?> handleValidationException(MethodArgumentNotValidException e) {
log.warn("[VALIDATION_FAIL] {}", e.getMessage());
//유효성 검증 실패한 모든 필드 오류 리스트를 가져옴
//유효성 검증 실패한 필드명과 이유를 콤마로 연결해서 한 줄 메시지로 만들어줌
String msg = e.getBindingResult().getFieldErrors().stream()
.map(fieldError -> fieldError.getField() + ": " + fieldError.getDefaultMessage())
.reduce((m1, m2) -> m1 + ", " + m2)
.orElse("유효성 검사 실패");
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(msg);
}
// 401: 인증 실패
@ExceptionHandler(BadCredentialsException.class)
public ResponseEntity<?> handleBadCredentials(BadCredentialsException e) {
log.warn("[LOGIN_FAIL] {}", e.getMessage());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.getMessage());
}
// 403: 인가(권한) 실패
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<?> handleAccessDenied(AccessDeniedException e) {
log.warn("[ACCESS_DENIED] {}", e.getMessage());
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("권한이 없습니다.");
}
// 404: 리소스 없음
@ExceptionHandler(NotFound.class)
public ResponseEntity<?> handleNotFound(NotFound e) {
log.warn("[NOT_FOUND] {}", e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("리소스가 없습니다.");
}
// 405: 허용하지 않는 메서드
@ExceptionHandler(MethodNotAllowed.class)
public ResponseEntity<?> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
log.warn("[METHOD_ARGUMENT_NOT_VALID] {}", e.getMessage());
return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body("허용하지 않는 메서드입니다.");
}
// 409: 요청 충돌 - 예) 중복 데이터
@ExceptionHandler(Conflict.class)
public ResponseEntity<?> handleConflict(Conflict e) {
log.warn("[CONFLICT] {}", e.getMessage());
return ResponseEntity.status(HttpStatus.CONFLICT).body("요청이 충돌하고 있습니다.");
}
// 415: 지원하지 않는 미디어 타입
@ExceptionHandler(UnsupportedMediaType.class)
public ResponseEntity<?> handleUnsupportedMediaType(UnsupportedMediaType e) {
log.warn("[UNSUPPORTED_MEDIA_TYPE] {}", e.getMessage());
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).body("지원하지 않는 미디어 타입입니다.");
}
// 422: 처리할 수 없는 엔티티(유효성 통과 실패 등)
@ExceptionHandler(UnprocessableEntity.class)
public ResponseEntity<?> handleUnprocessableEntity(UnprocessableEntity e) {
log.warn("[UNPROCESSABLE_ENTITY] {}", e.getMessage());
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body("처리할 수 없는 엔티티입니다.");
}
// 429: 너무 많은 요청(요청 제한)
@ExceptionHandler(TooManyRequests.class)
public ResponseEntity<?> handleTooManyRequests(TooManyRequests e) {
log.warn("[TOO_MANY_REQUESTS] {}", e.getMessage());
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body("너무 많은 요청이 들어오고 있습니다.");
}
// 500: 그 외 모든 예외
@ExceptionHandler(Exception.class)
public ResponseEntity<?> handleException(Exception e) {
log.error("[EXCEPTION][UNHANDLED] ", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("서버 내부 오류가 발생했습니다.");
}
// 502: 게이트웨이/프록시 오류
@ExceptionHandler(BadGateway.class)
public ResponseEntity<?> handleBadGateway(BadGateway e) {
log.warn("[BAD_GATEWAY] {}", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body("게이트웨이/프록시 오류가 발생했습니다.");
}
// 503: 서비스 사용 불가
@ExceptionHandler(ServiceUnavailable.class)
public ResponseEntity<?> handleServiceUnavailable(ServiceUnavailable e) {
log.warn("[SERVICE_UNAVAILABLE] {}", e.getMessage());
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body("서비스를 사용할 수 없습니다.");
}
// 504: 게이트웨이/프록시 시간 초과
@ExceptionHandler(GatewayTimeout.class)
public ResponseEntity<?> handleGatewayTimeout(GatewayTimeout e) {
log.warn("[GATEWAY_TIMEOUT] {}", e.getMessage());
return ResponseEntity.status(HttpStatus.GATEWAY_TIMEOUT).body("게이트웨이/프록시 시간 초과가 발생했습니다.");
}
}
Java
복사
•
HTTP 상태코드와 관련된 예외 처리를 하였다.
ErrorResponse
package org.example.backendproject.exception;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public class ErrorResponse {
private int code; // 상태코드
private String message; // 커스텀 에러 메세지
private String detail; // 실제 에러 메세지
}
Java
복사
•
실행 결과
주요 사용하는 예외 처리 관련 코드
<<< 주요 HTTP 상태코드 >>>
2xx: 성공
코드 상수명 의미
200 OK 성공적으로 요청 처리
201 CREATED 새 리소스 생성 성공
202 ACCEPTED 요청이 접수되었으나, 처리 미완료
204 NO_CONTENT 성공했지만 반환 데이터 없음
3xx: 리다이렉트
코드 상수명 의미
301 MOVED_PERMANENTLY 영구 이동 (리소스 위치 바뀜)
302 FOUND 임시 이동 (일시적 리다이렉트)
304 NOT_MODIFIED 변경 없음 (캐시 활용)
4xx: 클라이언트 오류 (클라이언트 잘못)
코드 상수명 의미
400 BAD_REQUEST 잘못된 요청, 파라미터/데이터 오류
401 UNAUTHORIZED 인증 실패 (로그인 필요)
403 FORBIDDEN 권한 없음 (로그인했지만 권한부족)
404 NOT_FOUND 리소스 없음
405 METHOD_NOT_ALLOWED 허용하지 않는 메서드
409 CONFLICT 요청 충돌 (예: 중복 데이터)
415 UNSUPPORTED_MEDIA_TYPE 지원하지 않는 미디어 타입
422 UNPROCESSABLE_ENTITY 처리할 수 없는 엔티티(유효성 불통과 등)
429 TOO_MANY_REQUESTS 너무 많은 요청(요청 제한)
5xx: 서버 오류 (서버 잘못)
코드 상수명 의미
500 INTERNAL_SERVER_ERROR 서버 내부 에러
501 NOT_IMPLEMENTED 아직 구현 안 됨
502 BAD_GATEWAY 게이트웨이/프록시 오류
503 SERVICE_UNAVAILABLE 서비스 사용 불가(점검중, 과부하 등)
504 GATEWAY_TIMEOUT 게이트웨이/프록시 시간 초과
Plain Text
복사
예외 테스트
로그의 목적
•
어떤 경로로 함수가 호출됐는지 확인할 수 있음
•
해당 시점에 코드가 정상적으로 실행 되었는지의 여부 확인
•
어떤 값이 들어왔는지 확인
•
예외 발생 위치 추적
로그 적용
•
Slf4j란?
◦
로깅에 대한 통합 인터페이스를 제공하는 라이브러리
◦
Slf4j는 실제 로그를 기록하지 않고 logback과 같은 구현체를 통해 구현!
로그 수집기 (Elasticsearch + Logstash + Kibana 구축)
application-properties
# never -> 절대 상세 정보 제공 안함 (항상 status만 응답)
# when-authorized -> 인증된 사용자/로컬 요청에만 상세 정보 제공 (기본값, 권장)
# always -> 항상 상세 정보 포함 (외부/내부 관계없이 상세 정보 노출, 개발·테스트용)
# log
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %highlight([%-3level]) %cyan(%logger{5}) - %msg%n
Plain Text
복사
ELK 설정 후 docker-compose.yml 파일 실행
services:
prometheus:
image: prom/prometheus:latest
container_name: prometheus
restart: unless-stopped
ports:
- "9090:9090"
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml # [설정파일]
- ./volumes/prometheus:/prometheus # [데이터 볼륨]
# depends_on:
# - backend1
# - backend2
# - backend3
networks:
- prod_server
grafana:
image: grafana/grafana:latest
container_name: grafana
restart: unless-stopped
ports:
- "3000:3000"
depends_on:
- prometheus
volumes:
- ./volumes/grafana:/var/lib/grafana # [데이터 볼륨]
networks:
- prod_server
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.12.0
container_name: elasticsearch
environment:
- node.name=es01
- discovery.type=single-node
- xpack.security.enabled=false
ulimits:
memlock:
soft: -1
hard: -1
ports:
- "9200:9200"
- "9300:9300"
volumes:
- ./volumes/esdata:/usr/share/elasticsearch/data
networks:
- prod_server
kibana:
image: docker.elastic.co/kibana/kibana:8.12.0
container_name: kibana
environment:
- ELASTICSEARCH_HOSTS=http://elasticsearch:9200
- SERVER_SSL_ENABLED=false
ports:
- "5601:5601"
volumes:
- ./volumes/kibana-data:/usr/share/kibana/data
depends_on:
- elasticsearch
networks:
- prod_server
logstash:
image: docker.elastic.co/logstash/logstash:8.12.0
container_name: logstash
ports:
- "5044:5044" # For beats (optional)
- "5000:5000" # TCP input
volumes:
- ./logstash/logstash.conf:/usr/share/logstash/pipeline/logstash.conf #logstash 설정파일
- ./logstash/logstash.yml:/usr/share/logstash/config/logstash.yml:ro # <-- 이 줄 추가!
- ../../logs:/logs #로그 볼륨
depends_on:
- elasticsearch
networks:
- prod_server
networks:
prod_server:
external: true
#도커 자체 볼륨을 사용할떄 선언해야 함
#volumes:
# volumes:
YAML
복사
logback-spring.xml 작성
•
참고로 맨 앞의 property 설정에서 LOG_PATH의 value를 “../logs”로 설정해야만 실시간 이벤트 발생 시 ElasticSearch에서 실시간으로 확인할 수 있다. 저 value는 환경에 따라 값이 다를 수 있으며 나 같은 경우는 “../logs”로 변경한 이후에 http://localhost:9200/_cat/indices?v 접속한 결과 정상적으로 갱신되는 모습을 확인할 수 있었다.
<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
<!-- 로그 파일이 저장될 경로 변수 -->
<property
name="LOG_PATH"
value="../logs"
/>
<!-- 파일 로그 출력 패턴: 색상 X (ELK 연동용) -->
<property
name="FILE_PATTERN"
value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%-3level] %logger{5} - %msg%n"
/>
<!-- 운영 환경(prod) 전용 로그 설정 -->
<!-- 콘솔 출력 없이 파일(app.log)에만 로그가 기록됨 -->
<!-- <springProfile name="prod">-->
<!-- <root level="INFO">-->
<!-- <appender-ref ref="FILE"/>-->
<!-- </root>-->
<!-- </springProfile>-->
<!-- ==========================
파일 로그 설정 (ELK 연동용)
========================== -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <!-- RollingFileAppender: 로그를 파일로 남김, 일자별로 파일 자동 분할 -->
<file>${LOG_PATH}/app.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/app-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>10</maxHistory> <!-- : 10일치까지만 파일 보관(자동 삭제) -->
</rollingPolicy>
<encoder>
<Pattern>${FILE_PATTERN}</Pattern>
</encoder>
</appender>
<!-- ==========================
콘솔(터미널) 로그 설정
========================== -->
<!-- 콘솔(터미널) 출력 패턴: 색상 O -->
<!--
thread <- 현재 로그를 찍은 스레드 이름
level <- 로그 레벨 (INFO, WARN, ERROR)
logger <- 로그를 찍은 클래스의 이름
msg <- 로그 메세지
-->
<!-- 콘솔(터미널)에 출력할 때 사용할 패턴 (색상 적용) -->
<property
name="CONSOLE_PATTERN"
value="%d{yyyy-MM-dd HH:mm:ss.SSS} %magenta([%thread]) %highlight([%-3level]) %logger{5} - %msg %n"
/>
<!-- 개발 환경(dev) 전용 로그 설정 -->
<!-- 파일로 로그 기록 없이, 콘솔(터미널)에만 로그 출력 -->
<!-- <springProfile name="dev">-->
<!-- <root level="DEBUG">-->
<!-- <appender-ref ref="STDOUT"/> <!– STDOUT 이름을 가진 appender로 로그 전송 –>-->
<!-- </root>-->
<!-- </springProfile>-->
<!-- 터미널에 출력하는 Appender 설정 (STDOUT) -->
<appender
name="STDOUT"
class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<Pattern>${CONSOLE_PATTERN}</Pattern>
</encoder>
</appender>
<!-- 콘솔 로그 비동기 처리(성능 최적화, 버퍼링)
아래 ASYNC는 위 STDOUT 출력을 비동기로 최적화(성능 향상) -->
<appender
name="ASYNC"
class="ch.qos.logback.classic.AsyncAppender">
<appender-ref ref="STDOUT"/>
</appender>
<!-- ==========================
패키지별 로그 레벨 지정
========================== -->
<!--(INFO, WARN, ERROR도 모두 출력)-->
<!-- 특정 패키지(여기서는 org.boot.backend5project)만 따로 레벨 지정 가능
additive="false"면, 해당 패키지 로그는 상위(root)에 전달되지 않음 -->
<logger
name="org.boot.backend5Project"
level="DEBUG, INFO, WARN, ERROR"
additive="false" >
<appender-ref ref="STDOUT"/>
</logger>
<!-- ==========================
전체(Global) 로그 설정
========================== -->
<!-- <root level="DEBUG">-->
<!-- <root level="TRACE">-->
<!-- <root level="INFO">-->
<root level="INFO">
<appender-ref ref="ASYNC"/> <!-- 콘솔(터미널) 로그 비동기 처리 -->
<appender-ref ref="FILE"/> <!-- 파일 로그(ELK 연동) -->
</root>
<!-- 로그 하나가 발생하면, 콘솔에도 찍히고 파일에도 기록됨(동시) -->
<!--
참고: springProfile 태그를 쓰면
<springProfile name="prod">,
<springProfile name="dev">
환경별로 다른 로그정책 적용 가능
-->
</configuration>
XML
복사
localhost:9200/_cat/indices?v 에 접속하여 로그 확인
•
Grafana 확인
•
ElasticSearch 접속
•
그래프 시각화
로그 보기
ElasticSearch 인덱스 확인
명칭 | 내용 |
Name | 인덱스 이름 |
Health | 색깔로 상태 표시
- yellow: 프라이머리 샤드는 정상, 리플리카(복제본) 샤드는 일부 부족
- green: 프라이머리, 리플리카 모두 정상
- red: 일부 혹은 모든 인덱스의 primary와 리플리카 샤드가 정상적으로 동작하고 있지 않음 (데이터 유실 발생할 가능성이 있음) |
Status | 인덱스가 open(읽기/쓰기) 가능 상태인지 확인 |
Primaries | 프라이머리 샤드 개수 |
Replicase | 리플리카 샤드 개수 |
Docs count | 인덱스에 저장된 문서(로그) 개수 |
Storage size | 인덱스의 저장 공간 |
샤드란 무엇인가?
•
샤드란 ElasticSearch에서 데이터를 쪼개서 저장하는 조각을 의마한다.
◦
Primary Shard
▪
진짜 데이터가 저장되는 메인 조각
▪
인덱스마다 기본값 1개 (설정으로 N개 가능)
◦
Replica Shard
▪
Primary를 복제한 “백업” 조각
▪
서버 장애 시에도 데이터가 안전함
▪
인덱스마다 기본값 1개 (설정으로 N개 가능)
•
샤드를 사용하는 이유
◦
성능
▪
데이터를 여러 샤드로 나누면 여러 서버(노드)가 병렬로 검색/저장 처리
▪
속도가 빠르고, 데이터가 많아도 버틸 수 있음
◦
확장성
▪
서버를 추가하면 샤드를 분산시켜 자동으로 데이터 확장이 가능
◦
고가용성(장애 대비)
▪
Primary/Replica로 장애 발생 시에도 데이터 유실을 최소화 할 수 있음
AOP (Aspect-Oriented Programming)
용어 | 설명 |
Aspect | 공통 기능 클래스 (@Aspect) |
JoinPoint | 메서드 실행 지점 |
Pointcut | 적용 대상 메서드 정의 (execution, @annotation 등) |
Advice | 적용할 공통 기능 (@Before, @After, @Around) |
스프링에서의 공통 기능
상황 | 해결 방법 (AOP) |
모든 메서드 시작/종료 시 로그 남기기 | 메서드 호출 전후에 공통 로직 적용 |
트랜잭션 처리 | 메서드 실행 전후에 커밋/롤백 적용 |
실행 시간 측정 | 메서드 실행 전 시간 측정, 후에 계산 |
인증 체크 | 메서드 실행 전 사용자 권한 확인 |
⇒ 굳이 매번 메서드에 작성하지 않아도 AOP로 한 번에 처리 가능!
AOP 용어
•
Target
◦
어떤 대상에 부가 기능을 부여할 것인가
•
Advice
◦
어떤 부가 기능? Before, AfterReturning, AfterThrowing, After, Around
◦
@Before: 실행 직전을 확인하고 싶을 때 사용
◦
@After: 실행 직후를 확인하고 싶을 때 사용
◦
@Around: 메서드 실행 전후를 확인하고 싶을 때 사용
•
Join Point
◦
어디에 적용할 것인가? 메서드, 필드, 객체, 생성자 등
•
Point cut
◦
실제 advice가 적용될 지점, Spring AOP에서는 advice가 적용될 메서드를 선정
•
LogAspect
◦
boardService와 boardController 실행 시 각각의 메서드 호출 시작 시간과 종료시간을 로그를 통해 확인하도록 구성하였다.
package org.example.backendproject.aop;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@Aspect // 공통으로 관리하고 싶은 기능을 담당하는 클래스에 붙히는 어노테이션
public class LogAspect {
// AOP를 적용할 클래스 - 메서드, 클래스 모두 적용 가능
@Pointcut("execution(* org.example.backendproject.board.service.BoardService..*(String, String))" +
"execution(* org.example.backendproject.board.controller..*(..))"
)
public void method(){}
// @Around는 호출 시작과 종료 모두에 관여할 수 있는 AOP Advice
@Around("method()")
public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
String methodName = joinPoint.getSignature().getName(); // aop가 실행된 메서드
try {
log.info("[AOP_LOG] {} 메서드 호출 시작 ", methodName);
Object result = joinPoint.proceed(); // JoinPoint // AOP를 적용할 시점
return result;
} catch (Exception e) {
log.error("[AOP_LOG] {} 메서드 예외 {} ", methodName, e.getMessage());
return e;
} finally {
long end = System.currentTimeMillis();
log.info("[AOP_LOG] {} 메서드 실행 완료 시간 = {}", methodName, end - start);
}
}
// aop가 실행되기 직전에 호출
@Before("execution(* org.example.backendproject.board.service..*(..))")
public void beforeLog(JoinPoint joinPoint) {
String method = joinPoint.getSignature().toShortString();
log.info("[AOP_LOG][START] -> 메서드 = {} 호출 시작", method);
}
// aop가 실행된 이후에 호출
@After("execution(* org.example.backendproject.board.service..*(..))")
public void afterLog(JoinPoint joinPoint) {
String method = joinPoint.getSignature().toShortString();
log.info("[AOP_LOG][END] -> 메서드 = {} 호출 종료", method);
}
}
Java
복사
컨테이너 여러 개 실행하여 Grafana 에서 확인하는 절차
1. prometheus.yml 파일 주석 해제
global:
scrape_interval: 5s
scrape_configs:
- job_name: 'springboot'
metrics_path: '/actuator/prometheus'
static_configs:
- targets:
- 'host.docker.internal:8080' # 도커 컨테이너가 로컬 호스트를 바라보는 공식 DNS 이름
- 'backend1:8080'
- 'backend2:8080'
- 'backend3:8080'
YAML
복사
2. Jenkins build 실행
•
주의할 점
◦
기존 Jenkins의 구성 파일은 다음과 같이 설정되어 있다.
spring.application.name=backendProject
db.server=${DB_SERVER:database}
db.port=${DB_PORT:3306}
db.username=${DB_USER:root}
db.password=${DB_PASS:1234}
REDIS.HOST=${REDIS_HOST:redis}
spring.data.redis.host=${REDIS.HOST}
spring.data.redis.port=6379
spring.datasource.url=jdbc:mysql://${db.server}:${db.port}/backendDB?serverTimezone=Asia/Seoul&characterEncoding=UTF-8&rewriteBatchedStatements=true
spring.datasource.username=${db.username}
spring.datasource.password=${db.password}
openai.api.key=
spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect
spring.jpa.hibernate.ddl-auto=update
Plain Text
복사
◦
하지만 oauth나 board 등 다른 서비스들이 적용되어 있는 상황에서 구성 파일의 내용들을 전면 수정해줘야 정상적인 빌드가 진행된다. 소셜 로그인, logStash, Prometheus 등의 구성도 적용되어 있어야 실행이 될 것이다.
spring.application.name=backendProject
db.server=${DB_SERVER:database}
db.port=${DB_PORT:3306}
db.username=${DB_USER:root}
db.password=${DB_PASS:1234}
REDIS.HOST=${REDIS_HOST:redis}
spring.data.redis.host=${REDIS.HOST}
spring.data.redis.port=6379
spring.datasource.url=jdbc:mysql://${db.server}:${db.port}/backendDB?serverTimezone=Asia/Seoul&characterEncoding=UTF-8&rewriteBatchedStatements=true
spring.datasource.username=${db.username}
spring.datasource.password=${db.password}
openai.api.key=내용 추가
spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect
spring.jpa.hibernate.ddl-auto=update
# batch mode
spring.jpa.properties.hibernate.jdbc.batch_size=1000
spring.jpa.properties.hibernate.order_inserts=true
#spring.jpa.properties.hibernate.order_updates=true
#
#
spring.jpa.properties.hibernate.generate_statistics=true
jwt.accessTokenExpirationTime=1000000
jwt.refreshTokenExpirationTime=86400000
jwt.secretKey=내용 추가
#google
spring.security.oauth2.client.registration.google.client-id=내용 추가
spring.security.oauth2.client.registration.google.client-secret=내용 추가
#spring.security.oauth2.client.registration.google.redirect-uri=http://localhost:8080/login/oauth2/code/google
spring.security.oauth2.client.registration.google.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}
spring.security.oauth2.client.registration.google.scope=profile, email
spring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code
#kakao
spring.security.oauth2.client.registration.kakao.client-id=내용 추가
spring.security.oauth2.client.registration.kakao.client-secret=내용 추가
spring.security.oauth2.client.registration.kakao.client-authentication-method=client_secret_post
#spring.security.oauth2.client.registration.kakao.redirect-uri=http://localhost:8080/login/oauth2/code/kakao
spring.security.oauth2.client.registration.kakao.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}
spring.security.oauth2.client.registration.kakao.scope=profile_nickname, profile_image
spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code
spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize
spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/token
spring.security.oauth2.client.provider.kakao.user-info-uri=https://kapi.kakao.com/v2/user/me
spring.security.oauth2.client.provider.kakao.user-name-attribute=id
# actuator and metric and prometheus
# prometheus 전용 앤드포인트 생성
management.prometheus.metrics.export.enabled=true
#prometheus 엔드포인트를 노출
management.endpoints.web.exposure.include=*
#앤드 포인트에서 어떤 정보를 보여줄지 설정
management.endpoint.health.show-details=always
# never -> 절대 상세 정보 제공 안함 (항상 status만 응답)
# when-authorized -> 인증된 사용자/로컬 요청에만 상세 정보 제공 (기본값, 권장)
# always -> 항상 상세 정보 포함 (외부/내부 관계없이 상세 정보 노출, 개발·테스트용)
# log
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %highlight([%-3level]) %cyan(%logger{5}) - %msg%n
Plain Text
복사
•
위의 내용으로 구성해준 다음 Jenkins에 접속하여 빌드하면 정상적으로 진행된다.
Started by user codesche
[Pipeline] Start of Pipeline
[Pipeline] node
Running on Jenkins
in /var/jenkins_home/workspace/backend5_Test_local
[Pipeline] {
[Pipeline] withEnv
[Pipeline] {
[Pipeline] stage
[Pipeline] { (Cleanup Containers)
[Pipeline] dir
Running in /var/jenkins_home/workspace/backend5_Test_local/backendProject
[Pipeline] {
[Pipeline] sh
+ docker-compose -f docker-compose.backend.yml down
Removing nginx ...
Removing backend1 ...
Removing backend2 ...
Removing backend3 ...
Removing backend1 ... done
Removing backend3 ... done
Removing nginx ... done
Removing backend2 ... done
Network prod_server is external, skipping
[Pipeline] }
[Pipeline] // dir
[Pipeline] }
[Pipeline] // stage
[Pipeline] stage
[Pipeline] { (Build)
[Pipeline] dir
Running in /var/jenkins_home/workspace/backend5_Test_local/backendProject
[Pipeline] {
[Pipeline] sh
+ chmod +x gradlew
[Pipeline] sh
+ ./gradlew clean build
Starting a Gradle Daemon (subsequent builds will be faster)
> Task :clean
> Task :compileJava
Note: /var/jenkins_home/workspace/backend5_Test_local/backendProject/src/main/java/org/example/backendproject/oauth2/OAuth2UserService.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.
> Task :processResources
> Task :classes
> Task :resolveMainClassName
> Task :bootJar
> Task :jar
> Task :assemble
> Task :compileTestJava
> Task :processTestResources
> Task :testClasses
2025-06-28 13:09:47.493 [35m[SpringApplicationShutdownHook][0;39m [34m[INFO][0;39m o.s.m.s.b.SimpleBrokerMessageHandler - Stopping...
2025-06-28 13:09:47.494 [35m[SpringApplicationShutdownHook][0;39m [34m[INFO][0;39m o.s.m.s.b.SimpleBrokerMessageHandler - BrokerAvailabilityEvent[available=false, SimpleBrokerMessageHandler [org.springframework.messaging.simp.broker.DefaultSubscriptionRegistry@456c2b84]]
2025-06-28 13:09:47.494 [35m[SpringApplicationShutdownHook][0;39m [34m[INFO][0;39m o.s.m.s.b.SimpleBrokerMessageHandler - Stopped.
2025-06-28 13:09:47.612 [35m[SpringApplicationShutdownHook][0;39m [34m[INFO][0;39m o.s.o.j.LocalContainerEntityManagerFactoryBean - Closing JPA EntityManagerFactory for persistence unit 'default'
2025-06-28 13:09:47.619 [35m[SpringApplicationShutdownHook][0;39m [34m[INFO][0;39m c.z.h.HikariDataSource - HikariPool-1 - Shutdown initiated...
2025-06-28 13:09:47.625 [35m[SpringApplicationShutdownHook][0;39m [34m[INFO][0;39m c.z.h.HikariDataSource - HikariPool-1 - Shutdown completed.
> Task :test
> Task :check
> Task :build
[Incubating] Problems report is available at: file:///var/jenkins_home/workspace/backend5_Test_local/backendProject/build/reports/problems/problems-report.html
BUILD SUCCESSFUL in 17s
9 actionable tasks: 9 executed
[Pipeline] }
[Pipeline] // dir
[Pipeline] }
[Pipeline] // stage
[Pipeline] stage
[Pipeline] { (Compose Up)
[Pipeline] dir
Running in /var/jenkins_home/workspace/backend5_Test_local/backendProject
[Pipeline] {
[Pipeline] sh
+ docker-compose -f docker-compose.backend.yml up -d --build
Building backend1
#1 [internal] load build definition from Dockerfile
#1 sha256:df2a1525516ce5122ad267360c539869f297f4d40bed5dedf81d8109d779d886
#1 transferring dockerfile: 552B done
#1 DONE 0.0s
#2 [internal] load metadata for docker.io/library/openjdk:17-jdk
#2 sha256:ca51d00d9530d240e743cd0ab7d53583e2fed8269e748b1001b3fe78fb46ffcf
#2 DONE 1.8s
#3 [internal] load .dockerignore
#3 sha256:7a1b000314113b76f24e71e0f1479e48258781389ad969e05f709eda4ac4910b
#3 transferring context: 2B 0.0s done
#3 DONE 0.0s
#7 [1/3] FROM docker.io/library/openjdk:17-jdk@sha256:528707081fdb9562eb819128a9f85ae7fe000e2fbaeaf9f87662e7b3f38cb7d8
#7 sha256:f73aed2f80bd0e561c39fdfc2e68670e1949d1bfa96639f3c70771849d802a7e
#7 DONE 0.0s
#5 [internal] load build context
#5 sha256:6f2ec87115f784df2a4ee76a23f99e4d22a96b510997b545da12cebe6473fb64
#5 transferring context: 75.92MB 0.6s done
#5 DONE 0.6s
#6 [2/3] WORKDIR /app
#6 sha256:ebfac0acd0d242235faaf3af5c14ddf7d85661355fc853ee47f047b0c80672c3
#6 CACHED
#4 [3/3] COPY build/libs/backendProject-0.0.1-SNAPSHOT.jar /app/backendProject-0.0.1-SNAPSHOT.jar
#4 sha256:8d2c920eaf13b50e0ad8e3e2a18ed10a5bcf5c35ef0fb23ba36d508de082ebb8
#4 DONE 0.1s
#8 exporting to image
#8 sha256:4d32beb69dfe892e36693df28c3f1b81f5e865102b5552427b4d79e00a9463fa
#8 exporting layers 0.1s done
#8 writing image sha256:9312a48cf3b5204aa064004131496c1a01b0f759073ca2e7241d84d3392c8c81
#8 writing image sha256:9312a48cf3b5204aa064004131496c1a01b0f759073ca2e7241d84d3392c8c81 done
#8 naming to docker.io/library/backendproject_backend1:latest done
#8 DONE 0.1s
Building backend2
#1 [internal] load build definition from Dockerfile
#1 sha256:f3bcfa20e188aafd8eae6e006b9b4cc995790131a6561e6e7145b1f4ff58311f
#1 transferring dockerfile: 552B done
#1 DONE 0.0s
#2 [internal] load metadata for docker.io/library/openjdk:17-jdk
#2 sha256:ca51d00d9530d240e743cd0ab7d53583e2fed8269e748b1001b3fe78fb46ffcf
#2 DONE 0.3s
#3 [internal] load .dockerignore
#3 sha256:d573d4d9c9d4adca8cfa73af13243b9a66aaa9233358304912d5be29daf69bca
#3 transferring context: 2B done
#3 DONE 0.0s
#7 [1/3] FROM docker.io/library/openjdk:17-jdk@sha256:528707081fdb9562eb819128a9f85ae7fe000e2fbaeaf9f87662e7b3f38cb7d8
#7 sha256:f73aed2f80bd0e561c39fdfc2e68670e1949d1bfa96639f3c70771849d802a7e
#7 DONE 0.0s
#5 [internal] load build context
#5 sha256:025a640f36f79810b6a4cd3a52e2cda5da0c98d586638377bd1d0614b0243b93
#5 transferring context: 123B done
#5 DONE 0.0s
#6 [2/3] WORKDIR /app
#6 sha256:ebfac0acd0d242235faaf3af5c14ddf7d85661355fc853ee47f047b0c80672c3
#6 CACHED
#4 [3/3] COPY build/libs/backendProject-0.0.1-SNAPSHOT.jar /app/backendProject-0.0.1-SNAPSHOT.jar
#4 sha256:6ea8d8eff6a6bb17706553b0d05c1bca0d71a31be4487b9983fd7052b3db68dd
#4 CACHED
#8 exporting to image
#8 sha256:bff4cd16ab4bc643ca50f04eaccc6e317c87c64fff88dedc1b12240b95b934a3
#8 exporting layers done
#8 writing image sha256:9312a48cf3b5204aa064004131496c1a01b0f759073ca2e7241d84d3392c8c81 done
#8 naming to docker.io/library/backendproject_backend2:latest done
#8 DONE 0.0s
Building backend3
#1 [internal] load build definition from Dockerfile
#1 sha256:ba4a5fdd708b0301fcb10783f36dcffadbeeee762b93159086017d83a090edd6
#1 transferring dockerfile: 552B done
#1 DONE 0.0s
#2 [internal] load metadata for docker.io/library/openjdk:17-jdk
#2 sha256:ca51d00d9530d240e743cd0ab7d53583e2fed8269e748b1001b3fe78fb46ffcf
#2 DONE 0.5s
#3 [internal] load .dockerignore
#3 sha256:cf63f943cf8b73ed9e0ac30b3bb7ca6a569a360b1953863edce7df02985850fb
#3 transferring context: 2B done
#3 DONE 0.0s
#7 [1/3] FROM docker.io/library/openjdk:17-jdk@sha256:528707081fdb9562eb819128a9f85ae7fe000e2fbaeaf9f87662e7b3f38cb7d8
#7 sha256:f73aed2f80bd0e561c39fdfc2e68670e1949d1bfa96639f3c70771849d802a7e
#7 DONE 0.0s
#5 [internal] load build context
#5 sha256:9e0fa30d9d4545aba11e6a49d1c708122aad43a6c6cf2ac4cbd9d325ab9e3996
#5 transferring context: 123B done
#5 DONE 0.0s
#6 [2/3] WORKDIR /app
#6 sha256:ebfac0acd0d242235faaf3af5c14ddf7d85661355fc853ee47f047b0c80672c3
#6 CACHED
#4 [3/3] COPY build/libs/backendProject-0.0.1-SNAPSHOT.jar /app/backendProject-0.0.1-SNAPSHOT.jar
#4 sha256:a9358ea60459edc6e998dd1e29a3fafe46a755fe62e180a0651b29f744525653
#4 CACHED
#8 exporting to image
#8 sha256:d8785ebae59c896024f05dbf89c74d060115d3bec59f68e2789519d7608b9f28
#8 exporting layers done
#8 writing image sha256:9312a48cf3b5204aa064004131496c1a01b0f759073ca2e7241d84d3392c8c81 done
#8 naming to docker.io/library/backendproject_backend3:latest done
#8 DONE 0.0s
Building nginx
#1 [internal] load build definition from Dockerfile
#1 sha256:c61bbbfd5fc82cf73a5ad9239dd5a2499b1fa9ce5c59c04dc76ba06d87fad2a5
#1 transferring dockerfile: 190B done
#1 DONE 0.0s
#2 [internal] load metadata for docker.io/library/nginx:latest
#2 sha256:9ad844bdd951526fe21f5db65442779c28ef3778b7341f24b3fff4059e36d00e
#2 DONE 0.0s
#3 [internal] load .dockerignore
#3 sha256:cb68cb99e88a8f3b5119002d00a1c02a9c70975809f342e87fca63d81a839b15
#3 transferring context: 2B done
#3 DONE 0.0s
#7 [1/3] FROM docker.io/library/nginx:latest
#7 sha256:d5687ae241157e32429b22dff7fad05be88477a77988fa0517f84408d5fc5efe
#7 DONE 0.0s
#5 [internal] load build context
#5 sha256:ed5d0f9c4a0caa930d6c572840d217a27715fe160116a9bc33418c56a5858a4e
#5 transferring context: 32B done
#5 DONE 0.0s
#6 [2/3] RUN rm /etc/nginx/nginx.conf
#6 sha256:9c4a004d5c82d81d57602daa5fc87bfbba857d28e6f109f506e0f67cac6f6f6f
#6 CACHED
#4 [3/3] COPY nginx.conf /etc/nginx/nginx.conf
#4 sha256:1fa718b226588379f86949baa576852b6cc514be26ffd1b57700d75cbd916cb4
#4 CACHED
#8 exporting to image
#8 sha256:b7e144d35e86bd2a32b129496ba9d113f7abfdc9df9965e006f0e50d3acabbc9
#8 exporting layers done
#8 writing image sha256:21be59e6d5ecc3f082be4c86c8a1a8c33dca7212ae92b7174f3b68783fe21abf done
#8 naming to docker.io/library/backendproject_nginx:latest done
#8 DONE 0.0s
Creating backend1 ...
Creating backend3 ...
Creating backend2 ...
Creating backend1 ... done
Creating backend2 ... done
Creating backend3 ... done
Creating nginx ...
Creating nginx ... done
[Pipeline] }
[Pipeline] // dir
[Pipeline] }
[Pipeline] // stage
[Pipeline] stage
[Pipeline] { (Restart Nginx)
[Pipeline] sh
+ docker restart nginx
nginx
[Pipeline] }
[Pipeline] // stage
[Pipeline] }
[Pipeline] // withEnv
[Pipeline] }
[Pipeline] // node
[Pipeline] End of Pipeline
Finished: SUCCESS
Plain Text
복사
❗️Jenkins 빌드를 하게되는 과정에서 갑자기 ElasticSearch 컨테이너가 다운되는 현상이 있는데 설정 메뉴의 Resource 에서 Memory limit을 조정해주면 정상적으로 작동된다.
(기존에 실행되던 게 갑자기 다운돼서 다시 실행을 했지만 자꾸만 다운되는 이슈가 발생 - 메모리 조절 후 해결)
•
기존에 실행하였던 프로메테우스 컨테이너 삭제 후 docker compose -f docker-compose.monitoring.yml up -d
를 입력한 후 실행한다.
•
localhost:9090 후 Target health 에서 다음과 같이 확인된다면 정상적으로 실행되고 있는 것이다.
•
localhost:3000 번에 접속하여 Dashboard 에서 이전에 설정했던 JVM(Micrometer)에 들어간다.
•
Instance 란에 다음과 같이 보인다면 성공한 것이다!
오늘 푸시한 커밋리스트 (다음 날 커밋, 푸시 진행)
날짜 | 커밋 메시지 |
2025-06-28 | |
2025-06-28 | |
2025-06-28 | |
2025-06-28 | |
2025-06-28 | |
2025-06-28 | |
2025-06-28 | |
2025-06-28 | |
2025-06-28 | |
2025-06-28 | |
2025-06-28 |