Skip to content

[feat] 관리자 페이지 회원 리스트 검색/필터 기능 수정 및 질문 관리 페이지 개선#385

Merged
sgo722 merged 6 commits intodevelopfrom
feature/#384
Jan 12, 2026
Merged

[feat] 관리자 페이지 회원 리스트 검색/필터 기능 수정 및 질문 관리 페이지 개선#385
sgo722 merged 6 commits intodevelopfrom
feature/#384

Conversation

@sgo722
Copy link
Contributor

@sgo722 sgo722 commented Jan 10, 2026

PR의 목적이 무엇인가요?

관리자 페이지의 회원 리스트 및 질문 관리 페이지 필터링 기능 개선.
이름 검색, 날짜 필터, 상태 필터의 정확도를 향상시키고, 필터 상태 유지 및 시간대 일관성 문제를 해결하여 관리자의 사용 편의성을 크게 개선.

이슈 ID는 무엇인가요?

설명

📋 변경 사항

1. 회원 리스트 이름 검색 정확도 개선

문제: 이름 검색 시 이메일, 가입일 등 다른 컬럼까지 검색되어 부정확한 결과 반환
해결: codeName 필드만 검색하도록 쿼리 수정

변경 파일:

  • src/main/kotlin/codel/member/infrastructure/MemberJpaRepository.kt
// Before
WHERE ... AND (
  :keyword IS NULL OR :keyword = ''
  OR LOWER(m.email) LIKE LOWER(CONCAT('%', :keyword, '%'))  // ❌ 제거
  OR LOWER(p.codeName) LIKE LOWER(CONCAT('%', :keyword, '%'))
)

// After
WHERE ... AND (
  :keyword IS NULL OR :keyword = ''
  OR LOWER(p.codeName) LIKE LOWER(CONCAT('%', :keyword, '%'))  // ✅ codeName만
)

2. 회원 가입일 범위 필터링 기능 추가

문제: 시작일/종료일 필터가 UI에만 존재하고 실제로 동작하지 않음
해결: Service → Repository로 날짜 파라미터 전달 및 쿼리 조건 추가

변경 파일:

  • src/main/kotlin/codel/member/infrastructure/MemberJpaRepository.kt
  • src/main/kotlin/codel/member/business/MemberService.kt
// MemberJpaRepository.kt
@Query("""
    SELECT m FROM Member m JOIN FETCH m.profile p
    WHERE (:status IS NULL OR m.memberStatus = :status)
      AND (:keyword IS NULL OR :keyword = '' OR LOWER(p.codeName) LIKE LOWER(CONCAT('%', :keyword, '%')))
      AND (:startDate IS NULL OR m.createdAt >= :startDate)  // ✅ 추가
      AND (:endDate IS NULL OR m.createdAt < :endDate)       // ✅ 추가
""")
fun findMembersWithFilterAdvanced(
    @Param("keyword") keyword: String?,
    @Param("status") status: MemberStatus?,
    @Param("startDate") startDate: LocalDateTime?,  // ✅ 추가
    @Param("endDate") endDate: LocalDateTime?,      // ✅ 추가
    pageable: Pageable
): Page<Member>

3. WITHDRAWN/PERSONALITY_COMPLETED 상태 필터 추가

문제: 탈퇴 회원 및 오픈프로필 작성 완료 회원을 필터링할 수 없음
해결: Backend 상태 집계 추가 및 Frontend UI 요소 추가

변경 파일:

  • src/main/kotlin/codel/admin/presentation/AdminController.kt
  • src/main/resources/templates/memberList.html

Backend 수정:

val statusCounts = mapOf(
    "total" to adminService.countAllMembers(),
    "PENDING" to adminService.countMembersByStatus("PENDING"),
    "DONE" to adminService.countMembersByStatus("DONE"),
    "REJECT" to adminService.countMembersByStatus("REJECT"),
    "PHONE_VERIFIED" to adminService.countMembersByStatus("PHONE_VERIFIED"),
    "WITHDRAWN" to adminService.countMembersByStatus("WITHDRAWN"),  // ✅ 추가
    "PERSONALITY_COMPLETED" to adminService.countMembersByStatus("PERSONALITY_COMPLETED")  // ✅ 추가
)

Frontend 추가 요소:

  • 통계 카드에 탈퇴 회원 수 표시
  • 필터 select box에 "탈퇴", "오픈프로필 작성 완료" 옵션 추가
  • 탭에 해당 상태 버튼 추가
  • 테이블에 상태 배지 추가

4. 질문 관리 페이지 필터 상태 유지

문제: 질문 수정/토글/삭제 후 필터가 초기화되어 반복 작업 시 불편
해결: 필터 파라미터를 hidden input으로 전달하고 리다이렉트 시 쿼리스트링에 포함

변경 파일:

  • src/main/kotlin/codel/admin/presentation/AdminController.kt
  • src/main/resources/templates/questionList.html
  • src/main/resources/templates/questionEditForm.html

Controller 수정:

@PostMapping("/v1/admin/questions/{questionId}")
fun updateQuestion(
    @PathVariable questionId: Long,
    @RequestParam content: String,
    @RequestParam category: String,
    @RequestParam(required = false) description: String?,
    @RequestParam(defaultValue = "false") isActive: Boolean,
    @RequestParam(required = false) keyword: String?,          // ✅ 추가
    @RequestParam(required = false) filterCategory: String?,   // ✅ 추가
    @RequestParam(required = false) filterIsActive: String?,   // ✅ 추가
    @RequestParam(required = false, defaultValue = "0") page: Int,    // ✅ 추가
    @RequestParam(required = false, defaultValue = "20") size: Int,   // ✅ 추가
    redirectAttributes: RedirectAttributes
): String {
    // ... 질문 수정 로직 ...

    // 필터 조건 유지하여 리다이렉트
    val params = mutableListOf<String>()
    keyword?.let { if (it.isNotBlank()) params.add("keyword=$it") }
    filterCategory?.let { if (it.isNotBlank()) params.add("category=$it") }
    filterIsActive?.let { if (it.isNotBlank()) params.add("isActive=$it") }
    params.add("page=$page")
    params.add("size=$size")

    val queryString = if (params.isNotEmpty()) "?${params.joinToString("&")}" else ""
    return "redirect:/v1/admin/questions$queryString"
}

Template 수정:

<!-- questionList.html - 수정 버튼 -->
<a th:href="@{/v1/admin/questions/{id}/edit(
       id=${question.id},
       keyword=${param.keyword},
       category=${param.category},
       isActive=${param.isActive},
       page=${questions.number},
       size=${questions.size}
   )}">수정</a>

<!-- questionList.html - 토글/삭제 form -->
<form th:action="@{/v1/admin/questions/{id}/toggle(id=${question.id})}" method="post">
    <input type="hidden" name="keyword" th:value="${param.keyword}">
    <input type="hidden" name="category" th:value="${param.category}">
    <input type="hidden" name="isActive" th:value="${param.isActive}">
    <input type="hidden" name="page" th:value="${questions.number}">
    <input type="hidden" name="size" th:value="${questions.size}">
    <button type="submit">토글</button>
</form>

5. 질문 관리 페이지 필터 선택 값 UI 표시

문제: 검색 후 선택한 필터가 select box에 표시되지 않아 현재 상태 확인 불가
원인: Controller가 param이라는 model attribute를 추가했으나 Thymeleaf 기본 객체 param과 충돌
해결: 명확한 이름(selectedKeyword, selectedCategory, selectedIsActive)으로 변경

변경 파일:

  • src/main/kotlin/codel/admin/presentation/AdminController.kt
  • src/main/resources/templates/questionList.html

Controller 수정:

// Before
model.addAttribute("param", mapOf(  // ❌ Thymeleaf param 객체와 충돌
    "keyword" to (keyword ?: ""),
    "category" to (category ?: ""),
    "isActive" to (isActive?.toString() ?: "")
))

// After
model.addAttribute("selectedKeyword", keyword ?: "")       // ✅ 명확한 이름
model.addAttribute("selectedCategory", category ?: "")     // ✅ 명확한 이름
model.addAttribute("selectedIsActive", isActive?.toString() ?: "")  // ✅ 명확한 이름

Template 수정:

<!-- Before -->
<select name="category">
    <option th:selected="${param.category == cat.name}">...</option>
</select>

<!-- After -->
<select name="category">
    <option th:selected="${selectedCategory == cat.name}">...</option>
</select>

6. 회원 가입 시간 KST 표시 및 날짜 검색 KST 기준 동작

문제:

  • 가입 시간이 UTC로 표시되어 한국 관리자가 보기에 9시간 빠름
  • 날짜 검색도 UTC 기준으로 동작하여 KST 날짜와 불일치

해결:

  • 표시: DateTimeFormatter.convertUtcToKst()로 UTC → KST 변환
  • 검색: DateTimeFormatter.getUtcRangeForKstDate()로 KST → UTC 변환

변경 파일:

  • src/main/resources/templates/memberList.html
  • src/main/kotlin/codel/member/business/MemberService.kt

Template 수정 (표시):

<!-- Before -->
<td>
    <small th:text="${#temporals.format(member.createdAt, 'yyyy-MM-dd HH:mm')}"></small>
</td>

<!-- After -->
<td>
    <small th:text="${#temporals.format(
        T(codel.common.util.DateTimeFormatter).convertUtcToKst(member.createdAt),
        'yyyy-MM-dd HH:mm'
    )}"></small>
</td>

Service 수정 (검색):

// Before - 잘못된 시간대 처리
val startDateTime = if (!startDate.isNullOrBlank()) {
    try {
        LocalDate.parse(startDate).atStartOfDay()  // KST를 UTC로 착각
    } catch (e: Exception) {
        null
    }
} else {
    null
}

// After - 올바른 시간대 변환
val startDateTime = if (!startDate.isNullOrBlank()) {
    try {
        val kstDate = LocalDate.parse(startDate)
        // KST 날짜의 시작 시간(00:00:00)을 UTC로 변환
        codel.common.util.DateTimeFormatter.getUtcRangeForKstDate(kstDate).first
    } catch (e: Exception) {
        null
    }
} else {
    null
}

val endDateTime = if (!endDate.isNullOrBlank()) {
    try {
        val kstDate = LocalDate.parse(endDate)
        // KST 날짜의 종료 시간(23:59:59.999...)을 UTC로 변환
        codel.common.util.DateTimeFormatter.getUtcRangeForKstDate(kstDate).second
    } catch (e: Exception) {
        null
    }
} else {
    null
}

동작 예시:

  • 사용자가 "2026-01-11" 검색
    • KST 2026-01-11 00:00:00 ~ 23:59:59 범위 의미
    • DB 검색: UTC 2026-01-10 15:00:00 ~ 2026-01-11 14:59:59로 변환
    • 결과: KST 기준 정확히 해당 날짜 가입자만 조회

🔄 전체 변경 파일 목록

Backend (Kotlin)

  1. src/main/kotlin/codel/member/infrastructure/MemberJpaRepository.kt - 이름 검색 및 날짜 필터 쿼리 수정
  2. src/main/kotlin/codel/member/business/MemberService.kt - KST→UTC 날짜 변환 로직 추가
  3. src/main/kotlin/codel/admin/presentation/AdminController.kt - 상태 집계 추가, 필터 파라미터 처리, model attribute 이름 수정

Frontend (Thymeleaf)

  1. src/main/resources/templates/memberList.html - WITHDRAWN/PERSONALITY_COMPLETED UI 추가, KST 시간 표시
  2. src/main/resources/templates/questionList.html - 필터 파라미터 전달, selectedX 사용
  3. src/main/resources/templates/questionEditForm.html - 필터 파라미터 hidden input 추가

📊 커밋 구조

4cd1790 [feat] 회원 리스트 이름 검색을 codeName 필드만 대상으로 수정
2ecd3ef [feat] 회원 리스트에 가입일 기준 날짜 범위 필터링 기능 추가
26f90e1 [feat] 회원 리스트 상태 필터에 탈퇴 및 오픈프로필 작성 완료 추가
1ff03b3 [feat] 질문 관리 페이지에서 수정/상태 변경/삭제 후 필터 조건 유지
cd2516d [feat] 질문 관리 페이지 검색 필터 선택 값 유지 기능 추가
3fca098 [feat] 회원 리스트 가입 시간 표시를 UTC에서 KST로 변경
921ab93 [fix] 회원 리스트 날짜 필터링 시 KST를 UTC로 변환하여 검색

🎯 핵심 설계 결정

  1. 검색 정확도 우선

    • 이름 검색은 codeName만 대상으로 하여 정확도 향상
    • 추후 전체 검색이 필요하면 별도 필터 추가 가능
  2. 시간대 일관성 유지

    • 표시와 검색 모두 KST 기준으로 동작
    • DB는 UTC로 저장하되, 사용자는 KST로 인지하도록 양방향 변환
    • 기존 DateTimeFormatter 유틸리티 적극 활용
  3. 필터 상태 유지

    • 모든 작업(수정/토글/삭제) 후에도 필터 조건 유지
    • hidden input과 쿼리스트링 조합으로 구현
    • 페이지 번호, 정렬 정보도 함께 유지
  4. Thymeleaf 객체 충돌 회피

    • 기본 제공 객체(param, session 등)와 이름 중복 방지
    • 명확한 이름(selectedX) 사용으로 가독성 및 유지보수성 향상

✅ 해결된 문제

  1. ✅ 회원 이름 검색이 정확하게 동작 (codeName만 검색)
  2. ✅ 회원 가입일 범위 필터링 기능 추가 및 정상 동작
  3. ✅ 탈퇴/오픈프로필 작성 완료 상태 필터링 가능
  4. ✅ 질문 수정/삭제/상태 변경 후 필터 유지
  5. ✅ 질문 검색 후 선택된 필터 값이 UI에 표시
  6. ✅ 회원 가입 시간이 KST로 표시 및 날짜 검색도 KST 기준으로 동작

📚 상세 문서

프로젝트 루트의 docs/work/adminPage/implementation-details.md에서 각 커밋별 상세 분석 확인 가능:

  • 문제점 분석
  • 원인 파악 (코드 예시 포함)
  • 해결 방법 (Before/After 코드 비교)
  • 적용 효과

질문 혹은 공유 사항 (Optional)

향후 개선 가능 사항

  1. 검색 기능 확장

    • 현재는 codeName만 검색
    • 이메일, ID 등으로도 검색하고 싶다면 별도 필터 추가 고려
  2. 페이지네이션 개선

    • 현재는 모든 페이지 번호가 표시됨
    • 페이지가 많아지면 "1 2 3 ... 98 99 100" 형식으로 표시 고려
  3. 시간대 설정 유연화

    • 현재는 KST로 하드코딩
    • 관리자별 시간대 설정 기능 추가 고려 (향후)
  4. 필터 프리셋 기능

    • 자주 사용하는 필터 조합을 저장하고 빠르게 적용
    • 예: "이번 주 탈퇴 회원", "오늘 가입 후 심사 대기" 등

- Profile 엔티티 생성 및 양방향 연관관계 설정 추가
- asyncNotificationService mock 의존성 추가
- 검증 로직을 실제 동작에 맞게 수정 (save 호출 대신 member 상태 직접 확인)
- 예상 상태를 PENDING으로 수정 (completeHiddenProfile 실제 동작 반영)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@sgo722 sgo722 merged commit b0a1cf9 into develop Jan 12, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant