Problem
Korean real estate taxes are calculated differently for acquisition, holding, transfer, gifts and inheritance, and the rates change. Every calculator had to follow the law exactly.
For people choosing a home, calculators alone weren't enough. But public data like transaction prices was hard to trust as is, because of reporting delays, canceled deals and direct-deal outliers.
Goals
- Offer more than 50 calculators, and update them automatically when tax rates change.
- Render on the server so pages show up well in search, and support mobile and dark mode.
- Collect transaction prices, addresses and building data, but cross-check them for reliability before showing them.
- Give people a reason to come back with a community and articles.
How I got there
Mar 2026
A shared calculation base first, then tax by tax
Instead of building each calculator separately, I built a shared calculation base first. On top of it I planned the order: brokerage fees and acquisition tax, holding and transfer tax, gift and inheritance tax, loan limits, units and dates, then finance and auction calculators, with the community and real estate data after that.
May 2026
Cross-checking public data
Comparing the transaction, address and geocoding APIs showed that no single source covered everything: geocoders have no lasting building ID, and the transaction data has no normalized address key. So I enriched records with road-name addresses and building registers, and linked transactions to addresses only when the building management numbers matched exactly.
Jul 2026
Reviewing the roadmap for gaps
Reviewing the feature roadmap, I confirmed there was still no layer that handles data quality. Rules for filtering out reporting delays, canceled deals and direct-deal outliers are still the next task.
How it works now
Public data is collected and checked, then shown through the calculators and the map.
- 1
It collects MOLIT real transaction prices and enriches them with road-name addresses and building registers. It also imports building footprints from the Ministry of the Interior and Safety.
- 2
Every collection run leaves a record, and transaction prices and road-name addresses are checked against each other.
- 3
Housing subscription notices are merged from 5 sources by priority, with duplicates removed.
- 4
A calculation engine that follows the tax law computes with Decimal. There are 66 calculators.
- 5
AI writes the complex summaries, which are cached for 7 days by a hash of the inputs.
- 6
An LLM drafts the articles, and only those that clear a score threshold are published automatically.


Key decisions
Auto-publish only drafts that pass a score threshold
- Why
- The LLM only writes drafts. Deterministic code handles publishing, retries and duplicate prevention. A draft is auto-published only if both its intro score and content score are 7 or higher and there are no review warnings. Drafts that fall short are held.
- Alternatives
- A person approves every draft. One setting can still switch to this mode today.
- Trade-off
- The scores are the LLM grading itself, so they can be biased.
Cache AI summaries, and fall back to rules on failure
- Why
- Calling the LLM on every page view is slow, and if the AI server goes down, the page goes blank. With the same inputs, the same summary is reused for 7 days, and if the AI server doesn't respond, a rule-based summary is shown.
- Alternatives
- Call the LLM directly on every page view
- Trade-off
- A summary up to 7 days old can show, and the fallback summary is less rich.
Clusters when zoomed out, markers when zoomed in
- Why
- Drawing every complex in the country at once is more than the browser can handle. The total count of complexes in view is kept in the clusters, and individual markers are drawn only when zoomed in.
- Alternatives
- Draw markers for every complex at once
- Trade-off
- When zoomed out, you can't tap an individual complex directly.
Hard problems
This includes problems I caught before release.
A year-over-year holding tax change that was always 0 KRW
At the time, an audit that checked 58 calculation engines against the law, one by one, found 4 P0 bugs. One was in the combined holding tax calculator: its year-over-year change was always 0 KRW, and it had no tax burden cap. It also found that the inheritance tax disability deduction estimated life expectancy as 80 years, and that the rental income tax expense rate came from a single example.
Why it was hard
0 KRW is a plausible value, so it didn't look like an error. And since the previous year's tax was fixed at 0, the result on screen was the same with or without the bug. It was the kind of bug you can only find by checking the formula line by line with the law beside you.
How I traced it
1. Does the change field actually compare anything?
It was subtracting the previous year's tax from itself.
2. Where does the previous year's value come from?
It was fixed at 0, and there was no way to input it at all.
3. Checked against the law, what's missing?
The tax burden cap in Article 122 of the Local Tax Act wasn't implemented.
4. Do other calculators have the same kind of problem?
The same method found more P0s in inheritance tax, rental income tax and capital gains tax. In capital gains tax, 8 inputs taken on the screen weren't used in the calculation.
Root cause
The holding tax engine didn't take the previous year's tax as input. It fixed it at 0 and computed the change as 'previous year's tax minus previous year's tax'.
Fix
It now takes the previous year's tax as input and applies a tax burden cap of 105% for a one-home household and 130% otherwise. The change is this year's tax minus the previous year's. In the same audit, I fixed inheritance tax to use Statistics Korea's life expectancy table with linear interpolation, and rental income tax to separate the simple and standard expense rates and compute deemed rent.
Before
previous_year_total = 0
"year_over_year_change": previous_year_total - previous_year_total,After
previous_year_total = self._to_int(self.inputs.get("previous_year_tax", 0))
tax_burden_cap_rate = 1.05 if is_one_household_one_home else 1.30
if previous_year_total > 0:
tax_burden_cap_amount = self.floor_to_won(previous_year_total * tax_burden_cap_rate)
if total_holding_tax > tax_burden_cap_amount:
tax_burden_cap_applied = True
total_holding_tax = tax_burden_cap_amount
"year_over_year_change": total_holding_tax - previous_year_total,Verification
- 3 holding tax tests (cap applies when the previous year's tax is given, API alias input, change equals the total when the previous year isn't given)
- Added 6 inheritance tax, 5 rental income tax and 6 capital gains tax tests
- All 302 audit tests pass
Prevention
- Every tax rate that was hardcoded now has a named constant and a legal source.
- Items outside this scope are logged separately as P3 so the next audit can pick them up.
- Splitting the housing tax burden cap by official-price bracket is left for the next audit.
Takeaway
A plausible value like 0 KRW doesn't look like an error, so tax calculations get checked against the law line by line.
Open tabs getting stuck on an error screen after a deploy
In a tab opened before a deploy, going to the calculators, community, real estate or My Page sections got stuck on an error screen, and 'Try again' didn't bring it back. It reproduced on every calculator right after a deploy.
Why it was hard
It didn't reproduce in a freshly opened tab. The error screen hid the cause behind a generic message, and the on-screen 'Try again' couldn't fetch files that were already gone.
How I traced it
1. Is it the API?
It happened in 4 groups of screens at once. I suspected the browser's file loading before the server.
2. What went missing?
When the deploy replaced the container, all the lazy-loaded chunk files from the previous build disappeared.
3. How can it recover?
The only way for an open tab to get the new build was a full reload.
4. How do I confirm it's fixed?
I used a headless browser to inject chunk 404s into a local production build on purpose. Before the fix, it got stuck on the error screen. After the fix, it reloaded once and then loaded normally.
Root cause
The 4 error boundaries (error.tsx) only logged the error and didn't check whether it was a chunk loading error.
- 1Open, holding the previous build's file listPrevious container
- 2Deploy, new container, previous chunks deleted
- 3Navigates, requests a previous chunk404
- 4Chunk loading error, stuck on the error screen
- 5(After the fix) Reloads once and gets the new buildServes the new chunks
Fix
On a chunk loading error, it reloads once. It keeps the last reload time in session storage and won't reload again within 30 seconds. If storage isn't available, there's no way to prevent a loop, so it doesn't auto-reload at all.
Before
useEffect(() => {
console.error("Calculator error:", error);
}, [error]);After
useEffect(() => {
if (reloadOnChunkLoadError(error)) return;
console.error("Calculator error:", error);
}, [error]);
// core of chunk-reload.ts
try { lastReloadAt = Number(sessionStorage.getItem(KEY)) || 0; }
catch { return false; } // without storage we can't prevent a loop, so don't reload
if (Date.now() - lastReloadAt < 30_000) return false;
sessionStorage.setItem(KEY, String(Date.now()));
window.location.reload();
return true;Verification
- 8 unit tests (detection by name and message, ignoring unrelated errors, reloading only once, before and after the cooldown, no storage)
- Injected chunk 404s with a headless browser and compared before and after the fix
- 1,332 frontend tests, type check, lint and build pass
Prevention
- A 30-second cooldown keeps reloads from repeating.
- Where session storage isn't available, it doesn't auto-reload.
Results
- Calculators
- 66
- Calculator audit
- 4 P0 bugs fixed, 302 tests pass
- Complex coordinates
- 173,602 of 190,857 obtained (91%)
- Backend tests
- 1,027 test functions
- Calculators, the transaction price map, housing subscription notices, a real estate wiki and a community all live on one site.
