명령어를 저장해 두어도 그대로 쓰는 경우는 드물다. 도커 로그를 보려면 컨테이너 이름이, 포트를 찾으려면 포트 번호가 매번 다르다. 복사해서 붙여 넣은 뒤 그 부분만 고치는 일이 생각보다 번거롭다. 커서를 옮기고, 지우고, 다시 친다.

그래서 본문에 변수 자리를 두게 했다.

docker logs -f --tail {{줄수:100}} {{컨테이너}}

이 스니펫을 복사하면 입력 창이 뜬다. 줄수에는 기본값 100이 들어가 있고, 컨테이너만 치고 Enter를 누르면 치환된 명령어가 클립보드에 들어간다.

변수 입력 창: 위쪽은 치환 결과 미리보기, 아래는 변수별 입력칸

문법은 세 가지만

표기 의미
{{이름}} 변수
{{이름:기본값}} 기본값이 있는 변수
\{{ 치환하지 않고 {{ 그대로

규칙을 더 늘리지 않았다. 조건문이나 반복 같은 템플릿 엔진 기능은 명령어 노트에 필요 없다. 대신 세 가지 규칙이 어떤 입력에서도 예측대로 동작하도록 경계 조건을 꼼꼼히 정했다.

  • 변수명은 한글·영문·숫자·_·-만. 앞뒤 공백은 무시한다 ({{ 포트 }} = {{포트}})
  • 기본값에는 콜론이 들어가도 된다 ({{주소:http://localhost:8080}} — 첫 콜론에서만 나눈다)
  • 닫히지 않은 {{나 규칙에 맞지 않는 이름은 그대로 출력한다
  • 같은 이름이 여러 번 나오면 한 번만 묻고 모두 바꾼다. 기본값은 처음 지정된 것을 쓴다

왜 "그대로 출력"인가

명령어 본문에는 중괄호가 원래 많다. 셸 스크립트의 ${VAR}, Go 템플릿, Jinja, PowerShell 스크립트 블록, JSON. 예를 들어 이런 도커 명령어를 저장한다고 하자.

docker inspect -f '{{.State.Status}}' {{컨테이너}}

{{.State.Status}}는 도커 자체의 Go 템플릿이다. 이름 규칙에 .이 없으므로 Cliply는 이것을 변수로 보지 않고 그대로 둔다. {{컨테이너}}만 변수가 된다. 만약 잘못된 문법을 오류로 처리했다면 이런 명령어는 저장할 수도, 복사할 수도 없었을 것이다.

규칙에 맞는 이름인데도 치환하고 싶지 않을 때만 \{{로 이스케이프하면 된다. 변수가 하나도 없는 스니펫도 복사할 때 이스케이프는 풀어 준다.

정규식 대신 한 글자씩 읽는 파서

처음 떠오르는 방법은 \{\{(.+?)\}\} 같은 정규식이다. 하지만 이스케이프, 닫히지 않은 괄호, 잘못된 이름 뒤에 오는 정상 변수까지 정규식 하나로 처리하면 읽기 어려워진다. 그래서 앞에서부터 한 글자씩 읽는 파서를 짰다.

while (i < text.Length)
{
    if (string.CompareOrdinal(text, i, EscapedOpen, 0, EscapedOpen.Length) == 0)
    {
        literal.Append(Open);
        i += EscapedOpen.Length;
        continue;
    }

    if (string.CompareOrdinal(text, i, Open, 0, Open.Length) == 0
        && TryReadVariable(text, i, out var variable, out var next))
    {
        // 지금까지 모은 글자는 LiteralSegment, 변수는 VariableSegment
        ...
        i = next;
        continue;
    }

    literal.Append(text[i]);
    i++;
}

TryReadVariable이 실패하면(닫는 }}가 없거나 이름이 규칙에 맞지 않으면) { 한 글자만 일반 글자로 넘기고 다음 위치부터 다시 읽는다. 그래서 {{잘못된 이름}} {{포트}}에서 앞쪽은 그대로, 뒤쪽 {{포트}}는 변수로 인식된다. 이름 검사에만 정규식을 쓴다.

[GeneratedRegex(@"^[가-힣ㄱ-ㅎㅏ-ㅣA-Za-z0-9_\-]+$")]
private static partial Regex NameRegex();

GeneratedRegex는 컴파일 타임에 정규식 코드를 생성해서 런타임 비용이 없다.

파싱 결과는 LiteralSegment와 VariableSegment의 목록이다. 이 목록에서 "입력받을 변수"(같은 이름은 한 번, 처음 나온 순서)를 뽑아 두고, Render(values)로 치환한다. 값이 없으면 기본값, 기본값도 없으면 빈 문자열이다.

이 파서는 Core 프로젝트에 있고 UI도 DB도 모른다. 그래서 단위 테스트가 쉽다. 테스트 이름이 곧 명세다.

변수가_없으면_그대로
같은_이름은_한_번만_입력받고_모두_치환
중복_변수의_기본값은_처음_지정된_것
기본값에는_콜론이_들어갈_수_있다
역슬래시_이스케이프는_중괄호_그대로
닫히지_않은_중괄호는_그대로_출력
규칙에_맞지_않는_이름은_그대로_출력
잘못된_여는_괄호_뒤의_정상_변수는_인식
여러_줄_본문

Phase 4에서 이 파서 테스트 25개를 포함해 28개를 추가했다.

입력 창: 손을 키보드에서 떼지 않게

입력 창의 목표는 빠른 검색과 같다. 마우스 없이 끝낸다.

키 동작
Enter 다음 칸으로. 마지막 칸이면 복사
Ctrl+Enter 지금 바로 복사
Esc 취소

칸에 포커스가 들어가면 내용 전체를 선택한다. 기본값이나 지난번 값이 들어 있어도 바로 덮어 칠 수 있다. 위쪽에는 치환 결과를 실시간으로 보여 준다. 무엇이 복사될지 눈으로 확인하고 Enter를 누른다.

창은 빠른 검색 팝업과 같은 방식으로 마우스 커서가 있는 모니터 가운데에 뜬다. 복사가 끝나면 빠른 검색을 호출하기 전의 창으로 포커스를 되돌린다.

지난번에 쓴 값을 기억한다

같은 스니펫은 대개 같은 대상에 반복해서 쓴다. 오늘 web-api 컨테이너 로그를 봤다면 내일도 볼 가능성이 높다. 그래서 변수별 마지막 입력값을 DB에 저장한다.

테이블 SnippetVariableValues
SnippetId 스니펫 (복합 PK)
Name 변수명 (복합 PK)
LastValue 마지막 입력값
UpdatedAt 수정 시각

입력칸의 초기값은 마지막 입력값 → 기본값 → 빈 값 순서로 정한다. 기본값은 "처음 쓰는 사람을 위한 값", 마지막 입력값은 "나에게 맞춘 값"이다. 둘 다 있으면 마지막 입력값이 이긴다. 기본값은 칸 아래에 작게 표시해서 원래 값이 무엇이었는지 알 수 있게 했다.

값은 복사에 성공했을 때만 저장한다. 입력하다가 Esc로 취소하거나 클립보드가 잠겨 실패하면 저장하지 않는다.

if (!await clipboard.SetTextAsync(text))
{
    return CopyResult.ClipboardBusy;
}

if (values is not null)
{
    await repository.SaveVariableValuesAsync(snippet.Id, values);
}

await repository.RecordUseAsync(snippet.Id);
return CopyResult.Copied;

복사는 한 곳에서

복사는 빠른 검색의 Enter, 더블클릭, 관리 창 목록의 Ctrl+C, 관리 창의 복사 버튼에서 일어난다. 이 모든 경로가 SnippetCopyService.CopyAsync 하나를 거친다. 변수 입력, 클립보드 쓰기, 입력값 저장, 사용 기록(UseCount, LastUsedAt)이 항상 같은 순서로 일어난다. 경로마다 따로 구현했다면 어느 한 곳에서 사용 기록을 빠뜨렸을 것이다.

정리

  • 변수 문법은 {{이름}}, {{이름:기본값}}, \{{ 세 가지로 끝냈다.
  • 규칙에 맞지 않는 {{는 오류가 아니라 그대로 출력한다. 명령어에는 원래 중괄호가 많다.
  • 이스케이프와 복구가 섞인 문법은 정규식보다 한 글자씩 읽는 파서가 읽기 쉽고 테스트하기 쉽다.
  • 입력칸 초기값은 마지막 입력값 → 기본값 → 빈 값. 값은 복사 성공 시에만 저장한다.
  • 복사 경로는 서비스 하나로 모은다.

다음 글에서는 2차 동기화를 대비해 처음부터 넣은 스키마 결정과, 그 결정이 JSON 가져오기에서 어떻게 쓰였는지를 다룬다.


Cliply 개발기

  1. 자주 쓰는 명령어를 단축키 한 번으로 — 「Cliply」 개발기
  2. SQLite FTS5 trigram으로 한글·부분 문자열 검색하기
  3. 어느 창에서든 단축키로 뜨는 WPF 검색 팝업
  4. {{이름:기본값}} 변수 치환 파서와 입력 창 (이 글)
  5. 동기화를 대비한 스키마 — GUID, UpdatedAt, 소프트 삭제와 JSON 가져오기 (10월 7일 공개)
  6. 트레이 상주 앱 마무리 — 단일 인스턴스, 한/영 전환, 70MB 단일 exe (10월 7일 공개)