Skip to content

구민주 Sprint6#141

Open
minjukoo wants to merge 22 commits into
codeit-bootcamp-spring:구민주from
minjukoo:sprint6
Open

구민주 Sprint6#141
minjukoo wants to merge 22 commits into
codeit-bootcamp-spring:구민주from
minjukoo:sprint6

Conversation

@minjukoo
Copy link
Copy Markdown
Collaborator

@minjukoo minjukoo commented Mar 15, 2026

요구사항

기본

  • 데이터베이스 환경 설정 및 DDL 작성
  • Spring Data JPA 의존성 추가 및 엔티티 정의
  • 엔티티 간 연관 관계 매핑 및 영속성 전이 설정
  • 기존 파일/JCF 기반 레포지토리를 JpaRepository로 완전 전환
  • 엔티티 노출 방지를 위한 DTO 전면 도입 및 MapStruct 활용
  • BinaryContent 저장 로직 고도화 (DB 메타정보와 실제 바이너리 파일 저장소 분리)
  • 오프셋 기반 페이지네이션 구현

심화

  • N+1 문제 식별 및 Join Fetch / @EntityGraph를 통한 성능 최적화
  • OSIV 비활성화 설정 및 @transactional(readOnly = true) 적용
  • 커서 기반 페이지네이션(No-offset) 리팩토링 및 API v1.2 명세 준수

엔티티 연관 관계 매핑 정리

엔티티 관계 다중성 방향성 부모-자식 관계 연관관계의 주인
User : BinaryContent (Profile) 1:1 단방향 부모: User, 자식: BinaryContent User (profile_id)
User : UserStatus 1:1 양방향 부모: User, 자식: UserStatus UserStatus (user_id)
Channel : User (Participant) N:M 단방향 - channel_participants 테이블
Channel : Message 1:N 양방향 부모: Channel, 자식: Message Message (channel_id)
Message : User (Author) N:1 단방향 - Message (author_id)
Message : BinaryContent (Attachment) N:M 단방향 - message_attachments 테이블
ReadStatus : User N:1 단방향 - ReadStatus (user_id)
ReadStatus : Channel N:1 단방향 - ReadStatus (channel_id)

주요 변경사항

1. 5차 과제 코드 리뷰 피드백 반영

  • 보안 및 예외 처리: 비밀번호 평문 저장 문제를 해결하기 위해 BCryptPasswordEncoder를 도입했습니다. 또한 전역 예외 처리기에서 e.printStackTrace()를 제거하고 SLF4J 로거를 사용하여 로그를 관리하도록 개선했습니다.
  • 계층 구조 및 의존성: 컨트롤러가 레포지토리에 직접 의존하던 설계를 수정하여 서비스 계층을 통해서만 데이터에 접근하도록 변경했습니다.
  • 데이터 자동 관리: BaseEntity와 JPA Auditing(@CreatedDate, @LastModifiedDate)을 도입하여 엔티티의 생성/수정 시간을 시스템에서 자동으로 관리하도록 일관성을 확보했습니다.

2. 6차 과제 기술적 특이점

  • RDBMS 전환 및 JPA 도입: 기존 파일 시스템 기반의 임시 저장 로직을 제거하고 PostgreSQL로 전환했습니다. JPA 연관 관계 매핑을 통해 객체 지향적인 데이터 모델링을 수행했습니다.
  • 바이너리 저장 구조 고도화: DB 부하를 줄이기 위해 BinaryContent 엔티티에서 바이트 데이터를 제거했습니다. DB에는 파일명, 크기 등의 메타정보만 저장하고 실제 파일은 별도의 스토리지 인터페이스를 통해 로컬 디스크에 저장 및 스트리밍하도록 설계했습니다.
  • DTO 및 MapStruct 적용: 엔티티를 컨트롤러까지 노출했을 때 발생하는 강한 결합과 순환 참조 문제를 해결하기 위해 모든 API 응답에 DTO를 도입했습니다. MapStruct를 사용하여 반복되는 매핑 코드를 자동화했습니다.

3. 성능 최적화 및 페이지네이션

  • N+1 문제 해결: 메시지 목록 및 채널 목록 조회 시 발생하는 N+1 문제를 방지하기 위해 쿼리 메서드에 Join Fetch 및 @EntityGraph를 적용했습니다.
  • 커서 기반 페이지네이션: 대량의 메시지 조회 시 발생할 수 있는 오프셋 방식의 성능 저하를 방지하기 위해 lastMessageId 기반의 커서 페이징 방식으로 리팩토링했습니다.
  • OSIV False 설정: 실무 환경의 성능과 트랜잭션 경계를 고려하여 OSIV를 끄고, 서비스 계층 내에서 필요한 데이터를 모두 로딩하도록 트랜잭션 범위를 조정했습니다.

멘토에게

  • Entity와 DTO 분리: 엔티티를 컨트롤러에 노출하지 않음으로써 API 명세 변경에 유연하게 대응할 수 있음을 확인했습니다. 다만 프로젝트 규모가 커질 때 수많은 DTO와 매퍼를 효율적으로 관리하는 실무적인 노하우가 궁금합니다.
  • N+1 문제 해결 기준: Fetch Join과 EntityGraph를 상황에 따라 혼용했습니다. 연관 관계가 복잡해질 때 어떤 방식을 우선적으로 고려하는 것이 유지보수 측면에서 유리할지 의견을 여쭙고 싶습니다.
  • 커서 페이징 정렬: 현재 createdAt 으로 정렬 중입니다. 생성 시간이 동일한 데이터가 있을 경우를 대비해 ID를 보조 정렬 조건으로 사용하는 방식에 대해 어떻게 생각하시는지 궁금합니다.

@minjukoo minjukoo changed the title Sprint6 구민주 Sprint6 Mar 15, 2026
Copy link
Copy Markdown

@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.

전체 요약

Spring Boot + JPA로의 전면 마이그레이션이 잘 수행되었습니다. 계층 분리, MapStruct 활용, 커서 기반 페이징 등 구조적으로 좋은 설계입니다.

다만 비밀번호 업데이트 시 암호화 누락(P1)과 채널 삭제 시 ReadStatus FK 제약 위반(P1) 이슈가 있어 수정이 필요합니다.

라인 코멘트에 포함하지 못한 항목

P4. .gitignore 중복 항목: 루트 .gitignorediscodeit/.gitignore 모두에서 .idea/, *.iml, out/, build/ 항목이 파일 내에서 두 번 선언되어 있습니다. 정리하면 관리가 편해집니다.

P5. BaseEntity.java 포매팅: 모든 import, 어노테이션, 필드 사이에 빈 줄이 삽입되어 있어 일반적인 Java 컨벤션과 다릅니다. 정리를 권장합니다.


@Override
public List<User> findAll() { return userRepository.findAll(); }
user.update(request.newUsername(), request.newEmail(), request.newPassword(), newProfile);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1. 비밀번호 업데이트 시 암호화 누락 🔴

회원가입(create) 시에는 passwordEncoder.encode()로 암호화하여 저장하지만, update() 시에는 request.newPassword()평문 그대로 전달하고 있습니다.

이후 로그인 시 passwordEncoder.matches()로 비교하므로, 비밀번호 변경 후 로그인이 불가능해지는 버그가 발생합니다.

Suggested change
user.update(request.newUsername(), request.newEmail(), request.newPassword(), newProfile);
String encodedPassword = request.newPassword() != null
? passwordEncoder.encode(request.newPassword())
: null;
user.update(request.newUsername(), request.newEmail(), encodedPassword, newProfile);

@Override
@Transactional
public void delete(UUID channelId) {
channelRepository.deleteById(channelId);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1. 채널 삭제 시 ReadStatus 미정리로 FK 제약 위반 🔴

ReadStatus 엔티티가 channel_id를 FK로 참조하고 있지만, Channel 엔티티에서 ReadStatus로의 cascade 설정이 없습니다.

채널 삭제 시 read_statuses 테이블의 FK 제약 조건 위반으로 DataIntegrityViolationException이 발생합니다.

ReadStatusRepository.deleteAllByChannelId()를 먼저 호출해야 합니다.

Suggested change
channelRepository.deleteById(channelId);
readStatusRepository.deleteAllByChannelId(channelId);
channelRepository.deleteById(channelId);

@JoinColumn(name = "channel_id", nullable = false)
private Channel channel;

@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REMOVE})
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2. @manytomany에 CascadeType.REMOVE 사용 주의 ⚠️

@ManyToMany 관계에서 CascadeType.REMOVE는 위험합니다. 메시지 삭제 시 연결된 BinaryContent가 다른 곳에서도 참조될 수 있는데, cascade로 인해 의도치 않게 삭제될 수 있습니다.

현재 BasicMessageService.delete()에서 이미 수동으로 첨부파일을 삭제하고 있으므로, cascade에서 REMOVE를 제거하는 것이 안전합니다.

Suggested change
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REMOVE})
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})


void delete(UUID id);

ResponseEntity<Resource> download(UUID binaryContentId);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2. Service 인터페이스에서 ResponseEntity 반환

서비스 레이어가 ResponseEntity를 반환하면 HTTP 관심사가 비즈니스 레이어에 침투합니다.

서비스는 Resource(또는 InputStream + 메타데이터)만 반환하고, ResponseEntity 구성은 컨트롤러에서 담당하는 것이 계층 분리 원칙에 부합합니다.

현재 동작에는 문제 없으나, 테스트 작성이나 다른 클라이언트(배치 등)에서 재사용하기 어려워질 수 있습니다.

log.error("서버 내부 오류 발생", e);

return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("서버 내부 오류가 발생했습니다: " + e.getMessage());
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3. 500 에러 시 내부 예외 메시지 노출

e.getMessage()를 클라이언트에 직접 반환하면 내부 구현 정보(SQL 에러, 스택 정보 등)가 노출될 수 있습니다.

프로덕션 환경에서는 고정 메시지만 반환하고, 상세 정보는 로그(log.error)에만 남기는 것이 좋습니다.

Suggested change
.body("서버 내부 오류가 발생했습니다: " + e.getMessage());
.body("서버 내부 오류가 발생했습니다.");

@Query("SELECT DISTINCT m FROM Message m " +
"JOIN FETCH m.author " +
"WHERE m.channel.id = :channelId " +
"AND (:lastMessageId IS NULL OR m.createdAt < (SELECT m2.createdAt FROM Message m2 WHERE m2.id = :lastMessageId)) "
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3. 커서 기반 페이징에서 createdAt 동일 시 메시지 누락 가능

createdAt이 동일한 메시지가 여러 개일 경우(같은 밀리초에 생성), 일부 메시지가 누락될 수 있습니다.

createdAtid를 복합 조건으로 사용하면 정확한 커서 기반 페이징이 가능합니다:

AND (:lastMessageId IS NULL OR 
  (m.createdAt < (SELECT m2.createdAt FROM Message m2 WHERE m2.id = :lastMessageId))
  OR (m.createdAt = (SELECT m2.createdAt FROM Message m2 WHERE m2.id = :lastMessageId) AND m.id < :lastMessageId))

현재 트래픽 수준에서는 문제가 없을 수 있지만, 질문 2에서 언급하신 대로 데이터가 방대해질 경우를 대비하면 좋습니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants