공공부하자개발 · 영어 학습 노트
자바
실무 확장Excel · 파일 업로드 · DB 연동0/22 완료
  • 01Excel(XLSX) 구조와 순수 JDK로 읽기/쓰기
  • 02Apache POI로 Excel 업로드/다운로드
  • 03파일 업로드/다운로드 서버 (HttpServer)
  • 04JDBC 기초와 트랜잭션 (H2)
  • 05MyBatis 어노테이션 매퍼로 쿼리 연동
  • 06MyBatis XML 매퍼 · Oracle 방언 · PageHelper · Spring Boot
  • 07REST API 서버와 JSON
  • 08Vue 3 SPA 와 Java 서버 연동
  • 09@Scheduled 운영
  • 10로깅 실무: 레벨·계층, MDC 추적, 예외·성능, 마스킹, 롤링, JSON 로그
  • 11외부 API 연동
  • 12테스트 실무
  • 13암호화·개인정보 보호
  • 14인코딩·한글 실무
  • 15@Transactional 심화
  • 16긴 작업 비동기 처리와 진행률
  • 17SFTP·FTP 파일 연계
  • 18로컬 캐시와 @Cacheable
  • 19메일·알림 발송
  • 20웹 보안 체크리스트
  • 21빌드 도구와 폐쇄망 의존성 반입
  • 22성능 측정: p50·p95·p99, 측정 계층, JFR, JMH 함정, 자체 부하 테스트, 병목 순위
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 실무 확장 › 05 / 22

MyBatis 어노테이션 매퍼로 쿼리 연동

섹션 7진행 0 / 22
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

2. 핵심 원리

2.1 구성 요소 — Configuration → SqlSessionFactory → SqlSession → Mapper

text
Configuration          설정 한 덩어리. Environment(DataSource + TransactionFactory), 매퍼 목록, TypeHandler, 옵션
   │ build
SqlSessionFactory      애플리케이션에 하나. 스레드 안전. 커넥션 풀을 안고 있다
   │ openSession()
SqlSession             요청/작업 하나에 하나. 스레드 불안전. 커넥션 하나 = 트랜잭션 하나. 반드시 close()
   │ getMapper(MemberMapper.class)
MemberMapper (프록시)  인터페이스 구현체를 MyBatis 가 런타임에 생성. 메서드 호출 → 어노테이션의 SQL 실행
java
Environment env = new Environment("dev", new JdbcTransactionFactory(), ds);   // 트랜잭션은 JDBC conn.commit()/rollback() 직접
Configuration conf = new Configuration(env);
conf.setMapUnderscoreToCamelCase(true);                                      // joined_at → joinedAt
conf.getTypeHandlerRegistry().register(Boolean.class, YesNoTypeHandler.class);
conf.addMapper(MemberMapper.class);                                          // 어노테이션 매퍼 등록
SqlSessionFactory factory = new SqlSessionFactoryBuilder().build(conf);

mybatis-config.xml 이 하는 일이 이 6줄입니다. XML 이 없으니 설정 오타가 컴파일 시점에 잡히고, 테스트에서 설정을 바꿔 끼우기가 쉽습니다. 매퍼 인터페이스는 addMapper 로 하나씩, 또는 addMappers("패키지명") 으로 한 번에 등록합니다.

2.2 매퍼 프록시 — 인터페이스만 있는데 어떻게 동작하는가

MemberMapper 는 인터페이스이고 구현 클래스는 어디에도 없습니다. session.getMapper(MemberMapper.class) 가 java.lang.reflect.Proxy 로 구현체를 만듭니다. 메서드가 호출되면 프록시는 다음을 수행합니다.

  1. 메서드에 붙은 어노테이션(@Select 등)에서 SQL 과 종류(SELECT/INSERT/UPDATE/DELETE)를 읽는다.
  2. 파라미터를 이름 → 값 맵으로 만든다. 파라미터가 하나면 그 객체의 프로퍼티, 여럿이면 @Param 이름.
  3. SQL 의 #{name} 을 ? 로 바꾸고 PreparedStatement 에 바인딩한다.
  4. 실행하고, 반환 타입(Member, List<Member>, long, int)에 맞게 결과를 매핑한다.

즉 매퍼 메서드 하나 = JDBC 레슨의 SimpleJdbc.query(sql, mapper, args) 호출 하나입니다. 반환 타입이 int 인 변경 메서드는 영향 행 수를 돌려주고, List<T> 는 전체를 메모리에 올립니다. 대량 결과는 ResultHandler 로 스트리밍합니다(변형 1).

2.3 #{} vs ${} — 바인딩과 치환

표기 처리 SQL 에 들어가는 형태 안전성
#{name} PreparedStatement 파라미터 바인딩 WHERE name = ? 그리고 setString(1, value) 인젝션 불가. 값이 SQL 문법이 될 수 없다
${sortCol} 문자열 치환 ORDER BY balance 그대로 이어 붙임 인젝션 가능. 입력값이 SQL 이 된다
java
@Select("SELECT COUNT(*) FROM member WHERE name = '${name}'")   // ❌ 입력 "' OR '1'='1" → 전원 반환
long countByNameUnsafe(@Param("name") String name);

@Select("SELECT COUNT(*) FROM member WHERE name = #{name}")      // ✅ 입력이 통째로 문자열 값
long countByNameSafe(@Param("name") String name);

${} 는 ? 로 바인딩할 수 없는 자리, 즉 테이블명·컬럼명·정렬 방향 에만 씁니다. 그리고 반드시 화이트리스트로 검증한 값만 넣습니다. ORDER BY ${sortCol} 에 사용자 입력이 그대로 들어가면 1; DROP TABLE member 가 실행됩니다. 예제 2 가 실제로 시도해 봅니다.

2.4 파라미터 규칙 — 하나면 프로퍼티, 둘 이상이면 @Param

java
int insert(Member m);                                        // 파라미터 1개 객체 → #{name}, #{email} 은 m 의 getter
Member findById(long id);                                    // 파라미터 1개 원시값 → #{id} (이름은 아무거나 가능)
int updateGrade(@Param("id") long id, @Param("grade") Member.Grade grade);   // 2개 이상 → @Param 필수
int insertOrder(Map<String, Object> order);                  // Map → #{memberId} 는 order.get("memberId")

@Param 을 빼면 #{arg0}, #{param1} 같은 이름으로만 접근할 수 있어 SQL 이 읽히지 않습니다. 파라미터가 둘 이상이면 항상 @Param 을 붙이는 것이 규칙입니다. 조건이 5개를 넘어가면 MemberSqlProvider.Search 같은 조건 객체(record) 하나로 묶는 것이 낫습니다.

2.5 결과 매핑 — 세 가지 방식

방식 대상 조건
자동 매핑 기본 생성자 + setter 클래스 컬럼명 = 프로퍼티명. mapUnderscoreToCamelCase 로 joined_at → joinedAt
@Results / @Result 컬럼명 ≠ 프로퍼티명, 연관 객체 @Result(property, column). id= 를 주면 @ResultMap 으로 재사용
@ConstructorArgs / 자동 생성자 매핑 record, 불변 객체 setter 가 없으므로 생성자로. @Arg 를 컬럼 순서대로. 컬럼 순서·타입이 딱 맞으면 어노테이션 없이도 자동
java
@Select("SELECT * FROM member WHERE id = #{id}")
@Results(id = "memberMap", value = {
    @Result(property = "id", column = "id", id = true),
    @Result(property = "active", column = "active_yn")        // 이것만 명시. 나머지는 자동 매핑이 채운다
})
Member findById(long id);

@Select("SELECT * FROM member ORDER BY id")
@ResultMap("memberMap")                                        // 같은 매핑 재사용
List<Member> findAll();

@Results 에 명시한 컬럼과 자동 매핑은 함께 동작합니다(autoMappingBehavior=PARTIAL 기본). 그래서 다른 컬럼과 이름이 다른 active_yn 하나만 적으면 됩니다.

record 는 setter 가 없어 자동 매핑이 불가능하므로 @ConstructorArgs 로 생성자 인자를 지정하거나, SELECT 컬럼 순서를 record 컴포넌트 순서와 똑같이 맞춰 자동 생성자 매핑에 맡깁니다(예제 5).

2.6 TypeHandler — 자바 타입 ↔ JDBC 타입 변환기

MyBatis 는 String, 숫자, LocalDate/LocalDateTime(3.4.5+), enum(기본은 name() 문자열) 의 핸들러를 내장합니다. 국내 레거시 스키마의 *_yn CHAR(1) 'Y'/'N' 컬럼을 boolean 으로 다루려면 직접 만듭니다.

java
public class YesNoTypeHandler extends BaseTypeHandler<Boolean> {
    @Override public void setNonNullParameter(PreparedStatement ps, int i, Boolean v, JdbcType t) throws SQLException { ps.setString(i, v ? "Y" : "N"); }
    @Override public Boolean getNullableResult(ResultSet rs, String col) throws SQLException { return "Y".equals(rs.getString(col)); }
    @Override public Boolean getNullableResult(ResultSet rs, int col) throws SQLException { return "Y".equals(rs.getString(col)); }
    @Override public Boolean getNullableResult(CallableStatement cs, int col) throws SQLException { return "Y".equals(cs.getString(col)); }
}
conf.getTypeHandlerRegistry().register(Boolean.class, YesNoTypeHandler.class);   // 전역 등록. 또는 @Result(typeHandler=) 로 컬럼별

등록 후에는 #{active} 바인딩과 active_yn 컬럼 읽기 양방향에 자동 적용됩니다. 금액 NUMBER → BigDecimal, 날짜 DATE → LocalDate 는 내장 핸들러가 처리하므로 JDBC 레슨의 실수 6("돈을 double 로")이 매핑 단계에서 원천 차단됩니다.

2.7 세션 생명주기 = 트랜잭션

text
factory.openSession()          autoCommit=false. 커넥션을 풀에서 하나 꺼낸다 (실제로는 첫 SQL 때)
  mapper.insert(...)           SQL 실행. DB 에는 갔지만 커밋 안 됨. 다른 세션에서 안 보임
  mapper.update(...)
  session.commit()             conn.commit(). 이 시점에 확정
session.close()                커넥션 반납. commit 안 했으면 rollback

factory.openSession(true)      autoCommit=true. 문장마다 커밋. 단건 조회·단건 변경에만
factory.openSession(ExecutorType.BATCH)   insert/update 를 addBatch 로 쌓고 flushStatements()/commit() 때 executeBatch

핵심 규칙: try-with-resources 로 열고, 성공 경로 끝에 commit() 을 부르고, 예외는 잡지 않고 전파합니다. 그러면 예외 시 close() 가 롤백을 해 줍니다. JDBC 레슨의 setAutoCommit(false) → commit → rollback → 반납 패턴을 세션이 캡슐화한 것입니다. SqlSession 은 스레드 안전하지 않으므로 필드에 두고 공유하면 안 됩니다.

2.8 Executor — SIMPLE / REUSE / BATCH

Executor 동작 쓰는 곳
SIMPLE (기본) 문장마다 PreparedStatement 새로 prepare → execute → close 일반 서비스
REUSE 같은 SQL 의 PreparedStatement 를 세션 안에서 재사용 같은 SQL 을 반복 실행하는 루프
BATCH 같은 SQL 을 addBatch 로 모아 commit() 때 executeBatch 대량 insert/update, 정산 배치

BATCH 에서 주의할 점 두 가지. 첫째, 다른 SQL 이 끼어들면 그 앞까지 쌓인 배치가 먼저 flush 됩니다(순서 보장). 둘째, insert 의 반환값(영향 행)은 flush 전까지 의미가 없고 useGeneratedKeys 도 flush 시점에 채워집니다. 예제 7 에서 2만 건으로 SIMPLE 과 BATCH 를 실측하고, 예제 8 에서 배치 레슨의 청크 커밋을 BATCH 세션으로 다시 구현합니다.

2.9 동적 SQL — <script> 와 Provider

조건이 있을 때만 WHERE 절에 넣고 싶을 때, 두 가지 방법이 있습니다.

java
// 방법 1: 어노테이션 안에 XML 동적 태그를 <script> 로 인라인. XML 매퍼와 같은 문법
@Select("""
    <script>
    SELECT * FROM member
    <where>
        <if test="name != null">AND name LIKE '%' || #{name} || '%'</if>
        <if test="grades != null and !grades.isEmpty()">
            AND grade IN <foreach collection="grades" item="g" open="(" separator="," close=")">#{g}</foreach>
        </if>
    </where>
    ORDER BY id
    </script>
    """)
List<Member> searchScript(@Param("name") String name, @Param("grades") List<Member.Grade> grades, ...);

// 방법 2: Provider 클래스의 메서드가 자바 코드로 SQL 문자열을 만들어 반환
@SelectProvider(type = MemberSqlProvider.class, method = "search")
List<Member> search(MemberSqlProvider.Search cond);

<where> 는 조건이 하나도 없으면 WHERE 자체를 빼고, 첫 조건의 선행 AND 를 지웁니다. <script> 는 자바 텍스트 블록(""")과 만나 XML 매퍼와 거의 같은 모양이 됩니다. >= 같은 비교 연산자는 XML 이므로 &gt;= 로 이스케이프해야 합니다.

Provider 는 MyBatis 의 SQL 빌더 클래스로 SELECT().FROM().WHERE() 를 조합하고 toString() 으로 문자열을 만듭니다. WHERE() 를 여러 번 부르면 AND 로 이어지고 하나도 안 부르면 WHERE 절이 생략됩니다.

자바 코드이므로 단위 테스트가 가능하고, 정렬 컬럼 화이트리스트 검증 같은 로직을 자연스럽게 넣을 수 있습니다. 조건이 서너 개면 <script>, 정렬·페이징·권한별 조건이 섞여 복잡해지면 Provider 가 낫습니다.

2.10 1차 캐시와 연관 조회의 N+1

SqlSession 은 같은 세션 안에서 같은 SQL + 같은 파라미터 조회 결과를 캐시합니다(1차 캐시, 로컬 캐시). 두 번째 findById(1) 은 SQL 을 실행하지 않고 같은 객체를 돌려줍니다. insert/update/delete/commit/clearCache() 가 캐시를 비우고, 세션이 다르면 공유되지 않습니다.

세션이 요청 단위로 짧게 살기 때문에 실무에서 이 캐시가 문제를 일으키는 경우는 드물지만, 한 세션에서 "조회 → 다른 경로로 DB 변경 → 다시 조회" 하면 옛 값이 나올 수 있다는 것은 알아야 합니다.

@One(select = "MemberMapper.findById") 연관 조회는 주문 목록을 1번 조회한 뒤 주문마다 회원을 1번씩 추가 조회합니다. 주문 100건이면 SQL 101번, 이것이 N+1 문제입니다. 목록 화면에서는 JOIN 한 방으로 가져와 @Result(column="m_name") 처럼 평탄하게 매핑하는 것이 정답이고(변형 2), @One 은 단건 상세 조회처럼 N 이 1 인 곳에서만 씁니다.

핵심 원리
  • 2.1 구성 요소 — Configuration → SqlSessionFactory → SqlSession → Mapper
  • 2.2 매퍼 프록시 — 인터페이스만 있는데 어떻게 동작하는가
  • 2.3 #{} vs ${} — 바인딩과 치환
  • 2.4 파라미터 규칙 — 하나면 프로퍼티, 둘 이상이면 @Param
  • 2.5 결과 매핑 — 세 가지 방식
  • 2.6 TypeHandler — 자바 타입 ↔ JDBC 타입 변환기
  • 2.7 세션 생명주기 = 트랜잭션
  • 2.8 Executor — SIMPLE / REUSE / BATCH
  • 2.9 동적 SQL — <script> 와 Provider
  • 2.10 1차 캐시와 연관 조회의 N+1
이전 섹션1 왜 배우는가2 / 7다음 섹션3 코드 예제