📝 개요
Spring Boot 프레임워크로 백엔드 서버를 개발하게 되면 DB와 연동에 어떤 ORM, SQL Mapper 를 사용할지 고민해야된다. 프로젝트의 특성과 환경에 따라서 ORM, SQLMapper 을 결정짓게 되는데, 한 가지로만 정해놓고 쓰는게 아닌, 각각의 장점을 살려서 사용하기도 한다. (JPA + MyBatis or JDBC)
Groupware 프로젝트를 진행하면서 MyBatis 를 사용한것은 일단 가장 익숙한 것이 컸다. 그리고 DB 를 My SQL 이나 MariaDB 를 선택하지 않고 OracleDB 을 선택하면서 Oracle 베이스의 쿼리 문법을 학습하려는 의도 도 있었다. 하지만 Groupware 프로젝트 특성상 기본적인 CRUD 에 기능이 집중되어 있으므로 JPA 를 선택하는 것이 더 효율적이었다는 것을 프로젝트를 진행하면서 깨달았다.
그리고 DB 와의 매핑을 위해서는 어떤 객체를 만들어야되는지 생각하고 설계애햐되며, 내가 선택한 방법은 DB의 테이블과 매칭되는 VO(Value Object) 와 실제로 데이터를 요청하거나, 응답해야되는 상황에서는 DTO (Data Trasfer Object) 를 사용했다. 해당 객체들을 사용하면서 MyBatis 의 객체 매핑에 대해서 알아보려고 한다.
📕 개념
1️⃣ ORM
ORM(Object-Relational Mapping) 은 객체와 관계형 데이터베이스의 테이블을 자동으로 연결(매핑)해주는 기술이다. ORM 을 사용하면 개발자는 객체만 다루게 되고 객체와 DB 테이블 간 변환 (INSERT, UPDATE, DELETE, SELECT) 은 ORM 프레임워크가 처리하게 된다.
객체지향 프로그래밍(클래스, 객체)과 관계형 데이터베이스(테이블, 행) 간에는 불일치가 존재하는데 ORM 은 객체간의 관계를 바탕으로 SQL 을 자동으로 생성하여 불일치를 해결해준다. 객체를 통해서 간접적으로 DB를 다루는 것이다.
Java 의 대표 ORM 기술은 JPA/Hibernate 가 있다.
JPA(Java Persistence API) 는 자바 ORM 기술의 표준이고 인터페이스 모음이다.
Hibernate 는 JPA 표준 명세를 구현한 구현체이다.
장점
- 생산성 증가 : 반복적인 SQL 코드 작성 최소화
- 유지보수성 증가 : 테이블 구조가 바뀌면 클래스만 수정, 코드 전체 일관성 유지
- 비즈니스 로직에 집중 : 쿼리보다는 객체 지향적으로 로직 설계가 가능하다.
- 트랜잭션, 캐싱, 지연로딩 등 부가 기능을 제공
단점
- 복잡한 쿼리/고성능 튜닝이 어려움 (JPA 는 복잡한 조인, 특정 DB 고유 기능 사용이 불편)
- 동작 원리 학습 곡선 (JPA 영속성 컨텍스트, flush, dirty checking 등 이해 필요)
- 쿼리 추적 어려움 (자동 생성 쿼리라서 실제 어떤 쿼리인지 명확하게 구분이 어려움)
- DB 구조가 바뀌면 코드도 맞춰줘야 한다. (자동매핑 특성)
2️⃣ SQL Mapper
SQL Mapper 는 SQL 쿼리를 직접 작성하고, 쿼리 실행 결과를 객체(VO, DTO) 와 매핑해주는 프레임워크이다. 개발자가 직접 SQL 을 작성하여 Mapper XML 또는 Annotation 에 저장한다. MyBatis 는 쿼리를 실행하고 컬럼명과 객체의 필드명이 매칭되면 자동으로 세팅하여 객체와 DB 간의 연결을 해준다.
SQL 주도 개발이라는 특징이 있으며 복잡한 비즈니스 로직이나 DB 튜닝, 최적화에 유리하다. 객체 매핑을 지원하여 SQL 결과가 VO, DTO 에 매핑된다. 비즈니스 로직 / 트랜잭션 등은 직접 구현하며, 단방향 매핑 (테이블-객체 간의 1:1) 으로 원하는 쿼리 겨로가만 특정 VO/DTO 에 매핑이 가능하다.
SQL Mapper 중 MyBatis 는 거의 표준처럼 사용된다.
Java 진영에서 가장 유명하며, Spring Boot 연동이 매우 쉽다.
JDBC Template 도 SQL 을 직접 다루지만 자동 매핑 / 동적 SQL 지원 등에서 많이 떨어진다.
장점
- 복잡한 쿼리 / 튜닝에 강함 (SQL 을 개발자가 직접 작성)
- DB 구조가 복잡하거나 비즈니스 로직이 DB 중심일 때 유리
- 쿼리와 매핑이 명확해서 디버깅 / 추적이 쉬움
- 다양한 DB 지원 (
Oracle, MySQL, PostgreSQL등) - 객체 매핑이 필요한 때만 간단하게 사용 가능
단점
- SQL 을 직접 작성해야해서 쿼리의 양이 많아짐
- 객체 지향 코드와 분리된 SQL 관리로 유지보수 이슈 (XML 파일)
- ORM 처럼 영속성 / 자동 트랜잭션 처리 등 고급 기능이 없음
Mapper / SQL / VO / DTO코드가 따로 관리 됨
3️⃣ VO, DTO
VO(Value Object) 는 DB 테이블 한 행(row) 또는 그 “값” 자체를 표현하는 객체이다. 불변인 경우가 많으면서 DB 컬럼과 객체 필드의 1:1 매핑이 특징이다. 단일 / 복수 데이터 전달용이며 Service 와 Mapeer (DAO) 사이에서 많이 사용된다. 보통 도메인 모델로도 많이 쓰인다.
DTO(Data Transfer Object) 는 계층 간 (Controller, Service) “데이터 전달” 을 위해 설계된 객체이다. 요청/응답 등 API 에 최적화 되어 있다. 필요한 데이터만 담거나 여러 VO 의 데이터 조합도 가능하다. 유효성 검사와 직렬화/역직렬화 에 강하며 API 요청/응답 DTO 로 구분하여 설계한다. DTO 는 1:1 매핑도 필요하지 않고 사용자의 정의에 따라 필드를 자유롭게 구성 가능하다.
VO/DTO 분리하는 이유
@Data
public class UserVO {
private String userId;
private String userName;
private String email;
private String password;
private boolean isAdmin;
}
@Data
public class UserResDTO {
private String userId;
private String userName;
private String email;
}
// Controller
@GetMapping("/users/{id}")
public UserResDTO getUser(@PathVariable String id) {
UserVO vo = userMapper.selectUser(id);
UserResDTO dto = new UserResDTO();
dto.setUserId(vo.getUserId());
dto.setUserName(vo.getUserName());
dto.setEmail(vo.getEmail());
return dto; // password, isAdmin 등은 노출하지 않음
}
VO와 DTO 를 분리하는 이유는 VO 는 DB 와 1대1로 매핑되기 때문에 사용자에게 드러내면 안되는 정보를 걸러서 보낼 수 있기 때문이다. 또한, 사용자로부터 받아오는 데이터를 VO 에 다 넣어서 보내는 것은 유지보수성이나, 유연성, 재사용성에 불리하기 때문에 요청과 응답에 대해서 VO 와 DTO 를 정의하고 변환하여 사용한다.
실제 프로젝트에서의 사용 흐름
1. 클라이언트 요청 → (API 요청) → Controller
- DTO 로 클라이언트의 입력값을 받음 (@RequestBody + @Valid)
2. Controller → Service
- DTO 전달, 필요시 VO 로 변환 (Mapper 호출용)
- Controller 를 통해서 @Valid 유효성 검증을 받은 값을 VO 로 변환 (DB 데이터 조작을 위해)
3. Service → ㅡMapper(DAO)
- VO 를 넘김, DB 저장 / 조회
- 삽입이나 업데이트 되어야 되는 경우 VO 를 넘겨 DB에 저장함
- 조회의 경우 조건을 파라미터로 넘기는 경우 DTO 를 그대로 넘기기도 함
4. Mapper → Service → Controller
- VO 조회 결과 → DTO 로 변환해서 응답
4️⃣ Lombok
Lombok 은 Java 클래스에서 반복적으로 사용되는 코드 (Boilerplate : getter/setter) 를 어노테이션 한 줄로 자동 생성해주는 라이브러리이다. Lombok 을 사용하면 코드가 간결해지고 유지보수가 쉬워진다. IDE 에는 Lombok 에 대한 라이브러리 설치가 필수이다.
자주 사용되는 어노테이션
| 어노테이션 | 설명 |
|---|---|
| @Getter | 모든 필드의 getter 생성 |
| @Setter | 모든 필드의 setter 생성 |
| @ToString | toString() 메서드 자동 생성 |
| @EqualsAndHashCode | equals(), hashCode() 자동 생성 |
| @NoArgsConstructor | 파라미터 없는 기본 생성자 자동 생성 |
| @AllArgsConstructor | 모든 필드를 받는 생성자 자동 생성 |
| @RequiredArgsConstructor | final 필드만 받는 생성자 자동 생성 |
| @Builder | 빌더 패턴 메서드 자동 생성 |
| @Data | @Getter, @Setter, @ToString, @EqualsAndHashCode, @RequiredArgsConstructor 모두 포함 |
| @Value | 불변 객체용(모든 필드 final, getter만, equals/hashCode 포함) |
🚀 MyBatis 객체 매핑
1️⃣ MyBatis 동작 원리
- 파라미터 지정, 쿼리 실행 : MyBatis 는 xml 정의된 쿼리를 실행하게 된다.
parameterType에 지정된 클래스를 통해서 파라미터 값을 넘김- 해당 파라미터가 포함된 SQL을 통해서 결과를
ResultSet으로 받게 됨
- 쿼리 실행 결과 반환, 매핑 객체 인스턴스 생성
- 조회 결과를
resultType/resultMap에 지정된 클래스의 기본 생성자로 빈 객체를 생성 - 매핑 대상 객체(VO/DTO) 인스턴스를 생성한다.
- 조회 결과를
- ResultSet 에서 Java 클스의 필드에 할당 : setter 또는 필드에 직접 할당 (Reflection)
- ReusltSet 에서 각 컬럼 값을 꺼냄
- setter 메서드를 찾아 값을 할당 / setter 가 없으면 Reflection 으로 필드에 직접 할당 시도
- 모든 컬럼 반복 : 모든 컬럼에 대해서 필드 할당을 반복
- 객체 반환 : 매핑이 된 객체(VO/DTO) 를 서비스 코드에 반환 (Mapper 를 통해서 호출한 곳)
<!-- MyBatis xml 쿼리 작성 예시 -->
<select id="selectUserList" parameterType="String" resultType="UserVO">
SELECT USER_ID,
USER_PASSWORD,
USER_NAME,
USER_BIRTH,
USER_NUMBER,
USER_EMAIL,
FROM USERS
WHERE USER_ID = #{userId}
ORDER BY CREATED_AT DESC
</select>
여기서 parameterType 과 resultType 은 생략이 가능하다. MyBatis 는 메서드 시그니처(리턴타입) 을 보고 자동으로 타입을 매핑 대상으로 판단한다.
mybatis:
mapper-locations: classpath:mapper/*.xml
configuration:
map-underscore-to-camel-case: true
또한, MyBatis 는 객체를 자동매핑 할 때 SQL 결과 컬럼명과 VO/DTO 의 필드명이 (스네이크 ↔ 카멜 변환 포함) 일치하면 자동으로 값을 할당한다. 아래는 스네이크 카멜 변환 설정 코드이다. (application.yaml)
그래서 필자는 DB 의 컬럼명을 모두 스네이크 방식을 사용하고, VO/DTO 의 필드에 대해서는 카멜 방식을 사용했다.
2️⃣ 파라미터 바인딩
MyBatis 는 Mapper 를 통해서 자바 객체를 파라미터 값으로 넘기게 되는데 이 파라미터 값은 사용자의 요청으로 부터 들어온 값이 될 수 있고 Service 코드에서 넣어주는 값이 될 수 있다.
바인딩은 #{} (샵 중괄호) 를 통해서 하게 되는데, 이는 Mapper 에서 넘겨주는 파라미터 값을 바인딩한다.
#{} 바인딩 이유
#{} 는 PreparedStatement 파라미터 바인딩 방식으로 쿼리에서 값이 들어갈 위치에 플레이스 홀더(?) 를 넣고 MyBatis 가 값을 따로 세팅한다. 이는 SQL 인젝션을 방지한다. 그리고 Java 타입을 SQL 타입으로 자동변환한다. (${} 방식은 문자열 그대로 치환하기 때문에 SQL 인젝션의 위험이 있음)
[파라미터 예시]
// UserMapper.java (MyBatis xml 파일과 연동되는 Mapper Interface)
List<UserVO> selectUserList(String userId); // 사용자 리스트 조회
List<UserVO> selectUserList(UserSearchReqDTO userSearchReqDTO, int size, int offset);
// @param 값 사용 : 생략 가능
List<UserVO> selectUserList(
@Param("userSearchReqDTO") UserSearchReqDTO userSearchReqDTO,
@Param("size") int size,
@Param("offset") int offset
);
자동매핑을 사용하는 경우 Mapper 에서 넘겨주는 파라미터에 따라 xml 에서 파라미터 바인딩하는 방식이 달라진다. 2개 이상 사용할 경우 @Param 을 통해서 지정해줘도 되지만, 생략 가능하다.
파라미터 개수에 따른 바인딩 방법
| 구분 | 예시 | #{} 사용법 | 내부 동작 |
|---|---|---|---|
| 단일값 | (String userId) | #{userId} | 파라미터명 = 변수명 |
| DTO/VO | (UserDTO dto) | #{userId}, #{userName} | DTO 필드명 = 변수명 |
| 2개 이상 | (@Param("dto") dto, @Param("status") status) | #{dto.userId}, #{status} | @Param명 + 필드명 |
🚀 MyBatis + Lombok 활용
MyBatis 와 VO/DTO Java 객체와 연결 할 때 @setter 와 @NoArgsConstructor 를 사용한다. MyBatis 를 사용하면서 생성자는 필수이지만, setter 함수는 리플렉션으로 대체 가능하다.
@setter: 모든 필드에 대해서setter함수 자동 생성, MyBatis 사용 시 객체에ResultSet결과 값 할당 시 사용 (없으면 Reflection 을 통해서 할당)@NoArgsConstructor: 필드를 포함하지 않는 생성자 생성, 객체 인스턴스 생성 시 필요
MyBatis 의 객체 매핑 조건은 위와 같지만, 사실상 Class 를 만들어 명시적으로 생성자를 두지 않으면 Java 는 기본 생성자를 자동으로 만든다. 이는 MyBatis 가 객체를 매핑하도록 하는 조건을 만족한다.
그래서 사실살 MyBatis 는 아무런 Lombok 어노테이션을 선언하지 않고, 생성자를 명시적으로 만들지 않는 이상 객체 매핑이 가능하다. 하지만, Getter 는 JSON과 같은 응답값을 바인딩하고, 비즈니스 로직에 필요하기 때문에 @Getter 의 사용은 필수적이다.
다음은 가능한 조합의 어노테이션이다.
| 조합 | 기본 생성자 존재 | MyBatis 매핑 | 특징/비고 |
|---|---|---|---|
| (아무것도 없음) | O | O | 자바가 기본 생성자 자동 생성 |
| @Getter | O | O | Getter만, MyBatis 매핑 O |
| @Builder | O | O | 빌더, 기본 생성자 자동 생성 |
| @NoArgsConstructor | O | O | 명시적 기본 생성자 |
| @AllArgsConstructor | X | X | 명시적 모든 필드 생성자, 기본X |
| @Builder + @AllArgsConstructor | X | X | 빌더, 모든 필드 생성자, 기본X |
| @Builder + @NoArgsConstructor | O | O | 빌더+기본 생성자, 일부 환경에선 AllArgs 필요 경고 가능 |
| @NoArgsConstructor + @AllArgsConstructor | O | O | 명시적 기본+모든필드 생성자 |
| @Builder + @NoArgsConstructor + @AllArgsConstructor | O | O | 가장 안전한 조합! |
| @Data | O | O | Getter/Setter/toString 등 한번에 사용 |
| (final 필드 + @NoArgsConstructor) | X | X | final 필드는 기본 생성자 생성X |
1️⃣ 상황에 따른 Lombok 조합
| 상황 | Lombok 조합 | 이유 |
|---|---|---|
| API 응답 DTO (ResponseDTO) | @Getter + @Builder |
선택적으로 필드를 설정할 수 있어 유연성 증가 |
| 비즈니스 로직 DTO | @Getter + @Builder |
생성자 파라미터 순서를 신경 쓰지 않고 유연하게 객체 생성 가능 |
| MyBatis에서 사용하는 VO | @Getter + @NoArgsConstructor + @AllArgsConstructor |
MyBatis는 기본 생성자가 필요하며, Reflection 없이 안정적으로 객체 생성 가능 |
| 모든 필드를 다 쓰는 DTO | @Getter + @NoArgsConstructor + @AllArgsConstructor |
필드를 전부 다 채우는 경우 MyBatis에서도 문제없이 동작 |
VO/DTO 간의 변환에 있어서 정적 팩토리 메서드 패턴을 쓴다면 @Builder 를 사용하는 것이 좋다. 그래서 필자는 DB 와 직접적인 연관이 있는 VO와 응답 DTO의 경우 @Builder 를 사용한다. 그리고 요청 DTO 의 같은 경우 사실상 @Getter 만 사용하여 구성이 가능하지만, 로깅과 테스트 데이터를 유연하게 생성하기 위해 @ToString 과 @AllArgsConstructor + @NoArgsConstructor 를 사용한다.
여기서@AllArgsConstructor + @NoArgsConstructor 를 같이 사용하는 이유는 AllArgsConstructor 만 사용할 경우 생성자 기반 바인딩이 동작하기 때문에 모든 파라미터의 값이 들어와야만 정상 동작하고, 명시적으로 모든 생성자가 생성되기 때문에 Java 가 기본 생성자를 자동으로 생성해주지 않기 때문에 MyBatis 바인딩 시 오류가 발생하기 때문이다.
따라서 MyBatis 를 사용할 때 기억해야 할 것이 기본 생성자의 유무가 굉장히 중요하기 때문에 Java 에 의해서 자동으로 생성되는지, 명시적으로 생성자를 넣었을 때 기본 생성자가 생성되는지, 기본 생성자를 명시적으로 넣어줘야 하는지 생각하면서 어노테이션을 사용해야 한다.
현재 MyBatis 최신 버전에서는 리플렉션, Unsafe, Objenesis 같은 트릭으로 생성자 없이 객체를 “강제로” 생성하는 로직이 들어가 있다. 그래서 기본 생성자 없이도 객체가 생성되고 setter 로 들어가게 된다. 하지만 JVM, MyBatis 설정에 따라 오류가 발생할 수 있으므로 기본 생성자 유무를 판단하는게 중요하고 명시적으로 기본 생성자를 넣어주는 것이 중요하다.
2️⃣ @Builder + @NoArgsConstructor
MyBatis 에서는 기본 생성자의 유무가 중요하다고 했다. @Builder + @NoArgsConstructor 를 같이 사용하면 기본생성자가 어떻게 구성되는지 알아보겠다.
먼저 @Builder 는 Builder 패턴을 쉽게 구현하도록 도와주는데, “명시적인 생성자” 가 존재하지 않는 경우 내부적으로 모든 필드를 private 접근 제어자로 만들고 모든 필드를 포함한 생성자를 자동 생성한다. 또한, @Builder 만 사용할 경우 “명시적인 생성자” 가 없기 때문에 Java 에서 기본 생성자를 넣어준다.

위 사진은 @Getter 와 @Builder 만 넣은 DTO 를 코드를 보여준다. MyBatis 의 객체 매핑을 사용하기 위해서 @NoArgsConstructor 를 추가하게 되면 다음과 같은 오류가 발생한다.
error: constructor HealthDetailResDTO in class HealthDetailResDTO cannot be applied to given types;
@Builder
^
required: no arguments
found: long,int,String,LocalDate,LocalDateTime
reason: actual and formal argument lists differ in length
이 오류는 @Builder 와 관련 있는 오류로 @Builder 는 명시적으로 생성자를 넣지 않으면 위와 같이 자동으로 모든 필드를 포함하는 생성자를 자동으로 생성하지만, 명시적으로 생성자를 넣은 경우 생성하지 않기 때문이다. 하지만 @Builder 를 사용하기 위해서는 모든 필드를 포함하는 생성자가 포함되어야 되므로 @AllArgsConstructor 를 사용하여 해결한다.
결론적으로 MyBatis 의 버전과 Java 의 버전에 따라 달라지겠지만, @Builder 와 @NoArgsconstructor 를 함께 사용할 때는 빌드 시 오류가 나지 않도록 @AllArgsConstructor 를 포함하도록 해야한다.
(VO, DTO 시 : @Builder + @AllargsConstructor + @NoArgsConstructor)
🔗 REF
- Groupware Project : HealthCheck Package
[DB] ORM이란 - Heee's Development Blog
Step by step goes a long way.
gmlwjd9405.github.io
