(Spring) 취향 기반 향수 추천 서비스 - 3. 설문 패키지 개발 (첫번째 메인 기능)

김준석·2023년 3월 17일
1

향수 추천 서비스

목록 보기
4/21

목표

첫번째 구조는 다음과 같다.

사용자가 5가지의 질문(성별,향,무드,계절,스타일)을 완료하면 해당 응답 결과를 토대로 서버에서 질문에 부합하는 향수 List를 반환해주는 것이다.


데이터가 분류된 방식이 다음과 같아서. 이 방식에 맞게 향수 데이터를 추출할 수 있도록 만들었다.

Domain

Survey.java

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity(name = "survey")
public class Survey {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id")
    private Long id;

    @NotNull
    @Column(nullable = false, length = 10)
    private String genderAnswer;

    @NotNull
    @Column(nullable = false, length = 10)
    private String scentAnswer;

    @NotNull
    @Column(nullable = false, length = 10)
    private String moodAnswer;

    @NotNull
    @Column(nullable = false, length = 10)
    private String seasonAnswer;

    @NotNull
    @Column(nullable = false, length = 10)
    private String styleAnswer;

    @ManyToOne
    private Perfume perfume;

    @Builder
    public Survey(Long id, String genderAnswer, String scentAnswer, String moodAnswer, String seasonAnswer, String styleAnswer,Perfume perfume) {
        this.id = id;
        this.genderAnswer = genderAnswer;
        this.scentAnswer = scentAnswer;
        this.moodAnswer = moodAnswer;
        this.seasonAnswer = seasonAnswer;
        this.styleAnswer = styleAnswer;
        this.perfume= perfume;
    }

}

5가지의 질문으로 구성되어 있어 필드에도 5가지의 질문을 넣었고, 향수를 @ManyToOne으로 참조할 수 있게 설정하였다. 각 질문에 부합하는 향수는 한개씩 있다.

Type

ScentType.java

@Getter
public enum ScentType {

    CITRUS("시트러스", "당신은 오렌지나 자몽처럼 상큼한 향을 좋아하시는군요. 신선한 향은 당신을 더욱 매력적으로 보이게 할 거예요."),
    WOODY("우디", "당신은 자연에서 나는 듯한 냄새와 잘 어울릴 것 같아요 우디향 향수는 당신을 더욱 매력적인 사람으로 만들거예요."),
    SOAPY("소피", "당신은 은은하게 나는 비누향과 잘 어울릴 것 같아요. 깔끔하고 단정한 이미지를 만들어보아요."),
    FRUITY("프루티", "당신은 달콤한 과일같은 향기와 잘 어울릴 것 같아요. 이런 향수를 쓰면 지나간 사람들이 한번씩 뒤돌아볼 것 같아요."),
    FLORAL("플로럴", "당신은 우아하고 고급스러운 꽃 향과 잘 어울릴 것 같아요. 꽃 향기로 당신의 매력을 어필해보세요."),
    VANILLA("바닐라", "당신은 달짝지근한 향수와 잘 어울릴 것 같아요. 은은하게 퍼지는 향은 당신을 더욱 매력적인 사람으로 만들거예요.");

    private final String scent;
    private final String feature;

    ScentType(String scent, String feature) {
        this.scent = scent;
        this.feature = feature;
    }

    public static String getFeature(Survey survey) {
        String expectedScent = survey.getScentAnswer();
        ScentType scentType = Arrays.stream(ScentType.values())
                .filter(scent -> scent.getScent().equals(expectedScent))
                .findAny().orElseThrow(ScentNotFoundException::new);
        return scentType.getFeature();
    }

}

선택한 향수에 맞는 메세지가 반환될 수 있도록 Enum객체를 생성하였다.
이는 첫번째 기능 중 향수 세부정보 조회에서 사용할 것이다.
getFeature는 request로 받은 향과 일치한 향의 특징을 반환해주는 메서드이다.
메세지 처리는 프론트엔드 친구에게 부탁할걸 ㅜ

ScentType.java

@Getter
public enum ScentType {

    CITRUS("시트러스", "당신은 오렌지나 자몽처럼 상큼한 향을 좋아하시는군요. 신선한 향은 당신을 더욱 매력적으로 보이게 할 거예요."),
    WOODY("우디", "당신은 자연에서 나는 듯한 냄새와 잘 어울릴 것 같아요 우디향 향수는 당신을 더욱 매력적인 사람으로 만들거예요."),
    SOAPY("소피", "당신은 은은하게 나는 비누향과 잘 어울릴 것 같아요. 깔끔하고 단정한 이미지를 만들어보아요."),
    FRUITY("프루티", "당신은 달콤한 과일같은 향기와 잘 어울릴 것 같아요. 이런 향수를 쓰면 지나간 사람들이 한번씩 뒤돌아볼 것 같아요."),
    FLORAL("플로럴", "당신은 우아하고 고급스러운 꽃 향과 잘 어울릴 것 같아요. 꽃 향기로 당신의 매력을 어필해보세요."),
    VANILLA("바닐라", "당신은 달짝지근한 향수와 잘 어울릴 것 같아요. 은은하게 퍼지는 향은 당신을 더욱 매력적인 사람으로 만들거예요.");

    private final String scent;
    private final String feature;

    ScentType(String scent, String feature) {
        this.scent = scent;
        this.feature = feature;
    }

    public static String getFeature(Survey survey) {
        String expectedScent = survey.getScentAnswer();
        ScentType scentType = Arrays.stream(ScentType.values())
                .filter(scent -> scent.getScent().equals(expectedScent))
                .findAny().orElseThrow(ScentNotFoundException::new);
        return scentType.getFeature();
    }

}

MoodType.java

@Getter
public enum MoodType {
    //따뜻한
    WARMTH("따뜻한", "따뜻하고 온화한 분위기를 연출하고 싶군요. 이 향수를 사용하시면 추운 겨울에도 따뜻한 분위기를 낼 수 있을거에요. 또 겉으로는 시크하지만 내면은 따뜻한 무드를 가지고 있음을 잘 나타내줄 거에요."),
    //산뜻한
    NEAT("산뜻한","산뜻한 느낌이 나는 향을 선호하시군요. 산뜻한 분위기의 향은 언제나 부담스럽지 않아요. 또한 뭔가 일이 잘 안풀리고, 속이 답답해서 기분전환을 해야 할 때에 사용해보세요."),
    //관능적인
    SENSUAL("관능적인","관능적인 매력의 소유자이시군요. 이성과 함께있는 자리에서 사용하셔서 이성에게 어필해보세요. 이 향수와 함께라면 관능적인 섹시미로 이성을 유혹할 수 있을거에요."),
    //시크한
    CHIC("시크한","파리지앵처럼 시크한 분위기를 연출하고 싶군요. 많은 사람들은 자신도 모르게 시크한 분위기에 끌린다 하지요. 이 향수와 함께 당신의 시크한 매력을 더욱 발산해 보세요."),
    //차분한
    TRANQUIL("차분한","차분한 분위기를 좋아하시군요. 차분한 분위기인 만큼 언제 어디서나 누구와 만나든 호불호 없이 데일리로 사용하시기 좋을거에요."),
    //깨끗한
    CLEAN("깨끗한","금방이라도 샤워를 하고 나온 듯한 깨끗한 향과 분위기를 좋아하시군요. 이 향수와 함께라면 투명하고 깨끗한 분위기를 연출하실 수 있을거에요."),
    //포근한
    COZY("포근한","포근한 분위기를 좋아하시군요. 이 향수로 꽃들 사이로 살포시 내려앉은 햇살같은 포근한 분위기를 연출할 수 있을거에요. 멋들어지거나 화려하지는 않지만, 가만히 안기고 싶은 포근한 매력을 가지고 있어요."),
    //세련된
    REFINED("세련된","세련된 도시적 느낌을 연출하고 싶군요. 중요한 사람을 만날 때 사용하신다면 보다더 고급스럽고 세련된 인상을 각인시켜줄거에요."),
    //상큼한
    FRESH("상큼한","상큼 발랄한 매력의 소유자이시군요. 이 향수와 함께 순정만화의 주인공처럼 상큼한 분위기를 연출해 보세요. 묵직하고 분위기있어야 할 때보다 가볍고 편하게 있을 때 사용해보세요."),
    //달콤한
    SWEET("달콤한","달콤한 향과 분위기를 좋아하시군요. 사랑스러운 분위기를 내고 싶을 때에도 사용해보세요. 당신이 달달한 향을 뿜어내면 상대방의 분위기도 좋게 바꿔줄 거에요."),
    //싱그러운
    REFRESHING("싱그러운","예쁜 정원에 있는 듯한 싱그러운 분위기를 좋아하시군요. 생동감 있고 프레쉬한 경쾌함을 느끼고 싶을 때 사용해보세요. 또 힘이 없는 날에 사용하신다면 지친 심신에 큰 활력이 될거에요."),
    SWEET_NEAT("달콤한 산뜻한",""),
    //달콤한 상큼한
    SWEET_FRESH("달콤한 상큼한",""),
    //달콤한 세련된
    SWEET_REFINED("달콤한 세련된",""),
    //달콤한 시크한
    SWEET_CHIC("달콤한 시크한",""),
    //달콤한 차분한
    SWEET_TRANQUIL("달콤한 차분한",""),
    //따뜻한 시크한
    WARMTH_CHIC("따뜻한 시크한",""),
    //따뜻한 차분한
    WARMTH_TRANQUIL("따뜻한 차분한",""),
    //산뜻한 세련된
    NEAT_REFINED("산뜻한 세련된",""),
    //산뜻한 시크한
    NEAT_CHIC("산뜻한 시크한",""),
    //산뜻한 차분한
    NEAT_TRANQUIL("산뜻한 차분한",""),
    //산뜻한 포근한
    NEAT_COZY("산뜻한 포근한",""),
    //상큼한 세련된
    FRESH_REFINED("상큼한 세련된",""),
    //세련된 포근한
    REFINED_COZY("세련된 포근한",""),
    //시크한 차분한
    CHIC_TRANQUIL("시크한 차분한",""),
    NEAT_SWEET("산뜻한 달콤한", "");

    private final String mood;
    private final String message;

    MoodType(String mood, String message){
        this.mood = mood;
        this.message = message;
    }

    public static String getMessage(Survey survey) {
        String expectedMood = survey.getMoodAnswer();
        MoodType moodType = Arrays.stream(MoodType.values())
                .filter(mood -> mood.getMood().equals(expectedMood)).findAny()
                .orElseThrow(MoodNotFoundException::new);
        return moodType.getMessage();
    }
}

무드에 관한 질문은 하나밖에 없다. 하지만 db엔 두가지 이상으로 저장되어 있다.
첫번째 기능에서는 request로 받은 응답에 맞춰 MoodMessage를 응답해주면 돼서, 하나의 무드 답변 ex)시크한 만 있으면 된다. 하지만 두번째 기능에서 사용하기 위해 두가지의 무드를 갖고 있는 타입도 선언하였다.

SurveyType.java

@Getter
public enum SurveyType {

    GENDERLESS("젠더리스"),
    DEFAULT("디폴트"),
    FOUR_SEASON("무관");
    private final String value;

    SurveyType(String value) {
        this.value = value;
    }
}

원래 Service 코드 필드에 선언되어 있던 상수를 Enum객체로 빼돌렸다. Service 코드에는 내부 로직만 존재하게 하려고 수정했다.

Exception

앞 게시물의 향수 Exception처럼 RuntimeException을 최 상위 부모 클래스로 상속하여 커스텀 Exception을 생성하였다.


public class MoodNotFoundException extends BadRequestException {
    private static final String message = "해당 무드를 찾을 수 없습니다.";

    public MoodNotFoundException() {
        super(message);
    }
}

public class ScentNotFoundException extends BadRequestException {

    private static final String message = "해당 향을 찾을 수 없습니다.";

    public ScentNotFoundException() {
        super(message);
    }
}

public class SeasonNotFoundException extends BadRequestException {

    private static final String message = "해당 계절을 찾을 수 없습니다.";

    public SeasonNotFoundException() {
        super(message);
    }
}

public class SurveyNotFoundException extends BadRequestException {

    private static final String message = "해당 설문 응답을 찾을 수 없습니다.";

    public SurveyNotFoundException() {
        super(message);
    }
}

Repository

SurveyRepository.java

@Repository
public interface SurveyRepository extends JpaRepository<Survey, Long> {

    Optional<Survey> findById(Long id);

    List<Survey> findByScentAnswer(String scentAnswer);

    List<Survey> findByGenderAnswerContainingAndScentAnswerAndMoodAnswerContainingAndSeasonAnswerContainingAndStyleAnswerContaining
            (String genderAnswer, String scentAnswer, String moodAnswer, String seasonAnswer, String styleAnswer);

    List<Survey> findByGenderAnswerContainingAndScentAnswerAndMoodAnswerContaining
            (String genderAnswer, String scentAnswer, String moodAnswer);

    List<Survey> findByGenderAnswerContainingAndScentAnswerAndMoodAnswerContainingAndStyleAnswerContaining
            (String genderAnswer, String scentAnswer, String moodAnswer, String styleAnswer);
}

원래 다중 조건을 걸려고 하지 않았다. Service코드에서 해당 부분을 대신하여 모든 로직을 수행할 수 있게 코드를 썼었다. 하지만, 로직이 너무 복잡해져 초보 개발자인 나에겐 더 힘든 선택이 되었고..
또, 성능면에서 전혀 고려하지 않았던 선택이었기에, 불필요한 쿼리가 마구잡이로 실행되었다. 서비스 배포를 하고 나서야 이를 깨달았고, 결국 수정했다.

Service

DataService.java

    private SurveyResponseDto makeList(Long id, int firstIndex, SurveyList surveyList) {
        return SurveyResponseDto.builder()
                .id(id)
                .genderAnswer(surveyList.getFirstAnswer().get(firstIndex))
                .scentAnswer(surveyList.getSecondAnswer().get(firstIndex))
                .moodAnswer(surveyList.getThirdAnswer().get(firstIndex))
                .seasonAnswer(surveyList.getFourthAnswer().get(firstIndex))
                .styleAnswer(surveyList.getFifthAnswer().get(firstIndex))
                .build();
    }

    public void saveSurveyData(Long id, SurveyList surveyList) throws IOException {
        surveyList = surveyCSVFileLoading.extractAllSurveyData(surveyList);
        for (int firstIndex = 0; firstIndex < surveyList.getMaxSize(); firstIndex++) {
            Long secondIndex = (long) firstIndex + 1;
            Survey surveyDataSet = makeList(id, firstIndex, surveyList).toEntity(perfumeService.findPerfumeById(secondIndex));
            surveyService.saveSurveyData(surveyDataSet);
        }
    }

Csv파일에서 데이터들을 추출하여 Db에 저장시키는 과정이다.
Perfume객체가 Survey에게 맵핑했기 때문에, 설문에 대한 정보 + PerfumeId까지 디비에 저장시켰다.

드디어.. 첫번째 기능

SurveyUtil.java

@Service
public class SurveyUtil {

    private static final String BLANK = "\\s";

    private static final int MOOD_COLUMN_SIZE = 1;

    public int getRandomMoodAnswer(String[] moodAnswerArray) {
        Random random = new Random();
        return random.nextInt(moodAnswerArray.length);
    }

    public SurveyRequestDto showMoodAnswer(Survey survey) {
        String[] moodAnswerArray = survey.getMoodAnswer().split(BLANK);
        int randomMoodAnswer = getRandomMoodAnswer(moodAnswerArray);
        if (moodAnswerArray.length == MOOD_COLUMN_SIZE) {
            return SurveyRequestDto.builder().moodAnswer(survey.getMoodAnswer()).build();
        }
        return SurveyRequestDto.builder().moodAnswer(moodAnswerArray[randomMoodAnswer]).build();
    }
}

첫번째 기능을 위한 Util을 담당한 클래스이다.

getRandomMoodAnswer는 DB에 저장된 Mood컬럼에서 두가지 이상의 무드가 들어있을 경우 그 두가지이상의 무드 중에서 랜덤으로 하나를 뽑아서 view에 전달할 수 있게 하기 위해 만들었다.

showMoodAnswer은 무드가 두가지 이상일 경우 Random으로 선택하여 view로 전달하는 메서드이다.

SurveyService.java

@Service
public class SurveyService {
    private final SurveyRepository surveyRepository;

    public SurveyService(SurveyRepository surveyRepository) {
        this.surveyRepository = surveyRepository;
    }

    public Survey findSurveyById(Long id) {
        return surveyRepository.findById(id).orElseThrow(SurveyNotFoundException::new);
    }

    public Survey saveSurveyData(Survey survey) {
        return surveyRepository.save(survey);
    }

    public List<Perfume> convertToPerfumeData(List<Survey> surveyList) {
        return surveyList.stream()
                .map(data -> data.getPerfume()).collect(Collectors.toList());
    }

    public List<Survey> filterSurveyResultByQuestion(SurveyRequestDto surveyRequestDto) {
        if (isNotSelectedSeasonAnswer(surveyRequestDto)) {
            return surveyRepository.findByGenderAnswerContainingAndScentAnswerAndMoodAnswerContainingAndStyleAnswerContaining
                    (surveyRequestDto.getGenderAnswer(), surveyRequestDto.getScentAnswer(), surveyRequestDto.getMoodAnswer(), surveyRequestDto.getStyleAnswer());
        }
        return surveyRepository.findByGenderAnswerContainingAndScentAnswerAndMoodAnswerContainingAndSeasonAnswerContainingAndStyleAnswerContaining
                (surveyRequestDto.getGenderAnswer(), surveyRequestDto.getScentAnswer(), surveyRequestDto.getMoodAnswer(), surveyRequestDto.getSeasonAnswer(), surveyRequestDto.getStyleAnswer());
    }

    public List<Perfume> showPerfumeListBySurvey(SurveyRequestDto surveyRequestDto) {
        List<Survey> surveyList = filterSurveyResultByQuestion(surveyRequestDto);

        if (isEmptyRecommendedPerfumeList(surveyList)) {
            List<Survey> surveyListByMood = surveyRepository.findByGenderAnswerContainingAndScentAnswerAndMoodAnswerContaining
                    (surveyRequestDto.getGenderAnswer(), surveyRequestDto.getScentAnswer(), surveyRequestDto.getMoodAnswer());
            return convertToPerfumeData(surveyListByMood);
        }
        return convertToPerfumeData(surveyList);
    }

    public List<Perfume> showSimilarPerfumeList(Survey survey) {
        List<Survey> findSimilarData = surveyRepository.findByGenderAnswerContainingAndScentAnswerAndMoodAnswerContaining
                (survey.getGenderAnswer(), survey.getScentAnswer(), survey.getMoodAnswer());

        return convertToPerfumeData(findSimilarData);
    }

    public boolean isEmptyRecommendedPerfumeList(List<Survey> surveyList) {
        if (convertToPerfumeData(surveyList).isEmpty()) {
            return true;
        }
        return false;
    }

    public boolean isNotSelectedSeasonAnswer(SurveyRequestDto surveyRequestDto) {
        if (surveyRequestDto.getSeasonAnswer().equals(SurveyType.NOT_SELECT_SEASON.getValue())) {
            return true;
        }
        return false;
    }
}
  1. findSurveyById()는 단순히 request로 들어온 id를 바탕으로 엔티티를 반환해주는 메서드이다.

  2. convertToPerfumeData()는 찾은 Survey Entity에서 Perfume에 해당하는 부분을 List로 변환시켜주는 메서드이다.
    조건에 부합하는 응답에 대한 결과를 향수로 Response해주기 위해 썼다.

  3. isEmptyRecommendedPerfumeList() 메서드는 최종 결과 List가 비어있을 경우를 고려한 메서드이다.

  4. isNotSelectedSeasonAnswer()는 추후에 추가한 내용인데, 계절에 봄, 여름, 가을, 겨울도 있지만 계절을 고려하지 않고 향수를 쓰는 사람도 있기 때문에 추가했다.
    계절을 상관없음으로 선택하게 되면 해당 메서드가 실행된다.

  5. filterSurveyResultByQuestion()은 질문에 응답에 따라 return을 달리해주는 메서드이다. 계절을 "상관없음"으로 선택했을 경우와 "봄 여름 가을 겨울"중 선택했을 경우로 나눴다.

  6. showPerfumeListBySurvey()는 최종 향수 추천 리스트를 반환해주는 메서드이다
    테스트코드는 맨~ 마지막 게시물에 올리겠다.

    포스트맨으로 테스트한 결과

향수 세부정보 조회

FeatureService.java

@Service
public class FeatureService {
    private final SurveyService surveyService;
    private final PerfumeService perfumeService;

    public FeatureService(SurveyService surveyService, PerfumeService perfumeService) {
        this.surveyService = surveyService;
        this.perfumeService = perfumeService;
    }

    public FeatureResponseDto showFeatureDetails(Long id) {
        return  FeatureResponseDto.builder()
                .perfume(perfumeService.findPerfumeById(id))
                .scentRecommend(selectScent(id))
                .moodRecommend(selectMood(id))
                .seasonRecommend(selectSeason(id))
                .build();
    }

    private String selectScent(Long id) {
        Survey survey = surveyService.findSurveyById(id);
        return ScentType.getFeature(survey);
    }

    private String selectSeason(Long id) {
        Survey survey = surveyService.findSurveyById(id);

        return SeasonType.getFeature(survey);
    }

    private String selectMood(Long id) {
        Survey survey = surveyService.findSurveyById(id);
        return MoodType.getMessage(survey);
    }
}

앞에 타입에서 정의해놓은 것들 (향,무드,계절)에 관한 메세지를 찾아 Response해주는 역할을 한다.

테스트

Controller

    @PostMapping("/show-perfume-by-survey")
    public ResponseEntity<List<Perfume>> showPerfumeDataBySurvey(@RequestBody SurveyRequestDto surveyRequestDto) {
        return ResponseEntity.ok(surveyService.showPerfumeListBySurvey(surveyRequestDto));
    }

    @PostMapping("/show")
    public ResponseEntity<List<Survey>> showSelectedData(@RequestBody SurveyRequestDto surveyRequestDto) {
        return ResponseEntity.ok(surveyRepository.findByScentAnswer(surveyRequestDto.getScentAnswer()));
    }

세부정보 Api는 GetMapping으로 바꿀 예정이다.

첫번째 기능을 끝내며..

우리 서비스의 핵심 기능이다. 모든 기능의 로직이 대부분 여기서 시작이 되기 때문에.. 2주정도의 기간을 갖고 개발했다. 여기서 오류가 발생하면 큰일! 큰일! 이기때문에 이 로직들은 현재도 꾸준히 리팩토링하고 테스트코드를 돌리는 중이다.

전체 코드는 깃허브에 있습니다.

향수추천서비스

profile
기록하면서 성장하기!

0개의 댓글