내 환경에 맞는 핸드드립 레시피 찾기

10 min

맛있어보이는 원두를 사서 집에서 그라인딩해서 원하는 방향으로 추출해서 내려 마시는 핸드드립을 좋아한다

처음에는 어려웠지만 수백번 시행착오를 겪다 보니 취향대로 뽑는 노하우가 생겼다. 어떤 날은 쫀쫀하게 속이 불편한 날은 티라이크하게, 여름에는 청량하게 원하는 뉘앙스로 꽤 안정적으로 뽑아 먹을 수 있게 됐다.

레시피를 쓰더라도 원두와 원두 상태에 따라 조정해서 내려 먹는데 핸드드립을 처음 시작할 때는 분쇄도 맞추는 것부터가 고난이었다. 여기에 물온도까지 동시에 조절해버리면 뭐 때문에 맛이 이상한지 아무런 것도 알 수 없게 된다.(개발과 똑같다^^)

생각해보면 같은 레시피를 봐도 왜 쓴맛이 났지?? 같은 걸 잘 해석하지 못해서 레시피를 무작정 갈아타거나 도구를 바꾸곤 했다 ㅎㅎ. 그렇게 여러 시도를 하다보니 나만의 규칙이 조금씩 생겼다.

커피 유튜브 10편 보는 것보다 2~3번 추출하고 로깅&디버깅해보는게 훨씬 많은 걸 알 수 있었다.

앵커가 있으면 어떨까?

그러면서 gaze heuristic처럼 딱 한가지만 보고 레시피의 의도에 맞게 조율할 수 있으면 어떨까 싶었다. 나머지 변수들이 같이 따라올 수 밖에 없는 어떤 앵커 한두개만 관측해서 보정하도록..!

많은 사람들이 “하리오V60 / 15g / 물240g 92도 / 3차 푸어”까지는 잘 따라라는 것 같다. 그런데 분쇄/푸어 방식/디개싱/필터/물 차이로 맛이 많이 바뀐다. 심지어 습도도 영향을 준다. 그래서 레시피를 그대로 따라해도 실패하는 경우가 있다. 자신의 환경에 맞게 레시피를 조율하는게 중요하다. 레시피의 앵커(기준?)를 통해 결과를 관찰 가능한 목표로 바꾸고 틀어졌을 때 다음 번에 무엇을 바꾸면 되는지 자동으로 제시할 수 있으면 좋겠다 싶었다. 사람은 타이머와 눈으로 확인 가능한 1~2 가지, 앵커만 보는거다(나는 물이 다 빠지는 시간을 자주 본다).

상상을 해보자면, 사용자는 딱 12개만 입력한다. 물이 다 빠진 시간과 주간적인 맛 점수(아니면, 쓴맛신맛 슬라이더ui로?) 그럼 프로그램이 다음 추출용 패치를 생성해준다. -> 오늘은 1

너무 빨랐으니 grinder 클릭크를 2개 더 쪼이고 bloom은 +5초!

이게 3~5회 누적되면 사용자가 자기 환경에 맞는 개인화된 레시피를 가져갈 수 읶게

원두가 완전히 다른 걸 써도 tags(예: +natural +low_degas)만 바꿔서 다시 컴파일하고 조율할 수 있으면 좋겠다. 앵커 1~2개만 맞추면 일정 퀄리티가 보장되는 레시피가 나온다는게 핵심인 것 같다.

관측 가능한 앵커로 단순화하면 레시피를 그대로 복붙하다가 실패하는 걸 막을 수 있고 레시피 작성자의 의도를 살리면서도 자신의 환경에 맞는 레시피를 생성할 수 있게 될 것 같다.

document        = { line } ;

line            = ws? ( comment
                      | directive
                      | assignment
                      | block_header
                      | block_item
                      | blank ) newline ;

blank           = "" ;
comment         = "#" { any_char - newline } ;

directive       = "@profile" ws ident { ws tag } ;
tag             = "+" ident ;

assignment      = key ":" ws? value ;
key             = ident ;

block_header    = "pours:" | "anchors:" | "tune:" | "observe:" ;

block_item      = "-" ws ( pour_item | anchor_def | tune_rule | observe_def ) ;

pour_item       = time ws delta ws mode { ws attr } ;
anchor_def      = ident ws? "=" ws? measure ws anchor_kv { ws anchor_kv } ;
tune_rule       = "when" ws expr ws "->" ws patch { ";" ws patch } ;
observe_def     = ident ws? "=" ws? atom ;

measure         = func_call ;
func_call       = ident "(" [ args ] ")" ;
args            = atom { "," ws? atom } ;

anchor_kv       = ident ws? "=" ws? atom ;

patch           = path ws op ws atom ;
path            = ident { "." ident } ;
op              = "=" | "?=" | "+=" | "-=" | "*=" | "/=" ;

expr            = or_expr ;
or_expr         = and_expr { ws "||" ws and_expr } ;
and_expr        = unary { ws "&&" ws unary } ;
unary           = "!" unary | "(" ws? expr ws? ")" | predicate | comparison ;

predicate       = "tag" "(" ident ")"
                | "obs" "(" ident ")"
                | "target" "(" ident ")"
                | "tol" "(" ident ")"
                | "delta" "(" ident ")"
                | "abs" "(" atom_or_ref ")" ;

comparison      = atom_or_ref ws cmp ws atom_or_ref ;
cmp             = "==" | "!=" | "<" | "<=" | ">" | ">=" ;

atom_or_ref     = atom | predicate | path ;

attr            = ident "=" atom ;

atom            = quoted | bare ;
quoted          = "\"" { any_char - "\"" } "\"" ;
bare            = { any_char - ws - newline - ";" } ;

value           = { any_char - newline } ;

time            = mm ":" ss | ss ;
mm              = digit | digit digit ;
ss              = digit digit ;

delta           = "+" amount ;
amount          = number unit? ;
number          = digit { digit } [ "." digit { digit } ] ;
unit            = "g" | "ml" | "C" | "um" | "c" | "d" ;

mode            = "C" | "SC" | "D" | "M"
                | "circle" | "small" | "dot" | "mix"
                | "wait" ;

ident           = alpha { alpha | digit | "_" | "-" } ;

ws              = " " | "\t" ;
newline         = "\n" | "\r\n" ;
alpha           = "A".."Z" | "a".."z" ;
digit           = "0".."9" ;
any_char        = ? any unicode scalar ? ;

핵심 개념

  • Anchor: 관측 가능한 값(시간/무게/온도/기타)의 목표치. ex) 센터 푸어 레시피는 물이 완전히 빠지는 시점(drain_end)이 2
    한다
  • Observation: 실제로 나온 측정값. ex) 이번 추출에서 drain_end는 1
  • Tuning rules: 관측이 목표에서 벗어났을 때, 다음 추출(또는 즉시) 무엇을 얼마나 바꿀지. 레시피 설계자의 휴리스틱이 많이 들어갈 것 같다. ex) drain_end가 2
    빨리 끝나면 grind를 10% 굵게

문법 설명

앵커/관측 관련

  • anchor / anchors:
    • 레시피의 “기준 신호(앵커)” 목록 블록. 목표치(target)와 허용오차(tol)를 둠
    • A_dd (예시): Anchor ID
      • 의미: “물이 완전히 빠지는 타이밍/드로우다운 관련 앵커”
    • A_sens (예시): Anchor ID
      • 의미: 맛/향/바디/산미 등 주관 점수(예: 1~10)를 앵커로 둔 것
  • measure = time(drain_end)
    • 앵커가 무엇을 측정하는지 정의
    • time(…)는 “시간 측정”이라는 의미의 측정 함수
  • drain_end
    • “완전히 배드가 비고 물줄기가 끊기는 시점” 같은 이벤트 이름(도메인 이벤트)
    • drawdown_end, finish 등으로 표준화해도 됨
  • target=2
    • 앵커의 목표값
  • tol=0
    • 허용오차(±5초). 오차 범위 안이면 튜닝 안 함
  • observe:
    • 실제 관측값을 적는 블록. (이번 추출 결과 기록)
  • obs(A_dd)
    • 해당 앵커의 관측값을 가져오는 함수(표현식에서 사용)
  • target(A_dd), tol(A_dd)
    • 앵커 정의에 있는 목표/허용오차를 가져오는 함수
  • delta(A_dd)
    • 관측값 - 목표값 (부호 있는 차이). 예, 1
      vs 2
      -0
  • abs(x)
    • 절댓값

튜닝 규칙 관련

  • tune:
    • “조건 → 조정(패치)” 규칙 블록
  • when … -> …
    • 조건이 맞으면 오른쪽의 패치들을 적용

패치 연산자

  • = 강제 설정
  • ?= 값이 없을 때만 설정(명시값 존중)
  • +=, -= 더하기/빼기(도징 +1g 같은 것)
  • *= 배수(“10% 굵게” = grind *= 1.10)
  • /= 나누기(배수의 반대)

도메인 키워드(레시피 파라미터)

  • dose 도징량 (g)
  • ratio 물
    비율(1
    )
  • total 총 물량(g)
  • temp 물 온도(C)
  • grind 분쇄도(um/click/dial 등)
  • bloom 뜸(물량/방식/홀드 포함)
  • pours 푸어 스케줄
  • mode 푸어 방식 토큰 (C, SC, D, M 등)
  • end 추출 종료 목표 시간(레시피 설계 목표)

Comments

Join the conversation on Mastodon

Loading comments...