AI-R2DB 사용 설명서
1. AI-R2DB 라이브러리 소개
AI-R2DB는 R2DBC(Reactive Relational Database Connectivity) 환경에서 동적 SQL 매핑을 지원하는 라이브러리입니다. XML 파일을 통해 SQL 쿼리를 정의하고, 조건에 따라 동적으로 SQL을 생성하여 데이터베이스 작업을 수행할 수 있도록 돕습니다. 기존 MyBatis와 유사한 방식으로 SQL을 관리하면서도, 비동기/논블로킹 방식의 R2DBC를 활용할 수 있게 해줍니다.
2. 라이브러리 포함 방법
AI-R2DB 라이브러리를 다른 Spring Boot 프로젝트에서 사용하려면, 빌드 도구(Gradle 또는 Maven)의 의존성에 추가해야 합니다.
2.1. JAR 파일 직접 추가 (권장하지 않음)
가장 간단하지만 권장하지 않는 방법입니다. 빌드된 air2db-0.0.1.jar 파일을 프로젝트의 libs 디렉터리(또는 유사한 위치)에 복사한 후, 빌드 스크립트에 다음과 같이 추가합니다.
Gradle (build.gradle)
dependencies {
implementation files('libs/air2db-0.0.1.jar')
}
Maven (pom.xml)
<dependencies>
<dependency>
<groupId>com.aiready</groupId>
<artifactId>air2db</artifactId>
<version>0.0.1</version>
<scope>system</scope>
<systemPath>${project.basedir}/libs/air2db-0.0.1.jar</systemPath>
</dependency>
</dependencies>
2.2. 로컬 Maven 저장소에 배포 후 사용 (권장)
라이브러리를 로컬 Maven 저장소에 배포하면, 다른 프로젝트에서 표준 의존성 방식으로 쉽게 참조할 수 있습니다.
라이브러리 프로젝트에서 로컬 Maven 저장소에 배포: 라이브러리 프로젝트의 루트 디렉터리에서 다음 Gradle 명령어를 실행합니다.
gradle publishToMavenLocal이 명령은
~/.m2/repository(또는 Maven 설정에 따라 다른 위치)에 라이브러리를 설치합니다.사용할 프로젝트에서 의존성 추가: Gradle (build.gradle)
repositories { mavenLocal() // 로컬 Maven 저장소 사용 선언 mavenCentral() } dependencies { implementation 'com.aiready:air2db:0.0.1' }Maven (pom.xml)
<repositories> <repository> <id>local-maven-repo</id> <url>file://${user.home}/.m2/repository</url> </repository> </repositories> <dependencies> <dependency> <groupId>com.aiready</groupId> <artifactId>air2db</artifactId> <version>0.0.1</version> </dependency> </dependencies>
3. Spring Boot 애플리케이션 설정
AI-R2DB는 R2DBC를 기반으로 동작하므로, 사용하는 Spring Boot 프로젝트에서 R2DBC 데이터베이스 연결 설정을 완료해야 합니다.
application.yml 예시:
spring:
r2dbc:
url: r2dbc:mariadb://localhost:3306/your_database
username: your_username
password: your_password
pool:
initial-size: 5
max-size: 10
# ... 다른 설정들
4. SQL 매퍼 정의 (XML 파일) 및 ORM 작성 방법
AI-R2DB는 SQL 쿼리를 XML 파일에 정의합니다. 이 XML 파일들은 라이브러리 JAR 내의 src/main/resources/mapper 디렉터리에 위치해야 합니다. sample.xml 파일을 참고하여 ORM XML 파일을 작성하는 방법을 상세히 설명합니다.
4.1. XML 파일 기본 구조
모든 매퍼 파일은 <mapper> 태그로 시작하며, namespace 속성을 통해 해당 매퍼의 고유한 이름을 지정합니다. 이 namespace는 쿼리를 호출할 때 사용됩니다.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="your.mapper.namespace">
<!-- 여기에 SQL 쿼리 정의 -->
</mapper>
4.2. 쿼리 정의 (<select>, <insert>, <update>, <delete>)
각 SQL 작업은 <select>, <insert>, <update>, <delete> 태그를 사용하여 정의합니다.
id: 해당 쿼리의 고유한 식별자입니다.namespace와 함께 쿼리를 호출할 때 사용됩니다.parameterType="JSON": AI-R2DB의 핵심 특징입니다. 쿼리에 전달되는 파라미터가JSONObject타입임을 명시합니다. 이는 Map이나 DTO 객체 대신JSONObject를 사용하여 유연하게 데이터를 전달할 수 있게 합니다.resultType: 쿼리 결과를 매핑할 클래스의 FQCN(Fully Qualified Class Name) 또는JSON을 지정합니다.JSON으로 지정하면 결과가JSONObject형태로 반환됩니다.
예시: src/main/resources/mapper/user.xml (sample.xml 기반)
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="aiready"> <!-- sample.xml의 namespace와 동일 -->
<!-- SELECT 쿼리 예시 -->
<select id="S2_CONTACT_BLOG" parameterType="JSON" resultType="JSON">
SELECT
p.BLOG_ID AS blogId,
p.CTG_CODE AS ctgCode,
c.CTG_NAME AS ctgName,
p.TITLE AS title,
p.NAME AS name,
p.EMAIL AS email,
SUBSTRING(p.CONTENT_STRIP, 1, 250) AS contentStrip,
CASE
WHEN TIMESTAMPDIFF(MINUTE, p.CREATED_AT, NOW()) < 60 THEN
CONCAT(TIMESTAMPDIFF(MINUTE, p.CREATED_AT, NOW()), ' minutes ago')
WHEN TIMESTAMPDIFF(HOUR, p.CREATED_AT, NOW()) < 24 THEN
CONCAT(TIMESTAMPDIFF(HOUR, p.CREATED_AT, NOW()), ' hours ago')
WHEN TIMESTAMPDIFF(DAY, p.CREATED_AT, NOW()) < 30 THEN
CONCAT(TIMESTAMPDIFF(DAY, p.CREATED_AT, NOW()), ' days ago')
ELSE DATE_FORMAT(p.CREATED_AT, '%Y-%m-%d')
END AS createdAt,
p.IP_ADDRESS AS ipAddress,
GROUP_CONCAT(t2.TAG_NAME ORDER BY t2.TAG_NAME) AS tags
FROM CONTACT_BLOG p
LEFT JOIN BLOG_CTG c
ON c.CTG_CODE = p.CTG_CODE AND c.TARGET_TYPE = #{targetType}
LEFT JOIN TAG_MAPPING tm2
ON tm2.TARGET_TYPE = #{targetType} AND tm2.TARGET_ID = p.BLOG_ID
LEFT JOIN TAG t2
ON t2.TAG_ID = tm2.TAG_ID
<where>
1 = 1
<!-- 검색용 태그 필터 -->
<if test="@com.aiready.util.JSONValidator@notEmpty(tagName)">
AND p.BLOG_ID IN (
SELECT tm.TARGET_ID
FROM TAG_MAPPING tm
JOIN TAG t ON t.TAG_ID = tm.TAG_ID
WHERE tm.TARGET_TYPE = #{targetType} AND t.TAG_NAME = #{tagName}
)
</if>
</where>
GROUP BY p.BLOG_ID
<if test="sort != null">
ORDER BY ${sort}
</if>
<if test="limit != null">
LIMIT #{limit}
<if test="offset != null">
OFFSET #{offset}
</if>
</if>
</select>
<!-- INSERT 쿼리 예시 -->
<insert id="I1_CONTACT_BLOG" parameterType="JSON" useGeneratedKeys="true" keyProperty="techBlogId">
INSERT INTO CONTACT_BLOG (
<trim prefix="" suffixOverrides=",">
<if test="@com.aiready.util.JSONValidator@notEmpty(ctgCode)">
CTG_CODE,
</if>
<if test="@com.aiready.util.JSONValidator@notEmpty(title)">
TITLE,
</if>
<!-- ... 다른 필드들 ... -->
</trim>
) VALUES (
<trim prefix="" suffixOverrides=",">
<if test="@com.aiready.util.JSONValidator@notEmpty(ctgCode)">
#{ctgCode, jdbcType=VARCHAR},
</if>
<if test="@com.aiready.util.JSONValidator@notEmpty(title)">
#{title, jdbcType=VARCHAR},
</if>
<!-- ... 다른 값들 ... -->
</trim>
)
</insert>
<!-- UPDATE 쿼리 예시 -->
<update id="U1_CONTACT_BLOG" parameterType="JSON">
UPDATE CONTACT_BLOG
<set>
<if test="containsKey('ctgCode')">
CTG_CODE = #{ctgCode},
</if>
<if test="containsKey('title')">
TITLE = #{title},
</if>
<!-- ... 다른 필드들 ... -->
</set>
WHERE BLOG_ID = #{blogId}
</update>
<!-- DELETE 쿼리 예시 -->
<delete id="D1_CONTACT_BLOG" parameterType="JSON">
DELETE FROM CONTACT_BLOG
WHERE BLOG_ID = #{blogId}
</delete>
</mapper>
4.3. 파라미터 처리 및 동적 SQL 조건 (AI-R2DB의 특징)
AI-R2DB는 parameterType="JSON"을 통해 쿼리 파라미터를 JSONObject로 받습니다. 이로 인해 동적 SQL 조건문(if 태그의 test 속성)에서 파라미터의 존재 여부나 값의 유효성을 확인하는 방식이 일반 MyBatis와 다릅니다.
#{propertyName}:JSONObject에서propertyName에 해당하는 값을 추출하여 SQL 파라미터로 바인딩합니다.${propertyName}:JSONObject에서propertyName에 해당하는 값을 문자열로 직접 삽입합니다. (SQL Injection 위험이 있으므로ORDER BY등 제한적인 경우에만 사용)
4.3.1. if 조건문에서 containsKey('key') 사용
JSONObject는 containsKey() 메서드를 제공하므로, 특정 키(파라미터 이름)가 JSONObject 내에 존재하는지 직접 확인할 수 있습니다. 이는 MyBatis에서 _parameter.containsKey('key')를 사용하는 것과 유사하지만, AI-R2DB에서는 파라미터 객체 자체가 containsKey 메서드를 가지고 있으므로 더 간결하게 작성합니다.
예시:
<if test="containsKey('ctgCode')">
CTG_CODE = #{ctgCode},
</if>
이 조건은 전달된 JSONObject 파라미터에 ctgCode라는 키가 존재할 경우에만 해당 SQL 조각을 포함시킵니다.
4.3.2. if 조건문에서 @com.aiready.util.JSONValidator@notEmpty(key) 사용
sample.xml에서 볼 수 있듯이, AI-R2DB는 @com.aiready.util.JSONValidator@notEmpty(key)와 같은 형태로 정적 유틸리티 메서드를 호출하여 파라미터의 유효성을 검사할 수 있습니다. 이는 단순히 키의 존재 여부를 넘어, 해당 키의 값이 비어있지 않은지(null이 아니거나 빈 문자열이 아닌지 등)를 확인하는 데 유용합니다.
예시:
<if test="@com.aiready.util.JSONValidator@notEmpty(tagName)">
AND p.BLOG_ID IN (...)
</if>
이 조건은 tagName이라는 키가 JSONObject에 존재하고, 그 값이 비어있지 않을 경우에만 해당 SQL 조각을 포함시킵니다.
5. AI-R2DB 사용 방법
AI-R2DB는 DynamicSqlRepository 인터페이스와 DynamicSqlMapper 클래스를 통해 SQL 매퍼를 사용합니다.
5.1. DynamicSqlRepository 사용 (권장)
DynamicSqlRepository는 Spring Data R2DBC의 R2dbcRepository와 유사하게 동작하며, XML 매퍼와 연동하여 사용하기 편리합니다.
리포지토리 인터페이스 정의:
DynamicSqlRepository를 상속받는 인터페이스를 정의합니다. 제네릭 타입은엔티티 타입과ID 타입입니다.package com.yourcompany.repository; import com.aiready.repository.DynamicSqlRepository; import com.yourcompany.model.User; // 실제 프로젝트의 User 모델 클래스 public interface UserRepository extends DynamicSqlRepository<User, Long> { // 추가적인 쿼리 메서드를 정의할 수 있지만, XML 매퍼를 주로 사용합니다. }서비스 또는 컴포넌트에서 사용: Spring의
@Autowired를 통해UserRepository를 주입받아 사용합니다.package com.yourcompany.service; import com.yourcompany.model.User; import com.yourcompany.repository.UserRepository; import org.springframework.stereotype.Service; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @Service public class UserService { private final UserRepository userRepository; public UserService(UserRepository userRepository) { this.userRepository = userRepository; } public Flux<User> getAllUsers() { // user.xml의 findAllUsers 쿼리 실행 (예시) // sample.xml의 S2_CONTACT_BLOG 쿼리 호출 예시 // 파라미터는 JSONObject 또는 Map<String, Object> 형태로 전달 return userRepository.selectList("aiready.S2_CONTACT_BLOG", Map.of("targetType", "CONTACT"), User.class); } public Mono<User> getUserById(Long id) { // user.xml의 findUserById 쿼리 실행 (예시) return userRepository.selectOne("aiready.R1_CONTACT_BLOG", Map.of("blogId", id, "targetType", "CONTACT"), User.class); } public Mono<Integer> createUser(User user) { // user.xml의 insertUser 쿼리 실행 (예시) // User 객체를 JSONObject로 변환하여 전달하거나, Map으로 변환하여 전달 return userRepository.insert("aiready.I1_CONTACT_BLOG", user); } public Mono<Integer> updateUser(User user) { // user.xml의 updateUser 쿼리 실행 (예시) return userRepository.update("aiready.U1_CONTACT_BLOG", user); } public Mono<Integer> deleteUser(Long id) { // user.xml의 deleteUser 쿼리 실행 (예시) return userRepository.delete("aiready.D1_CONTACT_BLOG", Map.of("blogId", id)); } public Flux<User> findUsersByCondition(String name, String email) { // 동적 쿼리 예시: user.xml의 findUsersByCondition 쿼리 실행 // 파라미터는 Map 또는 DTO 객체로 전달할 수 있습니다. return userRepository.selectList("aiready.S2_CONTACT_BLOG", Map.of("name", name, "email", email, "targetType", "CONTACT"), User.class); } }selectList,selectOne,insert,update,delete메서드:- 첫 번째 인자:
네임스페이스.쿼리ID(예:com.aiready.mapper.UserMapper.findAllUsers) - 두 번째 인자: 쿼리에 전달할 파라미터 (단일 값, Map, DTO 객체 등)
- 세 번째 인자 (select 계열): 결과를 매핑할 클래스 타입
- 첫 번째 인자:
5.2. DynamicSqlMapper 직접 사용
DynamicSqlMapper는 SQL 매퍼를 직접 로드하고 실행하는 저수준(low-level) API입니다. 특별한 경우가 아니라면 DynamicSqlRepository를 사용하는 것이 더 편리합니다.
package com.yourcompany.service;
import com.aiready.mapper.DynamicSqlMapper;
import com.yourcompany.model.User;
import io.r2dbc.spi.ConnectionFactory;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.Map;
@Service
public class RawUserService {
private final DynamicSqlMapper dynamicSqlMapper;
public RawUserService(ConnectionFactory connectionFactory) {
// ConnectionFactory를 주입받아 DynamicSqlMapper를 직접 생성
this.dynamicSqlMapper = new DynamicSqlMapper(connectionFactory);
}
public Flux<User> getAllUsersRaw() {
// sample.xml의 S2_CONTACT_BLOG 쿼리 호출 예시
return dynamicSqlMapper.selectList("aiready.S2_CONTACT_BLOG", Map.of("targetType", "CONTACT"), User.class);
}
public Mono<User> getUserByIdRaw(Long id) {
// sample.xml의 R1_CONTACT_BLOG 쿼리 호출 예시
return dynamicSqlMapper.selectOne("aiready.R1_CONTACT_BLOG", Map.of("blogId", id, "targetType", "CONTACT"), User.class);
}
// ... 다른 쿼리들도 유사하게 사용
}
6. 동적 SQL 노드 활용
AI-R2DB는 MyBatis와 유사한 동적 SQL 노드를 지원하여 유연한 쿼리 작성을 가능하게 합니다.
<if test="조건">...</if>: 조건이 참일 때 내부 SQL 포함 (위에서 설명한containsKey또는@JSONValidator활용)<where>...</where>:WHERE절을 자동으로 생성하고, 내부AND또는OR를 제거<set>...</set>:SET절을 자동으로 생성하고, 내부 콤마(,)를 제거<choose>,<when>,<otherwise>: 다중 조건 분기<foreach collection="리스트/배열" item="요소" open="(" separator="," close=")">...</foreach>: 컬렉션 반복 처리 (IN 절 등에 유용)<trim prefix="접두사" prefixOverrides="오버라이드할 접두사" suffix="접미사" suffixOverrides="오버라이드할 접미사">...</trim>: SQL 조각의 앞뒤를 정리 (INSERT/UPDATE 문에서 유용)<bind name="변수명" value="표현식"/>: SpEL 표현식으로 변수 정의<include refid="SQL_ID"/>: 다른 SQL 조각 재사용
예시 (sample.xml의 S2_CONTACT_BLOG 및 U1_CONTACT_BLOG 참고):
<!-- S2_CONTACT_BLOG의 if 조건 예시 -->
<select id="S2_CONTACT_BLOG" parameterType="JSON" resultType="JSON">
SELECT ...
<where>
1 = 1
<if test="@com.aiready.util.JSONValidator@notEmpty(tagName)">
AND p.BLOG_ID IN (...)
</if>
</where>
GROUP BY p.BLOG_ID
<if test="sort != null">
ORDER BY ${sort}
</if>
<if test="limit != null">
LIMIT #{limit}
<if test="offset != null">
OFFSET #{offset}
</if>
</if>
</select>
<!-- U1_CONTACT_BLOG의 set 및 if 조건 예시 -->
<update id="U1_CONTACT_BLOG" parameterType="JSON">
UPDATE CONTACT_BLOG
<set>
<if test="containsKey('ctgCode')">
CTG_CODE = #{ctgCode},
</if>
<if test="containsKey('title')">
TITLE = #{title},
</if>
<!-- ... 다른 필드들 ... -->
</set>
WHERE BLOG_ID = #{blogId}
</update>
7. 주의사항 및 모범 사례
네임스페이스와 쿼리 ID: XML 매퍼 파일의
namespace와 각 쿼리의id는 고유해야 하며,네임스페이스.쿼리ID형식으로 호출됩니다.파라미터 전달: 쿼리에 파라미터를 전달할 때는 단일 값,
Map<String, Object>, 또는 DTO(Data Transfer Object) 객체를 사용할 수 있습니다. DTO 객체를 사용할 경우, 필드 이름이 XML의#{필드명}과 일치해야 합니다.결과 매핑:
resultType은 쿼리 결과의 각 행을 매핑할 클래스의 FQCN(Fully Qualified Class Name)이어야 합니다. 이 클래스는 기본 생성자를 가지고 있어야 하며, 필드 이름이 SQL 쿼리의 컬럼 이름과 일치하거나, 적절한 Setter 메서드를 가지고 있어야 합니다.R2DBC ConnectionFactory:
DynamicSqlMapper는io.r2dbc.spi.ConnectionFactory를 필요로 합니다. Spring Boot 환경에서는 자동으로 빈으로 등록되므로 주입받아 사용하면 됩니다.에러 처리: R2DBC는
Mono나Flux와 같은 Reactor 타입을 반환하므로, 에러 처리도 Reactor의 연산자(예:.onErrorResume,.doOnError)를 활용해야 합니다.보안: 동적 SQL을 사용할 때는 SQL Injection에 주의해야 합니다. AI-R2DB의
#{}문법은 PreparedStatement를 사용하므로 기본적인 SQL Injection 방어는 되지만,<bind>태그나 SpEL 표현식을 사용할 때는 항상 보안을 고려해야 합니다.JSONObject파라미터의 유연성:parameterType="JSON"덕분에, 쿼리 호출 시 Map, DTO, 또는 직접JSONObject인스턴스를 파라미터로 전달할 수 있습니다. 라이브러리 내부에서 이를JSONObject로 변환하여 처리하므로, XML 내에서containsKey()나@JSONValidator와 같은 메서드를 일관되게 사용할 수 있습니다.