[nextjs]SNS Server-8(myapp18)

๐Ÿ‘‰๐Ÿป 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
Timeline API

โœ”๏ธ ํƒ€์ž„๋ผ์ธ UI / Timeline UI

https://aloy-horizon.duckdns.org/usersui/user1
TImeline UI

๐Ÿ“ ์ธ๋ฑ์Šค / 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”

Leave a Reply