P6Spy Spring Boot 설정

비딴·2024년 1월 25일
0

작성 이유와 목적

기존 sql문 로깅 방식으로는 SQL 문과 인자를 따로 보여주어 SQL 문을 한 번에 보기 힘들다는 단점이 있습니다.
p6spy를 이용하면 SQL 문과 인자를 조합하여 한 번에 보여주어 보기 편리해 보였습니다.
이 글을 통해 p6spy 설정을 익히고 다음번에도 손쉽게 사용할 수 있도록 글을 정리해 보겠습니다.

기존 로깅 환경

기존 로깅 환경에서 SQL 문을 확인한 후 p6spy 설정을 추가하며 변경해 보겠습니다.
아래는 기존 로깅 코드입니다.

// build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.2'
    id 'io.spring.dependency-management' version '1.1.4'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    sourceCompatibility = '17'
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    // jpa
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

    // lombok
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    // h2
    runtimeOnly 'com.h2database:h2'

    // test
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}
# application.yml

spring:
  datasource:
    url: jdbc:h2:tcp://localhost/~/jpashop
    username: sa
    password:
    driver-class-name: org.h2.Driver

  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true

logging.level:
  # SQL 문을 기록합니다.
  org.hibernate.SQL: debug
  # Hibernate6 JDBC 매개변수에 바인딩된 값을 기록합니다.
  org.hibernate.orm.jdbc.bind: trace

로깅 설정 참조 자료

위와 같이 Spring Boot 환경을 구성하면 로깅으로 SQL 문이 기록됩니다.

아래는 테스트에서 사용할 엔티티와 레포지토리 및 테스트 코드입니다.

@Builder
@AllArgsConstructor
@NoArgsConstructor
@Setter
@Getter
@Entity
public class Member {

    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Id
    private Long id;
    private String name;
}
@Transactional(readOnly = true)
@Repository
public class MemberRepository {

    @PersistenceContext
    EntityManager entityManager;

    @Transactional
    public Long save(Member member) {
        entityManager.persist(member);
        return member.getId();
    }

    public Member find(Long id) {
        return entityManager.find(Member.class, id);
    }
}
@Transactional
@SpringBootTest
class MemberRepositoryTest {

    @Autowired
    MemberRepository memberRepository;

    @DisplayName("멤버를 저장하면 아이디 값을 부여합니다.")
    @Test
    void test() {
        // given
        Member member = Member.builder()
                .name("새로운 멤버")
                .build();

        // when
        Long savedId = memberRepository.save(member);

        // then
        assertThat(savedId).isNotNull();
    }
}

MemberRepositoryTest

SQL 문과 바인딩된 값을 잘 출력합니다. 하지만 로그가 2개로 나누어져 보는데 불편함이 있습니다.

P6spy

P6spy를 이용하게 되면 기존 애플리케이션 코드 변경 없이 Connection, Statement, ResultSet를 가로채어 기록해줍니다.
Spring Boot 에 설정하는 방법도 간단합니다. 깃허브 문서에 따라 설정해 보겠습니다.

P6spy 깃허브

dependencies {
    // p6spy
    testImplementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.1'
}

P6spy 의존을 추가해줍니다. application.yml 에 추가 커스텀할 수 있습니다. 하지만 기본 설정으로도 충분합니다.
또한, testImplementation 로 테스트 환경의 의존만 추가해주었습니다.
로그를 찍는 것이 성능 저하를 일으킬 수 있고, SQL 문을 포매팅해준다면 더 큰 성능 저하로 이루어질 수 있기 때문에 테스트 환경에서만 사용할 예정입니다.

여기서 2가지를 수정해야 합니다.

  • 기존 로깅과 p6spy 를 둘 다 기록합니다. 기존 로깅을 제거해야 합니다.
  • p6spy 이 포매팅 되어 있지 않습니다. 추가 설정을 통해 포매팅 해주어야 합니다.

기존 로깅 제거

# application.yml

spring:
  jpa:
    properties:
      hibernate:
        format_sql: true

logging.level:
  org.hibernate.SQL: debug
  org.hibernate.orm.jdbc.bind: trace

application.yml 에서 위에 보이는 로깅 관련 속성들을 제거해줍니다.

p6spy 관련 로그들만 남은 것을 볼 수 있습니다.

P6spy 포매팅

p6spy 동작 방식과 코드
p6spy JdbcEventListener를 만들어야 하는 이유

포매팅하는 코드는 위 2개의 글을 참조하여 만들었습니다. 자세한 동작 방식과 이유들을 알려줍니다.

public class P6spyPrettySqlFormatter implements MessageFormattingStrategy {

    private static final String NEW_LINE = System.lineSeparator();
    private static final String P6SPY_FORMATTER = "P6spyPrettySqlFormatter";
    private static final String PACKAGE = "io.p6spy";
    private static final String CREATE = "create";
    private static final String ALTER = "alter";
    private static final String COMMENT = "comment";

    @Override
    public String formatMessage(int connectionId, String now, long elapsed, String category, String prepared,
            String sql, String url) {
        return sqlFormatToUpper(sql, category, getMessage(connectionId, elapsed, getStackBuilder()));
    }

    private String sqlFormatToUpper(String sql, String category, String message) {
        if (sql.trim().isEmpty()) {
            return "";
        }
        return NEW_LINE
                + sqlFormatToUpper(sql, category)
                + message;
    }

    private String sqlFormatToUpper(String sql, String category) {
        if (isStatementDDL(sql, category)) {
            return FormatStyle.DDL
                    .getFormatter()
                    .format(sql)
                    .toUpperCase(Locale.ROOT)
                    .replace("+0900", "");
        }
        return FormatStyle.BASIC
                .getFormatter()
                .format(sql)
                .toUpperCase(Locale.ROOT)
                .replace("+0900", "");
    }

    private boolean isStatementDDL(String sql, String category) {
        return isStatement(category) && isDDL(sql.trim().toLowerCase(Locale.ROOT));
    }

    private boolean isStatement(String category) {
        return Category.STATEMENT.getName().equals(category);
    }

    private boolean isDDL(String lowerSql) {
        return lowerSql.startsWith(CREATE) || lowerSql.startsWith(ALTER) || lowerSql.startsWith(COMMENT);
    }

    private String getMessage(int connectionId, long elapsed, StringBuilder callStackBuilder) {
        return NEW_LINE
                + NEW_LINE
                + "\t" + String.format("Connection ID: %s", connectionId)
                + NEW_LINE
                + "\t" + String.format("Execution Time: %s ms", elapsed)
                + NEW_LINE
                + NEW_LINE
                + "\t" + String.format("Call Stack (number 1 is entry point): %s", callStackBuilder)
                + NEW_LINE
                + NEW_LINE
                + "----------------------------------------------------------------------------------------------------";
    }

    private StringBuilder getStackBuilder() {
        Stack<String> callStack = new Stack<>();
        stream(new Throwable().getStackTrace())
                .map(StackTraceElement::toString)
                .filter(isExcludeWords())
                .forEach(callStack::push);

        int order = 1;
        StringBuilder callStackBuilder = new StringBuilder();
        while (!callStack.empty()) {
            callStackBuilder.append(MessageFormat.format("{0}\t\t{1}. {2}", NEW_LINE, order++, callStack.pop()));
        }
        return callStackBuilder;
    }

    private Predicate<? super String> isExcludeWords() {
        return charSequence -> charSequence.startsWith(PACKAGE) && !charSequence.contains(P6SPY_FORMATTER);
    }
}
public class P6SpyEventListener extends JdbcEventListener {

    @Override
    public void onAfterGetConnection(ConnectionInformation connectionInformation, SQLException e) {
        P6SpyOptions.getActiveInstance().setLogMessageFormat(P6spyPrettySqlFormatter.class.getName());
    }
}
@Configuration
public class P6SpyConfig {

    @Bean
    public P6SpyEventListener p6SpyCustomEventListener() {
        return new P6SpyEventListener();
    }

    @Bean
    public P6spyPrettySqlFormatter p6SpyCustomFormatter() {
        return new P6spyPrettySqlFormatter();
    }
}

로그가 Data Definition Language 에서 Data Manipulation Language 까지 읽기 쉬운 형식으로 기록되는 것을 볼 수 있습니다.

참조

P6spy 쿼리 로그 출력하기 - raegon

profile
비 온 뒤 딴딴

0개의 댓글