Skip to content

[연예림] sprint3 #178

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Conversation

yinneu
Copy link
Collaborator

@yinneu yinneu commented Feb 16, 2025

요구사항

기본요구사항

Spring 프로젝트 초기화

  • [ ] Spring Initializr를 통해 zip 파일을 다운로드하세요.
    • 빌드 시스템은 Gradle - Groovy를 사용합니다.
    • 언어는 Java 17를 사용합니다.
    • Spring Boot의 버전은 3.4.0입니다.
    • GroupId는 com.sprint.mission입니다.
    • ArtifactId와 Name은 discodeit입니다.
    • packaging 형식은 Jar입니다
    • Dependency를 추가합니다.
      • Lombok
      • Spring Web
  • zip 파일을 압축해제하고 원래 진행 중이던 프로젝트에 붙여넣기하세요. 일부 파일은 덮어쓰기할 수 있습니다.
  • [ ] application.properties 파일을 yaml 형식으로 변경하세요.
  • [ ] DiscodeitApplication의 main 메서드를 실행하고 로그를 확인해보세요.

Bean 선언 및 테스트

  • File*Repository 구현체를 Repository 인터페이스의 Bean으로 등록하세요.
  • Basic*Service 구현체를 Service 인터페이스의 Bean으로 등록하세요.
  • [ ] JavaApplication에서 테스트했던 코드를 DiscodeitApplication에서 테스트해보세요.
    • JavaApplication 의 main 메소드를 제외한 모든 메소드를 DiscodeitApplication클래스로 복사하세요.
    • JavaApplication의 main 메소드에서 Service를 초기화하는 코드를 Spring Context를 활용하여 대체하세요.
    • JavaApplication의 main 메소드의 셋업, 테스트 부분의 코드를 DiscodeitApplication클래스로 복사하세요.

Lombok 적용

  • 도메인 모델의 getter 메소드를 @Getter로 대체해보세요.
  • [ ] Basic*Service의 생성자를 @RequiredArgsConstructor로 대체해보세요.

추가 기능 요구사항

시간 타입 변경하기

  • 시간을 다루는 필드의 타입은 Instant로 통일합니다.
    • 기존에 사용하던 Long보다 가독성이 뛰어나며, 시간대(Time Zone) 변환과 정밀한 시간 연산이 가능해 확장성이 높습니다.

새로운 도메인 추가하기

  • 공통: 앞서 정의한 도메인 모델과 동일하게 공통 필드(idcreatedAtupdatedAt)를 포함합니다.
  • [ ] ReadStatus
    • 사용자가 채널 별 마지막으로 메시지를 읽은 시간을 표현하는 도메인 모델입니다. 사용자별 각 채널에 읽지 않은 메시지를 확인하기 위해 활용합니다.
  • [ ] UserStatus
    • 사용자 별 마지막으로 확인된 접속 시간을 표현하는 도메인 모델입니다. 사용자의 온라인 상태를 확인하기 위해 활용합니다.
    • 마지막 접속 시간을 기준으로 현재 로그인한 유저로 판단할 수 있는 메소드를 정의하세요.
      • 마지막 접속 시간이 현재 시간으로부터 5분 이내이면 현재 접속 중인 유저로 간주합니다.
  • [ ] BinaryContent
    • 이미지, 파일 등 바이너리 데이터를 표현하는 도메인 모델입니다. 사용자의 프로필 이미지, 메시지에 첨부된 파일을 저장하기 위해 활용합니다.
    • 수정 불가능한 도메인 모델로 간주합니다. 따라서 updatedAt 필드는 정의하지 않습니다.
    • [ ] UserMessage 도메인 모델과의 의존 관계 방향성을 잘 고려하여 id 참조 필드를 추가하세요.
  • 각 도메인 모델 별 레포지토리 인터페이스를 선언하세요.
    • 레포지토리 구현체(File, JCF)는 아직 구현하지 마세요. 이어지는 서비스 고도화 요구사항에 따라 레포지토리 인터페이스에 메소드가 추가될 수 있어요.

UserService 고도화

  • 고도화
    • create
      • 선택적으로 프로필 이미지를 같이 등록할 수 있습니다.
      • DTO를 활용해 파라미터를 그룹화합니다.
        • 유저를 등록하기 위해 필요한 파라미터, 프로필 이미지를 등록하기 위해 필요한 파라미터 등
      • [ ] username과 email은 다른 유저와 같으면 안됩니다.
      • [ ] UserStatus를 같이 생성합니다.
    • findfindAll
      • DTO를 활용하여:
        • 사용자의 온라인 상태 정보를 같이 포함하세요.
        • 패스워드 정보는 제외하세요.
    • update
      • 선택적으로 프로필 이미지를 대체할 수 있습니다.
      • DTO를 활용해 파라미터를 그룹화합니다.
        • 수정 대상 객체의 id 파라미터, 수정할 값 파라미터
    • delete
      • 관련된 도메인도 같이 삭제합니다.
        • BinaryContent(프로필), UserStatus

AuthService 구현

  • login
    • [ ] usernamepassword과 일치하는 유저가 있는지 확인합니다.
      • 일치하는 유저가 있는 경우: 유저 정보 반환
      • 일치하는 유저가 없는 경우: 예외 발생
    • DTO를 활용해 파라미터를 그룹화합니다.

ChannelService 고도화

  • 고도화
    • create
      • PRIVATE 채널과 PUBLIC 채널을 생성하는 메소드를 분리합니다.
      • 분리된 각각의 메소드를 DTO를 활용해 파라미터를 그룹화합니다.
      • PRIVATE 채널을 생성할 때:
        • 채널에 참여하는 User의 정보를 받아 User 별 ReadStatus 정보를 생성합니다.
        • [ ] name과 description 속성은 생략합니다.
      • PUBLIC 채널을 생성할 때에는 기존 로직을 유지합니다.
    • find
      • DTO를 활용하여:
        • 해당 채널의 가장 최근 메시지의 시간 정보를 포함합니다.
        • [ ] PRIVATE 채널인 경우 참여한 User의 id 정보를 포함합니다.
    • findAll
      • DTO를 활용하여:
        • 해당 채널의 가장 최근 메시지의 시간 정보를 포함합니다.
        • [ ] PRIVATE 채널인 경우 참여한 User의 id 정보를 포함합니다.
      • 특정 User가 볼 수 있는 Channel 목록을 조회하도록 조회 조건을 추가하고, 메소드 명을 변경합니다. findAllByUserId
      • [ ] PUBLIC 채널 목록은 전체 조회합니다.
      • [ ] PRIVATE 채널은 조회한 User가 참여한 채널만 조회합니다.
    • update
      • DTO를 활용해 파라미터를 그룹화합니다.
        • 수정 대상 객체의 id 파라미터, 수정할 값 파라미터
      • PRIVATE 채널은 수정할 수 없습니다.
    • delete
      • 관련된 도메인도 같이 삭제합니다.
        • MessageReadStatus

MessageService 고도화

  • 고도화
    • create
      • 선택적으로 여러 개의 첨부파일을 같이 등록할 수 있습니다.
      • DTO를 활용해 파라미터를 그룹화합니다.
    • findAll
      • 특정 Channel의 Message 목록을 조회하도록 조회 조건을 추가하고, 메소드 명을 변경합니다. findallByChannelId
    • update
      • DTO를 활용해 파라미터를 그룹화합니다.
        • 수정 대상 객체의 id 파라미터, 수정할 값 파라미터
    • delete
      • 관련된 도메인도 같이 삭제합니다.
        • 첨부파일(BinaryContent)

ReadStatusService 구현

  • create
    • DTO를 활용해 파라미터를 그룹화합니다.
    • 관련된 Channel이나 User가 존재하지 않으면 예외를 발생시킵니다.
    • 같은 Channel과 User와 관련된 객체가 이미 존재하면 예외를 발생시킵니다.
  • find
    • [ ] id로 조회합니다.
  • findAllByUserId
    • [ ] userId를 조건으로 조회합니다.
  • update
    • DTO를 활용해 파라미터를 그룹화합니다.
      • 수정 대상 객체의 id 파라미터, 수정할 값 파라미터
  • delete
    • [ ] id로 삭제합니다.

UserStatusService 고도화

  • create
    • DTO를 활용해 파라미터를 그룹화합니다.
    • 관련된 User가 존재하지 않으면 예외를 발생시킵니다.
    • 같은 User와 관련된 객체가 이미 존재하면 예외를 발생시킵니다.
  • find
    • [ ] id로 조회합니다.
  • findAll
    • 모든 객체를 조회합니다.
  • update
    • DTO를 활용해 파라미터를 그룹화합니다.
      • 수정 대상 객체의 id 파라미터, 수정할 값 파라미터
  • updateByUserId
    • [ ] userId 로 특정 User의 객체를 업데이트합니다.
  • delete
    • [ ] id로 삭제합니다.

BinaryContentService 구현

  • create
    • DTO를 활용해 파라미터를 그룹화합니다.
  • find
    • [ ] id로 조회합니다.
  • findAllByIdIn
    • [ ] id 목록으로 조회합니다.
  • delete
    • [ ] id로 삭제합니다.

새로운 도메인 Repository 구현체 구현

  • 지금까지 인터페이스로 설계한 각각의 Repository를 JCF, File로 각각 구현하세요.

심화

심화 요구사항

Bean 다루기

  • Repository 구현체 중에 어떤 구현체를 Bean으로 등록할지 Java 코드의 변경 없이 application.yaml 설정 값을 통해 제어해보세요.
    • [ ] discodeit.repository.type 설정값에 따라 Repository 구현체가 정해집니다.
      • 값이 jcf 이거나 없으면 JCF*Repository 구현체가 Bean으로 등록되어야 합니다.
      • 값이 file 이면 File*Repository 구현체가 Bean으로 등록되어야 합니다.
  • File*Repository 구현체의 파일을 저장할 디렉토리 경로를 application.yaml 설정 값을 통해 제어해보세요.

주요 변경사항

  • sprint2 pr 반영
    • 불필요한 추상화 제거
    • 클래스 관련 매개변수 id로 통일
    • sout 대신 로깅기능 사용
    • final 명 변경
    • enum class 내부로 변경
    • 정적 팩토리 메소드 적용해보기
  • 업데이트 필드를 하나의 메소드로 합쳐보기
  • service 인터페이스명 통일성있게 변경

스크린샷

image

멘토에게

  • 셀프 코드 리뷰를 통해 질문 이어가겠습니다.

@yinneu yinneu added the 매운맛🔥 뒤는 없습니다. 그냥 필터 없이 말해주세요. 책임은 제가 집니다. label Feb 16, 2025
@yinneu yinneu requested a review from JJong0416 February 16, 2025 14:57
Copy link
Collaborator Author

@yinneu yinneu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

셀프로 코드 리뷰를 하다보니, 하면서 정리되는 부분도 있고 헷갈리는 부분도 있었던 것 같습니다! 리뷰 잘 부탁드립니다🥹

Comment on lines +19 to +22
public static ChannelResponse entityToDto(Channel channel, Instant lastMessageTime, List<UUID> joinUsers) {
return new ChannelResponse(channel.getId(), channel.getTitle(), channel.getDescription(), channel.getChannelType(), channel.getCreatedAt(), channel.getUpdatedAt(), lastMessageTime, joinUsers);
}
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dto 안 메소드로 정의해서 변환하고 있습니다.
변환 메소드를 dto 안에서 정의하면 오히려 사용하기 쉬울 것 같았는데, 찾아보니 mapper 클래스를 별도로 만들어 관리하는 것 같습니다. 관련기능을 관리하기에는 mapper 클래스로 분리하는 게 나을까요?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이건 정답이 있는 문제는 아닙니다.

modelMapper나 mapStruct를 사용하는 것이 생산성이 좋다!
라고 하는 분들이 계실거고,
modelMapper나 mapStruct를 사용해서 변환하는 것은 생산성에 큰 영향이 없고 관리해야 하는 대상이 더 늘어난다!
라고 하시는 분들이 계십니다.

멘토의 입장에서, 그리고 실무에서 일을 하고 있는 제 입장으로는 '한번쯤은 배우고 갈 만 하지만, 굳이 사용할 필요는 없다'인 것 같아요

몇가지 이유가 있는데요,

  • DTO는 본래 변환을 위한 객체
  • 엔티티와 DTO 변환 생산성이 크지 않다.
  • mapper를 빈으로 등록하여 스프링 관리 대상에 놓아야 한다.

사실 마지막 이유가 가장 꺼려지는 것 같아요. 빈으로 등록하게 되면 결국에는 의존관계를 신경써야 하는 것인데 운영 환경에서 이런 부분까지 신경을 써야 한다는게 조금 부담이 되는 것 같아요

지금은 단순한 스프린트 기간이니 한번쯤은 써보시고, 각각의 장단점과 성능을 비교해보시는 것도 좋을 것 같아요 :D
(개인적으로 아직 실무에서 Mapper를 쓰는 팀이나 회사는 못본것 같아요. 제가 2년차라서 그럴수도 있고요)

저랑 비슷한 의견의 블로그

Comment on lines +8 to +20
public record MessageRequest() {
public record Create(
String content,
UUID channelId,
UUID userId,
List<MultipartFile> files
) {}

public record Update(
String content,
List<MultipartFile> files
) {}
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사실 dto 코드는 길지 않고 서로 관련이 있다고 생각해서 하나의 클래스 안에 넣었습니다. 실제로도 이렇게 많이 사용하시는지 궁금합니다.
생각해보니 계속 기능이 늘어나면 dto를 사용하는데 있어서 오히려 복잡해질 수도 있을 것 같습니다

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네, 참고로 저도 저렇게 쓰고 있습니다 :D record를 쓰진 않고, 인터페이스에 inner class로 쓰고는 있긴 하지만, 저런 방식도 괜찮은 것 같아요.

어차피 대부분 CRUD라서, 도메인 통채로 Response, Request를 하나의 파일로 관리하는 것 아니면 그렇게 크게 늘어나지도 않을 수 있어요

이번 스프린트가 아마 DTO에 대한 내용을 배우셨던 것 같은데요, record를 쓰시기 전에 히스토리를 배우고 가시면 좋을 것 같아요. (시온님이랑 태식님한테도 같은 리뷰를 드렸어요.)

예전에는 record를 Request DTO로 사용할 때는 상당히 불편한(?) 문제를 마주칠 수 있습니다.

  • Jackson 역직렬화 문제 (Deserialization Issue)

Spring에서는 JSON 요청을 객체로 변환할 때 Jackson을 사용해 역직렬화를 수행하는데요. record에서는 기본적으로 기본 생성자가 따로 없습니다. 그래서 아래와 같이 작성해주셔야 합니다.

public record UserRequest(
        @JsonProperty("username") String username,
        @JsonProperty("password") String password,
        @JsonProperty("email") String email,
        @JsonProperty("phoneNumber") String phoneNumber,
        @JsonProperty("profileImageId") UUID profileImageId
//        @JsonProperty("profileImageName") String profileImageName
) {
    // @JsonCreator를 추가하여 명시적으로 생성자를 지정
    @JsonCreator
    public UserRequest(
            @JsonProperty("username") String username,
            @JsonProperty("password") String password,
            @JsonProperty("email") String email,
            @JsonProperty("phoneNumber") String phoneNumber,
            @JsonProperty("profileImageId") UUID profileImageId
            // @JsonProperty("profileImageName") String profileImageName
    ) {
        this.username = username;
        this.password = password;
        this.email = email;
        this.phoneNumber = phoneNumber;
        this.profileImageId = profileImageId;
        // this.profileImageName = profileImageName; // 주석처리된 부분도 필요에 맞게 추가 가능
    }
}

뭔가 상당히 불편해 보이시지 않나요 ^-^..?

@JsonCreator를 통해서 생성자를 명시적으로 지정하여 Jackson이 record 객체를 생성할 때 사용할 생성자를 알 수 있도록 해야 하고, @JsonProperty를 사용해서 JSON 필드 이름과 record 필드명을 매핑시켜줘야 역직렬화가 이루어지게 됩니다.

이러한 이유들 때문에 예전에는 안쓰는 팀도 많았습니다. 단순히 class에 Lombok 어노테이션을 쓰면 간소화 할 수 있기 때문이지요.

그런데 JDK 14부터는 그런 문제들이 해결되었다고 하더라구요. 그렇기 때문에 Record 도입은 버전을 잘 고려하시면서 적용시키면 좋을 것 같습니다

Jackson 2.12
Jackson databind

(나중에 프로젝트를 하시고, 면접에 가셨을 때 DTO를 record를 쓰신 이유를 물어보거나 역직렬화 이슈에 대해 면접관들이 물어보면, 잘 대답해주시면 면접관들이 좋아 죽습니다 😄)

Comment on lines +17 to +19
private ParentType parentType;
private UUID userId;
private UUID messageId;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

필드를 정의할 때 고민했었던 부분이 있습니다.
처음에는 parentType과 parentId로 구성하려고 했었습니다.
'UUID로 id 값이 같으니 오히려 parentId로만 구성한다면 값을 저장할 때 편하지 않을까?' 라고 생각했었습니다.
그래도 걸리는 부분이 2가지가 있었는데요.

  1. 데이터를 가져올 때 항상 parentType을 확인해야하고 잘못된 파일에서 값을 가져올 수도 있겠다는 부분이었습니다.
  2. 나중에 데이터베이스로 연결할 때, 참조되려면 결국 나누는게 맞다고 생각되더라구요. 그런데 생각했던 건 userId와 messageId가 구분된다면 parentType 필드가 굳이 필요할까 라는 생각이 들기도 합니다..

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저라면 parentId값만 가지고 올 것 같아요 :D 필요할때마다 즉각즉각 조회하면 되니깐요
(1~2번 추가 쿼리는 성능적으로 큰 영향이 없습니다, 오히려 다른 부분에 더 많은 영향을 끼치기 때문에)
만약에, parentId의 type이 변경이 된다면 당연히 BinaryContent도 조회를 하고 변경을 해줘야 하기 때문에 더 신경써야 하는 부분이 많지 않을까요

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아 그러네요 ㅎㅎ,,
사실 파일만 가지고 오면 되는데 Message파일인지 , User파일 구분할 필요가 없을 것 같습니다 😂
자꾸 분류해야 한다는 생각을 가지고 있었던 것 같습니다.

Comment on lines +26 to +32
public static BinaryContent createBinaryContent(String fileName, String contentType, byte[] bytes, ParentType parentType, UUID parentId) {
if (parentType == ParentType.USER) {
return new BinaryContent(fileName, contentType, bytes, parentType, parentId, null);
} else {
return new BinaryContent(fileName, contentType, bytes, parentType, null, parentId);
}
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

일단 parentType을 사용하기로 하고 생성자를 만들었습니다. 생성할 때, parentType을 조건으로 두고 생성하고 있습니다.
그런데 entity 안에서 이 로직을 쓰는게 괜찮은 건지 모르겠습니다.
지금 보니 엔티티의 책임이 너무 많은 것 같아 아래 두 가지 방법을 생각했는데요.

  1. 엔티티 안에서 createUserBinaryContent, createMessageBinaryContent로 분리하기
  2. 엔티티는 단순히 생성하고 service 에서 처리하기
    다시 정리해서 쓰다보니 둘 다 적용하면 되겠다는 생각이 드네요 ㅎㅎ..
    정적 팩토리 메서드를 쓰는 이유를 생각해보면 첫 번째를 적용해야할 것 같고, 타입 조건은 service에서 처리해야 할 것 같습니다.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

둘 다 틀린 방식은 아닙니다!
생성만 엔티티에 할 것인지, 타입을 보고 생성하는 역할까지 엔티티에게 줄 것인지는 정말 스타일 차이인 것 같아요.

(보통 저는 생성 자체는 엔티티에, 그리고 타입을 보고 어떤 객체를 생성해야하는지는 서비스 계층에서 하는 것 같아요)

Enum을 조금 더 확장해서 다형성을 조금 이용해보면 이런 방식도 있을 것 같아요.

    public enum ParentType {
        USER {
            @Override
            public BinaryContent createBinaryContent(String fileName, String contentType, byte[] bytes, UUID parentId) {
                return new BinaryContent(fileName, contentType, bytes, this, parentId, null);
            }
        },
        MESSAGE {
            @Override
            public BinaryContent createBinaryContent(String fileName, String contentType, byte[] bytes, UUID parentId) {
                return new BinaryContent(fileName, contentType, bytes, this, null, parentId);
            }
        };

        public abstract BinaryContent createBinaryContent(String fileName, String contentType, byte[] bytes, UUID parentId);
    }
// 호출부
BinaryContent binaryContentForUser = ParentType.USER.createBinaryContent(xxx);

어떤 방식이든 좋습니다. 예림님이 생각이 담겨있는 코드로 작성해주세요 :D

Comment on lines +33 to +50
public void update(String newName, String newEmail, String newPassword) {
boolean isChanged = false;
if (newName != null && !newName.equals(this.name)) {
this.name = newName;
isChanged = true;
}
if (newEmail != null && !newEmail.equals(this.email)) {
this.email = newEmail;
isChanged = true;
}
if (newPassword != null && !newPassword.equals(this.password)) {
this.password = newPassword;
isChanged = true;
}

public void removeChannel(Channel removeChannel) {
channelList.remove(removeChannel);
if (isChanged) {
this.updatedAt = Instant.now();
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

기존에 모든 업데이트 분리되어 있어서 updateAt이 여러 번 갱신 되는 부분을 코드를 참고하여 변경하였습니다. 모든 필드마다 update문이 나누어진 것 보다 메서드 하나의 모아 처리하는 방식이 좋은 것인지?는 잘 모르겠습니다... 멘토님은 어떤식으로 하시나요??

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

null 체크를 엔티티 즉 도메인 내에서 해야하는 이유가 따로 있을까요?

만약에 객체의 특정 타입으로 인해서 반드시 null로 세팅을 해야하는 거라면 예림님 방식을 채택할 것 같은데,
단순히 파라미터들이 null인지에 대해서 체크하는 거라면 저는 Validator에서 처리하거나 서비스 로직에서 처리할 것 같아요.

public void update(String newName, String newEmail, String newPassword) {
        this.name = newName;
        this.email = newEmail;
        this.password = newPassword;
        this.updatedAt= LocalDateTime.now();
    }

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

service에서 받을 때는 validation으로 null 체크를 하고 있습니다.

entity에서의 null은 기존값과 다르지 않으면 service에서 update 매개변수에 null을 넣어주는 방식을 쓰고 있기 때문입니다 ,,ㅎㅎ
다시보니 entity안에서 기존값과 비교하고 있는데, service에서 한 번 더 비교하고 있었네요.

기존 값과 비교하고 다른 값이 있으면 해당 필드만 변경하도록 하는 의도로 했었습니다!
갱신할 필요가 없는 필드는 접근하지 않으려고 했던 것 같습니다.

Comment on lines +24 to +33
public static UserStatus createUserStatus(UUID userId) {
return new UserStatus(userId);
}

private UserStatus(UUID userId) {
this.id = UUID.randomUUID();
this.createdAt = Instant.now();
this.updatedAt = this.createdAt;
this.status = Status.ONLINE;
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분도 createUserStatus에서 매개변수로 값을 넣어주는 게 맞는 것 같습니다,,ㅎ
제가 정적 팩토리 메서드를 제대로 적용하지 못한 것 같습니다

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:D 천천히 적용시켜보세요.

Comment on lines +35 to +47
public Status getStatus() {
updateStatus();
return this.status;
}

public void updateStatus() {
Instant ValidTime = this.updatedAt.plusSeconds(ADDITIONAL_TIME_SECONDS);
if (ValidTime.compareTo(Instant.now()) > 0) {
this.status = Status.ONLINE;
} else {
this.status = Status.OFFLINE;
}
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

상태를 얻어올 때 시간에 따라 자동으로 갱신 되어야 한다고 생각해서 entity 안에서 구현했던 것 같습니다. 시간을 직접 갱신하는게 아니라면 serviece로 빼야하는게 맞을까요?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아니요, 잘 작성하셨습니다👍.

Comment on lines +15 to +16
private static final String FILE_PATH = "binary_content.ser";
private final FileManager<BinaryContent> fileManager = new FileManager<>(FILE_PATH);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

의존성 부분을 고려해서 변경하려고 했는데, bean을 등록하려고 하다보니 제네릭 클래스를 어떻게 해야할지 잘 모르겠어서 변경하지 못했습니다. 공부해서 config 파일에서 설정해야 할 것 같습니다.

Comment on lines +11 to +18
BinaryContent createUserProfileFile(MultipartFile file, UUID userId);
BinaryContent createMessageFile(MultipartFile file, UUID messageId);
BinaryContent updateUserProfileFile(MultipartFile file, UUID userId);
BinaryContent findById(UUID id);
List<BinaryContent> findAllByIdIn(List<UUID> ids);
void deleteById(UUID id);
void deleteByUserId(UUID userId);
void deleteAllByMessageId(UUID messageId);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

항상 메서드명이나 필드명이 고민이 많이 되는 것 같습니다.
지금은 repository와 비슷하게 해놓았는데요.
멘토님은 service 메서드명을 지으실 때, 어떤 식으로 정하시나요??
deleteMessage , deleteMessageById, deleteById 처럼 여러 방식이 있는데, 어떤 객체인지를 확실히 들어내는지, 아니면 이미 어떤 서비스인지 보이니 필요한 매개변수 정보를 넣으시는지 궁금합니다

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 사실 네이밍에 시간을 많이 투자하는 편입니다 ^-^;;
(가끔씩은 chatgpt한테 추천도 받습니다😢)

저는 좀 규칙이 있는 것 같아요

조회 : getByXXX
생성 : createByXXX
...
만약에 여러건이다 하면
getAllByXXX
등등 쓰는 것 같아요.

확실한건 예를 들어 ContentService라고 했을 때,
get**Content**ByXXX
해당 클래스의 도메인은 중복으로 하지 않는 것 같아요.

또한, 엔티티에서 id값(pk든, fk든)을 여러개 들고 있다면 파라미터로 추상화를 풀어주는 것 같아요

ex)
getById(Long Id) // 도메인이 content이니깐 contentId는 사용 X
getByparentId(Long parentId) // 같은 파라미터일 때는 오버로딩이 안되기 때문에

뭐 이런식으로 하는 것 같아요!

Comment on lines +21 to +22
private final UserStatusRepository userStatusRepository;
// private final UserService userService; 순환 참조 발생
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제가 서비스 레이어끼리 데이터를 주고 받으려고 할 때 순환 참조 문제가 있었습니다.
예를 들어 유저 서비스에서 메세지 정보가 필요할 때, 바로 메세지 레파지토리에서 사용해도 되는지 궁금합니다.

사실 처음에는 각각의 서비스에서 각각의 레파지토리만을 접근할 수 있어야 한다고 생각했습니다. 여러 서비스에서 하나의 레파지토리를 접근한다면 결합도 부분에서 문제가 생길 수 있다 라는 생각 때문이었습니다.

하지만 구현하다보니 문제가 생겼습니다.
UserStatus 서비스에서 유저가 존재하는지 확인하려고 할 때, 유저 서비스를 거치려고 하니 순환 참조가 발생했습니다. 유저 서비스에서 UserStatusService를 사용하고 있기 때문이었습니다.

이런 문제가 생기다 보니 굳이 service 레이어를 거쳐야 할지 고민이 생겼습니다. service 레이어끼리만 통신하는게 맞는 것 같은데,, 어떻게 해야할지 모르겠습니다.

추가적으로, service 레이어끼리 통신할 때에도 dto 로 감싸서 해야할까요?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이것도 팀바팀, 회바회입니다. 이건 멘토링 시간에 다같이 들으면 좋을 것 같으니 그때 이야기해봐요 :D

Copy link
Collaborator

@JJong0416 JJong0416 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

예림님 안녕하세요~! 🙇
이번 스프린트에는 저번에 리뷰 드린 내용들을 잘 반영해주신 모습이 인상적이였습니다 👍 코드에서도 많은 부분을 고민하고, 많은 이슈들도 마주친 게 느껴집니다.

너무 정답을 알려드리는 경향이 많은 것 같아서 꼭 다시 한 번 고민해보시는걸 추천드립니다. 고민을 하는 시간만큼 머릿속에 남기 때문이죠

이번 스프린트 너무너무 고생 많으셨고, 혹시 코멘트 관련하여 궁금하거나 이해가 안되는 점은 멘토링때 들고오시면 언제든 답변드리도록 하겠습니다 :D

Comment on lines +19 to +22
public static ChannelResponse entityToDto(Channel channel, Instant lastMessageTime, List<UUID> joinUsers) {
return new ChannelResponse(channel.getId(), channel.getTitle(), channel.getDescription(), channel.getChannelType(), channel.getCreatedAt(), channel.getUpdatedAt(), lastMessageTime, joinUsers);
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이건 정답이 있는 문제는 아닙니다.

modelMapper나 mapStruct를 사용하는 것이 생산성이 좋다!
라고 하는 분들이 계실거고,
modelMapper나 mapStruct를 사용해서 변환하는 것은 생산성에 큰 영향이 없고 관리해야 하는 대상이 더 늘어난다!
라고 하시는 분들이 계십니다.

멘토의 입장에서, 그리고 실무에서 일을 하고 있는 제 입장으로는 '한번쯤은 배우고 갈 만 하지만, 굳이 사용할 필요는 없다'인 것 같아요

몇가지 이유가 있는데요,

  • DTO는 본래 변환을 위한 객체
  • 엔티티와 DTO 변환 생산성이 크지 않다.
  • mapper를 빈으로 등록하여 스프링 관리 대상에 놓아야 한다.

사실 마지막 이유가 가장 꺼려지는 것 같아요. 빈으로 등록하게 되면 결국에는 의존관계를 신경써야 하는 것인데 운영 환경에서 이런 부분까지 신경을 써야 한다는게 조금 부담이 되는 것 같아요

지금은 단순한 스프린트 기간이니 한번쯤은 써보시고, 각각의 장단점과 성능을 비교해보시는 것도 좋을 것 같아요 :D
(개인적으로 아직 실무에서 Mapper를 쓰는 팀이나 회사는 못본것 같아요. 제가 2년차라서 그럴수도 있고요)

저랑 비슷한 의견의 블로그

Comment on lines +8 to +20
public record MessageRequest() {
public record Create(
String content,
UUID channelId,
UUID userId,
List<MultipartFile> files
) {}

public record Update(
String content,
List<MultipartFile> files
) {}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네, 참고로 저도 저렇게 쓰고 있습니다 :D record를 쓰진 않고, 인터페이스에 inner class로 쓰고는 있긴 하지만, 저런 방식도 괜찮은 것 같아요.

어차피 대부분 CRUD라서, 도메인 통채로 Response, Request를 하나의 파일로 관리하는 것 아니면 그렇게 크게 늘어나지도 않을 수 있어요

이번 스프린트가 아마 DTO에 대한 내용을 배우셨던 것 같은데요, record를 쓰시기 전에 히스토리를 배우고 가시면 좋을 것 같아요. (시온님이랑 태식님한테도 같은 리뷰를 드렸어요.)

예전에는 record를 Request DTO로 사용할 때는 상당히 불편한(?) 문제를 마주칠 수 있습니다.

  • Jackson 역직렬화 문제 (Deserialization Issue)

Spring에서는 JSON 요청을 객체로 변환할 때 Jackson을 사용해 역직렬화를 수행하는데요. record에서는 기본적으로 기본 생성자가 따로 없습니다. 그래서 아래와 같이 작성해주셔야 합니다.

public record UserRequest(
        @JsonProperty("username") String username,
        @JsonProperty("password") String password,
        @JsonProperty("email") String email,
        @JsonProperty("phoneNumber") String phoneNumber,
        @JsonProperty("profileImageId") UUID profileImageId
//        @JsonProperty("profileImageName") String profileImageName
) {
    // @JsonCreator를 추가하여 명시적으로 생성자를 지정
    @JsonCreator
    public UserRequest(
            @JsonProperty("username") String username,
            @JsonProperty("password") String password,
            @JsonProperty("email") String email,
            @JsonProperty("phoneNumber") String phoneNumber,
            @JsonProperty("profileImageId") UUID profileImageId
            // @JsonProperty("profileImageName") String profileImageName
    ) {
        this.username = username;
        this.password = password;
        this.email = email;
        this.phoneNumber = phoneNumber;
        this.profileImageId = profileImageId;
        // this.profileImageName = profileImageName; // 주석처리된 부분도 필요에 맞게 추가 가능
    }
}

뭔가 상당히 불편해 보이시지 않나요 ^-^..?

@JsonCreator를 통해서 생성자를 명시적으로 지정하여 Jackson이 record 객체를 생성할 때 사용할 생성자를 알 수 있도록 해야 하고, @JsonProperty를 사용해서 JSON 필드 이름과 record 필드명을 매핑시켜줘야 역직렬화가 이루어지게 됩니다.

이러한 이유들 때문에 예전에는 안쓰는 팀도 많았습니다. 단순히 class에 Lombok 어노테이션을 쓰면 간소화 할 수 있기 때문이지요.

그런데 JDK 14부터는 그런 문제들이 해결되었다고 하더라구요. 그렇기 때문에 Record 도입은 버전을 잘 고려하시면서 적용시키면 좋을 것 같습니다

Jackson 2.12
Jackson databind

(나중에 프로젝트를 하시고, 면접에 가셨을 때 DTO를 record를 쓰신 이유를 물어보거나 역직렬화 이슈에 대해 면접관들이 물어보면, 잘 대답해주시면 면접관들이 좋아 죽습니다 😄)

Comment on lines +15 to +16
private Instant createdAt;
private Instant updatedAt;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

예림님이 알고 계신지 모르기 때문에 가상의 상황을 제공해볼게요

요구사항으로 만약에 A라는 채널을 하나 만들었고, 요구사항중에 채널 생성일로부터 1시간 후의 시간을 노출해야한다고 해봅시다.

Channel channel = new Channel(...Instant.now() , Instant.now());

// getter로 받은 lastSeenAt에 1시간 추가한 Instant를 생성
Instant requiredInstant= channel.getCreatedAt().plusSeconds(3600);

System.out.println(channel.getCreatedAt());
System.out.println(requiredInstant);

각각 어떤 값이 나오는지 생각해보시는게 좋을 것 같아요!
왜 나왔는지 고민해보시고 아래 레퍼런스를 참고해보시길 바랍니다 :D
(실무에서 정말로 필요한 상황이 아니라면 Instant, Date는 잘 사용하지 않습니다)

effective-java item50 적시에 방어적 복사본을 만들어라

Comment on lines +26 to +27
public static Channel createChannel(ChannelType channelType, String title, String description) {
return new Channel(channelType, title, description);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

자, 사실 정적 팩토리 메소드를 쓰셨다면 빌더 패턴과 함께 쓰면 더 맛있을 것 같지 않나요😋?

인파님의 빌더 패턴

정적 팩토리 메소드를 사용하면 받아야 하는 객체의 필드가 줄어들거나 많아진다면 그만큼의 생성자를 작성하고 호출을 해야 할 것 같아요. 그러면 코드도 점점 더 난잡해지겠죠?

@Builder(access = AccessLevel.PRIVATE)
public class Channel implements Serializable {
...
      public static Channel createByXXX(ChannelType type, String name, String description) {
        return Channel.builder()
                .type(type)
                .name(name)
                .description(description)
                .build();
    }
}

이런 방식도 있다는 것을 알아가시면 좋을 것 같아요

Comment on lines +33 to +50
public void update(String newName, String newEmail, String newPassword) {
boolean isChanged = false;
if (newName != null && !newName.equals(this.name)) {
this.name = newName;
isChanged = true;
}
if (newEmail != null && !newEmail.equals(this.email)) {
this.email = newEmail;
isChanged = true;
}
if (newPassword != null && !newPassword.equals(this.password)) {
this.password = newPassword;
isChanged = true;
}

public void removeChannel(Channel removeChannel) {
channelList.remove(removeChannel);
if (isChanged) {
this.updatedAt = Instant.now();
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

null 체크를 엔티티 즉 도메인 내에서 해야하는 이유가 따로 있을까요?

만약에 객체의 특정 타입으로 인해서 반드시 null로 세팅을 해야하는 거라면 예림님 방식을 채택할 것 같은데,
단순히 파라미터들이 null인지에 대해서 체크하는 거라면 저는 Validator에서 처리하거나 서비스 로직에서 처리할 것 같아요.

public void update(String newName, String newEmail, String newPassword) {
        this.name = newName;
        this.email = newEmail;
        this.password = newPassword;
        this.updatedAt= LocalDateTime.now();
    }

}

public void updateStatus() {
Instant ValidTime = this.updatedAt.plusSeconds(ADDITIONAL_TIME_SECONDS);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

변수명에 첫 글자는 소문자, 그 다음부터는 대문자를 사용해주시면 됩니다.
validTime은 어떠실까요?

매직넘버는 잘 제거해주셨군요 👍

Comment on lines +35 to +47
public Status getStatus() {
updateStatus();
return this.status;
}

public void updateStatus() {
Instant ValidTime = this.updatedAt.plusSeconds(ADDITIONAL_TIME_SECONDS);
if (ValidTime.compareTo(Instant.now()) > 0) {
this.status = Status.ONLINE;
} else {
this.status = Status.OFFLINE;
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아니요, 잘 작성하셨습니다👍.

Comment on lines +11 to +18
BinaryContent createUserProfileFile(MultipartFile file, UUID userId);
BinaryContent createMessageFile(MultipartFile file, UUID messageId);
BinaryContent updateUserProfileFile(MultipartFile file, UUID userId);
BinaryContent findById(UUID id);
List<BinaryContent> findAllByIdIn(List<UUID> ids);
void deleteById(UUID id);
void deleteByUserId(UUID userId);
void deleteAllByMessageId(UUID messageId);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 사실 네이밍에 시간을 많이 투자하는 편입니다 ^-^;;
(가끔씩은 chatgpt한테 추천도 받습니다😢)

저는 좀 규칙이 있는 것 같아요

조회 : getByXXX
생성 : createByXXX
...
만약에 여러건이다 하면
getAllByXXX
등등 쓰는 것 같아요.

확실한건 예를 들어 ContentService라고 했을 때,
get**Content**ByXXX
해당 클래스의 도메인은 중복으로 하지 않는 것 같아요.

또한, 엔티티에서 id값(pk든, fk든)을 여러개 들고 있다면 파라미터로 추상화를 풀어주는 것 같아요

ex)
getById(Long Id) // 도메인이 content이니깐 contentId는 사용 X
getByparentId(Long parentId) // 같은 파라미터일 때는 오버로딩이 안되기 때문에

뭐 이런식으로 하는 것 같아요!

Comment on lines +17 to +19
private ParentType parentType;
private UUID userId;
private UUID messageId;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저라면 parentId값만 가지고 올 것 같아요 :D 필요할때마다 즉각즉각 조회하면 되니깐요
(1~2번 추가 쿼리는 성능적으로 큰 영향이 없습니다, 오히려 다른 부분에 더 많은 영향을 끼치기 때문에)
만약에, parentId의 type이 변경이 된다면 당연히 BinaryContent도 조회를 하고 변경을 해줘야 하기 때문에 더 신경써야 하는 부분이 많지 않을까요

Comment on lines +21 to +22
private final UserStatusRepository userStatusRepository;
// private final UserService userService; 순환 참조 발생
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이것도 팀바팀, 회바회입니다. 이건 멘토링 시간에 다같이 들으면 좋을 것 같으니 그때 이야기해봐요 :D

@JJong0416 JJong0416 merged commit 7c44d88 into codeit-bootcamp-spring:part1-연예림 Feb 19, 2025
@yinneu yinneu self-assigned this May 16, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
매운맛🔥 뒤는 없습니다. 그냥 필터 없이 말해주세요. 책임은 제가 집니다.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants