Skip to content

[전승현] sprint 6#122

Open
seunghyeonjeon57-dot wants to merge 2 commits intocodeit-bootcamp-spring:전승현from
seunghyeonjeon57-dot:전승현

Hidden character warning

The head ref may contain hidden characters: "\uc804\uc2b9\ud604"
Open

[전승현] sprint 6#122
seunghyeonjeon57-dot wants to merge 2 commits intocodeit-bootcamp-spring:전승현from
seunghyeonjeon57-dot:전승현

Conversation

@seunghyeonjeon57-dot
Copy link
Copy Markdown
Collaborator

@seunghyeonjeon57-dot seunghyeonjeon57-dot commented Mar 11, 2026

요구사항

기본
-[x]Spring Data JPA 적용하기
-[x]엔티티 정의하기
-[x] JPA 주요 어노테이션을 활용해 ERD, 연관관계 매핑 정보를 도메인 모델에 반영해보세요.
-[x] ERD의 외래키 제약 조건과 연관관계 매핑 정보의 부모-자식 관계를 고려해 영속성 전이와 고아 객체를 정의하세요.
-[x]DTO 적극 도입하기
-[x]BinaryContent 저장 로직 고도화
-[x]페이징과 정렬

심화
-[x] N+1 문제가 발생하는 쿼리를 찾고 해결해보세요.
-[x] 프로덕션 환경에서는 OSIV를 비활성화하는 경우가 많습니다. 이때 서비스 레이어의 조회 메소드에서 발생할 수 있는 문제를 식별하고, 읽기 전용 트랜잭션을 활용해 문제를 해결해보세요.
-[x]페이지네이션 최적화
-[x] Entity와 DTO를 매핑하는 보일러플레이트 코드를 MapStruct 라이브러리를 활용해 간소화해보세요.

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

Entity를 Controller까지 그대로 노출했을때 불필요한 정보를 그대로 노출시킬수 있다.
-> 예를 들어 User 엔티티를 controller에서 그대로 노출하게되면 비밀번호같은 공개되어야하지않은 정보가 공개될수가있다
순환 참조 문제: 양방향 매핑이 설정된 엔티티를 JSON으로 변환할때 서로를 계속 참조하는 상황 발생할 수 있다
얻는 이점: API스펙의 독립성 유지: 엔티티 필드명이 바뀌거나 구조가 변경되어도 DTO에서 변환만 잘해주면 API스펙은 유지할 수 있다
→내부 구현의 변화가 외부에 영향 주지않음
목적에 맞는 데이터 필터링
책임분리: 엔티티는 도메인 모델링에 DTO는 데이터전달(필요한) 검증에 집중하여 코드 가독성을 높임
-- [x] 오프셋 페이지네이션과 커서 페이지네이션 방식의 차이에 대해 정리해보세요.

  • 오프셋 페이지네이션:LIMIT와 OFFSET 쿼리를 사용하여 특정 페이지의 데이터를 가져옴

  • 동작 방식→ 앞에서부터 100개를 건너뛰고(OFFSET) 그 다음 10개를 가져와(LIMIT)라는 식

  • 문제점

  • 성능 저하: 뒤 페이지로 갈수록 데이터베이스는 앞부분의 데이터를 모두 읽어서 버려야하므로 속도가 급격하게 느려짐

  • 데이터 중복/누락:조회를 하던 도중에 새로운 데이터가 추가되면 페이지 경계가 밀리면서 이전페이지에서 봤던 데이터가 다음 페이지에 또 나올 수 있다

  • 커서 페이지네이션:마지막으로 본 데이터의 식별자를 기준으로 그 이후의 데이터를 가져옴

    • 동작 방식→”ID가 100번인 데이터 다음부터 10개를 가져와”
  • 장점

    • 고정된 성능: 인덱스를 태워 즉시 해당 지점으로 점프하기때문에 전체 데이터 양이 많아져도 조회성능 일정핢
    • 데이터 일관성: 조회를 하는 도중 새로운 데이터가 추가되어도 내가 보고 있는 기준점은 변하지않으므로 데이터 중복 문제가 발생하지않음
    • 무한 스크롤에 최적화: 모바일 앱이나 SNS 피드에 가장 적합한 방식

주요 변경사항
JPA를 적용을 했으며 JPA기능으로 수정 완료했습니다.
커서 페이지네이션으로 리펙토링했습니다.
스크린샷
스크린샷 2026-03-10 오전 9 41 59 -> ERD와 클래스 다이어그램을 토대로 연관관계 매핑 정보를 표로 정리해보세요.(이 내용은 PR에 첨부해주세요.)
스크린샷 2026-03-11 오전 11 46 49

멘토에게
-Postman/Swagger에서는 괜찮았는데 웹 실행 시 쿼리가 폭발하는 것이, 단순히 웹 페이지에서 여러 API를 동시에 호출해서 발생하는 현상인지, 아니면 제가 작성한 엔티티 연관 관계 때문에 발생하는 구조적 결함인지 판단하기 어렵습니다. 멘토님은 실무에서 이런 대량의 쿼리 로그를 분석할 때 어떤 포인트부터 디버깅하시는지 궁금합니다.
-ChannelMapper같은 경우 mapStruct를 쓰지않는게 더 가독성이 좋아보이는데 굳이 써야하는지 궁금합니다
-저번리뷰대로 gitignore 수정했는데 맞게 수정된건지 여쭤보고싶습니다
-현재 controller service repository등의 기능별 패키지 구조를 유지하고 있는데 프로젝트 규모가 커짐에 따라 도메인별로 패키지를 나누는 방식으로 전환하는 시점이 언제쯤이 적당한지 여쭤보고싶습니다
-브랜치를 하나 새로파서 pr을 새로 남겻어야햇는데 제이름브렌치로 해서 pr생성이 안됬습니다. 다음 미션부터 주의하겠습니다
-git오류가 생겨서 수정후 다시 올립니다 정상적인지 확인 부탁드립니다..
-한글 브랜치명 인코딩 문제로 제 레포지토리 메인에서 404가 뜨는 경우가 있으나, PR의 Files changed 탭에서는 코드가 정상 확인됩니다->이게 한글브랜치명 인코딩 문제인지 확인이 안되서 확인부탁드리겠습니다.

@seunghyeonjeon57-dot seunghyeonjeon57-dot changed the title 전승현[sprint 6] [전승현] sprint 6 Mar 11, 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 Data JPA 적용, 엔티티 연관관계 매핑, DTO 도입, MapStruct 적용, 커서 페이지네이션 구현 등 Sprint 6 요구사항을 전반적으로 잘 구현했습니다. 다만 몇 가지 버그와 구조적 이슈가 있어 수정이 필요합니다.

라인 코멘트로 달 수 없는 항목

[p1] .discodeit/storage/ 바이너리 파일 커밋됨
.discodeit/Channel/, .discodeit/Message/, .discodeit/ReadStatus/ 등의 .ser 직렬화 파일과 storage/ 디렉토리 내 업로드 파일들이 PR에 포함되어 있습니다. .gitignore.discodeit/*.ser만 추가되어 있어 하위 디렉토리의 .ser 파일은 무시되지 않습니다.

수정 제안:

.discodeit/
storage/

이미 커밋된 파일은 git rm --cached로 추적을 제거해야 합니다.

[p4] 코드 전반 — 주석 처리된 코드 정리
UserController, MessageRepository, BasicUserService 등 여러 파일에 주석 처리된 코드(//)가 남아 있습니다. Git 히스토리에서 언제든 복구할 수 있으므로 제거하는 것이 깔끔합니다.

})
.orElseGet(() -> {
ReadStatus newStatus = new ReadStatus(user, channel, request.lastReadAt());
return mapper.toDto(newStatus);
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] 🐛 새로 생성한 ReadStatusDB에 저장되지 않습니다.

existsByUserIdAndChannelIdtrue이면 위에서 이미 예외를 던지므로, 여기 findByUserIdAndChannelId는 항상 empty입니다. 따라서 항상 orElseGet 분기를 타는데, save() 호출 없이 DTO 변환만 하고 있어서 실제로 DB에 저장되지 않습니다.

존재 여부 체크 + 다시 조회하는 구조 자체가 모순적이므로 간단히 아래처럼 정리하는 것을 추천합니다:

Suggested change
return mapper.toDto(newStatus);
ReadStatus newStatus = new ReadStatus(user, channel, request.lastReadAt());
return mapper.toDto(readStatusRepository.save(newStatus));

// CAST 추가
Slice<Message> findByChannelIdAndCreatedAtBefore(
@Param("channelId") UUID channelId,
@Param("cursor") LocalDateTime cursor,
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] 🐛 커서 타입 불일치 — LocalDateTime vs Instant

BaseEntity.createdAtInstant 타입인데, 커서 파라미터가 LocalDateTime입니다. 타입이 다르므로 비교 시 시간대(timezone) 문제가 발생하거나 런타임 에러가 날 수 있습니다.

Suggested change
@Param("cursor") LocalDateTime cursor,
@Param("cursor") Instant cursor,

MessageControllerMessageService의 커서 파라미터도 함께 Instant로 변경해야 합니다.

if (channel.getMessages() == null) {
return null;
}
return channel.getMessages().stream()
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]channel.getMessages()는 해당 채널의 모든 메시지를 Lazy Loading합니다. 메시지가 수천 건인 채널에서는 심각한 성능 문제가 됩니다. 심화 요구사항에서 N+1 문제 해결을 했음에도 여기서 다시 발생하고 있습니다.

Repository에 별도 쿼리를 만들어 최신 메시지 하나만 조회하는 방식을 추천합니다:

// MessageRepository에 추가
@Query("SELECT MAX(m.createdAt) FROM Message m WHERE m.channel.id = :channelId")
Optional<Instant> findLastMessageAtByChannelId(@Param("channelId") UUID channelId);

또는 주석 처리된 findFirstByChannelIdOrderByCreatedAtDesc를 활용하세요.

CREATE table binary_contents(
id uuid PRIMARY KEY ,
created_at timestamptz NOT NULL ,
file_name varchar(255) NOT NULL ,
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] BinaryContent 엔티티에서 bytes 필드를 제거하고 파일 스토리지로 분리했는데, schema.sql에는 여전히 bytes bytea NOT NULL이 남아 있습니다. ddl-auto: update를 사용하고 있어서 런타임에는 영향이 없을 수 있지만, 이 스키마 파일을 직접 실행하면 엔티티와 불일치가 발생합니다.

email varchar(100) UNIQUE NOT NULL,
password varchar(60) NOT NULL,
profile_id UUID UNIQUE
)
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]CREATE TABLE 문 뒤에 세미콜론(;)이 없습니다. 이 SQL 파일을 직접 실행하면 구문 오류가 발생합니다.

Suggested change
)
)
;

다른 CREATE TABLE 문들도 동일하게 수정이 필요합니다.


public interface BinaryContentRepository extends JpaRepository<BinaryContent, UUID> {

List<BinaryContentDto> findAllByIdIn(List<UUID> ids);
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] Repository 계층에서 BinaryContentDto를 직접 반환하고 있습니다. Repository는 Entity를 반환하고, Service/Mapper 계층에서 DTO로 변환하는 것이 일관성 있는 구조입니다.

또한 BinaryContentDto가 record인 경우 Spring Data JPA의 class-based projection 동작 여부를 확인해보세요. 필드명이 정확히 일치해야 합니다.

Suggested change
List<BinaryContentDto> findAllByIdIn(List<UUID> ids);
List<BinaryContent> findAllByIdIn(List<UUID> ids);

)
.map(this::toDto)
.toList();
List<UUID> channelIds = mySubscribedChannels.stream().map(Channel::getId).toList();
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] 조회 결과를 아무 변수에도 할당하지 않고 있습니다. Hibernate 1차 캐시를 워밍업하여 이후 channelMapper.toDto() 호출 시 추가 쿼리를 방지하려는 의도로 보이는데, 의도가 불분명하니 주석을 추가해주세요.

Suggested change
List<UUID> channelIds = mySubscribedChannels.stream().map(Channel::getId).toList();
// Hibernate 1차 캐시에 ReadStatus를 프리로드하여 Mapper에서 N+1 방지
readStatusRepository.findAllByChannelIdIn(channelIds);

import com.sprint.mission.discodeit.entity.Message;
import com.sprint.mission.discodeit.service.BinaryContentService;
import com.sprint.mission.discodeit.service.MessageService;
import java.awt.Cursor;
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] java.awt.Cursor는 AWT GUI 패키지이며, 커서 페이지네이션과 관련이 없습니다. 사용되지 않는 import이므로 제거해주세요.

datasource:
url: jdbc:postgresql://localhost:5432/discodeit
username: discodeit_user
password: discodeit1234
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] DB 비밀번호가 하드코딩되어 있습니다. 학습 프로젝트라서 당장 문제는 없지만, 실무에서는 환경 변수나 application-local.yaml 등을 활용하여 관리합니다. .gitignore에 민감 정보가 포함된 설정 파일을 추가하는 습관을 들이면 좋습니다.

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