테스트에 임베디드 DB 쓰지 말라니까

    케이랑 함께 집넘기기 프로젝트를 진행하면서, 그동안 제대로 해보지 못했던 것들을 차근차근 시도하면서 또 여러 시행착오를 거치는 중이다. 그 중에서도 역시 꽃은 테스트라고 할 수 있겠다. 이번에도 역시나 밑도끝도 없(는 것처럼 보이는) 문제가 터져서 좀 당황했었는데, 생각보다 꽤 흥미롭고 재미있는 주제라서 정리해 본다.

     

    문제의 시작

    더미 데이터를 담은 data.sql를 정의해 주고, 일단 테스트용 DB는 임베디드 인메모리 DB(H2)를 쓰기로 했다. 사실상 이게 문제의 시작이라고 보면 된다.

     

    INSERT INTO  rent_article (rent_article_id, address ...)
    VALUES
    ('1', '서울특별시 성동구', ...),
    ('2', '서울특별시 성동구', ...),
    ('3', '서울특별시 성동구', ...),
    ...

     

    //application.yml
    datasource:
        driver-class-name: org.h2.Driver
        url: jdbc:h2:mem:test;MODE=MySQL;
        username: SA
        password:

     

    실 서버에서는 DB가 MySQL을 기반으로 작동하고 있는데, 자료를 찾아 보니 MySQL과 H2의 문법이나 기능 차이가 있어서 문제가 발생하는 경우가 있어서 TestContainers 등을 가지고 테스트용 MySQL DB를 따로 정의해 주라는 이야기가 많았다. (추가로 RANDOM_PORT를 사용해 실제 웹 환경에서 API 테스트를 돌릴 경우 테스트 스레드와 톰캣을 돌리는 스레드가 달라 @Transactional이 메서드별로 롤백을 처리해주지 않는 문제도 있다.)

     

    그치만 일단 테스트에 익숙하지 않은 상황에서 꽤 설정이 복잡한 TestContainers 이전에 우선 인메모리 DB로 돌려본 뒤에 개선하자는 생각이었다. MySQL에서만 지원하는 기능을 사용하는 쿼리도 특별히 없다고 생각하기도 했다. 대신 MODE=MySQL 옵션을 명시해 주면 이런 호환성 문제를 해결할 수 있다는 이야기를 듣고 해당 옵션을 명시해 주었다.

     

    그런데 이상한 문제가 터진다.

     

    could not execute statement; SQL [n/a];
    constraint ["PRIMARY KEY ON PUBLIC.RENT_ARTICLE(RENT_ARTICLE_ID)
    ( /* key:1 */ CAST(1 AS BIGINT)
    insert into rent_article (rent_article_id, ...
    ...

     

    @DataJpaTest를 통한 저장소 계층 테스트에서 문제가 발생한 것. 저장 로직이 제대로 작동하나 보려고 했는데, data.sql을 가지고 rent_article 데이터를 12번까지 넣어줬는데 새로운 엔티티를 save() 처리할 때 ID 1번으로 집어넣으려고 시도하다가 PK 중복 에러를 뿜는 것이었다. 아니 왜 이러지? 엔티티의 @GeneratedValue 애노테이션도 잘 설정되어 있어서 당연히 13번부터 들어가야 하는데, 왜 1번부터 들어가는 건지 알 수가 없었다.

     

    둘이서 이런저런 이야기를 하면서 머리를 싸매고 있는데, 케이가 문제를 발견!

     

    @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) ” 이 애노테이션이 빠졌는데요. 이걸 넣으니까 잘 작동해요!

     

    정말 해당 옵션을 넣으니까 잘 작동했다. (나중에 알게 됐지만, 이걸 전역적으로 설정하려면 yml에서 spring.test.database.replace 속성을 바꾸면 된다) 일단 문제는 해결됐는데, 그게 더 이상하다는 생각이 들었다. 이 옵션을 넣으나 넣으나 빼나 둘 다 H2를 쓰는데 어디서 문제가 생기는 거지? 다시 머리 싸매기 시작.

     

    한참을 고민하다 보니 잊고 있던 MODE=MySQL; 옵션이 생각이 났다. 그래서 혹시 이 옵션이 꺼지면 문제가 발생하는 건가? 하는 생각이 들어 해당 옵션을 빼 보니까 @AutoConfigureTestDatabase의 replace를 NONE으로 설정해도 똑같은 에러가 난다. 즉, 이건 H2에서 발생하는 문제이고, MySQL에선 이런 문제가 없다. MySQL 모드를 세팅하면 똑같은 그제서야 역시 개발 선배님들의 조언이 다 이유가 있었구나 하는 생각이 들었다.

     

    그치만 일단 테스트에 익숙하지 않은 상황에서 꽤 설정이 복잡한 TestContainers 이전에 우선 인메모리 DB로 돌려본 뒤에 개선하자는 생각이었다. MySQL에서만 지원하는 기능을 사용하는 쿼리도 특별히 없다고 생각하기도 했다.

     

    일단 필요성이 잘 안 와닿을 땐, 삽질을 하고 직접 아… 해보면 진짜 이유를 알게 된다. 개발 공부를 하면서 늘 느끼는 지점!

     

    H2에서는 왜 문제가 발생하는 거지?

    문제가 H2인 걸 알고 나서 찾아보니 같은 문제에 대한 자료가 있었다. H2 버전을 1.4.200에서 2.1.212로 올리고부터 문제가 발생한다는 글. 답변을 보니, 특정 버전 이상부터 H2에서는 MySQL의 AUTO_INCREMENT에 해당하는 기본키 자동 증가 기능을 GENERATED { BY DEFAULT | ALWAYS } AS IDENTITY 명령으로 실행한다. JPA의 DDL 자동생성 기능을 통해 테이블을 생성할 때는 H2 방언에서 GENERATED BY DEFAULT AS IDENTITY 쿼리가 나간다. 그리고 위에서 살펴 본 것처럼 data.sql에는 가독성을 위해서 ID값을 직접 명시해서 넣어줬는데, 이 부분이 맞물려서 문제가 발생한 것.

     

    INSERT INTO  rent_article (rent_article_id, address ...)
    VALUES
    ('1', '서울특별시 성동구', ...),
    ('2', '서울특별시 성동구', ...),
    ('3', '서울특별시 성동구', ...),
    ...

     

    GENERATED BY ALWAYS AS IDENTITY 옵션은 이렇게 임의로 ID를 넣어주는 걸 아예 허용하지 않고, GENERATED MY DEFAULT AS IDENTITY 옵션은 허용한다. 그런데 문제는, 이렇게 사용자가 임의의 ID값을 넣어 주면 기본키를 자동 증가해서 생성하는 generator의 base 값을 변경하지 않는다는 것이다. 즉, data.sql에서 12번까지 데이터를 집어넣었지만, 유저가 직접 ID를 명시해서 넣었기 때문에 H2 내부의 현재 base key 값은 여전히 1번이라는 것이다. 그래서 새로운 엔티티를 JPA를 통해 넣으려고 하면 key 1번으로 데이터 삽입을 시도하다 에러를 뿜게 되는 것. 그래서 웬만하면 GENERATED AS IDENTITY 옵션으로 테이블을 정의했을 때는 ID를 직접 명시하지 않기를 권장하고 있다.

     

    그런데 MODE=MySQL을 켜면, H2 내부적으로 Compatibility Mode로 작동하면서 MySQL의 동작 방식을 에뮬레이트한다. 공식 문서에 따르면, 이 모드를 켜면서 변경되는 지점들은 아래와 같다.

     

     

    AUTO_INCREMENT clause can be used. 라고 되어 있는데, 이 옵션을 켜면 내부적으로 MYSQL의 AUTO_INCREMENT와 같은 방식으로 기본키를 생성하는 것으로 추측된다.

     

    그럼 MySQL의 AUTO_INCREMENT는?

    MySQL이 개인적으로는 프로그래머 입장에서 조금 더 직관적으로 편하게 동작한다는 생각이 든다. MySQL이 자동 생성하는 키 값은 해당 테이블에 마지막으로 삽입된 키 값을 기준으로 한다. 만약 AUTO_INCREMENT로 5번까지 생성한 이후에 유저가 ID를 100으로 명시해서 INSERT를 했다면, 다음 번에 해당 테이블에 새로운 레코드를 삽입할 때는 ID 101번으로 삽입된다.

     

    재미있는 지점은 PK는 NOT NULL 제약이 들어가 있는데, ID 컬럼을 (굳이굳이) NULL로 직접 명시해서 레코드를 삽입해도 해당 규칙에 맞춰서 ID를 자동으로 부여한다는 것이다. 이 예시가 공식 문서에도 나와 있다. 위의 예에서 ID 100번 레코드를 삽입한 다음에 ID를 NULL로 명시해서 새 레코드를 넣어도 101번으로 들어간다는 것이다. 유효한 값일 때는 해당 번호가 그대로 삽입되는 걸로 봐서는 NULL일 때 따로 조건을 처리해 줬다는 건데, 이렇게까지? 하는 생각이 들긴 하지만 재미있다.

     

    이전에 쓴 백만 개 Row를 수정하다가 트랜잭션을 롤백한다면? 글에서 살펴본 MVCC 구현도 그렇고, 이런 거 보면 ‘결국 SQL 쿼리 돌리는데 DBMS가 달라봐야 얼마나 다르겠어’ 싶지만 꽤 벤더별로 내부 구현 방식에 다양한 차이가 있다는 걸 느끼게 되어서 흥미가 막 생긴다. 예전에 호눅스가 코드 단위로 까서 공부해보고 싶으면 PostgreSQL이 좋다는 이야기를 한 적이 있었는데… 언젠가는 도전해 볼 것.

     


     

    테스트 얘기로 시작했다가 DB 얘기로 너무 간 것 같지만, 어쨌든 DBMS들은 각각 생각보다 꽤 많이 다르다! 우선은 H2의 compatibility mode로 이 글에서 다룬 문제는 해결하긴 했지만, 문서에서도 밝히고 있듯이 DBMS간의 모든 차이를 해결하는 것은 아니다. 따라서 추후에는 TestContainer 등을 통해서 실제 운영 환경과 최대한 유사한 DB 환경에서 테스트를 돌리는 것이 좋은 방법일 듯하다. 테스트도 고려해야 할 점들이 엄청 많다는 것을 피부로 느끼게 된다.

     

    참고자료

    댓글