From 564861614bc04eeafd57bef3f9de0de59b0a86f7 Mon Sep 17 00:00:00 2001 From: Kimyoonbeom Date: Thu, 8 May 2025 14:56:19 +0900 Subject: [PATCH 01/12] =?UTF-8?q?1.=20=EC=BD=94=EB=93=9C=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=ED=80=B4=EC=A6=88=20-=20@Transactional=EC=9D=98=20?= =?UTF-8?q?=EC=9D=B4=ED=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TodoService의 saveTodo 메서드에 @Transactional 어노테이션을 추가. --- .../java/org/example/expert/domain/todo/service/TodoService.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/org/example/expert/domain/todo/service/TodoService.java b/src/main/java/org/example/expert/domain/todo/service/TodoService.java index 922991ce7..0b50f3ff2 100644 --- a/src/main/java/org/example/expert/domain/todo/service/TodoService.java +++ b/src/main/java/org/example/expert/domain/todo/service/TodoService.java @@ -25,6 +25,7 @@ public class TodoService { private final TodoRepository todoRepository; private final WeatherClient weatherClient; + @Transactional // 읽기 전용이 아닌 일반 트랜잭션으로 오버라이드 public TodoSaveResponse saveTodo(AuthUser authUser, TodoSaveRequest todoSaveRequest) { User user = User.fromAuthUser(authUser); From 70754c2d9b24b905d9daba88ba246996ac89a21a Mon Sep 17 00:00:00 2001 From: Kimyoonbeom Date: Thu, 8 May 2025 16:01:32 +0900 Subject: [PATCH 02/12] =?UTF-8?q?2.=20=EC=BD=94=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=ED=80=B4=EC=A6=88=20-=20JWT=EC=9D=98=20=EC=9D=B4?= =?UTF-8?q?=ED=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User 엔티티 및 생성자에 nickname 필드 추가, fromAuthUser메서드를 위해 해당 dto(AuthUser)에도 추가, JwtUtil에 추가하면서 해당 서비스(AuthService) 수정, JwtFilter에 추가 및 수정. --- src/main/java/org/example/expert/config/JwtFilter.java | 1 + src/main/java/org/example/expert/config/JwtUtil.java | 3 ++- .../expert/domain/auth/dto/request/SignupRequest.java | 2 ++ .../example/expert/domain/auth/service/AuthService.java | 5 +++-- .../org/example/expert/domain/common/dto/AuthUser.java | 4 +++- .../java/org/example/expert/domain/user/entity/User.java | 9 ++++++--- 6 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/example/expert/config/JwtFilter.java b/src/main/java/org/example/expert/config/JwtFilter.java index 03908abe1..1f1ffdd70 100644 --- a/src/main/java/org/example/expert/config/JwtFilter.java +++ b/src/main/java/org/example/expert/config/JwtFilter.java @@ -59,6 +59,7 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha httpRequest.setAttribute("userId", Long.parseLong(claims.getSubject())); httpRequest.setAttribute("email", claims.get("email")); + httpRequest.setAttribute("nickname", claims.get("nickname")); httpRequest.setAttribute("userRole", claims.get("userRole")); if (url.startsWith("/admin")) { diff --git a/src/main/java/org/example/expert/config/JwtUtil.java b/src/main/java/org/example/expert/config/JwtUtil.java index 07e0a2c7c..dd7370cd0 100644 --- a/src/main/java/org/example/expert/config/JwtUtil.java +++ b/src/main/java/org/example/expert/config/JwtUtil.java @@ -34,13 +34,14 @@ public void init() { key = Keys.hmacShaKeyFor(bytes); } - public String createToken(Long userId, String email, UserRole userRole) { + public String createToken(Long userId, String email, String nickname, UserRole userRole) { Date date = new Date(); return BEARER_PREFIX + Jwts.builder() .setSubject(String.valueOf(userId)) .claim("email", email) + .claim("nickname", nickname) .claim("userRole", userRole) .setExpiration(new Date(date.getTime() + TOKEN_TIME)) .setIssuedAt(date) // 발급일 diff --git a/src/main/java/org/example/expert/domain/auth/dto/request/SignupRequest.java b/src/main/java/org/example/expert/domain/auth/dto/request/SignupRequest.java index cdb103690..92c45e783 100644 --- a/src/main/java/org/example/expert/domain/auth/dto/request/SignupRequest.java +++ b/src/main/java/org/example/expert/domain/auth/dto/request/SignupRequest.java @@ -16,5 +16,7 @@ public class SignupRequest { @NotBlank private String password; @NotBlank + private String nickname; + @NotBlank private String userRole; } diff --git a/src/main/java/org/example/expert/domain/auth/service/AuthService.java b/src/main/java/org/example/expert/domain/auth/service/AuthService.java index a662239dc..fd40a14c7 100644 --- a/src/main/java/org/example/expert/domain/auth/service/AuthService.java +++ b/src/main/java/org/example/expert/domain/auth/service/AuthService.java @@ -37,12 +37,13 @@ public SignupResponse signup(SignupRequest signupRequest) { User newUser = new User( signupRequest.getEmail(), + signupRequest.getNickname(), encodedPassword, userRole ); User savedUser = userRepository.save(newUser); - String bearerToken = jwtUtil.createToken(savedUser.getId(), savedUser.getEmail(), userRole); + String bearerToken = jwtUtil.createToken(savedUser.getId(), savedUser.getEmail(), savedUser.getNickname(), userRole); return new SignupResponse(bearerToken); } @@ -56,7 +57,7 @@ public SigninResponse signin(SigninRequest signinRequest) { throw new AuthException("잘못된 비밀번호입니다."); } - String bearerToken = jwtUtil.createToken(user.getId(), user.getEmail(), user.getUserRole()); + String bearerToken = jwtUtil.createToken(user.getId(), user.getEmail(), user.getNickname(), user.getUserRole()); return new SigninResponse(bearerToken); } diff --git a/src/main/java/org/example/expert/domain/common/dto/AuthUser.java b/src/main/java/org/example/expert/domain/common/dto/AuthUser.java index 7f4bc52e1..436ce262c 100644 --- a/src/main/java/org/example/expert/domain/common/dto/AuthUser.java +++ b/src/main/java/org/example/expert/domain/common/dto/AuthUser.java @@ -8,11 +8,13 @@ public class AuthUser { private final Long id; private final String email; + private final String nickname; private final UserRole userRole; - public AuthUser(Long id, String email, UserRole userRole) { + public AuthUser(Long id, String email, String nickname, UserRole userRole) { this.id = id; this.email = email; + this.nickname = nickname; this.userRole = userRole; } } diff --git a/src/main/java/org/example/expert/domain/user/entity/User.java b/src/main/java/org/example/expert/domain/user/entity/User.java index 30a0cc54f..cd3503a91 100644 --- a/src/main/java/org/example/expert/domain/user/entity/User.java +++ b/src/main/java/org/example/expert/domain/user/entity/User.java @@ -18,23 +18,26 @@ public class User extends Timestamped { @Column(unique = true) private String email; private String password; + private String nickname; @Enumerated(EnumType.STRING) private UserRole userRole; - public User(String email, String password, UserRole userRole) { + public User(String email, String password, String nickname, UserRole userRole) { this.email = email; this.password = password; + this.nickname = nickname; this.userRole = userRole; } - private User(Long id, String email, UserRole userRole) { + private User(Long id, String email, String nickname, UserRole userRole) { this.id = id; this.email = email; + this.nickname = nickname; this.userRole = userRole; } public static User fromAuthUser(AuthUser authUser) { - return new User(authUser.getId(), authUser.getEmail(), authUser.getUserRole()); + return new User(authUser.getId(), authUser.getEmail(), authUser.getNickname(), authUser.getUserRole()); } public void changePassword(String password) { From 3f2f651929ca6ee467ed596cf97fdf1196703bce Mon Sep 17 00:00:00 2001 From: Kimyoonbeom Date: Thu, 8 May 2025 20:06:01 +0900 Subject: [PATCH 03/12] =?UTF-8?q?3.=20=EC=BD=94=EB=93=9C=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=ED=80=B4=EC=A6=88=20-=20=20JPA=EC=9D=98=20?= =?UTF-8?q?=EC=9D=B4=ED=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JPA에서 제공하는 Specification을 사용하여 코드 작성. --- .../todo/controller/TodoController.java | 12 +++++++--- .../todo/repository/TodoRepository.java | 3 ++- .../domain/todo/service/TodoService.java | 23 +++++++++++++++--- .../expert/domain/todo/spec/TodoSpecs.java | 24 +++++++++++++++++++ 4 files changed, 55 insertions(+), 7 deletions(-) create mode 100644 src/main/java/org/example/expert/domain/todo/spec/TodoSpecs.java diff --git a/src/main/java/org/example/expert/domain/todo/controller/TodoController.java b/src/main/java/org/example/expert/domain/todo/controller/TodoController.java index eed1a1b46..9351baeff 100644 --- a/src/main/java/org/example/expert/domain/todo/controller/TodoController.java +++ b/src/main/java/org/example/expert/domain/todo/controller/TodoController.java @@ -9,9 +9,12 @@ import org.example.expert.domain.todo.dto.response.TodoSaveResponse; import org.example.expert.domain.todo.service.TodoService; import org.springframework.data.domain.Page; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.time.LocalDateTime; + @RestController @RequiredArgsConstructor public class TodoController { @@ -29,9 +32,12 @@ public ResponseEntity saveTodo( @GetMapping("/todos") public ResponseEntity> getTodos( @RequestParam(defaultValue = "1") int page, - @RequestParam(defaultValue = "10") int size - ) { - return ResponseEntity.ok(todoService.getTodos(page, size)); + @RequestParam(defaultValue = "10") int size, + @RequestParam String weather, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)LocalDateTime start, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)LocalDateTime end + ) { + return ResponseEntity.ok(todoService.getTodos(page, size, weather, start, end)); } @GetMapping("/todos/{todoId}") diff --git a/src/main/java/org/example/expert/domain/todo/repository/TodoRepository.java b/src/main/java/org/example/expert/domain/todo/repository/TodoRepository.java index a3e4e0749..e71287e19 100644 --- a/src/main/java/org/example/expert/domain/todo/repository/TodoRepository.java +++ b/src/main/java/org/example/expert/domain/todo/repository/TodoRepository.java @@ -4,12 +4,13 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.util.Optional; -public interface TodoRepository extends JpaRepository { +public interface TodoRepository extends JpaRepository, JpaSpecificationExecutor { @Query("SELECT t FROM Todo t LEFT JOIN FETCH t.user u ORDER BY t.modifiedAt DESC") Page findAllByOrderByModifiedAtDesc(Pageable pageable); diff --git a/src/main/java/org/example/expert/domain/todo/service/TodoService.java b/src/main/java/org/example/expert/domain/todo/service/TodoService.java index 0b50f3ff2..b3647f6e1 100644 --- a/src/main/java/org/example/expert/domain/todo/service/TodoService.java +++ b/src/main/java/org/example/expert/domain/todo/service/TodoService.java @@ -9,14 +9,19 @@ import org.example.expert.domain.todo.dto.response.TodoSaveResponse; import org.example.expert.domain.todo.entity.Todo; import org.example.expert.domain.todo.repository.TodoRepository; +import org.example.expert.domain.todo.spec.TodoSpecs; import org.example.expert.domain.user.dto.response.UserResponse; import org.example.expert.domain.user.entity.User; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; + @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -48,10 +53,22 @@ public TodoSaveResponse saveTodo(AuthUser authUser, TodoSaveRequest todoSaveRequ ); } - public Page getTodos(int page, int size) { - Pageable pageable = PageRequest.of(page - 1, size); + public Page getTodos(int page, int size, String weather, LocalDateTime start, LocalDateTime end) { + Pageable pageable = PageRequest.of(page - 1, size, + Sort.by(Sort.Direction.DESC, "updatedAt")); + + // Specification 사용. + Specification spec = Specification.where(TodoSpecs.equalWeather(weather)) + .and(TodoSpecs.updatedAfter(start)) + .and(TodoSpecs.updatedBefore(end)); - Page todos = todoRepository.findAllByOrderByModifiedAtDesc(pageable); + // 모든 조건이 null이면 기존 메서드를 사용 아니면 Specification을 사용. + Page todos; + if (weather == null && start == null && end == null){ + todos = todoRepository.findAllByOrderByModifiedAtDesc(pageable); + } else { + todos = todoRepository.findAll(spec, pageable); + } return todos.map(todo -> new TodoResponse( todo.getId(), diff --git a/src/main/java/org/example/expert/domain/todo/spec/TodoSpecs.java b/src/main/java/org/example/expert/domain/todo/spec/TodoSpecs.java new file mode 100644 index 000000000..9b9d4f233 --- /dev/null +++ b/src/main/java/org/example/expert/domain/todo/spec/TodoSpecs.java @@ -0,0 +1,24 @@ +package org.example.expert.domain.todo.spec; + +import org.example.expert.domain.todo.entity.Todo; +import org.springframework.data.jpa.domain.Specification; + +import java.time.LocalDateTime; + +public class TodoSpecs { + // 날씨가 일치 하는지 필터링 + public static Specification equalWeather(String weather){ + return (root, query, criteriaBuilder) -> + weather == null ? null : criteriaBuilder.equal(root.get("weather"), weather); + } + // 특정 날짜/시간 같거나 이후에 수정된 항목 필터링 + public static Specification updatedAfter(LocalDateTime start){ + return (root, query, criteriaBuilder)-> + start == null ? null : criteriaBuilder.greaterThanOrEqualTo(root.get("updatedAfter"), start); + } + // 특정 날짜/시간 같거나 이전에 수정된 항목 필터링 + public static Specification updatedBefore(LocalDateTime end){ + return (root, query, criteriaBuilder)-> + end == null ? null : criteriaBuilder.greaterThanOrEqualTo(root.get("updatedBefore"), end); + } +} From 114d140726443a0437e50d2bfa3527714cb0c0c1 Mon Sep 17 00:00:00 2001 From: Kimyoonbeom Date: Thu, 8 May 2025 20:59:44 +0900 Subject: [PATCH 04/12] =?UTF-8?q?3.=20=EC=BD=94=EB=93=9C=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=ED=80=B4=EC=A6=88=20-=20=20JPA=EC=9D=98=20?= =?UTF-8?q?=EC=9D=B4=ED=95=B4(=EC=88=98=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JPQL을 직접 작성해서 사용하는 코드(과제 요구에 맞는 코드)로 수정 --- .../todo/controller/TodoController.java | 6 ++-- .../todo/repository/TodoRepository.java | 18 ++++++++--- .../domain/todo/service/TodoService.java | 30 +++++++------------ .../user/dto/response/UserResponse.java | 4 ++- .../todo/controller/TodoControllerTest.java | 4 +-- 5 files changed, 32 insertions(+), 30 deletions(-) diff --git a/src/main/java/org/example/expert/domain/todo/controller/TodoController.java b/src/main/java/org/example/expert/domain/todo/controller/TodoController.java index 9351baeff..bd7b12421 100644 --- a/src/main/java/org/example/expert/domain/todo/controller/TodoController.java +++ b/src/main/java/org/example/expert/domain/todo/controller/TodoController.java @@ -13,7 +13,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.time.LocalDateTime; +import java.time.LocalDate; @RestController @RequiredArgsConstructor @@ -34,8 +34,8 @@ public ResponseEntity> getTodos( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size, @RequestParam String weather, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)LocalDateTime start, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)LocalDateTime end + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)LocalDate start, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)LocalDate end ) { return ResponseEntity.ok(todoService.getTodos(page, size, weather, start, end)); } diff --git a/src/main/java/org/example/expert/domain/todo/repository/TodoRepository.java b/src/main/java/org/example/expert/domain/todo/repository/TodoRepository.java index e71287e19..b511e8edc 100644 --- a/src/main/java/org/example/expert/domain/todo/repository/TodoRepository.java +++ b/src/main/java/org/example/expert/domain/todo/repository/TodoRepository.java @@ -4,16 +4,26 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.Optional; -public interface TodoRepository extends JpaRepository, JpaSpecificationExecutor { +public interface TodoRepository extends JpaRepository { - @Query("SELECT t FROM Todo t LEFT JOIN FETCH t.user u ORDER BY t.modifiedAt DESC") - Page findAllByOrderByModifiedAtDesc(Pageable pageable); + @Query("SELECT t FROM Todo t " + + "LEFT JOIN FETCH t.user u " + + "WHERE (:weather is null OR t.weather LIKE :weather) " + + "AND (:start is null OR t.modifiedAt >= :start) " + + "AND (:end is null OR t.modifiedAt <= :end) " + + "ORDER BY t.modifiedAt DESC") + Page findAllByFilters( + Pageable pageable, + @Param("weather") String weather, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end + ); @Query("SELECT t FROM Todo t " + "LEFT JOIN t.user " + diff --git a/src/main/java/org/example/expert/domain/todo/service/TodoService.java b/src/main/java/org/example/expert/domain/todo/service/TodoService.java index b3647f6e1..144b7307f 100644 --- a/src/main/java/org/example/expert/domain/todo/service/TodoService.java +++ b/src/main/java/org/example/expert/domain/todo/service/TodoService.java @@ -9,17 +9,15 @@ import org.example.expert.domain.todo.dto.response.TodoSaveResponse; import org.example.expert.domain.todo.entity.Todo; import org.example.expert.domain.todo.repository.TodoRepository; -import org.example.expert.domain.todo.spec.TodoSpecs; import org.example.expert.domain.user.dto.response.UserResponse; import org.example.expert.domain.user.entity.User; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; import java.time.LocalDateTime; @Service @@ -49,33 +47,25 @@ public TodoSaveResponse saveTodo(AuthUser authUser, TodoSaveRequest todoSaveRequ savedTodo.getTitle(), savedTodo.getContents(), weather, - new UserResponse(user.getId(), user.getEmail()) + new UserResponse(user.getId(), user.getEmail(), user.getNickname()) ); } - public Page getTodos(int page, int size, String weather, LocalDateTime start, LocalDateTime end) { - Pageable pageable = PageRequest.of(page - 1, size, - Sort.by(Sort.Direction.DESC, "updatedAt")); + public Page getTodos(int page, int size, String weather, LocalDate start, LocalDate end) { + Pageable pageable = PageRequest.of(page - 1, size); - // Specification 사용. - Specification spec = Specification.where(TodoSpecs.equalWeather(weather)) - .and(TodoSpecs.updatedAfter(start)) - .and(TodoSpecs.updatedBefore(end)); + LocalDateTime startDateTime = (start != null) ? start.atStartOfDay() : null; + LocalDateTime endDateTime = (end != null) ? end.atTime(23, 59, 59) : null; + String keywordWeather = (weather != null) ? "%" + weather + "%" : null; - // 모든 조건이 null이면 기존 메서드를 사용 아니면 Specification을 사용. - Page todos; - if (weather == null && start == null && end == null){ - todos = todoRepository.findAllByOrderByModifiedAtDesc(pageable); - } else { - todos = todoRepository.findAll(spec, pageable); - } + Page todos = todoRepository.findAllByFilters(pageable, keywordWeather, startDateTime, endDateTime); return todos.map(todo -> new TodoResponse( todo.getId(), todo.getTitle(), todo.getContents(), todo.getWeather(), - new UserResponse(todo.getUser().getId(), todo.getUser().getEmail()), + new UserResponse(todo.getUser().getId(), todo.getUser().getEmail(), todo.getUser().getNickname()), todo.getCreatedAt(), todo.getModifiedAt() )); @@ -92,7 +82,7 @@ public TodoResponse getTodo(long todoId) { todo.getTitle(), todo.getContents(), todo.getWeather(), - new UserResponse(user.getId(), user.getEmail()), + new UserResponse(user.getId(), user.getEmail(), user.getNickname()), todo.getCreatedAt(), todo.getModifiedAt() ); diff --git a/src/main/java/org/example/expert/domain/user/dto/response/UserResponse.java b/src/main/java/org/example/expert/domain/user/dto/response/UserResponse.java index 23794a3ca..91f3240aa 100644 --- a/src/main/java/org/example/expert/domain/user/dto/response/UserResponse.java +++ b/src/main/java/org/example/expert/domain/user/dto/response/UserResponse.java @@ -7,9 +7,11 @@ public class UserResponse { private final Long id; private final String email; + private final String nickname; - public UserResponse(Long id, String email) { + public UserResponse(Long id, String email, String nickname) { this.id = id; this.email = email; + this.nickname = nickname; } } diff --git a/src/test/java/org/example/expert/domain/todo/controller/TodoControllerTest.java b/src/test/java/org/example/expert/domain/todo/controller/TodoControllerTest.java index 737193874..823f87272 100644 --- a/src/test/java/org/example/expert/domain/todo/controller/TodoControllerTest.java +++ b/src/test/java/org/example/expert/domain/todo/controller/TodoControllerTest.java @@ -35,9 +35,9 @@ class TodoControllerTest { // given long todoId = 1L; String title = "title"; - AuthUser authUser = new AuthUser(1L, "email", UserRole.USER); + AuthUser authUser = new AuthUser(1L, "email", "bum", UserRole.USER); User user = User.fromAuthUser(authUser); - UserResponse userResponse = new UserResponse(user.getId(), user.getEmail()); + UserResponse userResponse = new UserResponse(user.getId(), user.getEmail(), user.getNickname()); TodoResponse response = new TodoResponse( todoId, title, From d2d9eddeaf6390e1ce81523dc94018d25d70a6db Mon Sep 17 00:00:00 2001 From: Kimyoonbeom Date: Thu, 8 May 2025 21:02:54 +0900 Subject: [PATCH 05/12] =?UTF-8?q?3.=20=EC=BD=94=EB=93=9C=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=ED=80=B4=EC=A6=88=20-=20=20JPA=EC=9D=98=20?= =?UTF-8?q?=EC=9D=B4=ED=95=B4(=EC=88=98=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JPQL을 직접 작성해서 사용하는 코드(과제 요구에 맞는 코드)로 수정 --- .../org/example/expert/config/AuthUserArgumentResolver.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/example/expert/config/AuthUserArgumentResolver.java b/src/main/java/org/example/expert/config/AuthUserArgumentResolver.java index db00211de..ff44ec7a3 100644 --- a/src/main/java/org/example/expert/config/AuthUserArgumentResolver.java +++ b/src/main/java/org/example/expert/config/AuthUserArgumentResolver.java @@ -39,8 +39,9 @@ public Object resolveArgument( // JwtFilter 에서 set 한 userId, email, userRole 값을 가져옴 Long userId = (Long) request.getAttribute("userId"); String email = (String) request.getAttribute("email"); + String nickname = (String) request.getAttribute("nickname"); UserRole userRole = UserRole.of((String) request.getAttribute("userRole")); - return new AuthUser(userId, email, userRole); + return new AuthUser(userId, email, nickname,userRole); } } From 6d1abc1fc4c57877eef89c4b718897506d51a60b Mon Sep 17 00:00:00 2001 From: Kimyoonbeom Date: Fri, 9 May 2025 11:30:39 +0900 Subject: [PATCH 06/12] =?UTF-8?q?4.=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=ED=80=B4=EC=A6=88=20-=20=EC=BB=A8?= =?UTF-8?q?=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=9D=98=20=EC=9D=B4=ED=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 단건 조회 성공 메서드에 nickname 추가, 과제 부분에서 200으로 응답 받던 것을 400으로 수정. --- .../expert/domain/comment/service/CommentService.java | 4 ++-- .../expert/domain/manager/service/ManagerService.java | 4 ++-- .../example/expert/domain/user/service/UserService.java | 2 +- .../expert/domain/todo/controller/TodoControllerTest.java | 7 +++---- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/example/expert/domain/comment/service/CommentService.java b/src/main/java/org/example/expert/domain/comment/service/CommentService.java index 37f857491..3da98ba15 100644 --- a/src/main/java/org/example/expert/domain/comment/service/CommentService.java +++ b/src/main/java/org/example/expert/domain/comment/service/CommentService.java @@ -43,7 +43,7 @@ public CommentSaveResponse saveComment(AuthUser authUser, long todoId, CommentSa return new CommentSaveResponse( savedComment.getId(), savedComment.getContents(), - new UserResponse(user.getId(), user.getEmail()) + new UserResponse(user.getId(), user.getEmail(), user.getNickname()) ); } @@ -56,7 +56,7 @@ public List getComments(long todoId) { CommentResponse dto = new CommentResponse( comment.getId(), comment.getContents(), - new UserResponse(user.getId(), user.getEmail()) + new UserResponse(user.getId(), user.getEmail(), user.getNickname()) ); dtoList.add(dto); } diff --git a/src/main/java/org/example/expert/domain/manager/service/ManagerService.java b/src/main/java/org/example/expert/domain/manager/service/ManagerService.java index 9e14df0f1..6f3fdbb3b 100644 --- a/src/main/java/org/example/expert/domain/manager/service/ManagerService.java +++ b/src/main/java/org/example/expert/domain/manager/service/ManagerService.java @@ -52,7 +52,7 @@ public ManagerSaveResponse saveManager(AuthUser authUser, long todoId, ManagerSa return new ManagerSaveResponse( savedManagerUser.getId(), - new UserResponse(managerUser.getId(), managerUser.getEmail()) + new UserResponse(managerUser.getId(), managerUser.getEmail(), managerUser.getNickname()) ); } @@ -67,7 +67,7 @@ public List getManagers(long todoId) { User user = manager.getUser(); dtoList.add(new ManagerResponse( manager.getId(), - new UserResponse(user.getId(), user.getEmail()) + new UserResponse(user.getId(), user.getEmail(), user.getNickname()) )); } return dtoList; diff --git a/src/main/java/org/example/expert/domain/user/service/UserService.java b/src/main/java/org/example/expert/domain/user/service/UserService.java index 15baec417..15bdfa48b 100644 --- a/src/main/java/org/example/expert/domain/user/service/UserService.java +++ b/src/main/java/org/example/expert/domain/user/service/UserService.java @@ -20,7 +20,7 @@ public class UserService { public UserResponse getUser(long userId) { User user = userRepository.findById(userId).orElseThrow(() -> new InvalidRequestException("User not found")); - return new UserResponse(user.getId(), user.getEmail()); + return new UserResponse(user.getId(), user.getEmail(), user.getNickname()); } @Transactional diff --git a/src/test/java/org/example/expert/domain/todo/controller/TodoControllerTest.java b/src/test/java/org/example/expert/domain/todo/controller/TodoControllerTest.java index 823f87272..88b0c2486 100644 --- a/src/test/java/org/example/expert/domain/todo/controller/TodoControllerTest.java +++ b/src/test/java/org/example/expert/domain/todo/controller/TodoControllerTest.java @@ -69,9 +69,8 @@ class TodoControllerTest { // then mockMvc.perform(get("/todos/{todoId}", todoId)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value(HttpStatus.OK.name())) - .andExpect(jsonPath("$.code").value(HttpStatus.OK.value())) - .andExpect(jsonPath("$.message").value("Todo not found")); + .andExpect(status().is4xxClientError()) + .andExpect(jsonPath("$.status").value(HttpStatus.BAD_REQUEST.name())) + .andExpect(jsonPath("$.code").value(HttpStatus.BAD_REQUEST.value())); } } From 17e5465bc2de677e79a50b6784a02cc80fdc9cda Mon Sep 17 00:00:00 2001 From: Kimyoonbeom Date: Fri, 9 May 2025 11:44:51 +0900 Subject: [PATCH 07/12] =?UTF-8?q?5.=20=EC=BD=94=EB=93=9C=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=ED=80=B4=EC=A6=88=20-=20AOP=EC=9D=98=20=EC=9D=B4?= =?UTF-8?q?=ED=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실행 후(@After)가 아닌, 실행 전(@Before)으로 변경. 포인트컷의 위치 제대로(개발 의도에 맞게) 수정. --- .../java/org/example/expert/aop/AdminAccessLoggingAspect.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/example/expert/aop/AdminAccessLoggingAspect.java b/src/main/java/org/example/expert/aop/AdminAccessLoggingAspect.java index c90e8c792..ee6234e79 100644 --- a/src/main/java/org/example/expert/aop/AdminAccessLoggingAspect.java +++ b/src/main/java/org/example/expert/aop/AdminAccessLoggingAspect.java @@ -4,8 +4,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.JoinPoint; -import org.aspectj.lang.annotation.After; import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; import org.springframework.stereotype.Component; import java.time.LocalDateTime; @@ -18,7 +18,7 @@ public class AdminAccessLoggingAspect { private final HttpServletRequest request; - @After("execution(* org.example.expert.domain.user.controller.UserController.getUser(..))") + @Before("execution(* org.example.expert.domain.user.controller.UserAdminController.changeUserRole(..))") public void logAfterChangeUserRole(JoinPoint joinPoint) { String userId = String.valueOf(request.getAttribute("userId")); String requestUrl = request.getRequestURI(); From a997d33a8267e2b1b38e96f7b55a003b944f3f33 Mon Sep 17 00:00:00 2001 From: Kimyoonbeom Date: Fri, 9 May 2025 12:02:12 +0900 Subject: [PATCH 08/12] 6. JPA Cascade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cascade = CascadeType.PERSIST 추가 --- src/main/java/org/example/expert/domain/todo/entity/Todo.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/example/expert/domain/todo/entity/Todo.java b/src/main/java/org/example/expert/domain/todo/entity/Todo.java index b4efcced1..52d1f6f1b 100644 --- a/src/main/java/org/example/expert/domain/todo/entity/Todo.java +++ b/src/main/java/org/example/expert/domain/todo/entity/Todo.java @@ -30,7 +30,7 @@ public class Todo extends Timestamped { @OneToMany(mappedBy = "todo", cascade = CascadeType.REMOVE) private List comments = new ArrayList<>(); - @OneToMany(mappedBy = "todo") + @OneToMany(mappedBy = "todo", cascade = CascadeType.PERSIST) // cascade 추가 private List managers = new ArrayList<>(); public Todo(String title, String contents, String weather, User user) { From 3d8f507e393c3e211b4935adcbfe710a75773065 Mon Sep 17 00:00:00 2001 From: Kimyoonbeom Date: Fri, 9 May 2025 12:29:28 +0900 Subject: [PATCH 09/12] 7. N+1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fetch Join 사용 --- .../expert/domain/comment/repository/CommentRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/example/expert/domain/comment/repository/CommentRepository.java b/src/main/java/org/example/expert/domain/comment/repository/CommentRepository.java index 3c97b95dc..ecb21ce56 100644 --- a/src/main/java/org/example/expert/domain/comment/repository/CommentRepository.java +++ b/src/main/java/org/example/expert/domain/comment/repository/CommentRepository.java @@ -9,6 +9,6 @@ public interface CommentRepository extends JpaRepository { - @Query("SELECT c FROM Comment c JOIN c.user WHERE c.todo.id = :todoId") + @Query("SELECT c FROM Comment c JOIN FETCH c.user WHERE c.todo.id = :todoId") List findByTodoIdWithUser(@Param("todoId") Long todoId); } From 0a356fbc5707fd6f9253465d28bfb094483e4d20 Mon Sep 17 00:00:00 2001 From: Kimyoonbeom Date: Fri, 9 May 2025 17:05:16 +0900 Subject: [PATCH 10/12] 8. QueryDSL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QueryDSL에 관한 의존성 주입, 리포지토리 인터페이스 생성, 리포지토리 인터페이스의 구현체 생성, 리포지토리에 상속을 추가. 구현체(TodoRepositoryImpl)에서 EntityManager를 사용하여 쿼리 DSL 사용 함수 생성. --- build.gradle | 6 ++++ .../example/expert/config/QueryDslConfig.java | 18 +++++++++++ .../todo/repository/TodoRepository.java | 2 +- .../todo/repository/TodoRepositoryImpl.java | 30 +++++++++++++++++++ .../repository/TodoRepositoryQueryDsl.java | 9 ++++++ 5 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/example/expert/config/QueryDslConfig.java create mode 100644 src/main/java/org/example/expert/domain/todo/repository/TodoRepositoryImpl.java create mode 100644 src/main/java/org/example/expert/domain/todo/repository/TodoRepositoryQueryDsl.java diff --git a/build.gradle b/build.gradle index a7fd3e706..df3c6d1f3 100644 --- a/build.gradle +++ b/build.gradle @@ -41,6 +41,12 @@ dependencies { compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5' runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5' + + // QueryDSL(SpringBoot3.0 부터는 jakarta 사용해야함) + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" } tasks.named('test') { diff --git a/src/main/java/org/example/expert/config/QueryDslConfig.java b/src/main/java/org/example/expert/config/QueryDslConfig.java new file mode 100644 index 000000000..52f3ed775 --- /dev/null +++ b/src/main/java/org/example/expert/config/QueryDslConfig.java @@ -0,0 +1,18 @@ +package org.example.expert.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QueryDslConfig { + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory(){ + return new JPAQueryFactory(entityManager); + } +} diff --git a/src/main/java/org/example/expert/domain/todo/repository/TodoRepository.java b/src/main/java/org/example/expert/domain/todo/repository/TodoRepository.java index b511e8edc..304392b79 100644 --- a/src/main/java/org/example/expert/domain/todo/repository/TodoRepository.java +++ b/src/main/java/org/example/expert/domain/todo/repository/TodoRepository.java @@ -10,7 +10,7 @@ import java.time.LocalDateTime; import java.util.Optional; -public interface TodoRepository extends JpaRepository { +public interface TodoRepository extends JpaRepository, TodoRepositoryQueryDsl { @Query("SELECT t FROM Todo t " + "LEFT JOIN FETCH t.user u " diff --git a/src/main/java/org/example/expert/domain/todo/repository/TodoRepositoryImpl.java b/src/main/java/org/example/expert/domain/todo/repository/TodoRepositoryImpl.java new file mode 100644 index 000000000..29efd80a6 --- /dev/null +++ b/src/main/java/org/example/expert/domain/todo/repository/TodoRepositoryImpl.java @@ -0,0 +1,30 @@ +package org.example.expert.domain.todo.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.example.expert.domain.todo.entity.QTodo; +import org.example.expert.domain.todo.entity.Todo; +import org.example.expert.domain.user.entity.QUser; + +import java.util.Optional; + +@RequiredArgsConstructor +public class TodoRepositoryImpl implements TodoRepositoryQueryDsl{ + + private final JPAQueryFactory queryFactory; + + @Override + public Optional findByIdWithUser(Long todoId) { + QTodo todo = QTodo.todo; + QUser user = QUser.user; + + Todo result = queryFactory + .selectFrom(todo) + .leftJoin(todo.user, user).fetchJoin() + .where(todo.id.eq(todoId)) + .fetchOne(); + + return Optional.ofNullable(result); + } +} + diff --git a/src/main/java/org/example/expert/domain/todo/repository/TodoRepositoryQueryDsl.java b/src/main/java/org/example/expert/domain/todo/repository/TodoRepositoryQueryDsl.java new file mode 100644 index 000000000..8512e8225 --- /dev/null +++ b/src/main/java/org/example/expert/domain/todo/repository/TodoRepositoryQueryDsl.java @@ -0,0 +1,9 @@ +package org.example.expert.domain.todo.repository; + +import org.example.expert.domain.todo.entity.Todo; + +import java.util.Optional; + +public interface TodoRepositoryQueryDsl { + Optional findByIdWithUser(Long todoId); +} From f652275b595644148739201a4401b2fcd4aaeacc Mon Sep 17 00:00:00 2001 From: Kimyoonbeom Date: Tue, 13 May 2025 12:06:07 +0900 Subject: [PATCH 11/12] 9. Spring Security MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 의존성 주입, SecurityConfig 생성 및 JwtFilter 수정. 그에 따른 관련 파일들 제거, 컨트롤러 수정(@Auth -> @AuthenticationPrincipal). --- build.gradle | 4 + .../config/AuthUserArgumentResolver.java | 47 ------- .../example/expert/config/FilterConfig.java | 22 ---- .../org/example/expert/config/JwtFilter.java | 116 ++++++++++-------- .../example/expert/config/SecurityConfig.java | 36 ++++++ .../org/example/expert/config/WebConfig.java | 19 --- .../comment/controller/CommentController.java | 4 +- .../manager/controller/ManagerController.java | 6 +- .../todo/controller/TodoController.java | 4 +- .../user/controller/UserController.java | 4 +- 10 files changed, 112 insertions(+), 150 deletions(-) delete mode 100644 src/main/java/org/example/expert/config/AuthUserArgumentResolver.java delete mode 100644 src/main/java/org/example/expert/config/FilterConfig.java create mode 100644 src/main/java/org/example/expert/config/SecurityConfig.java delete mode 100644 src/main/java/org/example/expert/config/WebConfig.java diff --git a/build.gradle b/build.gradle index df3c6d1f3..58f145469 100644 --- a/build.gradle +++ b/build.gradle @@ -47,6 +47,10 @@ dependencies { annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + //Spring Security + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' } tasks.named('test') { diff --git a/src/main/java/org/example/expert/config/AuthUserArgumentResolver.java b/src/main/java/org/example/expert/config/AuthUserArgumentResolver.java deleted file mode 100644 index ff44ec7a3..000000000 --- a/src/main/java/org/example/expert/config/AuthUserArgumentResolver.java +++ /dev/null @@ -1,47 +0,0 @@ -package org.example.expert.config; - -import jakarta.servlet.http.HttpServletRequest; -import org.example.expert.domain.auth.exception.AuthException; -import org.example.expert.domain.common.annotation.Auth; -import org.example.expert.domain.common.dto.AuthUser; -import org.example.expert.domain.user.enums.UserRole; -import org.springframework.core.MethodParameter; -import org.springframework.lang.Nullable; -import org.springframework.web.bind.support.WebDataBinderFactory; -import org.springframework.web.context.request.NativeWebRequest; -import org.springframework.web.method.support.HandlerMethodArgumentResolver; -import org.springframework.web.method.support.ModelAndViewContainer; - -public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver { - - @Override - public boolean supportsParameter(MethodParameter parameter) { - boolean hasAuthAnnotation = parameter.getParameterAnnotation(Auth.class) != null; - boolean isAuthUserType = parameter.getParameterType().equals(AuthUser.class); - - // @Auth 어노테이션과 AuthUser 타입이 함께 사용되지 않은 경우 예외 발생 - if (hasAuthAnnotation != isAuthUserType) { - throw new AuthException("@Auth와 AuthUser 타입은 함께 사용되어야 합니다."); - } - - return hasAuthAnnotation; - } - - @Override - public Object resolveArgument( - @Nullable MethodParameter parameter, - @Nullable ModelAndViewContainer mavContainer, - NativeWebRequest webRequest, - @Nullable WebDataBinderFactory binderFactory - ) { - HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); - - // JwtFilter 에서 set 한 userId, email, userRole 값을 가져옴 - Long userId = (Long) request.getAttribute("userId"); - String email = (String) request.getAttribute("email"); - String nickname = (String) request.getAttribute("nickname"); - UserRole userRole = UserRole.of((String) request.getAttribute("userRole")); - - return new AuthUser(userId, email, nickname,userRole); - } -} diff --git a/src/main/java/org/example/expert/config/FilterConfig.java b/src/main/java/org/example/expert/config/FilterConfig.java deleted file mode 100644 index 34cb4088a..000000000 --- a/src/main/java/org/example/expert/config/FilterConfig.java +++ /dev/null @@ -1,22 +0,0 @@ -package org.example.expert.config; - -import lombok.RequiredArgsConstructor; -import org.springframework.boot.web.servlet.FilterRegistrationBean; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -@RequiredArgsConstructor -public class FilterConfig { - - private final JwtUtil jwtUtil; - - @Bean - public FilterRegistrationBean jwtFilter() { - FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); - registrationBean.setFilter(new JwtFilter(jwtUtil)); - registrationBean.addUrlPatterns("/*"); // 필터를 적용할 URL 패턴을 지정합니다. - - return registrationBean; - } -} diff --git a/src/main/java/org/example/expert/config/JwtFilter.java b/src/main/java/org/example/expert/config/JwtFilter.java index 1f1ffdd70..04a4ae62e 100644 --- a/src/main/java/org/example/expert/config/JwtFilter.java +++ b/src/main/java/org/example/expert/config/JwtFilter.java @@ -4,92 +4,102 @@ import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.MalformedJwtException; import io.jsonwebtoken.UnsupportedJwtException; -import jakarta.servlet.FilterConfig; import jakarta.servlet.*; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.example.expert.domain.common.dto.AuthUser; import org.example.expert.domain.user.enums.UserRole; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.util.List; +@Component @Slf4j @RequiredArgsConstructor -public class JwtFilter implements Filter { +public class JwtFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; @Override - public void init(FilterConfig filterConfig) throws ServletException { - Filter.super.init(filterConfig); - } - - @Override - public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { - HttpServletRequest httpRequest = (HttpServletRequest) request; - HttpServletResponse httpResponse = (HttpServletResponse) response; - - String url = httpRequest.getRequestURI(); + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + String url = request.getRequestURI(); if (url.startsWith("/auth")) { - chain.doFilter(request, response); + filterChain.doFilter(request, response); return; } - String bearerJwt = httpRequest.getHeader("Authorization"); - - if (bearerJwt == null) { - // 토큰이 없는 경우 400을 반환합니다. - httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "JWT 토큰이 필요합니다."); + String bearerToken = request.getHeader("Authorization"); + if (bearerToken == null || !bearerToken.startsWith("Bearer ")) { + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "JWT 토큰이 필요합니다."); return; } - String jwt = jwtUtil.substringToken(bearerJwt); + processJwtToken(request, response, filterChain, bearerToken); + } + private void processJwtToken( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain, + String bearerToken + ) throws IOException { try { - // JWT 유효성 검사와 claims 추출 + String jwt = jwtUtil.substringToken(bearerToken); Claims claims = jwtUtil.extractClaims(jwt); - if (claims == null) { - httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다."); - return; - } - - UserRole userRole = UserRole.valueOf(claims.get("userRole", String.class)); - httpRequest.setAttribute("userId", Long.parseLong(claims.getSubject())); - httpRequest.setAttribute("email", claims.get("email")); - httpRequest.setAttribute("nickname", claims.get("nickname")); - httpRequest.setAttribute("userRole", claims.get("userRole")); + AuthUser authUser = createAuthUserFromClaims(claims); + setSecurityContextAuthentication(authUser); - if (url.startsWith("/admin")) { - // 관리자 권한이 없는 경우 403을 반환합니다. - if (!UserRole.ADMIN.equals(userRole)) { - httpResponse.sendError(HttpServletResponse.SC_FORBIDDEN, "관리자 권한이 없습니다."); - return; - } - chain.doFilter(request, response); - return; - } - - chain.doFilter(request, response); - } catch (SecurityException | MalformedJwtException e) { - log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.", e); - httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않는 JWT 서명입니다."); + filterChain.doFilter(request, response); } catch (ExpiredJwtException e) { - log.error("Expired JWT token, 만료된 JWT token 입니다.", e); - httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다."); + handleJwtException(response, "만료된 JWT 토큰입니다.", HttpServletResponse.SC_UNAUTHORIZED, e); + } catch (MalformedJwtException | SecurityException e) { + handleJwtException(response, "유효하지 않은 JWT 서명입니다.", HttpServletResponse.SC_UNAUTHORIZED, e); } catch (UnsupportedJwtException e) { - log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.", e); - httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "지원되지 않는 JWT 토큰입니다."); + handleJwtException(response, "지원되지 않는 JWT 토큰입니다.", HttpServletResponse.SC_BAD_REQUEST, e); } catch (Exception e) { - log.error("Internal server error", e); - httpResponse.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + handleJwtException(response, "서버 내부 오류", HttpServletResponse.SC_INTERNAL_SERVER_ERROR, e); } } - @Override - public void destroy() { - Filter.super.destroy(); + private AuthUser createAuthUserFromClaims(Claims claims) { + return new AuthUser( + Long.parseLong(claims.getSubject()), + claims.get("email", String.class), + claims.get("nickname", String.class), + UserRole.valueOf(claims.get("userRole", String.class)) + ); + } + + private void setSecurityContextAuthentication(AuthUser authUser) { + Authentication authentication = new UsernamePasswordAuthenticationToken( + authUser, + null, + List.of(new SimpleGrantedAuthority("ROLE_" + authUser.getUserRole().name())) + ); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + private void handleJwtException( + HttpServletResponse response, + String message, + int statusCode, + Exception e + ) throws IOException { + log.error(message, e); + response.sendError(statusCode, message); } } diff --git a/src/main/java/org/example/expert/config/SecurityConfig.java b/src/main/java/org/example/expert/config/SecurityConfig.java new file mode 100644 index 000000000..f47fb0c77 --- /dev/null +++ b/src/main/java/org/example/expert/config/SecurityConfig.java @@ -0,0 +1,36 @@ +package org.example.expert.config; + +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.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + private final JwtFilter jwtFilter; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + .httpBasic(AbstractHttpConfigurer::disable) // 기본 인증 비활성화 + .formLogin(AbstractHttpConfigurer::disable) // 폼 로그인 비활성화 + .csrf(AbstractHttpConfigurer::disable) // CSRF 보호 비활성화 + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/auth/**").permitAll() // 인증 없이 접근 허용 + .requestMatchers("/admin/**").hasRole("ADMIN") // ADMIN만 접근 허용 + .anyRequest().authenticated() // 그 외 모든 요청 인증 필요 + ) + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) // JWT 필터 삽입 + .build(); // SecurityFilterChain 반환 + } +} diff --git a/src/main/java/org/example/expert/config/WebConfig.java b/src/main/java/org/example/expert/config/WebConfig.java deleted file mode 100644 index adff06b82..000000000 --- a/src/main/java/org/example/expert/config/WebConfig.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.example.expert.config; - -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.method.support.HandlerMethodArgumentResolver; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -import java.util.List; - -@Configuration -@RequiredArgsConstructor -public class WebConfig implements WebMvcConfigurer { - - // ArgumentResolver 등록 - @Override - public void addArgumentResolvers(List resolvers) { - resolvers.add(new AuthUserArgumentResolver()); - } -} diff --git a/src/main/java/org/example/expert/domain/comment/controller/CommentController.java b/src/main/java/org/example/expert/domain/comment/controller/CommentController.java index 51264b12e..e8734a2ef 100644 --- a/src/main/java/org/example/expert/domain/comment/controller/CommentController.java +++ b/src/main/java/org/example/expert/domain/comment/controller/CommentController.java @@ -6,9 +6,9 @@ import org.example.expert.domain.comment.dto.response.CommentResponse; import org.example.expert.domain.comment.dto.response.CommentSaveResponse; import org.example.expert.domain.comment.service.CommentService; -import org.example.expert.domain.common.annotation.Auth; import org.example.expert.domain.common.dto.AuthUser; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -21,7 +21,7 @@ public class CommentController { @PostMapping("/todos/{todoId}/comments") public ResponseEntity saveComment( - @Auth AuthUser authUser, + @AuthenticationPrincipal AuthUser authUser, @PathVariable long todoId, @Valid @RequestBody CommentSaveRequest commentSaveRequest ) { diff --git a/src/main/java/org/example/expert/domain/manager/controller/ManagerController.java b/src/main/java/org/example/expert/domain/manager/controller/ManagerController.java index 327b6452b..918ffe28c 100644 --- a/src/main/java/org/example/expert/domain/manager/controller/ManagerController.java +++ b/src/main/java/org/example/expert/domain/manager/controller/ManagerController.java @@ -2,13 +2,13 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.example.expert.domain.common.annotation.Auth; import org.example.expert.domain.common.dto.AuthUser; import org.example.expert.domain.manager.dto.request.ManagerSaveRequest; import org.example.expert.domain.manager.dto.response.ManagerResponse; import org.example.expert.domain.manager.dto.response.ManagerSaveResponse; import org.example.expert.domain.manager.service.ManagerService; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -21,7 +21,7 @@ public class ManagerController { @PostMapping("/todos/{todoId}/managers") public ResponseEntity saveManager( - @Auth AuthUser authUser, + @AuthenticationPrincipal AuthUser authUser, @PathVariable long todoId, @Valid @RequestBody ManagerSaveRequest managerSaveRequest ) { @@ -35,7 +35,7 @@ public ResponseEntity> getMembers(@PathVariable long todoI @DeleteMapping("/todos/{todoId}/managers/{managerId}") public void deleteManager( - @Auth AuthUser authUser, + @AuthenticationPrincipal AuthUser authUser, @PathVariable long todoId, @PathVariable long managerId ) { diff --git a/src/main/java/org/example/expert/domain/todo/controller/TodoController.java b/src/main/java/org/example/expert/domain/todo/controller/TodoController.java index bd7b12421..75e1d2595 100644 --- a/src/main/java/org/example/expert/domain/todo/controller/TodoController.java +++ b/src/main/java/org/example/expert/domain/todo/controller/TodoController.java @@ -2,7 +2,6 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.example.expert.domain.common.annotation.Auth; import org.example.expert.domain.common.dto.AuthUser; import org.example.expert.domain.todo.dto.request.TodoSaveRequest; import org.example.expert.domain.todo.dto.response.TodoResponse; @@ -11,6 +10,7 @@ import org.springframework.data.domain.Page; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.time.LocalDate; @@ -23,7 +23,7 @@ public class TodoController { @PostMapping("/todos") public ResponseEntity saveTodo( - @Auth AuthUser authUser, + @AuthenticationPrincipal AuthUser authUser, @Valid @RequestBody TodoSaveRequest todoSaveRequest ) { return ResponseEntity.ok(todoService.saveTodo(authUser, todoSaveRequest)); diff --git a/src/main/java/org/example/expert/domain/user/controller/UserController.java b/src/main/java/org/example/expert/domain/user/controller/UserController.java index bb1ef7a95..f737e84cf 100644 --- a/src/main/java/org/example/expert/domain/user/controller/UserController.java +++ b/src/main/java/org/example/expert/domain/user/controller/UserController.java @@ -1,12 +1,12 @@ package org.example.expert.domain.user.controller; import lombok.RequiredArgsConstructor; -import org.example.expert.domain.common.annotation.Auth; import org.example.expert.domain.common.dto.AuthUser; import org.example.expert.domain.user.dto.request.UserChangePasswordRequest; import org.example.expert.domain.user.dto.response.UserResponse; import org.example.expert.domain.user.service.UserService; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @RestController @@ -21,7 +21,7 @@ public ResponseEntity getUser(@PathVariable long userId) { } @PutMapping("/users") - public void changePassword(@Auth AuthUser authUser, @RequestBody UserChangePasswordRequest userChangePasswordRequest) { + public void changePassword(@AuthenticationPrincipal AuthUser authUser, @RequestBody UserChangePasswordRequest userChangePasswordRequest) { userService.changePassword(authUser.getId(), userChangePasswordRequest); } } From f3b53e86519ff289479baa40554870ebcbdd944d Mon Sep 17 00:00:00 2001 From: Kimyoonbeom Date: Tue, 13 May 2025 20:42:12 +0900 Subject: [PATCH 12/12] 9. Spring Security-refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuthUser에 UserDetails 추가, 컨트롤러 수정. --- .../comment/controller/CommentController.java | 4 ++-- .../expert/domain/common/dto/AuthUser.java | 20 ++++++++++++++++++- .../manager/controller/ManagerController.java | 8 ++++---- .../user/controller/UserAdminController.java | 2 +- .../user/controller/UserController.java | 2 +- 5 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/example/expert/domain/comment/controller/CommentController.java b/src/main/java/org/example/expert/domain/comment/controller/CommentController.java index e8734a2ef..3688361ea 100644 --- a/src/main/java/org/example/expert/domain/comment/controller/CommentController.java +++ b/src/main/java/org/example/expert/domain/comment/controller/CommentController.java @@ -22,14 +22,14 @@ public class CommentController { @PostMapping("/todos/{todoId}/comments") public ResponseEntity saveComment( @AuthenticationPrincipal AuthUser authUser, - @PathVariable long todoId, + @PathVariable("todoId") long todoId, @Valid @RequestBody CommentSaveRequest commentSaveRequest ) { return ResponseEntity.ok(commentService.saveComment(authUser, todoId, commentSaveRequest)); } @GetMapping("/todos/{todoId}/comments") - public ResponseEntity> getComments(@PathVariable long todoId) { + public ResponseEntity> getComments(@PathVariable("todoId") long todoId) { return ResponseEntity.ok(commentService.getComments(todoId)); } } diff --git a/src/main/java/org/example/expert/domain/common/dto/AuthUser.java b/src/main/java/org/example/expert/domain/common/dto/AuthUser.java index 436ce262c..8f92fef9f 100644 --- a/src/main/java/org/example/expert/domain/common/dto/AuthUser.java +++ b/src/main/java/org/example/expert/domain/common/dto/AuthUser.java @@ -2,9 +2,15 @@ import lombok.Getter; import org.example.expert.domain.user.enums.UserRole; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; @Getter -public class AuthUser { +public class AuthUser implements UserDetails { private final Long id; private final String email; @@ -17,4 +23,16 @@ public AuthUser(Long id, String email, String nickname, UserRole userRole) { this.nickname = nickname; this.userRole = userRole; } + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority("ROLE_" + userRole.name())); + } + + @Override public String getPassword() { return null; } + @Override public String getUsername() { return email; } + @Override public boolean isAccountNonExpired() { return true; } + @Override public boolean isAccountNonLocked() { return true; } + @Override public boolean isCredentialsNonExpired() { return true; } + @Override public boolean isEnabled() { return true; } } diff --git a/src/main/java/org/example/expert/domain/manager/controller/ManagerController.java b/src/main/java/org/example/expert/domain/manager/controller/ManagerController.java index 918ffe28c..a916275ef 100644 --- a/src/main/java/org/example/expert/domain/manager/controller/ManagerController.java +++ b/src/main/java/org/example/expert/domain/manager/controller/ManagerController.java @@ -22,22 +22,22 @@ public class ManagerController { @PostMapping("/todos/{todoId}/managers") public ResponseEntity saveManager( @AuthenticationPrincipal AuthUser authUser, - @PathVariable long todoId, + @PathVariable("todoId") long todoId, @Valid @RequestBody ManagerSaveRequest managerSaveRequest ) { return ResponseEntity.ok(managerService.saveManager(authUser, todoId, managerSaveRequest)); } @GetMapping("/todos/{todoId}/managers") - public ResponseEntity> getMembers(@PathVariable long todoId) { + public ResponseEntity> getMembers(@PathVariable("todoId") long todoId) { return ResponseEntity.ok(managerService.getManagers(todoId)); } @DeleteMapping("/todos/{todoId}/managers/{managerId}") public void deleteManager( @AuthenticationPrincipal AuthUser authUser, - @PathVariable long todoId, - @PathVariable long managerId + @PathVariable("todoId") long todoId, + @PathVariable("managerId") long managerId ) { managerService.deleteManager(authUser, todoId, managerId); } diff --git a/src/main/java/org/example/expert/domain/user/controller/UserAdminController.java b/src/main/java/org/example/expert/domain/user/controller/UserAdminController.java index 53d45c8b5..2ca41b9d6 100644 --- a/src/main/java/org/example/expert/domain/user/controller/UserAdminController.java +++ b/src/main/java/org/example/expert/domain/user/controller/UserAdminController.java @@ -15,7 +15,7 @@ public class UserAdminController { private final UserAdminService userAdminService; @PatchMapping("/admin/users/{userId}") - public void changeUserRole(@PathVariable long userId, @RequestBody UserRoleChangeRequest userRoleChangeRequest) { + public void changeUserRole(@PathVariable("userId") long userId, @RequestBody UserRoleChangeRequest userRoleChangeRequest) { userAdminService.changeUserRole(userId, userRoleChangeRequest); } } diff --git a/src/main/java/org/example/expert/domain/user/controller/UserController.java b/src/main/java/org/example/expert/domain/user/controller/UserController.java index f737e84cf..d9fd8200f 100644 --- a/src/main/java/org/example/expert/domain/user/controller/UserController.java +++ b/src/main/java/org/example/expert/domain/user/controller/UserController.java @@ -16,7 +16,7 @@ public class UserController { private final UserService userService; @GetMapping("/users/{userId}") - public ResponseEntity getUser(@PathVariable long userId) { + public ResponseEntity getUser(@PathVariable("userId") long userId) { return ResponseEntity.ok(userService.getUser(userId)); }