Ver3.0 ๐ PHP์ Elasticsearch๋ก ๋ง๋๋ ๊ฐ๋ ฅํ ์ ๋ฌธ ๊ฒ์ ์์คํ ์๋ฒฝ ๊ฐ์ด๋

๐ PHP์ Elasticsearch๋ก ๋ง๋๋ ๊ฐ๋ ฅํ ์ ๋ฌธ ๊ฒ์ ์์คํ ์๋ฒฝ ๊ฐ์ด๋
๊ฒ์ ๊ธฐ๋ฅ์ ํ ๋จ๊ณ ์ ๊ทธ๋ ์ด๋ํ๊ณ ์ถ๋ค๋ฉด? ์ง๊ธ ๋ฐ๋ก ์์ํด๋ณด์!
๐ฏ ์ Elasticsearch์ธ๊ฐ?
์น์ฌ์ดํธ๋ ์ ํ๋ฆฌ์ผ์ด์ ์ ๋ง๋ค๋ค ๋ณด๋ฉด ๊ฒ์ ๊ธฐ๋ฅ์ ํ์์ผ. ๊ทธ๋ฐ๋ฐ MySQL์ LIKE ์ฟผ๋ฆฌ๋ก๋ ํ๊ณ๊ฐ ๋ช ํํ์ง. ์ฌ์ฉ์๊ฐ ๋์ด๋๊ณ ๋ฐ์ดํฐ๊ฐ ์์ด๋ฉด ๊ฒ์ ์๋๊ฐ ๋๋ ค์ง๊ณ , ์คํ๋ฅผ ์ ๋ ฅํ๋ฉด ๊ฒฐ๊ณผ๊ฐ ์ ๋์ค๊ณ , ๊ด๋ จ์ฑ ๋์ ๊ฒฐ๊ณผ๋ฅผ ์ฐ์ ์ ์ผ๋ก ๋ณด์ฌ์ฃผ๊ธฐ๋ ์ด๋ ต์ง. ๐
์ด๋ด ๋ ๋ฑ์ฅํ๋ ๊ฒ ๋ฐ๋ก Elasticsearch์ผ! ์ด๊ฑด ๋จ์ํ ๋ฐ์ดํฐ๋ฒ ์ด์ค๊ฐ ์๋๋ผ ๊ฒ์์ ํนํ๋ ์์ง์ด๊ฑฐ๋ . ์ค์๊ฐ์ผ๋ก ๋น ๋ฅด๊ฒ ๊ฒ์ํ๊ณ , ์์ฐ์ด ์ฒ๋ฆฌ๋ ๊ฐ๋ฅํ๊ณ , ์ฌ์ง์ด ์คํ๋ ์ด๋ ์ ๋ ์ปค๋ฒํด์ค. ๋ค์ด๋ฒ, ๊ตฌ๊ธ ๊ฐ์ ๋ํ ํฌํธ๋ถํฐ ์์ ์คํํธ์ ๊น์ง ๋ง์ด ์ฌ์ฉํ๋ ์ด์ ๊ฐ ๋ค ์์ด.
๐ก ์ฌ๋ฅ๋ท ๊ฐ์ ํ๋ซํผ์์๋ ํ์ฉ ๊ฐ๋ฅ!
์ฌ๋ฅ๋ท์ฒ๋ผ ๋ค์ํ ์ฌ๋ฅ๊ณผ ์๋น์ค๋ฅผ ์ฐ๊ฒฐํ๋ ํ๋ซํผ์์๋ ์ฌ์ฉ์๊ฐ ์ํ๋ ์ฌ๋ฅ์ ๋น ๋ฅด๊ณ ์ ํํ๊ฒ ์ฐพ๋ ๊ฒ ํต์ฌ์ด์ผ. Elasticsearch๋ฅผ ํ์ฉํ๋ฉด "๋์์ธ", "๋์์ด๋", "๋์์ธ ์์ " ๊ฐ์ ์ ์ฌ ๊ฒ์์ด๋ ๋๋ํ๊ฒ ์ฒ๋ฆฌํ ์ ์์ง!
๐๏ธ Elasticsearch ๊ธฐ๋ณธ ๊ฐ๋ ์ดํดํ๊ธฐ
๋ณธ๊ฒฉ์ ์ผ๋ก ์ฝ๋๋ฅผ ์์ฑํ๊ธฐ ์ ์ Elasticsearch์ ๊ธฐ๋ณธ ๊ฐ๋ ์ ์์์ผ ํด. ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ๋น์ทํ์ง๋ง ์ฉ์ด๊ฐ ์ข ๋ฌ๋ผ์ ์ฒ์์ ํท๊ฐ๋ฆด ์ ์๊ฑฐ๋ .
๐ฆ Index (์ธ๋ฑ์ค)
MySQL์ ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ๋น์ทํ ๊ฐ๋ ์ด์ผ. ๊ด๋ จ๋ ๋ฌธ์๋ค์ ๋ชจ์๋์ ์ปฌ๋ ์ ์ด์ง. ์๋ฅผ ๋ค์ด 'products', 'users', 'posts' ๊ฐ์ ์ธ๋ฑ์ค๋ฅผ ๋ง๋ค ์ ์์ด.
๐ Document (๋ฌธ์)
MySQL์ row์ ๊ฐ์ ๊ฐ๋ . JSON ํํ๋ก ์ ์ฅ๋๋ฉฐ, ์ค์ ๊ฒ์ ๋์์ด ๋๋ ๋ฐ์ดํฐ์ผ. ๊ฐ ๋ฌธ์๋ ๊ณ ์ ํ ID๋ฅผ ๊ฐ์ง๊ณ ์์ง.
๐ท๏ธ Field (ํ๋)
MySQL์ column๊ณผ ๊ฐ์. ๋ฌธ์ ๋ด์ ๊ฐ ์์ฑ์ ์๋ฏธํด. 'title', 'content', 'price' ๊ฐ์ ํ๋๋ค์ด ์์ ์ ์์ง.
๐ Mapping (๋งคํ)
MySQL์ ์คํค๋ง์ ๋น์ทํด. ๊ฐ ํ๋์ ๋ฐ์ดํฐ ํ์ ๊ณผ ๋ถ์ ๋ฐฉ๋ฒ์ ์ ์ํ๋ ๊ฑฐ์ผ. ๊ฒ์ ํ์ง์ ํฐ ์ํฅ์ ๋ฏธ์น๋ ์ค์ํ ๋ถ๋ถ์ด์ง.
๐ ์ญ์์ธ(Inverted Index)์ด๋?
Elasticsearch๊ฐ ๋น ๋ฅธ ์ด์ ๋ ์ญ์์ธ ๊ตฌ์กฐ ๋๋ถ์ด์ผ. ์ผ๋ฐ์ ์ธ ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ "๋ฌธ์ โ ๋จ์ด" ๋ฐฉํฅ์ผ๋ก ์ ์ฅํ์ง๋ง, Elasticsearch๋ "๋จ์ด โ ๋ฌธ์" ๋ฐฉํฅ์ผ๋ก ์ ์ฅํด.
์๋ฅผ ๋ค์ด๋ณผ๊ฒ:
๋ฌธ์ 1: "PHP๋ ์น ๊ฐ๋ฐ์ ์ข์ ์ธ์ด์
๋๋ค"
๋ฌธ์ 2: "PHP์ Elasticsearch๋ฅผ ํจ๊ป ์ฌ์ฉํ๋ฉด ์ข์ต๋๋ค"
์ญ์์ธ ๊ตฌ์กฐ:
โข PHP โ [๋ฌธ์1, ๋ฌธ์2]
โข ์น โ [๋ฌธ์1]
โข ๊ฐ๋ฐ โ [๋ฌธ์1]
โข Elasticsearch โ [๋ฌธ์2]
โข ์ข์, ์ข์ต๋๋ค โ [๋ฌธ์1, ๋ฌธ์2]
์ด๋ ๊ฒ ์ ์ฅํ๋ฉด "PHP"๋ฅผ ๊ฒ์ํ ๋ ๋ชจ๋ ๋ฌธ์๋ฅผ ๋ค์ง ํ์ ์์ด ๋ฐ๋ก ๊ด๋ จ ๋ฌธ์๋ฅผ ์ฐพ์ ์ ์์ด! ๐
โ๏ธ ํ๊ฒฝ ์ค์ : PHP์ Elasticsearch ์ฐ๊ฒฐํ๊ธฐ
์, ์ด์ ์ค์ ์ผ๋ก ๋ค์ด๊ฐ๋ณด์! PHP์์ Elasticsearch๋ฅผ ์ฌ์ฉํ๋ ค๋ฉด ๊ณต์ ํด๋ผ์ด์ธํธ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ค์นํด์ผ ํด.
Step 1: Elasticsearch ์ค์น
๋จผ์ Elasticsearch๋ฅผ ์ค์นํด์ผ ํด. Docker๋ฅผ ์ฌ์ฉํ๋ฉด ์ ๋ง ๊ฐ๋จํด:
docker pull docker.elastic.co/elasticsearch/elasticsearch:8.11.0
docker run -d --name elasticsearch \
-p 9200:9200 -p 9300:9300 \
-e "discovery.type=single-node" \
-e "xpack.security.enabled=false" \
docker.elastic.co/elasticsearch/elasticsearch:8.11.0
์ค์น๊ฐ ์๋ฃ๋๋ฉด ๋ธ๋ผ์ฐ์ ์์ http://localhost:9200์ ์ ์ํด๋ด. JSON ํํ์ ์๋ต์ด ๋์ค๋ฉด ์ฑ๊ณต์ด์ผ! ๐
Step 2: PHP Elasticsearch ํด๋ผ์ด์ธํธ ์ค์น
Composer๋ฅผ ์ฌ์ฉํด์ ๊ณต์ ํด๋ผ์ด์ธํธ๋ฅผ ์ค์นํ์:
composer require elasticsearch/elasticsearch
์ค์น๊ฐ ์๋ฃ๋๋ฉด PHP ์ฝ๋์์ ๋ฐ๋ก ์ฌ์ฉํ ์ ์์ด!
Step 3: ๊ธฐ๋ณธ ์ฐ๊ฒฐ ํ ์คํธ
์ ๋๋ก ์ฐ๊ฒฐ๋๋์ง ํ์ธํด๋ณด์:
<?php
require 'vendor/autoload.php';
use Elastic\Elasticsearch\ClientBuilder;
// Elasticsearch ํด๋ผ์ด์ธํธ ์์ฑ
$client = ClientBuilder::create()
->setHosts(['localhost:9200'])
->build();
// ์ฐ๊ฒฐ ํ
์คํธ
try {
$response = $client->info();
echo "์ฐ๊ฒฐ ์ฑ๊ณต!\n";
print_r($response->asArray());
} catch (Exception $e) {
echo "์ฐ๊ฒฐ ์คํจ: " . $e->getMessage();
}
โ ๏ธ ์ฃผ์์ฌํญ
Elasticsearch 8.x ๋ฒ์ ๋ถํฐ๋ ๊ธฐ๋ณธ์ ์ผ๋ก ๋ณด์ ๊ธฐ๋ฅ์ด ํ์ฑํ๋์ด ์์ด. ๊ฐ๋ฐ ํ๊ฒฝ์์๋ xpack.security.enabled=false๋ก ๋นํ์ฑํํ ์ ์์ง๋ง, ํ๋ก๋์
ํ๊ฒฝ์์๋ ๋ฐ๋์ ์ธ์ฆ์ ์ค์ ํด์ผ ํด!
๐ ์ธ๋ฑ์ค ์์ฑ๊ณผ ๋งคํ ์ค์
์ด์ ์ค์ ๋ก ๋ฐ์ดํฐ๋ฅผ ์ ์ฅํ ์ธ๋ฑ์ค๋ฅผ ๋ง๋ค์ด๋ณด์. ์๋ฅผ ๋ค์ด ์ํ ๊ฒ์ ์์คํ ์ ๋ง๋ ๋ค๊ณ ๊ฐ์ ํด๋ณผ๊ฒ.
๐จ ๋งคํ ์ค๊ณ์ ์ค์์ฑ
๋งคํ์ ๊ฒ์ ํ์ง์ ๊ฒฐ์ ํ๋ ํต์ฌ์ด์ผ. ๊ฐ ํ๋๋ฅผ ์ด๋ป๊ฒ ๋ถ์ํ ์ง, ์ด๋ค ํ์ ์ผ๋ก ์ ์ฅํ ์ง ์ ์ํ๋ ๊ฑฐ๊ฑฐ๋ . ํ ๋ฒ ์ค์ ํ๋ฉด ๋ณ๊ฒฝ์ด ์ด๋ ค์ฐ๋๊น ์ฒ์๋ถํฐ ์ ์คํ๊ฒ ์ค๊ณํด์ผ ํด!
<?php
require 'vendor/autoload.php';
use Elastic\Elasticsearch\ClientBuilder;
$client = ClientBuilder::create()
->setHosts(['localhost:9200'])
->build();
// ์ธ๋ฑ์ค ์์ฑ ๋ฐ ๋งคํ ์ค์
$params = [
'index' => 'products',
'body' => [
'settings' => [
'number_of_shards' => 1,
'number_of_replicas' => 0,
'analysis' => [
'analyzer' => [
'korean' => [
'type' => 'custom',
'tokenizer' => 'nori_tokenizer',
'filter' => ['lowercase', 'nori_part_of_speech']
]
]
]
],
'mappings' => [
'properties' => [
'id' => [
'type' => 'integer'
],
'name' => [
'type' => 'text',
'analyzer' => 'korean',
'fields' => [
'keyword' => [
'type' => 'keyword'
]
]
],
'description' => [
'type' => 'text',
'analyzer' => 'korean'
],
'category' => [
'type' => 'keyword'
],
'price' => [
'type' => 'integer'
],
'tags' => [
'type' => 'keyword'
],
'created_at' => [
'type' => 'date',
'format' => 'yyyy-MM-dd HH:mm:ss'
],
'rating' => [
'type' => 'float'
],
'stock' => [
'type' => 'integer'
]
]
]
]
];
try {
// ๊ธฐ์กด ์ธ๋ฑ์ค๊ฐ ์์ผ๋ฉด ์ญ์
if ($client->indices()->exists(['index' => 'products'])->asBool()) {
$client->indices()->delete(['index' => 'products']);
}
// ์ ์ธ๋ฑ์ค ์์ฑ
$response = $client->indices()->create($params);
echo "์ธ๋ฑ์ค ์์ฑ ์๋ฃ!\n";
} catch (Exception $e) {
echo "์๋ฌ ๋ฐ์: " . $e->getMessage();
}
๐ ๋งคํ ์ค์ ํด์ค
1. text vs keyword
โข text: ์ ๋ฌธ ๊ฒ์์ฉ. ํํ์ ๋ถ์์ ๊ฑฐ์ณ ํ ํฐ์ผ๋ก ๋๋์ด ์ ์ฅ๋ผ. "์ค๋งํธํฐ ์ผ์ด์ค"๋ "์ค๋งํธํฐ", "์ผ์ด์ค"๋ก ๋ถ๋ฆฌ๋์ง.
โข keyword: ์ ํํ ๋งค์นญ์ฉ. ๋ถ์ ์์ด ์๋ณธ ๊ทธ๋๋ก ์ ์ฅ๋ผ. ์นดํ
๊ณ ๋ฆฌ, ํ๊ทธ, ์ํ๊ฐ ๊ฐ์ ๊ณณ์ ์ฌ์ฉํด.
2. multi-field (fields)
name ํ๋๋ฅผ ๋ณด๋ฉด text์ keyword ๋ ๊ฐ์ง๋ก ์ ์ฅํ๊ณ ์์ด. ์ด๋ ๊ฒ ํ๋ฉด ์ ๋ฌธ ๊ฒ์๋ ํ๊ณ , ์ ๋ ฌ์ด๋ ์ง๊ณ๋ ํ ์ ์์ง!
3. analyzer
ํ๊ตญ์ด ๊ฒ์์ ์ํด nori ๋ถ์๊ธฐ๋ฅผ ์ฌ์ฉํ์ด. ์ด๊ฑด Elasticsearch์ ๊ธฐ๋ณธ ํฌํจ๋ ํ๊ตญ์ด ํํ์ ๋ถ์๊ธฐ์ผ. "๋จน์์ต๋๋ค"๋ฅผ "๋จน๋ค"๋ก ์ ๊ทํํด์ฃผ๋ ๋๋ํ ๋ ์์ด์ง! ๐ง
๐พ ๋ฌธ์ ์์ธํ๊ธฐ (Indexing)
์ธ๋ฑ์ค๋ฅผ ๋ง๋ค์์ผ๋ ์ด์ ์ค์ ๋ฐ์ดํฐ๋ฅผ ๋ฃ์ด๋ณด์! ๋ฌธ์๋ฅผ ์ถ๊ฐํ๋ ๋ฐฉ๋ฒ์ ์ฌ๋ฌ ๊ฐ์ง๊ฐ ์์ด.
๋จ์ผ ๋ฌธ์ ์์ธ
<?php
// ๋จ์ผ ์ํ ์ถ๊ฐ
$params = [
'index' => 'products',
'id' => 1,
'body' => [
'id' => 1,
'name' => '๋ฌด์ ๋ธ๋ฃจํฌ์ค ์ด์ดํฐ',
'description' => '๊ณ ์์ง ๋ฌด์ ์ด์ดํฐ์ผ๋ก ์ต๋ 24์๊ฐ ์ฌ์ ๊ฐ๋ฅํฉ๋๋ค. ๋
ธ์ด์ฆ ์บ์ฌ๋ง ๊ธฐ๋ฅ ์ง์',
'category' => '์ ์์ ํ',
'price' => 89000,
'tags' => ['์ด์ดํฐ', '๋ธ๋ฃจํฌ์ค', '๋ฌด์ ', '๋
ธ์ด์ฆ์บ์ฌ๋ง'],
'created_at' => date('Y-m-d H:i:s'),
'rating' => 4.5,
'stock' => 150
]
];
try {
$response = $client->index($params);
echo "๋ฌธ์ ์ถ๊ฐ ์๋ฃ! ID: " . $response['_id'] . "\n";
} catch (Exception $e) {
echo "์๋ฌ: " . $e->getMessage();
}
๋๋ ๋ฌธ์ ์์ธ (Bulk API)
์ฌ๋ฌ ๋ฌธ์๋ฅผ ํ ๋ฒ์ ์ถ๊ฐํ ๋๋ Bulk API๋ฅผ ์ฌ์ฉํ๋ฉด ํจ์ฌ ๋นจ๋ผ! ๋คํธ์ํฌ ์๋ณต ํ์๊ฐ ์ค์ด๋ค๊ฑฐ๋ .
<?php
// ์ฌ๋ฌ ์ํ ํ ๋ฒ์ ์ถ๊ฐ
$products = [
[
'id' => 2,
'name' => '์ค๋งํธ์์น ํ๋ก',
'description' => '๊ฑด๊ฐ ๊ด๋ฆฌ์ ์ด๋ ์ถ์ ์ด ๊ฐ๋ฅํ ์ค๋งํธ์์น',
'category' => '์ ์์ ํ',
'price' => 350000,
'tags' => ['์ค๋งํธ์์น', 'ํฌ์ค์ผ์ด', '์ด๋'],
'created_at' => date('Y-m-d H:i:s'),
'rating' => 4.7,
'stock' => 80
],
[
'id' => 3,
'name' => '๊ฒ์ด๋ฐ ํค๋ณด๋',
'description' => '๊ธฐ๊ณ์ ์ค์์น๋ฅผ ์ฌ์ฉํ RGB ๊ฒ์ด๋ฐ ํค๋ณด๋',
'category' => '์ปดํจํฐ์ฃผ๋ณ๊ธฐ๊ธฐ',
'price' => 129000,
'tags' => ['ํค๋ณด๋', '๊ฒ์ด๋ฐ', '๊ธฐ๊ณ์', 'RGB'],
'created_at' => date('Y-m-d H:i:s'),
'rating' => 4.3,
'stock' => 200
],
[
'id' => 4,
'name' => '๋ฌด์ ๋ง์ฐ์ค',
'description' => '์ธ์ฒด๊ณตํ์ ๋์์ธ์ ๋ฌด์ ๋ง์ฐ์ค',
'category' => '์ปดํจํฐ์ฃผ๋ณ๊ธฐ๊ธฐ',
'price' => 45000,
'tags' => ['๋ง์ฐ์ค', '๋ฌด์ ', '์ธ์ฒด๊ณตํ'],
'created_at' => date('Y-m-d H:i:s'),
'rating' => 4.1,
'stock' => 300
]
];
$params = ['body' => []];
foreach ($products as $product) {
$params['body'][] = [
'index' => [
'_index' => 'products',
'_id' => $product['id']
]
];
$params['body'][] = $product;
}
try {
$response = $client->bulk($params);
if ($response['errors']) {
echo "์ผ๋ถ ๋ฌธ์ ์ถ๊ฐ ์คํจ\n";
} else {
echo "๋ชจ๋ ๋ฌธ์ ์ถ๊ฐ ์๋ฃ!\n";
}
} catch (Exception $e) {
echo "์๋ฌ: " . $e->getMessage();
}
๐ ์ฑ๋ฅ ์ต์ ํ ํ
โข Bulk API ์ฌ์ฉ ์ ํ ๋ฒ์ 1000~5000๊ฐ ์ ๋๊ฐ ์ ๋นํด
โข ๋๋ฌด ๋ง์ผ๋ฉด ๋ฉ๋ชจ๋ฆฌ ๋ฌธ์ ๊ฐ ์๊ธธ ์ ์๊ณ , ๋๋ฌด ์ ์ผ๋ฉด ํจ์จ์ด ๋จ์ด์ ธ
โข ์ค์๊ฐ ๋ฐ์์ด ํ์ํ๋ฉด refresh=true ์ต์
์ ์ฌ์ฉํ๋, ์ฑ๋ฅ์ ์ํฅ์ ์ค ์ ์์ผ๋ ์ฃผ์ํด์ผ ํด
MySQL ๋ฐ์ดํฐ๋ฅผ Elasticsearch๋ก ๋ง์ด๊ทธ๋ ์ด์
๊ธฐ์กด MySQL ๋ฐ์ดํฐ๋ฅผ Elasticsearch๋ก ์ฎ๊ธฐ๋ ์ค์ ์์ ์ผ:
<?php
// MySQL ์ฐ๊ฒฐ
$pdo = new PDO('mysql:host=localhost;dbname=myshop', 'username', 'password');
// ์ํ ๋ฐ์ดํฐ ๊ฐ์ ธ์ค๊ธฐ
$stmt = $pdo->query("SELECT * FROM products");
$products = $stmt->fetchAll(PDO::FETCH_ASSOC);
$params = ['body' => []];
$count = 0;
foreach ($products as $product) {
$params['body'][] = [
'index' => [
'_index' => 'products',
'_id' => $product['id']
]
];
$params['body'][] = [
'id' => (int)$product['id'],
'name' => $product['name'],
'description' => $product['description'],
'category' => $product['category'],
'price' => (int)$product['price'],
'tags' => explode(',', $product['tags']),
'created_at' => $product['created_at'],
'rating' => (float)$product['rating'],
'stock' => (int)$product['stock']
];
$count++;
// 1000๊ฐ์ฉ ๋ฌถ์ด์ ์ ์ก
if ($count % 1000 == 0) {
$client->bulk($params);
$params = ['body' => []];
echo "$count ๊ฐ ์ฒ๋ฆฌ ์๋ฃ...\n";
}
}
// ๋จ์ ๋ฐ์ดํฐ ์ฒ๋ฆฌ
if (!empty($params['body'])) {
$client->bulk($params);
}
echo "์ด $count ๊ฐ ๋ง์ด๊ทธ๋ ์ด์
์๋ฃ!\n";
๐ ๊ฒ์ ์ฟผ๋ฆฌ ์์ฑํ๊ธฐ
๋๋์ด ๊ฒ์์ ํต์ฌ ๋ถ๋ถ์ด์ผ! Elasticsearch๋ ์ ๋ง ๋ค์ํ ๊ฒ์ ๋ฐฉ๋ฒ์ ์ ๊ณตํด. ํ๋์ฉ ์ดํด๋ณด์.
1๏ธโฃ ๊ธฐ๋ณธ ๋งค์น ์ฟผ๋ฆฌ (Match Query)
๊ฐ์ฅ ๊ธฐ๋ณธ์ ์ธ ์ ๋ฌธ ๊ฒ์์ด์ผ. ์ ๋ ฅํ ํ ์คํธ๋ฅผ ๋ถ์ํด์ ๊ด๋ จ ๋ฌธ์๋ฅผ ์ฐพ์์ค.
<?php
// "๋ธ๋ฃจํฌ์ค ์ด์ดํฐ" ๊ฒ์
$params = [
'index' => 'products',
'body' => [
'query' => [
'match' => [
'name' => '๋ธ๋ฃจํฌ์ค ์ด์ดํฐ'
]
]
]
];
$response = $client->search($params);
foreach ($response['hits']['hits'] as $hit) {
echo "์ํ๋ช
: " . $hit['_source']['name'] . "\n";
echo "์ ์: " . $hit['_score'] . "\n";
echo "---\n";
}
"๋ธ๋ฃจํฌ์ค ์ด์ดํฐ"์ ๊ฒ์ํ๋ฉด "๋ธ๋ฃจํฌ์ค"๋ "์ด์ดํฐ" ์ค ํ๋๋ผ๋ ํฌํจ๋ ๋ฌธ์๋ฅผ ์ฐพ์์ค. ๋ ๋ค ํฌํจ๋ ๋ฌธ์๊ฐ ๋ ๋์ ์ ์๋ฅผ ๋ฐ์ง!
2๏ธโฃ ๋ฉํฐ ๋งค์น ์ฟผ๋ฆฌ (Multi Match Query)
์ฌ๋ฌ ํ๋๋ฅผ ๋์์ ๊ฒ์ํ ๋ ์ฌ์ฉํด. ์ ๋ชฉ๊ณผ ์ค๋ช ์ ํจ๊ป ๊ฒ์ํ๋ ๊ฒฝ์ฐ๊ฐ ๋ง์์?
<?php
$params = [
'index' => 'products',
'body' => [
'query' => [
'multi_match' => [
'query' => '๋ฌด์ ๊ฒ์ด๋ฐ',
'fields' => ['name^2', 'description'], // name ํ๋์ 2๋ฐฐ ๊ฐ์ค์น
'type' => 'best_fields'
]
]
]
];
$response = $client->search($params);
^2๋ ๊ฐ์ค์น์ผ. name ํ๋์์ ๋งค์นญ๋๋ฉด ์ ์๋ฅผ 2๋ฐฐ๋ก ์ฌ๋ ค์ค. ์ ๋ชฉ์ด ์ค๋ช
๋ณด๋ค ์ค์ํ๋๊น!
3๏ธโฃ ๋ถ ์ฟผ๋ฆฌ (Bool Query)
์ฌ๋ฌ ์กฐ๊ฑด์ ์กฐํฉํ ๋ ์ฌ์ฉํด. must(AND), should(OR), must_not(NOT), filter ๊ฐ์ ์กฐ๊ฑด์ ์์ ์ ์์ด.
<?php
// "์ ์์ ํ" ์นดํ
๊ณ ๋ฆฌ์์ "๋ฌด์ "์ด ํฌํจ๋๊ณ ,
// ๊ฐ๊ฒฉ์ด 100,000์ ์ดํ์ธ ์ํ ๊ฒ์
$params = [
'index' => 'products',
'body' => [
'query' => [
'bool' => [
'must' => [
['match' => ['description' => '๋ฌด์ ']]
],
'filter' => [
['term' => ['category' => '์ ์์ ํ']],
['range' => ['price' => ['lte' => 100000]]]
],
'should' => [
['match' => ['tags' => '๋ธ๋ฃจํฌ์ค']]
],
'minimum_should_match' => 0
]
],
'sort' => [
['rating' => ['order' => 'desc']],
['_score' => ['order' => 'desc']]
]
]
];
$response = $client->search($params);
๐ must vs filter ์ฐจ์ด์
โข must: ์ค์ฝ์ด ๊ณ์ฐ์ ์ํฅ์ ์ค. ๊ด๋ จ์ฑ์ด ๋์์๋ก ์ ์๊ฐ ์ฌ๋ผ๊ฐ
โข filter: ์ค์ฝ์ด ๊ณ์ฐ ์ ํด. ๋จ์ํ ์กฐ๊ฑด ๋ง์กฑ ์ฌ๋ถ๋ง ์ฒดํฌํด. ์บ์ฑ๋์ด์ ๋ ๋นจ๋ผ!
์นดํ ๊ณ ๋ฆฌ๋ ๊ฐ๊ฒฉ ๊ฐ์ ์ ํํ ์กฐ๊ฑด์ filter๋ก, ํ ์คํธ ๊ฒ์์ must๋ก ์ฒ๋ฆฌํ๋ ๊ฒ ์ข์.
4๏ธโฃ ํผ์ง ์ฟผ๋ฆฌ (Fuzzy Query)
์คํ๋ฅผ ์ฒ๋ฆฌํด์ฃผ๋ ๋๋ํ ๊ฒ์์ด์ผ! ์ฌ์ฉ์๊ฐ "๋ธํฌํฌ์ค"๋ผ๊ณ ์๋ชป ์ ๋ ฅํด๋ "๋ธ๋ฃจํฌ์ค"๋ฅผ ์ฐพ์์ค.
<?php
$params = [
'index' => 'products',
'body' => [
'query' => [
'match' => [
'name' => [
'query' => '๋ธํฌํฌ์ค',
'fuzziness' => 'AUTO' // ์๋์ผ๋ก ํธ์ง ๊ฑฐ๋ฆฌ ๊ณ์ฐ
]
]
]
]
];
$response = $client->search($params);
fuzziness๋ ์ผ๋ง๋ ๋ค๋ฅธ ๊ธ์๋ฅผ ํ์ฉํ ์ง ์ ํด. 'AUTO'๋ก ์ค์ ํ๋ฉด ๋จ์ด ๊ธธ์ด์ ๋ฐ๋ผ ์๋์ผ๋ก ์กฐ์ ๋ผ!
5๏ธโฃ ๊ตฌ๋ฌธ ๊ฒ์ (Phrase Query)
์ ํํ ๊ตฌ๋ฌธ์ ์ฐพ์ ๋ ์ฌ์ฉํด. "๋ฌด์ ์ด์ดํฐ"์ด๋ผ๋ ์์ ๊ทธ๋๋ก ๋์ค๋ ๋ฌธ์๋ง ์ฐพ์์ค.
<?php
$params = [
'index' => 'products',
'body' => [
'query' => [
'match_phrase' => [
'description' => [
'query' => '๋
ธ์ด์ฆ ์บ์ฌ๋ง',
'slop' => 1 // ๋จ์ด ์ฌ์ด์ 1๊ฐ๊น์ง ๋ค๋ฅธ ๋จ์ด ํ์ฉ
]
]
]
]
];
$response = $client->search($params);
๐จ ๊ฒ์ ๊ฒฐ๊ณผ ํ์ด๋ผ์ดํ
๊ฒ์ ๊ฒฐ๊ณผ์์ ๊ฒ์์ด๋ฅผ ๊ฐ์กฐ ํ์ํ๋ฉด ์ฌ์ฉ์ ๊ฒฝํ์ด ํจ์ฌ ์ข์์ ธ. ๋ค์ด๋ฒ๋ ๊ตฌ๊ธ์์ ๊ฒ์์ด๊ฐ ๊ตต๊ฒ ํ์๋๋ ๊ฒ์ฒ๋ผ ๋ง์ด์ผ!
<?php
$params = [
'index' => 'products',
'body' => [
'query' => [
'multi_match' => [
'query' => '๋ฌด์ ๋ธ๋ฃจํฌ์ค',
'fields' => ['name', 'description']
]
],
'highlight' => [
'fields' => [
'name' => [
'pre_tags' => ['<strong class="highlight">'],
'post_tags' => ['</strong>']
],
'description' => [
'pre_tags' => ['<strong class="highlight">'],
'post_tags' => ['</strong>'],
'fragment_size' => 150, // ํ์ํ ๋ฌธ์ ์
'number_of_fragments' => 3 // ์ต๋ ์กฐ๊ฐ ์
]
]
]
]
];
$response = $client->search($params);
foreach ($response['hits']['hits'] as $hit) {
echo "์ํ๋ช
: ";
if (isset($hit['highlight']['name'])) {
echo $hit['highlight']['name'][0]; // ํ์ด๋ผ์ดํธ๋ ์ ๋ชฉ
} else {
echo $hit['_source']['name'];
}
echo "\n";
if (isset($hit['highlight']['description'])) {
echo "์ค๋ช
: " . implode(' ... ', $hit['highlight']['description']) . "\n";
}
echo "---\n";
}
โจ ํ์ด๋ผ์ดํ ๊ฒฐ๊ณผ ์์
์ํ๋ช
: ๋ฌด์ ๋ธ๋ฃจํฌ์ค ์ด์ดํฐ
์ค๋ช
: ๊ณ ์์ง ๋ฌด์ ์ด์ดํฐ์ผ๋ก ์ต๋ 24์๊ฐ ์ฌ์ ๊ฐ๋ฅํฉ๋๋ค ... ๋ธ๋ฃจํฌ์ค 5.0์ ์ง์ํ์ฌ ์์ ์ ์ธ ์ฐ๊ฒฐ์ด ๊ฐ๋ฅํฉ๋๋ค
๐ ์ง๊ณ(Aggregation)๋ก ํต๊ณ ๋ด๊ธฐ
Elasticsearch์ ๊ฐ๋ ฅํ ๊ธฐ๋ฅ ์ค ํ๋๊ฐ ๋ฐ๋ก ์ง๊ณ์ผ! ๊ฒ์ ๊ฒฐ๊ณผ๋ฅผ ๋ถ์ํ๊ณ ํต๊ณ๋ฅผ ๋ผ ์ ์์ด. ์ผํ๋ชฐ์ ํํฐ ๊ธฐ๋ฅ ๊ฐ์ ๊ฑฐ ์๊ฐํ๋ฉด ๋ผ.
์นดํ ๊ณ ๋ฆฌ๋ณ ์ํ ์ ์ง๊ณ
<?php
$params = [
'index' => 'products',
'body' => [
'size' => 0, // ๋ฌธ์๋ ๊ฐ์ ธ์ค์ง ์๊ณ ์ง๊ณ๋ง
'aggs' => [
'categories' => [
'terms' => [
'field' => 'category',
'size' => 10
]
]
]
]
];
$response = $client->search($params);
echo "์นดํ
๊ณ ๋ฆฌ๋ณ ์ํ ์:\n";
foreach ($response['aggregations']['categories']['buckets'] as $bucket) {
echo $bucket['key'] . ": " . $bucket['doc_count'] . "๊ฐ\n";
}
๊ฐ๊ฒฉ๋๋ณ ๋ถํฌ ์ง๊ณ
<?php
$params = [
'index' => 'products',
'body' => [
'size' => 0,
'aggs' => [
'price_ranges' => [
'range' => [
'field' => 'price',
'ranges' => [
['to' => 50000],
['from' => 50000, 'to' => 100000],
['from' => 100000, 'to' => 200000],
['from' => 200000]
]
]
],
'avg_price' => [
'avg' => [
'field' => 'price'
]
],
'max_price' => [
'max' => [
'field' => 'price'
]
],
'min_price' => [
'min' => [
'field' => 'price'
]
]
]
]
];
$response = $client->search($params);
echo "๊ฐ๊ฒฉ๋๋ณ ์ํ ์:\n";
foreach ($response['aggregations']['price_ranges']['buckets'] as $bucket) {
$from = $bucket['from'] ?? 0;
$to = $bucket['to'] ?? '์ด์';
echo number_format($from) . "์ ~ " . (is_numeric($to) ? number_format($to) . "์" : $to) . ": ";
echo $bucket['doc_count'] . "๊ฐ\n";
}
echo "\nํต๊ณ:\n";
echo "ํ๊ท ๊ฐ๊ฒฉ: " . number_format($response['aggregations']['avg_price']['value']) . "์\n";
echo "์ต๊ณ ๊ฐ๊ฒฉ: " . number_format($response['aggregations']['max_price']['value']) . "์\n";
echo "์ต์ ๊ฐ๊ฒฉ: " . number_format($response['aggregations']['min_price']['value']) . "์\n";
์ค์ฒฉ ์ง๊ณ: ์นดํ ๊ณ ๋ฆฌ๋ณ ํ๊ท ๊ฐ๊ฒฉ
<?php
$params = [
'index' => 'products',
'body' => [
'size' => 0,
'aggs' => [
'categories' => [
'terms' => [
'field' => 'category'
],
'aggs' => [
'avg_price' => [
'avg' => [
'field' => 'price'
]
],
'avg_rating' => [
'avg' => [
'field' => 'rating'
]
]
]
]
]
]
];
$response = $client->search($params);
echo "์นดํ
๊ณ ๋ฆฌ๋ณ ํต๊ณ:\n";
foreach ($response['aggregations']['categories']['buckets'] as $bucket) {
echo "\n" . $bucket['key'] . ":\n";
echo " ์ํ ์: " . $bucket['doc_count'] . "๊ฐ\n";
echo " ํ๊ท ๊ฐ๊ฒฉ: " . number_format($bucket['avg_price']['value']) . "์\n";
echo " ํ๊ท ํ์ : " . round($bucket['avg_rating']['value'], 2) . "์ \n";
}
๐ฏ ์ง๊ณ ํ์ฉ ์์ด๋์ด
โข ์ผํ๋ชฐ ํํฐ: ์นดํ
๊ณ ๋ฆฌ, ๋ธ๋๋, ๊ฐ๊ฒฉ๋๋ณ ์ํ ์ ํ์
โข ๋์๋ณด๋: ์ผ๋ณ/์๋ณ ํต๊ณ, ์ธ๊ธฐ ๊ฒ์์ด ์์
โข ์ถ์ฒ ์์คํ
: ๋น์ทํ ๊ฐ๊ฒฉ๋/์นดํ
๊ณ ๋ฆฌ ์ํ ์ถ์ฒ
โข ํธ๋ ๋ ๋ถ์: ์๊ฐ๋๋ณ ๊ฒ์ ํจํด ๋ถ์
๐ง ์ค์ ๊ฒ์ ์์คํ ๊ตฌ์ถํ๊ธฐ
์ด์ ๋ฐฐ์ด ๋ด์ฉ์ ์ข ํฉํด์ ์ค์ ๋ก ์ฌ์ฉํ ์ ์๋ ๊ฒ์ ์์คํ ์ ๋ง๋ค์ด๋ณด์! ์ฌ๋ฅ๋ท ๊ฐ์ ํ๋ซํผ์์๋ ๋ฐ๋ก ์ ์ฉํ ์ ์๋ ์์ค์ด์ผ.
๊ฒ์ ํด๋์ค ๋ง๋ค๊ธฐ
<?php
class ProductSearch {
private $client;
private $index = 'products';
public function __construct($hosts = ['localhost:9200']) {
$this->client = ClientBuilder::create()
->setHosts($hosts)
->build();
}
/**
* ํตํฉ ๊ฒ์ ๋ฉ์๋
*/
public function search($keyword, $filters = [], $options = []) {
$query = $this->buildQuery($keyword, $filters);
$params = [
'index' => $this->index,
'body' => [
'query' => $query,
'from' => $options['from'] ?? 0,
'size' => $options['size'] ?? 20,
'sort' => $this->buildSort($options['sort'] ?? 'relevance'),
'highlight' => [
'fields' => [
'name' => new \stdClass(),
'description' => [
'fragment_size' => 150,
'number_of_fragments' => 2
]
]
]
]
];
// ์ง๊ณ ์ถ๊ฐ
if (!empty($options['aggregations'])) {
$params['body']['aggs'] = $this->buildAggregations();
}
return $this->client->search($params);
}
/**
* ์ฟผ๋ฆฌ ๋น๋
*/
private function buildQuery($keyword, $filters) {
$must = [];
$filter = [];
// ํค์๋ ๊ฒ์
if (!empty($keyword)) {
$must[] = [
'multi_match' => [
'query' => $keyword,
'fields' => ['name^3', 'description', 'tags^2'],
'type' => 'best_fields',
'fuzziness' => 'AUTO'
]
];
}
// ์นดํ
๊ณ ๋ฆฌ ํํฐ
if (!empty($filters['category'])) {
$filter[] = ['term' => ['category' => $filters['category']]];
}
// ๊ฐ๊ฒฉ ๋ฒ์ ํํฐ
if (!empty($filters['price_min']) || !empty($filters['price_max'])) {
$range = [];
if (!empty($filters['price_min'])) {
$range['gte'] = $filters['price_min'];
}
if (!empty($filters['price_max'])) {
$range['lte'] = $filters['price_max'];
}
$filter[] = ['range' => ['price' => $range]];
}
// ํ์ ํํฐ
if (!empty($filters['rating_min'])) {
$filter[] = ['range' => ['rating' => ['gte' => $filters['rating_min']]]];
}
// ์ฌ๊ณ ์๋ ์ํ๋ง
if (!empty($filters['in_stock'])) {
$filter[] = ['range' => ['stock' => ['gt' => 0]]];
}
// Bool ์ฟผ๋ฆฌ ๊ตฌ์ฑ
$bool = [];
if (!empty($must)) {
$bool['must'] = $must;
}
if (!empty($filter)) {
$bool['filter'] = $filter;
}
// ํค์๋๊ฐ ์์ผ๋ฉด ์ ์ฒด ๊ฒ์
if (empty($bool)) {
return ['match_all' => new \stdClass()];
}
return ['bool' => $bool];
}
/**
* ์ ๋ ฌ ์ต์
๋น๋
*/
private function buildSort($sortType) {
switch ($sortType) {
case 'price_asc':
return [['price' => ['order' => 'asc']]];
case 'price_desc':
return [['price' => ['order' => 'desc']]];
case 'rating':
return [['rating' => ['order' => 'desc']], ['_score' => ['order' => 'desc']]];
case 'newest':
return [['created_at' => ['order' => 'desc']]];
case 'relevance':
default:
return [['_score' => ['order' => 'desc']]];
}
}
/**
* ์ง๊ณ ๋น๋
*/
private function buildAggregations() {
return [
'categories' => [
'terms' => ['field' => 'category', 'size' => 20]
],
'price_ranges' => [
'range' => [
'field' => 'price',
'ranges' => [
['key' => '5๋ง์ ๋ฏธ๋ง', 'to' => 50000],
['key' => '5๋ง์~10๋ง์', 'from' => 50000, 'to' => 100000],
['key' => '10๋ง์~20๋ง์', 'from' => 100000, 'to' => 200000],
['key' => '20๋ง์ ์ด์', 'from' => 200000]
]
]
],
'avg_price' => [
'avg' => ['field' => 'price']
]
];
}
/**
* ์๋์์ฑ ์ ์
*/
public function suggest($prefix) {
$params = [
'index' => $this->index,
'body' => [
'suggest' => [
'product-suggest' => [
'prefix' => $prefix,
'completion' => [
'field' => 'name.suggest',
'size' => 10,
'fuzzy' => [
'fuzziness' => 'AUTO'
]
]
]
]
]
];
return $this->client->search($params);
}
}
๊ฒ์ API ์๋ํฌ์ธํธ ๋ง๋ค๊ธฐ
<?php
// search_api.php
require 'vendor/autoload.php';
require 'ProductSearch.php';
header('Content-Type: application/json; charset=utf-8');
$search = new ProductSearch();
// GET ํ๋ผ๋ฏธํฐ ๋ฐ๊ธฐ
$keyword = $_GET['q'] ?? '';
$page = max(1, intval($_GET['page'] ?? 1));
$perPage = min(100, max(1, intval($_GET['per_page'] ?? 20)));
$sort = $_GET['sort'] ?? 'relevance';
// ํํฐ ์ค์
$filters = [];
if (!empty($_GET['category'])) {
$filters['category'] = $_GET['category'];
}
if (!empty($_GET['price_min'])) {
$filters['price_min'] = intval($_GET['price_min']);
}
if (!empty($_GET['price_max'])) {
$filters['price_max'] = intval($_GET['price_max']);
}
if (!empty($_GET['rating_min'])) {
$filters['rating_min'] = floatval($_GET['rating_min']);
}
if (!empty($_GET['in_stock'])) {
$filters['in_stock'] = true;
}
// ๊ฒ์ ์ต์
$options = [
'from' => ($page - 1) * $perPage,
'size' => $perPage,
'sort' => $sort,
'aggregations' => true
];
try {
$response = $search->search($keyword, $filters, $options);
// ๊ฒฐ๊ณผ ํฌ๋งทํ
$result = [
'success' => true,
'total' => $response['hits']['total']['value'],
'took' => $response['took'],
'page' => $page,
'per_page' => $perPage,
'items' => [],
'aggregations' => []
];
// ๊ฒ์ ๊ฒฐ๊ณผ
foreach ($response['hits']['hits'] as $hit) {
$item = $hit['_source'];
$item['score'] = $hit['_score'];
// ํ์ด๋ผ์ดํธ ์ ์ฉ
if (isset($hit['highlight'])) {
if (isset($hit['highlight']['name'])) {
$item['name_highlighted'] = $hit['highlight']['name'][0];
}
if (isset($hit['highlight']['description'])) {
$item['description_highlighted'] = implode(' ... ', $hit['highlight']['description']);
}
}
$result['items'][] = $item;
}
// ์ง๊ณ ๊ฒฐ๊ณผ
if (isset($response['aggregations'])) {
$result['aggregations'] = [
'categories' => $response['aggregations']['categories']['buckets'],
'price_ranges' => $response['aggregations']['price_ranges']['buckets'],
'avg_price' => $response['aggregations']['avg_price']['value']
];
}
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
} catch (Exception $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => $e->getMessage()
], JSON_UNESCAPED_UNICODE);
}
ํ๋ก ํธ์๋ ์ฐ๋ ์์
<!DOCTYPE html>
<html>
<head>
<title>์ํ ๊ฒ์</title>
<style>
.search-box { margin: 20px; }
.filters { margin: 20px; }
.results { margin: 20px; }
.product-item {
border: 1px solid #ddd;
padding: 15px;
margin: 10px 0;
border-radius: 8px;
}
.highlight {
background: #fff9c4;
font-weight: bold;
}
</style>
</head>
<body>
<div class="search-box">
<input type="text" id="searchInput" placeholder="์ํ ๊ฒ์..." style="width: 400px; padding: 10px;">
<button onclick="search()">๊ฒ์</button>
</div>
<div class="filters">
<select id="categoryFilter">
<option value="">์ ์ฒด ์นดํ
๊ณ ๋ฆฌ</option>
</select>
<select id="sortFilter">
<option value="relevance">๊ด๋ จ๋์</option>
<option value="price_asc">๊ฐ๊ฒฉ ๋ฎ์์</option>
<option value="price_desc">๊ฐ๊ฒฉ ๋์์</option>
<option value="rating">ํ์ ์</option>
<option value="newest">์ต์ ์</option>
</select>
<label>
<input type="checkbox" id="inStockFilter"> ์ฌ๊ณ ์๋ ์ํ๋ง
</label>
</div>
<div class="results" id="results"></div>
<script>
async function search() {
const keyword = document.getElementById('searchInput').value;
const category = document.getElementById('categoryFilter').value;
const sort = document.getElementById('sortFilter').value;
const inStock = document.getElementById('inStockFilter').checked;
const params = new URLSearchParams({
q: keyword,
sort: sort,
per_page: 20
});
if (category) params.append('category', category);
if (inStock) params.append('in_stock', '1');
try {
const response = await fetch(`search_api.php?${params}`);
const data = await response.json();
displayResults(data);
updateFilters(data.aggregations);
} catch (error) {
console.error('๊ฒ์ ์ค๋ฅ:', error);
}
}
function displayResults(data) {
const resultsDiv = document.getElementById('results');
if (data.total === 0) {
resultsDiv.innerHTML = '<p>๊ฒ์ ๊ฒฐ๊ณผ๊ฐ ์์ต๋๋ค.</p>';
return;
}
let html = `<p>์ด ${data.total}๊ฐ์ ์ํ (${data.took}ms)</p>`;
data.items.forEach(item => {
const name = item.name_highlighted || item.name;
const desc = item.description_highlighted || item.description.substring(0, 150);
html += `
<div class="product-item">
<h3>${name}</h3>
<p>${desc}</p>
<p>
<strong>๊ฐ๊ฒฉ:</strong> ${item.price.toLocaleString()}์ |
<strong>ํ์ :</strong> ${item.rating}์ |
<strong>์ฌ๊ณ :</strong> ${item.stock}๊ฐ
</p>
<p><small>์นดํ
๊ณ ๋ฆฌ: ${item.category} | ํ๊ทธ: ${item.tags.join(', ')}</small></p>
</div>
`;
});
resultsDiv.innerHTML = html;
}
function updateFilters(aggregations) {
if (!aggregations) return;
// ์นดํ
๊ณ ๋ฆฌ ํํฐ ์
๋ฐ์ดํธ
const categoryFilter = document.getElementById('categoryFilter');
const currentCategory = categoryFilter.value;
categoryFilter.innerHTML = '<option value="">์ ์ฒด ์นดํ
๊ณ ๋ฆฌ</option>';
aggregations.categories.forEach(bucket => {
const option = document.createElement('option');
option.value = bucket.key;
option.textContent = `${bucket.key} (${bucket.doc_count})`;
if (bucket.key === currentCategory) {
option.selected = true;
}
categoryFilter.appendChild(option);
});
}
// ์ํฐํค๋ก ๊ฒ์
document.getElementById('searchInput').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
search();
}
});
// ํํฐ ๋ณ๊ฒฝ ์ ์๋ ๊ฒ์
document.getElementById('categoryFilter').addEventListener('change', search);
document.getElementById('sortFilter').addEventListener('change', search);
document.getElementById('inStockFilter').addEventListener('change', search);
</script>
</body>
</html>
โก ์ฑ๋ฅ ์ต์ ํ ์ ๋ต
๊ฒ์ ์์คํ ์ด ๋๋ ค์ง๋ฉด ์ฌ์ฉ์๋ค์ด ๋ ๋๊ฐ. ๋น ๋ฅด๊ณ ํจ์จ์ ์ธ ๊ฒ์์ ์ํ ์ต์ ํ ๋ฐฉ๋ฒ๋ค์ ์์๋ณด์!
๐ฏ ์ธ๋ฑ์ฑ ์ต์ ํ
Bulk API ํ์ฉ
ํ ๋ฒ์ ์ฌ๋ฌ ๋ฌธ์๋ฅผ ์์ธํ๋ฉด ๋คํธ์ํฌ ์ค๋ฒํค๋๊ฐ ์ค์ด๋ค์ด. 1000~5000๊ฐ์ฉ ๋ฌถ์ด์ ์ฒ๋ฆฌํ๋ ๊ฒ ์ข์.
Refresh ๊ฐ๊ฒฉ ์กฐ์
์ค์๊ฐ ๊ฒ์์ด ํ์ ์๋ค๋ฉด refresh_interval์ ๋๋ ค์ ์ฑ๋ฅ์ ๋์ผ ์ ์์ด.
PUT /products/_settings
{
"index": {
"refresh_interval": "30s"
}
}
๐พ ์ฟผ๋ฆฌ ์บ์ฑ
Filter Context ์ฌ์ฉ
filter๋ ์บ์ฑ๋๊ธฐ ๋๋ฌธ์ ๋ฐ๋ณต์ ์ธ ์ฟผ๋ฆฌ๊ฐ ๋นจ๋ผ์ ธ. ์นดํ
๊ณ ๋ฆฌ, ๊ฐ๊ฒฉ ๋ฒ์ ๊ฐ์ ๊ฑด filter๋ก ์ฒ๋ฆฌํด.
Request Cache
size=0์ธ ์ง๊ณ ์ฟผ๋ฆฌ๋ ์๋์ผ๋ก ์บ์ฑ๋ผ. ํต๊ณ ๋ฐ์ดํฐ๋ฅผ ๋น ๋ฅด๊ฒ ๊ฐ์ ธ์ฌ ์ ์์ง.
๐ ๊ฒ์ ์ฟผ๋ฆฌ ์ต์ ํ
ํ์ํ ํ๋๋ง ๊ฐ์ ธ์ค๊ธฐ
_source ํํฐ๋ง์ผ๋ก ํ์ํ ํ๋๋ง ๊ฐ์ ธ์ค๋ฉด ๋คํธ์ํฌ ์ ์ก๋์ด ์ค์ด๋ค์ด.
"_source": ["name", "price", "rating"]
from + size ์ ํ
๊น์ ํ์ด์ง๋ค์ด์
์ ๋๋ ค. search_after๋ scroll API๋ฅผ ์ฌ์ฉํ๋ ๊ฒ ์ข์.
๐ ์ค๋ ์ค์
์ ์ ํ ์ค๋ ์
๋๋ฌด ๋ง์ผ๋ฉด ์ค๋ฒํค๋๊ฐ ํฌ๊ณ , ๋๋ฌด ์ ์ผ๋ฉด ํ์ฅ์ฑ์ด ๋จ์ด์ ธ. ์ค๋๋น 20~50GB๊ฐ ์ ๋นํด.
๋ ํ๋ฆฌ์นด ํ์ฉ
์ฝ๊ธฐ ์ฑ๋ฅ์ ๋์ด๋ ค๋ฉด ๋ ํ๋ฆฌ์นด๋ฅผ ๋๋ ค. ๊ฒ์ ์์ฒญ์ด ์ฌ๋ฌ ๋
ธ๋์ ๋ถ์ฐ๋ผ.
โ ๏ธ ํผํด์ผ ํ ์ํฐํจํด
1. Wildcard ์ฟผ๋ฆฌ ๋จ์ฉ
*๊ฒ์์ด* ๊ฐ์ ํจํด์ ๋งค์ฐ ๋๋ ค. ๊ฐ๋ฅํ๋ฉด n-gram์ด๋ edge n-gram์ ์ฌ์ฉํด.
2. ๋๋ฌด ๋ง์ ํ๋ ๊ฒ์
multi_match์์ ์์ญ ๊ฐ ํ๋๋ฅผ ๊ฒ์ํ๋ฉด ๋๋ ค์ ธ. ์ค์ํ ํ๋ 3~5๊ฐ๋ง ์ ํํด.
3. ๊น์ ํ์ด์ง๋ค์ด์
from=10000 ๊ฐ์ ๊ฑด ํผํด. 10,000๊ฐ ๋ฌธ์๋ฅผ ๋ชจ๋ ์ ๋ ฌํด์ผ ํ๊ฑฐ๋ .
4. ์ค์๊ฐ refresh
๋งค๋ฒ refresh=true๋ฅผ ์ฌ์ฉํ๋ฉด ์ฑ๋ฅ์ด ํฌ๊ฒ ๋จ์ด์ ธ. ๊ผญ ํ์ํ ๊ฒฝ์ฐ๋ง ์ฌ์ฉํด.
โ ์ฑ๋ฅ ๋ชจ๋ํฐ๋ง
<?php
// ๋๋ฆฐ ์ฟผ๋ฆฌ ๋ก๊น
$params = [
'index' => 'products',
'body' => [
'profile' => true, // ์ฟผ๋ฆฌ ํ๋กํ์ผ๋ง ํ์ฑํ
'query' => [
'match' => ['name' => '๊ฒ์์ด']
]
]
];
$response = $client->search($params);
// ํ๋กํ์ผ ์ ๋ณด ํ์ธ
if (isset($response['profile'])) {
$profile = $response['profile'];
// ๊ฐ ์ค๋๋ณ ์คํ ์๊ฐ ๋ถ์
foreach ($profile['shards'] as $shard) {
foreach ($shard['searches'] as $search) {
echo "์ฟผ๋ฆฌ ์๊ฐ: " . $search['query'][0]['time_in_nanos'] / 1000000 . "ms\n";
}
}
}
๐ก๏ธ ๋ณด์๊ณผ ์ด์ ๊ด๋ฆฌ
ํ๋ก๋์ ํ๊ฒฝ์์๋ ๋ณด์์ด ์ ๋ง ์ค์ํด! ๊ธฐ๋ณธ ์ค์ ์ผ๋ก ์ด์ํ๋ฉด ํฐ์ผ ๋.
๐ ํ์ ๋ณด์ ์ค์
1. X-Pack Security ํ์ฑํ
# elasticsearch.yml
xpack.security.enabled: true
xpack.security.transport.ssl.enabled: true
xpack.security.http.ssl.enabled: true
2. ์ฌ์ฉ์ ์ธ์ฆ ์ค์
<?php
$client = ClientBuilder::create()
->setHosts(['https://localhost:9200'])
->setBasicAuthentication('username', 'password')
->setSSLVerification(false) // ๊ฐ๋ฐ ํ๊ฒฝ์์๋ง
->build();
3. ์ญํ ๊ธฐ๋ฐ ์ ๊ทผ ์ ์ด
์ฝ๊ธฐ ์ ์ฉ ์ฌ์ฉ์, ์ฐ๊ธฐ ๊ถํ ์ฌ์ฉ์๋ฅผ ๋ถ๋ฆฌํด์ ๊ด๋ฆฌํด. ์ ํ๋ฆฌ์ผ์ด์
์์๋ ์ต์ ๊ถํ๋ง ๋ถ์ฌํ๋ ๊ฒ ์์น์ด์ผ.
๐ ๋ชจ๋ํฐ๋ง๊ณผ ๋ก๊น
ํด๋ฌ์คํฐ ์ํ ํ์ธ
<?php
// ํด๋ฌ์คํฐ ํฌ์ค ์ฒดํฌ
$health = $client->cluster()->health();
if ($health['status'] === 'red') {
// ์๋ฆผ ๋ฐ์ก
error_log('Elasticsearch cluster is RED!');
}
// ๋
ธ๋ ํต๊ณ
$stats = $client->nodes()->stats();
foreach ($stats['nodes'] as $node) {
$jvmHeapPercent = $node['jvm']['mem']['heap_used_percent'];
if ($jvmHeapPercent > 85) {
error_log("Node {$node['name']} heap usage: {$jvmHeapPercent}%");
}
}
์ธ๋ฑ์ค ๊ด๋ฆฌ
<?php
// ์ค๋๋ ์ธ๋ฑ์ค ์ญ์ (๋ก๊ทธ ๋ฐ์ดํฐ ๋ฑ)
$indices = $client->cat()->indices(['format' => 'json']);
foreach ($indices as $index) {
$indexName = $index['index'];
// logs-2024-01-01 ํ์์ ์ธ๋ฑ์ค์์ ๋ ์ง ์ถ์ถ
if (preg_match('/logs-(\d{4}-\d{2}-\d{2})/', $indexName, $matches)) {
$date = new DateTime($matches[1]);
$now = new DateTime();
$diff = $now->diff($date)->days;
// 30์ผ ์ด์ ๋ ์ธ๋ฑ์ค ์ญ์
if ($diff > 30) {
$client->indices()->delete(['index' => $indexName]);
echo "Deleted old index: $indexName\n";
}
}
}
๐ก ๋ฐฑ์ ๊ณผ ๋ณต๊ตฌ
์ค๋ ์ท ์์ฑ
<?php
// ์ค๋
์ท ๋ฆฌํฌ์งํ ๋ฆฌ ๋ฑ๋ก
$client->snapshot()->createRepository([
'repository' => 'my_backup',
'body' => [
'type' => 'fs',
'settings' => [
'location' => '/mount/backups/elasticsearch'
]
]
]);
// ์ค๋
์ท ์์ฑ
$client->snapshot()->create([
'repository' => 'my_backup',
'snapshot' => 'snapshot_' . date('Y-m-d'),
'body' => [
'indices' => 'products',
'ignore_unavailable' => true,
'include_global_state' => false
]
]);
์ ๊ธฐ์ ์ผ๋ก ์ค๋ ์ท์ ์์ฑํด์ ๋ฐ์ดํฐ ์์ค์ ๋๋นํด์ผ ํด. cron์ผ๋ก ์๋ํํ๋ ๊ฒ ์ข์!
๐ ๊ณ ๊ธ ๊ธฐ๋ฅ ํ์ฉํ๊ธฐ
๊ธฐ๋ณธ ๊ฒ์์ ๋์ด์ ๋ ๋๋ํ ๊ธฐ๋ฅ๋ค์ ์ถ๊ฐํด๋ณด์!
๐ค ์๋์์ฑ (Autocomplete)
์ฌ์ฉ์๊ฐ ํ์ดํํ๋ ๋์ ์ค์๊ฐ์ผ๋ก ์ ์์ ๋ณด์ฌ์ฃผ๋ ๊ธฐ๋ฅ์ด์ผ. ๊ฒ์ ๊ฒฝํ์ ํฌ๊ฒ ํฅ์์์ผ์ค!
<?php
// ์ธ๋ฑ์ค ์์ฑ ์ suggest ํ๋ ์ถ๊ฐ
$params = [
'index' => 'products',
'body' => [
'mappings' => [
'properties' => [
'name' => [
'type' => 'text',
'fields' => [
'suggest' => [
'type' => 'completion'
]
]
]
]
]
]
];
// ์๋์์ฑ ๊ฒ์
function autocomplete($client, $prefix) {
$params = [
'index' => 'products',
'body' => [
'suggest' => [
'product-suggest' => [
'prefix' => $prefix,
'completion' => [
'field' => 'name.suggest',
'size' => 10,
'skip_duplicates' => true,
'fuzzy' => [
'fuzziness' => 1
]
]
]
]
]
];
$response = $client->search($params);
$suggestions = [];
foreach ($response['suggest']['product-suggest'][0]['options'] as $option) {
$suggestions[] = $option['text'];
}
return $suggestions;
}
// ์ฌ์ฉ ์์
$suggestions = autocomplete($client, '๋ธ๋ฃจ');
// ๊ฒฐ๊ณผ: ['๋ธ๋ฃจํฌ์ค ์ด์ดํฐ', '๋ธ๋ฃจํฌ์ค ์คํผ์ปค', '๋ธ๋ฃจํฌ์ค ํค๋ณด๋']
๐ฏ ๊ฐ์ธํ ๊ฒ์
์ฌ์ฉ์์ ๊ณผ๊ฑฐ ํ๋์ ๊ธฐ๋ฐ์ผ๋ก ๊ฒ์ ๊ฒฐ๊ณผ๋ฅผ ์กฐ์ ํ ์ ์์ด. ๋ ๊ด๋ จ์ฑ ๋์ ๊ฒฐ๊ณผ๋ฅผ ๋ณด์ฌ์ฃผ๋ ๊ฑฐ์ง!
<?php
function personalizedSearch($client, $keyword, $userId) {
// ์ฌ์ฉ์์ ๊ณผ๊ฑฐ ๊ตฌ๋งค/์กฐํ ์นดํ
๊ณ ๋ฆฌ ๊ฐ์ ธ์ค๊ธฐ
$userPreferences = getUserPreferences($userId);
$params = [
'index' => 'products',
'body' => [
'query' => [
'bool' => [
'must' => [
['multi_match' => [
'query' => $keyword,
'fields' => ['name^3', 'description']
]]
],
'should' => [
// ์ฌ์ฉ์๊ฐ ์ ํธํ๋ ์นดํ
๊ณ ๋ฆฌ์ ๊ฐ์ค์น
['terms' => [
'category' => $userPreferences['categories'],
'boost' => 2.0
]],
// ์ฌ์ฉ์์ ๊ฐ๊ฒฉ๋์ ๋ง๋ ์ํ
['range' => [
'price' => [
'gte' => $userPreferences['price_min'],
'lte' => $userPreferences['price_max'],
'boost' => 1.5
]
]]
]
]
]
]
];
return $client->search($params);
}
function getUserPreferences($userId) {
// ์ค์ ๋ก๋ ๋ฐ์ดํฐ๋ฒ ์ด์ค์์ ๊ฐ์ ธ์์ผ ํจ
return [
'categories' => ['์ ์์ ํ', '์ปดํจํฐ์ฃผ๋ณ๊ธฐ๊ธฐ'],
'price_min' => 50000,
'price_max' => 200000
];
}
๐ ๊ฒ์ ๋ถ์ (Search Analytics)
์ด๋ค ๊ฒ์์ด๊ฐ ๋ง์ด ์ฌ์ฉ๋๋์ง, ๊ฒฐ๊ณผ๊ฐ ์๋ ๊ฒ์์ ๋ฌด์์ธ์ง ๋ถ์ํ๋ฉด ์๋น์ค ๊ฐ์ ์ ๋์์ด ๋ผ!
<?php
class SearchAnalytics {
private $pdo;
public function logSearch($keyword, $resultCount, $userId = null) {
$stmt = $this->pdo->prepare("
INSERT INTO search_logs
(keyword, result_count, user_id, created_at)
VALUES (?, ?, ?, NOW())
");
$stmt->execute([$keyword, $resultCount, $userId]);
}
public function getPopularSearches($limit = 10) {
$stmt = $this->pdo->prepare("
SELECT keyword, COUNT(*) as count
FROM search_logs
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
GROUP BY keyword
ORDER BY count DESC
LIMIT ?
");
$stmt->execute([$limit]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function getZeroResultSearches($limit = 10) {
$stmt = $this->pdo->prepare("
SELECT keyword, COUNT(*) as count
FROM search_logs
WHERE result_count = 0
AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
GROUP BY keyword
ORDER BY count DESC
LIMIT ?
");
$stmt->execute([$limit]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
}
// ์ฌ์ฉ ์์
$analytics = new SearchAnalytics($pdo);
// ๊ฒ์ ์ ๋ก๊น
$response = $client->search($params);
$analytics->logSearch(
$keyword,
$response['hits']['total']['value'],
$currentUserId
);
// ์ธ๊ธฐ ๊ฒ์์ด ํ์ธ
$popular = $analytics->getPopularSearches();
foreach ($popular as $item) {
echo "{$item['keyword']}: {$item['count']}ํ\n";
}
// ๊ฒฐ๊ณผ ์๋ ๊ฒ์์ด ํ์ธ (์ํ ์ถ๊ฐ ํ์)
$zeroResults = $analytics->getZeroResultSearches();
foreach ($zeroResults as $item) {
echo "{$item['keyword']}: {$item['count']}ํ ๊ฒ์๋จ\n";
}
๐ ๋์์ด ์ฒ๋ฆฌ (Synonyms)
"๋ ธํธ๋ถ"์ ๊ฒ์ํด๋ "๋ฉํ"์ด ๋์ค๊ฒ ํ๋ ค๋ฉด ๋์์ด ์ค์ ์ด ํ์ํด!
<?php
// ๋์์ด๊ฐ ํฌํจ๋ ์ธ๋ฑ์ค ์์ฑ
$params = [
'index' => 'products',
'body' => [
'settings' => [
'analysis' => [
'filter' => [
'synonym_filter' => [
'type' => 'synonym',
'synonyms' => [
'๋
ธํธ๋ถ, ๋ฉํ, ๋
ธํธ๋ถ์ปดํจํฐ',
'์ค๋งํธํฐ, ํด๋ํฐ, ํธ๋ํฐ',
'์ด์ดํฐ, ์ด์ด๋ฒ๋, ์ด์ด์
',
'๋ฌด์ , ์์ด์ด๋ฆฌ์ค',
'๋ธ๋ฃจํฌ์ค, BT'
]
]
],
'analyzer' => [
'synonym_analyzer' => [
'type' => 'custom',
'tokenizer' => 'standard',
'filter' => ['lowercase', 'synonym_filter']
]
]
]
],
'mappings' => [
'properties' => [
'name' => [
'type' => 'text',
'analyzer' => 'synonym_analyzer'
]
]
]
]
];
$client->indices()->create($params);
์ด์ "๋ฉํ"์ ๊ฒ์ํด๋ "๋ ธํธ๋ถ"์ด ํฌํจ๋ ์ํ์ด ๋์! ์ฌ์ฉ์ ๊ฒฝํ์ด ํจ์ฌ ์ข์์ง์ง? ๐
๐ ์ค์ ํ๋ก์ ํธ: ์ฌ๋ฅ ๊ฒ์ ์์คํ
์ง๊ธ๊น์ง ๋ฐฐ์ด ๋ด์ฉ์ ์ข ํฉํด์ ์ฌ๋ฅ๋ท ๊ฐ์ ํ๋ซํผ์์ ์ฌ์ฉํ ์ ์๋ ์ฌ๋ฅ ๊ฒ์ ์์คํ ์ ๋ง๋ค์ด๋ณด์!
Step 1: ์ฌ๋ฅ ์ธ๋ฑ์ค ์ค๊ณ
<?php
$params = [
'index' => 'talents',
'body' => [
'settings' => [
'number_of_shards' => 2,
'number_of_replicas' => 1,
'analysis' => [
'analyzer' => [
'korean' => [
'type' => 'custom',
'tokenizer' => 'nori_tokenizer',
'filter' => ['lowercase', 'nori_part_of_speech']
]
]
]
],
'mappings' => [
'properties' => [
'id' => ['type' => 'integer'],
'title' => [
'type' => 'text',
'analyzer' => 'korean',
'fields' => [
'keyword' => ['type' => 'keyword'],
'suggest' => ['type' => 'completion']
]
],
'description' => [
'type' => 'text',
'analyzer' => 'korean'
],
'category' => ['type' => 'keyword'],
'subcategory' => ['type' => 'keyword'],
'tags' => ['type' => 'keyword'],
'seller_id' => ['type' => 'integer'],
'seller_name' => [
'type' => 'text',
'fields' => ['keyword' => ['type' => 'keyword']]
],
'price' => ['type' => 'integer'],
'rating' => ['type' => 'float'],
'review_count' => ['type' => 'integer'],
'order_count' => ['type' => 'integer'],
'delivery_days' => ['type' => 'integer'],
'location' => ['type' => 'geo_point'],
'is_online' => ['type' => 'boolean'],
'is_express' => ['type' => 'boolean'],
'created_at' => ['type' => 'date'],
'updated_at' => ['type' => 'date']
]
]
]
];
$client->indices()->create($params);
Step 2: ์ฌ๋ฅ ๊ฒ์ ํด๋์ค
<?php
class TalentSearch {
private $client;
private $index = 'talents';
public function __construct($client) {
$this->client = $client;
}
/**
* ์ฌ๋ฅ ๊ฒ์
*/
public function search($options = []) {
$keyword = $options['keyword'] ?? '';
$category = $options['category'] ?? '';
$priceMin = $options['price_min'] ?? null;
$priceMax = $options['price_max'] ?? null;
$ratingMin = $options['rating_min'] ?? null;
$isOnline = $options['is_online'] ?? null;
$isExpress = $options['is_express'] ?? null;
$location = $options['location'] ?? null;
$distance = $options['distance'] ?? 10; // km
$sort = $options['sort'] ?? 'relevance';
$page = max(1, $options['page'] ?? 1);
$perPage = min(100, max(1, $options['per_page'] ?? 20));
// ์ฟผ๋ฆฌ ๊ตฌ์ฑ
$must = [];
$filter = [];
$should = [];
// ํค์๋ ๊ฒ์
if (!empty($keyword)) {
$must[] = [
'multi_match' => [
'query' => $keyword,
'fields' => [
'title^3',
'description^2',
'tags^2',
'seller_name',
'category'
],
'type' => 'best_fields',
'fuzziness' => 'AUTO'
]
];
}
// ์นดํ
๊ณ ๋ฆฌ ํํฐ
if (!empty($category)) {
$filter[] = ['term' => ['category' => $category]];
}
// ๊ฐ๊ฒฉ ๋ฒ์
if ($priceMin !== null || $priceMax !== null) {
$range = [];
if ($priceMin !== null) $range['gte'] = $priceMin;
if ($priceMax !== null) $range['lte'] = $priceMax;
$filter[] = ['range' => ['price' => $range]];
}
// ํ์ ํํฐ
if ($ratingMin !== null) {
$filter[] = ['range' => ['rating' => ['gte' => $ratingMin]]];
}
// ์จ๋ผ์ธ/์คํ๋ผ์ธ
if ($isOnline !== null) {
$filter[] = ['term' => ['is_online' => $isOnline]];
}
// ๋น ๋ฅธ ๋ฐฐ์ก
if ($isExpress) {
$filter[] = ['term' => ['is_express' => true]];
}
// ์์น ๊ธฐ๋ฐ ๊ฒ์
if ($location && !empty($location['lat']) && !empty($location['lon'])) {
$filter[] = [
'geo_distance' => [
'distance' => $distance . 'km',
'location' => [
'lat' => $location['lat'],
'lon' => $location['lon']
]
]
];
}
// ์ธ๊ธฐ ํ๋งค์ ๋ถ์คํธ
$should[] = [
'range' => [
'order_count' => [
'gte' => 100,
'boost' => 1.5
]
]
];
// ๋์ ํ์ ๋ถ์คํธ
$should[] = [
'range' => [
'rating' => [
'gte' => 4.5,
'boost' => 1.3
]
]
];
// Bool ์ฟผ๋ฆฌ
$query = ['bool' => []];
if (!empty($must)) $query['bool']['must'] = $must;
if (!empty($filter)) $query['bool']['filter'] = $filter;
if (!empty($should)) {
$query['bool']['should'] = $should;
$query['bool']['minimum_should_match'] = 0;
}
// ์ ์ฒด ๊ฒ์
if (empty($must) && empty($filter)) {
$query = ['match_all' => new \stdClass()];
}
// ์ ๋ ฌ
$sortOptions = $this->buildSort($sort, $location);
// ๊ฒ์ ์คํ
$params = [
'index' => $this->index,
'body' => [
'query' => $query,
'from' => ($page - 1) * $perPage,
'size' => $perPage,
'sort' => $sortOptions,
'highlight' => [
'fields' => [
'title' => new \stdClass(),
'description' => [
'fragment_size' => 150,
'number_of_fragments' => 2
]
]
],
'aggs' => [
'categories' => [
'terms' => ['field' => 'category', 'size' => 20]
],
'price_ranges' => [
'range' => [
'field' => 'price',
'ranges' => [
['key' => '1๋ง์ ๋ฏธ๋ง', 'to' => 10000],
['key' => '1๋ง์~5๋ง์', 'from' => 10000, 'to' => 50000],
['key' => '5๋ง์~10๋ง์', 'from' => 50000, 'to' => 100000],
['key' => '10๋ง์ ์ด์', 'from' => 100000]
]
]
],
'avg_price' => ['avg' => ['field' => 'price']],
'avg_rating' => ['avg' => ['field' => 'rating']]
]
]
];
return $this->client->search($params);
}
/**
* ์ ๋ ฌ ์ต์
*/
private function buildSort($sort, $location = null) {
switch ($sort) {
case 'price_asc':
return [['price' => ['order' => 'asc']]];
case 'price_desc':
return [['price' => ['order' => 'desc']]];
case 'rating':
return [
['rating' => ['order' => 'desc']],
['review_count' => ['order' => 'desc']]
];
case 'popular':
return [['order_count' => ['order' => 'desc']]];
case 'newest':
return [['created_at' => ['order' => 'desc']]];
case 'distance':
if ($location && !empty($location['lat']) && !empty($location['lon'])) {
return [[
'_geo_distance' => [
'location' => [
'lat' => $location['lat'],
'lon' => $location['lon']
],
'order' => 'asc',
'unit' => 'km'
]
]];
}
// fallthrough
case 'relevance':
default:
return [['_score' => ['order' => 'desc']]];
}
}
/**
* ์๋์์ฑ
*/
public function autocomplete($prefix) {
$params = [
'index' => $this->index,
'body' => [
'suggest' => [
'talent-suggest' => [
'prefix' => $prefix,
'completion' => [
'field' => 'title.suggest',
'size' => 10,
'skip_duplicates' => true,
'fuzzy' => ['fuzziness' => 1]
]
]
]
]
];
$response = $this->client->search($params);
$suggestions = [];
foreach ($response['suggest']['talent-suggest'][0]['options'] as $option) {
$suggestions[] = [
'text' => $option['text'],
'score' => $option['_score']
];
}
return $suggestions;
}
/**
* ์ ์ฌ ์ฌ๋ฅ ์ถ์ฒ
*/
public function moreLikeThis($talentId) {
$params = [
'index' => $this->index,
'body' => [
'query' => [
'more_like_this' => [
'fields' => ['title', 'description', 'tags'],
'like' => [
[
'_index' => $this->index,
'_id' => $talentId
]
],
'min_term_freq' => 1,
'max_query_terms' => 12
]
],
'size' => 10
]
];
return $this->client->search($params);
}
}
Step 3: API ์๋ํฌ์ธํธ
<?php
// talent_search_api.php
require 'vendor/autoload.php';
require 'TalentSearch.php';
header('Content-Type: application/json; charset=utf-8');
$client = ClientBuilder::create()
->setHosts(['localhost:9200'])
->build();
$search = new TalentSearch($client);
$action = $_GET['action'] ?? 'search';
try {
switch ($action) {
case 'search':
$options = [
'keyword' => $_GET['q'] ?? '',
'category' => $_GET['category'] ?? '',
'price_min' => isset($_GET['price_min']) ? intval($_GET['price_min']) : null,
'price_max' => isset($_GET['price_max']) ? intval($_GET['price_max']) : null,
'rating_min' => isset($_GET['rating_min']) ? floatval($_GET['rating_min']) : null,
'is_online' => isset($_GET['is_online']) ? ($_GET['is_online'] === '1') : null,
'is_express' => isset($_GET['is_express']) ? ($_GET['is_express'] === '1') : null,
'sort' => $_GET['sort'] ?? 'relevance',
'page' => intval($_GET['page'] ?? 1),
'per_page' => intval($_GET['per_page'] ?? 20)
];
// ์์น ์ ๋ณด
if (!empty($_GET['lat']) && !empty($_GET['lon'])) {
$options['location'] = [
'lat' => floatval($_GET['lat']),
'lon' => floatval($_GET['lon'])
];
$options['distance'] = intval($_GET['distance'] ?? 10);
}
$response = $search->search($options);
$result = [
'success' => true,
'total' => $response['hits']['total']['value'],
'took' => $response['took'],
'items' => [],
'aggregations' => $response['aggregations'] ?? []
];
foreach ($response['hits']['hits'] as $hit) {
$item = $hit['_source'];
$item['score'] = $hit['_score'];
if (isset($hit['highlight'])) {
$item['highlight'] = $hit['highlight'];
}
if (isset($hit['sort'])) {
$item['distance'] = $hit['sort'][0] ?? null;
}
$result['items'][] = $item;
}
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
break;
case 'autocomplete':
$prefix = $_GET['q'] ?? '';
$suggestions = $search->autocomplete($prefix);
echo json_encode([
'success' => true,
'suggestions' => $suggestions
], JSON_UNESCAPED_UNICODE);
break;
case 'similar':
$talentId = $_GET['id'] ?? '';
$response = $search->moreLikeThis($talentId);
$items = [];
foreach ($response['hits']['hits'] as $hit) {
$items[] = $hit['_source'];
}
echo json_encode([
'success' => true,
'items' => $items
], JSON_UNESCAPED_UNICODE);
break;
default:
throw new Exception('Invalid action');
}
} catch (Exception $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => $e->getMessage()
], JSON_UNESCAPED_UNICODE);
}
๐ญ ๋ง๋ฌด๋ฆฌํ๋ฉฐ
์! ์ฌ๊ธฐ๊น์ง ์ ๋ง ๊ธด ์ฌ์ ์ด์์ด. PHP์ Elasticsearch๋ฅผ ํ์ฉํ ์ ๋ฌธ ๊ฒ์ ์์คํ ๊ตฌ์ถ์ ๋ํด ์์๋ดค๋๋ฐ, ์ด๋? ์๊ฐ๋ณด๋ค ๋ณต์กํ์ง๋ง ๋ง์ ํด๋ณด๋ฉด ์ฌ๋ฏธ์์ง? ๐
๐ฏ ํต์ฌ ์์ฝ
1. Elasticsearch์ ๊ฐ์
โข ๋น ๋ฅธ ๊ฒ์ ์๋ (์ญ์์ธ ๊ตฌ์กฐ)
โข ์คํ ํ์ฉ (ํผ์ง ๊ฒ์)
โข ๊ด๋ จ์ฑ ๊ธฐ๋ฐ ์ ๋ ฌ (์ค์ฝ์ด๋ง)
โข ๊ฐ๋ ฅํ ์ง๊ณ ๊ธฐ๋ฅ
โข ํ์ฅ์ฑ (์ค๋ฉ, ๋ ํ๋ฆฌ์นด)
2. ๊ฒ์ ํ์ง ํฅ์ ๋ฐฉ๋ฒ
โข ์ ์ ํ ๋งคํ ์ค๊ณ
โข ํ๊ตญ์ด ํํ์ ๋ถ์๊ธฐ ํ์ฉ
โข ๋์์ด ์ฒ๋ฆฌ
โข ๊ฐ์ธํ ๊ฒ์
โข ํ์ด๋ผ์ดํ
3. ์ฑ๋ฅ ์ต์ ํ
โข Bulk API ์ฌ์ฉ
โข Filter context ํ์ฉ
โข ์ ์ ํ ์ค๋ ์ค์
โข ์ฟผ๋ฆฌ ์บ์ฑ
โข ๋ชจ๋ํฐ๋ง
4. ๋ณด์๊ณผ ์ด์
โข ์ธ์ฆ ์ค์
โข ์ญํ ๊ธฐ๋ฐ ์ ๊ทผ ์ ์ด
โข ์ ๊ธฐ ๋ฐฑ์
โข ๋ก๊ทธ ๋ถ์
๐ ๋ค์ ๋จ๊ณ๋ก
์ด์ ๊ธฐ๋ณธ์ ๋ง์คํฐํ์ผ๋ ๋ ๋์๊ฐ๋ณด์!
ํ์ต ์ถ์ฒ:
โข Logstash๋ก ๋ฐ์ดํฐ ํ์ดํ๋ผ์ธ ๊ตฌ์ถ
โข Kibana๋ก ์๊ฐํ ๋์๋ณด๋ ๋ง๋ค๊ธฐ
โข ๋จธ์ ๋ฌ๋ ๊ธฐ๋ฅ ํ์ฉ (์ด์ ํ์ง, ์ถ์ฒ)
โข ํด๋ฌ์คํฐ ๊ตฌ์ฑ๊ณผ ์ค๋ ๊ด๋ฆฌ
โข ์ฑ๋ฅ ํ๋ ์ฌํ
์ค์ ํ๋ก์ ํธ ์์ด๋์ด:
โข ์ด์ปค๋จธ์ค ์ํ ๊ฒ์
โข ์ฑ์ฉ ๊ณต๊ณ ๊ฒ์
โข ๋ถ๋์ฐ ๋งค๋ฌผ ๊ฒ์
โข ๋ธ๋ก๊ทธ/๋ด์ค ๊ฒ์
โข ์ฌ๋ฅ ๋ง์ผํ๋ ์ด์ค (์ฌ๋ฅ๋ท์ฒ๋ผ!)
โจ ๋ง์ง๋ง ์กฐ์ธ
๊ฒ์์ ์ฌ์ฉ์ ๊ฒฝํ์ ํต์ฌ์ด์ผ. ๋น ๋ฅด๊ณ ์ ํํ ๊ฒ์์ ์ฌ์ฉ์๋ฅผ ๋ง์กฑ์ํค๊ณ , ๋น์ฆ๋์ค ์ฑ๊ณผ๋ก ์ด์ด์ ธ. Elasticsearch๋ ๊ฐ๋ ฅํ ๋๊ตฌ์ง๋ง, ์ ๋๋ก ํ์ฉํ๋ ค๋ฉด ์ง์์ ์ธ ํ์ต๊ณผ ์ต์ ํ๊ฐ ํ์ํด.
์ฒ์์ ์ด๋ ต๊ฒ ๋๊ปด์ง ์ ์์ง๋ง, ํ๋์ฉ ์ฐจ๊ทผ์ฐจ๊ทผ ์ ์ฉํด๋ณด๋ฉด ๊ธ๋ฐฉ ์ต์ํด์ง ๊ฑฐ์ผ. ์์ ํ๋ก์ ํธ๋ถํฐ ์์ํด์ ์ ์ ํ์ฅํด๋๊ฐ๋ ๊ฑธ ์ถ์ฒํด!
์ฌ๋ฅ๋ท ๊ฐ์ ํ๋ซํผ์ ๋ง๋ค๊ณ ์ถ๋ค๋ฉด, ์ด ๊ธ์์ ๋ฐฐ์ด ๋ด์ฉ์ด ํฐ ๋์์ด ๋ ๊ฑฐ์ผ. ์ฌ์ฉ์๋ค์ด ์ํ๋ ์ฌ๋ฅ์ ๋น ๋ฅด๊ณ ์ ํํ๊ฒ ์ฐพ์ ์ ์๊ฒ ํด์ฃผ๋ ๊ฒ์ ์์คํ , ์ ๋ง ๋ฉ์ง์ง ์์? ๐จ
๊ถ๊ธํ ์ ์ด ์๊ฑฐ๋ ๋งํ๋ ๋ถ๋ถ์ด ์๋ค๋ฉด Elasticsearch ๊ณต์ ๋ฌธ์์ ์ปค๋ฎค๋ํฐ๋ฅผ ํ์ฉํด๋ด. ๋ง์ ๊ฐ๋ฐ์๋ค์ด ๋น์ทํ ๋ฌธ์ ๋ฅผ ๊ฒช๊ณ ํด๊ฒฐ์ฑ ์ ๊ณต์ ํ๊ณ ์๊ฑฐ๋ !
์, ์ด์ ๋น์ ์ ํ๋ก์ ํธ์ ๊ฐ๋ ฅํ ๊ฒ์ ๊ธฐ๋ฅ์ ์ถ๊ฐํด๋ณผ ์๊ฐ์ด์ผ. ํ์ดํ ! ๐ช
๊ด๋ จ ํค์๋
๋๊ธ ์์ฑ
์ด ๊ธ์ ๋ํ ์ฌ๋ฌ๋ถ์ ์๊ฐ์ ๋ค๋ ค์ฃผ์ธ์
๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค
๋๊ธ์ ์์ฑํ๋ ค๋ฉด ๋จผ์ ๋ก๊ทธ์ธํด์ฃผ์ธ์.
๋๊ธ 0
์ง์์ธ์ ์ฒ - ์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
- ์ ์๊ถ ๋ฐ ์์ ๊ถ: ๋ณธ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ๋ ์ AI ๊ธฐ์ ๋ก ์์ฑ๋์์ผ๋ฉฐ, ๋ํ๋ฏผ๊ตญ ์ ์๊ถ๋ฒ ๋ฐ ๊ตญ์ ์ ์๊ถ ํ์ฝ์ ์ํด ๋ณดํธ๋ฉ๋๋ค.
- AI ์์ฑ ์ปจํ ์ธ ์ ๋ฒ์ ์ง์: ๋ณธ AI ์์ฑ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ์ง์ ์ฐฝ์๋ฌผ๋ก ์ธ์ ๋๋ฉฐ, ๊ด๋ จ ๋ฒ๊ท์ ๋ฐ๋ผ ์ ์๊ถ ๋ณดํธ๋ฅผ ๋ฐ์ต๋๋ค.
- ์ฌ์ฉ ์ ํ: ์ฌ๋ฅ๋ท์ ๋ช ์์ ์๋ฉด ๋์ ์์ด ๋ณธ ์ปจํ ์ธ ๋ฅผ ๋ณต์ , ์์ , ๋ฐฐํฌ, ๋๋ ์์ ์ ์ผ๋ก ํ์ฉํ๋ ํ์๋ ์๊ฒฉํ ๊ธ์ง๋ฉ๋๋ค.
- ๋ฐ์ดํฐ ์์ง ๊ธ์ง: ๋ณธ ์ปจํ ์ธ ์ ๋ํ ๋ฌด๋จ ์คํฌ๋ํ, ํฌ๋กค๋ง, ๋ฐ ์๋ํ๋ ๋ฐ์ดํฐ ์์ง์ ๋ฒ์ ์ ์ฌ์ ๋์์ด ๋ฉ๋๋ค.
- AI ํ์ต ์ ํ: ์ฌ๋ฅ๋ท์ AI ์์ฑ ์ปจํ ์ธ ๋ฅผ ํ AI ๋ชจ๋ธ ํ์ต์ ๋ฌด๋จ ์ฌ์ฉํ๋ ํ์๋ ๊ธ์ง๋๋ฉฐ, ์ด๋ ์ง์ ์ฌ์ฐ๊ถ ์นจํด๋ก ๊ฐ์ฃผ๋ฉ๋๋ค.
์์ง ๋๊ธ์ด ์์ต๋๋ค.