사용자 행동 데이터에 가중치를 적용하여 사용자에게 맞춤형 정책 추천을 제공하려고 한다.
현재 프로젝트에서 정책 조회수와 정책 찜하기수 데이터를 저장하고 있다.
사용자 행동 데이터로 사용할 수 있는 것은 “조회수”와 “찜하기수” 이다.
조회와 찜하기 모두 사용자 행동 데이터이지만, 조회보다는 찜하기에 더 높은 가중치를 부여하는 것이, 사용자의 선호도를 더 정확하게 반영할 수 있다고 판단했다.
그리고 이런 행동 데이터에서도 최근 조회나 찜하기에 더 높은 가중치를 부여하는 것도 정확도에 기여할 수 있다고 생각했다.
가중치 부여 기준 설정
- 조회: 사용자가 정책을 조회할 때마다 가중치 1을 부여한다. (조회가 많을수록 가중치가 쌓임)
- 찜하기: 사용자가 특정 정책을 찜한 경우, 가중치 3을 부여한다. 찜한 정책은 사용자의 선호도가 더 높다고 판단하여 조회보다 가중치를 더 높게 설정한다.
- 최근 상호작용: 사용자가 최근에 상호작용한 정책에 더 높은 가중치를 부여한다. 최근 1주 내에 상호작용한 가중치는 더 높은 가중치를 주고, 1개월 이상 지난 정책에는 가중치를 감소시킨다.
작업 내용 요약
- 사용자 테이블에 “지역(`region`)” 컬럼 추가 - 기존 `EPolicyRegion` 사용
- 정책 조회 테이블(`ViewPolicy`)에 `user`, `createDate` 컬럼 추가
- 사용자 상호작용 테이블(`UserInteractions`) 생성
- 맞춤 정책 리스트 조회 API
- 호출 시 지역 정보 입력받음 (중복 허용, “,”로 구분)
- 요청받은 지역 정보 없으면 사용자 초기 지역 정보 가져옴
- `UserInteractions` 테이블에서 가져온 정책에서 가중치 적용
- 가중치 적용한 정책 리스트에 지역 정보 필터링 적용 (지역 정보 없으면 필터링 X)
- `UserInteractions` 테이블에 사용자 상호작용 데이터가 없으면 “지금 핫한 정책” 으로 대체
1. 기존 User 테이블에 컬럼 추가
1.1. User 테이블에 "지역(region)" 컬럼 추가
맞춤 정책 조회 시 사용자 지역에 따라 정책을 필터링할 수 있도록, 사용자 테이블에 “지역(regieon)” 컬럼을 추가했다.
User
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "user_regions", joinColumns = @JoinColumn(name = "user_id"))
@Enumerated(EnumType.STRING)
@Column(name = "region")
private List<EPolicyRegion> regions;
public void setRegions(List<EPolicyRegion> regions) {
this.regions = regions; // 새로운 지역 리스트를 설정
}
- `@ElementCollection`
- `@ElementCollection` 어노테이션을 사용하여 다중 값을 별도의 테이블로 관리한다. 이 테이블은 `user_regions`라는 이름으로 생성되고, 각 `User`는 여러 개의 `EPolicyRegion` 값을 가질 수 있다.
- 지연 로딩(`FetchType.LAZY`)을 사용하여, 실제 지역 데이터가 필요할 때만 조회되도록 했다. 즉시 로딩(EAGER)을 사용할 경우, User 객체를 조회할 때마다 지역 정보까지 항상 함께 불러오게 되어 비효율적일 수 있다.
- `@CollectionTable`
- `user_regions` 테이블을 생성하며, `User` 의 지역 정보가 저장된다.
- `joinColumns = @JoinColumn(name = "user_id")` 를 통해 `User` 테이블의 `user_id` 와 `user_regions` 테이블이 연결된다.
- `List<EPolicyRegion>`
- `regions` 필드를 `List<EPolicyRegion>` 로 설정해 한 사용자가 여러 지역 정보를 가질 수 있도록 했다.
- `@Enumerated(EnumType.STRING)` 을 통해 `EPolicyRegion` 의 값을 데이터베이스에 문자열로 저장한다. 이 방법이 가독성이 좋고 데이터베이스에서 직접 확인하기 쉽다.
- `setRegions` 메서드
- 사용자의 지역 정보를 일괄적으로 업데이트할 수 있도록 했다.
1.2. User 테이블에 "정책분야(classifications)" 컬럼 추가
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "user_classifications", joinColumns = @JoinColumn(name = "user_id"))
@Enumerated(EnumType.STRING)
@Column(name = "classification")
private List<EPolicyClassification> classifications;
public void setClassifications(List<EPolicyClassification> classifications) {
this.classifications = classifications; // 새로운 정책분야 리스트를 설정
}
1.1. 설명 참고
2. UserInteractions 엔티티 생성
조회수와, 찜하기수, 그리고 최근 상호작용을 모두 반영하기 위해 `UserInterations` 테이블을 추가했다.
2.1. UserInteraction 테이블 설계
CREATE TABLE user_interactions (
id BIGINT AUTO_INCREMENT PRIMARY KEY, -- 고유 ID
user_id BIGINT NOT NULL, -- User 테이블의 ID와 연결
policy_id VARCHAR(255) NOT NULL, -- 정책 ID (VARCHAR)
action_type ENUM('VIEW', 'FAVORITE') NOT NULL, -- 행동 유형 ('view', 'favorite')
action_time TIMESTAMP NOT NULL, -- 상호작용 시간 (TIMESTAMP)
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, -- 사용자 연결
INDEX idx_user_id (user_id) -- 사용자 ID에 대한 인덱스
);
UserInteraction 테이블을 추가한 이유
기존의 ViewPolicy와 FavoritePolicy 테이블은 각각의 행위(조회, 찜하기)를 독립적으로 저장하고 있었기 때문에, 사용자의 행동을 통합적으로 분석하거나 시간 순서대로 정렬하는 데 어려움이 있었다. UserInteraction 테이블은 모든 상호작용을 통합적으로 저장하여 시간순 정렬, 가중치 계산 등에서 훨씬 유연하게 사용할 수 있다.
2.2. EActionType Enum 클래스 생성
package com.example.withpeace.type;
public enum EActionType {
VIEW,
FAVORITE
}
UserInteractions 엔티티에서 사용하기 위해 생성했다.
Enum을 사용하면 값이 제한되기 때문에 데이터 무결성을 보장할 수 있다. 만약 추천 시스템을 확장하게 된다면 이 클래스에 새로운 유형을 쉽게 추가할 수 있다.
2.3. UserInteractions 엔티티 클래스 생성
package com.example.withpeace.domain;
import com.example.withpeace.type.EActionType;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.DynamicUpdate;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;
import java.time.LocalDateTime;
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@DynamicUpdate
@Table(name = "user_interactions", indexes = {
@Index(name = "idx_user_id", columnList = "user_id")
})
public class UserInteraction {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false, updatable = false, unique = true)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private User user;
@Column(name = "policy_id", nullable = false)
private String policyId;
@Column(name = "action_type", nullable = false)
@Enumerated(EnumType.STRING)
private EActionType actionType;
@Column(name = "action_time", nullable = false)
private LocalDateTime actionTime;
@Builder
public UserInteraction(User user, String policyId, EActionType actionType) {
this.user = user;
this.policyId = policyId;
this.actionType = actionType;
this.actionTime = LocalDateTime.now();
}
}
`user_id` 에 인덱스를 추가한 이유
@Table(name = "user_interactions", indexes = {
@Index(name = "idx_user_id", columnList = "user_id")
})
특정 사용자의 상호작용 데이터를 빠르게 조회하기 위해 `user_id` 에 인덱스를 추가했다. 인덱스를 추가하면 특정 컬럼(`user_id`) 기준으로 효율적으로 검색할 수 있다.
`WHERE user_id = ?` 조건의 쿼리를 실행하면, `user_id` 에 인덱스가 있는 경우 해당 값만 빠르게 찾아낼 수 있다.
인덱스 성능 검증 (링크)
인덱스가 실제로 사용되는지, 성능 검증을 실행해봤다.
2.4. UserInteraction 리포지토리 클래스 생성
UserInteractionRepository
package com.example.withpeace.repository;
import com.example.withpeace.domain.UserInteraction;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserInteractionRepository extends JpaRepository<UserInteraction, Long> {
}
2.5. 기존 ViewPolicy(조회), FavoritePolicy(찜하기) 테이블의 데이터 동기화
이미 저장된 ViewPolicy와 FavoritePolicy 데이터를 기반으로 UserInteraction에 데이터를 동기화하는 작업이 필요하다.
-- FavoritePolicy 데이터를 UserInteraction에 삽입 (찜하기 데이터)
INSERT INTO user_interactions (user_id, policy_id, action_type, action_time)
SELECT
f.user_id,
f.policy_id,
'favorite' AS action_type,
f.create_date
FROM favorite_policies f;
3. 정책 상세조회, 찜하기 요청 시 UserInteraction 데이터 추가
3.1. 정책 상세조회 시 → 상호작용 데이터 생성
@Transactional
public PolicyDetailResponseDto getPolicyDetail(Long userId, String policyId) {
User user = getUserById(userId);
YouthPolicy policy = getPolicyById(policyId);
boolean isFavorite = isFavoritePolicy(user, policy.getId());
viewPolicyRepository.incrementViewCount(policyId);
// 사용자 상호작용 데이터 생성 - start
userInteractionRepository.save(UserInteraction.builder()
.user(user)
.policyId(policy.getId())
.actionType(EActionType.VIEW)
.build());
// 사용자 상호작용 데이터 생성 - end
return PolicyDetailResponseDto.from(policy, isFavorite);
}
3.2. 정책 찜하기 시 → 상호작용 데이터 생성
@Transactional
public void registerFavoritePolicy(Long userId, String policyId) {
User user = getUserById(userId);
YouthPolicy policy = getPolicyById(policyId);
try{
// 찜하기 되어있지 않은 경우 찜하기 처리 수행
if(!isFavoritePolicy(user, policyId)) {
favoritePolicyRepository.save(FavoritePolicy.builder()
.policyId(policy.getId())
.user(user)
.title(policy.getTitle())
.build());
// 사용자 상호작용 데이터 생성 - start
userInteractionRepository.save(UserInteraction.builder()
.user(user)
.policyId(policy.getId())
.actionType(EActionType.VIEW)
.build());
// 사용자 상호작용 데이터 생성 - end
}
} catch (Exception e) {
throw new CommonException(ErrorCode.FAVORITE_YOUTH_POLICY_ERROR);
}
}
3.3. 정책 찜하기 해제 시 → 상호작용 데이터 삭제
@Transactional
public void deleteFavoritePolicy(Long userId, String policyId) {
User user = getUserById(userId);
FavoritePolicy favoritePolicy = favoritePolicyRepository.findByUserAndPolicyId(user, policyId);
try {
// 찜하기 해제가 되어있지 않은 경우 찜하기 해제 처리 수행
if(favoritePolicy != null) {
favoritePolicyRepository.delete(favoritePolicy);
// 상호작용 데이터 삭제 - start
UserInteraction interaction =
userInteractionRepository.findByUserAndPolicyIdAndActionType(user, policyId, EActionType.FAVORITE);
if (interaction != null) userInteractionRepository.delete(interaction); // 찜하기 상호작용 데이터 삭제
// 상호작용 데이터 삭제 - end
}
} catch (Exception e) {
throw new CommonException(ErrorCode.FAVORITE_YOUTH_POLICY_ERROR);
}
}
4. “맞춤 정책 리스트 조회” 컨트롤러 코드 생성
getRecommendationPolicyList
// 맞춤 정책 리스트 조회
@GetMapping("/recommendations")
public ResponseDto<?> getRecommendationPolicyList(@UserId Long userId,
@RequestParam(defaultValue = "") String region,
@RequestParam(defaultValue = "") String classification) {
List<PolicyListResponseDto> policyList = youthPolicyService.getRecommendationPolicyList(userId, region, classification);
return ResponseDto.ok(policyList);
}
userId와 region을 요청받는다.
region의 기본값은 빈 문자열로 설정하여, 지역 정보가 없을 경우를 처리한다.
PolicyListResponseDto (기존 코드)
package com.example.withpeace.dto.response;
import com.example.withpeace.domain.YouthPolicy;
import com.example.withpeace.type.EPolicyClassification;
import com.example.withpeace.type.EPolicyRegion;
import lombok.Builder;
@Builder
public record PolicyListResponseDto(
String id,
String title,
String introduce,
EPolicyClassification classification,
EPolicyRegion region,
String ageInfo,
boolean isFavorite) {
public static PolicyListResponseDto from(YouthPolicy policy, boolean isFavorite) {
return new PolicyListResponseDto(
policy.getId(),
policy.getTitle(),
policy.getIntroduce(),
policy.getClassification(),
policy.getRegion(),
policy.getAgeInfo(),
isFavorite
);
}
}
5. “맞춤 정책 리스트 조회” 서비스 코드 생성
서비스 로직 흐름
- 지역 및 정책 분야 필터링 리스트 생성
- 사용자의 지역(region) 및 정책 분야(classification) 정보를 확인하고, 사용자가 요청한 필터링 값이 있으면 사용자 정보를 업데이트한다.
- 사용자 상호작용 기반 가중치 계산
- 정책 조회, 찜하기 등의 상호작용 데이터를 기반으로 각 정책에 가중치를 부여하고, 최근 상호작용일 경우 추가 가중치를 더한다.
- 가중치 기반 맞춤 정책 리스트 생성
- 가중치가 높은 정책 순서대로, 지역 및 관심 분야 조건을 만족하는 상위 6개의 정책을 추출한다.
- 핫한 정책 리스트 추가
- 만약 추천할 정책이 6개 미만인 경우, 전체 사용자 데이터를 기준으로 조회수와 찜하기 수를 반영한 핫한 정책을 추가하여 부족한 정책을 채운다.
5.1. 지역 필터링 리스트 조회 - updateAndGetRegionList
사용자의 지역 정보(region)가 요청에 포함되었을 경우, 이를 반영해 사용자 정보를 업데이트한다. 요청받은 지역 정보가 없으면 사용자의 기본 지역 정보를 그대로 반환한다. (필터링에 사용할 리스트)
@Transactional
// 지역 정보를 처리 & User의 region 필드를 업데이트 & 필터링에 사용할 지역 리스트를 반환
private List<EPolicyRegion> updateAndGetRegionList(User user, String region) {
List<EPolicyRegion> regionList = Collections.emptyList();
if (StringUtils.isNotBlank(region)) { // null, 빈 문자열, 공백만 있는 문자열을 모두 처리
regionList = Arrays.stream(region.split(","))
.map(EPolicyRegion::fromCode)
.collect(Collectors.toList());
if (!regionList.isEmpty()) {
user.setRegions(regionList); // 사용자 객체에 새로운 지역 리스트 설정
userRepository.save(user); // User 테이블에 저장
}
}
return user.getRegions();
}
5.2. 정책 분야 필터링 리스트 조회 - updateAndGetClassificationList
사용자의 정책 분야(classification)가 요청에 포함되었을 경우, 이를 반영해 사용자 정보를 업데이트한다. 요청받은 정책 분야 정보가 없으면 사용자의 기본 정책 분야 정보를 그대로 반환한다. (필터링에 사용할 리스트)
@Transactional
// 정책 분야 정보를 처리 & User의 classification 필드를 업데이트 & 필터링에 사용할 분야 리스트를 반환
private List<EPolicyClassification> updateAndGetClassificationList(User user, String classification) {
List<EPolicyClassification> classificationList = Collections.emptyList();
if(StringUtils.isNotBlank(classification)) {
classificationList = Arrays.stream(classification.split(","))
.map(EPolicyClassification::fromCode)
.collect(Collectors.toList());
}
user.setClassifications(classificationList); // 사용자 정책 분야 정보 업데이트
userRepository.save(user); // User 테이블에 저장
return user.getClassifications();
}
5.3. 가중치 계산 - calculateInteractionWeight
사용자의 조회와 찜하기 상호작용 데이터를 기반으로, 정책마다 가중치를 계산한다. 조회는 가중치 1, 찜하기는 가중치 3을 부여하며, 최근 1주 이내의 상호작용에는 추가 가중치 1을 더한다.
private Map<String, Integer> calculateInteractionWeight(User user) {
List<UserInteraction> interactions = userInteractionRepository.findByUserOrderByActionTimeDesc(user);
Map<String, Integer> policyWeights = new HashMap<>(); // 정책별 가중치 저장
for(UserInteraction interaction : interactions) {
String policyId = interaction.getPolicyId();
EActionType actionType = interaction.getActionType();
LocalDateTime actionTime = interaction.getActionTime();
int weight = policyWeights.getOrDefault(policyId, 0); // 누적된 가중치 가져옴
if(actionType == EActionType.VIEW) { // 조회
weight += 1;
} else if(actionType == EActionType.FAVORITE) { // 찜하기
weight += 3;
}
// 최근 상호작용에 대한 가중치 1 추가 (최근 1주 이내)
if(actionTime.isAfter(LocalDateTime.now().minusWeeks(1))) {
weight += 1;
}
policyWeights.put(policyId, weight); // 정책별 가중치 정보 갱신
}
return policyWeights;
}
“찜하기”를 “조회”보다 더 높은 선호 신호로 간주해 가중치를 더 높게 설정했다. 사용자의 관심은 시간이 지날수록 변할 수 있기 때문에 “최근 1주 이내의 상호작용”에 대해 추가 가중치를 주어, 최신 관심사를 더 반영했다.
5.4. 핫한 정책 리스트 조회 - getHotPolicyList
모든 사용자 데이터를 기반으로 조회수와 찜하기 수를 반영해 가중치를 부여한 정책을 조회한다. 지역 및 정책 분야 필터링을 적용한 후 필요한 수만큼의 정책을 반환한다.
private List<PolicyListResponseDto> getHotPolicyList(User user, List<EPolicyRegion> regionList,
List<EPolicyClassification> classificationList, int count) {
List<YouthPolicy> hotPolicyList = youthPolicyRepository.findHotPolicies();
return hotPolicyList.stream()
.filter(policy -> // // 지역 & 정책분야 필터링 적용
(regionList.isEmpty() || regionList.contains(policy.getRegion()))
&& (classificationList.isEmpty() || classificationList.contains(policy.getClassification())))
.limit(count) // 필요한 정책 수만큼 가져옴
.map(policy -> {
boolean isFavorite = isFavoritePolicy(user, policy.getId());
return PolicyListResponseDto.from(policy, isFavorite);
})
.collect(Collectors.toList());
}
5.5. 가중치 & 필터링 기반 정책 추천 - getRecommendationPolicyList
가중치와 지역 및 정책 분야 필터링을 기반으로 추천 정책 리스트를 생성한다. 만약 추천 정책이 6개 미만일 경우, 핫한 정책을 추가하여 부족한 정책을 채운다.
@Transactional
public List<PolicyListResponseDto> getRecommendationPolicyList(Long userId, String region, String classification) {
User user = getUserById(userId);
List<PolicyListResponseDto> recommendationList = new ArrayList<>();
List<EPolicyRegion> regionList = updateAndGetRegionList(user, region); // 지역 필터링 리스트
List<EPolicyClassification> classificationList = updateAndGetClassificationList(user, classification); // 정책분야 필터링 리스트
Map<String, Integer> policyWeights = calculateInteractionWeight(user); // 사용자별 가중치 계산
if(!policyWeights.isEmpty()) { // 상호작용 데이터가 있는 경우
// 가중치 높은 순으로 정렬 & 지역 필터링 적용 & 상위 6개의 정책 가져오기
recommendationList = policyWeights.entrySet().stream()
.sorted(Map.Entry.<String, Integer>comparingByValue().reversed()) // 가중치 내림차순
.map(entry -> {
String policyId = entry.getKey();
return youthPolicyRepository.findById(policyId)
.filter(policy -> // // 지역 & 정책분야 필터링 적용
(regionList.isEmpty() || regionList.contains(policy.getRegion()))
&& (classificationList.isEmpty() || classificationList.contains(policy.getClassification())))
.map(policy -> {
boolean isFavorite = isFavoritePolicy(user, policy.getId()); // 찜하기 여부
return PolicyListResponseDto.from(policy, isFavorite);
})
.orElse(null); // 정책 없으면 null 반환
})
.filter(Objects::nonNull) // null값 제거 (유효하지 않은 정책 필터링)
.limit(6) // 상위 6개 정책 선택
.collect(Collectors.toList());
}
// 정책이 6개 미만일 경우 "핫한 정책"으로 부족한 갯수를 채움
if(recommendationList.size() < 6) {
// recommendationList에 이미 포함된 정책 ID들을 추출
Set<String> existingPolicyIds = recommendationList.stream()
.map(PolicyListResponseDto::id)
.collect(Collectors.toSet());
// "핫한 정책" 리스트에서 기존에 포함된 정책을 제외한 정책들만 가져옴
List<PolicyListResponseDto> hotPolicyList = getHotPolicyList(user, regionList, classificationList, 12).stream()
.filter(policy -> !existingPolicyIds.contains(policy.id())) // 기존 정책과 중복되지 않는 것만 선택
.collect(Collectors.toList());
// 두 리스트를 합친 후, 최대 6개의 정책을 반환
recommendationList = Stream.concat(recommendationList.stream(), hotPolicyList.stream())
.limit(6)
.collect(Collectors.toList());
}
return recommendationList;
}
6. 테스트
'Projects > 청하-청년을 위한 커뮤니티 서비스' 카테고리의 다른 글
[청하] 인덱스 성능 검증 (0) | 2024.12.31 |
---|---|
[청하] AI 사서 프롬프트 엔지니어링 (0) | 2024.12.31 |
[청하] 홈화면 리뉴얼: 맞춤 정책, 핫한 정책, 커뮤니티 미리보기 설계 (0) | 2024.10.27 |
[청하] Docker 환경에서 Spring Boot 모니터링 시스템 구축 (with. Prometheus, Grafana) (2) | 2024.10.27 |
[청하] 로컬 환경에서 Spring Boot 모니터링 시스템 구축 (with. Prometheus, Grafana) (0) | 2024.10.27 |
댓글