[아이티센 부트캠프] My Batis

이언덕·2026년 4월 27일

아이티센 부트캠프

목록 보기
77/115
post-thumbnail

MyBatis 기본 개념과 Mapper 사용 흐름

이 글은 MyBatis 코드를 바로 외우는 글이 아니다.
웹 애플리케이션에서 DB 처리가 어디에 위치하는지 먼저 보고, JDBC 방식의 반복 문제를 이해한 뒤, 그 문제를 MyBatis가 어떻게 줄여 주는지 정리한다.


MyBatis의 핵심은 데이터 접근 계층에서 SQL과 Java 메서드를 연결하고, SQL 실행 결과를 Java 객체로 매핑해 주는 것이다.
이 흐름을 먼저 잡아야 Mapper, @Select, #{} 같은 문법도 단순 암기가 아니라 구조로 이해할 수 있다.


정리 흐름은 웹 계층 구조 → JDBC의 반복 문제 → MyBatis의 역할 → Mapper 기본 구조 → 기본 CRUD 예제 → Controller 연결 흐름 → 동적 파라미터 문법 차이 순서로 간다.




웹 계층 구조와 MyBatis의 위치

웹 애플리케이션은 크게 사용자가 보는 쪽, 요청을 처리하는 쪽, 데이터를 저장하는 쪽으로 나눌 수 있다.
이 구조를 먼저 알아야 MyBatis가 어디에서 필요한 기술인지 이해할 수 있다.


가장 단순하게 보면 웹 애플리케이션은 Client, Server, Database로 나뉜다.
Client는 사용자가 직접 보는 화면이다.
Server는 사용자의 요청을 받아 처리하는 영역이다.
Database는 서비스에서 필요한 데이터를 저장하는 영역이다.

웹 애플리케이션은 사용자의 요청을 받는 Client, 요청을 처리하는 Server, 데이터를 저장하는 Database로 나누어 볼 수 있다.


사용자가 버튼을 누르거나 주소를 입력하면 요청은 Client에서 Server로 간다.
Server는 필요한 처리를 한 뒤, 데이터가 필요하면 Database에 접근한다.
그리고 처리 결과를 다시 Client에게 응답한다.
즉, 사용자는 화면만 보는 것처럼 느끼지만 내부에서는 요청 처리와 데이터 조회가 함께 일어난다.


클라이언트, 웹/애플리케이션 서버, 데이터베이스 서버 구조

조금 더 자세히 보면 웹 애플리케이션은 클라이언트, 웹/애플리케이션 서버, 데이터베이스 서버로 나누어 볼 수 있다.
클라이언트는 사용자가 직접 화면을 보고 조작하는 영역이다.
웹/애플리케이션 서버는 사용자의 요청을 받아 실제 처리를 수행하는 영역이다.
데이터베이스 서버는 서비스에서 사용하는 데이터를 저장하고, 필요한 데이터를 조회해서 돌려주는 영역이다.

이 그림은 클라이언트, 웹/애플리케이션 서버, 데이터베이스 서버를 나누고, 각 영역 안에서 계층이 어떻게 나뉘는지 보여 준다.


이 그림에서 중요한 점은 프리젠테이션 계층이 한 곳에만 있는 것이 아니라는 점이다.
클라이언트에도 사용자가 직접 보는 화면 영역이 있으므로 프리젠테이션 계층이 있다.
그리고 웹/애플리케이션 서버 안에도 화면 응답을 준비하는 프리젠테이션 계층이 있다.


Thymeleaf는 바로 이 중에서 웹/애플리케이션 서버 쪽 프리젠테이션 계층과 연결된다.
Thymeleaf는 서버에서 HTML 응답 화면을 만들 때 사용하는 템플릿 엔진이다.
따라서 사용자가 보는 최종 화면은 Client에 나타나지만, 그 화면을 준비하는 과정은 웹/애플리케이션 서버의 프리젠테이션 계층에서도 일어난다.


웹/애플리케이션 서버 안의 계층은 다음처럼 이해하면 된다.

  • 프리젠테이션 계층은 사용자에게 보여 줄 화면이나 응답을 준비하는 영역이다.
  • 이 흐름에서는 Thymeleaf가 서버에서 HTML 응답 화면을 만드는 역할과 연결된다.
  • 비즈니스 로직 계층은 요청에 따라 어떤 처리를 할지 판단하고 처리 흐름을 구성하는 영역이다.
  • 이 단계에서는 Controller가 요청을 받아 처리 흐름을 연결하는 진입점 역할을 한다.
  • 다만 Spring 기준으로 더 정확히 나누면 Controller는 요청과 응답을 연결하는 Presentation Layer에 가깝고, 실제 핵심 비즈니스 로직은 보통 Service 계층으로 분리한다.
  • 데이터 접근 계층은 실제 DB에 접근해서 데이터를 조회하거나 변경하는 영역이다.
  • 이 흐름에서는 DAO 또는 Repository가 데이터 접근 계층에 해당한다.

즉, 사용자는 클라이언트에서 화면을 보고 요청한다.
요청은 웹/애플리케이션 서버로 이동하고, 서버 안에서 화면 응답 준비, 요청 처리, 데이터 접근 역할이 나뉘어 처리된다.
데이터가 필요하면 데이터 접근 계층이 데이터베이스 서버와 연결된다.


MyBatis는 이 흐름에서 화면을 만드는 기술이 아니라, DAO 또는 Repository처럼 DB에 접근하는 계층을 도와주는 기술이다.
그래서 MyBatis를 이해하려면 먼저 데이터 접근 계층이 어디에 있는지 잡아야 한다.


MVC 구조와 데이터 접근 흐름

MVC는 화면 요청을 처리할 때 자주 사용하는 구조이다.
MVC는 Model, View, Controller를 나누어 관리하는 방식이다.
Model은 화면에 필요한 데이터 또는 데이터를 담는 객체이다.
View는 사용자에게 보여 줄 화면이다.
Controller는 요청을 받아 어떤 화면과 데이터를 사용할지 연결한다.

MVC 구조와 3계층 구조를 함께 보면, 화면 요청 흐름과 데이터 접근 흐름이 분리되어 있다는 점을 더 쉽게 이해할 수 있다.


이 그림에서 Presentation Layer에는 Controller와 View가 있다.
Controller는 요청을 받고, View는 응답 화면을 담당한다.
Business Layer에는 서비스 모델이나 도메인 모델처럼 실제 처리 흐름에 필요한 객체가 놓인다.
Data Access Layer에는 DAO가 있고, DAO가 Database와 연결된다.


여기서 중요한 점은 Controller가 직접 DB에 붙어서 모든 일을 처리하지 않는다는 것이다.
Controller는 요청 흐름을 잡고, 데이터 접근이 필요하면 DAO 같은 데이터 접근 객체에게 일을 맡긴다.
그리고 DAO는 DB와 연결해서 필요한 데이터를 가져오거나 변경한다.


초보자는 Controller가 모든 일을 다 한다고 생각하기 쉽다.
하지만 역할을 나누면 코드가 훨씬 읽기 쉬워진다.
요청 처리는 Controller, 화면 출력은 View, 데이터 접근은 DAO가 맡는다고 구분하면 된다.


Spring 계층 구조에서 보는 MyBatis 위치

Spring에서는 역할에 따라 계층을 더 명확하게 나누는 경우가 많다.
요청을 처리하는 계층, 핵심 로직을 처리하는 계층, 데이터를 접근하는 계층을 분리한다.

Spring에서는 요청 처리, 비즈니스 처리, 데이터 접근 처리를 계층으로 나누고, 각 계층을 @Controller, @Service, @Repository로 구분한다.


Spring에서 자주 보는 계층은 다음과 같다.

  • Presentation Layer는 요청과 응답을 처리하는 계층이다.
  • 이 계층에서는 주로 @Controller가 사용된다.
  • Business Layer는 핵심 처리 흐름이나 비즈니스 로직을 담당하는 계층이다.
  • 이 계층에서는 주로 @Service가 사용된다.
  • Persistence Layer는 DB나 파일 같은 외부 저장소에 접근하는 계층이다.
  • 이 계층에서는 주로 @Repository 또는 DAO가 사용된다.

이 중에서 MyBatis는 Persistence Layer와 가장 관련이 깊다.
왜냐하면 MyBatis는 DB에 보낼 SQL을 실행하고, 실행 결과를 Java 객체로 받아오는 일을 도와주기 때문이다.

MyBatis는 Controller나 화면에서 직접 쓰는 기술이 아니라, Repository 또는 DAO 계층에서 DB 접근을 처리할 때 사용하는 기술이다.


전체 흐름은 이렇게 이어진다.

  • Client가 요청을 보낸다.
  • Controller가 요청을 받는다.
  • 필요한 처리는 Service로 넘길 수 있다.
  • 데이터가 필요하면 Repository 또는 DAO가 DB에 접근한다.
  • 이때 Repository 또는 DAO 안에서 JDBC, MyBatis, JPA 같은 기술을 사용할 수 있다.

즉, MyBatis는 화면을 만드는 기술이 아니라, DB와 Java 코드 사이를 연결하는 데이터 접근 기술이다.
이 위치를 정확히 잡아야 Mapper가 왜 필요한지도 자연스럽게 이어진다.




JDBC로 직접 DB를 다룰 때 생기는 문제

JDBC는 Java에서 관계형 Database와 연결하기 위한 기본 기술이다.
Java 프로그램이 MySQL 같은 DB에 접근하려면 결국 JDBC 흐름을 거쳐야 한다.


다만 JDBC를 직접 사용하면 작성해야 할 코드가 많다.
DB 연결을 만들고, SQL을 실행하고, 결과를 읽고, 마지막에 자원까지 닫아야 한다.
이 과정이 조회, 추가, 수정, 삭제 기능마다 반복된다.

JDBC는 연결 생성, SQL 실행, 결과 처리, 자원 정리 과정이 반복되기 때문에 코드가 길어지기 쉽다.


JDBC의 기본 흐름은 다음과 같다.

  • DriverManager를 통해 DB 연결을 준비한다.
  • Connection으로 DB와 연결된 통로를 만든다.
  • Statement 또는 PreparedStatement로 SQL 실행 준비를 한다.
  • SELECT는 executeQuery()로 실행하고 결과를 ResultSet으로 받는다.
  • INSERT, UPDATE, DELETE는 executeUpdate()로 실행하고 영향받은 행 수를 받는다.
  • 사용이 끝난 Connection, Statement, ResultSet은 close()로 닫는다.

여기서 ResultSet은 조회 결과를 한 줄씩 읽기 위한 객체이다.
예를 들어 직원 목록을 조회하면 ResultSet 안에는 여러 행의 데이터가 들어 있고, 개발자는 next()로 한 줄씩 이동하면서 getInt(), getString() 같은 메서드로 값을 꺼낸다.


JDBC 코드가 복잡해지는 이유

JDBC는 세밀하게 제어할 수 있다는 장점이 있다.
하지만 매번 비슷한 코드가 반복된다는 단점이 있다.


예를 들어 직원 한 명을 조회한다고 해도 다음 작업이 필요하다.

  • DB 연결을 만든다.
  • SQL 문자열을 작성한다.
  • SQL을 실행한다.
  • 조회 결과에서 컬럼 값을 하나씩 꺼낸다.
  • 꺼낸 값을 VO 또는 DTO 객체에 직접 넣는다.
  • 사용한 자원을 닫는다.

여기서 VO와 DTO는 데이터를 담아 이동시키기 위한 객체라고 이해하면 된다.
VO는 값 객체라는 뜻으로 많이 사용되고, DTO는 계층 사이에서 데이터를 전달하는 객체라는 뜻으로 많이 사용된다.
이 단계에서는 둘 다 조회 결과를 담는 그릇 정도로 이해하면 된다.


문제는 이 과정이 기능마다 계속 반복된다는 것이다.
직원 조회, 직원 목록 조회, 직원 추가, 직원 수정, 직원 삭제를 만들 때마다 연결, 실행, 결과 처리, 종료 코드가 반복된다.
또 Java 코드 안에 SQL 문자열이 길게 들어가면 코드가 읽기 어려워진다.


JDBC의 문제는 DB 접근이 불가능하다는 것이 아니라, 반복 코드가 많고 Java 코드와 SQL이 섞여 복잡해지기 쉽다는 것이다.
MyBatis는 이 불편함을 줄이기 위해 사용한다.




MyBatis란 무엇인가

MyBatis는 SQL Mapper 기능을 지원하는 퍼시스턴스 프레임워크이다.
여기서 퍼시스턴스는 데이터를 오래 저장하는 영역, 즉 DB와 관련된 처리를 뜻한다.
프레임워크는 반복되는 작업을 정해진 구조로 편하게 처리할 수 있게 도와주는 도구이다.


따라서 MyBatis는 쉽게 말해 DB 접근 코드를 더 편하게 작성하도록 도와주는 도구이다.
특히 개발자가 작성한 SQL과 Java 메서드를 연결하고, SQL 실행 결과를 Java 객체로 받을 수 있게 도와준다.

MyBatis는 개발자가 작성한 SQL을 실행하고, 그 실행 결과를 Java 객체로 매핑해 주는 프레임워크이다.


MyBatis를 사용하면 SQL은 Mapper에 작성하고, Java 코드는 그 Mapper 메서드를 호출한다.
그러면 MyBatis가 중간에서 메서드 호출과 SQL 실행을 연결한다.
그리고 실행 결과를 VO, DTO, List 같은 Java 객체로 받아올 수 있게 도와준다.


앞에서 본 계층 구조와 연결하면 더 분명하다.
Controller는 요청을 받고, Service는 필요한 처리를 맡고, Repository 또는 DAO는 DB에 접근한다.
MyBatis는 이 마지막 데이터 접근 구간에서 SQL 실행과 객체 매핑을 도와준다.


SQL Mapper라는 말의 의미

SQL Mapper는 SQL과 객체를 연결해 주는 기능이라고 보면 된다.
개발자는 select empno, ename from emp 같은 SQL을 작성한다.
그리고 그 결과를 EmpVO 같은 Java 객체로 받고 싶어 한다.
이때 MyBatis가 SQL 실행 결과와 Java 객체 사이를 연결해 준다.


이 구조가 필요한 이유는 DB와 Java가 데이터를 다루는 방식이 다르기 때문이다.
DB는 데이터를 테이블, 행, 컬럼으로 다룬다.
Java는 데이터를 객체와 필드로 다룬다.
MyBatis는 이 둘 사이를 이어 준다.


MyBatis를 사용하면 다음 흐름이 가능해진다.

  • 개발자는 SQL을 작성한다.
  • Mapper 메서드에 그 SQL을 연결한다.
  • Controller나 Service는 Mapper 메서드를 호출한다.
  • MyBatis가 연결된 SQL을 실행한다.
  • 실행 결과는 Java 객체나 리스트로 돌아온다.

즉, MyBatis는 SQL을 없애는 기술이 아니다.
오히려 개발자가 직접 작성한 SQL을 유지하면서, 그 SQL을 Java 코드와 편하게 연결해 주는 기술이다.


JDBC와 MyBatis의 차이

JDBC와 MyBatis는 완전히 다른 세계가 아니다.
MyBatis도 내부적으로는 JDBC 기반으로 DB와 통신한다.
다만 개발자가 직접 작성해야 했던 반복 작업을 MyBatis가 많이 대신 처리해 준다.


차이는 이렇게 정리할 수 있다.

  • JDBC는 연결, 실행, 결과 처리, 자원 정리를 개발자가 직접 작성한다.
  • MyBatis는 SQL과 메서드 연결에 집중하게 해 준다.
  • JDBC는 결과를 객체에 옮기는 코드를 직접 작성해야 한다.
  • MyBatis는 결과를 VO, DTO, List로 받을 수 있게 도와준다.
  • JDBC는 Java 코드 안에 SQL이 섞이기 쉽다.
  • MyBatis는 SQL을 Mapper 중심으로 관리할 수 있다.

MyBatis는 JDBC를 완전히 없애는 기술이 아니라, 개발자가 직접 작성하던 JDBC 반복 코드를 줄여 주는 기술이다.
그래서 SQL을 직접 제어하고 싶지만, 연결과 결과 처리 같은 반복 코드는 줄이고 싶을 때 사용한다.


JPA처럼 SQL을 많이 숨기는 방식과는 다르게, MyBatis는 SQL을 직접 보면서 개발할 수 있다는 특징이 있다.




MyBatis를 사용하기 위한 기본 설정

Spring Boot에서 MyBatis를 사용하려면 먼저 필요한 설정을 해야 한다.
설정의 목적은 크게 두 가지이다.
첫 번째는 DB 접속 정보를 Spring에게 알려 주는 것이다.
두 번째는 Mapper 인터페이스가 어디에 있는지 Spring에게 알려 주는 것이다.


설정이 되어 있어야 Spring이 DB에 연결할 수 있고, MyBatis가 Mapper를 찾아 SQL을 실행할 수 있다.
즉, 설정은 단순한 준비 작업이 아니라 Spring, MyBatis, DB를 서로 연결하는 시작점이다.


build.gradle 의존성 설정

MyBatis를 쓰려면 프로젝트에 MyBatis 관련 라이브러리가 필요하다.
Spring Boot에서는 보통 MyBatis 스타터 의존성을 추가한다.

// exam01.gradle
dependencies {
    implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3' // MyBatis 사용 준비
    runtimeOnly 'com.mysql:mysql-connector-j' // MySQL 연결 드라이버
}

이 설정에서 mybatis-spring-boot-starter는 Spring Boot와 MyBatis를 함께 쓰기 위한 의존성이다.
mysql-connector-j는 Java 프로그램이 MySQL에 접속할 수 있게 해 주는 JDBC 드라이버이다.


즉, 첫 번째 의존성은 MyBatis 사용을 위한 것이고, 두 번째 의존성은 MySQL 연결을 위한 것이다.
실제 버전은 프로젝트에서 사용하는 Spring Boot 버전과 코드 기준에 맞추면 된다.


application.properties DB 접속 설정

의존성을 추가해도 DB 주소와 계정 정보를 모르면 연결할 수 없다.
그래서 application.properties에 접속 정보를 작성한다.

// exam02.properties
spring.datasource.url=jdbc:mysql://localhost:3306/edudb?characterEncoding=UTF-8
spring.datasource.username=jdbctest
spring.datasource.password=jdbctest
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

여기서 spring.datasource.url은 접속할 DB 주소이다.
localhost:3306은 내 컴퓨터의 MySQL 기본 포트에 접속한다는 뜻이다.
edudb는 사용할 데이터베이스 이름이다.


username과 password는 DB 접속 계정이다.
driver-class-name은 어떤 JDBC 드라이버를 사용할지 지정하는 설정이다.
즉, 이 설정들은 어느 DB에 어떤 계정으로 접속할지 알려 주는 정보이다.


MapperScan 설정

Mapper 인터페이스를 만들었다고 해서 Spring이 무조건 자동으로 찾는 것은 아니다.
Mapper가 있는 패키지를 알려 주어야 한다.
이때 @MapperScan을 사용할 수 있다.

// exam03.java
@SpringBootApplication
@MapperScan(value = {"mybatis.dao"}) // Mapper 인터페이스가 있는 패키지 지정
public class SpringeduApplication {
    public static void main(String[] args) {
        SpringApplication.run(SpringeduApplication.class, args); // Spring Boot 실행
    }
}

@MapperScan은 MyBatis가 사용할 Mapper 인터페이스를 어디에서 찾을지 알려 주는 설정이다.
예를 들어 mybatis.dao 패키지 안에 EmpMapper가 있다면, @MapperScan(value = {"mybatis.dao"})로 해당 패키지를 스캔하게 만든다.


@MapperScan으로 패키지를 지정하면 해당 패키지 안의 Mapper 인터페이스들을 한 번에 찾을 수 있다.
반대로 개별 인터페이스마다 @Mapper를 붙여서 Mapper임을 표시할 수도 있다.
둘 다 Mapper를 Spring이 인식하게 만드는 방법이지만, 보통 프로젝트에서는 한 가지 방식을 기준으로 사용한다.


정리하면 build.gradle은 필요한 도구를 추가하는 설정이고, application.properties는 DB 접속 정보이며, @MapperScan은 Mapper 위치를 알려 주는 설정이다.




Mapper 인터페이스 기본 구조

Mapper 인터페이스는 Java 메서드와 SQL을 연결하는 곳이다.
초보자 기준에서는 Mapper를 DAO 역할을 하는 인터페이스라고 이해해도 된다.


일반적인 DAO는 클래스 안에 JDBC 코드를 직접 작성해서 DB에 접근했다.
반면 MyBatis의 Mapper는 인터페이스 메서드 위에 SQL을 연결해 둔다.
그러면 MyBatis가 그 메서드 호출을 보고 연결된 SQL을 실행한다.


Mapper에서 자주 보는 어노테이션

Mapper 인터페이스에서는 다음 어노테이션을 자주 본다.

  • @Mapper는 이 인터페이스가 MyBatis의 Mapper라는 표시이다.
  • @Select는 조회용 SQL을 연결한다.
  • @Insert는 추가용 SQL을 연결한다.
  • @Update는 수정용 SQL을 연결한다.
  • @Delete는 삭제용 SQL을 연결한다.

이 어노테이션들은 직접 DB 작업을 실행하는 코드가 아니다.
정확히는 이 메서드가 호출되면 어떤 SQL을 실행해야 하는지 MyBatis에게 알려 주는 표시이다.


가장 단순한 Mapper 예제

아래 코드는 직원 수를 조회하는 가장 단순한 Mapper 예제이다.

// exam04.java
@Mapper
public interface EmpMapper {
    @Select("select count(*) from emp") // emp 테이블의 전체 행 개수 조회
    int countAll(); // 조회 결과 숫자 하나를 int로 반환
}

이 코드에서 countAll()은 메서드 이름이다.
Java 코드에서는 이 메서드를 호출한다.
그러면 MyBatis는 메서드 위의 @Select에 적힌 SQL을 실행한다.


select count(*) from emp는 숫자 하나를 반환하는 SQL이다.
그래서 메서드 반환 타입도 int로 작성했다.
즉, Mapper 메서드의 반환 타입은 SQL 실행 결과가 어떤 형태로 돌아오는지에 맞춰야 한다.




기본 SELECT 예제로 Mapper 이해하기

SELECT는 DB에서 데이터를 조회할 때 사용한다.
MyBatis에서는 @Select를 사용해서 조회 SQL을 Mapper 메서드에 연결할 수 있다.


조회 결과는 상황에 따라 다르게 받을 수 있다.
숫자 하나만 조회하면 int로 받을 수 있다.
직원 한 명을 조회하면 EmpVO 같은 객체 하나로 받을 수 있다.
직원 여러 명을 조회하면 List<EmpVO>로 받을 수 있다.


조회 결과를 담을 VO

먼저 조회 결과를 담을 객체가 필요하다.
아래 EmpVO는 직원 한 명의 정보를 담는 객체이다.

// exam05.java
@Getter
@Setter
public class EmpVO {
    private int empno; // 사번
    private String ename; // 직원명
    private String job; // 직무
}

EmpVO의 필드 이름은 SQL 조회 결과의 컬럼명과 맞추는 것이 좋다.
예를 들어 SQL에서 empno, ename, job을 조회하면 MyBatis가 이 값을 EmpVO의 필드와 연결할 수 있다.


여기서 @Getter, @Setter는 Lombok을 사용할 때 게터와 세터를 자동으로 만들어 주는 어노테이션이다.
학습 단계에서는 EmpVO가 직원 조회 결과를 담는 그릇이라고 이해하면 된다.


전체 개수 조회

전체 개수 조회는 결과가 숫자 하나이다.
그래서 반환 타입은 int가 자연스럽다.

// exam06.java
@Mapper
public interface EmpMapper {
    @Select("select count(*) from emp") // 전체 직원 수 조회
    int countAll(); // 숫자 하나 반환
}

이 예제는 Mapper의 가장 단순한 조회 구조를 보여 준다.
SQL 결과가 숫자 하나이므로 int로 받는다.
즉, 조회 결과가 단일 숫자이면 기본형 숫자 타입으로 받을 수 있다.


조건 하나로 직원 한 명 조회

이번에는 사번으로 직원 한 명을 조회한다.
조회 결과가 한 명이면 EmpVO 객체 하나로 받는다.

// exam07.java
@Mapper
public interface EmpMapper {
    @Select("select empno, ename, job from emp where empno = #{empno}") // 사번 조건으로 한 명 조회
    EmpVO findByEmpno(@Param("empno") int empno); // 매개변수 이름을 SQL에서 empno로 사용
}

여기서 #{empno}는 메서드 매개변수 empno 값을 SQL에 넣는 자리이다.
예를 들어 findByEmpno(7788)을 호출하면 #{empno} 자리에 7788 값이 들어간다고 이해하면 된다.


@Param("empno")는 SQL 안에서 이 매개변수를 empno라는 이름으로 사용하겠다는 뜻이다.
이렇게 이름을 붙이면 SQL의 #{empno}와 메서드 매개변수의 연결이 분명해진다.


조회 결과가 있으면 EmpVO 객체로 돌아온다.
조회 결과가 없으면 null이 될 수 있다.
즉, 한 명 조회는 항상 객체가 나온다고 단정하면 안 된다.
조건에 맞는 데이터가 없을 수도 있다.


전체 목록 조회

이번에는 직원 목록 전체를 조회한다.
조회 결과가 여러 행이면 List<EmpVO>로 받는다.

// exam08.java
@Mapper
public interface EmpMapper {
    @Select("select empno, ename, job from emp") // 직원 목록 전체 조회
    List<EmpVO> findAll(); // 여러 행을 List로 반환
}

List<EmpVO>는 EmpVO 객체가 여러 개 들어 있는 목록이다.
SQL 조회 결과가 여러 행이면 한 행마다 EmpVO 객체가 만들어지고, 그 객체들이 List에 담긴다고 이해하면 된다.


정리하면 조회 결과에 따라 반환 타입이 달라진다.

  • 숫자 하나를 조회하면 int를 사용할 수 있다.
  • 한 행을 조회하면 VO 또는 DTO 객체 하나를 사용할 수 있다.
  • 여러 행을 조회하면 List<VO> 또는 List<DTO>를 사용할 수 있다.

이 차이를 알아야 Mapper 메서드를 작성할 때 반환 타입을 자연스럽게 정할 수 있다.




동적 파라미터 이해하기

#{}는 Mapper 메서드의 매개변수 값을 SQL 안에 넣을 때 사용한다.
여기서 동적 파라미터는 실행할 때마다 바뀔 수 있는 값을 뜻한다.


예를 들어 사번 7788을 조회할 수도 있고, 사번 7902를 조회할 수도 있다.
이처럼 매번 달라지는 값을 SQL에 넣기 위해 #{}를 사용한다.


일반적인 조건값은 ${}보다 #{}를 우선 사용해야 한다.
#{}는 값을 안전하게 전달하는 방식이고, 값의 타입에 맞게 처리되기 때문이다.


단일 매개변수 연결

가장 단순한 형태는 매개변수 하나를 SQL에 넣는 방식이다.

// exam09.java
@Mapper
public interface EmpMapper {
    @Select("select empno, ename, job from emp where ename = #{ename}") // 이름 조건으로 조회
    EmpVO findByEname(@Param("ename") String ename); // ename 값을 SQL에 전달
}

이 예제에서 findByEname("SCOTT")을 호출하면 #{ename} 자리에 "SCOTT" 값이 들어간다.
즉, #{}는 메서드 매개변수를 SQL 조건에 연결하는 역할을 한다.


여러 매개변수 연결

매개변수가 여러 개일 때는 이름을 분명하게 지정해 주는 방식이 안전하다.
이때 @Param을 사용할 수 있다.

// exam10.java
@Mapper
public interface EmpMapper {
    @Select("select empno, ename, job from emp where empno = #{empno} and job = #{job}") // 사번과 직무 조건
    EmpVO findByEmpnoAndJob(@Param("empno") int empno, @Param("job") String job); // 각각의 이름을 SQL에서 사용
}

@Param("empno")는 첫 번째 매개변수를 SQL에서 #{empno}라는 이름으로 쓰겠다는 뜻이다.
@Param("job")은 두 번째 매개변수를 #{job}이라는 이름으로 쓰겠다는 뜻이다.


이렇게 이름을 붙이면 매개변수가 여러 개여도 SQL에서 어떤 값이 어디에 들어가는지 읽기 쉽다.
즉, @Param은 여러 값을 SQL에 넘길 때 이름을 분명하게 해 주는 도구이다.


객체 프로퍼티 연결

VO나 DTO 객체를 매개변수로 넘길 수도 있다.
이때는 객체의 프로퍼티 이름을 #{} 안에 작성한다.

// exam11.java
@Mapper
public interface EmpMapper {
    @Select("select empno, ename, job from emp where empno = #{empno} and ename = #{ename}") // 객체 필드 조건 사용
    EmpVO findByEmpInfo(EmpVO emp); // EmpVO 안의 empno, ename 값을 사용
}

이 예제에서 EmpVO 객체 안에 empno와 ename 값이 들어 있다면, SQL에서는 #{empno}, #{ename}으로 그 값을 사용할 수 있다.
즉, 객체를 넘길 때는 객체 안의 필드 이름을 기준으로 값을 꺼내 쓴다고 이해하면 된다.


이 구조는 INSERT, UPDATE에서 더 자주 보인다.
추가나 수정은 여러 값을 한 번에 넘겨야 하므로 객체 하나로 묶어 전달하는 방식이 편하다.




기본 INSERT, UPDATE, DELETE 예제

SELECT가 데이터를 조회하는 작업이라면, INSERT, UPDATE, DELETE는 데이터를 변경하는 작업이다.
이 셋은 조회 결과 객체를 반환하는 것보다, 실행이 되었는지 또는 몇 행이 영향을 받았는지를 확인하는 경우가 많다.


MyBatis에서는 @Insert, @Update, @Delete로 각각의 SQL을 메서드에 연결한다.
이때 객체를 매개변수로 넘기면 객체 안의 값을 #{}로 꺼내 사용할 수 있다.


INSERT 기본 예제

INSERT는 데이터를 추가할 때 사용한다.
직원 한 명을 추가하려면 EmpVO 객체에 사번, 이름, 직무를 담아 넘길 수 있다.

// exam12.java
@Mapper
public interface EmpMapper {
    @Insert("insert into emp (empno, ename, job) values (#{empno}, #{ename}, #{job})") // 객체 값을 사용해 행 추가
    int insertEmp(EmpVO emp); // 추가된 행 수 반환
}

이 예제에서 #{empno}, #{ename}, #{job}은 EmpVO 객체 안의 값을 사용한다.
예를 들어 emp.getEmpno(), emp.getEname(), emp.getJob()에 해당하는 값이 SQL로 전달된다고 이해하면 된다.


반환 타입 int는 영향받은 행 수를 의미한다.
직원 한 명이 정상적으로 추가되면 보통 1이 반환된다.
즉, 변경 작업에서는 몇 행이 처리되었는지를 확인할 수 있다.


UPDATE 기본 예제

UPDATE는 기존 데이터를 수정할 때 사용한다.
아래 예제는 사번이 같은 직원의 직무를 수정한다.

// exam13.java
@Mapper
public interface EmpMapper {
    @Update("update emp set job = #{job} where empno = #{empno}") // 사번이 같은 직원의 직무 수정
    int updateJob(EmpVO emp); // 수정된 행 수 반환
}

이 예제에서는 empno가 조건으로 사용되고, job이 수정할 값으로 사용된다.
즉, #{empno}는 어떤 직원을 수정할지 찾는 값이고, #{job}은 어떤 직무로 바꿀지 정하는 값이다.


UPDATE도 반환 타입을 int로 두면 수정된 행 수를 확인할 수 있다.
조건에 맞는 직원이 없으면 0이 반환될 수 있다.
따라서 1이 아니라고 해서 무조건 코드가 깨진 것은 아니고, 조건에 맞는 데이터가 없을 수도 있다.


DELETE 기본 예제

DELETE는 데이터를 삭제할 때 사용한다.
아래 예제는 사번을 기준으로 직원 한 명을 삭제한다.

// exam14.java
@Mapper
public interface EmpMapper {
    @Delete("delete from emp where empno = #{empno}") // 사번 조건으로 직원 삭제
    int deleteEmp(@Param("empno") int empno); // 삭제된 행 수 반환
}

deleteEmp(9000)을 호출하면 #{empno} 자리에 9000이 들어간다.
그리고 사번이 9000인 행이 있으면 삭제된다.


DELETE도 반환 타입을 int로 두면 삭제된 행 수를 확인할 수 있다.
삭제할 데이터가 없으면 0이 반환될 수 있다.
즉, 변경 작업에서는 실행 결과를 객체로 받기보다 처리된 행 수로 확인하는 흐름이 자연스럽다.


조회 작업과 변경 작업의 반환 타입 차이

SELECT와 변경 작업은 반환 타입을 다르게 생각해야 한다.
SELECT는 데이터를 가져오는 작업이다.
그래서 숫자, 객체, 리스트를 반환하는 경우가 많다.


반대로 INSERT, UPDATE, DELETE는 데이터를 바꾸는 작업이다.
그래서 보통 처리된 행 수를 int로 받거나, 성공 여부를 boolean으로 받을 수 있다.


정리하면 다음과 같다.

  • SELECT count(*)는 int로 받을 수 있다.
  • 한 행 조회는 VO 또는 DTO로 받을 수 있다.
  • 여러 행 조회는 List<VO> 또는 List<DTO>를 사용할 수 있다.
  • INSERT, UPDATE, DELETE는 처리된 행 수를 int로 받을 수 있다.
  • 변경 성공 여부만 보고 싶으면 boolean으로 받을 수도 있다.

조회는 결과 데이터를 받는 작업이고, 변경은 처리 결과를 확인하는 작업이다.
이 차이를 기억하면 Mapper 메서드의 반환 타입을 훨씬 쉽게 정할 수 있다.




Controller에서 Mapper를 사용하는 흐름

Mapper를 만들었다고 해서 사용자가 바로 Mapper를 호출하는 것은 아니다.
사용자는 브라우저에서 요청을 보내고, 그 요청은 먼저 Controller로 들어온다.


Controller는 직접 SQL을 실행하지 않는다.
대신 Mapper를 주입받아 필요한 메서드를 호출한다.
그러면 Mapper가 연결된 SQL을 실행하고 결과를 반환한다.
Controller는 그 결과를 Model이나 ModelAndView에 담아 화면으로 넘긴다.


Mapper를 주입받는 기본 구조

아래 코드는 생성자 주입 방식으로 EmpMapper를 사용하는 예제이다.
생성자 주입은 필요한 객체를 생성자를 통해 받는 방식이다.

// exam15.java
@Controller
public class EmpController {
    private final EmpMapper empMapper; // Mapper 의존성 보관

    public EmpController(EmpMapper empMapper) {
        this.empMapper = empMapper; // Spring이 EmpMapper를 주입
    }

    @GetMapping("/emp/count")
    public String count(Model model) {
        int count = empMapper.countAll(); // Mapper 메서드 호출
        model.addAttribute("count", count); // 조회 결과를 화면에 전달
        return "empCount"; // 결과 화면 이름 반환
    }
}

이 코드에서 Controller는 SQL을 직접 작성하지 않는다.
empMapper.countAll()만 호출한다.
그러면 EmpMapper에 연결된 select count(*) from emp가 실행된다.


이 흐름에서 각 역할은 분명하다.

  • Controller는 요청을 받는다.
  • Controller는 Mapper 메서드를 호출한다.
  • Mapper는 연결된 SQL을 실행한다.
  • DB는 결과를 반환한다.
  • Controller는 결과를 Model에 담는다.
  • View는 Model에 담긴 값을 화면에 출력한다.

이 구조를 한 줄로 쓰면 Controller → Mapper → DB → 결과 객체 → View 흐름이다.


직원 한 명 조회 흐름

이번에는 사번을 받아 직원 한 명을 조회하는 흐름이다.

// exam16.java
@Controller
public class EmpController {
    private final EmpMapper empMapper; // Mapper 의존성 보관

    public EmpController(EmpMapper empMapper) {
        this.empMapper = empMapper; // Spring이 EmpMapper를 주입
    }

    @GetMapping("/emp/detail")
    public String detail(@RequestParam("empno") int empno, Model model) {
        EmpVO emp = empMapper.findByEmpno(empno); // 사번 조건으로 직원 조회
        model.addAttribute("emp", emp); // 조회 결과 객체를 화면에 전달
        return "empDetail"; // 결과 화면 이름 반환
    }
}

이 예제에서 사용자가 /emp/detail?empno=7788처럼 요청하면 empno 값이 컨트롤러 매개변수로 들어온다.
그다음 empMapper.findByEmpno(empno)가 호출된다.
Mapper는 #{empno} 자리에 전달받은 사번 값을 넣어 SQL을 실행한다.


조회 결과가 있으면 EmpVO 객체가 emp라는 이름으로 모델에 담긴다.
화면에서는 이 emp 객체를 사용해서 직원 정보를 출력할 수 있다.
즉, Controller는 DB에서 직접 값을 꺼내는 것이 아니라, Mapper를 통해 조회 결과 객체를 받아 화면으로 넘기는 역할을 한다.




동적 파라미터 문법 차이

MyBatis에서 값을 넣을 때 가장 많이 쓰는 문법은 #{}이다.
하지만 ${}도 존재한다.
둘 다 비슷해 보이지만 역할은 다르다.


초보자는 #{}와 ${}를 단순히 같은 치환 문법으로 생각하기 쉽다.
하지만 일반적인 값 데이터에는 #{}를 사용하고, 테이블명이나 컬럼명처럼 SQL 문장 일부를 바꿔야 할 때만 ${}를 조심해서 사용한다.


#{}는 값 데이터를 넣을 때 사용한다

#{}는 메서드 매개변수 값을 SQL에 안전하게 전달하는 방식이다.
조건값처럼 실제 데이터에 해당하는 값은 대부분 #{}로 처리한다.

// exam17.java
@Mapper
public interface EmpMapper {
    @Select("select empno, ename, job from emp where ename = #{ename}") // 값 데이터는 #{} 사용
    EmpVO findByName(@Param("ename") String ename); // 직원명 조건 조회
}

이 예제에서 #{ename}은 직원명이라는 값 데이터이다.
SCOTT, KING, FORD 같은 실제 값이 들어간다.
이런 값은 #{}로 처리하는 것이 기본이다.


#{}를 사용하면 MyBatis가 값을 안전하게 전달하고 타입에 맞게 처리한다.
그래서 문자열 값이면 문자열 값으로, 숫자 값이면 숫자 값으로 처리된다.


${}는 SQL 문장 일부를 바꿀 때만 조심해서 사용한다

${}는 값을 안전하게 전달하는 방식이라기보다, 문자열을 그대로 SQL 문장에 붙이는 방식에 가깝다.
그래서 테이블명이나 컬럼명처럼 SQL 문법 자체가 바뀌어야 할 때 사용할 수 있다.

// exam18.java
@Mapper
public interface DynamicTableMapper {
    @Select("select empno, ename, job from ${tableName} where empno = #{empno}") // 테이블명은 ${}로 문장 일부를 변경
    EmpVO findFromTable(@Param("tableName") String tableName, @Param("empno") int empno); // 테이블명과 사번 전달
}

이 예제에서 tableName은 값 데이터가 아니라 테이블명이다.
테이블명에는 따옴표가 붙으면 안 된다.
그래서 이런 경우에는 ${tableName}처럼 SQL 문장 일부를 바꾸는 방식이 필요할 수 있다.


하지만 ${}는 문자열을 그대로 붙이는 방식이기 때문에 반드시 조심해야 한다.
사용자가 입력한 값을 그대로 ${}에 넣으면 위험한 SQL이 만들어질 수 있다.
즉, where ename = ${ename}처럼 일반 조건값에 ${}를 쓰는 방식은 피해야 한다.


두 문법의 핵심 차이

두 문법은 아래처럼 구분하면 된다.

  • #{}는 값 데이터에 사용한다.
  • ${}는 테이블명이나 컬럼명처럼 SQL 문장 일부를 바꿀 때 사용한다.
  • #{}는 일반적인 조건값 처리에 적합하다.
  • ${}는 문자열이 그대로 붙기 때문에 조심해야 한다.
  • 대부분의 상황에서는 #{}를 먼저 생각하는 것이 안전하다.

예를 들어 직원명을 조건으로 검색한다면 where ename = #{ename}이 자연스럽다.
반대로 조회할 테이블 이름 자체를 바꿔야 하는 특수한 상황이라면 ${tableName}이 필요할 수 있다.


정리하면 이렇다.
#{}는 값을 넣는 문법이고, ${}는 SQL 문장 조각을 바꾸는 문법이다.
이 차이를 구분하면 MyBatis의 동적 파라미터를 훨씬 안전하게 사용할 수 있다.


이 글에서 잡아야 할 최종 흐름은 하나다.
MyBatis는 데이터 접근 계층에서 Mapper를 통해 Java 메서드와 SQL을 연결하고, JDBC의 반복 작업을 줄여 주는 도구이다.




응용예제로 이해하기

앞에서 MyBatis의 기본 개념을 정리했다면, 이제 실제 코드에서 그 흐름이 어떻게 쓰이는지 확인해야 한다.
개념만 보면 Mapper, DTO, Controller, View가 각각 따로 있는 것처럼 보일 수 있다.
하지만 실제 예제에서는 이 파일들이 하나의 요청 흐름 안에서 연결된다.


응용예제의 핵심은 Controller → Mapper → DB → DTO → View 흐름을 실제 코드와 결과 화면으로 확인하는 것이다.
사용자가 브라우저에서 요청을 보내면 Controller가 요청을 받고, Mapper가 SQL을 실행하고, 조회 결과가 DTO에 담긴 뒤, 화면에서 출력된다.


이번 응용예제에서는 먼저 Emp 테이블 조회 흐름을 보고, 이어서 방명록 CRUD, 미팅 스케줄 CRUD, 동적 SQL과 부분 컬럼 매핑, Mapper 테스트에서의 insert와 select 매핑 흐름을 확인한다.

1. Emp 테이블 데이터를 MyBatis로 조회하기

Emp 테이블 조회 예제는 앞에서 배운 MyBatis의 조회 흐름을 실제 웹 요청으로 확인하는 예제이다.
Mapper 메서드가 어떤 SQL을 실행하고, Controller가 그 결과를 어떻게 화면으로 넘기는지 확인할 수 있다.


이 예제는 단순히 데이터를 출력하는 예제가 아니다.
조회 결과가 숫자 하나일 때, 객체 하나일 때, 목록일 때 화면에서 어떻게 다르게 처리되는지 함께 보여 준다.


사용하는 파일은 크게 EmpDTO.java, PageDTO.java, EmpMapper.java, EmpController.java, empResult.html이다.
EmpMapper는 Emp 테이블 조회 SQL을 담당하고, EmpController는 이 Mapper를 dao라는 이름으로 주입받아 사용한다.


이 예제에서 다시 확인할 MyBatis 흐름

이 예제에서 가장 먼저 봐야 할 MyBatis 흐름은 EmpMapper가 Emp 테이블 조회 SQL을 담당한다는 점이다.
Controller는 직접 SQL을 작성하지 않고 EmpMapper 메서드를 호출한다.
Mapper는 연결된 SQL을 실행하고, 결과를 int, EmpDTO, List<EmpDTO> 형태로 돌려준다.


앞에서 정리한 것처럼 Mapper 메서드의 반환 타입은 SQL 실행 결과에 맞춰야 한다.
전체 개수 조회처럼 결과가 숫자 하나이면 int로 받는다.
직원 한 명 조회처럼 결과가 한 행이면 EmpDTO 하나로 받는다.
전체 목록이나 부분 목록처럼 결과가 여러 행이면 List<EmpDTO>로 받는다.


또 이 예제는 #{}가 요청값을 SQL 조건에 연결하는 흐름도 보여 준다.
사번 조회에서는 empno 값이 Mapper로 전달되고, 이름 조회에서는 ename 값이 Mapper로 전달된다.
부분 목록 조회에서는 PageDTO 객체 안의 값이 Mapper의 조건으로 사용된다.


이 예제에서 다시 확인할 MyBatis 핵심은 다음과 같다.

  • 조회 결과가 숫자 하나이면 int로 받는다.
  • 조회 결과가 한 행이면 EmpDTO로 받는다.
  • 조회 결과가 여러 행이면 List<EmpDTO>로 받는다.
  • 요청 파라미터 값은 Mapper 메서드의 매개변수로 전달된다.
  • 객체를 매개변수로 넘기면 객체 안의 필드값을 SQL에서 사용할 수 있다.
  • 조회 결과가 없으면 null이 될 수 있으므로 화면에 전달할 값을 다르게 처리해야 한다.

즉, 1번 예제의 중심은 EmpMapper가 SQL을 실행하고, 조회 결과 형태에 맞게 Java 객체로 매핑하는 흐름이다.


이 예제에 같이 섞인 화면/요청 처리 흐름

MyBatis 흐름이 끝나면 그 결과를 웹 화면으로 연결하는 과정이 붙는다.
EmpController는 브라우저 요청을 받고, 필요한 Mapper 메서드를 호출한다.
그리고 조회 결과를 ModelAndView에 담아 empResult.html로 넘긴다.


empResult.html은 Thymeleaf 화면이다.
이 화면은 Controller가 넘긴 이름에 따라 다른 영역을 출력한다.
num이 있으면 전체 개수를 출력하고, list가 있으면 목록을 반복 출력한다.
msg가 있으면 실패 메시지를 출력하고, emp가 있으면 직원 한 명의 정보를 출력한다.


이 예제에 같이 섞인 흐름은 다음과 같다.

  • @GetMapping은 요청 주소와 컨트롤러 메서드를 연결한다.
  • @RequestParam(defaultValue="7788")은 요청값이 없을 때 기본값을 넣는다.
  • ModelAndView는 화면에 전달할 데이터와 화면 이름을 함께 담는다.
  • Thymeleaf의 th:if는 값이 있을 때만 해당 영역을 출력한다.
  • Thymeleaf의 th:each는 목록을 반복 출력한다.

이 부분은 MyBatis 자체 문법은 아니지만, MyBatis로 조회한 결과가 실제 화면까지 어떻게 이어지는지 보여 주는 보조 흐름이다.


코드 설명

먼저 EmpDTO.java는 Emp 테이블에서 조회한 직원 한 명의 정보를 담는 객체이다.
DTO는 Data Transfer Object의 줄임말이다.
계층 사이에서 데이터를 전달하기 위한 객체라고 이해하면 된다.

// EmpDTO.java
package com.example.springedu.domain;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Getter
@Setter
@ToString
public class EmpDTO {
    private int empno; // 사번
    private String ename; // 직원명
    private String job; // 직무
    private String hiredate; // 입사일
    private int sal; // 급여
}

@Getter와 @Setter는 필드 값을 읽고 저장하는 메서드를 자동으로 만들어 준다.
@ToString은 객체 내용을 문자열로 보기 쉽게 출력해 준다.


그래서 화면에 [[${emp}]]처럼 EmpDTO 객체를 그대로 출력하면 EmpDTO(empno=..., ename=..., job=...) 형태로 보인다.
화면에 보이는 EmpDTO(...) 문자열은 @ToString 덕분에 출력되는 결과이다.


다음으로 PageDTO.java는 부분 목록 조회에 필요한 요청 값을 담는 객체이다.
/part?startNum=1&endNum=5&countNum=5처럼 여러 요청 파라미터가 들어오면, Spring은 이름이 같은 필드에 값을 자동으로 넣어 준다.

// PageDTO.java
package com.example.springedu.domain;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class PageDTO {
    private int startNum; // 시작 번호
    private int endNum; // 끝 번호
    private int countNum; // 가져올 개수
}

이 구조는 앞에서 배운 객체 프로퍼티 연결 방식과 이어진다.
Mapper에서 객체를 매개변수로 받으면 #{startNum}, #{endNum}, #{countNum}처럼 객체 안의 값을 꺼내 사용할 수 있다.


다만 실제 부분 조회 SQL에서 어떤 필드를 사용할지는 Mapper 쿼리 작성 방식에 따라 달라진다.
예를 들어 limit #{startNum}, #{countNum}처럼 작성했다면 실제 조회 범위에는 startNum과 countNum이 핵심으로 사용된다.
endNum은 요청값으로 받을 수 있지만, 실제 쿼리에서 직접 사용되는지는 SQL 작성 방식에 따라 달라진다.


다음으로 EmpMapper.java는 Emp 테이블 조회에 필요한 SQL을 메서드와 연결하는 인터페이스이다.
Mapper는 DAO처럼 DB에 접근하는 역할을 한다.
Controller는 직접 SQL을 실행하지 않고, 이 Mapper 메서드를 호출해서 조회 결과를 받는다.

// EmpMapper.java
package mybatis.dao;

import com.example.springedu.domain.EmpDTO;
import com.example.springedu.domain.PageDTO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;

import java.util.List;

@Mapper
public interface EmpMapper {
    @Select("select count(*) from emp")
    public int getAllDataNum(); // 전체 데이터 개수 조회

    @Select("select empno, ename, job, date_format(hiredate, '%Y년 %m월 %d일') hiredate, sal  from emp")
    public List<EmpDTO> listAll(); // 전체 직원 목록 조회

    @Select("select empno, ename, job, date_format(hiredate, '%Y년 %m월 %d일') hiredate, sal  from emp where empno = #{no}")
    public EmpDTO findEmp1(int no); // 사번으로 직원 한 명 조회

    @Select("select empno, ename, job, date_format(hiredate, '%Y년 %m월 %d일') hiredate, sal  from emp where ename = #{name}")
    public EmpDTO findEmp2(String name); // 이름으로 직원 한 명 조회

    @Select("select empno, ename, job, date_format(hiredate, '%Y년 %m월 %d일') hiredate, sal  from emp where empno = #{no} and job = #{job}")
    public EmpDTO findEmp3(int no, String job); // 사번과 직무로 직원 조회

    @Select("select empno, ename, job, date_format(hiredate, '%Y년 %m월 %d일') hiredate, sal from emp order by sal limit #{startNum}, #{countNum}")
    public List<EmpDTO> listPart(PageDTO vo); // 급여 기준 정렬 후 일부 데이터 조회
}

getAllDataNum()은 select count(*) from emp를 실행한다.
조회 결과가 숫자 하나이므로 반환 타입은 int이다.


listAll()은 Emp 테이블의 전체 직원 목록을 조회한다.
조회 결과가 여러 행이므로 반환 타입은 List<EmpDTO>이다.
한 행마다 EmpDTO 객체가 만들어지고, 그 객체들이 List에 담긴다.


findEmp1(int no)는 사번으로 직원 한 명을 조회한다.
#{no}는 메서드 매개변수 no 값을 SQL 조건에 넣는 자리이다.
조회 결과가 한 행이면 EmpDTO 객체 하나로 반환된다.
조회 결과가 없으면 null이 될 수 있다.


findEmp2(String name)은 직원 이름으로 직원 한 명을 조회한다.
#{name} 자리에 요청으로 전달된 이름 값이 들어간다.
이 예제에서는 이름이 정확히 일치하는 직원을 찾는다.


findEmp3(int no, String job)은 사번과 직무 두 조건을 함께 사용한다.
#{no}에는 사번 값이 들어가고, #{job}에는 직무 값이 들어간다.
두 조건을 모두 만족해야 직원이 조회된다.


listPart(PageDTO vo)는 여러 요청값을 PageDTO 객체로 받아 부분 목록을 조회한다.
#{startNum}과 #{countNum}은 PageDTO 객체 안의 필드값을 꺼내 사용하는 문법이다.
이 SQL은 sal 기준으로 정렬한 뒤, 지정한 위치부터 지정한 개수만큼 데이터를 가져온다.


이 Mapper를 보면 1번 예제의 핵심이 더 분명해진다.
Controller는 dao.listAll()처럼 메서드를 호출할 뿐이고, 실제로 어떤 SQL이 실행되는지는 EmpMapper의 @Select가 결정한다.
조회 결과는 int, EmpDTO, List<EmpDTO>처럼 SQL 결과 형태에 맞는 Java 타입으로 돌아온다.


다음으로 EmpController.java는 사용자의 요청을 받는 컨트롤러이다.
/empnum, /list, /findEmp1, /findEmp2, /findEmp3, /part 요청을 각각 처리한다.

// EmpController.java
package com.example.springedu.controller;

import java.util.List;

import lombok.extern.slf4j.Slf4j;
import mybatis.dao.EmpMapper;
import com.example.springedu.domain.EmpDTO;
import com.example.springedu.domain.PageDTO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.ModelAndView;

@Controller
@Slf4j
public class EmpController {
    @Autowired
    EmpMapper dao; // MyBatis Mapper를 주입받는다

    @GetMapping("/empnum")
    public ModelAndView count() {
        log.info("[LOG] EmpController 의 count() 수행시작"); // 실행 시작 로그
        ModelAndView mav = new ModelAndView(); // 데이터와 화면 이름을 함께 담는 객체
        int num = dao.getAllDataNum(); // 전체 데이터 개수 조회
        mav.addObject("num", num); // 화면에 num이라는 이름으로 전달
        mav.setViewName("empResult"); // empResult.html로 이동
        log.info("[LOG] EmpController 의 count() 수행종료"); // 실행 종료 로그
        return mav;
    }

    @GetMapping("/list")
    public ModelAndView list() {
        log.info("[LOG] EmpController 의 list() 수행시작"); // 실행 시작 로그
        ModelAndView mav = new ModelAndView(); // 데이터와 화면 이름을 함께 담는 객체
        List<EmpDTO> list = dao.listAll(); // 전체 직원 목록 조회
        mav.addObject("list", list); // 화면에 list라는 이름으로 전달
        mav.setViewName("empResult"); // empResult.html로 이동
        log.info("[LOG] EmpController 의 list() 수행종료"); // 실행 종료 로그
        return mav;
    }

    @GetMapping("/findEmp1")
    public ModelAndView findEmp1(@RequestParam(defaultValue="7788") int empno) {
        ModelAndView mav = new ModelAndView(); // 데이터와 화면 이름을 함께 담는 객체
        EmpDTO emp = dao.findEmp1(empno); // 사번으로 직원 한 명 조회
        if (emp == null)
            mav.addObject("msg", "사번이 " + empno + "인 직원은 없어요~~"); // 조회 실패 메시지 전달
        else
            mav.addObject("emp", emp); // 조회 성공 객체 전달
        mav.setViewName("empResult"); // empResult.html로 이동
        return mav;
    }

    @GetMapping("/findEmp2")
    public ModelAndView findEmp2(String ename) {
        ModelAndView mav = new ModelAndView(); // 데이터와 화면 이름을 함께 담는 객체
        EmpDTO emp = dao.findEmp2(ename); // 이름으로 직원 한 명 조회
        if (emp == null)
            mav.addObject("msg", "성명이 " + ename + "인 직원은 없어요~~"); // 조회 실패 메시지 전달
        else
            mav.addObject("emp", emp); // 조회 성공 객체 전달
        mav.setViewName("empResult"); // empResult.html로 이동
        return mav;
    }

    @GetMapping("/findEmp3")
    public ModelAndView findEmp3(int empno, String job) {
        ModelAndView mav = new ModelAndView(); // 데이터와 화면 이름을 함께 담는 객체
        EmpDTO emp = dao.findEmp3(empno, job); // 사번과 직무로 직원 조회
        if (emp == null)
            mav.addObject("msg", "사번이 " + empno + "이고 직무가 " + job + "인 직원은 없어요~~"); // 조회 실패 메시지 전달
        else
            mav.addObject("emp", emp); // 조회 성공 객체 전달
        mav.setViewName("empResult"); // empResult.html로 이동
        return mav;
    }

    @GetMapping("/part")
    public ModelAndView part(PageDTO vo) {
        ModelAndView mav = new ModelAndView(); // 데이터와 화면 이름을 함께 담는 객체
        List<EmpDTO> list = dao.listPart(vo); // PageDTO 값으로 부분 목록 조회
        mav.addObject("list", list); // 화면에 list라는 이름으로 전달
        mav.setViewName("empResult"); // empResult.html로 이동
        return mav;
    }
}

이 코드에서 dao는 EmpMapper를 참조한다.
즉, Controller는 dao.getAllDataNum(), dao.listAll(), dao.findEmp1() 같은 메서드를 호출할 뿐이고, 실제 SQL 실행은 Mapper가 담당한다.


ModelAndView는 화면에 전달할 데이터와 이동할 화면 이름을 함께 담는 객체이다.
예를 들어 mav.addObject("num", num)은 num 값을 화면에서 사용할 수 있게 넣는 코드이다.
mav.setViewName("empResult")는 empResult.html을 결과 화면으로 사용하겠다는 뜻이다.


마지막으로 empResult.html은 모든 조회 결과를 출력하는 공통 화면이다.
이 화면은 전달받은 값이 무엇인지에 따라 다른 내용을 보여 준다.

// empResult.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<h1>Emp 테이블에서 읽어오기</h1>
<hr>
<th:block th:if="${num}">
    <h2>데이터는 총 [[${ num }]]개가 있어요.....</h2>
</th:block>
<th:block th:if="${list}">
    <ul>
    <th:block th:each="emp : ${list}">
        <li>[[${emp}]]</li>
    </th:block>
    </ul>
</th:block>
<th:block th:if="${msg}">
    <h2>[[${ msg }]]</h2>
</th:block>
<th:block th:if="${emp}">
    <h2>[[${ emp }]]</h2>
</th:block>
</body>
</html>

th:if는 조건이 참일 때만 해당 영역을 출력한다.
예를 들어 num이 전달되면 전체 데이터 개수를 출력하고, list가 전달되면 목록을 반복 출력한다.


th:each는 반복 출력에 사용한다.
list 안에 들어 있는 EmpDTO 객체를 하나씩 꺼내 emp라는 이름으로 사용한다.
그리고 [[${emp}]]를 통해 직원 한 명의 정보를 화면에 출력한다.


이 화면의 출력 조건은 다음처럼 정리할 수 있다.

  • num이 있으면 전체 데이터 개수를 출력한다.
  • list가 있으면 직원 목록을 반복 출력한다.
  • msg가 있으면 조회 실패 메시지를 출력한다.
  • emp가 있으면 직원 한 명의 정보를 출력한다.

즉, 하나의 화면 파일이지만 Controller가 어떤 이름으로 데이터를 넘겼는지에 따라 결과 화면이 달라진다.


그래서 결과가 이렇게 나온다

전체 데이터 개수 조회 결과

/empnum 요청은 Emp 테이블에 데이터가 총 몇 개 있는지 조회한다.
조회 결과는 숫자 하나이므로 int로 반환되고, Controller는 이 값을 num이라는 이름으로 화면에 전달한다.


실행 흐름은 다음과 같다.

  • 사용자가 /empnum으로 요청한다.
  • EmpController의 count() 메서드가 실행된다.
  • dao.getAllDataNum()이 호출된다.
  • Mapper가 전체 데이터 개수를 조회한다.
  • 조회 결과 14가 num이라는 이름으로 화면에 전달된다.
  • empResult.html은 num이 있으므로 전체 개수를 출력한다.

이 화면은 Emp 테이블의 전체 데이터 개수를 조회한 결과이다.
num 값이 전달되었기 때문에 empResult.html의 num 출력 조건이 동작한다.


전체 직원 목록 조회 결과

/list 요청은 Emp 테이블의 전체 직원 목록을 조회한다.
조회 결과가 여러 행이면 직원 한 명마다 EmpDTO 객체가 만들어지고, 여러 객체가 List<EmpDTO>에 담겨 반환된다.


실행 흐름은 다음과 같다.

  • 사용자가 /list로 요청한다.
  • EmpController의 list() 메서드가 실행된다.
  • dao.listAll()이 호출된다.
  • Mapper가 전체 직원 목록을 조회한다.
  • 조회 결과는 List<EmpDTO>로 반환된다.
  • Controller는 이 목록을 list라는 이름으로 화면에 전달한다.
  • empResult.html은 th:each로 직원 목록을 반복 출력한다.

이 화면은 List<EmpDTO>를 반복 출력한 결과이다.
직원 데이터가 여러 행이므로 각 행이 EmpDTO 객체로 매핑되고, 목록 형태로 화면에 출력된다.


사번 기본값 조회 결과

/findEmp1 요청은 사번을 기준으로 직원 한 명을 조회한다.
이 메서드는 @RequestParam(defaultValue="7788")을 사용하므로, 사용자가 empno를 직접 넘기지 않으면 기본값 7788이 사용된다.


실행 흐름은 다음과 같다.

  • 사용자가 /findEmp1로 요청한다.
  • empno가 없으므로 기본값 7788이 사용된다.
  • dao.findEmp1(7788)이 호출된다.
  • 사번이 7788인 직원이 조회된다.
  • 조회 결과가 emp라는 이름으로 화면에 전달된다.

이 화면은 /findEmp1 요청에서 기본값 7788로 조회한 결과이다.
사번 7788에 해당하는 직원 SCOTT이 조회되어 EmpDTO 형태로 출력된다.


사번 파라미터 직접 전달 조회 결과

이번에는 주소에 empno=7698을 직접 전달한다.
그러면 기본값 7788이 아니라 사용자가 보낸 7698 값으로 조회한다.


실행 흐름은 다음과 같다.

  • 사용자가 /findEmp1?empno=7698로 요청한다.
  • 요청 파라미터 empno 값이 7698로 들어온다.
  • dao.findEmp1(7698)이 호출된다.
  • 사번이 7698인 직원이 조회된다.
  • 조회 결과가 emp라는 이름으로 화면에 전달된다.

이 화면은 /findEmp1?empno=7698 요청 결과이다.
요청 파라미터로 전달한 7698이 Mapper까지 전달되고, 사번이 7698인 직원 BLAKE가 조회된다.


이름 조회 실패 결과

/findEmp2 요청은 직원 이름을 기준으로 직원 한 명을 조회한다.
findEmp2(String ename)은 요청 파라미터 ename을 받아 dao.findEmp2(ename)으로 넘긴다.
조회 결과가 있으면 emp를 화면에 전달하고, 결과가 없으면 msg를 화면에 전달한다.


ename=lee로 요청하면 Emp 테이블에 해당 이름이 없기 때문에 조회 결과가 null이 된다.
이때 Controller는 emp 대신 msg를 화면에 전달한다.

이 화면은 ename=lee로 조회했지만 결과가 없는 경우이다.
조회 결과가 null이므로 msg가 전달되고, 화면에는 “성명이 lee인 직원은 없어요~~”가 출력된다.


이름 조회 성공 결과

반대로 ename=BLAKE로 요청하면 해당 직원이 존재하므로 EmpDTO 객체가 반환된다.
이때 Controller는 이 객체를 emp라는 이름으로 화면에 전달한다.

이 화면은 ename=BLAKE로 조회한 결과이다.
이름이 BLAKE인 직원 정보가 EmpDTO 형태로 출력된다.


사번과 직무 조회에서 파라미터가 없을 때

/findEmp3 요청은 사번과 직무 두 조건을 함께 사용해서 직원 한 명을 조회한다.
findEmp3(int empno, String job)은 empno와 job 두 값을 받는다.
두 값이 모두 있어야 정상적으로 조회할 수 있다.


/findEmp3처럼 empno와 job 없이 요청하면 문제가 생긴다.
특히 empno 매개변수 타입이 int이기 때문에 null을 넣을 수 없다.


int는 기본형 타입이다.
기본형 타입은 값이 반드시 있어야 하며, null을 담을 수 없다.
그래서 요청 파라미터가 없을 수 있는 상황이라면 Integer처럼 객체형 타입을 사용하거나, @RequestParam(defaultValue="...")로 기본값을 지정해야 한다.

이 화면은 /findEmp3처럼 필요한 파라미터 없이 요청했을 때 발생한 예외이다.
empno가 없는데 매개변수 타입이 int라서 null을 넣을 수 없기 때문에 오류가 발생한다.


사번과 직무 조회 실패 결과

empno=7798, job=manager로 요청하면 두 조건을 모두 만족하는 직원이 없다.
이 경우 Mapper 조회 결과는 null이 되고, Controller는 emp 대신 msg를 화면에 전달한다.

이 화면은 사번이 7798이고 직무가 manager인 직원을 조회했지만 결과가 없는 경우이다.
조회 결과가 null이므로 화면에는 “사번이 7798이고 직무가 manager인 직원은 없어요~~”가 출력된다.


사번과 직무 조회 성공 결과

empno=7698, job=manager로 요청하면 조건에 맞는 직원이 존재한다.
조회 결과는 EmpDTO 객체로 반환되고, 화면에는 직원 정보가 출력된다.


실행 흐름은 다음과 같다.

  • 사용자가 /findEmp3?empno=7698&job=manager로 요청한다.
  • empno 값은 7698, job 값은 manager로 전달된다.
  • dao.findEmp3(empno, job)이 호출된다.
  • Mapper는 두 조건을 모두 사용해서 직원을 조회한다.
  • 조건에 맞는 직원 BLAKE가 조회된다.
  • 조회 결과가 emp라는 이름으로 화면에 전달된다.

조건에 맞는 직원이 있으면 EmpDTO 객체가 화면에 출력된다.
이 예제에서는 사번 7698, 직무 manager 조건으로 BLAKE가 조회된다.


이 예제에서는 요청값으로 manager를 보냈지만 결과에는 MANAGER가 출력된다.
이는 DB의 문자열 비교 설정에 따라 대소문자를 구분하지 않고 비교될 수 있기 때문이다.


부분 목록 조회 결과

마지막으로 /part 요청은 여러 개의 파라미터를 PageDTO 객체로 받아 부분 목록을 조회한다.
요청 주소는 /part?startNum=1&endNum=5&countNum=5 형태이다.
이때 startNum, endNum, countNum 값은 PageDTO 객체의 필드에 자동으로 담긴다.


다만 실제 부분 조회에서 어떤 값을 사용할지는 Mapper의 SQL 작성 방식에 따라 달라진다.
예를 들어 limit #{startNum}, #{countNum}처럼 작성했다면 실제 조회에는 startNum과 countNum이 핵심으로 사용된다.
endNum은 객체에 담기는 요청값이지만, 실제 SQL에서 직접 쓰이는지는 쿼리 작성 방식에 따라 달라진다.


실행 흐름은 다음과 같다.

  • 사용자가 /part?startNum=1&endNum=5&countNum=5로 요청한다.
  • startNum, endNum, countNum 값이 PageDTO 객체에 담긴다.
  • Controller는 dao.listPart(vo)를 호출한다.
  • Mapper는 PageDTO 안의 값을 사용해서 필요한 범위의 데이터를 조회한다.
  • 조회 결과는 List<EmpDTO>로 반환된다.
  • 화면은 직원 목록을 반복 출력한다.

이 화면은 부분 목록 조회 결과이다.
PageDTO에 담긴 값으로 필요한 범위의 직원 데이터만 조회하고, 그 결과를 List<EmpDTO>로 출력한다.


이 예제의 핵심은 요청값이 Controller로 들어오고, Mapper를 거쳐 DB 조회에 사용된 뒤, 결과가 DTO와 화면으로 이어진다는 흐름이다.
전체 개수 조회, 목록 조회, 단일 조회, 조건 조회, 부분 목록 조회는 모두 형태만 다를 뿐 같은 구조 안에서 동작한다.



2. 방명록 CRUD와 Ajax 수정 폼 이해하기

Visitor 방명록 예제는 앞에서 본 Emp 조회 예제보다 기능이 더 많다.
Emp 예제는 주로 조회 흐름을 확인했다.
이번 예제는 조회뿐만 아니라 등록, 검색, 삭제, 수정까지 함께 다룬다.


즉, 이 예제는 MyBatis를 사용해서 DB 데이터를 읽고, 추가하고, 삭제하고, 수정하는 전체 흐름을 보여 준다.
여기에 수정 버튼을 눌렀을 때 기존 글 데이터를 가져오기 위해 Ajax 요청도 함께 사용한다.


이 예제의 핵심은 Controller → Mapper → DB → DTO → View 흐름이 CRUD 기능 전체에서 반복된다는 점이다.
CRUD는 데이터를 다루는 기본 기능을 뜻한다.
Create는 등록, Read는 조회, Update는 수정, Delete는 삭제이다.


사용하는 파일은 크게 VisitorDTO.java, VisitorMapper.java, VisitorController.java, visitorMain.html, visitorForm.html, visitorView.html이다.
VisitorDTO는 방명록 글 한 개의 데이터를 담고, VisitorMapper는 SQL을 실행한다.
VisitorController는 요청을 받아 Mapper를 호출하고, 화면 파일들은 결과를 출력하거나 입력을 받는다.


이 예제가 같이 보여주는 개념

이 예제는 단순히 방명록 화면을 만드는 예제가 아니다.
앞에서 배운 MyBatis의 데이터 접근 흐름을 실제 방명록 기능에 적용한 예제이다.


VisitorMapper에서는 @Select, @Insert, @Delete, @Update를 사용한다.
각 어노테이션은 Mapper 메서드와 실행할 SQL을 연결한다.
목록 조회와 검색은 여러 행을 가져오므로 List<VisitorDTO>로 받는다.
단건 조회는 한 행만 가져오므로 VisitorDTO 하나로 받는다.
등록, 삭제, 수정은 성공 여부를 boolean으로 받는다.


이 예제에서 같이 봐야 하는 개념은 다음과 같다.

  • VisitorDTO는 방명록 한 행 데이터를 담는다.
  • VisitorMapper는 방명록 테이블에 실행할 SQL을 메서드와 연결한다.
  • VisitorController는 요청을 받고 Mapper 메서드를 호출한다.
  • ModelAndView는 조회 결과와 화면 이름을 함께 담는다.
  • visitorMain.html은 방명록 메인 화면이다.
  • visitorForm.html은 방명록 글 등록 화면이다.
  • visitorView.html은 목록, 검색 결과, 수정 폼을 보여 주는 화면이다.
  • Thymeleaf의 th:each는 목록 반복 출력에 사용된다.
  • Thymeleaf의 th:if는 전달받은 값이 있을 때만 출력한다.
  • Ajax는 화면 전체를 다시 요청하지 않고 필요한 데이터만 따로 요청할 때 사용한다.

즉, 이 예제는 MyBatis의 기본 조회 흐름에 등록, 삭제, 수정, Ajax 단건 조회까지 확장한 예제이다.


코드 설명

먼저 VisitorDTO.java는 방명록 글 한 개의 데이터를 담는 객체이다.
DTO는 Data Transfer Object의 줄임말이다.
계층 사이에서 데이터를 전달하기 위한 객체라고 이해하면 된다.

// VisitorDTO.java
package com.example.springedu.domain;

import lombok.Data;

@Data
public class VisitorDTO {
    int id; // 글 번호
    String name; // 작성자 이름
    String writedate; // 작성일
    String memo; // 방명록 내용
}

@Data는 Lombok에서 제공하는 어노테이션이다.
@Getter, @Setter, @ToString 등을 한 번에 만들어 준다.
그래서 VisitorDTO 객체는 값을 저장하고 읽을 수 있고, 화면이나 로그에서 객체 내용을 문자열로 확인할 수도 있다.


이 예제에서는 visitor 테이블의 한 행이 VisitorDTO 객체 하나로 매핑된다.
예를 들어 id, name, writedate, memo 컬럼 값이 각각 VisitorDTO의 필드에 담긴다.


다음으로 VisitorMapper.java는 방명록 기능에 필요한 SQL을 메서드와 연결하는 인터페이스이다.
Mapper는 DAO처럼 DB에 접근하는 역할을 한다.

// VisitorMapper.java
package mybatis.dao;

import com.example.springedu.domain.VisitorDTO;
import org.apache.ibatis.annotations.*;

import java.util.List;

@Mapper
public interface VisitorMapper {
    @Select("select id, name, date_format(writedate, '%Y년 %m월 %d일') writedate, memo from visitor")
    public List<VisitorDTO> list(); // 전체 방명록 목록 조회

    @Select("select id, name, date_format(writedate, '%Y년 %m월 %d일') writedate, memo from visitor where id = #{id}")
    public VisitorDTO one(int id); // 글 번호로 방명록 한 개 조회

    @Select("select id, name, date_format(writedate, '%Y년 %m월 %d일') writedate, memo from visitor where memo like concat('%', #{key},'%')")
    public List<VisitorDTO> search(String keyword); // 내용에 검색어가 포함된 글 조회

    @Insert("insert into visitor (name, writedate, memo) values (#{name}, now(), #{memo})")
    public boolean insert(VisitorDTO visitor); // 방명록 글 등록

    @Delete("delete from visitor where id = #{id}")
    public boolean delete(String id); // 글 번호로 방명록 글 삭제

    @Update("update visitor set name = #{name}, memo = #{memo} where id = #{id}")
    public boolean update(VisitorDTO visitor); // 글 번호 기준으로 이름과 내용 수정
}

list()는 전체 방명록 글을 조회한다.
조회 결과가 여러 행이므로 반환 타입은 List<VisitorDTO>이다.


one(int id)는 글 번호 하나로 방명록 글 한 개를 조회한다.
조회 결과가 한 행이므로 반환 타입은 VisitorDTO이다.
이 메서드는 수정 버튼을 눌렀을 때 기존 글 내용을 수정 폼에 채우는 데 사용된다.


search(String keyword)는 방명록 내용에서 검색어가 포함된 글을 조회한다.
like concat('%', #{key}, '%')는 검색어 앞뒤에 %를 붙여 포함 검색을 하겠다는 뜻이다.
예를 들어 검색어가 안녕이면 내용 중에 안녕이 들어간 글을 찾는다.


insert(VisitorDTO visitor)는 새 방명록 글을 등록한다.
#{name}과 #{memo}는 VisitorDTO 객체 안의 name, memo 값을 사용한다.
작성일은 사용자가 입력하지 않고 now()로 현재 시간을 저장한다.


delete(String id)는 글 번호를 기준으로 글을 삭제한다.
update(VisitorDTO visitor)는 글 번호를 기준으로 이름과 내용을 수정한다.
등록, 삭제, 수정은 성공 여부를 확인하기 위해 boolean을 반환한다.


다음으로 VisitorController.java는 사용자의 요청을 받는 컨트롤러이다.
컨트롤러는 직접 SQL을 작성하지 않고, VisitorMapper를 주입받아 필요한 메서드를 호출한다.

// VisitorController.java
package com.example.springedu.controller;

import java.util.List;

import mybatis.dao.VisitorMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.ModelAndView;

import com.example.springedu.domain.VisitorDTO;

@Controller
public class VisitorController {
    @Autowired
    VisitorMapper dao; // MyBatis Mapper를 주입받는다

    @RequestMapping("/vlist")
    public ModelAndView list() {
        List<VisitorDTO> list = dao.list(); // 전체 방명록 목록 조회
        ModelAndView mav = new ModelAndView(); // 데이터와 화면 이름을 담는 객체
        if (list.size() != 0) {
            mav.addObject("list", list); // 조회 결과가 있으면 list 전달
        } else {
            mav.addObject("msg", "추출된 결과가 없어요"); // 결과가 없으면 메시지 전달
        }
        mav.setViewName("visitorView"); // visitorView.html로 이동
        return mav;
    }

    @RequestMapping("/vsearch")
    public ModelAndView search(String key) {
        List<VisitorDTO> list = dao.search(key); // 검색어로 방명록 조회
        System.out.println(list.size()); // 검색 결과 개수 확인
        ModelAndView mav = new ModelAndView(); // 데이터와 화면 이름을 담는 객체
        if (list.size() != 0) {
            mav.addObject("list", list); // 검색 결과가 있으면 list 전달
        } else {
            mav.addObject("msg", "추출된 결과가 없어요"); // 검색 결과가 없으면 메시지 전달
        }
        mav.setViewName("visitorView"); // visitorView.html로 이동
        return mav;
    }

    @RequestMapping(value = "/vdelete")
    public ModelAndView delete(String id) {
        boolean result = dao.delete(id); // 글 번호로 삭제 실행
        ModelAndView mav = new ModelAndView(); // 데이터와 화면 이름을 담는 객체
        if (result) {
            mav.addObject("list", dao.list()); // 삭제 성공 후 최신 목록 전달
        } else {
            mav.addObject("msg", "글 삭제에 실패했습니다."); // 삭제 실패 메시지 전달
        }
        mav.setViewName("visitorView"); // visitorView.html로 이동
        return mav;
    }

    @RequestMapping(value = "/one", produces = "application/json; charset=utf-8")
    @ResponseBody
    public VisitorDTO one(int id) {
        return dao.one(id); // 글 번호로 한 개 조회 후 JSON으로 응답
    }

    @RequestMapping(value = "/vinsert", method = RequestMethod.POST)
    public ModelAndView insert(VisitorDTO vo) {
        boolean result = dao.insert(vo); // 입력받은 값으로 방명록 등록
        ModelAndView mav = new ModelAndView(); // 데이터와 화면 이름을 담는 객체
        if (result) {
            mav.addObject("list", dao.list()); // 등록 성공 후 최신 목록 전달
        } else {
            mav.addObject("msg", "글 등록에 실패했습니다."); // 등록 실패 메시지 전달
        }
        mav.setViewName("visitorView"); // visitorView.html로 이동
        return mav;
    }

    @RequestMapping(value = "/vupdate", method = RequestMethod.POST)
    public ModelAndView update(VisitorDTO vo) {
        boolean result = dao.update(vo); // 입력받은 값으로 방명록 수정
        ModelAndView mav = new ModelAndView(); // 데이터와 화면 이름을 담는 객체
        if (result) {
            mav.addObject("list", dao.list()); // 수정 성공 후 최신 목록 전달
        } else {
            mav.addObject("msg", "글 수정에 실패했습니다."); // 수정 실패 메시지 전달
        }
        mav.setViewName("visitorView"); // visitorView.html로 이동
        return mav;
    }
}

/vlist는 전체 방명록 목록을 조회한다.
조회 결과가 있으면 list를 화면에 전달하고, 없으면 msg를 전달한다.


/vsearch는 검색어 key를 받아 방명록 내용을 검색한다.
Controller의 매개변수 이름이 key이므로 검색 폼의 name="key"와 연결된다.


/vdelete는 글 번호 id를 받아 삭제한다.
삭제 성공 후에는 다시 전체 목록을 조회해서 최신 목록을 화면에 전달한다.
삭제된 상태를 바로 보여 주기 위해서이다.


/vinsert는 POST 방식으로 전달된 이름과 내용을 VisitorDTO 객체로 받는다.
Spring은 폼의 name, memo 값을 VisitorDTO의 같은 이름 필드에 자동으로 넣어 준다.


/vupdate도 POST 방식으로 전달된 id, name, memo 값을 VisitorDTO 객체로 받는다.
이 값으로 기존 글을 수정한 뒤 최신 목록을 다시 화면에 전달한다.


/one은 조금 다르다.
화면 전체를 이동하는 것이 아니라, 수정 폼에 기존 데이터를 채우기 위해 글 하나를 JSON으로 반환한다.
@ResponseBody는 반환 객체를 화면 이름으로 해석하지 않고 응답 데이터로 보내겠다는 뜻이다.


다음으로 visitorMain.html은 방명록 메인 화면이다.
목록 보기, 작성 화면 이동, 검색 기능을 제공한다.

// visitorMain.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
<link rel="stylesheet" href="css/visitor.css">
</head>
<body>
<h1>방명록</h1>
<hr>
<a href="/vlist"><button>방명록 리스트 보기</button></a>
<hr>
<a href="/visitorForm.html"><button>방명록 작성</button></a>
<hr>
<form method="GET" action="/vsearch">
<input type="text" name="key">
<input type="submit" value="방명록 검색">
</form>
<script>
window.onload = function (e) {
    let xhr = new XMLHttpRequest(); // 비동기 요청 객체 생성
    xhr.onload = function() {
        let jsonobj = JSON.parse(xhr.responseText); // JSON 문자열을 객체로 변환
        let imgsrc = "/" + jsonobj.img; // 이미지 경로 생성
        document.body.style.backgroundImage = "url(" + imgsrc + ")"; // 배경 이미지 적용
        document.body.style.backgroundRepeat = "no-repeat"; // 반복 없음
        document.body.style.backgroundAttachment = "fixed"; // 배경 고정
        document.body.style.backgroundPosition = "center"; // 가운데 배치
    };
    xhr.open("GET", "/weather", true); // 날씨 이미지 정보 요청
    xhr.send(); // 요청 전송
};
</script>
</body>
</html>

방명록 리스트 보기 버튼은 /vlist로 이동한다.
방명록 작성 버튼은 visitorForm.html로 이동한다.
검색 폼은 GET 방식으로 /vsearch에 key 값을 전달한다.


아래 script는 페이지가 열릴 때 /weather로 Ajax 요청을 보내고, 응답으로 받은 이미지 정보를 배경 이미지로 적용한다.
이 부분은 방명록 CRUD 자체보다는 메인 화면의 배경을 동적으로 바꾸는 기능이다.


다음으로 visitorForm.html은 방명록 글을 등록하는 입력 화면이다.

// visitorForm.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
<link rel="stylesheet" href="css/visitor.css">
</head>
<body>
<h2>글을 남겨주세요</h2>
<hr>
<form method="post" action="/vinsert">
<input type="text" name="name" placeholder="이름을 입력하세요" required><br>
<textarea cols="35" rows="10" name="memo" placeholder="내용을 입력하세요" required></textarea><br>
<input type="submit" value="등록">
<input type="reset" value="재작성">
<input type="button" value="취소" onclick="location.href = 'visitorMain.html'">
</form>
</body>
</html>

이 화면에서 입력한 name, memo 값은 POST 방식으로 /vinsert에 전달된다.
VisitorController의 insert(VisitorDTO vo)가 이 값을 VisitorDTO 객체로 받는다.


required는 입력값을 비워 둘 수 없게 하는 속성이다.
이름과 내용을 입력하지 않으면 폼이 제출되지 않는다.


마지막으로 visitorView.html은 방명록 목록을 출력하고, 삭제와 수정 기능을 제공하는 화면이다.

// visitorView.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
<link rel="stylesheet" href="/css/visitor.css">
<style>
table {
    margin: 5px auto;
}

td {
    border-bottom: 1px dotted black;
    padding: 10px;
    text-align: left;
}

tr:hover {
    background-color: pink;
}

div {
    margin: 10px auto;
    padding: 50px;
    background: linear-gradient(to bottom, #33ccff 0%, #ff99cc 100%);
    width: 800px;
    border-radius: 5px;
}
</style>
</head>
<body>
    <h1>방명록 글 리스트</h1>
    <hr>
    <div>
        <table th:if="${list}">
            <tr th:each="vo : ${list}">
                <td>[[${vo.name}]]</td>
                <td>[[${vo.writedate}]]</td>
                <td>[[${vo.memo}]]</td>
                <td><a th:href="@{/vdelete(id=${vo.id})}"><img src="/images/delete.png" width="38"></a></td>
                <td><img src="/images/edit.png" width="38" th:onclick="|displayUpdateForm(${vo.id})|"></td>
            </tr>
        </table>

        <h3 th:if="${msg}" th:text="${msg}"></h3>
        <hr>
        <a href="/visitorMain.html"><button>메인페이지로 돌아가기</button></a>
    </div>
    <div id="updateform" style="display: none">
        <h2>글을 수정하세요</h2>
        <hr>
        <form method="post" action="/vupdate">
            <input type="hidden" name="id" value="">
            <input type="text" name="name" value="" required><br>
            <textarea cols="35" rows="10" name="memo" required></textarea>
            <br>
            <input type="submit" value="수정">
            <input type="reset" value="재작성" onclick="resetForm();return false;">
            <input type="button" value="취소" onclick="document.getElementById('updateform').style.display='none'">
        </form>
    </div>
    <script>
    let jsonobj = null; // 기존 글 데이터를 저장할 변수
    let namedom = null; // 이름 입력칸 DOM
    let memodom = null; // 내용 입력칸 DOM

    function displayUpdateForm(id) {
        let xhr = new XMLHttpRequest(); // 비동기 요청 객체 생성
        xhr.onload = function() {
            if (xhr.status == 200) {
                document.getElementById('updateform').style.display = 'block'; // 수정 폼 표시
                let iddom = document.querySelector("#updateform input[name=id]"); // hidden id input
                namedom = document.querySelector("#updateform input[name=name]"); // name input
                memodom = document.querySelector("#updateform textarea"); // memo textarea
                jsonobj = JSON.parse(xhr.responseText); // JSON 응답을 객체로 변환
                iddom.value = jsonobj.id; // 글 번호 채우기
                namedom.value = jsonobj.name; // 이름 채우기
                memodom.value = jsonobj.memo; // 내용 채우기
            }
        };
        xhr.open('GET', '/one?id=' + id, true); // 글 하나 조회 요청
        xhr.send(); // 요청 전송
    }

    function resetForm() {
        if (jsonobj) {
            namedom.value = jsonobj.name; // 기존 이름으로 되돌리기
            memodom.value = jsonobj.memo; // 기존 내용으로 되돌리기
        }
    }
    </script>
</body>
</html>

table th:if="${list}"는 list가 있을 때만 목록 테이블을 출력한다.
tr th:each="vo : ${list}"는 list 안의 VisitorDTO 객체를 하나씩 꺼내 vo라는 이름으로 사용한다.


삭제 아이콘은 th:href="@{/vdelete(id=${vo.id})}"로 만들어진다.
즉, 각 글의 id 값을 /vdelete 요청에 함께 전달한다.
삭제 요청이 처리되면 컨트롤러는 최신 목록을 다시 조회해서 화면에 보여 준다.


수정 아이콘은 th:onclick="|displayUpdateForm(${vo.id})|"로 JavaScript 함수를 호출한다.
이때 글 번호 id를 함께 넘긴다.
displayUpdateForm(id) 함수는 /one?id=글번호로 Ajax 요청을 보내고, 응답받은 기존 글 데이터를 수정 폼에 채운다.


수정 폼의 id 입력칸은 type="hidden"이다.
사용자에게 보이지는 않지만, 수정할 글 번호를 서버에 다시 보내기 위해 필요하다.
수정 버튼을 누르면 id, name, memo 값이 POST 방식으로 /vupdate에 전달된다.


그래서 결과가 이렇게 나온다

메인 화면에서 목록 조회, 작성 화면 이동, 검색 흐름

먼저 메인 화면에서는 방명록 목록 보기, 방명록 작성, 검색을 할 수 있다.
방명록 목록 보기 버튼을 누르면 /vlist 요청이 실행되고, 작성 버튼을 누르면 visitorForm.html로 이동한다.
검색어를 입력하면 /vsearch?key=검색어 요청이 실행된다.

이 결과는 방명록 메인 화면에서 목록 조회, 작성 화면 이동, 검색 흐름을 확인하는 장면이다.
메인 화면은 직접 DB를 조회하지 않고, 버튼과 폼을 통해 각각의 컨트롤러 요청으로 이동한다.


방명록 글 등록 후 목록에 반영되는 흐름

방명록 작성 화면에서 이름과 내용을 입력하고 등록하면 /vinsert 요청이 실행된다.
Controller는 입력값을 VisitorDTO 객체로 받고, dao.insert(vo)를 호출한다.
등록이 성공하면 다시 전체 목록을 조회해서 visitorView.html에 보여 준다.

이 결과는 방명록 글을 작성하고 등록한 뒤, 새 글이 목록에 반영되는 흐름을 보여 준다.
폼의 name, memo 값이 VisitorDTO에 담기고, Mapper의 insert()가 실행된 뒤, 최신 목록이 다시 화면에 출력된다.


수정 폼 표시, 수정, 삭제 흐름

목록 화면에서는 삭제 아이콘과 수정 아이콘을 사용할 수 있다.
삭제 아이콘을 누르면 /vdelete?id=글번호 요청이 실행되고, 해당 글이 삭제된다.
수정 아이콘을 누르면 /one?id=글번호 요청을 Ajax로 보내 기존 글 데이터를 가져오고, 수정 폼에 채운다.
수정 내용을 제출하면 /vupdate 요청이 실행되어 DB의 글이 수정된다.

이 결과는 방명록 목록에서 기존 글을 불러와 수정 폼에 표시하고, 수정 또는 삭제 흐름을 확인하는 장면이다.
수정 폼은 처음에는 숨겨져 있다가, 수정 아이콘을 클릭하면 Ajax로 기존 데이터를 받아 화면에 나타난다.


이 예제의 핵심은 MyBatis가 방명록 CRUD의 SQL 실행을 맡고, Controller가 요청 흐름을 연결하며, Thymeleaf와 Ajax가 화면에서 결과를 보여 주거나 필요한 데이터를 가져오는 구조이다.
조회, 검색, 등록, 삭제, 수정은 기능은 다르지만 모두 Controller → Mapper → DB → DTO → View 흐름 안에서 동작한다.



3. 미팅 스케줄 CRUD와 댓글 Ajax 이해하기

Meeting 예제는 앞에서 본 Emp 조회 예제와 Visitor 방명록 예제를 한 단계 더 확장한 예제이다.
Emp 예제는 조회 중심이었고, Visitor 예제는 등록, 조회, 수정, 삭제 흐름에 Ajax 수정 폼이 추가되었다.
이번 예제는 여기에 댓글 작성과 댓글 목록 조회 흐름까지 함께 들어간다.


즉, 이 예제는 MyBatis로 미팅 정보를 등록, 조회, 검색, 수정, 삭제하고, 특정 미팅 글에 달린 댓글을 Ajax로 처리하는 구조를 보여 준다.


이 예제의 핵심은 Meeting 테이블과 reply 테이블을 각각 Mapper로 다루고, 화면에서는 JavaScript와 Ajax로 댓글 기능을 처리한다는 점이다.
미팅 정보 자체는 ModelAndView로 화면에 전달되고, 댓글 기능은 @ResponseBody로 JSON 데이터를 주고받는다.


사용하는 파일은 크게 MeetingDTO.java, MeetingMapper.java, MeetingController.java, meetingView.html이다.
MeetingDTO는 미팅 일정 한 개의 데이터를 담고, MeetingMapper는 미팅과 댓글에 필요한 SQL을 실행한다.
MeetingController는 요청을 받아 Mapper를 호출하고, meetingView.html은 목록, 작성, 검색, 수정, 댓글 기능을 화면에서 처리한다.


이 예제가 같이 보여주는 개념

이 예제는 단순히 미팅 목록을 출력하는 예제가 아니다.
앞에서 배운 MyBatis의 데이터 접근 흐름이 미팅 일정 관리와 댓글 기능 안에서 어떻게 반복되는지 보여 준다.


미팅 목록 조회와 검색은 여러 행을 가져오므로 List<MeetingDTO>로 받는다.
미팅 등록, 수정, 삭제는 성공 여부를 boolean으로 받는다.
댓글 목록 조회는 특정 미팅 글 번호인 refid를 기준으로 List<ReplyVO>를 반환한다.
댓글 등록은 ReplyVO에 담긴 작성자, 내용, 참조 글 번호를 이용해 reply 테이블에 데이터를 추가한다.


이 예제에서 같이 봐야 하는 개념은 다음과 같다.

  • MeetingDTO는 미팅 일정 한 행 데이터를 담는다.
  • MeetingMapper는 meeting 테이블과 reply 테이블에 실행할 SQL을 메서드와 연결한다.
  • MeetingController는 요청을 받고 Mapper 메서드를 호출한다.
  • ModelAndView는 미팅 목록 결과와 화면 이름을 함께 담는다.
  • @ResponseBody는 댓글 등록 결과나 댓글 목록을 화면 이름이 아니라 응답 데이터로 보낸다.
  • meetingView.html은 목록 출력, 작성 폼, 검색 폼, 수정 폼, 댓글 기능을 한 화면에서 처리한다.
  • Thymeleaf의 th:each는 미팅 목록 반복 출력에 사용된다.
  • JavaScript는 작성 폼과 검색 폼을 보이거나 숨기고, 수정 폼에 기존 값을 채운다.
  • Ajax는 댓글 작성과 댓글 목록 조회처럼 화면 전체를 다시 요청하지 않아도 되는 기능에 사용된다.

즉, 이 예제는 MyBatis의 기본 CRUD 흐름에 Ajax 댓글 기능까지 연결한 예제이다.


코드 설명

먼저 MeetingDTO.java는 미팅 일정 한 개의 데이터를 담는 객체이다.
DTO는 Data Transfer Object의 줄임말이다.
계층 사이에서 데이터를 전달하기 위한 객체라고 이해하면 된다.

// MeetingDTO.java
package com.example.springedu.domain;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Getter
@Setter
@ToString
public class MeetingDTO {
    private int id; // 미팅 글 번호
    private String name; // 미팅 대상 이름
    private String title; // 미팅 목적
    private String meetingDate; // 미팅 날짜와 시간
}

id는 미팅 일정 한 개를 구분하는 번호이다.
name은 미팅 대상 이름이고, title은 미팅 목적이다.
meetingDate는 미팅 날짜와 시간을 문자열로 담는다.


@Getter와 @Setter는 필드 값을 읽고 저장하는 메서드를 자동으로 만들어 준다.
@ToString은 객체 내용을 문자열로 확인할 수 있게 만들어 준다.


이 예제에서는 meeting 테이블의 한 행이 MeetingDTO 객체 하나로 매핑된다.
예를 들어 id, name, title, meetingdate 컬럼 값이 각각 MeetingDTO의 필드에 담긴다.


다음으로 MeetingMapper.java는 미팅 일정과 댓글 기능에 필요한 SQL을 메서드와 연결하는 인터페이스이다.
Mapper는 DAO처럼 DB에 접근하는 역할을 한다.

// MeetingMapper.java
package mybatis.dao;

import com.example.springedu.domain.MeetingDTO;
import com.example.springedu.domain.ReplyVO;
import org.apache.ibatis.annotations.*;

import java.util.List;

@Mapper
public interface MeetingMapper {
    @Select("select id, name, title, date_format(meetingdate, '%Y/%m/%d %H:%i') meetingdate from Meeting")
    public List<MeetingDTO> listM(); // 전체 미팅 목록 조회

    @Select("select id, name, title, date_format(meetingdate, '%Y/%m/%d %H:%i') meetingDate from meeting where title like  concat('%',#{key},'%')")
    public List<MeetingDTO> searchM1(String keyword); // 미팅 목적에서 검색어 포함 조회

    @Select("select id, name, title, date_format(meetingdate, '%Y/%m/%d %H:%i') meetingDate from meeting where name = #{name}")
    public List<MeetingDTO> searchM2(String name); // 미팅 대상 이름으로 조회

    @Insert("insert into meeting (name, title, meetingdate) values (#{name}, #{title}, date_format(#{meetingDate}, '%Y/%m/%d %H:%i'))")
    public boolean insertM(MeetingDTO vo); // 미팅 일정 등록

    @Delete("delete from meeting where id = #{id}")
    public boolean deleteM(int id); // 글 번호로 미팅 일정 삭제

    @Update("update meeting set name = #{name}, title = #{title}, meetingdate = date_format(#{meetingDate}, '%Y/%m/%d %H:%i')  where id = #{id}")
    public boolean updateM(MeetingDTO vo); // 글 번호 기준으로 미팅 일정 수정

    @Select("select id, name, content from reply where refid = #{refid}")
    public List<ReplyVO> listReply(int refid); // 특정 미팅 글의 댓글 목록 조회

    @Insert("insert into reply (name, content, refid) values (#{name}, #{content}, #{refid})")
    public boolean insertReply(ReplyVO vo); // 특정 미팅 글에 댓글 등록
}

listM()은 전체 미팅 목록을 조회한다.
조회 결과가 여러 행이므로 반환 타입은 List<MeetingDTO>이다.


searchM1(String keyword)는 미팅 목적에서 검색어가 포함된 글을 찾는다.
like concat('%', #{key}, '%')는 검색어 앞뒤에 %를 붙여 포함 검색을 하겠다는 뜻이다.
예를 들어 검색어가 회의라면 미팅 목적에 회의가 들어간 데이터를 찾는다.


searchM2(String name)은 미팅 대상 이름이 정확히 일치하는 데이터를 찾는다.
즉, title 검색은 포함 검색이고, name 검색은 같은 이름을 찾는 검색이다.


insertM(MeetingDTO vo)는 새 미팅 일정을 등록한다.
#{name}, #{title}, #{meetingDate}는 MeetingDTO 객체 안의 값을 사용한다.


deleteM(int id)는 글 번호를 기준으로 미팅 일정을 삭제한다.
updateM(MeetingDTO vo)는 글 번호를 기준으로 미팅 대상 이름, 목적, 날짜와 시간을 수정한다.
등록, 삭제, 수정은 성공 여부를 확인하기 위해 boolean을 반환한다.


listReply(int refid)는 특정 미팅 글에 달린 댓글 목록을 조회한다.
여기서 refid는 댓글이 어떤 미팅 글에 달린 것인지 연결하는 값이다.


insertReply(ReplyVO vo)는 댓글을 등록한다.
ReplyVO에는 댓글 작성자 이름, 댓글 내용, 참조 미팅 글 번호가 담긴다고 이해하면 된다.


다음으로 MeetingController.java는 사용자의 요청을 받는 컨트롤러이다.
컨트롤러는 직접 SQL을 작성하지 않고, MeetingMapper를 주입받아 필요한 메서드를 호출한다.

// MeetingController.java
package com.example.springedu.controller;

import java.util.List;

import mybatis.dao.MeetingMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.ModelAndView;

import com.example.springedu.domain.MeetingDTO;
import com.example.springedu.domain.ReplyVO;

@Controller
@RequiredArgsConstructor
public class MeetingController  {
    @Autowired
    MeetingMapper dao; // MyBatis Mapper를 주입받는다

    @GetMapping("/meeting")
    public ModelAndView list() {
        List<MeetingDTO> list = dao.listM(); // 전체 미팅 목록 조회
        ModelAndView mav = new ModelAndView(); // 데이터와 화면 이름을 담는 객체
        if (list.size() != 0) {
            mav.addObject("list", list); // 조회 결과가 있으면 list 전달
        } else {
            mav.addObject("msg", "추출된 결과가 없어요"); // 결과가 없으면 메시지 전달
        }
        mav.setViewName("meetingView"); // meetingView.html로 이동
        return mav;
    }

    @GetMapping("/meeting/search")
    public ModelAndView search(String keyword) {
        List<MeetingDTO> list = dao.searchM1(keyword); // 미팅 목적 검색
        ModelAndView mav = new ModelAndView(); // 데이터와 화면 이름을 담는 객체
        if (list.size() != 0) {
            mav.addObject("list", list); // 검색 결과 전달
            mav.addObject("button", "메인화면으로"); // 메인 화면 이동 버튼 표시
        } else {
            mav.addObject("msg", "추출된 결과가 없어요"); // 검색 결과 없음 메시지
        }
        mav.setViewName("meetingView"); // meetingView.html로 이동
        return mav;
    }

    @GetMapping("/meeting/searchname")
    public ModelAndView searchName(String name) {
        List<MeetingDTO> list = dao.searchM2(name); // 이름으로 미팅 검색
        ModelAndView mav = new ModelAndView(); // 데이터와 화면 이름을 담는 객체
        if (list.size() != 0) {
            mav.addObject("list", list); // 검색 결과 전달
            mav.addObject("button", "메인화면으로"); // 메인 화면 이동 버튼 표시
        } else {
            mav.addObject("msg", "추출된 결과가 없어요"); // 검색 결과 없음 메시지
        }
        mav.setViewName("meetingView"); // meetingView.html로 이동
        return mav;
    }

    @GetMapping("/meeting/delete")
    public ModelAndView delete(int id) {
        boolean result = dao.deleteM(id); // 글 번호로 미팅 일정 삭제
        ModelAndView mav = new ModelAndView(); // 데이터와 화면 이름을 담는 객체
        if (result) {
            mav.addObject("list", dao.listM()); // 삭제 성공 후 최신 목록 전달
        } else {
            mav.addObject("msg", "삭제를 처리하는 동안 오류 발생"); // 삭제 실패 메시지
        }
        mav.setViewName("meetingView"); // meetingView.html로 이동
        return mav;
    }

    @PostMapping("/meeting/insert")
    public ModelAndView insert(MeetingDTO vo) {
        boolean result = dao.insertM(vo); // 입력받은 값으로 미팅 일정 등록
        ModelAndView mav = new ModelAndView(); // 데이터와 화면 이름을 담는 객체
        if (result) {
            mav.addObject("list", dao.listM()); // 등록 성공 후 최신 목록 전달
        } else {
            mav.addObject("msg", "글 작성을 처리하는 동안 오류 발생"); // 등록 실패 메시지
        }
        mav.setViewName("meetingView"); // meetingView.html로 이동
        return mav;
    }

    @PostMapping("/meeting/update")
    public ModelAndView update(MeetingDTO vo) {
        boolean result = dao.updateM(vo); // 입력받은 값으로 미팅 일정 수정
        ModelAndView mav = new ModelAndView(); // 데이터와 화면 이름을 담는 객체
        if (result) {
            mav.addObject("list", dao.listM()); // 수정 성공 후 최신 목록 전달
        } else {
            mav.addObject("msg", "글 수정을 처리하는 동안 오류 발생"); // 수정 실패 메시지
        }
        mav.setViewName("meetingView"); // meetingView.html로 이동
        return mav;
    }

    @GetMapping(value="/meeting/ireply", produces = "application/json; charset=utf-8")
    @ResponseBody
    public String insert_reply(ReplyVO vo) {
        boolean result = dao.insertReply(vo); // 댓글 등록
        String jsonStr; // JSON 응답 문자열
        if (result) {
             jsonStr = "{ \"result\": true }"; // 등록 성공 응답
        } else {
             jsonStr = "{ \"result\": false }"; // 등록 실패 응답
        }
        return jsonStr; // JSON 문자열 반환
    }

    @GetMapping(value="/meeting/lreply", produces = "application/json; charset=utf-8")
    @ResponseBody
    public List<ReplyVO> list_reply(int refid) {
        List<ReplyVO> list = dao.listReply(refid); // 특정 미팅 글 댓글 조회
        return list; // JSON 배열로 반환
    }
}

/meeting은 전체 미팅 목록을 조회한다.
조회 결과가 있으면 list를 화면에 전달하고, 없으면 msg를 전달한다.


/meeting/search는 미팅 목적을 기준으로 검색한다.
검색 결과가 있으면 list와 button 값을 함께 전달한다.
button 값이 있으면 화면에서 메인화면으로 버튼을 보여 준다.


/meeting/searchname은 미팅 대상 이름을 기준으로 검색한다.
목적 검색은 포함 검색이고, 이름 검색은 정확히 같은 이름을 찾는 검색이다.


/meeting/delete는 글 번호 id를 받아 미팅 일정을 삭제한다.
삭제 성공 후에는 다시 전체 목록을 조회해서 최신 목록을 화면에 전달한다.


/meeting/insert는 POST 방식으로 전달된 이름, 목적, 날짜와 시간을 MeetingDTO 객체로 받는다.
Spring은 폼의 name, title, meetingDate 값을 MeetingDTO의 같은 이름 필드에 자동으로 넣어 준다.


/meeting/update도 POST 방식으로 전달된 id, name, title, meetingDate 값을 MeetingDTO 객체로 받는다.
이 값으로 기존 미팅 일정을 수정한 뒤 최신 목록을 다시 화면에 전달한다.


/meeting/ireply와 /meeting/lreply는 화면 전체를 다시 그리기 위한 요청이 아니다.
댓글 작성과 댓글 목록 조회를 위한 Ajax 요청이다.
그래서 두 메서드에는 @ResponseBody가 붙어 있다.
@ResponseBody는 반환값을 화면 이름으로 해석하지 않고, 응답 데이터로 바로 보내겠다는 뜻이다.


마지막으로 meetingView.html은 미팅 목록 출력, 작성 폼, 검색 폼, 수정 폼, 댓글 기능을 모두 처리하는 화면이다.

// meetingView.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org/">
<head>
<style>
    @font-face {
        font-family: 'OTWelcomeRA';
        src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_2110@1.0/OTWelcomeRA.woff2') format('woff2');
        font-weight: normal;
        font-style: normal;
    }

    * {
        font-family: 'OTWelcomeRA';
    }

    td {
        border-bottom : 2px dotted green;
        padding-left : 20px;
    }

    tr:hover {
        background-color : pink;
        font-weight : bold;
    }

    td:nth-child(2) {
        width : 300px;
    }

    input, textarea {
        padding : 5px;
        border-radius : 5px;
    }

    div {
        margin-top : 20px;
    }

    button {
        padding-top : 5px;
    }
</style>
</head>
<body>
<th:block th:if="${ list }">
    <h1 onclick="location.href='/meeting'">미팅 스케쥴</h1>
    <hr>
    <table>
        <tr th:each="vo : ${list}">
            <td th:class="${vo.id}">[[${ vo.name }]]</td>
            <td th:class="${vo.id}" th:onclick="|displayReply(${vo.id})|">[[${ vo.title }]]</td>
            <td th:class="${vo.id}">[[${ vo.meetingDate }]]</td>
            <td><a th:href="@{/meeting/delete(id=${vo.id})}"><img src="/images/delete.png" width="20"></a></td>
            <td><img th:onclick="|displayUpdateForm(${vo.id})|" src="/images/edit.png" width="20"></td>
            <td><img th:onclick="|insertReply(${vo.id})|" src="/images/comment.png" width="25"></td>
        </tr>
    </table>
</th:block>
<th:block th:if="${ msg }">
    <script th:inline="javascript">
        alert([[${ msg }]]); // 메시지가 있으면 알림창 출력
    </script>
</th:block>
<hr>
<button onclick="displayDiv(1);">미팅 정보 작성</button>
<button onclick="displayDiv(2);">미팅 정보 검색</button>
<th:block th:if="${ button }">
    <hr>
    <button onclick="location.href='/meeting'">[[${ button }]]</button>
</th:block>
<script>
function displayDiv(type) {
    if(type == 1) {
        document.getElementById("search").style.display='none'; // 검색 폼 숨김
        document.getElementById("write").style.display='block'; // 작성 폼 표시
        document.getElementById("divT").textContent="미팅정보 작성"; // 제목 변경
        document.querySelector("#write [type=submit]").value="등록"; // 버튼 문구 변경
    } else if(type == 2) {
        document.getElementById("write").style.display='none'; // 작성 폼 숨김
        document.getElementById("search").style.display='block'; // 검색 폼 표시
    }
}

function displayUpdateForm(cv) {
    var doms = document.getElementsByClassName(cv); // 같은 글 번호를 가진 칸들 찾기
    document.getElementById("search").style.display='none'; // 검색 폼 숨김
    document.getElementById("write").style.display='block'; // 작성 폼을 수정 폼처럼 표시
    document.getElementById("m_name").value = doms[0].textContent; // 기존 이름 채우기
    document.getElementById("m_title").value = doms[1].textContent; // 기존 목적 채우기
    var str = doms[2].textContent; // 기존 날짜 문자열
    var ary = str.split(/\D+/g); // 숫자만 분리
    var meeting_dt = ary[0]+"-"+ary[1]+"-"+ary[2]+"T"+ary[3]+":"+ary[4]; // datetime-local 형식 생성
    document.getElementById("m_dt").value = meeting_dt; // 기존 날짜 채우기
    document.getElementById("divT").textContent="미팅정보 수정"; // 제목 변경
    document.querySelector("#write [type=submit]").value="수정"; // 버튼 문구 변경
    document.querySelector("#write [type=hidden]").value=cv; // 수정할 글 번호 저장
    document.querySelector("#write form").action="/meeting/update"; // 수정 요청 주소로 변경
}

function displayReply(id) {
    var xhr = new XMLHttpRequest(); // 비동기 요청 객체 생성
    xhr.onload = function () {
        if(xhr.status == 200) {
            let jsondoms = JSON.parse(xhr.responseText); // 댓글 JSON 배열을 객체로 변환
            let replyContent = ""; // 알림창에 보여 줄 댓글 문자열
            for(let i in jsondoms) {
                i = Number(i);
                replyContent += "[댓글"+(i+1)+"] 작성자명 : "+jsondoms[i].name+", 내용 : "+jsondoms[i].content+"\n";
            }
            if (!replyContent)
                replyContent = "아직 댓글이 없네요!!"; // 댓글이 없을 때 메시지
            window.alert(replyContent); // 댓글 목록 알림창 출력
        }
    }
    xhr.open("GET", "/meeting/lreply?refid="+id, true); // 댓글 목록 요청
    xhr.send(); // 요청 전송
}

function insertReply(id) {
    let name = window.prompt("댓글 작성자의 성명을 입력하세요.."); // 댓글 작성자 입력
    let content = window.prompt("댓글 내용을 입력하세요.."); // 댓글 내용 입력
    let query = "name="+name+"&content="+content+"&refid="+id; // 요청 파라미터 생성
    var xhr = new XMLHttpRequest(); // 비동기 요청 객체 생성
    xhr.onload = function () {
        if(xhr.status == 200) {
            let jsondom = JSON.parse(xhr.responseText); // 등록 결과 JSON 변환
            if (jsondom.result == true)
                window.alert("댓글 작성이 완료되었습니다."); // 성공 메시지
            else
                window.alert("댓글 작성에 실패했습니다."); // 실패 메시지
        }
    };
    xhr.open("GET", "/meeting/ireply?"+query, true); // 댓글 등록 요청
    xhr.send(); // 요청 전송
}
</script>
<div id="write" style="display:none">
 <hr><h2 id="divT">미팅정보 작성</h2>
 <form method="post" action="/meeting/insert">
 <input type="hidden" name="id" value="0">
 미팅 대상 이름 : <input id="m_name" type="text" name="name" required>
 <br>
 미팅 목적 : <br>
 <textarea id="m_title" rows="3" cols="60" name="title" required></textarea>
 <br>
 날짜와 시간 : <input id="m_dt" type="datetime-local" name="meetingDate" required>
 <br><br>
 <input type="submit" value="등록">
 <input type="button" value="취소" onclick="document.getElementById('write').style.display='none';return false">
 </form>
</div>
<div id="search" style="display:none">
 <hr>
 <form method="get" action="/meeting/search">
 검색어 : <input type="search" name="keyword">
 <input type="submit" value="검색">
 </form>
 <hr>
 <form method="get" action="/meeting/searchname">
 이름으로 검색: <input type="search" name="name">
 <input type="submit" value="검색">
 </form>
</div>
</body>
</html>

table은 list가 있을 때만 출력된다.
tr th:each="vo : ${list}"는 list 안의 MeetingDTO 객체를 하나씩 꺼내 vo라는 이름으로 사용한다.


각 행의 name, title, meetingDate에는 th:class="${vo.id}"가 붙어 있다.
이 값은 수정 버튼을 눌렀을 때 같은 글 번호를 가진 칸들을 찾아 기존 데이터를 수정 폼에 채우기 위해 사용된다.


삭제 아이콘은 /meeting/delete?id=글번호 요청을 보낸다.
수정 아이콘은 displayUpdateForm(id) 함수를 실행한다.
댓글 아이콘은 insertReply(id) 함수를 실행한다.


displayDiv(1)은 작성 폼을 보여 주고, displayDiv(2)는 검색 폼을 보여 준다.
displayUpdateForm(id)는 목록에 이미 출력된 값을 읽어 작성 폼에 채운 뒤, 폼의 요청 주소를 /meeting/update로 바꾼다.
즉, 하나의 폼을 등록용으로도 쓰고 수정용으로도 다시 사용하는 구조이다.


displayReply(id)는 /meeting/lreply?refid=글번호로 Ajax 요청을 보내 댓글 목록을 가져온다.
응답으로 받은 댓글 목록은 알림창에 출력된다.


insertReply(id)는 prompt로 댓글 작성자와 내용을 입력받고, /meeting/ireply로 Ajax 요청을 보내 댓글을 등록한다.
응답으로 { "result": true }가 오면 댓글 작성 완료 메시지를 보여 준다.


그래서 결과가 이렇게 나온다

미팅 목록 조회와 미팅 정보 작성 흐름

처음 /meeting으로 요청하면 전체 미팅 목록이 조회된다.
Controller는 dao.listM()을 호출하고, Mapper는 meeting 테이블의 전체 미팅 정보를 조회한다.
조회 결과는 List<MeetingDTO>로 반환되고, 화면에서는 th:each로 목록을 반복 출력한다.


미팅 정보 작성 버튼을 누르면 작성 폼이 열린다.
이름, 미팅 목적, 날짜와 시간을 입력하고 등록하면 /meeting/insert 요청이 실행된다.
등록이 성공하면 다시 전체 목록을 조회해서 최신 미팅 목록을 화면에 보여 준다.

이 결과는 전체 미팅 목록을 확인하고, 미팅 정보 작성 폼을 열어 새 일정을 등록하는 흐름을 보여 준다.
입력한 값은 MeetingDTO에 담기고, Mapper의 insertM()이 실행된 뒤 최신 목록이 다시 출력된다.


미팅 목적 검색과 이름 검색 흐름

미팅 정보 검색 버튼을 누르면 검색 폼이 열린다.
첫 번째 검색 폼은 keyword 값을 /meeting/search로 보내고, 미팅 목적에서 검색어가 포함된 데이터를 찾는다.
두 번째 검색 폼은 name 값을 /meeting/searchname으로 보내고, 미팅 대상 이름이 같은 데이터를 찾는다.


검색 결과가 있으면 list가 화면에 전달된다.
그리고 button 값도 함께 전달되어 메인화면으로 버튼이 나타난다.
이 버튼을 누르면 다시 /meeting으로 이동해서 전체 목록을 볼 수 있다.

이 결과는 미팅 목적 검색과 이름 검색을 통해 원하는 미팅 일정만 따로 확인하는 흐름을 보여 준다.
목적 검색은 포함 검색이고, 이름 검색은 정확히 같은 이름을 찾는 검색이다.


미팅 정보 수정과 삭제 흐름

수정 아이콘을 누르면 displayUpdateForm(id) 함수가 실행된다.
이 함수는 같은 글 번호를 class로 가진 name, title, meetingDate 값을 읽어 작성 폼에 채운다.
그리고 폼 제목을 미팅정보 수정으로 바꾸고, 전송 주소를 /meeting/update로 변경한다.


수정 버튼을 누르면 POST 방식으로 수정 데이터가 전달된다.
Controller는 MeetingDTO 객체로 값을 받고, dao.updateM(vo)를 호출한다.
수정이 성공하면 다시 전체 목록을 조회해서 변경된 결과를 화면에 보여 준다.


삭제 아이콘을 누르면 /meeting/delete?id=글번호 요청이 실행된다.
삭제가 성공하면 삭제된 데이터가 빠진 최신 목록이 다시 출력된다.

이 결과는 목록에 있는 미팅 정보를 수정하거나 삭제하는 흐름을 보여 준다.
수정은 이미 화면에 출력된 값을 폼에 다시 채우고, 삭제는 글 번호를 기준으로 DB에서 해당 행을 제거한다.


댓글 작성과 댓글 목록 확인 흐름

댓글 아이콘을 누르면 insertReply(id) 함수가 실행된다.
이 함수는 prompt로 댓글 작성자 이름과 댓글 내용을 입력받는다.
그리고 name, content, refid 값을 /meeting/ireply로 보낸다.


Controller는 ReplyVO 객체로 댓글 값을 받고, dao.insertReply(vo)를 호출한다.
댓글 등록이 성공하면 { "result": true } 형태의 JSON 응답이 돌아오고, 화면에는 댓글 작성 완료 메시지가 나타난다.


미팅 목적 칸을 클릭하면 displayReply(id) 함수가 실행된다.
이 함수는 /meeting/lreply?refid=글번호로 요청을 보내 해당 미팅 글의 댓글 목록을 가져온다.
댓글이 있으면 댓글 작성자와 내용이 알림창에 출력되고, 댓글이 없으면 “아직 댓글이 없네요!!”라는 메시지가 출력된다.

이 결과는 특정 미팅 글에 댓글을 작성하고, 다시 그 미팅 글의 댓글 목록을 확인하는 흐름을 보여 준다.
댓글 기능은 화면 전체를 새로 그리지 않고 Ajax로 필요한 데이터만 주고받는다.


이 예제의 핵심은 미팅 일정 CRUD는 ModelAndView로 화면을 다시 구성하고, 댓글 등록과 댓글 조회는 Ajax와 @ResponseBody로 JSON 데이터를 주고받는다는 점이다.
미팅 일정은 Controller → Mapper → DB → DTO → View 흐름으로 화면을 다시 구성하고, 댓글은 Controller → Mapper → DB → VO → JSON 응답 → JavaScript 처리 흐름으로 동작한다.



4. 동적 SQL과 부분 컬럼 매핑 이해하기

ETC 예제는 앞에서 본 Emp 조회 예제를 더 유연하게 확장한 예제이다.
앞에서는 정해진 SQL을 실행해서 전체 목록, 조건 조회, 부분 목록 조회를 확인했다.
이번 예제에서는 실행할 때마다 테이블명, 컬럼명, 조건, 조회 컬럼을 다르게 구성하는 흐름을 확인한다.


즉, 이 예제는 단순히 Emp 테이블을 조회하는 예제가 아니다.
MyBatis에서 #{}와 ${}를 언제 다르게 쓰는지, <script>, <where>, <if>로 조건을 어떻게 동적으로 붙이는지, @SelectProvider로 SQL을 어떻게 코드에서 조립하는지 보여 준다.


이 예제의 핵심은 SQL 문장 자체가 실행 상황에 따라 달라질 수 있다는 점이다.
다만 모든 값을 무조건 동적으로 바꾸면 위험하므로, 값 데이터는 #{}로 처리하고 테이블명이나 컬럼명처럼 SQL 문장 일부가 되어야 하는 경우에만 ${}를 조심해서 사용해야 한다.


사용하는 파일은 크게 ETCMapper.java, ETCController.java, EmpDTO.java, EmpPartDTO.java, etcResult.html이다.
ETCMapper는 다양한 동적 조회 SQL을 실행하고, ETCController는 /list1부터 /list6까지 요청을 받아 결과를 화면으로 넘긴다.


이 예제가 같이 보여주는 개념

이 예제는 앞에서 배운 MyBatis 조회 흐름을 바탕으로 동적 SQL 작성 방식을 보여 준다.
동적 SQL은 실행할 때 조건이나 정렬 기준이 달라지는 SQL을 뜻한다.


list1은 테이블명과 정렬 컬럼을 바꿔서 조회한다.
list2는 검색할 컬럼명을 바꿔서 조회한다.
list3은 여러 사번을 IN 조건으로 조회한다.
list4는 전달된 값이 있을 때만 조건을 붙인다.
list5는 @SelectProvider와 SQL 빌더로 조회문을 만든다.
list6은 전체 직원 정보가 아니라 이름과 급여만 조회해서 다른 DTO에 담는다.


이 예제에서 같이 봐야 하는 개념은 다음과 같다.

  • ${}는 테이블명이나 컬럼명처럼 SQL 문장 일부를 바꿀 때 사용한다.
  • #{}는 검색어, 급여, 이름 같은 값 데이터를 안전하게 넣을 때 사용한다.
  • @Param은 여러 매개변수 이름을 SQL에서 분명하게 사용할 때 필요하다.
  • IN 조건은 여러 값 중 하나라도 일치하는 데이터를 조회할 때 사용한다.
  • <script>는 어노테이션 안에서 동적 SQL 태그를 사용할 수 있게 한다.
  • <where>는 조건이 있을 때만 where를 붙이고, 앞쪽의 불필요한 and를 정리해 준다.
  • <if>는 조건에 따라 특정 SQL 조각을 붙일지 말지 결정한다.
  • @SelectProvider는 SQL을 메서드 안에서 조립해서 제공하는 방식이다.
  • EmpPartDTO는 필요한 컬럼만 담기 위한 별도 DTO이다.

즉, 이 예제는 MyBatis의 기본 조회 문법을 넘어서, 상황에 따라 달라지는 조회 조건을 어떻게 처리하는지 확인하는 예제이다.


코드 설명

먼저 EmpDTO.java는 직원 한 명의 전체 정보를 담는 객체이다.
empno, ename, job, hiredate, sal을 모두 가진다.

// EmpDTO.java
package com.example.springedu.domain;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Getter
@Setter
@ToString
public class EmpDTO {
    private int empno; // 사번
    private String ename; // 직원명
    private String job; // 직무
    private String hiredate; // 입사일
    private int sal; // 급여
}

EmpDTO는 직원 한 명의 전체 정보를 담을 때 사용한다.
그래서 select empno, ename, job, hiredate, sal처럼 여러 컬럼을 조회하는 결과를 받을 수 있다.


화면에서 [[${emp}]]로 출력하면 @ToString에 의해 EmpDTO(empno=..., ename=..., job=..., hiredate=..., sal=...) 형태로 출력된다.


다음으로 EmpPartDTO.java는 직원 정보 중 일부 컬럼만 담는 객체이다.
이 예제에서는 직원명과 급여만 담는다.

// EmpPartDTO.java
package com.example.springedu.domain;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Getter
@Setter
@ToString
public class EmpPartDTO {
    private String ename; // 직원명
    private int sal; // 급여
}

EmpPartDTO는 EmpDTO보다 필드가 적다.
즉, 직원 전체 정보가 아니라 직원명과 급여만 필요할 때 사용하는 객체이다.


조회하는 컬럼이 달라지면 결과를 담는 DTO도 달라질 수 있다.
list6이 바로 이 흐름을 보여 준다.


다음으로 ETCMapper.java는 다양한 방식의 동적 조회 SQL을 작성한 Mapper이다.

// ETCMapper.java
package mybatis.dao;

import com.example.springedu.domain.EmpDTO;
import com.example.springedu.domain.EmpPartDTO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.SelectProvider;
import org.apache.ibatis.jdbc.SQL;

import java.util.List;

@Mapper
public interface ETCMapper {
    @Select("select empno, ename, job, date_format(hiredate, '%Y년 %m월 %d일') hiredate, sal from ${t} order by ${c}")
    public List<EmpDTO> list1(@Param("t") String t, @Param("c") String c); // 테이블명과 정렬 컬럼을 동적으로 사용

    @Select("select empno, ename, job, date_format(hiredate, '%Y년 %m월 %d일') hiredate, sal from emp where ${colname} like concat('%',#{key},'%')")
    public List<EmpDTO> list2(@Param("colname") String cn, @Param("key") String key); // 검색 컬럼명은 동적, 검색값은 안전하게 전달

    @Select("select empno, ename, job, date_format(hiredate, '%Y년 %m월 %d일') " +
            "hiredate, sal from emp where empno in (${datalist})")
    public List<EmpDTO> list3(String datalist); // 여러 사번을 IN 조건으로 조회

    @Select("<script> select empno, ename, job, date_format(hiredate, '%Y년 %m월 %d일') hiredate, sal from emp " +
            "<where> " +
            "<if test='ename != null'> ename like concat('%',#{ename},'%')</if>" +
            "<if test='sal gt 0'> and sal > #{sal}</if>" +
            "</where></script>")
    public List<EmpDTO> list4(EmpDTO vo); // 전달된 값이 있을 때만 조건 추가

    @SelectProvider(type = EmpSqlBuilder.class, method = "buildGetEmpsByName")
    List<EmpDTO> list5(String name); // SQL 빌더가 만든 SQL 실행

    class EmpSqlBuilder {
        public static String buildGetEmpsByName(final String name) {
            return new SQL(){{
                SELECT("empno, ename, job, hiredate, sal"); // 조회 컬럼 지정
                FROM("emp"); // 조회 테이블 지정
                if (name != null) {
                    WHERE("ename like concat(#{name}, '%')"); // 이름이 있으면 조건 추가
                }
                ORDER_BY("sal"); // 급여 기준 정렬
            }}.toString();
        }
    }

    @Select("select ename, sal from emp")
    public List<EmpPartDTO> list6(); // 이름과 급여만 조회
}

list1()은 테이블명과 정렬 컬럼을 매개변수로 받는다.
그래서 /list1?tname=emp&cname=sal처럼 요청하면 emp 테이블을 sal 기준으로 정렬해서 조회한다.


여기서 테이블명과 컬럼명은 값 데이터가 아니라 SQL 문장 일부이다.
그래서 #{}가 아니라 ${}를 사용한다.
다만 ${}는 문자열이 그대로 들어가기 때문에 사용자가 아무 값이나 넣을 수 있는 구조에서는 위험할 수 있다.


list2()는 검색할 컬럼명을 동적으로 바꾼다.
cname=ename, key=A를 전달하면 ename 컬럼에서 A가 포함된 직원을 찾는다.
컬럼명은 ${colname}으로 바꾸고, 검색값은 #{key}로 안전하게 넣는다.


list3()은 IN 조건을 사용한다.
IN은 괄호 안에 있는 여러 값 중 하나와 일치하면 조회하는 조건이다.
이 예제에서는 컨트롤러에서 "7788, 7839, 7902" 문자열을 넘기므로 해당 사번 세 명만 조회된다.


list4()는 <script>, <where>, <if>를 사용한다.
ename 값이 있으면 이름 조건을 붙이고, sal 값이 0보다 크면 급여 조건을 붙인다.
조건이 하나도 없으면 where 자체가 붙지 않을 수 있고, 조건이 있으면 <where>가 적절히 where를 붙여 준다.


list5()는 @SelectProvider를 사용한다.
@SelectProvider는 SQL을 문자열로 바로 적는 대신, 별도의 메서드에서 SQL을 만들어서 제공하는 방식이다.
이 예제에서는 EmpSqlBuilder.buildGetEmpsByName()이 실행할 SQL을 만든다.


list6()은 ename, sal 두 컬럼만 조회한다.
그래서 반환 타입도 EmpDTO가 아니라 EmpPartDTO이다.
전체 직원 정보가 필요 없고 일부 컬럼만 필요할 때 이런 방식으로 별도 DTO를 사용할 수 있다.


다음으로 ETCController.java는 /list1부터 /list6 요청을 받아 ETCMapper의 메서드를 호출한다.

// ETCController.java
package com.example.springedu.controller;

import com.example.springedu.domain.EmpPartDTO;
import mybatis.dao.ETCMapper;
import com.example.springedu.domain.EmpDTO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.servlet.ModelAndView;

import java.util.List;

@Controller
public class ETCController {
    @Autowired
    ETCMapper dao; // MyBatis Mapper를 주입받는다

    @GetMapping("/list1")
    public ModelAndView list1(String tname, String cname) {
        ModelAndView mav = new ModelAndView(); // 데이터와 화면 이름을 담는 객체
        List<EmpDTO> list = dao.list1(tname, cname); // 테이블명과 정렬 컬럼 전달
        mav.addObject("list", list); // 조회 결과를 list 이름으로 전달
        mav.setViewName("etcResult"); // etcResult.html로 이동
        return mav;
    }

    @GetMapping("/list2")
    public ModelAndView list2(String cname, String key) {
        ModelAndView mav = new ModelAndView(); // 데이터와 화면 이름을 담는 객체
        List<EmpDTO> list = dao.list2(cname, key); // 검색 컬럼과 검색어 전달
        mav.addObject("list", list); // 조회 결과를 list 이름으로 전달
        mav.setViewName("etcResult"); // etcResult.html로 이동
        return mav;
    }

    @GetMapping("/list3")
    public ModelAndView list3() {
        ModelAndView mav = new ModelAndView(); // 데이터와 화면 이름을 담는 객체
        List<EmpDTO> list = dao.list3("7788, 7839, 7902"); // 사번 목록 문자열 전달
        mav.addObject("list", list); // 조회 결과를 list 이름으로 전달
        mav.setViewName("etcResult"); // etcResult.html로 이동
        return mav;
    }

    @GetMapping("/list4")
    public ModelAndView list4(EmpDTO vo) {
        ModelAndView mav = new ModelAndView(); // 데이터와 화면 이름을 담는 객체
        List<EmpDTO> list = dao.list4(vo); // EmpDTO 안의 값으로 조건 조회
        mav.addObject("list", list); // 조회 결과를 list 이름으로 전달
        mav.setViewName("etcResult"); // etcResult.html로 이동
        return mav;
    }

    @GetMapping("/list5")
    public ModelAndView list5(String name) {
        ModelAndView mav = new ModelAndView(); // 데이터와 화면 이름을 담는 객체
        List<EmpDTO> list = dao.list5(name); // SQL Provider에 이름 전달
        mav.addObject("list", list); // 조회 결과를 list 이름으로 전달
        mav.setViewName("etcResult"); // etcResult.html로 이동
        return mav;
    }

    @GetMapping("/list6")
    public ModelAndView list6(String name) {
        ModelAndView mav = new ModelAndView(); // 데이터와 화면 이름을 담는 객체
        List<EmpPartDTO> list = dao.list6(); // 이름과 급여만 조회
        mav.addObject("list", list); // 조회 결과를 list 이름으로 전달
        mav.setViewName("etcResult"); // etcResult.html로 이동
        return mav;
    }
}

ETCController의 메서드들은 모두 비슷한 흐름을 가진다.
요청 파라미터를 받고, Mapper 메서드를 호출하고, 조회 결과를 list라는 이름으로 화면에 넘긴다.


다른 점은 각 메서드가 호출하는 Mapper 메서드와 전달하는 값이다.
list1()은 테이블명과 정렬 컬럼을 전달하고, list2()는 검색 컬럼과 검색어를 전달한다.
list3()은 컨트롤러 안에서 정해 둔 사번 목록을 전달한다.
list4()는 요청 파라미터를 EmpDTO 객체로 받아 동적 조건에 사용한다.
list5()는 @SelectProvider에 이름 값을 넘긴다.
list6()은 별도 조건 없이 이름과 급여만 조회한다.


마지막으로 etcResult.html은 조회 결과를 출력하는 공통 화면이다.

// etcResult.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<h1>Emp 테이블에서 읽어오기</h1>
<hr>
<th:block th:if="${num}">
    <h2>데이터는 총 [[${ num }]]개가 있어요.....</h2>
</th:block>
<th:block th:if="${list}">
    <ul>
    <th:block th:each="emp : ${list}">
        <li>[[${emp}]]</li>
    </th:block>
    </ul>
</th:block>
<th:block th:if="${msg}">
    <h2>[[${ msg }]]</h2>
</th:block>
<th:block th:if="${emp}">
    <h2>[[${ emp }]]</h2>
</th:block>
</body>
</html>

etcResult.html은 list가 있으면 목록을 반복 출력한다.
이 구조는 앞에서 본 empResult.html과 거의 같다.


그래서 /list1부터 /list6까지 결과 화면의 모양은 비슷하다.
하지만 출력되는 데이터는 각 Mapper 메서드의 SQL에 따라 달라진다.


특히 /list6은 EmpDTO가 아니라 EmpPartDTO 목록을 출력한다.
그래서 화면에도 EmpPartDTO(ename=..., sal=...) 형태로 표시된다.


그래서 결과가 이렇게 나온다

테이블명과 정렬 컬럼을 동적으로 받은 결과

/list1?tname=emp&cname=sal 요청은 emp 테이블을 sal 기준으로 정렬해서 조회한다.
ETCController는 tname과 cname 값을 받아 dao.list1(tname, cname)으로 넘긴다.


ETCMapper의 list1()에서는 ${t} 자리에 emp, ${c} 자리에 sal이 들어간다.
그 결과 급여가 낮은 직원부터 높은 직원 순서로 출력된다.

이 화면은 sal 기준으로 정렬된 직원 목록이다.
SMITH의 급여 800부터 KING의 급여 5000까지 오름차순으로 출력된다.


검색 컬럼명과 검색어를 동적으로 받은 결과

/list2?cname=ename&key=A 요청은 ename 컬럼에서 A가 포함된 직원을 조회한다.
cname은 검색할 컬럼명이고, key는 검색어이다.


ETCMapper의 list2()에서는 ${colname} 자리에 ename이 들어가고, #{key} 자리에 A가 안전하게 전달된다.
그 결과 이름에 A가 포함된 직원들이 출력된다.

이 화면은 직원명에 A가 포함된 데이터만 조회한 결과이다.
ALLEN, WARD, MARTIN, BLAKE, CLARK, ADAMS, JAMES처럼 이름에 A가 들어간 직원들이 출력된다.


IN 조건으로 정해진 사번만 조회한 결과

/list3 요청은 컨트롤러에서 미리 지정한 사번 목록만 조회한다.
이 예제에서는 "7788, 7839, 7902" 문자열을 Mapper로 넘긴다.


ETCMapper의 list3()은 empno in (${datalist}) 조건을 사용한다.
그래서 사번이 7788, 7839, 7902인 직원만 조회된다.

이 화면은 IN 조건으로 특정 사번 세 개만 조회한 결과이다.
SCOTT, KING, FORD 세 명만 목록에 출력된다.


script, where, if로 조건을 동적으로 붙인 결과

/list4?ename=A&sal=1000 요청은 이름에 A가 포함되고, 급여가 1000보다 큰 직원을 조회한다.
요청 파라미터 ename과 sal은 EmpDTO 객체에 자동으로 담긴다.


ETCMapper의 list4()는 <if> 조건을 검사한다.
ename이 null이 아니므로 이름 조건을 붙이고, sal이 0보다 크므로 급여 조건도 붙인다.
그 결과 두 조건을 모두 만족하는 직원만 출력된다.

이 화면은 이름에 A가 포함되고 급여가 1000보다 큰 직원만 조회한 결과이다.
조건에 맞는 ALLEN, WARD, MARTIN, BLAKE, CLARK, ADAMS가 출력된다.


SelectProvider로 SQL을 만들어 조회한 결과

/list5?name=A 요청은 @SelectProvider가 만든 SQL로 직원을 조회한다.
ETCController는 name=A 값을 dao.list5(name)으로 넘긴다.


EmpSqlBuilder는 name이 null이 아니면 ename like concat(#{name}, '%') 조건을 추가한다.
이 조건은 이름이 A로 시작하는 직원을 찾는 조건이다.
그리고 ORDER_BY("sal")에 의해 급여 기준으로 정렬된다.

이 화면은 이름이 A로 시작하는 직원을 @SelectProvider로 조회한 결과이다.
ADAMS와 ALLEN이 조회되고, 급여 기준으로 ADAMS, ALLEN 순서로 출력된다.


이 결과에서 hiredate가 1983-01-12처럼 출력되는 이유는 list5()의 SQL에서 date_format()을 사용하지 않았기 때문이다.
앞의 예제들은 날짜를 한글 형식으로 바꿔 조회했지만, 이 예제는 원래 날짜 값에 가까운 형태로 출력된다.


필요한 컬럼만 조회해서 다른 DTO로 받은 결과

/list6 요청은 직원 전체 정보가 아니라 직원명과 급여만 조회한다.
ETCMapper의 list6()은 select ename, sal from emp만 실행한다.


조회 컬럼이 ename, sal 두 개뿐이므로 반환 타입도 List<EmpPartDTO>이다.
EmpPartDTO에는 ename과 sal 필드만 있다.

이 화면은 직원명과 급여만 조회해서 EmpPartDTO로 받은 결과이다.
그래서 EmpDTO(...)가 아니라 EmpPartDTO(ename=..., sal=...) 형태로 출력된다.


이 예제의 핵심은 같은 Emp 데이터를 조회하더라도 SQL을 어떻게 구성하느냐에 따라 정렬, 검색 조건, 조회 컬럼, 반환 DTO가 달라진다는 점이다.
MyBatis에서는 정적인 SQL만 쓰는 것이 아니라 ${}, #{}, <script>, <if>, @SelectProvider를 이용해 실행 상황에 맞는 동적 조회도 구성할 수 있다.


다만 ${}는 문자열을 그대로 SQL 문장에 붙이는 방식이므로, 실제 서비스에서는 사용자가 입력한 값을 그대로 넣으면 위험하다.
테이블명이나 컬럼명처럼 허용할 값이 정해진 경우에는 서버에서 허용 목록을 확인한 뒤 사용하는 것이 안전하다.


5. MyTable Mapper insert 테스트와 트랜잭션 흐름 이해하기

MyTableMapperInsertTest 예제는 웹 화면을 거치지 않고 테스트 코드에서 Mapper의 insert 동작을 직접 확인하는 예제이다.
앞의 방명록 예제에서는 사용자가 화면에서 값을 입력하고, Controller가 그 값을 Mapper로 넘겨 DB에 저장했다.
이번 예제는 화면과 Controller를 빼고, JUnit 테스트 코드에서 DTO를 직접 만들고 Mapper의 insert()를 호출한다.


이 예제의 핵심은 DTO → Mapper → SQL 실행 → DB insert 흐름을 테스트 코드만으로 확인하는 것이다.
즉, 웹 요청이 없어도 Mapper가 제대로 DB에 데이터를 추가하는지 먼저 확인할 수 있다.


또 이 예제에는 @Transactional과 @Rollback(false)가 함께 나온다.
이 두 어노테이션은 테스트에서 실행한 DB 작업을 실제로 저장할지, 테스트가 끝난 뒤 되돌릴지 결정하는 데 중요하다.


이 예제에서 다시 확인할 MyBatis 흐름

이 예제에서 가장 먼저 봐야 할 MyBatis 흐름은 MyTableMapper의 insert() 메서드가 실제 insert SQL을 실행한다는 점이다.
테스트 코드에서는 MyTableDTO 객체를 만들고, 그 안에 이름과 나이를 저장한다.
그리고 dao.insert(dto)를 호출한다.


이때 dao는 MyTableMapper를 주입받은 변수이다.
즉, 테스트 코드는 직접 SQL을 작성하거나 실행하지 않고 Mapper 메서드를 호출한다.
Mapper는 연결된 insert SQL을 실행하고, 성공 여부를 boolean으로 돌려준다.


이 예제에서 다시 확인할 MyBatis 핵심은 다음과 같다.

  • DTO 객체에 저장할 데이터를 담는다.
  • Mapper의 insert() 메서드에 DTO 객체를 전달한다.
  • Mapper는 DTO 안의 값을 #{myName}, #{myAge}로 꺼내 insert SQL에 넣는다.
  • insert가 성공하면 boolean 결과가 true로 돌아온다.
  • 테스트 로그에서 실제 실행된 SQL, 전달된 값, 처리된 행 수를 확인할 수 있다.

즉, 5번 예제의 중심은 MyTableDTO에 담은 값이 MyTableMapper의 insert SQL로 전달되어 DB에 저장되는 흐름이다.


이 예제에 같이 섞인 테스트와 트랜잭션 흐름

이 예제는 일반 웹 요청이 아니라 JUnit 테스트로 실행된다.
그래서 @SpringBootTest, @Test, @Transactional, @Rollback(false)가 함께 나온다.


@SpringBootTest는 Spring Boot 테스트 환경을 실행한다.
이 설정이 있어야 테스트 코드에서도 Spring이 관리하는 Mapper를 주입받을 수 있다.


@Test는 이 메서드가 테스트 메서드라는 뜻이다.
insert() 메서드를 실행하면 테스트가 시작되고, 안에 작성한 반복문이 실행된다.


@Transactional은 테스트 메서드 안의 DB 작업을 하나의 트랜잭션으로 묶는다.
트랜잭션은 여러 DB 작업을 하나의 작업 단위로 다루는 개념이다.
전체 작업이 성공하면 저장하고, 문제가 생기면 되돌릴 수 있다.


@Rollback(false)는 테스트가 끝난 뒤 데이터를 되돌리지 말라는 뜻이다.
보통 테스트에서는 DB가 더러워지지 않도록 자동으로 rollback되는 경우가 많다.
하지만 이 예제에서는 실제로 데이터가 들어가는지 확인해야 하므로 @Rollback(false)를 사용한다.


이 예제에 같이 섞인 흐름은 다음과 같다.

  • @SpringBootTest는 Spring Boot 테스트 환경을 실행한다.
  • @Autowired는 MyTableMapper를 테스트 클래스에 주입한다.
  • @Test는 테스트 메서드를 실행 대상으로 만든다.
  • @Transactional은 테스트 안의 DB 작업을 하나의 트랜잭션으로 묶는다.
  • @Rollback(false)는 테스트가 끝나도 insert 결과를 되돌리지 않게 한다.

이 부분은 MyBatis 자체 문법은 아니지만, Mapper의 insert 동작을 안전하게 테스트하기 위해 같이 사용되는 흐름이다.


코드 설명

먼저 MyTableDTO.java는 my_table에 저장할 데이터를 담는 객체이다.
DTO는 Data Transfer Object의 줄임말이다.
계층 사이에서 데이터를 전달하기 위한 객체라고 이해하면 된다.

// MyTableDTO.java
package com.example.springedu.domain;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Getter
@Setter
@ToString
public class MyTableDTO {
    private String myName; // 이름 값
    private int myAge; // 나이 값
}

myName은 저장할 이름 값이다.
myAge는 저장할 나이 값이다.


@Getter와 @Setter는 값을 읽고 저장하는 메서드를 자동으로 만들어 준다.
테스트 코드에서 dto.setMyName("둘리1"), dto.setMyAge(11)처럼 값을 넣을 수 있는 이유가 여기에 있다.


@ToString은 객체 내용을 문자열로 확인할 수 있게 만들어 준다.
이번 테스트에서는 직접 toString()을 출력하지 않지만, DTO 값을 확인해야 할 때 유용하다.


다음으로 MyTableMapper.java는 my_table에 접근하는 Mapper이다.
Mapper는 DAO처럼 DB에 접근하는 역할을 한다.

// MyTableMapper.java
package mybatis.dao;

import com.example.springedu.domain.MyTableDTO;
import org.apache.ibatis.annotations.*;

import java.util.List;

@Mapper
public interface MyTableMapper {
    @Select("select my_name, my_age from my_table")
    public List<MyTableDTO> list(); // my_table 전체 목록 조회

    @Insert("insert into my_table values (#{myName}, #{myAge})")
    public boolean insert(MyTableDTO visitor); // DTO 값을 사용해 데이터 추가
}

list()는 my_table의 전체 데이터를 조회한다.
조회 결과가 여러 행일 수 있으므로 반환 타입은 List<MyTableDTO>이다.


insert(MyTableDTO visitor)는 my_table에 한 행을 추가한다.
여기서 중요한 부분은 #{myName}과 #{myAge}이다.
이 두 값은 MyTableDTO 객체 안의 myName, myAge 필드값을 꺼내 SQL에 전달한다.


즉, 테스트 코드에서 dto.setMyName("둘리1"), dto.setMyAge(11)을 실행한 뒤 dao.insert(dto)를 호출하면, #{myName}에는 둘리1, #{myAge}에는 11이 전달된다.


다음으로 MyTableMapperInsertTest.java는 Mapper의 insert()가 실제로 동작하는지 확인하는 테스트 코드이다.

// MyTableMapperInsertTest.java
package com.example.springedu;

import com.example.springedu.domain.MyTableDTO;
import mybatis.dao.MyTableMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Rollback;
import org.springframework.transaction.annotation.Transactional;

@SpringBootTest
class MyTableMapperInsertTest {
    @Autowired
    MyTableMapper dao; // MyBatis Mapper를 주입받는다

    @Test
    @Transactional // 테스트 메서드의 DB 작업을 하나의 트랜잭션으로 묶는다
    @Rollback(false) // 테스트가 끝나도 insert 결과를 되돌리지 않는다
    void insert() {
        MyTableDTO dto; // insert에 사용할 DTO 변수

        for(int i=1; i < 6; i++) { // 1부터 5까지 반복한다
            dto = new MyTableDTO(); // 새 DTO 객체 생성
            dto.setMyName("둘리"+i); // 이름 값 저장
            dto.setMyAge(10+i); // 나이 값 저장

            boolean result = dao.insert(dto); // Mapper의 insert SQL 실행

            if(result)
                System.out.println("둘리"+i+" 입력 완료"); // 성공 시 출력
        }
    }
}

이 코드에서 dao는 MyTableMapper이다.
테스트 메서드는 dao.insert(dto)를 호출해서 Mapper에 연결된 insert SQL을 실행한다.


반복문은 총 다섯 번 실행된다.
i가 1일 때는 이름이 둘리1, 나이가 11인 객체가 만들어진다.
i가 2일 때는 이름이 둘리2, 나이가 12인 객체가 만들어진다.
이 흐름이 둘리5, 15까지 반복된다.


각 반복마다 새 MyTableDTO 객체를 만든다.
그래서 같은 객체를 계속 덮어쓰는 것이 아니라, 매번 새 데이터를 담은 객체를 Mapper에 전달한다.


dao.insert(dto)가 성공하면 true가 반환되고, 콘솔에는 둘리1 입력 완료, 둘리2 입력 완료처럼 메시지가 출력된다.
실제 DB에는 insert SQL이 반복 실행된다.


그래서 결과가 이렇게 나온다

Mapper insert 테스트 실행 결과

테스트를 실행하면 MyBatis 로그에 실제 실행된 SQL과 전달된 값이 출력된다.
로그에서 Preparing은 실행할 SQL을 준비했다는 뜻이다.
Parameters는 SQL에 전달된 실제 값이다.
Updates: 1은 한 번의 insert로 한 행이 추가되었다는 뜻이다.


예를 들어 첫 번째 반복에서는 둘리1, 11이 전달된다.
두 번째 반복에서는 둘리2, 12가 전달된다.
이런 식으로 다섯 번 반복되면서 둘리1부터 둘리5까지 저장된다.

이 결과는 MyTableMapperInsertTest의 insert() 테스트가 성공한 화면이다.
insert into my_table values (?, ?)가 반복 실행되고, Parameters에 둘리1, 11처럼 DTO에 저장한 값이 전달된다.
각 실행마다 Updates: 1이 출력되므로, 한 행씩 정상적으로 추가되었다고 볼 수 있다.


여기서 ?는 실제 값이 들어갈 자리이다.
MyBatis는 DTO에서 꺼낸 값을 이 자리에 안전하게 전달한다.
그래서 로그에는 SQL 문장과 실제 파라미터 값이 분리되어 보인다.


이 예제의 핵심은 테스트 코드에서 DTO를 만들고 Mapper의 insert()를 호출하면, MyBatis가 DTO 값을 SQL 파라미터로 연결해 실제 DB에 저장한다는 점이다.
@Rollback(false)가 붙어 있으므로 테스트가 끝난 뒤에도 저장 결과가 되돌아가지 않고 유지된다.



6. MyTable Mapper read 테스트와 컬럼명 매핑 이해하기

MyTableMapperReadTest 예제는 MyTableMapper의 list() 메서드를 테스트 코드에서 직접 실행하는 예제이다.
앞의 5번 예제에서는 DTO에 값을 담아 insert SQL로 DB에 저장하는 흐름을 확인했다.
이번 예제는 반대로 DB에 저장된 데이터를 조회하고, 조회 결과가 DTO 객체에 제대로 담기는지 확인한다.


이 예제의 핵심은 SELECT 결과 → 컬럼명 → DTO 필드명이 제대로 연결되어야 조회 결과를 객체 값으로 사용할 수 있다는 점이다.
SQL이 정상 실행되어도 컬럼명과 필드명이 맞지 않으면 조회된 행이 DTO 필드에 제대로 연결되지 않는다.
이 경우 객체가 null로 출력되거나, 필드값이 기본값처럼 보일 수 있다.


이번 예제에서는 my_table의 컬럼명이 my_name, my_age이고, MyTableDTO의 필드명은 myName, myAge이다.
즉, DB는 언더스코어 표기법을 사용하고, Java 객체는 카멜 표기법을 사용한다.
이 차이를 MyBatis가 자동으로 연결하도록 설정해야 한다.


이 예제에서 다시 확인할 MyBatis 흐름

이 예제에서 가장 먼저 봐야 할 MyBatis 흐름은 Mapper의 SELECT 결과가 DTO 객체로 매핑되는 과정이다.
MyTableMapper의 list() 메서드는 select my_name, my_age from my_table을 실행한다.
조회 결과는 여러 행이므로 List<MyTableDTO>로 반환된다.


하지만 조회 결과가 있다고 해서 무조건 DTO 필드에 값이 들어가는 것은 아니다.
MyBatis는 조회된 컬럼명과 DTO의 필드명을 기준으로 값을 연결한다.
따라서 my_name 컬럼이 myName 필드와 연결되어야 하고, my_age 컬럼이 myAge 필드와 연결되어야 한다.


이 예제에서 다시 확인할 MyBatis 핵심은 다음과 같다.

  • SELECT 결과가 여러 행이면 List<DTO>로 받을 수 있다.
  • Mapper의 list()는 조회 SQL을 실행한다.
  • 조회된 컬럼명은 DTO 필드명과 연결되어야 한다.
  • my_name과 myName처럼 표기법이 다르면 자동 매핑 설정이 필요하다.
  • map-underscore-to-camel-case 설정은 언더스코어 컬럼명을 카멜 표기법 필드명으로 연결해 준다.

즉, 6번 예제의 중심은 SELECT 결과가 DTO 객체에 제대로 매핑되는지 확인하는 흐름이다.


이 예제에 같이 섞인 테스트와 설정 흐름

이 예제는 웹 화면에서 조회하는 방식이 아니라 JUnit 테스트로 조회 결과를 확인한다.
@SpringBootTest를 사용하면 테스트 코드에서도 Spring Boot 환경이 실행되고, MyTableMapper를 주입받을 수 있다.


application.properties에는 MyBatis 로그 출력 설정과 컬럼명 자동 매핑 설정이 들어간다.
처음에는 map-underscore-to-camel-case 설정이 주석 처리되어 있어서 조회 결과가 DTO 필드에 제대로 들어가지 않는다.
이후 주석을 해제하면 my_name이 myName으로, my_age가 myAge로 자동 연결된다.


이 예제에 같이 섞인 흐름은 다음과 같다.

  • @SpringBootTest는 테스트에서 Spring Boot 환경을 실행한다.
  • @Autowired는 MyTableMapper를 테스트 클래스에 주입한다.
  • dao.list()는 Mapper의 조회 메서드를 실행한다.
  • System.out::println은 조회된 DTO 객체를 하나씩 출력한다.
  • application.properties 설정에 따라 컬럼명 자동 매핑 결과가 달라진다.

이 부분은 MyBatis 자체 SQL 문법은 아니지만, 조회 결과가 객체에 제대로 담기도록 설정하는 데 필요한 흐름이다.


코드 설명

먼저 MyTableDTO.java는 my_table 조회 결과를 담는 객체이다.

// MyTableDTO.java
package com.example.springedu.domain;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Getter
@Setter
@ToString
public class MyTableDTO {
    private String myName; // 이름 값
    private int myAge; // 나이 값
}

myName은 my_table의 my_name 컬럼 값이 들어가야 하는 필드이다.
myAge는 my_table의 my_age 컬럼 값이 들어가야 하는 필드이다.


여기서 중요한 점은 컬럼명과 필드명이 완전히 같지 않다는 것이다.
DB 컬럼명은 my_name, my_age처럼 언더스코어 표기법을 사용한다.
Java 필드명은 myName, myAge처럼 카멜 표기법을 사용한다.


다음으로 MyTableMapper.java는 my_table의 데이터를 조회하는 Mapper이다.

// MyTableMapper.java
package mybatis.dao;

import com.example.springedu.domain.MyTableDTO;
import org.apache.ibatis.annotations.*;

import java.util.List;

@Mapper
public interface MyTableMapper {
    @Select("select my_name, my_age from my_table")
    public List<MyTableDTO> list(); // my_table 전체 목록 조회

    @Insert("insert into my_table values (#{myName}, #{myAge})")
    public boolean insert(MyTableDTO visitor); // DTO 값을 사용해 데이터 추가
}

list()는 my_table의 my_name, my_age 컬럼을 조회한다.
조회 결과가 여러 행이므로 반환 타입은 List<MyTableDTO>이다.


정상적으로 매핑되면 my_name 컬럼 값은 MyTableDTO의 myName 필드에 들어간다.
my_age 컬럼 값은 MyTableDTO의 myAge 필드에 들어간다.


다음으로 MyTableMapperReadTest.java는 Mapper의 list()가 실제로 동작하는지 확인하는 테스트 코드이다.

// MyTableMapperReadTest.java
package com.example.springedu;

import mybatis.dao.MyTableMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
class MyTableMapperReadTest {
    @Autowired
    MyTableMapper dao; // MyBatis Mapper를 주입받는다

    @Test
    void read() {
        dao.list().stream().forEach(System.out::println); // 조회 결과를 하나씩 출력한다
    }
}

dao.list()는 MyTableMapper의 list() 메서드를 호출한다.
그러면 select my_name, my_age from my_table이 실행된다.


stream().forEach(System.out::println)은 조회된 MyTableDTO 객체를 하나씩 출력한다.
@ToString이 있기 때문에 객체 안에 값이 제대로 들어가면 MyTableDTO(myName=둘리1, myAge=11)처럼 출력된다.


다음으로 application.properties 설정을 확인해야 한다.

// application.properties
mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
#mybatis.configuration.map-underscore-to-camel-case=true

mybatis.configuration.log-impl 설정은 MyBatis가 실행한 SQL과 조회 결과를 콘솔에 보여 주게 한다.
그래서 테스트 실행 결과에서 Preparing, Columns, Row, Total 같은 로그를 확인할 수 있다.


처음에는 map-underscore-to-camel-case 설정이 주석 처리되어 있다.
주석 처리되어 있으면 my_name 컬럼과 myName 필드가 자동으로 연결되지 않는다.
그래서 실제 Row는 조회되지만, MyBatis가 컬럼을 DTO 필드에 연결하지 못해 출력 결과가 null로 나타난다.


아래처럼 주석을 해제하면 언더스코어 컬럼명이 카멜 표기법 필드명으로 자동 연결된다.

// application.properties
mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
mybatis.configuration.map-underscore-to-camel-case=true

이 설정을 적용하면 my_name은 myName으로 연결된다.
my_age는 myAge로 연결된다.
따라서 조회 결과가 MyTableDTO 객체에 정상적으로 담긴다.


그래서 결과가 이렇게 나온다

컬럼명 자동 매핑 설정 전 결과

처음 실행 결과에서는 select my_name, my_age from my_table이 정상 실행된다.
로그에도 Row: 둘리1, 11, Row: 둘리2, 12처럼 실제 데이터가 조회된 것이 보인다.


하지만 아래쪽 출력 결과는 null로 나온다.
이것은 조회 자체가 실패한 것이 아니다.
조회된 컬럼명이 MyTableDTO의 필드명과 자동으로 연결되지 못했기 때문이다.

이 결과는 DB에서는 데이터가 정상적으로 조회되었지만, DTO 필드에 값이 매핑되지 않은 상황이다.
my_name, my_age 컬럼명과 myName, myAge 필드명이 서로 다르기 때문에 자동 매핑 설정이 필요하다.


컬럼명 자동 매핑 설정 후 결과

application.properties에서 map-underscore-to-camel-case 설정을 활성화하면 결과가 달라진다.
이제 my_name 컬럼은 myName 필드로, my_age 컬럼은 myAge 필드로 자동 연결된다.


그 결과 System.out::println으로 출력했을 때 MyTableDTO(myName=둘리1, myAge=11)처럼 객체 값이 정상적으로 보인다.

이 결과는 컬럼명 자동 매핑 설정을 적용한 뒤의 출력이다.
DB의 my_name, my_age 컬럼 값이 MyTableDTO의 myName, myAge 필드에 정상적으로 들어갔다.


이 예제의 핵심은 SELECT 결과가 단순히 조회되는 것에서 끝나는 것이 아니라, 컬럼명이 DTO 필드명과 연결되어야 실제 객체 값으로 사용할 수 있다는 점이다.
MyBatis에서는 컬럼명과 필드명이 다를 때 map-underscore-to-camel-case 설정을 사용하거나, SQL에서 별칭을 사용해 매핑 문제를 해결할 수 있다.

0개의 댓글