From acbba17e4aca43f214f8d0ca2d12775184e9c5ab Mon Sep 17 00:00:00 2001 From: Trudy2645 Date: Thu, 9 Oct 2025 21:02:02 +0900 Subject: [PATCH 1/8] =?UTF-8?q?Feat:=20CRUD=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 21 +++++-- .../devSns/controller/PostController.java | 56 +++++++++++++++++++ .../java/com/example/devSns/domain/Post.java | 29 ++++++++++ .../example/devSns/dto/PostRequestDto.java | 11 ++++ .../example/devSns/dto/PostResponseDto.java | 17 ++++++ .../devSns/repository/PostRepository.java | 10 ++++ .../example/devSns/service/PostService.java | 50 +++++++++++++++++ 7 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/example/devSns/controller/PostController.java create mode 100644 src/main/java/com/example/devSns/domain/Post.java create mode 100644 src/main/java/com/example/devSns/dto/PostRequestDto.java create mode 100644 src/main/java/com/example/devSns/dto/PostResponseDto.java create mode 100644 src/main/java/com/example/devSns/repository/PostRepository.java create mode 100644 src/main/java/com/example/devSns/service/PostService.java diff --git a/build.gradle b/build.gradle index 610d6a6..4453062 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.5.6' - id 'io.spring.dependency-management' version '1.1.7' + id "java" + id "org.springframework.boot" version "3.5.6" + id "io.spring.dependency-management" version "1.1.7" } group = 'com.example' @@ -19,11 +19,24 @@ repositories { } dependencies { + // 기본 웹 라이브러리 implementation 'org.springframework.boot:spring-boot-starter-web' + + // --- ▼▼▼▼▼ 여기가 빠져있었습니다! ▼▼▼▼▼ --- + // 데이터베이스 연동(JPA) 라이브러리 + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + // 테스트용 H2 인메모리 데이터베이스 + runtimeOnly 'com.h2database:h2' + // Lombok 라이브러리 (코드 자동 생성) + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + // --- ▲▲▲▲▲ 여기까지 추가 ▲▲▲▲▲ --- + + // 기본 테스트 라이브러리 testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } tasks.named('test') { useJUnitPlatform() -} +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/controller/PostController.java b/src/main/java/com/example/devSns/controller/PostController.java new file mode 100644 index 0000000..7b72b06 --- /dev/null +++ b/src/main/java/com/example/devSns/controller/PostController.java @@ -0,0 +1,56 @@ +package com.example.devSns.controller; + +import com.example.devSns.domain.Post; +import com.example.devSns.dto.PostRequestDto; +import com.example.devSns.dto.PostResponseDto; +import com.example.devSns.service.PostService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/posts") // http://.../api/posts 로 시작하는 모든 요청을 이 컨트롤러가 처리 +@RequiredArgsConstructor +public class PostController { + + private final PostService postService; + + // 1. 게시글 생성 (POST) + @PostMapping + public PostResponseDto createPost(@RequestBody PostRequestDto requestDto) { + Post post = postService.createPost(requestDto); + return new PostResponseDto(post); + } + + // 2. 모든 게시글 조회 (GET) + @GetMapping + public List getAllPosts() { + return postService.getAllPosts().stream() + .map(PostResponseDto::new) + .collect(Collectors.toList()); + } + + // 3. 특정 게시글 조회 (GET) + @GetMapping("/{id}") + public PostResponseDto getPostById(@PathVariable Long id) { + Post post = postService.getPostById(id); + return new PostResponseDto(post); + } + + // 4. 게시글 수정 (PUT) + @PutMapping("/{id}") + public PostResponseDto updatePost(@PathVariable Long id, @RequestBody PostRequestDto requestDto) { + Post updatedPost = postService.updatePost(id, requestDto); + return new PostResponseDto(updatedPost); + } + + // 5. 게시글 삭제 (DELETE) + @DeleteMapping("/{id}") + public ResponseEntity deletePost(@PathVariable Long id) { + postService.deletePost(id); + return ResponseEntity.ok().build(); // 성공적으로 처리되었음을 응답 (body는 없음) + } +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/domain/Post.java b/src/main/java/com/example/devSns/domain/Post.java new file mode 100644 index 0000000..dc4d1db --- /dev/null +++ b/src/main/java/com/example/devSns/domain/Post.java @@ -0,0 +1,29 @@ +package com.example.devSns.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity // 이 클래스가 데이터베이스 테이블과 매핑됨을 나타냅니다. +@Getter +@Setter +@NoArgsConstructor // 기본 생성자를 자동으로 만들어줍니다. +public class Post { + + @Id // 기본 키(Primary Key)임을 나타냅니다. + @GeneratedValue(strategy = GenerationType.IDENTITY) // ID가 자동으로 생성되고 증가함을 나타냅니다. + private Long id; + + private String title; + + private String content; + + public Post(String title, String content) { + this.title = title; + this.content = content; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/dto/PostRequestDto.java b/src/main/java/com/example/devSns/dto/PostRequestDto.java new file mode 100644 index 0000000..07ae570 --- /dev/null +++ b/src/main/java/com/example/devSns/dto/PostRequestDto.java @@ -0,0 +1,11 @@ +package com.example.devSns.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class PostRequestDto { + private String title; + private String content; +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/dto/PostResponseDto.java b/src/main/java/com/example/devSns/dto/PostResponseDto.java new file mode 100644 index 0000000..83b10db --- /dev/null +++ b/src/main/java/com/example/devSns/dto/PostResponseDto.java @@ -0,0 +1,17 @@ +package com.example.devSns.dto; + +import com.example.devSns.domain.Post; +import lombok.Getter; + +@Getter +public class PostResponseDto { + private final Long id; + private final String title; + private final String content; + + public PostResponseDto(Post post) { + this.id = post.getId(); + this.title = post.getTitle(); + this.content = post.getContent(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/repository/PostRepository.java b/src/main/java/com/example/devSns/repository/PostRepository.java new file mode 100644 index 0000000..8ba9a2d --- /dev/null +++ b/src/main/java/com/example/devSns/repository/PostRepository.java @@ -0,0 +1,10 @@ +package com.example.devSns.repository; + +import com.example.devSns.domain.Post; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface PostRepository extends JpaRepository { + // <엔티티 이름, ID의 타입> +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/service/PostService.java b/src/main/java/com/example/devSns/service/PostService.java new file mode 100644 index 0000000..01b4801 --- /dev/null +++ b/src/main/java/com/example/devSns/service/PostService.java @@ -0,0 +1,50 @@ +package com.example.devSns.service; + +import com.example.devSns.domain.Post; +import com.example.devSns.dto.PostRequestDto; +import com.example.devSns.repository.PostRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor // final 필드에 대한 생성자를 자동으로 만들어줍니다. +public class PostService { + + private final PostRepository postRepository; + + // 게시글 생성 + @Transactional + public Post createPost(PostRequestDto requestDto) { + Post post = new Post(requestDto.getTitle(), requestDto.getContent()); + return postRepository.save(post); + } + + // 모든 게시글 조회 + public List getAllPosts() { + return postRepository.findAll(); + } + + // 특정 게시글 조회 + public Post getPostById(Long id) { + return postRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("ID not found: " + id)); + } + + // 게시글 수정 + @Transactional + public Post updatePost(Long id, PostRequestDto requestDto) { + Post post = getPostById(id); + post.setTitle(requestDto.getTitle()); + post.setContent(requestDto.getContent()); + return postRepository.save(post); + } + + // 게시글 삭제 + @Transactional + public void deletePost(Long id) { + postRepository.deleteById(id); + } +} \ No newline at end of file From 345af32f489712d5a6eb04991a2747f2d8d1130c Mon Sep 17 00:00:00 2001 From: Trudy2645 Date: Thu, 9 Oct 2025 21:12:13 +0900 Subject: [PATCH 2/8] =?UTF-8?q?Chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=A3=BC=EC=84=9D=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/example/devSns/domain/Post.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/example/devSns/domain/Post.java b/src/main/java/com/example/devSns/domain/Post.java index dc4d1db..e60e5ee 100644 --- a/src/main/java/com/example/devSns/domain/Post.java +++ b/src/main/java/com/example/devSns/domain/Post.java @@ -8,14 +8,14 @@ import lombok.NoArgsConstructor; import lombok.Setter; -@Entity // 이 클래스가 데이터베이스 테이블과 매핑됨을 나타냅니다. +@Entity // 이 클래스가 데이터베이스 테이블과 매핑 @Getter @Setter -@NoArgsConstructor // 기본 생성자를 자동으로 만들어줍니다. +@NoArgsConstructor public class Post { - @Id // 기본 키(Primary Key)임을 나타냅니다. - @GeneratedValue(strategy = GenerationType.IDENTITY) // ID가 자동으로 생성되고 증가함을 나타냅니다. + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; From 70b519d0ce7cb59a633205ed879aeb76fe6484a0 Mon Sep 17 00:00:00 2001 From: Trudy2645 Date: Fri, 10 Oct 2025 15:57:53 +0900 Subject: [PATCH 3/8] =?UTF-8?q?Chore:=20=EB=A9=98=ED=86=A0=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20=EB=B0=98=EC=98=81:=20@Column=20TEXT=20?= =?UTF-8?q?=EB=A1=9C=20=EC=A7=80=EC=A0=95,=20Setter=EB=8A=94=20=EC=BA=A1?= =?UTF-8?q?=EC=8A=90=ED=99=94=EB=A5=BC=20=EC=9C=84=ED=95=B4=20=EC=88=98?= =?UTF-8?q?=EC=A0=95,=20DTO=EC=97=90=20id=20=ED=8F=AC=ED=95=A8=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EA=B5=AC=EC=A1=B0=20=EB=8B=A8=EC=88=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devSns/controller/PostController.java | 6 ++-- .../java/com/example/devSns/domain/Post.java | 30 +++++++++++++------ .../example/devSns/dto/PostRequestDto.java | 9 +++++- .../example/devSns/service/PostService.java | 9 +++--- 4 files changed, 37 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/example/devSns/controller/PostController.java b/src/main/java/com/example/devSns/controller/PostController.java index 7b72b06..4d75e77 100644 --- a/src/main/java/com/example/devSns/controller/PostController.java +++ b/src/main/java/com/example/devSns/controller/PostController.java @@ -41,9 +41,9 @@ public PostResponseDto getPostById(@PathVariable Long id) { } // 4. 게시글 수정 (PUT) - @PutMapping("/{id}") - public PostResponseDto updatePost(@PathVariable Long id, @RequestBody PostRequestDto requestDto) { - Post updatedPost = postService.updatePost(id, requestDto); + @PutMapping + public PostResponseDto updatePost(@RequestBody PostRequestDto requestDto) { + Post updatedPost = postService.updatePost(requestDto); return new PostResponseDto(updatedPost); } diff --git a/src/main/java/com/example/devSns/domain/Post.java b/src/main/java/com/example/devSns/domain/Post.java index e60e5ee..97f46f6 100644 --- a/src/main/java/com/example/devSns/domain/Post.java +++ b/src/main/java/com/example/devSns/domain/Post.java @@ -1,4 +1,4 @@ -package com.example.devSns.domain; +package com.example.devSns.domain; // domain 패키지에 속한다 import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -6,24 +6,36 @@ import jakarta.persistence.Id; import lombok.Getter; import lombok.NoArgsConstructor; -import lombok.Setter; +//import lombok.Setter; +import jakarta.persistence.Column; @Entity // 이 클래스가 데이터베이스 테이블과 매핑 -@Getter -@Setter +@Getter // getId() 등 getter 메서드 자동 생성 +//@Setter 이거 위험함! 캡슐화 측면에서 주의할 것. - 멘토 조언 @NoArgsConstructor -public class Post { +public class Post { // 별도 이름 지정 안하면 클래스 명 소문자형태가 테이블명으로 사용됨 - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) + @Id // 이 필드가 Primary Key임을 나타냄 + @GeneratedValue(strategy = GenerationType.IDENTITY) // 값 자동 생성 방식 지정 + // 즉 새 게시글 저장할 때 마다 DB가 알아서 id를 1,2,3 순으로 자동 부여함 private Long id; - private String title; + // 내용은 길이가 길 수 있으므로 DB에 TEXT로 지정(멘토 조언) + @Column(columnDefinition = "TEXT") + private String title; // 게시글 제목 - private String content; + @Column(columnDefinition = "TEXT") // 멘토 조언 + private String content; // 게시글 내용 + // 생성자 public Post(String title, String content) { this.title = title; this.content = content; } + + // 안전한 업데이트 메서드 + public void update(String title, String content) { + this.title = title; + this.content = content; + } } \ No newline at end of file diff --git a/src/main/java/com/example/devSns/dto/PostRequestDto.java b/src/main/java/com/example/devSns/dto/PostRequestDto.java index 07ae570..3106135 100644 --- a/src/main/java/com/example/devSns/dto/PostRequestDto.java +++ b/src/main/java/com/example/devSns/dto/PostRequestDto.java @@ -6,6 +6,13 @@ @Getter @Setter public class PostRequestDto { + private Long id; // 추가 구조 단순화를 위한 -멘토 조언 private String title; private String content; -} \ No newline at end of file +} +/* +PostRequestDto 클라이언트 → 서버로 전달되는 데이터 구조 +PostResponseDto 서버 → 클라이언트로 반환되는 데이터 구조 +Post (Entity) DB 테이블과 직접 연결된 데이터 구조 + +* */ \ No newline at end of file diff --git a/src/main/java/com/example/devSns/service/PostService.java b/src/main/java/com/example/devSns/service/PostService.java index 01b4801..3e5dc37 100644 --- a/src/main/java/com/example/devSns/service/PostService.java +++ b/src/main/java/com/example/devSns/service/PostService.java @@ -35,10 +35,11 @@ public Post getPostById(Long id) { // 게시글 수정 @Transactional - public Post updatePost(Long id, PostRequestDto requestDto) { - Post post = getPostById(id); - post.setTitle(requestDto.getTitle()); - post.setContent(requestDto.getContent()); + public Post updatePost(PostRequestDto requestDto) { + Post post = getPostById(requestDto.getId()); +// post.setTitle(requestDto.getTitle()); +// post.setContent(requestDto.getContent()); 위험함! 무결성 해칠 수 있음 + post.update(requestDto.getTitle(), requestDto.getContent()); // 엔티티 메서드 사용 return postRepository.save(post); } From eb5fbb1fc8a4e5090522ad7b9738673c5c1febce Mon Sep 17 00:00:00 2001 From: Trudy2645 Date: Fri, 7 Nov 2025 12:49:43 +0900 Subject: [PATCH 4/8] =?UTF-8?q?Feat:=20=EB=8C=93=EA=B8=80=20=EB=B0=8F=20?= =?UTF-8?q?=EB=8C=80=EB=8C=93=EA=B8=80=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 - .../devSns/controller/CommentController.java | 51 +++++++++++ .../devSns/controller/PostController.java | 4 +- .../com/example/devSns/domain/Comment.java | 88 +++++++++++++++++++ .../java/com/example/devSns/domain/Post.java | 13 +-- .../example/devSns/dto/CommentRequestDto.java | 12 +++ .../devSns/dto/CommentResponseDto.java | 33 +++++++ .../example/devSns/dto/CommentUpdateDto.java | 11 +++ .../devSns/repository/CommentRepository.java | 14 +++ .../devSns/service/CommentService.java | 76 ++++++++++++++++ .../example/devSns/service/PostService.java | 9 +- 11 files changed, 300 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/example/devSns/controller/CommentController.java create mode 100644 src/main/java/com/example/devSns/domain/Comment.java create mode 100644 src/main/java/com/example/devSns/dto/CommentRequestDto.java create mode 100644 src/main/java/com/example/devSns/dto/CommentResponseDto.java create mode 100644 src/main/java/com/example/devSns/dto/CommentUpdateDto.java create mode 100644 src/main/java/com/example/devSns/repository/CommentRepository.java create mode 100644 src/main/java/com/example/devSns/service/CommentService.java diff --git a/build.gradle b/build.gradle index 4453062..13e40b5 100644 --- a/build.gradle +++ b/build.gradle @@ -22,7 +22,6 @@ dependencies { // 기본 웹 라이브러리 implementation 'org.springframework.boot:spring-boot-starter-web' - // --- ▼▼▼▼▼ 여기가 빠져있었습니다! ▼▼▼▼▼ --- // 데이터베이스 연동(JPA) 라이브러리 implementation 'org.springframework.boot:spring-boot-starter-data-jpa' // 테스트용 H2 인메모리 데이터베이스 diff --git a/src/main/java/com/example/devSns/controller/CommentController.java b/src/main/java/com/example/devSns/controller/CommentController.java new file mode 100644 index 0000000..c1c74fd --- /dev/null +++ b/src/main/java/com/example/devSns/controller/CommentController.java @@ -0,0 +1,51 @@ +package com.example.devSns.controller; + +import com.example.devSns.domain.Comment; +import com.example.devSns.dto.CommentRequestDto; +import com.example.devSns.dto.CommentResponseDto; +import com.example.devSns.dto.CommentUpdateDto; +import com.example.devSns.service.CommentService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api") // /api/posts 와 /api/comments 로 분리 +@RequiredArgsConstructor +public class CommentController { + + private final CommentService commentService; + + // 1. 댓글 생성 (POST /api/comments) + // (대댓글이든 일반 댓글이든 이 API 하나로 처리) + @PostMapping("/comments") + public CommentResponseDto createComment(@RequestBody CommentRequestDto requestDto) { + Comment comment = commentService.createComment(requestDto); + // 생성된 댓글이 대댓글일 수 있으므로, 재귀 DTO 변환을 피하기 위해 + // 간단히 id와 content만 반환하거나, from을 쓰되 1레벨만 반환하도록 할 수 있습니다. + // 여기서는 간단하게 from을 사용합니다. + return CommentResponseDto.from(comment); + } + + // 2. 특정 게시글의 모든 댓글/대댓글 조회 (GET /api/posts/{postId}/comments) + @GetMapping("/posts/{postId}/comments") + public List getComments(@PathVariable Long postId) { + return commentService.getCommentsByPost(postId); + } + + // 3. 댓글 수정 (PUT /api/comments/{id}) + @PutMapping("/comments/{id}") + public CommentResponseDto updateComment(@PathVariable Long id, @RequestBody CommentUpdateDto updateDto) { + Comment updatedComment = commentService.updateComment(id, updateDto.getContent()); + return CommentResponseDto.from(updatedComment); + } + + // 4. 댓글 삭제 (DELETE /api/comments/{id}) + @DeleteMapping("/comments/{id}") + public ResponseEntity deleteComment(@PathVariable Long id) { + commentService.deleteComment(id); + return ResponseEntity.ok().build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/controller/PostController.java b/src/main/java/com/example/devSns/controller/PostController.java index 4d75e77..40ea602 100644 --- a/src/main/java/com/example/devSns/controller/PostController.java +++ b/src/main/java/com/example/devSns/controller/PostController.java @@ -28,9 +28,7 @@ public PostResponseDto createPost(@RequestBody PostRequestDto requestDto) { // 2. 모든 게시글 조회 (GET) @GetMapping public List getAllPosts() { - return postService.getAllPosts().stream() - .map(PostResponseDto::new) - .collect(Collectors.toList()); + return postService.getAllPosts(); } // 3. 특정 게시글 조회 (GET) diff --git a/src/main/java/com/example/devSns/domain/Comment.java b/src/main/java/com/example/devSns/domain/Comment.java new file mode 100644 index 0000000..b159d84 --- /dev/null +++ b/src/main/java/com/example/devSns/domain/Comment.java @@ -0,0 +1,88 @@ +package com.example.devSns.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@NoArgsConstructor +@Getter +@Table(name = "comments") +@Entity +public class Comment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(columnDefinition = "TEXT", nullable = false) // 댓글 내용 (필수!) + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + private Post post; + + // 대댓글 (부모) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id") + private Comment parent; + + // 대댓글 (자식) + @OneToMany(mappedBy = "parent", cascade = CascadeType.REMOVE, orphanRemoval = true) + private List children = new ArrayList<>(); + + // --- 생성자 --- + public Comment(Post post, Comment parent, String content) { + this.post = post; + this.parent = parent; + this.content = content; + } + + // --- 업데이트 메서드 --- + public void update(String content) { + this.content = content; + } +} +// 데이터 모델 + +// Commnet (comment_id, post_id ) +// 연관관계 매핑 + +// 외래키 기본키 개념 +// 자동차랑 , 바퀴 + +// 자동차 바퀴 외래키 +// 자동차 (자동차 ID , 이름, 년식, 번호판, 수리 여부, 가격) +// 1 그랜저 , 21년, 999 , true, 10000 +// 2 스타렉스 , 24년 + + +// 바퀴에 외래키 +// 바퀴( 바퀴 id, 자동차 id(fk), 바퀴이름, 색깔 , 수리여부 ) +// 1 , 1, 1번 바퀴, 노란색 , false +// 2 , 1, 2번 바퀴, 빨간색 , true +// 3 , 2, 3번 바퀴, 초록색 , false +// 4 , 1, 4번 바퀴, 노란색 , true + +// 테이블은 양뱡향 참조 +// 객체는 단방향 참조 + +// 테이블에는 join inner join +// select * from wheel w join car c Using(car_id) +// on(w.car_id == c.car_id ) +// 두개가 같은 의미 +// 객체는 단방향 참조야 +// 댓글(id, post) +// 1, post + + +// 정규화 1정규화만 DB의 컬럼이 원자값을 가져야 함. + +// 댓글은 게시글 밑에 달리지, 댓글 또 댓글 이 달려 +// List commentList = new ArrayList<>(); +// Post post; 객체 +//--------- 이 중간단게 JPA +// 테이블 실제로 저장되는 정보는 객체 단위 x PK를 아이디의 형태로 ID +// 댓글 (내용, 작성자 ) diff --git a/src/main/java/com/example/devSns/domain/Post.java b/src/main/java/com/example/devSns/domain/Post.java index 97f46f6..0e642aa 100644 --- a/src/main/java/com/example/devSns/domain/Post.java +++ b/src/main/java/com/example/devSns/domain/Post.java @@ -1,13 +1,11 @@ package com.example.devSns.domain; // domain 패키지에 속한다 -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; +import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; -//import lombok.Setter; -import jakarta.persistence.Column; + +import java.util.ArrayList; +import java.util.List; @Entity // 이 클래스가 데이터베이스 테이블과 매핑 @Getter // getId() 등 getter 메서드 자동 생성 @@ -27,6 +25,9 @@ public class Post { // 별도 이름 지정 안하면 클래스 명 소문자형 @Column(columnDefinition = "TEXT") // 멘토 조언 private String content; // 게시글 내용 + @OneToMany(mappedBy ="post", cascade = CascadeType.REMOVE, orphanRemoval = true) + private List comments = new ArrayList<>(); + // 생성자 public Post(String title, String content) { this.title = title; diff --git a/src/main/java/com/example/devSns/dto/CommentRequestDto.java b/src/main/java/com/example/devSns/dto/CommentRequestDto.java new file mode 100644 index 0000000..cc5301d --- /dev/null +++ b/src/main/java/com/example/devSns/dto/CommentRequestDto.java @@ -0,0 +1,12 @@ +package com.example.devSns.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CommentRequestDto { + private String content; + private Long postId; + private Long parentId; // 대댓글인 경우 부모 댓글의 ID, 아니면 null +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/dto/CommentResponseDto.java b/src/main/java/com/example/devSns/dto/CommentResponseDto.java new file mode 100644 index 0000000..40851a1 --- /dev/null +++ b/src/main/java/com/example/devSns/dto/CommentResponseDto.java @@ -0,0 +1,33 @@ +package com.example.devSns.dto; + +import com.example.devSns.domain.Comment; +import lombok.Getter; + +import java.util.List; +import java.util.stream.Collectors; + +@Getter +public class CommentResponseDto { + private Long id; + private String content; + private List children; // 대댓글 목록 + + // 엔티티를 DTO로 변환하는 정적 팩토리 메서드 (재귀 호출) + public static CommentResponseDto from(Comment comment) { + return new CommentResponseDto( + comment.getId(), + comment.getContent(), + // 자식 댓글(children)들도 재귀적으로 DTO로 변환 + comment.getChildren().stream() + .map(CommentResponseDto::from) + .collect(Collectors.toList()) + ); + } + + // private 생성자 + private CommentResponseDto(Long id, String content, List children) { + this.id = id; + this.content = content; + this.children = children; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/dto/CommentUpdateDto.java b/src/main/java/com/example/devSns/dto/CommentUpdateDto.java new file mode 100644 index 0000000..98b7bfc --- /dev/null +++ b/src/main/java/com/example/devSns/dto/CommentUpdateDto.java @@ -0,0 +1,11 @@ +package com.example.devSns.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CommentUpdateDto { + private String content; +} +// 수정 시에는 content만 변경 가능하도록 \ No newline at end of file diff --git a/src/main/java/com/example/devSns/repository/CommentRepository.java b/src/main/java/com/example/devSns/repository/CommentRepository.java new file mode 100644 index 0000000..956d694 --- /dev/null +++ b/src/main/java/com/example/devSns/repository/CommentRepository.java @@ -0,0 +1,14 @@ +package com.example.devSns.repository; + +import com.example.devSns.domain.Comment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface CommentRepository extends JpaRepository { + + // 특정 게시글의 모든 최상위 댓글을 조회 (대댓글이 아닌 댓글) + List findByPostIdAndParentIsNull(Long postId); +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/service/CommentService.java b/src/main/java/com/example/devSns/service/CommentService.java new file mode 100644 index 0000000..696df6d --- /dev/null +++ b/src/main/java/com/example/devSns/service/CommentService.java @@ -0,0 +1,76 @@ +package com.example.devSns.service; + +import com.example.devSns.domain.Comment; +import com.example.devSns.domain.Post; +import com.example.devSns.dto.CommentRequestDto; +import com.example.devSns.dto.CommentResponseDto; +import com.example.devSns.repository.CommentRepository; +import com.example.devSns.repository.PostRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class CommentService { + + private final CommentRepository commentRepository; + private final PostRepository postRepository; // PostService의 getPostById를 써도 됩니다. + + // 1. 댓글 생성 + @Transactional + public Comment createComment(CommentRequestDto requestDto) { + Post post = postRepository.findById(requestDto.getPostId()) + .orElseThrow(() -> new IllegalArgumentException("Post not found")); + + Comment parent = null; + // parentId가 null이 아니면 (즉, 대댓글이면) + if (requestDto.getParentId() != null) { + parent = commentRepository.findById(requestDto.getParentId()) + .orElseThrow(() -> new IllegalArgumentException("Parent comment not found")); + } + + Comment comment = new Comment(post, parent, requestDto.getContent()); + return commentRepository.save(comment); + } + + // 2. 특정 게시글의 모든 댓글 조회 (대댓글 구조 포함) + public List getCommentsByPost(Long postId) { + // 게시글이 존재하는지 확인 (없으면 PostService의 getPostById처럼 예외 처리) + postRepository.findById(postId) + .orElseThrow(() -> new IllegalArgumentException("Post not found")); + + // 1. 해당 게시글의 "최상위 댓글"만 찾는다. + List topLevelComments = commentRepository.findByPostIdAndParentIsNull(postId); + + // 2. 최상위 댓글들을 DTO로 변환 (CommentResponseDto.from이 재귀적으로 대댓글도 처리) + return topLevelComments.stream() + .map(CommentResponseDto::from) + .collect(Collectors.toList()); + } + + // 3. 댓글 수정 + @Transactional + public Comment updateComment(Long id, String content) { + Comment comment = commentRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Comment not found")); + + comment.update(content); // 엔티티의 update 메서드 사용 + return commentRepository.save(comment); + } + + // 4. 댓글 삭제 + @Transactional + public void deleteComment(Long id) { + // 댓글 존재 확인 + Comment comment = commentRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Comment not found")); + + // CaskadeType.REMOVE와 orphanRemoval=true 덕분에 + // 이 댓글(과 그 자식 댓글들)이 삭제됨 + commentRepository.delete(comment); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/service/PostService.java b/src/main/java/com/example/devSns/service/PostService.java index 3e5dc37..cc1d8f3 100644 --- a/src/main/java/com/example/devSns/service/PostService.java +++ b/src/main/java/com/example/devSns/service/PostService.java @@ -2,12 +2,15 @@ import com.example.devSns.domain.Post; import com.example.devSns.dto.PostRequestDto; +import com.example.devSns.dto.PostResponseDto; import com.example.devSns.repository.PostRepository; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import java.time.LocalTime; import java.util.List; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor // final 필드에 대한 생성자를 자동으로 만들어줍니다. @@ -23,8 +26,10 @@ public Post createPost(PostRequestDto requestDto) { } // 모든 게시글 조회 - public List getAllPosts() { - return postRepository.findAll(); + public ListgetAllPosts() { + return postRepository.findAll().stream() + .map(PostResponseDto::new) + .collect(Collectors.toList()); } // 특정 게시글 조회 From 9cd5eb655e625749736cadac3ccdf99de738b54c Mon Sep 17 00:00:00 2001 From: Trudy2645 Date: Fri, 7 Nov 2025 16:23:49 +0900 Subject: [PATCH 5/8] =?UTF-8?q?Feat:=20=ED=86=B5=ED=95=A9=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=B0=8F,=20service=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/CommentControllerTest.java | 78 +++++++++++++++++++ .../devSns/service/CommentServiceTest.java | 58 ++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 src/test/java/com/example/devSns/controller/CommentControllerTest.java create mode 100644 src/test/java/com/example/devSns/service/CommentServiceTest.java diff --git a/src/test/java/com/example/devSns/controller/CommentControllerTest.java b/src/test/java/com/example/devSns/controller/CommentControllerTest.java new file mode 100644 index 0000000..cd5759f --- /dev/null +++ b/src/test/java/com/example/devSns/controller/CommentControllerTest.java @@ -0,0 +1,78 @@ +package com.example.devSns.controller; + +import com.example.devSns.domain.Comment; +import com.example.devSns.domain.Post; +import com.example.devSns.repository.CommentRepository; +import com.example.devSns.repository.PostRepository; +import jakarta.persistence.EntityManager; // 1. 임포트 확인 +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional // 테스트 후 DB 롤백 (데이터 깔끔하게) +class CommentControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private PostRepository postRepository; + + @Autowired + private CommentRepository commentRepository; + + @Autowired + private EntityManager em; // 2. EntityManager 주입 + + private Post savedPost; + private Comment parentComment; + private Comment childComment; + + @BeforeEach // 각 테스트 실행 전에 DB 세팅 + void setUp() { + // 1. Given (준비) + savedPost = postRepository.save(new Post("테스트 게시글", "내용")); + + parentComment = commentRepository.save( + new Comment(savedPost, null, "1번 부모 댓글") + ); + + childComment = commentRepository.save( + new Comment(savedPost, parentComment, "1-1번 자식 댓글(대댓글)") + ); + + // 3. DB 강제 반영 및 1차 캐시 초기화 (★수정된 부분★) + em.flush(); + em.clear(); + } + + @DisplayName("특정 게시글의 댓글/대댓글 목록을 올바르게 조회한다.") + @Test + void getCommentsByPost() throws Exception { + // 2. When (실행) + // savedPost의 ID를 동적으로 가져와서 URL을 만듭니다. + mockMvc.perform(get("/api/posts/" + savedPost.getId() + "/comments")) + + // 3. Then (검증) + .andExpect(status().isOk()) + // $는 JSON 전체 응답, $[0]는 첫번째 댓글 + .andExpect(jsonPath("$.length()").value(1)) // 최상위 댓글은 1개 + .andExpect(jsonPath("$[0].id").value(parentComment.getId())) + .andExpect(jsonPath("$[0].content").value("1번 부모 댓글")) + // 이게 진짜배기: 자식 댓글(children) 검증 + .andExpect(jsonPath("$[0].children.length()").value(1)) // 1번 댓글의 자식은 1개 + .andExpect(jsonPath("$[0].children[0].id").value(childComment.getId())) + .andExpect(jsonPath("$[0].children[0].content").value("1-1번 자식 댓글(대댓글)")); + } +} \ No newline at end of file diff --git a/src/test/java/com/example/devSns/service/CommentServiceTest.java b/src/test/java/com/example/devSns/service/CommentServiceTest.java new file mode 100644 index 0000000..ac2ebf5 --- /dev/null +++ b/src/test/java/com/example/devSns/service/CommentServiceTest.java @@ -0,0 +1,58 @@ +package com.example.devSns.service; + +import com.example.devSns.domain.Comment; +import com.example.devSns.domain.Post; +import com.example.devSns.dto.CommentRequestDto; +import com.example.devSns.repository.CommentRepository; +import com.example.devSns.repository.PostRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) // Mockito 사용 +class CommentServiceTest { + + @Mock // 가짜 Repository 만들기 + private CommentRepository commentRepository; + + @Mock // 가짜 Repository 만들기 + private PostRepository postRepository; + + @InjectMocks // @Mock을 @InjectMocks에 주입 + private CommentService commentService; + + @DisplayName("대댓글을 성공적으로 생성한다.") + @Test + void 한글() { + // 1. Given (준비) + CommentRequestDto requestDto = new CommentRequestDto(); + requestDto.setContent("대댓글입니다."); + requestDto.setPostId(1L); + requestDto.setParentId(10L); // 부모 댓글 ID 10 + + Post mockPost = new Post("테스트", "내용"); + Comment mockParentComment = new Comment(mockPost, null, "부모 댓글"); + + // "postRepository.findById(1L)이 호출되면, 가짜 Post를 반환해라" + when(postRepository.findById(1L)).thenReturn(Optional.of(mockPost)); + + // "commentRepository.findById(10L)이 호출되면, 가짜 부모 Comment를 반환해라" + when(commentRepository.findById(10L)).thenReturn(Optional.of(mockParentComment)); + + // 2. When (실행) + commentService.createComment(requestDto); + + // 3. Then (검증) + // "commentRepository.save() 메서드가 1번 호출되었는지 검증" + // (즉, 저장이 잘 되었는지) + verify(commentRepository, times(1)).save(any(Comment.class)); + } +} \ No newline at end of file From 05ba092173576bac39cf121e9c1136e2ad0b6b97 Mon Sep 17 00:00:00 2001 From: Trudy2645 Date: Fri, 7 Nov 2025 16:26:25 +0900 Subject: [PATCH 6/8] =?UTF-8?q?Chore:=20=EB=B3=80=EC=88=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/example/devSns/service/CommentServiceTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/example/devSns/service/CommentServiceTest.java b/src/test/java/com/example/devSns/service/CommentServiceTest.java index ac2ebf5..b1b2faa 100644 --- a/src/test/java/com/example/devSns/service/CommentServiceTest.java +++ b/src/test/java/com/example/devSns/service/CommentServiceTest.java @@ -31,7 +31,7 @@ class CommentServiceTest { @DisplayName("대댓글을 성공적으로 생성한다.") @Test - void 한글() { + void createReplyComment() { // 1. Given (준비) CommentRequestDto requestDto = new CommentRequestDto(); requestDto.setContent("대댓글입니다."); From 1f907f490e64c18ebd5341e291ff551268d26475 Mon Sep 17 00:00:00 2001 From: Trudy2645 Date: Fri, 14 Nov 2025 09:48:40 +0900 Subject: [PATCH 7/8] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EB=B0=8F=20'=EC=A2=8B=EC=95=84=EC=9A=94',=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SNS의 핵심 기능인 회원 시스템을 추가합니다. 이제 모든 게시글과 댓글에 작성자 정보가 포함되며, '좋아요'와 회원 프로필 조회가 가능합니다. Member (회원가입, 닉네임 검색) 기능 추가 PostLike ('좋아요' 토글) 기능 추가 회원 프로필 (작성 글/댓글/좋아요 목록) 조회 API 추가 기존 Post, Comment에 작성자 연동 및 권한 확인 로직 추가 --- .../devSns/controller/CommentController.java | 22 +++-- .../devSns/controller/LikeController.java | 25 ++++++ .../devSns/controller/MemberController.java | 34 ++++++++ .../devSns/controller/PostController.java | 21 ++--- .../com/example/devSns/domain/Comment.java | 63 +++----------- .../com/example/devSns/domain/Member.java | 44 ++++++++++ .../java/com/example/devSns/domain/Post.java | 37 +++++---- .../com/example/devSns/domain/PostLike.java | 29 +++++++ .../example/devSns/dto/CommentRequestDto.java | 3 +- .../devSns/dto/CommentResponseDto.java | 12 +-- .../example/devSns/dto/CommentUpdateDto.java | 4 +- .../example/devSns/dto/LikeRequestDto.java | 11 +++ .../devSns/dto/MemberProfileResponseDto.java | 23 +++++ .../example/devSns/dto/MemberResponseDto.java | 15 ++++ .../devSns/dto/MemberSignUpRequestDto.java | 12 +++ .../example/devSns/dto/PostRequestDto.java | 11 +-- .../example/devSns/dto/PostResponseDto.java | 5 ++ .../devSns/repository/CommentRepository.java | 4 +- .../devSns/repository/MemberRepository.java | 13 +++ .../devSns/repository/PostLikeRepository.java | 19 +++++ .../devSns/repository/PostRepository.java | 5 +- .../devSns/service/CommentService.java | 32 ++++--- .../example/devSns/service/LikeService.java | 41 +++++++++ .../example/devSns/service/MemberService.java | 83 +++++++++++++++++++ .../example/devSns/service/PostService.java | 31 +++++-- 25 files changed, 473 insertions(+), 126 deletions(-) create mode 100644 src/main/java/com/example/devSns/controller/LikeController.java create mode 100644 src/main/java/com/example/devSns/controller/MemberController.java create mode 100644 src/main/java/com/example/devSns/domain/Member.java create mode 100644 src/main/java/com/example/devSns/domain/PostLike.java create mode 100644 src/main/java/com/example/devSns/dto/LikeRequestDto.java create mode 100644 src/main/java/com/example/devSns/dto/MemberProfileResponseDto.java create mode 100644 src/main/java/com/example/devSns/dto/MemberResponseDto.java create mode 100644 src/main/java/com/example/devSns/dto/MemberSignUpRequestDto.java create mode 100644 src/main/java/com/example/devSns/repository/MemberRepository.java create mode 100644 src/main/java/com/example/devSns/repository/PostLikeRepository.java create mode 100644 src/main/java/com/example/devSns/service/LikeService.java create mode 100644 src/main/java/com/example/devSns/service/MemberService.java diff --git a/src/main/java/com/example/devSns/controller/CommentController.java b/src/main/java/com/example/devSns/controller/CommentController.java index c1c74fd..5ed25bc 100644 --- a/src/main/java/com/example/devSns/controller/CommentController.java +++ b/src/main/java/com/example/devSns/controller/CommentController.java @@ -12,40 +12,38 @@ import java.util.List; @RestController -@RequestMapping("/api") // /api/posts 와 /api/comments 로 분리 +@RequestMapping("/api") @RequiredArgsConstructor public class CommentController { private final CommentService commentService; - // 1. 댓글 생성 (POST /api/comments) - // (대댓글이든 일반 댓글이든 이 API 하나로 처리) + // 1. 댓글 생성 @PostMapping("/comments") public CommentResponseDto createComment(@RequestBody CommentRequestDto requestDto) { Comment comment = commentService.createComment(requestDto); - // 생성된 댓글이 대댓글일 수 있으므로, 재귀 DTO 변환을 피하기 위해 - // 간단히 id와 content만 반환하거나, from을 쓰되 1레벨만 반환하도록 할 수 있습니다. - // 여기서는 간단하게 from을 사용합니다. return CommentResponseDto.from(comment); } - // 2. 특정 게시글의 모든 댓글/대댓글 조회 (GET /api/posts/{postId}/comments) + // 2. 특정 게시글의 모든 댓글/대댓글 조회 @GetMapping("/posts/{postId}/comments") public List getComments(@PathVariable Long postId) { return commentService.getCommentsByPost(postId); } - // 3. 댓글 수정 (PUT /api/comments/{id}) + // 3. 댓글 수정 @PutMapping("/comments/{id}") public CommentResponseDto updateComment(@PathVariable Long id, @RequestBody CommentUpdateDto updateDto) { - Comment updatedComment = commentService.updateComment(id, updateDto.getContent()); + // DTO에 memberId가 포함되어 서비스단에서 권한 확인 + Comment updatedComment = commentService.updateComment(id, updateDto); return CommentResponseDto.from(updatedComment); } - // 4. 댓글 삭제 (DELETE /api/comments/{id}) + // 4. 댓글 삭제 @DeleteMapping("/comments/{id}") - public ResponseEntity deleteComment(@PathVariable Long id) { - commentService.deleteComment(id); + public ResponseEntity deleteComment(@PathVariable Long id, @RequestParam Long memberId) { + // 삭제는 memberId를 파라미터로 받아 권한 확인 + commentService.deleteComment(id, memberId); return ResponseEntity.ok().build(); } } \ No newline at end of file diff --git a/src/main/java/com/example/devSns/controller/LikeController.java b/src/main/java/com/example/devSns/controller/LikeController.java new file mode 100644 index 0000000..17c1654 --- /dev/null +++ b/src/main/java/com/example/devSns/controller/LikeController.java @@ -0,0 +1,25 @@ +package com.example.devSns.controller; + +import com.example.devSns.dto.LikeRequestDto; +import com.example.devSns.service.LikeService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/likes") +@RequiredArgsConstructor +public class LikeController { + + private final LikeService likeService; + + // 좋아요 토글 API + @PostMapping("/toggle") + public ResponseEntity toggleLike(@RequestBody LikeRequestDto requestDto) { + String result = likeService.toggleLike(requestDto); + return ResponseEntity.ok(result); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/controller/MemberController.java b/src/main/java/com/example/devSns/controller/MemberController.java new file mode 100644 index 0000000..a5fe346 --- /dev/null +++ b/src/main/java/com/example/devSns/controller/MemberController.java @@ -0,0 +1,34 @@ +package com.example.devSns.controller; + +import com.example.devSns.dto.MemberProfileResponseDto; +import com.example.devSns.dto.MemberResponseDto; +import com.example.devSns.dto.MemberSignUpRequestDto; +import com.example.devSns.service.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/members") +@RequiredArgsConstructor +public class MemberController { + + private final MemberService memberService; + + // 1. 회원가입 + @PostMapping("/signup") + public MemberResponseDto signup(@RequestBody MemberSignUpRequestDto requestDto) { + return memberService.signup(requestDto); + } + + // 2. 회원 닉네임으로 검색 + @GetMapping("/search") + public MemberResponseDto searchByNickname(@RequestParam String nickname) { + return memberService.searchByNickname(nickname); + } + + // 3. 회원 프로필 조회 (작성한 글, 댓글, 좋아요한 글) + @GetMapping("/{id}/profile") + public MemberProfileResponseDto getMemberProfile(@PathVariable Long id) { + return memberService.getMemberProfile(id); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/controller/PostController.java b/src/main/java/com/example/devSns/controller/PostController.java index 40ea602..72d6c1c 100644 --- a/src/main/java/com/example/devSns/controller/PostController.java +++ b/src/main/java/com/example/devSns/controller/PostController.java @@ -9,46 +9,47 @@ import org.springframework.web.bind.annotation.*; import java.util.List; -import java.util.stream.Collectors; @RestController -@RequestMapping("/api/posts") // http://.../api/posts 로 시작하는 모든 요청을 이 컨트롤러가 처리 +@RequestMapping("/api/posts") @RequiredArgsConstructor public class PostController { private final PostService postService; - // 1. 게시글 생성 (POST) + // 1. 게시글 생성 @PostMapping public PostResponseDto createPost(@RequestBody PostRequestDto requestDto) { Post post = postService.createPost(requestDto); return new PostResponseDto(post); } - // 2. 모든 게시글 조회 (GET) + // 2. 모든 게시글 조회 @GetMapping public List getAllPosts() { return postService.getAllPosts(); } - // 3. 특정 게시글 조회 (GET) + // 3. 특정 게시글 조회 @GetMapping("/{id}") public PostResponseDto getPostById(@PathVariable Long id) { Post post = postService.getPostById(id); return new PostResponseDto(post); } - // 4. 게시글 수정 (PUT) + // 4. 게시글 수정 @PutMapping public PostResponseDto updatePost(@RequestBody PostRequestDto requestDto) { + // DTO에 memberId가 포함되어 서비스단에서 권한 확인 Post updatedPost = postService.updatePost(requestDto); return new PostResponseDto(updatedPost); } - // 5. 게시글 삭제 (DELETE) + // 5. 게시글 삭제 @DeleteMapping("/{id}") - public ResponseEntity deletePost(@PathVariable Long id) { - postService.deletePost(id); - return ResponseEntity.ok().build(); // 성공적으로 처리되었음을 응답 (body는 없음) + public ResponseEntity deletePost(@PathVariable Long id, @RequestParam Long memberId) { + // 삭제는 memberId를 파라미터로 받아 권한 확인 + postService.deletePost(id, memberId); + return ResponseEntity.ok().build(); } } \ No newline at end of file diff --git a/src/main/java/com/example/devSns/domain/Comment.java b/src/main/java/com/example/devSns/domain/Comment.java index b159d84..2f9152a 100644 --- a/src/main/java/com/example/devSns/domain/Comment.java +++ b/src/main/java/com/example/devSns/domain/Comment.java @@ -17,72 +17,35 @@ public class Comment { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(columnDefinition = "TEXT", nullable = false) // 댓글 내용 (필수!) + @Column(columnDefinition = "TEXT", nullable = false) private String content; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "post_id") private Post post; - // 대댓글 (부모) + // 댓글 작성자 (N:1) @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "parent_id") + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name="parent_id") private Comment parent; - // 대댓글 (자식) - @OneToMany(mappedBy = "parent", cascade = CascadeType.REMOVE, orphanRemoval = true) + @OneToMany(mappedBy="parent",cascade = CascadeType.REMOVE, orphanRemoval = true) private List children = new ArrayList<>(); - // --- 생성자 --- - public Comment(Post post, Comment parent, String content) { + // 생성자 (Member 추가) + public Comment(Post post, Comment parent, String content, Member member) { this.post = post; this.parent = parent; this.content = content; + this.member = member; } - // --- 업데이트 메서드 --- + // 업데이트 메서드 public void update(String content) { this.content = content; } -} -// 데이터 모델 - -// Commnet (comment_id, post_id ) -// 연관관계 매핑 - -// 외래키 기본키 개념 -// 자동차랑 , 바퀴 - -// 자동차 바퀴 외래키 -// 자동차 (자동차 ID , 이름, 년식, 번호판, 수리 여부, 가격) -// 1 그랜저 , 21년, 999 , true, 10000 -// 2 스타렉스 , 24년 - - -// 바퀴에 외래키 -// 바퀴( 바퀴 id, 자동차 id(fk), 바퀴이름, 색깔 , 수리여부 ) -// 1 , 1, 1번 바퀴, 노란색 , false -// 2 , 1, 2번 바퀴, 빨간색 , true -// 3 , 2, 3번 바퀴, 초록색 , false -// 4 , 1, 4번 바퀴, 노란색 , true - -// 테이블은 양뱡향 참조 -// 객체는 단방향 참조 - -// 테이블에는 join inner join -// select * from wheel w join car c Using(car_id) -// on(w.car_id == c.car_id ) -// 두개가 같은 의미 -// 객체는 단방향 참조야 -// 댓글(id, post) -// 1, post - - -// 정규화 1정규화만 DB의 컬럼이 원자값을 가져야 함. - -// 댓글은 게시글 밑에 달리지, 댓글 또 댓글 이 달려 -// List commentList = new ArrayList<>(); -// Post post; 객체 -//--------- 이 중간단게 JPA -// 테이블 실제로 저장되는 정보는 객체 단위 x PK를 아이디의 형태로 ID -// 댓글 (내용, 작성자 ) +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/domain/Member.java b/src/main/java/com/example/devSns/domain/Member.java new file mode 100644 index 0000000..08a379d --- /dev/null +++ b/src/main/java/com/example/devSns/domain/Member.java @@ -0,0 +1,44 @@ +package com.example.devSns.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "members") // 'user'는 H2 DB 등에서 예약어일 수 있으므로 'members' 사용 +public class Member { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String username; // 로그인 ID + + @Column(nullable = false) + private String password; // 실제로는 해싱(Hashing) 필요 + + @Column(nullable = false, unique = true) + private String nickname; // 사용자가 표시할 이름 + + // Member가 삭제되면, 관련 Post도 모두 삭제 (Cascade) + @OneToMany(mappedBy = "member", cascade = CascadeType.REMOVE, orphanRemoval = true) + private List posts = new ArrayList<>(); + + @OneToMany(mappedBy = "member", cascade = CascadeType.REMOVE, orphanRemoval = true) + private List comments = new ArrayList<>(); + + @OneToMany(mappedBy = "member", cascade = CascadeType.REMOVE, orphanRemoval = true) + private List likes = new ArrayList<>(); + + public Member(String username, String password, String nickname) { + this.username = username; + this.password = password; + this.nickname = nickname; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/domain/Post.java b/src/main/java/com/example/devSns/domain/Post.java index 0e642aa..0d0413c 100644 --- a/src/main/java/com/example/devSns/domain/Post.java +++ b/src/main/java/com/example/devSns/domain/Post.java @@ -1,4 +1,4 @@ -package com.example.devSns.domain; // domain 패키지에 속한다 +package com.example.devSns.domain; import jakarta.persistence.*; import lombok.Getter; @@ -7,34 +7,41 @@ import java.util.ArrayList; import java.util.List; -@Entity // 이 클래스가 데이터베이스 테이블과 매핑 -@Getter // getId() 등 getter 메서드 자동 생성 -//@Setter 이거 위험함! 캡슐화 측면에서 주의할 것. - 멘토 조언 +@Entity +@Getter @NoArgsConstructor -public class Post { // 별도 이름 지정 안하면 클래스 명 소문자형태가 테이블명으로 사용됨 +public class Post { - @Id // 이 필드가 Primary Key임을 나타냄 - @GeneratedValue(strategy = GenerationType.IDENTITY) // 값 자동 생성 방식 지정 - // 즉 새 게시글 저장할 때 마다 DB가 알아서 id를 1,2,3 순으로 자동 부여함 + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - // 내용은 길이가 길 수 있으므로 DB에 TEXT로 지정(멘토 조언) @Column(columnDefinition = "TEXT") - private String title; // 게시글 제목 + private String title; - @Column(columnDefinition = "TEXT") // 멘토 조언 - private String content; // 게시글 내용 + @Column(columnDefinition = "TEXT") + private String content; + + // Post는 Member에 속함 (N:1) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; @OneToMany(mappedBy ="post", cascade = CascadeType.REMOVE, orphanRemoval = true) private List comments = new ArrayList<>(); - // 생성자 - public Post(String title, String content) { + // '좋아요' 목록 + @OneToMany(mappedBy = "post", cascade = CascadeType.REMOVE, orphanRemoval = true) + private List likes = new ArrayList<>(); + + // 생성자 (Member 추가) + public Post(String title, String content, Member member) { this.title = title; this.content = content; + this.member = member; } - // 안전한 업데이트 메서드 + // 업데이트 메서드 public void update(String title, String content) { this.title = title; this.content = content; diff --git a/src/main/java/com/example/devSns/domain/PostLike.java b/src/main/java/com/example/devSns/domain/PostLike.java new file mode 100644 index 0000000..d41265d --- /dev/null +++ b/src/main/java/com/example/devSns/domain/PostLike.java @@ -0,0 +1,29 @@ +package com.example.devSns.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "post_likes") +public class PostLike { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + private Post post; + + public PostLike(Member member, Post post) { + this.member = member; + this.post = post; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/dto/CommentRequestDto.java b/src/main/java/com/example/devSns/dto/CommentRequestDto.java index cc5301d..0d1a4ba 100644 --- a/src/main/java/com/example/devSns/dto/CommentRequestDto.java +++ b/src/main/java/com/example/devSns/dto/CommentRequestDto.java @@ -8,5 +8,6 @@ public class CommentRequestDto { private String content; private Long postId; - private Long parentId; // 대댓글인 경우 부모 댓글의 ID, 아니면 null + private Long parentId; + private Long memberId; // 작성자 ID 추가 } \ No newline at end of file diff --git a/src/main/java/com/example/devSns/dto/CommentResponseDto.java b/src/main/java/com/example/devSns/dto/CommentResponseDto.java index 40851a1..d1cdfa1 100644 --- a/src/main/java/com/example/devSns/dto/CommentResponseDto.java +++ b/src/main/java/com/example/devSns/dto/CommentResponseDto.java @@ -10,24 +10,26 @@ public class CommentResponseDto { private Long id; private String content; - private List children; // 대댓글 목록 + private String authorNickname; // 작성자 닉네임 + private List children; - // 엔티티를 DTO로 변환하는 정적 팩토리 메서드 (재귀 호출) public static CommentResponseDto from(Comment comment) { + String nickname = (comment.getMember() != null) ? comment.getMember().getNickname() : "Unknown"; + return new CommentResponseDto( comment.getId(), comment.getContent(), - // 자식 댓글(children)들도 재귀적으로 DTO로 변환 + nickname, // 닉네임 전달 comment.getChildren().stream() .map(CommentResponseDto::from) .collect(Collectors.toList()) ); } - // private 생성자 - private CommentResponseDto(Long id, String content, List children) { + private CommentResponseDto(Long id, String content, String authorNickname, List children) { this.id = id; this.content = content; + this.authorNickname = authorNickname; this.children = children; } } \ No newline at end of file diff --git a/src/main/java/com/example/devSns/dto/CommentUpdateDto.java b/src/main/java/com/example/devSns/dto/CommentUpdateDto.java index 98b7bfc..e22d777 100644 --- a/src/main/java/com/example/devSns/dto/CommentUpdateDto.java +++ b/src/main/java/com/example/devSns/dto/CommentUpdateDto.java @@ -7,5 +7,5 @@ @Setter public class CommentUpdateDto { private String content; -} -// 수정 시에는 content만 변경 가능하도록 \ No newline at end of file + private Long memberId; // 수정을 요청한 사람의 ID (권한 확인용) +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/dto/LikeRequestDto.java b/src/main/java/com/example/devSns/dto/LikeRequestDto.java new file mode 100644 index 0000000..75c858d --- /dev/null +++ b/src/main/java/com/example/devSns/dto/LikeRequestDto.java @@ -0,0 +1,11 @@ +package com.example.devSns.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class LikeRequestDto { + private Long memberId; + private Long postId; +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/dto/MemberProfileResponseDto.java b/src/main/java/com/example/devSns/dto/MemberProfileResponseDto.java new file mode 100644 index 0000000..7bd5348 --- /dev/null +++ b/src/main/java/com/example/devSns/dto/MemberProfileResponseDto.java @@ -0,0 +1,23 @@ +package com.example.devSns.dto; + +import com.example.devSns.domain.Member; +import lombok.Getter; + +import java.util.List; + +@Getter +public class MemberProfileResponseDto { + private Long id; + private String nickname; + private List posts; // 내가 쓴 글 + private List comments; // 내가 쓴 댓글 + private List likedPosts; // 내가 좋아요 한 글 + + public MemberProfileResponseDto(Member member, List posts, List comments, List likedPosts) { + this.id = member.getId(); + this.nickname = member.getNickname(); + this.posts = posts; + this.comments = comments; + this.likedPosts = likedPosts; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/dto/MemberResponseDto.java b/src/main/java/com/example/devSns/dto/MemberResponseDto.java new file mode 100644 index 0000000..e96af6b --- /dev/null +++ b/src/main/java/com/example/devSns/dto/MemberResponseDto.java @@ -0,0 +1,15 @@ +package com.example.devSns.dto; + +import com.example.devSns.domain.Member; +import lombok.Getter; + +@Getter +public class MemberResponseDto { + private Long id; + private String nickname; + + public MemberResponseDto(Member member) { + this.id = member.getId(); + this.nickname = member.getNickname(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/dto/MemberSignUpRequestDto.java b/src/main/java/com/example/devSns/dto/MemberSignUpRequestDto.java new file mode 100644 index 0000000..f45b00e --- /dev/null +++ b/src/main/java/com/example/devSns/dto/MemberSignUpRequestDto.java @@ -0,0 +1,12 @@ +package com.example.devSns.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class MemberSignUpRequestDto { + private String username; + private String password; + private String nickname; +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/dto/PostRequestDto.java b/src/main/java/com/example/devSns/dto/PostRequestDto.java index 3106135..06ae256 100644 --- a/src/main/java/com/example/devSns/dto/PostRequestDto.java +++ b/src/main/java/com/example/devSns/dto/PostRequestDto.java @@ -6,13 +6,8 @@ @Getter @Setter public class PostRequestDto { - private Long id; // 추가 구조 단순화를 위한 -멘토 조언 + private Long id; private String title; private String content; -} -/* -PostRequestDto 클라이언트 → 서버로 전달되는 데이터 구조 -PostResponseDto 서버 → 클라이언트로 반환되는 데이터 구조 -Post (Entity) DB 테이블과 직접 연결된 데이터 구조 - -* */ \ No newline at end of file + private Long memberId; // 작성자 ID 추가 +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/dto/PostResponseDto.java b/src/main/java/com/example/devSns/dto/PostResponseDto.java index 83b10db..f91351a 100644 --- a/src/main/java/com/example/devSns/dto/PostResponseDto.java +++ b/src/main/java/com/example/devSns/dto/PostResponseDto.java @@ -8,10 +8,15 @@ public class PostResponseDto { private final Long id; private final String title; private final String content; + private final String authorNickname; // 작성자 닉네임 + private final int likeCount; // 좋아요 수 public PostResponseDto(Post post) { this.id = post.getId(); this.title = post.getTitle(); this.content = post.getContent(); + // Member가 null일 경우를 대비한 방어 코드 + this.authorNickname = (post.getMember() != null) ? post.getMember().getNickname() : "Unknown"; + this.likeCount = (post.getLikes() != null) ? post.getLikes().size() : 0; } } \ No newline at end of file diff --git a/src/main/java/com/example/devSns/repository/CommentRepository.java b/src/main/java/com/example/devSns/repository/CommentRepository.java index 956d694..1d242d8 100644 --- a/src/main/java/com/example/devSns/repository/CommentRepository.java +++ b/src/main/java/com/example/devSns/repository/CommentRepository.java @@ -9,6 +9,8 @@ @Repository public interface CommentRepository extends JpaRepository { - // 특정 게시글의 모든 최상위 댓글을 조회 (대댓글이 아닌 댓글) List findByPostIdAndParentIsNull(Long postId); + + // 특정 회원이 작성한 모든 댓글 조회 (프로필용) + List findByMemberId(Long memberId); } \ No newline at end of file diff --git a/src/main/java/com/example/devSns/repository/MemberRepository.java b/src/main/java/com/example/devSns/repository/MemberRepository.java new file mode 100644 index 0000000..2488af8 --- /dev/null +++ b/src/main/java/com/example/devSns/repository/MemberRepository.java @@ -0,0 +1,13 @@ +package com.example.devSns.repository; + +import com.example.devSns.domain.Member; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface MemberRepository extends JpaRepository { + // 닉네임으로 회원 검색 (검색 기능용) + Optional findByNickname(String nickname); +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/repository/PostLikeRepository.java b/src/main/java/com/example/devSns/repository/PostLikeRepository.java new file mode 100644 index 0000000..9fe439e --- /dev/null +++ b/src/main/java/com/example/devSns/repository/PostLikeRepository.java @@ -0,0 +1,19 @@ +package com.example.devSns.repository; + +import com.example.devSns.domain.Member; +import com.example.devSns.domain.Post; +import com.example.devSns.domain.PostLike; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface PostLikeRepository extends JpaRepository { + // 특정 회원이 특정 게시글에 '좋아요'를 눌렀는지 확인 + Optional findByMemberAndPost(Member member, Post post); + + // 특정 회원이 '좋아요' 누른 모든 PostLike 조회 (프로필용) + List findByMember(Member member); +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/repository/PostRepository.java b/src/main/java/com/example/devSns/repository/PostRepository.java index 8ba9a2d..9736075 100644 --- a/src/main/java/com/example/devSns/repository/PostRepository.java +++ b/src/main/java/com/example/devSns/repository/PostRepository.java @@ -4,7 +4,10 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; // import 추가 + @Repository public interface PostRepository extends JpaRepository { - // <엔티티 이름, ID의 타입> + // 특정 회원이 작성한 모든 게시글 조회 (프로필용) + List findByMemberId(Long memberId); } \ No newline at end of file diff --git a/src/main/java/com/example/devSns/service/CommentService.java b/src/main/java/com/example/devSns/service/CommentService.java index 696df6d..4dd2ead 100644 --- a/src/main/java/com/example/devSns/service/CommentService.java +++ b/src/main/java/com/example/devSns/service/CommentService.java @@ -1,9 +1,11 @@ package com.example.devSns.service; import com.example.devSns.domain.Comment; +import com.example.devSns.domain.Member; import com.example.devSns.domain.Post; import com.example.devSns.dto.CommentRequestDto; import com.example.devSns.dto.CommentResponseDto; +import com.example.devSns.dto.CommentUpdateDto; // DTO import import com.example.devSns.repository.CommentRepository; import com.example.devSns.repository.PostRepository; import jakarta.transaction.Transactional; @@ -18,7 +20,8 @@ public class CommentService { private final CommentRepository commentRepository; - private final PostRepository postRepository; // PostService의 getPostById를 써도 됩니다. + private final PostRepository postRepository; + private final MemberService memberService; // Member 조회용 // 1. 댓글 생성 @Transactional @@ -26,27 +29,25 @@ public Comment createComment(CommentRequestDto requestDto) { Post post = postRepository.findById(requestDto.getPostId()) .orElseThrow(() -> new IllegalArgumentException("Post not found")); + Member member = memberService.getMemberById(requestDto.getMemberId()); + Comment parent = null; - // parentId가 null이 아니면 (즉, 대댓글이면) if (requestDto.getParentId() != null) { parent = commentRepository.findById(requestDto.getParentId()) .orElseThrow(() -> new IllegalArgumentException("Parent comment not found")); } - Comment comment = new Comment(post, parent, requestDto.getContent()); + Comment comment = new Comment(post, parent, requestDto.getContent(), member); return commentRepository.save(comment); } // 2. 특정 게시글의 모든 댓글 조회 (대댓글 구조 포함) public List getCommentsByPost(Long postId) { - // 게시글이 존재하는지 확인 (없으면 PostService의 getPostById처럼 예외 처리) postRepository.findById(postId) .orElseThrow(() -> new IllegalArgumentException("Post not found")); - // 1. 해당 게시글의 "최상위 댓글"만 찾는다. List topLevelComments = commentRepository.findByPostIdAndParentIsNull(postId); - // 2. 최상위 댓글들을 DTO로 변환 (CommentResponseDto.from이 재귀적으로 대댓글도 처리) return topLevelComments.stream() .map(CommentResponseDto::from) .collect(Collectors.toList()); @@ -54,23 +55,30 @@ public List getCommentsByPost(Long postId) { // 3. 댓글 수정 @Transactional - public Comment updateComment(Long id, String content) { + public Comment updateComment(Long id, CommentUpdateDto updateDto) { Comment comment = commentRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("Comment not found")); - comment.update(content); // 엔티티의 update 메서드 사용 + // 권한 확인 + if (!comment.getMember().getId().equals(updateDto.getMemberId())) { + throw new IllegalArgumentException("You are not authorized to update this comment."); + } + + comment.update(updateDto.getContent()); return commentRepository.save(comment); } // 4. 댓글 삭제 @Transactional - public void deleteComment(Long id) { - // 댓글 존재 확인 + public void deleteComment(Long id, Long memberId) { Comment comment = commentRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("Comment not found")); - // CaskadeType.REMOVE와 orphanRemoval=true 덕분에 - // 이 댓글(과 그 자식 댓글들)이 삭제됨 + // 권한 확인 + if (!comment.getMember().getId().equals(memberId)) { + throw new IllegalArgumentException("You are not authorized to delete this comment."); + } + commentRepository.delete(comment); } } \ No newline at end of file diff --git a/src/main/java/com/example/devSns/service/LikeService.java b/src/main/java/com/example/devSns/service/LikeService.java new file mode 100644 index 0000000..fef0817 --- /dev/null +++ b/src/main/java/com/example/devSns/service/LikeService.java @@ -0,0 +1,41 @@ +package com.example.devSns.service; + +import com.example.devSns.domain.Member; +import com.example.devSns.domain.Post; +import com.example.devSns.domain.PostLike; +import com.example.devSns.dto.LikeRequestDto; +import com.example.devSns.repository.PostLikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class LikeService { + + private final PostLikeRepository postLikeRepository; + private final MemberService memberService; // Member 조회용 + private final PostService postService; // Post 조회용 + + // 좋아요 토글 (눌렀으면 취소, 안 눌렀으면 추가) + @Transactional + public String toggleLike(LikeRequestDto requestDto) { + Member member = memberService.getMemberById(requestDto.getMemberId()); + Post post = postService.getPostById(requestDto.getPostId()); + + Optional existingLike = postLikeRepository.findByMemberAndPost(member, post); + + if (existingLike.isPresent()) { + // 이미 좋아요 누름 -> 좋아요 취소 + postLikeRepository.delete(existingLike.get()); + return "Like removed"; + } else { + // 좋아요 안 누름 -> 좋아요 추가 + PostLike newLike = new PostLike(member, post); + postLikeRepository.save(newLike); + return "Like added"; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/service/MemberService.java b/src/main/java/com/example/devSns/service/MemberService.java new file mode 100644 index 0000000..2f1e606 --- /dev/null +++ b/src/main/java/com/example/devSns/service/MemberService.java @@ -0,0 +1,83 @@ +package com.example.devSns.service; + +import com.example.devSns.domain.Comment; +import com.example.devSns.domain.Member; +import com.example.devSns.domain.Post; +import com.example.devSns.domain.PostLike; +import com.example.devSns.dto.CommentResponseDto; +import com.example.devSns.dto.MemberProfileResponseDto; +import com.example.devSns.dto.MemberResponseDto; +import com.example.devSns.dto.MemberSignUpRequestDto; +import com.example.devSns.dto.PostResponseDto; +import com.example.devSns.repository.CommentRepository; +import com.example.devSns.repository.MemberRepository; +import com.example.devSns.repository.PostLikeRepository; +import com.example.devSns.repository.PostRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class MemberService { + + private final MemberRepository memberRepository; + private final PostRepository postRepository; + private final CommentRepository commentRepository; + private final PostLikeRepository postLikeRepository; + + // 회원가입 + @Transactional + public MemberResponseDto signup(MemberSignUpRequestDto requestDto) { + // 닉네임 중복 확인 (간단하게) + if (memberRepository.findByNickname(requestDto.getNickname()).isPresent()) { + throw new IllegalArgumentException("이미 존재하는 닉네임입니다."); + } + // TODO: 패스워드 암호화 + Member member = new Member(requestDto.getUsername(), requestDto.getPassword(), requestDto.getNickname()); + Member savedMember = memberRepository.save(member); + return new MemberResponseDto(savedMember); + } + + // 회원 ID로 조회 (내부 로직용) + public Member getMemberById(Long id) { + return memberRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Member not found")); + } + + // 회원 닉네임으로 검색 + @Transactional(readOnly = true) + public MemberResponseDto searchByNickname(String nickname) { + Member member = memberRepository.findByNickname(nickname) + .orElseThrow(() -> new IllegalArgumentException("Member not found")); + return new MemberResponseDto(member); + } + + // 회원 프로필 조회 (작성한 글, 댓글, 좋아요한 글) + @Transactional(readOnly = true) + public MemberProfileResponseDto getMemberProfile(Long id) { + Member member = getMemberById(id); + + // 1. 회원이 작성한 글 + List posts = postRepository.findByMemberId(id).stream() + .map(PostResponseDto::new) + .collect(Collectors.toList()); + + // 2. 회원이 작성한 댓글 (대댓글 구조 미포함, 단순 리스트) + List comments = commentRepository.findByMemberId(id).stream() + .map(CommentResponseDto::from) + .collect(Collectors.toList()); + + // 3. 회원이 좋아요한 글 + List likes = postLikeRepository.findByMember(member); + List likedPosts = likes.stream() + .map(PostLike::getPost) + .map(PostResponseDto::new) + .collect(Collectors.toList()); + + return new MemberProfileResponseDto(member, posts, comments, likedPosts); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/service/PostService.java b/src/main/java/com/example/devSns/service/PostService.java index cc1d8f3..9322549 100644 --- a/src/main/java/com/example/devSns/service/PostService.java +++ b/src/main/java/com/example/devSns/service/PostService.java @@ -1,5 +1,6 @@ package com.example.devSns.service; +import com.example.devSns.domain.Member; import com.example.devSns.domain.Post; import com.example.devSns.dto.PostRequestDto; import com.example.devSns.dto.PostResponseDto; @@ -8,31 +9,32 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import java.time.LocalTime; import java.util.List; import java.util.stream.Collectors; @Service -@RequiredArgsConstructor // final 필드에 대한 생성자를 자동으로 만들어줍니다. +@RequiredArgsConstructor public class PostService { private final PostRepository postRepository; + private final MemberService memberService; // Member 조회용 // 게시글 생성 @Transactional public Post createPost(PostRequestDto requestDto) { - Post post = new Post(requestDto.getTitle(), requestDto.getContent()); + Member member = memberService.getMemberById(requestDto.getMemberId()); + Post post = new Post(requestDto.getTitle(), requestDto.getContent(), member); return postRepository.save(post); } // 모든 게시글 조회 - public ListgetAllPosts() { + public List getAllPosts() { return postRepository.findAll().stream() .map(PostResponseDto::new) .collect(Collectors.toList()); } - // 특정 게시글 조회 + // 특정 게시글 조회 (ID) public Post getPostById(Long id) { return postRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("ID not found: " + id)); @@ -42,15 +44,26 @@ public Post getPostById(Long id) { @Transactional public Post updatePost(PostRequestDto requestDto) { Post post = getPostById(requestDto.getId()); -// post.setTitle(requestDto.getTitle()); -// post.setContent(requestDto.getContent()); 위험함! 무결성 해칠 수 있음 - post.update(requestDto.getTitle(), requestDto.getContent()); // 엔티티 메서드 사용 + + // 간단한 권한 확인 (작성자 본인만 수정 가능) + if (!post.getMember().getId().equals(requestDto.getMemberId())) { + throw new IllegalArgumentException("You are not authorized to update this post."); + } + + post.update(requestDto.getTitle(), requestDto.getContent()); return postRepository.save(post); } // 게시글 삭제 @Transactional - public void deletePost(Long id) { + public void deletePost(Long id, Long memberId) { + Post post = getPostById(id); + + // 간단한 권한 확인 (작성자 본인만 삭제 가능) + if (!post.getMember().getId().equals(memberId)) { + throw new IllegalArgumentException("You are not authorized to delete this post."); + } + postRepository.deleteById(id); } } \ No newline at end of file From a1213474a6e2ff8e808e494055ef4a07a376b99e Mon Sep 17 00:00:00 2001 From: Trudy2645 Date: Thu, 27 Nov 2025 23:47:29 +0900 Subject: [PATCH 8/8] =?UTF-8?q?Feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20+=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20[JWT=20=EA=B8=B0=EB=B0=98=20=EA=B5=AC=ED=98=84]=20+?= =?UTF-8?q?=20RefreshToken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 22 ++-- .../example/devSns/config/SecurityConfig.java | 52 ++++++++ .../devSns/controller/AuthController.java | 36 ++++++ .../com/example/devSns/domain/Authority.java | 5 + .../com/example/devSns/domain/Member.java | 21 ++- .../example/devSns/domain/RefreshToken.java | 34 +++++ .../devSns/dto/MemberLoginRequestDto.java | 19 +++ .../example/devSns/dto/MemberResponseDto.java | 17 ++- .../devSns/dto/MemberSignUpRequestDto.java | 15 +++ .../java/com/example/devSns/dto/TokenDto.java | 15 +++ .../example/devSns/dto/TokenRequestDto.java | 11 ++ .../devSns/repository/MemberRepository.java | 8 +- .../repository/RefreshTokenRepository.java | 12 ++ .../example/devSns/security/JwtFilter.java | 47 +++++++ .../devSns/security/JwtTokenProvider.java | 120 ++++++++++++++++++ .../example/devSns/service/AuthService.java | 94 ++++++++++++++ .../service/CustomUserDetailsService.java | 41 ++++++ .../example/devSns/service/MemberService.java | 4 +- .../example/devSns/service/PostService.java | 1 + src/main/resources/appication.yml | 16 +++ src/main/resources/application.properties | 1 - 21 files changed, 571 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/example/devSns/config/SecurityConfig.java create mode 100644 src/main/java/com/example/devSns/controller/AuthController.java create mode 100644 src/main/java/com/example/devSns/domain/Authority.java create mode 100644 src/main/java/com/example/devSns/domain/RefreshToken.java create mode 100644 src/main/java/com/example/devSns/dto/MemberLoginRequestDto.java create mode 100644 src/main/java/com/example/devSns/dto/TokenDto.java create mode 100644 src/main/java/com/example/devSns/dto/TokenRequestDto.java create mode 100644 src/main/java/com/example/devSns/repository/RefreshTokenRepository.java create mode 100644 src/main/java/com/example/devSns/security/JwtFilter.java create mode 100644 src/main/java/com/example/devSns/security/JwtTokenProvider.java create mode 100644 src/main/java/com/example/devSns/service/AuthService.java create mode 100644 src/main/java/com/example/devSns/service/CustomUserDetailsService.java create mode 100644 src/main/resources/appication.yml delete mode 100644 src/main/resources/application.properties diff --git a/build.gradle b/build.gradle index 13e40b5..bb943af 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id "java" - id "org.springframework.boot" version "3.5.6" + id "org.springframework.boot" version "3.5.6" // 사용자 버전 유지 id "io.spring.dependency-management" version "1.1.7" } @@ -19,19 +19,25 @@ repositories { } dependencies { - // 기본 웹 라이브러리 + // 1. 기본 웹 라이브러리 implementation 'org.springframework.boot:spring-boot-starter-web' - // 데이터베이스 연동(JPA) 라이브러리 + // 2. 데이터베이스 연동(JPA) 라이브러리 implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - // 테스트용 H2 인메모리 데이터베이스 - runtimeOnly 'com.h2database:h2' - // Lombok 라이브러리 (코드 자동 생성) + + // 3. 보안 및 JWT 관련 라이브러리 (추가됨) + implementation 'org.springframework.boot:spring-boot-starter-security' // Spring Security 코어 + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' // JWT 인터페이스 + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' // JWT 구현체 + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' // JSON 처리를 위한 Jackson 연동 + testImplementation 'org.springframework.security:spring-security-test' // 보안 테스트용 + + // 4. 유틸리티 및 DB 드라이버 compileOnly 'org.projectlombok:lombok' + runtimeOnly 'com.h2database:h2' annotationProcessor 'org.projectlombok:lombok' - // --- ▲▲▲▲▲ 여기까지 추가 ▲▲▲▲▲ --- - // 기본 테스트 라이브러리 + // 5. 테스트 라이브러리 testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } diff --git a/src/main/java/com/example/devSns/config/SecurityConfig.java b/src/main/java/com/example/devSns/config/SecurityConfig.java new file mode 100644 index 0000000..1fa1644 --- /dev/null +++ b/src/main/java/com/example/devSns/config/SecurityConfig.java @@ -0,0 +1,52 @@ +package com.example.devSns.config; + +import com.example.devSns.security.JwtFilter; +import com.example.devSns.security.JwtTokenProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtTokenProvider jwtTokenProvider; + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + // CSRF 설정 Disable + .csrf((csrf) -> csrf.disable()) + + // 세션 사용 안 함 (STATELESS) + .sessionManagement((session) -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + + // 요청 권한 설정 + .authorizeHttpRequests((requests) -> requests + .requestMatchers("/api/auth/**", "/h2-console/**").permitAll() // 로그인, 회원가입은 누구나 접근 가능 + .anyRequest().authenticated() // 그 외 모든 요청은 인증 필요 + ) + + // H2 Console iframe 허용 + .headers((headers) -> headers.frameOptions(frameOptions -> frameOptions.disable())) + + // JwtFilter 를 UsernamePasswordAuthenticationFilter 앞에 등록 + .addFilterBefore(new JwtFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/controller/AuthController.java b/src/main/java/com/example/devSns/controller/AuthController.java new file mode 100644 index 0000000..aa0ea28 --- /dev/null +++ b/src/main/java/com/example/devSns/controller/AuthController.java @@ -0,0 +1,36 @@ +package com.example.devSns.controller; + +import com.example.devSns.dto.MemberLoginRequestDto; +import com.example.devSns.dto.MemberResponseDto; +import com.example.devSns.dto.MemberSignUpRequestDto; +import com.example.devSns.dto.TokenRequestDto; +import com.example.devSns.dto.TokenDto; +import com.example.devSns.service.AuthService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + private final AuthService authService; + + @PostMapping("/signup") + public ResponseEntity signup(@RequestBody MemberSignUpRequestDto requestDto) { + return ResponseEntity.ok(authService.signup(requestDto)); + } + + @PostMapping("/login") + public ResponseEntity login(@RequestBody MemberLoginRequestDto requestDto) { + return ResponseEntity.ok(authService.login(requestDto)); + } + + @PostMapping("/reissue") + public ResponseEntity reissue(@RequestBody TokenRequestDto requestDto) { + return ResponseEntity.ok(authService.reissue(requestDto)); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/domain/Authority.java b/src/main/java/com/example/devSns/domain/Authority.java new file mode 100644 index 0000000..694c963 --- /dev/null +++ b/src/main/java/com/example/devSns/domain/Authority.java @@ -0,0 +1,5 @@ +package com.example.devSns.domain; + +public enum Authority { + ROLE_USER, ROLE_ADMIN +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/domain/Member.java b/src/main/java/com/example/devSns/domain/Member.java index 08a379d..97ea6a7 100644 --- a/src/main/java/com/example/devSns/domain/Member.java +++ b/src/main/java/com/example/devSns/domain/Member.java @@ -1,6 +1,8 @@ package com.example.devSns.domain; import jakarta.persistence.*; +import lombok.AllArgsConstructor; // 추가 +import lombok.Builder; // 추가 import lombok.Getter; import lombok.NoArgsConstructor; @@ -10,7 +12,9 @@ @Entity @Getter @NoArgsConstructor -@Table(name = "members") // 'user'는 H2 DB 등에서 예약어일 수 있으므로 'members' 사용 +@AllArgsConstructor // Builder 패턴 사용 시 필요 +@Builder // ★ 클래스 레벨에 추가하여 builder() 메서드 자동 생성 ★ +@Table(name = "members") public class Member { @Id @@ -21,21 +25,30 @@ public class Member { private String username; // 로그인 ID @Column(nullable = false) - private String password; // 실제로는 해싱(Hashing) 필요 + private String password; @Column(nullable = false, unique = true) - private String nickname; // 사용자가 표시할 이름 + private String nickname; + + @Enumerated(EnumType.STRING) + private Authority authority; // 권한 - // Member가 삭제되면, 관련 Post도 모두 삭제 (Cascade) @OneToMany(mappedBy = "member", cascade = CascadeType.REMOVE, orphanRemoval = true) + @Builder.Default // Builder 사용 시 초기화 값을 유지하기 위해 필요 private List posts = new ArrayList<>(); @OneToMany(mappedBy = "member", cascade = CascadeType.REMOVE, orphanRemoval = true) + @Builder.Default private List comments = new ArrayList<>(); @OneToMany(mappedBy = "member", cascade = CascadeType.REMOVE, orphanRemoval = true) + @Builder.Default private List likes = new ArrayList<>(); + // 특정 필드만 사용하는 생성자가 필요하다면 유지, @Builder가 클래스에 있으면 생략 가능하나 + // 명시적인 생성자가 필요할 경우 아래와 같이 별도 @Builder 적용 가능 + // 여기서는 클래스 레벨 @Builder로 통일하는 것을 권장합니다. + public Member(String username, String password, String nickname) { this.username = username; this.password = password; diff --git a/src/main/java/com/example/devSns/domain/RefreshToken.java b/src/main/java/com/example/devSns/domain/RefreshToken.java new file mode 100644 index 0000000..ebf6343 --- /dev/null +++ b/src/main/java/com/example/devSns/domain/RefreshToken.java @@ -0,0 +1,34 @@ +package com.example.devSns.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@Table(name = "refresh_token") +@Entity +public class RefreshToken { + + @Id + @Column(name = "rt_key") + private String key; // Member ID (username) + + @Column(name = "rt_value") + private String value; // Refresh Token 값 + + @Builder + public RefreshToken(String key, String value) { + this.key = key; + this.value = value; + } + + public RefreshToken updateValue(String token) { + this.value = token; + return this; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/dto/MemberLoginRequestDto.java b/src/main/java/com/example/devSns/dto/MemberLoginRequestDto.java new file mode 100644 index 0000000..e4d9411 --- /dev/null +++ b/src/main/java/com/example/devSns/dto/MemberLoginRequestDto.java @@ -0,0 +1,19 @@ +package com.example.devSns.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class MemberLoginRequestDto { + private String username; + private String password; + + // 아이디/비번을 인증 토큰 형태로 변환하는 메서드 + public UsernamePasswordAuthenticationToken toAuthentication() { + return new UsernamePasswordAuthenticationToken(username, password); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/dto/MemberResponseDto.java b/src/main/java/com/example/devSns/dto/MemberResponseDto.java index e96af6b..85e8b4d 100644 --- a/src/main/java/com/example/devSns/dto/MemberResponseDto.java +++ b/src/main/java/com/example/devSns/dto/MemberResponseDto.java @@ -1,15 +1,24 @@ package com.example.devSns.dto; import com.example.devSns.domain.Member; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; @Getter +@AllArgsConstructor +@NoArgsConstructor +@Builder public class MemberResponseDto { - private Long id; + private String username; private String nickname; - public MemberResponseDto(Member member) { - this.id = member.getId(); - this.nickname = member.getNickname(); + // Member 엔티티를 받아서 DTO로 변환해주는 정적 메서드 + public static MemberResponseDto of(Member member) { + return MemberResponseDto.builder() + .username(member.getUsername()) + .nickname(member.getNickname()) + .build(); } } \ No newline at end of file diff --git a/src/main/java/com/example/devSns/dto/MemberSignUpRequestDto.java b/src/main/java/com/example/devSns/dto/MemberSignUpRequestDto.java index f45b00e..14ba90b 100644 --- a/src/main/java/com/example/devSns/dto/MemberSignUpRequestDto.java +++ b/src/main/java/com/example/devSns/dto/MemberSignUpRequestDto.java @@ -1,12 +1,27 @@ package com.example.devSns.dto; +import com.example.devSns.domain.Authority; +import com.example.devSns.domain.Member; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; +import org.springframework.security.crypto.password.PasswordEncoder; @Getter @Setter +@NoArgsConstructor public class MemberSignUpRequestDto { private String username; private String password; private String nickname; + + // DTO 정보를 바탕으로 Member 엔티티를 만드는 메서드 + public Member toMember(PasswordEncoder passwordEncoder) { + return Member.builder() + .username(username) + .password(passwordEncoder.encode(password)) + .nickname(nickname) + .authority(Authority.ROLE_USER) + .build(); + } } \ No newline at end of file diff --git a/src/main/java/com/example/devSns/dto/TokenDto.java b/src/main/java/com/example/devSns/dto/TokenDto.java new file mode 100644 index 0000000..e3b4094 --- /dev/null +++ b/src/main/java/com/example/devSns/dto/TokenDto.java @@ -0,0 +1,15 @@ +package com.example.devSns.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +@AllArgsConstructor +public class TokenDto { + private String grantType; + private String accessToken; + private String refreshToken; + private Long accessTokenExpiresIn; +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/dto/TokenRequestDto.java b/src/main/java/com/example/devSns/dto/TokenRequestDto.java new file mode 100644 index 0000000..8e1bb90 --- /dev/null +++ b/src/main/java/com/example/devSns/dto/TokenRequestDto.java @@ -0,0 +1,11 @@ +package com.example.devSns.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class TokenRequestDto { + private String accessToken; + private String refreshToken; +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/repository/MemberRepository.java b/src/main/java/com/example/devSns/repository/MemberRepository.java index 2488af8..3e90b3d 100644 --- a/src/main/java/com/example/devSns/repository/MemberRepository.java +++ b/src/main/java/com/example/devSns/repository/MemberRepository.java @@ -8,6 +8,12 @@ @Repository public interface MemberRepository extends JpaRepository { - // 닉네임으로 회원 검색 (검색 기능용) + // 닉네임으로 회원 검색 Optional findByNickname(String nickname); + + // 로그인 ID(username)로 회원 검색 (★ 이 부분이 빠져있어서 에러가 났습니다 ★) + Optional findByUsername(String username); + + // 중복 가입 방지용 존재 여부 확인 (★ 이 부분도 같이 추가해주세요 ★) + boolean existsByUsername(String username); } \ No newline at end of file diff --git a/src/main/java/com/example/devSns/repository/RefreshTokenRepository.java b/src/main/java/com/example/devSns/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..6b7fd77 --- /dev/null +++ b/src/main/java/com/example/devSns/repository/RefreshTokenRepository.java @@ -0,0 +1,12 @@ +package com.example.devSns.repository; + +import com.example.devSns.domain.RefreshToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface RefreshTokenRepository extends JpaRepository { + Optional findByKey(String key); +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/security/JwtFilter.java b/src/main/java/com/example/devSns/security/JwtFilter.java new file mode 100644 index 0000000..efda3d2 --- /dev/null +++ b/src/main/java/com/example/devSns/security/JwtFilter.java @@ -0,0 +1,47 @@ +package com.example.devSns.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + + public static final String AUTHORIZATION_HEADER = "Authorization"; + public static final String BEARER_PREFIX = "Bearer "; + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + + // 1. Request Header 에서 토큰을 꺼냄 + String jwt = resolveToken(request); + + // 2. validateToken 으로 토큰 유효성 검사 + // 정상 토큰이면 해당 토큰으로 Authentication 을 가져와서 SecurityContext 에 저장 + if (StringUtils.hasText(jwt) && jwtTokenProvider.validateToken(jwt)) { + Authentication authentication = jwtTokenProvider.getAuthentication(jwt); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } + + // Request Header 에서 토큰 정보 꺼내오기 + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader(AUTHORIZATION_HEADER); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { + return bearerToken.substring(7); + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/security/JwtTokenProvider.java b/src/main/java/com/example/devSns/security/JwtTokenProvider.java new file mode 100644 index 0000000..65094d4 --- /dev/null +++ b/src/main/java/com/example/devSns/security/JwtTokenProvider.java @@ -0,0 +1,120 @@ +package com.example.devSns.security; + +import com.example.devSns.dto.TokenDto; +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Arrays; +import java.util.Collection; +import java.util.Date; +import java.util.stream.Collectors; + +@Slf4j +@Component +public class JwtTokenProvider { + + private final Key key; + private final long accessTokenValidityInMilliseconds; + + private static final String AUTHORITIES_KEY = "auth"; + // Refresh Token은 보통 Access Token보다 길게 잡으므로 별도 상수로 두거나 설정으로 뺄 수 있습니다. (여기선 기존 7일 유지) + private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7; + + // 생성자: @Value에 :기본값 문법을 사용하여 설정 파일 누락 시에도 안전하게 작동하도록 수정 + public JwtTokenProvider( + @Value("${jwt.secret:c2lsdmVyLW5pbmUtZGV2LXNucy1qd3Qtc2VjcmV0LWtleS1leGFtcGxlCg==}") String secretKey, + @Value("${jwt.expiration:1800000}") long accessTokenValidityInMilliseconds) { // 기본값 30분 + + // Base64 디코딩 + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + this.accessTokenValidityInMilliseconds = accessTokenValidityInMilliseconds; + } + + // 토큰 생성 (Access Token & Refresh Token) + public TokenDto generateToken(Authentication authentication) { + String authorities = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + long now = (new Date()).getTime(); + + // Access Token 생성 (설정된 만료 시간 사용) + Date accessTokenExpiresIn = new Date(now + this.accessTokenValidityInMilliseconds); + String accessToken = Jwts.builder() + .setSubject(authentication.getName()) + .claim(AUTHORITIES_KEY, authorities) + .setExpiration(accessTokenExpiresIn) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + + // Refresh Token 생성 + String refreshToken = Jwts.builder() + .setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME)) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + + return TokenDto.builder() + .grantType("Bearer") + .accessToken(accessToken) + .refreshToken(refreshToken) + .accessTokenExpiresIn(accessTokenExpiresIn.getTime()) + .build(); + } + + // JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드 + public Authentication getAuthentication(String accessToken) { + // 토큰 복호화 + Claims claims = parseClaims(accessToken); + + if (claims.get(AUTHORITIES_KEY) == null) { + throw new RuntimeException("권한 정보가 없는 토큰입니다."); + } + + // 클레임에서 권한 정보 가져오기 + Collection authorities = + Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(",")) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + + // UserDetails 객체를 만들어서 Authentication 리턴 + UserDetails principal = new User(claims.getSubject(), "", authorities); + return new UsernamePasswordAuthenticationToken(principal, "", authorities); + } + + // 토큰 정보를 검증하는 메서드 + public boolean validateToken(String token) { + try { + Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); + return true; + } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) { + log.info("Invalid JWT Token", e); + } catch (ExpiredJwtException e) { + log.info("Expired JWT Token", e); + } catch (UnsupportedJwtException e) { + log.info("Unsupported JWT Token", e); + } catch (IllegalArgumentException e) { + log.info("JWT claims string is empty.", e); + } + return false; + } + + private Claims parseClaims(String accessToken) { + try { + return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody(); + } catch (ExpiredJwtException e) { + return e.getClaims(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/service/AuthService.java b/src/main/java/com/example/devSns/service/AuthService.java new file mode 100644 index 0000000..400ef73 --- /dev/null +++ b/src/main/java/com/example/devSns/service/AuthService.java @@ -0,0 +1,94 @@ +package com.example.devSns.service; + +import com.example.devSns.domain.Member; +import com.example.devSns.domain.RefreshToken; +import com.example.devSns.dto.MemberLoginRequestDto; +import com.example.devSns.dto.MemberResponseDto; +import com.example.devSns.dto.MemberSignUpRequestDto; +import com.example.devSns.dto.TokenRequestDto; +import com.example.devSns.repository.MemberRepository; +import com.example.devSns.repository.RefreshTokenRepository; +import com.example.devSns.security.JwtTokenProvider; +import com.example.devSns.dto.TokenDto; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final AuthenticationManagerBuilder authenticationManagerBuilder; + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenRepository refreshTokenRepository; + + @Transactional + public MemberResponseDto signup(MemberSignUpRequestDto requestDto) { + if (memberRepository.existsByUsername(requestDto.getUsername())) { + throw new RuntimeException("이미 가입되어 있는 유저입니다"); + } + + Member member = requestDto.toMember(passwordEncoder); + return MemberResponseDto.of(memberRepository.save(member)); + } + + @Transactional + public TokenDto login(MemberLoginRequestDto requestDto) { + // 1. Login ID/PW 를 기반으로 AuthenticationToken 생성 + UsernamePasswordAuthenticationToken authenticationToken = requestDto.toAuthentication(); + + // 2. 실제로 검증 (사용자 비밀번호 체크) 이 이루어지는 부분 + // authenticate 메서드가 실행될 때 CustomUserDetailsService 에서 만든 loadUserByUsername 메서드가 실행됨 + Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken); + + // 3. 인증 정보를 기반으로 JWT 토큰 생성 + TokenDto tokenDto = jwtTokenProvider.generateToken(authentication); + + // 4. RefreshToken 저장 + RefreshToken refreshToken = RefreshToken.builder() + .key(authentication.getName()) + .value(tokenDto.getRefreshToken()) + .build(); + + refreshTokenRepository.save(refreshToken); + + // 5. 토큰 발급 + return tokenDto; + } + + @Transactional + public TokenDto reissue(TokenRequestDto tokenRequestDto) { + // 1. Refresh Token 검증 + if (!jwtTokenProvider.validateToken(tokenRequestDto.getRefreshToken())) { + throw new RuntimeException("Refresh Token 이 유효하지 않습니다."); + } + + // 2. Access Token 에서 Member ID 가져오기 + Authentication authentication = jwtTokenProvider.getAuthentication(tokenRequestDto.getAccessToken()); + + // 3. 저장소에서 Member ID 를 기반으로 Refresh Token 값 가져옴 + RefreshToken refreshToken = refreshTokenRepository.findByKey(authentication.getName()) + .orElseThrow(() -> new RuntimeException("로그아웃 된 사용자입니다.")); + + // 4. Refresh Token 일치하는지 검사 + if (!refreshToken.getValue().equals(tokenRequestDto.getRefreshToken())) { + throw new RuntimeException("토큰의 유저 정보가 일치하지 않습니다."); + } + + // 5. 새로운 토큰 생성 + TokenDto tokenDto = jwtTokenProvider.generateToken(authentication); + + // 6. 저장소 정보 업데이트 + RefreshToken newRefreshToken = refreshToken.updateValue(tokenDto.getRefreshToken()); + refreshTokenRepository.save(newRefreshToken); + + // 토큰 발급 + return tokenDto; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/service/CustomUserDetailsService.java b/src/main/java/com/example/devSns/service/CustomUserDetailsService.java new file mode 100644 index 0000000..c2e3ae4 --- /dev/null +++ b/src/main/java/com/example/devSns/service/CustomUserDetailsService.java @@ -0,0 +1,41 @@ +package com.example.devSns.service; + +import com.example.devSns.domain.Member; +import com.example.devSns.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final MemberRepository memberRepository; + + @Override + @Transactional + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + return memberRepository.findByUsername(username) + .map(this::createUserDetails) + .orElseThrow(() -> new UsernameNotFoundException(username + " -> 데이터베이스에서 찾을 수 없습니다.")); + } + + // DB 에 있는 Member 정보 -> UserDetails 객체로 변환 + private UserDetails createUserDetails(Member member) { + GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(member.getAuthority().toString()); + + return new User( + String.valueOf(member.getId()), + member.getPassword(), + Collections.singleton(grantedAuthority) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/service/MemberService.java b/src/main/java/com/example/devSns/service/MemberService.java index 2f1e606..7b898c7 100644 --- a/src/main/java/com/example/devSns/service/MemberService.java +++ b/src/main/java/com/example/devSns/service/MemberService.java @@ -39,7 +39,7 @@ public MemberResponseDto signup(MemberSignUpRequestDto requestDto) { // TODO: 패스워드 암호화 Member member = new Member(requestDto.getUsername(), requestDto.getPassword(), requestDto.getNickname()); Member savedMember = memberRepository.save(member); - return new MemberResponseDto(savedMember); + return MemberResponseDto.of(savedMember); } // 회원 ID로 조회 (내부 로직용) @@ -53,7 +53,7 @@ public Member getMemberById(Long id) { public MemberResponseDto searchByNickname(String nickname) { Member member = memberRepository.findByNickname(nickname) .orElseThrow(() -> new IllegalArgumentException("Member not found")); - return new MemberResponseDto(member); + return MemberResponseDto.of(member); } // 회원 프로필 조회 (작성한 글, 댓글, 좋아요한 글) diff --git a/src/main/java/com/example/devSns/service/PostService.java b/src/main/java/com/example/devSns/service/PostService.java index 1f6ed56..7a84a1c 100644 --- a/src/main/java/com/example/devSns/service/PostService.java +++ b/src/main/java/com/example/devSns/service/PostService.java @@ -24,6 +24,7 @@ public class PostService { public Post createPost(PostRequestDto requestDto) { Member member = memberService.getMemberById(requestDto.getMemberId()); Post post = new Post(requestDto.getTitle(), requestDto.getContent(), member); + return postRepository.save(post); } // 모든 게시글 조회 diff --git a/src/main/resources/appication.yml b/src/main/resources/appication.yml new file mode 100644 index 0000000..82186fa --- /dev/null +++ b/src/main/resources/appication.yml @@ -0,0 +1,16 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb + driverClassName: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + format_sql: true + +jwt: + secret: c2lsdmVyLW5pbmUtZGV2LXNucy1qd3Qtc2VjcmV0LWtleS1leGFtcGxlCg== # 사용자 제공 키 유지 + expiration: 86400000 # 1일 (24시간) - 토큰 만료 시간 추가 \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index f3f10af..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=devSns