Skip to content

[장현민] sprint6#124

Open
ope336 wants to merge 12 commits into
codeit-bootcamp-spring:장현민from
ope336:장현민_sprint6

Hidden character warning

The head ref may contain hidden characters: "\uc7a5\ud604\ubbfc_sprint6"
Open

[장현민] sprint6#124
ope336 wants to merge 12 commits into
codeit-bootcamp-spring:장현민from
ope336:장현민_sprint6

Conversation

@ope336
Copy link
Copy Markdown

@ope336 ope336 commented Mar 11, 2026

기본 요구사항

데이터베이스

  • 아래와 같이 데이터베이스 환경을 설정하세요.
  • ERD를 참고하여 DDL을 작성하고, 테이블을 생성하세요.

Spring Data JPA 적용하기

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

엔티티 정의하기

  • 클래스 다이어그램을 참고해 도메인 모델의 공통 속성을 추상 클래스로 정의하고 상속 관계를 구현하세요.

  • JPA의 어노테이션을 활용해 createdAt, updatedAt 속성이 자동으로 설정되도록 구현하세요.

  • 클래스 다이어그램을 참고해 클래스 참조 관계를 수정하세요. 필요한 경우 생성자, update 메소드를 수정할 수 있습니다.

  • ERD와 클래스 다이어그램을 토대로 연관관계 매핑 정보를 표로 정리해보세요.(이 내용은 PR에 첨부해주세요.)
    1

  • JPA 주요 어노테이션을 활용해 ERD, 연관관계 매핑 정보를 도메인 모델에 반영해보세요.

  • ERD의 외래키 제약 조건과 연관관계 매핑 정보의 부모-자식 관계를 고려해 영속성 전이와 고아 객체를 정의하세요.

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

  • 기존의 Repository 인터페이스를 JPARepository로 정의하고 쿼리메소드로 대체하세요.
  • 영속성 컨텍스트의 특징에 맞추어 서비스 레이어를 수정해보세요.

DTO 적극 도입하기

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

  • -Entity를 프론트엔드(웹 브라우저)에 그대로 노출시키면 엔티티 안에는 프론트엔드가 몰라도 되거나 알면 안되는, 정보들까지 전부 들어있기 때문입니다. 또한, 유저가 메시지를 부르고, 메시지가 다시 유저를 부르는 뫼비우스의 띠(순환 참조)에 빠져 서버가 다운될 수도 있습니다. 그래서 DTO에 안전한 정보만 담아서 전달해야 합니다. 그래야 보안상 치명적인 문제가 발생하지 않기 때문입니다.

  • 다음의 클래스 다이어그램을 참고하여 DTO를 정의하세요.

  • Entity를 DTO로 매핑하는 로직을 책임지는 Mapper 컴포넌트를 정의해 반복되는 코드를 줄여보세요.

BinaryContent 저장 로직 고도화

  • BinaryContent 엔티티는 파일의 메타 정보(fileName, size, contentType)만 표현하도록 bytes 속성을 제거하세요.
  • BinaryContent의 byte[] 데이터 저장을 담당하는 인터페이스를 설계하세요.
  • 서비스 레이어에서 기존에 BinaryContent를 저장하던 로직을 BinaryContentStorage를 활용하도록 리팩토링하세요.
  • BinaryContentController에 파일을 다운로드하는 API를 추가하고, BinaryContentStorage에 로직을 위임하세요.
  • 로컬 디스크 저장 방식으로 BinaryContentStorage 구현체를 구현하세요.
  • discodeit.storage.type 값이 local 인 경우에만 Bean으로 등록되어야 합니다.

페이징과 정렬

  • 메시지 목록을 조회할 때 다음의 조건에 따라 페이지네이션 처리를 해보세요.
  • 일관된 페이지네이션 응답을 위해 제네릭을 활용해 DTO로 구현하세요.
  • Slice 또는 Page 객체로부터 DTO를 생성하는 Mapper를 구현하세요.

심화 요구사항

N+1 문제

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

읽기전용 트랜잭션 활용

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

페이지네이션 최적화

  • 오프셋 페이지네이션과 커서 페이지네이션 방식의 차이에 대해 정리해보세요.(이 내용은 PR에 첨부해주세요.)
    2

  • 기존에 구현한 오프셋 페이지네이션을 커서 페이지네이션으로 리팩토링하세요.

MapStruct 적용

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

@ope336 ope336 changed the title 장현민 sprint6 [장현민] sprint6 Mar 13, 2026
@ope336 ope336 added the 순한맛🐑 마음이 많이 여립니다.. label Mar 13, 2026
@hagyutae
Copy link
Copy Markdown
Collaborator

충돌 해결해주세요~!

Copy link
Copy Markdown
Collaborator

@hagyutae hagyutae left a comment

Choose a reason for hiding this comment

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

피드백 확인하고 수정해주세요~!

@Transactional
@Override
public void delete(UUID userId) {
if (userRepository.existsById(userId)) {
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.

조건문이 반전되어 있습니다. existsById(userId)true일 때 (즉, 사용자가 존재할 때) 예외를 던지고, 존재하지 않을 때 삭제를 시도합니다. 이는 런타임에 모든 사용자 삭제 요청이 실패하거나, 존재하지 않는 사용자 삭제 시 JPA에서 다른 예외가 발생하는 심각한 버그입니다.

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.

uploads/*
git ignore 처리해주세요~!

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.

git history에서도 제거해주시구요

|| mySubscribedChannelIds.contains(channel.getId())
)
.map(channelMapper::toDto)
.toList();
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.

// ChannelRepository - messages만 fetch join
@Query("SELECT DISTINCT c FROM Channel c LEFT JOIN FETCH c.messages")
List<Channel> findAllWithMessages();

readStatuses 접근 시 추가 쿼리가 발생하고, 각 ReadStatus의 User, User의 Profile, User의 Status에 대해서도 별도 쿼리가 발생할 수 있습니다. default_batch_fetch_size: 100 설정으로 완화되긴 하지만, Hibernate의 Fetch Join은 한 번에 하나의 collection만 join 가능하므로 readStatuses에 대한 별도 쿼리 전략(예: @BatchSize 또는 별도 쿼리 분리)을 고려하면 더 효율적입니다.

public interface ChannelMapper {

@Mapping(target = "participants", source = "readStatuses")
@Mapping(target = "lastMessageAt", expression = "java(calculateLastMessageAt(entity))")
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.

ChannelMapper.calculateLastMessageAt()에서 entity.getMessages()를 호출하고, participants 매핑에서 readStatuses를 통해 User 엔티티에 접근합니다. findAllByUserId()에서 findAllWithMessages()로 messages는 fetch join 했지만, readStatuses와 그 안의 user, user.profile, user.status는 fetch join하지 않았습니다.

.status(HttpStatus.OK)
.body(binaryContent);
}

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.

findfindAllByIdIn 메서드에서 JPA Entity(BinaryContent)를 직접 JSON 응답으로 반환하고 있습니다. PR 본문에서 "Entity 직접 노출 시 보안 위험, 순환 참조, 서버 크래시" 문제를 언급했는데, 이 컨트롤러에서는 정작 BinaryContentDto로 변환하지 않고 Entity를 그대로 노출하고 있습니다. BinaryContentMapper가 이미 존재하므로 toDto()를 활용해야 합니다.

this.lastActiveAt = lastActiveAt;
}
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserStatus extends BaseEntity {
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.

schema.sql에서 user_statusesread_statuses 테이블에 updated_at 컬럼이 정의되어 있지만, UserStatusReadStatus 엔티티는 BaseEntity(createdAt만 있음)를 상속하고 있어 @LastModifiedDate가 동작하지 않습니다. update() 메서드에서 값을 변경하면 JPA dirty checking으로 UPDATE 쿼리가 발생하지만, updated_at 컬럼은 항상 NULL로 남게 됩니다.

UserStatus와 ReadStatus 모두 update() 메서드를 제공하므로 BaseUpdatableEntity를 상속하는 것이 의미적으로 올바릅니다.

Slice<Message> findAllByChannelId(UUID channelId, Pageable pageable);

@EntityGraph(attributePaths = {"author"})
@Query("SELECT m FROM Message m WHERE m.channel.id = :channelId AND m.id < :cursor ORDER BY m.id DESC")
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.

커서 페이지네이션에서 혼란이 있습니다:

  • findByCursor에서는 m.id < :cursor로 UUID 비교, ORDER BY m.id DESC로 정렬
  • Controller에서는 Sort.by("createdAt").descending()으로 다른 정렬 기준을 전달
  • findByCursor@Query에 이미 ORDER BY가 있으므로 Pageable의 Sort는 무시되지만, findAllByChannelId(커서 없을 때)는 createdAt 정렬을 사용

첫 페이지와 이후 페이지의 정렬 기준이 다를 수 있어 일관성 문제가 발생합니다. 또한 UUID v4는 시간순 정렬이 보장되지 않으므로(GenerationType.UUID 사용 시), createdAt 기반 커서가 더 적합합니다.

try {
Files.createDirectories(root);
} catch (IOException e) {
e.printStackTrace();
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.

e.printStackTrace();  // 프로덕션 코드에서 부적절

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