SPARTA 과제/SPRING

Plus spring 필수 과제

kjw81024 2026. 4. 3. 00:41

Level 0

1. 마일스톤 작성

0일차 (3월 31일) 1일차 (4월1일) 2일차 (4월 2일) 3일차 (4월 3일) 4일차 (4월 6일)
ch1 ~ ch5 강의 수강 강의 TIL 로 정리 필수 LV 1 필수 LV 2,
도전 10, 11
도전 12, 13

 

2. fork - clone 

 

3. yml 파일 생성

jwt secret key 가 없다그래서 yml 파일 생성 

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/nbcam
    username: root
    password: 12345678
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    show-sql: true
    hibernate:
      ddl-auto: create-drop
    properties:
      hibernate:
        format_sql: true
    defer-datasource-initialization: true

## ???? ??? ???
jwt:
  secret:
    key: thisistako220410iamhappytolivewithyou220410mycutesleepycat220410

 Level 1

todos 호출 오류 해결

Caused by: java.sql.SQLException: Connection is read-only. Queries leading to data modification are not allowed

POST /todos 요청은 게시글을 생성하는 요청이고, DB 에 써야하는데 

service 단에서 readOnly = true 로 설정되어있다

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true) # service 단에서 readOnly 를 붙여줘야 안전
public class TodoService {

    private final TodoRepository todoRepository;
    private final WeatherClient weatherClient;

    @Transactional # 여기 수정!
    public TodoSaveResponse saveTodo(AuthUser authUser, TodoSaveRequest todoSaveRequest) {
		# 생략
    }

Level 2

jwt 이해 

1. userEntity 에 String nickname 추가 

생성자를 사용하고 있는 곳을 수정 

 

2. jwt token 을 생성하고 있는 곳에서 추가 claim 으로 nickname 을 넣어줬다

이제 파싱할때 getClaims 로 nickname 을 사용할 수 있음 

public String createToken(Long userId, String email, UserRole userRole, String nickname) {
    Date date = new Date();

    return BEARER_PREFIX +
            Jwts.builder()
                    .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) // 암호화 알고리즘
                    .compact();
}

Level 3

JPQL 로 구현...

weather 조건으로 검색할 수 있도록 함

Weather 먼저 해봤다

수정일을 String 으로 받으니까 비교가 안 됨 

-> 그냥 LocalDateTime 으로 받아오기 

@Query("SELECT t " +
        "FROM Todo t LEFT JOIN FETCH t.user u " +
        "WHERE (:weather IS NULL OR t.weather = :weather) " +
        "AND (:startModified IS NULL OR t.modifiedAt >= :startModified) " +
        "AND (:endModified IS NULL OR t.modifiedAt <= :endModified) " +
        "ORDER BY t.modifiedAt DESC")
Page<Todo> findAllByOrderByModifiedAtDesc(
        @Param("weather") String weather,
        @Param("startModified") LocalDateTime startModified,
        @Param("endModified") LocalDateTime endModified,
        Pageable pageable);

처음에 작성했을 때 ( ) 괄호를 쳐주지 않아서 계속 postman 에서 인식을 제대로 못 했다

 

AND 보다 OR 이 먼저 계산되므로 아래처럼 코드를 작성한다면 주석처럼 해석됨

A or B and C or D and E or F

# -> A or (B and C) or (D and E) or F

꼭 괄호를 적절히 작성해서 원하는 결과값이 나올 수 있도록 하기

 

추가로 query 문에서 Order By 쓰면 충돌이 날 수 있다고 했으니까 Order By 부분은 Pageable 안으로 넣어줬다

어차피 사용하는 method 가 findAllByOrderByModifiedAtDesc 였으니까!

Pageable pageable = PageRequest.of(page - 1, size, Sort.by("modifiedAt").descending());


Level 4

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"));

실패 코드인데 status 를 isOk 로 주고있다 

코드 실패 화면에서도 확인 가능

mockMvc.perform(get("/todos/{todoId}", todoId))
        .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"));

NotFound 404 를 내리면 더 좋았겠지만 일단 실제로 나와야하는 값이 400이므로 BAD_REQUEST 내리기 


Level 5

수정 전 AOP 코드 : UserController 의 getUser 메서드를 실행한 후 log를 남기는 AOP 

수정 후 AOP 목표 :UserAdminController 클래스의 changeUserRole 메서드 실행 전 log를 남기도록 함

 

@Before("execution(* org.example.expert.domain.user.controller.UserAdminController.changeUserRole(..))")

실행 위치와 시점을 수정

 

접근해서 제대로 log 가 찍히는지 확인해보기 

2026-04-03T00:06:44.147+09:00  INFO 13868 --- [nio-8080-exec-4] o.e.expert.aop.AdminAccessLoggingAspect  : Admin Access Log - User ID: 2, Request Time: 2026-04-03T00:06:44.147186600, Request URL: /admin/users/1, Method: changeUserRole

원하는 로그가 잘 찍힌다 

설정해둔 changeUserRole method 가 아니면 log가 찍히지 않는 것도 확인


Level 6

사실 cascade 가 삭제될때만 사용이 되는건줄 알았는데

부모 엔티티의 상태가 어떤 변화든 생긴다면 자식 엔티티도 영향을 받게하는 내용이었다 (ㄷㄷ)

할 일을 새로 저장할 시, 할 일을 생성한 유저는 담당자로 자동 등록되도록 설정 

manager 와 todo 의 관계를 보면 todo 쪽이 부모, manager 쪽이 자식 

@OneToMany(mappedBy = "todo", cascade = CascadeType.PERSIST)
private List<Manager> managers = new ArrayList<>();

 

코드를 수정해줬다 

 

manager 테이블에 아까까지 만들었던 todo 10개는 manager 가 없고, 새로 만든거에만 추가된 것을 확인


Level 7

N+1 문제 해결 

Todo 테이블의 Comment 를 조회하려고 하면, 해당 comment 를 작성한 user 의 정보까지 모두 조회하게 되는 문제

(튜터님이 N+1 이라고 읽지말고 1+N 이라고 이해하면 쉽다고 해주셨는데 진짜 머릿속에 잘 들어옴!!! 하하)

 

해결법... FETCH 를 붙였다

@Query("SELECT c FROM Comment c JOIN FETCH c.user WHERE c.todo.id = :todoId")
List<Comment> findByTodoIdWithUser(@Param("todoId") Long todoId);

ㅋㅋㅋㅋ 이게 맞나....


Level 8

JPQL -> Query DSL 로 수정하기 

일단 build.gradle 에 querydsl 을 추가

implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'

 

CustomRepository 에 method 를 생성해주고 Impl (구현체) 에 가서 메서드를 구현해준다

public interface UserCustomRepository {
    Todo findByIdWithUser(Long userId); # 메서드 명은 일단 기존 TodoRepository 와 동일
}

Todo entity 에서 userId 만 가질 수 있게 수정해줄까 고민을 좀 했는데 그럼 애초에 findByIdWithUser << 이라는걸 구현을 할 수 가 없는듯하다 (Todo 와 해당 Todo 를 작성한 User를 같이 가져오는게 목표)

# 전

@Query("SELECT t FROM Todo t " +
        "LEFT JOIN t.user " +
        "WHERE t.id = :todoId")
Optional<Todo> findByIdWithUser(@Param("todoId") Long todoId);

# 후

@Override
public Optional<Todo> findByIdWithUser(Long todoId) {

    log.info("Query DSL 실행됨");
    return Optional.ofNullable(
            factory.selectFrom(todo)
            .leftJoin(todo.user, user).fetchJoin()
            .where(todo.id.eq(todoId))
            .fetchOne());
}

 

그래도 작성해본 manyToOne 삭제 시 동작 형식 

userId 만 가지게 된다면 queryDSL 에서 fetch join 을 해줄 필요가 없음 (애초에 jfetch join 이 JPA 연관관계 최적화용)

left join 을 사용해서 찾아주기 

return Optional.ofNullable(
    factory.selectFrom(todo)
        .leftJoin(user).on(todo.userId.eq(user.id))
        .where(todo.id.eq(todoId))
        .fetchOne()
);

Level 9 

 

기존 코드 분석

 

JwtUtil

- id, email, userRole, nickname 을 claim 으로 jwt token 에 넣어주기 

- token 파싱, claims 파싱 메서드 

 

AuthUserArgumentResolver

- @Auth 어노테이션과 AuthUser 타입이 함께 사용되지 않았으면 예외를 발생

- attribute 값 가져와서 -> AuthUser 만들기 

 

JwtFilter 

- 접근중인 url 이 /auth (로그인, 회원가입) 인 경우 pass

- Jwt token == null -> pass 

- 유효성 검사, token 에서 요소를 빼서 httpRequest 로 넣기

 

FilterConfig

- jwtFilter 를 등록하기 

- 모든 요청이 filter 를 타게 되므로 jwtFilter 에서 /auth 인지 확인 

사실 맞는지 잘 모르겠다....


일단 바꿔야 하는 부분 정리

 

1. Filter Config 에 있는 내용을 SecurityConfig 로 옮기고 SecurityFilterChain 에서 해당 역할을 수행하도록 수정

2. token 이 null 이면 일단 filter 를 통과할 수 있도록

3. request 에 setAttribute 로 값을 넣는게 아니라 SecurityContext 에 넣을 수 있도록 하기 

4. webConfig, AuthuserArgumentResolver,  삭제 

 

 

접근 권한 설정

auth 는 permitAll, admin 은 Role 제한 

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    private final JwtFilter jwtFilter;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{

        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(authorize -> {
                authorize
                    .requestMatchers("/auth/**").permitAll()
                    .requestMatchers("/admin/**").hasRole("ADMIN")
                    .anyRequest().authenticated();
        })
                .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}
@Slf4j
@RequiredArgsConstructor
@Component
public class JwtFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain chain
    ) throws ServletException, IOException {

        String bearerJwt = request.getHeader("Authorization");
        if (bearerJwt == null || !bearerJwt.startsWith("Bearer ")) {
            # 토큰이 없는 경우 그냥 통과
            chain.doFilter(request, response);
            return;
        }
        String jwt = jwtUtil.substringToken(bearerJwt);

        try {
            Authentication authentication = jwtUtil.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);

            chain.doFilter(request, response);
        } catch # 생략 
    }
}

jwt util 에 token 에서 AuthenticationToken 을 만들어서 주는 로직 추가

public UsernamePasswordAuthenticationToken getAuthentication(String jwt) {
    Claims claims = extractClaims(jwt);

    Long userId = claims.get("id", Long.class);
    String email = claims.get("email", String.class);
    UserRole role = UserRole.valueOf(claims.get("userRole", String.class));
    String nickname = claims.get("nickname", String.class);

    AuthUser user = new AuthUser(userId, email, role, nickname);

    return new UsernamePasswordAuthenticationToken(
            user, null, List.of(new SimpleGrantedAuthority("ROLE_"+role))
    );
}

/admin 이 붙었다면 USER 가 접근했을 때 403

 

ADMIN 으로 로그인 한 후 다시 접근하면 200 OK!

-> Role Based 구현 완료


뿌듯한 git flow 

'SPARTA 과제 > SPRING' 카테고리의 다른 글

JOOQ 코드 생성 트러블슈팅  (1) 2026.04.13
Plus spring 도전과제  (1) 2026.04.06
심화) LV 7 Test code  (1) 2026.03.07
심화) LV 6 코드 리팩토링  (0) 2026.03.07
심화) LV 5 API 로깅  (0) 2026.03.04