Skip to content

Conversation

@DDINGJOO
Copy link
Owner

목적

변경 요약

핵심 기능

  1. 문의 엔티티 및 도메인 모델

    • Inquiry 엔티티: 문의 정보 관리 (제목, 내용, 카테고리, 상태, 작성자)
    • Answer 엔티티: 1:1 답변 관리 (양방향 관계)
    • InquiryCategory enum: FAQ 카테고리와 통일 (전체, 예약, 이용/입실, 요금/결제, 리뷰/신고, 기타)
    • InquiryStatus enum: 문의 처리 상태 (UNANSWERED, ANSWERED, CONFIRMED)
    • InquiryFile: 이미지 정보 임베디드 엔티티 (Kafka 연동 대비)
  2. RESTful API 설계

    • 문의 등록/조회/삭제 API
    • 답변 등록/삭제/확인 API
    • 작성자별, 카테고리별, 상태별 필터링
    • 리소스 중심의 직관적인 엔드포인트 설계
  3. 비즈니스 로직 캡슐화

    • 엔티티 내부에 연관관계 편의 메소드 구현
    • 상태 변경 로직을 도메인 모델에서 관리
    • 서비스 레이어는 엔티티 메소드 호출로 간소화
  4. 예외 처리 체계

    • Report 도메인과 동일한 ErrorCode enum + CustomException 패턴 적용
    • HTTP 상태 코드와 도메인 에러 코드 체계적 매핑
    • 향후 공통 모듈 분리를 대비한 일관된 구조

주요 파일/모듈

  • Entity: Inquiry, Answer, InquiryCategory, InquiryStatus, InquiryFile
  • Repository: InquiryRepository, AnswerRepository
  • Service: InquiryService, InquiryServiceImpl
  • Controller: InquiryController
  • DTO: InquiryCreateRequest, AnswerCreateRequest, InquiryResponse, AnswerResponse
  • Exception: InquiryException, ErrorCode
  • Test: 51개 테스트 (엔티티 22개, 리포지토리 29개)

아키텍처 설계 결정사항

1. 단일 엔티티 vs 상속 구조

결정: 단일 Inquiry 엔티티 채택

초기 설계 시 문의 타입별로 ProfileInquiry, PaymentInquiry 등으로 상속 구조를 고려했으나, 다음과 같은 이유로 단일 엔티티를 선택했습니다:

단일 엔티티를 선택한 이유:

  • 일관성: 모든 문의 타입이 동일한 핵심 속성을 가짐 (제목, 내용, 카테고리, 상태)
  • 단순성: 테이블 구조가 단순하여 조회 성능 최적화 용이
  • 확장성: 새로운 카테고리 추가 시 ENUM만 수정하면 되어 유지보수 편리
  • 통합 조회: 전체 문의 목록 조회 시 단일 테이블 스캔으로 처리 가능
  • 기존 패턴 준수: Report 도메인도 동일한 설계 패턴 사용 (referenceType으로 타입 구분)
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private InquiryCategory category; // 카테고리로 타입 구분

2. 양방향 관계 설정 이유

Inquiry ↔ Answer 양방향 OneToOne 관계 채택

단방향 관계도 가능했지만, 다음과 같은 이유로 양방향 관계를 선택했습니다:

양방향 관계를 선택한 이유:

  • 객체 그래프 탐색: Inquiry에서 Answer로, Answer에서 Inquiry로 자연스럽게 접근 가능
  • 상태 동기화: 답변 등록/삭제 시 문의 상태를 자동으로 변경하기 위함
  • 비즈니스 규칙 강제: Inquiry 엔티티에서 답변 존재 여부를 직접 확인하여 중복 답변 방지
  • Cascade 활용: 문의 삭제 시 답변도 자동 삭제 (orphanRemoval = true)
// Inquiry 엔티티
@OneToOne(mappedBy = "inquiry", fetch = FetchType.LAZY, 
          cascade = CascadeType.ALL, orphanRemoval = true)
private Answer answer;

public void addAnswer(Answer answer) {
    if (this.answer != null) {
        throw new IllegalStateException("이미 답변이 등록된 문의입니다.");
    }
    this.answer = answer;
    this.status = InquiryStatus.ANSWERED;
    this.answeredAt = LocalDateTime.now();
}

3. 연관관계 편의 메소드 패턴

엔티티 내부에 비즈니스 로직 캡슐화

Report 도메인의 설계 패턴을 참고하여, 상태 변경 로직을 엔티티 내부에 구현했습니다:

이 패턴을 선택한 이유:

  • 단일 책임 원칙: 상태 변경과 관련된 비즈니스 규칙을 Inquiry 엔티티가 담당
  • 불변성 보장: 외부에서 상태를 직접 변경하지 못하도록 제어
  • 일관성 보장: 답변 등록 시 상태 변경, 타임스탬프 갱신을 원자적으로 처리
  • 테스트 용이성: 엔티티 단위 테스트로 비즈니스 로직 검증 가능
public void confirmAnswer() {
    if (this.status != InquiryStatus.ANSWERED) {
        throw new IllegalStateException("답변완료 상태에서만 확인할 수 있습니다.");
    }
    this.status = InquiryStatus.CONFIRMED;
    this.confirmedAt = LocalDateTime.now();
}

4. DTO 이중 팩토리 메소드 패턴

InquiryResponse에 두 가지 변환 메소드 제공

// 답변 포함 (상세 조회용)
public static InquiryResponse from(Inquiry inquiry)

// 답변 제외 (목록 조회용)
public static InquiryResponse fromWithoutAnswer(Inquiry inquiry)

이 패턴을 선택한 이유:

  • 성능 최적화: 목록 조회 시 불필요한 Answer 조회를 방지 (Lazy Loading)
  • 응답 크기 최소화: 목록 조회는 기본 정보만 포함하여 네트워크 대역폭 절약
  • 명확한 의도 표현: 메소드 이름으로 어떤 데이터를 포함할지 명확히 표현

5. 예외 처리 일관성 유지

Report 도메인과 동일한 예외 처리 패턴 적용

@Getter
public enum ErrorCode {
    INQUIRY_NOT_FOUND("INQUIRY_NOT_FOUND", "Inquiry Not Found", HttpStatus.NOT_FOUND),
    ANSWER_ALREADY_EXISTS("ANSWER_ALREADY_EXISTS", "Answer Already Exists", HttpStatus.CONFLICT),
    UNAUTHORIZED_ACCESS("UNAUTHORIZED_ACCESS", "Unauthorized Access", HttpStatus.FORBIDDEN),
    // ...
}

이 패턴을 선택한 이유:

  • 코드베이스 일관성: Report 도메인과 동일한 구조로 학습 곡선 최소화
  • 공통 모듈 분리 준비: 향후 ErrorCode를 공통 모듈로 분리 시 패턴이 동일하여 작업 용이
  • HTTP 상태 코드 매핑: ErrorCode에서 HTTP 상태 코드를 관리하여 일관된 API 응답 보장
  • 디버깅 편의성: 에러 코드, 메시지, HTTP 상태를 한곳에서 관리하여 추적 용이
// 서비스 레이어에서 엔티티 예외를 도메인 예외로 변환
try {
    inquiry.addAnswer(answer);
} catch (IllegalStateException e) {
    throw new InquiryException(ErrorCode.ANSWER_ALREADY_EXISTS);
}

6. 카테고리 통일 전략

InquiryCategory를 FAQ 카테고리와 동일하게 설계

public enum InquiryCategory {
    ALL("전체"),
    RESERVATION("예약 관련"),
    CHECK_IN("이용/입실"),
    PAYMENT("요금/결제"),
    REVIEW_REPORT("리뷰/신고"),
    ETC("기타");
}

이 전략을 선택한 이유:

  • 사용자 경험 일관성: FAQ와 1:1 문의의 카테고리가 동일하여 사용자 혼란 방지
  • 데이터 정합성: 통계 집계 시 FAQ와 문의를 카테고리별로 통합 분석 가능
  • 유지보수 편의성: 카테고리 변경 시 FAQ와 Inquiry를 동시에 수정하여 일관성 유지
  • 비즈니스 로직 간소화: 카테고리 변환 로직 불필요

API 명세

1. 문의 생성 (POST /api/v1/inquiries)

Request Body:

{
  "title": "예약 취소 문의",
  "contents": "예약 취소는 어떻게 하나요?",
  "category": "RESERVATION",
  "writerId": "USER-001"
}

Validation:

  • title: 필수 (NotBlank), 최대 200자
  • contents: 필수 (NotBlank), 최대 500자
  • category: 필수 (NotNull)
  • writerId: 필수 (NotBlank)

Response (201 Created):

{
  "id": "uuid-generated",
  "title": "예약 취소 문의",
  "contents": "예약 취소는 어떻게 하나요?",
  "category": "RESERVATION",
  "status": "UNANSWERED",
  "writerId": "USER-001",
  "createdAt": "2025-10-17T10:00:00",
  "files": []
}

2. 문의 상세 조회 (GET /api/v1/inquiries/{inquiryId})

Response (200 OK):

{
  "id": "uuid",
  "title": "예약 취소 문의",
  "contents": "예약 취소는 어떻게 하나요?",
  "category": "RESERVATION",
  "status": "ANSWERED",
  "writerId": "USER-001",
  "createdAt": "2025-10-17T10:00:00",
  "answer": {
    "id": "answer-uuid",
    "contents": "예약 취소는 마이페이지에서 가능합니다.",
    "writerId": "ADMIN-001",
    "createdAt": "2025-10-17T11:00:00"
  }
}

3. 문의 목록 조회 (GET /api/v1/inquiries)

Query Parameters:

  • writerId: 작성자 ID (선택)
  • category: 카테고리 (선택, ENUM)
  • status: 상태 (선택, ENUM)

조합 가능한 필터링:

  • 전체 조회: 파라미터 없음
  • 작성자별: ?writerId=USER-001
  • 카테고리별: ?category=RESERVATION
  • 상태별: ?status=UNANSWERED
  • 작성자 + 상태: ?writerId=USER-001&status=UNANSWERED

Response (200 OK):

[
  {
    "id": "uuid",
    "title": "예약 취소 문의",
    "category": "RESERVATION",
    "status": "ANSWERED",
    "writerId": "USER-001",
    "createdAt": "2025-10-17T10:00:00"
  }
]

설계 결정: 목록 조회는 fromWithoutAnswer() 사용하여 답변 제외

4. 답변 생성 (POST /api/v1/inquiries/answers)

Request Body:

{
  "inquiryId": "uuid",
  "contents": "예약 취소는 마이페이지에서 가능합니다.",
  "writerId": "ADMIN-001"
}

Response (201 Created):

{
  "id": "answer-uuid",
  "contents": "예약 취소는 마이페이지에서 가능합니다.",
  "writerId": "ADMIN-001",
  "createdAt": "2025-10-17T11:00:00"
}

부가 효과: Inquiry 상태가 UNANSWERED → ANSWERED로 자동 변경

5. 답변 확인 (PATCH /api/v1/inquiries/{inquiryId}/confirm)

Request Param: writerId (본인 확인용)

Response (200 OK)

부가 효과: Inquiry 상태가 ANSWERED → CONFIRMED로 변경

6. 문의 삭제 (DELETE /api/v1/inquiries/{inquiryId})

Request Param: writerId (본인 확인용)

Response (204 No Content)

부가 효과: Answer도 Cascade로 함께 삭제

7. 답변 삭제 (DELETE /api/v1/inquiries/{inquiryId}/answer)

Response (204 No Content)

부가 효과: Inquiry 상태가 UNANSWERED로 되돌아감

수용 기준 검증

#10 1:1 문의 등록 기능 - AC 충족

  • 사용자는 문의 분야(카테고리)를 선택할 수 있다
  • 사용자는 문의 제목을 입력할 수 있다 (최대 200자)
  • 사용자는 문의 내용을 입력할 수 있다 (최대 500자)
  • 문의 등록 시 적절한 응답을 반환한다 (201 Created)
  • 이미지 첨부를 위한 InquiryFile 구조 준비 완료 (Kafka 연동 대비)

#11 1:1 문의 조회 및 관리 - AC 충족

  • 사용자는 자신의 문의 목록을 조회할 수 있다
  • 사용자는 문의 상세 정보를 조회할 수 있다 (답변 포함)
  • 사용자는 카테고리별로 문의를 필터링할 수 있다
  • 사용자는 상태별로 문의를 조회할 수 있다 (미확인, 답변완료, 확인완료)
  • 사용자는 답변을 확인 처리할 수 있다
  • 사용자는 자신의 문의를 삭제할 수 있다 (본인 확인)
  • 관리자는 문의에 답변을 등록/삭제할 수 있다

추가 기능 요구사항

  • 상태 자동 관리: 답변 등록 시 ANSWERED, 확인 시 CONFIRMED
  • 본인 확인: 문의 삭제 및 답변 확인 시 작성자 검증
  • 중복 답변 방지: 이미 답변이 있는 문의에 답변 재등록 차단
  • Cascade 삭제: 문의 삭제 시 답변도 자동 삭제

테스트 커버리지

  • 엔티티 테스트: 22개

    • InquiryEntityTest: 17개 (생성, 상태 변경, 답변 관리, 검증)
    • AnswerEntityTest: 5개 (생성, 관계, 타임스탬프)
  • 리포지토리 테스트: 29개

    • InquiryRepositoryTest: 14개 (CRUD, 작성자별/카테고리별/상태별 조회)
    • AnswerRepositoryTest: 15개 (CRUD, 문의 관계, 존재 여부 확인)
  • 전체 테스트: 51개 모두 통과

브레이킹/마이그레이션

  • Breaking Change 없음: 신규 기능 추가
  • 데이터베이스 변경: inquiry, answer 테이블 생성
  • 스키마:
    • inquiry: id(PK), title, contents, category, status, writer_id, answered_at, confirmed_at, created_at, updated_at
    • answer: id(PK), inquiry_id(FK), contents, writer_id, created_at, updated_at
    • inquiry_file: 임베디드 (image_id, image_url, file_name)

테스트

단위 테스트

# 엔티티 테스트
./gradlew test --tests "com.teambind.supportserver.inquiries.entity.*"

# 리포지토리 테스트
./gradlew test --tests "com.teambind.supportserver.inquiries.repository.*"

수동 검증 방법

# 1. 빌드 및 테스트
cd SupportServer
./gradlew clean build

# 2. 서버 실행
./gradlew bootRun

# 3. 문의 생성
curl -X POST http://localhost:8080/api/v1/inquiries \
  -H "Content-Type: application/json" \
  -d '{
    "title": "예약 취소 문의",
    "contents": "예약 취소는 어떻게 하나요?",
    "category": "RESERVATION",
    "writerId": "USER-001"
  }'

# 4. 문의 목록 조회 (필터링)
curl -X GET "http://localhost:8080/api/v1/inquiries?writerId=USER-001&status=UNANSWERED"

# 5. 답변 등록
curl -X POST http://localhost:8080/api/v1/inquiries/answers \
  -H "Content-Type: application/json" \
  -d '{
    "inquiryId": "inquiry-uuid",
    "contents": "예약 취소는 마이페이지에서 가능합니다.",
    "writerId": "ADMIN-001"
  }'

# 6. 답변 확인
curl -X PATCH "http://localhost:8080/api/v1/inquiries/{inquiryId}/confirm?writerId=USER-001"

성능 고려사항

1. Lazy Loading 전략

  • Answer는 FetchType.LAZY로 설정하여 목록 조회 시 N+1 문제 방지
  • 상세 조회 시에만 Answer를 함께 로드

2. 인덱스 전략

-- 조회 성능 최적화를 위한 인덱스
CREATE INDEX idx_inquiry_writer_id ON inquiry(writer_id);
CREATE INDEX idx_inquiry_category ON inquiry(category);
CREATE INDEX idx_inquiry_status ON inquiry(status);
CREATE INDEX idx_inquiry_writer_status ON inquiry(writer_id, status);

3. DTO 최적화

  • 목록 조회: fromWithoutAnswer() 사용하여 불필요한 데이터 제외
  • 응답 크기 최소화로 네트워크 대역폭 절약

향후 작업 (Phase 2)

본 PR에서는 1:1 문의의 핵심 기능을 구현했으며, 다음 기능은 Phase 2에서 구현 예정입니다:

이미지 서버 연동 (#12)

현재 상태: InquiryFile 엔티티는 구현되어 있으나, Kafka 연동 전까지는 빈 리스트로 관리

알림 기능

  • 답변 등록 시 문의 작성자에게 알림 발송
  • 답변 확인 시 관리자에게 알림 발송
  • Kafka 이벤트 발행을 통한 비동기 알림 처리

조회 기능 개선

  • 페이징 처리 (Cursor 기반 또는 Offset 기반)
  • 정렬 기능 (등록일, 상태 변경일)
  • 전문 검색 (제목, 내용)

통계 기능

  • 카테고리별 문의 통계
  • 답변 처리 시간 분석
  • 미답변 문의 모니터링

관리자 기능

  • 답변 수정 기능
  • 문의 상태 일괄 변경
  • 답변 템플릿 관리

참조

SupportServer/src/main/java/com/teambind/supportserver/inquiries/entity/Inquiry.java: 문의 엔티티 추가 (제목, 내용, 카테고리, 상태, 작성자, 첨부파일)
SupportServer/src/main/java/com/teambind/supportserver/inquiries/entity/Answer.java: 답변 엔티티 추가 (1:1 관계, 작성자, 내용)
SupportServer/src/main/java/com/teambind/supportserver/inquiries/entity/InquiryFile.java: 첨부파일 임베디드 타입 (이미지 서버 요구사항 반영)
SupportServer/src/main/java/com/teambind/supportserver/inquiries/entity/InquiryCategory.java: 문의 카테고리 Enum (결제, 예약, 체크인/체크아웃, 리뷰 신고, 기타, 전체)
SupportServer/src/main/java/com/teambind/supportserver/inquiries/entity/InquiryStatus.java: 문의 상태 Enum (미확인, 답변완료, 확인완료)
SupportServer/src/main/java/com/teambind/supportserver/inquiries/repository/InquiryRepository.java: 문의 조회 메서드 (작성자, 카테고리, 상태별)
SupportServer/src/main/java/com/teambind/supportserver/inquiries/repository/AnswerRepository.java: 답변 조회 메서드 (문의ID, 작성자 존재 확인)
SupportServer/src/test/java/com/teambind/supportserver/inquiries/entity/InquiryEntityTest.java: Inquiry 엔티티 테스트 8개 추가
SupportServer/src/test/java/com/teambind/supportserver/inquiries/entity/AnswerEntityTest.java: Answer 엔티티 테스트 5개 추가
SupportServer/src/test/java/com/teambind/supportserver/inquiries/repository/InquiryRepositoryTest.java: InquiryRepository 통합 테스트 13개 추가
SupportServer/src/test/java/com/teambind/supportserver/inquiries/repository/AnswerRepositoryTest.java: AnswerRepository 통합 테스트 14개 추가

참고: JPA 엔티티 설계 및 기본 CRUD 리포지토리 구현
SupportServer/src/main/java/com/teambind/supportserver/inquiries/exceptions/ErrorCode.java: 문의 도메인 에러 코드 Enum (문의 미존재, 답변 미존재, 권한 없음, 이미 답변됨, 파일 개수 초과)
SupportServer/src/main/java/com/teambind/supportserver/inquiries/exceptions/InquiryException.java: 문의 비즈니스 예외 클래스

참고: 문의 도메인 전용 예외 처리 체계 구축
SupportServer/src/main/java/com/teambind/supportserver/inquiries/dto/request/InquiryCreateRequest.java: 문의 생성 요청 DTO (제목, 내용, 카테고리, 작성자ID, 첨부파일 목록, 유효성 검사)
SupportServer/src/main/java/com/teambind/supportserver/inquiries/dto/request/AnswerCreateRequest.java: 답변 생성 요청 DTO (답변 내용, 작성자ID, 유효성 검사)

참고: @notblank, @NotNull, @SiZe 애노테이션으로 입력값 검증
SupportServer/src/main/java/com/teambind/supportserver/inquiries/dto/response/InquiryResponse.java: 문의 조회 응답 DTO (문의 정보, 답변 정보, 첨부파일, 엔티티 변환 메서드)
SupportServer/src/main/java/com/teambind/supportserver/inquiries/dto/response/AnswerResponse.java: 답변 조회 응답 DTO (답변 내용, 작성자ID, 생성일시)

참고: 엔티티를 클라이언트 친화적인 형태로 변환
SupportServer/src/main/java/com/teambind/supportserver/inquiries/service/InquiryService.java: 문의 비즈니스 로직 인터페이스 (문의 생성, 조회, 답변 작성, 확인 처리, 작성자별 조회)

참고: 비즈니스 계층 추상화 및 구현체 분리
SupportServer/src/main/java/com/teambind/supportserver/inquiries/service/InquiryServiceImpl.java: 문의 비즈니스 로직 구현 (문의 생성, 단건 조회, 목록 조회, 답변 작성, 확인 처리, 트랜잭션 관리)

참고: Repository 계층과 연동하여 비즈니스 규칙 적용
SupportServer/src/main/java/com/teambind/supportserver/inquiries/controller/InquiryController.java: 문의 관리 REST API 추가
- POST /api/v1/inquiries: 문의 등록
- GET /api/v1/inquiries/{inquiryId}: 문의 상세 조회
- GET /api/v1/inquiries: 작성자별 문의 목록 조회
- GET /api/v1/inquiries/category/{category}: 카테고리별 문의 목록 조회
- POST /api/v1/inquiries/{inquiryId}/answers: 답변 등록
- PATCH /api/v1/inquiries/{inquiryId}/confirm: 문의 확인 처리

참고: RESTful 원칙에 따라 리소스 중심 URL 설계
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants