본문 바로가기

프로젝트/APM Web Server 개발

(in 연산 → join) 및 커스텀 NativeQuery (24.06.14)

목차

    현재

    현재 사용하고 있는 IN 연산 최적화에 대한 접근 방식은 다음과 같다

    1. 시간 단위로 파티셔닝을 통해 필요한 데이터의 범위를 줄인 후,

    (where created_at between (a, b) .. create_at을 기준으로 파티셔닝)

    1. IN 연산을 사용하여 특정 조건의 값들을 필터링하고 있음.

    이 과정에서 IN SET의 경우 SET<Long>을 사용하며, 최대 배치 사이즈 = 5000으로 실시한 뒤 스플릿하여 IO를 여러 번 보내고 있음.

    //조회 로직 중 일부
    
     public List<SpanDto> getSpansUseTraceIdSet(List<UUID> inputTraceIdSet, Long minTime, Long maxTime ) {
            try {
                Timestamp createdAtMin = new Timestamp(minTime / 1_000_000);
                Timestamp createdAtMax = new Timestamp(maxTime / 1_000_000);
                List<SpanDto> result = new ArrayList<>();
                List<Set<UUID>> traceIdSets = sliceSet(inputTraceIdSet);
                
                for (Set<UUID> traceIdSet : traceIdSets) {
                    result.addAll(
                            jpaQueryFactory.select(Projections.constructor(SpanDto.class,
                                            qSpan.id,
                                            qSpan.scopeName,
                                            qSpan.traceId,
                                            qSpan.spanId,
                                            qSpan.parentSpanId,
                                            qSpan.flag,
                                            qSpan.name,
                                            qSpan.kind,
                                            qSpan.startTimeUnixNano,
                                            qSpan.endTimeUnixNano,
                                            qSpan.statusIsError,
                                            qSpan.attributes,
                                            qSpan.hasEvent
                                    ))
                                    .from(qSpan)
                                    .where(qSpan.createdAt.between(createdAtMin, createdAtMax) //파티션 분할 먼저 조회
                                    .and(qSpan.traceId.in(traceIdSet)))
                                    .fetch()
                    );
                }
    
                return result;
            } catch (Exception e) {
                ErrorLogger.log(this.getClass().getName(), "getSpansUseTraceIdSet");
                throw e;
            }
            
            
                //Set을 Batch 크기에 맞는 List로 잘라 할당하기
        private static <T> List<Set<T>> sliceSet(List<T> originalSet) {
            List<Set<T>> slicedSets = new ArrayList<>();
            Iterator<T> iterator = originalSet.iterator();
    
            while (iterator.hasNext()) {
                Set<T> batch = new HashSet<>();
                for (int i = 0; i < BATCH_SIZE && iterator.hasNext(); i++) {
                    batch.add(iterator.next());
                }
                slicedSets.add(batch);
            }
            return slicedSets;
        }
    
    

    (테스트 중 가장 나은 퍼포먼스를 낸 기록. 일반적으로 1000ms 이상 지연이 발생한다)

    기본적으로 컬럼의 개수가 상당히 많고, 단순 조회가 아닌 내부적으로 복잡한 매핑 연산을 사용하기에, 성능이 많이 떨어지는 상황이다.

    대략적으로 1~3만개의 쿼리를 3개의 테이블에서 가져와서 스트림으로 매핑하여 제공하는데 1-2초 내외의 시간이 걸린다. 현실적으로 서비스로 제공하기 어려운 시간이다.

    현재 사용 중인 IN 연산은 다음과 같은 방식으로 동작한다

    1. traceIdSet에 있는 값을 기반으로 traceId가 일치하는 행을 필터링한다.
    2. 큰 traceIdSet을 여러 개의 작은 배치로 나누어 각각 쿼리를 수행한다.

    이 방식은 간단하고 직관적이지만, traceIdSet의 크기가 클 경우 성능에 영향을 미칠 수 있다.

    이유는 각 쿼리마다 IN 연산을 수행하기 위해 많은 값을 비교해야 하기 때문이다.

    특히 현재 BATCH_SIZE 를 5000개~ 정도로 잡고 있는데, 내부 파라미터가 늘어날 수록 성능이 유의미하게 떨어지는 것을 확인할 수 있었다.

    이를 극복하기 위해 다른 방식의 쿼리를 사용해보았다.

    1. IN 대신 JOIN 사용

    IN 연산은 많은 값을 처리할 때 성능이 떨어질 수 있다. 조인을 사용하여 대규모 데이터 집합을 처리할 때 임시 테이블을 활용하는 것은 매우 유용할 수 있다. 임시 테이블을 사용하면 많은 수의 ID를 효율적으로 처리할 수 있으며, 데이터베이스의 조인 최적화 기능을 활용할 수 있다.

    다음과 같은 방식으로 변경할 수 있다.

    1. inputTraceIdSet을 임시 테이블로 만든다.
    2. JOIN을 사용하여 traceId가 일치하는 행을 필터링한다.

    임시 테이블용 QClass

    import com.querydsl.core.types.PathMetadata;
    import com.querydsl.core.types.PathMetadataFactory;
    import com.querydsl.core.types.dsl.EntityPathBase;
    import com.querydsl.core.types.dsl.PathBuilder;
    import com.querydsl.core.types.dsl.SimplePath;
    
    import java.util.UUID;
    
    /**
     * Trace ID 임시 테이블 선언용
     * 임시 테이블을 다루기 위해 임시적으로 Q 클래스를 수동으로 정의
     */
    public class QTempTraceIds extends EntityPathBase<UUID> {
    
        private static final long serialVersionUID = 1L;
    
        public static final QTempTraceIds tempTraceIds = new QTempTraceIds("temp_trace_ids");
    
        public final SimplePath<UUID> traceId = createSimple("trace_id", UUID.class);
    
        public QTempTraceIds(String variable) {
            super(UUID.class, PathMetadataFactory.forVariable(variable));
        }
    
        public QTempTraceIds(PathMetadata metadata) {
            super(UUID.class, metadata);
        }
    
        public QTempTraceIds(PathBuilder<?> entity) {
            super(UUID.class, entity.getMetadata());
        }
    }
    
    

    네이티브 쿼리 적용하기

       @Override
        public List<SpanDto> getSpansUseTraceIdSet(List<UUID> inputTraceIdSet, Long minTime, Long maxTime ) {
            try {
                Timestamp createdAtMin = new Timestamp(minTime / 1_000_000);
                Timestamp createdAtMax = new Timestamp(maxTime / 1_000_000);
                List<SpanDto> result = new ArrayList<>();
    
                //List<UUID>를 가지는 임시 테이블 생성
                entityManager.createNativeQuery("CREATE TEMPORARY TABLE temp_trace_ids (trace_id UUID)").executeUpdate();
                traceUtil.batchInsertTraceIds(inputTraceIdSet); // 임시 테이블에 데이터 배치 삽입
    
                //임시 테이블 Set을 기반으로 JOIN 실행
                result.addAll(
                        jpaQueryFactory.select(Projections.constructor(SpanDto.class,
                                        qSpan.id,
                                        qSpan.scopeName,
                                        qSpan.traceId,
                                        qSpan.spanId,
                                        qSpan.parentSpanId,
                                        qSpan.flag,
                                        qSpan.name,
                                        qSpan.kind,
                                        qSpan.startTimeUnixNano,
                                        qSpan.endTimeUnixNano,
                                        qSpan.statusIsError,
                                        qSpan.attributes,
                                        qSpan.hasEvent
                                ))
                                .from(qSpan)
                                .where(qSpan.createdAt.between(createdAtMin, createdAtMax)) //원본 데이터 파티셔닝 기반으로 가져오기
                                .join(tempTraceIds) //파티셔닝된 값 x 임시 테이블 조인 (모수가 훨씬 적음)
                                .on(qTrace.spanTraceId.eq(tempTraceIds.traceId))
                                .fetch()
                );
    
                return result;
            } catch (Exception e) {
                ErrorLogger.log(this.getClass().getName(), "getSpansUseTraceIdSet");
                throw e;
            } finally {
                //모든 작업이 끝난 후 임시 테이블을 삭제하여 메모리에서 해제
                entityManager.createNativeQuery("DROP TABLE temp_trace_ids").executeUpdate();
            }
    

    그런데, 위의 쿼리는 로직상 적용되지 않았다.

    QueryDSL 및 JPA에서 지원하지 않기 때문이다.. 따라서 네이티브 쿼리로 변경해야 했다.

    https://shanepark.tistory.com/393 (만일 객체가 간단하다면 이 방법을 사용하자.)

    이 와중에 JDBC 방언 문제(1111)도 발생했다.

    이를 매핑하기 위해 Entity 자체를 변경해주어야 한다.

    @Entity
    @Getter
    @Table(name = "tbl_apm_trace_span")
    @TypeDef(name = "jsonb", typeClass = JsonbType.class)
    @TypeDef(name = "bytearray", typeClass = ByteArrayType.class)
    @TypeDef(name = "uuid", typeClass = UUIDType.class)
    @SqlResultSetMapping( //JPA에서 네이티브 쿼리의 결과를 엔티티 또는 DTO로 매핑하기 위해 사용하는 어노테이션.
            name = "SpanMapping", //매핑 이름 정의
            entities = @EntityResult(
                    entityClass = Span.class,
                    fields = {
                            @FieldResult(name = "id", column = "id"),
                            @FieldResult(name = "scopeName", column = "scope_name"),
                            @FieldResult(name = "traceId", column = "trace_id"),
                            @FieldResult(name = "spanId", column = "span_id"),
                            @FieldResult(name = "parentSpanId", column = "parent_span_id"),
                            @FieldResult(name = "flag", column = "flag"),
                            @FieldResult(name = "name", column = "name"),
                            @FieldResult(name = "kind", column = "kind"),
                            @FieldResult(name = "startTimeUnixNano", column = "start_time_unix_nano"),
                            @FieldResult(name = "endTimeUnixNano", column = "end_time_unix_nano"),
                            @FieldResult(name = "statusIsError", column = "status_is_error"),
                            @FieldResult(name = "attributes", column = "attributes"),
                            @FieldResult(name = "hasEvent", column = "has_event"),
                            @FieldResult(name = "createdAt", column = "created_at")
                    }
            )
    )
    public class Span {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private long id;
    
        private String scopeName;
    
    //기본 JPA에서 제공하지 않는 타입은 TypeDef를 통해 어떻게 변환할 지 알려주어야 한다.
    //여기서 다룰 내용은 아닌 것 같아 생략한다. (UserType Override 사용하면 된다.)
        @Type(type = "uuid") 
        private UUID traceId;
    
        @Type(type = "bytearray")
        private byte[] spanId;
    
        @Type(type = "bytearray")
        private byte[] parentSpanId;
    
        private int flag;
    
        private String name;
    
        private int kind;
    
        private long startTimeUnixNano;
    
        private long endTimeUnixNano;
    
        private boolean statusIsError;
    
        @Type(type = "jsonb")
        private JsonNode attributes;
    
        private boolean hasEvent;
    
        private Timestamp createdAt;
    }
    
    
    //네이티브 쿼리
        @Override
        public List<SpanDto> getSpansUseTraceIdSet(List<UUID> inputTraceIdSet, Long minTime, Long maxTime) {
            try {
                Timestamp createdAtMin = new Timestamp(minTime / 1_000_000);
                Timestamp createdAtMax = new Timestamp(maxTime / 1_000_000);
                List<SpanDto> result = new ArrayList<>();
    
                //List<UUID>를 가지는 임시 테이블 생성
                entityManager.createNativeQuery("CREATE TEMPORARY TABLE temp_trace_ids (trace_id UUID)").executeUpdate();
                traceUtil.batchInsertTraceIds(inputTraceIdSet); // 임시 테이블에 데이터 배치 삽입
    
                // 2. 네이티브 쿼리로 조인 실행
                // JPA 설계 원칙 상 임시 테이블을 사용한 조인을 JPA나 QueryDSL에서 직접적으로 지원하지 않는다.. (객체지향 접근성 위반)
                String nativeQuery = "SELECT span.id, span.scope_name, span.trace_id, span.span_id, span.parent_span_id, span.flag, span.name, span.kind, span.start_time_unix_nano, span.end_time_unix_nano, span.status_is_error, span.attributes, span.has_event " +
                        "FROM span " +
                        "JOIN temp_trace_ids ON span.trace_id = temp_trace_ids.trace_ide_id " +
                        "WHERE span.created_at BETWEEN :createdAtMin AND :createdAtMax";
    
                List<Span> spans = entityManager.createNativeQuery(nativeQuery)
                        .setParameter("createdAtMin", createdAtMin)
                        .setParameter("createdAtMax", createdAtMax)
                        .getResultList();
    
                // Span 엔티티 리스트를 SpanDto 리스트로 변환
                return spans.stream()
                        .map(SpanDto::new) // Span 엔티티를 SpanDto로 변환
                        .collect(Collectors.toList());
            } catch (Exception e) {
                ErrorLogger.log(this.getClass().getName(), "getSpansUseTraceIdSet");
                throw e;
            } finally {
                //모든 작업이 끝난 후 임시 테이블을 삭제하여 메모리에서 해제
                entityManager.createNativeQuery("DROP TABLE temp_trace_ids").executeUpdate();
            }
    
    
    /**
     * Trace 레이어 비즈니스 로직 연산 시 공통적으로 사용하는 유틸 클래스
     */
    @Component
    @RequiredArgsConstructor
    public class TraceUtil {
    
        @PersistenceContext
        private final EntityManager entityManager;
    
        /**
         *     Set을 Batch 크기에 맞는 List로 잘라 할당하기
         *     In 연산에서 사용
         */
        public static <T> List<Set<T>> sliceSet(List<T> originalSet) {
            List<Set<T>> slicedSets = new ArrayList<>();
            Iterator<T> iterator = originalSet.iterator();
    
            while (iterator.hasNext()) {
                Set<T> batch = new HashSet<>();
                for (int i = 0; i < BATCH_SIZE && iterator.hasNext(); i++) {
                    batch.add(iterator.next());
                }
                slicedSets.add(batch);
            }
            return slicedSets;
        }
    
        /**
         * Batch Size만큼 traceIds를 임시 테이블에 저장
         */
        public void batchInsertTraceIds(List<UUID> traceIds) {
            try {
                int batchSize = BATCH_SIZE;
                int totalSize = traceIds.size();
                for (int i = 0; i < totalSize; i += batchSize) {
                    int endIndex = Math.min(i + batchSize, totalSize);
                    List<UUID> batchList = traceIds.subList(i, endIndex);
                    insertBatch(batchList);
                }
            } catch (Exception e) {
                ErrorLogger.log(this.getClass().getName(), "batchInsertTraceIds");
                throw e;
            }
        }
    
        private void insertBatch(List<UUID> batchList) {
            StringBuilder sql = new StringBuilder("INSERT INTO temp_trace_ids (trace_id) VALUES ");
            for (int i = 0; i < batchList.size(); i++) {
                sql.append("(:traceId").append(i).append("),");
            }
            sql.deleteCharAt(sql.length() - 1); // 마지막 쉼표 제거
    
            Query query = entityManager.createNativeQuery(sql.toString());
            for (int i = 0; i < batchList.size(); i++) {
                query.setParameter("traceId" + i, batchList.get(i));
            }
            query.executeUpdate();
        }
    
    }
    
    

    발생한 중간 이슈 : 이론과 정 반대의 결과.. 더 느려졌다.

    놀랍게도, 동일한 쿼리 기준 5초 이상 (기존은 1초 내외..)이 걸리는 절망적인 사태가 발생했다.

    이것의 이유는 임시 테이블을 만드는 과정에서 Insert가 효율적이지 않아 이 과정에서 오버헤드가 상당히 많이 발생했기 때문이다.

    이를 최적화하기 위해 위 테이블의 batchInsertTraceIds를 기본 JDBC 구현체를 사용하여 Batch 처리해보자.

        /**
         * Batch Size만큼 traceIds를 임시 테이블에 저장, 기본 JDBC Batch 사용
         */
        public void batchInsertTraceIds(List<UUID> traceIds) throws SQLException {
            String sql = "INSERT INTO temp_trace_ids (trace_id) VALUES (?)";
            try (Connection connection = dataSource.getConnection();
                 PreparedStatement ps = connection.prepareStatement(sql)) {
                connection.setAutoCommit(false);
    
                int batchSize = BATCH_SIZE;
                int count = 0;
    
                for (UUID traceId : traceIds) {
                    ps.setObject(1, traceId);
                    ps.addBatch();
    
                    if (++count % batchSize == 0) {
                        ps.executeBatch();
                    }
                }
                ps.executeBatch(); // 남은 배치 실행
                connection.commit();
            } catch (Exception e) {
                ErrorLogger.log(this.getClass().getName(), "batchInsertTraceIds");
                throw e;
            }
        }
    
    

    기존 방법은 entityManager를 통해 네이티브 쿼리를 생성하고 실행했다.

    이는 각 파라미터 설정 시 entityManager를 통해 처리되므로 추가적인 오버헤드가 많이 발생했다.

    지금의 방법은 기본적인 JDBC Batch를 사용하여 성능을 최대한 끌어올린 것이다.

    최종 결과

    //1. 파티션 테이블 IN 사용 로직 : 10000개를 넘는 순간 많은 시간 Loss가 발생한다.
    {
      "SIZE": 1013,
      "TIME USED(ms)": 31
    }
    
    {
      "SIZE": 10068,
      "TIME USED(ms)": 431
    }
    
    {
      "SIZE": 101423,
      "TIME USED(ms)": 4279
    }
    
    {
      "SIZE": 352489,
      "TIME USED(ms)": 43912 //기간 최대 조회 : 43초
    }
    
    //2. 임시 테이블 x 파티션 테이블 JOIN 사용 로직
    {
      "SIZE": 1009,
      "TIME USED(ms)": 19
    }
    
    {
      "SIZE": 10089,
      "TIME USED(ms)": 48
    }
    
    {
      "SIZE": 101410, //기술적 목표치 : 300ms 내에서 안정적 제공 가능
      "TIME USED(ms)": 309 
    }
    
    {
      "SIZE": 353540,
      "TIME USED(ms)": 1128
    }
    
    //번외 : 다른 로직(Trace) 역시 임시 테이블을 수행하여 최대로 최적화했을 경우
    

    동일 비즈니스 로직에서 ‘Span’의 조회 부분만 변경했을 시 걸리는 시간.

    temp 테이블 생성 오버헤드 + (임시 테이블 생성 x 파티셔닝된 데이터 모수 Join)의 결과가 기존 (파티셔닝 테이블 조회 In (조건)) 보다 대략 8배 이상 효율적인 것이다.

    결론

    성능 차이와 개념적 이해

    1. IN 연산
      • 많은 값을 비교할 때 성능이 저하될 수 있다.
      • 데이터베이스가 각 값을 순차적으로 비교하여 필터링하기 때문에 시간이 많이 걸릴 수 있다.
    2. JOIN 연산
      • 임시 테이블을 사용하여 대량의 값을 저장하고, JOIN을 통해 결합한다.
      • 데이터베이스는 JOIN을 최적화할 수 있는 다양한 전략을 가지고 있어, 일반적으로 대량의 데이터를 처리할 때 더 효율적이다.
      • 데이터베이스의 인덱스를 잘 활용할 수 있다.
    3. EXISTS 연산
      • 서브쿼리를 통해 조건에 맞는 행이 존재하는지 확인한다.
      • EXISTS는 서브쿼리가 참일 경우 즉시 반환하기 때문에 많은 경우에 효율적이다.
      • 하지만 서브쿼리를 반복적으로 수행하므로, 경우에 따라 성능이 저하될 수 있다.

    0617

    /**
     * Trace 레이어 비즈니스 로직 연산 시 공통적으로 사용하는 유틸 클래스
     */
    @Component
    @RequiredArgsConstructor
    public class TraceUtil {
    
        private final ObjectMapper objectMapper = new ObjectMapper();
        private final DataSource dataSource;
    
        /**
         * Set을 Batch 크기에 맞는 List로 잘라 할당하기
         * In 연산에서 사용
         */
        public <T> List<Set<T>> sliceSet(List<T> originalSet) {
            List<Set<T>> slicedSets = new ArrayList<>();
            Iterator<T> iterator = originalSet.iterator();
    
            while (iterator.hasNext()) {
                Set<T> batch = new HashSet<>();
                for (int i = 0; i < BATCH_SIZE && iterator.hasNext(); i++) {
                    batch.add(iterator.next());
                }
                slicedSets.add(batch);
            }
            return slicedSets;
        }
    
        public <T> List<Set<T>> sliceSet(Set<T> originalSet) {
            List<Set<T>> slicedSets = new ArrayList<>();
            Iterator<T> iterator = originalSet.iterator();
    
            while (iterator.hasNext()) {
                Set<T> batch = new HashSet<>();
                for (int i = 0; i < BATCH_SIZE && iterator.hasNext(); i++) {
                    batch.add(iterator.next());
                }
                slicedSets.add(batch);
            }
    
            return slicedSets;
        }
    
        /**
         * 임시 테이블 사용하여 새롭게 생성 후 데이터 삽입
         * Connection 및 기본 JDBC 구현체 사용 (EM의 경우 성능 이슈 발생)
         * @param traceIds
         * @return
         * @throws SQLException
         */
    
        public <T> List<T> batchInsertTraceIds(List<UUID> traceIds, Timestamp createdAtMin, Timestamp createdAtMax, int type) throws SQLException, IOException {
            long startTime = System.currentTimeMillis();//log
            final String createTableSQL = "CREATE TEMPORARY TABLE IF NOT EXISTS temp_trace_ids (trace_id UUID)";
            final String insertSQL = "INSERT INTO temp_trace_ids (trace_id) VALUES (?)"; //임시 테이블
            final String createIndexSQL = "CREATE INDEX idx_trace_id ON temp_trace_ids(trace_id)"; //임시 테이블 인덱스
            List<T> result = new ArrayList<>(); //결과값
            try (Connection connection = dataSource.getConnection();
                 PreparedStatement createTableStmt = connection.prepareStatement(createTableSQL);
                 PreparedStatement createIndexStmt = connection.prepareStatement(createIndexSQL);
                 PreparedStatement insertStmt = connection.prepareStatement(insertSQL);
                 PreparedStatement queryStmt = connection.prepareStatement(joinTempTableQuery[type])) {
    
                connection.setAutoCommit(false);
    
                long createTableStart = System.currentTimeMillis(); //log
                createTableStmt.executeUpdate(); // 임시 테이블 생성
                long createTableEnd = System.currentTimeMillis(); //log
                System.out.println("임시 테이블 생성 시간: " + (createTableEnd - createTableStart) + "ms");
    
                //인덱스
                long createIndexStart = System.currentTimeMillis(); //log
                createIndexStmt.executeUpdate(); // 인덱스 생성
                long createIndexEnd = System.currentTimeMillis(); //log
                System.out.println("인덱스 생성 시간: " + (createIndexEnd - createIndexStart) + "ms");
    
                // 데이터 삽입 후 Commit 시행
                long insertStart = System.currentTimeMillis(); //log
                for (UUID traceId : traceIds) {
                    insertStmt.setObject(1, traceId);
                    insertStmt.addBatch();
                }
                insertStmt.executeBatch();
    
                long insertEnd = System.currentTimeMillis(); //log
                System.out.println("데이터 삽입 시간: " + (insertEnd - insertStart) + "ms");
    
                // 네이티브 쿼리로 조인 실행
                long queryStart = System.currentTimeMillis();//log
                queryStmt.setTimestamp(1, createdAtMin);
                queryStmt.setTimestamp(2, createdAtMax);
                try (ResultSet rs = queryStmt.executeQuery()) {
                    System.out.println("데이터 조회 시간 : " + (System.currentTimeMillis() - queryStart));
                    while (rs.next()) {
                        switch (type) {
                            case 0 : {
                                result.add((T) spanDtoMapper(rs));
                                break;
                            }
                            case 1 : {
                                result.add((T) traceDtoMapper(rs));
                                break;
                            }
                            default: {
                                ErrorLogger.log(this.getClass().getName(), "Native Query 시행 시 Type Input Param 유효하지 않음");
                            }
                        }
                    }
                }
                long queryEnd = System.currentTimeMillis(); //log
                System.out.println("데이터 조회 및 매핑 시간: " + (queryEnd - queryStart) + "ms");
            } catch (Exception e) {
                ErrorLogger.log(this.getClass().getName(), "batchInsertTraceIds");
                throw e;
            }
            long endTime = System.currentTimeMillis();
            System.out.println("총 처리 시간: " + (endTime - startTime) + "ms");
    
            return result;
    
        }
    
        /**
         * 모듈화 위한 네이티브 쿼리 모음
         */
        private final String[] joinTempTableQuery = {
                // 0 : SpanDto
                "SELECT s.id, s.scope_name, cast(s.trace_id as varchar), s.span_id, s.parent_span_id, s.flag, s.name, " +
                        "s.kind, s.start_time_unix_nano, s.end_time_unix_nano, s.status_is_error, s.attributes, s.has_event " +
                        "FROM tbl_apm_trace_span s " +
                        "JOIN temp_trace_ids t ON s.trace_id = t.trace_id " +
                        "WHERE s.created_at BETWEEN ? AND ?",
    
                // 1 : TraceDto
                "SELECT tr.id, tr.agent_id, tr.span_trace_id, tr.span_start_time_unix_nano, tr.span_end_time_unix_nano," +
                        "tr.is_err, tr.resource_id, tr.created_at " +
                        "FROM tbl_apm_trace tr " +
                        "JOIN temp_trace_ids te ON tr.span_trace_id = te.trace_id " +
                        "WHERE tr.created_at BETWEEN :createdAtMin AND :createdAtMax"
    
                // for more ..
        };
    
        /**
         * ResultSet SpanDto Mapper
         */
        private SpanDto spanDtoMapper(ResultSet rs) throws SQLException, IOException {
            return new SpanDto(
                    rs.getLong("id"),
                    rs.getString("scope_name"),
                    UUID.fromString(rs.getString("trace_id")),
                    rs.getBytes("span_id"),
                    rs.getBytes("parent_span_id"),
                    rs.getInt("flag"),
                    rs.getString("name"),
                    rs.getInt("kind"),
                    rs.getLong("start_time_unix_nano"),
                    rs.getLong("end_time_unix_nano"),
                    rs.getBoolean("status_is_error"),
                    objectMapper.readTree(rs.getString("attributes")),
                    rs.getBoolean("has_event")
                    // rs.getTimestamp("created_at")
            );
        }
    
        /**
         * ResultMap TraceDto Mapper
         */
        private TraceDto traceDtoMapper(ResultSet rs) throws SQLException {
            return new TraceDto(
                    rs.getLong("id"),
                    UUID.fromString(rs.getString("span_trace_id")),
                    rs.getLong("span_start_time_unix_nano"),
                    rs.getLong("span_end_time_unix_nano"),
                    rs.getBoolean("status_is_error"),
                    rs.getLong("agent_id"),
                    rs.getLong("resource_id")
    
            );
        }
    
    }
    
    

    1440분 = 하루 조회의 경우, 전체 출력 시 대략 100~400MB 용량의 json 크기이다.

    : 현 상황에서 DB CP가 부족하기에, 매 IO 요청마다 200~300ms 정도 로스가 발생하는 상황이다.

    분석 결과

    타임스탬프: 1718596751478900325, 1718600345714547837
    2024-06-17 12:52:54.192
    2024-06-17 13:52:45.935
    2024-06-17 14:45:25.961 DEBUG 1732 --- [nio-8089-exec-5] org.hibernate.SQL: 
        select id, scope_name, trace_id, span_id, parent_span_id, flag, name, kind, start_time_unix_nano, end_time_unix_nano, status_is_error, attributes, has_event 
        from tbl_apm_trace_span 
        where created_at between ?1 and ?2
    2024-06-17 14:45:25.963 TRACE 1732 --- [nio-8089-exec-5] o.h.type.descriptor.sql.BasicBinder: binding parameter [1] as [TIMESTAMP] - [2024-06-17 12:52:54.192]
    2024-06-17 14:45:25.963 TRACE 1732 --- [nio-8089-exec-5] o.h.type.descriptor.sql.BasicBinder: binding parameter [2] as [TIMESTAMP] - [2024-06-17 13:52:45.935]
    걸린 시간: 511ms
    2024-06-17 14:45:26.472 INFO 1732 --- [nio-8089-exec-5] i.StatisticalLoggingSessionEventListener: Session Metrics {
        889000 nanoseconds spent acquiring 1 JDBC connections;
        0 nanoseconds spent releasing 0 JDBC connections;
        945800 nanoseconds spent preparing 1 JDBC statements;
        244547200 nanoseconds spent executing 1 JDBC statements;
        0 nanoseconds spent executing 0 JDBC batches;
        0 nanoseconds spent performing 0 L2C puts;
        0 nanoseconds spent performing 0 L2C hits;
        0 nanoseconds spent performing 0 L2C misses;
        0 nanoseconds spent executing 0 flushes (flushing a total of 0 entities and 0 collections);
        0 nanoseconds spent executing 0 partial-flushes (flushing a total of 0 entities and 0 collections)
    }
    
    

    Spring 애플리케이션

    • 총 시간: 511ms
    • JDBC 커넥션 획득 시간: 0.89ms
    • JDBC 스테이트먼트 준비 시간: 0.94ms
    • JDBC 스테이트먼트 실행 시간: 244.55ms
    • 기타 작업 시간: 264.62ms

    EXPLAIN ANALYZE

    • 총 실행 시간: 27.770ms
      • Index Scan 및 Seq Scan 시간: 26.869ms
      • 계획 수립 시간: 0.238ms

    네트워크, DB IO 등 바꿀 수 없는 부분

    네트워크 지연과 데이터베이스 I/O는 애플리케이션 레벨에서 변경하기 어렵다.

    네트워크 지연 (Network Latency)

    • 설명: Spring 애플리케이션이 데이터베이스와 통신하는 동안 발생하는 네트워크 왕복 시간.
    • 로그: JDBC 커넥션 획득 및 준비에 소요된 시간 (약 1.83ms, 즉 0.89ms + 0.94ms).

    데이터베이스 I/O (DB I/O)

    • 설명: 데이터베이스가 쿼리를 실행하고 결과를 반환하는 시간.
    • 로그: JDBC 스테이트먼트 실행 시간 (244.55ms).

    가상 테이블 조회 5초~6초

    where trace.spanStartTimeUnixNano between ?1 and ?2 and trace.agentId = ?3 */ select trace0_.id as col_0_0_, trace0_.span_trace_id as col_1_0_, trace0_.span_start_time_unix_nano as col_2_0_, trace0_.span_end_time_unix_nano as col_3_0_, trace0_.is_err as col_4_0_, trace0_.agent_id as col_5_0_, trace0_.resource_id as col_6_0_ from tbl_apm_trace trace0_ where (trace0_.span_start_time_unix_nano between ? and ?) and trace0_.agent_id=?
    2024-06-17 15:45:04.008 TRACE 13768 --- [nio-8089-exec-8] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [1718520304008000000]
    2024-06-17 15:45:04.008 TRACE 13768 --- [nio-8089-exec-8] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BIGINT] - [1718606704008000000]
    2024-06-17 15:45:04.008 TRACE 13768 --- [nio-8089-exec-8] o.h.type.descriptor.sql.BasicBinder      : binding parameter [3] as [BIGINT] - [217]
    사이즈 : 86336
    타임스탬프 : 1718520305000000000 , 1718606701000000000
    2024-06-16 15:45:05.0
    2024-06-17 15:45:01.0
    시작
    임시 테이블 생성 시간: 14ms
    인덱스 생성 시간: 1ms
    데이터 삽입 시간: 1566ms
    데이터 조회 시간 : 2655
    데이터 조회 및 매핑 시간: 3935ms
    총 처리 시간: 5581ms
    걸린 시간 : 5581
    2024-06-17 15:45:10.194  INFO 13768 --- [nio-8089-exec-8] i.StatisticalLoggingSessionEventListener : Session Metrics {
        987500 nanoseconds spent acquiring 1 JDBC connections;
        0 nanoseconds spent releasing 0 JDBC connections;
        29200 nanoseconds spent preparing 1 JDBC statements;
        111419400 nanoseconds spent executing 1 JDBC statements;
        0 nanoseconds spent executing 0 JDBC batches;
        0 nanoseconds spent performing 0 L2C puts;
        0 nanoseconds spent performing 0 L2C hits;
        0 nanoseconds spent performing 0 L2C misses;
        0 nanoseconds spent executing 0 flushes (flushing a total of 0 entities and 0 collections);
        4400 nanoseconds spent executing 1 partial-flushes (flushing a total of 0 entities and 0 collections)
    }
    
    ---
    
    사이즈 : 86338
    타임스탬프 : 1718520353000000000 , 1718606751001000000
    2024-06-16 15:45:53.0
    2024-06-17 15:45:51.001
    시작
    임시 테이블 생성 시간: 3ms
    인덱스 생성 시간: 0ms
    데이터 삽입 시간: 1546ms
    데이터 조회 시간 : 2981
    데이터 조회 및 매핑 시간: 4567ms
    총 처리 시간: 6185ms
    걸린 시간 : 6185
    2024-06-17 15:45:59.382  INFO 16460 --- [nio-8089-exec-2] i.StatisticalLoggingSessionEventListener : Session Metrics {
        1116200 nanoseconds spent acquiring 1 JDBC connections;
        0 nanoseconds spent releasing 0 JDBC connections;
        1644100 nanoseconds spent preparing 1 JDBC statements;
        150084300 nanoseconds spent executing 1 JDBC statements;
        0 nanoseconds spent executing 0 JDBC batches;
        0 nanoseconds spent performing 0 L2C puts;
        0 nanoseconds spent performing 0 L2C hits;
        0 nanoseconds spent performing 0 L2C misses;
        0 nanoseconds spent executing 0 flushes (flushing a total of 0 entities and 0 collections);
        22500 nanoseconds spent executing 1 partial-flushes (flushing a total of 0 entities and 0 collections)
    }
    

    IN 요청

        @Override
        @Transactional(propagation = Propagation.REQUIRED)
        public List<SpanDto> getSpansUseTraceIdSet(List<UUID> inputTraceIdSet, Long minTime, Long maxTime) {
            List<SpanDto> result = new ArrayList<>();
            Timestamp createdAtMin = new Timestamp(minTime / 1_000_000);
            Timestamp createdAtMax = new Timestamp(maxTime / 1_000_000);
            System.out.println("타임스탬프 : " + minTime + " , " + maxTime);
            System.out.println(createdAtMin);
            System.out.println(createdAtMax);
            System.out.println("시작");
            long now = System.currentTimeMillis();
            try {
    //            traceUtil.batchInsertTraceIds(inputTraceIdSet, createdAtMin, createdAtMax, 0);
                //List<UUID>를 가지는 임시 테이블 생성
    
                List<Set<UUID>> traceIdSet =  traceUtil.sliceSet(inputTraceIdSet);
                for (Set<UUID> traceIds : traceIdSet) {
                    result.addAll(jpaQueryFactory.select(
                            Projections.constructor(SpanDto.class,
                                            qSpan.id,
                                            qSpan.scopeName,
                                            qSpan.traceId,
                                            qSpan.spanId,
                                            qSpan.parentSpanId,
                                            qSpan.flag,
                                            qSpan.name,
                                            qSpan.kind,
                                            qSpan.startTimeUnixNano,
                                            qSpan.endTimeUnixNano,
                                            qSpan.statusIsError,
                                            qSpan.attributes,
                                            qSpan.hasEvent
                            )).from(qSpan)
                            .where(qSpan.createdAt.between(createdAtMin, createdAtMax).and(qSpan.traceId.in(traceIds)))
                            .fetch()
                    );
                }
    
            } catch (Exception e) {
                ErrorLogger.log(this.getClass().getName(), "getSpansUseTraceIdSet", e);
            }
    
            System.out.println("걸린 시간 : " + (System.currentTimeMillis() - now));
            return result;
        }
    

    BATCH SIZE : 2000 정도로 두고 요청을 보내는데, 조회가 10만건++ 정도가 되다 보니, 기본적으로 하나의 요청에 수십 개의 CP를 먹는 것 같다. (Wait으로 인해 사실상 로직이 사용 불가능 수준이다. DB에 이 서버만 연결된 것이 아니다보니.. )

    전체 수를 줄여 144분( 비교 대상의 1/5)로 조회해보자. 사실 로직 상 모수가 작을 수록 매핑 속도가 훨씬 더 빠르다.

    2024-06-17 15:51:03.469 TRACE 7728 --- [nio-8089-exec-2] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [TIMESTAMP] - [2024-06-17 11:03:02.0]
    2024-06-17 15:51:03.469 TRACE 7728 --- [nio-8089-exec-2] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [TIMESTAMP] - [2024-06-17 15:51:01.0]
    걸린 시간 : 2326
    2024-06-17 15:51:03.614  INFO 7728 --- [nio-8089-exec-2] i.StatisticalLoggingSessionEventListener : Session Metrics {
        926000 nanoseconds spent acquiring 1 JDBC connections;
        0 nanoseconds spent releasing 0 JDBC connections;
        6734300 nanoseconds spent preparing 10 JDBC statements;
        1189451400 nanoseconds spent executing 10 JDBC statements;
        0 nanoseconds spent executing 0 JDBC batches;
        0 nanoseconds spent performing 0 L2C puts;
        0 nanoseconds spent performing 0 L2C hits;
        0 nanoseconds spent performing 0 L2C misses;
        0 nanoseconds spent executing 0 flushes (flushing a total of 0 entities and 0 collections);
        56300 nanoseconds spent executing 10 partial-flushes (flushing a total of 0 entities and 0 collections)
    }
    
    

    0827 개선

    코드 최적화 및 임시 테이블 다중 사용

    검색 시 특정 이름 제외 과정을 개발 중이었다. analyze Query 분석 과정에서 생각보다 많은 시간이 걸리는 점을 확인했다.

    이것의 원인은, from 등 서브쿼리 스칼라 절의 테이블 결과값에는 ‘인덱스가 적용되지 않는다는 점’이다.

    즉, (A Table) - Merge / Hash Join - (B Table) 과정에서 인덱스를 전혀 사용할 수 없다는 말이다.

    이는 서브쿼리 결과는 메모리 상 임시 공간에 저장되기 때문으로, 모수를 줄일 수 있다고 해도 상당히 치명적인 문제이다.

    비교

    1. 서브쿼리를 제거하고 직접 조인
    • 직접적인 인덱스 활용: 서브쿼리를 제거하고 직접 조인하면, 원본 테이블에 설정된 인덱스를 그대로 활용
    • 쿼리 단순화 : 단순하고 최적화된 쿼리
    • 단점 - 데이터 중복 처리: 서브쿼리를 제거하면, 원본 테이블에서 불필요한 데이터까지 모두 조인 대상이 됨. 즉, ‘테이블 내 모든 조건’을 조인 대상으로 삼아야 한다. 이것은 테이블 당 100만 ~ 1000만건의 데이터를 순회해야 하는 내 상황에서는 치명적이다.
    1. 임시 테이블 사용 조인
    • 데이터 정제 및 최적화: 서브쿼리 결과를 임시 테이블에 저장하고, 필요한 인덱스를 생성하여 조인 성능을 최적화할 수 있다. 현재 가장 필요한 기능이다.
    • 단점 - 임시 테이블 생성 오버헤드 : 개별 세션 임시 테이블 생성시 발생할 수 있는 메모리 소모를 고려해야 한다. 그 외 코드가 조금 복잡해진다는 점 역시 존재한다.

    결론 : 대량의 데이터 처리와 조건 처리의 관점에서, ‘임시 테이블’을 사용하여 서브쿼리를 대체하는 것이 좋은 방법이라고 판단했다.

    /**
     * TraceId 기준 조회 배치 로직
     * 대규모 조회 고려하여 Native Query 사용
     */
    @Component
    @RequiredArgsConstructor
    public class BatchGetSpanDtoUsingTraceId {
    
        private final DataSource dataSource;
        // Span Join Query
        private final String spanJoinSql =
                "SELECT s.id, s.scope_name, s.trace_id, s.span_id, s.parent_span_id, s.flag, s.name, " +
                        "s.kind, s.start_time_unix_nano, s.end_time_unix_nano, s.status_is_error, s.attributes, s.has_event " +
                        "FROM (SELECT * FROM tbl_apm_trace_span WHERE created_at BETWEEN ? AND ?) as s " +
                        "JOIN temp_trace_ids t ON s.trace_id = t.trace_id ";
    
        //서브쿼리 대체, 임시 테이블 생성 및 삽입 로직
        private final String fullTempTableSetupAndInsertSql =
                "CREATE TEMPORARY TABLE IF NOT EXISTS temp_trace_ids (trace_id UUID); " + //임시 테이블 생성
                        "CREATE INDEX idx_trace_id ON temp_trace_ids(trace_id); " + //인덱스 생성
                        "INSERT INTO temp_trace_ids (trace_id) " + //Insert 로직
                        "SELECT span_trace_id " +
                        "FROM tbl_apm_trace " +
                        "WHERE span_start_time_unix_nano BETWEEN ? AND ? AND agent_id = ?";
    
        private String addNotInName(List<String> excludeNameSet) {
            if (excludeNameSet == null || excludeNameSet.isEmpty()) return "";
            String names = excludeNameSet.stream()
                    .map(name -> "'" + name + "'")
                    .collect(Collectors.joining(", "));
            return " AND name not in (" + names + ")";
        }
    
        public <T> List<T> getSpans(RequestParamResult req, List<String> excludeNameList) throws SQLException, IOException {
            List<T> result = new ArrayList<>(); //결과값
            Connection connection = dataSource.getConnection();
    
            try (
                    PreparedStatement createTempTablePstmt = connection.prepareStatement(fullTempTableSetupAndInsertSql + addNotInName(excludeNameList));
                    PreparedStatement spanJoinPstmt = connection.prepareStatement(spanJoinSql);
            ) {
                connection.setAutoCommit(false);
    
                //temp table insert
                int idx = 1;
                createTempTablePstmt.setLong(idx++, req.getMinTimeUnixNano());
                createTempTablePstmt.setLong(idx++, req.getTimeUnixNano());
                createTempTablePstmt.setLong(idx++, req.getAgentId());
                createTempTablePstmt.executeUpdate();
    
                //span join
                idx = 1;
                spanJoinPstmt.setTimestamp(idx++, req.getCreatedAt()[0]);
                spanJoinPstmt.setTimestamp(idx++, req.getCreatedAt()[1]);
    
                ResultSet rs = spanJoinPstmt.executeQuery();
                while (rs.next()) {
                    result.add((T) spanDtoMapper(rs));
                }
    
                connection.commit(); // Commit transaction
            } catch (Exception e) {
                connection.rollback(); // Rollback transaction on exception
                ErrorLogger.log(this.getClass().getName(), "batchInsertTraceIdGetSpanDtos");
                throw e;
            } finally {
                connection.setAutoCommit(true);
            }
            return result;
        }
    
        /**
         * ResultSet SpanDto Mapper
         */
        private SpanDto spanDtoMapper(ResultSet rs) throws SQLException, IOException {
            return new SpanDto(
                    rs.getLong("id"),
                    rs.getString("scope_name"),
                    rs.getObject("trace_id", UUID.class),
                    rs.getBytes("span_id"),
                    rs.getBytes("parent_span_id"),
                    rs.getInt("flag"),
                    rs.getString("name"),
                    rs.getInt("kind"),
                    rs.getLong("start_time_unix_nano"),
                    rs.getLong("end_time_unix_nano"),
                    rs.getBoolean("status_is_error"),
                    ObjectMapperProvider.get().readTree(rs.getBytes("attributes")),
                    rs.getBoolean("has_event")
                    // rs.getTimestamp("created_at")
            );
        }
    

    HikariCP가 커넥션을 풀에 반환할 때, 해당 커넥션의 세션은 그대로 유지된다. 이는 세션을 가지고 있는 Connection 객체 자체가 세션을 끊지 않고 CP에 반환되기 때문일 것이다.

    즉, 임시 테이블 등 세션 내에 존재하는 모든 데이터도 유지된 상태로 남아 있게 된다.

    이를 해결하기 위해 명시적 DROP 및 임시 테이블 이름을 동적으로 만들어 동시성 처리를 수행할 필요가 있다.