๐๐ป myapp18์์๋ ๊ฐ๋ณ๊ธ ํ์ธ๊ณผ ํ์๋ผ์ธ์ ๊ตฌํํฉ๋๋ค.
In myapp18, we implement the viewing of individual posts and the timeline.
๐๐ป ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฑ๋ฅ ํฅ์์ ์ํด์ ์ธ๋ฑ์ค๋ฅผ ์ถ๊ฐํฉ๋๋ค.
Indexes are added to improve database performance.
๐ ์ ์ฒด ํ๋ก์ ํธ ๊ตฌ์กฐ / Overall Project Structure
myapp18/
โโโ app/ (Next.js App Router)
โ โโโ .well-known/webfinger/route.ts -> webfinger
โ โโโ api/posts/route.ts -> Writing API
โ โโโ api/follow/route.ts -> Follow API(temporary)
โ โโโ api/timeline/route.ts -> Timeline API
โ โโโ users/[username]/
โ โ โโโ statuses/[id]/route.ts -> Indivisual Post
โ โ โโโ route.ts -> Acotr Information
โ โ โโโ followers/route.ts -> Followers List
โ โ โโโ following/route.ts -> Following List
โ โ โโโ inbox/route.ts -> Inbox
โ โ โโโ outbox/route.ts -> outbox
โ โโโ usersui/[username]/page.tsx -> Timeline UI
โ โโโ layout.tsx, page.tsx, globals.css
โ โโโ favicon.ico
โโโ lib/
โ โโโ ap.ts -> Follow Accept
โ โโโ db.ts -> DB connection
โโโ data/
โ โโโ keys/ -> private.pem, public.pem
โโโ data.sqlite -> Database
โโโ Caddyfile -> https
โโโ package.json
๐ ํ๋ก์ ํธ ์์(myapp18)
Project Start (myapp18)
npx create-next-app@latest
๐ SQLite์ค์น / Installing SQLite
cd ~/myapp18
npm install better-sqlite3
npm install -D @types/better-sqlite3
๐ DDNS,https์ค์ / DDNS,https settings
๐ ๋ฐ์ดํฐ๋ฒ ์ด์ค ์คํค๋ง ๋ณด์
Database schema supplementation
โ๏ธ ๋ฐ์ดํฐ ๋ฒ ์ด์ค ๋ถ๋ถ๊ณผ ์ฝ๋๋ฅผ ๋ณด์ํฉ๋๋ค.
I am refining the database components and the code.
โ๏ธ ์ธ๋ฑ์ค ์ถ๊ฐ / Add Index
— ์ธ๋ฑ์ค์ ๋ํ ์ถ๊ฐ ์ค๋ช
์ ๊ฐ์ฅ ์๋ ๋ถ๋ถ์ ์ค๋ช
์ ์ฐธ์กฐ ํ์ธ์
Please refer to the explanation at the very bottom for additional details regarding the index.
-- 1. posts ํ์๋ผ์ธ์ฉ (ํ์) / Posts for timeline (Required)
CREATE INDEX idx_posts_username_created ON posts(username, created_at DESC);
-- 2. inbox_posts ํ์๋ผ์ธ์ฉ (ํ์) / inbox_posts for timeline (Required)
CREATE INDEX idx_inbox_username_created ON inbox_posts(username, created_at DESC);
-- 3. inbox_posts actor ๊ฒ์์ฉ (์ ํ) / inbox_posts actor search term (optional)
CREATE INDEX idx_inbox_actor ON inbox_posts(actor);
โ๏ธ ์ธ๋ฑ์ค ์๋ ํ์ธ / Verify index operation
sqlite> EXPLAIN QUERY PLAN SELECT * FROM posts WHERE username='user1' ORDER BY created_at DESC;
QUERY PLAN
`--SEARCH posts USING INDEX idx_posts_username_created (username=?)
sqlite> EXPLAIN QUERY PLAN SELECT * FROM inbox_posts WHERE username='user1' ORDER BY created_at DESC;
QUERY PLAN
`--SEARCH inbox_posts USING INDEX idx_inbox_username_created (username=?)
sqlite>
๐ ๊ฐ๋ณ๊ธ ๋ผ์ฐํธ / Individual Post Route
โ๏ธ myapp18/app/users/[username]/statuses/[id]/route.ts
// myapp18โ
/app/users/[username]/statuses/[id]/route.ts
import db from '@/lib/db';
import { NextRequest, NextResponse } from 'next/server';
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ username: string; id: string }> }
) {
const { username, id } = await params;
// posts table
let post: any = db.prepare(
`SELECT * FROM posts WHERE id = ? AND username = ?`
).get(id, username);
// inbox_posts table
if (!post) {
// inbox๋ id๊ฐ ์ ์ฒด URL์ด๋ผ LIKE๋ก ์ฐพ๊ธฐ
// inbox uses the full URL as id, so we search with LIKE
post = db.prepare(
`SELECT * FROM inbox_posts WHERE id LIKE ? AND username = ?`
).get(`%${id}`, username);
}
if (!post) {
return new NextResponse('Not found', { status: 404 });
}
const base = process.env.NEXT_PUBLIC_BASE_URL || 'https://aloy-horizon.duckdns.org';
const url = `${base}/users/${username}/statuses/${id}`;
return NextResponse.json({
"@context": "https://www.w3.org/ns/activitystreams",
id: url,
type: "Note",
attributedTo: `${base}/users/${username}`,
content: post.content,
published: post.created_at,
to: ["https://www.w3.org/ns/activitystreams#Public"],
});
}
๐ ํ์๋ผ์ธ API / Timeline API
โ๏ธ myapp18/app/api/timeline/route.ts
// app/api/timeline/route.ts
// myapp18โ
import db from '@/lib/db';
import { NextResponse } from 'next/server';
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const username = searchParams.get('username') || 'user1';
const timeline = db.prepare(`
SELECT id, content, username, created_at, 'mine' as source
FROM posts WHERE username = ?
UNION ALL
SELECT id, content, username, created_at, 'inbox' as source
FROM inbox_posts WHERE username = ?
ORDER BY created_at DESC
LIMIT 50
`).all(username, username);
return NextResponse.json(timeline);
}
๐ ํ๋กํ UI์์ ํ์๋ผ์ธ ๋ถ์ด๊ธฐ
Adding a timeline to the profile UI
โ๏ธ myapp18/app/usersui/[username]/page.tsx
// app/usersui/[username]/page.tsx
import db from '@/lib/db';
export default async function Page({ params }: { params: Promise<{ username: string }> }) {
const { username } = await params;
// ๋์ผํ ํ
์ด๋ธ์ด๊ธฐ ๋๋ฌธ์ ์ฌ์ฉ๊ฐ๋ฅ
// Same table, so it's available
const timeline = db.prepare(`
SELECT id, content, created_at, 'mine' as source FROM posts WHERE username=?
UNION ALL
SELECT id, content, created_at, 'inbox' as source FROM inbox_posts WHERE username=?
ORDER BY created_at DESC LIMIT 50
`).all(username, username) as any[];
/*
// UNION ALL์ ์ฐ์ง ์๊ณ , ๋ ์ฟผ๋ฆฌ๋ฅผ ๋ฐ๋ก ์คํํ ํ ํฉ์น๊ณ ์ ๋ ฌํ๋ ๋ฐฉ๋ฒ
// You can also execute two queries separately and then merge and sort them without using UNION ALL
const mine = db.prepare(`SELECT ... FROM posts ...`).all(username);
const inbox = db.prepare(`SELECT ... FROM inbox_posts ...`).all(username);
const timeline = [...mine, ...inbox].sort(...)
*/
return (
<div style={{ padding: 20 }}>
<h1>{username} ํ์๋ผ์ธ / Timeline</h1>
<ul>
{timeline.map((p:any) => (
<li key={p.id}>[{p.source}] {p.content} - {p.created_at}</li>
))}
</ul>
</div>
);
}
๐ ํ ์คํธ / Test
โ๏ธ ๊ฐ๋ณ ๊ธ ๋ผ์ฐํธ / Indivisual post route
— ๋ฐ์ดํฐ๋ฒ ์ด์ค์ username๊ณผ id๊ฐ ํ๋ผ๋ฉํฐ๊ฐ ๋ฉ๋๋ค.
The database username and ID serve as parameters.
— inbox_postsํ
์ด๋ธ์ ๊ฐ๋ณ๊ธ์ id๋ก url์ ์ ์ธํ๊ณ ํด์๊ฐ๋ง ์
๋ ฅํฉ๋๋ค.
For individual posts in the inbox_posts table, only the hash value is enteredโexcluding the URLโbased on the ID.
# posts table post
https://aloy-horizon.duckdns.org/users/user1/statuses/aea0ec89-a368-44a9-8493-b2bd82a797a8
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://aloy-horizon.duckdns.org/users/user1/statuses/aea0ec89-a368-44a9-8493-b2bd82a797a8",
"type": "Note",
"attributedTo": "https://aloy-horizon.duckdns.org/users/user1",
"content": "post test! #test",
"published": "2026-08-11 12:08:18",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
]
}
# inbox_posts table post
https://aloy-horizon.duckdns.org/users/user1/statuses/01M0EJPP57WC47FAK0N395S7MB
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://aloy-horizon.duckdns.org/users/user1/statuses/01M0EJPP57WC47FAK0N395S7MB",
"type": "Note",
"attributedTo": "https://aloy-horizon.duckdns.org/users/user1",
"content": "\u003Cp\u003Einbox_posts table test\u003C/p\u003E",
"published": "2026-08-15 12:16:15",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
]
}
โ๏ธ ํ์๋ผ์ธ API / TImeline API
https://aloy-horizon.duckdns.org/api/timeline?username=user1

โ๏ธ ํ์๋ผ์ธ UI / Timeline UI
https://aloy-horizon.duckdns.org/usersui/user1

๐ ์ธ๋ฑ์ค / Index
โ๏ธ ์ธ๋ฑ์ค๋ ๋ฐ์ดํฐ๋ฒ ์ด์ค ๊ฒ์์ ๋น ๋ฅธ ๊ฒ์์ ์ํด์ ์ฌ์ฉ๋ฉ๋๋ค.
Indexes are used to enable fast searches when querying a database.
โ๏ธ sqlite๋ฐ์ดํฐ๋ฒ ์ด์ค์ ๋ค์๊ณผ ๊ฐ์ ์ธ๋ฑ์ค๋ฅผ ์ฌ์ฉํ๊ณ ์์ต๋๋ค.
I am using the following index in the SQLite database.
CREATE INDEX idx_posts_username_published ON posts(username, published DESC);
— ๊ทธ๋ฌ๋ฉด ์๋์ ๊ฐ์ ์ํฉ์์ ์๋ํฉ๋๋ค.
Then, it operates in the following situation.
— ์์์๋ถํฐ ์์๋๋ก ๋ง์ผ๋ฉด ์๋ํฉ๋๋ค.
It operates if the conditions are met in the specified order, starting from the beginning.
(ON posts(username, published DESC))
-- username๋ง ์์ด๋ ์๋ํฉ๋๋ค.
It works with just the username.
-- ๋ผ๋ฒจ ์๋ถ๋ถ์ผ๋ก ์ฐพ๊ธฐ ๊ฐ๋ฅ ํฉ๋๋ค.
You can search using the front part of the label.
WHERE username='user1'
-- username + published ์ ๋ ฌ์ ์๋ฒฝํ ์๋ํฉ๋๋ค.
Sorting by username + published works perfectly.
-- ๋ผ๋ฒจ์ด๋ 100% ์ผ์นํ๋ ๊ฒฝ์ฐ์ด๊ณ ์ ์ผ ๋น ๋ฆ
๋๋ค.
This is the case where it matches the label 100%, and it is the fastest method.
WHERE username='user1' ORDER BY published DESC
-- username + published ์กฐ๊ฑด๋ ์๋ํฉ๋๋ค.
The username + published condition also works.
-- ์+๋ค ๋ค ์ฌ์ฉํ๋ ๊ฒฝ์ฐ์
๋๋ค.
This is a case where both the front and back are used.
WHERE username='user1' AND published > '2026-05-10'
— ๋ค์๊ณผ ๊ฐ์ด username๊ฐ์ด ์๋ถ๋ถ ๋ถํฐ ์กฐ๊ฑด์ด ๋ง์ง ์๋ ์กฐ๊ฑด์ ์๋ํ์ง ์์ต๋๋ค.
Conditions that do not match from the beginningโsuch as usernameโwill not work.
-- published๋ง ๊ฒ์ํ๋ ๊ฒฝ์ฐ ์
๋๋ค.
This applies when searching only for 'published' items.
-- ๋ผ๋ฒจ ์์ด username์ธ๋ฐ username ์กฐ๊ฑด์ด ์๊ธฐ ๋๋ฌธ์ ์ธ๋ฑ์ค ๊ฒ์์ด ์๋ํ์ง ์์ต๋๋ค.
The `username` field appears at the beginning of the label, but since there is no condition specified for `username`, the index search does not work.
WHERE published DESC
-- content๋ก ๊ฒ์ํ๋ ๊ฒฝ์ฐ์
๋๋ค.
This is the case when searching by content.
-- ์ธ๋ฑ์ค์ content ์์ด์ ์ธ๋ฑ์ค ๊ฒ์์ด ์๋ํ์ง ์์ต๋๋ค.
Index search is not working because the index lacks content.
WHERE content='hello'
— sqlite๋ B-Tree๊ตฌ์กฐ๋ก ๋์ด ์์ต๋๋ค.
SQLite uses a B-Tree structure.
— ๋๋ฌด์ฒ๋ผ ๋ฟ๋ฆฌ์์ ๊ฐ์ง,์๊ณผ ์ ์ฌํ ํํ๋ก ์ ์ ๋ป์ด๋๊ฐ๋ฉด์ ๋ฐ์ดํฐ ์ธ๋ฑ์ค(๋ผ๋ฒจ)๊ฐ ์ ์ฅ๋ฉ๋๋ค.
A data index stores data indexes (labels) in a form similar to how a tree branches out into limbs and leaves.
— ์ด๋ ์์ด๋ ๊ฐ์ง์ ํด๋นํ๋ ๊ณณ์ ๋ผ๋ฒจ๋ก ๋ฐ์ดํฐ๋ฒ ์ด์ค ํ๋์ ๋ ์ง๋ฅผ ๋ง์ด ์ฌ์ฉํฉ๋๋ค.
In this case, database fields and dates are frequently used as labels for the parts corresponding to leaves or branches.
— ์ธ๋ฑ์ค๋ฅผ ์ฌ์ฉํ๋ฉด ๊ฒ์์ ๋ฐ์ดํฐ ์ ์ฒด ๊ฒ์์ ํ์ง ์์๋ ๋ฉ๋๋ค.
Using an index eliminates the need to scan the entire dataset during a search.
— ์ธ๋ฑ์ค์์ ๋ฐ์ดํฐ๋ฒ ์ด์ค ํ๋์ด๋ฆ์ด๋ ๋ ์ง๊ฐ ๊ฐ์ ๋ผ๋ฒจ์์๋ง ์ฐพ์ผ๋ฉด ๋๊ธฐ๋๋ฌธ์ ์๋๊ฐ ๋น ๋ฆ
๋๋ค.
It is fast because the search within the index is limited to labels matching the database field name or date.
์ํ๋ณต์ 8์ฅ 32์ / John 8:32
“๊ทธ๋ฆฌ๊ณ ๋ํฌ๋ ์ง๋ฆฌ๋ฅผ ์๊ฒ ๋ ๊ฒ์ด๋ฉฐ, ์ง๋ฆฌ๊ฐ ๋ํฌ๋ฅผ ์์ ๋กญ๊ฒ ํ ๊ฒ์ด๋ค.”
“Then you will know the truth ,and the truth will set you free”