同じ商品にLIKEしたユーザーを1名マッチングする

こんにちは。ゆうせいです。

新人研修中に受講者から以下の質問をいただきました。

同じ商品を選択したユーザー1名を表示するにはどうしたらいいですか?

今回は、この質問に答えたいと思います。

「ユーザー同士のマッチング」を作ります。

条件は次の通りです。

条件内容
目的同じ商品にLIKEしたユーザーを1名見つける
ユーザーアクションLIKEだけ使う
DAOSuperDaoを継承する
DTO使う
実行方法DAOクラスのmainメソッドから実行する

イメージとしては、マッチングアプリや趣味コミュニティに近いです。

たとえば、ユーザー1が「Java入門書」にLIKEしているとします。

ユーザー2も同じ「Java入門書」にLIKEしていたら、ユーザー1とユーザー2は少し好みが近そうですよね。

今回は、このように「同じ商品にLIKEしたユーザー」を1名だけ取得します。

今回作るマッチングの考え方

今回のマッチングでは、対象ユーザーと同じ商品にLIKEした別ユーザーを探します。

同じLIKE商品が多いユーザーほど、マッチング候補として優先します。

たとえば、次のようなデータがあるとします。

ユーザーLIKEした商品
ユーザー1Java入門書、MySQL入門書
ユーザー2Java入門書、MySQL入門書、Spring Boot入門書
ユーザー3Docker入門書
ユーザー4Java入門書

ユーザー1を対象にした場合、ユーザー2は2つの商品が一致しています。

ユーザー4は1つの商品が一致しています。

そのため、ユーザー2をマッチング対象として選びます。

文化祭でたとえるなら、好きな出し物が2つ同じ友達のほうが、1つだけ同じ友達より話が合いそうですよね。

使用するテーブル

今回は、次の3つのテーブルを使います。

テーブル役割
usersユーザー情報を保存する
items商品情報を保存する
user_actionsユーザーがLIKEした履歴を保存する

usersテーブル

CREATE TABLE users (
    user_id BIGINT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(100) NOT NULL
);




itemsテーブル

CREATE TABLE items (
    item_id BIGINT PRIMARY KEY AUTO_INCREMENT,
    title VARCHAR(200) NOT NULL,
    category VARCHAR(100) NOT NULL,
    deleted_at DATETIME NULL
);




user_actionsテーブル

CREATE TABLE user_actions (
    action_id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    item_id BIGINT NOT NULL,
    action_type VARCHAR(20) NOT NULL,
    created_at DATETIME NOT NULL
);




今回は、action_typeにはLIKEだけを使います。

action_type = 'LIKE'





user_actionsは、ユーザーの行動ログです。

ログとは、あとから確認できるように残しておく記録のことです。

学校の出席簿に「誰がいつ出席したか」を記録するように、user_actionsには「誰がどの商品にLIKEしたか」を記録します。

サンプルデータ

動作確認用のサンプルデータです。

INSERT INTO users (name) VALUES
('山田'),
('佐藤'),
('鈴木'),
('田中');

INSERT INTO items (title, category, deleted_at) VALUES
('Java入門書', 'BOOK', NULL),
('Spring Boot入門書', 'BOOK', NULL),
('MySQL入門書', 'BOOK', NULL),
('Docker入門書', 'BOOK', NULL),
('古い教材', 'BOOK', NOW());

INSERT INTO user_actions (user_id, item_id, action_type, created_at) VALUES
(1, 1, 'LIKE', NOW()),
(1, 3, 'LIKE', NOW()),

(2, 1, 'LIKE', NOW()),
(2, 2, 'LIKE', NOW()),
(2, 3, 'LIKE', NOW()),

(3, 4, 'LIKE', NOW()),

(4, 1, 'LIKE', NOW());




このデータでは、user_idが1の山田さんは、Java入門書とMySQL入門書にLIKEしています。

佐藤さんは、Java入門書とMySQL入門書の両方にLIKEしています。

田中さんは、Java入門書だけにLIKEしています。

そのため、山田さんのマッチング対象は佐藤さんになります。

SuperDao

まず、DB接続を担当するSuperDaoを使います。

SuperDaoは、ほかのDAOから継承される親クラスです。

package com.example.demo.model.dao;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

/**
 * SuperDao - データベース接続の取得を担当する基底クラス
 */
public class SuperDao {

    private static final String DB_URI =
            "jdbc:mysql://localhost:3306/sip_a?"
            + "characterEncoding=utf8&"
            + "useSSL=false&serverTimezone=GMT%2B9&"
            + "rewriteBatchedStatements=true";

    private static final String DB_USER = "newuser";
    private static final String DB_PASS = "0";

    protected Connection getConnection() throws SQLException {
        return DriverManager.getConnection(DB_URI, DB_USER, DB_PASS);
    }
}




protectedのgetConnectionは、SuperDaoを継承した子クラスから使えます。

今回作るUserMatchingDaoは、SuperDaoを継承してDBに接続します。

DTOを作る

次に、マッチング結果を入れるDTOを作ります。

DTOとは、Data Transfer Objectの略です。

データを受け渡しするための専用クラスです。

今回のDTOには、マッチしたユーザー情報と、共通LIKE数、共通LIKE商品名を入れます。

項目意味
matchedUserIdマッチしたユーザーID
matchedUserNameマッチしたユーザー名
commonLikeCount同じ商品にLIKEした数
commonItemTitles共通してLIKEした商品名

MatchedUserDto.java

package com.example.demo.model.dto;

public class MatchedUserDto {

    private Long matchedUserId;
    private String matchedUserName;
    private int commonLikeCount;
    private String commonItemTitles;

    public MatchedUserDto() {
    }

    public MatchedUserDto(Long matchedUserId, String matchedUserName, int commonLikeCount, String commonItemTitles) {
        this.matchedUserId = matchedUserId;
        this.matchedUserName = matchedUserName;
        this.commonLikeCount = commonLikeCount;
        this.commonItemTitles = commonItemTitles;
    }

    public Long getMatchedUserId() {
        return matchedUserId;
    }

    public void setMatchedUserId(Long matchedUserId) {
        this.matchedUserId = matchedUserId;
    }

    public String getMatchedUserName() {
        return matchedUserName;
    }

    public void setMatchedUserName(String matchedUserName) {
        this.matchedUserName = matchedUserName;
    }

    public int getCommonLikeCount() {
        return commonLikeCount;
    }

    public void setCommonLikeCount(int commonLikeCount) {
        this.commonLikeCount = commonLikeCount;
    }

    public String getCommonItemTitles() {
        return commonItemTitles;
    }

    public void setCommonItemTitles(String commonItemTitles) {
        this.commonItemTitles = commonItemTitles;
    }

    @Override
    public String toString() {
        return "MatchedUserDto{" +
                "matchedUserId=" + matchedUserId +
                ", matchedUserName='" + matchedUserName + '\'' +
                ", commonLikeCount=" + commonLikeCount +
                ", commonItemTitles='" + commonItemTitles + '\'' +
                '}';
    }
}




toStringメソッドを用意している理由は、mainメソッドで実行したときに中身を表示しやすくするためです。

SuperDaoを継承したUserMatchingDao

次に、実際にマッチングを行うDAOを作ります。

UserMatchingDaoのmainメソッドから直接実行します。

UserMatchingDao.java

package com.example.demo.model.dao;

import com.example.demo.model.dto.MatchedUserDto;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

public class UserMatchingDao extends SuperDao {

    public MatchedUserDto findOneMatchedUserByLike(Long targetUserId) throws SQLException {
        String sql =
                "SELECT " +
                "    u.user_id AS matched_user_id, " +
                "    u.name AS matched_user_name, " +
                "    COUNT(DISTINCT my_action.item_id) AS common_like_count, " +
                "    GROUP_CONCAT(DISTINCT i.title ORDER BY i.title SEPARATOR ', ') AS common_item_titles " +
                "FROM user_actions my_action " +
                "JOIN user_actions other_action " +
                "    ON my_action.item_id = other_action.item_id " +
                "    AND other_action.action_type = 'LIKE' " +
                "    AND other_action.user_id <> my_action.user_id " +
                "JOIN users u " +
                "    ON other_action.user_id = u.user_id " +
                "JOIN items i " +
                "    ON my_action.item_id = i.item_id " +
                "WHERE my_action.user_id = ? " +
                "AND my_action.action_type = 'LIKE' " +
                "AND i.deleted_at IS NULL " +
                "GROUP BY " +
                "    u.user_id, " +
                "    u.name " +
                "ORDER BY " +
                "    common_like_count DESC, " +
                "    u.user_id ASC " +
                "LIMIT 1";

        try (
                Connection connection = getConnection();
                PreparedStatement statement = connection.prepareStatement(sql)
        ) {
            statement.setLong(1, targetUserId);

            try (ResultSet resultSet = statement.executeQuery()) {
                if (resultSet.next()) {
                    MatchedUserDto dto = new MatchedUserDto();
                    dto.setMatchedUserId(resultSet.getLong("matched_user_id"));
                    dto.setMatchedUserName(resultSet.getString("matched_user_name"));
                    dto.setCommonLikeCount(resultSet.getInt("common_like_count"));
                    dto.setCommonItemTitles(resultSet.getString("common_item_titles"));

                    return dto;
                }
            }
        }

        return null;
    }

    public static void main(String[] args) {
        UserMatchingDao dao = new UserMatchingDao();

        Long targetUserId = 1L;

        System.out.println("LIKEベースのユーザーマッチングを開始します。");
        System.out.println("対象ユーザーID: " + targetUserId);

        try {
            MatchedUserDto matchedUser = dao.findOneMatchedUserByLike(targetUserId);

            if (matchedUser == null) {
                System.out.println("マッチング対象のユーザーは見つかりませんでした。");
                return;
            }

            System.out.println("マッチング対象ユーザー:");
            System.out.println(matchedUser);

        } catch (SQLException e) {
            System.err.println("ユーザーマッチング中にエラーが発生しました。");
            System.err.println("原因: " + e.getMessage());
            e.printStackTrace();
        }

        System.out.println("処理を終了します。");
    }
}




このSQLの意味

DAOの中で実行しているSQLを、単独で見ると次の形です。(「my_action.user_id = 1」で1番の人のマッチング候補者を表示しています)

SELECT
    u.user_id AS matched_user_id,
    u.name AS matched_user_name,
    COUNT(DISTINCT my_action.item_id) AS common_like_count,
    GROUP_CONCAT(DISTINCT i.title ORDER BY i.title SEPARATOR ', ') AS common_item_titles
FROM user_actions my_action
JOIN user_actions other_action
    ON my_action.item_id = other_action.item_id
    AND other_action.action_type = 'LIKE'
    AND other_action.user_id <> my_action.user_id
JOIN users u
    ON other_action.user_id = u.user_id
JOIN items i
    ON my_action.item_id = i.item_id
WHERE my_action.user_id = 1
AND my_action.action_type = 'LIKE'
AND i.deleted_at IS NULL
GROUP BY
    u.user_id,
    u.name
ORDER BY
    common_like_count DESC,
    u.user_id ASC
LIMIT 1;




長く見えますが、やっていることはシンプルです。

対象ユーザーがLIKEした商品を探します。

同じ商品にLIKEした別ユーザーを探します。

共通LIKE数が多いユーザーを上に並べます。

先頭の1名だけ取得します。

同じ商品にLIKEした別ユーザーを探す部分

JOIN user_actions other_action
    ON my_action.item_id = other_action.item_id
    AND other_action.action_type = 'LIKE'
    AND other_action.user_id <> my_action.user_id




この部分が一番大事です。

my_actionは対象ユーザーのLIKE履歴です。

other_actionは他のユーザーのLIKE履歴です。

my_action.item_id = other_action.item_id によって、同じ商品にLIKEしたユーザーを探しています。

other_action.user_id <> my_action.user_id によって、自分自身を除外しています。

自分と自分をマッチングしても意味がありませんよね。

共通LIKE数を数える部分

COUNT(DISTINCT my_action.item_id) AS common_like_count





この部分では、同じ商品にLIKEした数を数えています。

同じ商品に2つ一致していれば2、1つだけ一致していれば1です。

数が多いほど、好みが近いユーザーだと考えます。

数式で書くと、次の形です。

commonLikeCount = COUNT(commonLikedItems)

共通LIKE数 = 共通してLIKEした商品の数

とてもシンプルですね。

共通LIKE商品名をまとめる部分

GROUP_CONCAT(DISTINCT i.title ORDER BY i.title SEPARATOR ', ') AS common_item_titles





GROUP_CONCATは、MySQLで複数行の値を1つの文字列にまとめる関数です。

たとえば、Java入門書とMySQL入門書が共通している場合、次のようにまとめます。

Java入門書, MySQL入門書

これにより、なぜそのユーザーがマッチしたのかを表示しやすくなります。

マッチングでは、「誰がマッチしたか」だけでなく、「なぜマッチしたか」も大切です。

理由が見えると、納得しやすいですよね。

1名だけ取得する部分

ORDER BY
    common_like_count DESC,
    u.user_id ASC
LIMIT 1




ORDER BY common_like_count DESC によって、共通LIKE数が多いユーザーを上に並べます。

u.user_id ASC は、同じ共通LIKE数だった場合の並び順を安定させるためです。

LIMIT 1 によって、マッチング対象を1名だけ取得します。

つまり、「一番好みが近そうなユーザーを1人だけ選ぶ」ということです。

実行結果のイメージ

mainメソッドを実行すると、次のような結果になります。

LIKEベースのユーザーマッチングを開始します。
対象ユーザーID: 1
マッチング対象ユーザー:
MatchedUserDto{matchedUserId=2, matchedUserName='佐藤', commonLikeCount=2, commonItemTitles='Java入門書, MySQL入門書'}
処理を終了します。




この結果は、ユーザー1とユーザー2が、Java入門書とMySQL入門書の2つに共通してLIKEしていることを表しています。

ユーザー4もJava入門書にLIKEしていますが、共通LIKE数は1です。

そのため、共通LIKE数が2のユーザー2が優先されます。

この構成のクラス一覧

今回必要なクラスは、次の3つです。

com.example.demo
└── model
    ├── dao
    │   ├── SuperDao.java
    │   └── UserMatchingDao.java
    └── dto
        └── MatchedUserDto.java
クラス役割
SuperDaoDB接続を取得する親クラス
UserMatchingDao同じ商品にLIKEしたユーザーを1名探す
MatchedUserDtoマッチング結果を入れる

この方法のメリット

メリット内容
シンプルに作れるLIKEだけを見るのでSQLが理解しやすい
理由を説明しやすい同じ商品にLIKEしたからマッチ、と言える
DAO単体で試せるmainメソッドからすぐ実行できる
DTOの役割がわかりやすいマッチング結果だけをまとめて返せる

特に新人エンジニアにとっては、「同じ商品にLIKEした人を探す」という条件がわかりやすいです。

複雑な機械学習を使わなくても、マッチング機能の基本は作れます。

この方法の注意点

注意点内容
LIKEが少ないとマッチしにくい共通する商品がないと候補が出ない
人気商品だけでマッチする可能性がある誰でもLIKEする商品だと個性が出にくい
1名だけだと偏る場合がある同率の候補が複数いても1名しか返さない
ブロックや非表示条件がない実務では除外条件が必要になる

たとえば、全員がLIKEするような人気商品だけでマッチすると、本当に好みが近いとは限りません。

学校でたとえるなら、「給食のカレーが好き」という共通点だけで親友を決めるようなものです。

それだけでは少し弱いですよね。

将来的には、共通LIKE数だけでなく、商品のカテゴリ、最近LIKEしたかどうか、プロフィール情報なども組み合わせると精度が上がります。

まとめ

今回は、ユーザーのアクションをLIKEだけにして、同じ商品にLIKEしたユーザーを1名マッチングするDAOを作りました。

UserMatchingDaoのmainメソッドから直接実行する形です。

ポイント内容
マッチング条件同じ商品にLIKEしていること
優先順位共通LIKE数が多いユーザーを優先
取得件数LIMIT 1で1名だけ取得
DAOSuperDaoを継承する
DTOMatchedUserDtoを使う
実行方法DAOのmainメソッドで確認する

一言でまとめるなら、こうです。

LIKEベースのユーザーマッチングは、自分と同じ商品にLIKEした人を探し、共通LIKE数が多い人を1名選ぶ仕組みです。

まずはこのDAOを動かして、user_actionsのLIKEデータを増やしながら、どのユーザーがマッチするか確認してみてください。

今後は、共通LIKE数だけでなく、カテゴリ一致、最新LIKE日時、ブロック済みユーザー除外、すでにマッチ済みユーザー除外を追加すると、より実務に近いマッチング機能になります。まずは「なぜこのユーザーがマッチしたのか」をSQLで説明できる状態を目指しましょう!

セイ・コンサルティング・グループでは新人エンジニア研修のアシスタント講師を募集しています。

投稿者プロフィール

山崎講師
山崎講師代表取締役
セイ・コンサルティング・グループ株式会社代表取締役。
岐阜県出身。
2000年創業、2004年会社設立。
IT企業向け人材育成研修歴業界歴20年以上。
すべての無駄を省いた費用対効果の高い「筋肉質」な研修を提供します!
この記事に間違い等ありましたらぜひお知らせください。

学生時代は趣味と実益を兼ねてリゾートバイトにいそしむ。長野県白馬村に始まり、志賀高原でのスキーインストラクター、沖縄石垣島、北海道トマム。高じてオーストラリアのゴールドコーストでツアーガイドなど。現在は野菜作りにはまっている。