1. 화상 채팅 예제로 익히는 WebRTC - 기본 예제
  2. 화상 채팅 예제로 익히는 WebRTC - 기능 추가
  3. WebRTC 외부에서 사용하기 – coturn

 

여기에서는 앞서 정리한 구글 WebRTC 예제에 다음 4가지 사항을 추가한다.

  1. SSL 적용
  2. 서버 기능 보강
  3. 오디오 추가
  4. 조금 나은 디자인

기존 예제에 추가된 소스는 GitHub에서 받을 수 있다.

 

먼저, NodeJS에 SSL을 적용하기 위해, SSL을 생성한다.

SSL은 HTTP를 암호화 해서 사용하기 위한 것으로 자세한 것은 찾아보길 바라고,

제대로 된 SSL은 유료로 구매해야 하지만

여기서는 개발을 위해 제공되는 OpenSSL로 간단하게 만들어서 사용한다.

OpenSSL을 설치하는 방법은 여기에서 정리하지 않으니 찾아보고 설치한다.



OpenSSL로 개인키(private.pem) 파일을 생성한다.

         openssl genrsa 1024 > private.pem

그리고, 개인키와 쌍이 되는 공개키를(public.pem) 다음과 같이 생성한다.

         openssl req -x509 -new -key private.pem > public.pem

탐색기로 개인키(private.pem)와 공개키(public.pem) 파일이 생성된 것을 확인 할 수 있다.

 

공개키를 생성하면, 국가, 도시 등의 여러 가지 값을 입력하라고 한다.

개발용 SSL이니 아무 값이나 입력하면 된다.

 참고: 여기서는 간단하게 키만 생성해서 사용하고, 제대로된 사설 인증서를 만드는 방법은 검색해서 익혀두면 유용하다.

 

생성한 인증서 파일을 구글 예제 폴더에 복사한다.

index.js 파일과 같은 폴더에 복사하고, index.js 파일을 적당한 편집기로 수정한다.

index.js는 다음과 같이 8080 포트의 http로 접속하도록 작성되어 있다.

var http = require('http');
var socketIO = require('socket.io');

var fileServer = new(nodeStatic.Server)();
var app = http.createServer(function(req, res) {
  fileServer.serve(req, res);
}).listen(8080);

이 내용을 다음과 같이 앞서 생성한 키 파일을 이용해서 https, 3000포트로 접속하도록 수정한다.

//var http = require('http');
var socketIO = require('socket.io');

const https = require('https');
const fs = require('fs');

const options = {
  key: fs.readFileSync('./private.pem'),
  cert: fs.readFileSync('./public.pem')
};

var fileServer = new(nodeStatic.Server)();
let app = https.createServer(options, (req,res)=>{
  fileServer.serve(req, res);
}).listen(3000);

console.log('Started chating server...');

기존에는 http://127.0.0.1:8080으로 접속했고,

이제는 https://127.0.0.1:3000으로 접속하면 된다.

그리고, "Started chating server..." 문장을 출력해서 서버가 가동된 사실을 알 수 있도록 한다.

주의: 실제로는 127.0.0.1로 접속하지 않고, 서버(PC)에 할당된 사설 IP(예: 192.168.35.154)나 공인 IP로 접속한다.

 

콘솔창에서 node index.js를 입력해서 실행하면, 다음과 같이 서버가 실행된 것을 알 수 있다.

PC나 휴대폰의 크롬 브라우저로 https://서버IP:3000으로 접속하면 다음과 같은 경고창이 실행된다.

정상 SSL이 아니라는 의미로, 웹 페이지 하단의 [고급] 버튼을 선택한다.

다음 페이지에서 "192.168.35.154(안전하지 않음)(으)로 이동"을 선택해서 서버 접속을 계속한다.

다음과 같이 PC와 휴대폰으로 접속하면, 비디오(만) 화상채팅이 실행된다.

 

두 번째 예제 보강 작업으로 서버 기능을 보강한다.

구글 예제에서 socket.io를 이용해 구현한 네트워크 연결 부분은 아주 기초적이고 단순한 구조로

두 대의 클라이언트에서 실행된 WebRTC가 서로 RTCPeerConnection로 연결할 수 있도록 도와 주는 역할을 한다(signaling).

 

현재의 예제는 채팅을 종료(웹브라우저를 닫거나 갱신-F5)하면 다시 채팅을 할 수 없다.

서버를 재가동하고 다시 접속해야만 한다.

주의: 서버 파일(index.js) 대부분과 클라이언트(main.js)의 네트워크 연결 관련 부분의 코드는 모두 socket.io로 작성되었다. socket.io에 대해서 잘 모른다면 socketio nodejs로 검색해서 관련 자료를 읽어보는 것이 좋다.

socketio nodejs 채팅방으로 검색 결과의 예제와 여기서 사용된 예제가 비슷한 것이 많으니 관련 내용을 읽어보고 구글 예제를 보강해 보는 것도 좋다.

 

다음 코드는 클라이언트의 각종 message를 받아서 전달(broadcast)하는 기존 코드(회색)에

message가 bye이면 채팅방(foo)을 비우는(leave) 코드를 추가한 것이다.

즉, 화상 채팅 중에 한 사람이라도 채팅방을 나가면(bye) 채팅방을 비우도록 한 것이다.

  socket.on('message', function(message) {
    log('Client said: ', message);
    
    if (message==="bye" && socket.rooms['foo']) {
        io.of('/').in('foo').clients((error, socketIds) => {
            if (error) throw error;

            socketIds.forEach(socketId => {
                io.sockets.sockets[socketId].leave('foo');
            });
        });
    }
    socket.broadcast.emit('message', message);
  });

다음 코드에서 확인 할 수 있지만,

다시 빈 채팅방에 입장한 순서로 연결해서 화상 채팅을 하게 된다.

참고: bye라는 메시지는 클라이언트(main.js 파일)에서 웹 브라우저를 닫거나 해당 채팅 웹 페이지를 떠날 때(onbeforeunload 이벤트시) 채팅 종료를 알리도록 작성된 메시지이다.

채팅방 이름인 foo는 클라이언트에 변수명으로 고정되어 있다 (구글 예제에서 그렇게 작성) .

제대로 된 채팅 프로그램이면 채팅방 이름이 bye란 메시지와 같이 전송되게 했을 것이다.

추가로 구현하면 예제를 이해하는데 도움이 될 것이다.

 

추가적으로 create or join 메시지의 기존 코드에 다음과 같이(빨간색),

첫 사용자가 입장하면 채팅방을 생성한다는 의미로 created,

두번째 사용자가 입장했다는 의미로 joined를 콘솔창에 출력하도록 한다.

  socket.on('create or join', function(room) {
    log('Received request to create or join room ' + room);

    var clientsInRoom = io.sockets.adapter.rooms[room];
    var numClients = clientsInRoom ? Object.keys(clientsInRoom.sockets).length : 0;
    log('Room ' + room + ' now has ' + numClients + ' client(s)');

    if (numClients === 0) {
      socket.join(room);
      log('Client ID ' + socket.id + ' created room ' + room);
      socket.emit('created', room, socket.id);
      console.log('created');
    } else if (numClients === 1) {
      log('Client ID ' + socket.id + ' joined room ' + room);
      io.sockets.in(room).emit('join', room);
      socket.join(room);
      socket.emit('joined', room, socket.id);
      io.sockets.in(room).emit('ready');
      console.log('joined');
    } else { // max two clients
      socket.emit('full', room);
    }
  });

위 코드는 이해를 하고 있는 것이 좋을 것 같아서 간단하게 정리한다.

create or join 메시지시 파라미터(room)에 채팅방 이름이(foo)이 같이 전송되어 온다.

해당 채팅방의 정보(clientsInRoom) 중에서 사용자 수가 0명이면 (numClients === 0),

해당 접속자(socket)를 지정된 채팅방(room)의 생성자로 참여(join)시키고,

해당 접속자에게 created란 메시지를 전달한다(emit).

해당 접속자는 메시지를 수신하면 방 생성자 또는 방장 등의 의미를 가지도록 설정한다.

다시 다른 사용자가 접속하면 접속자수는 현재 1이기 때문에 (numClients === 1),

해당 접속자에게 joined란 메시지와 이제 화상 채팅을 실행할 준비를(ready) 하라고 메시지를 전달한다(emit).

 

서버를 재가동하고, 다시 접속하면 화상 채팅을 할 수 있다.

이제 서버 재가동없이 한 사람이 채팅을 종료하고 다시 접속(둘다)하면 화상 채팅을 다시 할 수 있다.

양쪽에서 웹 페이지 갱신(F5)을 하면 확인 할 수 있다.

 

참고: 다소 어렵지만 한사람이 채팅방을 나가면, 채팅방을 비우기 보다는 남은 사람은 그대로 두고 새로 입장하는 사람과 채팅할 수 있도록 하는 것이 좋다.

 

세번째로 오디오가 전송 되도록 한다.

오디오가 전송되도록 하기 위해서

클라이언트에서(main.js) WebRTC가 비디오 정보를 가지고 오도록 하는 다음의 기존 코드를 수정한다.

audio가 false로 되어 있다.

navigator.mediaDevices.getUserMedia({
  audio: false,
  video: true
})
.then(gotStream)
.catch(function(e) {
  alert('getUserMedia() error: ' + e.name);
});

화상 채팅을 위해 비디오 정보를 가지고 오는 getUserMedia의 파라미터에 지정된 audio 옵션을 다음과 같이 true로 지정한다.

  const mediaOption = {
    audio: true,
    video: {
      mandatory: {
        maxWidth: 160,
        maxHeight: 120,
        maxFrameRate: 5,
      },
      optional: [
        { facingMode: 'user' },
      ],
    },
  };
  
navigator.mediaDevices.getUserMedia(mediaOption)

오디오 설정외에 인터넷에서 검색한 비디오의 다양한 옵션도 추가로 작성하였다.

추가한 옵션은 이름에서 어느 정도 의미를 알 수 있을 것이고, 자세한 내용은 여기서 정리하지 않는다.

 

다시 채팅을 시작하면, 오디오가 전송되는 것을 확인할 수 있다.

다만, 두 개의 클라이언트가 가까이 있는 경우 심한 잡음(?)을 들을 수 있다.

 

이제 마지막으로 조금더 그럴듯하게 디자인을 변경해 본다.

디자인을 변경하기 위해서는 CSS를 알아야 한다.

자세한 내용은 정리하지 않으니 따로 찾아보고,

index.html 파일의 head에 다음과 같이 메타 태그를(빨간색) 추가한다.

이 코드는 모바일 화면을 위해 추가하는 것이다.

<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
  <title>Realtime communication with WebRTC</title>
  <link rel="stylesheet" href="/css/main.css" />

</head>

css/main.css 파일의 내용을 다음과 같이 수정한다.

body {
  font-family: sans-serif;
}

#videos {
  width: 100%;
  max-width: 500px;
}

#localVideo {
  max-width: 100%;
  width: 100%;
  float:left;
  border: 1px solid;
}

#remoteVideo {
  max-width: 100%;
  width: 100%;
  float:left;
  border: 1px solid;
  display: none;
}

.localVideoInChatting {
  width: 100px !important;
  position: absolute;
}
.remoteVideoInChatting {
  display: inline-block !important;
}

위 CSS에서 localVideoInChattingremoteVideoInChatting 클래스(CSS의 클래스)를 기억해야 한다.

이 CSS의 내용은 채팅을 시작하기 전에는 내 화면(#localVideo)만 크게 보이고(width:100%),

상대 화면(#remoteVideo)은 보이지 않도록(display: none;)하는 것이다.

 

그러다 화상채팅이 시작되면(handleRemoteStreamAdded - 다음 코드)

내 화면(#localVideo)에 localVideoInChatting 클래스를 추가하여(add) 내 화면을 작게 만들고,

상대 화면(#remoteVideo)은 remoteVideoInChatting 클래스를 추가하여 크게 잘 보이도록 한다.

이 기능을 위해 다음 코드의 빨간색으로 표시된 부분을 추가하면 된다.

function handleRemoteStreamAdded(event) {
  console.log('Remote stream added.');
  remoteStream = event.stream;
  remoteVideo.srcObject = remoteStream;

  remoteVideo.classList.add("remoteVideoInChatting");
  localVideo.classList.add("localVideoInChatting");
}

function handleRemoteHangup() {
  remoteVideo.classList.remove("remoteVideoInChatting");
  localVideo.classList.remove("localVideoInChatting");
    
  console.log('Session terminated.');
  stop();
  isInitiator = false;
}

대화가 종료되면(handleRemoteHangup)

추가한 클래스들을 제거(remove)하여 원래데로 되돌린다.

 

이상으로 구글의 WebRTC 예제에 4가지를 추가하여 제법 그럴듯한 화상채팅 프로그램을 만들었다.

기존 예제에 추가된 소스는 GitHub에서 받을 수 있다.

 

제대로 화상채팅 프로그램을 만들기 위해서는 NodeJS Socket.IO, CSS를 알아야 하고,

WebRTCRTCPeerConnection를 알아야 하는 것을 알 수 있다.

그리고 signaling, STUN, TURN등의 용어를 사용하지 않았지만

예제 코드에 사용되어 있으니 확인하면서 알아 두어야 한다.

 

제대로된 화상채팅을 만들려면, 아직 많은 기능을 구현해야한다 (특히, 채팅방).

하나씩 기능을 추가하면서 제대로된 화상채팅 프로그램을 제작하다 보면 WebRTC에 대해서 잘 알게 되고

멋진 프로그램을 제작하게 될 것이다.

 

그리고 WebRTC를 이용하면,

화상채팅 외에 여러 명이 채팅 하는 컨퍼런스, 방송 등 미디어와 관련된 많은 것을 구현할 수 있다.

사용해보진 않았지만 WebRTC 기반의 Janus, Licode, Medooza, Jitsi 등을 이용하면 더 쉽게 구현할 수 있다고 한다.

 

 

오픈 소스 Lucene(루씬)을 기반으로 하는 

대표적인 검색엔진인 Solr(쏠라)와 Elasticsearch(엘라스틱서치)는 Lucene을 기반으로 하지만 사용법은 전혀 다르다.

특히 데이터 수집(색인-Indexing)에 있어서, 

일반적인 문서 색인은 Solr가 쉽게 구현할 수 있고, 웹 로그와 같은 짧은 형태의 데이터는 Elasticsearch가 쉽게 구현할 수 있는 것 같다.

또, 검색(Search) 문법에 있어서 Solr는 Lucene의 문법을 그대로 따르고, Elasticsearch는 자신만의 문법을 구현한 차이가 있다.

이 두 가지 검색엔진의 차이를 이해하고, 

둘 다 익히기 위해 예제를 만드는 중으로 세부 기능을 구현하는데 시간이 많이 걸려 (언제 끝날지...)

일단 먼저 기본적인 색인 / 검색 기능을 구현하고 설치 / 사용하는 방법을 정리한다.

Solr와 Elasticsearch예제는 각각 Github 에서 다운로드 받을 수 있다.

 

    1. Solr 설치
    2. Solr 주요 설정과 코드
    3. Elasticsearch 설치
    4. Elasticsearch 주요 설정과 코드

 

먼저, Solr 예제 사용법을 정리한다.

예제는 그림과 같이 기존에 공유한 웹 프로젝트인 Project9에 검색 기능을 추가하는 형태로 작성했다.

Project9에 대한 설명은 기존 블로그에서 확인 할 수 있다.

Project9의 게시판 데이터를 대상으로 하고,

공지사항과 일반 게시판의 게시판 종류를 데이터 종류로 구분해서 통합 검색 기능을 구현하였다.

 

설치 방법은 Solr설치 (데이터 수집)와 Project9 설치(데이터 검색)로 구분된다.

Solr 설치

  1. Solr 다운로드 및 설치
  2. 실행: solr start
  3. 라이브러리 설치
  4. 데이터 구성
  5. 색인

 

Project9 설치

    소스 다운로드 및 설치

참고: Project9 설치는 기존 방식과 동일하기 때문에 여기에서 별도로 정리하지 않는다.

 

Solr 설치

 

1.Solr 다운로드 및 설치

Solr 다운로드 사이트에서 운영체제에 맞는 압축 파일을 다운로드 받아서, 압축을 해제 한다.

 

2. 실행: solr start

콘솔창(cmd)에서 Solr를 실행하고(start),  데이터를 저장할 코어(project9)를 생성한다(create).

주의: Solr를 사용하려면 JDK1.8 이상이 설치 되어 있어야 한다.

 

웹 브라우저에서 http://localhost:8983/solr/를 입력해서 Solr 관리창에 접속한다.

생성된 project9 코어르 확인하고, 색인을 하기 위해 선택한다.

 

3. 라이브러리 설치

웹 프로젝트인 Project9은 MariaDB에 데이터를 저장하기 때문에,

데이터를 수집 하기 위해서 MariaDB 드라이버를 설치하고,

수집된 한글 데이터를 색인하기 위해 아리랑 형태소 분석기를 추가한다.

먼저, MariaDB 공식 웹 사이트에서 드라이버를 다운로드 받거나  Project9을 설치하고 .m2 폴더에서 mariadb-java-client-x.x.x.jar파일을 복사해서 solr/dist 폴더에 넣어준다.

형태소 분석기는 아리랑을 사용하고, 커뮤니티 사이트의 다운로드 메뉴에서 적절한 버전을 다운로드 받아서

[solr설치경로]\server\solr-webapp\webapp\WEB-INF\lib 폴더에 넣어 준다.

 

4. 데이터 구성

이 작업을 하기 위해서는 Project9의 소스가 설치 되어 있어야 한다.

데이터 구성에 필요한 정보가 Project9의 소스의 solr 폴더에 있기 때문이다.

문서 작성의 단계상 다음 단계에 하는 것으로 정리했지만, Project9의 Solr 예제 소스를 다음과 같이 다운로드 받는다.

또는, Eclipse에서 다운로드 받는 것이 편리하고, Eclipse로 git 소스를 다운로드 받는 것은 기존 블로그를 참고하면 된다.

        git clone https://github.com/gujc71/Project9_Solr.git

 

다운 받은 Project9 소스 폴더에서 project9_solr\solr에 있는 모든 파일을 [solr설치경로]\server\solr\project9\conf 폴더에 복사한다.

 

지금까지 설정한(복사한) 내용들이 반영될 수 있도록 Solr를 재가동 한다.

         solr restart -p 8983

 

5. 색인

Solr의 색인은 DIH (Data Import Handler)를 이용하며 자세한 정리는 DIH 예제 분석에서 정리되어 있다.

생성한 project9 코어를 선택하고, Dataimport 메뉴에서 풀색인(full-import)을 실행한다(execute).

위 그림의 우측 녹색 배경에 출력된 내용처럼 41건의 데이터가 조회되면 잘 색인(Indexing)된 것이다.

주의: 실행 결과는 [Refresh Status] 버튼을 계속 클릭하거나, [Auto-Refresh Status]를 선택하면 된다.

 

[Query] 메뉴에서 실행(Execute Query] 버튼을 클릭해서 색인한 데이터를 조회한다.

검색과 관련된 자세한 사항은 인터넷 검색을 해보거나 기존 블로그 내용을 참고하면 된다.

 

Project9 설치

 

Project9의 설치는 기존에 공개한 웹 프로젝트로 기존 블로그에 정리되어 있다.

이 내용과 동일하고, Github 프로젝트 명만 project9이 아닌 project9_solr로 바꾸어 설치하면 된다.

그리고, 적당한 계정으로 로그인해서 [통합검색] 메뉴에서 다양한 검색 결과를 확인할 수 있다.

 

이상으로 예제를 실행하는 방법을 정리하였고

데이터를 수집해서 색인하고, 색인한 데이터를 프로그램에서 조회하는 주요 부분에 대한 코드 정리는 다음 블로그에서 정리한다.

 

 

검색 서버를 구축 하려면 필요한 사항이 많지만, 가장 중요한 부분은 색인(Indexing)과 검색(Search)이다.

Solr에서 색인은 DIH (Data Import Handler)로 처리하고

검색식을 Solr 검색엔진에 전송하고 결과를 받아서 보여주는 Java(SolrJ) 부분으로 나누어 구현하였다.

DIH에 대한 사항SolrJ 사용법은 각각 이전에 정리하였고

구현 방법을 모두 여기에 정리 할 수 없기 때문에, 색인과 검색에 관련된 몇 가지 주요한 사항만 정리한다.

주의: 용어나 옵션에 대한 상세한 설명을 정리하지 않기 때문에 Solr를 잘 모른다면 다음 내용이 어려울 수 있다.

    1. Solr 설치
    2. Solr 주요 설정과 코드
    3. Elasticsearch 설치
    4. Elasticsearch 주요 설정과 코드

 

정리할 주요 내용은 다음 그림과 같다.

 

색인

색인 작업은 Solr 검색 서버가 처리하는 것이고,

개발자가 구현하는 것은 Solr가 색인 할 수 있도록 데이터를 수집하는 작업을 의미한다.

데이터 수집 작업은 수집한 데이터를 저장하는 공간에 대한 정의와 데이터를 수집하는 방법에 대한 정의로 구분된다.

 

앞서서 코어(project9)를 생성하고, 설정에([solr설치경로]\server\solr\project9\conf) 몇 가지 파일을 복사했다.

이 복사한 파일 중 managed-schema 파일은 다음과 같이 코어에 저장한 데이터 구조를 지정하는 파일이다.

<field name="brdtype" type="string" indexed="true" stored="true"/>
<field name="bgno" type="string" indexed="true" stored="true"/>
<field name="brdno" type="string" indexed="true" stored="true"/>
<field name="brdwriter" type="string" indexed="true" stored="true"/>
<field name="brdtitle" type="text_ko" indexed="true" stored="true"/>
<field name="brdmemo" type="text_ko" indexed="true" stored="true"/>
<field name="brddate" type="string" indexed="true" stored="true"/>
<field name="brdtime" type="string" indexed="true" stored="true"/>

<field name="reno" type="string" indexed="true" stored="true" multiValued="false" required="false" />
<field name="rememo" type="string" indexed="true" stored="true" multiValued="false" required="false" />

<field name="fileno" type="string" indexed="true" stored="true" multiValued="false" required="false" />
<field name="filename" type="string" indexed="true" stored="true" multiValued="false" required="false" />
<field name="filememo" type="text_ko" indexed="true" stored="true" multiValued="false" required="false" />

managed-schema

게시판의 내용(brd*)과 댓글(reno, rememo), 첨부파일(file*)에 대한 정보를 저장한다.

검색엔진은 RDBMS와 다르게 데이터를 정규화해서 저장하지 않고, 위 코드와 같이 하나의 문서로 모든 내용을 저장한다.

 

데이터 타입(type)은 여러가지가 있지만

여기서는 형태소 분석기로 분석해서 색인할 데이터는 text_ko라는 사용자 정의 타입으로 지정하고,

그대로 색인할 데이터는 string으로 지정한다.

 

사용자 정의 타입인 text_ko는 managed-schema 파일 하단에 다음과 같이 정의하였다.

<fieldType name="text_ko" class="solr.TextField" >
    <analyzer type="index">
        <tokenizer class="org.apache.lucene.analysis.ko.KoreanTokenizerFactory"/>
        <filter class="solr.LowerCaseFilterFactory"/>
        <filter class="solr.ClassicFilterFactory"/>
        <filter class="org.apache.lucene.analysis.ko.KoreanFilterFactory" hasOrigin="true" hasCNoun="true"  bigrammable="false" queryMode="false"/>
        <filter class="solr.SynonymFilterFactory" synonyms="synonyms.txt" ignoreCase="true" expand="false" />
        <filter class="org.apache.lucene.analysis.ko.WordSegmentFilterFactory" hasOrijin="true"/>
        <!--filter class="org.apache.lucene.analysis.ko.HanjaMappingFilterFactory"/>
        <filter class="org.apache.lucene.analysis.ko.PunctuationDelimitFilterFactory"/-->
        <filter class="solr.StopFilterFactory" ignoreCase="true" words="stopwords.txt"/>
    </analyzer>
    <analyzer type="query">
        <tokenizer class="org.apache.lucene.analysis.ko.KoreanTokenizerFactory"/>
        <filter class="solr.LowerCaseFilterFactory"/>
        <filter class="solr.ClassicFilterFactory"/>
        <filter class="org.apache.lucene.analysis.ko.KoreanFilterFactory" hasOrigin="true" hasCNoun="true" bigrammable="false" queryMode="false"/>
        <filter class="solr.SynonymFilterFactory" synonyms="synonyms.txt" ignoreCase="true" expand="false" />
        <filter class="org.apache.lucene.analysis.ko.WordSegmentFilterFactory" hasOrijin="true"/>
        <filter class="org.apache.lucene.analysis.ko.HanjaMappingFilterFactory"/>
        <filter class="org.apache.lucene.analysis.ko.PunctuationDelimitFilterFactory"/>
        <filter class="solr.StopFilterFactory" ignoreCase="true" words="stopwords.txt"/>
    </analyzer>
</fieldType>

managed-schema

위 코드는 한글 형태소 분석기인 아리랑을 사용하기 위해 필요한 설정으로 상세한 설명은 인터넷에서 찾아보면 된다.

 

저장한 공간을 지정한 뒤에는 db-data-config.xml 파일에서 다음과 같이 수집할 데이터를 지정한다.

<dataSource driver="org.mariadb.jdbc.Driver" url="" user="" password="" readOnly="True"/>
<dataSource name="file" type="BinFileDataSource" basePath="d:\workspace\fileupload\" />
<document>
    <entity name="board" rootEntity="true" query="SELECT ">
       <field column="brdno" name="id" />
       필드들~~
       <entity name="reply" child="true" pk="reno" query="SELECT">
          <field column="reno" name="reno" />
           <field column="rememo" name="rememo" />
       </entity>

        <entity name="boardFileList" child="true" pk="fileno" query="SELECT">
          <field column="fileno" name="fileno" />
          <field column="filename" name="filename" />

           <entity name="boardFile" processor="TikaEntityProcessor">
             <field column="text" name="filememo" />
           </entity>
        </entity>
    </entity>
</document>

db-data-config.xml

데이터 수집은 SQL문으로 작성하고, DIH 예제 정리를 참고하면 된다.

단순한 구조는 DIH 예제에 정리한 것과 같이 데이터를 가지고 오는 SQL만 초기 전체 데이터와 증가된 데이터에 대해서 작성한다.

여기서 사용된 구조는 조금 복잡한데,

게시판 글이(board) 부모이고, 댓글(reply)과 첨부파일이(boardFileList) 자식인 구조로 작성되었다.

위 XML 코드를 자세히 보면 게시판(board) entity 안에 댓글(reply)과 첨부파일(boardFileList) entity 가 포함되어 있고,

게시판(board) entity는 root로, 댓글(reply)과 첨부파일(boardFileList) entity는 child로 설정한다.

이것을 nested documents라고 한다.

데이터가 직관적으로 저장되지 않아서 개인적으로 Solr가 Elasticsearch보다 조금 부족한 부분이라고 생각하는데,

다음 그림과 같이 자식 데이터인 댓글이 부모와 따로 저장되어 있다.

Elasticsearch에서는 자식 데이터가 배열형처럼 처리되어 부모 글과 같이 조회된다.

주의: Solr에서 자식 문서(nested) 처리를 찾기 위해 제법 많은 시간을 허비했지만 찾지 못 한 것 일 수 있음.

 

마지막으로 첨부 파일은 Tika(TikaEntityProcessor)를 사용해서 텍스트로 변환해서 색인한다.

Tika로 추출된 텍스트(text)는 filememo라는 이름으로 Solr에 색인된다.

 

검색

 

검색은 기존 Prohect9 프로젝트에 구현되어 있으며

하나의 Java파일(gu.search.SearchCtr)과 Jsp(search.jsp) 파일로 작성하였다.

search.jsp 파일은 검색 결과를 Json으로 받아서 적절하게 보여주는 기능을 하고, 여기서 정리하지 않는다.

SearchCtr.java 파일은 SolrJ를 기반으로 작성하였고, 

검색식을 만드는 부분, SolrJ를 이용하여 Solr에 질의하는 부분, Solr에서 반환 받은 결과를 정리해서 Json으로 작성하는 부분으로 나눌 수 있다.

검색식을 만드는 기본 코드는 다음과 같고

사용자가 입력한 키워드와 검색 필드에 맞추어 Solr(Lucene) 검색식을 생성한다.

Solr(Lucene) 검색식은 기존 자료를 참고하고, 기본적으로 [필드명 : 키워드] 형태로 작성한다.

예를 들어 글 제목에서 JS 란 단어를 찾는 경우 brdsubject:JS가 된다.

private String makeQuery(String keyword, String[] fields) {
    String queryStr = "";
    
    for (int i=0; i<fields.length; i++) {
        if (queryStr.length()>0) queryStr += " OR ";
        if ("brdfiles".equals(fields[i]))
            queryStr += " {!parent which=\"brdtype:1\"}filememo:" + keyword;
        else 
        if ("brdreply".equals(fields[i]))
             queryStr += " {!parent which=\"brdtype:1\"}rememo:" + keyword;
        else queryStr += " " + fields[i] + ":" + keyword;
    }
    
    return queryStr;
}

SearchCtr.java

makeQuery()함수에서는 작성자(brdwriter), 글 제목(brdsubject), 글 내용(brdmemo) 등의 여러 필드(fields)가 searchRange 변수에 콤마(,)로 묶여서 넘어오기 때문에 콤마로 나누어서(split) 개수만큼 반복한다.

여러개의 필드에서 해당 키워드를 찾기 때문에 OR로 연결한다.

즉, 작성자(brdwriter)에 키워드가 있거나(OR)  글 제목(brdsubject)에 키워드가 있는(OR ...)  문서를 찾는 것이 된다.

중요: 기본 검색식 brdsubject:JS는 위 코드에서 마지막 else 문에 있는 코드이다.

        queryStr += " " + fields[i] + ":" + keyword;

앞서 사용한 if 문들은 nested 처리, 즉, 하나의 게시글에 대한 댓글, 첨부 파일 같은 자식글 검색을 위한 코드로

댓글, 첨부 파일 같은 자식글에서 검색이 되더라도 부모글이 조회되어야 하기 때문에 다음과 같이 별도의 코드를 사용한다.

      {!parent which="brdtype:1"}rememo:키워드

댓글(rememo)에 키워드가 검색되더라도 글종류(brdtype)가 1인 데이터를 조회하는 것으로

글 종류는 게시물 색인시 1로 저장하도록 작성해 두었다.

 

이렇게 작성 한 makeQuery()함수를 호출하는 검색식의 메인 부분은 다음 코드이다.

String searchKeyword = searchVO.getSearchKeyword();

String[] fields = searchVO.getSearchRange().split(",");	 			// 검색 대상 필드 - 작성자, 제목, 내용 등
String[] words  = searchKeyword.split(" ");
String queryStr = "";

for (int i=0; i<words.length; i++) {
	queryStr += makeQuery(words[i], fields);
}

String searchType = searchVO.getSearchType();
if (searchType!=null & !"".equals(searchType)) {
	queryStr = "(" + queryStr + ") AND bgno:" + searchType;  
}

if (!"a".equals(searchVO.getSearchTerm())) { // 기간 검색		
	queryStr = "(" + queryStr + ") AND brddate:[" + searchVO.getSearchTerm1() + " TO " + searchVO.getSearchTerm2() + "]";  
}

SearchCtr.java

사용자가 입력하는 키워드(searchKeyword)는 공백으로 구분되어 여러 개가 입력될 수 있기 때문에 searchKeyword.split(" ")로 배열을 생성해서, 개수 만큼(words) Solr 검색식을 만든다(makeQuery).

여러 개의 키워드를 입력한 경우도 OR 연산으로 처리하였다.

한 필드에 A 키워드가 있거나 B 키워드가 있으면 조회하도록 한 것이다.

개발하는 조건에 따라 AND로 구현 할 수 있다.

 

사용자가 입력한 키워드(searchKeyword)와 검색할 필드(searchRange)를 이용해 기본 검색식을 생성하고

날짜(searchTerm)에 대한 조건을 지정할 수도 있고

통합 검색을 흉내내기 위해 게시판 종류(searchType)를 지정할 수 있다.

다만, 기본식은 OR로 연결했지만 날짜와 게시판 종류는 AND로 조회한다.

예를 들면, 게시판 종류가 공지사항이고(AND) 날짜는 3월 1일부터 3월 14일까지의 데이터 중에서(AND)

글제목에 해당 키워드가 있거나 글내용에 해당 키워드가 있는 문서를 조회하기 때문이다.

 

다음 부분은 검색식을 Solr로 보내는 SolrJ 코드이다.

SolrQuery solrQuery = new SolrQuery();
solrQuery.setQuery(queryStr);
solrQuery.setFields("");
solrQuery.addSort("id", ORDER.desc);								// 정렬
solrQuery.setStart( (searchVO.getPage()-1) * DISPLAY_COUNT);		// 페이징
solrQuery.setRows(DISPLAY_COUNT); 
solrQuery.setFacet(true);											// 합계
solrQuery.addFacetField("bgno");
solrQuery.setParam("hl.fl", "brdwriter, brdtitle, brdmemo");		// 하이라이팅
solrQuery.setHighlight(true).setHighlightSnippets(1);
logger.info(solrQuery.toString());

// 실제 조회
QueryResponse queryResponse = null;
SolrClient solrClient = new HttpSolrClient.Builder("http://localhost:8983/solr/project9").build();
try {
	queryResponse = solrClient.query(solrQuery);
	solrClient.close(); 

	logger.info(queryResponse.toString());
} catch (SolrServerException | IOException e) {
	logger.error("solrQuery error");
}

SearchCtr.java

정렬, 페이징 처리, 하이라이팅할 필드 등을 지정하고 개수를 집계(facet)할 필드를 지정한다.

facet는 통합 검색 기능을 위한 것으로 게시판 종류(bgno)별로 총 개수를 반환한다.

 

Solr 서버에 접속하고(SolrClient), 작성한 검색식을(solrQuery) 전송한다(질의한다-query).

실행 결과는 queryResponse에 Json(아닌 부분도 있다.) 형태로 반환된다.

 

마지막 부분은 검색 결과를 정리해서 Json 형태로 만드는 코드이다.

HashMap<String, Long> facetMap = new HashMap<String, Long>();
List<FacetField> ffList = queryResponse.getFacetFields();
for(FacetField ff : ffList){
    List<Count> counts = ff.getValues();
    for(Count c : counts){
        facetMap.put(c.getName(), c.getCount());
    }
}

//Map<String, Map<String, List<String>>> highlights = rsp1.getHighlighting();
HashMap<String, Object> resultMap = new HashMap<String, Object>();
resultMap.put("total", queryResponse.getResults().getNumFound());
resultMap.put("docs", JSONUtil.toJSON(queryResponse.getResults()).toString()) ;
resultMap.put("facet", facetMap) ;
resultMap.put("highlighting", queryResponse.getHighlighting()) ;

SearchCtr.java

반환된 queryResponse가 완전한 Json이면 처리 할 것이 없어서 그대로 JSP로 전달하면 좋은데,

아쉽게 조금 부족해서 조금의 처리를 해야 한다.

 

위 코드의 아래 부분에 있는 resultMap이 반환을 위한 JSon 작성부분으로

total은 검색된 전체 데이터 개수로 numFound 값으로 Solr에서 반환된다.

docs는 검색된 문서들로 5개씩(DISPLAY_COUNT) 반환되는데,  getResults()함수로 가져오고 기본적으로 Json이지만 쓸데 없는 코드가 있어서 JSONUtil.toJSON() 함수로 변환해서 사용한다.

facet는 게시판 종류별 개수로 어쩡쩡한 구조로 반환되기 때문에 위 코드 앞부분에서 조금 복잡한 변환 작업을 한다 (facetMap 변수 부분 참조).

highlighting는 Json으로 반환되기 때문에 그대로 전달한다.

 

다른 인터넷 자료에서는 반환 결과를 VO 개체에 넣는 등 복잡한 처리를 하지만,

JSP에서 Javascript로 쉽게 Json을 사용 할수 있기 때문에 여기서는 반환 데이터를 Json으로 그냥 전송한다.

 

JSP까지 정리하면 검색과 관계없는 코드로 너무 길어질 것 같아서 여기까지 정리한다.

이상의 예제는 게시글/댓글,파일에 대한 색인과 검색에 대한 것으로

글이 수정되거나 삭제 된 경우에 대한 것도 처리가 필요하다.

일부 구현하였지만 게시판 프로그램 자체를 수정해야 해서 모두 구현하지 않았다.

이런 부분까지 구현하면 실사용에서도 무난하게 사용할 수 있을 것이다.

 

 

 

+ Recent posts