QtGrpc 채팅
채팅방에서 다양한 종류의 메시지를 공유할 수 있는 채팅 애플리케이션입니다.
이 채팅 예제는 Qt GRPC 클라이언트 API의 고급 사용법을 보여줍니다. 서버는 사용자가 등록 및 인증을 통해 채팅방( ChatRoom)에 참여할 수 있도록 지원합니다. 참여 후 사용자는 채팅방에서 텍스트 메시지, 이미지, 사용자 활동 정보 또는 디스크에 저장된 기타 파일 등 다양한 유형의 메시지를 다른 모든 참가자와 공유할 수 있습니다.

예제 실행하기
qtgrpc_chat_server가 실행 중이고 정상적으로 리스닝 상태인지 확인하십시오.- 서버와 동일한 컴퓨터를 사용 중인 경우,
qtgrpc_chat_client을 실행할 때 기본localhost주소만 입력해도 됩니다. 서버를 호스팅하는 컴퓨터 이외의 기기를 사용하는 경우, 설정 대화 상자에서 서버가 실행 중인 호스트의 정확한 IP 주소를 지정하십시오. - 원활한 이모지 경험을 위해 🚀 클라이언트 측에서
GRPC_CHAT_USE_EMOJI_FONTCMake 옵션이 활성화되어 있는지 확인하십시오.

예제를 실행하려면 Qt Creator예제를 실행하려면 Welcome 모드를 열고 Examples 에서 예제를 선택하세요. 자세한 내용은 Qt Creator: 튜토리얼: 빌드 및 실행을 참조하세요.
관련 모듈 및 클래스.
이 예제에서는 다음과 같은 Qt 모듈과 클래스를 소개합니다.
- QGrpcBidiStream 를 통한 통신.
- 워커 thread 에서
QtGrpc클라이언트 사용. - protobuf 스키마에서 QtProtobufQtCoreTypes 모듈 사용하기.
- SSL을 통한 보안 통신.
- QML에서 QtProtobuf 메시지 시각화 ListView.
Protobuf 스키마
Protobuf 스키마는 채팅 애플리케이션에서 사용되는 메시지와 서비스의 구조를 정의합니다. 이 스키마는 두 개의 파일로 나뉘어 있습니다:
syntax = "proto3";
package chat;
import "chatmessages.proto";
service QtGrpcChat {
// Register a user with \a Credentials.
rpc Register(Credentials) returns (None);
// Join as a registered user and exchange \a ChatMessage(s)
rpc ChatRoom(stream ChatMessage) returns (stream ChatMessage) {}
}qtgrpcchat.proto 파일은 QtGrpcChat 서비스를 지정하며, 이 서비스는 다음 두 가지 RPC 메서드를 제공합니다:
Register: 제공된Credentials를 사용하여 사용자를 등록합니다. 서버는 데이터베이스에 저장된 사용자를 일반 텍스트 형식으로 저장하고 인증합니다.ChatRoom: 연결된 모든 클라이언트 간에ChatMessage를 주고받기 위한 양방향 스트림을 설정합니다. 서버는 수신된 모든 메시지를 다른 연결된 클라이언트들에게 브로드캐스트합니다.
syntax = "proto3";
package chat;
import "QtCore/QtCore.proto";
message ChatMessage {
string username = 1;
int64 timestamp = 2;
oneof content {
TextMessage text = 3;
FileMessage file = 4;
UserStatus user_status = 5;
}
}chatmessages.proto 파일은 ChatMessage 를 정의하며, 이는 태그가 지정된 연합형(sum type이라고도 함)입니다. 이는 ChatRoom 스트리밍 RPC를 통해 전송될 수 있는 모든 개별 메시지를 나타냅니다. 모든 ChatMessage 는 발신자를 식별하기 위해 username 와 timestamp 를 반드시 포함해야 합니다.
QtCore/QtCore.proto 임포트를 포함하면 QtProtobufQtCoreTypes 모듈의 타입을 사용할 수 있게 되어, QtCore 전용 타입과 이에 상응하는 Protobuf 타입 간의 원활한 변환이 가능해집니다.
message FileMessage {
enum Type {
UNKNOWN = 0;
IMAGE = 1;
AUDIO = 2;
VIDEO = 3;
TEXT = 4;
}
Type type = 1;
string name = 2;
bytes content = 3;
uint64 size = 4;
message Continuation {
uint64 index = 1;
uint64 count = 2;
QtCore.QUuid uuid = 3;
}
optional Continuation continuation = 5;
}FileMessage 는 ChatMessage 합성 유형에서 지원되는 메시지 유형 중 하나입니다. 이 유형을 사용하면 임의의 로컬 파일을 메시지로 래핑할 수 있습니다. 선택적 Continuation 필드는 대용량 파일 전송을 청크 단위로 처리하여 안정적인 전달을 보장합니다.
참고: Protobuf 스키마 및 애플리케이션 코드에서 ProtobufQtCoreTypes 모듈을 사용하는 방법에 대한 자세한 내용은 Qt Core usage를 참조하십시오.
서버
참고: 여기서 설명하는 서버 애플리케이션은 gRPC™ 라이브러리를 사용합니다.
이 서버 애플리케이션은 비동기 gRPC 콜백 API를 사용합니다. 이를 통해 완료 큐를 수동으로 관리해야 하는 복잡성 없이 비동기 API가 제공하는 성능상의 이점을 누릴 수 있습니다.
class QtGrpcChatService final : public chat::QtGrpcChat::CallbackService생성된 QtGrpcChat 서비스의 CallbackService 을 상속하는 QtGrpcChatService 클래스를 선언합니다.
grpc::ServerBidiReactor<chat::ChatMessage, chat::ChatMessage> *
ChatRoom(grpc::CallbackServerContext *context) override
{
return new ChatRoomReactor(this, context);
}
grpc::ServerUnaryReactor *Register(grpc::CallbackServerContext *context,
const chat::Credentials *request,
chat::None * /*response*/) override또한 서비스에서 제공하는 두 가지 ` gRPC ` 메서드의 기능을 구현하기 위해 가상 함수를 재정의하십시오:
Register메서드는 사용자를 검증하고 일반 텍스트 데이터베이스에 저장합니다.ChatRoom메서드는 메타데이터에 제공된 자격 증명을 데이터베이스와 대조하여 확인합니다. 확인에 성공하면 통신을 위한 양방향 스트림을 설정합니다.
// Broadcast \a message to all connected clients. Optionally \a skip a client
void broadcast(const std::shared_ptr<chat::ChatMessage> &message, const ChatRoomReactor *skip)
{
for (auto *client : activeClients()) {
assert(client);
if (skip && client == skip)
continue;
client->startSharedWrite(message);
}
}서비스 구현체는 ` ChatRoom ` 메서드를 통해 연결되거나 연결이 해제되는 모든 활성 클라이언트를 추적합니다. 이를 통해 연결된 모든 클라이언트와 메시지를 공유하는 ` broadcast ` 기능이 가능해집니다. 저장 공간과 오버헤드를 줄이기 위해 ` ChatMessage `는 ` shared_ptr`로 래핑됩니다.
// Share \a response. It will be kept alive until the last write operation finishes.
void startSharedWrite(std::shared_ptr<chat::ChatMessage> response)
{
std::scoped_lock lock(m_writeMtx);
if (m_response) {
m_responseQueue.emplace(std::move(response));
} else {
m_response = std::move(response);
StartWrite(m_response.get());
}
}startSharedWrite 메서드는 ChatRoomReactor 의 멤버 함수입니다. 리액터가 현재 쓰기 작업을 수행 중인 경우, 메시지는 큐에 버퍼링됩니다. 그렇지 않은 경우, 쓰기 작업이 시작됩니다. 모든 클라이언트 간에 공유되는 단일하고 고유한 메시지가 하나 있습니다. response 메시지의 각 복사본은 use_count 의 크기를 증가시킵니다. 모든 클라이언트가 메시지 쓰기를 완료하고, 해당 use_count 가 0으로 떨어지면 자원이 해제됩니다.
// Distribute the incoming message to all other clients.
m_service->broadcast(m_request, this);
m_request = std::make_shared<chat::ChatMessage>(); // detach
StartRead(m_request.get());이 코드 조각은 ChatRoomReactor::OnReadDone 가상 메서드의 일부입니다. 이 메서드가 호출될 때마다 클라이언트로부터 새로운 메시지가 수신된 것입니다. 이 메시지는 발신자를 건너뛰고 다른 모든 클라이언트에게 브로드캐스트됩니다.
std::scoped_lock lock(m_writeMtx);
if (!m_responseQueue.empty()) {
m_response = std::move(m_responseQueue.front());
m_responseQueue.pop();
StartWrite(m_response.get());
return;
}
m_response.reset();이 코드 조각은 ` ChatRoomReactor::OnWriteDone ` 가상 메서드의 일부입니다. 이 메서드가 호출될 때마다 클라이언트에 메시지가 기록됩니다. 큐에 버퍼링된 메시지가 있는 경우 다음 메시지가 기록됩니다. 그렇지 않은 경우, ` m_response `을 재설정하여 기록 작업이 진행 중이 아님을 알립니다. ` broadcast ` 메서드와의 경합을 방지하기 위해 잠금(lock)이 사용됩니다.
클라이언트
클라이언트 애플리케이션은 제공된 Protobuf 스키마를 사용하여 서버와 통신합니다. 이 애플리케이션은 사용자 등록 및 ChatRoom 의 gRPC 메서드가 처리하는 장기적인 양방향 스트림을 처리하기 위한 프론트엔드 및 백엔드 기능을 모두 제공합니다. 이를 통해 ChatMessage의 시각화 및 통신이 가능해집니다.
설정
add_library(qtgrpc_chat_client_proto STATIC)
qt_add_protobuf(qtgrpc_chat_client_proto
QML
QML_URI QtGrpcChat.Proto
PROTO_FILES
../proto/chatmessages.proto
PROTO_INCLUDES
$<TARGET_PROPERTY:Qt6::ProtobufQtCoreTypes,QT_PROTO_INCLUDES>
)
qt_add_grpc(qtgrpc_chat_client_proto CLIENT
PROTO_FILES
../proto/qtgrpcchat.proto
PROTO_INCLUDES
$<TARGET_PROPERTY:Qt6::ProtobufQtCoreTypes,QT_PROTO_INCLUDES>
)먼저, Protobuf 스키마를 기반으로 소스 파일을 생성합니다. qtgrpcchat.proto 파일에는 message 정의가 포함되어 있지 않으므로 qtgrpcgen 생성만 수행하면 됩니다. 또한 "QtCore/QtCore.proto" 임포트가 유효하도록 ProtobufQtCoreTypes 모듈의 PROTO_INCLUDES 도 제공해야 합니다.
target_link_libraries(qtgrpc_chat_client_proto
PUBLIC
Qt6::Protobuf
Qt6::ProtobufQtCoreTypes
Qt6::Grpc
)독립적인 qtgrpc_chat_client_proto 타깃이 ProtobufQtCoreTypes 모듈을 포함한 종속성에 대해 공개적으로 링크되도록 하십시오. 그러면 애플리케이션 타깃이 이 라이브러리에 대해 링크됩니다.
백엔드 로직
애플리케이션의 백엔드는 다음 네 가지 핵심 요소를 기반으로 구축됩니다:
ChatEngine: 애플리케이션 로직을 관리하는 QML용 싱글톤.ClientWorker: gRPC 클라이언트 기능을 비동기적으로 제공하는 워커 객체.ChatMessageModel: 사용자 세션(ChatMessage)을 처리하고 저장하기 위한 사용자 정의 세션 관리자(QAbstractListModel).UserStatusModel: 사용자 활동을 관리하기 위한 사용자 정의QAbstractListModel.
explicit ChatEngine(QObject *parent = nullptr);
~ChatEngine() override;
// Register operations
Q_INVOKABLE void registerUser(const chat::Credentials &credentials);
// ChatRoom operations
Q_INVOKABLE void login(const chat::Credentials &credentials);
Q_INVOKABLE void logout();
Q_INVOKABLE void sendText(const QString &message);
Q_INVOKABLE void sendFile(const QUrl &url);
Q_INVOKABLE void sendFiles(const QList<QUrl> &urls);
Q_INVOKABLE bool sendFilesFromClipboard();위의 코드 조각은 서버와 상호 작용하기 위해 QML에서 호출되는 Q_INVOKABLE 기능 중 일부를 보여줍니다.
explicit ClientWorker(std::shared_ptr<LogModel> logger, QObject *parent = nullptr);
~ClientWorker() override;
public Q_SLOTS:
void registerUser(const chat::Credentials &credentials);
void login(const chat::Credentials &credentials);
void logout();
void sendFile(const QUrl &url);
void sendFiles(const QList<QUrl> &urls);
void sendMessage(const chat::ChatMessage &message);ClientWorker 가 제공하는 슬롯은 ChatEngine 에서 노출하는 API와 어느 정도 유사합니다. ClientWorker 는 전용 스레드에서 작동하여 대용량 파일 전송이나 수신과 같은 리소스 소모가 큰 작업을 백그라운드에서 처리합니다.
m_clientWorker->moveToThread(&m_clientThread);
m_clientThread.start();
connect(&m_clientThread, &QThread::finished, m_clientWorker, &QObject::deleteLater);
connect(m_clientWorker, &ClientWorker::registerFinished, this, &ChatEngine::registerFinished);
connect(m_clientWorker, &ClientWorker::chatError, this, &ChatEngine::chatError);
...ChatEngine 생성자에서 ClientWorker 를 전용 워커 스레드에 할당하고, 신호를 계속 처리 및 전달하여 QML 측에서 사용할 수 있도록 합니다.
void ChatEngine::registerUser(const chat::Credentials &credentials)
{
QMetaObject::invokeMethod(m_clientWorker, &ClientWorker::registerUser, credentials);
}
...
void ClientWorker::registerUser(const chat::Credentials &credentials)
{
if (credentials.name().isEmpty() || credentials.password().isEmpty()) {
emit chatError(tr("Invalid credentials for registration"));
return;
}
if ((!m_client || m_hostUriDirty) && !initializeClient()) {
emit chatError(tr("Failed registration: unabled to initialize client"));
return;
}
auto reply = m_client->Register(credentials, QGrpcCallOptions{}.setDeadlineTimeout(2s));
const auto *replyPtr = reply.get();
connect(
replyPtr, &QGrpcCallReply::finished, this,
[this, reply = std::move(reply)](const QGrpcStatus &status) {
emit registerFinished(status);
},
Qt::SingleShotConnection);
}이는 ChatEngine 가 ClientWorker 와 상호작용하여 사용자를 등록하는 방법을 보여줍니다. ClientWorker 는 별도의 스레드에서 실행되므로, invokeMethod 를 사용하여 해당 멤버 함수를 안전하게 호출하는 것이 중요합니다.
ClientWorker 에서는 클라이언트가 초기화되지 않았는지, 또는 호스트 URI가 변경되었는지 확인합니다. 두 조건 중 하나라도 충족되면 initializeClient 를 호출하여 새로운 QGrpcHttp2Channel 를 생성합니다. 이는 리소스 소모가 큰 작업이므로 발생 횟수를 최소화해야 합니다.
Register RPC를 처리할 때는 setDeadlineTimeout 옵션을 사용하여 서버의 비활성 상태를 방지하십시오. 일반적으로 단항 RPC에 대해서는 마감 시간을 설정하는 것이 권장됩니다.
void ClientWorker::login(const chat::Credentials &credentials)
{
if (credentials.name().isEmpty() || credentials.password().isEmpty()) {
emit chatError(tr("Invalid credentials for login"));
return;
}
...
QGrpcCallOptions opts;
opts.setMetadata({
{ "user-name", credentials.name().toUtf8() },
{ "user-password", credentials.password().toUtf8() },
});
connectStream(opts);
}ChatRoom 에 로그인할 때, 서버에서 인증을 위해 요구하는 사용자 자격 증명을 제공하려면 setMetadata 옵션을 사용할 수 있습니다. 실제 호출 및 연결 설정은 connectStream 메서드에서 처리됩니다.
void ClientWorker::connectStream(const QGrpcCallOptions &opts)
{
...
m_chatStream = m_client->ChatRoom(*initialMessage, opts);
...
connect(m_chatStream.get(), &QGrpcBidiStream::finished, this,
[this, opts](const QGrpcStatus &status) {
if (m_chatState == ChatState::Connected) {
// If we're connected retry again in 250 ms, no matter the error.
QTimer::singleShot(250, [this, opts]() { connectStream(opts); });
} else {
setState(ChatState::Disconnected);
m_chatResponse = {};
m_userCredentials = {};
m_chatStream.reset();
emit chatStreamFinished(status);
}
});
...연결 상태인 동안 스트림이 갑작스럽게 종료될 경우를 대비해 기본적인 재연결 로직을 구현합니다. 이는 초기 호출 시 사용된 QGrpcCallOptions 를 사용하여 connectStream 를 다시 호출하기만 하면 됩니다. 이렇게 하면 필요한 모든 연결도 함께 업데이트됩니다.
참고: FileDialog를 사용하거나 다른 앱으로 전환하는 등의 경우,Android의 Doze/App-Standby 모드가 활성화될 수 있습니다. 이 모드는 네트워크 액세스를 차단하여 모든 활성 QTcpSocket 연결을 종료하고, 스트림을 finished 상태로 만듭니다. 재연결 로직을 통해 이 문제를 해결할 수 있습니다.
connect(m_chatStream.get(), &QGrpcBidiStream::messageReceived, this, [this] {
...
switch (m_chatResponse.contentField()) {
case chat::ChatMessage::ContentFields::UninitializedField:
qDebug("Received uninitialized message");
return;
case chat::ChatMessage::ContentFields::Text:
if (m_chatResponse.text().content().isEmpty())
return;
break;
case chat::ChatMessage::ContentFields::File:
// 파일 메시지가 있으면 다운로드하고 , 다운로드된 URL을
// 콘텐츠에 저장 하여 모델이 해당 위치에서 참조할 수 있도록 합니다.
m_chatResponse.file()
.setContent(saveFileRequest(m_chatResponse.file()).toString().toUtf8());
break;
...
emit chatStreamMessageReceived(m_chatResponse);
});
setState(Backend::ChatState::Connecting);
}메시지가 수신되면 ` ClientWorker `는 ` FileMessage ` 콘텐츠를 저장하는 등의 사전 처리를 수행하여, ` ChatEngine `가 모델에만 집중할 수 있도록 합니다. ` ContentFields ` 열거형을 사용하여 `ChatMessage` 합성 유형의 ` oneof content ` 필드를 안전하게 확인하세요.
void ChatEngine::sendText(const QString &message)
{
if (message.trimmed().isEmpty())
return;
if (auto request = m_clientWorker->createMessage()) {
chat::TextMessage tmsg;
tmsg.setContent(message.toUtf8());
request->setText(std::move(tmsg));
QMetaObject::invokeMethod(m_clientWorker, &ClientWorker::sendMessage, *request);
m_chatMessageModel->appendMessage(*request);
}
}
...
void ClientWorker::sendMessage(const chat::ChatMessage &message)
{
if (!m_chatStream || m_chatState != ChatState::Connected) {
emit chatError(tr("Unable to send message"));
return;
}
m_chatStream->writeMessage(message);
}메시지를 전송할 때, ChatEngine 는 올바르게 형식이 지정된 요청을 생성합니다. 예를 들어, sendText 메서드는 QString 를 받아 createMessage 함수를 사용하여 username 및 timestamp 필드가 설정된 유효한 메시지를 생성합니다. 그런 다음 클라이언트가 호출되어 메시지를 전송하고, 그 사본이 우리 시스템의 ChatMessageModel 에 큐에 추가됩니다.
QML 프론트엔드
import QtGrpc
import QtGrpcChat
import QtGrpcChat.ProtoQML 코드에서는 다음 임포트 문이 사용됩니다:
QtGrpc: ` QtGrpc ` QML 기능을 제공합니다. 예를 들어 ` StatusCode` 등이 있습니다.QtGrpcChat:ChatEngine싱글톤과 같은 구성 요소를 포함하는 애플리케이션 모듈입니다.QtGrpcChat.Proto: 생성된 protobuf 유형에 대한 QML 접근 기능을 제공합니다.
Connections {
target: ChatEngine
function onChatStreamFinished(status) {
root.handleStatus(status)
loginView.clear()
}
function onChatStateChanged() {
if (ChatEngine.chatState === Backend.ChatState.Connected && mainView.depth === 1)
mainView.push("ChatView.qml")
else if (ChatEngine.chatState === Backend.ChatState.Disconnected && mainView.depth > 1)
mainView.pop()
}
function onRegisterFinished(status) {
root.handleStatus(status)
}
function onChatError(message) {
statusDisplay.text = message
statusDisplay.color = "yellow"
statusDisplay.restart()
}
}Main.qml ChatEngine 에서 발신하는 핵심 신호를 처리합니다. 이러한 신호의 대부분은 전역적으로 처리되며, 애플리케이션의 어떤 상태에서도 시각화됩니다.
Rectangle {
id: root
property credentials creds
...
ColumnLayout {
id: credentialsItem
...
RowLayout {
id: buttonLayout
...
Button {
id: loginButton
...
enabled: nameField.text && passwordField.text
text: qsTr("Login")
onPressed: {
root.creds.name = nameField.text
root.creds.password = passwordField.text
ChatEngine.login(root.creds)
}
}protobuf 스키마에서 생성된 메시지 유형은 QML_VALUE_TYPE(메시지 정의의 camelCase 버전)이므로 QML에서 액세스할 수 있습니다. LoginView.qml 는 credentials 값 유형 속성을 사용하여 ChatEngine 의 login 를 초기화합니다.
ListView {
id: chatMessageView
...
component DelegateBase: Item {
id: base
required property chatMessage display
default property alias data: chatLayout.data
...
}
...
// We use the DelegateChooser and the 'whatThis' role to determine
// the correct delegate for any ChatMessage
delegate: DelegateChooser {
role: "whatsThis"
...
DelegateChoice {
roleValue: "text"
delegate: DelegateBase {
id: dbt
TextDelegate {
Layout.fillWidth: true
Layout.maximumWidth: root.maxMessageBoxWidth
Layout.preferredHeight: implicitHeight
Layout.bottomMargin: root.margin
Layout.leftMargin: root.margin
Layout.rightMargin: root.margin
message: dbt.display.text
selectionColor: dbt.lightColor
selectedTextColor: dbt.darkColor
}
}
}ChatView.qml 에서는 ListView 가 ChatRoom 에 메시지를 표시합니다. 이 경우 ChatMessage 합성형을 조건에 따라 처리해야 하므로 약간 더 복잡합니다.
DelegateChooser를 사용하면 메시지 유형에 따라 적절한 델리게이트를 선택할 수 있습니다. 모델의 기본 whatThis 역할을 사용하면 각 ChatMessage 인스턴스에 대한 메시지 유형을 제공받을 수 있습니다. 그러면 DelegateBase 컴포넌트가 모델의 display 역할에 액세스하여 채팅 메시지(chatMessage) 데이터를 렌더링할 수 있게 됩니다.
TextEdit {
id: root
required property textMessage message
text: message.content
color: "#f3f3f3"
font.pointSize: 14
wrapMode: TextEdit.Wrap
readOnly: true
selectByMouse: true
}다음은 TextMessage 타입을 시각화하는 컴포넌트 중 하나입니다. 이 컴포넌트는 protobuf 모듈의 textMessage 값 타입을 사용하여 텍스트를 시각화합니다.
TextArea.flickable: TextArea {
id: inputField
function sendTextMessage() : void {
if (text === "")
return
ChatEngine.sendText(text)
text = ""
}
...
Keys.onPressed: (event) => {
if (event.key === Qt.Key_Return && event.modifiers & Qt.ControlModifier) {
sendTextMessage()
event.accepted = true
} else if (event.key === Qt.Key_V && event.modifiers & Qt.ControlModifier) {
if (ChatEngine.sendFilesFromClipboard())
event.accepted = true
}
}채팅 클라이언트는 다음과 같이 메시지를 전송하는 데 다양한 접근 방식을 제공합니다:
- 애플리케이션에 드래그 앤 드롭된 파일 수신.
- <Ctrl + V>를 눌러 QClipboard에 저장된 내용을 전송합니다.
- <Ctrl + Enter>를 눌러
inputField - 메시지 전송 버튼을 클릭하여
inputField - 파일 대화 상자를 통해 파일 선택
SSL
서버와 클라이언트 간의 통신 보안을 위해 SSL/TLS 암호화가 사용됩니다. 이를 위해서는 최소한 다음 사항이 필요합니다:
- 개인 키: 보안 연결을 설정하는 데 사용되는 서버의 개인 키를 포함합니다. 이 키는 기밀로 유지되어야 하며 절대 공유해서는 안 됩니다.
- 인증서: 서버의 공개 인증서를 포함하며, 서버의 신원을 확인하기 위해 클라이언트와 공유됩니다. 일반적으로 인증 기관(CA)에 의해 서명되거나, 테스트 목적으로 자체 서명될 수 있습니다.
- 선택 사항인 루트 CA 인증서: 사용자 지정 인증 기관(CA)을 사용하여 서버 인증서에 서명하는 경우, 클라이언트 측에서는 서버의 인증서 체인을 검증하기 위해 루트 CA 인증서가 필요합니다. 이는 공인 CA의 루트 인증서와 달리 사용자 지정 CA의 루트 인증서가 클라이언트의 신뢰 저장소에 미리 설치되어 있지 않기 때문에, 클라이언트가 서버의 인증서를 신뢰할 수 있도록 보장합니다.
OpenSSL을 사용하여 이러한 파일을 생성하고, SSL/TLS를 사용하도록 gRPC 통신을 설정하십시오.
grpc::SslServerCredentialsOptions sslOpts;
sslOpts.pem_key_cert_pairs.emplace_back(grpc::SslServerCredentialsOptions::PemKeyCertPair{
LocalhostKey,
LocalhostCert,
});
builder.AddListeningPort(QtGrpcChatService::httpsAddress(), grpc::SslServerCredentials(sslOpts));
builder.AddListeningPort(QtGrpcChatService::httpAddress(), grpc::InsecureServerCredentials());gRPC 서버에 개인 키와 인증서를 제공해야 합니다. 이를 통해 SslServerCredentials 를 구성하여 서버 측에서 TLS를 활성화할 수 있습니다. 보안 통신 외에도 암호화되지 않은 액세스도 허용해야 합니다.
서버는 다음 주소에서 연결을 수신 대기합니다:
- HTTPS :
0.0.0.0:65002 - HTTP :
0.0.0.0:65003
서버는 0.0.0.0 에 바인딩하여 모든 네트워크 인터페이스에서 수신 대기하므로, 동일한 네트워크에 있는 모든 장치에서 액세스할 수 있습니다.
if (m_hostUri.scheme()== "https") {
if (!QSslSocket::supportsSsl()) {
emit chatError(tr("이 기기는 SSL을 지원하지 않습니다. 'http' 스키마를 사용해 주세요."));
return false;
}
QFile crtFile(":/res/root.crt");
if (!crtFile.open(QFile::ReadOnly)) {
qFatal("Unable to load root certificate");
return false;
}
QSslConfiguration sslConfig;
QSslCertificate crt(crtFile.readAll());
sslConfig.addCaCertificate(crt);
sslConfig.setProtocol(QSsl::TlsV1_2OrLater);
sslConfig.setAllowedNextProtocols({ "h2" }); // HTTP/2 허용
// 호스트 이름 검증을 비활성화하여 모든 로컬 IP에서의 연결을 허용합니다.
// 개발 환경에서는 허용되지만, 보안상의 이유로 운영 환경에서는 피해야 합니다.
sslConfig.setPeerVerifyMode(QSslSocket::VerifyNone);
opts.setSslConfiguration(sslConfig);
}CA를 자체 서명했으므로 클라이언트는 루트 CA 인증서를 불러옵니다. 이 인증서는 QSslCertificate 를 생성하는 데 사용됩니다. HTTP/2를 사용하고 있으므로 "h2" 프로토콜에 setAllowedNextProtocols 를 지정하는 것이 중요합니다.
인터셉터
채팅 클라이언트는 QGrpcInterceptorChain 을 사용하여 모든 RPC 활동을 모니터링합니다. 초기화 과정에서 LoggingInterceptor 이 채널에 연결되어, 체인을 통과하는 모든 작업에 대한 실시간 정보를 애플리케이션에 제공합니다.
QGrpcInterceptorChain interceptorChain;
interceptorChain.add(std::make_unique<LoggingInterceptor>(m_logModel));
auto channel = std::make_shared<QGrpcHttp2Channel>(m_hostUri, opts,
std::move(interceptorChain));QGrpcInterceptorChain 를 생성하고, unique_ptr 오버로드를 사용하여 인터셉터를 체인에 추가하면 소유권이 체인으로 이전됩니다. 그런 다음 체인을 QGrpcHttp2Channel 생성자로 전달합니다. 채널이 소유권을 인수한 후, 인터셉터는 채널의 전체 수명 동안 유효하게 유지됩니다.
class LoggingInterceptor final : public QGrpcStartInterceptor,
public QGrpcInitialMetadataInterceptor,
public QGrpcMessageReceivedInterceptor,
public QGrpcWriteMessageInterceptor,
public QGrpcWritesDoneInterceptor,
public QGrpcTrailingMetadataInterceptor,
public QGrpcCancelInterceptor,
public QGrpcFinishedInterceptor
{
public:
explicit LoggingInterceptor(std::shared_ptr<LogModel> logModel);
~LoggingInterceptor() override;
Continuation onStart(QGrpcInterceptionContext &context, QProtobufMessage &message,
QGrpcCallOptions &callOptions) override;
void onInitialMetadata(QGrpcInterceptionContext &context,
QMultiHash<QByteArray, QByteArray> &metadata) override;
void onMessageReceived(QGrpcInterceptionContext &context, QByteArray &messageData) override;
void onWriteMessage(QGrpcInterceptionContext &context, QProtobufMessage &message) override;
void onWritesDone(QGrpcInterceptionContext &context) override;
void onTrailingMetadata(QGrpcInterceptionContext &context,
QMultiHash<QByteArray, QByteArray> &metadata) override;
void onCancel(QGrpcInterceptionContext &context) override;
void onFinished(QGrpcInterceptionContext &context, QGrpcStatus &status) override;
private:
std::shared_ptr<LogModel> m_log;
using Clock = std::chrono::steady_clock;
using Ms = std::chrono::duration<double, std::milli>;
QHash<quint64, Clock::time_point> m_activeRPCs;
};LoggingInterceptor 는 사용 가능한 모든 인터셉터 인터페이스를 구현하여 전체 RPC 수명 주기를 포괄합니다. 이 클래스는 로그 항목을 추가하기 위한 ` LogModel `에 대한 ` shared_ptr `를 보유하고 있으며, ID별로 활성 작업을 추적하기 위한 ` QHash `를 보유하고 있습니다.
로깅 인터셉터 구현
다음 코드 조각은 onStart 및 onFinished 인터셉션 포인트가 어떻게 작동하는지 보여줍니다. 나머지 인터셉션 포인트들은 각 이벤트에 대한 기본적인 로깅을 수행합니다.
LoggingInterceptor::Continuation LoggingInterceptor::onStart(QGrpcInterceptionContext &context,
QProtobufMessage &, QGrpcCallOptions &)
{
const auto id = context.operationId();
m_activeRPCs.insert(id, Clock::now());
m_log->add(LogModel::Level::Debug, id, context.descriptor(), u"Starting"_s);
return Continuation::Proceed;
}onStart 에서 인터셉터는 operationId()를 키로 사용하여 QHash 에 현재 타임스탬프를 저장합니다. Continuation::Proceed 를 반환하면 호출이 체인을 따라 아래로 전달됩니다.
void LoggingInterceptor::onFinished(QGrpcInterceptionContext &context, QGrpcStatus &status)
{
const auto it = m_activeRPCs.find(context.operationId());
Q_ASSERT(it != m_activeRPCs.cend());
const auto duration = Ms(Clock::now() - it.value()).count();
m_activeRPCs.erase(it);
const auto codeStr = QDebug::toString(status.code()).section("::", -1);
auto msg = u"Finished in %1 ms. StatusCode: %2"_s.arg(duration).arg(codeStr);
if (!status.message().isEmpty())
msg += u", Message: "_s + status.message();
const auto level = [&] {
switch (status.code()) {
case QtGrpc::StatusCode::Ok:
return LogModel::Level::Info;
case QtGrpc::StatusCode::NotFound:
case QtGrpc::StatusCode::Unauthenticated:
return LogModel::Level::Warning;
default:
return LogModel::Level::Error;
}
}();
m_log->add(level, context.operationId(), context.descriptor(), msg);
}onFinished 에서는 저장된 타임스탬프를 작업 ID로 조회하여 RPC 소요 시간을 계산합니다. 로그 레벨은 상태 코드에서 파생되며, 최종 항목이 LogModel 에 추가됩니다.
축적된 로그 항목은 ChatEngine::logModel 속성을 통해 QML에 노출됩니다. 전용 LogDialog 가 이를 ListView 로 렌더링합니다:
Dialog {
id: root
title: "Interceptor Logs"
...
ListView {
ScrollIndicator.horizontal: ScrollIndicator { }
ScrollIndicator.vertical: ScrollIndicator { }
anchors.fill: parent
model: ChatEngine.logModel
clip: true
delegate: ItemDelegate {
id: delegate
required property int level
required property string timestamp
required property int operationId
required property string service
required property string method
required property int rpcType
required property string message
width: ListView.view.width
contentItem: ColumnLayout {
RowLayout {
Label {
font.bold: true
text: root.levelToString(delegate.level)
color: root.levelToColor(delegate.level)
}
Item { Layout.fillWidth: true }
Label {
text: delegate.timestamp
opacity: 0.7
}
}
// further entries for visualizing the RPC context and message.
...
}
}
}
}각 델리게이트는 로그 수준, 타임스탬프 및 RPC 세부 정보를 표시합니다. ` LogModel `는 새로운 항목을 맨 앞에 추가하므로 가장 최근의 활동이 상단에 표시됩니다.

소스 파일
참조: 모든 Qt 예제.
© 2026 The Qt Company Ltd. Documentation contributions included herein are the copyrights of their respective owners. The documentation provided herein is licensed under the terms of the GNU Free Documentation License version 1.3 as published by the Free Software Foundation. Qt and respective logos are trademarks of The Qt Company Ltd. in Finland and/or other countries worldwide. All other trademarks are property of their respective owners.