이 글은 사용자 사용자 정의 필드(User custom fields)의 검증을 위해 맞춤형 플러그인을 작성해야 할 미래의 DEV(개발자)들을 위한 것입니다.
Discourse 버전 3.6.0.beta3-latest(현재 커밋 a7326abf15) 환경에서 작성되었으므로, 코드가 동작하지 않는다면 코어 코드가 변경되었을 가능성이 있습니다.
이 글을 작성한 가장 큰 이유는 사용자 사용자 정의 필드에 대한 사용자 정의 검증 추가에 관한 정보가 전혀 없기 때문입니다.
간단히 말하면, 사용자 사용자 정의 필드 중 하나(기본적으로 자유 텍스트 입력 필드)에 사용자 정의 검증을 추가해야 했습니다. 주요 요구 사항은 이 필드에 고유한 값(unique value)을 가지는 것이었습니다. Python/PHP 개발자로서 제가 작업하는 프레임워크/CMS에서는 구현이 매우 쉬웠지만, Discourse는 달랐습니다. 언어적 특유의 문제라기보다는, 이를 수행하는 방법에 대한 문서화가 없었으며, 비슷한 질문을 담은 포스트를 찾아봐도 답변이 없었기 때문입니다. 코어 연구를 반 주 동안 진행한 끝에 마침내 원하는 결과를 얻었고, 누군가에게 도움이 될 수 있기를 바라며 공유합니다.
즉, 고유한 값을 가져야 하는 사용자 사용자 정의 필드에 대한 이야기입니다.
이를 해결하려면 Discourse용 사용자 정의 플러그인을 생성해야 합니다. 그 이유는 값의 중복 여부를 데이터베이스(DB)에서 확인해야 하며, 이는 백엔드에서 이루어지기 때문입니다.
구조는 다음과 같습니다:
- my_custom_plugin
-- assets
--- javascripts
---- discourse
----- initializers
------ your-initializer-js-file.js
-- config
--- locales
---- server.en.yml
---- client.en.yml
--- settings.yml
-- plugin.rb
제가 마주한 주요 문제는 사용자 정의 필드에 어떻게 주입(inject)할 것인가였습니다. 코어를 검색한 끝에 api 메서드인 addCustomUserFieldValidationCallback을 발견했습니다. 이 메서드는 코어의 plugin-api.gjs 파일에서 찾을 수 있습니다. 이 메서드는 가입(Signup) 양식을 처음 제출하려고 할 때 실행되며, 이후에는 입력 필드나 사용자 사용자 정의 필드에 타이핑할 때마다 실행됩니다. 이것이 제가 필요로 했던 것이었습니다.
이 메서드의 예시에서는 **콜백 함수(callback function)**를 인수로 받아들이며, 이 메서드가 다시 반환하는 인자는 현재 상호작용하고 있는 userField 자체입니다.
다음은 your-initializer-js-file.js의 코드입니다(전체 코드는 아니지만 충분한 양입니다):
import { apiInitializer } from "discourse/lib/api";
import { i18n } from "discourse-i18n";
export default apiInitializer("1.8.0", (api) => {
// 고유 검증이 필요한 필드 ID 목록
const UNIQUE_FIELD_IDS = ["5"];
// 클라이언트 측 검증 콜백
api.addCustomUserFieldValidationCallback((userField) => {
const fieldId = userField.field.id.toString();
// 고유 필드 목록에 포함된 필드만 검증
if (UNIQUE_FIELD_IDS.includes(fieldId)) {
// 값이 비어 있는지 확인
if (!userField.value || userField.value.trim() === "") {
return null; // 빈 값은 기본 검증에 맡김
}
const value = userField.value.trim();
// 값 목록과 대조하여 확인
const unique = isValueUnique(fieldId, value);
if (!unique) {
return {
failed: true,
ok: false,
reason: i18n("js.my_custom_plugin_text_validator.value_taken"),
element: userField.field.element,
};
}
}
return null;
});
});
여기서 언급할 주요 포인트:
- UNIQUE_FIELD_IDS는 사용자 정의 로직의 영향을 받도록 필터링할 필드를 결정하는 데 사용됩니다. 숫자 5는 필드 ID이며, 사용자 정의 필드의 **편집 페이지(Edit page)**를 열었을 때 URL에서 확인할 수 있습니다.
- isValueUnique는 모든 사용자 정의 검증 로직이 수행되는 사용자 정의 함수입니다. api.addCustomUserFieldValidationCallback 외부에서 지정해야 합니다.
값을 DB에서 확인해야 하므로, 검증이 유효한지 아닌지에 대한 응답을 제공해 줄 엔드포인트로 API 호출을 해야 하거나, 아직 발견하지 못한 사용자 정의 엔드포인트 없이 이를 확인할 수 있는 다른 방법이 있을 수 있습니다. 검증을 위한 모듈이 하나 있지만, 이는 JS에서 바로 REGEX 체크로 제한된 검증이므로 제가 필요로 했던 것이 아닙니다.
plugin.rb 파일은 사용자 정의 플러그인에 대한 정보, 설정 및 추가 가능한 모든 사항을 지정하는 곳입니다. 제 경우, 검증을 위한 사용자 정의 엔드포인트를 등록했습니다. 가능한 검증의 예시이며, 다른 방식으로 구현할 수도 있습니다.
# frozen_string_literal: true
# name: my_custom_plugin
# about: Validates uniqueness of custom user fields
# version: 0.1
# authors: Yan R
enabled_site_setting :my_custom_plugin_enabled
after_initialize do
# 고유 필드 ID의 하드코딩된 목록
UNIQUE_FIELD_IDS = ["5"].freeze
# 검증을 위한 API 엔드포인트 추가
Discourse::Application.routes.append do
get "/my_custom_plugin_endpoint_url/:field_id" => "my_custom_plugin_validation#validate"
end
class ::MyCustomPluginValidationController < ::ApplicationController
skip_before_action :verify_authenticity_token
skip_before_action :check_xhr
skip_before_action :redirect_to_login_if_required
def validate
field_name = params[:field_name]
field_value = params[:field_value]
return render json: { valid: true } unless field_name.present? && field_value.present?
return render json: { valid: true } unless field_name.start_with?("user_field_")
# 값 정규화: 공백 제거 및 대소문자 무시 비교
normalized_value = field_value.to_s.strip
return render json: { valid: true } if normalized_value.empty?
existing = UserCustomField
.where(name: field_name)
.where("LOWER(TRIM(value)) = LOWER(?)", normalized_value)
.exists?
render json: { valid: !existing }
end
이제 어려운 부분이 시작됩니다. 이 엔드포인트를 사용하여 초기화 프로그램(initializer)에서 fetch/ajax 호출을 할 수 있지만, 이는 작동하지 않습니다. 주요 이유는 addCustomUserFieldValidationCallback이 비동기(async) 콜백과 함께 작동하지 않기 때문입니다. 따라서 비동기가 아닌 호출을 수행해야 합니다. 저는 예시로 xhr.open('GET', '/my_custom_plugin_endpoint_url/${fieldId}', true);에서 true가 비동기를 비활성화하는 방식으로 XMLHttpRequest를 사용했습니다.
이제 작동하기 시작하지만, 입력 필드에서 빠르게 타이핑할 수 없는 문제가 발생합니다. addCustomUserFieldValidationCallback은 제공되는 각 문자마다 실행되며 적절한 반환값을 받을 때까지 대기하기 때문에 입력이 동결(freeze)되기 때문입니다.
예시에는 보여주지 않았지만, 제 해결책은 페이지 로드 후 필드의 고유 값 목록을 가져와 추가 API 호출 없이 JS에서 바로 값을 필터링하는 것이었습니다. 합리적인 양의 값을 가지고 있는지 확인하고 엔드포인트를 보호해야 합니다. 제 경우, 값 목록 대신 해시(hash) 목록을 반환했으며, JS에서 입력된 값을 해시로 변환하여 해시를 비교했습니다. 조금 더 안전할 뿐만 아니라 응답 크기를 현저히 줄여 비교할 수 있는 값을 훨씬 더 많이 가져올 수 있습니다.
다음은 config 폴더에 대한 정보입니다.
settings.yml:
plugins:
my_custom_plugin_enabled:
default: true
client: true
server.en.yml
en:
site_settings:
my_custom_plugin_enabled: "Enable unique user fields validation"
my_custom_plugin_text_validator:
value_taken: "This value is already taken"
client.en.yml
en:
js:
my_custom_plugin_text_validator:
value_taken: "This value is already taken"
추가: Docker 설정을 사용 중인데, app.yml에 플러그인을 추가하는 것 외에 사용자 정의 플러그인을 추가하는 방법에 대한 정보를 찾지 못했습니다. 제가 한 일은 Discourse 설정이 포함된 docker 폴더/리포지토리에 사용자 정의 플러그인을 추가하고, Rebuild 작업 시 컨테이너 내 플러그인 폴더에 마운트하는 것이었습니다.
이 글이 누군가 사용자 필드에 대한 사용자 정의 검증을 구현하는 데 도움이 되기를 바랍니다.