MyBatis 백엔드¶
MyBatis 백엔드 (api-log-mybatis)는 @Mapper로 어노테이션된 인터페이스를
통해 감사 행을 씁니다. 이미 MyBatis를 쓰고 있는데 감사 로그 때문에 JPA /
Hibernate를 끌고 들어오기 싫을 때 이걸 고르세요.
언제 선택하나¶
- 이미 MyBatis 사용 중 (웹 스택은 무관 — Servlet 또는 WebFlux+JDBC).
- 프로젝트에 ORM을 하나만 두고 싶을 때. 감사 로깅 만을 위해 JPA를 추가하면 영속화 프레임워크 2개, 트랜잭션 매니저 2개, 컨벤션 2세트 — 보통은 그만한 가치가 없습니다.
JPA를 쓰고 있으면 api-log-jpa 선택. WebFlux + R2DBC에서
순수 리액티브 영속화를 원하면 api-log-r2dbc가 맞습니다.
api_log 스키마는 세 백엔드 모두 동일합니다.
설치¶
api-log-mybatis는 api-log-core, mybatis-spring-boot-starter:3.0.4
(Spring Boot 3.x 호환 라인), spring-jdbc, PostgreSQL JDBC 드라이버를
transitive하게 가져옵니다. DataSource는 별도로 구성해야 합니다 — Spring
Boot 기본 spring.datasource.*로 충분.
자동으로 등록되는 빈¶
MyBatis (org.apache.ibatis.session.SqlSessionFactory)가 classpath에 있고
api.log.enabled=true이면 ApiLogMybatisAutoConfiguration이 활성화되어
다음을 등록합니다:
| 빈 | 역할 |
|---|---|
MybatisApiLogWriter |
코어 리스너가 이벤트를 라우팅하는 ApiLogWriter 구현체 |
ApiLogMapper (@MapperScan 경유) |
INSERT SQL을 담은 MyBatis @Mapper |
ApiLogMybatisSchemaInitializer |
DataSource에 V1.0__create_api_log.sql 실행 (BUILTIN 모드만) |
@MapperScan(basePackageClasses = ApiLogMapper.class)가 자동으로 적용됩니다.
사용자 애플리케이션에 이미 다른 패키지를 향한 @MapperScan이 있으면 우리 것이
함께 합쳐 동작합니다 — 두 스캔 모두 실행.
매퍼¶
package kr.devslab.apilog.mybatis.mapper;
@Mapper
public interface ApiLogMapper {
@Insert("""
INSERT INTO api_log
(event_type, request_id, endpoint, payload, response,
status_code, error_message, timestamp, retry_count, is_retry)
VALUES
(#{eventType},
#{requestId},
#{endpoint},
CAST(#{payload,jdbcType=VARCHAR} AS jsonb),
CAST(#{response,jdbcType=VARCHAR} AS jsonb),
#{statusCode,jdbcType=INTEGER},
CAST(#{errorMessage,jdbcType=VARCHAR} AS jsonb),
#{timestamp},
#{retryCount},
#{isRetry})
""")
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
void insert(ApiLogRow row);
@Select("""
SELECT id, event_type AS eventType, request_id AS requestId, endpoint,
payload::text AS payload, response::text AS response,
status_code AS statusCode, error_message::text AS errorMessage,
timestamp, retry_count AS retryCount, is_retry AS isRetry
FROM api_log WHERE request_id = #{requestId} ORDER BY id ASC
""")
List<ApiLogRow> findByRequestId(String requestId);
}
CAST(...,jdbcType=VARCHAR) AS jsonb를 쓰는 이유¶
PostgreSQL은 Java String 파라미터를 JSONB 컬럼으로 묵시적 캐스트해주지
않습니다. 두 가지 해결법:
- 커스텀
TypeHandler— 보일러플레이트, JSONB 컬럼마다 핸들러 등록 필요. - SQL에서 명시적 캐스트 — 이 매퍼의 방식. 컬럼당 한 줄, 별도 와이어업 없음.
jdbcType=VARCHAR 어노테이션은 값이 null일 때도 VARCHAR 바인딩을 강제해서
PostgreSQL의 "could not determine data type of parameter" 에러를 회피합니다.
행 타입¶
public class ApiLogRow {
private Long id;
private String eventType;
private String requestId;
private String endpoint;
private String payload; // JSON 문자열 — PayloadJsonMapper의 canonical form
private String response; // JSON 문자열
private Integer statusCode;
private String errorMessage; // JSON 문자열
private LocalDateTime timestamp;
private Integer retryCount;
private Boolean isRetry;
}
"JSON" 필드 3개는 일반 String. api-log-core의
PayloadJsonMapper.toJsonString()이 canonical JSON 형태로 만들어주고, 매퍼의
캐스트가 insert 시점에 JSONB로 변환합니다.
트랜잭션 시맨틱¶
MybatisApiLogWriter의 메서드는 @Transactional(propagation = REQUIRES_NEW)로
실행됩니다:
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void writeInitiated(ApiCallInitiatedEvent event) { ... }
JPA 백엔드와 동일한 계약 — 감사 쓰기는 호출자의 outer 트랜잭션과 함께 롤백되면 안 됩니다. 감사 행은 작업 단위의 나머지 운명과 무관하게 자체적으로 커밋됩니다.
스키마 관리¶
기본값은 api.log.schema.management=builtin. MyBatis 백엔드는 JDBC 기반
DataSourceScriptDatabaseInitializer를 사용 (JPA 백엔드와 같은 방식),
MyBatis 자체가 JDBC 위에서 동작하므로. DDL은 IF NOT EXISTS를 사용해서 매
부팅마다 다시 돌려도 no-op.
api-log-mybatis에는 Flyway 통합이 번들되지 않습니다 — FlywayConfigurationCustomizer는
api-log-jpa에만 들어 있습니다. MyBatis 앱이 Flyway로 테이블 관리를
원한다면:
- Flyway를 의존성에 추가 (
flyway-core+flyway-database-postgresql). spring.flyway.locations에classpath:db/api-log를 사용자 위치와 함께 추가:
api.log.schema.management=none으로 설정해서 BUILTIN 초기화가 Flyway 부트스트랩과 충돌하지 않게 함.
none (DDL을 Liquibase / psql 등으로 직접 적용)도 유효:
행 다시 읽기¶
번들된 findByRequestId가 "한 호출의 타임라인" 쿼리를 커버합니다. 그 외에는
직접 ApiLogMapper에 질의를 추가하세요 (번들된 걸 확장하거나, 별도 매퍼에):
@Mapper
public interface MyApiLogQueries {
@Select("""
SELECT COUNT(*) FILTER (WHERE event_type = 'ERROR') * 100.0 / COUNT(*)
FROM api_log
WHERE endpoint = #{endpoint}
AND timestamp > NOW() - INTERVAL '1 hour'
""")
Double errorRateLastHour(String endpoint);
}
JSONB 쿼리 플레이북 (연산자, GIN 인덱스, 에러율) 전체는 로그 조회 가이드에 — 백엔드와 무관하게 동일합니다.
Writer 오버라이드¶
행 쓰기 방식을 커스터마이즈할 때는 직접 ApiLogWriter 빈을 정의 — 백엔드의
@ConditionalOnMissingBean(ApiLogWriter.class)가 뒤로 빠집니다:
@Bean
public ApiLogWriter customWriter(ApiLogMapper mapper, PayloadJsonMapper json,
PayloadMasker masker) {
return new MaskingMybatisApiLogWriter(mapper, json, masker);
}
번들된 writer를 감싸는 게 보통 충분합니다:
public class MaskingMybatisApiLogWriter implements ApiLogWriter {
private final ApiLogWriter delegate;
private final PayloadMasker masker;
public MaskingMybatisApiLogWriter(ApiLogMapper mapper, PayloadJsonMapper json,
PayloadMasker masker) {
this.delegate = new MybatisApiLogWriter(mapper, json);
this.masker = masker;
}
@Override
public void writeInitiated(ApiCallInitiatedEvent event) {
delegate.writeInitiated(masker.mask(event));
}
// ... writeSuccess / writeError도 동일
}