불경하다! 어딜 데이터베이스를 깔려고 하느냐? ESENT (ESE) DB 활용기

고남현 @gnh1201@hackers.pub

불경한게 많은 세상

세상에는 참 불경(?)한 것들이 많습니다.

개발에 쓰이는 도구가 불경하다며 어떠한 도구도 깔지말고 메모장(Notepad)으로만 코딩을 하라더니, 이제는 데이터베이스가 불경하다며 PC에 이미 설치된 기본 기능만으로 데이터베이스 처럼 사용할 수 있는 방법을 강구하라고 합니다.

아... 먹고 살기 쉽지 않습니다.

하지만 메모장 "Only" 코딩도 성공시킨 제가 데이터베이스라고 성공시키지 못하겠습니까? 해봅시다.

지금까지의 이야기

외부 개발 도구가 매우 제한적으로 공급되는 환경(사실상 쓸 수 있는 개발 도구가 "메모장"밖에 없는 환경)에서도 고급 기능을 구현할 수 있는 방안에 대한 요구는, 윈도우즈 운영체제의 내부 ECMAScript를 활용하는 WelsonJS 프레임워크의 공개를 통해 해결할 수 있었습니다.

WelsonJS 프레임워크는 2025년 6월 26일 기준, 깃허브(GitHub)에서 350개의 긍정적인 평가(Stars)를 받는 성과도 이루었습니다.

이후로, 지금까지의 경험을 데이터베이스 영역으로 확대해보자는 의견이 나오기 시작하였습니다. 상용 데이터베이스의 존재는 그 자체만으로도 매우 거대하기 때문에 외부 개발 도구의 공급이 제한되는 환경에서는 데이터베이스도 업무에 걸림돌이 되기 때문입니다.

결국 운영체제에서 기본적으로 지원되는 데이터베이스로 사용 가능한 시스템이 있는지 사전 조사를 시작합니다.

사전 조사가 끝나다

결국 윈도우즈 운영체제에는 Windows 2000부터 현재 버전(Windows 11)에도 탑재된 ESENT (ESE) 데이터베이스라는 것이 존재한다는걸 확인하게 됩니다.

제 그동안의 경험에 의한 관심법과, LLM(거대 언어 모델)과의 협공이라면 빠른 시간 내에 구현이 가능할거라 생각했지만 상대적으로 정보가 부족하여 생각보다는 오래 걸렸습니다.

데이터베이스 기능 추상화 (칼럼, 스키마, CRUD) 구현

ESENT (ESE) 데이터베이스를 사용하기 위한 API 구현은 존재하지만, Column(칼럼), Schema(스키마, 혹은 테이블), CRUD(생성, 읽기, 수정, 삭제) 등 실제 어플리케이션을 구현하는 용도로 사용하기에 적합하도록 데이터베이스의 개념을 추상화해둔 구현은 존재하지 않았습니다.

그런고로, 직접 작성을 해보았습니다! 먼저 칼럼과 스키마라는 개념을 구현해봅시다.

(참고: x86 호환을 유지해야되는 환경에서, EsentInterop의 경우 현재 라이브러리 서버에서 배포되는 버전은 x86을 위한 어셈블리(dll)을 제공하지 않기 때문에 직접 소스코드를 포함하여 빌드하여야 했습니다.)

// Column.cs (WelsonJS.Esent)
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2025 Namhyeon Go <gnh1201@catswords.re.kr>, Catswords OSS and WelsonJS Contributors
// https://github.com/gnh1201/welsonjs
// 
using System;
using System.Text;
using Microsoft.Isam.Esent.Interop;

namespace WelsonJS.Esent
{
    public class Column
    {
        public string Name { get; set; }
        public JET_coltyp Type { get; set; }
        public int MaxSize { get; set; }
        public JET_CP CodePage { get; set; }
        public bool IsPrimaryKey { get; set; } = false;

        public override string ToString()
        {
            return Name;
        }

        public static explicit operator string(Column c)
        {
            return c.ToString();
        }

        public Column(string name, JET_coltyp type, int maxSize = 0, JET_CP codePage = JET_CP.None)
        {
            Name = name;
            Type = type;
            MaxSize = maxSize;
            CodePage = codePage == JET_CP.None ?
                JET_CP.Unicode : codePage;
        }

        public Column(string name, Type dotNetType, int maxSize = 0, Encoding encoding = null)
        {
            Name = name;
            Type = GetJetColtypFromType(dotNetType);
            MaxSize = maxSize;
            CodePage = GetJetCpFromEncoding(encoding ?? Encoding.Unicode);
        }

        private static JET_coltyp GetJetColtypFromType(Type type)
        {
            if (type == typeof(string)) return JET_coltyp.Text;
            if (type == typeof(int)) return JET_coltyp.Long;
            if (type == typeof(long)) return JET_coltyp.Currency;
            if (type == typeof(bool)) return JET_coltyp.Bit;
            if (type == typeof(float)) return JET_coltyp.IEEESingle;
            if (type == typeof(double)) return JET_coltyp.IEEEDouble;
            if (type == typeof(DateTime)) return JET_coltyp.DateTime;
            if (type == typeof(byte[])) return JET_coltyp.LongBinary;

            throw new NotSupportedException($"Unsupported .NET type: {type.FullName}");
        }

        private static JET_CP GetJetCpFromEncoding(Encoding encoding)
        {
            if (encoding == Encoding.Unicode) return JET_CP.Unicode;
            if (encoding == Encoding.ASCII) return JET_CP.ASCII;
            if (encoding.CodePage == 1252) return (JET_CP)1252; // Windows-1252 / Latin1
            if (encoding.CodePage == 51949) return (JET_CP)51949; // EUC-KR
            if (encoding.CodePage == 949) return (JET_CP)949; // UHC (Windows Korean)
            if (encoding.CodePage == 932) return (JET_CP)932; // Shift-JIS (Japanese)
            if (encoding.CodePage == 936) return (JET_CP)936; // GB2312 (Simplified Chinese)
            if (encoding.CodePage == 65001) return (JET_CP)65001; // UTF-8
            if (encoding.CodePage == 28591) return (JET_CP)28591; // ISO-8859-1

            throw new NotSupportedException($"Unsupported encoding: {encoding.WebName} (code page {encoding.CodePage})");
        }
    }
}

// Schema.cs (WelsonJS.Esent)
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2025 Namhyeon Go <gnh1201@catswords.re.kr>, Catswords OSS and WelsonJS Contributors
// https://github.com/gnh1201/welsonjs
// 
using System;
using System.Collections.Generic;

namespace WelsonJS.Esent
{
    public class Schema
    {
        public string TableName { get; set; }
        public List<Column> Columns { get; set; }
        public Column PrimaryKey
        {
            get
            {
                return Columns.Find(c => c.IsPrimaryKey) ?? null;
            }
        }

        public Schema(string tableName, List<Column> columns)
        {
            TableName = tableName;
            Columns = columns ?? new List<Column>();
        }

        public void SetPrimaryKey(string columnName)
        {
            Column column = Columns.Find(c => c.Name.Equals(columnName, StringComparison.OrdinalIgnoreCase));
            if (column != null)
            {
                column.IsPrimaryKey = true;
            }
            else
            {
                throw new ArgumentException($"Column '{columnName}' does not exist in schema '{TableName}'.");
            }
        }
    }
}

그리고, 대망의 CRUD를 구현해줍니다. (예시의 내용 중 Logger 인터페이스의 활용과 관련된 내용은 생략하였습니다.)

// DataStore.cs (WelsonJS.Esent)
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2025 Namhyeon Go <gnh1201@catswords.re.kr>, Catswords OSS and WelsonJS Contributors
// https://github.com/gnh1201/welsonjs
// 
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using Microsoft.Isam.Esent.Interop;

namespace WelsonJS.Esent
{
    public class EsentDatabase : IDisposable
    {
        private const string _primaryKeyindexName = "primary";
        private const string _indexNamePrefix = "idx_";
        private const string _databaseName = "metadata.edb";

        private readonly ICompatibleLogger _logger;
        private static readonly object _lock = new object();
        private static bool _initialized = false;
        private static Instance _instance;
        private static string _workingDirectory;
        private static string _filePath;

        private readonly Session _session;
        private readonly JET_DBID _dbid;
        private readonly Schema _schema;
        private readonly Column _primaryKey;
        private readonly Dictionary<string, JET_COLUMNID> _columnIds;

        public EsentDatabase(Schema schema, string workingDirectory, ICompatibleLogger logger = null)
        {
            _logger = logger ?? new TraceLogger();

            if (schema == null)
                throw new ArgumentNullException(nameof(schema));

            _primaryKey = schema.PrimaryKey;

            if (_primaryKey == null)
                throw new ArgumentNullException();

            if (!schema.Columns.Exists(c => c == _primaryKey))
                throw new ArgumentException($"Primary key '{_primaryKey.Name}' is not in schema.");

            _workingDirectory = workingDirectory;
            _schema = schema;
            _columnIds = new Dictionary<string, JET_COLUMNID>(StringComparer.OrdinalIgnoreCase);

            InitializeInstance();

            _session = new Session(_instance);

            if (!File.Exists(_filePath))
            {
                Api.JetCreateDatabase(_session, _filePath, null, out _dbid, CreateDatabaseGrbit.None);
                CreateTable(_schema);
            }
            else
            {
                Api.JetAttachDatabase(_session, _filePath, AttachDatabaseGrbit.None);
                Api.JetOpenDatabase(_session, _filePath, null, out _dbid, OpenDatabaseGrbit.None);
            }

            CacheColumns();
        }

        private static void InitializeInstance()
        {
            if (_initialized) return;

            lock (_lock)
            {
                if (_initialized) return;

                // set the file path
                _filePath = Path.Combine(_workingDirectory, _databaseName);

                // config the instance
                _instance = new Instance(typeof(EsentDatabase).Namespace);
                _instance.Parameters.SystemDirectory = _workingDirectory;
                _instance.Parameters.LogFileDirectory = _workingDirectory;
                _instance.Parameters.TempDirectory = _workingDirectory;

                // initialize the instance
                _instance.Init();
                _initialized = true;
            }
        }

        private void CreateTable(Schema schema)
        {
            Api.JetBeginTransaction(_session);
            JET_TABLEID tableid;
            Api.JetCreateTable(_session, _dbid, schema.TableName, 0, 100, out tableid);

            foreach (var col in schema.Columns)
            {
                var coldef = new JET_COLUMNDEF
                {
                    coltyp = col.Type,
                    cbMax = col.MaxSize,
                    cp = col.CodePage
                };
                Api.JetAddColumn(_session, tableid, col.Name, coldef, null, 0, out _);
            }

            CreateIndex(tableid, new[] { _primaryKey }, CreateIndexGrbit.IndexPrimary | CreateIndexGrbit.IndexUnique);

            Api.JetCloseTable(_session, tableid);
            Api.JetCommitTransaction(_session, CommitTransactionGrbit.None);
        }

        public void CreateIndex(JET_TABLEID tableid, IEnumerable<Column> columns, CreateIndexGrbit grbit)
        {
            if (columns == null)
                throw new ArgumentNullException(nameof(columns));

            var columnList = columns.ToList();
            if (columnList.Count == 0)
                throw new ArgumentException("At least one column is required to create an index.", nameof(columns));

            if (tableid == JET_TABLEID.Nil)
                throw new ArgumentException("Invalid table ID.", nameof(tableid));

            bool isPrimaryKeyIndex = (columnList.Count == 1 && columnList[0].IsPrimaryKey);

            if (isPrimaryKeyIndex && (grbit & CreateIndexGrbit.IndexPrimary) == 0)
                throw new ArgumentException("Primary key index must have the CreateIndexGrbit.IndexPrimary flag set.", nameof(grbit));

            string indexName = isPrimaryKeyIndex
                ? _primaryKeyindexName
                : _indexNamePrefix + string.Join("_", columnList.Select(c => c.Name));

            string key = string.Concat(columnList.Select(c => "+" + c.Name));
            string keyDescription = key + "\0\0"; // double null-terminated
            int keyDescriptionLength = keyDescription.Length;

            Api.JetCreateIndex(
                _session,
                tableid,
                indexName,
                grbit,
                keyDescription,
                keyDescriptionLength,
                100
            );
        }

        private void CacheColumns()
        {
            using (var table = new Table(_session, _dbid, _schema.TableName, OpenTableGrbit.ReadOnly))
            {
                foreach (var col in _schema.Columns)
                {
                    try
                    {
                        JET_COLUMNID colid = Api.GetTableColumnid(_session, table, col.Name);
                        _columnIds[col.Name] = colid;
                    }
                    catch (EsentColumnNotFoundException)
                    {
                        _logger.Warn($"Column '{col.Name}' not found.");
                    }
                }
            }
        }

        public bool Insert(Dictionary<string, object> values, out object key)
        {
            return TrySaveRecord(values, JET_prep.Insert, expectSeek: false, out key);
        }

        public bool Update(Dictionary<string, object> values)
        {
            return TrySaveRecord(values, JET_prep.Replace, expectSeek: true, out _);
        }

        private bool TrySaveRecord(
            Dictionary<string, object> values,
            JET_prep prepType,
            bool expectSeek,
            out object primaryKeyValue)
        {
            primaryKeyValue = null;

            if (!TryGetPrimaryKeyValue(values, out var keyValue))
                return false;

            var keyType = _primaryKey.Type;

            using (var table = new Table(_session, _dbid, _schema.TableName, OpenTableGrbit.Updatable))
            {
                try
                {
                    Api.JetBeginTransaction(_session);

                    Api.JetSetCurrentIndex(_session, table, _primaryKeyindexName);
                    MakeKeyByType(keyValue, keyType, _session, table);
                    bool found = Api.TrySeek(_session, table, SeekGrbit.SeekEQ);

                    if (expectSeek != found)
                    {
                        _logger.Warn($"[ESENT] Operation skipped. Seek result = {found}, expected = {expectSeek}");
                        Api.JetRollback(_session, RollbackTransactionGrbit.None);
                        return false;
                    }

                    Api.JetPrepareUpdate(_session, table, prepType);
                    SetAllColumns(values, table);

                    Api.JetUpdate(_session, table);
                    Api.JetCommitTransaction(_session, CommitTransactionGrbit.None);

                    if (prepType == JET_prep.Insert)
                        primaryKeyValue = keyValue;

                    return true;
                }
                catch (Exception ex)
                {
                    Api.JetRollback(_session, RollbackTransactionGrbit.None);
                    throw new InvalidOperationException($"[ESENT] Operation failed: {ex.Message}");
                }
            }
        }

        public Dictionary<string, object> FindById(object keyValue)
        {
            var result = new Dictionary<string, object>();
            var keyType = _primaryKey.Type;

            using (var table = new Table(_session, _dbid, _schema.TableName, OpenTableGrbit.ReadOnly))
            {
                Api.JetSetCurrentIndex(_session, table, _primaryKeyindexName);
                MakeKeyByType(keyValue, keyType, _session, table);
                if (!Api.TrySeek(_session, table, SeekGrbit.SeekEQ))
                    return null;

                foreach (var col in _schema.Columns)
                {
                    if (!_columnIds.TryGetValue(col.Name, out var colid))
                        continue;

                    var value = RetrieveColumnByType(_session, table, colid, col.Type);
                    result[col.Name] = value;
                }
            }

            return result;
        }

        public List<Dictionary<string, object>> FindAll()
        {
            var results = new List<Dictionary<string, object>>();

            using (var table = new Table(_session, _dbid, _schema.TableName, OpenTableGrbit.ReadOnly))
            {
                Api.JetSetCurrentIndex(_session, table, _primaryKeyindexName);

                if (!Api.TryMoveFirst(_session, table))
                    return results;

                do
                {
                    var row = new Dictionary<string, object>();
                    foreach (var col in _schema.Columns)
                    {
                        if (!_columnIds.TryGetValue(col.Name, out var colid))
                            continue;

                        var value = RetrieveColumnByType(_session, table, colid, col.Type);
                        row[col.Name] = value;
                    }
                    results.Add(row);
                }
                while (Api.TryMoveNext(_session, table));
            }

            return results;
        }

        public bool DeleteById(object keyValue)
        {
            var keyType = _primaryKey.Type;

            using (var table = new Table(_session, _dbid, _schema.TableName, OpenTableGrbit.Updatable))
            {
                Api.JetSetCurrentIndex(_session, table, _primaryKeyindexName);
                MakeKeyByType(keyValue, keyType, _session, table);
                if (!Api.TrySeek(_session, table, SeekGrbit.SeekEQ))
                    return false;

                Api.JetDelete(_session, table);
                return true;
            }
        }

        private object RetrieveColumnByType(Session session, Table table, JET_COLUMNID columnId, JET_coltyp type)
        {
            switch (type)
            {
                case JET_coltyp.Text:
                    return Api.RetrieveColumnAsString(session, table, columnId, Encoding.Unicode);
                case JET_coltyp.Long:
                    return Api.RetrieveColumnAsInt32(session, table, columnId);
                case JET_coltyp.IEEEDouble:
                    return Api.RetrieveColumnAsDouble(session, table, columnId);
                case JET_coltyp.DateTime:
                    return Api.RetrieveColumnAsDateTime(session, table, columnId);
                case JET_coltyp.Binary:
                case JET_coltyp.LongBinary:
                    return Api.RetrieveColumn(session, table, columnId);
                default:
                    _logger.Warn($"[ESENT] Unsupported RetrieveColumn type: {type}");
                    return null;
            }
        }

        private bool TryGetPrimaryKeyValue(Dictionary<string, object> values, out object keyValue)
        {
            keyValue = null;

            if (!values.TryGetValue(_primaryKey.Name, out keyValue))
            {
                _logger.Warn($"[ESENT] Missing primary key '{_primaryKey.Name}'.");
                return false;
            }

            if (keyValue == null)
            {
                _logger.Warn("[ESENT] Primary key value cannot be null.");
                return false;
            }

            return true;
        }

        private JET_coltyp GetColumnType(string columnName)
        {
            var column = _schema.Columns.FirstOrDefault(c => c.Name == columnName);
            if (column == null)
                throw new ArgumentException($"Column '{columnName}' not found in schema.");

            return column.Type;
        }

        private void SetAllColumns(Dictionary<string, object> values, Table table)
        {
            foreach (var kv in values)
            {
                if (!_columnIds.TryGetValue(kv.Key, out var colid))
                {
                    _logger.Warn($"[ESENT] Column '{kv.Key}' not found in cache.");
                    continue;
                }

                var colType = GetColumnType(kv.Key);
                SetColumnByType(_session, table, colid, kv.Value, colType);
            }
        }

        private void SetColumnByType(Session session, Table table, JET_COLUMNID columnId, object value, JET_coltyp type)
        {
            if (value == null)
                return;

            switch (type)
            {
                case JET_coltyp.Text:
                    Api.SetColumn(session, table, columnId, value.ToString(), Encoding.Unicode);
                    break;
                case JET_coltyp.Long:
                    Api.SetColumn(session, table, columnId, Convert.ToInt32(value));
                    break;
                case JET_coltyp.IEEEDouble:
                    Api.SetColumn(session, table, columnId, Convert.ToDouble(value));
                    break;
                case JET_coltyp.DateTime:
                    Api.SetColumn(session, table, columnId, Convert.ToDateTime(value));
                    break;
                case JET_coltyp.Binary:
                case JET_coltyp.LongBinary:
                    Api.SetColumn(session, table, columnId, (byte[])value);
                    break;
                default:
                    _logger.Warn($"[ESENT] Unsupported SetColumn type: {type}");
                    break;
            }
        }

        private void MakeKeyByType(object value, JET_coltyp type, Session session, Table table)
        {
            switch (type)
            {
                case JET_coltyp.Text:
                    Api.MakeKey(session, table, value.ToString(), Encoding.Unicode, MakeKeyGrbit.NewKey);
                    break;
                case JET_coltyp.Long:
                    Api.MakeKey(session, table, Convert.ToInt32(value), MakeKeyGrbit.NewKey);
                    break;
                case JET_coltyp.IEEEDouble:
                    Api.MakeKey(session, table, Convert.ToDouble(value), MakeKeyGrbit.NewKey);
                    break;
                case JET_coltyp.DateTime:
                    Api.MakeKey(session, table, Convert.ToDateTime(value), MakeKeyGrbit.NewKey);
                    break;
                case JET_coltyp.Binary:
                case JET_coltyp.LongBinary:
                    Api.MakeKey(session, table, (byte[])value, MakeKeyGrbit.NewKey);
                    break;
                default:
                    _logger.Warn($"[ESENT] Unsupported MakeKey type: {type}");
                    break;
            }
        }

        public void Dispose()
        {
            _session?.Dispose();
        }
    }
}

이렇게하면 ESENT (ESE) 데이터베이스에서 어플리케이션 개발 용도에 적합한 칼럼, 스키마 및 CRUD를 위한 메소드를 구현할 수 있습니다.

활용

이렇게 만들어진 구현은 다음과 같이 사용할 수 있습니다.

using WelsonJS.Esent;

// connect the database to manage instances
Schema schema = new Schema("Instances", new List<Column>
{
    new Column("InstanceId", typeof(string), 255),
    new Column("FirstDeployTime", typeof(DateTime), 1)
});
schema.SetPrimaryKey("InstanceId");
_db = new EsentDatabase(schema, Path.GetTempPath());

// Insert row
try
{
    _db.Insert(new Dictionary<string, object>
    {
        ["InstanceId"] = instanceId,
        ["FirstDeployTime"] = now
    }, out _);
}
catch (Exception ex)
{
    // Handle exception
}

// find all
var instances = _db.FindAll();
foreach (var instance in instances)
{
    try
    {
        string instanceId = instance["InstanceId"].ToString();
        string firstDeployTime = instance.ContainsKey("FirstDeployTime")
            ? ((DateTime)instance["FirstDeployTime"]).ToString("yyyy-MM-dd HH:mm:ss")
            : "Unknown";

        Console.WriteLine($"{firstDeployTime}, {instanceId}");
    }
    catch (Exception ex)
    {
        // Handle exception
    }
}

우리에게 다소 익숙한 어플리케이션 개발에 적합한 메소드를 지원하고 있음을 확인할 수 있습니다.

완전한 구현

이렇게하여 새로운 라이브러리인 "WelsonJS.Esent"가 탄생하게 되었습니다. 완전한 구현은 아래 링크에서 확인 가능합니다.

https://github.com/gnh1201/welsonjs/tree/master/WelsonJS.Toolkit/WelsonJS.Esent

5

No comments

If you have a fediverse account, you can comment on this article from your own instance. Search https://hackers.pub/ap/articles/0197aaeb-842e-783a-9ac6-b52f4bc09084 on your instance and reply to it.