Home 단어 학습 기능 개선
Post
Cancel

단어 학습 기능 개선

단어 학습 기능 개선

학습해야 할 단어를 효과적으로 관리하기 위해 Redis를 활용해 캐싱 기능을 도입하고

학습 데이터를 조회 및 저장하는 방식을 개선했다.

이번 글에서는 문제 해결 과정과 주요 구현 내용을 정리했다.


전체 코드는 여기에서 확인 할 수 있다.

image





문제점 발견

사용자가 하루에 학습해야 할 10개의 단어 중 일부(예: 8개)만 학습했을 경우

퀴즈는 이미 학습한 단어에 대해서만 제공되어야 한다.



구현 메소드

참고로 기존에는 getStudyWord()만 존재했으며 10개씩 조회하고

해당 조회한 단어 목록을 한꺼번에 Study 테이블에 저장했으며,

Study 테이블에 저장된 날짜가 오늘 날짜일 경우 오늘 날짜에 해당하는 데이터를 10개 가져오게 작성했었다.



1. getStudyWord() : 학습 데이터 조회

오늘 학습 데이터가 있는 경우, Study 테이블에서 데이터를 조회한다.

부족한 데이터는 findNotInStudy()로 조회한다.

조회한 데이터를 Redis 캐시에 저장한다.

이 cache는 이후 Study 테이블을 저장하는 saveStudyWord()에서 쓰인다.

1
2
3
4
5
6
7
8
9
if(오늘 날짜가 아니라면) {
    findNotInStudy();
}else{
    List<Study> study = studyRepository.findLastDayForStudy(today, user.getId()); // 오늘날짜에 학습한 데이터 조회
    if (study.size() < 10) {
        findNotInStudy();
    }
}
redisService.addStudyList(username, studyList);




2. findNotInStudy() : 학습하지 않은 데이터 조회

최신 학습 날짜가 오늘이 아니거나 데이터가 10개 미만일 경우 부족한 데이터를 채운다.

1
Pageable pageable = PageRequest.of(0, len);




3. saveStudyWord() : 학습 데이터 저장

사용자가 학습한 데이터를 Study 테이블에 저장한다.

Redis에서 캐시 데이터를 가져와 DB에 저장후 캐시에 남은 데이터를 update한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
List<StudyDto> studiesToSaveDto;
List<StudyDto> remainingCache;

// 저장할 데이터와 Redis에 남길 데이터를 분리 
studiesToSaveDto = new ArrayList<>(cachedStudyList.subList(0, endIndex));
remainingCache = new ArrayList<>(cachedStudyList.subList(endIndex, cachedStudyList.size()));

// DTO → Entity 변환 후 DB 저장 
List<StudyDto> studiesToSave = cachedStudyList.stream()
    .map(dto -> Study.createStudy(...))
    .toList();
studyRepository.saveAll(studiesToSave);

// Redis Cache 업데이트
redisService.addStudyList(username, remainingCache);




4. generateStudyList() : 학습할 단어 생성

Redis 캐시에 값이 없을 경우 DB에서 학습하지 않은 단어를 조회해 캐싱한다.


오늘 날짜에 저장된 단어 개수를 확인한다.

부족한 데이터는 DB에서 조회 후 읽은 단어는 Study 테이블에 저장하고 읽지 않은 단어는 Redis 캐시에 저장한다.

1
2
Pageable pageable = PageRequest.of(0, 10-startPage);
Page<Object[]> results = meanRepository.findByMeanForStudyWithSentence(user.getId(), pageable);





flow :

1. 단어 학습 완료 후 모달 종료

사용자가 학습을 마치면 마지막으로 읽은 단어를 기준으로 페이지 번호(maxPage)를 서버로 전달한다.



2. startIndex 설정

Redis에서 maxPage 값을 확인하고 없으면

Study 테이블에서 오늘 날짜 기준 데이터 개수를 startIndex로 설정한다.



3. 데이터 조회 및 저장

  • Redis 캐시에 학습할 단어 목록이 있는 경우

    캐시 데이터에서 startIndex ~ maxPage 사이 데이터를 Study 테이블에 저장

    캐시에서 저장된 데이터를 제거하고 남은 데이터를 Redis에 update


  • Redis 캐시에 값이 없는 경우

    generateStudyList()를 호출해 학습하지 않은 단어를 DB에서 조회한 후 처리



4. Front에서 서버 요청 최소화

프론트엔드에서도 LocalStorage를 활용해 다음 정보를 관리한다.

  • 사용자가 학습하는 단어 목록
  • 사용자가 최대 읽은 페이지 수(maxPage)
  • 페이지 수 갱신 여부

이로써 서버 요청을 최소화했다.





오류와 해결

직렬화(Serialization) : 객체 데이터를 통신하기 쉬운 포맷(Byte, CSV, Json) 형태로 만들어주는 작업

Java 객체를 JSON으로 변환하는 과정

ex) User user = new User("hi");{"nickname" : "hi"}


역직렬화(Deserialization) : 포맷(Byte, CSV, Json) 형태에서 객체로 변환하는 과정

JSON을 Java 객체로 변환하는 과정

ex) {"nickname" : "hi"} 데이터를 받아서 User라는 객체의 nickname에 “hi”를 할당하고 객체를 생성




1. Redis 데이터 null 값 반환

문제

Redis에 저장된 값을 가져왔을 때 모든 값이 null로 표시되었다.

원인은 Redis가 데이터를 직렬화/역직렬화 과정에서 발생한 문제였다.


원인

Redis는 데이터를 직렬화하여 저장하고 가져올 때 역직렬화한다.

GenericJackson2JsonRedisSerializer는 데이터를 직렬화하면서 @Class라는 키로 클래스의 패키지 정보를 함께 저장한다.

특히 리스트를 저장할 경우 {“@class”:”..”} 형태가 아닌

다음과 같은 형태로 저장되어 패키지를 제대로 인식하지 못하는 상황이 발생했다

1
{"@class":"java.util.ArrayList", "values":[{"@class":"..."}]}

이후 DTO로 수정하면서 아래와 같은 새로운 오류를 접했다.





2. No serializer found 오류

com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor

Lazy 로딩된 엔티티를 Jackson이 JSON으로 변환하지 못해 발생(Serialize)


해결 과정

방법 1) : @JsonIgnore 적용

Lazy 로딩 컬럼에 대해 직렬화를 무시하도록 오류가 나는 컬럼에 @JsonIgnore를 적용하면 되지만 이미 적용을 한 상태였다.

@JsonIgnore은 JSON 직렬화, 역직렬화 작업에서 무시된다.


  • @JsonIgnore : 클래스의 속성(필드, 멤버변수) 수준에서 사용
  • @JsonIgnoreProperties : 클래스 수준(클래스 선언 바로 위에)에 사용
  • @JsonIgnoreType : 클래스 수준에서 사용되며 전체 클래스를 무시




방법 2) Hibernate5Module 을 스프링 Bean으로 등록

현재 코드의 로딩 전략은 지연로딩(LAZY)전략을 사용한다.

User, Word 엔티티들을 조회하는 시점에서는 실제 객체가 아닌 프록시 객체를 가지고 있다.

그렇기 때문에 jackson 라이브러리는 기본적으로

이 프록시 객체를 json으로 어떻게 생성해야 하는지 모르기 때문에 예외가 발생하는 것이다.

따라서 Hibernate의 Proxy 객체를 JSON으로 읽을 수 있게 Jackson의 Hibernate5Module을 등록했다.

Hibernate5Module을 스프링 Bean으로 등록하면… 해결될 줄 알았다.



1
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  @Bean
    public ObjectMapper objectMapper() {
        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModule(new Hibernate5Module()); // Hibernate 모듈 등록
        return mapper;
    }
    
    @Bean
    public RedisTemplate<String, List<StudyDto>> studyWordsRedisTemplate() {
        RedisTemplate<String, List<StudyDto>> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer(objectMapper()));
        return redisTemplate;
    }

하지만 여전히 No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor and no properties discovered to create BeanSerializer 오류가 떴다.



해결

Lazy 로딩된 엔티티의 모든 참조를 제거하고 필요한 ID 값만 저장하도록 DTO를 평면화했다.





3. LocalDate 직렬화/역직렬화 오류

Jackson이 LocalDate 타입을 처리하지 못해 다음과 같은 오류가 발생했다.

Java 8 date/time type java.time.LocalDate not supported by default:

LocalDataTime을 역직렬화하지 못해서 생기는 오류이다.



해결 방법

1) 의존성 추가 JavaTimeModule을 Jackson에 등록해 Java 8 날짜/시간 타입을 처리할 수 있도록 설정했다.

1
2
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
implementation 'com.fasterxml.jackson.core:jackson-databind'



2) 어노테이션 적용

필드에 직렬화/역직렬화를 담당하는 어노테이션을 추가했다.

1
2
3
@JsonSerialize(using = LocalDateSerializer.class)
@JsonDeserialize(using = LocalDateDeserializer.class)
private LocalDate date;



3) ObjectMapper 설정

ObjectMapper는 Java에서 JSON을 다루는 데 사용되는 Jackson 라이브러리의 주요 클래스다.

주로 JSON과 Java 객체 간의 변환(즉, 직렬화 및 역직렬화)을 담당한다.


날짜/시간 타입의 데이터를 처리하기 위해 추가해야 하는 모듈은 JavaTimeModule이다.

JavaTimeModule은 Java 8에 도입된 새로운 날짜 및 시간 API(LocalDate, LocalTime, LocalDateTime)를

Jackson 라이브러리에서 처리할 수 있게 해주는 모듈이다.


기본적으로 Jackson 라이브러리는 Java 8의 새로운 날짜 및 시간 타입들을 인식하지 못하기 때문에

해당 타입들을 JSON으로 직렬화하거나 JSON에서 역직렬화할 때 문제가 발생할 수 있다.

이러한 문제를 해결하기 위해 JavaTimeModule을 ObjectMapper에 등록하면

날짜/시간 타입들을 적절하게 직렬화하고 역직렬화할 수 있게 된다.

1
2
3
4
5
6
7
8
9
10
11
12
@Bean
public RedisTemplate<String, List<StudyDto>> studyWordsRedisTemplate() {
    RedisTemplate<String, List<StudyDto>> redisTemplate = new RedisTemplate<>();
    redisTemplate.setConnectionFactory(redisConnectionFactory());
    redisTemplate.setKeySerializer(new StringRedisSerializer());

    ObjectMapper objectMapper = new ObjectMapper();
    objectMapper.registerModule(new JavaTimeModule()); // For date/time if needed

    redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer(objectMapper));
    return redisTemplate;
}



참고)

1
2
3
4
5
6
7
8
9
10
11
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
/*
deserialize시 알지 못하는 property가 오더라도 실패하지 않도록 처리 
ex. User에 username이 없는 경우 Json에 usernmae이 들어있을 때 위 설정이 없을 경우 exception 발생
*/

objectMapper.configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, false);
/*
default 값이 false
true : json에서 null인 값이 전달 되는 경우 exception 발생
*/





4. java.util.LinkedHashMap cannot be cast to class

Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed:
java.lang.ClassCastException: class java.util.LinkedHashMap cannot be cast to class



문제 : 명확한 타입 설정 X

GenericJackson2JsonRedisSerializer는 내부적으로 ObjectMapper를 생성하며

기본적으로 아래와 같이 DefaultTyping을 활성화한다.

1
2
3
4
5
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.activateDefaultTyping(
    objectMapper.getPolymorphicTypeValidator(),
    ObjectMapper.DefaultTyping.NON_FINAL
);

GenericJackson2JsonRedisSerializer를 사용해 범용적인 Redis 캐싱을 구현하면서

구체적인 타입을 지정하지 못하고 역직렬화할 클래스의 타입을 Object 타입으로 넘기고 있었다.


원인

역직렬화의 타입을 Object로 적용 시

배열 또는 컬렉션 타입의 데이터를 역직렬화하지 못하는 문제가 발생한다.

Jackson에서 제공하는 DefaultTyping 옵션이 JSON에 구체적인 타입 정보를 포함하지 않기 때문이다.


Jackson은 기본적으로 ObjectMapper.readValue()에 전달된 타입 정보를 기반으로 역직렬화를 수행한다.

GenericJackson2JsonRedisSerializer는 타입 정보를 JSON에 포함하지만

명시적으로 @class 속성을 추가하지 않기 때문에 문제가 발생할 수 있다.



해결 방법: JSON에 타입 정보 포함하기

JsonTypeInfo.As.PROPERTY를 사용하면 JSON 내부에 타입 정보를 속성으로 포함시킬 수 있다.

1
2
3
4
5
{
  "@class": "com.eng.StudyDto",
  "userId": "1",
  "wordId": "55"
}


다음과 같이 설정하여 Jackson이 타입 정보를 처리할 수 있도록 했다.

1
2
3
4
5
objectMapper.activateDefaultTyping(
            objectMapper.getPolymorphicTypeValidator(),  // 안전한 타입 검증 활성화 (ex. @class)   
            ObjectMapper.DefaultTyping.NON_FINAL,        // NON_FINAL 타입 정보 포함 
            JsonTypeInfo.As.PROPERTY                     // JSON 내부에 타입 정보 추가 (@class 속성) 
);



Option

만약 유효성 검사를 수행하지 않고 모든 하위 유형을 허용하고 싶다면

objectMapper.getPolymorphicTypeValidator() 대신 LaissezFaireSubTypeValidator.instance를 작성하면 된다.

1
2
3
4
5
objectMapper.activateDefaultTyping(
    LaissezFaireSubTypeValidator.instance, 
    ObjectMapper.DefaultTyping.NON_FINAL, 
    JsonTypeInfo.As.PROPERTY
);

하지만 보안적인 측면에서 위험할 수 있다. (test 환경에서만 사용 권장)


DefaultTyping: JSON에 타입 정보를 저장할 범위를 설정하는 옵션

ObjectMapper.DefaultTyping.NON_FINAL은 모든 non-final 클래스를 대상으로 타입 정보를 포함시킨다.

→ Dto, Entity(해당 클래스에서 final을 사용하거나 다른 클래스에서 final로 선언되지 않은 경우)


enableDefaultTyping은 더 이상 사용하지 않으므로 activateDefaultTyping로 작성했다.

image




5. Duplicate entry 유니크 제약 조건 충돌

ex) Duplicate entry ‘1-67-25’ for key ‘study.user_id’

Study에서 unique 제약 조건으로 user_id + word_id + meaning_id을 설정했었다.

그런데 단어와 뜻은 같아도 예문을 여러 개 저장할 수 있게 설정했었는데 그 부분을 인지 못하고 있었다가 오류를 접했다.

그래서 Study 테이블에 Sentence를 넣는 것으로 코드를 수정했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ALTER TABLE study
    ADD COLUMN sentence_id BIGINT NOT NULL;

-- 기존 `sentence_id`가 유효하지 않다면 1로 설정
UPDATE study SET sentence_id = 1 WHERE sentence_id = 0; -- 1로 채우기 

-- `sentence_id`에 대한 외래 키 제약 추가
ALTER TABLE study
    ADD FOREIGN KEY (sentence_id) REFERENCES sentence (id) ON DELETE CASCADE;

-- 기존 `user_id`에 대한 UNIQUE 제약 제거 및 새로운 UNIQUE 제약 추가
ALTER TABLE study
    DROP INDEX user_id,
    ADD UNIQUE (user_id, word_id, meaning_id, sentence_id); 



[23000][1452] Cannot add or update a child row: a foreign key constraint fails
(eng.#sql-2018_500, CONSTRAINT study_ibfk_4 FOREIGN KEY (sentence_id) REFERENCES sentence (id) ON DELETE CASCADE)

외래 키 제약을 추가하려고 보니 오류가 떠서 id값을 1로 채운 후 동작했더니 오류가 뜨지 않았다.



1
2
3
4
5
6
7
8
9
10
11
12
CREATE TABLE study(
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    word_id BIGINT NOT NULL,
    meaning_id BIGINT NOT NULL,
    sentence_id BIGINT NOT NULL,
    FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE,
    FOREIGN KEY (word_id) REFERENCES word (id) ON DELETE CASCADE,
    FOREIGN KEY (meaning_id) REFERENCES meaning (id) ON DELETE CASCADE,
    FOREIGN KEY (sentence_id) REFERENCES sentence (id) ON DELETE CASCADE,
    UNIQUE user_id (user_id, word_id, meaning_id, sentence_id) -- 특정 단어-뜻-예문 중복 학습 방지
);





REFERENCE

오류와 해결 - 3. LocalDate 직렬화/역직렬화 오류

오류와 해결 - 4. java.util.LinkedHashMap cannot be cast to class

This post is licensed under CC BY 4.0 by the author.