Projects/청하-청년을 위한 커뮤니티 서비스

[청하] 맞춤 정책 리스트 조회 기능 구현

Lpromotion 2024. 12. 31. 02:06

사용자 행동 데이터에 가중치를 적용하여 사용자에게 맞춤형 정책 추천을 제공하려고 한다.

현재 프로젝트에서 정책 조회수와 정책 찜하기수 데이터를 저장하고 있다.

사용자 행동 데이터로 사용할 수 있는 것은 “조회수”와 “찜하기수” 이다.

 

조회와 찜하기 모두 사용자 행동 데이터이지만, 조회보다는 찜하기에 더 높은 가중치를 부여하는 것이, 사용자의 선호도를 더 정확하게 반영할 수 있다고 판단했다.

그리고 이런 행동 데이터에서도 최근 조회나 찜하기에 더 높은 가중치를 부여하는 것도 정확도에 기여할 수 있다고 생각했다.

 

가중치 부여 기준 설정

  • 조회: 사용자가 정책을 조회할 때마다 가중치 1을 부여한다. (조회가 많을수록 가중치가 쌓임)
  • 찜하기: 사용자가 특정 정책을 찜한 경우, 가중치 3을 부여한다. 찜한 정책은 사용자의 선호도가 더 높다고 판단하여 조회보다 가중치를 더 높게 설정한다.
  • 최근 상호작용: 사용자가 최근에 상호작용한 정책에 더 높은 가중치를 부여한다. 최근 1주 내에 상호작용한 가중치는 더 높은 가중치를 주고, 1개월 이상 지난 정책에는 가중치를 감소시킨다.

 

작업 내용 요약

  1. 사용자 테이블에 “지역(`region`)” 컬럼 추가 - 기존 `EPolicyRegion` 사용
  2. 정책 조회 테이블(`ViewPolicy`)에 `user`, `createDate` 컬럼 추가
  3. 사용자 상호작용 테이블(`UserInteractions`) 생성
  4. 맞춤 정책 리스트 조회 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. “맞춤 정책 리스트 조회” 서비스 코드 생성

서비스 로직 흐름

  1. 지역 및 정책 분야 필터링 리스트 생성
    • 사용자의 지역(region)정책 분야(classification) 정보를 확인하고, 사용자가 요청한 필터링 값이 있으면 사용자 정보를 업데이트한다.
  2. 사용자 상호작용 기반 가중치 계산
    • 정책 조회, 찜하기 등의 상호작용 데이터를 기반으로 각 정책에 가중치를 부여하고, 최근 상호작용일 경우 추가 가중치를 더한다.
  3. 가중치 기반 맞춤 정책 리스트 생성
    • 가중치가 높은 정책 순서대로, 지역 및 관심 분야 조건을 만족하는 상위 6개의 정책을 추출한다.
  4. 핫한 정책 리스트 추가
    • 만약 추천할 정책이 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. 테스트

반응형
댓글수0