From 22012aa2be89e8e2cfcd28f9f2d0148e1ab7f702 Mon Sep 17 00:00:00 2001 From: Park Jae Hong <105151063+PHJ2000@users.noreply.github.com> Date: Fri, 14 Nov 2025 13:31:57 +0900 Subject: [PATCH 1/5] =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 33 +++---- .../com/example/devSns/domain/Comment.java | 35 +++++++ .../devSns/domain/CommentRepository.java | 11 +++ .../java/com/example/devSns/domain/Post.java | 47 +++++++++ .../example/devSns/domain/PostRepository.java | 5 + .../devSns/service/CommentService.java | 56 +++++++++++ .../example/devSns/service/PostService.java | 48 +++++++++ .../example/devSns/web/CommentController.java | 50 ++++++++++ .../example/devSns/web/PostController.java | 53 ++++++++++ .../devSns/web/dto/CommentCreateRequest.java | 7 ++ .../devSns/web/dto/CommentResponse.java | 12 +++ .../devSns/web/dto/CommentUpdateRequest.java | 7 ++ .../devSns/web/dto/PostCreateRequest.java | 8 ++ .../example/devSns/web/dto/PostResponse.java | 13 +++ .../devSns/web/dto/PostUpdateRequest.java | 8 ++ src/main/resources/application.yml | 11 +++ .../devSns/web/CommentControllerTest.java | 98 +++++++++++++++++++ .../devSns/web/PostControllerTest.java | 77 +++++++++++++++ 18 files changed, 559 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/example/devSns/domain/Comment.java create mode 100644 src/main/java/com/example/devSns/domain/CommentRepository.java create mode 100644 src/main/java/com/example/devSns/domain/Post.java create mode 100644 src/main/java/com/example/devSns/domain/PostRepository.java create mode 100644 src/main/java/com/example/devSns/service/CommentService.java create mode 100644 src/main/java/com/example/devSns/service/PostService.java create mode 100644 src/main/java/com/example/devSns/web/CommentController.java create mode 100644 src/main/java/com/example/devSns/web/PostController.java create mode 100644 src/main/java/com/example/devSns/web/dto/CommentCreateRequest.java create mode 100644 src/main/java/com/example/devSns/web/dto/CommentResponse.java create mode 100644 src/main/java/com/example/devSns/web/dto/CommentUpdateRequest.java create mode 100644 src/main/java/com/example/devSns/web/dto/PostCreateRequest.java create mode 100644 src/main/java/com/example/devSns/web/dto/PostResponse.java create mode 100644 src/main/java/com/example/devSns/web/dto/PostUpdateRequest.java create mode 100644 src/main/resources/application.yml create mode 100644 src/test/java/com/example/devSns/web/CommentControllerTest.java create mode 100644 src/test/java/com/example/devSns/web/PostControllerTest.java diff --git a/build.gradle b/build.gradle index 610d6a6..f7c08a3 100644 --- a/build.gradle +++ b/build.gradle @@ -1,29 +1,22 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.5.6' - id 'io.spring.dependency-management' version '1.1.7' + id 'org.springframework.boot' version '3.5.6' + id 'io.spring.dependency-management' version '1.1.6' + id 'java' } - group = 'com.example' version = '0.0.1-SNAPSHOT' -description = 'Demo project for Spring Boot' +java { sourceCompatibility = '21' } -java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } -} - -repositories { - mavenCentral() -} +repositories { mavenCentral() } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-web' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + runtimeOnly 'com.h2database:h2' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' } -tasks.named('test') { - useJUnitPlatform() -} +tasks.named('test') { useJUnitPlatform() } 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..5edc090 --- /dev/null +++ b/src/main/java/com/example/devSns/domain/Comment.java @@ -0,0 +1,35 @@ +package com.example.devSns.domain; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Comment { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotBlank + @Column(nullable = false) + private String content; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + private Post post; + + @Builder + private Comment(String content, Post post) { + this.content = content; + this.post = post; + } + + public void update(String content) { + this.content = content; + } + + /** Post 편의 메서드에서만 설정하도록 제한 */ + void setPostInternal(Post post) { + this.post = post; + } +} diff --git a/src/main/java/com/example/devSns/domain/CommentRepository.java b/src/main/java/com/example/devSns/domain/CommentRepository.java new file mode 100644 index 0000000..d3059bd --- /dev/null +++ b/src/main/java/com/example/devSns/domain/CommentRepository.java @@ -0,0 +1,11 @@ +package com.example.devSns.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface CommentRepository extends JpaRepository { + List findByPostIdOrderByIdAsc(Long postId); + Optional findByIdAndPostId(Long id, Long postId); +} 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..4accbc3 --- /dev/null +++ b/src/main/java/com/example/devSns/domain/Post.java @@ -0,0 +1,47 @@ +package com.example.devSns.domain; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Post { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotBlank + @Column(nullable = false) + private String title; + + @Lob + private String content; + + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + private final List comments = new ArrayList<>(); + + @Builder + private Post(String title, String content) { + this.title = title; + this.content = content; + } + + public void update(String title, String content) { + this.title = title; + this.content = content; + } + + /** 양방향 편의 메서드 */ + void addComment(Comment c) { + comments.add(c); + c.setPostInternal(this); + } + void removeComment(Comment c) { + comments.remove(c); + c.setPostInternal(null); + } +} diff --git a/src/main/java/com/example/devSns/domain/PostRepository.java b/src/main/java/com/example/devSns/domain/PostRepository.java new file mode 100644 index 0000000..aa17274 --- /dev/null +++ b/src/main/java/com/example/devSns/domain/PostRepository.java @@ -0,0 +1,5 @@ +package com.example.devSns.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PostRepository extends JpaRepository { } 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..4de8323 --- /dev/null +++ b/src/main/java/com/example/devSns/service/CommentService.java @@ -0,0 +1,56 @@ +package com.example.devSns.service; + +import com.example.devSns.domain.Comment; +import com.example.devSns.domain.CommentRepository; +import com.example.devSns.domain.Post; +import com.example.devSns.domain.PostRepository; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@Service +public class CommentService { + private final CommentRepository commentRepo; + private final PostRepository postRepo; + + public CommentService(CommentRepository commentRepo, PostRepository postRepo) { + this.commentRepo = commentRepo; + this.postRepo = postRepo; + } + + public List list(Long postId) { + ensurePostExists(postId); + return commentRepo.findByPostIdOrderByIdAsc(postId); + } + + public Comment get(Long postId, Long commentId) { + return commentRepo.findByIdAndPostId(commentId, postId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Comment not found")); + } + + public Comment create(Long postId, String content) { + Post post = postRepo.findById(postId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Post not found")); + Comment comment = Comment.builder().content(content).post(post).build(); + return commentRepo.save(comment); + } + + public Comment update(Long postId, Long commentId, String content) { + Comment comment = get(postId, commentId); + comment.update(content); + return commentRepo.save(comment); + } + + public void delete(Long postId, Long commentId) { + Comment comment = get(postId, commentId); + commentRepo.delete(comment); + } + + private void ensurePostExists(Long postId) { + if (!postRepo.existsById(postId)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Post not found"); + } + } +} 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..de944f6 --- /dev/null +++ b/src/main/java/com/example/devSns/service/PostService.java @@ -0,0 +1,48 @@ +package com.example.devSns.service; + +import com.example.devSns.domain.Post; +import com.example.devSns.domain.PostRepository; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@Service +public class PostService { + + private final PostRepository repo; + + public PostService(PostRepository repo) { + this.repo = repo; + } + + public List findAll() { + return repo.findAll(); + } + + public Post findById(Long id) { + return repo.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Post not found")); + } + + public Post create(String title, String content) { + Post post = Post.builder() + .title(title) + .content(content) + .build(); + return repo.save(post); + } + + public Post update(Long id, String title, String content) { + Post post = findById(id); + post.update(title, content); + return repo.save(post); + } + + public void delete(Long id) { + Post post = repo.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Post not found")); + repo.delete(post); // 연관 댓글까지 안전하게 제거 + } +} diff --git a/src/main/java/com/example/devSns/web/CommentController.java b/src/main/java/com/example/devSns/web/CommentController.java new file mode 100644 index 0000000..115ee06 --- /dev/null +++ b/src/main/java/com/example/devSns/web/CommentController.java @@ -0,0 +1,50 @@ +package com.example.devSns.web; + +import com.example.devSns.service.CommentService; +import com.example.devSns.web.dto.CommentCreateRequest; +import com.example.devSns.web.dto.CommentUpdateRequest; +import com.example.devSns.web.dto.CommentResponse; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/posts/{postId}/comments") +public class CommentController { + private final CommentService svc; + + public CommentController(CommentService svc) { + this.svc = svc; + } + + @GetMapping + public List list(@PathVariable Long postId) { + return svc.list(postId).stream().map(CommentResponse::from).toList(); + } + + @GetMapping("/{commentId}") + public CommentResponse get(@PathVariable Long postId, @PathVariable Long commentId) { + return CommentResponse.from(svc.get(postId, commentId)); + } + + @ResponseStatus(HttpStatus.CREATED) + @PostMapping + public CommentResponse create(@PathVariable Long postId, + @Valid @RequestBody CommentCreateRequest req) { + return CommentResponse.from(svc.create(postId, req.content())); + } + + @PutMapping("/{commentId}") + public CommentResponse update(@PathVariable Long postId, @PathVariable Long commentId, + @Valid @RequestBody CommentUpdateRequest req) { + return CommentResponse.from(svc.update(postId, commentId, req.content())); + } + + @ResponseStatus(HttpStatus.NO_CONTENT) + @DeleteMapping("/{commentId}") + public void delete(@PathVariable Long postId, @PathVariable Long commentId) { + svc.delete(postId, commentId); + } +} diff --git a/src/main/java/com/example/devSns/web/PostController.java b/src/main/java/com/example/devSns/web/PostController.java new file mode 100644 index 0000000..ec1c6b2 --- /dev/null +++ b/src/main/java/com/example/devSns/web/PostController.java @@ -0,0 +1,53 @@ +package com.example.devSns.web; + +import com.example.devSns.domain.Post; +import com.example.devSns.service.PostService; +import com.example.devSns.web.dto.PostCreateRequest; +import com.example.devSns.web.dto.PostUpdateRequest; +import com.example.devSns.web.dto.PostResponse; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/posts") +public class PostController { + + private final PostService svc; + + public PostController(PostService svc) { + this.svc = svc; + } + + @GetMapping + public List getAll() { + return svc.findAll().stream().map(PostResponse::from).toList(); + } + + @GetMapping("/{id}") + public PostResponse getOne(@PathVariable Long id) { + Post p = svc.findById(id); + return PostResponse.from(p); + } + + @ResponseStatus(HttpStatus.CREATED) + @PostMapping + public PostResponse create(@Valid @RequestBody PostCreateRequest req) { + Post saved = svc.create(req.title(), req.content()); + return PostResponse.from(saved); + } + + @PutMapping("/{id}") + public PostResponse update(@PathVariable Long id, @Valid @RequestBody PostUpdateRequest req) { + Post updated = svc.update(id, req.title(), req.content()); + return PostResponse.from(updated); + } + + @ResponseStatus(HttpStatus.NO_CONTENT) + @DeleteMapping("/{id}") + public void delete(@PathVariable Long id) { + svc.delete(id); + } +} diff --git a/src/main/java/com/example/devSns/web/dto/CommentCreateRequest.java b/src/main/java/com/example/devSns/web/dto/CommentCreateRequest.java new file mode 100644 index 0000000..f6848dd --- /dev/null +++ b/src/main/java/com/example/devSns/web/dto/CommentCreateRequest.java @@ -0,0 +1,7 @@ +package com.example.devSns.web.dto; + +import jakarta.validation.constraints.NotBlank; + +public record CommentCreateRequest( + @NotBlank String content +) {} diff --git a/src/main/java/com/example/devSns/web/dto/CommentResponse.java b/src/main/java/com/example/devSns/web/dto/CommentResponse.java new file mode 100644 index 0000000..19eb489 --- /dev/null +++ b/src/main/java/com/example/devSns/web/dto/CommentResponse.java @@ -0,0 +1,12 @@ +package com.example.devSns.web.dto; + +import com.example.devSns.domain.Comment; + +public record CommentResponse( + Long id, + String content +) { + public static CommentResponse from(Comment c) { + return new CommentResponse(c.getId(), c.getContent()); + } +} diff --git a/src/main/java/com/example/devSns/web/dto/CommentUpdateRequest.java b/src/main/java/com/example/devSns/web/dto/CommentUpdateRequest.java new file mode 100644 index 0000000..a80b258 --- /dev/null +++ b/src/main/java/com/example/devSns/web/dto/CommentUpdateRequest.java @@ -0,0 +1,7 @@ +package com.example.devSns.web.dto; + +import jakarta.validation.constraints.NotBlank; + +public record CommentUpdateRequest( + @NotBlank String content +) {} diff --git a/src/main/java/com/example/devSns/web/dto/PostCreateRequest.java b/src/main/java/com/example/devSns/web/dto/PostCreateRequest.java new file mode 100644 index 0000000..a486cad --- /dev/null +++ b/src/main/java/com/example/devSns/web/dto/PostCreateRequest.java @@ -0,0 +1,8 @@ +package com.example.devSns.web.dto; + +import jakarta.validation.constraints.NotBlank; + +public record PostCreateRequest( + @NotBlank String title, + String content +) {} diff --git a/src/main/java/com/example/devSns/web/dto/PostResponse.java b/src/main/java/com/example/devSns/web/dto/PostResponse.java new file mode 100644 index 0000000..0130185 --- /dev/null +++ b/src/main/java/com/example/devSns/web/dto/PostResponse.java @@ -0,0 +1,13 @@ +package com.example.devSns.web.dto; + +import com.example.devSns.domain.Post; + +public record PostResponse( + Long id, + String title, + String content +) { + public static PostResponse from(Post p) { + return new PostResponse(p.getId(), p.getTitle(), p.getContent()); + } +} diff --git a/src/main/java/com/example/devSns/web/dto/PostUpdateRequest.java b/src/main/java/com/example/devSns/web/dto/PostUpdateRequest.java new file mode 100644 index 0000000..1315608 --- /dev/null +++ b/src/main/java/com/example/devSns/web/dto/PostUpdateRequest.java @@ -0,0 +1,8 @@ +package com.example.devSns.web.dto; + +import jakarta.validation.constraints.NotBlank; + +public record PostUpdateRequest( + @NotBlank String title, + String content +) {} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..d92d4f5 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,11 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb;MODE=PostgreSQL + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate.ddl-auto: update + show-sql: true + h2: + console.enabled: true diff --git a/src/test/java/com/example/devSns/web/CommentControllerTest.java b/src/test/java/com/example/devSns/web/CommentControllerTest.java new file mode 100644 index 0000000..caa6c46 --- /dev/null +++ b/src/test/java/com/example/devSns/web/CommentControllerTest.java @@ -0,0 +1,98 @@ +package com.example.devSns.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +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.http.MediaType; +import org.springframework.test.web.servlet.*; + +import jakarta.transaction.Transactional; +import java.util.Map; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class CommentControllerTest { + + @Autowired MockMvc mockMvc; + @Autowired ObjectMapper om; + + long postId; + + @BeforeEach + void setUp() throws Exception { + // 테스트용 게시글 하나 생성 + MvcResult res = mockMvc.perform( + post("/posts") + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(Map.of("title","댓글 테스트 글","content","본문"))) + ).andExpect(status().isCreated()) + .andReturn(); + + postId = om.readTree(res.getResponse().getContentAsString()).get("id").asLong(); + } + + @Test + void comment_CRUD_flow() throws Exception { + // CREATE + MvcResult create = mockMvc.perform( + post("/posts/{postId}/comments", postId) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(Map.of("content","첫 댓글"))) + ).andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").exists()) + .andExpect(jsonPath("$.content").value("첫 댓글")) + .andReturn(); + + long commentId = om.readTree(create.getResponse().getContentAsString()).get("id").asLong(); + + // LIST + mockMvc.perform(get("/posts/{postId}/comments", postId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(commentId)); + + // DETAIL + mockMvc.perform(get("/posts/{postId}/comments/{commentId}", postId, commentId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").value("첫 댓글")); + + // UPDATE + mockMvc.perform( + put("/posts/{postId}/comments/{commentId}", postId, commentId) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(Map.of("content","수정된 댓글"))) + ).andExpect(status().isOk()) + .andExpect(jsonPath("$.content").value("수정된 댓글")); + + // DELETE + mockMvc.perform(delete("/posts/{postId}/comments/{commentId}", postId, commentId)) + .andExpect(status().isNoContent()); + + // 404 확인 + mockMvc.perform(get("/posts/{postId}/comments/{commentId}", postId, commentId)) + .andExpect(status().isNotFound()); + } + + @Test + void comment_create_validation_fail_when_blank() throws Exception { + mockMvc.perform( + post("/posts/{postId}/comments", postId) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(Map.of("content",""))) + ).andExpect(status().isBadRequest()); + } + + @Test + void comment_404_when_post_not_found() throws Exception { + mockMvc.perform( + post("/posts/{postId}/comments", 999_999L) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(Map.of("content","x"))) + ).andExpect(status().isNotFound()); + } +} diff --git a/src/test/java/com/example/devSns/web/PostControllerTest.java b/src/test/java/com/example/devSns/web/PostControllerTest.java new file mode 100644 index 0000000..b4f7580 --- /dev/null +++ b/src/test/java/com/example/devSns/web/PostControllerTest.java @@ -0,0 +1,77 @@ +package com.example.devSns.web; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +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.http.MediaType; +import org.springframework.test.web.servlet.*; + +import jakarta.transaction.Transactional; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional // 각 테스트 후 롤백 +class PostControllerTest { + + @Autowired MockMvc mockMvc; + @Autowired ObjectMapper om; + + @Test + void post_CRUD_flow() throws Exception { + // CREATE + MvcResult createRes = mockMvc.perform( + post("/posts") + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(Map.of("title","첫 글","content","내용입니다"))) + ).andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").exists()) + .andExpect(jsonPath("$.title").value("첫 글")) + .andReturn(); + + long postId = om.readTree(createRes.getResponse().getContentAsString()).get("id").asLong(); + + // LIST + mockMvc.perform(get("/posts")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").exists()); + + // DETAIL + mockMvc.perform(get("/posts/{id}", postId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("첫 글")); + + // UPDATE + mockMvc.perform( + put("/posts/{id}", postId) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(Map.of("title","수정 제목","content","수정 내용"))) + ).andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("수정 제목")) + .andExpect(jsonPath("$.content").value("수정 내용")); + + // DELETE + mockMvc.perform(delete("/posts/{id}", postId)) + .andExpect(status().isNoContent()); + + // 404 확인 + mockMvc.perform(get("/posts/{id}", postId)) + .andExpect(status().isNotFound()); + } + + @Test + void post_create_validation_fail_when_title_blank() throws Exception { + mockMvc.perform( + post("/posts") + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(Map.of("title","", "content","x"))) + ).andExpect(status().isBadRequest()); + } +} From 2541be3f7c92e4e03245a8783b61f682a1fad4f0 Mon Sep 17 00:00:00 2001 From: Park Jae Hong <105151063+PHJ2000@users.noreply.github.com> Date: Fri, 21 Nov 2025 17:36:50 +0900 Subject: [PATCH 2/5] =?UTF-8?q?=EB=A7=B4=EB=B2=84=20=EA=B2=80=EC=83=89=20&?= =?UTF-8?q?=20=EB=A7=B4=EB=B2=84=EC=9D=98=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?&=20=EB=8C=93=EA=B8=80=20&=20=EC=A2=8B=EC=95=84=EC=9A=94=20&=20?= =?UTF-8?q?=ED=8C=94=EB=A1=9C=EC=9A=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/devSns/domain/Comment.java | 17 +++- .../devSns/domain/CommentRepository.java | 3 + .../com/example/devSns/domain/Follow.java | 33 ++++++++ .../devSns/domain/FollowRepository.java | 15 ++++ .../com/example/devSns/domain/Member.java | 36 +++++++++ .../devSns/domain/MemberRepository.java | 14 ++++ .../java/com/example/devSns/domain/Post.java | 22 +++++- .../com/example/devSns/domain/PostLike.java | 31 ++++++++ .../devSns/domain/PostLikeRepository.java | 16 ++++ .../example/devSns/domain/PostRepository.java | 7 +- .../devSns/service/CommentService.java | 30 +++++++- .../example/devSns/service/FollowService.java | 73 ++++++++++++++++++ .../example/devSns/service/MemberService.java | 77 +++++++++++++++++++ .../devSns/service/PostLikeService.java | 51 ++++++++++++ .../example/devSns/service/PostService.java | 38 ++++++--- .../example/devSns/web/CommentController.java | 8 +- .../example/devSns/web/FollowController.java | 48 ++++++++++++ .../example/devSns/web/MemberController.java | 73 ++++++++++++++++++ .../example/devSns/web/PostController.java | 15 ++-- .../devSns/web/PostLikeController.java | 40 ++++++++++ .../devSns/web/dto/CommentCreateRequest.java | 2 + .../example/devSns/web/dto/LikeRequest.java | 7 ++ .../devSns/web/dto/MemberCreateRequest.java | 9 +++ .../devSns/web/dto/MemberResponse.java | 19 +++++ .../devSns/web/dto/PostCreateRequest.java | 2 + 25 files changed, 658 insertions(+), 28 deletions(-) create mode 100644 src/main/java/com/example/devSns/domain/Follow.java create mode 100644 src/main/java/com/example/devSns/domain/FollowRepository.java create mode 100644 src/main/java/com/example/devSns/domain/Member.java create mode 100644 src/main/java/com/example/devSns/domain/MemberRepository.java create mode 100644 src/main/java/com/example/devSns/domain/PostLike.java create mode 100644 src/main/java/com/example/devSns/domain/PostLikeRepository.java create mode 100644 src/main/java/com/example/devSns/service/FollowService.java create mode 100644 src/main/java/com/example/devSns/service/MemberService.java create mode 100644 src/main/java/com/example/devSns/service/PostLikeService.java create mode 100644 src/main/java/com/example/devSns/web/FollowController.java create mode 100644 src/main/java/com/example/devSns/web/MemberController.java create mode 100644 src/main/java/com/example/devSns/web/PostLikeController.java create mode 100644 src/main/java/com/example/devSns/web/dto/LikeRequest.java create mode 100644 src/main/java/com/example/devSns/web/dto/MemberCreateRequest.java create mode 100644 src/main/java/com/example/devSns/web/dto/MemberResponse.java diff --git a/src/main/java/com/example/devSns/domain/Comment.java b/src/main/java/com/example/devSns/domain/Comment.java index 5edc090..91d5674 100644 --- a/src/main/java/com/example/devSns/domain/Comment.java +++ b/src/main/java/com/example/devSns/domain/Comment.java @@ -15,21 +15,32 @@ public class Comment { @Column(nullable = false) private String content; - @ManyToOne(fetch = FetchType.LAZY, optional = false) + /** 어느 Post 에 달린 댓글인지 */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) private Post post; + /** 누가 쓴 댓글인지 */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member author; + @Builder - private Comment(String content, Post post) { + private Comment(String content, Post post, Member author) { this.content = content; this.post = post; + this.author = author; } public void update(String content) { this.content = content; } - /** Post 편의 메서드에서만 설정하도록 제한 */ void setPostInternal(Post post) { this.post = post; } + + public void changeAuthor(Member author) { + this.author = author; + } } diff --git a/src/main/java/com/example/devSns/domain/CommentRepository.java b/src/main/java/com/example/devSns/domain/CommentRepository.java index d3059bd..a0f46e3 100644 --- a/src/main/java/com/example/devSns/domain/CommentRepository.java +++ b/src/main/java/com/example/devSns/domain/CommentRepository.java @@ -6,6 +6,9 @@ import java.util.Optional; public interface CommentRepository extends JpaRepository { + List findByPostIdOrderByIdAsc(Long postId); Optional findByIdAndPostId(Long id, Long postId); + + List findByAuthorIdOrderByIdDesc(Long memberId); } diff --git a/src/main/java/com/example/devSns/domain/Follow.java b/src/main/java/com/example/devSns/domain/Follow.java new file mode 100644 index 0000000..be57ab5 --- /dev/null +++ b/src/main/java/com/example/devSns/domain/Follow.java @@ -0,0 +1,33 @@ +package com.example.devSns.domain; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "follow", + uniqueConstraints = @UniqueConstraint(columnNames = {"follower_id", "following_id"}) +) +public class Follow { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // 나를 기준으로: 내가 팔로우 하는 사람 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "follower_id", nullable = false) + private Member follower; + + // 내가 팔로우 당하는 사람 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "following_id", nullable = false) + private Member following; + + @Builder + private Follow(Member follower, Member following) { + this.follower = follower; + this.following = following; + } +} diff --git a/src/main/java/com/example/devSns/domain/FollowRepository.java b/src/main/java/com/example/devSns/domain/FollowRepository.java new file mode 100644 index 0000000..3785305 --- /dev/null +++ b/src/main/java/com/example/devSns/domain/FollowRepository.java @@ -0,0 +1,15 @@ +package com.example.devSns.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface FollowRepository extends JpaRepository { + + boolean existsByFollowerIdAndFollowingId(Long followerId, Long followingId); + + void deleteByFollowerIdAndFollowingId(Long followerId, Long followingId); + + List findByFollowerId(Long followerId); // 내가 팔로우하는 사람들 + List findByFollowingId(Long followingId); // 나를 팔로우하는 사람들 +} 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..9f0ce1e --- /dev/null +++ b/src/main/java/com/example/devSns/domain/Member.java @@ -0,0 +1,36 @@ +package com.example.devSns.domain; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Member { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotBlank + @Column(nullable = false, unique = true, length = 50) + private String username; // 로그인/아이디 용 + + @Column(length = 50) + private String nickname; // 화면용 이름 + + @Column(length = 255) + private String bio; // 자기소개 (옵션) + + @Builder + private Member(String username, String nickname, String bio) { + this.username = username; + this.nickname = nickname; + this.bio = bio; + } + + public void updateProfile(String nickname, String bio) { + this.nickname = nickname; + this.bio = bio; + } +} diff --git a/src/main/java/com/example/devSns/domain/MemberRepository.java b/src/main/java/com/example/devSns/domain/MemberRepository.java new file mode 100644 index 0000000..c8705a5 --- /dev/null +++ b/src/main/java/com/example/devSns/domain/MemberRepository.java @@ -0,0 +1,14 @@ +package com.example.devSns.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface MemberRepository extends JpaRepository { + + // 멤버 검색용 (username 또는 nickname 기준) + List findByUsernameContainingIgnoreCaseOrNicknameContainingIgnoreCase( + String usernameKeyword, + String nicknameKeyword + ); +} diff --git a/src/main/java/com/example/devSns/domain/Post.java b/src/main/java/com/example/devSns/domain/Post.java index 4accbc3..9a35159 100644 --- a/src/main/java/com/example/devSns/domain/Post.java +++ b/src/main/java/com/example/devSns/domain/Post.java @@ -14,18 +14,29 @@ public class Post { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + // ★ 작성자(Member) 연결 (nullable=true 로 두어서 기존 기능 안 깨지게) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member author; + @NotBlank - @Column(nullable = false) + @Column(nullable = false, length = 100) private String title; - @Lob + @Column(columnDefinition = "TEXT") private String content; + // 댓글 @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) private final List comments = new ArrayList<>(); + // 좋아요 + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + private final List likes = new ArrayList<>(); + @Builder - private Post(String title, String content) { + private Post(Member author, String title, String content) { + this.author = author; this.title = title; this.content = content; } @@ -35,11 +46,16 @@ public void update(String title, String content) { this.content = content; } + public void changeAuthor(Member author) { + this.author = author; + } + /** 양방향 편의 메서드 */ void addComment(Comment c) { comments.add(c); c.setPostInternal(this); } + void removeComment(Comment c) { comments.remove(c); c.setPostInternal(null); 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..a6e9cfb --- /dev/null +++ b/src/main/java/com/example/devSns/domain/PostLike.java @@ -0,0 +1,31 @@ +package com.example.devSns.domain; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "post_like", + uniqueConstraints = @UniqueConstraint(columnNames = {"member_id", "post_id"}) +) +public class PostLike { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @Builder + private PostLike(Member member, Post post) { + this.member = member; + this.post = post; + } +} diff --git a/src/main/java/com/example/devSns/domain/PostLikeRepository.java b/src/main/java/com/example/devSns/domain/PostLikeRepository.java new file mode 100644 index 0000000..c73c4e1 --- /dev/null +++ b/src/main/java/com/example/devSns/domain/PostLikeRepository.java @@ -0,0 +1,16 @@ +package com.example.devSns.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PostLikeRepository extends JpaRepository { + + boolean existsByMemberIdAndPostId(Long memberId, Long postId); + + void deleteByMemberIdAndPostId(Long memberId, Long postId); + + long countByPostId(Long postId); + + List findByMemberIdOrderByIdDesc(Long memberId); +} diff --git a/src/main/java/com/example/devSns/domain/PostRepository.java b/src/main/java/com/example/devSns/domain/PostRepository.java index aa17274..3ca4fb7 100644 --- a/src/main/java/com/example/devSns/domain/PostRepository.java +++ b/src/main/java/com/example/devSns/domain/PostRepository.java @@ -2,4 +2,9 @@ import org.springframework.data.jpa.repository.JpaRepository; -public interface PostRepository extends JpaRepository { } +import java.util.List; + +public interface PostRepository extends JpaRepository { + + List findByAuthorIdOrderByIdDesc(Long memberId); +} diff --git a/src/main/java/com/example/devSns/service/CommentService.java b/src/main/java/com/example/devSns/service/CommentService.java index 4de8323..d6a910f 100644 --- a/src/main/java/com/example/devSns/service/CommentService.java +++ b/src/main/java/com/example/devSns/service/CommentService.java @@ -2,45 +2,67 @@ import com.example.devSns.domain.Comment; import com.example.devSns.domain.CommentRepository; +import com.example.devSns.domain.Member; +import com.example.devSns.domain.MemberRepository; import com.example.devSns.domain.Post; import com.example.devSns.domain.PostRepository; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; import java.util.List; @Service +@Transactional public class CommentService { + private final CommentRepository commentRepo; private final PostRepository postRepo; + private final MemberRepository memberRepo; - public CommentService(CommentRepository commentRepo, PostRepository postRepo) { + public CommentService(CommentRepository commentRepo, + PostRepository postRepo, + MemberRepository memberRepo) { this.commentRepo = commentRepo; this.postRepo = postRepo; + this.memberRepo = memberRepo; } + /** CommentController.list 에서 부르는 메서드 */ + @Transactional(readOnly = true) public List list(Long postId) { ensurePostExists(postId); return commentRepo.findByPostIdOrderByIdAsc(postId); } + @Transactional(readOnly = true) public Comment get(Long postId, Long commentId) { return commentRepo.findByIdAndPostId(commentId, postId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Comment not found")); } - public Comment create(Long postId, String content) { + /** CommentController.create 에서 부르는 메서드 */ + public Comment create(Long postId, Long memberId, String content) { Post post = postRepo.findById(postId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Post not found")); - Comment comment = Comment.builder().content(content).post(post).build(); + + Member author = memberRepo.findById(memberId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found")); + + Comment comment = Comment.builder() + .content(content) + .post(post) + .author(author) + .build(); + return commentRepo.save(comment); } public Comment update(Long postId, Long commentId, String content) { Comment comment = get(postId, commentId); comment.update(content); - return commentRepo.save(comment); + return comment; } public void delete(Long postId, Long commentId) { diff --git a/src/main/java/com/example/devSns/service/FollowService.java b/src/main/java/com/example/devSns/service/FollowService.java new file mode 100644 index 0000000..7465e99 --- /dev/null +++ b/src/main/java/com/example/devSns/service/FollowService.java @@ -0,0 +1,73 @@ +package com.example.devSns.service; + +import com.example.devSns.domain.Follow; +import com.example.devSns.domain.FollowRepository; +import com.example.devSns.domain.Member; +import com.example.devSns.domain.MemberRepository; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@Service +@Transactional +public class FollowService { + + private final MemberRepository memberRepository; + private final FollowRepository followRepository; + + public FollowService(MemberRepository memberRepository, + FollowRepository followRepository) { + this.memberRepository = memberRepository; + this.followRepository = followRepository; + } + + public void follow(Long followerId, Long followingId) { + if (followerId.equals(followingId)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "자기 자신은 팔로우할 수 없습니다."); + } + + Member follower = getMember(followerId); + Member following = getMember(followingId); + + if (followRepository.existsByFollowerIdAndFollowingId(followerId, followingId)) { + // 이미 팔로우 중이면 무시 + return; + } + + Follow follow = Follow.builder() + .follower(follower) + .following(following) + .build(); + followRepository.save(follow); + } + + public void unfollow(Long followerId, Long followingId) { + followRepository.deleteByFollowerIdAndFollowingId(followerId, followingId); + } + + @Transactional(readOnly = true) + public List getFollowers(Long memberId) { + getMember(memberId); // 존재 확인 + return followRepository.findByFollowingId(memberId) + .stream() + .map(Follow::getFollower) + .toList(); + } + + @Transactional(readOnly = true) + public List getFollowings(Long memberId) { + getMember(memberId); // 존재 확인 + return followRepository.findByFollowerId(memberId) + .stream() + .map(Follow::getFollowing) + .toList(); + } + + private Member getMember(Long id) { + return memberRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found")); + } +} 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..cdb86b7 --- /dev/null +++ b/src/main/java/com/example/devSns/service/MemberService.java @@ -0,0 +1,77 @@ +package com.example.devSns.service; + +import com.example.devSns.domain.*; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@Service +@Transactional +public class MemberService { + + private final MemberRepository memberRepository; + private final PostRepository postRepository; + private final CommentRepository commentRepository; + private final PostLikeRepository postLikeRepository; + + public MemberService(MemberRepository memberRepository, + PostRepository postRepository, + CommentRepository commentRepository, + PostLikeRepository postLikeRepository) { + this.memberRepository = memberRepository; + this.postRepository = postRepository; + this.commentRepository = commentRepository; + this.postLikeRepository = postLikeRepository; + } + + public Member create(String username, String nickname, String bio) { + Member member = Member.builder() + .username(username) + .nickname(nickname) + .bio(bio) + .build(); + return memberRepository.save(member); + } + + @Transactional(readOnly = true) + public Member get(Long memberId) { + return memberRepository.findById(memberId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found")); + } + + @Transactional(readOnly = true) + public List search(String keyword) { + if (keyword == null || keyword.isBlank()) { + return memberRepository.findAll(); + } + return memberRepository + .findByUsernameContainingIgnoreCaseOrNicknameContainingIgnoreCase(keyword, keyword); + } + + @Transactional(readOnly = true) + public List getPosts(Long memberId) { + ensureMemberExists(memberId); + return postRepository.findByAuthorIdOrderByIdDesc(memberId); + } + + @Transactional(readOnly = true) + public List getComments(Long memberId) { + ensureMemberExists(memberId); + return commentRepository.findByAuthorIdOrderByIdDesc(memberId); + } + + @Transactional(readOnly = true) + public List getLikes(Long memberId) { + ensureMemberExists(memberId); + return postLikeRepository.findByMemberIdOrderByIdDesc(memberId); + } + + private void ensureMemberExists(Long memberId) { + if (!memberRepository.existsById(memberId)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found"); + } + } +} diff --git a/src/main/java/com/example/devSns/service/PostLikeService.java b/src/main/java/com/example/devSns/service/PostLikeService.java new file mode 100644 index 0000000..9a6503d --- /dev/null +++ b/src/main/java/com/example/devSns/service/PostLikeService.java @@ -0,0 +1,51 @@ +package com.example.devSns.service; + +import com.example.devSns.domain.*; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +@Service +@Transactional +public class PostLikeService { + + private final MemberRepository memberRepository; + private final PostRepository postRepository; + private final PostLikeRepository postLikeRepository; + + public PostLikeService(MemberRepository memberRepository, + PostRepository postRepository, + PostLikeRepository postLikeRepository) { + this.memberRepository = memberRepository; + this.postRepository = postRepository; + this.postLikeRepository = postLikeRepository; + } + + public void like(Long memberId, Long postId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found")); + + Post post = postRepository.findById(postId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Post not found")); + + if (postLikeRepository.existsByMemberIdAndPostId(memberId, postId)) { + return; // 이미 좋아요 누른 상태 + } + + PostLike like = PostLike.builder() + .member(member) + .post(post) + .build(); + postLikeRepository.save(like); + } + + public void unlike(Long memberId, Long postId) { + postLikeRepository.deleteByMemberIdAndPostId(memberId, postId); + } + + @Transactional(readOnly = true) + public long countLikes(Long postId) { + return postLikeRepository.countByPostId(postId); + } +} diff --git a/src/main/java/com/example/devSns/service/PostService.java b/src/main/java/com/example/devSns/service/PostService.java index de944f6..4c97614 100644 --- a/src/main/java/com/example/devSns/service/PostService.java +++ b/src/main/java/com/example/devSns/service/PostService.java @@ -1,48 +1,64 @@ package com.example.devSns.service; +import com.example.devSns.domain.Member; +import com.example.devSns.domain.MemberRepository; import com.example.devSns.domain.Post; import com.example.devSns.domain.PostRepository; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; import java.util.List; @Service +@Transactional public class PostService { - private final PostRepository repo; + private final PostRepository postRepository; + private final MemberRepository memberRepository; - public PostService(PostRepository repo) { - this.repo = repo; + public PostService(PostRepository postRepository, + MemberRepository memberRepository) { + this.postRepository = postRepository; + this.memberRepository = memberRepository; } + /** PostController.list 에서 쓰는 메서드 */ + @Transactional(readOnly = true) public List findAll() { - return repo.findAll(); + return postRepository.findAll(); } + /** PostController.get 에서 쓰는 메서드 */ + @Transactional(readOnly = true) public Post findById(Long id) { - return repo.findById(id) + return postRepository.findById(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Post not found")); } - public Post create(String title, String content) { + /** PostController.create 에서 쓰는 메서드 */ + public Post create(Long memberId, String title, String content) { + Member author = memberRepository.findById(memberId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found")); + Post post = Post.builder() + .author(author) .title(title) .content(content) .build(); - return repo.save(post); + + return postRepository.save(post); } public Post update(Long id, String title, String content) { Post post = findById(id); post.update(title, content); - return repo.save(post); + return post; } public void delete(Long id) { - Post post = repo.findById(id) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Post not found")); - repo.delete(post); // 연관 댓글까지 안전하게 제거 + Post post = findById(id); + postRepository.delete(post); } } diff --git a/src/main/java/com/example/devSns/web/CommentController.java b/src/main/java/com/example/devSns/web/CommentController.java index 115ee06..dab1b46 100644 --- a/src/main/java/com/example/devSns/web/CommentController.java +++ b/src/main/java/com/example/devSns/web/CommentController.java @@ -33,7 +33,13 @@ public CommentResponse get(@PathVariable Long postId, @PathVariable Long comment @PostMapping public CommentResponse create(@PathVariable Long postId, @Valid @RequestBody CommentCreateRequest req) { - return CommentResponse.from(svc.create(postId, req.content())); + return CommentResponse.from( + svc.create( + postId, + req.memberId(), + req.content() + ) + ); } @PutMapping("/{commentId}") diff --git a/src/main/java/com/example/devSns/web/FollowController.java b/src/main/java/com/example/devSns/web/FollowController.java new file mode 100644 index 0000000..8e5bc84 --- /dev/null +++ b/src/main/java/com/example/devSns/web/FollowController.java @@ -0,0 +1,48 @@ +package com.example.devSns.web; + +import com.example.devSns.service.FollowService; +import com.example.devSns.web.dto.MemberResponse; +import com.example.devSns.domain.Member; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/members/{memberId}") +public class FollowController { + + private final FollowService followService; + + public FollowController(FollowService followService) { + this.followService = followService; + } + + /** memberId 가 targetId 를 팔로우 */ + @PostMapping("/follow/{targetId}") + @ResponseStatus(HttpStatus.CREATED) + public void follow(@PathVariable Long memberId, @PathVariable Long targetId) { + followService.follow(memberId, targetId); + } + + /** 언팔로우 */ + @DeleteMapping("/follow/{targetId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void unfollow(@PathVariable Long memberId, @PathVariable Long targetId) { + followService.unfollow(memberId, targetId); + } + + /** 나를 팔로우하는 사람들 */ + @GetMapping("/followers") + public List followers(@PathVariable Long memberId) { + List list = followService.getFollowers(memberId); + return list.stream().map(MemberResponse::from).toList(); + } + + /** 내가 팔로우하는 사람들 */ + @GetMapping("/followings") + public List followings(@PathVariable Long memberId) { + List list = followService.getFollowings(memberId); + return list.stream().map(MemberResponse::from).toList(); + } +} diff --git a/src/main/java/com/example/devSns/web/MemberController.java b/src/main/java/com/example/devSns/web/MemberController.java new file mode 100644 index 0000000..ed5c199 --- /dev/null +++ b/src/main/java/com/example/devSns/web/MemberController.java @@ -0,0 +1,73 @@ +package com.example.devSns.web; + +import com.example.devSns.domain.PostLike; +import com.example.devSns.service.MemberService; +import com.example.devSns.web.dto.*; +import com.example.devSns.domain.Member; +import com.example.devSns.domain.Post; +import com.example.devSns.domain.Comment; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/members") +public class MemberController { + + private final MemberService memberService; + + public MemberController(MemberService memberService) { + this.memberService = memberService; + } + + /** 멤버 생성 */ + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public MemberResponse create(@Valid @RequestBody MemberCreateRequest req) { + Member m = memberService.create(req.username(), req.nickname(), req.bio()); + return MemberResponse.from(m); + } + + /** 멤버 검색 (q 없으면 전체) */ + @GetMapping + public List search(@RequestParam(name = "q", required = false) String keyword) { + return memberService.search(keyword).stream() + .map(MemberResponse::from) + .toList(); + } + + /** 멤버 단건 조회 */ + @GetMapping("/{memberId}") + public MemberResponse get(@PathVariable Long memberId) { + return MemberResponse.from(memberService.get(memberId)); + } + + /** 멤버의 게시글 보기 */ + @GetMapping("/{memberId}/posts") + public List posts(@PathVariable Long memberId) { + List posts = memberService.getPosts(memberId); + return posts.stream() + .map(PostResponse::from) + .toList(); + } + + /** 멤버의 댓글 보기 */ + @GetMapping("/{memberId}/comments") + public List comments(@PathVariable Long memberId) { + List comments = memberService.getComments(memberId); + return comments.stream() + .map(CommentResponse::from) + .toList(); + } + + /** 멤버가 좋아요 누른 게시글 보기 */ + @GetMapping("/{memberId}/likes") + public List likedPosts(@PathVariable Long memberId) { + List likes = memberService.getLikes(memberId); + return likes.stream() + .map(pl -> PostResponse.from(pl.getPost())) + .toList(); + } +} diff --git a/src/main/java/com/example/devSns/web/PostController.java b/src/main/java/com/example/devSns/web/PostController.java index ec1c6b2..928290a 100644 --- a/src/main/java/com/example/devSns/web/PostController.java +++ b/src/main/java/com/example/devSns/web/PostController.java @@ -22,23 +22,28 @@ public PostController(PostService svc) { } @GetMapping - public List getAll() { + public List list() { return svc.findAll().stream().map(PostResponse::from).toList(); } @GetMapping("/{id}") - public PostResponse getOne(@PathVariable Long id) { + public PostResponse get(@PathVariable Long id) { Post p = svc.findById(id); return PostResponse.from(p); } - @ResponseStatus(HttpStatus.CREATED) @PostMapping + @ResponseStatus(HttpStatus.CREATED) public PostResponse create(@Valid @RequestBody PostCreateRequest req) { - Post saved = svc.create(req.title(), req.content()); - return PostResponse.from(saved); + Post created = svc.create( + req.memberId(), + req.title(), + req.content() + ); + return PostResponse.from(created); } + @PutMapping("/{id}") public PostResponse update(@PathVariable Long id, @Valid @RequestBody PostUpdateRequest req) { Post updated = svc.update(id, req.title(), req.content()); diff --git a/src/main/java/com/example/devSns/web/PostLikeController.java b/src/main/java/com/example/devSns/web/PostLikeController.java new file mode 100644 index 0000000..e1781c6 --- /dev/null +++ b/src/main/java/com/example/devSns/web/PostLikeController.java @@ -0,0 +1,40 @@ +package com.example.devSns.web; + +import com.example.devSns.service.PostLikeService; +import com.example.devSns.web.dto.LikeRequest; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/posts/{postId}/likes") +public class PostLikeController { + + private final PostLikeService postLikeService; + + public PostLikeController(PostLikeService postLikeService) { + this.postLikeService = postLikeService; + } + + /** 좋아요 누르기 */ + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public void like(@PathVariable Long postId, + @Valid @RequestBody LikeRequest req) { + postLikeService.like(req.memberId(), postId); + } + + /** 좋아요 취소 */ + @DeleteMapping + @ResponseStatus(HttpStatus.NO_CONTENT) + public void unlike(@PathVariable Long postId, + @Valid @RequestBody LikeRequest req) { + postLikeService.unlike(req.memberId(), postId); + } + + /** 게시글 좋아요 개수 */ + @GetMapping("/count") + public long count(@PathVariable Long postId) { + return postLikeService.countLikes(postId); + } +} diff --git a/src/main/java/com/example/devSns/web/dto/CommentCreateRequest.java b/src/main/java/com/example/devSns/web/dto/CommentCreateRequest.java index f6848dd..8a1b886 100644 --- a/src/main/java/com/example/devSns/web/dto/CommentCreateRequest.java +++ b/src/main/java/com/example/devSns/web/dto/CommentCreateRequest.java @@ -1,7 +1,9 @@ package com.example.devSns.web.dto; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; public record CommentCreateRequest( + @NotNull Long memberId, @NotBlank String content ) {} diff --git a/src/main/java/com/example/devSns/web/dto/LikeRequest.java b/src/main/java/com/example/devSns/web/dto/LikeRequest.java new file mode 100644 index 0000000..04f4627 --- /dev/null +++ b/src/main/java/com/example/devSns/web/dto/LikeRequest.java @@ -0,0 +1,7 @@ +package com.example.devSns.web.dto; + +import jakarta.validation.constraints.NotNull; + +public record LikeRequest( + @NotNull Long memberId +) {} diff --git a/src/main/java/com/example/devSns/web/dto/MemberCreateRequest.java b/src/main/java/com/example/devSns/web/dto/MemberCreateRequest.java new file mode 100644 index 0000000..eb34bba --- /dev/null +++ b/src/main/java/com/example/devSns/web/dto/MemberCreateRequest.java @@ -0,0 +1,9 @@ +package com.example.devSns.web.dto; + +import jakarta.validation.constraints.NotBlank; + +public record MemberCreateRequest( + @NotBlank String username, + String nickname, + String bio +) {} diff --git a/src/main/java/com/example/devSns/web/dto/MemberResponse.java b/src/main/java/com/example/devSns/web/dto/MemberResponse.java new file mode 100644 index 0000000..8584324 --- /dev/null +++ b/src/main/java/com/example/devSns/web/dto/MemberResponse.java @@ -0,0 +1,19 @@ +package com.example.devSns.web.dto; + +import com.example.devSns.domain.Member; + +public record MemberResponse( + Long id, + String username, + String nickname, + String bio +) { + public static MemberResponse from(Member m) { + return new MemberResponse( + m.getId(), + m.getUsername(), + m.getNickname(), + m.getBio() + ); + } +} diff --git a/src/main/java/com/example/devSns/web/dto/PostCreateRequest.java b/src/main/java/com/example/devSns/web/dto/PostCreateRequest.java index a486cad..ebfd6b5 100644 --- a/src/main/java/com/example/devSns/web/dto/PostCreateRequest.java +++ b/src/main/java/com/example/devSns/web/dto/PostCreateRequest.java @@ -1,8 +1,10 @@ package com.example.devSns.web.dto; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; public record PostCreateRequest( + @NotNull Long memberId, @NotBlank String title, String content ) {} From c662aa4d5ee2d34815e1a2da1799c56cdfa7cc72 Mon Sep 17 00:00:00 2001 From: Park Jae Hong <105151063+PHJ2000@users.noreply.github.com> Date: Fri, 21 Nov 2025 18:21:39 +0900 Subject: [PATCH 3/5] =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20&=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 + .../config/JwtAuthenticationFilter.java | 46 +++++++++++ .../devSns/config/JwtTokenProvider.java | 80 +++++++++++++++++++ .../example/devSns/config/SecurityConfig.java | 58 ++++++++++++++ .../com/example/devSns/domain/Member.java | 13 ++- .../devSns/domain/MemberRepository.java | 5 ++ .../example/devSns/service/MemberService.java | 16 +++- .../example/devSns/web/AuthController.java | 64 +++++++++++++++ .../example/devSns/web/MemberController.java | 8 +- .../example/devSns/web/dto/LoginRequest.java | 8 ++ .../example/devSns/web/dto/LoginResponse.java | 14 ++++ .../devSns/web/dto/MemberCreateRequest.java | 1 + src/main/resources/application.yml | 10 ++- 13 files changed, 319 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/example/devSns/config/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/example/devSns/config/JwtTokenProvider.java create mode 100644 src/main/java/com/example/devSns/config/SecurityConfig.java create mode 100644 src/main/java/com/example/devSns/web/AuthController.java create mode 100644 src/main/java/com/example/devSns/web/dto/LoginRequest.java create mode 100644 src/main/java/com/example/devSns/web/dto/LoginResponse.java diff --git a/build.gradle b/build.gradle index f7c08a3..96e5c03 100644 --- a/build.gradle +++ b/build.gradle @@ -13,6 +13,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' runtimeOnly 'com.h2database:h2' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' diff --git a/src/main/java/com/example/devSns/config/JwtAuthenticationFilter.java b/src/main/java/com/example/devSns/config/JwtAuthenticationFilter.java new file mode 100644 index 0000000..7ccb7c7 --- /dev/null +++ b/src/main/java/com/example/devSns/config/JwtAuthenticationFilter.java @@ -0,0 +1,46 @@ +package com.example.devSns.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) { + this.jwtTokenProvider = jwtTokenProvider; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { + + String token = resolveToken(request); + + if (token != null && jwtTokenProvider.validateToken(token)) { + Authentication authentication = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { + String header = request.getHeader("Authorization"); + if (header != null && header.startsWith("Bearer ")) { + return header.substring(7); + } + return null; + } +} diff --git a/src/main/java/com/example/devSns/config/JwtTokenProvider.java b/src/main/java/com/example/devSns/config/JwtTokenProvider.java new file mode 100644 index 0000000..e4ce22b --- /dev/null +++ b/src/main/java/com/example/devSns/config/JwtTokenProvider.java @@ -0,0 +1,80 @@ +package com.example.devSns.config; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.User; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Collections; +import java.util.Date; + +@Component +public class JwtTokenProvider { + + @Value("${jwt.secret}") + private String secretKey; + + @Value("${jwt.access-token-validity-in-seconds}") + private long validityInSeconds; + + private Key key; + + @PostConstruct + public void init() { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + public String createToken(String username, Long memberId) { + Date now = new Date(); + Date validity = new Date(now.getTime() + validityInSeconds * 1000); + + return Jwts.builder() + .setSubject(username) + .claim("memberId", memberId) + .setIssuedAt(now) + .setExpiration(validity) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public boolean validateToken(String token) { + try { + getClaims(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + private Claims getClaims(String token) { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + } + + public Authentication getAuthentication(String token) { + Claims claims = getClaims(token); + String username = claims.getSubject(); + + User principal = new User(username, "", Collections.emptyList()); + return new UsernamePasswordAuthenticationToken( + principal, + token, + principal.getAuthorities() + ); + } + + public Long getMemberId(String token) { + Claims claims = getClaims(token); + return claims.get("memberId", Long.class); + } +} 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..ccb0fd1 --- /dev/null +++ b/src/main/java/com/example/devSns/config/SecurityConfig.java @@ -0,0 +1,58 @@ +package com.example.devSns.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +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 +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { + this.jwtAuthenticationFilter = jwtAuthenticationFilter; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .httpBasic(basic -> basic.disable()) + .formLogin(form -> form.disable()) + .sessionManagement(sm -> + sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .authorizeHttpRequests(auth -> auth + // 로그인/회원가입, H2 콘솔은 누구나 허용 + .requestMatchers("/auth/**", "/h2-console/**").permitAll() + // 읽기(GET)은 일단 공개 + .requestMatchers(HttpMethod.GET, "/**").permitAll() + // 작성/수정/삭제는 인증 필요 + .requestMatchers(HttpMethod.POST, "/**").authenticated() + .requestMatchers(HttpMethod.PUT, "/**").authenticated() + .requestMatchers(HttpMethod.DELETE, "/**").authenticated() + .anyRequest().authenticated() + ); + + // H2 콘솔용 frame 옵션 + http.headers(headers -> headers.frameOptions(frame -> frame.sameOrigin())); + + // JWT 필터 등록 + http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/example/devSns/domain/Member.java b/src/main/java/com/example/devSns/domain/Member.java index 9f0ce1e..7d821ea 100644 --- a/src/main/java/com/example/devSns/domain/Member.java +++ b/src/main/java/com/example/devSns/domain/Member.java @@ -14,7 +14,11 @@ public class Member { @NotBlank @Column(nullable = false, unique = true, length = 50) - private String username; // 로그인/아이디 용 + private String username; // 로그인 아이디 + + @NotBlank + @Column(nullable = false, length = 100) + private String password; // 인코딩된 비밀번호 @Column(length = 50) private String nickname; // 화면용 이름 @@ -23,8 +27,9 @@ public class Member { private String bio; // 자기소개 (옵션) @Builder - private Member(String username, String nickname, String bio) { + private Member(String username, String password, String nickname, String bio) { this.username = username; + this.password = password; this.nickname = nickname; this.bio = bio; } @@ -33,4 +38,8 @@ public void updateProfile(String nickname, String bio) { this.nickname = nickname; this.bio = bio; } + + public void changePassword(String encodedPassword) { + this.password = encodedPassword; + } } diff --git a/src/main/java/com/example/devSns/domain/MemberRepository.java b/src/main/java/com/example/devSns/domain/MemberRepository.java index c8705a5..83f3260 100644 --- a/src/main/java/com/example/devSns/domain/MemberRepository.java +++ b/src/main/java/com/example/devSns/domain/MemberRepository.java @@ -3,6 +3,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; +import java.util.Optional; public interface MemberRepository extends JpaRepository { @@ -11,4 +12,8 @@ List findByUsernameContainingIgnoreCaseOrNicknameContainingIgnoreCase( String usernameKeyword, String nicknameKeyword ); + + Optional findByUsername(String username); + + boolean existsByUsername(String username); } diff --git a/src/main/java/com/example/devSns/service/MemberService.java b/src/main/java/com/example/devSns/service/MemberService.java index cdb86b7..a8d5c99 100644 --- a/src/main/java/com/example/devSns/service/MemberService.java +++ b/src/main/java/com/example/devSns/service/MemberService.java @@ -2,6 +2,7 @@ import com.example.devSns.domain.*; import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; @@ -16,23 +17,34 @@ public class MemberService { private final PostRepository postRepository; private final CommentRepository commentRepository; private final PostLikeRepository postLikeRepository; + private final PasswordEncoder passwordEncoder; // ✅ 추가 public MemberService(MemberRepository memberRepository, PostRepository postRepository, CommentRepository commentRepository, - PostLikeRepository postLikeRepository) { + PostLikeRepository postLikeRepository, + PasswordEncoder passwordEncoder) { // ✅ 생성자 수정 this.memberRepository = memberRepository; this.postRepository = postRepository; this.commentRepository = commentRepository; this.postLikeRepository = postLikeRepository; + this.passwordEncoder = passwordEncoder; } - public Member create(String username, String nickname, String bio) { + public Member create(String username, String rawPassword, String nickname, String bio) { + if (memberRepository.existsByUsername(username)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 사용 중인 아이디입니다."); + } + + String encodedPassword = passwordEncoder.encode(rawPassword); + Member member = Member.builder() .username(username) + .password(encodedPassword) // ✅ 인코딩된 비밀번호 저장 .nickname(nickname) .bio(bio) .build(); + return memberRepository.save(member); } diff --git a/src/main/java/com/example/devSns/web/AuthController.java b/src/main/java/com/example/devSns/web/AuthController.java new file mode 100644 index 0000000..8184168 --- /dev/null +++ b/src/main/java/com/example/devSns/web/AuthController.java @@ -0,0 +1,64 @@ +package com.example.devSns.web; + +import com.example.devSns.config.JwtTokenProvider; +import com.example.devSns.domain.Member; +import com.example.devSns.domain.MemberRepository; +import com.example.devSns.service.MemberService; +import com.example.devSns.web.dto.LoginRequest; +import com.example.devSns.web.dto.LoginResponse; +import com.example.devSns.web.dto.MemberCreateRequest; +import com.example.devSns.web.dto.MemberResponse; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/auth") +public class AuthController { + + private final MemberService memberService; + private final MemberRepository memberRepository; + private final JwtTokenProvider jwtTokenProvider; + private final PasswordEncoder passwordEncoder; + + public AuthController(MemberService memberService, + MemberRepository memberRepository, + JwtTokenProvider jwtTokenProvider, + PasswordEncoder passwordEncoder) { + this.memberService = memberService; + this.memberRepository = memberRepository; + this.jwtTokenProvider = jwtTokenProvider; + this.passwordEncoder = passwordEncoder; + } + + /** 회원가입 */ + @PostMapping("/signup") + @ResponseStatus(HttpStatus.CREATED) + public MemberResponse signup(@Valid @RequestBody MemberCreateRequest req) { + Member member = memberService.create( + req.username(), + req.password(), + req.nickname(), + req.bio() + ); + return MemberResponse.from(member); + } + + /** 로그인 + JWT 발급 */ + @PostMapping("/login") + public LoginResponse login(@Valid @RequestBody LoginRequest req) { + Member member = memberRepository.findByUsername(req.username()) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.UNAUTHORIZED, "아이디 혹은 비밀번호가 올바르지 않습니다.")); + + if (!passwordEncoder.matches(req.password(), member.getPassword())) { + throw new ResponseStatusException( + HttpStatus.UNAUTHORIZED, "아이디 혹은 비밀번호가 올바르지 않습니다."); + } + + String token = jwtTokenProvider.createToken(member.getUsername(), member.getId()); + return LoginResponse.of(token, member); + } +} diff --git a/src/main/java/com/example/devSns/web/MemberController.java b/src/main/java/com/example/devSns/web/MemberController.java index ed5c199..389824c 100644 --- a/src/main/java/com/example/devSns/web/MemberController.java +++ b/src/main/java/com/example/devSns/web/MemberController.java @@ -22,11 +22,15 @@ public MemberController(MemberService memberService) { this.memberService = memberService; } - /** 멤버 생성 */ @PostMapping @ResponseStatus(HttpStatus.CREATED) public MemberResponse create(@Valid @RequestBody MemberCreateRequest req) { - Member m = memberService.create(req.username(), req.nickname(), req.bio()); + Member m = memberService.create( + req.username(), + req.password(), + req.nickname(), + req.bio() + ); return MemberResponse.from(m); } diff --git a/src/main/java/com/example/devSns/web/dto/LoginRequest.java b/src/main/java/com/example/devSns/web/dto/LoginRequest.java new file mode 100644 index 0000000..0fc2484 --- /dev/null +++ b/src/main/java/com/example/devSns/web/dto/LoginRequest.java @@ -0,0 +1,8 @@ +package com.example.devSns.web.dto; + +import jakarta.validation.constraints.NotBlank; + +public record LoginRequest( + @NotBlank String username, + @NotBlank String password +) {} diff --git a/src/main/java/com/example/devSns/web/dto/LoginResponse.java b/src/main/java/com/example/devSns/web/dto/LoginResponse.java new file mode 100644 index 0000000..2366e18 --- /dev/null +++ b/src/main/java/com/example/devSns/web/dto/LoginResponse.java @@ -0,0 +1,14 @@ +package com.example.devSns.web.dto; + +import com.example.devSns.domain.Member; + +public record LoginResponse( + String accessToken, + String tokenType, + Long memberId, + String username +) { + public static LoginResponse of(String token, Member member) { + return new LoginResponse(token, "Bearer", member.getId(), member.getUsername()); + } +} diff --git a/src/main/java/com/example/devSns/web/dto/MemberCreateRequest.java b/src/main/java/com/example/devSns/web/dto/MemberCreateRequest.java index eb34bba..ffaf0bb 100644 --- a/src/main/java/com/example/devSns/web/dto/MemberCreateRequest.java +++ b/src/main/java/com/example/devSns/web/dto/MemberCreateRequest.java @@ -4,6 +4,7 @@ public record MemberCreateRequest( @NotBlank String username, + @NotBlank String password, String nickname, String bio ) {} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d92d4f5..9ef94a8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -5,7 +5,13 @@ spring: username: sa password: jpa: - hibernate.ddl-auto: update + hibernate: + ddl-auto: update show-sql: true h2: - console.enabled: true + console: + enabled: true + +jwt: + secret: zwKNCyglJK435Wj0ylqi/KF5xubUPnzYOQj8Jkl9QWxea4xj100mCRA9tNXjldjvDaQWo9neQCutSAjNd+N4uw== + access-token-validity-in-seconds: 3600 From 2e1d5f65c0b94cf010fed47fe45019b2edd36eda Mon Sep 17 00:00:00 2001 From: Park Jae Hong <105151063+PHJ2000@users.noreply.github.com> Date: Fri, 5 Dec 2025 18:28:52 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat(auth):=20Refresh=20Token=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=ED=86=A0=ED=81=B0=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/devSns/domain/RefreshToken.java | 41 ++++++++++++ .../devSns/domain/RefreshTokenRepository.java | 12 ++++ .../devSns/service/RefreshTokenService.java | 65 +++++++++++++++++++ .../example/devSns/web/AuthController.java | 30 +++++++-- .../example/devSns/web/dto/LoginResponse.java | 11 +++- .../devSns/web/dto/TokenRefreshRequest.java | 8 +++ src/main/resources/application.yml | 1 + 7 files changed, 162 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/example/devSns/domain/RefreshToken.java create mode 100644 src/main/java/com/example/devSns/domain/RefreshTokenRepository.java create mode 100644 src/main/java/com/example/devSns/service/RefreshTokenService.java create mode 100644 src/main/java/com/example/devSns/web/dto/TokenRefreshRequest.java 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..1c26e6b --- /dev/null +++ b/src/main/java/com/example/devSns/domain/RefreshToken.java @@ -0,0 +1,41 @@ +package com.example.devSns.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RefreshToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Column(nullable = false, unique = true, length = 200) + private String token; + + @Column(nullable = false) + private Instant expiryDate; + + @Builder + private RefreshToken(Member member, String token, Instant expiryDate) { + this.member = member; + this.token = token; + this.expiryDate = expiryDate; + } + + public void update(String token, Instant expiryDate) { + this.token = token; + this.expiryDate = expiryDate; + } +} diff --git a/src/main/java/com/example/devSns/domain/RefreshTokenRepository.java b/src/main/java/com/example/devSns/domain/RefreshTokenRepository.java new file mode 100644 index 0000000..15ecfd4 --- /dev/null +++ b/src/main/java/com/example/devSns/domain/RefreshTokenRepository.java @@ -0,0 +1,12 @@ +package com.example.devSns.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface RefreshTokenRepository extends JpaRepository { + + Optional findByToken(String token); + + void deleteByMember(Member member); +} diff --git a/src/main/java/com/example/devSns/service/RefreshTokenService.java b/src/main/java/com/example/devSns/service/RefreshTokenService.java new file mode 100644 index 0000000..2681fb1 --- /dev/null +++ b/src/main/java/com/example/devSns/service/RefreshTokenService.java @@ -0,0 +1,65 @@ +package com.example.devSns.service; + +import com.example.devSns.domain.Member; +import com.example.devSns.domain.RefreshToken; +import com.example.devSns.domain.RefreshTokenRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.Instant; +import java.util.UUID; + +@Service +@Transactional +public class RefreshTokenService { + + private final RefreshTokenRepository refreshTokenRepository; + + @Value("${jwt.refresh-token-validity-in-seconds}") + private long refreshValidityInSeconds; + + public RefreshTokenService(RefreshTokenRepository refreshTokenRepository) { + this.refreshTokenRepository = refreshTokenRepository; + } + + /** 새 리프레시 토큰 생성 + 저장 */ + public RefreshToken create(Member member) { + String token = UUID.randomUUID().toString(); + Instant expiryDate = Instant.now().plusSeconds(refreshValidityInSeconds); + + RefreshToken refreshToken = RefreshToken.builder() + .member(member) + .token(token) + .expiryDate(expiryDate) + .build(); + + return refreshTokenRepository.save(refreshToken); + } + + /** 토큰 문자열로 검증 (존재 + 만료 체크) */ + public RefreshToken validate(String token) { + RefreshToken refreshToken = refreshTokenRepository.findByToken(token) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.UNAUTHORIZED, + "유효하지 않은 리프레시 토큰입니다." + )); + + if (refreshToken.getExpiryDate().isBefore(Instant.now())) { + refreshTokenRepository.delete(refreshToken); + throw new ResponseStatusException( + HttpStatus.UNAUTHORIZED, + "리프레시 토큰이 만료되었습니다. 다시 로그인 해주세요." + ); + } + + return refreshToken; + } + + /** 해당 회원의 모든 리프레시 토큰 제거 (로그아웃 등에서 사용 가능) */ + public void deleteByMember(Member member) { + refreshTokenRepository.deleteByMember(member); + } +} diff --git a/src/main/java/com/example/devSns/web/AuthController.java b/src/main/java/com/example/devSns/web/AuthController.java index 8184168..d8eb984 100644 --- a/src/main/java/com/example/devSns/web/AuthController.java +++ b/src/main/java/com/example/devSns/web/AuthController.java @@ -3,11 +3,14 @@ import com.example.devSns.config.JwtTokenProvider; import com.example.devSns.domain.Member; import com.example.devSns.domain.MemberRepository; +import com.example.devSns.domain.RefreshToken; import com.example.devSns.service.MemberService; +import com.example.devSns.service.RefreshTokenService; import com.example.devSns.web.dto.LoginRequest; import com.example.devSns.web.dto.LoginResponse; import com.example.devSns.web.dto.MemberCreateRequest; import com.example.devSns.web.dto.MemberResponse; +import com.example.devSns.web.dto.TokenRefreshRequest; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.security.crypto.password.PasswordEncoder; @@ -22,15 +25,18 @@ public class AuthController { private final MemberRepository memberRepository; private final JwtTokenProvider jwtTokenProvider; private final PasswordEncoder passwordEncoder; + private final RefreshTokenService refreshTokenService; public AuthController(MemberService memberService, MemberRepository memberRepository, JwtTokenProvider jwtTokenProvider, - PasswordEncoder passwordEncoder) { + PasswordEncoder passwordEncoder, + RefreshTokenService refreshTokenService) { this.memberService = memberService; this.memberRepository = memberRepository; this.jwtTokenProvider = jwtTokenProvider; this.passwordEncoder = passwordEncoder; + this.refreshTokenService = refreshTokenService; } /** 회원가입 */ @@ -46,7 +52,7 @@ public MemberResponse signup(@Valid @RequestBody MemberCreateRequest req) { return MemberResponse.from(member); } - /** 로그인 + JWT 발급 */ + /** 로그인 + AccessToken / RefreshToken 발급 */ @PostMapping("/login") public LoginResponse login(@Valid @RequestBody LoginRequest req) { Member member = memberRepository.findByUsername(req.username()) @@ -58,7 +64,23 @@ public LoginResponse login(@Valid @RequestBody LoginRequest req) { HttpStatus.UNAUTHORIZED, "아이디 혹은 비밀번호가 올바르지 않습니다."); } - String token = jwtTokenProvider.createToken(member.getUsername(), member.getId()); - return LoginResponse.of(token, member); + String accessToken = jwtTokenProvider.createToken(member.getUsername(), member.getId()); + RefreshToken refreshToken = refreshTokenService.create(member); + + return LoginResponse.of(accessToken, refreshToken.getToken(), member); + } + + /** RefreshToken 으로 AccessToken 재발급 */ + @PostMapping("/refresh") + public LoginResponse refresh(@Valid @RequestBody TokenRefreshRequest req) { + // 1) 리프레시 토큰 검증 + RefreshToken refreshToken = refreshTokenService.validate(req.refreshToken()); + Member member = refreshToken.getMember(); + + // 2) 새 AccessToken 발급 + String newAccessToken = jwtTokenProvider.createToken(member.getUsername(), member.getId()); + + // 3) RefreshToken은 여기서는 재사용 (원하면 회전 로직 추가 가능) + return LoginResponse.of(newAccessToken, refreshToken.getToken(), member); } } diff --git a/src/main/java/com/example/devSns/web/dto/LoginResponse.java b/src/main/java/com/example/devSns/web/dto/LoginResponse.java index 2366e18..daf487c 100644 --- a/src/main/java/com/example/devSns/web/dto/LoginResponse.java +++ b/src/main/java/com/example/devSns/web/dto/LoginResponse.java @@ -5,10 +5,17 @@ public record LoginResponse( String accessToken, String tokenType, + String refreshToken, Long memberId, String username ) { - public static LoginResponse of(String token, Member member) { - return new LoginResponse(token, "Bearer", member.getId(), member.getUsername()); + public static LoginResponse of(String accessToken, String refreshToken, Member member) { + return new LoginResponse( + accessToken, + "Bearer", + refreshToken, + member.getId(), + member.getUsername() + ); } } diff --git a/src/main/java/com/example/devSns/web/dto/TokenRefreshRequest.java b/src/main/java/com/example/devSns/web/dto/TokenRefreshRequest.java new file mode 100644 index 0000000..6f134d3 --- /dev/null +++ b/src/main/java/com/example/devSns/web/dto/TokenRefreshRequest.java @@ -0,0 +1,8 @@ +package com.example.devSns.web.dto; + +import jakarta.validation.constraints.NotBlank; + +public record TokenRefreshRequest( + @NotBlank String refreshToken +) { +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9ef94a8..c7a94b3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -15,3 +15,4 @@ spring: jwt: secret: zwKNCyglJK435Wj0ylqi/KF5xubUPnzYOQj8Jkl9QWxea4xj100mCRA9tNXjldjvDaQWo9neQCutSAjNd+N4uw== access-token-validity-in-seconds: 3600 + refresh-token-validity-in-seconds: 1209600 From 68412a153fa2af333d16d939f3b70055ca04fada Mon Sep 17 00:00:00 2001 From: Park Jae Hong <105151063+PHJ2000@users.noreply.github.com> Date: Fri, 5 Dec 2025 18:29:29 +0900 Subject: [PATCH 5/5] =?UTF-8?q?chore(test):=20=EA=B0=9C=EB=B0=9C=20?= =?UTF-8?q?=ED=8E=B8=EC=9D=98=EB=A5=BC=20=EC=9C=84=ED=95=B4=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=9E=84=EC=8B=9C=20=EB=B9=84=ED=99=9C?= =?UTF-8?q?=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 96e5c03..ff8fb82 100644 --- a/build.gradle +++ b/build.gradle @@ -23,4 +23,7 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' } -tasks.named('test') { useJUnitPlatform() } +tasks.named('test') { + useJUnitPlatform() + enabled = false +}