diff --git a/README.md b/README.md
index e19f8e392..7c7fcc426 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,391 @@
# SPRING PLUS
+
+
+
+
+
+### 1-1. 문제 인식 및 정의
+- /todos API를 호출하여 할 일을 저장하려고 할 때 다음과 같은 에러가 발생합니다
+```
+jakarta.servlet.ServletException: Request processing failed:
+org.springframework.orm.jpa.JpaSystemException:
+could not execute statement [Connection is read-only.
+Queries leading to data modification are not allowed]
+```
+- 상단에 @Transactional(readOnly = true)어노테이션으로 인하여 DB 연결이 read-only 상태이기 때문에 INSERT, UPDATE와 같은 쓰기 작업이 차단되어 발생한 오류입니다.
+- 해당 API는 데이터를 저장하는 쓰기 작업이므로 트랜잭션이 read-write 모드로 설정되어 있어야 합니다.
+
+
+### 1-1. 해결 방안
+- @Transactional을 서비스 메서드에 올바르게 적용
+ - saveTodo() 같이 수정이 필요한 메서드에 @Transactional을 추가하여 쓰기 가능한 트랜잭션을 활성화
+
+
+### 1-1. 해결 완료
+```
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class TodoService {
+ private final TodoRepository todoRepository;
+ private final WeatherClient weatherClient;
+ private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
+
+ @Transactional
+ public TodoSaveResponse saveTodo(AuthUser authUser, TodoSaveRequest todoSaveRequest) {
+ User user = User.fromAuthUser(authUser);
+ ...
+ Todo savedTodo = todoRepository.save(newTodo);
+ ...
+ }
+}
+```
+- saveTodo()가 실행될 때 Spring이 트랜잭션을 열고, 쓰기 작업도 허용되기 때문에 정상적으로 할 일이 저장됩니다.
+
+
+
+
+
+### 1-2. 문제 인식 및 정의
+- 현재 서비스에서 JWT를 발급할 , 사용자 식별 정보로 userId, email, userRole만 포함하고 있으며 프론트엔드 화면에서 nickname을 함께 표시하고자 하는 요구사항이 생겼습니다.
+- 기존 JWT에는 nickname 정보가 포함되어 있지 않기 때문에 클라이언트는 서버에서 nickname을 다시 조회하거나 요청을 추가로 보내야 하는 불편이 있습니다.
+
+### 1-2. 해결 방안
+1. User 엔티티 및 요청 객체 수정
+ - User 테이블에 nickname 컬럼을 추가
+ - 닉네임은 중복 가능하고 별도의 제약은 두지 않음
+ - 회원가입 및 응답 객체에 nickname 추가
+2. JWT 토큰 생성 시 nickname 추가
+ - JwtUtil.createToken() 메서드의 파라미터에 nickname 추가
+ - .claim("nickname", nickname) 으로 JWT에 nickname 추가
+3. JwtFilter에서 nickname 추출
+4. AuthUser 클래스 수정
+ - nickname 컬럼을 추가
+5. 각종 응답값에 nickname 추가
+
+### 1-2. 해결 완료
+- User.nickname 일관되게 반영
+- JWT 생성시 nickname 추가
+
+
+
+
+### 1-3. 문제 인식 및 정의
+- 기존의 할 일 목록 조회 API (/todos)에 기능추가
+ - 날씨 조건으로 검색
+ - 수정일 기간 검색
+
+### 1-3. 해결 방안
+- @GetMapping 이므로 날씨, 시작일, 종료일 모두 바디가 아닌 @RequestParam 값으로 각각 요청
+- QueryDSL 사용하여 추후에 유지보수하기 용이함
+
+
+
+
+### 1-4. 문제 인식 및 정의
+- 존재하지 않는 할일인 경우 InvalidRequestException이 발생하도록 처리하고 있습니다.
+하지만 컨트롤러 테스트 메서드 todo_단건_조회_시_todo가_존재하지_않아_예외가_발생한다()는 예외가 발생하였음에도 불구하고 응답 코드로 HttpStatus.OK(200) 되어있습니다.
+
+### 1-4. 해결 방안
+```
+// 변경 전
+.andExpect(status().isOk())
+.andExpect(jsonPath("$.status").value(HttpStatus.OK.name()))
+.andExpect(jsonPath("$.code").value(HttpStatus.OK.value()))
+//변경 후
+.andExpect(status().isBadRequest())
+.andExpect(jsonPath("$.status").value(HttpStatus.BAD_REQUEST.name()))
+.andExpect(jsonPath("$.code").value(HttpStatus.BAD_REQUEST.value()))
+ ```
+- InvalidRequestException 처리에 따라 반환되는 HTTP 상태 코드 400 (BAD_REQUEST) 와 일치하도록 수정
+
+### 1-5. 문제 인식 및 정의
+- 현재 AOP 클래스인 AdminAccessLoggingAspect는 @After 어노테이션으로 UserController의 getUser 메소드 실행 시점에만 동작하도록 설정되어 있습니다.
+ 하지만 여기서의 요구하는 의도는 changeUserRole() 메소드 실행 시점에서 관리자가 접근로그를 남기려고 합니다.
+ AOP 포인트컷이 잘못 설정되어 있어 원하는 시점에 로그가 기록되지 않는 문제가 있었습니다.
+
+### 1-5. 해결 방안
+- AOP 포인트컷 수정
+```
+// 변경 전
+@After("execution(* org.example.expert.domain.user.controller.UserController.getUser(..))")
+// 변경 후
+@Before("execution(* org.example.expert.domain.user.controller.UserController.getUser(..))")
+```
+
+
+
+
+### 2-6. 문제 인식 및 정의
+- 할 일을 작성하여 저장시 유저가 자동적으로 담당자로 등로되어야 합니다. 하지만 Manager 엔티티가 Todo와 연관 관계를 맺고 있음에도 불구하고 Todo 저장 시 Manager는 저장되지 않아 문제가 발생했습니다.
+
+### 2-6. 해결 방안
+- CascadeType.PERSIST 적용
+ - @OneToMany(mappedBy = "todo", cascade = CascadeType.PERSIST)를 통해 Todo 저장 시 함께 등록된 Manager 엔티티도 함께 저장
+
+### 2-6. 해결 완료
+```
+@OneToMany(mappedBy = "todo", cascade = CascadeType.PERSIST)
+private List managers = new ArrayList<>();
+```
+
+
+
+
+### 2-7. 문제 인식 및 정의
+- N+1 문제
+ - CommentController.getComments() API 호출 시 각 Comment 마다 User를 별도 쿼리로 조회하여 N+1 문제 발생
+
+### 2-7 해결 방안
+```
+JOIN FETCH : 연관 객체도 한 번의 쿼리로 즉시 로딩
+ - 단점 : 페이징이 불가능 한 경우도 있음
+EntityGraph : 간결한 문법으로 fetch join 처리
+ - 단점 : 복잡한 쿼리에서는 사용이 불가능
+```
+
+### 2-7. 해결 완료
+```
+ @Query("SELECT c FROM Comment c JOIN FETCH c.user WHERE c.todo.id = :todoId")
+ List findByTodoIdWithUser(@Param("todoId") Long todoId);
+```
+- JOIN FETCH 를 사용하여 Comment 와 연관된 User 를 한 번의 쿼리로 함께 조회
+
+
+
+
+### 2-8. 문제 인식 및 정의
+- TodoService.getTodo 메소드 내 todoRepository.findByIdWithUser(todoId) 호출 시 기존 JPQL로 작성된 쿼리가 N+1 문제를 발생시킬 수 있습니다.
+
+### 2-8 해결 방안
+- QueryDSL 의존성 추가
+```
+ implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
+ annotationProcessor "com.querydsl:querydsl-apt:5.1.0:jakarta"
+ annotationProcessor "jakarta.annotation:jakarta.annotation-api"
+ annotationProcessor "jakarta.persistence:jakarta.persistence-api"
+```
+- JPAQueryFactory 빈을 등록하여 QueryDSL 쿼리를 사용할 수 있도록 설정
+```
+@Configuration
+public class QueryDslConfig {
+
+ private final EntityManager entityManager;
+
+ public QueryDslConfig(EntityManager entityManager) {
+ this.entityManager = entityManager;
+ }
+
+ @Bean
+ public JPAQueryFactory jpaQueryFactory() {
+ return new JPAQueryFactory(entityManager);
+ }
+}
+```
+- QueryDSL을 사용하여 구현할 메소드
+```
+public interface TodoCustomRepository {
+Optional findByIdWithUser(Long todoId);
+}
+```
+- TodoCustomRepository 인터페이스를 구현
+- JPAQueryFactory를 사용하여 Todo와 User를 fetchJoin()으로 함께 조회
+```
+public class TodoCustomRepositoryImpl implements TodoCustomRepository {
+ private final JPAQueryFactory jpaQueryFactory;
+ public TodoCustomRepositoryImpl(JPAQueryFactory jpaQueryFactory) {
+ this.jpaQueryFactory = jpaQueryFactory;
+ }
+
+ @Override
+ public Optional findByIdWithUser(Long todoId) {
+ // Q클래스 인스턴스 생성
+ // todo와 user 필드 접근 ?
+ QTodo todo = QTodo.todo;
+ QUser user = QUser.user;
+
+ // QueryDSL 쿼리 작성: todo와 user를 fetchJoin하여 함께 조회
+ return Optional.ofNullable(jpaQueryFactory
+ .selectFrom(todo)
+ .leftJoin(todo.user, user)
+ .fetchJoin()
+ .where(todo.id.eq(todoId))
+ .fetchOne());
+ }
+}
+```
+- JpaRepository를 상속받는 TodoRepository 인터페이스에 TodoCustomRepository 인터페이스를 추가로 상속
+
+```
+public interface TodoRepository extends JpaRepository, TodoCustomRepository {
+```
+### 2-8. 해결 완료
+- Todo를 조회할 때 연관된 User 정보도 fetchJoin을 통해 한번의 쿼리로 가져오게되어 N+1 문제를 해결
+
+
+
+
+### 2-9. 문제 인식 및 정의
+- Filter와 Argument Resolver를 사용하여 접근 권한 및 사용자 권한 관리를 하고 있습니다.
+- Spring Security로 전환하고, 기존의 접근 권한 및 사용자 권한 기능은 그대로 유지하되 권한 관리는 Spring Security의 기능을 활용하는 것
+- 기존의 토큰 기반 인증 방식(JWT)은 그대로 유지
+
+### 2-9. 해결 방안
+- Spring Security 의존성 추가
+```
+ implementation 'org.springframework.boot:spring-boot-starter-security'
+```
+- SecurityConfig 생성
+ - CSRF 비활성화
+ - BasicAuthenticationFilter 비활성화
+ - UsernamePasswordAuthenticationFilter, DefaultLoginPageGeneratingFilter 비활성화
+ - 세션 관리 정책: STATELESS (서버에 세션 저장 안함)
+ - 인가 규칙 설정
+
+```
+@Configuration
+@EnableWebSecurity //시큐리티 활성화
+@RequiredArgsConstructor
+public class SecurityConfig {
+
+ private final JwtFilter jwtFilter;
+
+ @Bean
+ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+ http
+ // CSRF 보호 비활성화 (JWT 토큰 기반 인증 사용)
+ .csrf(AbstractHttpConfigurer::disable)
+ .httpBasic(AbstractHttpConfigurer::disable) // BasicAuthenticationFilter 비활성화
+ .formLogin(AbstractHttpConfigurer::disable) // UsernamePasswordAuthenticationFilter, DefaultLoginPageGeneratingFilter 비활성화
+ // 세션 관리 정책: STATELESS (서버에 세션 저장 안함)
+ .sessionManagement(session ->
+ session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ // 접근 권한 설정
+ .authorizeHttpRequests(authorize -> authorize
+ //허용 경로지정
+ // "/auth"로 시작하는 모든 요청은 인증 없이 허용 (회원가입, 로그인)
+ .requestMatchers("/auth/**").permitAll()
+ // "/admin"으로 시작하는 요청은 'ADMIN' 역할을 가진 사용자만 허용
+ .requestMatchers("/admin/**").hasRole("ADMIN")
+ // 나머지는 인증 필요
+ .anyRequest().authenticated()
+ ).addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
+
+ return http.build();
+ }
+}
+```
+
+- JwtFilter를 OncePerRequestFilter로 변경
+ - 기존 Filter 인터페이스를 상속받던 JwtFilter를 Spring Security가 제공하는 OncePerRequestFilter로 변경
+ * HTTP 요청당 한 번만 필터를 실행하도록 보장하고 Spring Security의 필터 체인에 통합하기 좋다고 합니다.
+ * -> FilterConfig 제거
+```
+@Slf4j
+@RequiredArgsConstructor
+@Component // Spring Bean으로 등록
+public class JwtFilter extends OncePerRequestFilter {
+
+ private final JwtUtil jwtUtil;
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+ throws ServletException, IOException {
+
+ String bearerJwt = request.getHeader("Authorization");
+
+ if (bearerJwt == null || !bearerJwt.startsWith("Bearer ")) {
+ response.sendError(HttpServletResponse.SC_BAD_REQUEST, "JWT 토큰이 필요합니다.");
+ return;
+ }
+
+ try {
+ String jwt = bearerJwt.substring(7); // "Bearer " 제거
+ Claims claims = jwtUtil.extractClaims(jwt);
+
+ if (claims == null) {
+ response.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다.");
+ return;
+ }
+
+ // 클레임에서 사용자 정보 추출
+ Long id = Long.parseLong(claims.getSubject());
+ String email = claims.get("email", String.class);
+ String userRoleString = claims.get("userRole", String.class);
+ String nickname = claims.get("nickname", String.class);
+ UserRole userRole = UserRole.valueOf(userRoleString);
+
+ // AuthUser 객체 생성
+ AuthUser authUser = new AuthUser(id, email, userRole, nickname);
+
+ // Spring Security의 Authentication 객체 생성
+ UsernamePasswordAuthenticationToken authentication =
+ new UsernamePasswordAuthenticationToken(
+ authUser, null, authUser.getAuthorities());
+
+ // SecurityContextHolder에 Authentication 객체를 설정
+ SecurityContextHolder.getContext().setAuthentication(authentication);
+
+ ...
+ }
+}
+```
+- AuthUser에 UserDetails 인터페이스 구현
+ - AuthUser 클래스가 Spring Security에서 사용자 상세 정보를 가져올 수 있게 구현
+ -
+```
+@Getter
+public class AuthUser implements UserDetails {
+
+ ...
+
+ // 권환 반환
+ @Override
+ public Collection extends GrantedAuthority> getAuthorities() {
+ return Collections.singleton(userRole.getGrantedAuthority());
+ }
+
+ @Override
+ public String getPassword() {
+ return null;
+ }
+
+ @Override
+ public String getUsername() {
+ return email;
+ }
+}
+```
+
+- @AuthenticationPrincipal 어노테이션 활용
+ - 기존 @Auth 커스텀 Argument Resolver 대신해서 Spring Security에서 제공하는 @AuthenticationPrincipal 어노테이션을 사용해서 인증된 값을 주입받을 수 있다
+ - -> WebConfig, AuthUserArgumentResolver 제거
+
+
+
+
+
+
+### 3-10. 문제 인식 및 정의
+- 검색 api 작성
+- 검색 조건
+ - 검색 키워드로 일정의 제목을 검색
+ - 제목은 부분적으로 일치해도 검색이 가능
+ - 일정의 생성일 범위로 검색
+ - 일정을 생성일 최신순으로 정렬해주세요.
+ - 담당자의 닉네임으로도 검색이 가능
+ - 닉네임은 부분적으로 일치해도 검색이 가능
+- 다음의 내용을 포함해서 검색 결과를 반환
+ - 일정에 대한 모든 정보가 아닌, 제목만 반환
+ - 해당 일정의 담당자 수를 반환
+ - 해당 일정의 총 댓글 개수를 반환
+- 검색 결과는 페이징 처리되어 반환
+
+### 3-10. 해결 방안
+- SearchTodoConditionRequest
+ - keyword, nickname, startDate, endDate
+- BooleanExpression 사용
+ - QueryDSL에서 WHERE 조건을 표현하는 객체
+ - .where() 절에 조건을 걸고 싶을 때 사용 가능
+
diff --git a/build.gradle b/build.gradle
index a7fd3e706..f0a2f02cc 100644
--- a/build.gradle
+++ b/build.gradle
@@ -41,6 +41,15 @@ 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
+ implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
+ annotationProcessor "com.querydsl:querydsl-apt:5.1.0:jakarta"
+ annotationProcessor "jakarta.annotation:jakarta.annotation-api"
+ annotationProcessor "jakarta.persistence:jakarta.persistence-api"
+
+ // Spring Security
+ implementation 'org.springframework.boot:spring-boot-starter-security'
}
tasks.named('test') {
diff --git a/src/main/java/org/example/expert/aop/AdminAccessLoggingAspect.java b/src/main/java/org/example/expert/aop/AdminAccessLoggingAspect.java
index c90e8c792..b2cc2fddb 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.UserController.getUser(..))")
public void logAfterChangeUserRole(JoinPoint joinPoint) {
String userId = String.valueOf(request.getAttribute("userId"));
String requestUrl = request.getRequestURI();
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 db00211de..000000000
--- a/src/main/java/org/example/expert/config/AuthUserArgumentResolver.java
+++ /dev/null
@@ -1,46 +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");
- UserRole userRole = UserRole.of((String) request.getAttribute("userRole"));
-
- return new AuthUser(userId, email, 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 03908abe1..5224507b6 100644
--- a/src/main/java/org/example/expert/config/JwtFilter.java
+++ b/src/main/java/org/example/expert/config/JwtFilter.java
@@ -10,38 +10,33 @@
import jakarta.servlet.http.HttpServletResponse;
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.context.SecurityContextHolder;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Slf4j
@RequiredArgsConstructor
-public class JwtFilter implements Filter {
+// Been 등록
+@Component
+public class JwtFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
@Override
- public void init(FilterConfig filterConfig) throws ServletException {
- Filter.super.init(filterConfig);
- }
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain
+ ) throws ServletException, IOException {
- @Override
- public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
- HttpServletRequest httpRequest = (HttpServletRequest) request;
- HttpServletResponse httpResponse = (HttpServletResponse) response;
+ // auth 경로 Spring Security에서 설정
- String url = httpRequest.getRequestURI();
-
- if (url.startsWith("/auth")) {
- chain.doFilter(request, response);
- return;
- }
-
- String bearerJwt = httpRequest.getHeader("Authorization");
+ String bearerJwt = request.getHeader("Authorization");
if (bearerJwt == null) {
- // 토큰이 없는 경우 400을 반환합니다.
- httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "JWT 토큰이 필요합니다.");
+ response.sendError(HttpServletResponse.SC_BAD_REQUEST, "JWT 토큰이 필요합니다.");
return;
}
@@ -51,44 +46,45 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha
// JWT 유효성 검사와 claims 추출
Claims claims = jwtUtil.extractClaims(jwt);
if (claims == null) {
- httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다.");
+ response.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다.");
return;
}
- UserRole userRole = UserRole.valueOf(claims.get("userRole", String.class));
+ // 클레임에서 사용자 정보 추출
+ Long id = Long.parseLong(claims.getSubject());
+ String email = claims.get("email", String.class);
+ String userRoleString = claims.get("userRole", String.class);
+ String nickname = claims.get("nickname", String.class);
- httpRequest.setAttribute("userId", Long.parseLong(claims.getSubject()));
- httpRequest.setAttribute("email", claims.get("email"));
- httpRequest.setAttribute("userRole", claims.get("userRole"));
+ UserRole userRole = UserRole.valueOf(userRoleString);
- if (url.startsWith("/admin")) {
- // 관리자 권한이 없는 경우 403을 반환합니다.
- if (!UserRole.ADMIN.equals(userRole)) {
- httpResponse.sendError(HttpServletResponse.SC_FORBIDDEN, "관리자 권한이 없습니다.");
- return;
- }
- chain.doFilter(request, response);
- return;
- }
+ // authUser 가져온 값들을 주입
+ AuthUser authUser = new AuthUser(id, email, userRole, nickname);
+
+ // Spring Security의 Authentication 객체 생성
+ UsernamePasswordAuthenticationToken authentication =
+ new UsernamePasswordAuthenticationToken(
+ authUser, null, authUser.getAuthorities());
+
+ // SecurityContextHolder에 Authentication 객체를 설정합니다.
+ SecurityContextHolder.getContext().setAuthentication(authentication);
- chain.doFilter(request, response);
+ filterChain.doFilter(request, response);
} catch (SecurityException | MalformedJwtException e) {
log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.", e);
- httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않는 JWT 서명입니다.");
+ response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않는 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
log.error("Expired JWT token, 만료된 JWT token 입니다.", e);
- httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다.");
+ response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.", e);
- httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "지원되지 않는 JWT 토큰입니다.");
+ response.sendError(HttpServletResponse.SC_BAD_REQUEST, "지원되지 않는 JWT 토큰입니다.");
+ } catch (IllegalArgumentException e) { // UserRole.valueOf() 실패 등 추가 예외 처리
+ log.error("JWT claims parsing error or invalid user role.", e);
+ response.sendError(HttpServletResponse.SC_BAD_REQUEST, "JWT 클레임 처리 오류 또는 유효하지 않은 사용자 역할입니다.");
} catch (Exception e) {
- log.error("Internal server error", e);
- httpResponse.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+ log.error("Internal server error during JWT processing.", e);
+ response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "JWT 처리 중 서버 내부 오류가 발생했습니다.");
}
}
-
- @Override
- public void destroy() {
- Filter.super.destroy();
- }
-}
+}
\ No newline at end of file
diff --git a/src/main/java/org/example/expert/config/JwtUtil.java b/src/main/java/org/example/expert/config/JwtUtil.java
index 07e0a2c7c..230bec1b8 100644
--- a/src/main/java/org/example/expert/config/JwtUtil.java
+++ b/src/main/java/org/example/expert/config/JwtUtil.java
@@ -34,7 +34,7 @@ public void init() {
key = Keys.hmacShaKeyFor(bytes);
}
- public String createToken(Long userId, String email, UserRole userRole) {
+ public String createToken(Long userId, String email, UserRole userRole, String nickname) {
Date date = new Date();
return BEARER_PREFIX +
@@ -42,6 +42,7 @@ public String createToken(Long userId, String email, UserRole userRole) {
.setSubject(String.valueOf(userId))
.claim("email", email)
.claim("userRole", userRole)
+ .claim("nickname", nickname)
.setExpiration(new Date(date.getTime() + TOKEN_TIME))
.setIssuedAt(date) // 발급일
.signWith(key, signatureAlgorithm) // 암호화 알고리즘
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..2575bf500
--- /dev/null
+++ b/src/main/java/org/example/expert/config/QueryDslConfig.java
@@ -0,0 +1,22 @@
+package org.example.expert.config;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import jakarta.persistence.EntityManager;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class QueryDslConfig {
+
+ private EntityManager entityManager;
+
+ public QueryDslConfig(EntityManager entityManager) {
+ this.entityManager = entityManager;
+ }
+
+ @Bean
+ public JPAQueryFactory jpaQueryFactory() {
+ return new JPAQueryFactory(entityManager);
+ }
+}
+
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..06afc5109
--- /dev/null
+++ b/src/main/java/org/example/expert/config/SecurityConfig.java
@@ -0,0 +1,44 @@
+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 securityFilterChain(HttpSecurity http) throws Exception {
+ http
+ // CSRF 보호 비활성화 (JWT 토큰 기반 인증 사용)
+ .csrf(AbstractHttpConfigurer::disable)
+ .httpBasic(AbstractHttpConfigurer::disable) // BasicAuthenticationFilter 비활성화
+ .formLogin(AbstractHttpConfigurer::disable) // UsernamePasswordAuthenticationFilter, DefaultLoginPageGeneratingFilter 비활성화
+ // 세션 관리 정책: STATELESS (서버에 세션 저장 안함)
+ .sessionManagement(session ->
+ session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ // 접근 권한 설정
+ .authorizeHttpRequests(authorize -> authorize
+ //허용 경로지정
+ // "/auth"로 시작하는 모든 요청은 인증 없이 허용 (회원가입, 로그인)
+ .requestMatchers("/auth/**").permitAll()
+ // "/admin"으로 시작하는 요청은 'ADMIN' 역할을 가진 사용자만 허용
+ .requestMatchers("/admin/**").hasRole("ADMIN")
+ // 나머지는 인증 필요
+ .anyRequest().authenticated()
+ ).addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
+
+ return http.build();
+ }
+}
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/auth/dto/request/SignupRequest.java b/src/main/java/org/example/expert/domain/auth/dto/request/SignupRequest.java
index cdb103690..084e64b34 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
@@ -17,4 +17,6 @@ public class SignupRequest {
private String password;
@NotBlank
private String userRole;
+ @NotBlank
+ private String nickname;
}
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..2a5bc1a35 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
@@ -38,11 +38,12 @@ public SignupResponse signup(SignupRequest signupRequest) {
User newUser = new User(
signupRequest.getEmail(),
encodedPassword,
- userRole
+ userRole,
+ signupRequest.getNickname()
);
User savedUser = userRepository.save(newUser);
- String bearerToken = jwtUtil.createToken(savedUser.getId(), savedUser.getEmail(), userRole);
+ String bearerToken = jwtUtil.createToken(savedUser.getId(), savedUser.getEmail(), userRole, savedUser.getNickname());
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.getUserRole(),user.getNickname());
return new SigninResponse(bearerToken);
}
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/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);
}
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/common/annotation/Auth.java b/src/main/java/org/example/expert/domain/common/annotation/Auth.java
deleted file mode 100644
index 770061855..000000000
--- a/src/main/java/org/example/expert/domain/common/annotation/Auth.java
+++ /dev/null
@@ -1,11 +0,0 @@
-package org.example.expert.domain.common.annotation;
-
-import java.lang.annotation.ElementType;
-import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
-import java.lang.annotation.Target;
-
-@Target(ElementType.PARAMETER)
-@Retention(RetentionPolicy.RUNTIME)
-public @interface Auth {
-}
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..cde9a2032 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,17 +2,42 @@
import lombok.Getter;
import org.example.expert.domain.user.enums.UserRole;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.userdetails.UserDetails;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
@Getter
-public class AuthUser {
+public class AuthUser implements UserDetails {
private final Long id;
private final String email;
private final UserRole userRole;
+ private final String nickname;
- public AuthUser(Long id, String email, UserRole userRole) {
+ public AuthUser(Long id, String email, UserRole userRole, String nickname) {
this.id = id;
this.email = email;
this.userRole = userRole;
+ this.nickname = nickname;
+ }
+
+ // 권환 반환
+ @Override
+ public Collection extends GrantedAuthority> getAuthorities() {
+ return Collections.singleton(userRole.getGrantedAuthority());
+ }
+
+ @Override
+ public String getPassword() {
+ return null;
+ }
+
+ @Override
+ public String getUsername() {
+ return email;
}
+
}
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/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/todo/controller/TodoController.java b/src/main/java/org/example/expert/domain/todo/controller/TodoController.java
index eed1a1b46..955214ec7 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,14 +2,17 @@
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.SearchTodoConditionRequest;
import org.example.expert.domain.todo.dto.request.TodoSaveRequest;
+import org.example.expert.domain.todo.dto.response.SearchTodoResponse;
import org.example.expert.domain.todo.dto.response.TodoResponse;
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.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
@RestController
@@ -20,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));
@@ -29,13 +32,23 @@ public ResponseEntity saveTodo(
@GetMapping("/todos")
public ResponseEntity> getTodos(
@RequestParam(defaultValue = "1") int page,
- @RequestParam(defaultValue = "10") int size
+ @RequestParam(defaultValue = "10") int size,
+ @RequestParam(required = false) String weather,
+ @RequestParam(required = false) String startDate,
+ @RequestParam(required = false) String endDate
) {
- return ResponseEntity.ok(todoService.getTodos(page, size));
+ return ResponseEntity.ok(todoService.getTodos(page, size, weather, startDate, endDate));
}
@GetMapping("/todos/{todoId}")
public ResponseEntity getTodo(@PathVariable long todoId) {
return ResponseEntity.ok(todoService.getTodo(todoId));
}
+
+ @GetMapping("/search")
+ public ResponseEntity> searchTodo(
+ SearchTodoConditionRequest conditionRequest,
+ Pageable pageable) {
+ return ResponseEntity.ok(todoService.searchTodo(conditionRequest, pageable));
+ }
}
diff --git a/src/main/java/org/example/expert/domain/todo/dto/request/SearchTodoConditionRequest.java b/src/main/java/org/example/expert/domain/todo/dto/request/SearchTodoConditionRequest.java
new file mode 100644
index 000000000..eeb57a2a0
--- /dev/null
+++ b/src/main/java/org/example/expert/domain/todo/dto/request/SearchTodoConditionRequest.java
@@ -0,0 +1,12 @@
+package org.example.expert.domain.todo.dto.request;
+
+import lombok.Getter;
+import java.time.LocalDate;
+
+@Getter
+public class SearchTodoConditionRequest {
+ private String keyword;
+ private String Nickname;
+ private LocalDate startDate;
+ private LocalDate endDate;
+}
diff --git a/src/main/java/org/example/expert/domain/todo/dto/response/SearchTodoResponse.java b/src/main/java/org/example/expert/domain/todo/dto/response/SearchTodoResponse.java
new file mode 100644
index 000000000..2d63a6056
--- /dev/null
+++ b/src/main/java/org/example/expert/domain/todo/dto/response/SearchTodoResponse.java
@@ -0,0 +1,13 @@
+package org.example.expert.domain.todo.dto.response;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public class SearchTodoResponse {
+ private String title;
+ private Long managerCount;
+ private Long commentCount;
+
+}
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..6a1bbf3ae 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)
private List managers = new ArrayList<>();
public Todo(String title, String contents, String weather, User user) {
diff --git a/src/main/java/org/example/expert/domain/todo/repository/TodoCustomRepository.java b/src/main/java/org/example/expert/domain/todo/repository/TodoCustomRepository.java
new file mode 100644
index 000000000..31d7fafe3
--- /dev/null
+++ b/src/main/java/org/example/expert/domain/todo/repository/TodoCustomRepository.java
@@ -0,0 +1,15 @@
+package org.example.expert.domain.todo.repository;
+
+import org.example.expert.domain.todo.dto.request.SearchTodoConditionRequest;
+import org.example.expert.domain.todo.dto.response.SearchTodoResponse;
+import org.example.expert.domain.todo.entity.Todo;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+
+import java.util.Optional;
+
+public interface TodoCustomRepository {
+ Optional findByIdWithUser(Long todoId);
+
+ Page searchByCondition(SearchTodoConditionRequest conditionRequest, Pageable pageable);
+}
diff --git a/src/main/java/org/example/expert/domain/todo/repository/TodoCustomRepositoryImpl.java b/src/main/java/org/example/expert/domain/todo/repository/TodoCustomRepositoryImpl.java
new file mode 100644
index 000000000..c3c16c5e1
--- /dev/null
+++ b/src/main/java/org/example/expert/domain/todo/repository/TodoCustomRepositoryImpl.java
@@ -0,0 +1,118 @@
+package org.example.expert.domain.todo.repository;
+
+import com.querydsl.core.types.Projections;
+import com.querydsl.core.types.dsl.BooleanExpression;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import org.example.expert.domain.comment.entity.QComment;
+import org.example.expert.domain.manager.entity.QManager;
+import org.example.expert.domain.todo.dto.request.SearchTodoConditionRequest;
+import org.example.expert.domain.todo.dto.response.SearchTodoResponse;
+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 org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.Pageable;
+
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Optional;
+
+public class TodoCustomRepositoryImpl implements TodoCustomRepository{
+
+ private final JPAQueryFactory jpaQueryFactory;
+
+ public TodoCustomRepositoryImpl(JPAQueryFactory jpaQueryFactory) {
+ this.jpaQueryFactory = jpaQueryFactory;
+ }
+
+ @Override
+ public Optional findByIdWithUser(Long todoId) {
+
+ QTodo todo = QTodo.todo;
+ QUser user = QUser.user;
+
+ return Optional.ofNullable(jpaQueryFactory
+ .selectFrom(todo)
+ .leftJoin(todo.user,user)
+ .fetchJoin()
+ .where(todo.id.eq(todoId))
+ .fetchOne());
+ }
+
+ @Override
+ public Page searchByCondition(
+ SearchTodoConditionRequest conditionRequest,
+ Pageable pageable) {
+ QTodo todo = QTodo.todo;
+ QUser user = QUser.user;
+ QComment comment = QComment.comment;
+ QManager manager = QManager.manager;
+
+ List content = jpaQueryFactory
+ .select(Projections.constructor(SearchTodoResponse.class,
+ todo.title,
+ manager.id.countDistinct(),
+ comment.id.countDistinct()
+ ))
+ .from(todo)
+ .leftJoin(todo.managers, manager)
+ .leftJoin(manager.user,user)
+ .leftJoin(todo.comments, comment)
+ .where(
+ titleContains(conditionRequest.getKeyword()),
+ managerNicknameContains(conditionRequest.getNickname()),
+ createdDateBetween(conditionRequest.getStartDate(), conditionRequest.getEndDate())
+ )
+ .groupBy(todo.id)
+ .orderBy(todo.createdAt.desc())
+ .offset(pageable.getOffset())
+ .limit(pageable.getPageSize())
+ .fetch();
+
+ Long total = jpaQueryFactory
+ .select(todo.id.countDistinct())
+ .from(todo)
+ .leftJoin(todo.managers, manager)
+ .leftJoin(manager.user, user)
+ .where(
+ titleContains(conditionRequest.getKeyword()),
+ managerNicknameContains(conditionRequest.getNickname()),
+ createdDateBetween(conditionRequest.getStartDate(), conditionRequest.getEndDate())
+ )
+ .fetchOne();
+
+ long totalCount = 0;
+ if (total != null) {
+ totalCount = total;
+ }
+
+ return new PageImpl<>(content, pageable, totalCount);
+ }
+
+ private BooleanExpression titleContains(String keyword) {
+ if (keyword != null || keyword.isBlank()) {
+ return null;
+ }
+ return QTodo.todo.title.containsIgnoreCase(keyword);
+ }
+
+ private BooleanExpression managerNicknameContains(String nickname) {
+ if (nickname == null || nickname.isBlank()) {
+ return null;
+ }
+ return QManager.manager.user.nickname.containsIgnoreCase(nickname);
+ }
+
+ private BooleanExpression createdDateBetween(LocalDate start, LocalDate end) {
+ if (start != null && end != null) {
+ return QTodo.todo.createdAt.between(start.atStartOfDay(), end.plusDays(1).atStartOfDay());
+ } else if (start != null) {
+ return QTodo.todo.createdAt.goe(start.atStartOfDay());
+ } else if (end != null) {
+ return QTodo.todo.createdAt.loe(end.plusDays(1).atStartOfDay());
+ }
+ return null;
+ }
+}
\ No newline at end of file
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..2482c3e58 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
@@ -7,15 +7,25 @@
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 {
+public interface TodoRepository extends JpaRepository, TodoCustomRepository {
- @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 t.user " +
+// "WHERE t.id = :todoId")
+// Optional findByIdWithUser(@Param("todoId") Long todoId);
- @Query("SELECT t FROM Todo t " +
- "LEFT JOIN t.user " +
- "WHERE t.id = :todoId")
- Optional findByIdWithUser(@Param("todoId") Long todoId);
+ // 날씨가 없을경우
+ // 날짜없는 경우
+ // @Param 메소드와 연결을 위함
+ @Query("select t from Todo t " +
+ "WHERE (:weather IS NULL OR t.weather = :weather) " +
+ "AND (:startDate IS NULL OR t.modifiedAt >= :startDate) " +
+ "AND (:endDate IS NULL OR t.modifiedAt <= :endDate)")
+ Page findTodosByConditions(Pageable pageable,
+ @Param("weather") String weather,
+ @Param("startDate") LocalDateTime startDate,
+ @Param("endDate") LocalDateTime endDate);
}
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..d9d189136 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
@@ -4,7 +4,9 @@
import org.example.expert.client.WeatherClient;
import org.example.expert.domain.common.dto.AuthUser;
import org.example.expert.domain.common.exception.InvalidRequestException;
+import org.example.expert.domain.todo.dto.request.SearchTodoConditionRequest;
import org.example.expert.domain.todo.dto.request.TodoSaveRequest;
+import org.example.expert.domain.todo.dto.response.SearchTodoResponse;
import org.example.expert.domain.todo.dto.response.TodoResponse;
import org.example.expert.domain.todo.dto.response.TodoSaveResponse;
import org.example.expert.domain.todo.entity.Todo;
@@ -16,6 +18,11 @@
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+import java.time.format.DateTimeFormatter;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
@Service
@RequiredArgsConstructor
@@ -24,7 +31,10 @@ public class TodoService {
private final TodoRepository todoRepository;
private final WeatherClient weatherClient;
+ private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
+ @Transactional
public TodoSaveResponse saveTodo(AuthUser authUser, TodoSaveRequest todoSaveRequest) {
User user = User.fromAuthUser(authUser);
@@ -43,21 +53,46 @@ 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) {
+ public Page getTodos(int page, int size, String weather, String startDateStr, String endDateStr) {
Pageable pageable = PageRequest.of(page - 1, size);
- Page todos = todoRepository.findAllByOrderByModifiedAtDesc(pageable);
+ LocalDate startDate = null;
+ if (startDateStr != null && !startDateStr.isEmpty()) {
+ startDate = LocalDate.parse(startDateStr, DATE_FORMATTER);
+ }
+
+ LocalDate endDate = null;
+ if (endDateStr != null && !endDateStr.isEmpty()) {
+ endDate = LocalDate.parse(endDateStr, DATE_FORMATTER);
+ }
+
+ LocalDateTime startDateTime = null;
+ if (startDate != null) {
+ startDateTime = startDate.atStartOfDay();
+ }
+
+ LocalDateTime endDateTime = null;
+ if (endDate != null) {
+ endDateTime = endDate.atTime(LocalTime.MAX);
+ }
+
+ Page todos = todoRepository.findTodosByConditions(
+ pageable,
+ weather,
+ 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()
));
@@ -74,9 +109,15 @@ 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()
);
}
+
+ public Page searchTodo(
+ SearchTodoConditionRequest conditionRequest,
+ Pageable pageable) {
+ return todoRepository.searchByCondition(conditionRequest, pageable);
+ }
}
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);
}
}
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/main/java/org/example/expert/domain/user/entity/User.java b/src/main/java/org/example/expert/domain/user/entity/User.java
index 30a0cc54f..0d504aa76 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
@@ -21,20 +21,25 @@ public class User extends Timestamped {
@Enumerated(EnumType.STRING)
private UserRole userRole;
- public User(String email, String password, UserRole userRole) {
+ @Column(nullable = true)
+ private String nickname;
+
+ public User(String email, String password, UserRole userRole, String nickname) {
this.email = email;
this.password = password;
this.userRole = userRole;
+ this.nickname = nickname;
}
- private User(Long id, String email, UserRole userRole) {
+ private User(Long id, String email, UserRole userRole, String nickname) {
this.id = id;
this.email = email;
this.userRole = userRole;
+ this.nickname = nickname;
}
public static User fromAuthUser(AuthUser authUser) {
- return new User(authUser.getId(), authUser.getEmail(), authUser.getUserRole());
+ return new User(authUser.getId(), authUser.getEmail(), authUser.getUserRole(), authUser.getNickname());
}
public void changePassword(String password) {
diff --git a/src/main/java/org/example/expert/domain/user/enums/UserRole.java b/src/main/java/org/example/expert/domain/user/enums/UserRole.java
index 6fe177896..ec173c25b 100644
--- a/src/main/java/org/example/expert/domain/user/enums/UserRole.java
+++ b/src/main/java/org/example/expert/domain/user/enums/UserRole.java
@@ -1,6 +1,8 @@
package org.example.expert.domain.user.enums;
import org.example.expert.domain.common.exception.InvalidRequestException;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
import java.util.Arrays;
@@ -13,4 +15,10 @@ public static UserRole of(String role) {
.findFirst()
.orElseThrow(() -> new InvalidRequestException("유효하지 않은 UerRole"));
}
+
+ // 시큐리티 권한 반환을 위한 메서드
+ public GrantedAuthority getGrantedAuthority() {
+ return new SimpleGrantedAuthority("ROLE_" + this.name());
+ }
+
}
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/main/resources/application.yml b/src/main/resources/application.yml
new file mode 100644
index 000000000..141200635
--- /dev/null
+++ b/src/main/resources/application.yml
@@ -0,0 +1,19 @@
+jwt:
+ secret:
+ key: afd456af34s435s62dfa46ads5f425as4562asfd2564fads5462asdf4562afds2456
+
+spring:
+ datasource:
+ driver-class-name: com.mysql.cj.jdbc.Driver
+ url: jdbc:mysql://localhost:3306/spring_plus
+ username: root
+ password: 6745
+ jpa:
+ hibernate:
+ ddl-auto: update
+ properties:
+ hibernate:
+ show_sql: true
+ format_sql: true
+ use_sql_comments: true
+ dialect: org.hibernate.dialect.MySQLDialect
\ No newline at end of file
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..a0047abe1 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", UserRole.USER, "하룻강아지");
User user = User.fromAuthUser(authUser);
- UserResponse userResponse = new UserResponse(user.getId(), user.getEmail());
+ UserResponse userResponse = new UserResponse(user.getId(), user.getEmail(), authUser.getNickname());
TodoResponse response = new TodoResponse(
todoId,
title,
@@ -61,7 +61,7 @@ class TodoControllerTest {
@Test
void todo_단건_조회_시_todo가_존재하지_않아_예외가_발생한다() throws Exception {
// given
- long todoId = 1L;
+ long todoId = 5L;
// when
when(todoService.getTodo(todoId))
@@ -69,9 +69,9 @@ 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(status().isBadRequest())
+ .andExpect(jsonPath("$.status").value(HttpStatus.BAD_REQUEST.name()))
+ .andExpect(jsonPath("$.code").value(HttpStatus.BAD_REQUEST.value()))
.andExpect(jsonPath("$.message").value("Todo not found"));
}
}