울면서개발하기
DB / MYSQL

MySQL information_schema 로 MyBatis CRUD SQL 자동 생성하기

· 2026년 10월 25일 다시 씀

MyBatis 매퍼를 만들 때마다 열 수십 개를 손으로 옮겨 적는 일이 반복된다. information_schema 에서 열 정보를 읽어 기본 CRUD 문을 만들어 주는 쿼리를 쓰고 있다. MySQL 8.0 이상(창 함수·CTE 사용)에서 동작하고, MySQL 8.4 LTS 기준으로 정리했다.

예제 테이블

CREATE TABLE APP_ROLE (
    ROLE_CD        VARCHAR(20)  NOT NULL COMMENT '권한코드',
    PARENT_ROLE_CD VARCHAR(20)  NULL     COMMENT '상위권한코드',
    ROLE_NM        VARCHAR(100) NOT NULL COMMENT '권한명',
    NOTE           VARCHAR(500) NULL     COMMENT '비고',
    CREATED_BY     VARCHAR(50)  NOT NULL COMMENT '입력자',
    CREATED_AT     DATETIME     NOT NULL COMMENT '입력일시',
    UPDATED_BY     VARCHAR(50)  NOT NULL COMMENT '수정자',
    UPDATED_AT     DATETIME     NOT NULL COMMENT '수정일시',
    PRIMARY KEY (ROLE_CD)
);

CREATED_*, UPDATED_* 는 감사(audit) 열이다. 화면에서 받는 값이 아니라 로그인 사용자 ID 와 현재 시각으로 채운다.

생성 쿼리

SET SESSION group_concat_max_len = 1000000;  -- 기본값 1024 바이트면 결과가 잘린다

WITH COLS AS (
    SELECT C.TABLE_NAME
         , C.COLUMN_NAME
         , C.ORDINAL_POSITION                                         AS POS
         , C.COLUMN_COMMENT                                           AS CMT
         , CASE WHEN K.COLUMN_NAME IS NULL THEN 0 ELSE 1 END          AS IS_PK
         , CASE WHEN C.EXTRA LIKE '%auto_increment%' THEN 1 ELSE 0 END AS IS_AUTO
         , CASE WHEN IFNULL(C.GENERATION_EXPRESSION, '') <> '' THEN 1 ELSE 0 END AS IS_GEN
         , CASE WHEN C.COLUMN_NAME IN ('CREATED_BY', 'CREATED_AT') THEN 1 ELSE 0 END AS IS_CREATED
         -- 바인딩할 값: 감사 열은 고정값, 나머지는 #{열이름}
         , CASE WHEN C.COLUMN_NAME IN ('CREATED_AT', 'UPDATED_AT') THEN 'NOW()'
                WHEN C.COLUMN_NAME IN ('CREATED_BY', 'UPDATED_BY') THEN CONCAT('#', '{loginId}')
                ELSE CONCAT('#', '{', C.COLUMN_NAME, '}')
           END                                                        AS VAL
         -- 줄 맞춤용 최대 열 이름 길이
         , MAX(CHAR_LENGTH(C.COLUMN_NAME)) OVER ()                    AS W
      FROM information_schema.COLUMNS C
      LEFT JOIN information_schema.KEY_COLUMN_USAGE K
        ON K.TABLE_SCHEMA    = C.TABLE_SCHEMA
       AND K.TABLE_NAME      = C.TABLE_NAME
       AND K.COLUMN_NAME     = C.COLUMN_NAME
       AND K.CONSTRAINT_NAME = 'PRIMARY'
     WHERE C.TABLE_SCHEMA = 'mydb'      -- 스키마(데이터베이스) 이름
       AND C.TABLE_NAME   = 'APP_ROLE'  -- 테이블 이름 (실제 대소문자 그대로)
)
SELECT
    -- SELECT
    CONCAT('SELECT ',
           GROUP_CONCAT(CONCAT('A.', RPAD(COLUMN_NAME, W, ' '), ' AS ',
                               IF(CMT = '', COLUMN_NAME, CONCAT(RPAD(COLUMN_NAME, W, ' '), ' -- ', CMT)))
                        ORDER BY POS SEPARATOR '\n     , '),
           '\n  FROM ', TABLE_NAME, ' A',
           '\n WHERE ',
           GROUP_CONCAT(IF(IS_PK = 1, CONCAT('A.', COLUMN_NAME, ' = #', '{', COLUMN_NAME, '}'), NULL)
                        ORDER BY POS SEPARATOR '\n   AND ')
    ) AS SELECT_SQL

    -- INSERT: 자동 증가 열과 생성 열은 뺀다
  , CONCAT('INSERT INTO ', TABLE_NAME, ' (\n    ',
           GROUP_CONCAT(IF(IS_AUTO = 1 OR IS_GEN = 1, NULL, COLUMN_NAME)
                        ORDER BY POS SEPARATOR '\n  , '),
           '\n) VALUES (\n    ',
           GROUP_CONCAT(IF(IS_AUTO = 1 OR IS_GEN = 1, NULL, VAL)
                        ORDER BY POS SEPARATOR '\n  , '),
           '\n)'
    ) AS INSERT_SQL

    -- UPDATE: PK, 생성 열, CREATED_* 는 바꾸지 않는다
  , CONCAT('UPDATE ', TABLE_NAME,
           '\n   SET ',
           GROUP_CONCAT(IF(IS_PK = 1 OR IS_GEN = 1 OR IS_CREATED = 1, NULL,
                           CONCAT(RPAD(COLUMN_NAME, W, ' '), ' = ', VAL))
                        ORDER BY POS SEPARATOR '\n     , '),
           '\n WHERE ',
           GROUP_CONCAT(IF(IS_PK = 1, CONCAT(COLUMN_NAME, ' = #', '{', COLUMN_NAME, '}'), NULL)
                        ORDER BY POS SEPARATOR '\n   AND ')
    ) AS UPDATE_SQL

    -- DELETE
  , CONCAT('DELETE\n  FROM ', TABLE_NAME,
           '\n WHERE ',
           GROUP_CONCAT(IF(IS_PK = 1, CONCAT(COLUMN_NAME, ' = #', '{', COLUMN_NAME, '}'), NULL)
                        ORDER BY POS SEPARATOR '\n   AND ')
    ) AS DELETE_SQL
  FROM COLS
 GROUP BY TABLE_NAME;

쿼리를 읽을 때 알아 둘 것:

  • GROUP_CONCAT 은 NULL 을 건너뛴다. 그래서 IF(조건, NULL, 값) 으로 넣을 열과 뺄 열을 고른다
  • '\n' 은 MySQL 문자열 안에서 줄바꿈이다 (sql_mode 에 NO_BACKSLASH_ESCAPES 가 있으면 그대로 \n 두 글자가 된다)
  • '#', '{' 로 쪼갠 이유: 이 생성 쿼리 자체를 MyBatis 매퍼에 넣어 실행하면 MyBatis 가 문자열 안의 #{...} 도 파라미터로 바꿔 버린다. DB 클라이언트에서만 돌린다면 '#{' 로 써도 된다
  • PK 는 MySQL 에서 제약 이름이 항상 PRIMARY 라서 KEY_COLUMN_USAGE 로 찾았다. PK 가 없는 테이블은 WHERE 뒤에 붙을 값이 NULL 이라 CONCAT 전체가 NULL 이 되어, SELECT·UPDATE·DELETE 문이 NULL 로 나온다
  • 감사 열 이름과 #{loginId} 는 프로젝트 규칙에 맞게 바꾼다

결과

APP_ROLE 로 돌리면 이렇게 나온다.

SELECT A.ROLE_CD        AS ROLE_CD        -- 권한코드
     , A.PARENT_ROLE_CD AS PARENT_ROLE_CD -- 상위권한코드
     , A.ROLE_NM        AS ROLE_NM        -- 권한명
     , A.NOTE           AS NOTE           -- 비고
     , A.CREATED_BY     AS CREATED_BY     -- 입력자
     , A.CREATED_AT     AS CREATED_AT     -- 입력일시
     , A.UPDATED_BY     AS UPDATED_BY     -- 수정자
     , A.UPDATED_AT     AS UPDATED_AT     -- 수정일시
  FROM APP_ROLE A
 WHERE A.ROLE_CD = #{ROLE_CD}
INSERT INTO APP_ROLE (
    ROLE_CD
  , PARENT_ROLE_CD
  , ROLE_NM
  , NOTE
  , CREATED_BY
  , CREATED_AT
  , UPDATED_BY
  , UPDATED_AT
) VALUES (
    #{ROLE_CD}
  , #{PARENT_ROLE_CD}
  , #{ROLE_NM}
  , #{NOTE}
  , #{loginId}
  , NOW()
  , #{loginId}
  , NOW()
)
UPDATE APP_ROLE
   SET PARENT_ROLE_CD = #{PARENT_ROLE_CD}
     , ROLE_NM        = #{ROLE_NM}
     , NOTE           = #{NOTE}
     , UPDATED_BY     = #{loginId}
     , UPDATED_AT     = NOW()
 WHERE ROLE_CD = #{ROLE_CD}
DELETE
  FROM APP_ROLE
 WHERE ROLE_CD = #{ROLE_CD}

원래 쿼리에서 고친 것

처음 쓴 쿼리는 Oracle 용 생성 쿼리를 옮긴 것이었는데, 쓰다 보니 버그가 몇 개 있었다.

GROUP_CONCAT(DISTINCT …) 때문에 INSERT 값이 빠졌다

원래 쿼리는 모든 GROUP_CONCAT 에 DISTINCT 를 붙였다. DISTINCT 는 만들어진 문자열의 중복을 없앤다. 열 이름은 겹치지 않으니 열 목록은 멀쩡하지만, 값 목록에서는 입력자와 수정자가 둘 다 #{loginId}, 입력일시와 수정일시가 둘 다 NOW() 라서 하나씩 사라진다. 실제로 원래 글의 예시 결과도 INSERT 열은 18개인데 값은 14개였다. 이 상태로 실행하면 Column count doesn't match value count 오류가 난다. 열 이름이 겹칠 일이 없으니 DISTINCT 는 필요 없다.

테이블 이름 대소문자

원래 쿼리는 SELECT·INSERT 에는 UPPER(테이블명) 을, UPDATE·DELETE 에는 입력값을 그대로 써서 결과가 제각각이었다. MySQL 에서 테이블 이름의 대소문자 구분은 lower_case_table_names 설정에 따라 다르다. 리눅스 기본값(0)은 구분하므로, 소문자로 만든 테이블을 UPPER() 로 바꿔 쓰면 테이블을 못 찾는다. information_schema 에 저장된 실제 이름(TABLE_NAME)을 그대로 쓰도록 고쳤다.

줄 맞춤 길이

원래는 감사 열을 빼고 최대 길이를 구한 뒤 RPAD 로 맞췄다. RPAD 는 지정한 길이보다 긴 문자열을 잘라 버리기 때문에, 감사 열 이름이 다른 열보다 길면 열 이름이 잘린 SQL 이 나온다. 모든 열을 넣어 최대 길이를 구하고, 바이트 수인 LENGTH 대신 글자 수인 CHAR_LENGTH 를 쓴다.

자동 증가 열과 생성 열

  • AUTO_INCREMENT 열은 INSERT 에서 뺐다. 만들어진 키가 필요하면 MyBatis <insert useGeneratedKeys="true" keyProperty="..."> 를 쓴다
  • 생성 열(GENERATED ALWAYS AS)은 값을 넣거나 바꿀 수 없으므로 INSERT·UPDATE 에서 뺐다. information_schema.COLUMNS.GENERATION_EXPRESSION 이 비어 있지 않으면 생성 열이다. EXTRA 로 판단하면 DEFAULT CURRENT_TIMESTAMP 열에 붙는 DEFAULT_GENERATED 까지 걸리므로 주의한다

다시 쓰며 바로잡은 점

  • GROUP_CONCAT(DISTINCT ...) 때문에 INSERT 의 값 개수가 열 개수보다 적어지는 버그를 고쳤다
  • 테이블 이름을 UPPER() 로 바꾸지 않고 information_schema 의 실제 이름을 쓰도록 했다 (lower_case_table_names 에 따라 대소문자 구분)
  • RPAD 가 긴 열 이름을 자르는 문제, LENGTH(바이트)와 CHAR_LENGTH(글자) 차이를 고쳤다
  • 자동 증가 열·생성 열 처리를 추가하고, MyBatis 파라미터(#{OWNER}, #{TABLE_NAME})로 받던 스키마·테이블 이름을 문자열 값으로 바꿔 DB 클라이언트에서 바로 돌릴 수 있게 했다
  • 업무 테이블과 열 이름은 일반적인 예시(APP_ROLE)로 바꿨다