diff --git a/README.md b/README.md index aa95366..32416ad 100644 --- a/README.md +++ b/README.md @@ -352,3 +352,49 @@ public class Member extends BaseEntity { private String name; } ``` + +### 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`에 정의되어 있습니다.** + +#### 스키마 파일 + +- **위치**: `ddl/schema_entity.sql` +- **내용**: 모든 엔티티 테이블의 CREATE TABLE DDL 스크립트 + +#### 초기화 스크립트 + +- **위치**: `src/main/resources/schema_initial.sql` +- **내용**: 서버 부팅 시 자동 실행되는 초기화 스크립트 (예: shedlock 테이블 등) + +#### 사용 방법 + +- **자동 생성**: 애플리케이션 시작 시 `schema_entity.sql`로 엔티티 테이블 자동 생성 +- **초기화**: 서버 부팅 시 `schema_initial.sql`로 시스템 테이블 자동 생성 +- **설정**: `spring.jpa.hibernate.ddl-auto=none`으로 Hibernate 자동 스키마 생성 비활성화 diff --git a/build.gradle b/build.gradle index 844742c..0c7dfaa 100644 --- a/build.gradle +++ b/build.gradle @@ -69,10 +69,14 @@ dependencies { annotationProcessor 'jakarta.persistence:jakarta.persistence-api' // p6spy - implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0' + implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.12.0' // SpringDoc OpenAPI (Swagger) implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9' + + // ShedLock for distributed scheduling + implementation 'net.javacrumbs.shedlock:shedlock-spring:5.10.2' + implementation 'net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.10.2' } tasks.named('test') { diff --git a/ddl/schema.sql b/ddl/schema.sql deleted file mode 100644 index dbfc311..0000000 --- a/ddl/schema.sql +++ /dev/null @@ -1,96 +0,0 @@ - - create table st_common_code ( - sort_order integer not null, - use_flag boolean not null, - created_at timestamp(6) not null, - created_oid bigint, - oid bigint not null, - updated_at timestamp(6) not null, - updated_oid bigint, - code varchar(50) not null unique, - group_code varchar(50) not null, - parent_code varchar(50), - character_ref1 varchar(100), - character_ref2 varchar(100), - character_ref3 varchar(100), - character_ref4 varchar(100), - character_ref5 varchar(100), - name varchar(100) not null, - description varchar(500), - created_id varchar(255), - updated_id varchar(255), - primary key (oid) - ); - - create table st_common_group_code ( - sort_order integer not null, - use_flag boolean not null, - created_at timestamp(6) not null, - created_oid bigint, - oid bigint not null, - updated_at timestamp(6) not null, - updated_oid bigint, - code varchar(50) not null unique, - character_ref1_title varchar(100), - character_ref2_title varchar(100), - character_ref3_title varchar(100), - character_ref4_title varchar(100), - character_ref5_title varchar(100), - name varchar(100) not null, - created_id varchar(255), - updated_id varchar(255), - primary key (oid) - ); - - create table st_file ( - use_flag boolean not null, - created_at timestamp(6) not null, - created_oid bigint, - file_size bigint not null, - group_oid bigint, - oid bigint not null, - updated_at timestamp(6) not null, - updated_oid bigint, - content_type varchar(255) not null, - created_id varchar(255), - description varchar(255), - file_path varchar(255) not null, - original_file_name varchar(255) not null, - stored_file_name varchar(255) not null, - updated_id varchar(255), - primary key (oid) - ); - - create table st_member ( - use_flag boolean not null, - created_at timestamp(6) not null, - created_oid bigint, - last_login_at timestamp(6), - oid bigint not null, - updated_at timestamp(6) not null, - updated_oid bigint, - login_ip varchar(45), - name varchar(100) not null, - password varchar(100) not null, - user_id varchar(100) not null, - refresh_token varchar(1024), - created_id varchar(255), - email varchar(255) not null, - updated_id varchar(255), - primary key (oid) - ); - - create index idx_common_code_code - on st_common_code (code); - - create index idx_common_code_group_code - on st_common_code (group_code); - - create index idx_common_code_parent_code - on st_common_code (parent_code); - - create index idx_common_group_code_code - on st_common_group_code (code); - - create index idx_member_user_id - on st_member (user_id); diff --git a/ddl/schema_entity.sql b/ddl/schema_entity.sql new file mode 100644 index 0000000..1d89584 --- /dev/null +++ b/ddl/schema_entity.sql @@ -0,0 +1,291 @@ + + create table st_common_code ( + sort_order integer not null, + use_flag boolean not null, + created_at timestamp(6) not null, + created_oid bigint, + oid bigint not null, + updated_at timestamp(6) not null, + updated_oid bigint, + code varchar(50) not null unique, + group_code varchar(50) not null, + parent_code varchar(50), + character_ref1 varchar(100), + character_ref2 varchar(100), + character_ref3 varchar(100), + character_ref4 varchar(100), + character_ref5 varchar(100), + name varchar(100) not null, + description varchar(500), + created_id varchar(255), + updated_id varchar(255), + primary key (oid) + ); + + comment on column st_common_code.sort_order is + '정렬 순번'; + + comment on column st_common_code.use_flag is + '사용 여부'; + + comment on column st_common_code.created_at is + '생성일시'; + + comment on column st_common_code.created_oid is + '생성자 OID'; + + comment on column st_common_code.oid is + 'OID'; + + comment on column st_common_code.updated_at is + '수정일시'; + + comment on column st_common_code.updated_oid is + '수정자 OID'; + + comment on column st_common_code.code is + '코드'; + + comment on column st_common_code.group_code is + '그룹 코드'; + + comment on column st_common_code.parent_code is + '부모 코드'; + + comment on column st_common_code.character_ref1 is + '문자 참조1'; + + comment on column st_common_code.character_ref2 is + '문자 참조2'; + + comment on column st_common_code.character_ref3 is + '문자 참조3'; + + comment on column st_common_code.character_ref4 is + '문자 참조4'; + + comment on column st_common_code.character_ref5 is + '문자 참조5'; + + comment on column st_common_code.name is + '코드명'; + + comment on column st_common_code.description is + '설명'; + + comment on column st_common_code.created_id is + '생성자 ID'; + + comment on column st_common_code.updated_id is + '수정자 ID'; + + create table st_common_group_code ( + sort_order integer not null, + use_flag boolean not null, + created_at timestamp(6) not null, + created_oid bigint, + oid bigint not null, + updated_at timestamp(6) not null, + updated_oid bigint, + code varchar(50) not null unique, + character_ref1_title varchar(100), + character_ref2_title varchar(100), + character_ref3_title varchar(100), + character_ref4_title varchar(100), + character_ref5_title varchar(100), + name varchar(100) not null, + created_id varchar(255), + updated_id varchar(255), + primary key (oid) + ); + + comment on column st_common_group_code.sort_order is + '정렬 순번'; + + comment on column st_common_group_code.use_flag is + '사용 여부'; + + comment on column st_common_group_code.created_at is + '생성일시'; + + comment on column st_common_group_code.created_oid is + '생성자 OID'; + + comment on column st_common_group_code.oid is + 'OID'; + + comment on column st_common_group_code.updated_at is + '수정일시'; + + comment on column st_common_group_code.updated_oid is + '수정자 OID'; + + comment on column st_common_group_code.code is + '코드'; + + comment on column st_common_group_code.character_ref1_title is + '문자 참조 타이틀1'; + + comment on column st_common_group_code.character_ref2_title is + '문자 참조 타이틀2'; + + comment on column st_common_group_code.character_ref3_title is + '문자 참조 타이틀3'; + + comment on column st_common_group_code.character_ref4_title is + '문자 참조 타이틀4'; + + comment on column st_common_group_code.character_ref5_title is + '문자 참조 타이틀5'; + + comment on column st_common_group_code.name is + '코드명'; + + comment on column st_common_group_code.created_id is + '생성자 ID'; + + comment on column st_common_group_code.updated_id is + '수정자 ID'; + + create table st_file ( + use_flag boolean not null, + created_at timestamp(6) not null, + created_oid bigint, + file_size bigint not null, + group_oid bigint, + oid bigint not null, + updated_at timestamp(6) not null, + updated_oid bigint, + content_type varchar(255) not null, + created_id varchar(255), + description varchar(255), + file_path varchar(255) not null, + original_file_name varchar(255) not null, + stored_file_name varchar(255) not null, + updated_id varchar(255), + primary key (oid) + ); + + comment on column st_file.use_flag is + '사용 여부'; + + comment on column st_file.created_at is + '생성일시'; + + comment on column st_file.created_oid is + '생성자 OID'; + + comment on column st_file.file_size is + '파일 크기'; + + comment on column st_file.group_oid is + '그룹 OID'; + + comment on column st_file.oid is + 'OID'; + + comment on column st_file.updated_at is + '수정일시'; + + comment on column st_file.updated_oid is + '수정자 OID'; + + comment on column st_file.content_type is + '콘텐츠 타입'; + + comment on column st_file.created_id is + '생성자 ID'; + + comment on column st_file.description is + '설명'; + + comment on column st_file.file_path is + '파일 경로'; + + comment on column st_file.original_file_name is + '원본 파일명'; + + comment on column st_file.stored_file_name is + '저장 파일명'; + + comment on column st_file.updated_id is + '수정자 ID'; + + create table st_member ( + use_flag boolean not null, + created_at timestamp(6) not null, + created_oid bigint, + last_login_at timestamp(6), + oid bigint not null, + updated_at timestamp(6) not null, + updated_oid bigint, + login_ip varchar(45), + name varchar(100) not null, + password varchar(100) not null, + user_id varchar(100) not null, + refresh_token varchar(1024), + created_id varchar(255), + email varchar(255) not null, + updated_id varchar(255), + primary key (oid) + ); + + comment on column st_member.use_flag is + '사용 여부'; + + comment on column st_member.created_at is + '생성일시'; + + comment on column st_member.created_oid is + '생성자 OID'; + + comment on column st_member.last_login_at is + '마지막 로그인 일시'; + + comment on column st_member.oid is + 'OID'; + + comment on column st_member.updated_at is + '수정일시'; + + comment on column st_member.updated_oid is + '수정자 OID'; + + comment on column st_member.login_ip is + '로그인 IP'; + + comment on column st_member.name is + '이름'; + + comment on column st_member.password is + '비밀번호'; + + comment on column st_member.user_id is + '사용자 ID'; + + comment on column st_member.refresh_token is + '리프레시 토큰'; + + comment on column st_member.created_id is + '생성자 ID'; + + comment on column st_member.email is + '이메일'; + + comment on column st_member.updated_id is + '수정자 ID'; + + create index idx_common_code_code + on st_common_code (code); + + create index idx_common_code_group_code + on st_common_code (group_code); + + create index idx_common_code_parent_code + on st_common_code (parent_code); + + create index idx_common_group_code_code + on st_common_group_code (code); + + create index idx_member_user_id + on st_member (user_id); diff --git a/jpa-curd-0.0.1.vsix b/jpa-curd-0.0.1.vsix index 003eca2..fd32c07 100644 Binary files a/jpa-curd-0.0.1.vsix and b/jpa-curd-0.0.1.vsix differ diff --git a/nginx-1.28.0/conf/nginx.conf b/nginx-1.28.0/conf/nginx.conf index 15effda..3ff017e 100644 --- a/nginx-1.28.0/conf/nginx.conf +++ b/nginx-1.28.0/conf/nginx.conf @@ -31,6 +31,7 @@ http { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_http_version 1.1; + proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; } @@ -41,6 +42,7 @@ http { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; } # (선택) 업로드 크게 받을 때 diff --git a/src/main/java/com/bio/bio_backend/domain/admin/common_code/controller/CommonCodeController.java b/src/main/java/com/bio/bio_backend/domain/admin/common_code/controller/CommonCodeController.java index dc62e7c..3c20852 100644 --- a/src/main/java/com/bio/bio_backend/domain/admin/common_code/controller/CommonCodeController.java +++ b/src/main/java/com/bio/bio_backend/domain/admin/common_code/controller/CommonCodeController.java @@ -24,7 +24,7 @@ import java.util.List; @Slf4j @RestController -@RequestMapping("/admin/common-code") +@RequestMapping("/admin/common-codes") @RequiredArgsConstructor @Tag(name = "공통 코드 관리", description = "공통 코드 및 그룹 코드 관리 API") public class CommonCodeController { @@ -168,8 +168,8 @@ public class CommonCodeController { @ApiResponse(responseCode = "404", description = "공통 코드를 찾을 수 없음", content = @Content(schema = @Schema(implementation = ApiResponseDto.class))) }) @GetMapping("/{code}") - public ResponseEntity> getCode(@PathVariable String code) { - CommonCodeDto commonCode = commonCodeService.getCode(code); + public ResponseEntity> getCode(@PathVariable String code) { + GetCommonCodeResponseDto commonCode = commonCodeService.getCodeWithJoinInfo(code); return ResponseEntity.ok(ApiResponseDto.success(ApiResponseCode.COMMON_SUCCESS_RETRIEVED, commonCode)); } diff --git a/src/main/java/com/bio/bio_backend/domain/admin/common_code/dto/GetCommonCodeResponseDto.java b/src/main/java/com/bio/bio_backend/domain/admin/common_code/dto/GetCommonCodeResponseDto.java new file mode 100644 index 0000000..3731928 --- /dev/null +++ b/src/main/java/com/bio/bio_backend/domain/admin/common_code/dto/GetCommonCodeResponseDto.java @@ -0,0 +1,68 @@ +package com.bio.bio_backend.domain.admin.common_code.dto; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 공통 코드 조회용 Response DTO + * CommonGroupCode의 name과 Member의 name을 join을 통해 함께 조회 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@SqlResultSetMapping( + name = "GetCommonCodeResponseDtoMapping", + classes = @ConstructorResult( + targetClass = GetCommonCodeResponseDto.class, + columns = { + @ColumnResult(name = "code", type = String.class), + @ColumnResult(name = "name", type = String.class), + @ColumnResult(name = "description", type = String.class), + @ColumnResult(name = "group_code", type = String.class), + @ColumnResult(name = "parent_code", type = String.class), + @ColumnResult(name = "character_ref1", type = String.class), + @ColumnResult(name = "character_ref2", type = String.class), + @ColumnResult(name = "character_ref3", type = String.class), + @ColumnResult(name = "character_ref4", type = String.class), + @ColumnResult(name = "character_ref5", type = String.class), + @ColumnResult(name = "sort_order", type = Integer.class), + @ColumnResult(name = "use_flag", type = Boolean.class), + @ColumnResult(name = "created_at", type = LocalDateTime.class), + @ColumnResult(name = "updated_at", type = LocalDateTime.class), + @ColumnResult(name = "group_code_name", type = String.class), + @ColumnResult(name = "created_by_name", type = String.class), + @ColumnResult(name = "updated_by_name", type = String.class) + } + ) +) +public class GetCommonCodeResponseDto { + + // CommonCode 기본 정보 + private String code; + private String name; + private String description; + private String groupCode; + private String parentCode; + private String characterRef1; + private String characterRef2; + private String characterRef3; + private String characterRef4; + private String characterRef5; + private Integer sortOrder; + private Boolean useFlag; + + // BaseEntity 정보 + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + // Join을 통해 가져올 정보 + private String groupCodeName; // CommonGroupCode의 name + private String createdByName; // 생성자 Member의 name + private String updatedByName; // 수정자 Member의 name +} diff --git a/src/main/java/com/bio/bio_backend/domain/admin/common_code/entity/CommonCode.java b/src/main/java/com/bio/bio_backend/domain/admin/common_code/entity/CommonCode.java index 8723cc6..2895ddf 100644 --- a/src/main/java/com/bio/bio_backend/domain/admin/common_code/entity/CommonCode.java +++ b/src/main/java/com/bio/bio_backend/domain/admin/common_code/entity/CommonCode.java @@ -8,6 +8,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.hibernate.annotations.Comment; @Entity @Getter @Setter @@ -25,40 +26,52 @@ import lombok.Setter; public class CommonCode extends BaseEntity { @Column(name = "code", nullable = false, length = 50, unique = true) + @Comment("코드") private String code; @Column(name = "name", nullable = false, length = 100) + @Comment("코드명") private String name; @Column(name = "description", length = 500) + @Comment("설명") private String description; @Column(name = "group_code", nullable = false, length = 50) + @Comment("그룹 코드") private String groupCode; @Column(name = "parent_code", length = 50) + @Comment("부모 코드") private String parentCode; @Column(name = "character_ref1", length = 100) + @Comment("문자 참조1") private String characterRef1; @Column(name = "character_ref2", length = 100) + @Comment("문자 참조2") private String characterRef2; @Column(name = "character_ref3", length = 100) + @Comment("문자 참조3") private String characterRef3; @Column(name = "character_ref4", length = 100) + @Comment("문자 참조4") private String characterRef4; @Column(name = "character_ref5", length = 100) + @Comment("문자 참조5") private String characterRef5; @Column(name = "sort_order", nullable = false) + @Comment("정렬 순번") @Builder.Default private Integer sortOrder = 0; @Column(name = "use_flag", nullable = false) + @Comment("사용 여부") @Builder.Default private Boolean useFlag = true; diff --git a/src/main/java/com/bio/bio_backend/domain/admin/common_code/entity/CommonGroupCode.java b/src/main/java/com/bio/bio_backend/domain/admin/common_code/entity/CommonGroupCode.java index 4cccf73..9741a69 100644 --- a/src/main/java/com/bio/bio_backend/domain/admin/common_code/entity/CommonGroupCode.java +++ b/src/main/java/com/bio/bio_backend/domain/admin/common_code/entity/CommonGroupCode.java @@ -8,6 +8,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.hibernate.annotations.Comment; @Entity @Getter @Setter @@ -23,31 +24,40 @@ import lombok.Setter; public class CommonGroupCode extends BaseEntity { @Column(name = "code", nullable = false, length = 50, unique = true) + @Comment("코드") private String code; @Column(name = "name", nullable = false, length = 100) + @Comment("코드명") private String name; @Column(name = "character_ref1_title", length = 100) + @Comment("문자 참조 타이틀1") private String characterRef1Title; @Column(name = "character_ref2_title", length = 100) + @Comment("문자 참조 타이틀2") private String characterRef2Title; @Column(name = "character_ref3_title", length = 100) + @Comment("문자 참조 타이틀3") private String characterRef3Title; @Column(name = "character_ref4_title", length = 100) + @Comment("문자 참조 타이틀4") private String characterRef4Title; @Column(name = "character_ref5_title", length = 100) + @Comment("문자 참조 타이틀5") private String characterRef5Title; @Column(name = "sort_order", nullable = false) + @Comment("정렬 순번") @Builder.Default private Integer sortOrder = 0; @Column(name = "use_flag", nullable = false) + @Comment("사용 여부") @Builder.Default private Boolean useFlag = true; } diff --git a/src/main/java/com/bio/bio_backend/domain/admin/common_code/repository/CommonCodeRepository.java b/src/main/java/com/bio/bio_backend/domain/admin/common_code/repository/CommonCodeRepository.java index 39445b4..63878b1 100644 --- a/src/main/java/com/bio/bio_backend/domain/admin/common_code/repository/CommonCodeRepository.java +++ b/src/main/java/com/bio/bio_backend/domain/admin/common_code/repository/CommonCodeRepository.java @@ -2,28 +2,16 @@ package com.bio.bio_backend.domain.admin.common_code.repository; import com.bio.bio_backend.domain.admin.common_code.entity.CommonCode; 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 java.util.List; import java.util.Optional; @Repository -public interface CommonCodeRepository extends JpaRepository { +public interface CommonCodeRepository extends JpaRepository, CommonCodeRepositoryCustom { Optional findByCode(String code); - List findByGroupCodeAndUseFlagOrderBySortOrderAsc(String groupCode, Boolean useFlag); - - List findByParentCodeAndUseFlagOrderBySortOrderAsc(String parentCode, Boolean useFlag); - - @Query("SELECT cc FROM CommonCode cc WHERE cc.groupCode = :groupCode AND cc.useFlag = :useFlag ORDER BY cc.sortOrder ASC") - List findActiveCodesByGroupCodeOrderBySortOrder(@Param("groupCode") String groupCode, @Param("useFlag") Boolean useFlag); - - @Query("SELECT cc FROM CommonCode cc WHERE cc.parentCode = :parentCode AND cc.useFlag = :useFlag ORDER BY cc.sortOrder ASC") - List findActiveCodesByParentCodeOrderBySortOrder(@Param("parentCode") String parentCode, @Param("useFlag") Boolean useFlag); - boolean existsByCode(String code); boolean existsByGroupCode(String groupCode); diff --git a/src/main/java/com/bio/bio_backend/domain/admin/common_code/repository/CommonCodeRepositoryCustom.java b/src/main/java/com/bio/bio_backend/domain/admin/common_code/repository/CommonCodeRepositoryCustom.java new file mode 100644 index 0000000..573a540 --- /dev/null +++ b/src/main/java/com/bio/bio_backend/domain/admin/common_code/repository/CommonCodeRepositoryCustom.java @@ -0,0 +1,47 @@ +package com.bio.bio_backend.domain.admin.common_code.repository; + +import com.bio.bio_backend.domain.admin.common_code.dto.GetCommonCodeResponseDto; +import com.bio.bio_backend.domain.admin.common_code.entity.CommonCode; +import java.util.List; +import java.util.Optional; + +/** + * QueryDSL을 활용한 커스텀 쿼리 메서드들을 정의하는 인터페이스 + * 복잡한 쿼리나 동적 쿼리가 필요한 경우 이 인터페이스를 구현하여 사용합니다. + */ +public interface CommonCodeRepositoryCustom { + + /** + * 그룹 코드로 활성화된 공통 코드들을 정렬 순서대로 조회합니다. + * + * @param groupCode 그룹 코드 + * @return List 활성화된 공통 코드 목록 + */ + List findByGroupCode(String groupCode); + + /** + * 부모 코드로 활성화된 공통 코드들을 정렬 순서대로 조회합니다. + * + * @param parentCode 부모 코드 + * @return List 활성화된 공통 코드 목록 + */ + List findByParentCode(String parentCode); + + /** + * 특정 공통 코드를 조회하며 CommonGroupCode와 Member 정보를 join하여 함께 조회합니다. + * QueryDSL을 사용하여 성능 최적화된 쿼리를 실행합니다. + * + * @param code 조회할 공통 코드 + * @return Optional 조회된 공통 코드 정보 (없으면 empty) + */ + Optional findCodeWithJoinInfo(String code); + + /** + * 특정 공통 코드를 조회하며 CommonGroupCode와 Member 정보를 join하여 함께 조회합니다. + * Native Query를 사용한 버전 (사용하지 않음, 참고용) + * + * @param code 조회할 공통 코드 + * @return Optional 조회된 공통 코드 정보 (없으면 empty) + */ + Optional findCodeWithJoinInfoNative(String code); +} \ No newline at end of file diff --git a/src/main/java/com/bio/bio_backend/domain/admin/common_code/repository/CommonCodeRepositoryImpl.java b/src/main/java/com/bio/bio_backend/domain/admin/common_code/repository/CommonCodeRepositoryImpl.java new file mode 100644 index 0000000..c0937bc --- /dev/null +++ b/src/main/java/com/bio/bio_backend/domain/admin/common_code/repository/CommonCodeRepositoryImpl.java @@ -0,0 +1,104 @@ +package com.bio.bio_backend.domain.admin.common_code.repository; + +import com.bio.bio_backend.domain.admin.common_code.dto.GetCommonCodeResponseDto; +import com.bio.bio_backend.domain.admin.common_code.entity.CommonCode; +import com.bio.bio_backend.domain.admin.common_code.entity.QCommonCode; +import com.bio.bio_backend.domain.admin.common_code.entity.QCommonGroupCode; +import com.bio.bio_backend.domain.base.member.entity.QMember; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * QueryDSL을 활용하여 CommonCodeRepositoryCustom 인터페이스를 구현하는 클래스 + * 복잡한 쿼리나 동적 쿼리를 QueryDSL로 작성하여 성능과 가독성을 향상시킵니다. + */ +@Repository +@RequiredArgsConstructor +public class CommonCodeRepositoryImpl implements CommonCodeRepositoryCustom { + + private final JPAQueryFactory queryFactory; + private final EntityManager entityManager; + + /** + * Q클래스 인스턴스들을 생성하여 쿼리에서 사용합니다. + * QueryDSL의 Q클래스를 통해 타입 안전한 쿼리 작성이 가능합니다. + */ + private final QCommonCode commonCode = QCommonCode.commonCode; + private final QCommonGroupCode commonGroupCode = QCommonGroupCode.commonGroupCode; + private final QMember member = QMember.member; + + @Override + public List findByGroupCode(String groupCode) { + return queryFactory + .selectFrom(commonCode) + .where(commonCode.groupCode.eq(groupCode) + .and(commonCode.useFlag.eq(true))) + .orderBy(commonCode.sortOrder.asc()) + .fetch(); + } + + @Override + public List findByParentCode(String parentCode) { + return queryFactory + .selectFrom(commonCode) + .where(commonCode.parentCode.eq(parentCode) + .and(commonCode.useFlag.eq(true))) + .orderBy(commonCode.sortOrder.asc()) + .fetch(); + } + + @Override + public Optional findCodeWithJoinInfo(String code) { + return Optional.ofNullable( + queryFactory + .select(Projections.constructor(GetCommonCodeResponseDto.class, + commonCode.code, commonCode.name, commonCode.description, + commonCode.groupCode, commonCode.parentCode, + commonCode.characterRef1, commonCode.characterRef2, commonCode.characterRef3, + commonCode.characterRef4, commonCode.characterRef5, + commonCode.sortOrder, commonCode.useFlag, + commonCode.createdAt, commonCode.updatedAt, + commonGroupCode.name, member.name, member.name + )) + .from(commonCode) + .leftJoin(commonGroupCode).on(commonCode.groupCode.eq(commonGroupCode.code)) + .leftJoin(member).on(commonCode.createdOid.eq(member.oid)) + .leftJoin(member).on(commonCode.updatedOid.eq(member.oid)) + .where(commonCode.code.eq(code)) + .fetchOne() + ); + } + + @Override + public Optional findCodeWithJoinInfoNative(String code) { + String nativeQuery = """ + SELECT cc.code, cc.name, cc.description, cc.group_code, cc.parent_code, + cc.character_ref1, cc.character_ref2, cc.character_ref3, cc.character_ref4, cc.character_ref5, + cc.sort_order, cc.use_flag, cc.created_at, cc.updated_at, + cgc.name as group_code_name, cm1.name as created_by_name, cm2.name as updated_by_name + FROM st_common_code cc + LEFT JOIN st_common_group_code cgc ON cc.group_code = cgc.code + LEFT JOIN st_member cm1 ON cc.created_oid = cm1.oid + LEFT JOIN st_member cm2 ON cc.updated_oid = cm2.oid + WHERE cc.code = :code + """; + + try { + @SuppressWarnings("unchecked") + List results = entityManager + .createNativeQuery(nativeQuery, "GetCommonCodeResponseDtoMapping") + .setParameter("code", code) + .getResultList(); + + return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); + } catch (Exception e) { + return Optional.empty(); + } + } +} diff --git a/src/main/java/com/bio/bio_backend/domain/admin/common_code/repository/CommonGroupCodeRepository.java b/src/main/java/com/bio/bio_backend/domain/admin/common_code/repository/CommonGroupCodeRepository.java index 889d4a2..d23964d 100644 --- a/src/main/java/com/bio/bio_backend/domain/admin/common_code/repository/CommonGroupCodeRepository.java +++ b/src/main/java/com/bio/bio_backend/domain/admin/common_code/repository/CommonGroupCodeRepository.java @@ -2,8 +2,6 @@ package com.bio.bio_backend.domain.admin.common_code.repository; import com.bio.bio_backend.domain.admin.common_code.entity.CommonGroupCode; 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 java.util.List; @@ -16,8 +14,5 @@ public interface CommonGroupCodeRepository extends JpaRepository findByUseFlagOrderBySortOrderAsc(Boolean useFlag); - @Query("SELECT cgc FROM CommonGroupCode cgc WHERE cgc.useFlag = :useFlag ORDER BY cgc.sortOrder ASC") - List findActiveGroupCodesOrderBySortOrder(@Param("useFlag") Boolean useFlag); - boolean existsByCode(String code); } diff --git a/src/main/java/com/bio/bio_backend/domain/admin/common_code/service/CommonCodeService.java b/src/main/java/com/bio/bio_backend/domain/admin/common_code/service/CommonCodeService.java index d84e0ca..348f629 100644 --- a/src/main/java/com/bio/bio_backend/domain/admin/common_code/service/CommonCodeService.java +++ b/src/main/java/com/bio/bio_backend/domain/admin/common_code/service/CommonCodeService.java @@ -2,6 +2,7 @@ package com.bio.bio_backend.domain.admin.common_code.service; import com.bio.bio_backend.domain.admin.common_code.dto.CommonCodeDto; import com.bio.bio_backend.domain.admin.common_code.dto.CommonGroupCodeDto; +import com.bio.bio_backend.domain.admin.common_code.dto.GetCommonCodeResponseDto; import java.util.List; @@ -20,9 +21,8 @@ public interface CommonCodeService { void updateCode(String code, CommonCodeDto codeDto); void deleteCode(String code); CommonCodeDto getCode(String code); - List getCodesByGroupCode(String groupCode); + GetCommonCodeResponseDto getCodeWithJoinInfo(String code); List getActiveCodesByGroupCode(String groupCode); - List getCodesByParentCode(String parentCode); List getActiveCodesByParentCode(String parentCode); List getAllCodes(); } diff --git a/src/main/java/com/bio/bio_backend/domain/admin/common_code/service/CommonCodeServiceImpl.java b/src/main/java/com/bio/bio_backend/domain/admin/common_code/service/CommonCodeServiceImpl.java index b9fb8b5..a5b0b32 100644 --- a/src/main/java/com/bio/bio_backend/domain/admin/common_code/service/CommonCodeServiceImpl.java +++ b/src/main/java/com/bio/bio_backend/domain/admin/common_code/service/CommonCodeServiceImpl.java @@ -2,13 +2,13 @@ package com.bio.bio_backend.domain.admin.common_code.service; import com.bio.bio_backend.domain.admin.common_code.dto.CommonCodeDto; import com.bio.bio_backend.domain.admin.common_code.dto.CommonGroupCodeDto; +import com.bio.bio_backend.domain.admin.common_code.dto.GetCommonCodeResponseDto; import com.bio.bio_backend.domain.admin.common_code.entity.CommonCode; import com.bio.bio_backend.domain.admin.common_code.entity.CommonGroupCode; import com.bio.bio_backend.domain.admin.common_code.mapper.CommonCodeMapper; import com.bio.bio_backend.domain.admin.common_code.mapper.CommonGroupCodeMapper; import com.bio.bio_backend.domain.admin.common_code.repository.CommonCodeRepository; import com.bio.bio_backend.domain.admin.common_code.repository.CommonGroupCodeRepository; -import com.bio.bio_backend.global.constants.AppConstants; import com.bio.bio_backend.global.exception.ApiException; import com.bio.bio_backend.global.constants.ApiResponseCode; import lombok.RequiredArgsConstructor; @@ -46,7 +46,7 @@ public class CommonCodeServiceImpl implements CommonCodeService { @Transactional public void updateGroupCode(String code, CommonGroupCodeDto groupCodeDto) { CommonGroupCode existingGroupCode = commonGroupCodeRepository.findByCode(code) - .orElseThrow(() -> new ApiException(ApiResponseCode.COMMON_TARGET_NOT_FOUND, "그룹 코드를 찾을 수 없습니다: " + code)); + .orElseThrow(() -> new ApiException(ApiResponseCode.COMMON_TARGET_NOT_FOUND)); commonGroupCodeMapper.updateCommonGroupCodeFromDto(groupCodeDto, existingGroupCode); commonGroupCodeRepository.save(existingGroupCode); @@ -56,11 +56,11 @@ public class CommonCodeServiceImpl implements CommonCodeService { @Transactional public void deleteGroupCode(String code) { CommonGroupCode groupCode = commonGroupCodeRepository.findByCode(code) - .orElseThrow(() -> new ApiException(ApiResponseCode.COMMON_NOT_FOUND, "그룹 코드를 찾을 수 없습니다: " + code)); + .orElseThrow(() -> new ApiException(ApiResponseCode.COMMON_TARGET_NOT_FOUND)); // 하위 공통 코드가 있는지 확인 if (commonCodeRepository.existsByGroupCode(code)) { - throw new ApiException(ApiResponseCode.COMMON_BAD_REQUEST, "하위 공통 코드가 존재하여 삭제할 수 없습니다: " + code); + throw new ApiException(ApiResponseCode.COMMON_CODE_ERROR_001); } commonGroupCodeRepository.delete(groupCode); @@ -69,7 +69,7 @@ public class CommonCodeServiceImpl implements CommonCodeService { @Override public CommonGroupCodeDto getGroupCode(String code) { CommonGroupCode groupCode = commonGroupCodeRepository.findByCode(code) - .orElseThrow(() -> new ApiException(ApiResponseCode.COMMON_NOT_FOUND, "그룹 코드를 찾을 수 없습니다: " + code)); + .orElseThrow(() -> new ApiException(ApiResponseCode.COMMON_NOT_FOUND)); return commonGroupCodeMapper.toCommonGroupCodeDto(groupCode); } @@ -90,12 +90,12 @@ public class CommonCodeServiceImpl implements CommonCodeService { @Transactional public CommonCodeDto createCode(CommonCodeDto commonCodeDto) { if (commonCodeRepository.existsByCode(commonCodeDto.getCode())) { - throw new ApiException(ApiResponseCode.USER_ID_DUPLICATE, "이미 존재하는 공통 코드입니다: " + commonCodeDto.getCode()); + throw new ApiException(ApiResponseCode.COMMON_CODE_DUPLICATE); } // 그룹 코드 존재 여부 확인 if (!commonGroupCodeRepository.existsByCode(commonCodeDto.getGroupCode())) { - throw new ApiException(ApiResponseCode.COMMON_BAD_REQUEST, "존재하지 않는 그룹 코드입니다: " + commonCodeDto.getGroupCode()); + throw new ApiException(ApiResponseCode.COMMON_CODE_ERROR_002); } CommonCode commonCode = commonCodeMapper.toCommonCode(commonCodeDto); @@ -107,7 +107,7 @@ public class CommonCodeServiceImpl implements CommonCodeService { @Transactional public void updateCode(String code, CommonCodeDto commonCodeDto) { CommonCode existingCommonCode = commonCodeRepository.findByCode(code) - .orElseThrow(() -> new ApiException(ApiResponseCode.COMMON_NOT_FOUND, "공통 코드를 찾을 수 없습니다: " + code)); + .orElseThrow(() -> new ApiException(ApiResponseCode.COMMON_CODE_ERROR_003)); commonCodeMapper.updateCommonCodeFromDto(commonCodeDto, existingCommonCode); commonCodeRepository.save(existingCommonCode); @@ -117,11 +117,11 @@ public class CommonCodeServiceImpl implements CommonCodeService { @Transactional public void deleteCode(String code) { CommonCode commonCode = commonCodeRepository.findByCode(code) - .orElseThrow(() -> new ApiException(ApiResponseCode.COMMON_NOT_FOUND, "공통 코드를 찾을 수 없습니다: " + code)); + .orElseThrow(() -> new ApiException(ApiResponseCode.COMMON_CODE_ERROR_003)); // 하위 공통 코드가 있는지 확인 if (commonCodeRepository.existsByParentCode(code)) { - throw new ApiException(ApiResponseCode.COMMON_BAD_REQUEST, "하위 공통 코드가 존재하여 삭제할 수 없습니다: " + code); + throw new ApiException(ApiResponseCode.COMMON_CODE_ERROR_001); } commonCodeRepository.delete(commonCode); @@ -130,31 +130,25 @@ public class CommonCodeServiceImpl implements CommonCodeService { @Override public CommonCodeDto getCode(String code) { CommonCode commonCode = commonCodeRepository.findByCode(code) - .orElseThrow(() -> new ApiException(ApiResponseCode.COMMON_NOT_FOUND, "공통 코드를 찾을 수 없습니다: " + code)); + .orElseThrow(() -> new ApiException(ApiResponseCode.COMMON_CODE_ERROR_003)); return commonCodeMapper.toCommonCodeDto(commonCode); } @Override - public List getCodesByGroupCode(String groupCode) { - List commonCodes = commonCodeRepository.findByGroupCodeAndUseFlagOrderBySortOrderAsc(groupCode, true); - return commonCodeMapper.toCommonCodeDtoList(commonCodes); + public GetCommonCodeResponseDto getCodeWithJoinInfo(String code) { + return commonCodeRepository.findCodeWithJoinInfo(code) + .orElseThrow(() -> new ApiException(ApiResponseCode.COMMON_CODE_ERROR_003)); } @Override public List getActiveCodesByGroupCode(String groupCode) { - List commonCodes = commonCodeRepository.findActiveCodesByGroupCodeOrderBySortOrder(groupCode, true); - return commonCodeMapper.toCommonCodeDtoList(commonCodes); - } - - @Override - public List getCodesByParentCode(String parentCode) { - List commonCodes = commonCodeRepository.findByParentCodeAndUseFlagOrderBySortOrderAsc(parentCode, true); + List commonCodes = commonCodeRepository.findByGroupCode(groupCode); return commonCodeMapper.toCommonCodeDtoList(commonCodes); } @Override public List getActiveCodesByParentCode(String parentCode) { - List commonCodes = commonCodeRepository.findActiveCodesByParentCodeOrderBySortOrder(parentCode, true); + List commonCodes = commonCodeRepository.findByParentCode(parentCode); return commonCodeMapper.toCommonCodeDtoList(commonCodes); } diff --git a/src/main/java/com/bio/bio_backend/domain/base/file/entity/File.java b/src/main/java/com/bio/bio_backend/domain/base/file/entity/File.java index 8d33e9b..7e824f4 100644 --- a/src/main/java/com/bio/bio_backend/domain/base/file/entity/File.java +++ b/src/main/java/com/bio/bio_backend/domain/base/file/entity/File.java @@ -4,6 +4,7 @@ import com.bio.bio_backend.global.constants.AppConstants; import com.bio.bio_backend.global.entity.BaseEntity; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.Comment; @Entity @Table(name = AppConstants.TABLE_PREFIX + "file") @@ -15,27 +16,35 @@ import lombok.*; public class File extends BaseEntity { @Column(nullable = false) + @Comment("원본 파일명") private String originalFileName; @Column(nullable = false) + @Comment("저장 파일명") private String storedFileName; @Column(nullable = false) + @Comment("파일 경로") private String filePath; - + @Column(nullable = false) + @Comment("파일 크기") private Long fileSize; @Column(nullable = false) + @Comment("콘텐츠 타입") private String contentType; @Column + @Comment("설명") private String description; @Column + @Comment("그룹 OID") private Long groupOid; @Column(nullable = false) + @Comment("사용 여부") @Builder.Default private Boolean useFlag = true; } diff --git a/src/main/java/com/bio/bio_backend/domain/base/file/repository/FileRepository.java b/src/main/java/com/bio/bio_backend/domain/base/file/repository/FileRepository.java index 25bbc13..4c939bb 100644 --- a/src/main/java/com/bio/bio_backend/domain/base/file/repository/FileRepository.java +++ b/src/main/java/com/bio/bio_backend/domain/base/file/repository/FileRepository.java @@ -2,19 +2,12 @@ package com.bio.bio_backend.domain.base.file.repository; import com.bio.bio_backend.domain.base.file.entity.File; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; -import java.util.List; import java.util.Optional; @Repository public interface FileRepository extends JpaRepository { - - // use_flag가 true인 파일만 조회 + Optional findByOidAndUseFlagTrue(Long id); - - // use_flag가 true인 파일만 조회 (List 형태로 필요시 사용) - @Query("SELECT f FROM File f WHERE f.useFlag = true") - List findAllActiveFiles(); } diff --git a/src/main/java/com/bio/bio_backend/domain/base/file/service/FileServiceImpl.java b/src/main/java/com/bio/bio_backend/domain/base/file/service/FileServiceImpl.java index e2413fb..c85ff4d 100644 --- a/src/main/java/com/bio/bio_backend/domain/base/file/service/FileServiceImpl.java +++ b/src/main/java/com/bio/bio_backend/domain/base/file/service/FileServiceImpl.java @@ -9,7 +9,6 @@ import com.bio.bio_backend.domain.base.file.repository.FileRepository; import com.bio.bio_backend.global.exception.ApiException; import com.bio.bio_backend.global.constants.ApiResponseCode; import com.bio.bio_backend.global.utils.FileUtils; -import com.bio.bio_backend.global.utils.OidUtils; import com.bio.bio_backend.global.utils.SecurityUtils; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/bio/bio_backend/domain/base/member/controller/MemberController.java b/src/main/java/com/bio/bio_backend/domain/base/member/controller/MemberController.java index 9fcc38a..89a8dce 100644 --- a/src/main/java/com/bio/bio_backend/domain/base/member/controller/MemberController.java +++ b/src/main/java/com/bio/bio_backend/domain/base/member/controller/MemberController.java @@ -8,8 +8,14 @@ import org.springframework.web.bind.annotation.*; import jakarta.validation.Valid; 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,6 +32,9 @@ import com.bio.bio_backend.global.utils.JwtUtils; import jakarta.servlet.http.HttpServletResponse; +import java.util.List; + + @Tag(name = "Member", description = "회원 관련 API") @RestController @RequestMapping("/members") @@ -53,28 +62,51 @@ public class MemberController { return ResponseEntity.status(HttpStatus.CREATED).body(apiResponse); } + @LogExecution("회원 목록 조회") + @Operation(summary = "회원 목록 조회", description = "활성화된 모든 회원의 목록을 조회합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "회원 목록 조회 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청 파라미터", content = @Content(schema = @Schema(implementation = ApiResponseDto.class))) + }) + @GetMapping + public ResponseEntity>> getMembers(@ModelAttribute GetMembersRequestDto requestDto) { + MemberSearchCondition condition = memberMapper.toSearchCondition(requestDto); + List members = memberService.getMembers(condition); + ApiResponseDto> 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>> getMembersPaged(@ModelAttribute GetMembersPagedRequestDto requestDto) { + MemberSearchCondition condition = memberMapper.toSearchCondition(requestDto); + PagedResult pagedMembers = memberService.getMembersPaged(condition, requestDto.getPage(), requestDto.getSize()); + ApiResponseDto> apiResponse = ApiResponseDto.success(ApiResponseCode.COMMON_SUCCESS, pagedMembers); + log.info("페이지네이션된 회원 목록 조회 완료: 페이지={}, 크기={}, 전체={}명", requestDto.getPage(), requestDto.getSize(), pagedMembers.getTotalElements()); + + return ResponseEntity.ok(apiResponse); + } + @LogExecution("로그아웃") @Operation(summary = "로그아웃", description = "사용자 로그아웃을 처리합니다.") @ApiResponses({ - @ApiResponse(responseCode = "200", description = "로그아웃 성공"), - @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content(schema = @Schema(implementation = ApiResponseDto.class))) + @ApiResponse(responseCode = "200", description = "로그아웃 성공") }) @PostMapping("/logout") public ResponseEntity> 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)); } } diff --git a/src/main/java/com/bio/bio_backend/domain/base/member/dto/GetMemberResponseDto.java b/src/main/java/com/bio/bio_backend/domain/base/member/dto/GetMemberResponseDto.java new file mode 100644 index 0000000..15f1fe9 --- /dev/null +++ b/src/main/java/com/bio/bio_backend/domain/base/member/dto/GetMemberResponseDto.java @@ -0,0 +1,28 @@ +package com.bio.bio_backend.domain.base.member.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 회원 조회용 Response DTO + * 민감한 정보(password, refreshToken, loginIp)는 제외하고 안전한 정보만 포함 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GetMemberResponseDto { + + private Long oid; + private String userId; + private String name; + private String email; + private Boolean useFlag; + private LocalDateTime lastLoginAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/bio/bio_backend/domain/base/member/dto/GetMembersPagedRequestDto.java b/src/main/java/com/bio/bio_backend/domain/base/member/dto/GetMembersPagedRequestDto.java new file mode 100644 index 0000000..f302046 --- /dev/null +++ b/src/main/java/com/bio/bio_backend/domain/base/member/dto/GetMembersPagedRequestDto.java @@ -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; +} diff --git a/src/main/java/com/bio/bio_backend/domain/base/member/dto/GetMembersRequestDto.java b/src/main/java/com/bio/bio_backend/domain/base/member/dto/GetMembersRequestDto.java new file mode 100644 index 0000000..9089adf --- /dev/null +++ b/src/main/java/com/bio/bio_backend/domain/base/member/dto/GetMembersRequestDto.java @@ -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; +} diff --git a/src/main/java/com/bio/bio_backend/domain/base/member/dto/MemberDto.java b/src/main/java/com/bio/bio_backend/domain/base/member/dto/MemberDto.java index 178304f..ab8463a 100644 --- a/src/main/java/com/bio/bio_backend/domain/base/member/dto/MemberDto.java +++ b/src/main/java/com/bio/bio_backend/domain/base/member/dto/MemberDto.java @@ -5,7 +5,6 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import java.time.LocalDateTime; @@ -32,7 +31,7 @@ public class MemberDto implements UserDetails { @Override public Collection getAuthorities() { - return Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")); + return Collections.emptyList(); } @Override diff --git a/src/main/java/com/bio/bio_backend/domain/base/member/dto/MemberSearchCondition.java b/src/main/java/com/bio/bio_backend/domain/base/member/dto/MemberSearchCondition.java new file mode 100644 index 0000000..d70e5c0 --- /dev/null +++ b/src/main/java/com/bio/bio_backend/domain/base/member/dto/MemberSearchCondition.java @@ -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; +} diff --git a/src/main/java/com/bio/bio_backend/domain/base/member/entity/Member.java b/src/main/java/com/bio/bio_backend/domain/base/member/entity/Member.java index f3d68a0..3eb3c95 100644 --- a/src/main/java/com/bio/bio_backend/domain/base/member/entity/Member.java +++ b/src/main/java/com/bio/bio_backend/domain/base/member/entity/Member.java @@ -11,6 +11,8 @@ import lombok.Setter; import java.time.LocalDateTime; +import org.hibernate.annotations.Comment; + @Entity @Getter @Setter @NoArgsConstructor @@ -25,30 +27,36 @@ import java.time.LocalDateTime; public class Member extends BaseEntity { @Column(name = "user_id", nullable = false, length = 100) + @Comment("사용자 ID") private String userId; @Column(name = "password", nullable = false, length = 100) + @Comment("비밀번호") private String password; @Column(name = "name", nullable = false, length = 100) + @Comment("이름") private String name; @Column(name = "email", nullable = false, length = 255) + @Comment("이메일") private String email; - - @Column(name = "use_flag", nullable = false) + @Comment("사용 여부") @Builder.Default private Boolean useFlag = true; @Column(name = "refresh_token", length = 1024) + @Comment("리프레시 토큰") private String refreshToken; @Column(name = "login_ip", length = 45) // IPv6 지원을 위해 45자 + @Comment("로그인 IP") private String loginIp; @Column(name = "last_login_at") + @Comment("마지막 로그인 일시") private LocalDateTime lastLoginAt; } \ No newline at end of file diff --git a/src/main/java/com/bio/bio_backend/domain/base/member/mapper/MemberMapper.java b/src/main/java/com/bio/bio_backend/domain/base/member/mapper/MemberMapper.java index 886ec32..269dbbe 100644 --- a/src/main/java/com/bio/bio_backend/domain/base/member/mapper/MemberMapper.java +++ b/src/main/java/com/bio/bio_backend/domain/base/member/mapper/MemberMapper.java @@ -4,6 +4,10 @@ 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; import com.bio.bio_backend.global.config.GlobalMapperConfig; @@ -57,4 +61,24 @@ public interface MemberMapper { * MemberDto를 LoginResponseDto로 변환 */ LoginResponseDto toLoginResponseDto(MemberDto memberDto); + + /** + * Member 엔티티를 GetMemberResponseDto로 변환 (민감한 정보 제외) + */ + GetMemberResponseDto toGetMemberResponseDto(Member member); + + /** + * Member 엔티티 리스트를 GetMemberResponseDto 리스트로 변환 (민감한 정보 제외) + */ + List toGetMemberResponseDtoList(List members); + + /** + * GetMembersRequestDto를 MemberSearchCondition으로 변환 + */ + MemberSearchCondition toSearchCondition(GetMembersRequestDto request); + + /** + * GetMembersPagedRequestDto를 MemberSearchCondition으로 변환 + */ + MemberSearchCondition toSearchCondition(GetMembersPagedRequestDto request); } diff --git a/src/main/java/com/bio/bio_backend/domain/base/member/repository/MemberRepository.java b/src/main/java/com/bio/bio_backend/domain/base/member/repository/MemberRepository.java index 09be690..f1f18e3 100644 --- a/src/main/java/com/bio/bio_backend/domain/base/member/repository/MemberRepository.java +++ b/src/main/java/com/bio/bio_backend/domain/base/member/repository/MemberRepository.java @@ -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, MemberRep // 활성화된 회원 목록 조회 List findByUseFlagTrue(); + + // 활성화된 회원 목록 조회 (페이지네이션) + Page findByUseFlagTrue(Pageable pageable); } \ No newline at end of file diff --git a/src/main/java/com/bio/bio_backend/domain/base/member/repository/MemberRepositoryCustom.java b/src/main/java/com/bio/bio_backend/domain/base/member/repository/MemberRepositoryCustom.java index 8480762..5c4ee9c 100644 --- a/src/main/java/com/bio/bio_backend/domain/base/member/repository/MemberRepositoryCustom.java +++ b/src/main/java/com/bio/bio_backend/domain/base/member/repository/MemberRepositoryCustom.java @@ -1,12 +1,12 @@ 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; -/** - * QueryDSL을 활용한 커스텀 쿼리 메서드들을 정의하는 인터페이스 - * 복잡한 쿼리나 동적 쿼리가 필요한 경우 이 인터페이스를 구현하여 사용합니다. - */ public interface MemberRepositoryCustom { /** @@ -16,4 +16,21 @@ public interface MemberRepositoryCustom { * @return Optional 회원 정보 (없으면 empty) */ Optional findActiveMemberByUserId(String userId); + + /** + * QueryDSL을 사용한 회원 목록 조회 (검색 조건 + 페이징) + * + * @param condition 검색 조건 + * @param pageable 페이징 정보 + * @return Page 검색된 회원 목록 + */ + Page findMembers(MemberSearchCondition condition, Pageable pageable); + + /** + * QueryDSL을 사용한 회원 목록 조회 (검색 조건) + * + * @param condition 검색 조건 + * @return List 검색된 회원 목록 + */ + List findMembers(MemberSearchCondition condition); } diff --git a/src/main/java/com/bio/bio_backend/domain/base/member/repository/MemberRepositoryImpl.java b/src/main/java/com/bio/bio_backend/domain/base/member/repository/MemberRepositoryImpl.java index feecefd..cc71ceb 100644 --- a/src/main/java/com/bio/bio_backend/domain/base/member/repository/MemberRepositoryImpl.java +++ b/src/main/java/com/bio/bio_backend/domain/base/member/repository/MemberRepositoryImpl.java @@ -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; /** @@ -17,11 +26,6 @@ import java.util.Optional; public class MemberRepositoryImpl implements MemberRepositoryCustom { private final JPAQueryFactory queryFactory; - - /** - * QMember 인스턴스를 생성하여 쿼리에서 사용합니다. - * QueryDSL의 Q클래스를 통해 타입 안전한 쿼리 작성이 가능합니다. - */ private final QMember member = QMember.member; @Override @@ -35,4 +39,91 @@ public class MemberRepositoryImpl implements MemberRepositoryCustom { return Optional.ofNullable(foundMember); } + + @Override + public Page findMembers(MemberSearchCondition condition, Pageable pageable) { + BooleanBuilder builder = buildSearchConditions(condition); + + List 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 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; + } + } diff --git a/src/main/java/com/bio/bio_backend/domain/base/member/scheduler/MemberSyncScheduler.java b/src/main/java/com/bio/bio_backend/domain/base/member/scheduler/MemberSyncScheduler.java new file mode 100644 index 0000000..6942360 --- /dev/null +++ b/src/main/java/com/bio/bio_backend/domain/base/member/scheduler/MemberSyncScheduler.java @@ -0,0 +1,105 @@ +package com.bio.bio_backend.domain.base.member.scheduler; + +import com.bio.bio_backend.domain.base.member.dto.MemberDto; +import com.bio.bio_backend.domain.base.member.service.MemberService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.util.concurrent.ThreadLocalRandom; + +/** + * 멤버 동기화 스케줄러 + * ShedLock을 사용하여 분산 환경에서 중복 실행을 방지합니다. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class MemberSyncScheduler { + + private final MemberService memberService; + + /** + * 1시간마다 멤버 동기화를 실행합니다. + * 현재는 더미 데이터를 생성하지만, 추후 실제 조직도 연동으로 변경 예정입니다. + */ + @Scheduled(cron = "0 0 * * * *") // 1시간마다 + @SchedulerLock(name = "memberSync", lockAtMostFor = "50m", lockAtLeastFor = "5m") + public void syncMembersAtTopOfHour() { + log.info("1시간마다 멤버 동기화 시작"); + try { + int createdCount = createDummyMembers(5); // 1시간마다 5명씩 생성 + log.info("1시간마다 멤버 동기화 완료: {}명 생성", createdCount); + } catch (Exception e) { + log.error("1시간마다 멤버 동기화 실패: {}", e.getMessage(), e); + } + } + + /** + * 매일 새벽 2시에 대량 동기화를 실행합니다. + */ + @Scheduled(cron = "0 0 2 * * *") // 매일 새벽 2시 + @SchedulerLock(name = "memberBulkSync", lockAtMostFor = "30m", lockAtLeastFor = "5m") + public void bulkSyncMembers() { + log.info("일일 대량 멤버 동기화 시작"); + try { + int createdCount = createDummyMembers(20); // 새벽에 20명씩 생성 + log.info("일일 대량 멤버 동기화 완료: {}명 생성", createdCount); + } catch (Exception e) { + log.error("일일 대량 멤버 동기화 실패: {}", e.getMessage(), e); + } + } + + /** + * 더미 멤버를 생성합니다. + * 추후 실제 조직도 API 연동으로 변경될 예정입니다. + * + * @param count 생성할 멤버 수 + * @return 실제 생성된 멤버 수 + */ + private int createDummyMembers(int count) { + int createdCount = 0; + + for (int i = 0; i < count; i++) { + try { + String randomSuffix = String.valueOf(System.currentTimeMillis() + i); + + MemberDto memberDto = MemberDto.builder() + .userId("user_" + randomSuffix) + .password("password123") // 실제로는 더 복잡한 패스워드 생성 + .name(generateRandomName()) + .email("user_" + randomSuffix + "@company.com") + .build(); + + memberService.createMember(memberDto); + createdCount++; + + log.debug("더미 멤버 생성 완료: {}", memberDto.getUserId()); + + } catch (Exception e) { + log.warn("더미 멤버 생성 실패 ({}번째): {}", i + 1, e.getMessage()); + // 개별 멤버 생성 실패는 전체 프로세스를 중단하지 않음 + } + } + + return createdCount; + } + + /** + * 랜덤한 이름을 생성합니다. + * 실제 환경에서는 조직도에서 가져온 실제 이름을 사용합니다. + * + * @return 생성된 랜덤 이름 + */ + private String generateRandomName() { + String[] surnames = {"김", "이", "박", "최", "정", "강", "조", "윤", "장", "임"}; + String[] givenNames = {"민수", "지영", "현우", "수진", "태현", "은지", "동훈", "예린", "준호", "서연"}; + + String surname = surnames[ThreadLocalRandom.current().nextInt(surnames.length)]; + String givenName = givenNames[ThreadLocalRandom.current().nextInt(givenNames.length)]; + + return surname + givenName; + } +} diff --git a/src/main/java/com/bio/bio_backend/domain/base/member/service/MemberService.java b/src/main/java/com/bio/bio_backend/domain/base/member/service/MemberService.java index 39440fa..e8054d8 100644 --- a/src/main/java/com/bio/bio_backend/domain/base/member/service/MemberService.java +++ b/src/main/java/com/bio/bio_backend/domain/base/member/service/MemberService.java @@ -4,9 +4,11 @@ import org.springframework.security.core.userdetails.UserDetails; 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 { @@ -19,6 +21,20 @@ public interface MemberService extends UserDetailsService { void deleteRefreshToken(String id); void updateMember(MemberDto member); - - List selectMemberList(Map params); + + /** + * 검색 조건에 따른 회원 목록 조회 + * @param condition 검색 조건 + * @return GetMemberResponseDto 리스트 + */ + List getMembers(MemberSearchCondition condition); + + /** + * 페이지네이션된 회원 목록 조회 + * @param condition 검색 조건 + * @param page 페이지 번호 + * @param size 페이지 크기 + * @return PagedResult 페이지네이션된 회원 목록 + */ + PagedResult getMembersPaged(MemberSearchCondition condition, int page, int size); } diff --git a/src/main/java/com/bio/bio_backend/domain/base/member/service/MemberServiceImpl.java b/src/main/java/com/bio/bio_backend/domain/base/member/service/MemberServiceImpl.java index 928feb6..47fabac 100644 --- a/src/main/java/com/bio/bio_backend/domain/base/member/service/MemberServiceImpl.java +++ b/src/main/java/com/bio/bio_backend/domain/base/member/service/MemberServiceImpl.java @@ -1,6 +1,9 @@ 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; @@ -12,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 @@ -88,11 +94,20 @@ public class MemberServiceImpl implements MemberService { member.setRefreshToken(null); memberRepository.save(member); } - + @Override - public List selectMemberList(Map params) { - List members = memberRepository.findByUseFlagTrue(); + public List getMembers(MemberSearchCondition condition) { + List members = memberRepository.findMembers(condition); - return memberMapper.toMemberDtoList(members); + return memberMapper.toGetMemberResponseDtoList(members); + } + + @Override + public PagedResult getMembersPaged(MemberSearchCondition condition, int page, int size) { + Pageable pageable = PageRequest.of(page, size); + Page memberPage = memberRepository.findMembers(condition, pageable); + List members = memberMapper.toGetMemberResponseDtoList(memberPage.getContent()); + + return PagedResult.of(memberPage, members); } } diff --git a/src/main/java/com/bio/bio_backend/global/config/SchedulerConfig.java b/src/main/java/com/bio/bio_backend/global/config/SchedulerConfig.java new file mode 100644 index 0000000..f0cc578 --- /dev/null +++ b/src/main/java/com/bio/bio_backend/global/config/SchedulerConfig.java @@ -0,0 +1,38 @@ +package com.bio.bio_backend.global.config; + +import net.javacrumbs.shedlock.core.LockProvider; +import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider; +import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.EnableScheduling; + +import javax.sql.DataSource; + +/** + * 스케줄러 및 분산 락 설정을 위한 Configuration 클래스 + * ShedLock을 사용하여 분산 환경에서 스케줄러 중복 실행을 방지합니다. + */ +@Configuration +@EnableScheduling +@EnableSchedulerLock(defaultLockAtMostFor = "10m") +public class SchedulerConfig { + + /** + * ShedLock용 LockProvider Bean을 생성합니다. + * JDBC 기반으로 분산 락을 관리합니다. + * + * @param dataSource 데이터소스 + * @return JdbcTemplateLockProvider 인스턴스 + */ + @Bean + public LockProvider lockProvider(DataSource dataSource) { + return new JdbcTemplateLockProvider( + JdbcTemplateLockProvider.Configuration.builder() + .withJdbcTemplate(new JdbcTemplate(dataSource)) + .usingDbTime() // DB 시간 사용으로 서버 간 시간차 문제 해결 + .build() + ); + } +} diff --git a/src/main/java/com/bio/bio_backend/global/constants/ApiResponseCode.java b/src/main/java/com/bio/bio_backend/global/constants/ApiResponseCode.java index b10f764..ae7b561 100644 --- a/src/main/java/com/bio/bio_backend/global/constants/ApiResponseCode.java +++ b/src/main/java/com/bio/bio_backend/global/constants/ApiResponseCode.java @@ -84,7 +84,13 @@ public enum ApiResponseCode { // 500 Internal Server Error FILE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "파일 업로드에 실패했습니다"), FILE_DOWNLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "파일 다운로드에 실패했습니다"), - FILE_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "파일 삭제에 실패했습니다"); + FILE_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "파일 삭제에 실패했습니다"), + + /*공통 코드 관련 Code*/ + // 400 Bad Request + COMMON_CODE_ERROR_001(HttpStatus.BAD_REQUEST.value(), "하위 공통 코드가 존재하여 삭제할 수 없습니다."), + COMMON_CODE_ERROR_002(HttpStatus.BAD_REQUEST.value(), "존재하지 않는 그룹 코드입니다"), + COMMON_CODE_ERROR_003(HttpStatus.NOT_FOUND.value(), "공통 코드를 찾을 수 없습니다"); private final int statusCode; private final String description; diff --git a/src/main/java/com/bio/bio_backend/global/dto/BasePagedRequestDto.java b/src/main/java/com/bio/bio_backend/global/dto/BasePagedRequestDto.java new file mode 100644 index 0000000..a8a3c34 --- /dev/null +++ b/src/main/java/com/bio/bio_backend/global/dto/BasePagedRequestDto.java @@ -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; +} diff --git a/src/main/java/com/bio/bio_backend/global/dto/BasePagedResponseDto.java b/src/main/java/com/bio/bio_backend/global/dto/BasePagedResponseDto.java new file mode 100644 index 0000000..9999938 --- /dev/null +++ b/src/main/java/com/bio/bio_backend/global/dto/BasePagedResponseDto.java @@ -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; // 마지막 페이지 여부 +} diff --git a/src/main/java/com/bio/bio_backend/global/dto/PagedResult.java b/src/main/java/com/bio/bio_backend/global/dto/PagedResult.java new file mode 100644 index 0000000..4c5f3a8 --- /dev/null +++ b/src/main/java/com/bio/bio_backend/global/dto/PagedResult.java @@ -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 페이지네이션할 데이터의 타입 + */ +@Data +@NoArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class PagedResult extends BasePagedResponseDto { + + private List content; + + public static PagedResult of(Page page, List content) { + PagedResult 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; + } +} diff --git a/src/main/java/com/bio/bio_backend/global/entity/BaseEntity.java b/src/main/java/com/bio/bio_backend/global/entity/BaseEntity.java index a4ae824..94ae04a 100644 --- a/src/main/java/com/bio/bio_backend/global/entity/BaseEntity.java +++ b/src/main/java/com/bio/bio_backend/global/entity/BaseEntity.java @@ -3,6 +3,8 @@ package com.bio.bio_backend.global.entity; import jakarta.persistence.*; import lombok.Getter; import lombok.Setter; + +import org.hibernate.annotations.Comment; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; @@ -23,26 +25,33 @@ public abstract class BaseEntity { @Id @Column(name = "oid", nullable = false) + @Comment("OID") private Long oid; @CreatedDate @Column(name = "created_at", nullable = false, updatable = false) + @Comment("생성일시") private LocalDateTime createdAt; @LastModifiedDate @Column(name = "updated_at", nullable = false) + @Comment("수정일시") private LocalDateTime updatedAt; @Column(name = "created_oid", updatable = false) + @Comment("생성자 OID") private Long createdOid; @Column(name = "updated_oid") + @Comment("수정자 OID") private Long updatedOid; @Column(name = "created_id", updatable = false) + @Comment("생성자 ID") private String createdId; @Column(name = "updated_id") + @Comment("수정자 ID") private String updatedId; } diff --git a/src/main/java/com/bio/bio_backend/global/filter/HttpLoggingFilter.java b/src/main/java/com/bio/bio_backend/global/filter/HttpLoggingFilter.java index 80ee717..6fc7270 100644 --- a/src/main/java/com/bio/bio_backend/global/filter/HttpLoggingFilter.java +++ b/src/main/java/com/bio/bio_backend/global/filter/HttpLoggingFilter.java @@ -6,104 +6,194 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import org.springframework.core.annotation.Order; import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.util.ContentCachingRequestWrapper; import org.springframework.web.util.ContentCachingResponseWrapper; import java.io.IOException; -import java.util.HashMap; -import java.util.Map; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.*; +import org.slf4j.MDC; @Slf4j @Component +@Order(2) public class HttpLoggingFilter extends OncePerRequestFilter { + private static final int MAX_LOG_BODY = 10 * 1024; // 10 KB + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - + long startTime = System.currentTimeMillis(); - - // 요청 정보 로깅 (IP 정보 제거) - log.info("HTTP REQUEST: {} {} | Headers: {} | Body: {}", - request.getMethod(), request.getRequestURI(), - getRequestHeaders(request), getRequestBody(request)); - - ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(request); - ContentCachingResponseWrapper wrappedResponse = new ContentCachingResponseWrapper(response); - + + ContentCachingRequestWrapper wrappedRequest = + (request instanceof ContentCachingRequestWrapper) + ? (ContentCachingRequestWrapper) request + : new ContentCachingRequestWrapper(request); + + ContentCachingResponseWrapper wrappedResponse = + (response instanceof ContentCachingResponseWrapper) + ? (ContentCachingResponseWrapper) response + : new ContentCachingResponseWrapper(response); + + log.info("********************************************************************************"); + log.info("* [START] HTTP LOGGING | TRACE ID: {}", getCurrentTraceId()); + log.info("* Method: {} | URI: {}", wrappedRequest.getMethod(), wrappedRequest.getRequestURI()); + log.info("* Headers: {}", getRequestHeaders(wrappedRequest)); + log.info("********************************************************************************"); + try { filterChain.doFilter(wrappedRequest, wrappedResponse); } finally { long duration = System.currentTimeMillis() - startTime; - - // 응답 정보 로깅 - log.info("HTTP RESPONSE: {} {} | Status: {} | Time: {}ms | Headers: {} | Body: {}", - request.getMethod(), request.getRequestURI(), - wrappedResponse.getStatus(), duration, getResponseHeaders(wrappedResponse), getResponseBody(wrappedResponse)); - + String reqBody = extractRequestBody(wrappedRequest); + String resBody = extractResponseBody(wrappedResponse); + + log.info("********************************************************************************"); + log.info("* [END] HTTP LOGGING"); + log.info("* Method: {} | URI: {}", wrappedRequest.getMethod(), wrappedRequest.getRequestURI()); + log.info("* Status: {} | Duration: {}ms", wrappedResponse.getStatus(), duration); + log.info("* Req Body: {}", maskSensitiveData(reqBody)); + log.info("* Res Headers: {}", getResponseHeaders(wrappedResponse)); + log.info("* Res Body: {}", maskSensitiveData(resBody)); + log.info("********************************************************************************"); + wrappedResponse.copyBodyToResponse(); } } - - // 민감 정보 마스킹 메서드 + + private String extractRequestBody(ContentCachingRequestWrapper wrapper) { + // multipart/form-data 등은 보통 로깅 제외 + String ct = Optional.ofNullable(wrapper.getContentType()).orElse(""); + if (ct.toLowerCase(Locale.ROOT).startsWith("multipart/")) { + return "(multipart skipped)"; + } + + byte[] buf = wrapper.getContentAsByteArray(); + if (buf == null || buf.length == 0) return ""; + Charset cs = resolveCharset(wrapper.getCharacterEncoding()); + String s = new String(buf, cs); + return truncate(s, MAX_LOG_BODY); + } + + private String extractResponseBody(ContentCachingResponseWrapper wrapper) { + byte[] buf = wrapper.getContentAsByteArray(); + if (buf == null || buf.length == 0) return ""; + + // UTF-8로 고정하여 한글 깨짐 방지 + String s = new String(buf, StandardCharsets.UTF_8); + return truncate(s, MAX_LOG_BODY); + } + + private Charset resolveCharset(String enc) { + try { + return (enc != null) ? Charset.forName(enc) : StandardCharsets.UTF_8; + } catch (Exception e) { + return StandardCharsets.UTF_8; + } + } + + private String truncate(String s, int max) { + if (s == null) return "N/A"; + if (s.length() <= max) return s; + return s.substring(0, max) + "...(truncated)"; + } + + // 민감 정보 마스킹 (간단/범용) private String maskSensitiveData(String body) { if (body == null || body.isEmpty()) return "N/A"; - - // 비밀번호, 토큰 등 민감 정보 마스킹 - return body.replaceAll("\"password\"\\s*:\\s*\"[^\"]*\"", "\"password\":\"***\"") - .replaceAll("\"token\"\\s*:\\s*\"[^\"]*\"", "\"token\":\"***\"") - .replaceAll("\"refreshToken\"\\s*:\\s*\"[^\"]*\"", "\"refreshToken\":\"***\""); + // JSON 키 기준 토큰/비번류 마스킹 (대소문자 무시) + String masked = body + .replaceAll("(?i)(\"password\"\\s*:\\s*\")([^\"]+)(\")", "$1***$3") + .replaceAll("(?i)(\"AccessToken\"\\s*:\\s*\")([^\"]+)(\")", "$1***$3") + .replaceAll("(?i)(\"RefreshToken\"\\s*:\\s*\")([^\"]+)(\")", "$1***$3") + .replaceAll("(?i)(\"authorization\"\\s*:\\s*\")([^\"]+)(\")", "$1***$3"); + // x-www-form-urlencoded 형태의 password 파라미터 마스킹 + masked = masked.replaceAll("(?i)(^|[&])password=([^&]*)", "$1password=***"); + return masked; } - + private String getRequestHeaders(HttpServletRequest request) { - // 주요 헤더만 (Content-Type, Authorization, User-Agent) - Map headers = new HashMap<>(); - headers.put("Content-Type", request.getContentType()); - headers.put("User-Agent", request.getHeader("User-Agent")); - - // Authorization 헤더는 마스킹 - String auth = request.getHeader("Authorization"); - if (auth != null) { - headers.put("Authorization", auth.startsWith("Bearer ") ? "Bearer ***" : "***"); - } - + Map headers = new LinkedHashMap<>(); + // 필요한 헤더만 추리거나, 전부 찍고 민감한 건 마스킹 + Collections.list(request.getHeaderNames()).forEach(name -> { + String value = request.getHeader(name); + if (value == null) { + headers.put(name, null); + return; + } + + if ("cookie".equalsIgnoreCase(name)) { + headers.put(name, maskCookieHeader(value)); + return; + } + + // 기타 헤더는 그대로 기록 (요구사항에 따라 최소 마스킹 적용) + headers.put(name, value); + }); return headers.toString(); } - - private String getRequestBody(HttpServletRequest request) { - try { - ContentCachingRequestWrapper wrapper = (ContentCachingRequestWrapper) request; - String body = new String(wrapper.getContentAsByteArray()); - return maskSensitiveData(body); - } catch (Exception e) { - return "N/A"; - } - } - + private String getResponseHeaders(HttpServletResponse response) { - // 응답 헤더 정보 - return "Content-Type: " + response.getContentType(); - } - - private String getResponseBody(HttpServletResponse response) { - try { - ContentCachingResponseWrapper wrapper = (ContentCachingResponseWrapper) response; - String body = new String(wrapper.getContentAsByteArray()); - return maskSensitiveData(body); - } catch (Exception e) { - return "N/A"; + Map headers = new LinkedHashMap<>(); + for (String name : response.getHeaderNames()) { + if ("set-cookie".equalsIgnoreCase(name)) { + List values = new ArrayList<>(response.getHeaders(name)); + List masked = new ArrayList<>(); + for (String v : values) { + masked.add(maskSetCookieHeader(v)); + } + headers.put(name, String.join(",", masked)); + continue; + } + + headers.put(name, String.join(",", response.getHeaders(name))); } + if (response.getContentType() != null) { + headers.putIfAbsent("Content-Type", response.getContentType()); + } + return headers.toString(); } - + + // Cookie 헤더: "a=1; AccessToken=xxx; RefreshToken=yyy" 형태 중 AccessToken/RefreshToken만 마스킹 + private String maskCookieHeader(String cookieHeader) { + if (cookieHeader == null || cookieHeader.isEmpty()) return cookieHeader; + String masked = cookieHeader + .replaceAll("(?i)(^|;\\s*)(AccessToken)=([^;]*)", "$1$2=***") + .replaceAll("(?i)(^|;\\s*)(RefreshToken)=([^;]*)", "$1$2=***"); + return masked; + } + + // Set-Cookie: "AccessToken=xxx; Path=/; HttpOnly; ..." 형태 중 AccessToken/RefreshToken만 마스킹 + private String maskSetCookieHeader(String setCookie) { + if (setCookie == null || setCookie.isEmpty()) return setCookie; + String masked = setCookie + .replaceAll("(?i)^(AccessToken)=([^;]*)", "$1=***") + .replaceAll("(?i)^(RefreshToken)=([^;]*)", "$1=***"); + return masked; + } + @Override protected boolean shouldNotFilter(HttpServletRequest request) { String path = request.getRequestURI(); - return path.startsWith("/static/") || - path.startsWith("/css/") || - path.startsWith("/js/") || - path.startsWith("/images/") || - path.equals("/actuator/health") || - path.equals("/favicon.ico"); + return path.startsWith("/static/") + || path.startsWith("/css/") + || path.startsWith("/js/") + || path.startsWith("/images/") + || path.equals("/favicon.ico") + || path.startsWith("/actuator/health"); + } + + /** + * 현재 스레드의 TRACE ID를 반환합니다. + * @return TRACE ID 또는 "N/A" + */ + private String getCurrentTraceId() { + String traceId = MDC.get("traceId"); + return traceId != null ? traceId : "N/A"; } } diff --git a/src/main/java/com/bio/bio_backend/global/filter/JwtTokenIssuanceFilter.java b/src/main/java/com/bio/bio_backend/global/filter/JwtTokenIssuanceFilter.java index 1f637f3..4c67fd1 100644 --- a/src/main/java/com/bio/bio_backend/global/filter/JwtTokenIssuanceFilter.java +++ b/src/main/java/com/bio/bio_backend/global/filter/JwtTokenIssuanceFilter.java @@ -66,7 +66,7 @@ public class JwtTokenIssuanceFilter extends UsernamePasswordAuthenticationFilter MemberDto member = (MemberDto) userDetails; // 토큰 생성 - String accessToken = jwtUtils.createAccessToken(member.getUserId()); + String accessToken = jwtUtils.createAccessToken(member.getOid(), member.getUserId(), member.getName()); String refreshToken = jwtUtils.createRefreshToken(member.getUserId(), httpUtils.getClientIp()); member.setRefreshToken(refreshToken); diff --git a/src/main/java/com/bio/bio_backend/global/filter/JwtTokenValidationFilter.java b/src/main/java/com/bio/bio_backend/global/filter/JwtTokenValidationFilter.java index 4149517..5a163ca 100644 --- a/src/main/java/com/bio/bio_backend/global/filter/JwtTokenValidationFilter.java +++ b/src/main/java/com/bio/bio_backend/global/filter/JwtTokenValidationFilter.java @@ -1,12 +1,10 @@ package com.bio.bio_backend.global.filter; import java.io.IOException; -import java.time.LocalDateTime; -import java.util.Objects; import com.bio.bio_backend.domain.base.member.dto.MemberDto; import com.bio.bio_backend.global.utils.HttpUtils; -import org.springframework.core.env.Environment; + import org.springframework.http.MediaType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; @@ -24,7 +22,7 @@ import com.bio.bio_backend.global.dto.ApiResponseDto; import com.bio.bio_backend.domain.base.member.service.MemberService; import com.bio.bio_backend.global.constants.ApiResponseCode; import com.bio.bio_backend.global.utils.JwtUtils; -import com.bio.bio_backend.global.config.SecurityPathConfig; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -35,16 +33,8 @@ public class JwtTokenValidationFilter extends OncePerRequestFilter { private final JwtUtils jwtUtils; private final HttpUtils httpUtils; private final MemberService memberService; - private final Environment env; - private final SecurityPathConfig securityPathConfig; - @Override - protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { - String path = request.getRequestURI(); - String contextPath = env.getProperty("server.servlet.context-path", ""); - - return securityPathConfig.isPermittedPath(path, contextPath); - } + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, @@ -52,13 +42,13 @@ public class JwtTokenValidationFilter extends OncePerRequestFilter { log.debug("JWT 토큰 검증 필터 실행 - URI: {}", request.getRequestURI()); - String accessToken = jwtUtils.extractAccessJwtFromRequest(request); + String accessToken = jwtUtils.extractAccessJwtFromCookie(request); String refreshToken = jwtUtils.extractRefreshJwtFromCookie(request); // Access Token이 있고 유효한 경우 if (accessToken != null && jwtUtils.validateAccessToken(accessToken)) { String username = jwtUtils.extractUsername(accessToken); - UserDetails userDetails = memberService.loadUserByUsername(username); + UserDetails userDetails = jwtUtils.createUserDetailsFromToken(accessToken); if (userDetails != null) { UsernamePasswordAuthenticationToken authentication = @@ -94,7 +84,11 @@ public class JwtTokenValidationFilter extends OncePerRequestFilter { UserDetails userDetails = memberService.loadUserByUsername(username); // 새로운 Access Token 생성 - String newAccessToken = jwtUtils.createAccessToken(username); + String newAccessToken = jwtUtils.createAccessToken( + ((MemberDto) userDetails).getOid(), + userDetails.getUsername(), + ((MemberDto) userDetails).getName() + ); // 새로운 Access Token을 쿠키에 설정 jwtUtils.setAccessTokenCookie(response, newAccessToken); diff --git a/src/main/java/com/bio/bio_backend/global/filter/TraceIdFilter.java b/src/main/java/com/bio/bio_backend/global/filter/TraceIdFilter.java new file mode 100644 index 0000000..b91647e --- /dev/null +++ b/src/main/java/com/bio/bio_backend/global/filter/TraceIdFilter.java @@ -0,0 +1,51 @@ +package com.bio.bio_backend.global.filter; + +import org.slf4j.MDC; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.UUID; + +@Component +@Order(1) +public class TraceIdFilter extends OncePerRequestFilter { + + private static final String TRACE_ID_HEADER = "X-Trace-Id"; + private static final String TRACE_ID_MDC_KEY = "traceId"; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + try { + // 헤더에서 TRACE ID를 가져오거나 새로 생성 + String traceId = request.getHeader(TRACE_ID_HEADER); + if (traceId == null || traceId.trim().isEmpty()) { + traceId = generateTraceId(); + } + + // MDC에 TRACE ID 설정 + MDC.put(TRACE_ID_MDC_KEY, traceId); + + // 응답 헤더에 TRACE ID 추가 + response.addHeader(TRACE_ID_HEADER, traceId); + + filterChain.doFilter(request, response); + + } finally { + // 요청 처리 완료 후 MDC 정리 + MDC.remove(TRACE_ID_MDC_KEY); + } + } + + private String generateTraceId() { + // UUID 기반 TRACE ID 생성 (8자리로 축약) + return UUID.randomUUID().toString().substring(0, 8); + } +} diff --git a/src/main/java/com/bio/bio_backend/global/security/WebSecurity.java b/src/main/java/com/bio/bio_backend/global/security/WebSecurity.java index b209f47..ae8e7c4 100644 --- a/src/main/java/com/bio/bio_backend/global/security/WebSecurity.java +++ b/src/main/java/com/bio/bio_backend/global/security/WebSecurity.java @@ -4,7 +4,8 @@ import com.bio.bio_backend.global.filter.JwtTokenIssuanceFilter; import com.bio.bio_backend.global.filter.JwtTokenValidationFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.core.env.Environment; +import org.springframework.core.annotation.Order; + import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -23,7 +24,6 @@ import com.bio.bio_backend.domain.base.member.mapper.MemberMapper; import com.bio.bio_backend.global.exception.CustomAuthenticationFailureHandler; import com.bio.bio_backend.global.utils.JwtUtils; import com.bio.bio_backend.global.utils.HttpUtils; -import com.bio.bio_backend.global.config.SecurityPathConfig; import lombok.RequiredArgsConstructor; @Configuration @@ -35,12 +35,10 @@ public class WebSecurity { private final BCryptPasswordEncoder bCryptPasswordEncoder; private final JwtUtils jwtUtils; private final ObjectMapper objectMapper; - private final Environment env; - private final SecurityPathConfig securityPathConfig; private final HttpUtils httpUtils; private final MemberMapper memberMapper; - private JwtTokenIssuanceFilter getJwtTokenIssuanceFilter(AuthenticationManager authenticationManager) throws Exception { + private JwtTokenIssuanceFilter getJwtTokenIssuanceFilter(AuthenticationManager authenticationManager) { JwtTokenIssuanceFilter filter = new JwtTokenIssuanceFilter(authenticationManager, jwtUtils, objectMapper, memberService, httpUtils, memberMapper); filter.setFilterProcessesUrl("/login"); filter.setAuthenticationFailureHandler(new CustomAuthenticationFailureHandler(objectMapper)); @@ -48,14 +46,62 @@ public class WebSecurity { } private JwtTokenValidationFilter getJwtTokenValidationFilter() { - return new JwtTokenValidationFilter(jwtUtils, httpUtils, memberService, env, securityPathConfig); + return new JwtTokenValidationFilter(jwtUtils, httpUtils, memberService); } - + /** + * 공개 경로용 SecurityFilterChain (우선순위 1) + * + * 처리 경로: + * - 인증 관련: /login, /logout, /members/register + * - API 문서: /swagger-ui/**, /api-docs/** + * - WebSocket: /ws/** + * - 모니터링: /actuator/** + * + * 모든 경로는 인증 없이 접근 가능 + */ @Bean - SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + @Order(1) + SecurityFilterChain publicChain(HttpSecurity http) throws Exception { + http + .securityMatcher( + // 인증 관련 공개 경로 + "/logout", "/members/register", + + // Swagger UI 관련 경로 + "/swagger-ui/**", "/swagger-ui.html", "/swagger-ui/index.html", + + // API 문서 관련 경로 + "/api-docs", "/api-docs/**", "/v3/api-docs", "/v3/api-docs/**", + + // WebSocket 관련 경로 + "/ws/**", + + // Actuator 모니터링 경로 + "/actuator/**", "/actuator/health/**", "/actuator/info" + ) + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ); + return http.build(); + } + /** + * API 경로용 SecurityFilterChain (우선순위 2) + * + * 처리 경로: publicChain에서 처리되지 않는 모든 경로 + * - 비즈니스 API 엔드포인트 + * - 사용자 데이터 관련 API + * - 관리자 기능 API + * + * 모든 요청에 대해 JWT 토큰 인증 필수 + */ + @Bean + @Order(2) + SecurityFilterChain apiChain(HttpSecurity http) throws Exception { // AuthenticationManager 설정 AuthenticationManagerBuilder authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder.class); @@ -63,25 +109,16 @@ public class WebSecurity { AuthenticationManager authenticationManager = authenticationManagerBuilder.build(); - // 설정 파일에서 허용할 경로 가져오기 - String[] permitAllPaths = securityPathConfig.getPermitAllPaths().toArray(new String[0]); - - http.csrf(AbstractHttpConfigurer::disable) //csrf 비활성화 - .authorizeHttpRequests(request -> //request 허용 설정 - request - .requestMatchers(permitAllPaths).permitAll() // 설정 파일에서 허용할 경로 - .anyRequest().authenticated() // 나머지 요청은 인증 필요 - ) - .authenticationManager(authenticationManager) - .sessionManagement(session -> - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션 사용 안함 - ) - .logout(AbstractHttpConfigurer::disable); http - // 1단계: JWT 토큰 발급 필터 (로그인 요청 처리 및 토큰 발급) - .addFilterBefore(getJwtTokenIssuanceFilter(authenticationManager), UsernamePasswordAuthenticationFilter.class) - // 2단계: JWT 토큰 검증 필터 (자동 토큰 갱신 포함) - .addFilterBefore(getJwtTokenValidationFilter(), UsernamePasswordAuthenticationFilter.class); + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) + .authenticationManager(authenticationManager) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .logout(AbstractHttpConfigurer::disable) + .addFilterBefore(getJwtTokenIssuanceFilter(authenticationManager), UsernamePasswordAuthenticationFilter.class) // 토큰 발급 + .addFilterBefore(getJwtTokenValidationFilter(), UsernamePasswordAuthenticationFilter.class); // 토큰 검증 return http.build(); } diff --git a/src/main/java/com/bio/bio_backend/global/utils/CommonCodeUtils.java b/src/main/java/com/bio/bio_backend/global/utils/CommonCodeUtils.java new file mode 100644 index 0000000..cbfeeb2 --- /dev/null +++ b/src/main/java/com/bio/bio_backend/global/utils/CommonCodeUtils.java @@ -0,0 +1,132 @@ +package com.bio.bio_backend.global.utils; + +import org.springframework.stereotype.Component; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import com.bio.bio_backend.domain.admin.common_code.dto.CommonCodeDto; +import com.bio.bio_backend.domain.admin.common_code.dto.CommonGroupCodeDto; +import com.bio.bio_backend.domain.admin.common_code.service.CommonCodeService; +import java.util.List; +import java.util.Optional; + +/** + * 공통 코드 관련 유틸리티 클래스 + * 다른 서비스의 비즈니스 로직에서 공통 코드를 쉽게 조회할 수 있도록 제공 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CommonCodeUtils { + + private final CommonCodeService commonCodeService; + + /** + * 코드로 공통 코드 조회 + */ + public CommonCodeDto getCode(String code) { + try { + return commonCodeService.getCode(code); + } catch (Exception e) { + log.warn("공통 코드 조회 실패: code={}, error={}", code, e.getMessage()); + return null; + } + } + + /** + * 코드로 공통 코드 조회 (Optional 반환) + */ + public Optional getCodeOptional(String code) { + try { + return Optional.of(commonCodeService.getCode(code)); + } catch (Exception e) { + log.warn("공통 코드 조회 실패: code={}, error={}", code, e.getMessage()); + return Optional.empty(); + } + } + + /** + * 그룹 코드로 활성화된 공통 코드 목록 조회 + */ + public List getActiveCodesByGroup(String groupCode) { + try { + return commonCodeService.getActiveCodesByGroupCode(groupCode); + } catch (Exception e) { + log.warn("그룹별 공통 코드 조회 실패: groupCode={}, error={}", groupCode, e.getMessage()); + return List.of(); + } + } + + /** + * 부모 코드로 활성화된 공통 코드 목록 조회 + */ + public List getActiveCodesByParent(String parentCode) { + try { + return commonCodeService.getActiveCodesByParentCode(parentCode); + } catch (Exception e) { + log.warn("부모 코드별 공통 코드 조회 실패: parentCode={}, error={}", parentCode, e.getMessage()); + return List.of(); + } + } + + /** + * 그룹 코드 정보 조회 + */ + public CommonGroupCodeDto getGroupCode(String groupCode) { + try { + return commonCodeService.getGroupCode(groupCode); + } catch (Exception e) { + log.warn("그룹 코드 조회 실패: groupCode={}, error={}", groupCode, e.getMessage()); + return null; + } + } + + /** + * 코드명으로 공통 코드 조회 (그룹 내에서) + */ + public CommonCodeDto getCodeByName(String groupCode, String name) { + try { + List codes = commonCodeService.getActiveCodesByGroupCode(groupCode); + return codes.stream() + .filter(code -> name.equals(code.getName())) + .findFirst() + .orElse(null); + } catch (Exception e) { + log.warn("이름으로 공통 코드 조회 실패: groupCode={}, name={}, error={}", + groupCode, name, e.getMessage()); + return null; + } + } + + /** + * 코드 존재 여부 확인 + */ + public boolean existsCode(String code) { + return getCodeOptional(code).isPresent(); + } + + /** + * 그룹 코드 존재 여부 확인 + */ + public boolean existsGroupCode(String groupCode) { + try { + commonCodeService.getGroupCode(groupCode); + return true; + } catch (Exception e) { + return false; + } + } + + /** + * 코드 값 검증 (코드가 존재하고 활성화되어 있는지 확인) + */ + public boolean isValidCode(String groupCode, String code) { + try { + CommonCodeDto codeDto = commonCodeService.getCode(code); + return codeDto != null && + groupCode.equals(codeDto.getGroupCode()) && + Boolean.TRUE.equals(codeDto.getUseFlag()); + } catch (Exception e) { + return false; + } + } +} diff --git a/src/main/java/com/bio/bio_backend/global/utils/JwtUtils.java b/src/main/java/com/bio/bio_backend/global/utils/JwtUtils.java index 3b5f64c..eec01da 100644 --- a/src/main/java/com/bio/bio_backend/global/utils/JwtUtils.java +++ b/src/main/java/com/bio/bio_backend/global/utils/JwtUtils.java @@ -8,10 +8,12 @@ import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import com.bio.bio_backend.domain.base.member.service.MemberService; +import com.bio.bio_backend.domain.base.member.dto.MemberDto; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.env.Environment; +import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; @@ -34,10 +36,12 @@ public class JwtUtils { return Keys.hmacShaKeyFor(keyBytes); } - // Token 생성 - public String generateToken(String username, long expirationTime) { + // Token 생성 (사용자 정보 포함) + public String generateToken(Long oid, String username, String name, long expirationTime) { return Jwts.builder() .subject(username) + .claim("oid", oid) + .claim("name", name) .issuedAt(new Date(System.currentTimeMillis())) .expiration(new Date(System.currentTimeMillis() + expirationTime)) .signWith(getSigningKey()) @@ -56,11 +60,12 @@ public class JwtUtils { } // Access Token 생성 - public String createAccessToken(String username) { + public String createAccessToken(Long oid, String username, String name) { long expirationTime = Long.parseLong(Objects.requireNonNull(env.getProperty("token.expiration_time_access"))); - return generateToken(username, expirationTime); + return generateToken(oid, username, name, expirationTime); } + // Refresh Token 생성 시 IP 정보 포함 public String createRefreshToken(String username, String clientIp) { long expirationTime = Long.parseLong(Objects.requireNonNull(env.getProperty("token.expiration_time_refresh"))); @@ -131,7 +136,7 @@ public class JwtUtils { } // Access Token을 쿠키에서 추출 - public String extractAccessJwtFromRequest(HttpServletRequest request) { + public String extractAccessJwtFromCookie(HttpServletRequest request) { if (request.getCookies() != null) { for (Cookie cookie : request.getCookies()) { if ("AccessToken".equals(cookie.getName())) { @@ -191,4 +196,23 @@ public class JwtUtils { cookie.setMaxAge(maxAge); response.addCookie(cookie); } + + public UserDetails createUserDetailsFromToken(String accessToken) { + try { + Claims claims = extractAllClaims(accessToken); + String username = claims.getSubject(); + Long oid = claims.get("oid", Long.class); + String name = claims.get("name", String.class); + + // 토큰에서 직접 UserDetails 생성 (DB 조회 없음) + return MemberDto.builder() + .oid(oid) + .userId(username) + .name(name) + .build(); + } catch (Exception e) { + log.debug("토큰에서 UserDetails 생성 실패: {}", e.getMessage()); + return null; + } + } } diff --git a/src/main/java/com/bio/bio_backend/global/utils/TraceIdUtils.java b/src/main/java/com/bio/bio_backend/global/utils/TraceIdUtils.java new file mode 100644 index 0000000..27c3187 --- /dev/null +++ b/src/main/java/com/bio/bio_backend/global/utils/TraceIdUtils.java @@ -0,0 +1,27 @@ +package com.bio.bio_backend.global.utils; + +import org.slf4j.MDC; + +/** + * TRACE ID 관리를 위한 유틸리티 클래스 + */ +public class TraceIdUtils { + + private static final String TRACE_ID_KEY = "traceId"; + + /** + * 현재 스레드의 TRACE ID를 반환합니다. + * @return TRACE ID 또는 null + */ + public static String getCurrentTraceId() { + return MDC.get(TRACE_ID_KEY); + } + + /** + * TRACE ID가 존재하는지 확인합니다. + * @return TRACE ID 존재 여부 + */ + public static boolean hasTraceId() { + return getCurrentTraceId() != null; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 31078e2..ccdff03 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -15,7 +15,7 @@ spring.devtools.restart.additional-paths=src/main/java # ======================================== # 데이터베이스 설정 # ======================================== -spring.datasource.url=jdbc:postgresql://stam.kr:15432/imas +spring.datasource.url=jdbc:postgresql://stam.kr:15432/imas?options=-c%20TimeZone=Asia/Seoul spring.datasource.username=imas_user spring.datasource.password=stam1201 spring.datasource.driver-class-name=org.postgresql.Driver @@ -24,6 +24,10 @@ spring.datasource.driver-class-name=org.postgresql.Driver # spring.datasource.username=${DB_USERNAME:} # spring.datasource.password=${DB_PASSWORD:} +# 항상 schema_initial.sql, data.sql 실행 +spring.sql.init.mode=always +spring.sql.init.schema-locations=classpath:schema_initial.sql + # ======================================== # JPA/Hibernate 설정 # ======================================== @@ -32,7 +36,7 @@ spring.jpa.hibernate.ddl-auto=none spring.jpa.open-in-view=false spring.jpa.show-sql=false spring.jpa.properties.hibernate.format_sql=true -spring.jpa.properties.hibernate.highlight_sql=true +spring.jpa.properties.hibernate.highlight_sql=false spring.jpa.properties.hibernate.use_sql_comments=false # 배치 처리 설정 @@ -43,7 +47,7 @@ spring.jpa.properties.hibernate.order_updates=true # 스키마 생성 설정 spring.jpa.properties.javax.persistence.schema-generation.scripts.action=create -spring.jpa.properties.javax.persistence.schema-generation.scripts.create-target=ddl/schema.sql +spring.jpa.properties.javax.persistence.schema-generation.scripts.create-target=ddl/schema_entity.sql spring.jpa.properties.hibernate.hbm2ddl.schema-generation.script.append=false # ======================================== @@ -86,7 +90,6 @@ logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %m # P6Spy 설정 (SQL 로깅) # ======================================== decorator.datasource.p6spy.enable-logging=true -decorator.datasource.p6spy.log-format=%(sqlSingleLine) # ======================================== # JWT 설정 @@ -111,10 +114,6 @@ springdoc.swagger-ui.disable-swagger-default-url=true springdoc.default-produces-media-type=application/json springdoc.default-consumes-media-type=application/json -# ======================================== -# 보안 설정 - 허용할 경로 -security.permit-all-paths=/login,/logout,/members/register,/swagger-ui/**,/swagger-ui.html,/swagger-ui/index.html,/api-docs,/api-docs/**,/v3/api-docs,/v3/api-docs/**,/ws/**,/actuator/**,/actuator/health/**,/actuator/info - # 파일 업로드 설정 # ======================================== spring.servlet.multipart.enabled=true diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 1eab898..64c736d 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -3,7 +3,7 @@ - %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n + %d{yyyy-MM-dd HH:mm:ss} [%thread] [%X{traceId}] %-5level %logger{36} - %msg%n @@ -21,7 +21,7 @@ 1GB - %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n + %d{yyyy-MM-dd HH:mm:ss} [%thread] [%X{traceId}] %-5level %logger{36} - %msg%n @@ -40,7 +40,7 @@ 500MB - %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n + %d{yyyy-MM-dd HH:mm:ss} [%thread] [%X{traceId}] %-5level %logger{36} - %msg%n diff --git a/src/main/resources/schema_initial.sql b/src/main/resources/schema_initial.sql new file mode 100644 index 0000000..c3bd1e4 --- /dev/null +++ b/src/main/resources/schema_initial.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS shedlock ( + name varchar(64) NOT NULL, + lock_until timestamptz(3) NOT NULL, + locked_at timestamptz(3) NOT NULL, + locked_by varchar(255) NOT NULL, + CONSTRAINT shedlock_pkey PRIMARY KEY (name) +); \ No newline at end of file diff --git a/src/main/resources/spy.properties b/src/main/resources/spy.properties new file mode 100644 index 0000000..2e939b8 --- /dev/null +++ b/src/main/resources/spy.properties @@ -0,0 +1,18 @@ +################################################################## +# P6Spy 기본 설정 +################################################################## +appender=com.p6spy.engine.spy.appender.Slf4JLogger +logMessageFormat=com.p6spy.engine.spy.appender.CustomLineFormat +logLevel=info + +################################################################## +# SQL 포맷 +################################################################## +customLogMessageFormat=%(currentTime) | %(executionTime) ms | %(category) | connection %(connectionId) | %(sqlSingleLine) +excludecategories=info,debug,result,commit,resultset + +################################################################## +# filter +################################################################## +filter=true +executionThreshold=1 \ No newline at end of file