diff --git a/README.md b/README.md index b074b4a..d78e068 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,7 @@ OAuth 2.0 가이드 문서에 JSON 토큰에 대한 설명도 나와 있습니 회원 프로필 테이블입니다. 회원 테이블과 역할이 조금 다릅니다. 회원 테이블이 회원의 권한, 로그인 정보 등의 데이터를 담는다면, 회원 프로필 테이블은 프로필 이미지, 자기소개, 연락처, 이름 등 회원 프로필 데이터를 가지고 있습니다. -- `member_id` +- `id` - `member` 테이블을 참조하는 FK이자, `member_profile` 테이블의 PK입니다. `member_profile` 테이블과 `member` 테이블은 식별 관계입니다. ### `available_study_time` diff --git a/docs/extract-member-id/extract-member-id.md b/docs/extract-member-id/extract-member-id.md index f2eb7d1..cd58390 100644 --- a/docs/extract-member-id/extract-member-id.md +++ b/docs/extract-member-id/extract-member-id.md @@ -50,7 +50,7 @@ public class TestController { ```json { - "member_id": 1, + "id": 1, "created_at": "2025-05-04 20:24:28.000000", "updated_at": "2025-05-04 20:24:32.000000", "auto_matching": "F", @@ -178,4 +178,4 @@ public class TestController { ## 주의사항 -`SecurityContextHolder`, `OAuth2AuthenticationPrincipal` 등은 Spring Security 기술이고, **변할 수 있는 것**입니다. 이 때문에 **Service 객체에 Spring Security 기술을 노출시켜서는 안 됩니다**. 왜냐하면 Service 객체는 비즈니스 로직을 가지고 있는 Application 코드이고, 비즈니스 로직 코드는 요구사항이 변경될 경우를 제외하고 변경되어서는 안 됩니다. 다시 말해, 비즈니스 로직 코드는 외부 기술에 의존해서는 안 된다는 것입니다. Service 객체 안에서 `SecurityContextHolder`에 접근하여 `Authentication` 객체를 꺼내는 코드를 작성한다면 그 Service 객체는 Spring Security라는 기술에 종속됩니다. 웬만하면 계속 Spring Security를 사용하겠지만, 그래도 Spring Security가 아닌 다른 기술을 사용하게 되었다고 가정해 봅시다. 그러면 Spring Security에 종속된 Service 객체의 코드 변경 없이 Spring Security를 대체할 수 없게 됩니다. 저희 프로젝트 규모가 더 커져서 막 코드가 10만 줄이 넘어가는데, Application 코드 곳곳에 Spring Security에 대한 의존성이 발견된다고 합시다. 그러면 이 프로젝트는 최후의 순간까지 Spring Security를 사용해야 합니다. 더 개쩌는 기술이 나온다고 하더라도요. \ No newline at end of file +`SecurityContextHolder`, `OAuth2AuthenticationPrincipal` 등은 Spring Security 기술이고, **변할 수 있는 것**입니다. 이 때문에 **Service 객체에 Spring Security 기술을 노출시켜서는 안 됩니다**. 왜냐하면 Service 객체는 비즈니스 로직을 가지고 있는 Application 코드이고, 비즈니스 로직 코드는 요구사항이 변경될 경우를 제외하고 변경되어서는 안 됩니다. 다시 말해, 비즈니스 로직 코드는 외부 기술에 의존해서는 안 된다는 것입니다. Service 객체 안에서 `SecurityContextHolder`에 접근하여 `Authentication` 객체를 꺼내는 코드를 작성한다면 그 Service 객체는 Spring Security라는 기술에 종속됩니다. 웬만하면 계속 Spring Security를 사용하겠지만, 그래도 Spring Security가 아닌 다른 기술을 사용하게 되었다고 가정해 봅시다. 그러면 Spring Security에 종속된 Service 객체의 코드 변경 없이 Spring Security를 대체할 수 없게 됩니다. 저희 프로젝트 규모가 더 커져서 막 코드가 10만 줄이 넘어가는데, Application 코드 곳곳에 Spring Security에 대한 의존성이 발견된다고 합시다. 그러면 이 프로젝트는 최후의 순간까지 Spring Security를 사용해야 합니다. 더 개쩌는 기술이 나온다고 하더라도요. diff --git a/init_data/ZTO_LOCAL_DDL.sql b/init_data/ZTO_LOCAL_DDL.sql index f098709..b00b7be 100644 --- a/init_data/ZTO_LOCAL_DDL.sql +++ b/init_data/ZTO_LOCAL_DDL.sql @@ -18,7 +18,7 @@ SET FOREIGN_KEY_CHECKS = 1; CREATE TABLE `member` ( - `member_id` bigint PRIMARY KEY AUTO_INCREMENT COMMENT '회원 Identifier', + `id` bigint PRIMARY KEY AUTO_INCREMENT COMMENT '회원 Identifier', `oidc_id` varchar(50) COMMENT 'Open ID Connect에 의한 ID', `login_id` VARCHAR(50) COMMENT '로그인할 때 사용되는 ID (자체 로그인)', `member_status` VARCHAR(8) NOT NULL DEFAULT 'ACTIVE' COMMENT '회원 상태', @@ -29,18 +29,18 @@ CREATE TABLE `member` ( ); CREATE TABLE `role` ( - `role_id` varchar(20) PRIMARY KEY COMMENT '권한 ID', + `id` varchar(20) PRIMARY KEY COMMENT '권한 ID', `role_name` varchar(20) NOT NULL COMMENT '권한명' ); CREATE TABLE `access_permission` ( - `permission_id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '접근허가 ID', + `id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '접근허가 ID', `endpoint` VARCHAR(80) NOT NULL COMMENT '엔드포인트', `http_method` VARCHAR(8) NOT NULL COMMENT 'HTTP 메소드 (Upper Case)' /* CHAR로 변경 고려 */ ); CREATE TABLE `access_permission_role` ( - `access_permission_role_id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '접근허가_롤 ID', + `id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '접근허가_롤 ID', `permission_id` BIGINT NOT NULL COMMENT '접근허가 참조 FK', `role_id` VARCHAR(20) NOT NULL COMMENT '권한 참조 FK' ); @@ -59,7 +59,7 @@ CREATE TABLE `member_profile` ( ); CREATE TABLE `member_interest` ( - member_interest_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '관심사 ID', + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '관심사 ID', name VARCHAR(20) NOT NULL COMMENT '관심사 이름', member_id BIGINT NOT NULL COMMENT '회원 프로필 참조 FK', `created_at` timestamp NOT NULL COMMENT '생성시간', @@ -67,7 +67,7 @@ CREATE TABLE `member_interest` ( ); CREATE TABLE `available_study_time` ( - available_study_time_id BIGINT PRIMARY KEY COMMENT '스터디 가능 시간대 ID', + id BIGINT PRIMARY KEY COMMENT '스터디 가능 시간대 ID', from_time TIME(6) COMMENT '스터디 가능 시작 시간 (날짜 빼고 시간만 사용)', to_time TIME(6) COMMENT '스터디 가능 끝 시간 (날짜 빼고 시간만 사용)', label VARCHAR(10) NOT NULL COMMENT '시간대 라벨 (오전, 오후, 저녁 등등)', @@ -76,13 +76,13 @@ CREATE TABLE `available_study_time` ( ); CREATE TABLE `profile_avl_time` ( - available_study_time_id BIGINT NOT NULL COMMENT '스터디 가능 시간대 참조 FK', + id BIGINT NOT NULL COMMENT '스터디 가능 시간대 참조 FK', member_id BIGINT NOT NULL COMMENT '회원 프로필 참조 FK', PRIMARY KEY (available_study_time_id, member_id) ); CREATE TABLE `image` ( - `image_id` bigint PRIMARY KEY AUTO_INCREMENT COMMENT '이미지 ID', + `id` bigint PRIMARY KEY AUTO_INCREMENT COMMENT '이미지 ID', `location` VARCHAR(80) NOT NULL COMMENT '이미지가 저장된 장소 (도메인 URL)', `created_at` timestamp DEFAULT NOW() COMMENT '생성시간', `updated_at` timestamp DEFAULT NOW() COMMENT '수정시간', @@ -90,7 +90,7 @@ CREATE TABLE `image` ( ); CREATE TABLE `resized_image` ( - `resized_image_id` bigint AUTO_INCREMENT COMMENT '리사이징 이미지 ID', + `id` bigint AUTO_INCREMENT COMMENT '리사이징 이미지 ID', `image_id` bigint NOT NULL COMMENT '이미지 참조 FK', -- ENUM('THUMB','SMALL','LARGE','ETC') -> VARCHAR(10) `resized_image_url` varchar(255) NOT NULL COMMENT '리사이징 이미지 URL', @@ -100,13 +100,13 @@ CREATE TABLE `resized_image` ( ); CREATE TABLE `social_media_type` ( - `social_media_type_id` VARCHAR(20) PRIMARY KEY, + `id` VARCHAR(20) PRIMARY KEY, `social_media_name` varchar(100) NOT NULL, `icon_id` BIGINT ); CREATE TABLE `social_media` ( - `social_media_id` BIGINT PRIMARY KEY AUTO_INCREMENT, + `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `member_id` bigint NOT NULL, `social_media_type_id` VARCHAR(255) NOT NULL, `url` varchar(255) NOT NULL, @@ -115,7 +115,7 @@ CREATE TABLE `social_media` ( ); CREATE TABLE `tech_stack` ( - tech_stack_id BIGINT AUTO_INCREMENT PRIMARY KEY, + id BIGINT AUTO_INCREMENT PRIMARY KEY, code CHAR(3) NOT NULL UNIQUE, tech_stack_name VARCHAR(100) NOT NULL, parent_id BIGINT, @@ -126,8 +126,8 @@ CREATE TABLE `tech_stack` ( ); CREATE TABLE `tech_stack_ref` ( - `tech_stack_ref_id` bigint PRIMARY KEY AUTO_INCREMENT, + `id` bigint PRIMARY KEY AUTO_INCREMENT, `tech_stack_id` BIGINT NOT NULL, `member_id` bigint NOT NULL, `type` VARCHAR(30) NOT NULL -); \ No newline at end of file +); diff --git a/src/main/java/com/codezerotoone/mvp/domain/common/BaseEntity.java b/src/main/java/com/codezerotoone/mvp/domain/common/BaseEntity.java index 068aab7..b2992d3 100644 --- a/src/main/java/com/codezerotoone/mvp/domain/common/BaseEntity.java +++ b/src/main/java/com/codezerotoone/mvp/domain/common/BaseEntity.java @@ -29,11 +29,13 @@ public abstract class BaseEntity { protected BaseEntity() { } - public LocalDateTime getCreatedAt() { + // 변경 사유: 자신의 날짜는 여기저기서 꺼내 볼 수 있게 하는 것보다 스스로가 관리하는 것이 객체지향의 원칙에도, DDD의 지향점에도 부합한다고 생각됩니다. + protected LocalDateTime getCreatedAt() { return createdAt; } - public LocalDateTime getUpdatedAt() { + // 변경 사유: 자신의 날짜는 여기저기서 꺼내 볼 수 있게 하는 것보다 스스로가 관리하는 것이 객체지향의 원칙에도, DDD의 지향점에도 부합한다고 생각됩니다. + protected LocalDateTime getUpdatedAt() { return updatedAt; } diff --git a/src/main/java/com/codezerotoone/mvp/domain/common/BaseGeneralEntity.java b/src/main/java/com/codezerotoone/mvp/domain/common/BaseGeneralEntity.java new file mode 100644 index 0000000..fc138dd --- /dev/null +++ b/src/main/java/com/codezerotoone/mvp/domain/common/BaseGeneralEntity.java @@ -0,0 +1,45 @@ +package com.codezerotoone.mvp.domain.common; + +import jakarta.persistence.*; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +import static com.codezerotoone.mvp.domain.common.EntityConstant.BOOLEAN_DEFAULT_FALSE; +import static jakarta.persistence.GenerationType.IDENTITY; + +// 추가 사유: 일반적으로 공통되는 컬럼들은 엔티티가 추가될 때마다 일일이 설정하기보다는 공용 멤버로 묶는 게 나아 보입니다. +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseGeneralEntity extends BaseEntity { + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Column(nullable = false, columnDefinition = BOOLEAN_DEFAULT_FALSE) + private boolean deleteYn; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + public Long getId() { + return id; + } + + protected boolean isDeleted() { + return deleteYn; + } + + protected void updateId(Long id) { + this.id = id; + } + + protected void deleteEntity() { + deleteYn = true; + deletedAt = LocalDateTime.now(); + } + + protected void undeleteEntity() { + deleteYn = false; + } +} diff --git a/src/main/java/com/codezerotoone/mvp/domain/common/EntityConstant.java b/src/main/java/com/codezerotoone/mvp/domain/common/EntityConstant.java new file mode 100644 index 0000000..dd98ef3 --- /dev/null +++ b/src/main/java/com/codezerotoone/mvp/domain/common/EntityConstant.java @@ -0,0 +1,5 @@ +package com.codezerotoone.mvp.domain.common; + +public class EntityConstant { + public static final String BOOLEAN_DEFAULT_FALSE = "boolean default false"; +} diff --git a/src/main/java/com/codezerotoone/mvp/domain/image/entity/Image.java b/src/main/java/com/codezerotoone/mvp/domain/image/entity/Image.java index c45e62b..b9e7288 100644 --- a/src/main/java/com/codezerotoone/mvp/domain/image/entity/Image.java +++ b/src/main/java/com/codezerotoone/mvp/domain/image/entity/Image.java @@ -1,13 +1,15 @@ package com.codezerotoone.mvp.domain.image.entity; -import com.codezerotoone.mvp.domain.common.BaseEntity; +import com.codezerotoone.mvp.domain.common.BaseGeneralEntity; import com.codezerotoone.mvp.domain.image.entity.dto.ResizedImageInfo; -import jakarta.persistence.*; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; -import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -16,18 +18,16 @@ @Table(name = "image") @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter -public class Image extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long imageId; - - @OneToMany(mappedBy = "image", cascade = { CascadeType.PERSIST, CascadeType.REMOVE }) // TODO: 고아객체 삭제? +public class Image extends BaseGeneralEntity { + // 변경 사유: 원본 이미지와 연결되지 않은 경우 의미없는 데이터이므로, DB에 튜플을 남기지 않는 편이 장기적으로 낫다고 보입니다. + @OneToMany(mappedBy = "image", cascade = {CascadeType.PERSIST}, orphanRemoval = true) private List resizedImages = new ArrayList<>(); private String location; - private LocalDateTime deletedAt = null; + private Image(Long id) { + updateId(id); + } public static Image create(String location, ResizedImageInfo... resizedImages) { Image image = new Image(); @@ -47,17 +47,17 @@ public static Image create(String location, List resizedImages return image; } - public static Image getReference(Long id) { - Image image = new Image(); - image.imageId = id; - return image; + // 변경 사유: 메서드명과 역할 불일치 + public static Image of(Long id) { + // 변경 사유: 생성 로직 간소화 + return new Image(id); } public void delete() { - this.deletedAt = LocalDateTime.now(); + deleteEntity(); } public boolean isDeleted() { - return this.deletedAt != null; + return isDeleted(); } } diff --git a/src/main/java/com/codezerotoone/mvp/domain/image/entity/ResizedImage.java b/src/main/java/com/codezerotoone/mvp/domain/image/entity/ResizedImage.java index 3a43a16..b32f775 100644 --- a/src/main/java/com/codezerotoone/mvp/domain/image/entity/ResizedImage.java +++ b/src/main/java/com/codezerotoone/mvp/domain/image/entity/ResizedImage.java @@ -8,15 +8,16 @@ import java.time.LocalDateTime; +import static com.codezerotoone.mvp.domain.common.EntityConstant.BOOLEAN_DEFAULT_FALSE; + @Entity @Table(name = "resized_image") @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter public class ResizedImage { - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long resizedImageId; + private Long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "image_id") @@ -28,7 +29,11 @@ public class ResizedImage { @Column(name = "image_size_type", columnDefinition = "VARCHAR(10)") private ImageSizeType imageSizeType; - public LocalDateTime deletedAt = null; + @Column(nullable = false, columnDefinition = BOOLEAN_DEFAULT_FALSE) + private boolean deleteYn; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; public ResizedImage(Image image, String resizedImageUrl, ImageSizeType imageSizeType) { this.image = image; @@ -37,15 +42,12 @@ public ResizedImage(Image image, String resizedImageUrl, ImageSizeType imageSize } public void delete() { - this.deletedAt = LocalDateTime.now(); + deleteYn = true; + deletedAt = LocalDateTime.now(); } public boolean isDeleted() { - return this.deletedAt != null; - } - - public boolean isNotDeleted() { - return !isDeleted(); + return deleteYn; } public String getFullResizedImageUrl() { diff --git a/src/main/java/com/codezerotoone/mvp/domain/image/repository/ImageRepository.java b/src/main/java/com/codezerotoone/mvp/domain/image/repository/ImageRepository.java index 45221b1..05f49c8 100644 --- a/src/main/java/com/codezerotoone/mvp/domain/image/repository/ImageRepository.java +++ b/src/main/java/com/codezerotoone/mvp/domain/image/repository/ImageRepository.java @@ -13,8 +13,8 @@ public interface ImageRepository extends JpaRepository { @Query(""" SELECT i FROM Image i - WHERE i.deletedAt IS NULL - AND i.imageId = :imageId + WHERE i.deleteYn = false + AND i.id = :imageId """) Optional findById(@Param("imageId") Long imageId); } diff --git a/src/main/java/com/codezerotoone/mvp/domain/image/service/ImageService.java b/src/main/java/com/codezerotoone/mvp/domain/image/service/ImageService.java index 3196377..08953b0 100644 --- a/src/main/java/com/codezerotoone/mvp/domain/image/service/ImageService.java +++ b/src/main/java/com/codezerotoone/mvp/domain/image/service/ImageService.java @@ -24,6 +24,6 @@ public class ImageService { public Long saveImage(String location, List imageInfos) { Image newImage = Image.create(location, imageInfos); newImage = this.imageRepository.save(newImage); - return newImage.getImageId(); + return newImage.getId(); } } diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/auth/service/AuthService.java b/src/main/java/com/codezerotoone/mvp/domain/member/auth/service/AuthService.java index 3e97406..68d2e6b 100644 --- a/src/main/java/com/codezerotoone/mvp/domain/member/auth/service/AuthService.java +++ b/src/main/java/com/codezerotoone/mvp/domain/member/auth/service/AuthService.java @@ -22,7 +22,8 @@ public class AuthService { private final TokenSupport tokenSupport; private final MemberRepository memberRepository; - @Transactional + // 변경 사유: 리소스 서버로의 요청이 pending되는 경우 무한 대기를 하는 것보다 실패 피드백을 반환하는 편이 낫다고 생각됩니다. + @Transactional(timeout = 30) public LoginResult loginByOAuth2(String code, String redirectUri, AuthVendor authVendor) throws UnsupportedCodeException { GrantedTokenInfo grantedTokenInfo = this.tokenSupport.grantToken(code, redirectUri, authVendor); @@ -33,7 +34,7 @@ public LoginResult loginByOAuth2(String code, String redirectUri, AuthVendor aut .newMember(false) .accessToken(grantedTokenInfo.accessToken()) .refreshToken(grantedTokenInfo.refreshToken()) - .memberId(member.getMemberId()) + .memberId(member.getId()) .build(); } OAuth2UserInfo userInfo = this.tokenSupport.retrieveUserInfo(grantedTokenInfo.accessToken()); diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/member/dto/MemberCreationResponseDto.java b/src/main/java/com/codezerotoone/mvp/domain/member/member/dto/MemberCreationResponseDto.java index 9bc217f..852714f 100644 --- a/src/main/java/com/codezerotoone/mvp/domain/member/member/dto/MemberCreationResponseDto.java +++ b/src/main/java/com/codezerotoone/mvp/domain/member/member/dto/MemberCreationResponseDto.java @@ -10,4 +10,7 @@ public record MemberCreationResponseDto( @Schema(description = "프로필 이미지를 업로드할 URL") String uploadUrl ) { + public static MemberCreationResponseDto of(Long memberId) { + return new MemberCreationResponseDto(memberId, null); + } } diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/member/entity/Member.java b/src/main/java/com/codezerotoone/mvp/domain/member/member/entity/Member.java index 9f3ecca..a1fb485 100644 --- a/src/main/java/com/codezerotoone/mvp/domain/member/member/entity/Member.java +++ b/src/main/java/com/codezerotoone/mvp/domain/member/member/entity/Member.java @@ -1,6 +1,6 @@ package com.codezerotoone.mvp.domain.member.member.entity; -import com.codezerotoone.mvp.domain.common.BaseEntity; +import com.codezerotoone.mvp.domain.common.BaseGeneralEntity; import com.codezerotoone.mvp.domain.member.auth.entity.Role; import com.codezerotoone.mvp.domain.member.member.constant.MemberStatus; import com.codezerotoone.mvp.domain.member.memberprofile.entity.MemberProfile; @@ -21,11 +21,8 @@ @Table(name = "member") @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter -public class Member extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long memberId; +public class Member extends BaseGeneralEntity { + // 삭제 사유: 자신의 Primary Key는 테이블명을 생략하는 것이 일반적인 관례이며, 수정하기에는 이미 많이 개발되었지만 맛보기이므로 수정해 보았습니다. @Column(name = "login_id", unique = true) private String loginId; @@ -53,13 +50,11 @@ public class Member extends BaseEntity { * values (?,?,?,?,?,?,?,?,?,?)]; constraint [null] */ - private LocalDateTime deletedAt = null; - - @OneToOne(mappedBy = "member", cascade = { CascadeType.PERSIST, CascadeType.REMOVE }) + @OneToOne(mappedBy = "member", cascade = {CascadeType.PERSIST, CascadeType.REMOVE}) private MemberProfile memberProfile; private Member(Long memberId) { - this.memberId = memberId; + updateId(memberId); } @Builder(access = AccessLevel.PRIVATE) @@ -96,10 +91,15 @@ public static Member getReference(Long memberId) { } public void delete() { - this.deletedAt = LocalDateTime.now(); + deleteEntity(); } + // 수정 사유: 삭제 날짜의 존재 여부로 삭제 데이터를 판별하는 것은 관리적으로도, 성능적으로도 좋지 않다고 생각됩니다. public boolean isDeleted() { - return this.deletedAt != null; + return isDeleted(); + } + + public LocalDateTime getDeletedAt() { + return getDeletedAt(); } } diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/member/exception/MemberIdNoValueException.java b/src/main/java/com/codezerotoone/mvp/domain/member/member/exception/MemberIdNoValueException.java new file mode 100644 index 0000000..5ae13c2 --- /dev/null +++ b/src/main/java/com/codezerotoone/mvp/domain/member/member/exception/MemberIdNoValueException.java @@ -0,0 +1,13 @@ +package com.codezerotoone.mvp.domain.member.member.exception; + +import lombok.Getter; +import org.springframework.dao.DataRetrievalFailureException; + +@Getter +public class MemberIdNoValueException extends DataRetrievalFailureException { + private static final String MEMBER_ID_NO_VALUE_EXCEPTION_MESSAGE = "생성된 회원 ID를 받아 오지 못했습니다. memberId: %s"; + + public MemberIdNoValueException(Long memberId) { + super(String.format(MEMBER_ID_NO_VALUE_EXCEPTION_MESSAGE, memberId)); + } +} diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/member/repository/MemberRepository.java b/src/main/java/com/codezerotoone/mvp/domain/member/member/repository/MemberRepository.java index 0236b3d..64892e2 100644 --- a/src/main/java/com/codezerotoone/mvp/domain/member/member/repository/MemberRepository.java +++ b/src/main/java/com/codezerotoone/mvp/domain/member/member/repository/MemberRepository.java @@ -13,15 +13,15 @@ public interface MemberRepository extends JpaRepository, ExtendedM @Query(""" SELECT m FROM Member m - WHERE m.deletedAt IS NULL - AND m.memberId = :memberId + WHERE m.deleteYn = false + AND m.id = :memberId """) Optional findNotDeletedMemberById(@Param("memberId") Long memberId); @Query(""" SELECT m FROM Member m - WHERE m.deletedAt IS NULL + WHERE m.deleteYn = false AND m.loginId = :loginId """) Optional findByLoginId(@Param("loginId") String loginId); @@ -29,7 +29,7 @@ public interface MemberRepository extends JpaRepository, ExtendedM @Query(value = """ SELECT COUNT(*) > 0 FROM member m - WHERE m.deleted_at IS NULL + WHERE m.deleteYn = false AND m.login_id = :loginId LIMIT 1 """, nativeQuery = true) @@ -38,7 +38,7 @@ SELECT COUNT(*) > 0 @Query(value = """ SELECT m FROM Member m - WHERE m.deletedAt IS NULL + WHERE m.deleteYn = false AND m.oidcId = :oidcId """) Optional findByOdicId(@Param("oidcId") String oidcId); diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/member/repository/extend/DefaultExtendedMemberRepository.java b/src/main/java/com/codezerotoone/mvp/domain/member/member/repository/extend/DefaultExtendedMemberRepository.java index 45248b3..a2ac915 100644 --- a/src/main/java/com/codezerotoone/mvp/domain/member/member/repository/extend/DefaultExtendedMemberRepository.java +++ b/src/main/java/com/codezerotoone/mvp/domain/member/member/repository/extend/DefaultExtendedMemberRepository.java @@ -16,7 +16,7 @@ public class DefaultExtendedMemberRepository implements ExtendedMemberRepository @Override public boolean existsNotDeletedMemberByOidcId(String oidcId) { - return countByOidcId(this.member.oidcId.eq(oidcId).and(this.member.deletedAt.isNull())) + return countByOidcId(this.member.oidcId.eq(oidcId).and(member.deleteYn.eq(false))) .orElse(0L) > 0; } diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/member/service/DefaultMemberService.java b/src/main/java/com/codezerotoone/mvp/domain/member/member/service/DefaultMemberService.java index 18b74e7..2037eec 100644 --- a/src/main/java/com/codezerotoone/mvp/domain/member/member/service/DefaultMemberService.java +++ b/src/main/java/com/codezerotoone/mvp/domain/member/member/service/DefaultMemberService.java @@ -5,10 +5,12 @@ import com.codezerotoone.mvp.domain.member.member.dto.request.MemberCreationRequestDto; import com.codezerotoone.mvp.domain.member.member.entity.Member; import com.codezerotoone.mvp.domain.member.member.exception.DuplicateMemberException; +import com.codezerotoone.mvp.domain.member.member.exception.MemberIdNoValueException; import com.codezerotoone.mvp.domain.member.member.exception.MemberNotFoundException; import com.codezerotoone.mvp.domain.member.member.repository.MemberRepository; +import com.codezerotoone.mvp.domain.member.memberprofile.constant.MemberEndpoint; import com.codezerotoone.mvp.global.file.url.FileUrlResolver; -import jakarta.annotation.Nullable; +import com.codezerotoone.mvp.global.util.FormatValidator; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Isolation; @@ -28,28 +30,42 @@ public MemberCreationResponseDto createMember(MemberCreationRequestDto request, throw new DuplicateMemberException(oidcId); } - Member newMember = Member.createGeneralMemberBySocialLogin( - request.getName(), - oidcId + // 변경 사유: 엔티티를 바로 저장한다면 저장되지 않은 엔티티를 먼저 선언하고 다시 메모리 참조를 변경할 필요는 없어 보입니다. + Member newMember = memberRepository.save( + Member.createGeneralMemberBySocialLogin( + request.getName(), + oidcId + ) ); - newMember = this.memberRepository.save(newMember); + // 변경 사유: newMember의 getMemberId 메서드를 여러 번 참조하는 것보다는 변수에 할당하는 게 좋아 보입니다. + Long memberId = newMember.getId(); - return new MemberCreationResponseDto(newMember.getMemberId(), - getFileUploadUrl(newMember.getMemberId(), request.getImageExtension())); - } + // 제로투원 운영 페이지를 방문했을 때 최초 회원 가입 시 members/{memberId}/profile 경로의 memberId가 NaN으로 뜨는 치명적인 버그가 있었는데, + // 현재는 newMember 인스턴스의 메모리 재참조를 추가함으로써 해결한 것으로 보이나 버그 발생 이력이 있는 만큼 memberId에 대한 유효성 검사 로직이 필요하다고 생각됩니다. + if (!FormatValidator.hasValue(memberId)) { + throw new MemberIdNoValueException(memberId); + } + + ImageExtension extension = request.getImageExtension(); - private @Nullable String getFileUploadUrl(Long memberId, @Nullable ImageExtension imageExtension) { - if (imageExtension == null) { - return null; + // 추가 사유: 파라미터에 null을 전송하는 것보다는 팩토리 메서드를 추가하는 게 낫다고 생각됩니다. + if (!FormatValidator.hasValue(extension)) { + return MemberCreationResponseDto.of(memberId); } - String profileUri = this.fileUrlResolver.generateUuidFileUri( - imageExtension.getExtension(), - "members/" + memberId + "/profile/image" + + String profileImageUploadUrl = fileUrlResolver.generateFileUploadUrl( + // 변경 사유: 프로필 생성 엔드포인트가 MemberProfileService와 중복됩니다. 분리하여 재사용하는 편이 낫다고 생각됩니다. + MemberEndpoint.generateProfileImagePath(memberId), extension ); - return this.fileUrlResolver.generateFileUploadUrl(profileUri); + + return new MemberCreationResponseDto(memberId, profileImageUploadUrl); } + // 삭제 사유: 기존에 memberId를 파라미터로 받아 호출 객체와의 결합도가 높으며, 재사용성이 낮습니다. + // 유사한 로직에 private 메서드를 반복적으로 만드는 것보다는 기존의 FileUrlResolver를 호출하는 편이 나은 것 같습니다. + // MemberService의 역할에서도 벗어 났다고 생각됩니다. + @Override public void deleteMember(Long memberId) throws MemberNotFoundException { throw new UnsupportedOperationException(); diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/constant/MemberEndpoint.java b/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/constant/MemberEndpoint.java new file mode 100644 index 0000000..1829edd --- /dev/null +++ b/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/constant/MemberEndpoint.java @@ -0,0 +1,9 @@ +package com.codezerotoone.mvp.domain.member.memberprofile.constant; + +public class MemberEndpoint { + public static final String MEMBER_PROFILE_IMAGE_PATH = "members/%d/profile/image"; + + public static String generateProfileImagePath(Long memberId) { + return String.format(MEMBER_PROFILE_IMAGE_PATH, memberId); + } +} diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/dto/request/MemberProfileUpdateRequestDto.java b/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/dto/request/MemberProfileUpdateRequestDto.java index 41695df..9c5dacd 100644 --- a/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/dto/request/MemberProfileUpdateRequestDto.java +++ b/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/dto/request/MemberProfileUpdateRequestDto.java @@ -7,6 +7,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Pattern; import lombok.*; +import com.codezerotoone.mvp.global.util.FormatValidator; import java.util.List; @@ -49,4 +50,16 @@ public class MemberProfileUpdateRequestDto { requiredMode = Schema.RequiredMode.NOT_REQUIRED) @JsonDeserialize(using = ImageExtensionDeserializer.class) private ImageExtension profileImageExtension; + + public boolean hasGithubLink() { + return FormatValidator.hasValue(githubLink); + } + + public boolean hasBlogOrSnsLink() { + return FormatValidator.hasValue(blogOrSnsLink); + } + + public boolean hasProfileImageExtension() { + return FormatValidator.hasValue(profileImageExtension); + } } diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/dto/response/MemberProfileResponseDto.java b/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/dto/response/MemberProfileResponseDto.java index 8de0704..ff6849d 100644 --- a/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/dto/response/MemberProfileResponseDto.java +++ b/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/dto/response/MemberProfileResponseDto.java @@ -55,13 +55,13 @@ public static MemberProfileResponseDto of(MemberProfileData memberProfileData) { .memberName(memberProfileData.getMemberName()) .profileImage( memberProfileData.getProfileImage() != null - ? new ImageDto(memberProfileData.getProfileImage().getImageId(), + ? new ImageDto(memberProfileData.getProfileImage().getId(), memberProfileData.getProfileImage().getResizedImages() .stream() - .filter(ResizedImage::isNotDeleted) + .filter(ri -> !ri.isDeleted()) .map((ri) -> new ResizedImageDto( - ri.getResizedImageId(), + ri.getId(), ri.getFullResizedImageUrl(), new ImageSizeTypeDto( ri.getImageSizeType().name(), diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/dto/response/MemberProfileUpdateResponseDto.java b/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/dto/response/MemberProfileUpdateResponseDto.java index d13786e..86b4051 100644 --- a/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/dto/response/MemberProfileUpdateResponseDto.java +++ b/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/dto/response/MemberProfileUpdateResponseDto.java @@ -43,7 +43,30 @@ public record MemberProfileUpdateResponseDto( List interests ) { - public static MemberProfileUpdateResponseDto of(MemberProfile memberProfile, String profileImageUploadUrl) { + public static MemberProfileUpdateResponseDto from(MemberProfile memberProfile) { + return MemberProfileUpdateResponseDto.builder() + .memberId(memberProfile.getMemberId()) + .name(memberProfile.getMemberName()) + .tel(memberProfile.getMemberProfileData().getTel()) + .githubLink(NullSafetyUtils.extractSafely( + memberProfile.getMemberProfileData().getPrimarySocialMedia(PrimarySocialMediaType.GITHUB), + SocialMedia::getUrl)) + .blogOrSnsLink(NullSafetyUtils.extractSafely( + memberProfile.getMemberProfileData().getPrimarySocialMedia(PrimarySocialMediaType.BLOG_OR_SNS), + SocialMedia::getUrl)) + .simpleIntroduction(memberProfile.getMemberProfileData().getSimpleIntroduction()) + .mbti(memberProfile.getMemberProfileData().getMbti()) + .interests( + memberProfile.getMemberProfileData() + .getMemberInterests() + .stream() + .map((mi) -> new IdNameDto(mi.getMemberInterestId(), mi.getName())) + .toList() + ) + .build(); + } + + public static MemberProfileUpdateResponseDto from(MemberProfile memberProfile, String profileImageUploadUrl) { return MemberProfileUpdateResponseDto.builder() .memberId(memberProfile.getMemberId()) .name(memberProfile.getMemberName()) diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/entity/MemberInterest.java b/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/entity/MemberInterest.java index 058d829..8250875 100644 --- a/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/entity/MemberInterest.java +++ b/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/entity/MemberInterest.java @@ -22,17 +22,18 @@ public class MemberInterest extends BaseEntity { private String name; - public static MemberInterest create(MemberProfile memberProfile, String name) { - MemberInterest memberInterest = new MemberInterest(); - memberInterest.memberProfile = memberProfile; - memberInterest.name = name; - return memberInterest; + private MemberInterest(MemberProfile memberProfile, String name) { + this.memberProfile = memberProfile; + this.name = name; } - public void updateName(String name) { - this.name = name; + // 변경 사유: 로직 간소화 및 팩토리 메서드명 수정했습니다. + public static MemberInterest of(MemberProfile memberProfile, String name) { + return new MemberInterest(memberProfile, name); } + // 삭제 사유: 사용되지 않아 삭제했으며, 명칭만 다를 뿐 setter의 역할을 하는 메서드이므로 바람직하지 않다고 보입니다. + public void detachMemberProfile() { this.memberProfile = null; } diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/entity/MemberInterests.java b/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/entity/MemberInterests.java new file mode 100644 index 0000000..6d5b4b7 --- /dev/null +++ b/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/entity/MemberInterests.java @@ -0,0 +1,28 @@ +package com.codezerotoone.mvp.domain.member.memberprofile.entity; + +import com.codezerotoone.mvp.domain.member.memberprofile.dto.request.MemberProfileUpdateRequestDto; +import com.codezerotoone.mvp.global.util.NullSafetyUtils; + +import java.util.List; + +// 추가 사유: MemberInterest의 무결성과 상태를 관리하기 위해 일급 컬렉션을 추가하였습니다. +public class MemberInterests { + private List memberInterests; + + public static List from(MemberProfileUpdateRequestDto dto, MemberProfile memberProfile) { + List interests = NullSafetyUtils.replaceEmptyIfNull(dto.getInterests()); + + return new MemberInterests().memberInterests = interests.stream() + .map((interest) -> MemberInterest.of(memberProfile, interest)) + .toList(); + } + + // 외부에서의 습관적인 참조를 최소화하기 위해 default 제한자로 생성하였습니다. + MemberInterest get(int index) { + return memberInterests.get(index); + } + + int size() { + return memberInterests.size(); + } +} diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/entity/MemberProfile.java b/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/entity/MemberProfile.java index 96d3c6e..40bdb2a 100644 --- a/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/entity/MemberProfile.java +++ b/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/entity/MemberProfile.java @@ -3,6 +3,7 @@ import com.codezerotoone.mvp.domain.image.entity.Image; import com.codezerotoone.mvp.domain.member.member.entity.Member; import com.codezerotoone.mvp.domain.member.memberprofile.constant.PrimarySocialMediaType; +import com.codezerotoone.mvp.domain.member.memberprofile.entity.dto.MemberProfileAtomicUpdateDto; import jakarta.persistence.*; import lombok.*; import org.slf4j.Logger; @@ -54,6 +55,10 @@ public String getMemberName() { return this.memberProfileData.getMemberName(); } + public void updateAtomicValues(MemberProfileAtomicUpdateDto atomicUpdateDto, boolean ignoreNull) { + memberProfileData.updateAtomicValues(atomicUpdateDto, ignoreNull); + } + public void updateProfileImage(Image image) { this.memberProfileData.updateProfileImage(image); } diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/repository/jpa/JpaMemberProfileRepository.java b/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/repository/jpa/JpaMemberProfileRepository.java index 9d16a92..3a4b4b6 100644 --- a/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/repository/jpa/JpaMemberProfileRepository.java +++ b/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/repository/jpa/JpaMemberProfileRepository.java @@ -12,9 +12,9 @@ public interface JpaMemberProfileRepository extends JpaRepository findNotDeletedMemberProfileById(@Param("memberId") Long memberId); } diff --git a/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/service/DefaultMemberProfileService.java b/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/service/DefaultMemberProfileService.java index 3eeffab..fcc295b 100644 --- a/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/service/DefaultMemberProfileService.java +++ b/src/main/java/com/codezerotoone/mvp/domain/member/memberprofile/service/DefaultMemberProfileService.java @@ -2,13 +2,14 @@ import com.codezerotoone.mvp.domain.image.constant.ImageExtension; import com.codezerotoone.mvp.domain.member.member.exception.MemberNotFoundException; +import com.codezerotoone.mvp.domain.member.memberprofile.constant.MemberEndpoint; import com.codezerotoone.mvp.domain.member.memberprofile.constant.PrimarySocialMediaType; import com.codezerotoone.mvp.domain.member.memberprofile.dto.StudySubjectDto; import com.codezerotoone.mvp.domain.member.memberprofile.dto.request.MemberInfoUpdateRequestDto; import com.codezerotoone.mvp.domain.member.memberprofile.dto.request.MemberProfileUpdateRequestDto; import com.codezerotoone.mvp.domain.member.memberprofile.dto.response.*; import com.codezerotoone.mvp.domain.member.memberprofile.entity.MemberInfo; -import com.codezerotoone.mvp.domain.member.memberprofile.entity.MemberInterest; +import com.codezerotoone.mvp.domain.member.memberprofile.entity.MemberInterests; import com.codezerotoone.mvp.domain.member.memberprofile.entity.MemberProfile; import com.codezerotoone.mvp.domain.member.memberprofile.entity.dto.MemberInfoAtomicUpdateDto; import com.codezerotoone.mvp.domain.member.memberprofile.entity.dto.MemberProfileAtomicUpdateDto; @@ -20,18 +21,13 @@ import com.codezerotoone.mvp.domain.member.memberprofile.repository.MemberProfileRepository; import com.codezerotoone.mvp.domain.member.memberprofile.repository.StudySubjectRepository; import com.codezerotoone.mvp.global.file.url.FileUrlResolver; -import com.codezerotoone.mvp.global.util.NullSafetyUtils; -import jakarta.annotation.Nullable; +import com.codezerotoone.mvp.global.util.FormatValidator; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.ObjectUtils; -import java.util.ArrayList; -import java.util.HashSet; import java.util.List; -import java.util.Set; @Service @Transactional @@ -71,78 +67,89 @@ public MemberProfileUpdateResponseDto updateProfile(Long memberId, .orElseThrow(() -> new MemberNotFoundException(memberId)); MemberProfileAtomicUpdateDto atomicUpdateDto = mapper.toMemberProfileUpdateDto(dto); - memberProfile.getMemberProfileData().updateAtomicValues(atomicUpdateDto, ignoreNull); - this.memberInterestRepository.deleteByMemberId(memberId); + // 변경 사유: 서비스 컴포넌트에서 Getter로 꺼내어 수정하는 것보다, DDD 리치 모델에 입각해 MemberProfile 엔티티가 스스로의 상태를 관리하도록 하는 것이 나아 보입니다. + memberProfile.updateAtomicValues(atomicUpdateDto, ignoreNull); - this.memberInterestRepository.saveAll( - NullSafetyUtils.replaceEmptyIfNull(dto.getInterests()).stream() - .map((v) -> MemberInterest.create(memberProfile, v)) - .toList() + memberInterestRepository.deleteByMemberId(memberId); + + // 변경 사유: 서비스가 DTO와 Entity의 내부 구성에 의존하고 있으며, 너무 많은 역할을 하는 것으로 보입니다. + memberInterestRepository.saveAll( + MemberInterests.from(dto, memberProfile) ); - if (dto.getGithubLink() != null) { + // 변경 사유: 값이 있는지를 검사하려는 것이므로, null 체크만으로는 부족하다고 생각됩니다. + // 서비스가 DTO의 내부 구성에 대해 너무 많이 알고 있으며, 상태를 묻지 않고 getter로 꺼내어 대신 작업하는 분량이 너무 많습니다. + // 서비스의 역할을 최소화하고 DTO가 스스로의 상태를 관리하는 방식이 보다 캡슐화와 DDD 원칙에 부합하다고 생각됩니다. + if (dto.hasGithubLink()) { memberProfile.updatePrimarySocialMediaLink(dto.getGithubLink(), PrimarySocialMediaType.GITHUB); } - if (dto.getBlogOrSnsLink() != null) { + if (dto.hasBlogOrSnsLink()) { memberProfile.updatePrimarySocialMediaLink(dto.getBlogOrSnsLink(), PrimarySocialMediaType.BLOG_OR_SNS); } - if (dto.getProfileImageExtension() != null && dto.getProfileImageExtension().isDefaultImage()) { + // 추가 사유: 파라미터에 null을 전송하는 것보다는 팩토리 메서드를 오버로딩하는 게 낫다고 생각됩니다. + // 기존 로직은 바로 아래의 기본 확장자 조건문에서 확장자 존재 여부를 검사하고, generateUuidFileUri 메서드를 호출하기 직전에 한 번 더 검사하고 있어 비효율적입니다. + if (!dto.hasProfileImageExtension()) { + return MemberProfileUpdateResponseDto.from(memberProfile); + } + + // 추가 사유: 기존 코드는 dto 인스턴스에서 getProfileImageExtension 메서드를 반복적으로 호출하고 있으며, 읽기에도 어렵습니다. + ImageExtension extension = dto.getProfileImageExtension(); + + // 변경 사유: 부정 조건식과 긍정 조건식이 혼재돼 있는 경우, 읽기에 불편합니다. + if (extension.isDefaultImage()) { memberProfile.updateProfileImage(null); // TODO: delete Image and ResizedImage + + return MemberProfileUpdateResponseDto.from(memberProfile); } - // TODO: 굳이 쿼리를 한 번 더 날려야 하나? - MemberProfile findMemberProfile = this.memberProfileRepository.findNotDeletedMemberProfileById(memberId) - .orElseThrow(() -> new MemberNotFoundException(memberId)); + // 삭제 사유: 엔티티가 영속성 컨텍스트에서 관리되므로, 다시 조회할 필요가 없습니다. + + String profileImageUploadUrl = fileUrlResolver.generateFileUploadUrl( + // 변경 사유: 프로필 생성 엔드포인트가 MemberService와 중복됩니다. 분리하여 재사용하는 편이 낫다고 생각됩니다. + MemberEndpoint.generateProfileImagePath(memberId), extension + ); - return MemberProfileUpdateResponseDto.of(findMemberProfile, - generateProfileImageUploadUrl(memberId, dto.getProfileImageExtension())); + // 변경 사유: 객체 기반의 변환 생성이므로 from이 보다 적합하다고 생각됩니다. + return MemberProfileUpdateResponseDto.from(memberProfile, profileImageUploadUrl); } private void validateMemberUpdate(MemberProfileUpdateRequestDto dto, boolean ignoreNull) { + // 변경 사유: 부정 조건식과 긍정 조건식이 혼재돼 있는 경우, 읽기에 불편합니다. 중복된 조건식도 보입니다. // ignoreNull의 존재 때문에 null 체크를 서비스 레이어에서 수동으로 함 - if (!ignoreNull && dto.getName() == null) { - throw new NullArgumentException("name", "\"name\" should not be null"); - } - - if (!ignoreNull && dto.getTel() == null) { - throw new NullArgumentException("tel", "\"tel\" should not be null"); + if (!ignoreNull) { + validate(dto.getName(), dto.getTel()); } + // 변경 사유: 기존의 중복 관심사 검사 로직이 길어 유효성 검사가 아닌 다른 일을 하는 것처럼 보이므로 분리했습니다. // 관심사 validation - List interests = dto.getInterests(); - if (ObjectUtils.isEmpty(interests)) { - return; - } + validate(dto.getInterests()); + } - List duplicatedInterests = new ArrayList<>(); - Set visited = new HashSet<>(); - for (String interest : interests) { - if (visited.contains(interest)) { - duplicatedInterests.add(interest); - } - visited.add(interest); + private void validate(String name, String tel) { + if (!FormatValidator.hasValue(name)) { + throw new NullArgumentException("name", "\"name\" should not be blank"); } - if (!duplicatedInterests.isEmpty()) { - throw new DuplicatedMemberInterestException(duplicatedInterests); + + if (!FormatValidator.hasValue(tel)) { + throw new NullArgumentException("tel", "\"tel\" should not be blank"); } } - private String generateProfileImageUploadUrl(Long memberId, @Nullable ImageExtension imageExtension) { - if (imageExtension == null || imageExtension.isDefaultImage()) { - return null; + private void validate(List interests) { + // 변경 사유: 기존의 null 체크 및 List를 순회하며 검사하는 방식은 다소 장황해 보입니다. + if (FormatValidator.hasValue(interests) && interests.size() > interests.stream().distinct().count()) { + throw new DuplicatedMemberInterestException(interests); } - - String profileImageUri = this.fileUrlResolver.generateUuidFileUri( - imageExtension.getExtension(), - "members/" + memberId + "/profile/image" - ); - return this.fileUrlResolver.generateFileUploadUrl(profileImageUri); } + // 삭제 사유: 기존에 memberId를 파라미터로 받아 호출 객체와의 결합도가 높으며, 재사용성이 낮습니다. + // 유사한 로직에 private 메서드를 반복적으로 만드는 것보다는 기존의 FileUrlResolver를 호출하는 편이 나은 것 같습니다. + // MemberProfileService의 역할에서도 벗어 났다고 생각됩니다. + @Override public MemberProfileUpdateResponseDto updateProfile(Long memberId, MemberProfileUpdateRequestDto dto) throws MemberNotFoundException { return updateProfile(memberId, dto, false); diff --git a/src/main/java/com/codezerotoone/mvp/global/api/error/CommonErrorCode.java b/src/main/java/com/codezerotoone/mvp/global/api/error/CommonErrorCode.java index 0282b22..9f4b10a 100644 --- a/src/main/java/com/codezerotoone/mvp/global/api/error/CommonErrorCode.java +++ b/src/main/java/com/codezerotoone/mvp/global/api/error/CommonErrorCode.java @@ -4,6 +4,8 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; +import java.util.Arrays; + @RequiredArgsConstructor(access = AccessLevel.PACKAGE) @Getter public enum CommonErrorCode implements ErrorCodeSpec { @@ -16,4 +18,11 @@ public enum CommonErrorCode implements ErrorCodeSpec { private final int statusCode; private final String errorCode; private final String message; + + public static CommonErrorCode get(int statusCode) { + return Arrays.stream(CommonErrorCode.values()) + .filter(code -> code.getStatusCode() == statusCode) + .findFirst() + .orElse(INTERNAL_SERVER_ERROR); + } } diff --git a/src/main/java/com/codezerotoone/mvp/global/api/error/RuntimeExceptionHandlingControllerAdvice.java b/src/main/java/com/codezerotoone/mvp/global/api/error/RuntimeExceptionHandlingControllerAdvice.java index 6a38aa8..d4f1ccb 100644 --- a/src/main/java/com/codezerotoone/mvp/global/api/error/RuntimeExceptionHandlingControllerAdvice.java +++ b/src/main/java/com/codezerotoone/mvp/global/api/error/RuntimeExceptionHandlingControllerAdvice.java @@ -4,6 +4,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; +import org.springframework.dao.DataRetrievalFailureException; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -21,4 +22,13 @@ public ResponseEntity runtimeException(RuntimeException e) { return ResponseEntity.internalServerError() .body(ErrorResponse.of(CommonErrorCode.INTERNAL_SERVER_ERROR, e.getMessage())); } + + // 추가 사유: DB I/O 관련 런타임 에러 분류 + @ExceptionHandler(DataRetrievalFailureException.class) + public ResponseEntity dataRetrievalFailureException(DataRetrievalFailureException e) { + log.error("{}", e.getMessage(), e); + + return ResponseEntity.internalServerError() + .body(ErrorResponse.of(CommonErrorCode.INTERNAL_SERVER_ERROR, e.getMessage())); + } } diff --git a/src/main/java/com/codezerotoone/mvp/global/entity/BaseGeneralEntity.java b/src/main/java/com/codezerotoone/mvp/global/entity/BaseGeneralEntity.java new file mode 100644 index 0000000..52c1403 --- /dev/null +++ b/src/main/java/com/codezerotoone/mvp/global/entity/BaseGeneralEntity.java @@ -0,0 +1,45 @@ +package com.codezerotoone.mvp.global.entity; + +import jakarta.persistence.*; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +import static com.codezerotoone.mvp.domain.common.EntityConstant.BOOLEAN_DEFAULT_FALSE; +import static jakarta.persistence.GenerationType.IDENTITY; + +// 추가 사유: 일반적으로 공통되는 컬럼들은 엔티티가 추가될 때마다 일일이 설정하기보다는 공용 멤버로 묶는 게 나아 보입니다. +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseGeneralEntity extends BaseEntity { + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Column(nullable = false, columnDefinition = BOOLEAN_DEFAULT_FALSE) + private boolean deleteYn; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + public Long getId() { + return id; + } + + protected boolean isDeleted() { + return deleteYn; + } + + protected void updateId(Long id) { + this.id = id; + } + + protected void deleteEntity() { + deleteYn = true; + deletedAt = LocalDateTime.now(); + } + + protected void undeleteEntity() { + deleteYn = false; + } +} diff --git a/src/main/java/com/codezerotoone/mvp/global/file/service/FileService.java b/src/main/java/com/codezerotoone/mvp/global/file/service/FileService.java index 4659838..064fcea 100644 --- a/src/main/java/com/codezerotoone/mvp/global/file/service/FileService.java +++ b/src/main/java/com/codezerotoone/mvp/global/file/service/FileService.java @@ -38,6 +38,6 @@ public void uploadMemberProfileImage(Long memberId, String filePath, byte[] data // IOException이 발생할 경우 회원의 프로필 사진이 업데이트되지 않음 MemberProfile memberProfile = this.memberProfileRepository.findNotDeletedMemberProfileById(memberId) .orElseThrow(() -> new MemberNotFoundException(memberId)); - memberProfile.updateProfileImage(Image.getReference(generatedImageId)); + memberProfile.updateProfileImage(Image.of(generatedImageId)); } } diff --git a/src/main/java/com/codezerotoone/mvp/global/file/url/FileUrlResolver.java b/src/main/java/com/codezerotoone/mvp/global/file/url/FileUrlResolver.java index d9a5427..1a4639b 100644 --- a/src/main/java/com/codezerotoone/mvp/global/file/url/FileUrlResolver.java +++ b/src/main/java/com/codezerotoone/mvp/global/file/url/FileUrlResolver.java @@ -1,6 +1,8 @@ package com.codezerotoone.mvp.global.file.url; +import com.codezerotoone.mvp.domain.image.constant.ImageExtension; import com.codezerotoone.mvp.global.file.constant.FileClassification; +import jakarta.annotation.Nullable; /** *

Resolve URL of file. 파일이 어디에 저장되는가에 따라 다양한 구현체가 있을 수 있습니다.. @@ -30,7 +32,8 @@ public interface FileUrlResolver { * @return path + "/" + UUID + "_" + Epoch_time_in_millis + "." + extension * @throws IllegalArgumentException path 혹은 extension이 유효하지 않을 경우 */ - String generateUuidFileUri(String extension, String path) throws IllegalArgumentException; + // 변경 사유: Extension을 enum으로 관리하는 이상, String 값을 꺼내어 전송하는 것보다는 계속 무결성을 유지하고 Extension이 스스로의 상태를 관리하는 편이 낫다고 생각됩니다. + String generateUuidFileUri(String path, @Nullable ImageExtension extension) throws IllegalArgumentException; /** *

파일을 업로드할 위치를 생성합니다. 애플리케이션 실행 환경에 따라 파일을 업로드하는 위치가 달라집니다. @@ -41,6 +44,19 @@ public interface FileUrlResolver { */ String generateFileUploadUrl(String fileUri) throws NullPointerException; + /** + *

파일을 업로드할 위치를 생성합니다. 애플리케이션 실행 환경에 따라 파일을 업로드하는 위치가 달라집니다. + * + * @param path 파일 경로 + * @param extension 파일 확장자 + * @return 파일을 업로드할 위치 + * @throws NullPointerException fileUrinull일 경우 + */ + // 추가 사유: Member 도메인에서는 이미지 UUID, Upload URL 생성 절차같은 건 모르는 편이 낫다고 생각됩니다. + // 따라서 MemberService나 MemberProfileService에서 직접 여러 번 호출하지 않고 generateFileUploadUrl 메서드 내부에서 generateUuidFileUri 메서드를 호출하는 것으로 변경했습니다. + // 기존 메서드는 일단 유지하고 오버로딩했습니다. + String generateFileUploadUrl(String path, @Nullable ImageExtension extension) throws NullPointerException; + /** * 파일이 저장될(된) 위치를 반환합니다. * diff --git a/src/main/java/com/codezerotoone/mvp/global/file/url/LocalFileUrlResolver.java b/src/main/java/com/codezerotoone/mvp/global/file/url/LocalFileUrlResolver.java index d6b6e51..f6f5a82 100644 --- a/src/main/java/com/codezerotoone/mvp/global/file/url/LocalFileUrlResolver.java +++ b/src/main/java/com/codezerotoone/mvp/global/file/url/LocalFileUrlResolver.java @@ -1,6 +1,9 @@ package com.codezerotoone.mvp.global.file.url; +import com.codezerotoone.mvp.domain.image.constant.ImageExtension; import com.codezerotoone.mvp.global.file.constant.FileClassification; +import com.codezerotoone.mvp.global.util.FormatValidator; +import jakarta.annotation.Nullable; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -23,11 +26,14 @@ public LocalFileUrlResolver(@Value("${server.origin}") String serverOrigin) { } @Override - public String generateUuidFileUri(String extension, String path) throws IllegalArgumentException { - if (isInvalidPathPattern(path) || isInvalidExtensionPattern(extension)) { + public String generateUuidFileUri(String path, ImageExtension extension) throws IllegalArgumentException { + // 변경 사유: generateUuidFileUri 메서드에서 본업 외에 알아야 할 조건 정보가 너무 많아 가독성이 낮아지므로, 구체적인 유효성 검사는 타 메서드에 맡기는 게 낫다고 생각됩니다. + if (!isValid(path, extension)) { throw new IllegalArgumentException("Invalid path: " + path); } + // UUID와 밀리 초 값을 기반으로 생성된 이미지 파일명은 이미 매우 충분히 유니크하다고 생각됩니다. + // 이미지 파일이 UUID의 중복을 염려할 정도로 중요도가 높은 데이터도 아니라고 봅니다. String uuidAsString = UUID.randomUUID().toString(); Long epochTime = System.currentTimeMillis(); @@ -37,16 +43,23 @@ public String generateUuidFileUri(String extension, String path) throws IllegalA .append("_") .append(epochTime) .append(".") - .append(extension) + .append(extension.getExtension()) .toString(); } - private boolean isInvalidPathPattern(String path) { - return !pathPattern.matcher(path).matches(); + // !가 아닌 부정하는 단어(invalid 등)는 가독성이 떨어지고, 구체적인 검사에서도 전부 거짓 조건(!pattern.matcher)을 기반으로 boolean 값을 반환해 기존 로직은 읽기에 복잡하다고 생각됩니다. + private boolean isValid(String path, ImageExtension extension) { + return isValid(path) && isValid(extension); } - private boolean isInvalidExtensionPattern(String extension) { - return !this.extensionPattern.matcher(extension).matches(); + // isValid 메서드는 모두 파라미터에 대한 유효성 검사 결과를 기대하는 공용 메서드들이므로, 검사 대상을 메서드명에 중복해서 덧붙이는 것보다는 오버로딩하는 편이 낫다고 생각됩니다. + private boolean isValid(String path) { + // null 체크 및 pathPattern.matcher(path).matches() 로직은 모든 도메인에서 반복되는 유효성 검사이므로, 필요할 때마다 계속 추가하는 것보다 공통 유틸 클래스로 분리하는 게 낫다고 생각됩니다. + return FormatValidator.isValid(path, pathPattern); + } + + private boolean isValid(ImageExtension extension) { + return FormatValidator.isValid(extension.getExtension(), extensionPattern); } @Override @@ -54,6 +67,11 @@ public String generateFileUploadUrl(String fileUri) throws NullPointerException return this.serverUrl + "api/v1/files/" + fileUri; } + @Override + public String generateFileUploadUrl(String path, @Nullable ImageExtension extension) throws NullPointerException { + return generateFileUploadUrl(generateUuidFileUri(path, extension)); + } + @Override public String getFileLocation(FileClassification fileClassification) { int lastSlashIndex = this.serverUrl.lastIndexOf('/'); diff --git a/src/main/java/com/codezerotoone/mvp/global/security/token/introspector/DefaultOpaqueTokenIntrospector.java b/src/main/java/com/codezerotoone/mvp/global/security/token/introspector/DefaultOpaqueTokenIntrospector.java index 746e7e8..96137f7 100644 --- a/src/main/java/com/codezerotoone/mvp/global/security/token/introspector/DefaultOpaqueTokenIntrospector.java +++ b/src/main/java/com/codezerotoone/mvp/global/security/token/introspector/DefaultOpaqueTokenIntrospector.java @@ -36,7 +36,7 @@ public OAuth2AuthenticatedPrincipal introspect(String token) { Member member = memberOp.get(); return new DefaultOAuth2AuthenticatedPrincipal( - String.valueOf(member.getMemberId()), + String.valueOf(member.getId()), Map.of("sub", oidcId == null ? "" : oidcId), List.of(new SimpleGrantedAuthority(member.getRole().getRoleId())) ); diff --git a/src/main/java/com/codezerotoone/mvp/global/util/FormatValidator.java b/src/main/java/com/codezerotoone/mvp/global/util/FormatValidator.java new file mode 100644 index 0000000..c407117 --- /dev/null +++ b/src/main/java/com/codezerotoone/mvp/global/util/FormatValidator.java @@ -0,0 +1,18 @@ +package com.codezerotoone.mvp.global.util; + +import java.util.Collection; +import java.util.regex.Pattern; + +public class FormatValidator { + public static boolean isValid(CharSequence value, Pattern pattern) { + return hasValue(value) && hasValue(pattern) && pattern.matcher(value).matches(); + } + + public static boolean hasValue(Object value) { + return value != null && !value.toString().trim().isEmpty(); + } + + public static boolean hasValue(Collection value) { + return value != null && !value.isEmpty(); + } +} diff --git a/src/main/java/com/codezerotoone/mvp/global/web/error/ServletErrorController.java b/src/main/java/com/codezerotoone/mvp/global/web/error/ServletErrorController.java index 7191d02..7d79071 100644 --- a/src/main/java/com/codezerotoone/mvp/global/web/error/ServletErrorController.java +++ b/src/main/java/com/codezerotoone/mvp/global/web/error/ServletErrorController.java @@ -36,16 +36,8 @@ public ResponseEntity handleError(HttpServletRequest request) { } return new ResponseEntity<>( - ErrorResponse.of( - // TODO: switch문으로 하는 게 좋을 것인가, 아니면 Enum 값들을 순회하는 게 좋을 것인가? - switch (status) { - case 404 -> CommonErrorCode.RESOURCE_NOT_FOUND; - case 405 -> CommonErrorCode.HTTP_METHOD_NOT_ALLOWED; - case 415 -> CommonErrorCode.UNSUPPORTED_MEDIA_TYPE; - default -> CommonErrorCode.INTERNAL_SERVER_ERROR; - }, - detail - ), + // 변경 사유: 기존의 switch 문도 나쁘지 않지만, 결합도가 높아 ServletErrorController가 CommonErrorCode의 멤버 구성에 의존하게 되므로 유지보수가 어렵다고 생각됩니다. + ErrorResponse.of(CommonErrorCode.get(status), detail), HttpStatus.valueOf(status) ); } diff --git a/src/test/java/com/codezerotoone/mvp/domain/image/repository/ImageRepositoryTest.java b/src/test/java/com/codezerotoone/mvp/domain/image/repository/ImageRepositoryTest.java index 0b5515c..f318567 100644 --- a/src/test/java/com/codezerotoone/mvp/domain/image/repository/ImageRepositoryTest.java +++ b/src/test/java/com/codezerotoone/mvp/domain/image/repository/ImageRepositoryTest.java @@ -48,9 +48,9 @@ void testSave_cascadePersist() { List result = this.em.createQuery(""" SELECT ri FROM ResizedImage ri - WHERE ri.deletedAt IS NULL AND ri.image.imageId = :imageId + WHERE ri.deleteYn = false AND ri.image.id = :imageId """, ResizedImage.class) - .setParameter("imageId", image.getImageId()) + .setParameter("imageId", image.getId()) .getResultList(); // 사이즈 확인 @@ -65,6 +65,6 @@ void testSave_cascadePersist() { ); // Image 엔티티가 제대로 들어갔는지 확인 - assertThat(this.imageRepository.findById(image.getImageId())).isNotEmpty(); + assertThat(this.imageRepository.findById(image.getId())).isNotEmpty(); } -} \ No newline at end of file +} diff --git a/src/test/java/com/codezerotoone/mvp/domain/image/repository/ResizedImageRepositoryTest.java b/src/test/java/com/codezerotoone/mvp/domain/image/repository/ResizedImageRepositoryTest.java index 184925c..8b93aab 100644 --- a/src/test/java/com/codezerotoone/mvp/domain/image/repository/ResizedImageRepositoryTest.java +++ b/src/test/java/com/codezerotoone/mvp/domain/image/repository/ResizedImageRepositoryTest.java @@ -43,12 +43,12 @@ void save_simple() throws NoSuchMethodException, InvocationTargetException, Inst resizedImage = this.resizedImageRepository.save(resizedImage); // Then - Long generatedId = resizedImage.getResizedImageId(); + Long generatedId = resizedImage.getId(); Optional findResizedImageOp = this.resizedImageRepository.findById(generatedId); assertThat(findResizedImageOp).isNotEmpty(); - assertThat(findResizedImageOp.get().getResizedImageId()).isEqualTo(generatedId); - assertThat(findResizedImageOp.get().getImage().getImageId()).isEqualTo(image.getImageId()); + assertThat(findResizedImageOp.get().getId()).isEqualTo(generatedId); + assertThat(findResizedImageOp.get().getImage().getId()).isEqualTo(image.getId()); assertThat(findResizedImageOp.get().getImageSizeType()).isEqualTo(ImageSizeType.ORIGINAL); assertThat(findResizedImageOp.get().getResizedImageUrl()).isEqualTo(resizedImage.getResizedImageUrl()); } -} \ No newline at end of file +} diff --git a/src/test/java/com/codezerotoone/mvp/domain/member/member/repository/MemberRepositoryTest.java b/src/test/java/com/codezerotoone/mvp/domain/member/member/repository/MemberRepositoryTest.java index e8d41fb..6f3d6e5 100644 --- a/src/test/java/com/codezerotoone/mvp/domain/member/member/repository/MemberRepositoryTest.java +++ b/src/test/java/com/codezerotoone/mvp/domain/member/member/repository/MemberRepositoryTest.java @@ -171,4 +171,4 @@ void existsByOdicId_returnFalse_odicIdIsNotCorrect() { // Then assertThat(result).isFalse(); } -} \ No newline at end of file +} diff --git a/src/test/java/com/codezerotoone/mvp/domain/member/member/service/DefaultMemberServiceIntegrationTest.java b/src/test/java/com/codezerotoone/mvp/domain/member/member/service/DefaultMemberServiceIntegrationTest.java index 21aac7b..dc7a65d 100644 --- a/src/test/java/com/codezerotoone/mvp/domain/member/member/service/DefaultMemberServiceIntegrationTest.java +++ b/src/test/java/com/codezerotoone/mvp/domain/member/member/service/DefaultMemberServiceIntegrationTest.java @@ -132,7 +132,7 @@ void saveMember_preventDuplicateInMultithreadedEnvironment() throws ExecutionExc assertThat(generatedMemberId).isNotNull(); Member member = result.get(0); - assertThat(member.getMemberId()).isEqualTo(generatedMemberId); + assertThat(member.getId()).isEqualTo(generatedMemberId); // Clear em.remove(em.find(MemberProfile.class, generatedMemberId)); diff --git a/src/test/java/com/codezerotoone/mvp/domain/member/member/service/DefaultMemberServiceTest.java b/src/test/java/com/codezerotoone/mvp/domain/member/member/service/DefaultMemberServiceTest.java index e7f4588..5dd9a96 100644 --- a/src/test/java/com/codezerotoone/mvp/domain/member/member/service/DefaultMemberServiceTest.java +++ b/src/test/java/com/codezerotoone/mvp/domain/member/member/service/DefaultMemberServiceTest.java @@ -58,17 +58,14 @@ void createMember_imageExtension_null() throws Exception { ReflectionTestUtils.setField(generatedMember, "memberId", memberId); when(this.memberRepository.save(any())).thenReturn(generatedMember); - when(this.fileUrlResolver.generateUuidFileUri(any(), any())) + when(fileUrlResolver.generateFileUploadUrl(any(String.class), any(ImageExtension.class))) .thenAnswer((invocationOnMock) -> { + String path = invocationOnMock.getArgument(0); + ImageExtension extension = invocationOnMock.getArgument(1); String uuid = UUID.randomUUID().toString(); - String extension = invocationOnMock.getArgument(0); - String filePath = invocationOnMock.getArgument(1); - return filePath + "/" + uuid + "_" + System.currentTimeMillis() + "." + extension; - }); - when(this.fileUrlResolver.generateFileUploadUrl(any())) - .thenAnswer((invocationOnMock) -> { - String uri = invocationOnMock.getArgument(0); - return fileUploadBaseUri + uri; + String fileUri = path + "/" + uuid + "_" + System.currentTimeMillis() + "." + + extension.getExtension(); + return fileUploadBaseUri + fileUri; }); } diff --git a/src/test/java/com/codezerotoone/mvp/domain/member/memberprofile/integration/MemberProfileIntegrationTest.java b/src/test/java/com/codezerotoone/mvp/domain/member/memberprofile/integration/MemberProfileIntegrationTest.java index f9a79e8..a2ff1f7 100644 --- a/src/test/java/com/codezerotoone/mvp/domain/member/memberprofile/integration/MemberProfileIntegrationTest.java +++ b/src/test/java/com/codezerotoone/mvp/domain/member/memberprofile/integration/MemberProfileIntegrationTest.java @@ -291,7 +291,7 @@ void findMemberProfile_success() throws Exception { Member member = Member.createGeneralMemberBySocialLogin("안유진", "123"); Member savedMember = this.memberRepository.save(member); - RequestEntity request = RequestEntity.get(this.serverOrigin + "/api/v1/members/{memberId}/profile", savedMember.getMemberId()) + RequestEntity request = RequestEntity.get(this.serverOrigin + "/api/v1/members/{memberId}/profile", savedMember.getId()) .build(); // When @@ -310,7 +310,7 @@ void findMemberProfile_success() throws Exception { FullMemberProfileResponseDto content = responseBody.getContent(); - assertThat(content.memberId()).isEqualTo(savedMember.getMemberId()); + assertThat(content.memberId()).isEqualTo(savedMember.getId()); assertThat(content.autoMatching()).isFalse(); // autoMatching is false by default // Validate content.memberInfo @@ -341,7 +341,7 @@ void findMemberProfileForStudy_401_anonymous() throws Exception { Member member = Member.createGeneralMemberBySocialLogin("안유진", "123"); Member savedMember = this.memberRepository.save(member); - RequestEntity request = RequestEntity.get(this.serverOrigin + "/api/v1/members/{memberId}/profile/for-study", savedMember.getMemberId()) + RequestEntity request = RequestEntity.get(this.serverOrigin + "/api/v1/members/{memberId}/profile/for-study", savedMember.getId()) .build(); // When @@ -366,7 +366,7 @@ void findMemberProfileForStudy_403_guest() throws Exception { final String accessToken = "{\"id\":\"456\"}"; - RequestEntity request = RequestEntity.get(this.serverOrigin + "/api/v1/members/{memberId}/profile/for-study", savedMember.getMemberId()) + RequestEntity request = RequestEntity.get(this.serverOrigin + "/api/v1/members/{memberId}/profile/for-study", savedMember.getId()) // 로그인은 했지만 회원가입은 하지 않은 경우, Guest .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) .build(); @@ -498,7 +498,7 @@ void updateMemberProfile_ignoreNullFalse() throws Exception { member = this.memberRepository.save(member); RequestEntity request = RequestEntity.post(this.serverOrigin + "/api/v1/members/{memberId}/profile", - member.getMemberId()) + member.getId()) .header(HttpHeaders.AUTHORIZATION, jsonAccessToken) .header(HttpMethodOverrideConstant.HEADER_NAME, "PATCH") .contentType(MediaType.APPLICATION_JSON) @@ -526,7 +526,7 @@ void updateMemberProfile_ignoreNullFalse() throws Exception { // Then assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); - assertThat(responseContent.memberId()).isEqualTo(member.getMemberId()); + assertThat(responseContent.memberId()).isEqualTo(member.getId()); assertThat(responseContent.name()).isEqualTo("이현서"); assertThat(responseContent.tel()).isEqualTo("010-1234-1234"); assertThat(responseContent.githubLink()).isEqualTo("https://localhost:8080/github"); @@ -541,7 +541,7 @@ void updateMemberProfile_ignoreNullFalse() throws Exception { ); // Validate member profile in repository - Member findMember = this.memberRepository.findById(member.getMemberId()).orElseThrow(); + Member findMember = this.memberRepository.findById(member.getId()).orElseThrow(); MemberProfile memberProfile = findMember.getMemberProfile(); MemberProfileData memberProfileData = memberProfile.getMemberProfileData(); assertThat(memberProfile.getMemberName()).isEqualTo("이현서"); @@ -558,7 +558,7 @@ void updateMemberProfile_ignoreNullFalse() throws Exception { WHERE sm.memberProfile.memberId = :memberId AND smt.socialMediaTypeId = :socialMediaTypeId """, SocialMedia.class) - .setParameter("memberId", member.getMemberId()) + .setParameter("memberId", member.getId()) .setParameter("socialMediaTypeId", PrimarySocialMediaType.GITHUB.name()) .getSingleResult(); assertThat(github.getUrl()).isEqualTo("https://localhost:8080/github"); @@ -571,7 +571,7 @@ void updateMemberProfile_ignoreNullFalse() throws Exception { WHERE sm.memberProfile.memberId = :memberId AND smt.socialMediaTypeId = :socialMediaTypeId """, SocialMedia.class) - .setParameter("memberId", member.getMemberId()) + .setParameter("memberId", member.getId()) .setParameter("socialMediaTypeId", PrimarySocialMediaType.BLOG_OR_SNS.name()) .getSingleResult(); assertThat(blogOrSns.getUrl()).isEqualTo("https://localhost:8080/blog"); @@ -582,7 +582,7 @@ void updateMemberProfile_ignoreNullFalse() throws Exception { FROM MemberInterest mi WHERE mi.memberProfile.memberId = :memberId """, MemberInterest.class) - .setParameter("memberId", member.getMemberId()) + .setParameter("memberId", member.getId()) .getResultList(); assertThat(memberInterests).extracting("name") .containsExactlyInAnyOrder( @@ -602,7 +602,7 @@ void updateMemberProfile_idempotency() throws Exception { member = this.memberRepository.save(member); RequestEntity request = RequestEntity.post(this.serverOrigin + "/api/v1/members/{memberId}/profile", - member.getMemberId()) + member.getId()) .header(HttpHeaders.AUTHORIZATION, jsonAccessToken) .header(HttpMethodOverrideConstant.HEADER_NAME, "PATCH") .contentType(MediaType.APPLICATION_JSON) @@ -632,7 +632,7 @@ void updateMemberProfile_idempotency() throws Exception { // Then assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); - assertThat(responseContent.memberId()).isEqualTo(member.getMemberId()); + assertThat(responseContent.memberId()).isEqualTo(member.getId()); assertThat(responseContent.name()).isEqualTo("이현서"); assertThat(responseContent.tel()).isEqualTo("010-1234-1234"); assertThat(responseContent.githubLink()).isEqualTo("https://localhost:8080/github"); @@ -647,7 +647,7 @@ void updateMemberProfile_idempotency() throws Exception { ); // Validate member profile in repository - Member findMember = this.memberRepository.findById(member.getMemberId()).orElseThrow(); + Member findMember = this.memberRepository.findById(member.getId()).orElseThrow(); MemberProfile memberProfile = findMember.getMemberProfile(); MemberProfileData memberProfileData = memberProfile.getMemberProfileData(); assertThat(memberProfile.getMemberName()).isEqualTo("이현서"); @@ -658,37 +658,37 @@ void updateMemberProfile_idempotency() throws Exception { // Validate collections // GitHub SocialMedia github = this.em.createQuery(""" - SELECT sm - FROM SocialMedia sm - INNER JOIN SocialMediaType smt ON smt.socialMediaTypeId = sm.socialMediaType.socialMediaTypeId - WHERE sm.memberProfile.memberId = :memberId - AND smt.socialMediaTypeId = :socialMediaTypeId - """, SocialMedia.class) - .setParameter("memberId", member.getMemberId()) + SELECT sm + FROM SocialMedia sm + INNER JOIN SocialMediaType smt ON smt.socialMediaTypeId = sm.socialMediaType.socialMediaTypeId + WHERE sm.memberProfile.memberId = :memberId + AND smt.socialMediaTypeId = :socialMediaTypeId + """, SocialMedia.class) + .setParameter("memberId", member.getId()) .setParameter("socialMediaTypeId", PrimarySocialMediaType.GITHUB.name()) .getSingleResult(); assertThat(github.getUrl()).isEqualTo("https://localhost:8080/github"); // Blog or SNS SocialMedia blogOrSns = this.em.createQuery(""" - SELECT sm - FROM SocialMedia sm - INNER JOIN SocialMediaType smt ON smt.socialMediaTypeId = sm.socialMediaType.socialMediaTypeId - WHERE sm.memberProfile.memberId = :memberId - AND smt.socialMediaTypeId = :socialMediaTypeId - """, SocialMedia.class) - .setParameter("memberId", member.getMemberId()) + SELECT sm + FROM SocialMedia sm + INNER JOIN SocialMediaType smt ON smt.socialMediaTypeId = sm.socialMediaType.socialMediaTypeId + WHERE sm.memberProfile.memberId = :memberId + AND smt.socialMediaTypeId = :socialMediaTypeId + """, SocialMedia.class) + .setParameter("memberId", member.getId()) .setParameter("socialMediaTypeId", PrimarySocialMediaType.BLOG_OR_SNS.name()) .getSingleResult(); assertThat(blogOrSns.getUrl()).isEqualTo("https://localhost:8080/blog"); // Interest List memberInterests = this.em.createQuery(""" - SELECT mi - FROM MemberInterest mi - WHERE mi.memberProfile.memberId = :memberId - """, MemberInterest.class) - .setParameter("memberId", member.getMemberId()) + SELECT mi + FROM MemberInterest mi + WHERE mi.memberProfile.memberId = :memberId + """, MemberInterest.class) + .setParameter("memberId", member.getId()) .getResultList(); assertThat(memberInterests).extracting("name") .containsExactlyInAnyOrder( diff --git a/src/test/java/com/codezerotoone/mvp/domain/member/memberprofile/repository/JpaDefaultMemberProfileRepositoryTest.java b/src/test/java/com/codezerotoone/mvp/domain/member/memberprofile/repository/JpaDefaultMemberProfileRepositoryTest.java index 0c92b0e..19a27e9 100644 --- a/src/test/java/com/codezerotoone/mvp/domain/member/memberprofile/repository/JpaDefaultMemberProfileRepositoryTest.java +++ b/src/test/java/com/codezerotoone/mvp/domain/member/memberprofile/repository/JpaDefaultMemberProfileRepositoryTest.java @@ -71,7 +71,7 @@ void saveMember() { Long generatedMemberId; { // When - generatedMemberId = this.memberRepository.save(member).getMemberId(); + generatedMemberId = this.memberRepository.save(member).getId(); } { // Then @@ -90,7 +90,7 @@ void saveMember_delete() { member = this.memberRepository.save(member); // 회원 프로필 삭제 (영속성 컨텍스트 안에 있는 엔티티에 영향을 미침) member.delete(); - generatedMemberId = member.getMemberId(); + generatedMemberId = member.getId(); } Optional result; @@ -102,4 +102,4 @@ void saveMember_delete() { assertThat(result).isEmpty(); } } -} \ No newline at end of file +} diff --git a/src/test/java/com/codezerotoone/mvp/domain/member/memberprofile/repository/JpaMemberProfileImageTest.java b/src/test/java/com/codezerotoone/mvp/domain/member/memberprofile/repository/JpaMemberProfileImageTest.java index 258665e..2996d67 100644 --- a/src/test/java/com/codezerotoone/mvp/domain/member/memberprofile/repository/JpaMemberProfileImageTest.java +++ b/src/test/java/com/codezerotoone/mvp/domain/member/memberprofile/repository/JpaMemberProfileImageTest.java @@ -51,12 +51,12 @@ void imageTest() { // Given Image image = Image.create("http://localhost:8080", new ResizedImageInfo("a.png", ImageSizeType.ORIGINAL)); image = this.imageRepository.save(image); - generatedImageId = image.getImageId(); + generatedImageId = image.getId(); Member member = Member.createGeneralMember("sample@gmail.com", "GDP"); - member.getMemberProfile().updateProfileImage(Image.getReference(generatedImageId)); + member.getMemberProfile().updateProfileImage(Image.of(generatedImageId)); member = this.memberRepository.save(member); - generatedMemberId = member.getMemberId(); + generatedMemberId = member.getId(); this.em.flush(); this.em.clear(); @@ -69,7 +69,7 @@ void imageTest() { log.info("memberProfile.image: {}", memberProfile.getProfileImage().getClass()); // 프록시 객체 // Then - assertThat(memberProfile.getProfileImage().getImageId()).isEqualTo(generatedImageId); + assertThat(memberProfile.getProfileImage().getId()).isEqualTo(generatedImageId); assertThat(memberProfile.getProfileImage().getResizedImages()).size().isEqualTo(1); } } diff --git a/src/test/java/com/codezerotoone/mvp/domain/member/memberprofile/repository/jpa/MemberInterestRepositoryTest.java b/src/test/java/com/codezerotoone/mvp/domain/member/memberprofile/repository/jpa/MemberInterestRepositoryTest.java index 4fd65d0..0c57370 100644 --- a/src/test/java/com/codezerotoone/mvp/domain/member/memberprofile/repository/jpa/MemberInterestRepositoryTest.java +++ b/src/test/java/com/codezerotoone/mvp/domain/member/memberprofile/repository/jpa/MemberInterestRepositoryTest.java @@ -35,11 +35,11 @@ void delete() { Member member = Member.createGeneralMember("sample", "안유진"); this.em.persist(member); - MemberProfile memberProfile = this.em.find(MemberProfile.class, member.getMemberId()); + MemberProfile memberProfile = this.em.find(MemberProfile.class, member.getId()); List memberInterests = List.of( - MemberInterest.create(memberProfile, "hello"), - MemberInterest.create(memberProfile, "world") + MemberInterest.of(memberProfile, "hello"), + MemberInterest.of(memberProfile, "world") ); memberInterests.forEach(this.em::persist); diff --git a/src/test/java/com/codezerotoone/mvp/domain/member/memberprofile/service/MemberProfileServiceIntegrationTest.java b/src/test/java/com/codezerotoone/mvp/domain/member/memberprofile/service/MemberProfileServiceIntegrationTest.java index 32c450d..c499e1b 100644 --- a/src/test/java/com/codezerotoone/mvp/domain/member/memberprofile/service/MemberProfileServiceIntegrationTest.java +++ b/src/test/java/com/codezerotoone/mvp/domain/member/memberprofile/service/MemberProfileServiceIntegrationTest.java @@ -73,8 +73,8 @@ public FileUrlResolver testFileUrlResolver() { return new FileUrlResolver() { @Override - public String generateUuidFileUri(String extension, String path) throws IllegalArgumentException { - return path + "/test-file." + extension; + public String generateUuidFileUri(String path, ImageExtension extension) throws IllegalArgumentException { + return path + "/test-file." + extension.getExtension(); } @Override @@ -82,6 +82,11 @@ public String generateFileUploadUrl(String fileUri) throws NullPointerException return "https://img.zeroone.it.kr/" + fileUri; } + @Override + public String generateFileUploadUrl(String path, ImageExtension extension) throws NullPointerException { + return generateFileUploadUrl(generateUuidFileUri(path, extension)); + } + @Override public String getFileLocation(FileClassification fileClassification) { return "https://img.zeroone.it.kr"; @@ -190,7 +195,7 @@ void updateMemberInfo_idempotency(int count) { // When List results = new ArrayList<>(); for (int i = 0; i < count; i++) { - results.add(this.memberProfileService.updateMemberInfo(member.getMemberId(), dto, true)); + results.add(this.memberProfileService.updateMemberInfo(member.getId(), dto, true)); } // Then @@ -198,7 +203,7 @@ void updateMemberInfo_idempotency(int count) { // Compare parameters and returns { MemberInfoUpdateResponseDto firstResult = results.getFirst(); - assertThat(firstResult.memberId()).isEqualTo(member.getMemberId()); + assertThat(firstResult.memberId()).isEqualTo(member.getId()); assertThat(firstResult.selfIntroduction()).isEqualTo(selfIntroduction); assertThat(firstResult.studyPlan()).isEqualTo(studyPlan); assertThat(firstResult.preferredStudySubjectId()).isEqualTo(preferredStudySubjectId); @@ -216,7 +221,7 @@ void updateMemberInfo_idempotency(int count) { } // Validate entity - MemberProfile memberProfile = this.em.find(MemberProfile.class, member.getMemberId()); + MemberProfile memberProfile = this.em.find(MemberProfile.class, member.getId()); MemberInfo memberInfo = memberProfile.getMemberInfo(); assertThat(memberInfo.getSelfIntroduction()).isEqualTo(selfIntroduction); assertThat(memberInfo.getStudyPlan()).isEqualTo(studyPlan); @@ -251,7 +256,7 @@ void getMemberProfile_success() { // Update member profile // Iterate multiple times for (int i = 0; i < 100; i++) { - this.memberProfileService.updateProfile(member.getMemberId(), MemberProfileUpdateRequestDto.builder() + this.memberProfileService.updateProfile(member.getId(), MemberProfileUpdateRequestDto.builder() .name("유진수") .tel("010-4124-2422") .githubLink("https://github.com/rudeh1253") @@ -263,7 +268,7 @@ void getMemberProfile_success() { .profileImageExtension(profileImageExtension) .build()); - this.memberProfileService.updateMemberInfo(member.getMemberId(), MemberInfoUpdateRequestDto.builder() + this.memberProfileService.updateMemberInfo(member.getId(), MemberInfoUpdateRequestDto.builder() .selfIntroduction(selfIntroduction) .preferredStudySubjectId(preferredStudySubjectId) .availableStudyTimeIds(availableStudyTimeIds) @@ -279,7 +284,7 @@ void getMemberProfile_success() { member.getMemberProfile().getMemberProfileData().updateProfileImage(profileImage); // Get Member Profile - FullMemberProfileResponseDto memberProfileDto = this.memberProfileService.getMemberProfile(member.getMemberId()); + FullMemberProfileResponseDto memberProfileDto = this.memberProfileService.getMemberProfile(member.getId()); MemberProfileResponseDto memberProfileDataDto = memberProfileDto.memberProfile(); MemberInfoResponseDto memberInfoDto = memberProfileDto.memberInfo(); @@ -308,7 +313,7 @@ void updateMemberInfo_techStackDuplication() { Member member = Member.createGeneralMemberBySocialLogin(memberName, "152621421"); this.em.persist(member); - Long memberId = member.getMemberId(); + Long memberId = member.getId(); List techStackIdsToUpdate = this.techStacks.stream() .map(TechStack::getTechStackId) diff --git a/src/test/java/com/codezerotoone/mvp/global/api/error/CommonErrorCodeTest.java b/src/test/java/com/codezerotoone/mvp/global/api/error/CommonErrorCodeTest.java new file mode 100644 index 0000000..5b61355 --- /dev/null +++ b/src/test/java/com/codezerotoone/mvp/global/api/error/CommonErrorCodeTest.java @@ -0,0 +1,39 @@ +package com.codezerotoone.mvp.global.api.error; + +import com.codezerotoone.mvp.global.web.error.ServletErrorController; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +class CommonErrorCodeTest { + private final ServletErrorController servletErrorController = new ServletErrorController(); + + @DisplayName("상태 코드 값을 입력하면 해당하는 공통 예외 코드 객체를 반환한다.") + @ParameterizedTest + @CsvSource({ + "404", "405", "415", "500", "503" + }) + void get(int statusCode) { + // given + ErrorCodeSpec expectedCode = chooseCommonErrorCode(statusCode); + + // when + ErrorCodeSpec commonErrorCode = CommonErrorCode.get(statusCode); + + // then + assertThat(commonErrorCode).isEqualTo(expectedCode); + assertThat(commonErrorCode.hashCode()).isEqualTo(expectedCode.hashCode()); + } + + private ErrorCodeSpec chooseCommonErrorCode(int statusCode) { + for (CommonErrorCode errorCode : CommonErrorCode.values()) { + if (errorCode.getStatusCode() == statusCode) { + return errorCode; + } + } + + return CommonErrorCode.INTERNAL_SERVER_ERROR; + } +} diff --git a/src/test/java/com/codezerotoone/mvp/global/file/url/LocalFileUrlResolverTest.java b/src/test/java/com/codezerotoone/mvp/global/file/url/LocalFileUrlResolverTest.java index 0da8b14..34c353a 100644 --- a/src/test/java/com/codezerotoone/mvp/global/file/url/LocalFileUrlResolverTest.java +++ b/src/test/java/com/codezerotoone/mvp/global/file/url/LocalFileUrlResolverTest.java @@ -1,5 +1,6 @@ package com.codezerotoone.mvp.global.file.url; +import com.codezerotoone.mvp.domain.image.constant.ImageExtension; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -19,11 +20,11 @@ class LocalFileUrlResolverTest { @DisplayName("적절한 포맷의 파일 확장자와 path를 파라미터로 전달할 경우, \"/\"로 시작하지 않는 파일 URI 생성") void generateUuidFileUri_success() { // Given - final String extension = "jpg"; + final ImageExtension extension = ImageExtension.JPG; final String path = "files/images"; // When - String result = this.resolver.generateUuidFileUri(extension, path); + String result = this.resolver.generateUuidFileUri(path, extension); log.info("result={}", result); // Then diff --git a/src/test/java/com/codezerotoone/mvp/global/web/error/ServletErrorControllerTest.java b/src/test/java/com/codezerotoone/mvp/global/web/error/ServletErrorControllerTest.java new file mode 100644 index 0000000..d4c6e85 --- /dev/null +++ b/src/test/java/com/codezerotoone/mvp/global/web/error/ServletErrorControllerTest.java @@ -0,0 +1,35 @@ +package com.codezerotoone.mvp.global.web.error; + +import com.codezerotoone.mvp.global.api.format.ErrorResponse; +import jakarta.servlet.RequestDispatcher; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ServletErrorControllerTest { + private final ServletErrorController servletErrorController = new ServletErrorController(); + + @DisplayName("HTTP Servlet 요청 객체를 입력하면 적합한 예외 응답 객체를 반환한다.") + @ParameterizedTest + @CsvSource({ + "404", "405", "415", "500", "503" + }) + void handleError(int statusCode) { + // given + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE)).thenReturn(statusCode); + + // when + ResponseEntity response = servletErrorController.handleError(request); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.valueOf(statusCode)); + } +}