Repository: Kernel360/KDEV4-SODA-BE
Pull request #22: [Feature] 게시글 생성/수정/삭제/조회 API (파일 첨부 기능 제외)
Author: login: seoyeon-jung
Created: 2025-03-25T09:13:46Z | Merged: 2025-03-26T02:41:50Z


# 요약
프로젝트 내부 게시판의 게시글 관련 API를 개발했습니다.
파일 첨부 기능은 추후 추가할 예정입니다.


# 연관 이슈
#10 


# 작성한 코드
- 게시글  관련 CRUD 코드를 구현했습니다.


# 확인해야 할 부분들
### 1. DTO 관련 코드
```java
 @JsonInclude(JsonInclude.Include.NON_NULL)
public class ArticleDTO { }
```
- 여기서 저 어노테이션은 JSON 데이터 형태에서 null 값을 포함시키지 않는 어노테이션인데, 게시글 생성 시 선택 사항이 많아 추가해주었습니다.
### 2. 생성 API 개발 시 발생했던 트러블 슈팅
- 양방향 매핑을 제대로 이해하지 못해서 생겼던 오류입니다.
- 트러블 슈팅 해결글 : https://daydream-sy.tistory.com/392
### 3. 구현하지 않은 File 관련 기능
- 댓글 CRUD 구현 후 MultiPart 포함해서 수정할 예정입니다.


Diff:
diff --git a/src/main/java/com/soda/article/controller/ArticleController.java b/src/main/java/com/soda/article/controller/ArticleController.java
new file mode 100644
index 00000000..6d6cb54b
--- /dev/null
+++ b/src/main/java/com/soda/article/controller/ArticleController.java
@@ -0,0 +1,58 @@
+package com.soda.article.controller;
+
+import com.soda.article.domain.ArticleViewResponse;
+import com.soda.article.domain.ArticleModifyRequest;
+import com.soda.article.domain.ArticleModifyResponse;
+import com.soda.article.service.ArticleService;
+import com.soda.global.response.ApiResponseForm;
+import com.soda.global.security.auth.UserDetailsImpl;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/projects")
+@RequiredArgsConstructor
+public class ArticleController {
+
+    private final ArticleService articleService;
+
+    @PostMapping("/{projectId}/articles")
+    public ResponseEntity<ApiResponseForm<ArticleModifyResponse>> createArticle(@PathVariable Long projectId, @RequestBody ArticleModifyRequest request,
+                                                                                @AuthenticationPrincipal UserDetailsImpl userDetails) {
+        ArticleModifyResponse response = articleService.createArticle(projectId,request, userDetails);
+        return ResponseEntity.ok(ApiResponseForm.success(response, "게시글 생성 성공"));
+    }
+
+    @GetMapping("/{projectId}/articles")
+    public ResponseEntity<ApiResponseForm<List<ArticleViewResponse>>> getAllArticles(@AuthenticationPrincipal UserDetailsImpl userDetails,
+                                                                                     @PathVariable Long projectId) {
+        List<ArticleViewResponse> response = articleService.getAllArticles(userDetails, projectId);
+        return ResponseEntity.ok(ApiResponseForm.success(response));
+    }
+
+    @GetMapping("/{projectId}/articles/{articleId}")
+    public ResponseEntity<ApiResponseForm<ArticleViewResponse>> getArticle(@PathVariable Long projectId, @AuthenticationPrincipal UserDetailsImpl userDetails,
+                                                                           @PathVariable Long articleId) {
+        ArticleViewResponse response = articleService.getArticle(projectId, userDetails, articleId);
+        return ResponseEntity.ok(ApiResponseForm.success(response));
+    }
+
+    @DeleteMapping("/{projectId}/articles/{articleId}")
+    public ResponseEntity<Void> deleteArticle(@PathVariable Long projectId, @AuthenticationPrincipal UserDetailsImpl userDetails,
+                                              @PathVariable Long articleId) {
+        articleService.deleteArticle(projectId, userDetails, articleId);
+        return ResponseEntity.noContent().build();
+    }
+
+    @PutMapping("/{projectId}/articles/{articleId}")
+    public ResponseEntity<ApiResponseForm<ArticleModifyResponse>> updateArticle(@PathVariable Long projectId, @AuthenticationPrincipal UserDetailsImpl userDetails,
+                                           @PathVariable Long articleId, @RequestBody ArticleModifyRequest request) {
+        ArticleModifyResponse response = articleService.updateArticle(projectId, userDetails, articleId, request);
+        return ResponseEntity.ok(ApiResponseForm.success(response, "Article 수정 성공"));
+    }
+
+}
diff --git a/src/main/java/com/soda/article/domain/ArticleDTO.java b/src/main/java/com/soda/article/domain/ArticleDTO.java
new file mode 100644
index 00000000..d5e7340c
--- /dev/null
+++ b/src/main/java/com/soda/article/domain/ArticleDTO.java
@@ -0,0 +1,69 @@
+package com.soda.article.domain;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.soda.article.enums.ArticleStatus;
+import com.soda.article.enums.PriorityType;
+import jakarta.validation.constraints.NotNull;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ArticleDTO {
+    private Long id;
+
+    @NotNull(message = "Title is required")
+    private String title;
+
+    @NotNull(message = "Content is required")
+    private String content;
+
+    @NotNull(message = "Stage Id is required")
+    private Long stageId;
+
+    private PriorityType priority;
+    private LocalDateTime deadLine;
+    private ArticleStatus status; // article 생성 시 default PENDING
+
+    private Long parentArticleId;
+    private List<FileDTO> fileList;
+    private List<LinkDTO> linkList;
+    private List<CommentDTO> commentList;
+
+    @Getter
+    @AllArgsConstructor
+    @NoArgsConstructor
+    public static class FileDTO {
+        private String name;
+        private String url;
+    }
+
+    @Getter
+    @Builder
+    @AllArgsConstructor
+    @NoArgsConstructor
+    public static class LinkDTO {
+        private String urlAddress;
+        private String urlDescription;
+    }
+
+    @Getter
+    @Builder
+    @AllArgsConstructor
+    @NoArgsConstructor
+    public static class CommentDTO {
+        private Long id;
+        private String content;
+        private Long memberId;
+        private LocalDateTime createdDate;
+    }
+
+}
diff --git a/src/main/java/com/soda/article/domain/ArticleFileDTO.java b/src/main/java/com/soda/article/domain/ArticleFileDTO.java
new file mode 100644
index 00000000..1733be7a
--- /dev/null
+++ b/src/main/java/com/soda/article/domain/ArticleFileDTO.java
@@ -0,0 +1,15 @@
+package com.soda.article.domain;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ArticleFileDTO {
+    private String name;
+    private String url;
+}
diff --git a/src/main/java/com/soda/article/domain/ArticleLinkDTO.java b/src/main/java/com/soda/article/domain/ArticleLinkDTO.java
new file mode 100644
index 00000000..1ea04b18
--- /dev/null
+++ b/src/main/java/com/soda/article/domain/ArticleLinkDTO.java
@@ -0,0 +1,16 @@
+package com.soda.article.domain;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+public class ArticleLinkDTO {
+
+    private String urlAddress;
+    private String urlDescription;
+}
diff --git a/src/main/java/com/soda/article/domain/ArticleModifyRequest.java b/src/main/java/com/soda/article/domain/ArticleModifyRequest.java
new file mode 100644
index 00000000..b0433ab3
--- /dev/null
+++ b/src/main/java/com/soda/article/domain/ArticleModifyRequest.java
@@ -0,0 +1,30 @@
+package com.soda.article.domain;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.soda.article.enums.ArticleStatus;
+import com.soda.article.enums.PriorityType;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ArticleModifyRequest {
+
+    private String title;
+    private String content;
+    private PriorityType priority;
+    private LocalDateTime deadLine;
+    private Long memberId;
+    private Long stageId;
+    private List<ArticleFileDTO> fileList;
+    private List<ArticleLinkDTO> linkList;
+
+}
diff --git a/src/main/java/com/soda/article/domain/ArticleModifyResponse.java b/src/main/java/com/soda/article/domain/ArticleModifyResponse.java
new file mode 100644
index 00000000..3944ff07
--- /dev/null
+++ b/src/main/java/com/soda/article/domain/ArticleModifyResponse.java
@@ -0,0 +1,28 @@
+package com.soda.article.domain;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.soda.article.enums.PriorityType;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ArticleModifyResponse {
+
+    private String title;
+    private String content;
+    private PriorityType priority;
+    private LocalDateTime deadLine;
+    private String memberName;
+    private Long stageId;
+    private List<ArticleFileDTO> fileList;
+    private List<ArticleLinkDTO> linkList;
+}
diff --git a/src/main/java/com/soda/article/domain/ArticleViewResponse.java b/src/main/java/com/soda/article/domain/ArticleViewResponse.java
new file mode 100644
index 00000000..783fcc29
--- /dev/null
+++ b/src/main/java/com/soda/article/domain/ArticleViewResponse.java
@@ -0,0 +1,28 @@
+package com.soda.article.domain;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.soda.article.enums.PriorityType;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ArticleViewResponse {
+    private String title;
+    private String content;
+    private PriorityType priority;
+    private LocalDateTime deadLine;
+    private String memberName;
+    private String stageName;
+    private List<ArticleFileDTO> fileList;
+    private List<ArticleLinkDTO> linkList;
+    private List<CommentDTO> commentList;
+}
diff --git a/src/main/java/com/soda/article/domain/CommentDTO.java b/src/main/java/com/soda/article/domain/CommentDTO.java
new file mode 100644
index 00000000..3ac1024b
--- /dev/null
+++ b/src/main/java/com/soda/article/domain/CommentDTO.java
@@ -0,0 +1,46 @@
+package com.soda.article.domain;
+
+import com.soda.article.entity.Comment;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@Getter
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+public class CommentDTO {
+    private Long id; // 댓글 ID
+    private String content; // 댓글 내용
+    private MemberDTO member; // 댓글 작성자
+    private Long articleId; // 게시글 ID
+    private Long parentCommentId; // 부모 댓글 ID (대댓글의 경우)
+    private List<CommentDTO> childComments; // 자식 댓글 리스트
+
+    @Getter
+    @Builder
+    public static class MemberDTO {
+        private Long id; // 회원 ID
+        private String name; // 회원 이름
+    }
+
+    // 엔티티를 DTO로 변환하는 메소드
+    public static CommentDTO fromEntity(Comment comment) {
+        return CommentDTO.builder()
+                .id(comment.getId())
+                .content(comment.getContent())
+                .member(MemberDTO.builder()
+                        .id(comment.getMember().getId())
+                        .name(comment.getMember().getName())
+                        .build())
+                .articleId(comment.getArticle().getId())
+                .parentCommentId(comment.getParentComment() != null ? comment.getParentComment().getId() : null)
+                .childComments(comment.getChildComments().stream()
+                        .map(CommentDTO::fromEntity)
+                        .collect(java.util.stream.Collectors.toList()))
+                .build();
+    }
+}
diff --git a/src/main/java/com/soda/article/entity/Article.java b/src/main/java/com/soda/article/entity/Article.java
index f1f40e6d..35b6e097 100644
--- a/src/main/java/com/soda/article/entity/Article.java
+++ b/src/main/java/com/soda/article/entity/Article.java
@@ -6,12 +6,16 @@
 import com.soda.member.entity.Member;
 import com.soda.project.entity.Stage;
 import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Builder;
 import lombok.Getter;
+import lombok.NoArgsConstructor;
 
 import java.time.LocalDateTime;
 import java.util.ArrayList;
 import java.util.List;
 
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
 @Getter
 @Entity
 public class Article extends BaseEntity {
@@ -59,4 +63,29 @@ public boolean isChildComment() {
         return parentArticle != null;
     }
 
+    @Builder
+    public Article(String title, String content, PriorityType priority, LocalDateTime deadline, Member member, Stage stage, ArticleStatus status,
+                   List<ArticleFile> articleFileList, List<ArticleLink> articleLinkList) {
+        this.title = title;
+        this.content = content;
+        this.priority = priority;
+        this.deadline = deadline;
+        this.member = member;
+        this.stage = stage;
+        this.status = status;
+        this.articleFileList = articleFileList != null ? articleFileList : new ArrayList<>();
+        this.articleLinkList = articleLinkList != null ? articleLinkList : new ArrayList<>();
+    }
+
+    public void delete() {
+        this.markAsDeleted();
+    }
+
+    public void updateArticle(String title, String content, PriorityType priority, LocalDateTime deadline) {
+        this.title = title;
+        this.content = content;
+        this.priority = priority;
+        this.deadline = deadline;
+    }
+
 }
diff --git a/src/main/java/com/soda/article/entity/ArticleFile.java b/src/main/java/com/soda/article/entity/ArticleFile.java
index de148c3d..d675e167 100644
--- a/src/main/java/com/soda/article/entity/ArticleFile.java
+++ b/src/main/java/com/soda/article/entity/ArticleFile.java
@@ -2,8 +2,12 @@
 
 import com.soda.common.BaseEntity;
 import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Builder;
 import lombok.Getter;
+import lombok.NoArgsConstructor;
 
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
 @Getter
 @Entity
 public class ArticleFile extends BaseEntity {
@@ -15,4 +19,24 @@ public class ArticleFile extends BaseEntity {
     @ManyToOne(fetch = FetchType.LAZY)
     @JoinColumn(name = "article_id", nullable = false)
     private Article article;
+
+    @Builder
+    public ArticleFile (String name, String url, Article article) {
+        this.name = name;
+        this.url = url;
+        this.article = article;
+    }
+
+    public void delete() {
+        this.markAsDeleted();
+    }
+
+    public void reActive() {
+        this.markAsActive();
+    }
+
+    public void updateArticleFile(String name, String url) {
+        this.name = name;
+        this.url = url;
+    }
 }
diff --git a/src/main/java/com/soda/article/entity/ArticleLink.java b/src/main/java/com/soda/article/entity/ArticleLink.java
index 1cd5dcd1..0b430071 100644
--- a/src/main/java/com/soda/article/entity/ArticleLink.java
+++ b/src/main/java/com/soda/article/entity/ArticleLink.java
@@ -5,8 +5,12 @@
 import jakarta.persistence.FetchType;
 import jakarta.persistence.JoinColumn;
 import jakarta.persistence.ManyToOne;
+import lombok.AccessLevel;
+import lombok.Builder;
 import lombok.Getter;
+import lombok.NoArgsConstructor;
 
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
 @Getter
 @Entity
 public class ArticleLink extends BaseEntity {
@@ -19,4 +23,24 @@ public class ArticleLink extends BaseEntity {
     @JoinColumn(name = "article_id", nullable = false)
     private Article article;
 
+    @Builder
+    public ArticleLink(String urlAddress, String urlDescription, Article article) {
+        this.urlAddress = urlAddress;
+        this.urlDescription = urlDescription;
+        this.article = article;
+    }
+
+    public void delete() {
+        this.markAsDeleted();
+    }
+
+    public void reActive() {
+        this.markAsActive();
+    }
+
+    public void updateArticleLink(String urlAddress, String urlDescription) {
+        this.urlAddress = urlAddress;
+        this.urlDescription = urlDescription;
+    }
+
 }
diff --git a/src/main/java/com/soda/article/repository/ArticleFileRepository.java b/src/main/java/com/soda/article/repository/ArticleFileRepository.java
new file mode 100644
index 00000000..17d36899
--- /dev/null
+++ b/src/main/java/com/soda/article/repository/ArticleFileRepository.java
@@ -0,0 +1,15 @@
+package com.soda.article.repository;
+
+import com.soda.article.entity.ArticleFile;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+public interface ArticleFileRepository extends JpaRepository<ArticleFile, Long> {
+    List<ArticleFile> findByArticleId(Long articleId);
+
+    Optional<ArticleFile> findByArticleIdAndNameAndIsDeletedTrue(Long articleId, String name);
+}
diff --git a/src/main/java/com/soda/article/repository/ArticleLinkRepository.java b/src/main/java/com/soda/article/repository/ArticleLinkRepository.java
new file mode 100644
index 00000000..7b58bcc2
--- /dev/null
+++ b/src/main/java/com/soda/article/repository/ArticleLinkRepository.java
@@ -0,0 +1,15 @@
+package com.soda.article.repository;
+
+import com.soda.article.entity.ArticleLink;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+public interface ArticleLinkRepository extends JpaRepository<ArticleLink, Long> {
+    List<ArticleLink> findByArticleId(Long id);
+
+    Optional<ArticleLink> findByArticleIdAndUrlAddressAndIsDeletedTrue(Long articleId, String urlAddress);
+}
diff --git a/src/main/java/com/soda/article/repository/ArticleRepository.java b/src/main/java/com/soda/article/repository/ArticleRepository.java
new file mode 100644
index 00000000..bdb41639
--- /dev/null
+++ b/src/main/java/com/soda/article/repository/ArticleRepository.java
@@ -0,0 +1,17 @@
+package com.soda.article.repository;
+
+import com.soda.article.entity.Article;
+import com.soda.project.entity.Project;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+public interface ArticleRepository extends JpaRepository<Article, Long> {
+    // isDeleted가 false인 게시글만 조회하고, 특정 Project에 속한 게시글만 조회
+    List<Article> findByIsDeletedFalseAndStage_Project(Project project);
+
+    Optional<Article> findByIdAndIsDeletedFalse(Long articleId);
+}
diff --git a/src/main/java/com/soda/article/service/ArticleService.java b/src/main/java/com/soda/article/service/ArticleService.java
new file mode 100644
index 00000000..64927f4e
--- /dev/null
+++ b/src/main/java/com/soda/article/service/ArticleService.java
@@ -0,0 +1,317 @@
+package com.soda.article.service;
+
+import com.soda.article.domain.*;
+import com.soda.article.entity.Article;
+import com.soda.article.entity.ArticleFile;
+import com.soda.article.entity.ArticleLink;
+import com.soda.article.enums.ArticleStatus;
+import com.soda.article.repository.ArticleFileRepository;
+import com.soda.article.repository.ArticleLinkRepository;
+import com.soda.article.repository.ArticleRepository;
+import com.soda.global.response.ErrorCode;
+import com.soda.global.response.GeneralException;
+import com.soda.global.security.auth.UserDetailsImpl;
+import com.soda.global.security.jwt.JwtTokenProvider;
+import com.soda.member.entity.Member;
+import com.soda.member.repository.MemberRepository;
+import com.soda.project.entity.Project;
+import com.soda.project.entity.Stage;
+import com.soda.project.repository.MemberProjectRepository;
+import com.soda.project.repository.ProjectRepository;
+import com.soda.project.repository.StageRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Transactional(readOnly = true)
+@Service
+@RequiredArgsConstructor
+public class ArticleService {
+
+    private final ArticleRepository articleRepository;
+    private final MemberProjectRepository memberProjectRepository;
+    private final StageRepository stageRepository;
+    private final ProjectRepository projectRepository;
+    private final ArticleFileRepository articleFileRepository;
+    private final ArticleLinkRepository articleLinkRepository;
+
+    @Transactional
+    public ArticleModifyResponse createArticle(Long projectId, ArticleModifyRequest request, UserDetailsImpl userDetails) {
+        Member member = userDetails.getMember();
+        Project project = validateProject(projectId);
+        Stage stage = validateStage(request.getStageId(), project);
+        validateMemberInProject(project.getId(), member);
+
+        validateFileAndLinkSize(request);
+
+        Article article = saveArticle(request, member, stage);
+
+        // file & link 저장
+        processFilesAndLinks(request, article);
+
+        article = articleRepository.save(article);
+
+        return buildArticleModifyResponse(article);
+    }
+
+    // article 수정
+    @Transactional
+    public ArticleModifyResponse updateArticle(Long projectId, UserDetailsImpl userDetails, Long articleId, ArticleModifyRequest request) {
+        Member member = userDetails.getMember();
+        Project project = validateProject(projectId);
+        validateMemberInProject(project.getId(), member);
+
+        Article article = findArticleById(articleId);
+
+        validateFileAndLinkSize(request);
+
+        article.updateArticle(request.getTitle(), request.getContent(), request.getPriority(), request.getDeadLine());
+
+        // 기존 파일 및 링크 삭제
+        processDeletionForFilesAndLinks(articleId, article);
+
+        // 새 파일 및 링크 추가 또는 복원
+        processFilesAndLinks(request, article);
+
+        article = articleRepository.save(article);
+
+        return buildArticleModifyResponse(article);
+    }
+
+    // 게시글 삭제
+    @Transactional
+    public void deleteArticle(Long projectId, UserDetailsImpl userDetails, Long articleId) {
+        Member member = userDetails.getMember();
+        validateMemberInProject(projectId, member);
+
+        Article article = findArticleById(articleId);
+        validateArticleNotDeleted(article);
+
+        // 게시글 삭제
+        article.delete();
+
+        // 연관된 파일 및 링크 삭제
+        processDeletionForFilesAndLinks(articleId, article);
+
+        articleRepository.save(article);
+    }
+
+    // 게시글 저장
+    private Article saveArticle(ArticleModifyRequest request, Member member, Stage stage) {
+        Article article = Article.builder()
+                .title(request.getTitle())
+                .content(request.getContent())
+                .priority(request.getPriority())
+                .deadline(request.getDeadLine())
+                .member(member)
+                .stage(stage)
+                .status(ArticleStatus.PENDING)  // 기본 상태는 PENDING
+                .build();
+
+        return articleRepository.save(article);
+    }
+
+    // 공통된 파일 및 링크 처리 로직
+    private void processFilesAndLinks(ArticleModifyRequest request, Article article) {
+        if (request.getFileList() != null) {
+            request.getFileList().forEach(fileDTO -> {
+                ArticleFile file = processFile(fileDTO, article);
+                articleFileRepository.save(file);
+                article.getArticleFileList().add(file);
+            });
+        }
+
+        if (request.getLinkList() != null) {
+            request.getLinkList().forEach(linkDTO -> {
+                ArticleLink link = processLink(linkDTO, article);
+                articleLinkRepository.save(link);
+                article.getArticleLinkList().add(link);
+            });
+        }
+    }
+
+    // 기존 파일 및 링크 삭제
+    private void processDeletionForFilesAndLinks(Long articleId, Article article) {
+        List<ArticleFile> existingFiles = articleFileRepository.findByArticleId(articleId);
+        existingFiles.forEach(ArticleFile::delete);
+        article.getArticleFileList().removeIf(existingFiles::contains);
+
+        List<ArticleLink> existingLinks = articleLinkRepository.findByArticleId(articleId);
+        existingLinks.forEach(ArticleLink::delete);
+        article.getArticleLinkList().removeIf(existingLinks::contains);
+    }
+
+    // 파일 처리 (새로운 파일 추가 또는 복원)
+    private ArticleFile processFile(ArticleFileDTO fileDTO, Article article) {
+        ArticleFile file = articleFileRepository.findByArticleIdAndNameAndIsDeletedTrue(article.getId(), fileDTO.getName())
+                .orElse(null);
+
+        if (file != null) {
+            file.reActive();
+        } else {
+            file = ArticleFile.builder()
+                    .name(fileDTO.getName())
+                    .url(fileDTO.getUrl())
+                    .article(article)
+                    .build();
+        }
+
+        return file;
+    }
+
+    // 링크 처리 (새로운 링크 추가 또는 복원)
+    private ArticleLink processLink(ArticleLinkDTO linkDTO, Article article) {
+        ArticleLink link = articleLinkRepository.findByArticleIdAndUrlAddressAndIsDeletedTrue(article.getId(), linkDTO.getUrlAddress())
+                .orElse(null);
+
+        if (link != null) {
+            link.reActive();
+        } else {
+            link = ArticleLink.builder()
+                    .urlAddress(linkDTO.getUrlAddress())
+                    .urlDescription(linkDTO.getUrlDescription())
+                    .article(article)
+                    .build();
+        }
+
+        return link;
+    }
+
+    // 파일과 링크의 수가 10개를 초과하는지 체크
+    private void validateFileAndLinkSize(ArticleModifyRequest request) {
+        if (request.getFileList() != null && request.getFileList().size() > 10) {
+            throw new GeneralException(ErrorCode.INVALID_INPUT);
+        }
+
+        if (request.getLinkList() != null && request.getLinkList().size() > 10) {
+            throw new GeneralException(ErrorCode.INVALID_INPUT);
+        }
+    }
+
+    // 프로젝트 검증
+    private Project validateProject(Long projectId) {
+        return projectRepository.findByIdAndIsDeletedFalse(projectId)
+                .orElseThrow(() -> new GeneralException(ErrorCode.PROJECT_NOT_FOUND));
+    }
+
+    // 단계 검증
+    private Stage validateStage(Long stageId, Project project) {
+        Stage stage = stageRepository.findById(stageId)
+                .orElseThrow(() -> new GeneralException(ErrorCode.STAGE_NOT_FOUND));
+
+        if (!stage.getProject().equals(project)) {
+            throw new GeneralException(ErrorCode.INVALID_STAGE_FOR_PROJECT);
+        }
+
+        return stage;
+    }
+
+    // 게시글 조회
+    private Article findArticleById(Long articleId) {
+        return articleRepository.findByIdAndIsDeletedFalse(articleId)
+                .orElseThrow(() -> new GeneralException(ErrorCode.INVALID_ARTICLE));
+    }
+
+    // 이미 삭제된 게시글 체크
+    private void validateArticleNotDeleted(Article article) {
+        if (article.getIsDeleted()) {
+            throw new GeneralException(ErrorCode.ARTICLE_ALREADY_DELETED);
+        }
+    }
+
+    // 응답 객체 생성
+    private ArticleModifyResponse buildArticleModifyResponse(Article article) {
+        return ArticleModifyResponse.builder()
+                .title(article.getTitle())
+                .content(article.getContent())
+                .priority(article.getPriority())
+                .deadLine(article.getDeadline())
+                .memberName(article.getMember().getName())
+                .stageId(article.getStage().getId())
+                .fileList(article.getArticleFileList().stream()
+                        .map(file -> ArticleFileDTO.builder()
+                                .name(file.getName())
+                                .url(file.getUrl())
+                                .build())
+                        .collect(Collectors.toList()))
+                .linkList(article.getArticleLinkList().stream()
+                        .map(link -> ArticleLinkDTO.builder()
+                                .urlAddress(link.getUrlAddress())
+                                .urlDescription(link.getUrlDescription())
+                                .build())
+                        .collect(Collectors.toList()))
+                .build();
+    }
+
+    // Article List 조회
+    public List<ArticleViewResponse> getAllArticles(UserDetailsImpl userDetails, Long projectId) {
+        Member member = userDetails.getMember();
+
+        validateMemberInProject(projectId, member);
+        Project project = projectRepository.findByIdAndIsDeletedFalse(projectId)
+                .orElseThrow(() -> new GeneralException(ErrorCode.PROJECT_NOT_FOUND));
+
+        // 프로젝트에 속한 삭제되지 않은 게시글 조회
+        List<Article> articles = articleRepository.findByIsDeletedFalseAndStage_Project(project);
+
+        return articles.stream()
+                .map(this::buildArticleViewResponse)
+                .collect(Collectors.toList());
+    }
+
+    public ArticleViewResponse getArticle(Long projectId, UserDetailsImpl userDetails, Long articleId) {
+        Member member = userDetails.getMember();
+
+        validateMemberInProject(projectId, member);
+
+        Article article = articleRepository.findByIdAndIsDeletedFalse(articleId)
+                .orElseThrow(() -> new GeneralException(ErrorCode.INVALID_ARTICLE));
+
+        return buildArticleViewResponse(article);
+    }
+
+    // 프로젝트 및 멤버 검증 로직
+    private void validateMemberInProject(Long projectId, Member member) {
+        // 특정 프로젝트를 조회 (프로젝트가 존재하지 않으면 예외 발생)
+        Project project = projectRepository.findByIdAndIsDeletedFalse(projectId)
+                .orElseThrow(() -> new GeneralException(ErrorCode.PROJECT_NOT_FOUND));
+
+        // 로그인한 멤버가 해당 프로젝트의 멤버인지를 확인
+        boolean isMemberInProject = memberProjectRepository.existsByMemberAndProjectAndIsDeletedFalse(member, project);
+        if (!isMemberInProject && !member.isAdmin()) {
+            throw new GeneralException(ErrorCode.MEMBER_NOT_IN_PROJECT);
+        }
+    }
+
+    // ArticleViewResponse 생성 로직
+    private ArticleViewResponse buildArticleViewResponse(Article article) {
+        return ArticleViewResponse.builder()
+                .title(article.getTitle())
+                .content(article.getContent())
+                .priority(article.getPriority())
+                .deadLine(article.getDeadline())
+                .memberName(article.getMember().getName())
+                .stageName(article.getStage().getName())
+                .fileList(article.getArticleFileList().stream()
+                        .map(file -> ArticleFileDTO.builder()
+                                .name(file.getName())
+                                .url(file.getUrl())
+                                .build())
+                        .collect(Collectors.toList()))
+                .linkList(article.getArticleLinkList().stream()
+                        .map(link -> ArticleLinkDTO.builder()
+                                .urlAddress(link.getUrlAddress())
+                                .urlDescription(link.getUrlDescription())
+                                .build())
+                        .collect(Collectors.toList()))
+                .commentList(article.getCommentList().stream()
+                        .map(CommentDTO::fromEntity)
+                        .collect(Collectors.toList()))
+                .build();
+    }
+
+
+}
\ No newline at end of file
diff --git a/src/main/java/com/soda/global/response/ErrorCode.java b/src/main/java/com/soda/global/response/ErrorCode.java
index 72ac0f41..1ba932e4 100644
--- a/src/main/java/com/soda/global/response/ErrorCode.java
+++ b/src/main/java/com/soda/global/response/ErrorCode.java
@@ -27,6 +27,14 @@ public enum ErrorCode {
     PROJECT_NOT_FOUND("1006", "Invalid project Id", HttpStatus.NOT_FOUND),
     PROJECT_ALREADY_DELETED("1007", "This Project is already deleted", HttpStatus.NOT_FOUND),
     MEMBER_PROJECT_NOT_FOUND("1008", "The member project does not exist for the given project and role.", HttpStatus.NOT_FOUND),
+    STAGE_NOT_FOUND("1009", "The stage does not exist", HttpStatus.NOT_FOUND),
+    MEMBER_NOT_IN_PROJECT("1010", "This member does not exist in this project", HttpStatus.NOT_FOUND),
+    INVALID_INPUT("1011", "Invalid Input", HttpStatus.BAD_REQUEST),
+    INVALID_STAGE_FOR_PROJECT("1012", "Invalid stage for project", HttpStatus.NOT_FOUND),
+    INVALID_ARTICLE("1013", "The Article does not exist", HttpStatus.NOT_FOUND),
+    ARTICLE_ALREADY_DELETED("1014","This article is already deleted", HttpStatus.NOT_FOUND),
+    ARTICLE_FILE_NOT_FOUND("1015", "This article file does not exist", HttpStatus.NOT_FOUND),
+    ARTICLE_LINK_NOT_FOUND("1016", "This article link does not exist", HttpStatus.NOT_FOUND),
 
     INVALID_CREDENTIALS("2001", "아이디 또는 비밀번호가 올바르지 않습니다.", HttpStatus.UNAUTHORIZED),
     TOKEN_EXPIRED("2002", "토큰이 만료되었습니다.", HttpStatus.UNAUTHORIZED),
diff --git a/src/main/java/com/soda/member/entity/Member.java b/src/main/java/com/soda/member/entity/Member.java
index db39f103..7beb2b83 100644
--- a/src/main/java/com/soda/member/entity/Member.java
+++ b/src/main/java/com/soda/member/entity/Member.java
@@ -84,4 +84,8 @@ public void setMemberProjects(MemberProject memberProjects) {
         }
         this.memberProjects.add(memberProjects);
     }
+
+    public boolean isAdmin() {
+        return this.role == MemberRole.ADMIN;  // 예시로 role이 ADMIN일 경우 관리자
+    }
 }
diff --git a/src/main/java/com/soda/project/repository/MemberProjectRepository.java b/src/main/java/com/soda/project/repository/MemberProjectRepository.java
index c90f6edb..f99b17a9 100644
--- a/src/main/java/com/soda/project/repository/MemberProjectRepository.java
+++ b/src/main/java/com/soda/project/repository/MemberProjectRepository.java
@@ -24,4 +24,6 @@ public interface MemberProjectRepository extends JpaRepository<MemberProject, Lo
     List<MemberProject> findByProjectAndRole(Project project, MemberProjectRole role);
 
     MemberProject findByMemberAndProjectAndRole(Member member, Project project, MemberProjectRole role);
+
+    List<Project> findByMemberIdAndIsDeletedFalse(Long memberId);
 }


Reviews:
user: login: JunbeomKoreaUniv
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-26T01:19:49Z
user: login: seoyeon-jung
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-26T01:29:55Z
user: login: seoyeon-jung
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-26T01:31:41Z
user: login: JunbeomKoreaUniv
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-26T02:25:05Z
user: login: JunbeomKoreaUniv
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-26T02:27:07Z
user: login: yoodab
body:  굳굳
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-26T02:41:39Z

Review comments:
diff_hunk: @@ -0,0 +1,69 @@
+package com.soda.article.domain;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.soda.article.enums.ArticleStatus;
+import com.soda.article.enums.PriorityType;
+import jakarta.validation.constraints.NotNull;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ArticleDTO {
+    private Long id;
+
+    @NotNull(message = "Title is required")
+    private String title;
+
+    @NotNull(message = "Content is required")
+    private String content;
+
+    @NotNull(message = "Stage Id is required")
+    private Long stageId;
+
+    private PriorityType priority;
+    private LocalDateTime deadLine;
+    private ArticleStatus status; // article 생성 시 default PENDING
+
+    private Long parentArticleId;
+    private List<FileDTO> fileList;
+    private List<LinkDTO> linkList;
+    private List<CommentDTO> commentList;
+
+    @Getter
+    @AllArgsConstructor
+    @NoArgsConstructor
+    public static class FileDTO {
+        private String name;
+        private String url;
+    }
+
+    @Getter
+    @Builder
+    @AllArgsConstructor
+    @NoArgsConstructor
+    public static class LinkDTO {
+        private String urlAddress;
+        private String urlDescription;
+    }
path: src/main/java/com/soda/article/domain/ArticleDTO.java
user: login: JunbeomKoreaUniv
body: ArticleFileDTO와 ArticleLinkDTO 클래스와 위 DTO가 어떤 차이가 있는지 궁금합니다
설명해주시면 감사하겠습니다!
created_at: 2025-03-26T01:06:05Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,26 @@
+package com.soda.article.domain;
+
+import com.soda.article.enums.PriorityType;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class ArticleModifyResponse {
+
+    private String title;
+    private String content;
+    private PriorityType priority;
+    private LocalDateTime deadLine;
+    private String memberName;
+    private Long stageId;
+    private List<ArticleFileDTO> fileList;
+    private List<ArticleLinkDTO> linkList;
+}
path: src/main/java/com/soda/article/domain/ArticleModifyResponse.java
user: login: JunbeomKoreaUniv
body: 멤버는 memberName을, 스테이지는 stageId를 반환하시는 이유가 있을까요?
어떤 경우에 이름을 반환하고, 어떤 경우에 Id를 반환하는지에 대한 기준이 궁금합니다.
created_at: 2025-03-26T01:10:06Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,319 @@
+package com.soda.article.service;
+
+import com.soda.article.domain.*;
+import com.soda.article.entity.Article;
+import com.soda.article.entity.ArticleFile;
+import com.soda.article.entity.ArticleLink;
+import com.soda.article.enums.ArticleStatus;
+import com.soda.article.repository.ArticleFileRepository;
+import com.soda.article.repository.ArticleLinkRepository;
+import com.soda.article.repository.ArticleRepository;
+import com.soda.global.response.ErrorCode;
+import com.soda.global.response.GeneralException;
+import com.soda.global.security.auth.UserDetailsImpl;
+import com.soda.global.security.jwt.JwtTokenProvider;
+import com.soda.member.entity.Member;
+import com.soda.member.repository.MemberRepository;
+import com.soda.project.entity.Project;
+import com.soda.project.entity.Stage;
+import com.soda.project.repository.MemberProjectRepository;
+import com.soda.project.repository.ProjectRepository;
+import com.soda.project.repository.StageRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Transactional(readOnly = true)
+@Service
+@RequiredArgsConstructor
+public class ArticleService {
+
+    private final ArticleRepository articleRepository;
+    private final MemberRepository memberRepository;
+    private final MemberProjectRepository memberProjectRepository;
+    private final StageRepository stageRepository;
+    private final ProjectRepository projectRepository;
+    private final ArticleFileRepository articleFileRepository;
+    private final ArticleLinkRepository articleLinkRepository;
+    private final JwtTokenProvider jwtTokenProvider;
+
+    @Transactional
+    public ArticleModifyResponse createArticle(Long projectId, ArticleModifyRequest request, UserDetailsImpl userDetails) {
+        Member member = userDetails.getMember();
+        Project project = validateProject(projectId);
+        Stage stage = validateStage(request.getStageId(), project);
+        validateMemberInProject(project.getId(), member);
+
+        validateFileAndLinkSize(request);
+
+        Article article = saveArticle(request, member, stage);
+
+        // file & link 저장
+        processFilesAndLinks(request, article);
+
+        article = articleRepository.save(article);
+
+        return buildArticleModifyResponse(article);
+    }
+
+    // article 수정
+    @Transactional
+    public ArticleModifyResponse updateArticle(Long projectId, UserDetailsImpl userDetails, Long articleId, ArticleModifyRequest request) {
+        Member member = userDetails.getMember();
+        Project project = validateProject(projectId);
+        validateMemberInProject(project.getId(), member);
+
+        Article article = findArticleById(articleId);
+
+        validateFileAndLinkSize(request);
+
+        article.updateArticle(request.getTitle(), request.getContent(), request.getPriority(), request.getDeadLine());
+
+        // 기존 파일 및 링크 삭제
+        processDeletionForFilesAndLinks(articleId, article);
+
+        // 새 파일 및 링크 추가 또는 복원
+        processFilesAndLinks(request, article);
+
+        article = articleRepository.save(article);
+
+        return buildArticleModifyResponse(article);
+    }
+
+    // 게시글 삭제
+    @Transactional
+    public void deleteArticle(Long projectId, UserDetailsImpl userDetails, Long articleId) {
+        Member member = userDetails.getMember();
+        validateMemberInProject(projectId, member);
+
+        Article article = findArticleById(articleId);
+        validateArticleNotDeleted(article);
+
+        // 게시글 삭제
+        article.delete();
+
+        // 연관된 파일 및 링크 삭제
+        processDeletionForFilesAndLinks(articleId, article);
+
+        articleRepository.save(article);
+    }
+
+    // 게시글 저장
+    private Article saveArticle(ArticleModifyRequest request, Member member, Stage stage) {
+        Article article = Article.builder()
+                .title(request.getTitle())
+                .content(request.getContent())
+                .priority(request.getPriority())
+                .deadline(request.getDeadLine())
+                .member(member)
+                .stage(stage)
+                .status(ArticleStatus.PENDING)  // 기본 상태는 PENDING
+                .build();
+
+        return articleRepository.save(article);
+    }
+
+    // 공통된 파일 및 링크 처리 로직
+    private void processFilesAndLinks(ArticleModifyRequest request, Article article) {
+        if (request.getFileList() != null) {
+            request.getFileList().forEach(fileDTO -> {
+                ArticleFile file = processFile(fileDTO, article);
+                articleFileRepository.save(file);
+                article.getArticleFileList().add(file);
+            });
+        }
+
+        if (request.getLinkList() != null) {
+            request.getLinkList().forEach(linkDTO -> {
+                ArticleLink link = processLink(linkDTO, article);
+                articleLinkRepository.save(link);
+                article.getArticleLinkList().add(link);
+            });
+        }
+    }
+
+    // 기존 파일 및 링크 삭제
+    private void processDeletionForFilesAndLinks(Long articleId, Article article) {
+        List<ArticleFile> existingFiles = articleFileRepository.findByArticleId(articleId);
+        existingFiles.forEach(ArticleFile::delete);
+        article.getArticleFileList().removeIf(existingFiles::contains);
+
+        List<ArticleLink> existingLinks = articleLinkRepository.findByArticleId(articleId);
+        existingLinks.forEach(ArticleLink::delete);
+        article.getArticleLinkList().removeIf(existingLinks::contains);
+    }
+
+    // 파일 처리 (새로운 파일 추가 또는 복원)
+    private ArticleFile processFile(ArticleFileDTO fileDTO, Article article) {
+        ArticleFile file = articleFileRepository.findByArticleIdAndNameAndIsDeletedTrue(article.getId(), fileDTO.getName())
+                .orElse(null);
+
+        if (file != null) {
+            file.reActive();
+        } else {
+            file = ArticleFile.builder()
+                    .name(fileDTO.getName())
+                    .url(fileDTO.getUrl())
+                    .article(article)
+                    .build();
+        }
+
+        return file;
+    }
+
+    // 링크 처리 (새로운 링크 추가 또는 복원)
+    private ArticleLink processLink(ArticleLinkDTO linkDTO, Article article) {
+        ArticleLink link = articleLinkRepository.findByArticleIdAndUrlAddressAndIsDeletedTrue(article.getId(), linkDTO.getUrlAddress())
+                .orElse(null);
+
+        if (link != null) {
+            link.reActive();
+        } else {
+            link = ArticleLink.builder()
+                    .urlAddress(linkDTO.getUrlAddress())
+                    .urlDescription(linkDTO.getUrlDescription())
+                    .article(article)
+                    .build();
+        }
+
+        return link;
+    }
+
+    // 파일과 링크의 수가 10개를 초과하는지 체크
+    private void validateFileAndLinkSize(ArticleModifyRequest request) {
+        if (request.getFileList() != null && request.getFileList().size() > 10) {
+            throw new GeneralException(ErrorCode.INVALID_INPUT);
+        }
+
+        if (request.getLinkList() != null && request.getLinkList().size() > 10) {
+            throw new GeneralException(ErrorCode.INVALID_INPUT);
+        }
+    }
+
+    // 프로젝트 검증
+    private Project validateProject(Long projectId) {
+        return projectRepository.findByIdAndIsDeletedFalse(projectId)
+                .orElseThrow(() -> new GeneralException(ErrorCode.PROJECT_NOT_FOUND));
+    }
+
+    // 단계 검증
+    private Stage validateStage(Long stageId, Project project) {
+        Stage stage = stageRepository.findById(stageId)
+                .orElseThrow(() -> new GeneralException(ErrorCode.STAGE_NOT_FOUND));
+
+        if (!stage.getProject().equals(project)) {
+            throw new GeneralException(ErrorCode.INVALID_STAGE_FOR_PROJECT);
+        }
+
+        return stage;
+    }
+
+    // 게시글 조회
+    private Article findArticleById(Long articleId) {
+        return articleRepository.findByIdAndIsDeletedFalse(articleId)
+                .orElseThrow(() -> new GeneralException(ErrorCode.INVALID_ARTICLE));
+    }
+
+    // 이미 삭제된 게시글 체크
+    private void validateArticleNotDeleted(Article article) {
+        if (article.getIsDeleted()) {
+            throw new GeneralException(ErrorCode.ARTICLE_ALREADY_DELETED);
+        }
+    }
+
+    // 응답 객체 생성
+    private ArticleModifyResponse buildArticleModifyResponse(Article article) {
+        return ArticleModifyResponse.builder()
+                .title(article.getTitle())
+                .content(article.getContent())
+                .priority(article.getPriority())
+                .deadLine(article.getDeadline())
+                .memberName(article.getMember().getName())
+                .stageId(article.getStage().getId())
+                .fileList(article.getArticleFileList().stream()
+                        .map(file -> ArticleFileDTO.builder()
+                                .name(file.getName())
+                                .url(file.getUrl())
+                                .build())
+                        .collect(Collectors.toList()))
+                .linkList(article.getArticleLinkList().stream()
+                        .map(link -> ArticleLinkDTO.builder()
+                                .urlAddress(link.getUrlAddress())
+                                .urlDescription(link.getUrlDescription())
+                                .build())
+                        .collect(Collectors.toList()))
+                .build();
+    }
+
+    // Article List 조회
+    public List<ArticleViewResponse> getAllArticles(UserDetailsImpl userDetails, Long projectId) {
+        Member member = userDetails.getMember();
+
+        validateMemberInProject(projectId, member);
+        Project project = projectRepository.findByIdAndIsDeletedFalse(projectId)
+                .orElseThrow(() -> new GeneralException(ErrorCode.PROJECT_NOT_FOUND));
+
+        // 프로젝트에 속한 삭제되지 않은 게시글 조회
+        List<Article> articles = articleRepository.findByIsDeletedFalseAndStage_Project(project);
+
+        return articles.stream()
+                .map(this::buildArticleViewResponse)
+                .collect(Collectors.toList());
+    }
+
+    public ArticleViewResponse getArticle(Long projectId, UserDetailsImpl userDetails, Long articleId) {
+        Member member = userDetails.getMember();
+
+        validateMemberInProject(projectId, member);
+
+        Article article = articleRepository.findByIdAndIsDeletedFalse(articleId)
+                .orElseThrow(() -> new GeneralException(ErrorCode.INVALID_ARTICLE));
+
+        return buildArticleViewResponse(article);
+    }
+
+    // 프로젝트 및 멤버 검증 로직
+    private void validateMemberInProject(Long projectId, Member member) {
+        // 특정 프로젝트를 조회 (프로젝트가 존재하지 않으면 예외 발생)
+        Project project = projectRepository.findByIdAndIsDeletedFalse(projectId)
+                .orElseThrow(() -> new GeneralException(ErrorCode.PROJECT_NOT_FOUND));
+
+        // 로그인한 멤버가 해당 프로젝트의 멤버인지를 확인
+        boolean isMemberInProject = memberProjectRepository.existsByMemberAndProjectAndIsDeletedFalse(member, project);
+        if (!isMemberInProject && !member.isAdmin()) {
+            throw new GeneralException(ErrorCode.MEMBER_NOT_IN_PROJECT);
+        }
+    }
path: src/main/java/com/soda/article/service/ArticleService.java
user: login: JunbeomKoreaUniv
body: 어제 멘토링했던 것처럼 테스트 하기 좋은 코드가 되려면 인자로 member객체가 아니라 member의 role만 받으면 테스트하기 좋은 코드가 될 것 같습니다.
이 부분 어느정도로 리팩토링할 지 스크럼 때 얘기해보면 좋을 것 같습니다!
created_at: 2025-03-26T01:16:48Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,69 @@
+package com.soda.article.domain;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.soda.article.enums.ArticleStatus;
+import com.soda.article.enums.PriorityType;
+import jakarta.validation.constraints.NotNull;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ArticleDTO {
+    private Long id;
+
+    @NotNull(message = "Title is required")
+    private String title;
+
+    @NotNull(message = "Content is required")
+    private String content;
+
+    @NotNull(message = "Stage Id is required")
+    private Long stageId;
+
+    private PriorityType priority;
+    private LocalDateTime deadLine;
+    private ArticleStatus status; // article 생성 시 default PENDING
+
+    private Long parentArticleId;
+    private List<FileDTO> fileList;
+    private List<LinkDTO> linkList;
+    private List<CommentDTO> commentList;
+
+    @Getter
+    @AllArgsConstructor
+    @NoArgsConstructor
+    public static class FileDTO {
+        private String name;
+        private String url;
+    }
+
+    @Getter
+    @Builder
+    @AllArgsConstructor
+    @NoArgsConstructor
+    public static class LinkDTO {
+        private String urlAddress;
+        private String urlDescription;
+    }
path: src/main/java/com/soda/article/domain/ArticleDTO.java
user: login: seoyeon-jung
body: ArticleDTO 내부에 존재하는 DTO 클래스들은 ArticleDTO 객체의 부분적인 데이터를 나타내며, 외부에 전달되기 위한 데이터 전송 객체로 사용하기 위해 만들었습니다. 그래서 API 전송 데이터로서 사용되며, 요청 및 응답의 부분적인 정보를 담고 있습니다

반면
ArticleFileDTO, ArticleLinkDTO는 실제 DB에서 값을 읽고 쓰는데 사용하기 위해 만들었습니다.
created_at: 2025-03-26T01:29:55Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,26 @@
+package com.soda.article.domain;
+
+import com.soda.article.enums.PriorityType;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class ArticleModifyResponse {
+
+    private String title;
+    private String content;
+    private PriorityType priority;
+    private LocalDateTime deadLine;
+    private String memberName;
+    private Long stageId;
+    private List<ArticleFileDTO> fileList;
+    private List<ArticleLinkDTO> linkList;
+}
path: src/main/java/com/soda/article/domain/ArticleModifyResponse.java
user: login: seoyeon-jung
body: 프론트 화면에서 보여질 때 멤버 이름이 보여지고 멤버는 작성한 사람이기 때문에 수정될 부분이 없는 것 같아서 이름으로 반환했습니다. 그런데 스테이지는 변경될 수 있기 때문에 id를 반환했는데, 수정할 때 멤버 이름이 필요한가 고민이 생겨서 이 부분은 다같이 논이해보면 좋을 것 같습니다.
created_at: 2025-03-26T01:31:41Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,26 @@
+package com.soda.article.domain;
+
+import com.soda.article.enums.PriorityType;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class ArticleModifyResponse {
+
+    private String title;
+    private String content;
+    private PriorityType priority;
+    private LocalDateTime deadLine;
+    private String memberName;
+    private Long stageId;
+    private List<ArticleFileDTO> fileList;
+    private List<ArticleLinkDTO> linkList;
+}
path: src/main/java/com/soda/article/domain/ArticleModifyResponse.java
user: login: JunbeomKoreaUniv
body: 음 프론트쪽 기획이 나와야 구체화할 수 있을 것 같아 그때가서 수정하면 될 것 같네요~
created_at: 2025-03-26T02:25:05Z
author_association: COLLABORATOR

PR comments:
user: login: JunbeomKoreaUniv
created_at: 2025-03-25T14:04:27Z
body: ```java
 @JsonInclude(JsonInclude.Include.NON_NULL)
public class ArticleDTO { }
```
위 코드의 필요성에 대해 정확히 이해하지 못하였는데 조금만 더 자세히 설명해주실 수 있을까요?
author_association: COLLABORATOR
user: login: seoyeon-jung
created_at: 2025-03-26T01:25:43Z
body: > ```java
>  @JsonInclude(JsonInclude.Include.NON_NULL)
> public class ArticleDTO { }
> ```
> 
> 위 코드의 필요성에 대해 정확히 이해하지 못하였는데 조금만 더 자세히 설명해주실 수 있을까요?

1. null 값인 필드를 포함하지 않으면 JSON 응답의 크기가 작아집니다. 클라이언트에서 받아 처리해야 할 데이터가 줄어들어 네트워크 부담을 줄일 수 있습니다.
2. null 값이 포함된 응답은 클라이언트 측에서 불필요한 처리나 검사 로직을 추가해야 할 수 있습니다. 
3. 사용자가 파일을 업로드하지 않거나 링크를 추가하지 않았다면 해당 필드를 아예 포함하지 않도록 하는 것이 API 응답의 일관성 측면에서 더 깔끔하다고 생각해서 검색해서 찾아봤습니다.

그런데 초기 생각했던 것과 달리 로직이 변경되어 ArticleDTO파일에는 필요 없고, ArticleModifyRequest/ArticleModifyResponse 에 필요할 것 같아서 해당 어노테이션 추가했습니다.
author_association: COLLABORATOR
user: login: JunbeomKoreaUniv
created_at: 2025-03-26T02:26:59Z
body: 오 되게 좋은 애너테이션인 것 같네요~ 저도 필요한 부분에 사용 고려해보겠습니다.
수고하셨습니다!
author_association: COLLABORATOR