본문 바로가기

spring

Jlaner 개발기록 4 기능 구성

이번 포스팅에서는 저번 포스팅 마지막에 보았던 api를 사용해 인증을 하고 인증을 통해 기능을 수행한 것을 확인할 수 있었고 api를 구성해 기능을 채워보도록 하겠다.

 

저번 로그인 구성과 같은 흐름으로 진행될 것 같다.

구성하며 잘 되지 않는 부분 틀린 부분이 있겠지만 어떤 부분이 잘못되었고 어떤 생각으로 구성했고 어떻게 해결하는지에 대해서 기록하고 차차 나아가기 위함으로 봐주었음 한다.

 

구성해야 할 컨트롤러는 총 3가지이다.

post 데이터를 저장하는 컨트롤러, schedule 데이터를 저장하는 컨트롤러, post와 schedule의 데이터를 가져오는 컨트롤러

이렇게 3가지가 필요하고 가장 먼저 post 데이터를 저장하는 컨트롤러를 구성해 보고 이를 토대로 나머지도 구성하도록 하겠다.

우선 데이터를 저장할 Entity, Service, Repository, Dto를 만들어주도록 하자. 만들면서 Entity, Service, Repository는 post와 schedule을 같이 구성하도록 하겠다.

 

Entity

package com.jlaner.project.domain;

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.Date;

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Post {

    @Id
    @GeneratedValue
    @Column(name = "post_id")
    private Long id;
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    private String contentData;
    private Date scheduleDate;

    public Post(Member member, String contentData, Date scheduleDate) {
        this.member = member;
        this.contentData = contentData;
        this.scheduleDate = scheduleDate;
    }
}
package com.jlaner.project.domain;

import jakarta.persistence.*;
import lombok.*;

import java.util.ArrayList;
import java.util.List;

@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Setter
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long id;
    private String loginId;
    private String name;

    private String email;

    @OneToMany(mappedBy = "member")
    @Builder.Default
    private List<ScheduleData> scheduleDataList = new ArrayList<>();
    @OneToMany(mappedBy = "member")
    @Builder.Default
    private List<Post> postList = new ArrayList<>();


    @Enumerated(EnumType.STRING)
    private MemberRole role;

    private String provider;
    private String providerId;

    public Member(String loginId, String name, String email, MemberRole role) {
        this.loginId = loginId;
        this.name = name;
        this.email = email;
        this.role = role;
    }
}

 

Service

package com.jlaner.project.service;

import com.jlaner.project.domain.ScheduleData;
import com.jlaner.project.repository.ScheduleRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
@Slf4j
public class ScheduleService {

    private final ScheduleRepository scheduleRepository;

    public void saveScheduleData(ScheduleData scheduleData) {
        scheduleRepository.save(scheduleData);
    }
}
package com.jlaner.project.service;

import com.jlaner.project.domain.Member;
import com.jlaner.project.domain.Post;
import com.jlaner.project.dto.PostDto;
import com.jlaner.project.repository.PostRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Service
@Slf4j
@RequiredArgsConstructor
public class PostService {

    private final PostRepository postRepository;

    public void savePost(PostDto postDto, Member member) {
        Post post = new Post(member, postDto.getContentData(), postDto.getScheduleDate());
        postRepository.save(post);
    }
    public Post findByMemberId(Long memberId) {
        return postRepository.findByMemberId(memberId);
    }
}

 

필요한 부분만 먼저 구성했고 추후 필요하다면 메서드가 늘어날 것이다.

 

Repository

package com.jlaner.project.repository;

import com.jlaner.project.domain.Post;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
    Post findByMemberId(Long memberId);
}
package com.jlaner.project.repository;

import com.jlaner.project.domain.ScheduleData;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface ScheduleRepository extends JpaRepository<ScheduleData, Long> {
}

 

repository는 jpa를 통해 사용하도록 했다.

서로 다른 사용자 데이터를 사용자 데이터 기반 정보로 저장하고 불러오기 때문에 관계가 중요하니 mysql을 사용하기에 jpa를 사용했다.

 

데이터를 주고 받는 것에 원본 객체를 사용하면 필드를 드러내게 되는 것이기 때문에 dto를 사용하기로 하겠다.

package com.jlaner.project.dto;

import com.jlaner.project.domain.Member;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.Date;


@Data
@AllArgsConstructor
@NoArgsConstructor
public class PostDto {

    private Member member;
    private String contentData;
    private Date scheduleDate;
}

 

schedule은 dto를 추후에 구성하도록 하겠다.

 

이제 controller를 구성해 보도록 하자.

package com.jlaner.project.controller;

import com.jlaner.project.config.jwt.TokenProvider;
import com.jlaner.project.domain.Member;
import com.jlaner.project.domain.Post;
import com.jlaner.project.domain.RefreshToken;
import com.jlaner.project.domain.ScheduleData;
import com.jlaner.project.dto.PostDto;
import com.jlaner.project.service.MemberService;
import com.jlaner.project.service.PostService;
import com.jlaner.project.service.RefreshTokenRedisService;
import com.jlaner.project.util.CookieUtil;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.time.Duration;

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
@Slf4j
public class JlanerController {

    private final RefreshTokenRedisService refreshTokenRedisService;
    private final TokenProvider tokenProvider;
    private final MemberService memberService;
    private final PostService postService;


    @PostMapping("/jlaner/post/data")
    public ResponseEntity<?> savePostData(@RequestHeader("Authorization") String jwtToken,
                                          @RequestBody PostDto postDto,
                                          HttpServletRequest request,
                                          HttpServletResponse response
    ) {
        try {
            String token = jwtToken.substring(7);

            if (!checkAccessToken(token, request, response)) {
                return ResponseEntity.status(401).build();
            }
            log.info("PostData를 저장하는 AccessToken = {}", token);
            Long memberId = tokenProvider.getMemberId(token);
            Member findMember = memberService.findByMemberId(memberId);

            postService.savePost(postDto, findMember);
            log.info("데이터가 저장되었습니다.{}", postDto.getContentData());
            return ResponseEntity.status(200).build();
        } catch (Exception e) {
            log.error("post data 저장중 오류 발생 ", e);
        }
        return ResponseEntity.status(500).build();
    }

    /**
     * 토큰 유효성을 검사하는 메서드 만약 액세스 토큰이 유효하지 않다면 새로운 액세스 토큰을 발급해준다.
     * @param request
     * @param token
     * @param response
     * @return true라면 정상 흐름 false라면 로직에서 401을 반환하게 한다.
     */
    private boolean checkAccessToken( String token, HttpServletRequest request, HttpServletResponse response) {
        Long memberId;
        String newAccessToken = null;
        if (tokenProvider.isTokenExpired(token)) {
            log.info("토큰이 만료되었습니다. 리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급합니다.");
            String refreshTokenValue = CookieUtil.getCookie(request, "refresh_token")
                    .map(Cookie::getValue)
                    .orElse(null);

            if (refreshTokenValue != null) {
                RefreshToken refreshToken = refreshTokenRedisService.findByRefreshToken(refreshTokenValue);
                if (refreshToken != null) {
                    memberId = refreshToken.getMemberId();
                    Member findByMember = memberService.findByMemberId(memberId);

                    newAccessToken = tokenProvider.generateToken(findByMember, Duration.ofDays(1));
                    //새로운 액세스 토큰을 발급해야 하기 때문에 redis에 저장된 토큰 정보의 AccessToken를 업데이트
                    RefreshToken updateToken = refreshToken.accessTokenUpdate(newAccessToken);
                    refreshTokenRedisService.saveToken(updateToken);

                    response.setHeader("Authorization", "Bearer " + newAccessToken);
                    log.info("새로운 액세스 토큰 발급={}", newAccessToken);

                    return true;
                } else {
                    log.error("유효하지 않은 리프레시 토큰입니다.");
                    return false;
                }
            } else {
                log.error("리프레시 토큰이 존재하지 않습니다.");
                return false;
            }
        }
        return true;
    }

}

 

특이사항은 메서드로 따로 추출한 토큰을 검사하는 로직이다. 저번 시큐리티에서 요청 시에 헤더를 넘기지 못했다.

그러므로 헤더를 추출하고 검사하는 로직을 추가했다.

토큰을 검사하고 토큰의 사용자 정보를 추출해 사용자 정보를 토대로 전달 받은 데이터를 저장한다.

예외 처리에 대한 부분은 아직 조금 더 고민중에 있기에 컨트롤러 구성을 다하고 더 생각해서 진행하도록 하겠다.

 

html과 스크립트도 손봐야 하지만 인증 부분과 기능이 작동하는지 테스트해보자.

POST 요청과 데이터를 저장하는 것과 보안 필터가 작동을 잘하는지 테스트하기 위한 테스트이다.

package com.jlaner.project.controller;

import com.jlaner.project.config.jwt.TokenProvider;
import com.jlaner.project.domain.Member;
import com.jlaner.project.domain.Post;
import com.jlaner.project.dto.PostDto;
import com.jlaner.project.service.MemberService;
import com.jlaner.project.service.PostService;
import com.jlaner.project.service.RefreshTokenRedisService;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.MockitoAnnotations;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.security.core.Authentication;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import java.text.SimpleDateFormat;
import java.util.Date;

import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@Slf4j
public class JlanerControllerTest {
    @Autowired
    private WebApplicationContext webApplicationContext;

    private MockMvc mockMvc;

    @MockBean
    private TokenProvider tokenProvider;


    @MockBean
    private PostService postService;

    @MockBean
    private MemberService memberService;

    @BeforeEach
    void setUp() {
        MockitoAnnotations.openMocks(this);
        mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
                .addFilter((FilterChainProxy) webApplicationContext.getBean("springSecurityFilterChain"))
                .build();

    }

    @DisplayName("post 데이터를 저장하는 컨트롤러의 테스트")
    @Test
    @WithMockUser
    void postDataSaveTest() throws Exception {
        //given
        String email = "test@test.com";
        String accessToken = "accessToken";
        String contentData = "테스트 데이터입니다.";
        Date testDate = new Date();

        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");

        Member member = new Member();
        member.setId(1L);
        member.setEmail(email);


        PostDto postDto = new PostDto(member,contentData,testDate);

        //when
        when(tokenProvider.validToken(accessToken)).thenReturn(true);
        when(tokenProvider.getAuthentication(accessToken)).thenReturn(mock(Authentication.class));
        when(memberService.findByMemberId(1L)).thenReturn(member);

        Post mockPost = new Post(member, contentData, testDate);
        when(postService.findByMemberId(1L)).thenReturn(mockPost);

        mockMvc.perform(post("/api/jlaner/post/data")
                .header("Authorization", "Bearer " + accessToken)
                                .contentType("application/json")
                                .content("{\"contentData\":\"테스트 데이터입니다.\", \"scheduleDate\":\"2024-08-15\"}")  // JSON 데이터
                                .with(csrf()))
                .andExpect(status().is3xxRedirection());

        Post findPost = postService.findByMemberId(member.getId());


        assertThat(findPost.getContentData()).isEqualTo(postDto.getContentData());
        assertThat(findPost.getScheduleDate()).isEqualTo(postDto.getScheduleDate());


    }
}

 

데이터를 저장하는 엔드 포인트의 테스트이므로 사용자와 토큰을 구성해 주어서 인증을 진행하게 하고 POST 요청으로 방금 구성한 controller 엔드포인트로 요청을 해서 알맞게 데이터를 저장했는지 검사하기 위해 저장하고 assertThat으로 데이터를 꺼내 검증했다.

 

데이터도 알맞게 저장되는 것을 확인했으니 다음에는 home.html과 스크립트를 바꿔야 한다.

<div class="textarea-container">
    <form id="post-form">
        <input type="hidden" id="post-date" name="scheduleDate">
        <textarea th:field="*{contentData}" id="shared-textarea" placeholder="내용을 입력해 주세요."></textarea>
        <button type="submit" class="save-button">Save</button>
    </form>
        <div class="button-container">
            <button id="toggle-youtube-button" onclick="toggleYouTubeContainer()">Toggle YouTube</button>
        </div>
</div>

 

function confirmDate() {
        const selectedDate = document.getElementById('schedule-date').value;
        document.getElementById('post-date').value = selectedDate;
        alert("날짜가 설정되었습니다: " + selectedDate);
}
document.getElementById('post-form').addEventListener('submit', function(event) {
        event.preventDefault();  // 기본 폼 제출 방지
        savePostData();  // savePostData 함수 호출
    });

async function savePostData(){
    const contentData = document.getElementById('shared-textarea').value;
    const scheduleData = document.getElementById('post-date').value;
    const token = getAccessToken();

    const PostData = {
        contentData: contentData,
        scheduleData: scheduleData
    };

    const response = await fetch('/api/jlaner/post/data', {
        method: 'POST',
         headers: {
                    'Content-Type': 'application/json',
                    'Authorization': `Bearer ${token}`
                },
        body: JSON.stringify(PostData)
    });
    if(response.ok){
        alert("저장되었습니다.");
    } else{
        alert("저장에 실패했습니다.");
    }

}

 

날짜를 설정하고 데이터를 입력한 후 입력한 데이터를 저장하기 위해 /api/jlaner/post/data 엔드 포인트로 이동할 수 있게끔 구성했다.

실제로 동작을 확인해 보자.

 

2024-08-10T20:47:16.950+09:00  INFO 5479 --- [project] [nio-8080-exec-9] c.j.p.c.jwt.TokenAuthenticationFilter    : Incoming request: URI = /api/jlaner/post/data, Method = POST
2024-08-10T20:47:16.951+09:00  INFO 5479 --- [project] [nio-8080-exec-9] c.j.p.c.jwt.TokenAuthenticationFilter    : request=org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterRequest@4046b4e
2024-08-10T20:47:16.951+09:00  INFO 5479 --- [project] [nio-8080-exec-9] c.j.p.c.jwt.TokenAuthenticationFilter    : requestURI=/api/jlaner/post/data
2024-08-10T20:47:16.951+09:00  INFO 5479 --- [project] [nio-8080-exec-9] c.j.p.c.jwt.TokenAuthenticationFilter    : authorizationHeader =Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzIzMjkwNDIxLCJleHAiOjE3MjMzNzY4MjEsInN1YiI6Iu2VnOyngO2biCIsImlkIjoxfQ.CloEHIHt9QmAz-HL1nsoHzNigDS9vCTtlWd_OXduhKg
2024-08-10T20:47:16.951+09:00  INFO 5479 --- [project] [nio-8080-exec-9] c.j.p.c.jwt.TokenAuthenticationFilter    : token = eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzIzMjkwNDIxLCJleHAiOjE3MjMzNzY4MjEsInN1YiI6Iu2VnOyngO2biCIsImlkIjoxfQ.CloEHIHt9QmAz-HL1nsoHzNigDS9vCTtlWd_OXduhKg
2024-08-10T20:47:16.960+09:00  INFO 5479 --- [project] [nio-8080-exec-9] c.j.p.c.jwt.TokenAuthenticationFilter    : 인증 성공
2024-08-10T20:47:16.966+09:00  INFO 5479 --- [project] [nio-8080-exec-9] c.j.project.config.jwt.TokenProvider     : tokenProvider
2024-08-10T20:47:17.049+09:00  INFO 5479 --- [project] [nio-8080-exec-9] c.j.p.logTrace.ThreadLocalLogTrace       : [838ecb25] ResponseEntity com.jlaner.project.controller.JlanerController.savePostData(String,PostDto,HttpServletRequest,HttpServletResponse)
2024-08-10T20:47:17.052+09:00  INFO 5479 --- [project] [nio-8080-exec-9] c.j.project.config.jwt.TokenProvider     : tokenProvider
2024-08-10T20:47:17.053+09:00  INFO 5479 --- [project] [nio-8080-exec-9] c.j.project.controller.JlanerController  : PostData를 저장하는 AccessToken = eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzIzMjkwNDIxLCJleHAiOjE3MjMzNzY4MjEsInN1YiI6Iu2VnOyngO2biCIsImlkIjoxfQ.CloEHIHt9QmAz-HL1nsoHzNigDS9vCTtlWd_OXduhKg
2024-08-10T20:47:17.056+09:00  INFO 5479 --- [project] [nio-8080-exec-9] c.j.p.logTrace.ThreadLocalLogTrace       : [838ecb25] |-->Member com.jlaner.project.service.MemberService.findByMemberId(Long)
Hibernate: 
    select
        m1_0.member_id,
        m1_0.email,
        m1_0.login_id,
        m1_0.name,
        m1_0.provider,
        m1_0.provider_id,
        m1_0.role 
    from
        member m1_0 
    where
        m1_0.member_id=?
2024-08-10T20:47:17.060+09:00  INFO 5479 --- [project] [nio-8080-exec-9] c.j.p.logTrace.ThreadLocalLogTrace       : [838ecb25] |<--Member com.jlaner.project.service.MemberService.findByMemberId(Long) time=4ms
2024-08-10T20:47:17.063+09:00  INFO 5479 --- [project] [nio-8080-exec-9] c.j.p.logTrace.ThreadLocalLogTrace       : [838ecb25] |-->void com.jlaner.project.service.PostService.savePost(PostDto,Member)
Hibernate: 
    select
        next_val as id_val 
    from
        post_seq for update
Hibernate: 
    update
        post_seq 
    set
        next_val= ? 
    where
        next_val=?
Hibernate: 
    insert 
    into
        post
        (content_data, member_id, schedule_date, post_id) 
    values
        (?, ?, ?, ?)
2024-08-10T20:47:17.088+09:00  INFO 5479 --- [project] [nio-8080-exec-9] c.j.p.logTrace.ThreadLocalLogTrace       : [838ecb25] |<--void com.jlaner.project.service.PostService.savePost(PostDto,Member) time=25ms
2024-08-10T20:47:17.089+09:00  INFO 5479 --- [project] [nio-8080-exec-9] c.j.project.controller.JlanerController  : 데이터가 저장되었습니다.test content
2024-08-10T20:47:17.089+09:00  INFO 5479 --- [project] [nio-8080-exec-9] c.j.p.logTrace.ThreadLocalLogTrace       : [838ecb25] ResponseEntity com.jlaner.project.controller.JlanerController.savePostData(String,PostDto,HttpServletRequest,HttpServletResponse) time=40ms

 

실제 동작에서 확인해 보았다.

의도대로 동작하는 것을 확인할 수 있다.

다음으로는 mysql에 데이터가 저장된 것을 확인했다. 날짜가 저장되어야 할 scheduledate가 null이었다.

테스트에서는 데이트가 저장되었으니 html이나 스크립트에 문제가 있는 것인데 이를 다시 둘러보도록 하자.

 

function confirmDate() {
        const selectedDate = document.getElementById('schedule-date').value;
        document.getElementById('post-date').value = selectedDate;
        alert("날짜가 설정되었습니다: " + selectedDate);
}

document.getElementById('post-form').addEventListener('submit', function(event) {
        event.preventDefault();  // 기본 폼 제출 방지
        savePostData();  // savePostData 함수 호출
    });

async function savePostData(){
    const contentData = document.getElementById('shared-textarea').value;
    const scheduleDate = document.getElementById('post-date').value;
    const token = getAccessToken();

    const PostData = {
        contentData: contentData,
        scheduleDate: scheduleDate
    };

 

이는 DTO에서 scheduleDate로 받아야 할 것이 Data로 받고 있었다.

이를 수정하고 실행해 보자.

 

데이터는 잘 들어오지만 아직 완전하지 못하다. 만약 저장할 데이터가 같은 날짜에 데이터가 있다면 업데이트하도록 구성해야 한다.

우선 저장되는 날짜 데이터가 시간까지는 필요하지 않다 그러므로 아래와 같이 포맷을 지정하도록 하겠다.

package com.jlaner.project.dto;

import com.jlaner.project.domain.Member;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;

import java.util.Date;


@Data
@AllArgsConstructor
@NoArgsConstructor
public class PostDto {

    private Member member;
    private String contentData;
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private Date scheduleDate;
}

 

포맷 지정 후 잘못 생각한 점이 있다. 지금 데이터를 저장하는 부분만 생각하고 개발하다 보니 만약 해당하는 날짜에 데이터가 있을 때 데이터를 추가적으로 같은 날짜의 데이터가 추가된 문제가 있다.

이 문제를 해결해 보자.

 

해결하기 앞서 어떻게 해결해야 하는가 어느 곳이 문제인가를 생각해보아야 한다.

위에 컨트롤러 로직을 확인해 보면 save 하기만 하지 데이터가 있을 경우를 고려하지 않는 부분이 문제이다. 이 부분을 고쳐보자.

시나리오는 클라이언트에서 전달받은 PostDto에 날짜와 액세스 토큰의 member를 꺼내서 join을 사용해 쿼리를 날려 해당하는 포스트를 가져오고 만약 해당하는 포스트가 있다면 영속성 컨텍스트의 변경 감지를 통해서 update 해주고 가져올 수 있는 데이터가 null이라면 데이터를 새로 저장하게끔 구성하려 한다.

mvc패턴을 다시 손보자.

@Query("SELECT p FROM Post p WHERE p.scheduleDate = :scheduleDate AND p.member.id = :memberId")
Optional<Post> findByScheduleDateAndMemberId(@Param("scheduleDate") Date scheduleDate, @Param("memberId") Long memberId);

 

해당 쿼리를 실행하게끔 만들었다. 필자는 querydsl이 편하고 더 잘 사용할 수 있지만 이번 프로젝트에서는 쿼리를 직접 짜본 게 jpa 배울 때 말고는 사용해 본 적이 없어 많은 쿼리를 사용하진 않을 프로젝트지만 사용해서 진행해 보도록 하겠다.

 

다음은 서비스 로직을 보자.

package com.jlaner.project.service;

import com.jlaner.project.domain.Member;
import com.jlaner.project.domain.Post;
import com.jlaner.project.dto.PostDto;
import com.jlaner.project.repository.PostRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Date;

@Service
@Slf4j
@RequiredArgsConstructor
@Transactional
public class PostService {

    private final PostRepository postRepository;

    public void savePost(PostDto postDto, Member member) {
        Post post = new Post(member, postDto.getContentData(), postDto.getScheduleDate());
        postRepository.save(post);
    }
    @Transactional(readOnly = true)
    public Post findByMemberId(Long memberId) {
        return postRepository.findByMemberId(memberId)
                .orElse(null);
    }
    @Transactional(readOnly = true)
    public Post findByScheduleDate(Date scheduleDate, Long memberId) {
        return postRepository.findByScheduleDateAndMemberId(scheduleDate, memberId)
                .orElse(null);
    }

    public void postDataSaveOrUpdate(PostDto postDto,Member member) {
        Post post = postRepository.findByScheduleDateAndMemberId(postDto.getScheduleDate(), member.getId())
                .orElse(null);
        if (post != null) {
            post.updateContentData(postDto.getContentData());
        } else {
            Post savePost = new Post(member, postDto.getContentData(), postDto.getScheduleDate());
            postRepository.save(savePost);
        }
    }

}

 

이전과 다른 점은 트랜잭션을 설정했고 데이터를 업데이트하거나 저장하기 위해 하나의 트랜잭션 안에 구성해 주어서 service에서 처리하도록 구성했다. 사실 메서드나 다른 서비스 로직을 사용하더라도 전이를 사용하면 된다. 지금은 복잡하지 않기 때문에 하나의 트랜잭션에서 간편하게 사용하도록 하나의 메서드와 트랜잭션에서 처리할 수 있도록 구성했다.

Post 엔티티에서 데이터를 수정할 수 있도록 열어주어야 한다.

package com.jlaner.project.domain;

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.Date;

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Post {

    @Id
    @GeneratedValue
    @Column(name = "post_id")
    private Long id;
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    private String contentData;
    @Column(unique = true)
    private Date scheduleDate;

    public Post(Member member, String contentData, Date scheduleDate) {
        this.member = member;
        this.contentData = contentData;
        this.scheduleDate = scheduleDate;
    }

    public void updateContentData(String newContentData) {
        this.contentData = newContentData;
    }
}

 

updateContentData로 구성했으며 이는 setter의 역할을 하게 되는데 setter는 함부로 열어주면 안 된다. 어디서 사용되는지 실제 저장되고 사용되는 엔티티는 변경을 열어줄 때에는 항상 조심해야 한다. 하지만 이는 필요한 부분이기에 열어주었다.

더 안전하게 변경을 사용하지 않으려면 변경하고 싶은 데이터를 삭제하고 새로운 데이터를 구성해 저장하는 방식이 있지만 명확하게만 사용하게 된다면 효율이 좋은 변경을 사용하는 것이 좋다고 생각하기에 이렇게 구성했다.

 

이제 controller 로직을 수정해 보자.

package com.jlaner.project.controller;

import com.jlaner.project.config.jwt.TokenProvider;
import com.jlaner.project.domain.Member;
import com.jlaner.project.domain.Post;
import com.jlaner.project.domain.RefreshToken;
import com.jlaner.project.domain.ScheduleData;
import com.jlaner.project.dto.PostDto;
import com.jlaner.project.service.MemberService;
import com.jlaner.project.service.PostService;
import com.jlaner.project.service.RefreshTokenRedisService;
import com.jlaner.project.util.CookieUtil;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.time.Duration;

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
@Slf4j
public class JlanerController {

    private final RefreshTokenRedisService refreshTokenRedisService;
    private final TokenProvider tokenProvider;
    private final MemberService memberService;
    private final PostService postService;


    @PostMapping("/jlaner/post/data")
    public ResponseEntity<?> savePostData(@RequestHeader("Authorization") String jwtToken,
                                          @RequestBody PostDto postDto,
                                          HttpServletRequest request,
                                          HttpServletResponse response
    ) {
        try {
            String token = jwtToken.substring(7);

            if (!checkAccessToken(token, request, response)) {
                return ResponseEntity.status(401).build();
            }
            log.info("PostData를 저장하는 AccessToken = {}", token);
            Long memberId = tokenProvider.getMemberId(token);
            Member findMember = memberService.findByMemberId(memberId);

            postService.postDataSaveOrUpdate(postDto,findMember);

            log.info("데이터가 저장되었습니다.{}", postDto.getContentData());
            log.info("date={}", postDto.getScheduleDate());

            return ResponseEntity.status(200).build();
        } catch (Exception e) {
            log.error("post data 저장중 오류 발생 ", e);
        }
        return ResponseEntity.status(500).build();
    }

    /**
     * 토큰 유효성을 검사하는 메서드 만약 액세스 토큰이 유효하지 않다면 새로운 액세스 토큰을 발급해준다.
     * @param request
     * @param token
     * @param response
     * @return true라면 정상 흐름 false라면 로직에서 401을 반환하게 한다.
     */
    private boolean checkAccessToken( String token, HttpServletRequest request, HttpServletResponse response) {
        Long memberId;
        String newAccessToken = null;
        if (tokenProvider.isTokenExpired(token)) {
            log.info("토큰이 만료되었습니다. 리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급합니다.");
            String refreshTokenValue = CookieUtil.getCookie(request, "refresh_token")
                    .map(Cookie::getValue)
                    .orElse(null);

            if (refreshTokenValue != null) {
                RefreshToken refreshToken = refreshTokenRedisService.findByRefreshToken(refreshTokenValue);
                if (refreshToken != null) {
                    memberId = refreshToken.getMemberId();
                    Member findByMember = memberService.findByMemberId(memberId);

                    newAccessToken = tokenProvider.generateToken(findByMember, Duration.ofDays(1));
                    //새로운 액세스 토큰을 발급해야 하기 때문에 redis에 저장된 토큰 정보의 AccessToken를 업데이트
                    RefreshToken updateToken = refreshToken.accessTokenUpdate(newAccessToken);
                    refreshTokenRedisService.saveToken(updateToken);

                    response.setHeader("Authorization", "Bearer " + newAccessToken);
                    log.info("새로운 액세스 토큰 발급={}", newAccessToken);

                    return true;
                } else {
                    log.error("유효하지 않은 리프레시 토큰입니다.");
                    return false;
                }
            } else {
                log.error("리프레시 토큰이 존재하지 않습니다.");
                return false;
            }
        }
        return true;
    }

}

 

데이터를 저장하거나 변경하는 부분을 service에 위임했으니 controller는 단순히 불러오기만 하면 된다 이제 실행해서 확인해 보도록 하겠다.

2024-08-11T02:24:56.093+09:00  INFO 31693 --- [project] [io-8080-exec-10] c.j.p.c.jwt.TokenAuthenticationFilter    : Incoming request: URI = /api/jlaner/post/data, Method = POST
2024-08-11T02:24:56.093+09:00  INFO 31693 --- [project] [io-8080-exec-10] c.j.p.c.jwt.TokenAuthenticationFilter    : request=org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterRequest@5f2fc130
2024-08-11T02:24:56.094+09:00  INFO 31693 --- [project] [io-8080-exec-10] c.j.p.c.jwt.TokenAuthenticationFilter    : requestURI=/api/jlaner/post/data
2024-08-11T02:24:56.095+09:00  INFO 31693 --- [project] [io-8080-exec-10] c.j.p.c.jwt.TokenAuthenticationFilter    : authorizationHeader =Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzIzMzEwNjMyLCJleHAiOjE3MjMzMTA3NTIsInN1YiI6Iu2VnOyngO2biCIsImlkIjoxfQ.0nawPvIp4zaggQU5ITS6k1H3x68-uJx905OvadBEKe4
2024-08-11T02:24:56.095+09:00  INFO 31693 --- [project] [io-8080-exec-10] c.j.p.c.jwt.TokenAuthenticationFilter    : token = eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzIzMzEwNjMyLCJleHAiOjE3MjMzMTA3NTIsInN1YiI6Iu2VnOyngO2biCIsImlkIjoxfQ.0nawPvIp4zaggQU5ITS6k1H3x68-uJx905OvadBEKe4
2024-08-11T02:24:56.104+09:00  INFO 31693 --- [project] [io-8080-exec-10] c.j.p.c.jwt.TokenAuthenticationFilter    : 인증 성공
2024-08-11T02:24:56.108+09:00  INFO 31693 --- [project] [io-8080-exec-10] c.j.project.config.jwt.TokenProvider     : tokenProvider
2024-08-11T02:24:56.114+09:00  INFO 31693 --- [project] [io-8080-exec-10] c.j.p.logTrace.ThreadLocalLogTrace       : [49f43397] ResponseEntity com.jlaner.project.controller.JlanerController.savePostData(String,PostDto,HttpServletRequest,HttpServletResponse)
2024-08-11T02:24:56.121+09:00  INFO 31693 --- [project] [io-8080-exec-10] c.j.project.config.jwt.TokenProvider     : tokenProvider
2024-08-11T02:24:56.121+09:00  INFO 31693 --- [project] [io-8080-exec-10] c.j.project.controller.JlanerController  : PostData를 저장하는 AccessToken = eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzIzMzEwNjMyLCJleHAiOjE3MjMzMTA3NTIsInN1YiI6Iu2VnOyngO2biCIsImlkIjoxfQ.0nawPvIp4zaggQU5ITS6k1H3x68-uJx905OvadBEKe4
2024-08-11T02:24:56.125+09:00  INFO 31693 --- [project] [io-8080-exec-10] c.j.p.logTrace.ThreadLocalLogTrace       : [49f43397] |-->Member com.jlaner.project.service.MemberService.findByMemberId(Long)
Hibernate: 
    select
        m1_0.member_id,
        m1_0.email,
        m1_0.login_id,
        m1_0.name,
        m1_0.provider,
        m1_0.provider_id,
        m1_0.role 
    from
        member m1_0 
    where
        m1_0.member_id=?
2024-08-11T02:24:56.128+09:00  INFO 31693 --- [project] [io-8080-exec-10] c.j.p.logTrace.ThreadLocalLogTrace       : [49f43397] |<--Member com.jlaner.project.service.MemberService.findByMemberId(Long) time=3ms
2024-08-11T02:24:56.130+09:00  INFO 31693 --- [project] [io-8080-exec-10] c.j.p.logTrace.ThreadLocalLogTrace       : [49f43397] |-->void com.jlaner.project.service.PostService.postDataSaveOrUpdate(PostDto,Member)
Hibernate: 
    select
        p1_0.post_id,
        p1_0.content_data,
        p1_0.member_id,
        p1_0.schedule_date 
    from
        post p1_0 
    where
        p1_0.schedule_date=? 
        and p1_0.member_id=?
Hibernate: 
    select
        next_val as id_val 
    from
        post_seq for update
Hibernate: 
    update
        post_seq 
    set
        next_val= ? 
    where
        next_val=?
2024-08-11T02:24:56.147+09:00  INFO 31693 --- [project] [io-8080-exec-10] c.j.p.logTrace.ThreadLocalLogTrace       : [49f43397] |<--void com.jlaner.project.service.PostService.postDataSaveOrUpdate(PostDto,Member) time=17ms
Hibernate: 
    insert 
    into
        post
        (content_data, member_id, schedule_date, post_id) 
    values
        (?, ?, ?, ?)
2024-08-11T02:24:56.152+09:00  INFO 31693 --- [project] [io-8080-exec-10] c.j.project.controller.JlanerController  : 데이터가 저장되었습니다.content123
2024-08-11T02:24:56.152+09:00  INFO 31693 --- [project] [io-8080-exec-10] c.j.project.controller.JlanerController  : date=Sat Aug 10 09:00:00 KST 2024
2024-08-11T02:24:56.152+09:00  INFO 31693 --- [project] [io-8080-exec-10] c.j.p.logTrace.ThreadLocalLogTrace       : [49f43397] ResponseEntity com.jlaner.project.controller.JlanerController.savePostData(String,PostDto,HttpServletRequest,HttpServletResponse) time=38ms

 

로그를 확인해 보면 update 쿼리가 나가는 것을 확인할 수 있다.

 

구성이 끝난 줄 알았지만 문제가 더 생겼다.

직접 확인해 보자.

2024-08-11T03:52:29.595+09:00  INFO 38251 --- [project] [nio-8080-exec-1] c.j.p.c.jwt.TokenAuthenticationFilter    : Incoming request: URI = /api/jlaner/post/data, Method = POST
2024-08-11T03:52:29.596+09:00  INFO 38251 --- [project] [nio-8080-exec-1] c.j.p.c.jwt.TokenAuthenticationFilter    : request=org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterRequest@388b2d2d
2024-08-11T03:52:29.596+09:00  INFO 38251 --- [project] [nio-8080-exec-1] c.j.p.c.jwt.TokenAuthenticationFilter    : requestURI=/api/jlaner/post/data
2024-08-11T03:52:29.596+09:00  INFO 38251 --- [project] [nio-8080-exec-1] c.j.p.c.jwt.TokenAuthenticationFilter    : authorizationHeader =Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzIzMzEzNzI0LCJleHAiOjE3MjMzMTM4NDQsInN1YiI6Iu2VnOyngO2biCIsImlkIjoxfQ.KfszFnjy9qQ3MEIKXZ40Gj3-1_8h0r-HFXNWdEVj9QQ
2024-08-11T03:52:29.596+09:00  INFO 38251 --- [project] [nio-8080-exec-1] c.j.p.c.jwt.TokenAuthenticationFilter    : token = eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzIzMzEzNzI0LCJleHAiOjE3MjMzMTM4NDQsInN1YiI6Iu2VnOyngO2biCIsImlkIjoxfQ.KfszFnjy9qQ3MEIKXZ40Gj3-1_8h0r-HFXNWdEVj9QQ
2024-08-11T03:52:29.615+09:00 ERROR 38251 --- [project] [nio-8080-exec-1] c.j.project.config.jwt.TokenProvider     : 토큰이 만료되었습니다. JWT expired at 2024-08-10T18:17:24Z. Current time: 2024-08-10T18:52:29Z, a difference of 2105615 milliseconds.  Allowed clock skew: 0 milliseconds.
2024-08-11T03:52:29.638+09:00  INFO 38251 --- [project] [nio-8080-exec-5] c.j.p.c.jwt.TokenAuthenticationFilter    : Incoming request: URI = /login, Method = GET
2024-08-11T03:52:29.638+09:00  INFO 38251 --- [project] [nio-8080-exec-5] c.j.p.c.jwt.TokenAuthenticationFilter    : request=org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterRequest@7d2aed2b
2024-08-11T03:52:29.638+09:00  INFO 38251 --- [project] [nio-8080-exec-5] c.j.p.c.jwt.TokenAuthenticationFilter    : requestURI=/login
2024-08-11T03:52:29.638+09:00  INFO 38251 --- [project] [nio-8080-exec-5] c.j.p.c.jwt.TokenAuthenticationFilter    : authorizationHeader =Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzIzMzEzNzI0LCJleHAiOjE3MjMzMTM4NDQsInN1YiI6Iu2VnOyngO2biCIsImlkIjoxfQ.KfszFnjy9qQ3MEIKXZ40Gj3-1_8h0r-HFXNWdEVj9QQ
2024-08-11T03:52:29.638+09:00  INFO 38251 --- [project] [nio-8080-exec-5] c.j.p.c.jwt.TokenAuthenticationFilter    : token = eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzIzMzEzNzI0LCJleHAiOjE3MjMzMTM4NDQsInN1YiI6Iu2VnOyngO2biCIsImlkIjoxfQ.KfszFnjy9qQ3MEIKXZ40Gj3-1_8h0r-HFXNWdEVj9QQ
2024-08-11T03:52:29.644+09:00 ERROR 38251 --- [project] [nio-8080-exec-5] c.j.project.config.jwt.TokenProvider     : 토큰이 만료되었습니다. JWT expired at 2024-08-10T18:17:24Z. Current time: 2024-08-10T18:52:29Z, a difference of 2105644 milliseconds.  Allowed clock skew: 0 milliseconds.
2024-08-11T03:52:29.648+09:00  INFO 38251 --- [project] [nio-8080-exec-5] c.j.p.logTrace.ThreadLocalLogTrace       : [22f55507] String com.jlaner.project.controller.TestController.login()
2024-08-11T03:52:29.648+09:00  INFO 38251 --- [project] [nio-8080-exec-5] c.j.p.logTrace.ThreadLocalLogTrace       : [22f55507] String com.jlaner.project.controller.TestController.login() time=0ms

 

컨트롤러에서 토큰을 검사하는 로직을 추가했지만 이런 에러와 인증이 되지 않는 이유에 대해서다.

이전 포스팅에서 home에서 리다이렉트를 진행할 때 헤더에 토큰이 추가되지 않아서 home 컨트롤러에서 토큰 검사하고 인증하는 로직을 구성했었다.

그것 때문에 이 api 엔드포인트에도 토큰을 검사하는 로직을 추가했지만 액세스 토큰이 만료되었을 때 엔드포인트 요청에 위와 같은 오류가 난다.

이 이유를 알았고 이유와 개선점에 대해 포스팅하고 나머지 기능은 다음 포스팅에 하도록 하겠다.

 

이유는 home은 WebSecurityConfig에서 보면 

.requestMatchers(
        new AntPathRequestMatcher("/login/**"),
        new AntPathRequestMatcher("/pngs/**"),
        new AntPathRequestMatcher("/error/**"),
        new AntPathRequestMatcher("/favicon.ico"),
        new AntPathRequestMatcher("/h2-console/**"),
        new AntPathRequestMatcher("/actuator/**"))
.permitAll() // 위 경로들은 인증 없이 접근 가능
.requestMatchers(
        new AntPathRequestMatcher("/api/**")
)
.authenticated()
.anyRequest()
.permitAll()

 

이 부분 때문이었다. 이 구성에 대해서 자세하게 생각하고 내가 구성한 엔드포인트에 대한 생각이 조금만 더 깊었더라면 알아낼 수 있었겠지만 지금 기능에 필요한 RestAPI 엔드포인트를 구성하고 나서야 깨달았다.

이전에 home에서는 따로 토큰에 대한 검증이 필요했던 이유와 Authorization 헤더가 설정되지 않았던 이유는 home 엔드포인트는 URI에 토큰을 포함하고 있어야 하고 기능을 사용하기 위해서는 로그인이 필요했다 그러므로 인증을 하지 않고 permitAll로 접근이 가능하게 구성했었는데 인증을 하게끔 구성하지 않았기 때문에 헤더에 담기지 않았던 것이었다.

api는 인증을 진행하고 스크립트로 헤더를 설정해 주었기 때문에 헤더가 설정되는 것이었다.

즉, 필터에 이전과 같이 구성해도 되는 것이다. 필터는 필터대로 home은 그대로 구성하면 될 것이다.

다시 이전 포스팅과 같은 구조로 필터를 구성하고 컨트롤러를 수정한 후 진행하도록 하겠다.

package com.jlaner.project.config.jwt;


import com.jlaner.project.domain.Member;
import com.jlaner.project.domain.RefreshToken;
import com.jlaner.project.exception.CustomUnauthorizedException;
import com.jlaner.project.repository.MemberRepository;
import com.jlaner.project.service.RefreshTokenRedisService;
import com.jlaner.project.util.CookieUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.time.Duration;


@RequiredArgsConstructor
@Slf4j
@Order(2)
public class TokenAuthenticationFilter extends OncePerRequestFilter {
    private final TokenProvider tokenProvider;
    private final RefreshTokenRedisService refreshTokenRedisService;
    private final MemberRepository memberRepository;
    public static final String REFRESH_TOKEN_COOKIE_NAME = "refresh_token";
    public static final Duration ACCESS_TOKEN_DURATION = Duration.ofDays(1);
    private final static String HEADER_AUTHORIZATION = "Authorization";
    private final static String TOKEN_PREFIX = "Bearer ";
    private int count =0;
    /**
     * 요청을 필터링하여 JWT 토큰을 검증하고 인증 정보를 설정하는 메서드
     *
     * @param request     클라이언트에서 온 HTTP 요청
     * @param response    서버로부터 클라이언트로 응답
     * @param filterChain 다음 필터로 진행할 수 있도록 체인을 제공하는 객체
     * @throws ServletException 필터 처리 중 발생할 수 있는 예외
     * @throws IOException      입출력 예외
     */
    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {

        String requestURI = request.getRequestURI();

        //정적 리소스에 대해서는 필터를 무시
        if (requestURI.startsWith("/css") || requestURI.startsWith("/pngs") || requestURI.startsWith("/js")) {
            filterChain.doFilter(request, response);
            return;
        }
        log.info("Incoming request: URI = {}, Method = {}", request.getRequestURI(), request.getMethod());
        log.info("request={}", request);
        log.info("requestURI={}", request.getRequestURI());


        // HTTP 요청 헤더에서 Authorization 헤더 값을 가져옴
        String authorizationHeader = request.getHeader(HEADER_AUTHORIZATION);
        log.info("authorizationHeader ={}", authorizationHeader);
        // Authorization 헤더 값에서 토큰을 추출
        String token = getAccessToken(authorizationHeader);
        log.info("token = {}", token);

        // 추출된 토큰이 유효한 경우
        if (tokenProvider.validToken(token)) {
            log.info("인증 성공");
            // 토큰을 사용하여 인증 객체를 가져옴
            Authentication authentication = tokenProvider.getAuthentication(token);
            // SecurityContext에 인증 객체를 설정하여 인증 상태를 유지
            SecurityContextHolder.getContext().setAuthentication(authentication);
            filterChain.doFilter(request, response);
            return;
        }


        if (token != null && !tokenProvider.validToken(token)) {
            log.info("Access Token 만료");
            handleExpiredAccessToken(request, response, filterChain);
            return;
        }

        // 다음 필터로 요청과 응답을 전달
        filterChain.doFilter(request, response);
}

    private void handleExpiredAccessToken(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
        String refreshTokenValue = CookieUtil.getCookie(request, REFRESH_TOKEN_COOKIE_NAME)
                .map(Cookie::getValue)
                .orElse(null);
        log.info("Refresh Token = {}", refreshTokenValue);

        if (refreshTokenValue != null) {
            RefreshToken refreshToken = refreshTokenRedisService.findByRefreshToken(refreshTokenValue);

            if (refreshToken != null && tokenProvider.validToken(refreshTokenValue)) {
                Member member = memberRepository.findById(refreshToken.getMemberId()).orElse(null);

                if (member != null) {
                    String newAccessToken = tokenProvider.generateToken(member, ACCESS_TOKEN_DURATION);
                    refreshToken.accessTokenUpdate(newAccessToken);
                    refreshTokenRedisService.saveToken(refreshToken);

                    log.info("새로운 액세스 토큰 발급 = {}", newAccessToken);

                    response.setHeader(HEADER_AUTHORIZATION, TOKEN_PREFIX + newAccessToken);

                    Authentication authentication = tokenProvider.getAuthentication(newAccessToken);
                    SecurityContextHolder.getContext().setAuthentication(authentication);

                    // 발급 후 필터 체인 진행
                    filterChain.doFilter(request, response);
                } else {
                    log.info("Not Found Member");
                    response.sendRedirect("/login?error=memberNotFound");
                }
            } else {
                log.info("Invalid Refresh Token");
                response.sendRedirect("/login?error=invalidRefreshToken");
            }
        } else {
            log.info("Missing Refresh Token");
            response.sendRedirect("/login?error=missingRefreshToken");
        }
    }



    /**
     * Authorization 헤더에서 JWT 토큰을 추출하는 메서드
     *
     * @param authorizationHeader Authorization 헤더 값
     * @return 추출된 JWT 토큰
     */
    private String getAccessToken(String authorizationHeader) {
        if (authorizationHeader != null && authorizationHeader.startsWith(TOKEN_PREFIX)) {
            return authorizationHeader.substring(TOKEN_PREFIX.length());
        }
        return null;
    }
}

 

필터를 이와 같이 구조를 변경했다. 이전 구성했던 필터와 유사하지만 필요 없는 로직은 제외하고 깔끔하게 메서드로 빼서 구성했다.

토큰이 만료일경우 쿠키의 리프레시 토큰 값을 가져와 검증한다. 리프레시 토큰이 유효하다면 리프레시 토큰으로 사용자를 찾고 사용자도 존재한다면 새로운 액세스 토큰을 발급해 주고 이를 저장한 후 지속적인 사용을 할 수 있도록 구성했다.

지금은 response에 redirect로 이동하게 만들었지만 사용자 사용을 생각하면 알맞은 오류로 alert를 주거나 작성했던 데이터를 쿠키로 저장해 로그인 시 제공하는 방식으로 추후에 고려 중이다.

필터 로직을 수정했으니 다음으로는 컨트롤러를 수정해 보자.

package com.jlaner.project.controller;

import com.jlaner.project.config.jwt.TokenProvider;
import com.jlaner.project.domain.Member;
import com.jlaner.project.domain.Post;
import com.jlaner.project.domain.RefreshToken;
import com.jlaner.project.domain.ScheduleData;
import com.jlaner.project.dto.PostDto;
import com.jlaner.project.service.MemberService;
import com.jlaner.project.service.PostService;
import com.jlaner.project.service.RefreshTokenRedisService;
import com.jlaner.project.util.CookieUtil;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;

import org.springframework.web.bind.annotation.*;

import java.time.Duration;

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
@Slf4j
public class JlanerController {

    private final RefreshTokenRedisService refreshTokenRedisService;
    private final MemberService memberService;
    private final PostService postService;


    @PostMapping("/jlaner/post/data")
    public ResponseEntity<?> savePostData(@RequestBody PostDto postDto,
                                          HttpServletRequest request,
                                          HttpServletResponse response
    ) {
        String refreshToken = CookieUtil.getCookie(request, "refresh_token")
                .map(Cookie::getValue)
                .orElse(null);

        RefreshToken findByRefreshToken = refreshTokenRedisService.findByRefreshToken(refreshToken);
        Long findMemberId = findByRefreshToken.getMemberId();

        Member findMember = memberService.findByMemberId(findMemberId);

        postService.postDataSaveOrUpdate(postDto,findMember);

        log.info("데이터가 저장되었습니다.{}", postDto.getContentData());
        log.info("date={}", postDto.getScheduleDate());

        return ResponseEntity.status(200).build();
    }

}

 

액세스 토큰 검사하고 발급하는 로직은 제외했다.

필터가 처리를 해줄 테니 컨트롤러는 컨트롤러의 역할만 할 수 있게 되었다.

기존 액세스 토큰으로 사용자를 찾았지만 새로운 액세스 토큰을 필터에서 구성하기 때문에 쿠키의 리프레시 토큰을 사용하도록 했고 별다른 예외 처리가 없는 이유는 필터에서 처리를 해주기 때문에 부적절하게 필터에서 넘어오는 경우는 없을 것이기 때문이다.

이렇게 구성했고 다음은 실행해서 동작을 확인해 보자.

2024-08-11T05:47:55.319+09:00  INFO 55301 --- [project] [nio-8080-exec-4] c.j.p.c.jwt.TokenAuthenticationFilter    : Incoming request: URI = /api/jlaner/post/data, Method = POST
2024-08-11T05:47:55.323+09:00  INFO 55301 --- [project] [nio-8080-exec-4] c.j.p.c.jwt.TokenAuthenticationFilter    : request=org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterRequest@7acc2d40
2024-08-11T05:47:55.323+09:00  INFO 55301 --- [project] [nio-8080-exec-4] c.j.p.c.jwt.TokenAuthenticationFilter    : requestURI=/api/jlaner/post/data
2024-08-11T05:47:55.324+09:00  INFO 55301 --- [project] [nio-8080-exec-4] c.j.p.c.jwt.TokenAuthenticationFilter    : authorizationHeader =Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzIzMzIyNjU3LCJleHAiOjE3MjMzMjI3NzcsInN1YiI6Iu2VnOyngO2biCIsImlkIjoxfQ.sFH9vFnaVqCPbQu-ceV74TmIwdnw_1xyKW8TokC8PP4
2024-08-11T05:47:55.324+09:00  INFO 55301 --- [project] [nio-8080-exec-4] c.j.p.c.jwt.TokenAuthenticationFilter    : token = eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzIzMzIyNjU3LCJleHAiOjE3MjMzMjI3NzcsInN1YiI6Iu2VnOyngO2biCIsImlkIjoxfQ.sFH9vFnaVqCPbQu-ceV74TmIwdnw_1xyKW8TokC8PP4
2024-08-11T05:47:55.370+09:00  INFO 55301 --- [project] [nio-8080-exec-4] c.j.p.c.jwt.TokenAuthenticationFilter    : Access Token 만료
2024-08-11T05:47:55.374+09:00  INFO 55301 --- [project] [nio-8080-exec-4] c.j.p.c.jwt.TokenAuthenticationFilter    : Refresh Token = eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzIzMzIyNjU3LCJleHAiOjE3MjQ1MzIyNTcsInN1YiI6Iu2VnOyngO2biCIsImlkIjoxfQ.Wa8sVOE5RNKd10ifjySfgw5AFoDOAuHVAlHDm4NG4hM
2024-08-11T05:47:55.375+09:00  INFO 55301 --- [project] [nio-8080-exec-4] c.j.p.logTrace.ThreadLocalLogTrace       : [359c4aa9] RefreshToken com.jlaner.project.service.RefreshTokenRedisService.findByRefreshToken(String)
2024-08-11T05:47:55.383+09:00  INFO 55301 --- [project] [nio-8080-exec-4] c.j.p.logTrace.ThreadLocalLogTrace       : [359c4aa9] RefreshToken com.jlaner.project.service.RefreshTokenRedisService.findByRefreshToken(String) time=8ms
Hibernate: 
    select
        m1_0.member_id,
        m1_0.email,
        m1_0.login_id,
        m1_0.name,
        m1_0.provider,
        m1_0.provider_id,
        m1_0.role 
    from
        member m1_0 
    where
        m1_0.member_id=?
2024-08-11T05:47:55.411+09:00  INFO 55301 --- [project] [nio-8080-exec-4] c.j.p.logTrace.ThreadLocalLogTrace       : [34f7e6ea] void com.jlaner.project.service.RefreshTokenRedisService.saveToken(RefreshToken)
2024-08-11T05:47:55.415+09:00  INFO 55301 --- [project] [nio-8080-exec-4] c.j.p.logTrace.ThreadLocalLogTrace       : [34f7e6ea] void com.jlaner.project.service.RefreshTokenRedisService.saveToken(RefreshToken) time=3ms
2024-08-11T05:47:55.415+09:00  INFO 55301 --- [project] [nio-8080-exec-4] c.j.p.c.jwt.TokenAuthenticationFilter    : 새로운 액세스 토큰 발급 = eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzIzMzIyODc1LCJleHAiOjE3MjM0MDkyNzUsInN1YiI6Iu2VnOyngO2biCIsImlkIjoxfQ.oBC7JNDg2SlHJ9a9wnT0TxF-547C-9dgFH1ryrKkpU0
2024-08-11T05:47:55.512+09:00  INFO 55301 --- [project] [nio-8080-exec-4] c.j.p.logTrace.ThreadLocalLogTrace       : [f232cb51] ResponseEntity com.jlaner.project.controller.JlanerController.savePostData(PostDto,HttpServletRequest,HttpServletResponse)
2024-08-11T05:47:55.513+09:00  INFO 55301 --- [project] [nio-8080-exec-4] c.j.p.logTrace.ThreadLocalLogTrace       : [f232cb51] |-->RefreshToken com.jlaner.project.service.RefreshTokenRedisService.findByRefreshToken(String)
2024-08-11T05:47:55.516+09:00  INFO 55301 --- [project] [nio-8080-exec-4] c.j.p.logTrace.ThreadLocalLogTrace       : [f232cb51] |<--RefreshToken com.jlaner.project.service.RefreshTokenRedisService.findByRefreshToken(String) time=3ms
2024-08-11T05:47:55.518+09:00  INFO 55301 --- [project] [nio-8080-exec-4] c.j.p.logTrace.ThreadLocalLogTrace       : [f232cb51] |-->Member com.jlaner.project.service.MemberService.findByMemberId(Long)
Hibernate: 
    select
        m1_0.member_id,
        m1_0.email,
        m1_0.login_id,
        m1_0.name,
        m1_0.provider,
        m1_0.provider_id,
        m1_0.role 
    from
        member m1_0 
    where
        m1_0.member_id=?
2024-08-11T05:47:55.522+09:00  INFO 55301 --- [project] [nio-8080-exec-4] c.j.p.logTrace.ThreadLocalLogTrace       : [f232cb51] |<--Member com.jlaner.project.service.MemberService.findByMemberId(Long) time=4ms
2024-08-11T05:47:55.525+09:00  INFO 55301 --- [project] [nio-8080-exec-4] c.j.p.logTrace.ThreadLocalLogTrace       : [f232cb51] |-->void com.jlaner.project.service.PostService.postDataSaveOrUpdate(PostDto,Member)
Hibernate: 
    select
        p1_0.post_id,
        p1_0.content_data,
        p1_0.member_id,
        p1_0.schedule_date 
    from
        post p1_0 
    where
        p1_0.schedule_date=? 
        and p1_0.member_id=?
Hibernate: 
    select
        next_val as id_val 
    from
        post_seq for update
Hibernate: 
    update
        post_seq 
    set
        next_val= ? 
    where
        next_val=?
2024-08-11T05:47:55.586+09:00  INFO 55301 --- [project] [nio-8080-exec-4] c.j.p.logTrace.ThreadLocalLogTrace       : [f232cb51] |<--void com.jlaner.project.service.PostService.postDataSaveOrUpdate(PostDto,Member) time=61ms
Hibernate: 
    insert 
    into
        post
        (content_data, member_id, schedule_date, post_id) 
    values
        (?, ?, ?, ?)
2024-08-11T05:47:55.600+09:00  INFO 55301 --- [project] [nio-8080-exec-4] c.j.project.controller.JlanerController  : 데이터가 저장되었습니다.invaild token
2024-08-11T05:47:55.600+09:00  INFO 55301 --- [project] [nio-8080-exec-4] c.j.project.controller.JlanerController  : date=Sun Aug 11 09:00:00 KST 2024
2024-08-11T05:47:55.604+09:00  INFO 55301 --- [project] [nio-8080-exec-4] c.j.p.logTrace.ThreadLocalLogTrace       : [f232cb51] ResponseEntity com.jlaner.project.controller.JlanerController.savePostData(PostDto,HttpServletRequest,HttpServletResponse) time=92ms

 

위와 같이 토큰이 만료되어서 새로운 토큰을 발급하고 정상처리 되어서 진행되는 것을 확인할 수 있다.

 

데이터도 잘 저장되는 것을 확인할 수 있다.

 

역시 생각하는 대로만 흘러가지 않았다. 오류도 많았고 신경 쓰지 못한 부분이 작동하는 사이드 이펙트가 많았는데 이 이유는 기능적으로 동작만 하는지를 생각했기 때문이고 테스트 코드도 인증에 집중된 테스트였다. 이 포스팅이 길어져 이 포스팅은 여기까지이고 다음 포스팅으로 진행할 텐데 다음 포스팅부터는 인증 테스트는 물론 로직을 작성하면 로직을 검증하는 테스트 코드를 작성하며 사이드 이펙트를 줄이고 동작을 해야 확인하기보단 테스트에 중점적으로 시나리오와 함께 작성하도록 하겠다.