BEOMSEOK KIM

@bswebdev

I make the numbers nobody would catch if they were wrong, out of sources that can't be trusted.

Backend Engineer

Based in Korea · 3 yrs 4 mos backend · Japan is my first choice — I relocate as soon as an offer is signed.

About

Systems rarely break on the technology. They break on the trade-offs nobody made explicit. So I design the decisions before the technology.

For over three years I've worked in domains where the output goes straight into a client's external reporting — ESG emissions, carbon reduction, life-cycle assessment. My part was the stretch that takes sources you can't trust — paper forms, ERP ledgers, waste-transfer manifests — and turns them into official numbers. Double-counting, silent failures, grain distortion: I met all three in production and fixed them.

Making the probabilistic (LLMs) deterministic, and the precarious (migrations) lossless.

So far I've done this where accuracy is hard. I want to keep doing the same work where the conditions get one notch stricter — at a size no one person can hold in view, with the deadline set from outside and concurrency on top.

Focus

  • Consistency under concurrency
  • Settlement & idempotency
  • Event-driven architecture
  • Domain modeling

Also work across

  • Data integrity & grain
  • DB & runtime migration
  • LLM pipeline reliability
  • Enterprise integration (SAP, LDAP)
  • API & DB design
  • Cache & queue design

Engineering Principles

  1. 01

    Appropriate design is the goal — not the technology.

    On the ESG platform, when a Java/Spring client raised doubts about whether Node.js was a fit, I carried the decision not on preference but on documented evidence — there is a lot to multiply, but each calculation is plain multiplication, which makes the workload I/O-bound rather than CPU-bound. For the domain I analyzed OpenLCA, borrowed the data schema only, and designed the calculation logic myself.

  2. 02

    AI is a tool. The operational system is the product.

    The OCR pipeline uses AI only where deterministic rules can't reach, and keeps it verifiable — grounded in two OCR engines' confidence and diffs, with low-confidence cells surfaced for a human final check. Never a black box.

  3. 03

    "Done" means stable in operation — not "shipped."

    I shipped handling for a serverless connection failure — then confirmed in production that it wasn't actually firing. A shared error translation was erasing the original message the matcher depended on. It wasn't finished until I found the cause again and fixed it.

  4. 04

    I don't claim an improvement I didn't measure.

    Improving load performance, no way to measure it existed — so I built the bench harness first, measured per tier, and wrote down the measurement conditions before drawing any conclusion. Going the other way: when I found a figure in an older document that had been written without instrumentation, I went back for the evidence and withdrew it.

Selected Work

AI-Powered OCR Data Pipeline

carbon-reduction program · supporting documents

The extracted value becomes the official figure. Quietly wrong, and nobody finds out.

  • whole table → diff cells
  • double-counting → grain fixed to (file, doc date)

Extracts product-usage data (e.g. cement volume) from factory forms. Irregular layouts and merged cells were beyond rule-based parsing, so I designed a pipeline that hands two OCR engines' results, confidence scores, and diffs to an AI layer for correction and adjudication — making AI a judgment component in the system, not just automation.

  • NestJS
  • GraphQL
  • AWS
  • Redis
  • Supabase
  • OpenAI
  • Clova OCR
  • AWS Textract
  • Claude Code
Result
Redesigned the AI payload from whole tables down to diff cells. The old shape handed both OCR results over in full and asked the model to rewrite the finished table; the new one sends two header rows and the disagreeing cells, and the backend does the merge. A per-column sample cap keeps the prompt from growing linearly with the table. On top of that: confidence-based skipping of the second OCR, model selection by document complexity, and caching of re-analysis.
Takeaway
Where deterministic rules couldn't reach, an AI judgment call filled the gap — but I removed the logic that treated the model's self-reported confidence as a trust value, because nothing backs it. Confidence comes only from the vendor. And when the call is uncertain the pipeline doesn't skip; it invokes both engines. Ambiguity falls toward the expensive side, by design.

Enterprise ESG (LCA) Data Platform

for a large petrochemical manufacturer · small backend team

A million rows, 80 at a time, is 12,500 round trips.

  • 1M rows in 23.5s (dev env)
  • 12,500 round trips → 1

Rising demand from overseas customers for environmental data had outgrown Excel and manual work. I designed the ingestion for 10+ external system interfaces — SAP PI (SOAP), in-house utility and EHS systems, the national waste-transfer system, city gas — as a staging / refined / fact three-layer model, and coordinated data specs and server setup directly with the client's infra team.

  • NestJS 11
  • MSSQL
  • SAP PI (SOAP)
  • LDAP/AD
  • Windows Server
  • Nginx
  • GitHub Actions
  • Prometheus
  • Grafana
  • Loki
  • SonarQube
  • Claude Code
Result
Switched SAP CO (cost ledger) loading from 80-row chunked INSERTs to MSSQL Bulk Copy: 1M rows in 23.5s. No way to measure it existed, so I built the bench harness alongside (dev environment, synthetic data). Upstream systems retroactively correct and resend past data; I generalized that into a temporal + natural-key conditional MERGE and applied it across all 10 of the 17 staging sources that carry a natural key.
Takeaway
The bottleneck wasn't ORM overhead — it was round trips. A million rows at 80 per call is 12,500 of them. But Bulk Copy is all-or-nothing, which makes it hard to tell which row failed. I could accept that cost only because the target was a pure insert and re-running was safe, so I didn't apply it where an upsert was needed. The correction history was a separate call: the four sources whose natural key isn't unique were left out of the conditional MERGE with the reason recorded, and for one of them I gave up on row-level correction and kept every period snapshot by point in time instead.

pgloader moves rows. It doesn't move a changed data model.

  • MySQL → PostgreSQL
  • always-on server → serverless
  • shared pooler → dedicated

A live B2B service had outgrown its stack, so I led a migration that changed the data model — not just the host: MySQL→PostgreSQL, and an always-on server→serverless (Vercel/Supabase).

  • MySQL
  • PostgreSQL
  • Supabase
  • Vercel
  • pgloader
Result
Worked through every query-dialect difference — identifier quoting, date-format functions, JOIN semantics — and hand-wrote the structural transforms pgloader couldn't do. The cutover ran inside a night-maintenance window; afterwards I fixed value errors found in emission-factor calculation and in table references. The connection failures that followed traced to the managed DB's shared connection pooler, and I moved to a dedicated pooler.
Takeaway
The risk in a migration isn't the copy — it's the parts that don't automate. pgloader moves rows; it doesn't move a changed data model. There is something I failed to leave behind here, though: no full-reconciliation report or checksums in writing, and while there's a record of defects found and fixed, how they were found never became a documented procedure. Next time I'd build the expected-value set for the core aggregate functions before the cutover.

I shipped the fix, and the fix was not running.

  • error translation → original preserved
  • retry exhausted → dedicated code

Production was throwing intermittent connection timeouts. While a serverless function is frozen its JS timers stop, so dead connections stay in the pool; after thaw one gets checked out and drops. That was my read. I added a retry interceptor and graceful degradation, and shipped.

  • NestJS
  • PostgreSQL
  • Supabase
  • Vercel
  • Pino
Result
After shipping, I confirmed the interceptor wasn't actually firing. The project's shared error-translation layer was rewriting DB errors into a generic code and erasing the original message, so the pattern match never matched. I made stale-connection errors propagate untranslated, and gave a still-failing retry its own code so the frontend can tell the user something useful.
Takeaway
Had I stopped at root-cause analysis and a code change, this would still be broken. Two things pointed at the app rather than the platform: the stack trace sat on the existing-pool-connection path, not on new connection creation, and external clients connected fine at the same time. Conversely, a window in the same period where external connectivity was cut entirely is one I couldn't attribute — I stated what I had verified and closed there.

ESG Diagnostics Platform Backend

for a regional bank · SME customers

A coupon sent twice is money.

  • timestamp ID → record UUID
  • send failures → branched by result code

Led the backend for an ESG diagnostics and consulting platform aimed at a regional bank's SME customers — diagnostic scoring, a greenhouse-gas calculator, and reward-coupon issuance.

  • NestJS
  • GraphQL
  • PostgreSQL
  • Vercel
  • Cloud SMS/email API
Result
Found that the coupon transaction ID was timestamp-based and could collide under concurrent issuance, and moved it onto the reward record's UUID to get idempotency. Send failures are now branched by result code, with an alert to the dev team. The greenhouse-gas calculation got divide-by-zero guards, significant-figure rounding, and annual conversion.
Takeaway
A coupon sent twice is money. That a timestamp can order things but can't guarantee uniqueness is something I learned after I'd already built it that way — there was no reason to mint a new identifier when a record with guaranteed uniqueness already had one. The calculator is the same shape: the user sees a single number, and if it's quietly wrong behind that number, there's no way to notice.

Evidence Authority & Team Operating Rules

the same petrochemical LCA platform

All 26 tests passed. The values were wrong.

  • spec ↔ fixture → source data
  • 27 decisions recorded as ADRs
  • 250 concurrent · p95 3s (dev env)

I once finished a feature with the aggregation grain wrong. I built the test fixtures from the grain written in the spec, then verified the implementation against those fixtures. The two confirmed each other and nobody opened the source data. All 26 tests were green.

  • ADR
  • CI consistency checks
  • Claude Code
Result
I split the sources by what each one is qualified to answer — what the data actually looks like is a question only the source can answer; how it behaves right now goes to the code; what we agreed to build goes to the contract; why we did it that way goes to the ADR. When code and contract disagree, the contract wins. Which means the code is the bug. A factual claim carries a source grade (verified directly / relayed / hypothesis), and relayed facts and hypotheses are never used as grounds for a decision. Consistency across docs, DDL, ERD, and entities runs through a CI checker instead of human eyes, and 27 architecture decisions were recorded as ADRs. Performance sits under the same rule — a load scenario assuming 250 concurrent connections and p95 under 3s is written up front alongside the functional tests, and the work isn't finished until a pass/fail report exists (dev environment). The rules are kept in a domain-name-stripped form so another project can copy them as they are.
Takeaway
Most of these rules came after I got something wrong. "A row count refutes no assumption" came out of judging the grain from a COUNT alone. "Zero missing values is not evidence of completeness" came after I concluded the data was complete on the basis of zero nulls, with only 12 of 16 formulas loaded. I didn't set the rules first and then work. And these aren't documents for people — they're instructions given to AI agents. Since a large share of the implementation goes through tools, what I have to control isn't the code but what counts as grounds. Now I attach the source grade before I let a new fact stand.

prompt is suggestion, graph is law.

  • 8B local · 16GB · zero API cost
  • Phase 0 end-to-end
  • gateway: 0 deps · single binary

A multi-agent planning pipeline that turns a stochastic, slow 8B local model into something reliable and cheap enough to actually use — a founder's rough idea into a PRD and architecture doc on a 16GB MacBook, at zero API cost. An 8B model is two problems at once: unreliable, and expensive to run. So I clamp it — a separate critic, a save-validation gate, and state isolation wrap each probabilistic step in a deterministic shell — and I squeeze it, instrumenting every model call to measure and cut what buys nothing rather than guess.

  • LangGraph
  • Ollama (qwen3:8b)
  • deepseek-r1:8b
  • FastAPI
  • SqliteSaver
  • LangSmith
  • Go (stdlib)
Result
Phase 0 (idea → PRD → architecture) runs end-to-end, fully local. Scope is deliberately capped at Phase 0 as a finished product, given an 8B model's code-generation ceiling.
Takeaway
Squeeze is measured, not guessed. Stripping `<think>` from the reply doesn't buy the time back — on-device, every token generated is already wall-clock, and every context window is RAM, so I instrument each model call instead of guessing. Clamp's counterpart: state isolation runs in two directions. Outbound (a subagent's internal turns polluting the parent thread) is solved with separate state and a RemoveMessage finalize. Inbound (the subagent's LLM still seeing parent messages) is a deliberate trade-off, compensated with a structured briefing packet — not hidden. Determinism comes from the graph, not the prompt.

Phase 1–6 (build / QA / deploy) exist as persona designs — roadmap, not yet wired. The gateway is 610 lines and has never taken production traffic; I have no professional Go experience. Latency instrumentation (/metrics, TTFT) isn't attached yet, which is why no performance figures appear here.

Shipped

Try!Hanzi

A Korean–Japanese–Chinese hanzi comparison platform over a 10,000-character database — compare readings and meanings across three languages, with automatic "false friends" detection for look-alike characters that mean different things. Live with real users.

Try!Hanzi character page for 人, comparing Korean, Japanese, and Chinese readings and meanings side by side
  • Next.js 16
  • TypeScript
  • Tailwind
  • next-intl
  • PostgreSQL
  • Prisma
  • Redis

Jamming

A mobile-web (PWA) platform matching amateur musicians in Korea for jam sessions — hosts open sessions by region, genre, and skill level; players apply by instrument and get approved. Kakao OAuth, notifications, and song/venue search built in, with a four-layer test suite.

Jamming session list on mobile, with the date strip and region filter
  • Next.js 16
  • TypeScript
  • Supabase
  • Zustand
  • TanStack Query
  • Playwright
  • Vercel
Live ↗Code ↗Updated 2026·07

Writing