https://github.com/MinWoo1995/spring-plus-task
GitHub - MinWoo1995/spring-plus-task: spring-plus
spring-plus. Contribute to MinWoo1995/spring-plus-task development by creating an account on GitHub.
github.com
필수기능 LEVEL 1
문제1. 코드 개선 퀴즈 - @Transactional의 이해

- 할 일 저장 기능을 구현한 API(`/todos`)를 호출할 때, 아래와 같은 에러가 발생하고 있어요.

- 에러 로그 원문
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] [insert into todos (contents,created_at,modified_at,title,user_id,weather) values (?,?,?,?,?,?)]
- 에러가 발생하지 않고 정상적으로 할 일을 저장 할 수 있도록 코드를 수정해주세요.
해결방법
1.호출되고 있는 서비스 클래스와 해당 메서드 todoService.saveTodo를 우선 확인하였다.

2.클래스 상단에 성능 최적화를 위해 @Transactional(readOnly = true)는 하이버네이트의 스냅샷 저장 및 더티 체킹(Dirty Checking) 과정을 생략하게 하여 조회 성능을 향상시키기 위해서 보통 클래스 상단에 기본으로 걸어두곤 한다
그래서, 실수 방지를 위해 읽기 전용 메서드에서 의도치 않게 데이터가 수정되는 것을 방지하는 안전장치 역할도 한다
해결책: 클래스 레벨에 readOnly = true가 선언되어 있다면, CUD(생성, 수정, 삭제) 작업이 포함된 메서드 위에는 반드시 @Transactional을 추가하여 기본 설정(readOnly = false)으로 덮어쓰기(Override) 해야 한다
결과

문제2. 코드 추가 퀴즈 - JWT의 이해
🚨 기획자의 긴급 요청이 왔어요!
아래의 요구사항에 맞춰 기획 요건에 대응할 수 있는 코드를 작성해주세요.
- User의 정보에 nickname이 필요해졌어요.
- User 테이블에 nickname 컬럼을 추가해주세요.
- nickname은 중복 가능합니다.
- 프론트엔드 개발자가 JWT에서 유저의 닉네임을 꺼내 화면에 보여주길 원하고 있어요.
해결방법
1.우선 User 테이블에 nickname 컬럼을 추가하기

2.생성자 수정하기

3. 프론트엔드 개발자가 JWT에서 유저의 닉네임을 활용할수 있게 메서드 수정

4.AuthUser 수정

5.그외 닉네임 추가와 관련된 메서드 인자들 수정
토큰이라는 봉인된 편지에서 닉네임을 꺼내, 시스템 내부에서 쓸 수 있는 객체(AuthUser)로 변환하는 과정에 수정
AuthService,JwtUtil,JwtFilter,AuthUserArgumentResolver 등
결과

문제3. 코드 개선 퀴즈 - JPA의 이해

🚨 기획자의 긴급 요청이 왔어요!
아래의 요구사항에 맞춰 기획 요건에 대응할 수 있는 코드를 작성해주세요.
- 할 일 검색 시 `weather` 조건으로도 검색할 수 있어야해요.
- `weather` 조건은 있을 수도 있고, 없을 수도 있어요!
- 할 일 검색 시 수정일 기준으로 기간 검색이 가능해야해요.
- 기간의 시작과 끝 조건은 있을 수도 있고, 없을 수도 있어요!
- JPQL을 사용하고, 쿼리 메소드명은 자유롭게 지정하되 너무 길지 않게 해주세요.
💡 필요할 시, 서비스 단에서 if문을 사용해 여러 개의 쿼리(JPQL)를 사용하셔도 좋습니다.
해결방법
1.컨트롤러 수정

2.해당 서비스 내부 로직 추가
public Page<TodoResponse> getTodos(int page, int size,String weather, String startDate, String endDate) {
Pageable pageable = PageRequest.of(page - 1, size);
LocalDateTime start = (startDate != null) ? LocalDate.parse(startDate).atStartOfDay() : null;
LocalDateTime end = (endDate != null) ? LocalDate.parse(endDate).atTime(LocalTime.MAX) : null;
Page<Todo> todos = todoRepository.findAllByOrderByModifiedAtDesc(weather, start, end, pageable);
return todos.map(todo -> {
User user = todo.getUser();
UserResponse userResponse = new UserResponse(
user.getId(),
user.getEmail(),
user.getNickname()
);
return new TodoResponse(
todo.getId(),
todo.getTitle(),
todo.getContents(),
todo.getWeather(),
userResponse,
todo.getCreatedAt(),
todo.getModifiedAt()
);
});
}
3. TodoRepository (JPQL 쿼리 구현)
public interface TodoRepository extends JpaRepository<Todo, Long> {
@Query("SELECT t FROM Todo t " +
"LEFT JOIN FETCH t.user u " +
"WHERE (:weather IS NULL OR t.weather = :weather) " +
"AND (:startDate IS NULL OR t.modifiedAt >= :startDate) " +
"AND (:endDate IS NULL OR t.modifiedAt <= :endDate) " +
"ORDER BY t.modifiedAt DESC")
Page<Todo> findAllByOrderByModifiedAtDesc(
@Param("weather") String weather,
@Param("startDate") LocalDateTime startDate,
@Param("endDate") LocalDateTime endDate,
Pageable pageable
);
@Query("SELECT t FROM Todo t " +
"LEFT JOIN FETCH t.user " + // fetch 추가하여 N+1 방지 유지
"WHERE t.id = :todoId")
Optional<Todo> findByIdWithUser(@Param("todoId") Long todoId);
}
결과

문제4. 테스트 코드 퀴즈 - 컨트롤러 테스트의 이해

테스트 패키지 org.example.expert.domain.todo.controller의
todo_단건_조회_시_todo가_존재하지_않아_예외가_발생한다() 테스트가 실패하고 있어요.

테스트가 정상적으로 수행되어 통과할 수 있도록 테스트 코드를 수정해주세요.
해결방법
1. 기대하는 상태 코드 수정 (Status Code)
테스트 메서드 이름이 예외가_발생한다인 만큼, 응답은 200 OK가 아니라 400 Bad Request여야 한다
수정 전: .andExpect(status().isOk())
수정 후: .andExpect(status().isBadRequest())
2. JSON 응답 바디의 상태 값 수정 (JSON Path - Status)
시스템의 전역 예외 처리기(GlobalExceptionHandler)는 에러 발생 시 status 필드에 "BAD_REQUEST"라는 문자열을 담아 보낸다
수정 전: .andExpect(jsonPath("$.status").value("OK"))
수정 후: .andExpect(jsonPath("$.status").value("BAD_REQUEST"))
3. 에러 코드 값 일치 (JSON Path - Code)
에러가 났는데 코드만 200일 수는 없다. HttpStatus 상수를 활용해 논리적 일관성을 맞춘다
수정 전: .andExpect(jsonPath("$.code").value(HttpStatus.OK.value()))
수정 후: .andExpect(jsonPath("$.code").value(HttpStatus.BAD_REQUEST.value()))

결과

문제5. 코드 개선 퀴즈 - AOP의 이해

😱 AOP가 잘못 동작하고 있어요!
- `UserAdminController` 클래스의 `changeUserRole()` 메소드가 실행 전 동작해야해요.
- `AdminAccessLoggingAspect` 클래스에 있는 AOP가 개발 의도에 맞도록 코드를 수정해주세요.
해결방법
기획 의도는 '권한 변경 전'에 '관리자 컨트롤러'를 감시하는 것인데, 지금 코드는 '조회 후'에 '일반 컨트롤러'를 보고 있다.
어노테이션 수정과 메서드명 수정

실행후 포스트맨 요청

결과

필수기능 LEVEL 2
문제6. JPA Cascade
🤔 앗❗ 실수로 코드를 지웠어요!
- 할 일을 새로 저장할 시, 할 일을 생성한 유저는 담당자로 자동 등록되어야 합니다.
- JPA의 `cascade` 기능을 활용해 할 일을 생성한 유저가 담당자로 등록될 수 있게 해주세요.

해결방법
1.Todo를 저장할 때 Manager도 함께 저장(Persistence)되도록 cascade 설정이 빠져 있다. 이 설정을 하지 않으면 Todo 객체 안의 managers 리스트에 데이터를 넣어도 DB에는 반영되지 않는다.

결과
package org.example.expert.domain.todo.repository;
import org.example.expert.domain.todo.entity.Todo;
import org.example.expert.domain.user.enums.UserRole;
import org.example.expert.domain.user.repository.UserRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.example.expert.domain.user.entity.User;
import org.example.expert.domain.user.enums.UserRole;
import static org.junit.jupiter.api.Assertions.*;
@DataJpaTest
class TodoRepositoryTest {
@Autowired
private TodoRepository todoRepository;
@Autowired
private UserRepository userRepository;
@Test
void 할일_저장_시_생성자가_담당자로_자동_등록된다() {
// given
User user = new User("email@test.com", "password", UserRole.USER, "nickname");
userRepository.save(user); // 유저 먼저 저장
Todo todo = new Todo("제목", "내용", "Sunny", user);
// when
Todo savedTodo = todoRepository.save(todo);
// then
// 1. Todo가 잘 저장되었는지 확인
assertNotNull(savedTodo.getId());
// 2. Cascade에 의해 Manager가 자동으로 저장되었는지 확인
assertEquals(1, savedTodo.getManagers().size());
assertEquals(user.getId(), savedTodo.getManagers().get(0).getUser().getId());
System.out.println("담당자 이름: " + savedTodo.getManagers().get(0).getUser().getNickname());
}
}

문제7. N+1

- `CommentController` 클래스의 `getComments()` API를 호출할 때 N+1 문제가 발생하고 있어요. N+1 문제란, 데이터베이스 쿼리 성능 저하를 일으키는 대표적인 문제 중 하나로, 특히 연관된 엔티티를 조회할 때 발생해요.
- 해당 문제가 발생하지 않도록 코드를 수정해주세요.
- N+1 로그

해결방법
현재 @Query의 JOIN은 일반적인 Inner Join이다. 일반 Join은 SQL상에서는 조인을 수행하지만, JPA 관점에서는 연관된 User 엔티티를 메모리에 미리 올려두지(Eager Loading) 않는다. 그래서 루프를 돌 때 여전히 select 쿼리가 나가는 것이다
일반 Join: 유저 정보를 가진 댓글만 찾아달라는 필터링 역할만 하고, 정작 Comment 객체 안의 User 필드는 비어있는(Proxy) 상태이다.
Fetch Join: 유저 정보까지 싹 다 실어서 한 방에 가져와 달라고 명령하는 것이다. 이렇게 하면 루프를 돌며 comment.getUser().getNickname()을 호출해도 이미 메모리에 데이터가 다 들어있어서 추가 쿼리가 나가지 않는다.

결과

문제8. QueryDSL
TodoService.getTodo 메소드

- JPQL로 작성된 `findByIdWithUser` 를 QueryDSL로 변경합니다.
- 7번과 마찬가지로 N+1 문제가 발생하지 않도록 유의해 주세요!
해결방법
1.의존성 주입

2.QueryDSL 설정 클래스 만들기 (Bean 등록)->Q클래스 생성확인하

3.TodoRepositoryImpl,TodoRepositoryCustom 추가 TodoRepository 수정



결과


문제9. Spring Security
Spring Security를 도입하기로 결정했어요!
- 기존 `Filter`와 `Argument Resolver`를 사용하던 코드들을 Spring Security로 변경해주세요.
- 접근 권한 및 유저 권한 기능은 그대로 유지해주세요.
- 권한은 Spring Security의 기능을 사용해주세요.
- 토큰 기반 인증 방식은 유지할 거예요. JWT는 그대로 사용해주세요.
해결방법
1.의존성 주입
implementation 'org.springframework.boot:spring-boot-starter-security'
2. 인증 객체의 표준화 (UserDetails & UserDetailsService)
기존에는 AuthUser DTO를 직접 넘겼지만, 이제 스프링 시큐리티의 표준을 따릅니다.
UserDetailsImpl: 서비스의 User 엔티티를 시큐리티가 이해할 수 있는 객체로 감싸는 래퍼(Wrapper) 역할을 한다
public class UserDetailsImpl implements UserDetails {
private final User user;
public UserDetailsImpl(User user) {
this.user = user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority(user.getUserRole().name()));
}
public User getUser() { return user; }
@Override public String getPassword() { return user.getPassword(); }
@Override public String getUsername() { return user.getEmail(); }
public AuthUser toAuthUser() {
return new AuthUser(user.getId(), user.getEmail(), user.getUserRole(), user.getNickname());
}
}
UserDetailsServiceImpl: DB에서 유저를 찾아 인증 객체로 변환해주는 다리 역할을 수행
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("해당 이메일을 찾을 수 없습니다: " + email));
return new UserDetailsImpl(user);
}
}
3. 필터 체인의 현대화 (OncePerRequestFilter)
기존의 일반 Filter를 시큐리티 전용 필터로 교체
JwtSecurityFilter: 모든 요청에서 JWT를 추출해 검증하고, 성공 시 SecurityContextHolder에 인증 정보를 채워 넣는다.
유연한 예외 처리: 회원가입/로그인처럼 토큰이 없는 요청이 입구에서 막히지 않도록 substringToken에서 예외 대신 null을 반환하게 하여 permitAll 설정을 활성화
@RequiredArgsConstructor
public class JwtSecurityFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String token = jwtUtil.substringToken(request.getHeader("Authorization"));
if (token != null) {
if (jwtUtil.validateToken(token)) {
Claims claims = jwtUtil.extractClaims(token);
String email = claims.get("email", String.class);
UserDetails userDetails = userDetailsService.loadUserByUsername(email);
Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
filterChain.doFilter(request, response);
}
}
4. 컨트롤러 주입 방식 변경 (@AuthenticationPrincipal)
ArgumentResolver를 수동으로 등록하던 번거로움을 제거
컨트롤러 파라미터에서 @AuthenticationPrincipal UserDetailsImpl userDetails를 사용하여 세션/토큰 정보를 즉시 꺼내 사용

서비스 레이어에 넘길 때는 필요에 따라 userDetails.getUser().getId()(long)나 userDetails.toAuthUser()(DTO)를 선택해서 넘긴다
5. 보안 설정 중앙 집중화 (SecurityConfig)
흩어져 있던 보안 로직을 한곳에서 관리합니다.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // @Secured, @PreAuthorize 사용 가능
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtUtil jwtUtil;
private final UserDetailsService userDetailsService;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable) // JWT를 쓰므로 CSRF는 비활성화
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 미사용
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll() // 로그인, 회원가입은 허용
.requestMatchers("/admin/**").hasAuthority("ADMIN") // 권한 유지 관리자 전용
.anyRequest().authenticated() // 나머지는 인증 필요
)
.addFilterBefore(new JwtSecurityFilter(jwtUtil, userDetailsService), UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public PasswordEncoder bcryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
CSRF/Session: Stateless한 JWT 방식에 맞춰 비활성화.
화이트리스트: /auth/**는 누구나 접근 가능하게 설정.
권한 제어: .hasAuthority("ADMIN") 등을 통해 경로별 권한을 명시적으로 제어.
결과



'spring_2기[본캠프] > 과제' 카테고리의 다른 글
| [과제] Spring 플러스 프로젝트 Day 2 (1) | 2026.03.06 |
|---|---|
| [과제] Spring 플러스 프로젝트 Day 1 (0) | 2026.03.05 |
| [플러스] 챕터1 QueryDSL (0) | 2026.02.25 |
| [과제] Standard Spring Task 5 (0) | 2026.02.24 |
| [과제] 결제 시스템 Day9 (0) | 2026.02.20 |