Skip to content

[안승리] Sprint 6#194

Open
Atory0206 wants to merge 9 commits intocodeit-bootcamp-spring:안승리from
Atory0206:sprint6
Open

[안승리] Sprint 6#194
Atory0206 wants to merge 9 commits intocodeit-bootcamp-spring:안승리from
Atory0206:sprint6

Conversation

@Atory0206
Copy link
Copy Markdown
Collaborator

기본 요구사항

API 명세

이번 미션은 아래의 API 스펙과 비교하며 구현해보세요.

API 스펙 v1.1
API 스펙을 준수한다면, 아래의 프론트엔드 코드와 호환됩니다.

정적 리소스 v1.1.4
소스 코드(참고용) v1.1.4

데이터베이스

  • 아래와 같이 데이터베이스 환경을 설정하세요.
    • 데이터베이스: discodeit
    • 유저: discodeit_user
    • 패스워드: discodeit1234
  • ERD를 참고하여 DDL을 작성하고, 테이블을 생성하세요.
    작성한 DDL 파일은 /src/main/resources/schema.sql 경로에 포함하세요.
image
- PK: Primary Key
- UK: Unique Key
- NN: Not Null
- FK: Foreign Key 
- ON DELETE CASCADE: 연관 엔티티 삭제 시 같이 삭제
- ON DELETE SET NULL: 연관 엔티티 삭제 시 NULL로 변경

Spring Data JPA 적용하기

  • Spring Data JPA와 PostgreSQL을 위한 의존성을 추가하세요.
  • 앞서 구성한 데이터베이스에 연결하기 위한 설정값을 application.yaml 파일에 작성하세요.
  • 디버깅을 위해 SQL 로그와 관련된 설정값을 application.yaml 파일에 작성하세요.
    엔티티 정의하기
  • 클래스 다이어그램을 참고해 도메인 모델의 공통 속성을 추상 클래스로 정의하고 상속 관계를 구현하세요.
    이때 Serializable 인터페이스는 제외합니다.
image
  • JPA의 어노테이션을 활용해 createdAt, updatedAt 속성이 자동으로 설정되도록 구현하세요.
    @CreatedDate, @LastModifiedDate
  • 클래스 다이어그램을 참고해 클래스 참조 관계를 수정하세요. 필요한 경우 생성자, update 메소드를 수정할 수 있습니다. 단, 아직 JPA Entity와 관련된 어노테이션은 작성하지 마세요.
image
  • ERD와 클래스 다이어그램을 토대로 연관관계 매핑 정보를 표로 정리해보세요.(이 내용은 PR에 첨부해주세요.)
엔티티 관계 다중성 방향성 부모-자식 관계 연관관계의 주인
User : UserStatus 1 : 1 양방향 부모: User
자식: UserStatus
UserStatus
User : ReadStatus 1 : N ReadStatus -> User 단방향 부모: User
자식: ReadStatus
ReadStatus
User : Message 1 : N Message -> User 단방향 부모: User
자식: Message
Message
Channel : ReadStatus 1 : N ReadStatus -> Channel 단방향 부모: Channel
자식: ReadStatus
ReadStatus
Channel : Message 1 : N Message -> Channel 단방향 부모: Channel
자식: Message
Message
Message : BinaryContent 1 : N Message -> BinaryContent 단방향 부모: Message
자식: BinaryContent
Message
User : BinaryContent 1 : 0..1 User -> BinaryContent 단방향 부모: User
자식: BinaryContent
BinaryContent
  • JPA 주요 어노테이션을 활용해 ERD, 연관관계 매핑 정보를 도메인 모델에 반영해보세요.
    @entity, @table
    @column, @Enumerated
    @onetomany, @OnetoOne, @manytoone
    @joincolumn, @jointable
  • ERD의 외래키 제약 조건과 연관관계 매핑 정보의 부모-자식 관계를 고려해 영속성 전이와 고아 객체를 정의하세요.
  • cascade, orphanRemoval
    레포지토리와 서비스에 JPA 도입하기
  • 기존의 Repository 인터페이스를 JPARepository로 정의하고 쿼리메소드로 대체하세요.
    • FileRepository와 JCFRepository 구현체는 삭제합니다.
  • 영속성 컨텍스트의 특징에 맞추어 서비스 레이어를 수정해보세요.
    • 힌트: 트랜잭션, 영속성 전이, 변경 감지, 지연로딩

DTO 적극 도입하기

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

    • Entity와 API의 결합
    • 프로덕션 환경에서는 성능을 고려해 OSIV를 false로 설정하는 경우가 대부분
    • 양방향 연관관계 시 순환 참조
    • 민감한 데이터
    1. DTO를 도입하지 않으면 Entity와 API의 강한 결합이 발생하여 DB를 수정하거나 교체할때 API 도 수정해야하는 문제가 생길수 있다.
    1. OSIV는 영속성 컨텍스트를 VIEW까지 열어두는 옵션으로 true면 컨트롤러에서도 LAZY로딩이 가능해지지만 자원을 많이쓰는 단점이 있어서 보통 false로 두는데 이렇게하면 컨트롤러에서 엔티티의 LAZY필드로 접근하면 LazyInitializationException이 발생한다. 하지만 DTO로 변환하면 트랜잭션 안에서 필요한 데이터를 모두 담아서 반환하므로 이 문제를 해결할 수 있다.
    1. JPA의 양방향 관계에서 엔티티를 JSON으로 변환하려고 하다가 순환참조 오류가 발생할수 있는데 DTO는 필요한 데이터만 담고있는 객체이므로, 순환 참조를 차단할 수 있다.
    1. 엔티티에는 password같은 민감한 정보를 필드로 담고있는데 엔티티를 통째로 노출할경우 API요청으로 엔티티를 직접 받을 경우 의도치 않은 필드를 조작해서 DB에 그대로 반영될 수 있는 문제가 있는데 DTO는 외부로 보낼 필드와 입력받을 필드만 정의하므로 이런 문제를 해결할 수 있다.
  • 다음의 클래스 다이어그램을 참고하여 DTO를 정의하세요.

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

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

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

  • BinaryContent의 byte[] 데이터 저장을 담당하는 인터페이스를 설계하세요.
    저장 매체의 확장성(로컬 저장소, 원격 저장소)을 고려해 인터페이스부터 설계합니다.
    패키지명: com.sprint.mission.discodeit.storage

image

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

페이징과 정렬

  • 메시지 목록을 조회할 때 다음의 조건에 따라 페이지네이션 처리를 해보세요.
    • 50개씩 최근 메시지 순으로 조회합니다.
    • 총 메시지가 몇개인지 알 필요는 없습니다.
  • 일관된 페이지네이션 응답을 위해 제네릭을 활용해 DTO로 구현하세요.
    패키지명: com.sprint.mission.discodeit.dto.response
image - content: 실제 데이터입니다. - number: 페이지 번호입니다. - size: 페이지의 크기입니다. - totalElements: T 데이터의 총 갯수를 의미하며, null일 수 있습니다.
  • Slice 또는 Page 객체로부터 DTO를 생성하는 Mapper를 구현하세요.
    • 패키지명: com.sprint.mission.discodeit.mapper
image - 확장성을 위해 제네릭 메소드로 구현하세요

심화 요구사항

N+1 문제

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

읽기전용 트랜잭션 활용

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

페이지네이션 최적화

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

    • 오프셋 페이지네이션
      • 페이지 번호와 offset을 이용해서 데이터를 가져온다.
      • DB가 처음부터 offset 번호까지 모든 레코를 읽은 뒤 그 앞부분을 버리고 필요한 만큼만 반환한다.
      • 구현이 간단하지만 데이터가 많으면 뒤로 갈수록 읽고 버려야할 데이터가 많아져 속도가 느려지고 페이지 이동중에 새로운 데이터가 추가되거나 수정되면 중복된 항목을 보거나 건너뛰게 되는 무제가 발생한다.
    • 커서 페이지네이션
      • WHERE절을 이용해서 커서보다 크거나 작은 ID를 가진 데이터를 LIMIT만큼 가져온다.
      • 인덱스를 활용하기 때문에 데이터의 크기에 상관 없이 일정한 속도를 유지한다.
      • 데이터가 중간에 추가되거나 수정되더라도 중복이나 누락이 거의없다.
      • 무한 스크롤에 최적화 되어있다.
      • 특정 페이지로 직접 건너뛰기가 불가능하고 정렬기준이 복잡할수록 구현하기 어렵다.
  • 기존에 구현한 오프셋 페이지네이션을 커서 페이지네이션으로 리팩토링하세요.

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

스크린샷

image

멘토에게

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

@Atory0206 Atory0206 requested a review from joonfluence March 11, 2026 04:48
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.

전체 요약

Spring Data JPA 마이그레이션, DTO/Mapper 패턴 도입, BinaryContentStorage 추상화, 커서 기반 페이지네이션 구현이 포함된 Sprint 6 과제입니다. 전반적으로 잘 구현되었으나, application.yaml 설정 오류, N+1 문제, 엔티티 직접 노출 방지 미흡 등 일부 개선이 필요합니다.

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

[p4] .discodeit/*.ser 바이너리 파일과 storage/ 디렉토리의 바이너리 데이터가 PR에 포함되어 있습니다. 로컬 테스트 데이터이므로 .gitignore에 추가하여 VCS에서 제외하는 것이 좋습니다.

[p5] ReadStatusMapper, UserStatusMapper에 MapStruct를 적절히 도입한 점 좋습니다! 다른 Mapper(MessageMapper, ChannelMapper, UserMapper)도 MapStruct로 통일하면 보일러플레이트를 더 줄일 수 있습니다.

datasource:
url: jdbc:postgresql://localhost:5432/discodeit
username: discodeit_user
password: discodeit1234
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.

[p1] 꼭 반영해주세요

sql.init.modejpa 설정이 spring: 하위가 아닌 루트 레벨에 위치해 있어, Spring Boot가 이 설정을 인식하지 못합니다.

현재 구조:

spring:
  datasource: ...

sql:          # ← spring 밖
  init:
    mode: always

  jpa:        # ← spring 밖
    hibernate:
      ddl-auto: none

spring: 블록 안으로 옮겨야 DDL 초기화와 JPA 설정(ddl-auto, show-sql, format_sql 등)이 정상 동작합니다.

Suggested change
password: discodeit1234
datasource:
url: jdbc:postgresql://localhost:5432/discodeit
username: discodeit_user
password: discodeit1234
driver-class-name: org.postgresql.Driver
sql:
init:
mode: always

.findLastMessageTimesByChannelIds(channelIds)
.stream()
.collect(Collectors.toMap(
row -> (UUID) row[0],
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] 적극적으로 고려해주세요

findAllByChannelIdIn으로 ReadStatus를 가져온 뒤 rs.getUser()를 호출하면 N+1 문제가 발생합니다. ReadStatus마다 User를 개별 SELECT하게 됩니다.

Repository에 JOIN FETCH 쿼리를 추가하여 ReadStatus + User를 한 번에 가져오도록 개선해주세요:

@Query("SELECT rs FROM ReadStatus rs JOIN FETCH rs.user WHERE rs.channel.id IN :channelIds")
List<ReadStatus> findAllByChannelIdInWithUser(@Param("channelIds") List<UUID> channelIds);

return new MessageDto(
message.getId(),
message.getCreatedAt(),
message.getUpdatedAt(),
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] 적극적으로 고려해주세요

message.getAuthor()message.getAttachments()에 접근하는데, OSIV가 비활성화된 상태에서 이들이 LAZY 로딩이면 LazyInitializationException이 발생할 수 있습니다.

findByChannelIdWithCursor 쿼리에서 JOIN FETCH로 author와 attachments를 함께 로딩하거나, @EntityGraph를 사용해주세요:

@Query("""
    SELECT m FROM Message m
    JOIN FETCH m.author
    LEFT JOIN FETCH m.attachments
    WHERE m.channel.id = :channelId
    AND (CAST(:cursor AS java.time.Instant) IS NULL OR m.createdAt < :cursor)
    ORDER BY m.createdAt DESC
    """)
Slice<Message> findByChannelIdWithCursor(...);

encodedPassword,
profile
);
UserStatus userStatus = new UserStatus(user);
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] 적극적으로 고려해주세요

update 시 username/email 중복 검사에서 자기 자신의 기존 값도 중복으로 판정됩니다. 예를 들어 email만 바꾸고 username을 그대로 보내면 "이미 존재하는 유저네임" 에러가 발생합니다.

자기 자신을 제외하는 조건을 추가해주세요:

if (jpaUserRepository.existsByUsernameAndIdNot(request.newUsername(), userId)) {
    throw new IllegalArgumentException("이미 존재하는 유저네임입니다.");
}

private final BinaryContentMapper binaryContentMapper;

public UserDto toDto(User user) {
Boolean online = user.getUserStatus() != null
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] 웬만하면 반영해 주세요

User → UserStatus@OneToOne(mappedBy = "user")인데, findAll()에서 모든 유저를 조회하면 유저 수만큼 UserStatus SELECT가 추가 실행되어 N+1이 발생합니다.

Repository에 @EntityGraphJOIN FETCH를 적용하여 UserStatus를 함께 로딩하는 것을 고려해주세요.


@ManyToOne
@JoinColumn(name = "channel_id", nullable = false)
private Channel channel;
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] 웬만하면 반영해 주세요

컬럼명에 대문자 A가 포함되어 있습니다 (last_read_At). schema.sql의 last_read_at과 일관성을 위해 소문자로 수정하는 것이 좋습니다.

Suggested change
private Channel channel;
@Column(name = "last_read_at", nullable = false)

operationId = "create_2")
@ApiResponses(value = {
@ApiResponse(responseCode = "201",
description = "Message가 성공적으로 생성됨"),
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] 웬만하면 반영해 주세요

파일 읽기 실패 시 RuntimeException으로 감싸면 클라이언트에게 500 에러만 전달됩니다. 적절한 메시지를 포함하면 디버깅에 도움이 됩니다.

Suggested change
description = "Message가 성공적으로 생성됨"),
} catch (IOException e) {
throw new RuntimeException("첨부파일 읽기 실패: " + file.getOriginalFilename(), e);

private final PasswordEncoder passwordEncoder;
private final UserMapper userMapper;

public UserDto login(UserLoginRequest request) {
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] 웬만하면 반영해 주세요

login 메서드에 @Transactional(readOnly = true)가 없습니다. OSIV가 비활성화된 상태에서 userMapper.toDto(user) 시 UserStatus 등 LAZY 필드 접근 시 LazyInitializationException이 발생할 수 있습니다.

Suggested change
public UserDto login(UserLoginRequest request) {
@Transactional(readOnly = true)
public UserDto login(UserLoginRequest request) {

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