img

뮤랑이 프로젝트의 첫 번째 메인 기능은 표정 분석 후 음악 추천해주는 기능이다. 이 글에서는 관련해서 사용한 표정 분석 API, 백엔드 로직, Querydsl, Reflection, 리팩토링 과정까지 차근차근 정리해보았다.

목차

  1. 표정분석 API로 감정 수치 받아오기
  2. 두가지 감정이 높은 음악 조회하기, 동적 정렬 with Querydsl
  3. 감정명에 따라 감정 수치 업데이트하기 with Querydsl

1. 표정분석 API로 감정 수치 받아오기

뮤랑이에서는 사용자의 실시간 감정에 맞는 음악을 추천해준다. 이때 표정 분석은 위 API를 사용하였다. 이 API는 웹캠으로 사용자의 실시간 표정을 분석하여 7가지 감정들의 비율을 계산한다. 프로젝트에서는 총 5초간 분석하여 각 감정별 평균값을 계산한다. 그 중 가장 높게 나온 감정 2가지를 찾아 그 감정들이 높은 순서대로 음악을 조회하게 된다.

2. 두가지 감정이 높은 음악 조회하기, 동적 정렬 with Querydsl

음악과 그 음악의 감정 수치가 각각 Music, Emotion 테이블로 일대일 관계이다. 주감정/부감정에 맞는 음악을 추천해주려면 우선 두 테이블을 조인하고, 주감정으로 정렬하고 그 다음에 부감정으로 정렬해주는 과정이 필요했다.

Querydsl에서는 OrderBy를 이용해서 정렬을 해줄 수 있다. 이때, 인자로 들어오는 감정명에 따라 다르게 정렬해주어야 하므로 동적 정렬을 해주어야 한다.

  • 정적 정렬 : 정렬 파라미터가 정해짐
  • 동적 정렬 : 정렬 파라미터가 상황에 따라 달라짐

Querydsl에서 동적 정렬을 사용하려면 OrderBy에 인자로 OrderSpecifier로 사용해주면 된다.

@Override
public Page<Music> getMusicByTwoEmotion(String mainEmotion, String subEmotion, Pageable pageable) {

    EmotionType main = EmotionType.valueOf(mainEmotion);
    EmotionType sub = EmotionType.valueOf(subEmotion);

    List<Music> result =  queryFactory
            .select(music)
            .from(music)
            .leftJoin(figure).on(music.figure.id.eq(figure.id))
            .orderBy(itemSort(main), itemSort(sub)) // 동적 정렬
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

    return new PageImpl<>(result, pageable, result.size());
}

위의 orderBy( )에는 정렬방식이 들어가 있다. 아래 코드는 orderBy( )안에 들어갈 정렬방식을 반환하는 itemSort() 메소드이다. EmotionType별로 Switch문을 사용하여 정렬 방식을 반환한다.

public OrderSpecifier<?> itemSort(EmotionCategory emotionCategory) {
    switch (emotionCategory) {
        case disgusted:
            return QEmotion.emotion.disgusted.desc();
        case happy:
            return QEmotion.emotion.happy.desc();
        case sad:
            return QEmotion.emotion.sad.desc();
        case surprised:
            return QEmotion.emotion.surprised.desc();
        case fearful:
            return QEmotion.emotion.fearful.desc();
        case neutral:
            return QEmotion.emotion.neutral.desc();
        case angry:
            return QEmotion.emotion.angry.desc();
        default:
            throw new IllegalArgumentException("존재하지 않는 감정명입니다.");
    }
}

위 코드처럼 Querydsl을 사용할 때, OrderSpecifier을 사용하면 정렬 방식을 정해줄 수 있다. 인자로 들어오는 감정명의 내림차순으로 정렬하는 OrderSpecifier를 반환해준다. 일차적으로는 MusicRepositryImpl에 구현해주었지만, 이렇게 switch문을 쓰면 가독성도 떨어지고, 분기문마다 직접 컬럼명을 작성하다가 실수하기도 쉬워 리팩토링해주기로 했다. 이 Switch문을 리팩토링하려다가 OrderSpecifier 생성자랑 리플렉션도 공부하게 됐는데, 각 과정을 아래 정리해보았다.

🎨 리팩토링 - 감정에 따라 정렬하기

  • Querydsl로 동적 정렬하기

orderBy에는 OrderSpecifier 타입이 들어가는 데, 더 찾아보니 충분히 Switch문 없이도 구현할 수 있었다. OrderSpecifier 생성자를 보면, 정렬 방식과 Expression를 인자로 주어 생성할 수 있다. 여기서 Expression은 정렬 기준인 필드의 경로를 넣어주면 된다.

img

  • Path<Object> fieldPath = Expressions.path(Emotion.class, fieldStr); ⇒ 정렬 기준이 될 필드의 Path를 구해주고
  • return new OrderSpecifier(Order.DESC, fieldPath); ⇒ 이 경로와 정렬 방식(Order.DESC)을 통해 OrderSpecifier을 생성해주어 반환해주면 된다!

아래처럼 OrderSpecifier를 생성하여 반환하는 메소드를 활용해서 Switch문을 대신하였다.

		// 감정명을 받아 정렬하는 OrderSpecifier 반환하는 기능
		public OrderSpecifier<?> getOrderSpecifierByEmotion(String fieldStr) {
        Path<Object> fieldPath = Expressions.path(Emotion.class, fieldStr);
        return new OrderSpecifier(Order.DESC, fieldPath) ;
    }

		// 주감정, 부감정에 따라 동적 정렬하기
		@Override
    public Page<Music> getMusicByTwoEmotion(String mainEmotion, String subEmotion, Pageable pageable) {

        List<Music> result =  queryFactory
                .select(music)
                .from(music)
                .leftJoin(QEmotion.emotion).on(music.emotion.id.eq(QEmotion.emotion.id))
                .orderBy(getOrderSpecifierByEmotion(mainEmotion), getOrderSpecifierByEmotion(subEmotion))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        return new PageImpl<>(result, pageable, result.size());
    }

3. 감정명에 따라 감정 수치 업데이트하기 with Querydsl

Emotion 테이블에는 각 감정들의 수치가 Float 타입으로 저장된다.

뮤랑이에서 감정 수치를 업데이트하는 로직

  1. 표정 분석 후 가장 높은 수치의 감정 2가지(주감정/부감정)를 도출한다.
  2. 음악 추천 후 좋아요를 누르면 그 때의 주감정/부감정 이름이 서버에 전달된다.
  3. 그 음악의 해당 감정 필드들의 값이 0.01 더해지게 된다.

이렇게 각 음악별 감정 수치가 업데이트되어, 다음 조회 시에 이 수치들을 기반으로 정렬하게 된다.

위의 로직을 다시 예를 들어보면,

  1. 사용자의 감정 분석 결과가 Sad, Neutral이고
  2. 결과로 추천된 노래들 중 “딱 10cm만”이라는 노래를 사용자가 좋아요를 눌렀다면,
  3. 이 음악의 Sad, Neutral 필드의 값을 0.01 더해준다. (음악 추천이 적절했다고 본다)

따라서 필드명을 받아서 해당 필드의 값을 업데이트 시켜주어야 했다.

처음 구현할 때는 Switch문으로 감정명이 들어오면 계산해주었었는데, 이또한 위에서와 같은 이유로 Switch문을 대체할 수 있는 방법을 고민해보았다. Switch를 사용하지 않고, 모든 필드들 중에 입력된 String값에 해당하는 필드를 찾아주려면 어떻게 해야되는 지 고민했다..! 우선 모든 필드들의 배열을 얻고, 그 배열을 반복문을 돌리고 입력된 필드명과 같으면 해당 필드의 값을 0.01 업데이트 시켜주기로 했다. 이를 위해 리플렉션을 사용해주기로 했다.

🎨 리팩토링 - 감정 수치 업데이트하기

이전에 CS 스터디에서 리플렉션에 대해 발표한 적이 있었다. 그리고 JDBC 공부할 때, JDBC 드라이버를 로딩하기 위해서 리플렉션을 사용함을 배운 적 있었다. 그때 당시에는 DB의 jdbc 로더를 사용할 때만 쓸 일이 있겠구나 싶었는데, 이번처럼 필드명을 찾아서 값을 업데이트 해줄때도 사용할 수 있을 것 같다.

public Field findField(String fieldStr) throws NoSuchFieldException {
    Class<?> clazz = Emotion.class;
    return clazz.getDeclaredField(fieldStr);
}

public void updateEmotionFigure(String fieldStr) throws NoSuchFieldException, IllegalAccessException {
    validateEmotionFigure();
    Field field = findField(fieldStr);
    Float value = (Float) field.get(this);
    field.set(this, value + 0.01F);
}

리플렉션 코드를 findField(String fieldStr)에 구현해주고, updateEmotionFigure(String fieldStr)에서 get, set으로 값을 업데이트해주었다.

근데 리플렉션의 경우 느리고 비용이 높다고 알고 있다. 찾아보니 최초 한번 이후에는 캐시되어 크게 성능에 안좋은 영향을 끼치지 않는다고 하기도 하는데.. 이 부분은 성능이랑 다른 방식의 리팩토링을 찾아보고 업데이트 해야겠다..🖍 사실 리플렉션 성능이 느리고 다른 방안이 없다면 Switch문이 차라리 나을 수도 있을 것 같다..!

참고로, 필드값이 무한으로 커지는 것을 방지하기 위해서 7가지 감정 필드값의 총합은 1미만이다. 이 함수를 통해 매번 값을 체크하고, 1이 넘어가면 전체 필드들을 각각 10으로 나눠준다.

여기까지가 처음 개발부터 현재까지 메인 기능1(표정 분석 후 음악 추천)에 사용한 기술 및 리팩토링 과정이었다. 다음 글에서는 감정 분석 후 달력에 색상에 저장되는 로직을 정리해볼 예정이다.