사용자 정의 함수 작성하기

사용자 정의 함수 작성하기 (Write User Defined Functions)

JDBC 드라이버는 Java로 작성한 사용자 정의 함수(UDF)를 등록할 수 있어요. 스칼라 함수와 테이블 함수를 모두 지원해요. 이 페이지는 간단한 함수형 인터페이스 오버로드부터 벡터화된 인터페이스까지, 둘 다 빌드하고 등록하는 방법과 타입 매핑, 함수 레지스트리를 다룹니다.

출처: 문서

본문

개요 (Overview)

JDBC 드라이버는 Java로 작성한 사용자 정의 함수(UDF)를 등록할 수 있어요. 스칼라 함수와 테이블 함수를 모두 포함해요. 이 페이지는 간단한 함수형 인터페이스 오버로드부터 벡터화된 인터페이스까지, 둘 다 빌드하고 등록하는 방법을 타입 매핑과 함수 레지스트리와 함께 다룬다.

스칼라 함수 (Scalar Functions)

DuckDBFunctions.scalarFunction() 빌더로 스칼라 함수를 만들어요 — org.duckdb.DuckDBScalarFunctionBuilder를 반환해요 — 그리고 register()로 연결에 등록해요. 등록되면 withName()에 넘긴 이름으로 SQL에서 호출할 수 있어요. 빌더는 일회용이에요: register()(또는 close())가 호출되면 확정돼요.

가장 단순한 함수는 Java 함수형 인터페이스에 직접 매핑돼요. 인자 수와, 프리미티브의 경우 그 타입에 맞는 오버로드를 선택해요:

  • withFunction(Supplier), withFunction(Function), withFunction(BiFunction)은 0, 1, 2개의 박싱된(Object) 인자를 받아요.
  • withIntFunction(), withLongFunction(), withDoubleFunction()INTEGER, BIGINT, DOUBLE에 대한 단항 또는 이항 연산자를 받고 박싱을 피해요.
import java.sql.Connection;
import java.sql.DriverManager;
import org.duckdb.DuckDBFunctions;

try (Connection conn = DriverManager.getConnection("jdbc:duckdb:")) {
    DuckDBFunctions.scalarFunction()
        .withName("java_add_one")
        .withParameter(int.class)
        .withReturnType(int.class)
        .withIntFunction(x -> x + 1)
        .register(conn);
}
SELECT java_add_one(41);

이항 오버로드는 같은 타입의 두 인자를 받고, withParameters()로 선언해요:

DuckDBFunctions.scalarFunction()
    .withName("java_weighted_sum")
    .withParameters(double.class, double.class)
    .withReturnType(double.class)
    .withDoubleFunction((x, w) -> x * w + 10.0)
    .register(conn);
SELECT java_weighted_sum(2.5, 4.0);

가변 인자 (Variadic Arguments)

가변 개수의 인자를 위해 withVarArgs()로 요소 타입을 선언하고 withVarArgsFunction()으로 구현을 제공해요. 콜백은 가변 인자를 Object[]로 받아요. withVarArgs()withVarArgsFunction()보다 먼저 설정해야 하고, 객체 인자 withFunction() 오버로드는 varargs와 결합할 수 없어요.

import java.sql.Connection;
import java.sql.DriverManager;
import org.duckdb.DuckDBColumnType;
import org.duckdb.DuckDBFunctions;
import org.duckdb.DuckDBLogicalType;

try (Connection conn = DriverManager.getConnection("jdbc:duckdb:");
     DuckDBLogicalType intType = DuckDBLogicalType.of(DuckDBColumnType.INTEGER)) {
    DuckDBFunctions.scalarFunction()
        .withName("java_sum_varargs")
        .withParameter(Integer.class) // 고정 인자
        .withVarArgs(intType)         // 가변 인자 타입
        .withReturnType(Integer.class)
        .withVarArgsFunction(args -> {
            int sum = 0;
            for (Object arg : args) {
                sum += (Integer) arg;
            }
            return sum;
        })
        .register(conn);
}
SELECT java_sum_varargs(1, 2, 3, 4);

NULL 처리 (NULL Handling)

기본적으로 스칼라 함수는 SQL NULL을 포함한 모든 입력 행에 대해 호출돼요:

  • Object 인자를 받는 콜백(Function, BiFunction, varargs 형태)은 NULL 입력에 대해 null을 받고, NULL 결과를 만들기 위해 null을 반환할 수 있어요.
  • 프리미티브 콜백(withIntFunction(), withLongFunction(), withDoubleFunction())은 절대 null을 받지 않아요. DuckDB가 NULL을 전달하면 드라이버는 Java 호출을 건너뛰고 자동으로 출력에 NULL을 써요.

withNullInNullOut()을 호출하면 엔진이 NULL in, NULL out을 단락(short-circuit)하게 해줘요: 인자가 NULL일 때마다 호출을 건너뛰고 NULL을 반환할 수 있어요. 엔진이 항상 이 단락을 취하지는 않으므로, 객체 콜백은 여전히 자체 인자에서 null을 확인해야 해요.

휘발성 함수 (Volatile Functions)

DuckDB는 스칼라 함수를 결정적(deterministic)으로 취급하고 반복 입력에 대한 결과를 캐시하거나 상수 접기(constant-fold)할 수 있어요. 같은 인자로도 호출 간 출력이 바뀔 수 있는 함수(예: 외부 상태를 읽는 함수)에는 withVolatile()을 호출해서 엔진이 매 행마다 평가하게 해요.

에러 처리 (Error Handling)

  • 콜백이 입력을 읽거나 결과를 쓰는 동안 발생한 타입·값 에러는 DuckDBFunctions.FunctionException을 던져요. 이는 체크되지 않은 RuntimeException이에요.
  • 범위를 벗어난 행·컬럼 인덱스는 IndexOutOfBoundsException을 던져요.
  • 반환 타입이 정의되지 않았거나 타입 선언이 잘못된 것 같은 등록 시점 문제는 SQLException을 던져요.

벡터화된 스칼라 함수 (Vectorized Scalar Functions)

위 예시들은 호출마다 하나의 값을 처리해요. 한 번에 행 전체 배치를 처리하려면 withVectorizedFunction()으로 DuckDBScalarFunction을 등록해요. 콜백은 입력을 DuckDBDataChunkReader(최대 2,048행)로 받고 결과를 DuckDBWritableVector에 씁니다. 각 입력 컬럼은 input.vector(columnIndex)로 읽고, input.stream()으로 행 인덱스를 반복하며, 각 결과를 같은 행 인덱스에 써요. 이는 chunked results로 쿼리 결과를 읽는 것의 스칼라 함수 대응물이에요.

try (Connection conn = DriverManager.getConnection("jdbc:duckdb:");
     DuckDBLogicalType tsType = DuckDBLogicalType.of(DuckDBColumnType.TIMESTAMP);
     DuckDBLogicalType strType = DuckDBLogicalType.of(DuckDBColumnType.VARCHAR);
     DuckDBLogicalType dblType = DuckDBLogicalType.of(DuckDBColumnType.DOUBLE)) {
    DuckDBFunctions.scalarFunction()
        .withName("java_event_label")
        .withParameters(tsType, strType, dblType)
        .withReturnType(strType)
        .withVectorizedFunction((input, output) -> {
            input.stream().forEach(row -> {
                String value = input.vector(0).getLocalDateTime(row) + " | " +
                               String.valueOf(input.vector(1).getString(row)).trim().toUpperCase() + " | " +
                               input.vector(2).getDouble(row, 0.0d);
                output.setString(row, value);
            });
        })
        .register(conn);
}
SELECT java_event_label(TIMESTAMP '2026-04-04 12:00:00', 'launch', 4.5);

DuckDBDataChunkReader, 그 DuckDBReadableVector 컬럼들, DuckDBWritableVector는 콜백 실행 중에만 유효하며 보관하면 안 돼요.

스칼라 함수 빌더 메서드 (Scalar Function Builder Methods)

scalarFunction() 빌더는 다음 메서드를 노출해요:

  • withName(String)
  • withParameter(Class<?> | DuckDBColumnType | DuckDBLogicalType)
  • withParameters(Class<?>... | DuckDBColumnType... | DuckDBLogicalType...)
  • withReturnType(Class<?> | DuckDBColumnType | DuckDBLogicalType)
  • withFunction(Supplier | Function | BiFunction)
  • withIntFunction(IntUnaryOperator | IntBinaryOperator)
  • withLongFunction(LongUnaryOperator | LongBinaryOperator)
  • withDoubleFunction(DoubleUnaryOperator | DoubleBinaryOperator)
  • withVarArgs(DuckDBLogicalType)
  • withVarArgsFunction(Function<Object[], ?>)
  • withVectorizedFunction(DuckDBScalarFunction)
  • withVolatile()
  • withNullInNullOut()
  • register(java.sql.Connection)

테이블 함수 (Table Functions)

DuckDBFunctions.tableFunction() 빌더로 테이블 함수를 만들어요 — org.duckdb.DuckDBTableFunctionBuilder를 반환해요. 스칼라 빌더처럼 일회용이며 register()(또는 close())에 확정돼요. 구현은 다음과 같은 콜백을 가진 DuckDBTableFunction이에요:

  • bind는 문이 준비될 때 실행돼요. addResultColumn()으로 출력 컬럼을 등록하고 getParameter()getNamedParameter()로 위치 및 명명된 호출 파라미터를 읽어요. 이후 콜백에 전달되는 바인드 데이터를 반환할 수 있어요.
  • init은 실행 전에 한 번 실행되고 전역 상태를 반환해요.
  • localInit(선택)은 실행 스레드마다 한 번 실행되고 스레드 로컬 상태를 반환해요. 병렬 스캔이 스레드별 상태를 필요로 할 때 이를 재정의하고, 기본은 null을 반환해요.
  • apply는 행을 출력 벡터에 쓰고 쓴 행 수를 반환하며, bind, global-init, local-init 데이터에 접근할 수 있어요. 엔진은 0을 반환할 때까지 반복 호출해요.
import java.sql.Connection;
import java.sql.DriverManager;
import java.util.concurrent.atomic.AtomicBoolean;
import org.duckdb.DuckDBFunctions;
import org.duckdb.DuckDBTableFunction;
import org.duckdb.DuckDBTableFunctionBindInfo;
import org.duckdb.DuckDBTableFunctionInitInfo;
import org.duckdb.DuckDBTableFunctionCallInfo;
import org.duckdb.DuckDBDataChunkWriter;
import org.duckdb.DuckDBValue;

try (Connection conn = DriverManager.getConnection("jdbc:duckdb:")) {
    DuckDBFunctions.tableFunction()
        .withName("java_table_basic")
        .withParameter(int.class)
        .withNamedParameter("param1", String.class)
        .withFunction(new DuckDBTableFunction<Integer, AtomicBoolean, Object>() {
            @Override
            public Integer bind(DuckDBTableFunctionBindInfo info) throws Exception {
                info.addResultColumn("col1", Integer.TYPE)
                    .addResultColumn("col2", String.class);
                DuckDBValue param = info.getParameter(0);
                return param.getInt();
            }

            @Override
            public AtomicBoolean init(DuckDBTableFunctionInitInfo info) throws Exception {
                return new AtomicBoolean(false);
            }

            @Override
            public long apply(DuckDBTableFunctionCallInfo info, DuckDBDataChunkWriter output) throws Exception {
                Integer bindData = info.getBindData();
                AtomicBoolean done = info.getInitData();
                if (done.get()) {
                    return 0;
                }
                output.vector(0).setInt(0, bindData);
                output.vector(1).setString(0, "foo");
                output.vector(0).setNull(1);
                output.vector(1).setString(1, "bar");
                done.set(true);
                return 2;
            }
        })
        .register(conn);
}

DuckDBTableFunction의 제네릭 타입 파라미터는 순서대로 bind 데이터, global init 데이터, local init 데이터예요. SQL에서 함수를 호출해요:

FROM java_table_basic(42, param1 = 'foobar');

행은 결과 읽기에 사용되는 DuckDBDataChunkReader의 쓰기 측 대응물인 DuckDBDataChunkWriter를 통해 쓰여요. 각 출력 벡터는 컬럼 인덱스로 주소 지정되고, 값은 setInt(), setString(), setNull() 같은 메서드로 행 인덱스별로 설정돼요.

파라미터와 info 객체를 포함한 모든 콜백 인자는 콜백 실행 중에만 유효하며 보관하면 안 돼요. Java 테이블 함수 인터페이스는 DuckDB의 C API 테이블 함수를 최대한 그대로 따르고 있어요.

청크로 행 만들기 (Producing Rows in Chunks)

위 예시는 모든 행을 단일 apply 호출에 쓰지만, 테이블 함수가 그렇게 해야 하는 것은 아니에요. DuckDB는 apply를 반복 호출하며, 호출마다 최대 output.capacity()행(기본 2,048)을 담는 새 출력 청크를 전달해요. 그만큼 채우고 쓴 행 수를 반환하면, DuckDB는 0을 반환할 때까지 다음 청크를 위해 apply를 다시 호출해요. 따라서 소스로의 커서는 로컬 변수가 아니라 init(또는 local-init) 상태에 있어야 호출 간에 살아남아요.

아래 apply는 유계 정수 시리즈를 청크 단위로 스트리밍해요. 또한 DuckDBWritableVector.getType()으로 각 컬럼의 선언된 타입에 따라 분기하는데, 이는 같은 루틴이 서로 다른 타입의 컬럼을 채울 때 편리해요:

// init 상태에 보관되어 apply() 호출 간에 위치가 살아남는다.
final class Series {
    long next;
    final long end;
    Series(long end) { this.end = end; }
}

DuckDBFunctions.tableFunction()
    .withName("java_series")
    .withParameter(long.class)
    .withFunction(new DuckDBTableFunction<Long, Series, Object>() {
        @Override
        public Long bind(DuckDBTableFunctionBindInfo info) throws Exception {
            info.addResultColumn("n", Long.TYPE)
                .addResultColumn("label", String.class);
            return info.getParameter(0).getLong();
        }

        @Override
        public Series init(DuckDBTableFunctionInitInfo info) throws Exception {
            return new Series(info.getBindData());
        }

        @Override
        public long apply(DuckDBTableFunctionCallInfo info, DuckDBDataChunkWriter output) throws Exception {
            Series cursor = info.getInitData();
            long row = 0;
            // 최대 한 청크를 채운다; 나머지는 DuckDB가 apply()를 다시 호출한다.
            for (; row < output.capacity() && cursor.next < cursor.end; row++, cursor.next++) {
                for (long col = 0; col < output.columnCount(); col++) {
                    DuckDBWritableVector vector = output.vector(col);
                    switch (vector.getType()) {
                        case BIGINT:  vector.setLong(row, cursor.next); break;
                        case VARCHAR: vector.setString(row, "row-" + cursor.next); break;
                        default:      vector.setNull(row);
                    }
                }
            }
            return row; // 0을 반환하면 DuckDB에게 스캔이 끝났다고 알린다
        }
    })
    .register(conn);

FROM java_series(5000)을 호출하면 apply가 세 번 실행되는데 — 2,048행 청크 두 개와 마지막 부분 청크 하나 — 그 다음 네 번째 호출이 0을 반환해요.

리소스 정리 (Cleaning up Resources)

테이블 함수는 스캔이 끝나면 해제해야 하는 파일, 소켓, 이터레이터 같은 열린 리소스를 보유할 수 있어요. 그런 리소스를 소유한 상태는 DuckDBTableFunctionState를 구현해 DuckDB가 더 이상 필요로 하지 않을 때 결정적으로 close()를 받을 수 있어요:

final class SourceState implements DuckDBTableFunctionState {
    private final AutoCloseable resource;

    SourceState(AutoCloseable resource) {
        this.resource = resource;
    }

    @Override
    public void close() throws Exception {
        resource.close();
    }
}

DuckDB는 함수가 소진된 후, LIMIT 후, 또는 에러·취소·조기 JDBC close 시점에 close()를 호출해요. 정리는 순서 보장 없이 네이티브 워커 스레드에서 실행될 수 있으므로, close()는 빠르고 idempotent하며 스레드 안전해야 해요. SQL을 실행하거나 같은 연결을 재사용하면 안 되고, 던져지는 예외는 쿼리로 전파되지 않아요. AutoCloseable만 구현하는 상태나 null 상태는 건드리지 않아요.

병렬 실행 (Parallel Execution)

테이블 함수를 여러 스레드에서 실행하려면 init 콜백에서 info.setMaxThreads()로 스레드 수를 설정해요. 병렬 출력은 또한 연결 문자열이나 연결 속성에서 preserve_insertion_order 옵션을 false로 설정해야 할 수 있어요.

프로젝션 푸시다운 (Projection Pushdown)

기본적으로 테이블 함수는 쿼리가 몇 개만 선택하더라도 bind에서 선언한 모든 컬럼을 만들도록 요청받아요. 빌더에서 withProjectionPushdown()을 호출하면 DuckDB가 함수에게 실제로 필요한 컬럼을 알려줘서 apply가 나머지를 채우는 작업을 건너뛸 수 있어요.

푸시다운이 활성화되면 init 콜백은 DuckDBTableFunctionInitInfo에서 프로젝션된 컬럼을 알 수 있어요: getColumnCount()는 쿼리가 요구하는 컬럼 수를, getColumnIndex(position)은 각 프로젝션 위치를 bind와 출력 벡터에 사용된 컬럼 인덱스로 매핑해요. 이 매핑을 init 상태에 저장하고 apply에서 참조해요.

.withProjectionPushdown()
.withFunction(new DuckDBTableFunction<Void, long[], Object>() {
    // ... bind()가 전체 출력 컬럼 집합을 등록한다 ...

    @Override
    public long[] init(DuckDBTableFunctionInitInfo info) throws Exception {
        // 쿼리가 실제로 필요한 선언된 컬럼을 기록한다.
        long[] projected = new long[(int) info.getColumnCount()];
        for (int i = 0; i < projected.length; i++) {
            projected[i] = info.getColumnIndex(i);
        }
        return projected;
    }
    // ... apply()가 projected 매핑에 나열된 컬럼만 쓴다 ...
})

withProjectionPushdown()이 없으면 getColumnCount()가 모든 선언된 컬럼을 보고하고 함수는 전부 채워야 해요.

등록된 함수 검사하기 (Inspecting Registered Functions)

성공한 모든 register() 호출은 DuckDBFunctions.RegisteredFunction을 반환하고 프로세스 전역의 Java 측 레지스트리에 등록을 기록해요. 이 레지스트리는 JDBC API로 등록된 함수를 위한 부기(bookkeeping)이며, DuckDB 카탈로그의 권위 있는 뷰는 아니에요.

List<DuckDBFunctions.RegisteredFunction> functions = DuckDBDriver.registeredFunctions();

registeredFunctions()는 읽기 전용 스냅샷을 반환하고, 각 RegisteredFunctionname()functionKind()(SCALAR 또는 TABLE)를 노출해요. 같은 이름이 여러 번 등록되면 중복 이름이 나타날 수 있어요. DuckDBDriver.clearFunctionsRegistry()는 Java 측 레지스트리만 지우고 DuckDB에서 함수를 등록 해제하지는 않아요.

타입 매핑 (Type Mapping)

withParameter(), withParameters(), withReturnType()은 세 가지 형태의 타입 선언을 받아요:

  • Java Class<?>int.classInteger.TYPE 같은 프리미티브 클래스 리터럴 포함
  • DuckDBColumnType enum 값
  • DuckDBLogicalType 인스턴스

타입이 Java Class로 선언되면 드라이버는 다음과 같이 DuckDB 타입으로 매핑해요:

Java 타입 DuckDB 타입
int INTEGER
long BIGINT
float FLOAT
double DOUBLE
String VARCHAR
BigDecimal DECIMAL
BigInteger HUGEINT
LocalDate, java.sql.Date DATE
LocalDateTime, java.sql.Timestamp, java.util.Date TIMESTAMP

직접적인 Java 매핑이 없는 타입은 DuckDBColumnType이나 DuckDBLogicalType으로 명시적으로 선언해요. 예를 들어 UHUGEINT는 명시적인 DuckDBColumnType.UHUGEINT 선언이 필요하고, DuckDBLogicalType.decimal(width, scale)은 명시적인 DECIMAL 정밀도와 스케일을 설정해요.

LISTSTRUCT 같은 복합 타입은 현재 UDF에서 지원되지 않아요. 이에 대한 지원은 향후 릴리스에서 계획돼 있어요.

더 알아보기 (Further Reading)

  • Handle Results — 벡터화된 함수의 입력을 읽는 데 사용하는 DuckDBDataChunkReader API가 chunked results와 공유됨.
  • C API — Java 테이블 함수 API가 따르는 테이블 함수 인터페이스.
  • Run Queries — SQL에서 등록된 함수 호출하기.
  • Configuration — 병렬 테이블 함수가 필요로 할 수 있는 preserve_insertion_order 옵션.