MyBatis 쿼리 작성 가이드
기본 원칙
- 이 프로젝트는 JPA를 주 ORM으로 사용한다.
- MyBatis는 JPA로 표현하기 어려운 복잡한 동적 쿼리에만 사용한다.
- MySQL 8.0 기준으로 쿼리를 작성한다.
SELECT *금지 — 필요한 컬럼만 명시한다.
JPA vs MyBatis 사용 기준
| 상황 | 권장 |
|---|---|
| 단순 CRUD | JPA (Spring Data JPA) |
| 조건부 동적 쿼리 | JPA Specification 또는 MyBatis |
| 복잡한 JOIN + 집계 | MyBatis |
| 네이티브 쿼리가 불가피한 경우 | MyBatis 또는 JPA @Query(nativeQuery=true) |
| 대량 배치 INSERT | MyBatis (성능 유리) |
JPA 쿼리 작성
public interface NewsItemRepository extends JpaRepository<NewsItem, Long> {
// Spring Data 메서드명 쿼리
Optional<NewsItem> findByUrl(String url);
boolean existsByUrl(String url);
List<NewsItem> findByCategory(String category);
// JPQL — 파라미터 바인딩 필수
@Query("SELECT n FROM NewsItem n WHERE n.category = :category ORDER BY n.frstRegistDt DESC")
List<NewsItem> findByCategory(@Param("category") String category);
// 페이징 쿼리
@Query("SELECT n FROM NewsItem n ORDER BY n.frstRegistDt DESC")
Page<NewsItem> findAllPaged(Pageable pageable);
}
MyBatis XML 매퍼
<!-- src/main/resources/mapper/news/NewsMapper.xml -->
<mapper namespace="com.scraping.agent.domain.news.repository.NewsMapper">
<!-- #{} 사용 필수 (PreparedStatement — SQL Injection 방지) -->
<select id="findByKeyword" resultType="com.scraping.agent.domain.news.vo.NewsListRes">
SELECT
id,
title,
url,
category,
frst_regist_dt AS createdAt
FROM news_items
WHERE title LIKE CONCAT('%', #{keyword}, '%')
ORDER BY frst_regist_dt DESC
LIMIT #{size} OFFSET #{offset}
</select>
<!-- 동적 쿼리 — <if> 사용 -->
<select id="findByFilter" resultType="com.scraping.agent.domain.news.vo.NewsListRes">
SELECT
id,
title,
url,
category,
frst_regist_dt AS createdAt
FROM news_items
<where>
<if test="category != null and category != ''">
AND category = #{category}
</if>
<if test="keyword != null and keyword != ''">
AND title LIKE CONCAT('%', #{keyword}, '%')
</if>
</where>
ORDER BY frst_regist_dt DESC
LIMIT #{size} OFFSET #{offset}
</select>
</mapper>
MyBatis 인터페이스
@Mapper
public interface NewsMapper {
List<NewsListRes> findByKeyword(@Param("keyword") String keyword,
@Param("size") int size,
@Param("offset") int offset);
List<NewsListRes> findByFilter(@Param("category") String category,
@Param("keyword") String keyword,
@Param("size") int size,
@Param("offset") int offset);
}
MySQL 8.0 페이징
-- MySQL 페이징 (MyBatis)
SELECT id, title, url
FROM news_items
ORDER BY frst_regist_dt DESC
LIMIT #{size} OFFSET #{offset}
// Service에서 offset 계산
int offset = page * size;
newsMapper.findByFilter(category, keyword, size, offset);
JPA를 사용할 때는 Pageable을 활용한다:
Pageable pageable = PageRequest.of(page, size, Sort.by("frstRegistDt").descending());
Page<NewsItem> result = newsItemRepository.findAll(pageable);
SQL Injection 방지
<!-- 올바른 예 — #{} (PreparedStatement) -->
<select id="findByTitle" resultType="NewsItem">
SELECT id, title, url FROM news_items
WHERE title = #{title}
</select>
<!-- ${} 사용 시 — 반드시 화이트리스트 검증 후 전달 -->
<select id="findAllSorted" resultType="NewsItem">
SELECT id, title, url FROM news_items
ORDER BY ${sortColumn} <!-- sortColumn은 Service에서 화이트리스트 검증 -->
</select>
화이트리스트 검증 예:
// Service에서 sortColumn 화이트리스트 검증
private static final Set<String> ALLOWED_SORT_COLUMNS =
Set.of("frst_regist_dt", "title", "category");
public List<NewsListRes> findAllSorted(String sortColumn) {
if (!ALLOWED_SORT_COLUMNS.contains(sortColumn)) {
throw new IllegalArgumentException("허용되지 않은 정렬 컬럼: " + sortColumn);
}
return newsMapper.findAllSorted(sortColumn);
}
MyBatis 결과 매핑
<!-- snake_case → camelCase 자동 변환 설정 (application.yml) -->
<!--
mybatis:
configuration:
map-underscore-to-camel-case: true
-->
<!-- 자동 변환 시 resultMap 불필요 -->
<select id="findByKeyword" resultType="com.scraping.agent.domain.news.vo.NewsListRes">
SELECT id, title, url, frst_regist_dt <!-- → frstRegistDt (camelCase 자동 매핑) -->
FROM news_items
WHERE title LIKE CONCAT('%', #{keyword}, '%')
</select>
체크리스트
- [ ]
SELECT *사용 금지 — 필요 컬럼만 명시 - [ ]
#{}파라미터 바인딩 사용 (SQL Injection 방지) - [ ]
${}사용 시 화이트리스트 검증 코드 포함 - [ ] 페이징:
LIMIT #{size} OFFSET #{offset} - [ ] 동적 쿼리:
<where>,<if>활용 - [ ] Mapper 인터페이스에
@Mapper선언 - [ ] XML 파일 위치:
src/main/resources/mapper/{도메인}/