티스토리 뷰

Apache ShardingSphere에 올린 Pull Request #39126 Add MySQL exception mapping for ColumnNotFoundException이 머지됐습니다. 바뀐 코드는 많지 않습니다. 열거형 상수 두 개와 if 분기 하나를 추가했고 테스트를 더했습니다. 그런데 리뷰에서 수정 요청을 한 번 받았고, 그 과정에서 오류 응답도 클라이언트와 맺은 계약이라는 점을 알게 됐습니다.

방언별 예외 매퍼가 하는 일

ShardingSphere-Proxy는 클라이언트 입장에서 MySQL이나 PostgreSQL 서버처럼 보여야 합니다. 그래서 프록시 내부에서 생긴 예외를 클라이언트에 돌려줄 때도 해당 데이터베이스의 오류 형식에 맞춰야 합니다. 이 변환을 맡는 곳이 database/exception/dialect/* 아래의 방언별 매퍼입니다.

 

매퍼는 SQLDialectExceptionMapper 인터페이스를 구현하고, 이 인터페이스는 DatabaseTypedSPI를 확장합니다. 데이터베이스 타입별로 구현체가 하나씩 로드되는 확장점입니다. 내부에서는 데이터베이스와 무관한 SQLDialectException 계열 예외를 던지고, 방언 매퍼가 이를 SQLException으로 바꿉니다. 이때 벤더 오류 코드, SQLSTATE, 메시지가 정해집니다. MySQL 매퍼의 convert()는 instanceof 분기를 이어 붙인 구조이고, 어느 분기에도 걸리지 않으면 마지막 줄의 폴백으로 내려갑니다.

// AS-IS: MySQLDialectExceptionMapper.convert()의 끝부분
if (sqlDialectException instanceof IncorrectGlobalLocalVariableException) {
    IncorrectGlobalLocalVariableException ex = (IncorrectGlobalLocalVariableException) sqlDialectException;
    return toSQLException(MySQLVendorError.ER_INCORRECT_GLOBAL_LOCAL_VAR, ex.getVariableName(), ex.getScope());
}
return new UnknownSQLException(sqlDialectException).toSQLException();

UnknownSQLException은 SQLSTATE HY000(XOpenSQLState.GENERAL_ERROR), 오류 코드 30000, Unknown exception.으로 시작하는 메시지를 만듭니다. 원인을 알 수 없는 예외에는 맞는 처리입니다. 다만 원인이 분명한 예외가 이 폴백으로 내려가면 클라이언트는 무엇이 잘못됐는지 알 수 없습니다.

형제 방언과 비교해 빠진 분기를 찾았습니다

database/exception/core에는 ColumnNotFoundException이 있습니다. tableName과 columnName 두 필드만 가진 SQLDialectException입니다. PostgreSQL 매퍼는 이 예외를 PostgreSQLVendorError.UNDEFINED_COLUMN(SQLSTATE 42703)으로 변환하고 있었습니다.

// PostgreSQLDialectExceptionMapper.convert()
if (sqlDialectException instanceof ColumnNotFoundException) {
    ColumnNotFoundException cause = (ColumnNotFoundException) sqlDialectException;
    return new PostgreSQLException(new ServerErrorMessage(FATAL_SEVERITY, PostgreSQLVendorError.UNDEFINED_COLUMN, cause.getColumnName(), cause.getTableName()));
}

MySQL 매퍼에는 같은 분기가 없었습니다. 다만 형제 구현에 분기가 있다는 사실만으로는 결함이라고 말하기 어렵습니다. MySQL 경로에서 이 예외가 실제로 던져지지 않는다면, 분기를 추가해도 도달하지 않는 방어 코드일 뿐입니다. 그래서 호출하는 쪽부터 찾아봤습니다.

 

예외를 던지는 곳은 MySQL 프론트엔드의 MySQLComStmtPrepareParameterMarkerExtractor였습니다. MySQL 바이너리 프로토콜의 COM_STMT_PREPARE를 처리할 때 파라미터 마커(?)가 어느 컬럼에 대응하는지 알아내는 클래스입니다. INSERT 문이고 대상 테이블이 스키마 메타데이터에 있으면, INSERT 컬럼 목록의 각 이름을 테이블 메타데이터에서 찾고 없으면 예외를 던집니다.

private static ShardingSphereColumn getColumn(final ShardingSphereTable table, final String columnName) {
    ShardingSpherePreconditions.checkState(table.containsColumn(columnName), () -> new ColumnNotFoundException(table.getName(), columnName));
    return table.getColumn(columnName);
}

MySQL 클라이언트가 테이블에 없는 컬럼을 INSERT 컬럼 목록에 넣어 prepared statement를 만들면 이 경로를 탑니다. 코드상으로는 MySQL 서버라면 1054 오류가 나왔을 상황에서 HY000/30000의 일반 오류가 나가는 구조였습니다. infra에도 같은 이름의 커널 메타데이터 예외 ColumnNotFoundException이 있지만, 바인더가 던지는 이 클래스는 매퍼로 오지 않습니다. 패키지가 다른 동명 클래스를 구분하지 않으면 영향 범위를 실제보다 크게 잡게 되므로, 근거는 위 프론트엔드 경로 하나로 한정했습니다.

오류 코드와 SQLSTATE를 어디에 두었나

MySQL은 존재하지 않는 컬럼에 ER_BAD_FIELD_ERROR(1054), SQLSTATE 42S22를 씁니다. MySQLVendorError에는 이 항목이 없었고, XOpenSQLState에도 42S22 상수가 없었습니다.

 

SQLSTATE를 어디에 둘지 먼저 정해야 했습니다. PostgreSQL처럼 방언 모듈 안에 상태 enum을 두는 방식도 있습니다. 하지만 42S22는 특정 벤더 고유 코드가 아니라 X/Open 계열의 42S 클래스에 속하는 상태입니다. XOpenSQLState에는 이미 DUPLICATE("42S01")와 NOT_FOUND("42S02")가 있었고, MySQLVendorError의 기존 항목도 모두 XOpenSQLState만 참조하고 있었습니다. 그래서 infra/exception의 XOpenSQLState에 상수 하나를 추가했습니다.

// XOpenSQLState
NOT_FOUND("42S02"),

COLUMN_NOT_FOUND("42S22"),

CHECK_OPTION_VIOLATION("44000"),

infra는 여러 모듈이 공유하는 영역입니다. 기존 상수를 바꾸거나 지우지 않고 하나만 추가하는 선으로 범위를 제한했습니다. MySQLVendorError는 오류 번호 순으로 정렬돼 있어서 1050과 1062 사이에 새 항목을 넣었습니다.

ER_TABLE_EXISTS_ERROR(XOpenSQLState.DUPLICATE, 1050, "Table '%s' already exists"),

ER_BAD_FIELD_ERROR(XOpenSQLState.COLUMN_NOT_FOUND, 1054, "Unknown column '%s' in '%s'"),

ER_DUP_ENTRY(XOpenSQLState.INTEGRITY_CONSTRAINT_VIOLATION, 1062, "Duplicate entry '%s' for key %d"),

리뷰에서 받은 질문: 두 번째 '%s'는 테이블 이름인가

처음 올린 버전은 메시지의 두 %s에 컬럼 이름과 테이블 이름을 넣었습니다. 예외 객체에 두 필드가 있으니 자연스러워 보였고, PostgreSQL 매퍼도 컬럼과 테이블을 함께 넘깁니다. 이렇게 하면 메시지는 Unknown column 'order_id' in 't_order'가 됩니다.

메인테이너 리뷰에서 이 부분에 수정 요청이 왔습니다. 오류 코드와 SQLSTATE 방향은 맞지만, 메시지가 MySQL의 실제 의미와 다르다는 지적이었습니다. 리뷰에는 MySQL 8.0.45 소스 저장소의 테스트 결과 파일이 근거로 달려 있었습니다. INSERT 컬럼 목록에 없는 컬럼을 넣으면 MySQL 서버는 Unknown column '...' in 'field list'를 돌려줍니다. 두 번째 자리는 테이블 이름이 아니라, 서버가 컬럼 이름을 해석하던 위치를 나타내는 문맥 값입니다. MySQL 구현은 이 값을 thd->where로 전달하고, 기본값이 field list라는 설명도 함께 있었습니다.

 

저는 메시지 템플릿의 %s 두 개를 보고 바로 두 필드를 떠올렸습니다. 원본 서버가 그 자리에 무엇을 넣는지는 확인하지 않았습니다. 이번에 영향을 받는 경로가 INSERT 컬럼 목록 조회 하나이니 그 경로에 맞는 값인 field list를 넘기도록 고쳤습니다.

// TO-BE: 폴백 직전에 추가한 분기
if (sqlDialectException instanceof ColumnNotFoundException) {
    return toSQLException(MySQLVendorError.ER_BAD_FIELD_ERROR, ((ColumnNotFoundException) sqlDialectException).getColumnName(), "field list");
}
return new UnknownSQLException(sqlDialectException).toSQLException();

같은 리뷰에서 릴리스 노트 문제도 지적받았습니다. 항목을 Enhancements 아래에 넣었고 링크에는 PR 번호 대신 자리표시자가 그대로 남아 있었습니다. 연결된 이슈가 버그 보고였으니 Bug Fixes가 맞는 위치입니다. 항목을 옮기고 링크를 #39126으로 바로잡았습니다. ShardingSphere는 오타가 아닌 변경이면 RELEASE-NOTES.md에 모듈: 설명 - [#PR](링크) 형식으로 항목을 남깁니다. 머지된 버전에는 Proxy의 다른 MySQL 수정 항목 옆에 Proxy: Add MySQL exception mapping for ColumnNotFoundException 한 줄이 들어갔습니다.

테스트로 고정한 것

ShardingSphere 테스트는 JUnit 5와 Hamcrest로 작성합니다. 메서드 이름은 assert로 시작하고 검증은 assertThat(actual, is(expected)) 형태로 씁니다. 저는 기존 테스트 구조를 그대로 따랐습니다.

 

MySQLDialectExceptionMapperTest에는 예외 타입별로 변환 결과의 벤더 오류를 확인하는 파라미터화 테스트가 있습니다. 여기에 column_not_found 케이스를 한 줄 추가했습니다. 메시지까지 확인하려고 기존 헬퍼 assertSQLExceptionWithMessage를 쓰는 테스트도 하나 더 만들었습니다.

@Test
void assertConvertWithColumnNotFound() {
    assertSQLExceptionWithMessage(mapper.convert(new ColumnNotFoundException("t_order", "order_id")), MySQLVendorError.ER_BAD_FIELD_ERROR, "order_id", "field list");
}

이 테스트는 예외에 테이블 이름 t_order가 들어 있어도 메시지가 Unknown column 'order_id' in 'field list'로 나오는지 확인합니다. 처음 버전의 테스트는 테이블 이름이 들어간 메시지를 기대값으로 두고 있었습니다. 리뷰에서도 그 테스트가 잘못된 메시지를 고정하고 있다고 지적받았습니다. 테스트가 통과한다는 것은 코드가 기대값과 같다는 뜻일 뿐이고, 기대값을 무엇에 근거해 정했는지가 더 중요했습니다.

 

MySQLVendorErrorTest에는 새 항목의 오류 코드 1054, SQLSTATE 42S22, 메시지 템플릿을 확인하는 assertBadFieldError()를 추가했습니다. 클라이언트가 보는 세 값을 직접 확인하므로, 나중에 누군가 이 항목을 고치면 테스트가 바로 깨집니다.

PR 제목과 범위

ShardingSphere의 기본 브랜치는 master이고 PR은 squash merge됩니다. 그래서 PR 제목이 그대로 커밋 제목이 되고 GitHub이 끝에  (#39126)을 붙입니다. 제목은 접두사와 마침표 없이 대문자로 시작하는 명령형 문장으로 쓰는 것이 관례라서 Add MySQL exception mapping for ColumnNotFoundException으로 정했습니다. 리뷰를 반영한 두 번째 커밋도 squash 과정에서 이 제목 아래로 합쳐졌습니다.

 

변경 범위는 infra/exception의 상수 하나, database/exception/dialect/mysql의 enum 항목과 분기 하나, 테스트 두 파일, 릴리스 노트 한 줄입니다. 문법은 JDK 8 기준에 맞췄고 기존 분기 순서나 포맷은 건드리지 않았습니다. 저장소의 마감 순서는 spotless로 포맷을 맞춘 뒤 checkstyle을 확인하는 것입니다.

돌아보며

이번 기여에서 가장 크게 남은 것은 리뷰에서 받은 질문 하나입니다. 저는 오류 코드와 SQLSTATE를 맞추는 데 신경을 썼고, 메시지는 템플릿만 보고 채웠습니다. 프록시가 원본 데이터베이스처럼 동작해야 한다면 메시지 속 단어도 원본 서버와 맞아야 했습니다. 클라이언트 도구나 사람이 그 메시지를 보고 원인을 판단하기 때문입니다.

 

형제 방언 구현을 비교하면 빠진 부분을 찾기 쉽습니다. 다만 그 구현을 그대로 따르면 방언마다 다른 의미까지 옮겨 오게 됩니다. 앞으로 방언 차이가 있는 곳을 고칠 때는 형제 구현과 함께 원본 데이터베이스가 실제로 돌려주는 값도 확인하려고 합니다. 근거를 달아 차분하게 지적해 준 리뷰 덕분에 그 습관을 하나 더 갖게 됐습니다.