본문 바로가기
Projects/청하-청년을 위한 커뮤니티 서비스

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

by Lpromotion 2024. 12. 31.

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

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

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

 

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

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

 

가중치 부여 기준 설정

  • 조회: 사용자가 정책을 조회할 때마다 가중치 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. 테스트

반응형

댓글