Skip to content

Feat/#19 프로필 이미지 업로드 및 삭제, 테스트 global 설정, 도메인 테이블 이름 규칙 정의, application-local.yml로 운영환경 h2 설정, validation 의존성 추가 및 dto 검증#23

Open
tae0u0 wants to merge 48 commits intodevfrom
feat/#19_profile_image_upload
Open

Feat/#19 프로필 이미지 업로드 및 삭제, 테스트 global 설정, 도메인 테이블 이름 규칙 정의, application-local.yml로 운영환경 h2 설정, validation 의존성 추가 및 dto 검증#23
tae0u0 wants to merge 48 commits intodevfrom
feat/#19_profile_image_upload

Conversation

@tae0u0
Copy link
Collaborator

@tae0u0 tae0u0 commented Jun 8, 2025

#️⃣ 연관된 이슈

#19

📝 작업 내용


## 프로필 이미지 업로드 및 삭제

프로필 이미지 업로드 및 삭제 기능을 구현했습니다.
회원 가입시에도 이미지 업로드를 진행할 수 있기에 signUp api에 s3Uploader를 사용하여 이미지 업로드 기능을 추가하였습니다.

1️⃣ 이미지 업로드 기능

POST /api/profile

@CurrentMember Member member,
@NotEmptyFile @RequestPart("profileImage") MultipartFile multipartFile
  • member와 multipartFile을 파라미터로 받아, UploadProfileRequest(member, multipartFile)로 이미지를 업로드합니다.
  • member가 이미 업로드한 이미지가 있다면 삭제 후, 새로운 이미지를 업로드합니다.
  • 응답은 SuccessResponse 래퍼를 통해 OK 상태와 메시지를 함께 반환합니다.

2️⃣ 이미지 삭제 기능

DELETE /api/profile

  • member를 받아서, DeleteProfileRequest(member)로 이미지를 삭제합니다.
  • 삭제할 파일이 없을 경우, AmazonS3Exception 에러를 반환합니다.
  • 응답은 SuccessResponse 래퍼를 통해 OK 상태와 메시지를 함께 반환합니다.




유효성 검증

유의 사항

  1. 객체 단위(dto)에는 @Valid 사용, 파라미터 단위(pathVariable, requestParam 등)는 클래스 상단에 @validated를 붙이고, 파라미터 앞에 validation 어노테이션 사용
  2. controller 단에서는 @Valid 사용 가능, 서비스 단에서는 @validated를 사용하고, @Valid 사용 (모르겠으면 공부)
  3. message를 작성하면, 오류 발생 시 errors에 message 내용이 넣어집니다. (errors : 오류 발생 필드 설명 list)

간단 예제

@RestController
@RequestMapping("/api")
@Validated
@AllArgsConstructor
public class S3Controller {
    private final S3Service S3Service;

    @PostMapping(value = "/profile", consumes = "multipart/form-data")
    public SuccessResponse<UploadProfileResponse> uploadProfileImage(
            @CurrentMember Member member,
            @NotNull @RequestPart("profileImage") MultipartFile multipartFile) {
        UploadProfileRequest request = new UploadProfileRequest(member, multipartFile);
        return SuccessResponse.of(S3SuccessCode.PROFILE_UPLOAD_SUCCESS, S3Service.uploadProfileImage(request));
    }
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth")
public class AuthController {

    private final AuthService authService;
    private final FcmTokenService fcmTokenService;
    private final MemberService memberService;

    @PostMapping("/login/kakao")
    public SuccessResponse<? extends LoginResponse> kakaoLogin(@Valid @RequestBody KakaoLoginRequest request) {
        LoginResponse response = authService.kakaoLogin(request.code(), request.fcmToken());

        if(response instanceof KakaoLoginResponse) {
            return SuccessResponse.of(MemberSuccessCode.LOGIN_SUCCESS, response);
        } else {
            return SuccessResponse.of(MemberSuccessCode.SIGNUP_REQUIRED, response);
        }
    }
public record KakaoLoginRequest(
        @NotBlank(message = "code는 빈칸이면 안됩니다.")
        String code,        
        @NotBlank(message = "fcmToken은 빈칸이면 안됩니다.")
        String fcmToken
) {
}

1. ValidationErrorCode 클래스

Dto 검증 시, 유효성 검증 오류를 나타내는 클래스입니다.

간단 예제

@Getter
@RequiredArgsConstructor
public enum ValidationErrorCode implements ErrorCode {
    VALIDATION_FAILED("VALID_001", HttpStatus.BAD_REQUEST, "유효성 검증에 실패하였습니다.");

    private final String developCode;
    private final HttpStatus httpStatus;
    private final String errorDescription;
}

2. ValidationExceptionHandler

전역 예외 처리기로 유효성 검증 오류들을 잡아 HTTP 상태와 바디를 즉시 반환합니다.

흐름

  1. 예외 발생 -> 스프링이 Adivce 탐색
  2. 유효성 검증 오류들() 메소드 매핑
  3. errorCode.getHttpStatus()로 상태 결정, 바디에는 errorCode.getDevelopCode() 에러 코드 설명, details 에러 상세 설명, errors 유효성 검증 실패가 발생한 필드 및 설명 출력
  4. errors 필드는 유효성 검증 오류에 관한 것만 들어가야 합니다. 서비스단에서의 오류는 들어가지 않습니다.
public record UploadProfileRequest(
        @NotNull(message = "사용자가 존재하여야 합니다.")
         Member member,
        @NotEmptyFile 
        MultipartFile profileImage
) {
}




도메인 테이블 이름 규칙

@table(name = " ") 을 사용하여 테이블 명을 소문자로 만듭니다.
fcmToken과 같은 경우도 fcmtoken으로 단어를 잇고, 모두 소문자로 만듭니다.

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "bookmark")
public class Bookmark {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private Long userId;
    private Long storeId;

    @Builder
    public Bookmark(Long userId, Long storeId) {
        this.userId = userId;
        this.storeId = storeId;
    }
}




application-local.yml로 운영환경 h2 설정

기존 환경에서는 로컬에서 서버가 켜지지 않았는데, 로컬 및 테스트 데이터베이스를 h2로 설정하여, 서버 운용이 가능하도록 환경설정을 추가하였습니다.




테스트 global 설정

통합 테스트 파일 및 데이터 생성 및 삭제를 도와주는 클래스들을 만들었습니다.
각 통합 테스트 파일을 상황에 맞게, 상속받아 사용하면 됩니다.

1. BaseRestDocs 클래스

RestDocs 테스트 용 통합 클래스 파일입니다.
application-test.yml 파일을 사용하는 걸로 되어 있습니다.
application-secret.yml 파일은 application.yml (기본값)에서 active 하였기에 항상 적용됩니다.
각 테스트 메서드 실행 전, 데이터베이스를 초기화하고, 공용 member와 accessToken을 발급합니다.

  • service 클래스가 필요할 경우 @MockBean으로 목 서비스 객체를 주입받으면 됩니다.

간단 예제

@AutoConfigureMockMvc
@AutoConfigureRestDocs
@SpringBootTest
@ActiveProfiles("test")
public abstract class BaseRestDocsTest {

    @MockBean protected S3Service s3Service;
    @Autowired protected MockMvc mockMvc;
    @Autowired protected DummyGenerator dummyGenerator;
    @Autowired private DatabaseCleanUp databaseCleanUp;

    protected Member member;
    protected String GIVEN_ACCESS_TOKEN;

    @BeforeEach
    void setUp() {
        databaseCleanUp.execute();
        member = dummyGenerator.createSingleMemberWithMemberImage();
        GIVEN_ACCESS_TOKEN = dummyGenerator.createAccessToken(member);
    }
}

2. BaseIntegrationTest 클래스

전체 테스트 용 통합 클래스 파일입니다.
application-test.yml 파일을 사용하는 걸로 되어 있습니다.
각 테스트 메서드 실행 전, 데이터베이스를 초기화합니다.

  • @SpringBootTest이기에 @component, @bean 등으로 등록한 모든 빈들을 주입받을 수 있으므로 상황에 맞게 주입받아 사용하면 됩니다.
  • webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT 설정을 통해 테스트 환경에서도 8080 포트로 열리게끔 하였습니다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
@ActiveProfiles("test")
public abstract class BaseIntegrationTest {
    @Autowired
    private DatabaseCleanUp databaseCleanUp;

    @BeforeEach
    void setUpSpy() {
        //DATABASE
        databaseCleanUp.execute();
    }
}

3. DummyGenerator 클래스

더미 데이터를 생성하는 클래스입니다.

4. DatabaseCleanUp 클래스

@entity가 걸린 클래스명을 이용하여 테이블을 초기화하는 클래스입니다.

CommonSuccessResponseField

5. CommonSuccessResponseField클래스

공통 성공 응답 필드를 쉽게 사용할 수 있도록 만든 클래스입니다.

  • responseFields(CommonSuccessResponseField.createCommonResponseFields())를 사용하여 공통 필드를 정의할 수 있습니다.
public class CommonSuccessResponseField {
    public static FieldDescriptor[] createCommonResponseFields() {
        return new FieldDescriptor[]{
                fieldWithPath("code").description("요청 응답 코드"),
                fieldWithPath("message").description("응답 메시지"),
                fieldWithPath("result").description("응답 데이터")
        };
    }
}
 mockMvc.perform(multipart("/api/profile")
                        .file(multipartFile)
                        .header("Authorization", GIVEN_ACCESS_TOKEN)
                        .contentType(MediaType.MULTIPART_FORM_DATA))
                .andExpect(status().isOk())
                .andDo(document("profile/upload",
                        requestParts(partWithName("profileImage").description("프로필 이미지 파일")),
                        responseFields(CommonSuccessResponseField.createCommonResponseFields())
                                .andWithPrefix("result.",
                                        fieldWithPath("path").description("이미지 경로"))
                ));

6. CommonErrorResponseField 클래스

공통 에러 응답 필드를 쉽게 사용할 수 있도록 만든 클래스입니다.

  • responseFields(CommonErrorResponseField.createCommonErrorResponseFields())를 사용하여 공통 필드를 정의할 수 있습니다.
public class CommonErrorResponseField {
    public static FieldDescriptor[] createCommonErrorResponseFields() {
        return new FieldDescriptor[]{
                fieldWithPath("errorCode").description("요청 응답 에러 코드"),
                fieldWithPath("errorDescription").description("응답 메시지"),
                fieldWithPath("details").description("내부 에러 메시지"),
                fieldWithPath("errors").description("필드 에러 오류 설명")
        };
    }
}
mockMvc.perform(multipart("/api/profile")
                        .file(multipartFile)
                        .header("Authorization", GIVEN_ACCESS_TOKEN)
                        .contentType(MediaType.MULTIPART_FORM_DATA))
                .andExpect(status().isInternalServerError())
                .andDo(document("profile/upload-fail",
                        requestParts(partWithName("profileImage").description("프로필 이미지 파일")),
                        responseFields(CommonErrorResponseField.createCommonErrorResponseFields())));




테스트 구조 컨벤션

given-when-then 구조 사용

  • given : 테스트를 준비하는 과정(변수 생성, 입력 값 정의 등)
  • when : 실제로 액션을 하는 과정 (함수 호출)
  • then : 테스트를 검증하는 과정 (예상 값과 실제 값이 일치하는 지 검증 등)

예시

@Test
    @DisplayName("s3 프로필 이미지 업로드 성공")
    void signUpSuccess() throws Exception {
        // given
        MockMultipartFile multipartFile = dummyGenerator.createMockMultipartFile();

        UploadProfileResponse mockResponse = new UploadProfileResponse("test_url");
        doReturn(mockResponse).when(s3Service)
                .uploadProfileImage(any());

        // when & then
        mockMvc.perform(multipart("/api/profile")
                        .file(multipartFile)
                        .header("Authorization", GIVEN_ACCESS_TOKEN)
                        .contentType(MediaType.MULTIPART_FORM_DATA))
                .andExpect(status().isOk())
                .andDo(document("profile/upload",
                        requestParts(partWithName("profileImage").description("프로필 이미지 파일")),
                        responseFields(CommonSuccessResponseField.createCommonResponseFields())
                                .andWithPrefix("result.",
                                        fieldWithPath("path").description("이미지 경로"))
                ));
    }

### DynamicTest - 현재 각 메서드마다 데이터베이스를 초기화하기 때문에 연속된 시나리오를 테스트하려면 한 테스트에 몰아서 적을 수밖에 없습니다. - 하지만, 한 테스트에 한 주제만 있는 것이 좋기에, 일련의 연속적인 테스트를 진행할 경우 DynamicTest를 진행하는 것이 좋습니다. ```java @Autowired S3Service s3Service; @Autowired DummyGenerator dummyGenerator;
@TestFactory
@DisplayName("s3 service 프로필 이미지 업로드 및 삭제")
Collection<DynamicTest> uploadProfileImageServiceDynamicTest() {
    // given
    Member member = dummyGenerator.createSingleMemberWithoutMemberImage();
    MockMultipartFile file = dummyGenerator.createMockMultipartFile();

    return List.of(
            DynamicTest.dynamicTest("프로필 이미지 업로드 수행", () -> {
                // given
                UploadProfileRequest request = new UploadProfileRequest(member, file);

                // when
                UploadProfileResponse result = s3Service.uploadProfileImage(request);

                // then
                Assertions.assertThat(result.path()).isNotNull();
            }),
            DynamicTest.dynamicTest("프로필 이미지 삭제 수행", () -> {
                // given
                DeleteProfileRequest request = new DeleteProfileRequest(member);

                // when
                boolean result = s3Service.deleteProfileImage(request);

                // then
                Assertions.assertThat(result).isTrue();
            })
    );
}


> 리뷰어가 특별히 봐주었으면 하는 부분이 있다면 작성해주세요
test 컨벤션 및 dto에 validation 검증 부분 인지

@tae0u0 tae0u0 self-assigned this Jun 8, 2025
@tae0u0 tae0u0 added 🐞bug 오류 관련 이슈 📗 docs 문서 관련 이슈 🔎 refactor 리팩토링 관련 이슈 🌟 feat 기능 개발 관련 이슈 🧪 test 테스트 관련 이슈 ✂ style 코드 스타일 관련 이슈 labels Jun 8, 2025
@coderabbitai
Copy link

coderabbitai bot commented Jun 8, 2025

Important

Review skipped

Auto reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Explain this complex logic.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai explain this code block.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and explain its main purpose.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@tae0u0 tae0u0 changed the title Feat/#19 profile image upload Feat/#19 프로필 이미지 업로드 및 삭제, 테스트 global 설정, 도메인 테이블 이름 규칙 정의, application-local.yml로 운영환경 h2 설정, validation 의존성 추가 및 dto 검증 Jun 10, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🐞bug 오류 관련 이슈 📗 docs 문서 관련 이슈 🌟 feat 기능 개발 관련 이슈 🔎 refactor 리팩토링 관련 이슈 ✂ style 코드 스타일 관련 이슈 🧪 test 테스트 관련 이슈

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant