환경: java17, spring boot 3.1.5, spring batch 5.0.3, mysql5.7
이슈:
아래 에러가 간헐적으로 발생하며 배치 실패. 재실행 시 정상 처리
Caused by: com.mysql.cj.jdbc.MysqlXAException: XAER_DUPID: The XID already exists
at com.mysql.cj.jdbc.MysqlXAConnection.mapXAExceptionFromSQLException(MysqlXAConnection.java:344)
at com.mysql.cj.jdbc.MysqlXAConnection.dispatchCommand(MysqlXAConnection.java:329)
at com.mysql.cj.jdbc.MysqlXAConnection.start(MysqlXAConnection.java:290)
at com.atomikos.datasource.xa.XAResourceTransaction.resume(XAResourceTransaction.java:217)
... 81 common frames omitted
Caused by: java.sql.SQLException: XAER_DUPID: The XID already exists
at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:130)
at com.mysql.cj.jdbc.exceptions.SQLExceptionsMapping.translateException(SQLExceptionsMapping.java:122)
at com.mysql.cj.jdbc.StatementImpl.executeInternal(StatementImpl.java:763)
at com.mysql.cj.jdbc.StatementImpl.execute(StatementImpl.java:648)
at com.mysql.cj.jdbc.MysqlXAConnection.dispatchCommand(MysqlXAConnection.java:323)
디비에서 xa recover; 로 검색 시 남아있는 트랜젝션 없는 것 확인
해결:
XID 중복이라 우선 XID가 어떻게 생성되는지 확인
XID: <gtrid>:<bqual>
gtrid (Global Transaction ID): 분산 트랜잭션을 식별하는 고유한 값
bqual (Branch Qualifier): 트랜잭션 내에서 개별 브랜치를 구분하는 값
16진수 → ASCII 디코딩 제공된 XID는 16진수(Hex)로 인코딩 되어 있음. 이를 ASCII 문자로 변환해야 함
Call by Value: 인자로 전달된 변수의 값만 복사하여 사용. 원본 변수에는 영향을 주지 않는다.
Call by Reference: 인자로 전달된 변수의 메모리 주소(참조값, reference)를 전달. 함수 내에서 값이 변경되면 원본 변수에도 영향을 미친다.
자바는 Call by Value
자바의 메서드 호출 방식은 항상 Call by Value다. 즉, 메서드가 인자를 받을 때 원본 변수의 값을 복사하여 전달한다.
기본 타입 (Primitive Type)
기본 타입(예: int, double, char 등)은 Call by Value로 동작하여 원본 변수에 영향을 주지 않는다.
참조 타입 (Reference Type)
객체(Object)와 같은 참조 타입의 경우, 객체의 참조값(메모리 주소)이 값으로 전달된다. 따라서 참조하는 객체의 속성을 변경하면 원본 객체에도 영향을 준다. 하지만 참조 자체를 변경해도(새로운 객체를 할당하면) 원본에는 영향을 주지 않는다.
class Person {
String name;
}
public class Example {
public static void changePerson(Person p) {
p = new Person(); // 새로운 객체를 할당
p.name = "Charlie";
}
public static void main(String[] args) {
Person person = new Person();
person.name = "Bob";
changePerson(person);
System.out.println(person.name); // 여전히 Bob (새로운 객체는 원본에 영향을 주지 않음)
}
}
changePerson 함수 안에서 p가 새로운 객체를 가리키도록 변경되었지만, 이것은 메서드 내부의 p 변수가 가리키는 참조가 바뀐 것일 뿐, 원래 person 변수에는 영향을 주지 않는다. 만약 메서드 내부에서 새로운 객체를 만들어 원본에도 반영하고 싶다면, 리턴 값을 활용하여 원본 변수를 직접 변경해야 한다.
public static Person changePerson(Person p) {
return new Person("New Person");
}
public static void main(String[] args) {
Person person = new Person("Original");
person = changePerson(person); // 리턴 값을 원본 변수에 할당
System.out.println(person.name); // "New Person"
}
객체의 참조값(메모리 주소)을 복사하여 전달하기 때문에, 객체 내부 값은 변경할 수 있지만, 객체 자체를 변경할 수는 없다.
Prometheus 및 Loki, Tempo 등에서 발생한 알람을 관리하고, 이메일, Slack, PagerDuty 등의 채널로 알림을 전송하는 역할을 하는 도구
알람 수신 및 라우팅, 집계, mute, 알람 중복 방지 등 기능이 있음
프로메테우스 설정에서 ruler를 사용하도록 설정 후 별도 파일(yaml)로 설정 관리
Loki에서 직접 알람 가능?
Loki 자체적으로는 알람을 트리거할 기능이 제한적
logql쿼리를 사용하여 Prometheus Ruler에서 감지 후 Alertmanager로 전송하는 방식이 일반적
Prometheus에서 직접 알람 가능?
Prometheus는 자체적으로Prometheus Ruler를 통해 알람을 감지 가능
하지만 Alertmanager 없이 직접 알람을 보낼 수 없음
Tempo에서 직접 알람 가능?
Tempo는 직접적인 알람 기능이 없음
Trace 기반으로 메트릭을 생성한 후 Prometheus Ruler를 통해 감지하는 방식 사용
Prometheus Ruler + Alertmanager를 사용 시 장단점
장점
1. 유연한 알림 라우팅
Alertmanager는 알림을 '라벨' 기반으로 라우팅할 수 있어서, 다양한 알림 조건에 대해 수신자를 유연하게 지정할 수 있음.
예를 들어,severity,project,team과 같은 라벨을 기반으로 알림을 각기 다른 수신자 그룹(메일, 슬랙, 웹훅 등)으로 전달할 수 있음.
라벨을 이용하여 프로젝트별로 혹은 환경 별로 다양한 알림 조건을 설정할 수 있음
2. 알림 집계 및 수집
Alertmanager는 동일한 경고에 대해 여러 번 알림을 보내지 않도록 알림을 집계하고 알림 그룹화 기능을 제공
예를 들어, 여러 번 발생하는 동일한 경고를 하나의 알림으로 묶어서 처리할 수 있음
3. 알림 수신 채널 다채로움
알림을 다양한 채널(이메일, 슬랙, 페이지듀티, SMS 등)로 전송할 수 있음.
Alertmanager는 알림을 설정한 대로 다양한 형식으로 전송할 수 있는 기능을 제공함
4. 정밀한 알림 조건 설정
Prometheus Ruler에서 제공하는 고급 알림 규칙 설정을 통해, 알림 조건을 세밀하게 정의할 수 있음.
예를 들어, 특정 메트릭이 1분 동안 특정 값을 초과하거나, 특정 상황이 반복되는 경우에만 알림을 보내는 식으로 알림의 발생 조건을 세밀하게 조정할 수 있음.
단점
1. 설정이 복잡함
설정하는 화면이 없고 yaml 파일을 작성하는 방식
여러 팀이나 프로젝트별로 맞춤형 알림을 설정하는 경우, 설정 파일이 방대해질 수 있으며, 이를 관리하기 어려울 수 있다.
2. 리소스 요구사항
Prometheus Ruler와 Alertmanager는 각각 다른 시스템과 연동되어야 하므로, 시스템 자원의 관리가 필요함. Prometheus의 수집 데이터 양이 많아지면 알림 평가에 드는 시간과 리소스가 커질 수 있다.
알림을 너무 많이 생성하거나 복잡한 계산을 수행하면 시스템에 부하를 줄 수 있음
도입 시 각 어플리케이션 수정 양은?
1. 아래 의존성 추가
// 애플리케이션에서 메트릭, 트레이스, 로그와 같은 관측 데이터를 수집하는 데 필요한 인터페이스를 제공; trace id 생성
implementation 'io.opentelemetry:opentelemetry-api:${version}'
// OpenTelemetry API의 구현체로, 실제 데이터를 수집하고 처리하는 기능을 제공; 데이터 수집
implementation 'io.opentelemetry:opentelemetry-sdk:${version}'
// Traces, Metrics, Logs 데이터를 OTLP(HTTP/gRPC) 프로토콜을 통해 Collector로 전송; 외부로 전송
implementation 'io.opentelemetry:opentelemetry-exporter-otlp:${version}'
2. tracer 빈 등록 - 콜렉터 정보 등록 3. 로그백 설정
logback.xml파일에서 MDC(Mapped Diagnostic Context)를 사용하여 트래스 아이디와 스팬 아이디를 자동으로 포함시킬 수 있음
받은 요청에 트래스(Trace) 아이디가 있으면, 이미 존재하는 트래스 아이디를 그대로 사용하고, 새로운 스팬(Span)을 생성한다. 만약 요청에 트래스 아이디가 없다면, 새로운 트래스 아이디를 생성하고 이를 기반으로 스팬을 생성한다.
SimpleDateFormat은 내부적으로 Calendar 인스턴스를 공유하는데, 이 과정에서 공유 자원 변경이 발생하여 멀티스레드 환경에서 예상치 못한 결과를 초래할 수 있다. SimpleDateFormat은 멀티스레드 환경에서 사용할 경우 각 스레드마다 별도 인스턴스를 생성하거나 ThreadLocal을 이용해야 한다.
Java 8부터는 DateTimeFormatter가 제공되며, 이는 불변(immutable) 객체이므로 여러 스레드에서 동시에 안전하게 사용할 수 있다. 따라서 매번 새로 생성할 필요 없이, 재사용하는 것이 성능적으로도 더 유리하다.
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
public class DateTimeFormatterExample {
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public static void main(String[] args) {
Runnable task = () -> {
String formattedDate = LocalDateTime.now().format(FORMATTER);
System.out.println(Thread.currentThread().getName() + " : " + formattedDate);
};
for (int i = 0; i < 5; i++) {
new Thread(task).start();
}
}
}
index: 인덱스 전용 스캔(Index scan)이 사용된 경우 (테이블 데이터 대신 인덱스만 읽음)
ALL: 전체 테이블 스캔(Full Table scan)이 발생한 경우 (최악의 경우)
ref
참조 조건으로 특정 값을 기준으로 검색
주로 복합 인덱스의 일부를 활용하거나 WHERE 조건의 =, 또는 연산자 IN을 활용
인덱스를 활용하여 특정 값에 대한 행을 검색할 때 주로 출력됩니다.
index
인덱스 전체를 순차적으로 스캔하는 Index Full Scan을 뜻합니다.
Table Full Scan은 테이블 전체를 읽는 것을 뜻하지만, Index Full Scan은 설정된 인덱스 전체를 스캔하는 것을 뜻합니다.
즉, 복합 인덱스의에서는 복합 인덱스에 선언된 모든 컬럼을 순서대로 풀 스캔을 하는 것을 뜻합니다.
extra
(제일 빠름) Using index -> Using where; Using index -> Using index condition -> Null (제일 느림)
Using Index: covering index 사용하여 테이블 데이터를 읽지 않고 인덱스만으로 쿼리 결과를 만족하는 경우
Using where: 인덱스를 사용하여 데이터 접근은 하지만, WHERE 절의 조건 중 일부가 인덱스에 포함되지 않아서 서버에서 추가로 조건 검사를 수행해야 하는 경우. 즉, 인덱스 스캔 후에 반환된 결과에 대해 WHERE 조건을 한 번 더 필터링
Using index condition: 인덱스 컨디션 푸쉬다운 (ICP) 과 관련 있음. 인덱스 스캔 시 인덱스에 포함된 컬럼에 대해 일부 조건을 미리 평가하는데 인덱스만으로 조건을 완전히 만족하지 못하는 경우(예: 인덱스에 포함되지 않은 컬럼의 조건이 있을 때) 실제 테이블 데이터에 접근하여 조건을 최종 확인하게 됨
NULL: 인덱스는 사용하지만, 추가적인 extra 메시지가 표시되지 않는 경우로, 일반적으로 인덱스 스캔 후에도 테이블 데이터에 접근하는 경우, 인덱스 스캔 후 추가로 테이블 조회가 발생하기 때문에 디스크 I/O가 늘어나고, 전체 쿼리 실행 속도가 저하됨
Using temporary: 쿼리 실행 중 임시 테이블이 생성되었음 (ORDER BY나 GROUP BY 등에서 사용)
Using filesort: 인덱스가 아니라 별도의 정렬 알고리즘을 사용하여 정렬했음
Using join buffer: 조인 시 버퍼를 사용한 조인 전략이 사용됨
Using where; Using index 순서?
직관적으로는 인덱스를 먼저 타고 그 후에 WHERE 조건을 적용하므로 "Using index; Using where" 순서가 자연스러워 보이지만, 실제 EXPLAIN의 extra 컬럼에 나타나는 메시지 순서는 반드시 실행 순서를 그대로 반영하지 않는다. 즉, 출력되는 순서는 내부 구현이나 옵티마이저가 수집한 여러 플래그를 특정 순서로 나열한 결과일 뿐, 실제 동작은 인덱스를 사용한 후 WHERE 조건으로 최종 결과를 필터링하는 방식이다. 표시되는 순서는 내부 구현에 따른 것이며, 성능이나 처리 순서를 해석하는 데 큰 영향을 주지 않는다.
인덱스 조건 푸시다운 (ICP)
MySQL 5.6이상 버전부터 도입됐으며, 복합 인덱스의 일부 조건이 충족되면 나머지 조건을 인덱스 스캔 중에 필터링 할 수 있다.
즉, WHERE 조건의 일부 또는 전부를 인덱스에서 먼저 처리한 다음, 필요할 경우 데이터 테이블에 접근한다.
이를 통해 불필요한 디스크 I/O를 줄일 수 있다.
참고
인덱스를 사용하더라도 인덱스의 카디널리티가 낮은 경우, 옵티마이저는 인덱스를 타지 않고 Full Table Scan을 선택할 수 있음
MySQL 옵티마이저는 쿼리의 WHERE 조건을 분석하여 복합 인덱스의 순서에 맞게 조건을 재배열 할 수 있음
복합 인덱스의 첫 번째 컬럼을 타지 않는 상황이더라도, 옵티마이저는 커버링 인덱스를 통해 테이블 풀 스캔이 아닌 인덱스 풀 스캔을 채택해 테이블까지는 접근하지 않고 인덱스의 필터링을 통해서 데이터를 가져올 수 있음