Skip to content

[황준수] Sprint6#208

Open
JunSuHwang wants to merge 48 commits intocodeit-bootcamp-spring:황준수from
JunSuHwang:sprint6
Open

[황준수] Sprint6#208
JunSuHwang wants to merge 48 commits intocodeit-bootcamp-spring:황준수from
JunSuHwang:sprint6

Conversation

@JunSuHwang
Copy link
Copy Markdown
Collaborator

기본 요구사항

API 명세

데이터베이스

  • 아래와 같이 데이터베이스 환경을 설정하세요.

    • 데이터베이스: discodeit
    • 유저: discodeit_user
    • 패스워드: discodeit1234
  • 아래와 같이 데이터베이스 환경을 설정하세요.

    • 작성한 DDL 파일은 /src/main/resources/schema.sql 경로에 포함하세요.

Spring Data JPA 적용하기

  • Spring Data JPA와 PostgreSQL을 위한 의존성을 추가하세요.
  • 앞서 구성한 데이터베이스에 연결하기 위한 설정값을 application.yaml 파일에 작성하세요.
  • 디버깅을 위해 SQL 로그와 관련된 설정값을 application.yaml 파일에 작성하세요.

Spring Data JPA 적용하기

  • 클래스 다이어그램을 참고해 도메인 모델의 공통 속성을 추상 클래스로 정의하고 상속 관계를 구현하세요.
    • 이때 Serializable 인터페이스는 제외합니다.
    • 패키지명: com.sprint.mission.discodeit.entity.base
    • 클래스 다이어그램
  • JPA의 어노테이션을 활용해 createdAt, updatedAt 속성이 자동으로 설정되도록 구현하세요.
    • @CreatedDate, @LastModifiedDate
  • 클래스 다이어그램을 참고해 클래스 참조 관계를 수정하세요. 필요한 경우 생성자, update 메소드를 수정할 수 있습니다. 단, 아직 JPA Entity와 관련된 어노테이션은 작성하지 마세요.
    • 클래스 다이어그램
  • ERD와 클래스 다이어그램을 토대로 연관관계 매핑 정보를 표로 정리해보세요.(이 내용은 PR에 첨부해주세요.)
엔티티 관계 다중성 방향성 부모-자식 관계 연관관계의 주인
User : Message 1 : N Message → User 단방향 부모 : User, 자식 : Message Message
Channel : Message 1 : N Message → Channel 단방향 부모 : Channel, 자식 : Message Message
User : ReadStatus 1 : N ReadStatus → User 단방향 부모 : User, 자식 : ReadStatus User
Channel : ReadStatus 1 : N ReadStatus → Channel 단방향 부모 : Channel, 자식 : ReadStatus Channel
User : UserStatus 1 : 1 User ↔ UserStatus 양방향 부모 : User, 자식 : UserStatus UserStatus
User : BinaryContent 1 : 1 User → BinaryContent 단방향 부모 : User, 자식 : BinaryContent User
Message : BinaryContent 1 : N Message → BinaryContent 단방향 부모 : Message, 자식 : BinaryContent Message
  • JPA 주요 어노테이션을 활용해 ERD, 연관관계 매핑 정보를 도메인 모델에 반영해보세요.
    • @Entity, @Table
    • @Column, @Enumerated
    • @OneToMany, @OneToOne, @ManyToOne
    • @JoinColumn, @JoinTable
  • ERD의 외래키 제약 조건과 연관관계 매핑 정보의 부모-자식 관계를 고려해 영속성 전이와 고아 객체를 정의하세요.
    • cascade, orphanRemoval

레포지토리와 서비스에 JPA 도입하기

  • 기존의 Repository 인터페이스를 JPARepository로 정의하고 쿼리메소드로 대체하세요.
    • FileRepository와 JCFRepository 구현체는 삭제합니다.
  • 영속성 컨텍스트의 특징에 맞추어 서비스 레이어를 수정해보세요.
    • 힌트: 트랜잭션, 영속성 전이, 변경 감지, 지연로딩

DTO 적극 도입하기

  • Entity를 Controller 까지 그대로 노출했을 때 발생할 수 있는 문제점에 대해 정리해보세요. DTO를 적극 도입했을 때 보일러플레이트 코드가 많아지지만, 그럼에도 불구하고 어떤 이점이 있는지 알 수 있을거에요.(이 내용은 PR에 첨부해주세요.)

    • 엔티티를 컨트롤러까지 그대로 노출하면 API가 엔티티와 강한 결합을 가지게 됩니다. API의 요청과 응답이 엔티티 구조에 종속되며, 엔티티 변경 시 응답 및 요청도 의도치 않게 바뀌어버리는 문제가 발생합니다.
      또한 사용자 엔티티에서 비밀번호와 같은 민감한 정보가 그대로 응답에 담기는 문제도 발생할 수 있습니다.
      그리고 또 응답 시 엔티티 객체가 그대로 반환된다면 양방향 연관관계에서 순환 참조가 발생할 수 있습니다. 두 객체가 서로를 참조하고 있는 양방향 관계의 경우 한 객체를 반환하면 그 객체가 참조하고 있는 다른 객체도 반환하게 되고 이 과정이 반복되는 문제가 발생합니다.
  • 다음의 클래스 다이어그램을 참고하여 DTO를 정의하세요.

  • Entity를 DTO로 매핑하는 로직을 책임지는 Mapper 컴포넌트를 정의해 반복되는 코드를 줄여보세요.
    • 패키지명: com.sprint.mission.discodeit.mapper

BinaryContent 저장 로직 고도화

데이터베이스에 이미지와 같은 파일을 저장하면 성능 상 불리한 점이 많습니다. 따라서 실제 바이너리 데이터는 별도의 공간에 저장하고, 데이터베이스에는 바이너리 데이터에 대한 메타 정보(파일명, 크기, 유형 등)만 저장하는 것이 좋습니다.

  • BinaryContent 엔티티는 파일의 메타 정보(fileName, size, contentType)만 표현하도록 bytes 속성을 제거하세요.

  • BinaryContent의 byte[] 데이터 저장을 담당하는 인터페이스를 설계하세요.

    • 패키지명: com.sprint.mission.discodeit.storage
    • 클래스 다이어그램
    • BinaryContentStorage
      • 바이너리 데이터의 저장/로드를 담당하는 컴포넌트입니다.
      • UUID put(UUID, byte[])
        • UUID 키 정보를 바탕으로 byte[] 데이터를 저장합니다.
        • UUID는 BinaryContent의 Id 입니다.
      • InputStream get(UUID)
        • 키 정보를 바탕으로 byte[] 데이터를 읽어 InputStream 타입으로 반환합니다.
        • UUID는 BinaryContent의 Id 입니다.
      • ResponseEntity<?> download(BinaryContentDto)
        • HTTP API로 다운로드 기능을 제공합니다.
        • BinaryContentDto 정보를 바탕으로 파일을 다운로드할 수 있는 응답을 반환합니다.
  • 서비스 레이어에서 기존에 BinaryContent를 저장하던 로직을 BinaryContentStorage를 활용하도록 리팩토링하세요.

  • BinaryContentController에 파일을 다운로드하는 API를 추가하고, BinaryContentStorage에 로직을 위임하세요.

    • 엔드포인트: GET /api/binaryContents/{binaryContentId}/download
    • 요청
      • 값: BinaryContentId
      • 방식: Path Variable
    • 응답: ResponseEntity<?>
    • 클래스 다이어그램
  • 로컬 디스크 저장 방식으로 BinaryContentStorage 구현체를 구현하세요.

    • 클래스 다이어그램
  • discodeit.storage.type 값이 local 인 경우에만 Bean으로 등록되어야 합니다.

    • Path root
      • 로컬 디스크의 루트 경로입니다.
      • discodeit.storage.local.root-path 설정값을 정의하고, 이 값을 통해 주입합니다.
    • void init()
      • 루트 디렉토리를 초기화합니다.
      • Bean이 생성되면 자동으로 호출되도록 합니다.
    • Path resolvePath(UUID)
      • 파일의 실제 저장 위치에 대한 규칙을 정의합니다.
        • 파일 저장 위치 규칙 예시: {root}/{UUID}
      • put, get 메소드에서 호출해 일관된 파일 경로 규칙을 유지합니다.
    • ResponseEntity<Resource> donwload(BinaryContentDto)
      • get 메소드를 통해 파일의 바이너리 데이터를 조회합니다.
      • BinaryContentDto와 바이너리 데이터를 활용해 ResponseEntity<Resource> 응답을 생성 후 반환합니다.

페이징과 정렬

  • 메시지 목록을 조회할 때 다음의 조건에 따라 페이지네이션 처리를 해보세요.

    • 50개씩 최근 메시지 순으로 조회합니다.
    • 총 메시지가 몇개인지 알 필요는 없습니다.
  • 일관된 페이지네이션 응답을 위해 제네릭을 활용해 DTO로 구현하세요.

    • 패키지명: com.sprint.mission.discodeit.dto.response
    • 클래스 다이어그램
    • content: 실제 데이터입니다.
    • number: 페이지 번호입니다.
    • size: 페이지의 크기입니다.
    • totalElements: T 데이터의 총 갯수를 의미하며, null일 수 있습니다.
  • Slice 또는 Page 객체로부터 DTO를 생성하는 Mapper를 구현하세요.

    • 패키지명: com.sprint.mission.discodeit.mapper
    • 확장성을 위해 제네릭 메소드로 구현하세요.

심화 요구사항

N+1 문제

  • N+1 문제가 발생하는 쿼리를 찾고 해결해보세요.

읽기전용 트랜잭션 활용

  • 프로덕션 환경에서는 OSIV를 비활성화하는 경우가 많습니다. 이때 서비스 레이어의 조회 메소드에서 발생할 수 있는 문제를 식별하고, 읽기 전용 트랜잭션을 활용해 문제를 해결해보세요.
    • OSIV 비활성화하기
      spring:
        jpa:
          open-in-view: false
    

페이지네이션 최적화

  • 오프셋 페이지네이션과 커서 페이지네이션 방식의 차이에 대해 정리해보세요.

    • 오프셋 페이지네이션 방식은 SQL에서 단순히 LIMIT과 OFFSET으로 구현되는 방식입니다. OFFSET은 시작 위치를, LIMIT는 데이터 개수를 나타내는데, OFFSET을 지정하게 되면서 OFFSET 만큼의 데이터를 건너뛰는 과정을 수행합니다. 하지만 해당 OFFSET에 도달할 때까지 데이터를 스캔해야 하기에 성능 저하가 발생합니다.
      커서 페이지네이션 방식은 커서 값을 두어 마지막으로 조회한 데이터 값을 기준으로 페이징하는 방식입니다. 현재 프로젝트의 created_at을 기준으로 클라이언트가 커서로 마지막으로 읽은 페이지의 created_at 값을 지정하여 요청하면 created_at 값을 기준으로 조회된 값들을 커서 기반으로 조회합니다. OFFSET으로 인해 발생하는 성능 저하가 없지만, 커서 값이 unique하지 않으면 데이터 누락 문제가 생길 수 있습니다.
  • 기존에 구현한 오프셋 페이지네이션을 커서 페이지네이션으로 리팩토링하세요.

    • PageResponse는 다음과 같이 변경하세요.

MapStruct 적용

  • Entity와 DTO를 매핑하는 보일러플레이트 코드를 MapStruct 라이브러리를 활용해 간소화해보세요.

JunSuHwang and others added 18 commits March 4, 2026 19:02
- BinaryContent 메타데이터 구조로 변경
- 파일 다운로드 엔드포인트 추가
- StorageException 적용
page 공통 응답, 요청 dto 추가
message 목록 조회에 Pagination 적용
ChannelMessageController를 MessageController에 병합
불필요한 join, leave 메서드 제거

readstatus 생성시 channel의 생성 시점을 주입하도록 변경
* refactor: page dto를 커서 기반으로 리팩토링

* refactor: JPQL 추가

* refactor: controller, service에 cursor 적용
@JunSuHwang JunSuHwang requested a review from joonfluence March 11, 2026 04:47
@JunSuHwang JunSuHwang added the 매운맛🔥 뒤는 없습니다. 그냥 필터 없이 말해주세요. 책임은 제가 집니다. label Mar 11, 2026
Copy link
Copy Markdown
Collaborator

@joonfluence joonfluence left a comment

Choose a reason for hiding this comment

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

PR 리뷰 요약

File/JCF 기반 저장소를 Spring Data JPA + PostgreSQL로 마이그레이션하고, MapStruct를 활용한 DTO/Mapper 패턴, 커서 기반 페이지네이션, feature 기반 패키지 구조를 적용한 PR입니다. 전반적으로 JPA 엔티티 설계와 서비스 계층 구조가 잘 잡혀 있으나, N+1 문제와 일부 설계 개선이 필요합니다.

}
}
@Mapping(target = "lastMessageAt", expression = "java(getLastMessageAt(channel.getId()))")
@Mapping(target = "participants", expression = "java(getParticipants(channel.getId()))")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P2] toDto()가 호출될 때마다 messageRepositoryreadStatusRepository를 각각 호출합니다. 채널 목록을 조회하면 채널 수 × 2만큼 추가 쿼리가 발생하는 N+1 문제입니다.

Mapper는 단순 변환 역할만 맡기고, lastMessageAtparticipants는 Service 계층에서 JOIN FETCH나 별도 배치 쿼리로 미리 조회한 뒤 전달하는 것이 좋습니다.

return channelRepository.findAll()
.stream()
.filter(channel -> channel.getType() == ChannelType.PUBLIC
|| readStatusRepository.existsByUserIdAndChannelId(userId, channel.getId()))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P2] 전체 채널을 로드한 후 PRIVATE 채널마다 existsByUserIdAndChannelId()를 호출하므로 채널 수만큼 쿼리가 발생합니다.

JPQL로 SELECT c FROM Channel c WHERE c.type = 'PUBLIC' OR EXISTS (SELECT rs FROM ReadStatus rs WHERE rs.user.id = :userId AND rs.channel.id = c.id) 같은 단일 쿼리를 사용하세요.

public List<UserDto> findAllByChannelId(UUID channelId) {
return userRepository.findAll()
.stream()
.filter(user -> readStatusRepository.existsByUserIdAndChannelId(user.getId(), channelId))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P2]BasicChannelService.findAllByUserId와 동일한 패턴입니다. 전체 User를 로드한 후 유저마다 existsByUserIdAndChannelId()를 호출합니다.

ReadStatusRepositoryfindAllByChannelId → User 목록을 반환하는 JPQL 쿼리를 만들거나, JOIN 기반 쿼리로 대체해 주세요.

userStatus.getId()
);
}
@Mapping(target = "online", expression = "java(user.getUserStatus().isOnline())")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P2] User 엔티티에서 userStatusmappedBy 관계로 선언되어 있어 null일 수 있습니다 (예: 방금 생성된 사용자, 데이터 불일치 등).

user.getUserStatus() != null && user.getUserStatus().isOnline() 같은 null-safe 처리가 필요합니다.

public UserDto login(LoginRequest loginRequest) {
User findUser = userRepository.findByUsername(loginRequest.username())
.orElseThrow(UserNotFoundException::new);
if (!findUser.getPassword().equals(loginRequest.password())) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P2] 비밀번호를 평문으로 비교하고 있습니다. 현재 스프린트 범위가 아닐 수 있지만, 향후 PasswordEncoder(BCrypt 등)를 도입하여 해싱된 비밀번호와 비교하는 방식으로 전환을 고려해 주세요.

public int hashCode() {
return id.hashCode();
}
public BinaryContent(String fileName, Long size, String contentType, byte[] bytes) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P3] bytes 파라미터를 받지만 필드에 저장하지 않고 완전히 무시합니다. 엔티티에서 bytes 필드를 제거한 것은 좋은데, 생성자 시그니처에서도 제거하여 혼동을 방지해 주세요.

Suggested change
public BinaryContent(String fileName, Long size, String contentType, byte[] bytes) {
public BinaryContent(String fileName, Long size, String contentType) {

return profileId != null;
}
@Setter
@NoArgsConstructor
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P3] @Setter를 클래스 레벨에 적용하면 password, username, email 등 모든 필드가 외부에서 자유롭게 변경 가능해집니다. JPA 엔티티에서는 꼭 필요한 필드(예: profile, userStatus)에만 @Setter를 개별 적용하고, 나머지는 비즈니스 메서드로 변경을 제어하는 것이 안전합니다.

또한 @NoArgsConstructoraccess = AccessLevel.PROTECTED를 추가하여 외부에서 빈 객체 생성을 방지하세요.


public void update(String newContent) {
this.content = newContent;
this.updatedAt = Instant.now();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P3] BaseUpdatableEntity에서 @LastModifiedDateupdatedAt을 관리하고 있으므로, update() 메서드에서 수동으로 설정하면 중복입니다. JPA Auditing이 트랜잭션 커밋 시 자동으로 갱신해 주므로 수동 설정을 제거하세요. (ReadStatus.update()도 동일)

@@ -0,0 +1,73 @@
CREATE TABLE users
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P3] IF NOT EXISTS가 없어 애플리케이션 재시작 시 이미 테이블이 있으면 에러가 발생합니다. application.yamlsql.init.mode 설정이 없으므로, ddl-auto: none과 함께 사용할 경우를 대비해 방어적으로 CREATE TABLE IF NOT EXISTS를 추가하는 것이 좋습니다.

ADD CONSTRAINT fk_users_binary_contents FOREIGN KEY (profile_id)
REFERENCES binary_contents (id) ON DELETE SET NULL;
ALTER TABLE binary_contents
DROP COLUMN bytes; No newline at end of file
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P4] binary_contents 테이블에 bytes bytea NOT NULL 컬럼을 만든 뒤 마지막에 DROP COLUMN으로 제거하고 있습니다. 처음부터 bytes 컬럼 없이 테이블을 정의하면 DDL이 더 깔끔합니다.

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