MyBatis 활용 시 주의사항 및 최적화 전략

데이터베이스와 애플리케이션 간의 상호작용을 위해 MyBatis와 같은 ORM(Object-Relational Mapping) 프레임워크를 사용하는 것은 일반적인 개발 패턴입니다. 하지만 편리함 뒤에는 몇 가지 함정과 최적화 기회가 존재합니다. 이 글에서는 MyBatis를 효과적으로 사용하기 위한 중요한 고려사항들을 다룹니다.

1. 파라미터 처리: #{} vs ${}

MyBatis에서 SQL 쿼리에 파라미터를 전달하는 방식은 크게 #{}${} 두 가지가 있습니다. 둘의 주요 차이점은 SQL 인젝션 방어와 파라미터 처리 방식에 있습니다.

  • #{} (PreparedStatement): 이 방식은 파라미터를 PreparedStatement의 플레이스홀더로 처리하여, 데이터베이스에 쿼리를 보내기 전에 값이 이스케이프(escape)되도록 합니다. 이는 SQL 인젝션을 효과적으로 방어할 수 있는 가장 안전한 방법입니다.
  • ${} (문자열 치환): 이 방식은 파라미터 값을 SQL 쿼리 문자열에 직접 삽입합니다. 이는 SQL 인젝션에 취약하므로, 사용 시 극도의 주의가 필요합니다. 일반적으로 컬럼 이름, 테이블 이름, 정렬 순서 등과 같이 동적으로 변경해야 하는 SQL 구조에만 제한적으로 사용해야 합니다.

안전성을 위해 #{} 사용을 최우선으로 고려해야 합니다. 특정 상황에서 ${}가 불가피하다면, 해당 파라미터의 입력 값을 철저히 검증해야 합니다.

2. 날짜 및 시간 처리

데이터베이스의 날짜/시간 필드 타입과 MyBatis의 jdbcType 매핑은 중요합니다. 예를 들어, MySQL의 DATE 타입 컬럼에는 MyBatis의 jdbcType=DATE를 사용해야 하며, TIMESTAMP를 사용하면 예상치 못한 문제가 발생할 수 있습니다.

날짜/시간 컬럼의 기본값 설정도 유용합니다.

  • 생성 시점에만 현재 시간을 자동 설정하고 이후 수정 시에는 변경하지 않으려면: TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  • 생성 및 수정 시점에 모두 현재 시간을 자동으로 갱신하려면: TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP

3. 집계 함수 활용 및 Integer 타입의 '0' 처리

COUNT(*), COUNT(1), COUNT(컬럼명)의 차이를 이해하는 것이 중요합니다. COUNT(*)COUNT(1)은 모든 행을 세는 반면, COUNT(컬럼명)은 해당 컬럼 값이 NULL이 아닌 행의 수를 셉니다. SUM(컬럼명)은 특정 컬럼의 값들을 합산합니다.

MyBatis는 정수 타입 파라미터가 빈 문자열('')이나 NULL로 전달될 경우 자동으로 0으로 변환하는 경향이 있습니다. 이는 조건문에서 0, NULL, 빈 문자열을 구분해야 할 때 문제를 야기할 수 있습니다. 이를 방지하기 위해 서비스(Service) 계층에서 파라미터를 Mapper로 전달하기 전에 NULL 또는 빈 문자열 값을 적절한 상태 값(예: 특정 음수 값)으로 처리하거나, 숫자를 문자열로 변환하여 전달하는 방법을 고려할 수 있습니다.

4. 결과 매핑 시 주의사항

resultMap을 사용할 때, <id> 태그는 항상 가장 먼저 정의하는 것이 좋습니다. 그렇지 않을 경우 예상치 못한 매핑 오류가 발생할 수 있습니다.

5. intInteger 자동 언박싱(Unboxing)으로 인한 NPE

MyBatis의 selectOne 메소드가 단일 결과를 반환할 때, 만약 조회된 레코드가 없다면 null을 반환합니다. 이 반환 값을 기본 타입 int 변수에 직접 할당하려 하면, 자바의 자동 언박싱(autounboxing) 과정에서 NullPointerException(NPE)이 발생할 수 있습니다. Integer 객체가 null일 때 intValue() 메소드를 호출하면 NPE가 발생하기 때문입니다. 따라서 selectOne의 반환 값을 받을 때는 항상 Integer 객체로 받은 후, null 체크를 수행하는 것이 안전합니다.

// 안전한 사용 예시
Integer count = mapper.selectUserCount(userId);
if (count != null) {
    int userCount = count;
    // ... 로직 처리
} else {
    // 사용자가 없는 경우 처리 (예: userCount = 0)
}

6. 다중 파라미터 처리 방식

MyBatis Mapper 메소드에 여러 파라미터를 전달하는 방법은 다음과 같습니다.

  • 객체 매핑: 여러 필드를 가진 Java 객체(VO/DTO)를 파라미터로 사용합니다. Mapper XML에서는 #{객체속성명}으로 접근합니다.
  • Map 사용: Map<String, Object>을 파라미터로 사용하여, 키-값 쌍으로 데이터를 전달합니다. Mapper XML에서는 #{key}로 접근합니다.
  • @Param 어노테이션: 각 파라미터 앞에 @Param("파라미터명")을 붙여줍니다. Mapper XML에서는 이 이름을 사용하여 #{파라미터명}으로 접근합니다.
  • 순서 기반 접근: #{0} 또는 #{param1}, #{1} 또는 #{param2}와 같이 파라미터 순서에 따라 접근합니다. 이 방법은 가독성이 떨어지므로 추천하지 않습니다.

가장 명확하고 유지보수하기 좋은 방법은 객체 매핑 또는 @Param 어노테이션을 사용하는 것입니다. 특히 Service 계층에서 Mapper로 Map을 직접 전달하는 방식은 파라미터의 의미와 타입을 불분명하게 만들어 유지보수를 어렵게 할 수 있으므로 지양하는 것이 좋습니다.

7. 동적 SQL 사용 최소화

MyBatis의 <if>, <choose> 등의 동적 SQL 태그는 강력하지만, 과도하게 사용하면 SQL 가독성을 저해하고 유지보수를 복잡하게 만듭니다. 가능하다면 데이터베이스의 CASE WHEN 또는 DECODE와 같은 SQL 자체의 조건 처리 기능을 활용하는 것이 좋습니다. 동적 SQL이 너무 복잡해지면 생성되는 SQL이 매번 달라져 데이터베이스의 하드 파싱(Hard Parsing)을 증가시켜 성능 저하의 원인이 될 수도 있습니다.

8. SQL 주석 대신 XML 주석 활용

MyBatis Mapper XML 파일 내에서 SQL 쿼리 내부에 --/* ... */와 같은 SQL 주석을 사용하는 것은 피해야 합니다. 일부 MyBatis 플러그인이나 페이징 처리 라이브러리(예: SQL을 래핑하여 COUNT(*) 쿼리를 생성하는 경우)는 SQL 주석을 제대로 처리하지 못해 쿼리 오류를 발생시키거나 조건이 무시되는 문제가 발생할 수 있습니다. 대신 XML 주석(<!-- ... -->)을 사용하여 SQL에 대한 설명을 추가하는 것이 안전하고 권장됩니다.

<!-- 이 쿼리는 사용자 정보를 조회합니다. -->
<select id="selectUsers" resultType="User">
    SELECT id, name, email
    FROM users
    <!-- WHERE 절은 조건에 따라 동적으로 추가됩니다. -->
    <where>
        <if test="name != null">
            AND name LIKE CONCAT('%', #{name}, '%')
        </if>
    </where>
</select>

9. LIKE 연산자 사용 시 주의

LIKE 절에서 동적 파라미터를 사용할 때 ${} 대신 #{}를 활용하는 것이 중요합니다. 예를 들어, LIKE '%${keyword}%'는 SQL 인젝션에 취약하며 인덱스를 활용하기 어렵습니다. 대신 데이터베이스별 CONCAT 함수를 활용하여 LIKE CONCAT('%', #{keyword}, '%')와 같이 사용하는 것이 좋습니다. 이는 파라미터를 안전하게 바인딩하면서 LIKE 연산을 수행할 수 있도록 합니다. 만약 LIKE 'prefix%'와 같이 접두사 매칭만 필요한 경우, ID LIKE #{prefix} || '%' (Oracle) 또는 ID LIKE CONCAT(#{prefix}, '%') (MySQL) 형태로 사용하는 것이 인덱스를 활용하는 데 더 유리할 수 있습니다.

10. MyBatis의 역할과 단순한 활용

MyBatis는 강력한 SQL 매핑 프레임워크이지만, 과도한 비즈니스 로직이나 복잡한 동적 SQL 생성을 MyBatis 내부에서 처리하려 하기보다는, SQL 정의와 간단한 ORM 역할에 집중하는 것이 좋습니다. 복잡한 로직은 서비스 계층에서 처리하고, MyBatis는 순수하게 SQL 실행과 결과 매핑의 도구로 활용할 때 가장 효과적입니다. 이는 유지보수성을 높이고 디버깅을 용이하게 합니다.

MyBatis 활용 팁

  • 공통 필드 추출: 여러 SQL 쿼리에서 반복적으로 사용되는 SELECT 절의 컬럼 목록은 <sql> 태그로 추출하여 재사용성을 높일 수 있습니다.
    <sql id="baseAdColumns">
        id, type, shop_id AS shopId, img_path AS imgPath,
        from_date AS fromDate, to_date AS toDate,
        ins_date AS insDate, ins_user_id AS insUserId,
        upd_date AS updDate, upd_user_id AS updUserId, del_flg AS delFlg
    </sql>
    
    <select id="selectAdInfoById" resultType="AdInfo">
        SELECT <include refid="baseAdColumns"/>
        FROM ad_info
        WHERE id = #{id,jdbcType=INTEGER}
    </select>
    
  • 특수 문자 처리 (CDATA): SQL 쿼리 내부에 <, >, & 등의 XML 특수 문자를 사용해야 할 경우, <![CDATA[ ... ]]> 블록 안에 SQL을 작성하여 파싱 오류를 방지할 수 있습니다.
    <select id="selectFilteredAdInfo" resultType="AdInfo">
        SELECT type, shop_id AS shopId, img_path AS imgPath
        FROM ad_info
        WHERE del_flg = '0'
        <![CDATA[
        AND from_date <= #{currentDate} AND to_date >= #{currentDate}
        ]]>
    </select>
    
  • 캐시 관리: MyBatis의 1차 및 2차 캐시를 적절히 활용하여 데이터베이스 부하를 줄일 수 있지만, 데이터 정합성 문제가 발생하지 않도록 주의 깊게 관리해야 합니다. 특히 쓰기 작업(INSERT, UPDATE, DELETE) 시에는 캐시를 비워야 합니다.
  • SQL 내 산술 연산: 간단한 산술 연산은 SQL 쿼리 내에서 직접 수행할 수 있습니다.
    <update id="updateCollectionCount">
        UPDATE collection_stats
        SET total_count = total_count + #{incrementValue,jdbcType=INTEGER}
        WHERE shop_id = #{shopId,jdbcType=INTEGER}
        AND delete_flag = '0'
    </update>
    

MyBatis의 장단점

MyBatis는 다음과 같은 장단점을 가집니다.

장점:

  • 쉬운 학습 곡선: JDBC를 직접 다루는 것과 유사하여 빠르게 익힐 수 있습니다.
  • SQL 및 코드 분리: SQL 쿼리를 XML 파일에 정의함으로써 애플리케이션 코드와 분리하여 관리하고 최적화하기 용이합니다.
  • 높은 유연성: 개발자가 직접 SQL을 작성하므로, 복잡하거나 특정 데이터베이스에 최적화된 쿼리를 자유롭게 작성할 수 있습니다.
  • 객체 매핑 지원: 쿼리 결과를 Java 객체로 쉽게 매핑할 수 있는 기능을 제공합니다.
  • 동적 SQL: 조건에 따라 SQL 쿼리를 동적으로 구성할 수 있는 강력한 기능을 제공합니다.

단점:

  • 높은 SQL 작업량: 복잡한 비즈니스 로직이나 많은 테이블이 관련된 경우, 직접 SQL을 작성하는 데 많은 시간과 노력이 필요합니다.
  • 데이터베이스 종속성: SQL이 데이터베이스 특정 문법에 의존하게 되면, 다른 종류의 데이터베이스로 전환 시 쿼리 수정이 필요할 수 있습니다.
  • DAO 메소드 오버로딩 제약: XML Mapper의 SQL ID는 유일해야 하므로, 동일한 이름을 가진 DAO 메소드를 오버로딩하기 어렵습니다.
  • 제한적인 ORM 기능: Hibernate와 같은 완전한 ORM 프레임워크에 비해 객체 관계 관리(예: N+1 문제 해결, 지연 로딩, 캐스케이딩 업데이트/삭제) 기능이 제한적입니다.
  • 동적 SQL 디버깅의 어려움: 복잡한 동적 SQL은 런타임에 최종 SQL이 어떻게 생성되는지 추적하기 어려워 디버깅이 까다로울 수 있습니다.
  • 파라미터 타입 지원 불완전성: 일부 특정 Java 타입(예: java.util.Date)을 파라미터로 사용할 때 추가적인 jdbcType 지정이 필요할 수 있습니다.
  • 캐시 관리의 복잡성: 잘못된 캐시 사용은 오래된 데이터를 반환하거나 데이터 불일치 문제를 야기할 수 있습니다.

결론적으로, MyBatis는 개발자가 SQL을 직접 제어하며 성능을 최적화할 수 있는 강력한 도구입니다. 하지만 그만큼 SQL 작성 및 관리에 대한 개발자의 책임이 커집니다. 작은 프로젝트나 SQL에 대한 세밀한 제어가 필요한 경우에 매우 적합하며, 복잡한 객체 관계 관리보다는 SQL 중심의 접근 방식에 강점을 보입니다. Hibernate와 같은 완전한 ORM이 너무 무겁게 느껴지거나, Spring JDBC Template보다 객체 매핑 기능이 더 필요할 때 좋은 대안이 될 수 있습니다.

태그: MyBatis SQL Injection ORM JDBC Dynamic SQL

7월 9일 20:07에 게시됨