Troubleshooting: 무엇이 문제였는가?/취하여 프로젝트

2주 차: 알림 기능 찾아 삼만리 Day 9 - 로직 나누기는 마치 원심분리기 같아요. 영혼까지 분리될 뻔했거든요 (1)

writingforever162 2025. 2. 23. 21:10

[알림 기능 찾아 삼만리 Day 1 링크]

[알림 기능 찾아 삼만리 Day 2 링크]

[알림 기능 찾아 삼만리 Day 3 링크]

[알림 기능 찾아 삼만리 Day 4 링크]

[알림 기능 찾아 삼만리 Day 5 링크]

[알림 기능 찾아 삼만리 Day 6 링크]

[알림 기능 찾아 삼만리 Day 7 링크]

[알림 기능 찾아 삼만리 Day 8 링크]

[깃허브(GitHub) 링크]

더보기
package com.project.cheerha.domain.notice.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor
@Table(
    name = "email_job_opening_mapping",
    uniqueConstraints = {
        @UniqueConstraint(columnNames = {"email", "jobOpeningUrl"})
    })
public class EmailJobOpeningMapping {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String email;

    @Column(nullable = false)
    private String jobOpeningUrl;

    /**
     * 이메일과 채용 공고 URL을 받아 EmailJobOpeningMapping 객체 생성 
     * @param email 사용자의 이메일 주소
     * @param jobOpeningUrl 채용 공고 URL 
     * @return 생성된 EmailJobOpeningMapping 객체
     */
    public static EmailJobOpeningMapping toEntity(
        String email,
        String jobOpeningUrl
    ) {
        EmailJobOpeningMapping emailJobOpeningMapping = new EmailJobOpeningMapping();
        emailJobOpeningMapping.email = email;
        emailJobOpeningMapping.jobOpeningUrl = jobOpeningUrl;
        return emailJobOpeningMapping;
    }
}
더보기
package com.project.cheerha.domain.notice.repository;

import com.project.cheerha.domain.notice.entity.EmailJobOpeningMapping;
import org.springframework.data.jpa.repository.JpaRepository;

public interface EmailJobOpeningMappingRepository extends JpaRepository<EmailJobOpeningMapping, Long> {
}
더보기
package com.project.cheerha.domain.notice.repository;

import com.project.cheerha.domain.notice.UserDto;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Map;

public interface EmailRepositoryQuery {

    /**
     * 조회 시간으로 모든 채용 공고 정보를 조회
     *
     * @param referenceTime 조회 시간
     * @return 채용 공고 키워드와 연관된 URL 목록을 매핑한 맵
     * Key: KeywordId (키워드 식별자)
     * Value: 해당 키워드로 매칭되는 URL 목록
     */
    Map<Long, List<String>> findAllJobOpeningKeywords(ZonedDateTime referenceTime);

    /**
     * 모든 사용자의 이메일 및 등록한 키워드를 조회
     * @return 사용자 이메일과 선택한 키워드를 포함한 UserDto 리스트
     */
    List<UserDto> findAllUserKeywords();
}
package com.project.cheerha.domain.notice.repository;

import static com.querydsl.core.group.GroupBy.groupBy;
import static com.querydsl.core.group.GroupBy.list;
import static com.project.cheerha.domain.keyword.entity.QUserKeyword.*;
import static com.project.cheerha.domain.user.entity.QUser.*;
import static com.project.cheerha.domain.jobopening.entity.QJobOpening.*;
import static com.project.cheerha.domain.keyword.entity.QJobOpeningKeyword.*;

import com.project.cheerha.domain.notice.QUserDto;
import com.project.cheerha.domain.notice.UserDto;
import com.querydsl.jpa.impl.JPAQueryFactory;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

@Repository
@RequiredArgsConstructor
public class EmailRepositoryQueryImpl implements EmailRepositoryQuery {

    private final JPAQueryFactory queryFactory;

    @Override
    public Map<Long, List<String>> findAllJobOpeningKeywords(
        ZonedDateTime referenceTime
    ) {
        return queryFactory
            .from(jobOpeningKeyword)
            .join(jobOpeningKeyword.jobOpening, jobOpening)
            .where(jobOpening.createdAt.after(referenceTime)) 
            // 조회 시간 이후 생성된 채용 공고
            .transform(
                groupBy(jobOpeningKeyword.keyword.id) 
                // 키워드 ID별로 그룹화
                    .as(list(jobOpening.jobOpeningUrl)) 
                    // URL 목록을 그룹에 매핑
            );
    }

    @Override
    public List<UserDto> findAllUserKeywords() {

        return queryFactory
            .select(
                new QUserDto(
                    userKeyword.keyword.id, 
                    // 사용자가 선택한 키워드 ID
                    user.email 
                    // 사용자 이메일
                )
            ).from(userKeyword)
            .join(userKeyword.user, user) 
            // 사용자와 키워드 조인
            .fetch();
    }
}
더보기
package com.project.cheerha.domain.notice.service;

import com.project.cheerha.domain.notice.UserDto;
import com.project.cheerha.domain.notice.repository.EmailRepositoryQuery;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class DataFetchService {

    private final EmailRepositoryQuery repositoryQuery;

    public Map<Long, List<String>> findKeywordIdToUrlList(ZonedDateTime referenceTime) {
        return repositoryQuery.findAllJobOpeningKeywords(referenceTime);
    }

    public List<UserDto> findUserDtoList() {
        return repositoryQuery.findAllUserKeywords();
    }
}
더보기
package com.project.cheerha.domain.notice.service;

import com.project.cheerha.domain.notice.UserDto;
import com.project.cheerha.domain.notice.entity.EmailJobOpeningMapping;
import com.project.cheerha.domain.notice.repository.EmailJobOpeningMappingRepository;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class MappingService {

    private final EmailJobOpeningMappingRepository mappingRepository;

    /**
     * 이메일과 채용 공고 URL을 매핑하여 저장
     *
     * @param userDtoList 사용자 정보 리스트
     * @param jobOpeningKeywordMap 채용 공고 URL 목록을 키워드 ID로 매핑한 맵
     */
    @Transactional
    public void saveEmailJobOpeningMappings(
        List<UserDto> userDtoList,
        Map<Long, List<String>> jobOpeningKeywordMap
    ) {
        // 이메일과 채용 공고 URL을 매핑할 맵
        // key: 사용자 이메일
        // value: 채용 공고 URL Set
        Map<String, Set<String>> emailUrlMap = new HashMap<>();

        // (1) 사용자 정보 리스트 순회
        // (2) 사용자 이메일과 매칭된 채용 공고 URL 수집
        for (UserDto dto : userDtoList) {
            List<String> matchingUrlList = jobOpeningKeywordMap.get(dto.keywordId());

            // 매칭된 URL이 존재하면 해당 이메일에 URL 추가
            if (matchingUrlList != null && !matchingUrlList.isEmpty()) {
                emailUrlMap.computeIfAbsent(
                    dto.email(), // 이메일을 key로 사용
                    email -> new HashSet<>()
                ).addAll(matchingUrlList); // URL 목록을 Set에 추가
            }
        }

        // 이메일-채용 공고 URL 매핑 저장
        emailUrlMap.forEach((email, urlSet) -> {
            for (String jobOpeningUrl : urlSet) {
                // EmailJobOpeningMapping 객체 생성
                EmailJobOpeningMapping mapping = EmailJobOpeningMapping.toEntity(email, jobOpeningUrl);
                // 데이터베이스에 저장 
                mappingRepository.save(mapping);
            }
        });
    }
}

머릿속에 떠오른 대로 묘사 실력이 따라오지 못할 때 글 쓰다가 스트레스를 엄청나게 받듯이, 머릿속으로 그린 대로 로직(logic)을 막 분리하다가 하루가 끝났다. '처음부터 로직을 여러 개로 나눌걸' 후회가 밀려오면서도 어떻게든 알림 기능을 구현해 놓아서 그나마 구조를 개선하는 데 더 집중할 수 있나, 온갖 생각이 마음속을 헤집어서 하루 종일 더 큰 부담감에 짓눌렸다. 내일은 꼭 실시간 알림 기능까지 어떻게든 구현하고 싶다. 

 

할 수 있겠지, 나?

 

Day 10에서 계속…….