이 글은
MyBatis코드를 바로 외우는 글이 아니다.
웹 애플리케이션에서DB처리가 어디에 위치하는지 먼저 보고,JDBC방식의 반복 문제를 이해한 뒤, 그 문제를MyBatis가 어떻게 줄여 주는지 정리한다.
MyBatis의 핵심은 데이터 접근 계층에서SQL과Java메서드를 연결하고,SQL실행 결과를Java객체로 매핑해 주는 것이다.
이 흐름을 먼저 잡아야Mapper,@Select,#{}같은 문법도 단순 암기가 아니라 구조로 이해할 수 있다.
정리 흐름은 웹 계층 구조 →JDBC의 반복 문제 →MyBatis의 역할 →Mapper기본 구조 → 기본CRUD예제 →Controller연결 흐름 → 동적 파라미터 문법 차이 순서로 간다.
웹 애플리케이션은 크게 사용자가 보는 쪽, 요청을 처리하는 쪽, 데이터를 저장하는 쪽으로 나눌 수 있다.
이 구조를 먼저 알아야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는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는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을 직접 보면서 개발할 수 있다는 특징이 있다.
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인터페이스는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는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에서 더 자주 보인다.
추가나 수정은 여러 값을 한 번에 넘겨야 하므로 객체 하나로 묶어 전달하는 방식이 편하다.
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메서드의 반환 타입을 훨씬 쉽게 정할 수 있다.
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매핑 흐름을 확인한다.
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와 화면으로 이어진다는 흐름이다.
전체 개수 조회, 목록 조회, 단일 조회, 조건 조회, 부분 목록 조회는 모두 형태만 다를 뿐 같은 구조 안에서 동작한다.
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흐름 안에서 동작한다.
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 처리흐름으로 동작한다.
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문장에 붙이는 방식이므로, 실제 서비스에서는 사용자가 입력한 값을 그대로 넣으면 위험하다.
테이블명이나 컬럼명처럼 허용할 값이 정해진 경우에는 서버에서 허용 목록을 확인한 뒤 사용하는 것이 안전하다.
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)가 붙어 있으므로 테스트가 끝난 뒤에도 저장 결과가 되돌아가지 않고 유지된다.
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에서 별칭을 사용해 매핑 문제를 해결할 수 있다.