Repository: study-pals/backend
Pull request #41: [#34][FEATURE] category / study /redis dao 및 테스트
Author: login: unikal1
Labels: name: ✨ feature
description: 새로운 기능 구현
Created: 2025-04-10T08:17:12Z | Merged: 2025-04-11T05:40:14Z

<!-- PR의 제목은 "[#이슈번호] [종류(ex.FEATURE)] 이슈이름" 으로 작성 -->

<!-- Auto close 할 이슈 번호 -->
- close #34

## ✨ 구현 기능 명세

study_category, study_time, study_status dao 및 unit test 작성

## ✅ PR Point

### study_category
공부 카테고리에 대한 엔티티입니다.
- dayBelong 필드는 어떤 요일에 사용되는 카테고리인지 비트 마스킹으로 저장합니다.
- color 은 헥사 코드 기반으로 저장합니다.
- description 은 TEXT 로 지정하였습니다.

### StudyCategoryRepository
StudyCategory 에 대한 DAO 인터페이스입니다. findByMemberId 메서드를 정의하였습니다. 해당 유저의 모든 카테고리를 반환하는 메서드입니다.

### StudyTime
공부 시간에 대한 엔티티입니다.
- temporaryName은, 사용자가 특정 날 카테고리에 포함되지 않은 새로운 토픽을 임시로 추가하는 경우, category 필드를 비워두고, 해당 temporaryName 에 임시 토픽 이름을 저장합니다.
- time 은 long 으로 저장하였으며 현재 분 단위로 기록하고자 합니다.
- prePersist, PreUpdate 를 사용하여 temporaryName 과 category 가 동시에 null 인 경우 예외를 뱉도록 하였습니다.

### StudyTimeRepository
공부 시간에 대한 DAO 인터페이스입니다. 다음 세가지 메서드를 정의하였습니다.
- findByMemberIdAndStudiedAt 은 , 유저가 언제 얼만큼 공부했는지에 대한 StudyTime 객체를 반환합니다.
- sumTimeByMemberAndDate 는, @Query 를 이용하여 직접 작성하였으며, 특정 날짜의 총 공부 시간을 반환합니다. null-safe 를 위해 쿼리문에 `COALESCE` 를 사용하였습니다.
- findByMemberIdAndStudiedAtBetween 은, 특정 날짜 사이의 모든 StudyTime 객체를 반환합니다.

### StudyStatus 
공부 상태  등에 대한 redis 에 저장되는 엔티티입니다.
현재 공부 중인지, 공부 중이라면 언제 시작했는지, 오늘 하루 간 총 공부 시간에 대한 정보를 가지고 있습니다. "공부 중인지" 와 "총 공부 시간"을 분리하지 않은 까닭은, 어쩌피 총 공부 시간의 갱신 시점이 공부가 종료되는 시점이기 때문에, 하나로 관리하는 편이 더 좋다고 생각했습니다.

### StudyStatusRedisRepository 
CrudRepository 를 확장한 redis repository interface 입니다. `RedisConfig` 에서 해당 인터페이스가 redis respository 라고 설정을 해줬습니다.(아니면 JPA로 인식되던가, 안됩니다)

### 테스트
모든 테스트는 DataJpaTest 와 DataRedisTest 입니다. 모두 단위 테스트로 진행하였으며, 대부분 성공 케이스에 대한 테스트와, 일부 제약 조건 실패 처리를 하였습니다. 둘 모두 embedded DB를 사용하지 않았습니다.



Linked issue #34: [FEATURE] category / study /redis dao 및 테스트
<!-- Assignees 체크하기 -->

## 🌴 작업 브랜치 <!-- 작업할 브랜치 명시 -->

`feat/set-study-dao`

## 💼 TASK 개요 <!-- 개발할 기능에 대한 간단한 설명 작성 -->

- 공부 시간 및 카테고리 등에 사용되는 MySQL DAO 및 REDIS DAO 추가 
- repository 계층 단위 테스트

## ✅ TODO 및 진행현황 <!-- 할 일 목록을 만들고 진행사항 표시 -->

- [x] jpa DAO 구성
- [x] redis DAO 작성
- [x] jpa test 작성
- [x] redis test 작성


Diff:
diff --git a/build.gradle b/build.gradle
index 6fe740b5..cdd37476 100644
--- a/build.gradle
+++ b/build.gradle
@@ -51,6 +51,7 @@ dependencies {
     testImplementation 'org.mockito:mockito-core:4.6.1'
     testImplementation 'org.springframework.restdocs:spring-restdocs-core'
     testImplementation 'org.springframework.security:spring-security-test'
+    testImplementation 'org.springframework.boot:spring-boot-starter-data-redis'
     testCompileOnly 'org.projectlombok:lombok'
     testAnnotationProcessor 'org.projectlombok:lombok'
 
diff --git a/src/main/java/com/studypals/domain/memberManage/dao/MemberRepository.java b/src/main/java/com/studypals/domain/memberManage/dao/MemberRepository.java
index e7be4eb2..ac5a2a3f 100644
--- a/src/main/java/com/studypals/domain/memberManage/dao/MemberRepository.java
+++ b/src/main/java/com/studypals/domain/memberManage/dao/MemberRepository.java
@@ -13,7 +13,7 @@
  * <p>JPA 기반의 repository
  *
  * <p><b>상속 정보:</b><br>
- * JpaRepository<Member, Long>
+ * {@code JpaRepository<Member, Long>}
  *
  * @author jack8
  * @see Member
diff --git a/src/main/java/com/studypals/domain/memberManage/entity/Member.java b/src/main/java/com/studypals/domain/memberManage/entity/Member.java
index 365ca6b4..e3ebb89a 100644
--- a/src/main/java/com/studypals/domain/memberManage/entity/Member.java
+++ b/src/main/java/com/studypals/domain/memberManage/entity/Member.java
@@ -4,14 +4,10 @@
 
 import jakarta.persistence.*;
 
+import lombok.*;
 import org.springframework.data.annotation.CreatedDate;
 import org.springframework.data.jpa.domain.support.AuditingEntityListener;
 
-import lombok.AllArgsConstructor;
-import lombok.Builder;
-import lombok.Getter;
-import lombok.NoArgsConstructor;
-
 /**
  * Member 에 대한 엔티티입니다.
  *
@@ -26,7 +22,7 @@
 @Builder
 @Getter
 @AllArgsConstructor
-@NoArgsConstructor
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
 @EntityListeners(AuditingEntityListener.class)
 @Table(name = "member")
 public class Member {
diff --git a/src/main/java/com/studypals/domain/studyManage/dao/StudyCategoryRepository.java b/src/main/java/com/studypals/domain/studyManage/dao/StudyCategoryRepository.java
new file mode 100644
index 00000000..ba95ef45
--- /dev/null
+++ b/src/main/java/com/studypals/domain/studyManage/dao/StudyCategoryRepository.java
@@ -0,0 +1,24 @@
+package com.studypals.domain.studyManage.dao;
+
+import java.util.List;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import com.studypals.domain.studyManage.entity.StudyCategory;
+
+/**
+ * {@link StudyCategory} 에 대한 JPA DAO 클래스입니다.
+ *
+ * <p><b>상속 정보:</b><br>
+ * {@code JpaRepository<StudyCategory, Long>}
+ *
+ * @author jack8
+ * @see StudyCategory
+ * @since 2025-04-10
+ */
+@Repository
+public interface StudyCategoryRepository extends JpaRepository<StudyCategory, Long> {
+
+    List<StudyCategory> findByMemberId(Long memberId);
+}
diff --git a/src/main/java/com/studypals/domain/studyManage/dao/StudyStatusRedisRepository.java b/src/main/java/com/studypals/domain/studyManage/dao/StudyStatusRedisRepository.java
new file mode 100644
index 00000000..8411ff6e
--- /dev/null
+++ b/src/main/java/com/studypals/domain/studyManage/dao/StudyStatusRedisRepository.java
@@ -0,0 +1,20 @@
+package com.studypals.domain.studyManage.dao;
+
+import org.springframework.data.repository.CrudRepository;
+import org.springframework.stereotype.Repository;
+
+import com.studypals.domain.studyManage.entity.StudyStatus;
+
+/**
+ * {@link StudyStatus} 에 대한 Redis DAO 클래스입니다.
+ * <p>
+ * 유저의 공부 상태, 공부 시간 등에 대한 데이터를 저장합니다.
+ *
+ * <p><b>상속 정보:</b><br>
+ * CRUDRepository의 확장 인터페이스입니다.
+ *
+ * @author jack8
+ * @since 2025-04-10
+ */
+@Repository
+public interface StudyStatusRedisRepository extends CrudRepository<StudyStatus, Long> {}
diff --git a/src/main/java/com/studypals/domain/studyManage/dao/StudyTimeRepository.java b/src/main/java/com/studypals/domain/studyManage/dao/StudyTimeRepository.java
new file mode 100644
index 00000000..1130336a
--- /dev/null
+++ b/src/main/java/com/studypals/domain/studyManage/dao/StudyTimeRepository.java
@@ -0,0 +1,34 @@
+package com.studypals.domain.studyManage.dao;
+
+import java.time.LocalDate;
+import java.util.List;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import com.studypals.domain.studyManage.entity.StudyTime;
+
+/**
+ * {@link StudyTime} 에 대한 JPA DAO 클래스입니다.
+ *
+ *
+ * @author jack8
+ * @since 2025-04-10
+ */
+@Repository
+public interface StudyTimeRepository extends JpaRepository<StudyTime, Long> {
+
+    List<StudyTime> findByMemberIdAndStudiedAt(Long memberId, LocalDate studiedAt);
+
+    @Query(
+            """
+        SELECT COALESCE(SUM(s.time), 0)
+        FROM StudyTime s
+        WHERE s.member.id = :memberId AND s.studiedAt = :studiedAt
+        """)
+    Long sumTimeByMemberAndDate(@Param("memberId") Long memberId, @Param("studiedAt") LocalDate studiedAt);
+
+    List<StudyTime> findByMemberIdAndStudiedAtBetween(Long memberId, LocalDate start, LocalDate end);
+}
diff --git a/src/main/java/com/studypals/domain/studyManage/entity/StudyCategory.java b/src/main/java/com/studypals/domain/studyManage/entity/StudyCategory.java
new file mode 100644
index 00000000..0b54bcb5
--- /dev/null
+++ b/src/main/java/com/studypals/domain/studyManage/entity/StudyCategory.java
@@ -0,0 +1,58 @@
+package com.studypals.domain.studyManage.entity;
+
+import jakarta.persistence.*;
+
+import lombok.*;
+
+import com.studypals.domain.memberManage.entity.Member;
+
+/**
+ * 공부 카테고리에 대한 엔티티입니다. JPA 에 의해 관리됩니다.
+ * <p>
+ * 다음과 같은 필드를 가지고 있습니다.
+ * <pre>
+ *     {@code
+ * Long id;
+ * Member member;
+ * Integer dayBelong;
+ * String color;
+ * String description;
+ *     }
+ * </pre>
+ *
+ * <p><b>주요 생성자:</b><br>
+ * {@code Builder}  <br>
+ * builder 패턴을 통해 생성합니다. <br>
+ *
+ * @author jack8
+ * @since 2025-04-10
+ */
+@Entity
+@Builder
+@Getter
+@AllArgsConstructor
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Table(name = "study_category")
+public class StudyCategory {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    @Column(name = "id", nullable = false, unique = true)
+    private Long id;
+
+    @ManyToOne(fetch = FetchType.LAZY)
+    @JoinColumn(name = "member_id")
+    private Member member;
+
+    @Column(name = "name", nullable = false, length = 255)
+    private String name;
+
+    @Column(name = "day_belong", nullable = false, columnDefinition = "INTEGER")
+    private Integer dayBelong;
+
+    @Column(name = "color", nullable = true, length = 9)
+    private String color;
+
+    @Column(name = "description", nullable = true, columnDefinition = "TEXT")
+    private String description;
+}
diff --git a/src/main/java/com/studypals/domain/studyManage/entity/StudyStatus.java b/src/main/java/com/studypals/domain/studyManage/entity/StudyStatus.java
new file mode 100644
index 00000000..2f7e2ab7
--- /dev/null
+++ b/src/main/java/com/studypals/domain/studyManage/entity/StudyStatus.java
@@ -0,0 +1,46 @@
+package com.studypals.domain.studyManage.entity;
+
+import java.time.LocalTime;
+import java.util.concurrent.TimeUnit;
+
+import jakarta.persistence.Id;
+
+import org.springframework.data.redis.core.RedisHash;
+import org.springframework.data.redis.core.TimeToLive;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+
+/**
+ * 현재 유저의 공부 상태 및 하루 간 공부 총합을 저장합니다. redis에 의해 관리됩니다.
+ * <p>
+ * 다음과 같은 필드를 가지고 있습니다.
+ * <pre>
+ *     {@code
+ * Long id;
+ * boolean studying;
+ * LocalTime startTime;
+ * Long studyTime;
+ * Long expiration;
+ *     }
+ * </pre>
+ *
+ * @author jack8
+ * @since 2025-04-10
+ */
+@RedisHash("studyStatus")
+@AllArgsConstructor
+@Builder
+@Getter
+public class StudyStatus {
+    @Id
+    private Long id;
+
+    private boolean studying;
+    private LocalTime startTime;
+    private Long studyTime;
+
+    @TimeToLive(unit = TimeUnit.DAYS)
+    private Long expiration;
+}
diff --git a/src/main/java/com/studypals/domain/studyManage/entity/StudyTime.java b/src/main/java/com/studypals/domain/studyManage/entity/StudyTime.java
new file mode 100644
index 00000000..07619fbb
--- /dev/null
+++ b/src/main/java/com/studypals/domain/studyManage/entity/StudyTime.java
@@ -0,0 +1,72 @@
+package com.studypals.domain.studyManage.entity;
+
+import java.time.LocalDate;
+
+import jakarta.persistence.*;
+
+import lombok.*;
+import org.springframework.dao.DataIntegrityViolationException;
+
+import com.studypals.domain.memberManage.entity.Member;
+
+/**
+ * 공부 시간에 대한 엔티티입니다. JPA 에 의해 관리됩니다.
+ * <p>
+ * 다음과 같은 필드를 가지고 있습니다.
+ * <pre>
+ *     {@code
+ * Long id;
+ * Member member;
+ * StudyCategory studyCategory;
+ * LocalDate studiedAt;
+ * Long time;
+ *     }
+ * </pre>
+ * temporaryName 은 사용자가 category 에 포함되지 않은 내용을
+ * 임시로 사용할 때 부여되는 이름입니다. 만약 category 가 null 인 경우, 해당
+ * 이름을 반환합니다. 둘 중 하나의 값이 존재해야 한다는 강제성을 부여하였습니다.
+ * <p><b>주요 생성자:</b><br>
+ * {@code Builder}  <br>
+ * builder 패턴을 통해 생성합니다. <br>
+ *
+ * @author jack8
+ * @since 2025-04-10
+ */
+@Entity
+@Builder
+@Getter
+@AllArgsConstructor
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Table(name = "study_time")
+public class StudyTime {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    @Column(name = "id", nullable = false, unique = true)
+    private Long id;
+
+    @ManyToOne(fetch = FetchType.LAZY)
+    @JoinColumn(name = "member_id")
+    private Member member;
+
+    @ManyToOne(fetch = FetchType.LAZY)
+    @JoinColumn(name = "category_id", nullable = true)
+    private StudyCategory category;
+
+    @Column(name = "temporary_name", nullable = true, length = 255)
+    private String temporaryName;
+
+    @Column(name = "studied_at", nullable = false)
+    private LocalDate studiedAt;
+
+    @Column(name = "time", nullable = false)
+    private Long time;
+
+    @PrePersist
+    @PreUpdate
+    private void validateTemporaryOrCategory() {
+        if (this.temporaryName == null && this.category == null) {
+            throw new DataIntegrityViolationException("must have value temporary name or category");
+        }
+    }
+}
diff --git a/src/main/java/com/studypals/global/redis/RedisConfig.java b/src/main/java/com/studypals/global/redis/RedisConfig.java
index f98e3d97..81aae977 100644
--- a/src/main/java/com/studypals/global/redis/RedisConfig.java
+++ b/src/main/java/com/studypals/global/redis/RedisConfig.java
@@ -11,6 +11,7 @@
 import org.springframework.data.redis.serializer.StringRedisSerializer;
 
 import com.studypals.domain.memberManage.dao.RefreshTokenRedisRepository;
+import com.studypals.domain.studyManage.dao.StudyStatusRedisRepository;
 
 /**
  * redis에 대한 config 입니다.
@@ -24,7 +25,7 @@
  * @since 2025-04-04
  */
 @Configuration
-@EnableRedisRepositories(basePackageClasses = {RefreshTokenRedisRepository.class})
+@EnableRedisRepositories(basePackageClasses = {RefreshTokenRedisRepository.class, StudyStatusRedisRepository.class})
 public class RedisConfig {
 
     @Value("${spring.data.redis.host}")
diff --git a/src/main/java/com/studypals/global/security/jwt/JwtToken.java b/src/main/java/com/studypals/global/security/jwt/JwtToken.java
index a881e1eb..5fa17f41 100644
--- a/src/main/java/com/studypals/global/security/jwt/JwtToken.java
+++ b/src/main/java/com/studypals/global/security/jwt/JwtToken.java
@@ -1,9 +1,6 @@
 package com.studypals.global.security.jwt;
 
-import lombok.AllArgsConstructor;
-import lombok.Builder;
-import lombok.Getter;
-import lombok.NoArgsConstructor;
+import lombok.*;
 
 /**
  * JwtToken 에 대한 데이터를 담는 객체입니다.
@@ -19,7 +16,7 @@
  */
 @Builder
 @AllArgsConstructor
-@NoArgsConstructor
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
 @Getter
 public class JwtToken {
     private String grantType;
diff --git a/src/test/java/com/studypals/domain/studyManage/dao/StudyCategoryRepositoryTest.java b/src/test/java/com/studypals/domain/studyManage/dao/StudyCategoryRepositoryTest.java
new file mode 100644
index 00000000..8169568f
--- /dev/null
+++ b/src/test/java/com/studypals/domain/studyManage/dao/StudyCategoryRepositoryTest.java
@@ -0,0 +1,70 @@
+package com.studypals.domain.studyManage.dao;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.List;
+import java.util.stream.IntStream;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
+import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
+import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
+
+import com.studypals.domain.memberManage.entity.Member;
+import com.studypals.domain.studyManage.entity.StudyCategory;
+
+/**
+ *
+ * @author jack8
+ * @since 2025-04-10
+ */
+@DataJpaTest
+@DisplayName("StudyCategory_JPA_test")
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+class StudyCategoryRepositoryTest {
+
+    @Autowired
+    private TestEntityManager em;
+
+    @Autowired
+    private StudyCategoryRepository studyCategoryRepository;
+
+    private Member insertMember() {
+        return em.persist(Member.builder()
+                .username("username")
+                .password("password")
+                .nickname("nickname")
+                .build());
+    }
+
+    private StudyCategory make(Member member, String name) {
+        return StudyCategory.builder()
+                .name(name)
+                .member(member)
+                .color("color")
+                .dayBelong(12)
+                .description("description")
+                .build();
+    }
+
+    @Test
+    public void findByMemberId_success() {
+        // given
+        Member member = insertMember();
+        List<StudyCategory> categories = IntStream.range(1, 10)
+                .mapToObj(i -> make(member, "category " + i))
+                .toList();
+        List<String> expectedName =
+                categories.stream().map(StudyCategory::getName).toList();
+        studyCategoryRepository.saveAll(categories);
+
+        // when
+        List<StudyCategory> finded = studyCategoryRepository.findByMemberId(member.getId());
+
+        // then
+        List<String> actualName = finded.stream().map(StudyCategory::getName).toList();
+        assertThat(actualName).containsExactlyInAnyOrderElementsOf(expectedName);
+    }
+}
diff --git a/src/test/java/com/studypals/domain/studyManage/dao/StudyStatusRedisRepositoryTest.java b/src/test/java/com/studypals/domain/studyManage/dao/StudyStatusRedisRepositoryTest.java
new file mode 100644
index 00000000..65157de3
--- /dev/null
+++ b/src/test/java/com/studypals/domain/studyManage/dao/StudyStatusRedisRepositoryTest.java
@@ -0,0 +1,76 @@
+package com.studypals.domain.studyManage.dao;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.time.LocalTime;
+import java.util.Objects;
+import java.util.Optional;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.data.redis.DataRedisTest;
+import org.springframework.data.redis.core.StringRedisTemplate;
+
+import com.studypals.domain.studyManage.entity.StudyStatus;
+
+/**
+ * {@link StudyStatusRedisRepository} 에 대한 redis 테스트
+ *
+ * @author jack8
+ * @since 2025-04-10
+ */
+@DataRedisTest
+class StudyStatusRedisRepositoryTest {
+    @Autowired
+    private StudyStatusRedisRepository studyStatusRedisRepository;
+
+    @Autowired
+    private StringRedisTemplate redisTemplate;
+
+    @AfterEach
+    void tearDown() {
+        Objects.requireNonNull(redisTemplate.getConnectionFactory())
+                .getConnection()
+                .serverCommands()
+                .flushAll();
+    }
+
+    @Test
+    void save_success() {
+        // given
+        StudyStatus status = StudyStatus.builder()
+                .id(1L)
+                .startTime(LocalTime.of(10, 0))
+                .studyTime(120L)
+                .expiration(1L)
+                .build();
+
+        // when
+        studyStatusRedisRepository.save(status);
+        Optional<StudyStatus> result = studyStatusRedisRepository.findById(1L);
+
+        // then
+        assertThat(result).isPresent();
+        assertThat(result.get().getStudyTime()).isEqualTo(120L);
+    }
+
+    @Test
+    void delete_success() {
+        // given
+        StudyStatus status = StudyStatus.builder()
+                .id(2L)
+                .startTime(LocalTime.of(12, 0))
+                .studyTime(90L)
+                .expiration(1L)
+                .build();
+        studyStatusRedisRepository.save(status);
+
+        // when
+        studyStatusRedisRepository.deleteById(2L);
+        Optional<StudyStatus> result = studyStatusRedisRepository.findById(2L);
+
+        // then
+        assertThat(result).isEmpty();
+    }
+}
diff --git a/src/test/java/com/studypals/domain/studyManage/dao/StudyTimeRepositoryTest.java b/src/test/java/com/studypals/domain/studyManage/dao/StudyTimeRepositoryTest.java
new file mode 100644
index 00000000..bb36582d
--- /dev/null
+++ b/src/test/java/com/studypals/domain/studyManage/dao/StudyTimeRepositoryTest.java
@@ -0,0 +1,168 @@
+package com.studypals.domain.studyManage.dao;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.time.LocalDate;
+import java.util.List;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
+import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
+import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
+import org.springframework.dao.DataIntegrityViolationException;
+
+import com.studypals.domain.memberManage.entity.Member;
+import com.studypals.domain.studyManage.entity.StudyTime;
+
+/**
+ * {@link StudyTimeRepository} 에 대한 테스트 클래스
+ *
+ * @author jack8
+ * @since 2025-04-10
+ */
+@DataJpaTest
+@DisplayName("StudyTime_JPA_test")
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+class StudyTimeRepositoryTest {
+
+    @Autowired
+    private TestEntityManager em;
+
+    @Autowired
+    private StudyTimeRepository studyTimeRepository;
+
+    private Member insertMember() {
+        return em.persist(Member.builder()
+                .username("username")
+                .password("password")
+                .nickname("nickname")
+                .build());
+    }
+
+    private StudyTime make(Member member, String temporaryName, LocalDate date, Long time) {
+        return StudyTime.builder()
+                .temporaryName(temporaryName)
+                .member(member)
+                .studiedAt(date)
+                .time(time)
+                .build();
+    }
+
+    @Test
+    void save_fail_bothNameNull() {
+        // given
+        Member member = insertMember();
+        LocalDate date = LocalDate.of(1999, 8, 20);
+        StudyTime studyTime = StudyTime.builder().member(member).studiedAt(date).build();
+
+        // when & than
+        assertThatThrownBy(() -> studyTimeRepository.save(studyTime))
+                .isInstanceOf(DataIntegrityViolationException.class)
+                .hasMessageContaining("must have value temporary name or category");
+    }
+
+    @Test
+    void findByMemberIdAndStudiedAt_success() {
+        // given
+        Member member = insertMember();
+        LocalDate date = LocalDate.of(2024, 4, 10);
+
+        em.persist(make(member, "temp1", date, 100L));
+        em.persist(make(member, "temp2", date, 100L));
+        em.persist(make(member, "temp3", date, 100L));
+        em.persist(make(member, "temp4", date.plusDays(1), 100L)); // 다른 날
+
+        em.flush();
+        em.clear();
+
+        // when
+        List<StudyTime> results = studyTimeRepository.findByMemberIdAndStudiedAt(member.getId(), date);
+
+        // then
+        assertThat(results)
+                .hasSize(3)
+                .extracting(StudyTime::getTemporaryName)
+                .containsExactlyInAnyOrder("temp1", "temp2", "temp3");
+    }
+
+    @Test
+    void sumTimeByMemberAndDate_success() {
+        // given
+        Member member = insertMember();
+        LocalDate date = LocalDate.of(2024, 4, 10);
+
+        em.persist(make(member, "t1", date, 30L));
+        em.persist(make(member, "t2", date, 45L));
+        em.persist(make(member, "t3", date, 10L));
+
+        em.flush();
+        em.clear();
+
+        // when
+        Long total = studyTimeRepository.sumTimeByMemberAndDate(member.getId(), date);
+
+        // then
+        assertThat(total).isEqualTo(85L);
+    }
+
+    @Test
+    void sumTimeByMemberAndDate_success_return0() {
+        // given
+        Member member = insertMember();
+        LocalDate emptyDate = LocalDate.of(2024, 4, 11);
+
+        em.flush();
+        em.clear();
+
+        // when
+        Long result = studyTimeRepository.sumTimeByMemberAndDate(member.getId(), emptyDate);
+
+        // then
+        assertThat(result).isEqualTo(0L);
+    }
+
+    @Test
+    @DisplayName("findByMemberIdAndStudiedAtBetween - 특정 기간 내 데이터 조회")
+    void findByMemberIdAndStudiedAtBetween_returnsAllInRange() {
+        // given
+        Member member = insertMember();
+        LocalDate april = LocalDate.of(2024, 4, 1);
+
+        em.persist(make(member, "day1", april, 100L));
+        em.persist(make(member, "day2", april.plusDays(1), 100L));
+        em.persist(make(member, "day3", april.plusDays(2), 100L));
+        em.persist(make(member, "day4", april.plusDays(10), 100L));
+        em.persist(make(member, "day5", april.plusDays(20), 100L));
+        em.persist(make(member, "day6", april.plusDays(29), 100L));
+
+        em.flush();
+        em.clear();
+
+        // when
+        List<StudyTime> results =
+                studyTimeRepository.findByMemberIdAndStudiedAtBetween(member.getId(), april, april.plusDays(30));
+
+        // then
+        assertThat(results).hasSize(6);
+    }
+
+    @Test
+    void findByMemberIdAndStudiedAtBetween_success_return0() {
+        // given
+        Member member = insertMember();
+        LocalDate march = LocalDate.of(2024, 3, 1);
+
+        em.flush();
+        em.clear();
+
+        // when
+        List<StudyTime> results =
+                studyTimeRepository.findByMemberIdAndStudiedAtBetween(member.getId(), march, march.plusDays(30));
+
+        // then
+        assertThat(results).isEmpty();
+    }
+}


Reviews:
user: login: s0o0bn
body: LGTM 👍

여기서는 크게 리뷰 남길 부분이 없는 것 같습니다. 좋습니다~
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T05:16:51Z
user: login: unikal1
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T05:21:56Z
user: login: unikal1
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T05:22:03Z
user: login: unikal1
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T05:29:26Z
user: login: s0o0bn
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T05:31:26Z
user: login: s0o0bn
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T05:31:36Z

Review comments:
diff_hunk: @@ -0,0 +1,61 @@
+package com.studypals.domain.studyManage.entity;
+
+import jakarta.persistence.*;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import com.studypals.domain.memberManage.entity.Member;
+
+/**
+ * 공부 카테고리에 대한 엔티티입니다. JPA 에 의해 관리됩니다.
+ * <p>
+ * 다음과 같은 필드를 가지고 있습니다.
+ * <pre>
+ *     {@code
+ * Long id;
+ * Member member;
+ * Integer dayBelong;
+ * String color;
+ * String description;
+ *     }
+ * </pre>
+ *
+ * <p><b>주요 생성자:</b><br>
+ * {@code Builder}  <br>
+ * builder 패턴을 통해 생성합니다. <br>
+ *
+ * @author jack8
+ * @since 2025-04-10
+ */
+@Entity
+@Builder
+@Getter
+@AllArgsConstructor
+@NoArgsConstructor
path: src/main/java/com/studypals/domain/studyManage/entity/StudyCategory.java
user: login: s0o0bn
body: [P3]

최근에 프로젝트 정리하며 알게 된 사실인데, 이전 프로젝트에선 해당 생성자의 접근 권한을 `protected`로 했던 것을 발견했습니다.
그때 왜 그렇게 했는지 다시 찾아보니 기본 생성자의 사용을 제한함으로써 캡슐화를 지키고, 객체의 무분별한 생성을 막을 수 있다는 점에서 접근 권한을 제한한다고 합니다.
이때 `private`이 아닌 `protected`인 이유는 JPA가 기본 생성자를 통해 프록시 객체를 생성해 사용하기 때문이라고 합니다.

이것도 지난번 멤버 관련 PR에서 놓쳤던 부분인데 지금이라도 코멘트 남겨봅니다.

```suggestion
@NoArgsConstructor(access = AccessLevel.PROTECTED)
```
created_at: 2025-04-11T05:07:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,76 @@
+package com.studypals.domain.studyManage.entity;
+
+import java.time.LocalDate;
+
+import jakarta.persistence.*;
+
+import org.springframework.dao.DataIntegrityViolationException;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import com.studypals.domain.memberManage.entity.Member;
+
+/**
+ * 공부 시간에 대한 엔티티입니다. JPA 에 의해 관리됩니다.
+ * <p>
+ * 다음과 같은 필드를 가지고 있습니다.
+ * <pre>
+ *     {@code
+ * Long id;
+ * Member member;
+ * StudyCategory studyCategory;
+ * LocalDate studiedAt;
+ * Long time;
+ *     }
+ * </pre>
+ * temporaryName 은 사용자가 category 에 포함되지 않은 내용을
+ * 임시로 사용할 때 부여되는 이름입니다. 만약 category 가 null 인 경우, 해당
+ * 이름을 반환합니다. 둘 중 하나의 값이 존재해야 한다는 강제성을 부여하였습니다.
+ * <p><b>주요 생성자:</b><br>
+ * {@code Builder}  <br>
+ * builder 패턴을 통해 생성합니다. <br>
+ *
+ * @author jack8
+ * @since 2025-04-10
+ */
+@Entity
+@Builder
+@Getter
+@AllArgsConstructor
+@NoArgsConstructor
+@Table(name = "study_time")
+public class StudyTime {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    @Column(name = "id", nullable = false, unique = true)
+    private Long id;
+
+    @ManyToOne(fetch = FetchType.LAZY)
+    @JoinColumn(name = "member_id")
+    private Member member;
+
+    @ManyToOne(fetch = FetchType.LAZY)
+    @JoinColumn(name = "category_id", nullable = true)
+    private StudyCategory category;
+
+    @Column(name = "temporary_name", nullable = true, length = 255)
+    private String temporaryName;
+
+    @Column(name = "studied_at", nullable = false)
+    private LocalDate studiedAt;
+
+    @Column(name = "time", nullable = false)
+    private Long time;
+
+    @PrePersist
+    @PreUpdate
+    private void validateTemporaryOrCategory() {
+        if (this.temporaryName == null && this.category == null) {
+            throw new DataIntegrityViolationException("must have value temporary name or category");
+        }
+    }
path: src/main/java/com/studypals/domain/studyManage/entity/StudyTime.java
user: login: s0o0bn
body: 오 이렇게 엔티티에서 생성 및 수정 전에 직접 검증하는 거 좋네요.
created_at: 2025-04-11T05:10:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,34 @@
+package com.studypals.domain.studyManage.dao;
+
+import java.time.LocalDate;
+import java.util.List;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import com.studypals.domain.studyManage.entity.StudyTime;
+
+/**
+ * {@link StudyTime} 에 대한 JPA DAO 클래스입니다.
+ *
+ *
+ * @author jack8
+ * @since 2025-04-10
+ */
+@Repository
+public interface StudyTimeRepository extends JpaRepository<StudyTime, Long> {
+
+    List<StudyTime> findByMemberIdAndStudiedAt(Long memberId, LocalDate studiedAt);
+
+    @Query(
+            """
+        SELECT COALESCE(SUM(s.time), 0)
+        FROM StudyTime s
+        WHERE s.member.id = :memberId AND s.studiedAt = :studiedAt
+        """)
path: src/main/java/com/studypals/domain/studyManage/dao/StudyTimeRepository.java
user: login: s0o0bn
body: [Just Ask]

혹시 보통 어느 정도로 쿼리가 복잡해질 때 QueryDsl을 사용할 지에 대한 기준을 어떻게 두시는 편인가요?
created_at: 2025-04-11T05:15:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,34 @@
+package com.studypals.domain.studyManage.dao;
+
+import java.time.LocalDate;
+import java.util.List;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import com.studypals.domain.studyManage.entity.StudyTime;
+
+/**
+ * {@link StudyTime} 에 대한 JPA DAO 클래스입니다.
+ *
+ *
+ * @author jack8
+ * @since 2025-04-10
+ */
+@Repository
+public interface StudyTimeRepository extends JpaRepository<StudyTime, Long> {
+
+    List<StudyTime> findByMemberIdAndStudiedAt(Long memberId, LocalDate studiedAt);
+
+    @Query(
+            """
+        SELECT COALESCE(SUM(s.time), 0)
+        FROM StudyTime s
+        WHERE s.member.id = :memberId AND s.studiedAt = :studiedAt
+        """)
path: src/main/java/com/studypals/domain/studyManage/dao/StudyTimeRepository.java
user: login: unikal1
body: 음, 사실 딱히 기준이라고 잡을 건 없고, 쿼리문을 직접 작성했을 때, 그 흐름이 한 눈에 안보이면 사용하는 편입니다. QueryDsl 을 사용하기로 통일하길 원하시면 해당 부분을 바꿀까요?
created_at: 2025-04-11T05:21:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,76 @@
+package com.studypals.domain.studyManage.entity;
+
+import java.time.LocalDate;
+
+import jakarta.persistence.*;
+
+import org.springframework.dao.DataIntegrityViolationException;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import com.studypals.domain.memberManage.entity.Member;
+
+/**
+ * 공부 시간에 대한 엔티티입니다. JPA 에 의해 관리됩니다.
+ * <p>
+ * 다음과 같은 필드를 가지고 있습니다.
+ * <pre>
+ *     {@code
+ * Long id;
+ * Member member;
+ * StudyCategory studyCategory;
+ * LocalDate studiedAt;
+ * Long time;
+ *     }
+ * </pre>
+ * temporaryName 은 사용자가 category 에 포함되지 않은 내용을
+ * 임시로 사용할 때 부여되는 이름입니다. 만약 category 가 null 인 경우, 해당
+ * 이름을 반환합니다. 둘 중 하나의 값이 존재해야 한다는 강제성을 부여하였습니다.
+ * <p><b>주요 생성자:</b><br>
+ * {@code Builder}  <br>
+ * builder 패턴을 통해 생성합니다. <br>
+ *
+ * @author jack8
+ * @since 2025-04-10
+ */
+@Entity
+@Builder
+@Getter
+@AllArgsConstructor
+@NoArgsConstructor
+@Table(name = "study_time")
+public class StudyTime {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    @Column(name = "id", nullable = false, unique = true)
+    private Long id;
+
+    @ManyToOne(fetch = FetchType.LAZY)
+    @JoinColumn(name = "member_id")
+    private Member member;
+
+    @ManyToOne(fetch = FetchType.LAZY)
+    @JoinColumn(name = "category_id", nullable = true)
+    private StudyCategory category;
+
+    @Column(name = "temporary_name", nullable = true, length = 255)
+    private String temporaryName;
+
+    @Column(name = "studied_at", nullable = false)
+    private LocalDate studiedAt;
+
+    @Column(name = "time", nullable = false)
+    private Long time;
+
+    @PrePersist
+    @PreUpdate
+    private void validateTemporaryOrCategory() {
+        if (this.temporaryName == null && this.category == null) {
+            throw new DataIntegrityViolationException("must have value temporary name or category");
+        }
+    }
path: src/main/java/com/studypals/domain/studyManage/entity/StudyTime.java
user: login: unikal1
body: :)
created_at: 2025-04-11T05:22:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,61 @@
+package com.studypals.domain.studyManage.entity;
+
+import jakarta.persistence.*;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import com.studypals.domain.memberManage.entity.Member;
+
+/**
+ * 공부 카테고리에 대한 엔티티입니다. JPA 에 의해 관리됩니다.
+ * <p>
+ * 다음과 같은 필드를 가지고 있습니다.
+ * <pre>
+ *     {@code
+ * Long id;
+ * Member member;
+ * Integer dayBelong;
+ * String color;
+ * String description;
+ *     }
+ * </pre>
+ *
+ * <p><b>주요 생성자:</b><br>
+ * {@code Builder}  <br>
+ * builder 패턴을 통해 생성합니다. <br>
+ *
+ * @author jack8
+ * @since 2025-04-10
+ */
+@Entity
+@Builder
+@Getter
+@AllArgsConstructor
+@NoArgsConstructor
path: src/main/java/com/studypals/domain/studyManage/entity/StudyCategory.java
user: login: unikal1
body: 좋네요~ 수정했습니다. 
JwtToken ,Member ,StudyCategory, StudyTime 의 NoArgsConstructor을 protected로 변경하였습니다.
created_at: 2025-04-11T05:29:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,34 @@
+package com.studypals.domain.studyManage.dao;
+
+import java.time.LocalDate;
+import java.util.List;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import com.studypals.domain.studyManage.entity.StudyTime;
+
+/**
+ * {@link StudyTime} 에 대한 JPA DAO 클래스입니다.
+ *
+ *
+ * @author jack8
+ * @since 2025-04-10
+ */
+@Repository
+public interface StudyTimeRepository extends JpaRepository<StudyTime, Long> {
+
+    List<StudyTime> findByMemberIdAndStudiedAt(Long memberId, LocalDate studiedAt);
+
+    @Query(
+            """
+        SELECT COALESCE(SUM(s.time), 0)
+        FROM StudyTime s
+        WHERE s.member.id = :memberId AND s.studiedAt = :studiedAt
+        """)
path: src/main/java/com/studypals/domain/studyManage/dao/StudyTimeRepository.java
user: login: s0o0bn
body: 엇 아뇨 저도 뭔가 최근에 `@Query`로도 충분한 건 꼭 querydsl이 아니어도 괜찮지 않을까 하는 생각이 들어서 여쭤봤습니다.
지금도 괜찮을 거 같습니다~
created_at: 2025-04-11T05:31:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,61 @@
+package com.studypals.domain.studyManage.entity;
+
+import jakarta.persistence.*;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import com.studypals.domain.memberManage.entity.Member;
+
+/**
+ * 공부 카테고리에 대한 엔티티입니다. JPA 에 의해 관리됩니다.
+ * <p>
+ * 다음과 같은 필드를 가지고 있습니다.
+ * <pre>
+ *     {@code
+ * Long id;
+ * Member member;
+ * Integer dayBelong;
+ * String color;
+ * String description;
+ *     }
+ * </pre>
+ *
+ * <p><b>주요 생성자:</b><br>
+ * {@code Builder}  <br>
+ * builder 패턴을 통해 생성합니다. <br>
+ *
+ * @author jack8
+ * @since 2025-04-10
+ */
+@Entity
+@Builder
+@Getter
+@AllArgsConstructor
+@NoArgsConstructor
path: src/main/java/com/studypals/domain/studyManage/entity/StudyCategory.java
user: login: s0o0bn
body: 반영 감사합니다~!
created_at: 2025-04-11T05:31:36Z
author_association: CONTRIBUTOR

PR comments:
user: login: unikal1
created_at: 2025-04-11T05:30:18Z
body: 변경 완료 했고 QueryDsl 관련하여 추가적인 의견이 없으시면 확인 후 merge 부탁드립니다.
author_association: CONTRIBUTOR
user: login: s0o0bn
created_at: 2025-04-11T05:33:21Z
body: 브랜치가 현재 PR과 다르게 올라간 거 같은데 확인 부탁드립니다~
author_association: CONTRIBUTOR
user: login: unikal1
created_at: 2025-04-11T05:36:50Z
body: 아 ㅋㅋ 적용했습니다~
author_association: CONTRIBUTOR