목록
AI-R2DBC 대표 이미지

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 저장소에 배포하면, 다른 프로젝트에서 표준 의존성 방식으로 쉽게 참조할 수 있습니다.

  1. 라이브러리 프로젝트에서 로컬 Maven 저장소에 배포: 라이브러리 프로젝트의 루트 디렉터리에서 다음 Gradle 명령어를 실행합니다.

    gradle publishToMavenLocal
    

    이 명령은 ~/.m2/repository (또는 Maven 설정에 따라 다른 위치)에 라이브러리를 설치합니다.

  2. 사용할 프로젝트에서 의존성 추가: 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 매퍼와 연동하여 사용하기 편리합니다.

  1. 리포지토리 인터페이스 정의: 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 매퍼를 주로 사용합니다.
    }
    
  2. 서비스 또는 컴포넌트에서 사용: 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);
        }
    }
    

    selectListselectOneinsertupdatedelete 메서드:

    • 첫 번째 인자: 네임스페이스.쿼리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와 같은 메서드를 일관되게 사용할 수 있습니다.