[회원 목록 조회 기능 개선] 회원 목록 조회 API에 페이지네이션 기능을 추가하고, 검색 조건을 위한 DTO 및 관련 메서드를 구현하여 효율적인 데이터 조회를 지원. README.md에 DTO 네이밍 규칙을 추가하여 코드 일관성을 강화.

This commit is contained in:
2025-09-03 10:19:08 +09:00
parent 51fe350c6d
commit 4fac74b6a5
14 changed files with 387 additions and 44 deletions

View File

@@ -353,7 +353,33 @@ public class Member extends BaseEntity {
}
```
### 11. 데이터베이스 스키마
### 11. DTO 네이밍 규칙
#### 기본 원칙
- **API 계층**: `Dto` 접미사 유지
- **Service 계층**: 역할에 따라 `Dto` 접미사 결정
#### 사용 예시
```java
// API 계층 (Dto 유지)
CreateMemberRequestDto, GetMemberResponseDto
// Service 계층 - 비즈니스 핵심 (Dto 유지)
MemberDto
// Service 계층 - 내부 전달 (Dto 제거)
MemberSearchCondition
```
#### 핵심 규칙
- **API 노출**: `Dto` 유지
- **비즈니스 핵심**: `Dto` 유지
- **내부 전달**: `Dto` 제거
### 12. 데이터베이스 스키마
**데이터베이스 테이블 구조는 `ddl/schema_entity.sql`에 정의되어 있습니다.**

View File

@@ -11,6 +11,11 @@ import com.bio.bio_backend.domain.base.member.dto.MemberDto;
import com.bio.bio_backend.domain.base.member.dto.GetMemberResponseDto;
import com.bio.bio_backend.domain.base.member.dto.CreateMemberRequestDto;
import com.bio.bio_backend.domain.base.member.dto.CreateMemberResponseDto;
import com.bio.bio_backend.domain.base.member.dto.MemberSearchCondition;
import com.bio.bio_backend.domain.base.member.dto.GetMembersRequestDto;
import com.bio.bio_backend.domain.base.member.dto.GetMembersPagedRequestDto;
import com.bio.bio_backend.global.dto.PagedResult;
import com.bio.bio_backend.domain.base.member.service.MemberService;
import com.bio.bio_backend.domain.base.member.mapper.MemberMapper;
import lombok.RequiredArgsConstructor;
@@ -26,7 +31,7 @@ import com.bio.bio_backend.global.utils.SecurityUtils;
import com.bio.bio_backend.global.utils.JwtUtils;
import jakarta.servlet.http.HttpServletResponse;
import java.util.HashMap;
import java.util.List;
@@ -60,21 +65,33 @@ public class MemberController {
@LogExecution("회원 목록 조회")
@Operation(summary = "회원 목록 조회", description = "활성화된 모든 회원의 목록을 조회합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "회원 목록 조회 성공")
@ApiResponse(responseCode = "200", description = "회원 목록 조회 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청 파라미터", content = @Content(schema = @Schema(implementation = ApiResponseDto.class)))
})
@GetMapping
public ResponseEntity<ApiResponseDto<List<GetMemberResponseDto>>> getMembers() {
try {
List<GetMemberResponseDto> members = memberService.selectMemberListForDisplay(new HashMap<>());
ApiResponseDto<List<GetMemberResponseDto>> apiResponse = ApiResponseDto.success(ApiResponseCode.COMMON_SUCCESS, members);
log.info("전체 회원 목록 조회 완료: {}명", members.size());
return ResponseEntity.ok(apiResponse);
} catch (Exception e) {
log.error("회원 목록 조회 중 오류 발생: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponseDto.fail(ApiResponseCode.COMMON_INTERNAL_SERVER_ERROR));
}
public ResponseEntity<ApiResponseDto<List<GetMemberResponseDto>>> getMembers(@ModelAttribute GetMembersRequestDto requestDto) {
MemberSearchCondition condition = memberMapper.toSearchCondition(requestDto);
List<GetMemberResponseDto> members = memberService.getMembers(condition);
ApiResponseDto<List<GetMemberResponseDto>> apiResponse = ApiResponseDto.success(ApiResponseCode.COMMON_SUCCESS, members);
log.info("회원 목록 조회 완료: {}명", members.size());
return ResponseEntity.ok(apiResponse);
}
@LogExecution("회원 목록 조회 (페이지네이션)")
@Operation(summary = "회원 목록 조회 (페이지네이션)", description = "페이지네이션을 적용한 회원 목록을 조회합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "회원 목록 조회 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청 파라미터", content = @Content(schema = @Schema(implementation = ApiResponseDto.class)))
})
@GetMapping("/paged")
public ResponseEntity<ApiResponseDto<PagedResult<GetMemberResponseDto>>> getMembersPaged(@ModelAttribute GetMembersPagedRequestDto requestDto) {
MemberSearchCondition condition = memberMapper.toSearchCondition(requestDto);
PagedResult<GetMemberResponseDto> pagedMembers = memberService.getMembersPaged(condition, requestDto.getPage(), requestDto.getSize());
ApiResponseDto<PagedResult<GetMemberResponseDto>> apiResponse = ApiResponseDto.success(ApiResponseCode.COMMON_SUCCESS, pagedMembers);
log.info("페이지네이션된 회원 목록 조회 완료: 페이지={}, 크기={}, 전체={}명", requestDto.getPage(), requestDto.getSize(), pagedMembers.getTotalElements());
return ResponseEntity.ok(apiResponse);
}
@LogExecution("로그아웃")
@@ -84,18 +101,12 @@ public class MemberController {
})
@PostMapping("/logout")
public ResponseEntity<ApiResponseDto<Void>> logout(HttpServletResponse response) {
try {
String userId = SecurityUtils.getCurrentUserId();
memberService.deleteRefreshToken(userId);
// 모든 토큰 쿠키 삭제
jwtUtils.deleteAllTokenCookies(response);
log.info("사용자 로그아웃 완료: {}", userId);
return ResponseEntity.ok(ApiResponseDto.success(ApiResponseCode.COMMON_SUCCESS));
} catch (Exception e) {
log.error("로그아웃 처리 중 오류 발생: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponseDto.fail(ApiResponseCode.COMMON_INTERNAL_SERVER_ERROR));
}
String userId = SecurityUtils.getCurrentUserId();
memberService.deleteRefreshToken(userId);
// 모든 토큰 쿠키 삭제
jwtUtils.deleteAllTokenCookies(response);
log.info("사용자 로그아웃 완료: {}", userId);
return ResponseEntity.ok(ApiResponseDto.success(ApiResponseCode.COMMON_SUCCESS));
}
}

View File

@@ -0,0 +1,25 @@
package com.bio.bio_backend.domain.base.member.dto;
import com.bio.bio_backend.global.dto.BasePagedRequestDto;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* 회원 목록 조회 요청용 DTO (검색 조건 + 페이지네이션 포함)
* GET /members/paged
*/
@Data
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class GetMembersPagedRequestDto extends BasePagedRequestDto {
private String userId;
private String name;
private String email;
private String searchKeyword;
private String createdDateFrom;
private String createdDateTo;
private String lastLoginFrom;
private String lastLoginTo;
}

View File

@@ -0,0 +1,22 @@
package com.bio.bio_backend.domain.base.member.dto;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 회원 목록 조회 요청용 DTO (검색 조건만 포함)
* GET /members
*/
@Data
@NoArgsConstructor
public class GetMembersRequestDto {
private String userId;
private String name;
private String email;
private String searchKeyword;
private String createdDateFrom;
private String createdDateTo;
private String lastLoginFrom;
private String lastLoginTo;
}

View File

@@ -0,0 +1,23 @@
package com.bio.bio_backend.domain.base.member.dto;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
/**
* 회원 검색 조건
*/
@Builder
@Getter
@Setter
public class MemberSearchCondition {
private String userId;
private String name;
private String email;
private String searchKeyword;
private String createdDateFrom;
private String createdDateTo;
private String lastLoginFrom;
private String lastLoginTo;
}

View File

@@ -4,6 +4,9 @@ import com.bio.bio_backend.domain.base.member.dto.CreateMemberRequestDto;
import com.bio.bio_backend.domain.base.member.dto.CreateMemberResponseDto;
import com.bio.bio_backend.domain.base.member.dto.LoginResponseDto;
import com.bio.bio_backend.domain.base.member.dto.MemberDto;
import com.bio.bio_backend.domain.base.member.dto.MemberSearchCondition;
import com.bio.bio_backend.domain.base.member.dto.GetMembersRequestDto;
import com.bio.bio_backend.domain.base.member.dto.GetMembersPagedRequestDto;
import com.bio.bio_backend.domain.base.member.dto.GetMemberResponseDto;
import com.bio.bio_backend.domain.base.member.entity.Member;
import com.bio.bio_backend.global.annotation.IgnoreBaseEntityMapping;
@@ -68,4 +71,14 @@ public interface MemberMapper {
* Member 엔티티 리스트를 GetMemberResponseDto 리스트로 변환 (민감한 정보 제외)
*/
List<GetMemberResponseDto> toGetMemberResponseDtoList(List<Member> members);
/**
* GetMembersRequestDto를 MemberSearchCondition으로 변환
*/
MemberSearchCondition toSearchCondition(GetMembersRequestDto request);
/**
* GetMembersPagedRequestDto를 MemberSearchCondition으로 변환
*/
MemberSearchCondition toSearchCondition(GetMembersPagedRequestDto request);
}

View File

@@ -1,5 +1,7 @@
package com.bio.bio_backend.domain.base.member.repository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@@ -14,4 +16,7 @@ public interface MemberRepository extends JpaRepository<Member, Long>, MemberRep
// 활성화된 회원 목록 조회
List<Member> findByUseFlagTrue();
// 활성화된 회원 목록 조회 (페이지네이션)
Page<Member> findByUseFlagTrue(Pageable pageable);
}

View File

@@ -1,6 +1,10 @@
package com.bio.bio_backend.domain.base.member.repository;
import com.bio.bio_backend.domain.base.member.dto.MemberSearchCondition;
import com.bio.bio_backend.domain.base.member.entity.Member;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.List;
import java.util.Optional;
public interface MemberRepositoryCustom {
@@ -12,4 +16,21 @@ public interface MemberRepositoryCustom {
* @return Optional<Member> 회원 정보 (없으면 empty)
*/
Optional<Member> findActiveMemberByUserId(String userId);
/**
* QueryDSL을 사용한 회원 목록 조회 (검색 조건 + 페이징)
*
* @param condition 검색 조건
* @param pageable 페이징 정보
* @return Page<Member> 검색된 회원 목록
*/
Page<Member> findMembers(MemberSearchCondition condition, Pageable pageable);
/**
* QueryDSL을 사용한 회원 목록 조회 (검색 조건)
*
* @param condition 검색 조건
* @return List<Member> 검색된 회원 목록
*/
List<Member> findMembers(MemberSearchCondition condition);
}

View File

@@ -1,11 +1,20 @@
package com.bio.bio_backend.domain.base.member.repository;
import com.bio.bio_backend.domain.base.member.dto.MemberSearchCondition;
import com.bio.bio_backend.domain.base.member.entity.Member;
import com.bio.bio_backend.domain.base.member.entity.QMember;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Optional;
/**
@@ -30,4 +39,91 @@ public class MemberRepositoryImpl implements MemberRepositoryCustom {
return Optional.ofNullable(foundMember);
}
@Override
public Page<Member> findMembers(MemberSearchCondition condition, Pageable pageable) {
BooleanBuilder builder = buildSearchConditions(condition);
List<Member> members = queryFactory
.selectFrom(member)
.where(builder)
.orderBy(member.createdAt.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
Long total = queryFactory
.select(member.count())
.from(member)
.where(builder)
.fetchOne();
return new PageImpl<>(members, pageable, total);
}
@Override
public List<Member> findMembers(MemberSearchCondition condition) {
return queryFactory
.selectFrom(member)
.where(buildSearchConditions(condition))
.orderBy(member.createdAt.desc())
.fetch();
}
/**
* 검색 조건들을 조합하여 BooleanBuilder 생성
*/
private BooleanBuilder buildSearchConditions(MemberSearchCondition condition) {
BooleanBuilder builder = new BooleanBuilder();
// 기본 조건: useFlag = true (활성화된 회원만 조회)
builder.and(member.useFlag.eq(true));
// 사용자 ID (정확 일치)
if (StringUtils.hasText(condition.getUserId())) {
builder.and(member.userId.eq(condition.getUserId()));
}
// 이름 (부분 일치)
if (StringUtils.hasText(condition.getName())) {
builder.and(member.name.containsIgnoreCase(condition.getName()));
}
// 이메일 (부분 일치)
if (StringUtils.hasText(condition.getEmail())) {
builder.and(member.email.containsIgnoreCase(condition.getEmail()));
}
// 통합 검색 키워드 (이름, 이메일, 사용자ID에서 검색)
if (StringUtils.hasText(condition.getSearchKeyword())) {
String keyword = condition.getSearchKeyword();
builder.and(member.name.containsIgnoreCase(keyword)
.or(member.email.containsIgnoreCase(keyword))
.or(member.userId.containsIgnoreCase(keyword)));
}
// 생성일 범위
if (StringUtils.hasText(condition.getCreatedDateFrom())) {
LocalDate fromDate = LocalDate.parse(condition.getCreatedDateFrom(), DateTimeFormatter.ISO_LOCAL_DATE);
builder.and(member.createdAt.goe(fromDate.atStartOfDay()));
}
if (StringUtils.hasText(condition.getCreatedDateTo())) {
LocalDate toDate = LocalDate.parse(condition.getCreatedDateTo(), DateTimeFormatter.ISO_LOCAL_DATE);
builder.and(member.createdAt.loe(toDate.atTime(23, 59, 59)));
}
// 마지막 로그인 범위
if (StringUtils.hasText(condition.getLastLoginFrom())) {
LocalDate fromDate = LocalDate.parse(condition.getLastLoginFrom(), DateTimeFormatter.ISO_LOCAL_DATE);
builder.and(member.lastLoginAt.goe(fromDate.atStartOfDay()));
}
if (StringUtils.hasText(condition.getLastLoginTo())) {
LocalDate toDate = LocalDate.parse(condition.getLastLoginTo(), DateTimeFormatter.ISO_LOCAL_DATE);
builder.and(member.lastLoginAt.loe(toDate.atTime(23, 59, 59)));
}
return builder;
}
}

View File

@@ -5,9 +5,10 @@ import org.springframework.security.core.userdetails.UserDetailsService;
import com.bio.bio_backend.domain.base.member.dto.MemberDto;
import com.bio.bio_backend.domain.base.member.dto.GetMemberResponseDto;
import com.bio.bio_backend.domain.base.member.dto.MemberSearchCondition;
import com.bio.bio_backend.global.dto.PagedResult;
import java.util.List;
import java.util.Map;
public interface MemberService extends UserDetailsService {
@@ -20,13 +21,20 @@ public interface MemberService extends UserDetailsService {
void deleteRefreshToken(String id);
void updateMember(MemberDto member);
List<MemberDto> selectMemberList(Map<String, String> params);
/**
* 회원 목록 조회 (민감한 정보 제외)
* @param params 검색 파라미터
* 검색 조건에 따른 회원 목록 조회
* @param condition 검색 조건
* @return GetMemberResponseDto 리스트
*/
List<GetMemberResponseDto> selectMemberListForDisplay(Map<String, String> params);
List<GetMemberResponseDto> getMembers(MemberSearchCondition condition);
/**
* 페이지네이션된 회원 목록 조회
* @param condition 검색 조건
* @param page 페이지 번호
* @param size 페이지 크기
* @return PagedResult<GetMemberResponseDto> 페이지네이션된 회원 목록
*/
PagedResult<GetMemberResponseDto> getMembersPaged(MemberSearchCondition condition, int page, int size);
}

View File

@@ -2,6 +2,8 @@ package com.bio.bio_backend.domain.base.member.service;
import com.bio.bio_backend.domain.base.member.dto.MemberDto;
import com.bio.bio_backend.domain.base.member.dto.GetMemberResponseDto;
import com.bio.bio_backend.domain.base.member.dto.MemberSearchCondition;
import com.bio.bio_backend.global.dto.PagedResult;
import com.bio.bio_backend.domain.base.member.entity.Member;
import com.bio.bio_backend.domain.base.member.mapper.MemberMapper;
import com.bio.bio_backend.domain.base.member.repository.MemberRepository;
@@ -13,12 +15,15 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
@@ -89,18 +94,20 @@ public class MemberServiceImpl implements MemberService {
member.setRefreshToken(null);
memberRepository.save(member);
}
@Override
public List<MemberDto> selectMemberList(Map<String, String> params) {
List<Member> members = memberRepository.findByUseFlagTrue();
return memberMapper.toMemberDtoList(members);
}
@Override
public List<GetMemberResponseDto> selectMemberListForDisplay(Map<String, String> params) {
List<Member> members = memberRepository.findByUseFlagTrue();
public List<GetMemberResponseDto> getMembers(MemberSearchCondition condition) {
List<Member> members = memberRepository.findMembers(condition);
return memberMapper.toGetMemberResponseDtoList(members);
}
@Override
public PagedResult<GetMemberResponseDto> getMembersPaged(MemberSearchCondition condition, int page, int size) {
Pageable pageable = PageRequest.of(page, size);
Page<Member> memberPage = memberRepository.findMembers(condition, pageable);
List<GetMemberResponseDto> members = memberMapper.toGetMemberResponseDtoList(memberPage.getContent());
return PagedResult.of(memberPage, members);
}
}

View File

@@ -0,0 +1,28 @@
package com.bio.bio_backend.global.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Max;
/**
* 기본 페이징 요청 DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BasePagedRequestDto {
@Min(value = 0, message = "페이지 번호는 0 이상이어야 합니다")
@Builder.Default
private int page = 0;
@Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다")
@Max(value = 100, message = "페이지 크기는 100 이하여야 합니다")
@Builder.Default
private int size = 10;
}

View File

@@ -0,0 +1,24 @@
package com.bio.bio_backend.global.dto;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
/**
* 기본 페이징 응답 DTO
* 모든 페이징 응답에서 공통으로 사용되는 필드들을 포함
*/
@Data
@SuperBuilder
@NoArgsConstructor
public class BasePagedResponseDto {
private int currentPage; // 현재 페이지
private int totalPages; // 전체 페이지 수
private long totalElements; // 전체 요소 수
private int size; // 페이지 크기
private boolean hasNext; // 다음 페이지 존재 여부
private boolean hasPrevious; // 이전 페이지 존재 여부
private boolean isFirst; // 첫 번째 페이지 여부
private boolean isLast; // 마지막 페이지 여부
}

View File

@@ -0,0 +1,34 @@
package com.bio.bio_backend.global.dto;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import org.springframework.data.domain.Page;
import java.util.List;
/**
* 페이지네이션된 결과를 담는 제네릭 클래스
* @param <T> 페이지네이션할 데이터의 타입
*/
@Data
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class PagedResult<T> extends BasePagedResponseDto {
private List<T> content;
public static <T> PagedResult<T> of(Page<?> page, List<T> content) {
PagedResult<T> result = new PagedResult<>();
result.setContent(content);
result.setCurrentPage(page.getNumber());
result.setTotalPages(page.getTotalPages());
result.setTotalElements(page.getTotalElements());
result.setSize(page.getSize());
result.setHasNext(page.hasNext());
result.setHasPrevious(page.hasPrevious());
result.setFirst(page.isFirst());
result.setLast(page.isLast());
return result;
}
}