검색창에 '전역'을 쳤는데 「Git 마지막 커밋 수정」이 떴다.

전역 변수 기능을 막 넣은 참이었다. 검색창에는 분명히 '전역' 두 글자가 보였다. 그런데 결과 목록 맨 위에는 git commit --amend --no-edit 이 앉아 있었다. 전역과는 아무 관계가 없는 스니펫이다. 한 글자를 더 치면 결과가 맞게 바뀌었다가, 다음 글자를 조합하는 동안 다시 엉뚱해졌다.

이 글은 「Cliply」 개발기와 사용 가이드의 후속이다. 개발기 4편에서 {{이름:기본값}} 으로 복사할 때 값을 채우는 변수 치환을 만들었다. 0.3.0 에서는 그 위에 사람이 입력하지 않아도 채워지는 변수를 얹었다. 그리고 그 작업을 하다가 한글 검색의 오래된 구멍을 하나 찾았다.

결과가 아니라 검색어가 틀렸다

처음에는 검색 쪽을 의심했다. Cliply 의 검색은 SQLite FTS5 trigram 인데, trigram 은 세 글자 미만의 토큰을 찾지 못한다. 그래서 두 글자 이하의 검색어는 LIKE '%…%' 로 돌아간다. '전역' 은 두 글자니 LIKE 로 찾았을 것이고, 「Git 마지막 커밋 수정」의 어디에도 '전역' 은 없다.

그런데 '전' 한 글자라면 이야기가 달라진다. 그 스니펫의 설명은 "스테이징한 변경을 직전 커밋에 합친다" 이다. '직전' 에 '전' 이 들어 있다. 검색이 틀린 게 아니라, 검색이 받은 검색어가 '전' 이었던 것이다.

두벌식으로 '전역' 을 치는 과정을 한 타씩 따라가 보면 이유가 보인다. ㅈ, ㅓ, ㄴ 까지 치면 화면에 '전' 이 있다. 다음 ㅇ 은 ㄴ 받침과 겹받침이 되지 않으니, 입력기는 '전' 을 확정하고 ㅇ 으로 새 조합을 시작한다. 이어서 ㅕ, ㄱ 을 치면 화면은 '전역' 이 된다. 이때 '역' 은 아직 조합 중이다. 다음 키가 무엇이냐에 따라 '역' 이 될 수도, '여' 와 다음 글자로 갈라질 수도 있으니까. 그 조합 중인 '역' 이 검색어에 들어가지 않고 있었다.

관리 창에서는 엉뚱한 결과가 잠깐 보이는 정도지만, Enter 로 선택된 결과를 바로 복사하는 빠른 검색 팝업에서는 화면과 다른 검색어로 고른 스니펫이 복사될 수 있었다.

조합 중인 글자는 어디에 있나

검색창의 Text 는 {Binding SearchText, UpdateSourceTrigger=PropertyChanged} 로 묶여 있었다. UpdateSourceTrigger=PropertyChanged 는 글자가 바뀔 때마다 ViewModel 의 SearchText 를 갱신하라는 뜻이다. 영문을 칠 때는 실제로 그렇게 움직였다. 한 타마다 SearchText 가 바뀌고, 150ms 디바운스 뒤에 검색이 돈다. 설정이 빠진 게 아니었다.

차이는 IME 에 있었다. WPF 에서 키보드로 들어오는 글자는 TextComposition 이라는 단위로 오고, 영문처럼 조합이 필요 없는 글자는 들어오는 즉시 완료된다. 한글은 다르다. 조합이 진행되는 동안 TextBox 는 화면에 '역' 을 그리고 Text 도 '전역' 으로 바꾸며 TextChanged 도 올린다. 하지만 글자가 확정됐다는 신호인 TextInput 은 조합이 끝나야 온다. 그리고 TextBox 의 바인딩은 조합이 끝나기 전에는 원본, 즉 ViewModel 쪽을 갱신하지 않는다. 화면의 Text 와 ViewModel 의 SearchText 가 그 한 글자만큼 어긋나 있는 것이다.

이 동작 자체는 일리가 있다. 조합 중인 글자는 아직 사용자가 정한 값이 아니다. 문제는 검색창이 입력 폼이 아니라는 점이다. 검색창은 지금 화면에 보이는 글자로 지금 결과를 보여 줘야 한다. '역' 이 나중에 '여' 로 바뀌면 그때 다시 검색하면 된다.

그러니 질문은 하나로 줄었다. 조합 중인 글자를 볼 수 있는 이벤트가 무엇인가. TextInput 은 확정된 뒤에만 오니 늦다. TextChanged 는 조합 중에도 온다. 그 순간 바인딩에게 원본을 갱신하라고 직접 시키면 된다.

연결 속성 하나

검색창이 관리 창과 빠른 검색 팝업에 하나씩 있으니, 코드 비하인드에 각각 이벤트 처리기를 다는 대신 연결 속성(attached property)으로 만들었다.

public static class ImeTextBinding
{
    public static readonly DependencyProperty UpdateWhileComposingProperty =
        DependencyProperty.RegisterAttached(
            "UpdateWhileComposing", typeof(bool), typeof(ImeTextBinding),
            new PropertyMetadata(false, OnUpdateWhileComposingChanged));

    private static void OnUpdateWhileComposingChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (d is not TextBox textBox) return;

        textBox.TextChanged -= OnTextChanged;
        if (e.NewValue is true) textBox.TextChanged += OnTextChanged;
    }

    private static void OnTextChanged(object sender, TextChangedEventArgs e) =>
        ((TextBox)sender).GetBindingExpression(TextBox.TextProperty)?.UpdateSource();
}

하는 일은 마지막 한 줄이 전부다. TextChanged 가 올 때마다 Text 바인딩의 BindingExpression 을 꺼내 UpdateSource() 를 부른다. 바인딩이 조합 때문에 미뤄 둔 갱신을 미루지 말고 지금 하라는 뜻이다. 붙이기 전에 한 번 떼는 건 처리기가 겹쳐 붙지 않게 하려는 것이다.

쓰는 쪽은 속성 하나를 붙이면 끝난다.

<TextBox x:Name="QueryBox" Text="{Binding QueryText, UpdateSourceTrigger=PropertyChanged}"
         b:ImeTextBinding.UpdateWhileComposing="True" />

이걸 모든 TextBox 에 붙이지는 않았다. 스니펫 제목이나 본문을 고치는 칸은 조합 중간값이 ViewModel 에 늦게 가도 아무 문제가 없다. 입력하는 즉시 결과가 바뀌어야 하는 두 검색창에만 붙였다.

걱정했던 건 조합이 끊기는 일이었다. 조합 중에 원본을 건드리면 그 값이 다시 TextBox 로 돌아오면서 입력기가 조합을 놓치지 않을까. 한글 입력기로 직접 쳐 보며 확인했다. '전역' 을 조합하는 동안 결과는 비어 있었고, 예전처럼 Git 스니펫이 끼어들지 않았다. '로그' 를 치는 동안에는 '그' 를 조합하는 중에 이미 「도커 컨테이너 로그 보기」가 올라왔다. 조합이 끊기는 일도 없었다.

입력하지 않아도 채워지는 변수

다시 0.3.0 의 본론으로 돌아간다. 개발기 4편의 변수는 모두 사람이 채우는 값이었다. 복사하면 입력 창이 뜨고, 컨테이너 이름이나 포트를 친다. 그런데 스니펫을 쓰다 보면 사람이 칠 필요가 없는 값이 자주 나온다. 백업 파일 이름에 붙일 오늘 날짜, 지금 클립보드에 있는 내용, 내 컴퓨터 이름, 환경 변수 값. 이런 걸 매번 입력 창에서 치는 건 기능이 거꾸로 된 것이다.

문법은 기존 변수에 $ 하나를 붙였다. {{날짜}} 는 물어보는 변수, {{$날짜}} 는 알아서 채우는 변수다.

표기 채워지는 값
{{$date}} {{$time}} {{$datetime}} 지금 날짜·시각. {{$date:yyyyMMdd}} 처럼 .NET 날짜 형식 지정
{{$timestamp}} 유닉스 시각(초), {{$timestamp:ms}} 는 밀리초
{{$clipboard}} {{$user}} {{$computer}} 클립보드 텍스트, 사용자 이름, 컴퓨터 이름
{{$guid}} 새 GUID, {{$guid:N}} 처럼 형식 지정
{{$env:이름}} Windows 환경 변수
{{$그밖의이름}} 사용자가 정한 전역 변수

$날짜, $시각, $클립보드, $사용자, $컴퓨터 같은 한글 별칭도 받는다. 파서는 거의 그대로다. 4편의 파서가 {{ 와 }} 사이를 읽은 뒤, 첫 글자가 $ 면 떼어 내고 SystemVariableSegment 를 만든다. 이름 규칙과 첫 콜론에서 나누는 규칙은 같다. 그래서 {{$시각:HH시 mm분}} 처럼 인자에 공백이 들어가도 된다.

값을 채우는 쪽은 switch 하나다.

public string? Resolve(string name, string? argument) => name.ToLowerInvariant() switch
{
    "date" or "날짜" => Format(argument, DefaultDateFormat),
    "time" or "시각" or "시간" => Format(argument, DefaultTimeFormat),
    "clipboard" or "클립보드" => _clipboard.Value,
    "guid" => FormatGuid(argument),
    "env" => string.IsNullOrEmpty(argument) ? null : context.GetEnvironmentVariable(argument) ?? string.Empty,
    _ => _globals.TryGetValue(name, out var value) ? value : argument,
    // datetime, timestamp, user, computer 생략
};

null 을 돌려주면 원문 {{$…}} 을 그대로 출력한다. 4편에서 규칙에 맞지 않는 중괄호를 오류로 만들지 않고 그대로 두기로 한 것과 같은 판단이다. {{$dtae}} 처럼 이름을 잘못 쓰면 빈 값이 아니라 원문이 그대로 붙여 넣어지니, 붙여 넣은 자리에서 오타가 바로 보인다.

이 해석기는 복사 한 번에 하나씩 만든다. 그 안에서 시각은 만들 때 한 번 잡아 두고, GUID 도 하나만 만든다. {{$guid}} 가 두 번 나오면 두 곳에 같은 값이 들어간다. 입력 변수와 자동 변수가 섞인 스니펫은 입력 창이 뜨는데, 그 창의 미리보기와 실제 복사 결과가 같은 해석기를 쓰니 미리보기에서 본 시각과 복사된 시각이 다를 일도 없다. 자동 변수만 있는 스니펫은 물어볼 게 없으니 입력 창 없이 바로 복사된다.

클립보드는 순서가 중요했다. {{$clipboard}} 를 채우려면 클립보드를 읽어야 하고, 복사는 그 클립보드를 덮어쓴다. 그래서 클립보드 읽기는 Lazy 로 감싸 처음 필요할 때 한 번만 읽고, 치환은 클립보드에 쓰기 전에 끝낸다. 스니펫에 {{$clipboard}} 가 없으면 아예 읽지 않는다.

환경 변수는 세 군데를 본다

{{$env:이름}} 은 처음에 Environment.GetEnvironmentVariable(name) 한 줄이면 될 줄 알았다. 그런데 이 메서드가 보는 건 Cliply 프로세스의 환경 블록이다. 프로세스의 환경 변수는 시작할 때 부모에게서 복사해 온 것이고, 그 뒤로는 바뀌지 않는다. Cliply 는 트레이에 상주하는 앱이고, Windows 시작 시 자동 실행을 켜 두면 로그인할 때 떠서 끌 때까지 그대로 있다. 그동안 사용자가 새 환경 변수를 등록해도 Cliply 의 환경 블록에는 없다.

name => Environment.GetEnvironmentVariable(name)
        ?? Environment.GetEnvironmentVariable(name, EnvironmentVariableTarget.User)
        ?? Environment.GetEnvironmentVariable(name, EnvironmentVariableTarget.Machine),

그래서 프로세스에서 못 찾으면 사용자 범위, 그다음 시스템 범위를 차례로 본다. 이 두 범위는 레지스트리에 저장된 값을 읽으니, Cliply 를 켠 뒤에 추가한 변수도 보인다. 세 군데 모두 없으면 빈 값을 넣는다.

같은 버전에 환경 변수 예시 스니펫도 넣었다. 그중 값 보기 예시의 본문은 $env:{{변수명:PATH}} 인데, 여기서 $env: 는 Cliply 가 아니라 PowerShell 의 문법이고 {{변수명:PATH}} 만 입력 변수다. 중괄호 바깥의 $ 는 Cliply 가 건드리지 않는다. 파일 이름 뒤에 {{$date:yyyyMMdd}} 를 붙여 백업하는 Copy-Item 같은 날짜 예시도 함께 넣었다.

원래 예시는 빈 DB 에만 넣었으니 이미 쓰던 사람은 새 예시를 받을 길이 없었다. 그렇다고 시작할 때마다 넣으면 지운 예시가 되살아난다. 예시마다 언어별 고정 Id 가 있고 삭제는 행을 남기는 소프트 삭제라는 점을 이용해, 새로 추가된 예시는 어느 언어의 Id 로도 들어간 적이 없을 때만 넣는다.

전역 변수는 이름표가 달린 값

마지막 줄의 _ => 가 전역 변수다. 기본 제공 이름이 아니면 설정 창에서 사용자가 정해 둔 이름=값 목록에서 찾는다. {{$서버}} 라고 써 두면 여러 스니펫이 같은 서버 주소를 쓰고, 주소가 바뀌면 설정 창에서 한 번만 고치면 된다. 전역 변수에 그 이름이 없으면 인자를 기본값으로 쓴다. {{$서버:localhost}} 는 전역 변수가 없을 때 localhost 가 된다. 인자도 없으면 원문 그대로다.

이름이 기본 제공 이름과 겹치면 안 된다. date 라는 전역 변수가 있으면 {{$date}} 가 무엇인지 애매해진다. 그래서 전역 변수의 이름은 변수 이름 규칙에 맞아야 하고, date, 클립보드, env 같은 기본 제공 이름과 별칭은 대소문자를 무시하고 막는다. 같은 이름이 두 번 있는 것도 막는다.

전역 변수는 별도 테이블에 저장하고, 내보내기 파일에도 선택 항목으로 넣었다. 전역 변수가 없으면 항목 자체를 빼서 예전 버전의 파일과 모양이 같다. 가져올 때 같은 이름이 이미 있으면 5편에서 스니펫에 쓴 규칙을 그대로 따른다. 수정 시각이 더 최신인 쪽이 남는다.

다 만들고 보니 두 이야기가 같은 질문을 하고 있었다. 이 값은 언제 정해지는가. 날짜와 클립보드는 저장할 때가 아니라 복사하는 순간에 정해져야 했고, 검색어는 글자가 확정되는 순간이 아니라 화면에 보이는 순간에 정해져야 했다. 0.3.0 은 그 순간을 두 군데에서 바로잡은 버전이다.