์ฝ˜ํ…์ธ  ๋Œ€ํ‘œ ์ด๋ฏธ์ง€ - ๐Ÿ•ธ๏ธ Neo4j ๊ทธ๋ž˜ํ”„ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋กœ ์™„์„ฑํ•˜๋Š” ์Šค๋งˆํŠธ ์ถ”์ฒœ ์‹œ์Šคํ…œ ๊ตฌํ˜„ ์™„์ „ ๊ฐ€์ด๋“œ
DB / ์„œ๋ฒ„ ยท ํ”„๋กœ๊ทธ๋žจ๊ฐœ๋ฐœ

๐Ÿ•ธ๏ธ Neo4j ๊ทธ๋ž˜ํ”„ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋กœ ์™„์„ฑํ•˜๋Š” ์Šค๋งˆํŠธ ์ถ”์ฒœ ์‹œ์Šคํ…œ ๊ตฌํ˜„ ์™„์ „ ๊ฐ€์ด๋“œ

๊ด€๊ณ„(Relationship)๊ฐ€ ๊ณง ๋ฐ์ดํ„ฐ๋‹ค! ๊ทธ๋ž˜ํ”„ DB์˜ ์ฒ ํ•™๋ถ€ํ„ฐ ์‹ค์ „ ์ถ”์ฒœ ์•Œ๊ณ ๋ฆฌ์ฆ˜๊นŒ์ง€, ์นœ๊ตฌ์ฒ˜๋Ÿผ ์‰ฝ๊ณ  ์žฌ๋ฏธ์žˆ๊ฒŒ ํŒŒํ—ค์ณ๋ณด์ž ๐Ÿš€

๐Ÿค” ์™œ ๊ทธ๋ž˜ํ”„ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์ธ๊ฐ€? โ€” ๊ด€๊ณ„์˜ ํž˜

์šฐ๋ฆฌ๊ฐ€ ํ”ํžˆ ์“ฐ๋Š” MySQL, PostgreSQL ๊ฐ™์€ ๊ด€๊ณ„ํ˜• ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค(RDBMS)๋Š” ์ •๋ง ํ›Œ๋ฅญํ•œ ๋„๊ตฌ์•ผ. ๊ทผ๋ฐ ๋”ฑ ํ•œ ๊ฐ€์ง€ ์ƒํ™ฉ์—์„œ ์—„์ฒญ๋‚˜๊ฒŒ ํž˜๋“ค์–ด์ง€๋Š” ๊ฒฝ์šฐ๊ฐ€ ์žˆ์–ด. ๋ฐ”๋กœ "๊ด€๊ณ„๊ฐ€ ๋ณต์žกํ•˜๊ฒŒ ์–ฝํ˜€์žˆ์„ ๋•Œ"์•ผ.

์˜ˆ๋ฅผ ๋“ค์–ด๋ณผ๊ฒŒ. ๋„ทํ”Œ๋ฆญ์Šค ๊ฐ™์€ ์ŠคํŠธ๋ฆฌ๋ฐ ์„œ๋น„์Šค์—์„œ "์ด ์˜ํ™”๋ฅผ ๋ณธ ์‚ฌ๋žŒ๋“ค์ด ์ข‹์•„ํ•˜๋Š” ๋‹ค๋ฅธ ์˜ํ™”"๋ฅผ ์ฐพ์œผ๋ ค๋ฉด ์–ด๋–ป๊ฒŒ ํ•ด์•ผ ํ• ๊นŒ? RDBMS์—์„œ๋Š” JOIN์„ ์—ฌ๋Ÿฌ ๋ฒˆ ํ•ด์•ผ ํ•˜๋Š”๋ฐ, ๊ด€๊ณ„๊ฐ€ ๊นŠ์–ด์งˆ์ˆ˜๋ก ์ฟผ๋ฆฌ๊ฐ€ ํญ๋ฐœ์ ์œผ๋กœ ๋А๋ ค์ง€๊ฑฐ๋“ . ์ด๊ฑธ "JOIN ์ง€์˜ฅ"์ด๋ผ๊ณ  ๋ถ€๋ฅด๊ธฐ๋„ ํ•ด ๐Ÿ˜…

๊ด€๊ณ„ํ˜• DB (RDBMS) ๐Ÿ‘ค ์‚ฌ์šฉ์ž ํ…Œ์ด๋ธ” ๐ŸŽฌ ์˜ํ™” ํ…Œ์ด๋ธ” โญ ํ‰์  ํ…Œ์ด๋ธ” ... JOIN JOIN JOIN JOIN JOIN JOIN JOIN JOIN JOIN JOIN... ๐Ÿ˜ต ๋ณต์žกํ• ์ˆ˜๋ก ๋А๋ ค์ง! ๊ทธ๋ž˜ํ”„ DB (Neo4j) ์‚ฌ์šฉ์ž Alice ์˜ํ™” ์ธ์…‰์…˜ ์˜ํ™” ๋งคํŠธ๋ฆญ์Šค ์žฅ๋ฅด SF ์‚ฌ์šฉ์ž Bob ์‹œ์ฒญํ•จ ์‹œ์ฒญํ•จ ์žฅ๋ฅด ์นœ๊ตฌ ์‹œ์ฒญํ•จ โœ… ๊ด€๊ณ„ ํƒ์ƒ‰์ด ๋น ๋ฅด๊ณ  ์ง๊ด€์ !

๊ทธ๋ž˜ํ”„ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋Š” ์ด ๋ฌธ์ œ๋ฅผ ์™„์ „ํžˆ ๋‹ค๋ฅธ ๋ฐฉ์‹์œผ๋กœ ์ ‘๊ทผํ•ด. ๋ฐ์ดํ„ฐ ์ž์ฒด๋ฅผ ๋…ธ๋“œ(Node)์™€ ์—ฃ์ง€(Edge, ๊ด€๊ณ„)๋กœ ์ €์žฅํ•˜๋Š” ๊ฑฐ์•ผ. ๊ทธ๋ž˜์„œ "Alice์˜ ์นœ๊ตฌ๊ฐ€ ๋ณธ ์˜ํ™” ์ค‘ SF ์žฅ๋ฅด์ธ ๊ฒƒ"์„ ์ฐพ์„ ๋•Œ, ๊ทธ๋ƒฅ ๊ทธ๋ž˜ํ”„๋ฅผ ๋”ฐ๋ผ๊ฐ€๋ฉด ๋ผ. JOIN ์—†์ด!

Neo4j๋Š” ์ด ๊ทธ๋ž˜ํ”„ DB ์„ธ๊ณ„์—์„œ ๊ฐ€์žฅ ์œ ๋ช…ํ•˜๊ณ  ๋งŽ์ด ์“ฐ์ด๋Š” ์˜คํ”ˆ์†Œ์Šค ์†”๋ฃจ์…˜์ด์•ผ. 2007๋…„์— ์ฒ˜์Œ ๋‚˜์™”๊ณ , ํ˜„์žฌ ์ „ ์„ธ๊ณ„ ์ˆ˜์ฒœ ๊ฐœ ๊ธฐ์—…์—์„œ ์‚ฌ์šฉ ์ค‘์ด์ง€. ํŠนํžˆ ์ถ”์ฒœ ์‹œ์Šคํ…œ, ์†Œ์…œ ๋„คํŠธ์›Œํฌ ๋ถ„์„, ์‚ฌ๊ธฐ ํƒ์ง€, ์ง€์‹ ๊ทธ๋ž˜ํ”„ ๋ถ„์•ผ์—์„œ ๋…๋ณด์ ์ธ ์„ฑ๋Šฅ์„ ๋ณด์—ฌ์ค˜.

๐Ÿ’ก ํ•ต์‹ฌ ํฌ์ธํŠธ: ๊ทธ๋ž˜ํ”„ DB๋Š” "๊ด€๊ณ„๊ฐ€ ๋งŽ๊ณ  ๋ณต์žกํ• ์ˆ˜๋ก" RDBMS๋ณด๋‹ค ์••๋„์ ์œผ๋กœ ์œ ๋ฆฌํ•ด. ํŠนํžˆ ๊นŠ์ด ์žˆ๋Š” ๊ด€๊ณ„ ํƒ์ƒ‰(2~3๋‹จ๊ณ„ ์ด์ƒ)์—์„œ ์„ฑ๋Šฅ ์ฐจ์ด๊ฐ€ ์ˆ˜์‹ญ~์ˆ˜๋ฐฑ ๋ฐฐ๊นŒ์ง€ ๋ฒŒ์–ด์งˆ ์ˆ˜ ์žˆ์–ด!

๐Ÿ—๏ธ Neo4j ํ•ต์‹ฌ ๊ฐœ๋… ์™„์ „ ์ •๋ณต

Neo4j๋ฅผ ์ œ๋Œ€๋กœ ์“ฐ๋ ค๋ฉด ๋ช‡ ๊ฐ€์ง€ ํ•ต์‹ฌ ๊ฐœ๋…์„ ํ™•์‹คํžˆ ์ดํ•ดํ•ด์•ผ ํ•ด. ์–ด๋ ต์ง€ ์•Š์•„, ๊ฐ™์ด ํ•˜๋‚˜์”ฉ ์‚ดํŽด๋ณด์ž!

๐Ÿ“Œ ๋…ธ๋“œ(Node) โ€” ๋ฐ์ดํ„ฐ์˜ ์ฃผ์ธ๊ณต

๋…ธ๋“œ๋Š” ๊ทธ๋ž˜ํ”„์˜ ๊ธฐ๋ณธ ๋‹จ์œ„์•ผ. ์‚ฌ๋žŒ, ์˜ํ™”, ์ œํ’ˆ, ๋„์‹œ ๋“ฑ ์–ด๋–ค "๊ฐœ์ฒด(Entity)"๋“  ๋…ธ๋“œ๋กœ ํ‘œํ˜„ํ•  ์ˆ˜ ์žˆ์–ด. ๋…ธ๋“œ์—๋Š” ๋ ˆ์ด๋ธ”(Label)๊ณผ ํ”„๋กœํผํ‹ฐ(Property)๋ฅผ ๋ถ™์ผ ์ˆ˜ ์žˆ์–ด.

// ์‚ฌ์šฉ์ž ๋…ธ๋“œ ์˜ˆ์‹œ
(alice:User {name: "Alice", age: 28, city: "์„œ์šธ"})

// ์˜ํ™” ๋…ธ๋“œ ์˜ˆ์‹œ  
(inception:Movie {title: "์ธ์…‰์…˜", year: 2010, rating: 8.8})

// ๋ ˆ์ด๋ธ”: User, Movie
// ํ”„๋กœํผํ‹ฐ: name, age, city, title, year, rating
๐Ÿ”— ๊ด€๊ณ„(Relationship) โ€” ๋ฐ์ดํ„ฐ์˜ ์—ฐ๊ฒฐ๊ณ ๋ฆฌ

๊ด€๊ณ„๋Š” ๋‘ ๋…ธ๋“œ๋ฅผ ์—ฐ๊ฒฐํ•˜๋Š” ํ™”์‚ดํ‘œ์•ผ. ๊ด€๊ณ„์—๋„ ํƒ€์ž…(Type)๊ณผ ํ”„๋กœํผํ‹ฐ๋ฅผ ๋ถ™์ผ ์ˆ˜ ์žˆ์–ด. ๊ด€๊ณ„๋Š” ํ•ญ์ƒ ๋ฐฉํ–ฅ์ด ์žˆ์–ด (๋‹จ๋ฐฉํ–ฅ), ํ•˜์ง€๋งŒ ์ฟผ๋ฆฌํ•  ๋•Œ๋Š” ์–‘๋ฐฉํ–ฅ์œผ๋กœ ํƒ์ƒ‰ํ•  ์ˆ˜ ์žˆ์–ด.

// Alice๊ฐ€ ์ธ์…‰์…˜์„ ์‹œ์ฒญํ•˜๊ณ  ํ‰์  9์ ์„ ์คฌ๋‹ค
(alice)-[:WATCHED {score: 9, date: "2024-01-15"}]->(inception)

// Alice์™€ Bob์€ ์นœ๊ตฌ ๊ด€๊ณ„
(alice)-[:FRIEND_OF {since: "2020-03-01"}]->(bob)

// ์ธ์…‰์…˜์€ SF ์žฅ๋ฅด์— ์†ํ•œ๋‹ค
(inception)-[:BELONGS_TO]->(sf:Genre {name: "SF"})
๐Ÿ” Cypher ์ฟผ๋ฆฌ ์–ธ์–ด โ€” Neo4j์˜ SQL

Neo4j๋Š” Cypher๋ผ๋Š” ์ž์ฒด ์ฟผ๋ฆฌ ์–ธ์–ด๋ฅผ ์‚ฌ์šฉํ•ด. SQL์ฒ˜๋Ÿผ ์„ ์–ธ์ ์ด๊ณ , ๊ทธ๋ž˜ํ”„ ํŒจํ„ด์„ ASCII ์•„ํŠธ์ฒ˜๋Ÿผ ํ‘œํ˜„ํ•˜๋Š” ๊ฒŒ ํŠน์ง•์ด์•ผ. ์ฒ˜์Œ ๋ณด๋ฉด ์ข€ ๋‚ฏ์„ค ์ˆ˜ ์žˆ๋Š”๋ฐ, ์ต์ˆ™ํ•ด์ง€๋ฉด ์ •๋ง ์ง๊ด€์ ์ด์•ผ!

// ๊ธฐ๋ณธ ํŒจํ„ด: MATCH - WHERE - RETURN
MATCH (u:User {name: "Alice"})-[:WATCHED]->(m:Movie)
WHERE m.rating > 8.0
RETURN m.title, m.year
ORDER BY m.rating DESC

// ๊ฒฐ๊ณผ: Alice๊ฐ€ ๋ณธ ์˜ํ™” ์ค‘ ํ‰์  8.0 ์ด์ƒ์ธ ๊ฒƒ๋“ค
Cypher ํ‚ค์›Œ๋“œ์—ญํ• SQL ๋Œ€์‘
MATCH๊ทธ๋ž˜ํ”„ ํŒจํ„ด ํƒ์ƒ‰SELECT + FROM + JOIN
WHERE์กฐ๊ฑด ํ•„ํ„ฐ๋งWHERE
RETURN๊ฒฐ๊ณผ ๋ฐ˜ํ™˜SELECT (๊ฒฐ๊ณผ ์ปฌ๋Ÿผ)
CREATE๋…ธ๋“œ/๊ด€๊ณ„ ์ƒ์„ฑINSERT
MERGE์—†์œผ๋ฉด ์ƒ์„ฑ, ์žˆ์œผ๋ฉด ์—…๋ฐ์ดํŠธUPSERT
SETํ”„๋กœํผํ‹ฐ ์—…๋ฐ์ดํŠธUPDATE SET
DELETE๋…ธ๋“œ/๊ด€๊ณ„ ์‚ญ์ œDELETE
WITH์ค‘๊ฐ„ ๊ฒฐ๊ณผ ํŒŒ์ดํ”„๋ผ์ธCTE (WITH)
๐Ÿ—‚๏ธ ์ธ๋ฑ์Šค์™€ ์ œ์•ฝ์กฐ๊ฑด

Neo4j๋„ ์„ฑ๋Šฅ์„ ์œ„ํ•œ ์ธ๋ฑ์Šค๋ฅผ ์ง€์›ํ•ด. ํŠนํžˆ ์ž์ฃผ ๊ฒ€์ƒ‰ํ•˜๋Š” ํ”„๋กœํผํ‹ฐ์—๋Š” ๋ฐ˜๋“œ์‹œ ์ธ๋ฑ์Šค๋ฅผ ๊ฑธ์–ด์ค˜์•ผ ํ•ด.

// ์ธ๋ฑ์Šค ์ƒ์„ฑ
CREATE INDEX user_name_idx FOR (u:User) ON (u.name);
CREATE INDEX movie_title_idx FOR (m:Movie) ON (m.title);

// ์œ ๋‹ˆํฌ ์ œ์•ฝ์กฐ๊ฑด (์ž๋™์œผ๋กœ ์ธ๋ฑ์Šค๋„ ์ƒ์„ฑ๋จ)
CREATE CONSTRAINT user_id_unique FOR (u:User) REQUIRE u.id IS UNIQUE;
CREATE CONSTRAINT movie_id_unique FOR (m:Movie) REQUIRE m.id IS UNIQUE;
๐ŸŒฑ ํŒ: Neo4j 5.x ๋ฒ„์ „๋ถ€ํ„ฐ๋Š” ๋ณตํ•ฉ ์ธ๋ฑ์Šค(Composite Index)์™€ ํ…์ŠคํŠธ ์ธ๋ฑ์Šค(Full-text Index)๋„ ์ง€์›ํ•ด. ๊ฒ€์ƒ‰ ์„ฑ๋Šฅ์„ ๊ทน๋Œ€ํ™”ํ•˜๋ ค๋ฉด ์ฟผ๋ฆฌ ํŒจํ„ด์— ๋งž๋Š” ์ธ๋ฑ์Šค ์ „๋žต์„ ๊ผญ ์„ธ์›Œ์•ผ ํ•ด!

โš™๏ธ Neo4j ์„ค์น˜ ๋ฐ ํ™˜๊ฒฝ ์„ค์ •

์ž, ์ด์ œ ์‹ค์ œ๋กœ Neo4j๋ฅผ ์„ค์น˜ํ•ด๋ณด์ž! ๊ฐ€์žฅ ๋น ๋ฅธ ๋ฐฉ๋ฒ•์€ Docker๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฑฐ์•ผ. ๋กœ์ปฌ ํ™˜๊ฒฝ์„ ๋”๋Ÿฝํžˆ์ง€ ์•Š๊ณ  ๊น”๋”ํ•˜๊ฒŒ ์‹คํ—˜ํ•  ์ˆ˜ ์žˆ๊ฑฐ๋“ .

๐Ÿณ Docker๋กœ Neo4j ์‹คํ–‰ํ•˜๊ธฐ
# Neo4j ์ตœ์‹  ๋ฒ„์ „ Docker ์‹คํ–‰
docker run \
  --name neo4j-recommend \
  -p 7474:7474 \
  -p 7687:7687 \
  -e NEO4J_AUTH=neo4j/password123 \
  -e NEO4J_PLUGINS='["apoc", "graph-data-science"]' \
  -v $HOME/neo4j/data:/data \
  -v $HOME/neo4j/logs:/logs \
  -d \
  neo4j:5.15.0

# ์‹คํ–‰ ํ™•์ธ
docker ps
# ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:7474 ์ ‘์†!
๐ŸŒ Neo4j Browser: http://localhost:7474์— ์ ‘์†ํ•˜๋ฉด ์›น ๊ธฐ๋ฐ˜ ์ฟผ๋ฆฌ ๋„๊ตฌ๊ฐ€ ์—ด๋ ค. ์—ฌ๊ธฐ์„œ Cypher ์ฟผ๋ฆฌ๋ฅผ ์ง์ ‘ ์‹คํ–‰ํ•˜๊ณ  ๊ทธ๋ž˜ํ”„๋ฅผ ์‹œ๊ฐํ™”ํ•  ์ˆ˜ ์žˆ์–ด. ์ฒ˜์Œ ์ ‘์† ์‹œ ID: neo4j, PW: password123์œผ๋กœ ๋กœ๊ทธ์ธ!
๐Ÿ“ฆ Python ๋“œ๋ผ์ด๋ฒ„ ์„ค์น˜

์ถ”์ฒœ ์‹œ์Šคํ…œ์„ Python์œผ๋กœ ๊ตฌํ˜„ํ•  ๊ฑฐ๋‹ˆ๊นŒ, Neo4j Python ๋“œ๋ผ์ด๋ฒ„๋ฅผ ์„ค์น˜ํ•ด์•ผ ํ•ด.

# pip์œผ๋กœ ์„ค์น˜
pip install neo4j

# ๋˜๋Š” ์ถ”์ฒœ ์‹œ์Šคํ…œ ์ „์ฒด ์Šคํƒ ํ•œ๋ฒˆ์—
pip install neo4j pandas numpy scikit-learn fastapi uvicorn
๐Ÿ”Œ Python์—์„œ Neo4j ์—ฐ๊ฒฐํ•˜๊ธฐ
from neo4j import GraphDatabase

class Neo4jConnection:
    def __init__(self, uri, user, password):
        self.driver = GraphDatabase.driver(
            uri, 
            auth=(user, password),
            max_connection_pool_size=50,  # ์ปค๋„ฅ์…˜ ํ’€ ์„ค์ •
            connection_timeout=30
        )
    
    def close(self):
        self.driver.close()
    
    def query(self, query, parameters=None):
        with self.driver.session(database="neo4j") as session:
            result = session.run(query, parameters or {})
            return [record.data() for record in result]
    
    def write_query(self, query, parameters=None):
        with self.driver.session(database="neo4j") as session:
            result = session.execute_write(
                lambda tx: tx.run(query, parameters or {}).data()
            )
            return result

# ์—ฐ๊ฒฐ ์ธ์Šคํ„ด์Šค ์ƒ์„ฑ
conn = Neo4jConnection(
    uri="bolt://localhost:7687",
    user="neo4j",
    password="password123"
)

# ์—ฐ๊ฒฐ ํ…Œ์ŠคํŠธ
result = conn.query("RETURN 'Neo4j ์—ฐ๊ฒฐ ์„ฑ๊ณต! ๐ŸŽ‰' AS message")
print(result)
# [{'message': 'Neo4j ์—ฐ๊ฒฐ ์„ฑ๊ณต! ๐ŸŽ‰'}]
๐Ÿ—ƒ๏ธ APOC ํ”Œ๋Ÿฌ๊ทธ์ธ์ด๋ž€?

APOC(Awesome Procedures On Cypher)๋Š” Neo4j์˜ ๊ณต์‹ ํ™•์žฅ ํ”Œ๋Ÿฌ๊ทธ์ธ์ด์•ผ. ์ˆ˜๋ฐฑ ๊ฐ€์ง€ ์œ ํ‹ธ๋ฆฌํ‹ฐ ํ•จ์ˆ˜๋ฅผ ์ œ๊ณตํ•˜๋Š”๋ฐ, ์ถ”์ฒœ ์‹œ์Šคํ…œ ๊ตฌํ˜„์—์„œ๋„ ์ž์ฃผ ์“ฐ์—ฌ. ์œ„ Docker ๋ช…๋ น์–ด์—์„œ ์ด๋ฏธ ์„ค์น˜ํ–ˆ์œผ๋‹ˆ ๋ฐ”๋กœ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•ด!

// APOC ์„ค์น˜ ํ™•์ธ
CALL apoc.help("apoc")

// APOC ์œ ์šฉํ•œ ํ•จ์ˆ˜ ์˜ˆ์‹œ
// 1. ๋ฐฐ์น˜ ๋ฐ์ดํ„ฐ ์‚ฝ์ž…
CALL apoc.periodic.iterate(
  "UNWIND range(1, 1000) AS id RETURN id",
  "CREATE (u:User {id: id, name: 'User_' + id})",
  {batchSize: 100}
)

// 2. JSON ํŒŒ์‹ฑ
CALL apoc.load.json("https://api.example.com/movies")
YIELD value
MERGE (m:Movie {id: value.id})
SET m.title = value.title

๐ŸŽฌ ์ถ”์ฒœ ์‹œ์Šคํ…œ ๋ฐ์ดํ„ฐ ๋ชจ๋ธ ์„ค๊ณ„

์ด์ œ ๋ณธ๊ฒฉ์ ์œผ๋กœ ์ถ”์ฒœ ์‹œ์Šคํ…œ์„ ๋งŒ๋“ค์–ด๋ณด์ž! ์˜ํ™” ์ถ”์ฒœ ์‹œ์Šคํ…œ์„ ์˜ˆ์‹œ๋กœ ํ• ๊ฒŒ. ์‹ค์ œ๋กœ ๋„ทํ”Œ๋ฆญ์Šค, ์™“์ฑ  ๊ฐ™์€ ์„œ๋น„์Šค๋“ค์ด ๋น„์Šทํ•œ ๊ตฌ์กฐ๋ฅผ ์‚ฌ์šฉํ•ด.

์˜ํ™” ์ถ”์ฒœ ์‹œ์Šคํ…œ ๊ทธ๋ž˜ํ”„ ๋ฐ์ดํ„ฐ ๋ชจ๋ธ User id, name, age User id, name, age Movie id, title, year Movie id, title, year Genre id, name Director id, name Tag name WATCHED {score, date} RATED {stars} FRIEND_OF WATCHED BELONGS_TO DIRECTED_BY TAGGED_WITH WISHLISTED
๐Ÿท๏ธ ๋…ธ๋“œ ๋ ˆ์ด๋ธ” ์ •์˜
// ===== ๋…ธ๋“œ ์ƒ์„ฑ =====

// ์‚ฌ์šฉ์ž ๋…ธ๋“œ
CREATE (u:User {
  id: "user_001",
  name: "Alice",
  age: 28,
  city: "์„œ์šธ",
  joined: date("2023-01-15")
})

// ์˜ํ™” ๋…ธ๋“œ
CREATE (m:Movie {
  id: "movie_001",
  title: "์ธ์…‰์…˜",
  year: 2010,
  rating: 8.8,
  runtime: 148,
  language: "์˜์–ด"
})

// ์žฅ๋ฅด ๋…ธ๋“œ
CREATE (g:Genre {id: "genre_001", name: "SF"})
CREATE (g2:Genre {id: "genre_002", name: "์Šค๋ฆด๋Ÿฌ"})

// ๊ฐ๋… ๋…ธ๋“œ
CREATE (d:Director {
  id: "dir_001", 
  name: "ํฌ๋ฆฌ์Šคํ† ํผ ๋†€๋ž€",
  nationality: "์˜๊ตญ"
})

// ํƒœ๊ทธ ๋…ธ๋“œ
CREATE (t:Tag {name: "๋งˆ์ธ๋“œ๋ธ”๋กœ์ž‰"})
CREATE (t2:Tag {name: "๋ณต์žกํ•œํ”Œ๋กฏ"})
๐Ÿ”— ๊ด€๊ณ„ ์ƒ์„ฑ
// ===== ๊ด€๊ณ„ ์ƒ์„ฑ =====

// ์‚ฌ์šฉ์ž๊ฐ€ ์˜ํ™”๋ฅผ ์‹œ์ฒญ
MATCH (u:User {id: "user_001"}), (m:Movie {id: "movie_001"})
CREATE (u)-[:WATCHED {
  score: 9.0,
  date: date("2024-01-20"),
  completed: true
}]->(m)

// ์˜ํ™”๊ฐ€ ์žฅ๋ฅด์— ์†ํ•จ
MATCH (m:Movie {id: "movie_001"}), (g:Genre {name: "SF"})
CREATE (m)-[:BELONGS_TO]->(g)

// ์˜ํ™”์˜ ๊ฐ๋…
MATCH (m:Movie {id: "movie_001"}), (d:Director {id: "dir_001"})
CREATE (m)-[:DIRECTED_BY]->(d)

// ์‚ฌ์šฉ์ž ๊ฐ„ ์นœ๊ตฌ ๊ด€๊ณ„
MATCH (u1:User {id: "user_001"}), (u2:User {id: "user_002"})
CREATE (u1)-[:FRIEND_OF {since: date("2022-06-01")}]->(u2)
CREATE (u2)-[:FRIEND_OF {since: date("2022-06-01")}]->(u1)

// ์˜ํ™”์— ํƒœ๊ทธ ์ถ”๊ฐ€
MATCH (m:Movie {id: "movie_001"}), (t:Tag {name: "๋งˆ์ธ๋“œ๋ธ”๋กœ์ž‰"})
CREATE (m)-[:TAGGED_WITH]->(t)
๐ŸŒฑ ์„ค๊ณ„ ํŒ: ๊ด€๊ณ„ ํƒ€์ž… ์ด๋ฆ„์€ ํ•ญ์ƒ ๋Œ€๋ฌธ์ž + ์–ธ๋”์Šค์ฝ”์–ด ํ˜•์‹์œผ๋กœ (์˜ˆ: WATCHED, FRIEND_OF). ๊ทธ๋ฆฌ๊ณ  ๊ด€๊ณ„๋Š” ๋™์‚ฌ๋‚˜ ๋™์‚ฌ๊ตฌ๋กœ ํ‘œํ˜„ํ•˜๋Š” ๊ฒŒ ๊ฐ€๋…์„ฑ์ด ์ข‹์•„. ๋…ธ๋“œ ๋ ˆ์ด๋ธ”์€ ๋ช…์‚ฌ๋กœ!

๐Ÿค– ์ถ”์ฒœ ์•Œ๊ณ ๋ฆฌ์ฆ˜ ๊ตฌํ˜„ โ€” 3๊ฐ€์ง€ ํ•ต์‹ฌ ๋ฐฉ๋ฒ•

์ถ”์ฒœ ์‹œ์Šคํ…œ์˜ ํ•ต์‹ฌ ์•Œ๊ณ ๋ฆฌ์ฆ˜์€ ํฌ๊ฒŒ ์„ธ ๊ฐ€์ง€์•ผ. ๊ฐ๊ฐ์˜ ์žฅ๋‹จ์ ์ด ์žˆ๊ณ , ์‹ค์ œ ์„œ๋น„์Šค์—์„œ๋Š” ์ด๊ฒƒ๋“ค์„ ์กฐํ•ฉํ•ด์„œ ์‚ฌ์šฉํ•ด. ํ•˜๋‚˜์”ฉ Neo4j๋กœ ๊ตฌํ˜„ํ•ด๋ณด์ž!

์ถ”์ฒœ ์•Œ๊ณ ๋ฆฌ์ฆ˜ 3๋Œ€์žฅ โ‘  ํ˜‘์—… ํ•„ํ„ฐ๋ง Collaborative Filtering "๋น„์Šทํ•œ ์ทจํ–ฅ์˜ ์‚ฌ๋žŒ๋“ค์ด ์ข‹์•„ํ•œ ๊ฒƒ" โœ… ๊ฐœ์ธํ™” ๊ฐ•๋ ฅ โœ… ์ฝ˜ํ…์ธ  ๋ถ„์„ ๋ถˆํ•„์š” โš ๏ธ ์ฝœ๋“œ์Šคํƒ€ํŠธ ๋ฌธ์ œ โš ๏ธ ํฌ์†Œ์„ฑ ๋ฌธ์ œ โ‘ก ์ฝ˜ํ…์ธ  ๊ธฐ๋ฐ˜ ํ•„ํ„ฐ๋ง Content-Based Filtering "๋‚ด๊ฐ€ ์ข‹์•„ํ•œ ๊ฒƒ๊ณผ ๋น„์Šทํ•œ ์†์„ฑ์˜ ๊ฒƒ" โœ… ์ฝœ๋“œ์Šคํƒ€ํŠธ ํ•ด๊ฒฐ โœ… ์„ค๋ช… ๊ฐ€๋Šฅ์„ฑ ๋†’์Œ โš ๏ธ ๋‹ค์–‘์„ฑ ๋ถ€์กฑ โš ๏ธ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์˜์กด โ‘ข ํ•˜์ด๋ธŒ๋ฆฌ๋“œ ๋ฐฉ์‹ Hybrid Approach "โ‘ +โ‘ก ์กฐํ•ฉ์œผ๋กœ ์ตœ์ ์˜ ์ถ”์ฒœ" โœ… ๋‹จ์  ๋ณด์™„ โœ… ์‹ค์ œ ์„œ๋น„์Šค ํ‘œ์ค€ โš ๏ธ ๊ตฌํ˜„ ๋ณต์žก๋„ ๋†’์Œ โš ๏ธ ํŠœ๋‹ ํ•„์š”
โ‘  ํ˜‘์—… ํ•„ํ„ฐ๋ง (Collaborative Filtering) ๊ตฌํ˜„

ํ˜‘์—… ํ•„ํ„ฐ๋ง์˜ ํ•ต์‹ฌ ์•„์ด๋””์–ด๋Š” "๋‚˜์™€ ๋น„์Šทํ•œ ์ทจํ–ฅ์„ ๊ฐ€์ง„ ์‚ฌ๋žŒ๋“ค์ด ์ข‹์•„ํ•œ ๊ฒƒ์„ ๋‚˜๋„ ์ข‹์•„ํ•  ๊ฒƒ"์ด์•ผ. Neo4j์—์„œ๋Š” ์ด "๋น„์Šทํ•œ ์ทจํ–ฅ"์„ ๊ณตํ†ต์œผ๋กœ ์‹œ์ฒญํ•œ ์˜ํ™”์˜ ์ˆ˜๋กœ ์ธก์ •ํ•  ์ˆ˜ ์žˆ์–ด.

// ===== ์‚ฌ์šฉ์ž ๊ธฐ๋ฐ˜ ํ˜‘์—… ํ•„ํ„ฐ๋ง =====
// "๋‚˜์™€ ๋น„์Šทํ•œ ์‚ฌ์šฉ์ž๋“ค์ด ๋ณธ ์˜ํ™” ์ถ”์ฒœ"

// 1๋‹จ๊ณ„: ์œ ์‚ฌํ•œ ์‚ฌ์šฉ์ž ์ฐพ๊ธฐ (๊ณตํ†ต ์‹œ์ฒญ ์˜ํ™” ๊ธฐ๋ฐ˜)
MATCH (target:User {id: "user_001"})-[:WATCHED]->(m:Movie)
      <-[:WATCHED]-(similar:User)
WHERE target <> similar
WITH similar, COUNT(m) AS commonMovies
ORDER BY commonMovies DESC
LIMIT 10
RETURN similar.name, commonMovies

// 2๋‹จ๊ณ„: ์œ ์‚ฌ ์‚ฌ์šฉ์ž๋“ค์ด ๋ณธ ์˜ํ™” ์ค‘ ๋‚ด๊ฐ€ ์•ˆ ๋ณธ ๊ฒƒ ์ถ”์ฒœ
MATCH (target:User {id: "user_001"})-[:WATCHED]->(watched:Movie)
WITH target, COLLECT(watched.id) AS watchedIds

MATCH (target)-[:WATCHED]->(m:Movie)
      <-[:WATCHED]-(similar:User)-[:WATCHED]->(rec:Movie)
WHERE NOT rec.id IN watchedIds
  AND target <> similar

WITH rec, 
     COUNT(DISTINCT similar) AS recommendedBy,
     AVG(rec.rating) AS avgRating
ORDER BY recommendedBy DESC, avgRating DESC
LIMIT 10

RETURN rec.title, rec.year, rec.rating, recommendedBy
// ===== ์žฌํด๋ผ๋“œ ์œ ์‚ฌ๋„(Jaccard Similarity) ๊ธฐ๋ฐ˜ ํ˜‘์—… ํ•„ํ„ฐ๋ง =====
// ๋” ์ •๊ตํ•œ ์œ ์‚ฌ๋„ ๊ณ„์‚ฐ

MATCH (u1:User {id: "user_001"})-[:WATCHED]->(m:Movie)
WITH u1, COLLECT(m.id) AS u1Movies

MATCH (u2:User)-[:WATCHED]->(m2:Movie)
WHERE u2 <> u1
WITH u1, u1Movies, u2, COLLECT(m2.id) AS u2Movies

// ์žฌํด๋ผ๋“œ ์œ ์‚ฌ๋„ = ๊ต์ง‘ํ•ฉ / ํ•ฉ์ง‘ํ•ฉ
WITH u1, u2,
     [x IN u1Movies WHERE x IN u2Movies] AS intersection,
     u1Movies + [x IN u2Movies WHERE NOT x IN u1Movies] AS union_set

WITH u1, u2,
     toFloat(SIZE(intersection)) / SIZE(union_set) AS jaccardSimilarity
WHERE jaccardSimilarity > 0.1
ORDER BY jaccardSimilarity DESC
LIMIT 5

RETURN u2.name, jaccardSimilarity
โ‘ก ์ฝ˜ํ…์ธ  ๊ธฐ๋ฐ˜ ํ•„ํ„ฐ๋ง (Content-Based Filtering) ๊ตฌํ˜„

์ฝ˜ํ…์ธ  ๊ธฐ๋ฐ˜ ํ•„ํ„ฐ๋ง์€ "๋‚ด๊ฐ€ ์ข‹์•„ํ•œ ์˜ํ™”์˜ ์†์„ฑ(์žฅ๋ฅด, ๊ฐ๋…, ํƒœ๊ทธ ๋“ฑ)๊ณผ ๋น„์Šทํ•œ ์˜ํ™”๋ฅผ ์ถ”์ฒœ"ํ•˜๋Š” ๋ฐฉ์‹์ด์•ผ.

// ===== ์ฝ˜ํ…์ธ  ๊ธฐ๋ฐ˜ ํ•„ํ„ฐ๋ง =====
// "๋‚ด๊ฐ€ ์ข‹์•„ํ•œ ์˜ํ™”์™€ ๋น„์Šทํ•œ ์†์„ฑ์˜ ์˜ํ™” ์ถ”์ฒœ"

// ๋ฐฉ๋ฒ• 1: ๊ฐ™์€ ์žฅ๋ฅด + ๊ฐ™์€ ๊ฐ๋… ์˜ํ™” ์ถ”์ฒœ
MATCH (u:User {id: "user_001"})-[w:WATCHED]->(liked:Movie)
WHERE w.score >= 8.0  // ๋†’์€ ํ‰์ ์„ ์ค€ ์˜ํ™”๋งŒ
WITH u, liked

// ๊ฐ™์€ ์žฅ๋ฅด์˜ ๋‹ค๋ฅธ ์˜ํ™” ์ฐพ๊ธฐ
MATCH (liked)-[:BELONGS_TO]->(g:Genre)<-[:BELONGS_TO]-(rec:Movie)
WHERE rec <> liked

// ๋‚ด๊ฐ€ ์ด๋ฏธ ๋ณธ ์˜ํ™” ์ œ์™ธ
AND NOT (u)-[:WATCHED]->(rec)

WITH rec, 
     COUNT(DISTINCT g) AS sharedGenres,
     rec.rating AS movieRating
ORDER BY sharedGenres DESC, movieRating DESC
LIMIT 10

RETURN rec.title, rec.year, rec.rating, sharedGenres

// ๋ฐฉ๋ฒ• 2: ํƒœ๊ทธ ๊ธฐ๋ฐ˜ ์œ ์‚ฌ๋„
MATCH (u:User {id: "user_001"})-[w:WATCHED]->(liked:Movie)-[:TAGGED_WITH]->(t:Tag)
WHERE w.score >= 7.0
WITH u, COLLECT(DISTINCT t.name) AS likedTags

MATCH (rec:Movie)-[:TAGGED_WITH]->(t2:Tag)
WHERE t2.name IN likedTags
AND NOT (u)-[:WATCHED]->(rec)

WITH rec, COUNT(t2) AS tagMatches
ORDER BY tagMatches DESC
LIMIT 10

RETURN rec.title, tagMatches
โ‘ข ๊ทธ๋ž˜ํ”„ ๊ธฐ๋ฐ˜ ์ถ”์ฒœ โ€” Neo4j๋งŒ์˜ ๊ฐ•์ !

์ด๊ฒŒ ์ง„์งœ Neo4j์˜ ๊ฝƒ์ด์•ผ! ๊ทธ๋ž˜ํ”„ ๊ตฌ์กฐ๋ฅผ ํ™œ์šฉํ•œ ๊ฒฝ๋กœ ๊ธฐ๋ฐ˜ ์ถ”์ฒœ์€ RDBMS๋กœ๋Š” ๊ตฌํ˜„ํ•˜๊ธฐ ๋งค์šฐ ์–ด๋ ต์ง€๋งŒ, Neo4j์—์„œ๋Š” ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ํ‘œํ˜„๋ผ.

// ===== ๊ทธ๋ž˜ํ”„ ๊ฒฝ๋กœ ๊ธฐ๋ฐ˜ ์ถ”์ฒœ =====
// "์นœ๊ตฌ์˜ ์นœ๊ตฌ๊ฐ€ ์ข‹์•„ํ•œ ์˜ํ™”" (์†Œ์…œ ์ถ”์ฒœ)

MATCH (u:User {id: "user_001"})-[:FRIEND_OF*1..2]->(friend:User)
WHERE u <> friend
WITH u, friend, 
     length((u)-[:FRIEND_OF*..2]->(friend)) AS distance

MATCH (friend)-[w:WATCHED]->(rec:Movie)
WHERE w.score >= 8.0
AND NOT (u)-[:WATCHED]->(rec)

WITH rec, 
     SUM(1.0 / distance) AS socialScore,  // ๊ฐ€๊นŒ์šด ์นœ๊ตฌ์ผ์ˆ˜๋ก ๊ฐ€์ค‘์น˜ ๋†’์Œ
     AVG(w.score) AS avgFriendScore
ORDER BY socialScore DESC, avgFriendScore DESC
LIMIT 10

RETURN rec.title, rec.year, 
       ROUND(socialScore * 100) / 100 AS socialScore,
       ROUND(avgFriendScore * 10) / 10 AS avgFriendScore

// ===== PageRank ๊ธฐ๋ฐ˜ ์˜ํ™” ์ค‘์š”๋„ ๊ณ„์‚ฐ =====
// Graph Data Science ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ํ™œ์šฉ
CALL gds.graph.project(
  'movieGraph',
  ['User', 'Movie'],
  {WATCHED: {orientation: 'UNDIRECTED'}}
)

CALL gds.pageRank.stream('movieGraph')
YIELD nodeId, score
WITH gds.util.asNode(nodeId) AS node, score
WHERE node:Movie
RETURN node.title, score
ORDER BY score DESC
LIMIT 10
โš ๏ธ ์ฃผ์˜: FRIEND_OF*1..2์ฒ˜๋Ÿผ ๊ฐ€๋ณ€ ๊ธธ์ด ๊ฒฝ๋กœ ํƒ์ƒ‰์€ ๋ฐ์ดํ„ฐ๊ฐ€ ๋งŽ์„์ˆ˜๋ก ์„ฑ๋Šฅ์— ์˜ํ–ฅ์„ ์ค„ ์ˆ˜ ์žˆ์–ด. ์‹ค์ œ ์„œ๋น„์Šค์—์„œ๋Š” ์ตœ๋Œ€ ๊นŠ์ด๋ฅผ ์ œํ•œํ•˜๊ณ , ์ธ๋ฑ์Šค๋ฅผ ์ž˜ ์„ค๊ณ„ํ•ด์•ผ ํ•ด!

๐Ÿš€ Graph Data Science (GDS) ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ํ™œ์šฉ

Neo4j์˜ Graph Data Science(GDS) ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋Š” ๊ทธ๋ž˜ํ”„ ๋ถ„์„ ์•Œ๊ณ ๋ฆฌ์ฆ˜์„ ๋ฐ”๋กœ ์“ธ ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์ฃผ๋Š” ๊ฐ•๋ ฅํ•œ ๋„๊ตฌ์•ผ. ์ถ”์ฒœ ์‹œ์Šคํ…œ์—์„œ ํŠนํžˆ ์œ ์šฉํ•œ ์•Œ๊ณ ๋ฆฌ์ฆ˜๋“ค์„ ์‚ดํŽด๋ณด์ž!

๐Ÿ“Š Node Similarity โ€” ๋…ธ๋“œ ์œ ์‚ฌ๋„ ๊ณ„์‚ฐ

GDS์˜ Node Similarity ์•Œ๊ณ ๋ฆฌ์ฆ˜์€ ๋‘ ๋…ธ๋“œ๊ฐ€ ์–ผ๋งˆ๋‚˜ ๋น„์Šทํ•œ ์ด์›ƒ์„ ๊ฐ€์ง€๊ณ  ์žˆ๋Š”์ง€ ๊ณ„์‚ฐํ•ด์ค˜. ํ˜‘์—… ํ•„ํ„ฐ๋ง์˜ ํ•ต์‹ฌ ์—ฐ์‚ฐ์„ ์ž๋™์œผ๋กœ ํ•ด์ฃผ๋Š” ๊ฑฐ์•ผ!

// 1. ๊ทธ๋ž˜ํ”„ ํ”„๋กœ์ ์…˜ ์ƒ์„ฑ (๋ฉ”๋ชจ๋ฆฌ์— ๊ทธ๋ž˜ํ”„ ๋กœ๋“œ)
CALL gds.graph.project(
  'userMovieGraph',           // ๊ทธ๋ž˜ํ”„ ์ด๋ฆ„
  ['User', 'Movie'],          // ๋…ธ๋“œ ๋ ˆ์ด๋ธ”
  {
    WATCHED: {
      orientation: 'UNDIRECTED',
      properties: ['score']   // ๊ด€๊ณ„ ๊ฐ€์ค‘์น˜
    }
  }
)
YIELD graphName, nodeCount, relationshipCount
RETURN graphName, nodeCount, relationshipCount

// 2. Node Similarity ์‹คํ–‰ (์œ ์‚ฌํ•œ ์‚ฌ์šฉ์ž ์Œ ์ฐพ๊ธฐ)
CALL gds.nodeSimilarity.stream('userMovieGraph', {
  topK: 10,                   // ๊ฐ ๋…ธ๋“œ๋‹น ์ƒ์œ„ 10๊ฐœ ์œ ์‚ฌ ๋…ธ๋“œ
  similarityCutoff: 0.1,      // ์ตœ์†Œ ์œ ์‚ฌ๋„ ์ž„๊ณ„๊ฐ’
  nodeLabels: ['User'],       // User ๋…ธ๋“œ๋งŒ ๋น„๊ต
  relationshipTypes: ['WATCHED']
})
YIELD node1, node2, similarity
WITH gds.util.asNode(node1) AS u1, 
     gds.util.asNode(node2) AS u2, 
     similarity
RETURN u1.name, u2.name, 
       ROUND(similarity * 100) / 100 AS similarity
ORDER BY similarity DESC
LIMIT 20

// 3. ์œ ์‚ฌ๋„ ๊ด€๊ณ„๋ฅผ ๊ทธ๋ž˜ํ”„์— ์ €์žฅ (๋‚˜์ค‘์— ์žฌ์‚ฌ์šฉ)
CALL gds.nodeSimilarity.write('userMovieGraph', {
  topK: 10,
  similarityCutoff: 0.1,
  nodeLabels: ['User'],
  writeRelationshipType: 'SIMILAR_TO',
  writeProperty: 'score'
})
YIELD nodesCompared, relationshipsWritten
RETURN nodesCompared, relationshipsWritten
๐Ÿ”— Community Detection โ€” ์ทจํ–ฅ ๊ทธ๋ฃน ๋ฐœ๊ฒฌ

Louvain ์•Œ๊ณ ๋ฆฌ์ฆ˜์œผ๋กœ ๋น„์Šทํ•œ ์ทจํ–ฅ์„ ๊ฐ€์ง„ ์‚ฌ์šฉ์ž ๊ทธ๋ฃน์„ ์ž๋™์œผ๋กœ ์ฐพ์„ ์ˆ˜ ์žˆ์–ด. ์ด ๊ทธ๋ฃน ์ •๋ณด๋ฅผ ์ถ”์ฒœ์— ํ™œ์šฉํ•˜๋ฉด ๋” ์ •ํ™•ํ•œ ์ถ”์ฒœ์ด ๊ฐ€๋Šฅํ•ด!

// Louvain ์ปค๋ฎค๋‹ˆํ‹ฐ ํƒ์ง€
CALL gds.louvain.stream('userMovieGraph', {
  nodeLabels: ['User'],
  relationshipTypes: ['SIMILAR_TO'],
  relationshipWeightProperty: 'score'
})
YIELD nodeId, communityId
WITH gds.util.asNode(nodeId) AS user, communityId
RETURN communityId, 
       COLLECT(user.name) AS members, 
       COUNT(*) AS groupSize
ORDER BY groupSize DESC
LIMIT 10

// ์ปค๋ฎค๋‹ˆํ‹ฐ ID๋ฅผ ๋…ธ๋“œ์— ์ €์žฅ
CALL gds.louvain.write('userMovieGraph', {
  nodeLabels: ['User'],
  writeProperty: 'communityId'
})
YIELD communityCount, modularity
RETURN communityCount, modularity

// ๊ฐ™์€ ์ปค๋ฎค๋‹ˆํ‹ฐ ์‚ฌ์šฉ์ž๋“ค์˜ ์ธ๊ธฐ ์˜ํ™” ์ถ”์ฒœ
MATCH (u:User {id: "user_001"})
WITH u.communityId AS myCommunity, u

MATCH (peer:User {communityId: myCommunity})-[w:WATCHED]->(m:Movie)
WHERE peer <> u
AND NOT (u)-[:WATCHED]->(m)
WITH m, COUNT(peer) AS peerCount, AVG(w.score) AS avgScore
ORDER BY peerCount DESC, avgScore DESC
LIMIT 10
RETURN m.title, peerCount, ROUND(avgScore * 10) / 10 AS avgScore
๐Ÿ“ˆ PageRank โ€” ์˜ํ–ฅ๋ ฅ ์žˆ๋Š” ๋…ธ๋“œ ์ฐพ๊ธฐ
// ์˜ํ™” PageRank ๊ณ„์‚ฐ (๋งŽ์ด ์‹œ์ฒญ๋˜๊ณ  ์ข‹์€ ํ‰์ ์„ ๋ฐ›์€ ์˜ํ™”)
CALL gds.pageRank.write('userMovieGraph', {
  nodeLabels: ['Movie'],
  maxIterations: 20,
  dampingFactor: 0.85,
  writeProperty: 'pageRankScore'
})
YIELD nodePropertiesWritten, ranIterations

// PageRank ๋†’์€ ์˜ํ™” = ํŠธ๋ Œ๋””ํ•œ ์˜ํ™”๋กœ ์ถ”์ฒœ
MATCH (m:Movie)
WHERE m.pageRankScore IS NOT NULL
RETURN m.title, m.year, 
       ROUND(m.pageRankScore * 1000) / 1000 AS pageRank
ORDER BY m.pageRankScore DESC
LIMIT 10

๐Ÿ Python์œผ๋กœ ์ถ”์ฒœ API ๊ตฌํ˜„ํ•˜๊ธฐ

์ด์ œ ์ง€๊ธˆ๊นŒ์ง€ ๋งŒ๋“  Neo4j ์ถ”์ฒœ ๋กœ์ง์„ Python FastAPI๋กœ ๊ฐ์‹ธ์„œ ์‹ค์ œ ์„œ๋น„์Šค์—์„œ ์“ธ ์ˆ˜ ์žˆ๋Š” API๋ฅผ ๋งŒ๋“ค์–ด๋ณด์ž! ์žฌ๋Šฅ๋„ท ๊ฐ™์€ ํ”Œ๋žซํผ์—์„œ๋„ ์ด๋Ÿฐ ๋ฐฉ์‹์œผ๋กœ ์žฌ๋Šฅ ์ถ”์ฒœ ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ์–ด.

๐Ÿ“ ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ
recommendation-api/
โ”œโ”€โ”€ main.py              # FastAPI ์•ฑ ์ง„์ž…์ 
โ”œโ”€โ”€ database.py          # Neo4j ์—ฐ๊ฒฐ ๊ด€๋ฆฌ
โ”œโ”€โ”€ models.py            # Pydantic ๋ชจ๋ธ
โ”œโ”€โ”€ recommender.py       # ์ถ”์ฒœ ์•Œ๊ณ ๋ฆฌ์ฆ˜ ํด๋ž˜์Šค
โ”œโ”€โ”€ routers/
โ”‚   โ”œโ”€โ”€ recommend.py     # ์ถ”์ฒœ API ๋ผ์šฐํ„ฐ
โ”‚   โ””โ”€โ”€ users.py         # ์‚ฌ์šฉ์ž API ๋ผ์šฐํ„ฐ
โ””โ”€โ”€ requirements.txt
๐Ÿ”ง database.py โ€” ์—ฐ๊ฒฐ ๊ด€๋ฆฌ
# database.py
from neo4j import GraphDatabase
from contextlib import contextmanager
import os

class Neo4jDatabase:
    _instance = None
    
    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance.driver = GraphDatabase.driver(
                os.getenv("NEO4J_URI", "bolt://localhost:7687"),
                auth=(
                    os.getenv("NEO4J_USER", "neo4j"),
                    os.getenv("NEO4J_PASSWORD", "password123")
                ),
                max_connection_pool_size=50
            )
        return cls._instance
    
    @contextmanager
    def get_session(self):
        session = self.driver.session(database="neo4j")
        try:
            yield session
        finally:
            session.close()
    
    def execute_read(self, query: str, params: dict = None):
        with self.get_session() as session:
            result = session.run(query, params or {})
            return [record.data() for record in result]
    
    def execute_write(self, query: str, params: dict = None):
        with self.get_session() as session:
            result = session.execute_write(
                lambda tx: list(tx.run(query, params or {}))
            )
            return result
    
    def close(self):
        self.driver.close()

# ์‹ฑ๊ธ€ํ†ค ์ธ์Šคํ„ด์Šค
db = Neo4jDatabase()
๐Ÿค– recommender.py โ€” ์ถ”์ฒœ ์—”์ง„
# recommender.py
from database import db
from typing import List, Dict, Optional

class MovieRecommender:
    
    def collaborative_filter(
        self, 
        user_id: str, 
        limit: int = 10,
        min_similarity: float = 0.1
    ) -> List[Dict]:
        """ํ˜‘์—… ํ•„ํ„ฐ๋ง ๊ธฐ๋ฐ˜ ์ถ”์ฒœ"""
        query = """
        MATCH (target:User {id: $userId})-[:WATCHED]->(watched:Movie)
        WITH target, COLLECT(watched.id) AS watchedIds
        
        MATCH (target)-[:WATCHED]->(m:Movie)
              <-[:WATCHED]-(similar:User)-[:WATCHED]->(rec:Movie)
        WHERE NOT rec.id IN watchedIds
          AND target <> similar
        
        WITH rec,
             COUNT(DISTINCT similar) AS recommendedBy,
             AVG(rec.rating) AS avgRating,
             COLLECT(DISTINCT similar.name)[..3] AS topRecommenders
        
        ORDER BY recommendedBy DESC, avgRating DESC
        LIMIT $limit
        
        RETURN rec.id AS id,
               rec.title AS title,
               rec.year AS year,
               rec.rating AS rating,
               recommendedBy,
               topRecommenders,
               'collaborative' AS algorithm
        """
        return db.execute_read(query, {
            "userId": user_id, 
            "limit": limit
        })
    
    def content_based_filter(
        self, 
        user_id: str, 
        limit: int = 10,
        min_score: float = 7.0
    ) -> List[Dict]:
        """์ฝ˜ํ…์ธ  ๊ธฐ๋ฐ˜ ํ•„ํ„ฐ๋ง ์ถ”์ฒœ"""
        query = """
        MATCH (u:User {id: $userId})-[w:WATCHED]->(liked:Movie)
        WHERE w.score >= $minScore
        
        WITH u, liked
        MATCH (liked)-[:BELONGS_TO]->(g:Genre)<-[:BELONGS_TO]-(rec:Movie)
        WHERE rec <> liked
          AND NOT (u)-[:WATCHED]->(rec)
        
        WITH rec,
             COUNT(DISTINCT g) AS sharedGenres,
             COLLECT(DISTINCT g.name)[..3] AS matchedGenres
        
        ORDER BY sharedGenres DESC, rec.rating DESC
        LIMIT $limit
        
        RETURN rec.id AS id,
               rec.title AS title,
               rec.year AS year,
               rec.rating AS rating,
               sharedGenres,
               matchedGenres,
               'content_based' AS algorithm
        """
        return db.execute_read(query, {
            "userId": user_id,
            "minScore": min_score,
            "limit": limit
        })
    
    def social_recommend(
        self, 
        user_id: str, 
        limit: int = 10,
        max_depth: int = 2
    ) -> List[Dict]:
        """์†Œ์…œ ๊ทธ๋ž˜ํ”„ ๊ธฐ๋ฐ˜ ์ถ”์ฒœ (์นœ๊ตฌ์˜ ์นœ๊ตฌ)"""
        query = """
        MATCH (u:User {id: $userId})-[:FRIEND_OF*1..$maxDepth]->(friend:User)
        WHERE u <> friend
        
        WITH u, friend,
             length((u)-[:FRIEND_OF*..2]->(friend)) AS distance
        
        MATCH (friend)-[w:WATCHED]->(rec:Movie)
        WHERE w.score >= 7.5
          AND NOT (u)-[:WATCHED]->(rec)
        
        WITH rec,
             SUM(1.0 / distance) AS socialScore,
             AVG(w.score) AS avgFriendScore,
             COUNT(DISTINCT friend) AS friendCount
        
        ORDER BY socialScore DESC, avgFriendScore DESC
        LIMIT $limit
        
        RETURN rec.id AS id,
               rec.title AS title,
               rec.year AS year,
               rec.rating AS rating,
               ROUND(socialScore * 100) / 100 AS socialScore,
               friendCount,
               'social' AS algorithm
        """
        return db.execute_read(query, {
            "userId": user_id,
            "maxDepth": max_depth,
            "limit": limit
        })
    
    def hybrid_recommend(
        self, 
        user_id: str, 
        limit: int = 10
    ) -> List[Dict]:
        """ํ•˜์ด๋ธŒ๋ฆฌ๋“œ ์ถ”์ฒœ (ํ˜‘์—… + ์ฝ˜ํ…์ธ  + ์†Œ์…œ ์กฐํ•ฉ)"""
        # ๊ฐ ์•Œ๊ณ ๋ฆฌ์ฆ˜์—์„œ ํ›„๋ณด ์ˆ˜์ง‘
        collab = self.collaborative_filter(user_id, limit=20)
        content = self.content_based_filter(user_id, limit=20)
        social = self.social_recommend(user_id, limit=20)
        
        # ์ ์ˆ˜ ํ†ตํ•ฉ (๊ฐ€์ค‘ ํ‰๊ท )
        scores = {}
        weights = {'collaborative': 0.4, 'content_based': 0.35, 'social': 0.25}
        
        for item in collab:
            mid = item['id']
            scores[mid] = scores.get(mid, {'item': item, 'score': 0})
            scores[mid]['score'] += weights['collaborative'] * (item.get('recommendedBy', 1))
        
        for item in content:
            mid = item['id']
            if mid not in scores:
                scores[mid] = {'item': item, 'score': 0}
            scores[mid]['score'] += weights['content_based'] * (item.get('sharedGenres', 1))
        
        for item in social:
            mid = item['id']
            if mid not in scores:
                scores[mid] = {'item': item, 'score': 0}
            scores[mid]['score'] += weights['social'] * (item.get('socialScore', 0.1))
        
        # ์ •๋ ฌ ํ›„ ๋ฐ˜ํ™˜
        sorted_items = sorted(
            scores.values(), 
            key=lambda x: x['score'], 
            reverse=True
        )[:limit]
        
        return [
            {**item['item'], 'hybridScore': round(item['score'], 3), 'algorithm': 'hybrid'}
            for item in sorted_items
        ]

recommender = MovieRecommender()
๐ŸŒ main.py โ€” FastAPI ์•ฑ
# main.py
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from recommender import recommender
from typing import Optional

app = FastAPI(
    title="Neo4j ์˜ํ™” ์ถ”์ฒœ API",
    description="๊ทธ๋ž˜ํ”„ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๊ธฐ๋ฐ˜ ์Šค๋งˆํŠธ ์ถ”์ฒœ ์‹œ์Šคํ…œ",
    version="1.0.0"
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"]
)

@app.get("/recommend/{user_id}")
async def get_recommendations(
    user_id: str,
    algorithm: str = Query(
        default="hybrid",
        enum=["collaborative", "content_based", "social", "hybrid"]
    ),
    limit: int = Query(default=10, ge=1, le=50)
):
    """์‚ฌ์šฉ์ž ๋งž์ถค ์˜ํ™” ์ถ”์ฒœ"""
    try:
        if algorithm == "collaborative":
            results = recommender.collaborative_filter(user_id, limit)
        elif algorithm == "content_based":
            results = recommender.content_based_filter(user_id, limit)
        elif algorithm == "social":
            results = recommender.social_recommend(user_id, limit)
        else:  # hybrid
            results = recommender.hybrid_recommend(user_id, limit)
        
        if not results:
            raise HTTPException(
                status_code=404, 
                detail=f"์‚ฌ์šฉ์ž {user_id}์— ๋Œ€ํ•œ ์ถ”์ฒœ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค."
            )
        
        return {
            "userId": user_id,
            "algorithm": algorithm,
            "count": len(results),
            "recommendations": results
        }
    
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.post("/watch/{user_id}/{movie_id}")
async def record_watch(
    user_id: str, 
    movie_id: str, 
    score: float = Query(default=7.0, ge=1.0, le=10.0)
):
    """์‹œ์ฒญ ๊ธฐ๋ก ์ €์žฅ"""
    from database import db
    from datetime import date
    
    query = """
    MATCH (u:User {id: $userId}), (m:Movie {id: $movieId})
    MERGE (u)-[w:WATCHED]->(m)
    SET w.score = $score,
        w.date = date($today),
        w.updatedAt = datetime()
    RETURN u.name AS user, m.title AS movie, w.score AS score
    """
    
    result = db.execute_read(query, {
        "userId": user_id,
        "movieId": movie_id,
        "score": score,
        "today": str(date.today())
    })
    
    return {"message": "์‹œ์ฒญ ๊ธฐ๋ก ์ €์žฅ ์™„๋ฃŒ", "data": result[0] if result else {}}

# ์‹คํ–‰: uvicorn main:app --reload --port 8000
๐ŸŒฑ ์‹คํ–‰ ๋ฐฉ๋ฒ•:
uvicorn main:app --reload --port 8000
๊ทธ ๋‹ค์Œ http://localhost:8000/docs์— ์ ‘์†ํ•˜๋ฉด Swagger UI๋กœ API๋ฅผ ๋ฐ”๋กœ ํ…Œ์ŠคํŠธํ•  ์ˆ˜ ์žˆ์–ด!

โšก ์„ฑ๋Šฅ ์ตœ์ ํ™” ์ „๋žต

์ถ”์ฒœ ์‹œ์Šคํ…œ์€ ์‹ค์‹œ๊ฐ„์œผ๋กœ ์‘๋‹ตํ•ด์•ผ ํ•˜๊ธฐ ๋•Œ๋ฌธ์— ์„ฑ๋Šฅ์ด ์ •๋ง ์ค‘์š”ํ•ด. Neo4j์—์„œ ์„ฑ๋Šฅ์„ ์ตœ์ ํ™”ํ•˜๋Š” ํ•ต์‹ฌ ์ „๋žต๋“ค์„ ์•Œ์•„๋ณด์ž!

๐Ÿ—‚๏ธ ์ธ๋ฑ์Šค ์ „๋žต
// ํ•„์ˆ˜ ์ธ๋ฑ์Šค ์„ค์ •
CREATE INDEX user_id_idx FOR (u:User) ON (u.id);
CREATE INDEX movie_id_idx FOR (m:Movie) ON (m.id);
CREATE INDEX movie_rating_idx FOR (m:Movie) ON (m.rating);
CREATE INDEX movie_year_idx FOR (m:Movie) ON (m.year);

// ๋ณตํ•ฉ ์ธ๋ฑ์Šค (์—ฌ๋Ÿฌ ์กฐ๊ฑด ๋™์‹œ ์‚ฌ์šฉ ์‹œ)
CREATE INDEX movie_year_rating FOR (m:Movie) ON (m.year, m.rating);

// ์ „๋ฌธ ๊ฒ€์ƒ‰ ์ธ๋ฑ์Šค (์ œ๋ชฉ ๊ฒ€์ƒ‰์šฉ)
CREATE FULLTEXT INDEX movie_title_search FOR (m:Movie) ON EACH [m.title];

// ์ „๋ฌธ ๊ฒ€์ƒ‰ ์‚ฌ์šฉ ์˜ˆ์‹œ
CALL db.index.fulltext.queryNodes("movie_title_search", "์ธ์…‰์…˜")
YIELD node, score
RETURN node.title, score
ORDER BY score DESC
๐Ÿ’พ ์ฟผ๋ฆฌ ์บ์‹ฑ ์ „๋žต
# Python Redis ์บ์‹ฑ ์ ์šฉ
import redis
import json
from functools import wraps

redis_client = redis.Redis(host='localhost', port=6379, db=0)

def cache_recommendations(ttl: int = 300):  # 5๋ถ„ ์บ์‹œ
    def decorator(func):
        @wraps(func)
        def wrapper(self, user_id: str, *args, **kwargs):
            cache_key = f"rec:{func.__name__}:{user_id}"
            
            # ์บ์‹œ ํ™•์ธ
            cached = redis_client.get(cache_key)
            if cached:
                return json.loads(cached)
            
            # ์บ์‹œ ์—†์œผ๋ฉด ์‹ค์ œ ์ฟผ๋ฆฌ ์‹คํ–‰
            result = func(self, user_id, *args, **kwargs)
            
            # ๊ฒฐ๊ณผ ์บ์‹ฑ
            redis_client.setex(
                cache_key, 
                ttl, 
                json.dumps(result, ensure_ascii=False)
            )
            return result
        return wrapper
    return decorator

class CachedRecommender(MovieRecommender):
    
    @cache_recommendations(ttl=300)
    def collaborative_filter(self, user_id, limit=10, min_similarity=0.1):
        return super().collaborative_filter(user_id, limit, min_similarity)
    
    @cache_recommendations(ttl=600)  # ์ฝ˜ํ…์ธ  ๊ธฐ๋ฐ˜์€ ๋” ์˜ค๋ž˜ ์บ์‹œ
    def content_based_filter(self, user_id, limit=10, min_score=7.0):
        return super().content_based_filter(user_id, limit, min_score)
    
    def invalidate_cache(self, user_id: str):
        """์‚ฌ์šฉ์ž ์‹œ์ฒญ ๊ธฐ๋ก ์—…๋ฐ์ดํŠธ ์‹œ ์บ์‹œ ๋ฌดํšจํ™”"""
        patterns = [
            f"rec:collaborative_filter:{user_id}",
            f"rec:content_based_filter:{user_id}",
            f"rec:social_recommend:{user_id}",
            f"rec:hybrid_recommend:{user_id}"
        ]
        for key in patterns:
            redis_client.delete(key)
๐Ÿ“Š ์ฟผ๋ฆฌ ํ”„๋กœํŒŒ์ผ๋ง
// ์ฟผ๋ฆฌ ์‹คํ–‰ ๊ณ„ํš ํ™•์ธ (EXPLAIN)
EXPLAIN
MATCH (u:User {id: "user_001"})-[:WATCHED]->(m:Movie)
RETURN m.title

// ์‹ค์ œ ์‹คํ–‰ ํ†ต๊ณ„ ํ™•์ธ (PROFILE)
PROFILE
MATCH (u:User {id: "user_001"})-[:WATCHED]->(m:Movie)
WHERE m.rating > 8.0
RETURN m.title, m.rating
ORDER BY m.rating DESC

// ๋А๋ฆฐ ์ฟผ๋ฆฌ ๋กœ๊ทธ ํ™•์ธ
CALL dbms.listQueries()
YIELD queryId, query, elapsedTimeMillis
WHERE elapsedTimeMillis > 1000  // 1์ดˆ ์ด์ƒ ๊ฑธ๋ฆฐ ์ฟผ๋ฆฌ
RETURN queryId, query, elapsedTimeMillis
ORDER BY elapsedTimeMillis DESC
์ตœ์ ํ™” ๊ธฐ๋ฒ•ํšจ๊ณผ์ ์šฉ ์‹œ์ 
์ธ๋ฑ์Šค ์ถ”๊ฐ€์กฐํšŒ ์†๋„ 10~100๋ฐฐ ํ–ฅ์ƒ์ž์ฃผ WHERE ์กฐ๊ฑด์— ์“ฐ์ด๋Š” ํ”„๋กœํผํ‹ฐ
Redis ์บ์‹ฑ๋ฐ˜๋ณต ์ฟผ๋ฆฌ ์‘๋‹ต 1ms ์ดํ•˜๋ณ€๊ฒฝ์ด ์ ์€ ์ถ”์ฒœ ๊ฒฐ๊ณผ
๋ฐฐ์น˜ ์ฒ˜๋ฆฌ๋Œ€๋Ÿ‰ ์‚ฝ์ž… ์†๋„ ํ–ฅ์ƒ์ดˆ๊ธฐ ๋ฐ์ดํ„ฐ ๋กœ๋”ฉ, ์ผ๊ด„ ์—…๋ฐ์ดํŠธ
์ฟผ๋ฆฌ ์ตœ์ ํ™”๋ถˆํ•„์š”ํ•œ ํƒ์ƒ‰ ์ œ๊ฑฐPROFILE๋กœ ๋ณ‘๋ชฉ ํ™•์ธ ํ›„
GDS ์‚ฌ์ „ ๊ณ„์‚ฐ์‹ค์‹œ๊ฐ„ ๊ณ„์‚ฐ ๋ถ€ํ•˜ ์ œ๊ฑฐ์œ ์‚ฌ๋„, PageRank ๋“ฑ ๋ฌด๊ฑฐ์šด ์—ฐ์‚ฐ
์ปค๋„ฅ์…˜ ํ’€๋ง์—ฐ๊ฒฐ ์˜ค๋ฒ„ํ—ค๋“œ ๊ฐ์†Œ๊ณ ํŠธ๋ž˜ํ”ฝ ์„œ๋น„์Šค

๐Ÿ“Š ์‹ค์ „ ๋ฐ์ดํ„ฐ ์‹œ๋”ฉ โ€” ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ ๋งŒ๋“ค๊ธฐ

์‹ค์ œ๋กœ ํ…Œ์ŠคํŠธํ•ด๋ณด๋ ค๋ฉด ๋ฐ์ดํ„ฐ๊ฐ€ ํ•„์š”ํ•˜์ง€! Python์œผ๋กœ ํ˜„์‹ค์ ์ธ ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ๋ฅผ ๋งŒ๋“ค์–ด๋ณด์ž.

# seed_data.py โ€” ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ ์ƒ์„ฑ
import random
from datetime import date, timedelta
from database import db

# ์˜ํ™” ๋ฐ์ดํ„ฐ
MOVIES = [
    {"id": "m001", "title": "์ธ์…‰์…˜", "year": 2010, "rating": 8.8},
    {"id": "m002", "title": "์ธํ„ฐ์Šคํ…”๋ผ", "year": 2014, "rating": 8.6},
    {"id": "m003", "title": "๋งคํŠธ๋ฆญ์Šค", "year": 1999, "rating": 8.7},
    {"id": "m004", "title": "๊ธฐ์ƒ์ถฉ", "year": 2019, "rating": 8.5},
    {"id": "m005", "title": "์˜ฌ๋“œ๋ณด์ด", "year": 2003, "rating": 8.4},
    {"id": "m006", "title": "์–ด๋ฒค์ ธ์Šค", "year": 2012, "rating": 8.0},
    {"id": "m007", "title": "๋‹คํฌ๋‚˜์ดํŠธ", "year": 2008, "rating": 9.0},
    {"id": "m008", "title": "์‡ผ์ƒํฌํƒˆ์ถœ", "year": 1994, "rating": 9.3},
    {"id": "m009", "title": "๋ผ๋ผ๋žœ๋“œ", "year": 2016, "rating": 8.0},
    {"id": "m010", "title": "๊ฒจ์šธ์™•๊ตญ", "year": 2013, "rating": 7.4},
]

GENRES = ["SF", "์Šค๋ฆด๋Ÿฌ", "์•ก์…˜", "๋“œ๋ผ๋งˆ", "๋กœ๋งจ์Šค", "์• ๋‹ˆ๋ฉ”์ด์…˜", "๋ฒ”์ฃ„"]
MOVIE_GENRES = {
    "m001": ["SF", "์Šค๋ฆด๋Ÿฌ"], "m002": ["SF", "๋“œ๋ผ๋งˆ"],
    "m003": ["SF", "์•ก์…˜"], "m004": ["๋“œ๋ผ๋งˆ", "์Šค๋ฆด๋Ÿฌ"],
    "m005": ["์Šค๋ฆด๋Ÿฌ", "๋ฒ”์ฃ„"], "m006": ["์•ก์…˜", "SF"],
    "m007": ["์•ก์…˜", "๋ฒ”์ฃ„"], "m008": ["๋“œ๋ผ๋งˆ"],
    "m009": ["๋กœ๋งจ์Šค", "๋“œ๋ผ๋งˆ"], "m010": ["์• ๋‹ˆ๋ฉ”์ด์…˜"]
}

def seed_database():
    print("๐ŸŒฑ ๋ฐ์ดํ„ฐ ์‹œ๋”ฉ ์‹œ์ž‘...")
    
    # ์žฅ๋ฅด ์ƒ์„ฑ
    for genre in GENRES:
        db.execute_read(
            "MERGE (g:Genre {name: $name})",
            {"name": genre}
        )
    print(f"โœ… ์žฅ๋ฅด {len(GENRES)}๊ฐœ ์ƒ์„ฑ")
    
    # ์˜ํ™” ์ƒ์„ฑ + ์žฅ๋ฅด ์—ฐ๊ฒฐ
    for movie in MOVIES:
        db.execute_read(
            """
            MERGE (m:Movie {id: $id})
            SET m.title = $title, m.year = $year, m.rating = $rating
            """,
            movie
        )
        for genre_name in MOVIE_GENRES.get(movie["id"], []):
            db.execute_read(
                """
                MATCH (m:Movie {id: $movieId}), (g:Genre {name: $genre})
                MERGE (m)-[:BELONGS_TO]->(g)
                """,
                {"movieId": movie["id"], "genre": genre_name}
            )
    print(f"โœ… ์˜ํ™” {len(MOVIES)}๊ฐœ ์ƒ์„ฑ")
    
    # ์‚ฌ์šฉ์ž 100๋ช… ์ƒ์„ฑ
    cities = ["์„œ์šธ", "๋ถ€์‚ฐ", "๋Œ€๊ตฌ", "์ธ์ฒœ", "๊ด‘์ฃผ"]
    for i in range(1, 101):
        db.execute_read(
            """
            MERGE (u:User {id: $id})
            SET u.name = $name, u.age = $age, u.city = $city
            """,
            {
                "id": f"u{i:03d}",
                "name": f"์‚ฌ์šฉ์ž{i:03d}",
                "age": random.randint(18, 55),
                "city": random.choice(cities)
            }
        )
    print("โœ… ์‚ฌ์šฉ์ž 100๋ช… ์ƒ์„ฑ")
    
    # ์‹œ์ฒญ ๊ธฐ๋ก ์ƒ์„ฑ (์‚ฌ์šฉ์ž๋‹น ํ‰๊ท  5~15ํŽธ)
    watch_count = 0
    for i in range(1, 101):
        user_id = f"u{i:03d}"
        watched_movies = random.sample(MOVIES, random.randint(3, 10))
        for movie in watched_movies:
            score = round(random.uniform(5.0, 10.0), 1)
            days_ago = random.randint(1, 365)
            watch_date = date.today() - timedelta(days=days_ago)
            db.execute_read(
                """
                MATCH (u:User {id: $userId}), (m:Movie {id: $movieId})
                MERGE (u)-[w:WATCHED]->(m)
                SET w.score = $score, w.date = date($date)
                """,
                {
                    "userId": user_id,
                    "movieId": movie["id"],
                    "score": score,
                    "date": str(watch_date)
                }
            )
            watch_count += 1
    print(f"โœ… ์‹œ์ฒญ ๊ธฐ๋ก {watch_count}๊ฐœ ์ƒ์„ฑ")
    
    # ์นœ๊ตฌ ๊ด€๊ณ„ ์ƒ์„ฑ (์‚ฌ์šฉ์ž๋‹น ํ‰๊ท  3~8๋ช…)
    friend_count = 0
    for i in range(1, 101):
        friends = random.sample(
            [j for j in range(1, 101) if j != i],
            random.randint(2, 6)
        )
        for j in friends:
            db.execute_read(
                """
                MATCH (u1:User {id: $id1}), (u2:User {id: $id2})
                MERGE (u1)-[:FRIEND_OF]->(u2)
                """,
                {"id1": f"u{i:03d}", "id2": f"u{j:03d}"}
            )
            friend_count += 1
    print(f"โœ… ์นœ๊ตฌ ๊ด€๊ณ„ {friend_count}๊ฐœ ์ƒ์„ฑ")
    print("๐ŸŽ‰ ๋ฐ์ดํ„ฐ ์‹œ๋”ฉ ์™„๋ฃŒ!")

if __name__ == "__main__":
    seed_database()
๐Ÿ’ก ์ฐธ๊ณ : ์žฌ๋Šฅ๋„ท์ฒ˜๋Ÿผ ์žฌ๋Šฅ์„ ๊ฑฐ๋ž˜ํ•˜๋Š” ํ”Œ๋žซํผ์—์„œ๋„ ์ด์™€ ์œ ์‚ฌํ•œ ๊ทธ๋ž˜ํ”„ ๊ตฌ์กฐ๋ฅผ ํ™œ์šฉํ•  ์ˆ˜ ์žˆ์–ด. ์‚ฌ์šฉ์ž(User) - ์žฌ๋Šฅ(Talent) - ์นดํ…Œ๊ณ ๋ฆฌ(Category) - ๋ฆฌ๋ทฐ(Review) ๋…ธ๋“œ๋ฅผ ์—ฐ๊ฒฐํ•˜๋ฉด, "๋น„์Šทํ•œ ์žฌ๋Šฅ์„ ๊ตฌ๋งคํ•œ ์‚ฌ๋žŒ๋“ค์ด ๊ด€์‹ฌ ์žˆ๋Š” ๋‹ค๋ฅธ ์žฌ๋Šฅ"์„ ์ถ”์ฒœํ•˜๋Š” ์‹œ์Šคํ…œ์„ ๋งŒ๋“ค ์ˆ˜ ์žˆ๊ฑฐ๋“ !

๐Ÿงช ์ถ”์ฒœ ์‹œ์Šคํ…œ ํ‰๊ฐ€ ์ง€ํ‘œ

์ถ”์ฒœ ์‹œ์Šคํ…œ์„ ๋งŒ๋“ค์—ˆ์œผ๋ฉด ์–ผ๋งˆ๋‚˜ ์ž˜ ์ž‘๋™ํ•˜๋Š”์ง€ ํ‰๊ฐ€ํ•ด์•ผ๊ฒ ์ง€? ์ฃผ์š” ํ‰๊ฐ€ ์ง€ํ‘œ๋“ค์„ ์•Œ์•„๋ณด์ž!

์ถ”์ฒœ ์‹œ์Šคํ…œ ํ•ต์‹ฌ ํ‰๊ฐ€ ์ง€ํ‘œ Precision@K ์ถ”์ฒœํ•œ K๊ฐœ ์ค‘ ์‹ค์ œ ์ข‹์•„ํ•œ ๋น„์œจ ์ •๋ฐ€๋„ ๋†’์„์ˆ˜๋ก = ์“ธ๋ชจ์—†๋Š” ์ถ”์ฒœ์ด ์ ์Œ Recall@K ์ข‹์•„ํ•  ๊ฒƒ ์ค‘ ์‹ค์ œ ์ถ”์ฒœ๋œ ๋น„์œจ ์žฌํ˜„์œจ ๋†’์„์ˆ˜๋ก = ์ข‹์•„ํ•  ๊ฒƒ์„ ์ž˜ ์ฐพ์•„๋ƒ„ NDCG@K ์ˆœ์œ„๋ฅผ ๊ณ ๋ คํ•œ ์ถ”์ฒœ ํ’ˆ์งˆ ์ง€ํ‘œ ์ˆœ์œ„ํ’ˆ์งˆ ์ข‹์€ ๊ฒƒ์ด ์•ž์— ์˜ฌ์ˆ˜๋ก ๋†’์€ ์ ์ˆ˜ Coverage ์ „์ฒด ์•„์ดํ…œ ์ค‘ ์ถ”์ฒœ๋œ ์•„์ดํ…œ ๋น„์œจ ์ปค๋ฒ„๋ฆฌ์ง€ ๋‚ฎ์œผ๋ฉด ์ธ๊ธฐ ์•„์ดํ…œ๋งŒ ์ถ”์ฒœํ•˜๋Š” ๋ฌธ์ œ ๋ฐœ์ƒ ๐Ÿ“ ์ฃผ์š” ๊ณต์‹ Precision@K = ๊ด€๋ จ ์ถ”์ฒœ ์ˆ˜ / K Recall@K = ๊ด€๋ จ ์ถ”์ฒœ ์ˆ˜ / ์ „์ฒด ๊ด€๋ จ ์ˆ˜ F1 = 2 ร— (P ร— R) / (P + R) NDCG = DCG / IDCG (DCG = ฮฃ rel_i / logโ‚‚(i+1))
# evaluation.py โ€” ์ถ”์ฒœ ์‹œ์Šคํ…œ ํ‰๊ฐ€
import numpy as np
from typing import List

def precision_at_k(recommended: List[str], relevant: List[str], k: int) -> float:
    """Precision@K ๊ณ„์‚ฐ"""
    recommended_k = recommended[:k]
    hits = len(set(recommended_k) & set(relevant))
    return hits / k if k > 0 else 0.0

def recall_at_k(recommended: List[str], relevant: List[str], k: int) -> float:
    """Recall@K ๊ณ„์‚ฐ"""
    recommended_k = recommended[:k]
    hits = len(set(recommended_k) & set(relevant))
    return hits / len(relevant) if relevant else 0.0

def ndcg_at_k(recommended: List[str], relevant: List[str], k: int) -> float:
    """NDCG@K ๊ณ„์‚ฐ"""
    recommended_k = recommended[:k]
    
    # DCG ๊ณ„์‚ฐ
    dcg = sum(
        1.0 / np.log2(i + 2)  # i+2 because i is 0-indexed
        for i, item in enumerate(recommended_k)
        if item in relevant
    )
    
    # IDCG ๊ณ„์‚ฐ (์ด์ƒ์ ์ธ DCG)
    ideal_hits = min(len(relevant), k)
    idcg = sum(1.0 / np.log2(i + 2) for i in range(ideal_hits))
    
    return dcg / idcg if idcg > 0 else 0.0

def evaluate_recommender(recommender, test_data: dict, k: int = 10) -> dict:
    """์ „์ฒด ํ‰๊ฐ€ ์‹คํ–‰"""
    precisions, recalls, ndcgs = [], [], []
    
    for user_id, relevant_items in test_data.items():
        # ์ถ”์ฒœ ๊ฒฐ๊ณผ ๊ฐ€์ ธ์˜ค๊ธฐ
        recs = recommender.hybrid_recommend(user_id, limit=k)
        recommended_ids = [r['id'] for r in recs]
        
        # ์ง€ํ‘œ ๊ณ„์‚ฐ
        p = precision_at_k(recommended_ids, relevant_items, k)
        r = recall_at_k(recommended_ids, relevant_items, k)
        n = ndcg_at_k(recommended_ids, relevant_items, k)
        
        precisions.append(p)
        recalls.append(r)
        ndcgs.append(n)
    
    avg_p = np.mean(precisions)
    avg_r = np.mean(recalls)
    f1 = 2 * avg_p * avg_r / (avg_p + avg_r) if (avg_p + avg_r) > 0 else 0
    
    return {
        f"Precision@{k}": round(avg_p, 4),
        f"Recall@{k}": round(avg_r, 4),
        f"F1@{k}": round(f1, 4),
        f"NDCG@{k}": round(np.mean(ndcgs), 4)
    }

# ์‚ฌ์šฉ ์˜ˆ์‹œ
# test_data = {"u001": ["m003", "m007", "m009"], ...}
# metrics = evaluate_recommender(recommender, test_data, k=10)
# print(metrics)
# {'Precision@10': 0.3, 'Recall@10': 0.6, 'F1@10': 0.4, 'NDCG@10': 0.45}

๐ŸŒ ์‹ค์ œ ์„œ๋น„์Šค ์•„ํ‚คํ…์ฒ˜ ์„ค๊ณ„

๋งˆ์ง€๋ง‰์œผ๋กœ, ์‹ค์ œ ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ์—์„œ Neo4j ๊ธฐ๋ฐ˜ ์ถ”์ฒœ ์‹œ์Šคํ…œ์„ ์–ด๋–ป๊ฒŒ ๋ฐฐํฌํ•˜๋Š”์ง€ ์‚ดํŽด๋ณด์ž!

ํ”„๋กœ๋•์…˜ ์•„ํ‚คํ…์ฒ˜ ํด๋ผ์ด์–ธํŠธ ์›น / ์•ฑ API Gateway ์ธ์ฆ / ๋ผ์šฐํŒ… FastAPI ์„œ๋ฒ„ ์ถ”์ฒœ API Redis ์บ์‹œ ์ถ”์ฒœ ๊ฒฐ๊ณผ ์บ์‹ฑ (TTL 5๋ถ„) Neo4j ํด๋Ÿฌ์Šคํ„ฐ Primary Secondary Secondary ๋ฐฐ์น˜ ํ”„๋กœ์„ธ์„œ GDS ์‚ฌ์ „ ๊ณ„์‚ฐ (์•ผ๊ฐ„) ๋ชจ๋‹ˆํ„ฐ๋ง Prometheus + Grafana ์ฟผ๋ฆฌ ์„ฑ๋Šฅ / ์—๋Ÿฌ์œจ ์ถ”์  ์บ์‹œ ํžˆํŠธ ์‹œ Neo4j ์ฟผ๋ฆฌ ์—†์ด ์ฆ‰์‹œ ์‘๋‹ต โ†’ ํ‰๊ท  ์‘๋‹ต์‹œ๊ฐ„ < 10ms GDS ์‚ฌ์ „ ๊ณ„์‚ฐ์œผ๋กœ ์‹ค์‹œ๊ฐ„ ๋ถ€ํ•˜ ์ตœ์†Œํ™” โ†’ ์•ผ๊ฐ„ ๋ฐฐ์น˜๋กœ ์œ ์‚ฌ๋„ ์—…๋ฐ์ดํŠธ
๐Ÿณ Docker Compose๋กœ ์ „์ฒด ์Šคํƒ ์‹คํ–‰
# docker-compose.yml
version: '3.8'

services:
  neo4j:
    image: neo4j:5.15.0
    container_name: neo4j
    ports:
      - "7474:7474"
      - "7687:7687"
    environment:
      NEO4J_AUTH: neo4j/password123
      NEO4J_PLUGINS: '["apoc", "graph-data-science"]'
      NEO4J_dbms_memory_heap_max__size: 2G
      NEO4J_dbms_memory_pagecache_size: 1G
    volumes:
      - neo4j_data:/data
      - neo4j_logs:/logs
    healthcheck:
      test: ["CMD", "cypher-shell", "-u", "neo4j", "-p", "password123", "RETURN 1"]
      interval: 30s
      timeout: 10s
      retries: 5

  redis:
    image: redis:7-alpine
    container_name: redis
    ports:
      - "6379:6379"
    command: redis-server --maxmemory 512mb --maxmemory-policy allkeys-lru

  api:
    build: .
    container_name: recommend-api
    ports:
      - "8000:8000"
    environment:
      NEO4J_URI: bolt://neo4j:7687
      NEO4J_USER: neo4j
      NEO4J_PASSWORD: password123
      REDIS_HOST: redis
    depends_on:
      neo4j:
        condition: service_healthy
      redis:
        condition: service_started
    command: uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4

volumes:
  neo4j_data:
  neo4j_logs:
# ์ „์ฒด ์Šคํƒ ์‹คํ–‰
docker-compose up -d

# ๋กœ๊ทธ ํ™•์ธ
docker-compose logs -f api

# ๋ฐ์ดํ„ฐ ์‹œ๋”ฉ
docker-compose exec api python seed_data.py

# API ํ…Œ์ŠคํŠธ
curl http://localhost:8000/recommend/u001?algorithm=hybrid&limit=5

๐ŸŽฏ ๋งˆ๋ฌด๋ฆฌ โ€” Neo4j ์ถ”์ฒœ ์‹œ์Šคํ…œ์˜ ํ•ต์‹ฌ ์ •๋ฆฌ

์ž, ์ด์ œ Neo4j ๊ทธ๋ž˜ํ”„ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋กœ ์ถ”์ฒœ ์‹œ์Šคํ…œ์„ ๊ตฌํ˜„ํ•˜๋Š” ์ „์ฒด ์—ฌ์ •์„ ํ•จ๊ป˜ ๋‹ฌ๋ ค์™”์–ด! ๋งˆ์ง€๋ง‰์œผ๋กœ ํ•ต์‹ฌ ๋‚ด์šฉ์„ ์ •๋ฆฌํ•ด๋ณผ๊ฒŒ.

1
๊ทธ๋ž˜ํ”„ DB์˜ ๊ฐ•์  ์ดํ•ด: ๊ด€๊ณ„๊ฐ€ ๋ณต์žกํ• ์ˆ˜๋ก RDBMS๋ณด๋‹ค ์••๋„์ ์œผ๋กœ ์œ ๋ฆฌ. JOIN ์—†์ด ์ž์—ฐ์Šค๋Ÿฌ์šด ๊ด€๊ณ„ ํƒ์ƒ‰์ด ๊ฐ€๋Šฅํ•ด.
2
๋ฐ์ดํ„ฐ ๋ชจ๋ธ ์„ค๊ณ„: ๋…ธ๋“œ(User, Movie, Genre, Director, Tag)์™€ ๊ด€๊ณ„(WATCHED, BELONGS_TO, FRIEND_OF ๋“ฑ)๋ฅผ ๋ช…ํ™•ํžˆ ์ •์˜ํ•˜๋Š” ๊ฒŒ ํ•ต์‹ฌ์ด์•ผ.
3
Cypher ์ฟผ๋ฆฌ ๋งˆ์Šคํ„ฐ: MATCH-WHERE-RETURN ํŒจํ„ด๊ณผ ๊ฐ€๋ณ€ ๊ธธ์ด ๊ฒฝ๋กœ ํƒ์ƒ‰(*1..2)์„ ์ž˜ ํ™œ์šฉํ•˜๋ฉด ๋ณต์žกํ•œ ์ถ”์ฒœ ๋กœ์ง๋„ ๊ฐ„๊ฒฐํ•˜๊ฒŒ ํ‘œํ˜„ ๊ฐ€๋Šฅํ•ด.
4
3๊ฐ€์ง€ ์ถ”์ฒœ ์•Œ๊ณ ๋ฆฌ์ฆ˜: ํ˜‘์—… ํ•„ํ„ฐ๋ง + ์ฝ˜ํ…์ธ  ๊ธฐ๋ฐ˜ + ์†Œ์…œ ๊ทธ๋ž˜ํ”„๋ฅผ ์กฐํ•ฉํ•œ ํ•˜์ด๋ธŒ๋ฆฌ๋“œ ๋ฐฉ์‹์ด ์‹ค์ œ ์„œ๋น„์Šค์—์„œ ๊ฐ€์žฅ ํšจ๊ณผ์ ์ด์•ผ.
5
GDS ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ํ™œ์šฉ: Node Similarity, PageRank, Community Detection ๋“ฑ ๊ฐ•๋ ฅํ•œ ๊ทธ๋ž˜ํ”„ ์•Œ๊ณ ๋ฆฌ์ฆ˜์„ ๋ฐ”๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์–ด.
6
์„ฑ๋Šฅ ์ตœ์ ํ™”: ์ธ๋ฑ์Šค ์„ค๊ณ„ + Redis ์บ์‹ฑ + GDS ์‚ฌ์ „ ๊ณ„์‚ฐ ์กฐํ•ฉ์œผ๋กœ ์‹ค์‹œ๊ฐ„ ์‘๋‹ต 10ms ์ดํ•˜ ๋‹ฌ์„ฑ ๊ฐ€๋Šฅํ•ด.
7
ํ‰๊ฐ€์™€ ๊ฐœ์„ : Precision@K, Recall@K, NDCG@K๋กœ ์ถ”์ฒœ ํ’ˆ์งˆ์„ ์ธก์ •ํ•˜๊ณ  ์ง€์†์ ์œผ๋กœ ๊ฐœ์„ ํ•ด๋‚˜๊ฐ€์•ผ ํ•ด.
๐ŸŒŸ Neo4j ์ถ”์ฒœ ์‹œ์Šคํ…œ์˜ ์ง„์งœ ๋งค๋ ฅ: ๋‹จ์ˆœํžˆ "๋น„์Šทํ•œ ๊ฒƒ์„ ์ถ”์ฒœ"ํ•˜๋Š” ๊ฒƒ์„ ๋„˜์–ด์„œ, ์™œ ์ด๊ฒƒ์„ ์ถ”์ฒœํ•˜๋Š”์ง€ ์„ค๋ช…ํ•  ์ˆ˜ ์žˆ์–ด. "๋‹น์‹ ์˜ ์นœ๊ตฌ Alice๊ฐ€ ์ข‹์•„ํ–ˆ๊ณ , ๋‹น์‹ ์ด ์ข‹์•„ํ•˜๋Š” SF ์žฅ๋ฅด์ด๋ฉฐ, ํฌ๋ฆฌ์Šคํ† ํผ ๋†€๋ž€ ๊ฐ๋…์˜ ์ž‘ํ’ˆ์ด๊ธฐ ๋•Œ๋ฌธ์— ์ถ”์ฒœํ•ฉ๋‹ˆ๋‹ค" โ€” ์ด๋Ÿฐ ์„ค๋ช… ๊ฐ€๋Šฅํ•œ ์ถ”์ฒœ์ด ์‚ฌ์šฉ์ž ์‹ ๋ขฐ๋ฅผ ๋†’์ด๋Š” ํ•ต์‹ฌ์ด์•ผ!

์žฌ๋Šฅ๋„ท์—์„œ๋„ ์ด๋Ÿฐ ๊ทธ๋ž˜ํ”„ ๊ธฐ๋ฐ˜ ์ถ”์ฒœ ์‹œ์Šคํ…œ์„ ํ™œ์šฉํ•˜๋ฉด, ๋‹จ์ˆœํ•œ ์นดํ…Œ๊ณ ๋ฆฌ ๋งค์นญ์„ ๋„˜์–ด์„œ "๋‹น์‹ ๊ณผ ๋น„์Šทํ•œ ํ”„๋กœ์ ํŠธ๋ฅผ ์ง„ํ–‰ํ•œ ์‚ฌ๋žŒ๋“ค์ด ์ž์ฃผ ์ฐพ๋Š” ์žฌ๋Šฅ"์ด๋‚˜ "๋‹น์‹ ์ด ๊ตฌ๋งคํ•œ ์žฌ๋Šฅ๊ณผ ์‹œ๋„ˆ์ง€๊ฐ€ ์ข‹์€ ๋‹ค๋ฅธ ์žฌ๋Šฅ"์„ ์ถ”์ฒœํ•˜๋Š” ํ›จ์”ฌ ์Šค๋งˆํŠธํ•œ ๊ฒฝํ—˜์„ ๋งŒ๋“ค ์ˆ˜ ์žˆ์–ด!

๊ทธ๋ž˜ํ”„ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋Š” ์ฒ˜์Œ์—” ๋‚ฏ์„ค ์ˆ˜ ์žˆ์ง€๋งŒ, ํ•œ๋ฒˆ ์ต์ˆ™ํ•ด์ง€๋ฉด "์ด๊ฑธ ์™œ ์ด์ œ์•ผ ์•Œ์•˜์ง€?" ์‹ถ์„ ๋งŒํผ ๊ฐ•๋ ฅํ•œ ๋„๊ตฌ์•ผ. ํŠนํžˆ ์ถ”์ฒœ ์‹œ์Šคํ…œ์ฒ˜๋Ÿผ ๊ด€๊ณ„๊ฐ€ ํ•ต์‹ฌ์ธ ๋„๋ฉ”์ธ์—์„œ๋Š” ์ •๋ง ๋น›์„ ๋ฐœํ•˜๊ฑฐ๋“ . ์ž, ์ด์ œ ์ง์ ‘ Neo4j๋ฅผ ์„ค์น˜ํ•˜๊ณ  ๊ทธ๋ž˜ํ”„์˜ ์„ธ๊ณ„๋กœ ๋›ฐ์–ด๋“ค์–ด๋ด! ๐Ÿš€

๋Œ“๊ธ€ ์ž‘์„ฑ

์ด ๊ธ€์— ๋Œ€ํ•œ ์—ฌ๋Ÿฌ๋ถ„์˜ ์ƒ๊ฐ์„ ๋“ค๋ ค์ฃผ์„ธ์š”

๋Œ“๊ธ€ 0
๋Œ“๊ธ€์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.

์•„์ง ๋Œ“๊ธ€์ด ์—†์Šต๋‹ˆ๋‹ค.