Skip to content

[박승민] Sprint6#201

Open
raonPsm wants to merge 149 commits intocodeit-bootcamp-spring:박승민from
raonPsm:sprint6
Open

[박승민] Sprint6#201
raonPsm wants to merge 149 commits intocodeit-bootcamp-spring:박승민from
raonPsm:sprint6

Conversation

@raonPsm
Copy link
Copy Markdown
Collaborator

@raonPsm raonPsm commented Mar 11, 2026

[SB] 스프린트 미션 6

🏔️ 프로젝트 마일스톤

  • 데이터베이스 환경 설정 및 모델링
  • Spring Data JPA 환경 적용
  • Entity 연관 관계 매핑
  • 레포지토리와 서비스 계층에 JPA 적용
  • Transaction 처리
  • 페이지네이션과 정렬
  • DTO의 적극적인 도입과 MapStruct의 활용
  • BinaryContent 저장 로직 고도화
  • 메타정보와 바이너리 정보 분리
  • N+1 문제 해결

📝 요구사항

✏️ 기본 요구사항

API 명세

프론트엔드 소스 코드는 참고용으로만 활용하세요. 수정하여 활용하는 경우 이어지는 요구사항 또는 미션을 수행하는 데 어려움이 있을 수 있습니다.

데이터베이스

  • 아래와 같이 데이터베이스 환경을 설정하세요.
    • 데이터베이스: discodeit
    • 유저: discodeit_user
    • 패스워드: discodeit1234
postgres=# CREATE USER discodeit_user WITH PASSWORD 'discodeit1234';
CREATE ROLE

postgres=# CREATE DATABASE discodeit OWNER discodeit_user;
CREATE DATABASE

postgres=# GRANT ALL PRIVILEGES ON DATABASE discodeit TO discodeit_user;
GRANT
  • ERD를 참고하여 DDL을 작성하고, 테이블을 생성하세요.
    • 작성한 DDL 파일은 /src/main/resources/schema.sql 경로에 포함하세요. u0ghedzoz-image.png

      • 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 인터페이스는 제외합니다.

    • 패키지명: com.sprint.mission.discodeit.entity.base

    • 클래스 다이어그램

      xs6bzcvs6-image.png

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

    • @CreatedDate@LastModifiedDate
  • 클래스 다이어그램을 참고해 클래스 참조 관계를 수정하세요. 필요한 경우 생성자, update 메소드를 수정할 수 있습니다. 단, 아직 JPA Entity와 관련된 어노테이션은 작성하지 마세요.

    • 클래스 다이어그램 pq5iz92wt-image.png

    • 화살표의 방향과 화살표 유무에 유의하세요.

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

    • 예시
엔티티 관계 다중성 방향성 부모-자식 관계 연관관계의 주인
A:B 1:N B→A 단방향 부모: A, 자식: B B
엔티티 관계 다중성 방향성 부모-자식 관계 연관관계의 주인 비고 (FK 식별자)
Channel : Message 1 : N Message → Channel 단방향 부모: Channel자식: Message Message Message 테이블이 channel_id FK 소유
User : Message 1 : N Message → User 단방향 부모: User자식: Message Message Message 테이블이 author_id FK 소유
User : UserStatus 1 : 1 UserStatus → User 양방향 부모: User자식: UserStatus UserStatus UserStatus 테이블이 user_id FK 소유
Channel : ReadStatus 1 : N ReadStatus → Channel 단방향 부모: Channel자식: ReadStatus ReadStatus ReadStatus 테이블이 channel_id FK 소유
User : ReadStatus 1 : N ReadStatus → User 단방향 부모: User자식: ReadStatus ReadStatus ReadStatus 테이블이 user_id FK 소유
User : BinaryContent 1 : 1 User → BinaryContent 단방향 부모: User자식: BinaryContent User User 테이블이 profile_id FK 소유
Message : BinaryContent 1 : N Message → BinaryContent 단방향 부모: Message자식: BinaryContent Message 조인 테이블(message_attachments) 방식 사용
  • JPA 주요 어노테이션을 활용해 ERD, 연관관계 매핑 정보를 도메인 모델에 반영해보세요.
    • @Entity@Table
    • @Column@Enumerated
    • @OneToMany@OneToOne@ManyToOne
    • @JoinColumn@JoinTable
  • ERD의 외래키 제약 조건과 연관관계 매핑 정보의 부모-자식 관계를 고려해 영속성 전이와 고아 객체를 정의하세요.
    • cascadeorphanRemoval

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

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

DTO 적극 도입하기

  • Entity를 Controller 까지 그대로 노출했을 때 발생할 수 있는 문제점에 대해 정리해보세요. DTO를 적극 도입했을 때 보일러플레이트 코드가 많아지지만, 그럼에도 불구하고 어떤 이점이 있는지 알 수 있을거에요.(이 내용은 PR에 첨부해주세요.)
    • 힌트
      • Entity와 API의 결합
      • 프로덕션 환경에서는 성능을 고려해 OSIV를 false로 설정하는 경우가 대부분
      • 양방향 연관관계 시 순환 참조
      • 민감한 데이터
### Entity 직접 노출 시 발생할 수 있는 주요 문제점

1. Entity와 API 스펙의 강한 결합 (Tight Coupling)
   Entity는 데이터베이스 테이블 구조와 1:1 매핑되는 도메인 모델이다.
   만약 Entity를 API 응답으로 직접 반환하면, DB 스키마가 변경될 때마다 API 스펙(JSON 구조)이 함께 변하게 된다. 이는 API를 소비하는 프론트엔드나 외부 클라이언트의 코드를 강제로 수정하게 만드는 결합도를 유발한다.

2. OSIV(Open Session In View) Off 환경에서의 지연 로딩 문제
   실무 프로덕션 환경에서는 [[커넥션 풀]]의 효율적인 관리를 위해 spring.jpa.open-session-in-view 옵션을 false로 설정하는 경우가 많다.
- 문제: 트랜잭션 범위(Service 레이어) 밖인 Controller에서는 영속성 컨텍스트가 종료된다.
- 결과: Controller에서 Entity의 연관관계 객체를 참조하려고 하면 LazyInitializationException이 발생하여 정상적인 응답이 불가능하다.

3. 양방향 연관관계에 따른 순환 참조
   JPA의 양방향 연관관계 상황에서 Entity를 그대로 JSON 직렬화할 경우, Jackson 라이브러리가 서로를 무한히 참조하며 호출하다가 결국 StackOverflowError를 일으키며 서버가 다운될 수 있다.

4. 민감한 데이터 노출 및 보안 이슈
   Entity에는 비즈니스 로직상 필요한 비밀번호, 주민번호, 내부 시스템용 생성일자 등 클라이언트에게 노출되어서는 안 되는 민감한 정보가 포함될 수 있다. DTO 없이 Entity를 반환하면 이를 제어하기 위해 @JsonIgnore 같은 어노테이션을 Entity에 덕지덕지 붙이게 되어 도메인 모델이 오염된다.

### DTO 도입을 통한 아키텍처적 이점
보일러플레이트 코드가 다소 늘어나더라도 DTO를 사용함으로써 얻는 실익이 훨씬 크다
- API 스펙의 독립성 보장: DB 스키마가 변경되어도 DTO 구조만 유지하면 API 스펙을 안정적으로 관리할 수 있다. (유지보수성 향상)
- Validation 로직의 분리: @NotBlank, @Min 같은 빈 검증 어노테이션을 Entity가 아닌 DTO에 작성함으로써, 도메인 모델을 순수하게 유지하고 각 API 스펙에 맞는 검증 규칙을 적용할 수 있다.
- 데이터 필터링 및 가공: 필요한 데이터만 골라 담거나, 여러 Entity의 데이터를 조합하여 클라이언트가 요구하는 최적화된 형태의 JSON을 구성하기 용이하다.
- 가독성 및 문서화: DTO의 필드명만 봐도 해당 API가 어떤 데이터를 주고받는지 명확히 알 수 있으며, Swagger(OpenAPI) 문서화 시에도 훨씬 깔끔한 명세 작성이 가능하다.
  • 다음의 클래스 다이어그램을 참고하여 DTO를 정의하세요. hd4c6g1of-image.png

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

    • 패키지명: com.sprint.mission.discodeit.mapper buo7cmjvp-image.png

BinaryContent 저장 로직 고도화

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

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

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

    저장 매체의 확장성(로컬 저장소, 원격 저장소)을 고려해 인터페이스부터 설계합니다.

    • 패키지명: com.sprint.mission.discodeit.storage

    • 클래스 다이어그램 nqt5zw2pk-image.png

    • 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
      • 방식: Query Parameter
    • 응답: ResponseEntity<?>

    • 클래스 다이어그램

      5qwe2kqno-image.png

  • 로컬 디스크 저장 방식으로 BinaryContentStorage 구현체를 구현하세요.

    • 클래스 다이어그램

      skptrmm5p-image.png

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

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

페이징과 정렬

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

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

    • 패키지명: com.sprint.mission.discodeit.dto.response

    • 클래스 다이어그램 wj4q7nhn3-image.png

    • content: 실제 데이터입니다.

    • number: 페이지 번호입니다.

    • size: 페이지의 크기입니다.

    • totalElements: T 데이터의 총 갯수를 의미하며, null일 수 있습니다.

  • Slice 또는 Page 객체로부터 DTO를 생성하는 Mapper를 구현하세요.

    • 패키지명: com.sprint.mission.discodeit.mapper

      x7qjncxm0-image.png

    • 확장성을 위해 제네릭 메소드로 구현하세요.

✏️ 심화 요구사항

N+1 문제

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

읽기전용 트랜잭션 활용

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

      spring: jpa: open-in-view: false

페이지네이션 최적화

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

이 내용은 PR에 첨부해주세요.

### 오프셋 페이지네이션 (Offset-based Pagination)
전통적인 방식으로, SQL의 LIMIT과 OFFSET절을 사용하여 특정 지점부터 일정 개수의 데이터를 가져오는 방식이다.
- 데이터베이스가 처음부터 OFFSET 수만큼의 레코드를 읽은 후, 이를 버리고 LIMIT만큼의 데이터를 반환한다.
- 특징:
	- 임의 접근: 사용자가 특정 페이지로 바로 이동하는 것이 가능하다
	- 구현 난이도: 매우 단순하며 대부분의 ORM에서 기본적으로 지원한다
- 단점:
	- 성능 저하: OFFSET 값이 커질수록 데이터베이스는 앞선 레코드를 모두 읽어야 하므로 시간 복잡도는 O(N)에 비례하여 성능이 급격히 저하된다.
	- 데이터 정합성 문제: 조회를 수행하는 사이에 새로운 데이터가 삽입되거나 삭제되면, 사용자가 다음 페이지로 넘어갔을 때 중복된 데이터를 보거나 일부 데이터를 건너뛰는 현상이 발생한다
### 커서 페이지네이션 (Cursor-based Pagination)
사용자에게 제공한 마지막 데이터의 식별자(Cursor)를 기준으로 다음 데이터를 가져오는 방식이다. 'No-offset' 방식이라고도 불린다.
- WHERE 절에서 마지막으로 읽은 고유 값을 조건으로 걸고 LIMIT을 적용한다. 
	- `WHERE id < last_seen_id ORDER BY id DESC LIMIT 10`
- 특징:
	- 고성능: 인덱싱된 컬럼을 커서로 사용하면 데이터베이스는 해당 위치로 즉시 점프하므로, 전체 데이터 양과 관계없이 일정한 성능(O(1)에 근접)을 유지한다.
	- 데이터 정합성 유지: 현재 페이지 이후에 데이터가 추가되거나 삭제되어도 커서 기준 다음 페이지를 가져오므로 누락이나 중복이 발생하지 않는다. 
- 단점: 
	- 제한적 접근: 특정 페이지로의 점프가 불가능하며, '다음/이전' 버튼 형식의 무한 스크롤이나 더보기 방식에 적합하다.
	- 구현 복잡도: 정렬 조건이 여러 개일 경우 커서 로직을 구성하기 까다롭다.
  • 기존에 구현한 오프셋 페이지네이션을 커서 페이지네이션으로 리팩토링하세요.

프론트엔드 소스 코드는 참고용으로만 활용하세요. 수정하여 활용하는 경우 이어지는 요구사항 또는 미션을 수행하는 데 어려움이 있을 수 있습니다.

MapStruct 적용

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

🔄 주요 변경사항

📸 스크린샷

🙇🏽‍♂️ 멘토에게

  • sprint5에서 주신 피드백은 빠른 시일 내에 수정하도록 하겠습니다. 아직 수정하지 못했습니다.
  • N+1 문제 같은 경우 아직 전부 해결하지 못했습니다. 이 또한 빠른 시일 내에 수정하도록 하겠습니다.

⚖️ License & Copyright

  • 본 프로젝트의 테스트 용도로 사용된 리그 오브 레전드(League of Legends) 챔피언 이미지의 모든 저작권은 Riot Games, Inc.에 있습니다.
  • 본 프로젝트는 비상업적, 교육 및 학습 목적으로만 제작되었습니다.

raonPsm added 24 commits March 8, 2026 21:11
- 불필요한 @mapping 제거
- ChannelMapper participants, lastMessageAt 관련 문제 수정
@raonPsm raonPsm requested a review from byungwook-min March 11, 2026 04:21
@raonPsm raonPsm self-assigned this Mar 11, 2026
@raonPsm raonPsm added 미완성🛠️ 스프린트 미션 제출일이지만 미완성했습니다. 죄송합니다. and removed 미완성🛠️ 스프린트 미션 제출일이지만 미완성했습니다. 죄송합니다. labels Mar 11, 2026
@byungwook-min
Copy link
Copy Markdown
Collaborator

안녕하세요! 미션 진행하시느라 고생 많으셨습니다.

현재 sprint5, sprint 6 PR 모두 conflict 해결이 필요해 보입니다. 이전 미션에서 conflict로 인해 머지를 완료하지 못한 상태로 계속 진행하시다 보니, 이번 미션에서 순수하게 작업하신 변경 사항이 명확하게 드러나지 않는 것 같습니다.

conflict를 해결해 주시고, PR을 한 번 정리해 주시면 감사하겠습니다.

Copy link
Copy Markdown
Collaborator

@byungwook-min byungwook-min 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의 핵심 개념들(영속성 컨텍스트, 변경 감지, 영속성 전이, 지연 로딩)을 왜 그렇게 동작하는지 스스로 정리하며 학습한 흔적이 곳곳에 보입니다. 이 부분이 가장 인상적이었습니다.

DDL과 엔티티 매핑의 완성도가 높습니다. schema.sql과 JPA 엔티티의 매핑이 ERD/클래스 다이어그램과 정확히 일치하고, cascade와 orphanRemoval을 적재적소에 활용하고 있습니다. 특히 BaseEntityBaseUpdatableEntity 상속 구조와 @MappedSuperclass + @EntityListeners(AuditingEntityListener.class) 조합이 교과서적으로 잘 구현되어 있습니다.

DTO와 MapStruct 활용이 뛰어납니다. Entity를 절대 Controller 밖으로 노출하지 않고, Record 기반 DTO로 깔끔하게 분리했습니다. MapStruct의 uses, expression, abstract class 패턴 등 다양한 기법을 적절히 활용하고 있어, 보일러플레이트 코드 없이 매핑 로직이 간결합니다.

N+1 문제 식별과 대응이 적절합니다. LEFT JOIN FETCH@BatchSize를 상황에 맞게 사용하고 있고, OSIV=false 환경에서 트랜잭션 범위 내 DTO 변환을 수행하여 LazyInitializationException을 방지하고 있습니다.

눈에 띄는 개선 포인트는 ChannelMapper에서의 N+1 문제입니다. 현재 ChannelMapper.toDto()에서 채널마다 messageRepositoryreadStatusRepository를 호출하고 있는데, 이것은 Mapper 계층이 가져야 할 책임을 넘어서는 것이기도 하고, 채널 목록 조회 시 채널 수만큼 추가 쿼리가 발생하는 성능 문제이기도 합니다. 이 로직을 Service 계층으로 이동시키고, batch 조회 후 매핑하는 방식으로 리팩토링하면 좋겠습니다.

전반적으로 요구사항을 충실히 이행하면서도, 원리를 이해하려는 노력이 돋보이는 것 같습니다. 고생하셨습니다!

Comment on lines +5 to +19
-- 기존 테이블 삭제 (초기화용)
-- 1. 가장 하위 자식 테이블 (다른 테이블들을 참조함)
DROP TABLE IF EXISTS message_attachments;
DROP TABLE IF EXISTS read_statuses;

-- 2. 중간 계층 테이블 (상위 부모를 참조함)
DROP TABLE IF EXISTS messages;
DROP TABLE IF EXISTS user_statuses;

-- 3. 최상위 부모 테이블 (참조를 당하는 입장이므로 가장 마지막에 삭제)
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS channels;
DROP TABLE IF EXISTS binary_contents;

--
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에서 매번 모든 테이블을 DROP하고 다시 CREATE하고 있는데요, application.yaml에서 sql.init.mode: always로 설정되어 있어서 애플리케이션을 재시작할 때마다 기존 데이터가 전부 날아갑니다. 개발 단계에서는 편할 수 있지만, 이 설정이 그대로 적용되면 문제가 될 수 있습니다.

개발 환경에서는 CREATE TABLE IF NOT EXISTS를 사용하거나, ddl-auto: validate와 함께 sql.init.mode: embedded로 변경하는 것을 고려해보세요. 아니면 프로파일을 분리해서 application-dev.yaml에서만 always를 사용하는 방법도 있습니다.

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.

테이블 삭제 순서를 외래키 의존 관계에 따라 하위 → 상위로 정리한 것, CHECK 제약조건으로 ChannelType을 검증하는 것, ON DELETE CASCADEON DELETE SET NULL을 ERD에 맞게 적용한 것 등 DDL 작성이 꼼꼼합니다.

Comment on lines +6 to +13
datasource:
url: jdbc:postgresql://localhost:5432/discodeit # PostgreSQL DB에 접속하기 위한 JDBC URL
# jdbc:postgresql: -> PostgreSQL JDBC 드라이버 사용
# localhost -> DB 서버 주소
# 5432 -> PostgreSQL의 기본 포트 번호
# discodeit -> 접속할 데이터베이스 이름
username: discodeit_user # DB 접속 계정명
password: discodeit1234 # 비밀번호 FIXME: ${DB_PASSWORD} + .env 파일로 관리
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.

환경변수(${DB_PASSWORD})나 .env 파일로 분리하면 좋을 것 같습니다.

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.

@MappedSuperclass + @EntityListeners(AuditingEntityListener.class) + @CreatedDate 조합이 정확합니다. @GeneratedValue(strategy = GenerationType.UUID)로 UUID 자동 생성하는 것도 적절하고, @Column(updatable = false, nullable = false)로 id와 createdAt이 변경되지 않도록 보호한 것도 좋습니다. @NoArgsConstructor(access = AccessLevel.PROTECTED)로 직접 인스턴스화를 방지한 것도 좋은 것 같습니다.

Comment on lines +40 to +45
// 현재 '온라인' 상태인지 확인 (5분 이내 활동 시 true)
// TODO: (Later) 온라인 / 자리 비움 / 방해 금지 / 오프라인 표시 / 오프라인 -> Enum 으로 상태 구현
public Boolean isOnline() {
Instant instantFiveMinutesAgo = Instant.now().minus(Duration.ofMinutes(5));
return lastActiveAt.isAfter(instantFiveMinutesAgo);
}
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.

엔티티 안에 비즈니스 로직을 두는 것은 도메인 주도 설계 관점에서 좋은 선택인 것 같습니다. 다만 Boolean(wrapper)이 아닌 boolean(primitive)을 반환하는 것이 더 적절합니다.

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의 CONSTRAINT uk_user_channel UNIQUE (user_id, channel_id)와 일치하도록 JPA 엔티티에도 복합 유니크 제약을 명시한 것이 좋습니다. name까지 지정하여 DDL과 동일하게 맞춘 것도 좋은 것 같습니다.

Comment on lines +24 to +26
@Autowired protected MessageRepository messageRepository;
@Autowired protected ReadStatusRepository readStatusRepository;
@Autowired protected UserMapper userMapper;
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.

Mapper는 단순히 객체 간 변환을 담당하는 계층인데, 여기서 Repository를 직접 주입받아 DB 쿼리를 실행하고 있습니다.

이는 레이어 아키텍처 위반하여 Mapper가 데이터 접근 계층에 직접 의존하고 toDtoList() 호출 시 채널 수만큼 findTopByChannel_IdfindAllByChannel_Id 쿼리가 추가 발생하는 N+1 문제가 발생 가능합니다.

또한, Mapper에서 발생하는 DB 조회가 Service의 트랜잭션 범위 밖에서 실행될 수 있고 Mapper를 단위 테스트하려면 Repository를 mock해야 하므로 지양하는 것이 좋습니다.

BasicChannelService에서 처리하고, Mapper는 이미 조회된 데이터만 변환하는 역할로 제한하는 것이 좋습니다.

Comment on lines +102 to +105
if (user.getUserStatus() == null) {
throw new IllegalStateException("유저 상태 데이터가 누락되었습니다. (userId: " + 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.

IllegalStateException을 사용한 것은 좋은데, GlobalExceptionHandler에 이 예외에 대한 핸들러가 없습니다. 현재는 handleGeneralException(Exception e)에서 500으로 처리되겠지만, 명시적으로 IllegalStateException 핸들러를 추가하거나, 이 검증이 꼭 필요한지 재고해보면 좋을 것 같습니다. User 생성 시 항상 UserStatus를 함께 만들고 있으므로, 정상적인 흐름에서는 null이 될 수 없습니다.

Comment on lines +71 to +76
// 유효성 검증 - 유저 존재 여부 검증
for (UUID userId : requestedUserIds) {
if (!userRepository.existsById(userId)) {
throw new NoSuchElementException("유저가 존재하지 않습니다. id: " + 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 쿼리가 개별적으로 실행됩니다. 대신 findAllById(requestedUserIds)로 한 번에 조회한 후, 반환된 리스트의 크기와 요청된 ID 수를 비교하는 방식이 더 효율적입니다. 이미userRepository.findAllById(requestedUserIds)를 호출하고 있으므로, 검증과 조회를 합칠 수 있습니다.

Comment on lines +143 to +144
messageRepository.deleteAllByChannel_Id(channelId);
readStatusRepository.deleteAllByChannel_Id(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.

schema.sql에서 이미 ON DELETE CASCADE가 설정되어 있으므로, DB 레벨에서 채널 삭제 시 관련 messages와 read_statuses가 자동 삭제됩니다.

JPA 레벨에서도 명시적으로 삭제하면 불필요한 쿼리가 추가로 발생할 수 있습니다. 다만 JPA의 영속성 컨텍스트와 DB 간 일관성을 보장하기 위해 명시적으로 삭제하는 접근도 유효하므로, 의도적인 선택이라면 괜찮습니다.

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