Configuration 설정 한 덩어리. Environment(DataSource + TransactionFactory), 매퍼 목록, TypeHandler, 옵션
│ build
SqlSessionFactory 애플리케이션에 하나. 스레드 안전. 커넥션 풀을 안고 있다
│ openSession()
SqlSession 요청/작업 하나에 하나. 스레드 불안전. 커넥션 하나 = 트랜잭션 하나. 반드시 close()
│ getMapper(MemberMapper.class)
MemberMapper (프록시) 인터페이스 구현체를 MyBatis 가 런타임에 생성. 메서드 호출 → 어노테이션의 SQL 실행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("패키지명") 으로 한 번에 등록합니다.
MemberMapper 는 인터페이스이고 구현 클래스는 어디에도 없습니다. session.getMapper(MemberMapper.class) 가 java.lang.reflect.Proxy 로 구현체를 만듭니다. 메서드가 호출되면 프록시는 다음을 수행합니다.
@Select 등)에서 SQL 과 종류(SELECT/INSERT/UPDATE/DELETE)를 읽는다.@Param 이름.#{name} 을 ? 로 바꾸고 PreparedStatement 에 바인딩한다.Member, List<Member>, long, int)에 맞게 결과를 매핑한다.즉 매퍼 메서드 하나 = JDBC 레슨의 SimpleJdbc.query(sql, mapper, args) 호출 하나입니다. 반환 타입이 int 인 변경 메서드는 영향 행 수를 돌려주고, List<T> 는 전체를 메모리에 올립니다. 대량 결과는 ResultHandler 로 스트리밍합니다(변형 1).
#{} vs ${} — 바인딩과 치환| 표기 | 처리 | SQL 에 들어가는 형태 | 안전성 |
|---|---|---|---|
#{name} |
PreparedStatement 파라미터 바인딩 |
WHERE name = ? 그리고 setString(1, value) |
인젝션 불가. 값이 SQL 문법이 될 수 없다 |
${sortCol} |
문자열 치환 | ORDER BY balance 그대로 이어 붙임 |
인젝션 가능. 입력값이 SQL 이 된다 |
@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 가 실제로 시도해 봅니다.
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) 하나로 묶는 것이 낫습니다.
| 방식 | 대상 | 조건 |
|---|---|---|
| 자동 매핑 | 기본 생성자 + setter 클래스 | 컬럼명 = 프로퍼티명. mapUnderscoreToCamelCase 로 joined_at → joinedAt |
@Results / @Result |
컬럼명 ≠ 프로퍼티명, 연관 객체 | @Result(property, column). id= 를 주면 @ResultMap 으로 재사용 |
@ConstructorArgs / 자동 생성자 매핑 |
record, 불변 객체 | setter 가 없으므로 생성자로. @Arg 를 컬럼 순서대로. 컬럼 순서·타입이 딱 맞으면 어노테이션 없이도 자동 |
@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).
MyBatis 는 String, 숫자, LocalDate/LocalDateTime(3.4.5+), enum(기본은 name() 문자열) 의 핸들러를 내장합니다. 국내 레거시 스키마의 *_yn CHAR(1) 'Y'/'N' 컬럼을 boolean 으로 다루려면 직접 만듭니다.
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 로")이 매핑 단계에서 원천 차단됩니다.
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 은 스레드 안전하지 않으므로 필드에 두고 공유하면 안 됩니다.
| 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 세션으로 다시 구현합니다.
<script> 와 Provider조건이 있을 때만 WHERE 절에 넣고 싶을 때, 두 가지 방법이 있습니다.
// 방법 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 이므로 >= 로 이스케이프해야 합니다.
Provider 는 MyBatis 의 SQL 빌더 클래스로 SELECT().FROM().WHERE() 를 조합하고 toString() 으로 문자열을 만듭니다. WHERE() 를 여러 번 부르면 AND 로 이어지고 하나도 안 부르면 WHERE 절이 생략됩니다.
자바 코드이므로 단위 테스트가 가능하고, 정렬 컬럼 화이트리스트 검증 같은 로직을 자연스럽게 넣을 수 있습니다. 조건이 서너 개면 <script>, 정렬·페이징·권한별 조건이 섞여 복잡해지면 Provider 가 낫습니다.
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 인 곳에서만 씁니다.