24.06.10 월 TIL(Today I Learned)

신민금·2024년 6월 11일
0
post-thumbnail

TIL(Today I Learned)

: 매일 저녁, 하루를 마무리하며 작성 !
: ⭕ 지식 위주, 학습한 것을 노트 정리한다고 생각하고 작성하면서 머리 속 흩어져있는 지식들을 정리 !


알고리즘 코드카타

  • 문제 설명
    문자열 s가 주어졌을 때, s의 각 위치마다 자신보다 앞에 나왔으면서, 자신과 가장 가까운 곳에 있는 같은 글자가 어디 있는지 알고 싶습니다.
    예를 들어, s="banana"라고 할 때, 각 글자들을 왼쪽부터 오른쪽으로 읽어 나가면서 다음과 같이 진행할 수 있습니다.
    b는 처음 나왔기 때문에 자신의 앞에 같은 글자가 없습니다. 이는 -1로 표현합니다.
    a는 처음 나왔기 때문에 자신의 앞에 같은 글자가 없습니다. 이는 -1로 표현합니다.
    n은 처음 나왔기 때문에 자신의 앞에 같은 글자가 없습니다. 이는 -1로 표현합니다.
    a는 자신보다 두 칸 앞에 a가 있습니다. 이는 2로 표현합니다.
    n도 자신보다 두 칸 앞에 n이 있습니다. 이는 2로 표현합니다.
    a는 자신보다 두 칸, 네 칸 앞에 a가 있습니다. 이 중 가까운 것은 두 칸 앞이고, 이는 2로 표현합니다.
    따라서 최종 결과물은 [-1, -1, -1, 2, 2, 2]가 됩니다.
    문자열 s이 주어질 때, 위와 같이 정의된 연산을 수행하는 함수 solution을 완성해주세요.
  • 제한사항
    1 ≤ s의 길이 ≤ 10,000
    s은 영어 소문자로만 이루어져 있습니다.
class Solution {
    public int[] solution(String s) {
        int[] answer = new int[s.length()];
        
        for(int i=0;i<s.length();i++){
            if(i !=0){
                int idx = s.substring(0,i).lastIndexOf(s.charAt(i));
                if(idx != -1){
                    answer[i] = i-idx;
                    
                }
                else{
                    answer[i] = idx;
                }
            }
            else{
                answer[i] = -1;
            }
        }
        
        return answer;
    }
}

팀 프로젝트 완성, 마무리

뉴스 피드 프로젝트 전체 구현 완료

// TestDB

package com.sparta.wildcard_newsfeed;

import com.sparta.wildcard_newsfeed.domain.comment.entity.Comment;
import com.sparta.wildcard_newsfeed.domain.liked.entity.ContentsTypeEnum;
import com.sparta.wildcard_newsfeed.domain.liked.entity.Liked;
import com.sparta.wildcard_newsfeed.domain.post.entity.Post;
import com.sparta.wildcard_newsfeed.domain.user.entity.User;
import com.sparta.wildcard_newsfeed.domain.user.entity.UserRoleEnum;
import com.sparta.wildcard_newsfeed.domain.user.entity.UserStatusEnum;
import jakarta.annotation.PostConstruct;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@Component
@RequiredArgsConstructor
public class TestDB {

    private final InitService initService;

    @PostConstruct
    public void init() {
        initService.dbInit1();
    }

    @Component
    @Transactional
    @RequiredArgsConstructor
    static class InitService {
        private final EntityManager em;

        @Autowired
        PasswordEncoder passwordEncoder;

        public void dbInit1() {
            // 유저 1
            User user = User.builder()
                    .usercode("testid1234")
                    .password(passwordEncoder.encode("currentPWD999!"))
                    .name("B15_user1")
                    .email("test@naver.com")
                    .introduce("B15_user1의 하고 싶은 말")
                    .userRoleEnum(UserRoleEnum.USER)
                    .userStatus(UserStatusEnum.UNAUTHORIZED)
                    .authUserAt(LocalDateTime.now())
                    .userRoleEnum(UserRoleEnum.USER)
                    .build();
            save(user);
            // 유저 2
            User user2 = User.builder()
                    .usercode("testid5678")
                    .password(passwordEncoder.encode("currentPWD999!"))
                    .name("B15_user2")
                    .email("test2@naver.com")
                    .introduce("B15_user2의 하고 싶은 말")
                    .userRoleEnum(UserRoleEnum.USER)
                    .userStatus(UserStatusEnum.UNAUTHORIZED)
                    .authUserAt(LocalDateTime.now())
                    .userRoleEnum(UserRoleEnum.USER)
                    .build();
            save(user2);

            List<Post> postList = new ArrayList<>();
            // 유저 1의 게시물
            initPosts(user, postList);
            // 유저 2의 게시물
            initPosts(user2, postList);
            savePosts(postList);

            List<Comment> commentList = new ArrayList<>();
            // 유저 1의 댓글
            initComments(user, postList, commentList);
            // 유저 2의 댓글
            initComments(user2, postList, commentList);
            saveComments(commentList);

            // 사용자 10명을 추가하고 50% 확률로 게시물에 좋아요 추가
            addLikeToPost(postList);
        }

        private void addLikeToPost(List<Post> postList) {
            List<User> userList = createUsers();
            for (User user : userList) {
                for (Post post : postList) {
                    if (Math.random() < 0.5) {
                        post.setLikeCount(post.getLikeCount() + 1);
                        Liked liked = new Liked(user, post.getId(), ContentsTypeEnum.POST);
                        em.persist(post);
                        em.persist(liked);
                    }
                }
            }
        }

        private List<User> createUsers() {
            List<User> userList = new ArrayList<>();
            for (int i = 0; i < 10; i++) {
                User user = User.builder()
                        .usercode("dummyUser" + i)
                        .password(passwordEncoder.encode("currentPWD999!"))
                        .name("dummyUser" + i)
                        .email("test@naver.com")
                        .introduce("dummyUser" + i)
                        .userRoleEnum(UserRoleEnum.USER)
                        .userStatus(UserStatusEnum.UNAUTHORIZED)
                        .authUserAt(LocalDateTime.now())
                        .userRoleEnum(UserRoleEnum.USER)
                        .build();
                save(user);
                userList.add(user);
            }
            return userList;
        }

        private void initPosts(User user, List<Post> postList) {
            for (int i = 0; i < 33; i++) {
                Post post = new Post();
                post.setUser(user);
                post.setTitle("테스트 제목" + i);
                post.setContent("테스트 내용" + i);
                post.setLikeCount(0L);
                post.setTestDateTime();
                postList.add(post);
            }
        }

        private void initComments(User user, List<Post> postList, List<Comment> commentList) {
            for (int i = 0; i < 4; i++) {
                for (int j = 0; j < 4; j++) {
                    Comment comment = Comment.builder()
                            .content(user.getName() + "의 게시물" + i + "의 댓글" + j)
                            .user(user)
                            .post(postList.get(i))
                            .likeCount(0L)
                            .build();
                    comment.testDataInit();
                    commentList.add(comment);
                }
            }
        }

        public void save(Object... objects) {
            for (Object object : objects) {
                em.persist(object);
            }
        }

        public void savePosts(List<Post> list) {
            for (Post post : list) {
                em.persist(post);
            }
        }

        public void saveComments(List<Comment> list) {
            for (Comment comment : list) {
                em.persist(comment);
            }
        }
    }
}

comfig

// JpaAuditing

package com.sparta.wildcard_newsfeed.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@Configuration
@EnableJpaAuditing
public class JpaAuditing {
}



// MailConfig

package com.sparta.wildcard_newsfeed.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.JavaMailSenderImpl;

import java.util.Properties;

@Configuration
public class MailConfig {

    @Value("${spring.mail.host}")
    private String host;

    @Value("${spring.mail.port}")
    private int port;

    @Value("${spring.mail.username}")
    private String username;

    @Value("${spring.mail.password}")
    private String password;

    @Value("${spring.mail.properties.mail.smtp.auth}")
    private boolean auth;

    @Value("${spring.mail.properties.mail.smtp.starttls.enable}")
    private boolean starttlsEnable;

    @Bean
    public JavaMailSender javaMailSender() {
        JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
        mailSender.setHost(host);
        mailSender.setPort(port);
        mailSender.setUsername(username);
        mailSender.setPassword(password);
        mailSender.setDefaultEncoding("UTF-8");
        mailSender.setJavaMailProperties(getMailProperties());

        return mailSender;
    }

    private Properties getMailProperties() {
        Properties properties = new Properties();
        properties.put("mail.smtp.auth", auth);
        properties.put("mail.smtp.starttls.enable", starttlsEnable);
        return properties;
    }
}




// S3Config

package com.sparta.wildcard_newsfeed.config;

import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class S3Config {

    @Value("${cloud.aws.credentials.access-key}")
    private String accessKey;

    @Value("${cloud.aws.credentials.secret-key}")
    private String secretKey;

    @Value("${cloud.aws.region.static}")
    private String region;

    @Bean
    public AmazonS3Client amazonS3Client() {
        BasicAWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey);

        return (AmazonS3Client) AmazonS3ClientBuilder.standard()
                .withRegion(region)
                .withCredentials(new AWSStaticCredentialsProvider(awsCredentials))
                .build();
    }
}



// SwaggerConfiguration

package com.sparta.wildcard_newsfeed.config;

import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class SwaggerConfiguration {

    @Bean
    public OpenAPI openAPI() {
        return new OpenAPI()
                .components(new Components())
                .info(apiInfo());
    }

    public Info apiInfo() {
        return new Info()
                .title("WildCard API")
                .description("WildCard News Feed")
                .version("1.0.0");
    }
}



// SwaggerConstants

package com.sparta.wildcard_newsfeed.config;

public class SwaggerConstants {
    public static final String[] SWAGGER_PATTERNS = {
            "/swagger-ui/**", "/swagger-resources/**", "/swagger-ui.html", "/v3/api-docs",
            "/v3/api-docs/**", "/webjars/**"
    };
}



// WebSecurityConfig

package com.sparta.wildcard_newsfeed.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.sparta.wildcard_newsfeed.domain.user.repository.UserRepository;
import com.sparta.wildcard_newsfeed.security.AuthenticationUserService;
import com.sparta.wildcard_newsfeed.security.jwt.*;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import static com.sparta.wildcard_newsfeed.config.SwaggerConstants.SWAGGER_PATTERNS;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {

    private final JwtUtil jwtUtil;
    private final ObjectMapper objectMapper;
    private final AuthenticationConfiguration authenticationConfiguration;
    private final AuthenticationUserService authenticationUserService;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtLogoutSuccessHandler jwtLogoutSuccessHandler;
    private final JwtLogoutHandler jwtLogoutHandler;
    private final UserRepository userRepository;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }

    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
        JwtAuthenticationFilter filter = new JwtAuthenticationFilter(objectMapper, jwtUtil, userRepository);
        filter.setAuthenticationManager(authenticationManager(authenticationConfiguration));
        return filter;
    }

    @Bean
    public JwtAuthorizationFilter jwtAuthorizationFilter() {
        return new JwtAuthorizationFilter(jwtUtil, authenticationUserService, userRepository);
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf(AbstractHttpConfigurer::disable);
        http.formLogin(AbstractHttpConfigurer::disable);
        http.httpBasic(AbstractHttpConfigurer::disable);

        http.sessionManagement((sessionManagement) ->
                sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        );

        http.authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests
                .requestMatchers(SWAGGER_PATTERNS).permitAll()
                .requestMatchers(HttpMethod.POST, "/api/v1/auth/reissue").permitAll()
                .requestMatchers(HttpMethod.POST, "/api/v1/user/signup").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/v1/user/**").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/v1/post/**").permitAll()
                .requestMatchers(HttpMethod.POST, "/api/v1/post/page").permitAll() //게시글 페이지는 로그인x
                .anyRequest().authenticated()
        );

        //security filter
        http.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
        http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

        //로그아웃
        http.logout(logout ->
                logout.logoutUrl("/api/v1/user/logout")
                        .addLogoutHandler(jwtLogoutHandler)
                        .logoutSuccessHandler(jwtLogoutSuccessHandler)
        );

        //예외 검증
        http.exceptionHandling(exceptionHandling ->
                exceptionHandling.authenticationEntryPoint(jwtAuthenticationEntryPoint)
        );

        return http.build();
    }
}



domain

  • comment
// CommentController

package com.sparta.wildcard_newsfeed.domain.comment.controller;

import com.sparta.wildcard_newsfeed.domain.comment.dto.CommentRequestDto;
import com.sparta.wildcard_newsfeed.domain.comment.dto.CommentResponseDto;
import com.sparta.wildcard_newsfeed.domain.comment.service.CommentService;
import com.sparta.wildcard_newsfeed.domain.common.CommonResponseDto;
import com.sparta.wildcard_newsfeed.security.AuthenticationUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/post/{postId}/comment")
@Tag(name = "Comment 컨트롤러", description = "Comment API")
public class CommentController {

    private final CommentService commentService;

    // 댓글 추가
    @PostMapping
    @Operation(summary = "댓글 등록")
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "댓글 등록 성공",
                    content = @Content(mediaType = "application/json",
                            schema = @Schema(implementation = CommonResponseDto.class)))
    })
    public ResponseEntity<CommonResponseDto<CommentResponseDto>> addComment(
            @AuthenticationPrincipal AuthenticationUser user,
            @PathVariable(name = "postId") long postId,
            @RequestBody CommentRequestDto commentRequestDto
    ) {
        CommentResponseDto commentResponseDto = commentService.addComment(postId, commentRequestDto, user);
        return ResponseEntity.ok()
                .body(CommonResponseDto.<CommentResponseDto>builder()
                        .statusCode(HttpStatus.OK.value())
                        .message("댓글 등록 성공")
                        .data(commentResponseDto)
                        .build());
    }

    @PutMapping("{commentId}")
    @Operation(summary = "댓글 수정")
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "댓글 수정 성공",
                    content = @Content(mediaType = "application/json",
                            schema = @Schema(implementation = CommonResponseDto.class)))
    })
    public ResponseEntity<CommonResponseDto<CommentResponseDto>> updateComment(
            @AuthenticationPrincipal AuthenticationUser user,
            @Valid @RequestBody CommentRequestDto commentRequestDto,
            @PathVariable(name = "postId") Long postId,
            @PathVariable(name = "commentId") long commentId
    ) {
        CommentResponseDto commentResponseDto = commentService.updateComment(postId, commentId, commentRequestDto, user);
        return ResponseEntity.ok()
                .body(CommonResponseDto.<CommentResponseDto>builder()
                        .statusCode(HttpStatus.OK.value())
                        .message("댓글 수정 성공")
                        .data(commentResponseDto)
                        .build());
    }

    @DeleteMapping("{commentId}")
    @Operation(summary = "댓글 삭제")
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "댓글 삭제 성공",
                    content = @Content(mediaType = "application/json",
                            schema = @Schema(implementation = CommonResponseDto.class)))
    })
    public ResponseEntity<CommonResponseDto<CommentResponseDto>> deleteComment(
            @AuthenticationPrincipal AuthenticationUser user,
            @PathVariable(name = "postId") long postId,
            @PathVariable(name = "commentId") long commentId
    ) {
        commentService.deleteComment(postId, commentId, user.getUsername());
        return ResponseEntity.ok()
                .body(CommonResponseDto.<CommentResponseDto>builder()
                        .statusCode(HttpStatus.OK.value())
                        .message("댓글 삭제 성공")
                        .build());
    }
}



// CommentRequestDto

package com.sparta.wildcard_newsfeed.domain.comment.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class CommentRequestDto {

    @NotBlank(message = "댓글 내용은 필수 입력 값입니다.")
    @Schema(description = "댓글 내용", example = "내용")
    private String content;

    public CommentRequestDto(String content) {
        this.content = content;
    }
}



// CommentResponseDto

package com.sparta.wildcard_newsfeed.domain.comment.dto;

import com.fasterxml.jackson.annotation.JsonFormat;
import com.sparta.wildcard_newsfeed.domain.comment.entity.Comment;
import lombok.Getter;

import java.time.LocalDateTime;

@Getter
public class CommentResponseDto {
    private Long postId;
    private Long id;
    private String content;
    private String username;

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createdAt;
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime updatedAt;
    private Long likeCount;

    public CommentResponseDto(Comment comment) {
        this.id = comment.getId();
        this.content = comment.getContent();
        this.username = comment.getUser().getName();
        this.postId = comment.getPost().getId();
        this.createdAt = comment.getCreatedAt();
        this.updatedAt = comment.getUpdatedAt();
        this.likeCount = comment.getLikeCount();
    }
}



// PostWithCommentsResponseDto

package com.sparta.wildcard_newsfeed.domain.comment.dto;

import com.sparta.wildcard_newsfeed.domain.post.dto.PostResponseDto;
import lombok.Getter;
import lombok.Setter;

import java.util.List;

@Getter
@Setter
public class PostWithCommentsResponseDto {
    private PostResponseDto post;
    private List<CommentResponseDto> comments;

    public PostWithCommentsResponseDto(PostResponseDto post, List<CommentResponseDto> comments) {
        this.post = post;
        this.comments = comments;
    }
}



// Comment

package com.sparta.wildcard_newsfeed.domain.comment.entity;

import com.sparta.wildcard_newsfeed.domain.common.TimeStampEntity;
import com.sparta.wildcard_newsfeed.domain.post.entity.Post;
import com.sparta.wildcard_newsfeed.domain.user.entity.User;
import jakarta.persistence.*;
import lombok.*;

@Getter
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Comment extends TimeStampEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id", nullable = false)
    private Post post;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @Column(nullable = false)
    private String content;

    @Setter
    private Long likeCount;

    public Comment(String content, User user, Post post) {
        this.content = content;
        this.user = user;
        this.post = post;
        this.likeCount = 0L;
    }

    public void update(String comment) {
        this.content = comment;
    }

    public void testDataInit() {
        super.setDateTimeInit();
    }
}



// CommentRepository

package com.sparta.wildcard_newsfeed.domain.comment.repository;

import com.sparta.wildcard_newsfeed.domain.comment.entity.Comment;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface CommentRepository extends JpaRepository<Comment, Long> {
    List<Comment> findByPostId(long postId);
}



// CommentService

package com.sparta.wildcard_newsfeed.domain.comment.service;

import com.sparta.wildcard_newsfeed.domain.comment.dto.CommentRequestDto;
import com.sparta.wildcard_newsfeed.domain.comment.dto.CommentResponseDto;
import com.sparta.wildcard_newsfeed.domain.comment.entity.Comment;
import com.sparta.wildcard_newsfeed.domain.comment.repository.CommentRepository;
import com.sparta.wildcard_newsfeed.domain.post.entity.Post;
import com.sparta.wildcard_newsfeed.domain.post.repository.PostRepository;
import com.sparta.wildcard_newsfeed.domain.user.entity.User;
import com.sparta.wildcard_newsfeed.domain.user.repository.UserRepository;
import com.sparta.wildcard_newsfeed.security.AuthenticationUser;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

@Slf4j
@Service
@RequiredArgsConstructor
public class CommentService {

    private final PostRepository postRepository;
    private final CommentRepository commentRepository;
    private final UserRepository userRepository;

    @Transactional
    public CommentResponseDto addComment(long postId, CommentRequestDto request, AuthenticationUser user) {
        User byUsercode = userRepository.findByUsercode(user.getUsername())
                .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));

        // DB에 게시물이 존재하지 않는 경우
        Post post = findPostById(postId);
        Comment comment = commentRepository.save(new Comment(request.getContent(), byUsercode, post));
        return new CommentResponseDto(comment);
    }

    public CommentResponseDto updateComment(long postId, long commentId, CommentRequestDto request, AuthenticationUser user) {
        // DB에 게시물이 존재하지 않는 경우
        findPostById(postId);
        // 해당 댓글이 DB에 존재하지 않는 경우
        Comment comment = findCommentById(commentId);

        // 작성자가 동일하지 않는 경우
        if (!Objects.equals(comment.getUser().getUsercode(), user.getUsername())) {
            throw new IllegalArgumentException("작성자만 수정할 수 있습니다.");
        }

        comment.update(request.getContent());
        commentRepository.save(comment);
        return new CommentResponseDto(comment);
    }

    public void deleteComment(long postId, long commentId, String username) {
        // DB에 게시물이 존재하지 않는 경우
        findPostById(postId);

        // 해당 댓글이 DB에 존재하지 않는 경우
        Comment comment = findCommentById(commentId);

        // 작성자가 동일하지 않는 경우
        if (!Objects.equals(comment.getUser().getUsercode(), username)) {
            throw new IllegalArgumentException("작성자만 삭제할 수 있습니다.");
        }

        commentRepository.delete(comment);
    }

    public List<CommentResponseDto> findAllCommentsByPostId(long postId) {
        // 해당 postId와 연관된 댓글을 조회하는 로직 구현
        List<Comment> comments = commentRepository.findByPostId(postId);
        return comments.stream()
                .map(CommentResponseDto::new)
                .collect(Collectors.toList());
    }

    public Comment findCommentById(Long commentId) {
        return commentRepository.findById(commentId)
                .orElseThrow(() -> new IllegalArgumentException("해당 댓글이 존재하지 않습니다."));
    }

    private Post findPostById(long id) {
        return postRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 게시물이 존재하지 않습니다."));
    }

    public List<CommentResponseDto> findAll() {
        List<Comment> commentlist = commentRepository.findAll();
        return commentlist.stream()
                .sorted(Comparator.comparing(Comment::getCreatedAt).reversed())
                .map(CommentResponseDto::new)
                .toList();
    }
}


  • common
// ErrorResponseDto

package com.sparta.wildcard_newsfeed.domain.common.error;

import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
public class ErrorResponseDto {
    private Integer statusCode;
    private Object message;
}



// CommonResponseDto

package com.sparta.wildcard_newsfeed.domain.common;

import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
public class CommonResponseDto<T> {

    @Schema(description = "상태코드", example = "200")
    private Integer statusCode;

    @Schema(description = "메세지", example = "성공")
    private String message;

    @Schema(description = "응답 데이터")
    @JsonInclude(JsonInclude.Include.NON_NULL)
    private T data;
}



// TimeStampEntity

package com.sparta.wildcard_newsfeed.domain.common;

import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class TimeStampEntity {

    @Column(updatable = false)
    @CreatedDate
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;

    protected void setDateTimeInit() {
        this.createdAt = LocalDateTime.now();
        this.updatedAt = LocalDateTime.now();
    }
}

  • file / service
// FileService

package com.sparta.wildcard_newsfeed.domain.file.service;

import com.sparta.wildcard_newsfeed.exception.customexception.FileException;
import com.sparta.wildcard_newsfeed.util.FileUtils;
import com.sparta.wildcard_newsfeed.util.S3FileUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;

@Slf4j
@Service
@RequiredArgsConstructor
public class FileService {

    private final FileUtils fileUtils;
    private final S3FileUtils s3FileUtils;

    public File saveFileToLocal(MultipartFile multipartFile, String uuidFileName) {
        String localLocation = fileUtils.getAbsoluteUploadFolder();
        String fullFilePath = localLocation + uuidFileName;

        File saveFile = new File(fullFilePath);
        try {
            multipartFile.transferTo(saveFile);
        } catch (IOException e) {
            throw new FileException("local에 파일을 저장할 수 없습니다.", e);
        }
        return saveFile;
    }

    public String uploadFileToS3(MultipartFile multipartFile) {
        String uuidFileName = fileUtils.createUuidFileName(multipartFile.getOriginalFilename());
        File savedFile = saveFileToLocal(multipartFile, uuidFileName);

        try {
            String s3Url = s3FileUtils.uploadFile(uuidFileName, savedFile);
            if (!deleteFileFromLocal(savedFile)) {
                log.info("S3 업로드 후 로컬 파일 삭제 실패");
            }
            return s3Url;
        } catch (Exception e) {
            throw new FileException("S3에 파일 업로드 실패", e);
        }
    }

    public boolean deleteFileFromLocal(File savedFile) {
        if (!savedFile.delete()) {
            log.error("S3에 파일 저장 후 local 파일 삭제 실패");
            return false;
        }
        return true;
    }
}


  • liked
// LikedController

package com.sparta.wildcard_newsfeed.domain.liked.controller;

import com.sparta.wildcard_newsfeed.domain.common.CommonResponseDto;
import com.sparta.wildcard_newsfeed.domain.liked.dto.LikedRequestDto;
import com.sparta.wildcard_newsfeed.domain.liked.dto.LikedResponseDto;
import com.sparta.wildcard_newsfeed.domain.liked.service.LikedService;
import com.sparta.wildcard_newsfeed.security.AuthenticationUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/like")
@Tag(name = "Liked 컨트롤러", description = "Liked API")
public class LikedController {

    private final LikedService likedService;

    @PostMapping
    @Operation(summary = "좋아요 추가")
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "좋아요 추가 성공")
    })
    public ResponseEntity<CommonResponseDto<LikedResponseDto>> addLike(
            @AuthenticationPrincipal AuthenticationUser user,
            @RequestBody LikedRequestDto requestDto
    ) {
        LikedResponseDto responseDto = likedService.addLike(requestDto, user);
        return ResponseEntity.ok()
                .body(CommonResponseDto.<LikedResponseDto>builder()
                        .statusCode(HttpStatus.OK.value())
                        .message("좋아요 추가 성공")
                        .data(responseDto)
                        .build());
    }

    @DeleteMapping
    @Operation(summary = "좋아요 제거")
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "좋아요 제거 성공")
    })
    public ResponseEntity<CommonResponseDto<LikedResponseDto>> removeLike(
            @AuthenticationPrincipal AuthenticationUser user,
            @RequestBody LikedRequestDto requestDto
    ) {
        likedService.removeLike(requestDto, user);
        return ResponseEntity.ok()
                .body(CommonResponseDto.<LikedResponseDto>builder()
                        .statusCode(HttpStatus.OK.value())
                        .message("좋아요 제거 성공")
                        .build());
    }
}



// LikedRequestDto

package com.sparta.wildcard_newsfeed.domain.liked.dto;

import com.sparta.wildcard_newsfeed.domain.liked.entity.ContentsTypeEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class LikedRequestDto {
    @Schema(description = "컨텐츠 ID", example = "2")
    private Long contentsId;
    @Schema(description = "컨텐츠 타입 (POST || COMMENT)", example = "POST")
    private ContentsTypeEnum contentsType;
}



// LikedResponseDto

package com.sparta.wildcard_newsfeed.domain.liked.dto;

import com.fasterxml.jackson.annotation.JsonFormat;
import com.sparta.wildcard_newsfeed.domain.liked.entity.ContentsTypeEnum;
import com.sparta.wildcard_newsfeed.domain.liked.entity.Liked;
import lombok.Getter;

import java.time.LocalDateTime;

@Getter
public class LikedResponseDto {

    private Long likeId;
    private Long contentsId;
    private ContentsTypeEnum contentsType;
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createdAt;
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime updatedAt;

    public LikedResponseDto(Liked liked) {
        this.likeId = liked.getId();
        this.contentsId = liked.getContentsId();
        this.contentsType = liked.getContentsType();
        this.createdAt = liked.getCreatedAt();
        this.updatedAt = liked.getUpdatedAt();
    }
}



// ContentsTypeEnum

package com.sparta.wildcard_newsfeed.domain.liked.entity;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum ContentsTypeEnum {
    COMMENT("comment"),
    POST("post");
    private final String contentsType;
}



// Liked

package com.sparta.wildcard_newsfeed.domain.liked.entity;

import com.sparta.wildcard_newsfeed.domain.common.TimeStampEntity;
import com.sparta.wildcard_newsfeed.domain.user.entity.User;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Entity
@NoArgsConstructor
public class Liked extends TimeStampEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @Column(name = "contents_id", nullable = false)
    private Long contentsId;

    @Enumerated(EnumType.STRING)
    @Column(name = "content_type", nullable = false)
    private ContentsTypeEnum contentsType;

    public Liked(User user, Long contentsId, ContentsTypeEnum contentsType) {
        this.user = user;
        this.contentsId = contentsId;
        this.contentsType = contentsType;
    }
}



// LikedRepository

package com.sparta.wildcard_newsfeed.domain.liked.repository;

import com.sparta.wildcard_newsfeed.domain.liked.entity.ContentsTypeEnum;
import com.sparta.wildcard_newsfeed.domain.liked.entity.Liked;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface LikedRepository extends JpaRepository<Liked, Long> {
    Optional<Liked> findByUserIdAndContentsIdAndContentsType(Long user_id, Long contentsId, ContentsTypeEnum contentsType);
}



// LikedService

package com.sparta.wildcard_newsfeed.domain.liked.service;

import com.sparta.wildcard_newsfeed.domain.comment.entity.Comment;
import com.sparta.wildcard_newsfeed.domain.comment.repository.CommentRepository;
import com.sparta.wildcard_newsfeed.domain.liked.dto.LikedRequestDto;
import com.sparta.wildcard_newsfeed.domain.liked.dto.LikedResponseDto;
import com.sparta.wildcard_newsfeed.domain.liked.entity.ContentsTypeEnum;
import com.sparta.wildcard_newsfeed.domain.liked.entity.Liked;
import com.sparta.wildcard_newsfeed.domain.liked.repository.LikedRepository;
import com.sparta.wildcard_newsfeed.domain.post.entity.Post;
import com.sparta.wildcard_newsfeed.domain.post.repository.PostRepository;
import com.sparta.wildcard_newsfeed.domain.user.entity.User;
import com.sparta.wildcard_newsfeed.domain.user.repository.UserRepository;
import com.sparta.wildcard_newsfeed.security.AuthenticationUser;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.Optional;

@Slf4j
@Service
@RequiredArgsConstructor
public class LikedService {
    private final LikedRepository likedRepository;
    private final UserRepository userRepository;
    private final PostRepository postRepository;
    private final CommentRepository commentRepository;

    @Transactional
    public LikedResponseDto addLike(LikedRequestDto requestDto, AuthenticationUser user) {
        User currentUser = userRepository.findByUsercode(user.getUsername())
                .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));

        Optional<Liked> existingLike = likedRepository.findByUserIdAndContentsIdAndContentsType(currentUser.getId(), requestDto.getContentsId(), requestDto.getContentsType());

        if (existingLike.isPresent()) {
            throw new IllegalArgumentException("이미 좋아요를 눌렀습니다.");
        }

        // 본인이 작성한 게시물이나 댓글에 좋아요를 남길 수 없습니다.
        // POST
        if (requestDto.getContentsType() == ContentsTypeEnum.POST) {
            Post post = postRepository.findById(requestDto.getContentsId())
                    .orElseThrow(() -> new IllegalArgumentException("게시물을 찾을 수 없습니다."));
            if (post.getUser().getId().equals(currentUser.getId())) {
                throw new IllegalArgumentException("본인이 작성한 게시물에는 좋아요를 남길 수 없습니다.");
            }
            post.setLikeCount(post.getLikeCount() + 1); //변경 감지 -> 따로 save 필요없다.
        }
        //COMMENT
        else if (requestDto.getContentsType() == ContentsTypeEnum.COMMENT) {
            Comment comment = commentRepository.findById(requestDto.getContentsId())
                    .orElseThrow(() -> new IllegalArgumentException("댓글을 찾을 수 없습니다."));
            if (comment.getUser().getId().equals(currentUser.getId())) {
                throw new IllegalArgumentException("본인이 작성한 댓글에는 좋아요를 남길 수 없습니다.");
            }
            comment.setLikeCount(comment.getLikeCount() + 1);
        }

        Liked liked = new Liked(currentUser, requestDto.getContentsId(), requestDto.getContentsType());
        likedRepository.save(liked);

        return new LikedResponseDto(liked);
    }

    @Transactional
    public void removeLike(LikedRequestDto requestDto, AuthenticationUser user) {
        User currentUser = userRepository.findByUsercode(user.getUsername())
                .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));

        Liked existingLike = likedRepository.findByUserIdAndContentsIdAndContentsType(currentUser.getId(), requestDto.getContentsId(), requestDto.getContentsType())
                .orElseThrow(() -> new IllegalArgumentException("좋아요가 존재하지 않습니다."));

        // 좋아요 수 감소
        // POST
        if (requestDto.getContentsType() == ContentsTypeEnum.POST) {
            Post post = postRepository.findById(requestDto.getContentsId())
                    .orElseThrow(() -> new IllegalArgumentException("게시물을 찾을 수 없습니다."));
            post.setLikeCount(post.getLikeCount() - 1);
        }
        // COMMENT
        else if (requestDto.getContentsType() == ContentsTypeEnum.COMMENT) {
            Comment comment = commentRepository.findById(requestDto.getContentsId())
                    .orElseThrow(() -> new IllegalArgumentException("댓글을 찾을 수 없습니다."));
            comment.setLikeCount(comment.getLikeCount() - 1);
        }

        likedRepository.delete(existingLike);
    }
}


  • post
// PostController

package com.sparta.wildcard_newsfeed.domain.post.controller;

import com.sparta.wildcard_newsfeed.domain.comment.dto.CommentResponseDto;
import com.sparta.wildcard_newsfeed.domain.comment.dto.PostWithCommentsResponseDto;
import com.sparta.wildcard_newsfeed.domain.comment.service.CommentService;
import com.sparta.wildcard_newsfeed.domain.common.CommonResponseDto;
import com.sparta.wildcard_newsfeed.domain.post.dto.PostPageRequestDto;
import com.sparta.wildcard_newsfeed.domain.post.dto.PostPageResponseDto;
import com.sparta.wildcard_newsfeed.domain.post.dto.PostRequestDto;
import com.sparta.wildcard_newsfeed.domain.post.dto.PostResponseDto;
import com.sparta.wildcard_newsfeed.domain.post.repository.PostRepository;
import com.sparta.wildcard_newsfeed.domain.post.service.PostService;
import com.sparta.wildcard_newsfeed.security.AuthenticationUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/post")
@Tag(name = "Post 컨트롤러", description = "Post API")
public class PostController {

    private final PostService postService;
    private final CommentService commentService;
    private final PostRepository postRepository;

    // 게시물 등록
    @PostMapping
    @Operation(summary = "게시물 등록")
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "게시물 등록 성공",
                    content = @Content(mediaType = "application/json",
                            schema = @Schema(implementation = CommonResponseDto.class)))
    })
    public ResponseEntity<CommonResponseDto<PostResponseDto>> addPost(
            @AuthenticationPrincipal AuthenticationUser user,
            @Valid @ModelAttribute PostRequestDto postRequestDto
    ) {
        PostResponseDto postResponseDto = postService.addPost(postRequestDto, user);
        return ResponseEntity.ok()
                .body(CommonResponseDto.<PostResponseDto>builder()
                        .statusCode(HttpStatus.OK.value())
                        .message("게시물 등록 성공")
                        .data(postResponseDto)
                        .build());
    }

    // 게시물 전체 조회
    @GetMapping
    @Operation(summary = "게시물 전체 조회")
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "게시물 전체 조회 성공",
                    content = @Content(mediaType = "application/json",
                            schema = @Schema(implementation = CommonResponseDto.class)))
    })
    public ResponseEntity<CommonResponseDto<Object>> findAll() {
        List<PostResponseDto> posts = postService.findAll();

        return ResponseEntity.ok()
                .body(CommonResponseDto.builder()
                .statusCode(HttpStatus.OK.value())
                        .message("게시물 전체 조회 성공")
                        .data(posts)
                        .build());
    }

//    // 게시물 단일 조회 + 해당 게시물에 달린 댓글 전체 조회
    @GetMapping("/{postId}")
    @Operation(summary = "게시물 단일 조회")
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "게시물 단일 조회 성공",
                    content = @Content(mediaType = "application/json",
                            schema = @Schema(implementation = CommonResponseDto.class)))
    })
    public ResponseEntity<CommonResponseDto<PostWithCommentsResponseDto>> findById(@PathVariable(name = "postId") long id) {
        // 게시물 단일 조회
        PostResponseDto post = postService.findById(id);

        // 해당 게시물에 달린 댓글 전체 조회
        List<CommentResponseDto> comments = commentService.findAllCommentsByPostId(id);

        // Post와 Comments를 하나의 객체로 병합
        PostWithCommentsResponseDto postWithCommentsResponse = new PostWithCommentsResponseDto(post, comments);

        return ResponseEntity.ok()
                .body(CommonResponseDto.<PostWithCommentsResponseDto>builder()
                        .statusCode(HttpStatus.OK.value())
                        .message("게시물 단일 조회, 댓글 조회 성공")
                        .data(postWithCommentsResponse)
                        .build());
    }

    //게시물 수정
    @PutMapping("/{postId}")
    @Operation(summary = "게시물 수정")
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "게시물 수정 성공",
                    content = @Content(mediaType = "application/json",
                            schema = @Schema(implementation = CommonResponseDto.class)))
    })
    public ResponseEntity<CommonResponseDto<PostResponseDto>> updatePost(
            @AuthenticationPrincipal AuthenticationUser user,
            @Valid @ModelAttribute PostRequestDto postRequestDto,
            @PathVariable Long postId
    ) {
        PostResponseDto postResponseDto = postService.updatePost(postRequestDto, postId, user);
        return ResponseEntity.ok()
                .body(CommonResponseDto.<PostResponseDto>builder()
                        .statusCode(HttpStatus.OK.value())
                        .message("게시물 수정 성공")
                        .data(postResponseDto)
                        .build());
    }

    //게시물 삭제
    @DeleteMapping("/{postId}")
    @Operation(summary = "게시물 삭제")
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "게시물 삭제 성공",
                    content = @Content(mediaType = "application/json",
                            schema = @Schema(implementation = CommonResponseDto.class)))
    })
    public ResponseEntity<CommonResponseDto<PostResponseDto>> deletePost(
            @AuthenticationPrincipal AuthenticationUser user,
            @Valid @PathVariable Long postId
    ) {
        postService.deletePost(postId, user);
        return ResponseEntity.ok()
                .body(CommonResponseDto.<PostResponseDto>builder()
                        .statusCode(HttpStatus.OK.value())
                        .message("게시물 삭제 성공")
                        .build());
    }

    //페이지네이션
    @PostMapping("/page")
    @Operation(summary = "페이징")
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "페이징 성공",
                    content = @Content(mediaType = "application/json",
                            schema = @Schema(implementation = CommonResponseDto.class)))
    })
    public ResponseEntity<CommonResponseDto<Page<PostPageResponseDto>>> getPostPage(@Valid @RequestBody PostPageRequestDto requestDto) {
        Page<PostPageResponseDto> page = postService.getPostPage(requestDto);
        return ResponseEntity.ok()
                .body(CommonResponseDto.<Page<PostPageResponseDto>>builder()
                        .statusCode(HttpStatus.OK.value())
                        .message("게시물 페이지 조회 성공")
                        .data(page)
                        .build());
    }
}



// PostPageRequestDto

package com.sparta.wildcard_newsfeed.domain.post.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

@Getter
@NoArgsConstructor
@ToString
public class PostPageRequestDto {
    @Schema(description = "선택 페이지", example = "1")
    @NotNull(message = "선택 페이지 필수 입력 값입니다.")
    @Positive(message = "0이 아닌 양수만 가능합니다.")
    private int page; // 현재 페이지 0부터 시작

    @Schema(description = "게시글 수", example = "10")
    @NotNull(message = "게시글 수 필수 입력 값입니다.")
    private int size; // 한 페이지에 보이는 글 개수

    @Schema(description = "정렬 기준", example = "CREATE")
    @NotNull(message = "정렬 기준 필수 입력 값입니다.")
    private String sortBy; // 생성일자 최신순 or 좋아요 많은 순

    @Schema(description = "검색 기간 시작일", example = "2024-05-01")
    private String firstDate; // 생성일자 최신순 or 좋아요 많은 순

    @Schema(description = "검색 기간 마지막일", example = "2024-05-27")
    private String lastDate; // 생성일자 최신순 or 좋아요 많은 순

    /*
    - **페이지네이션**
        - 10개씩 페이지네이션하여, 각 페이지 당 뉴스피드 데이터가 10개씩 나오게 합니다.
    - **정렬 기능**
        - 생성일자 기준 최신순
        - 좋아요 많은 순
    - **기간별 검색 기능**
        - 예) 2024.05.01 ~ 2024.05.27 동안 작성된 뉴스피드 게시물 검색
     */
}



// PostPageResponseDto

package com.sparta.wildcard_newsfeed.domain.post.dto;

import com.fasterxml.jackson.annotation.JsonFormat;

import java.time.LocalDateTime;

// JPA -> DTO로 매핑할 때 호환이 잘 안되서 인터페이스로 받아야 한다고 한다
public interface PostPageResponseDto {
     Long getPostId();
     Long getUserId();
     String getTitle();
     String getContent();
     String getName();
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     LocalDateTime getCreatedAt();
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     LocalDateTime getUpdatedAt();
     Long getLikeCount();
}




// PostRequestDto

package com.sparta.wildcard_newsfeed.domain.post.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.web.multipart.MultipartFile;

import java.util.List;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class PostRequestDto {

    @Schema(description = "게시물 제목", example = "제목")
    @NotBlank(message = "제목은 필수 입력 값입니다.")
    private String title;

    @Schema(description = "게시물 내용", example = "내용")
    @NotBlank(message = "내용은 필수 입력 값입니다.")
    private String content;

    @Schema(description = "업로드 파일")
    private List<MultipartFile> files;
}



// PostResponseDto

package com.sparta.wildcard_newsfeed.domain.post.dto;

import com.fasterxml.jackson.annotation.JsonFormat;
import com.sparta.wildcard_newsfeed.domain.post.entity.Post;
import lombok.Getter;

import java.time.LocalDateTime;
import java.util.List;

@Getter
public class PostResponseDto {
    private Long id;
    private String title;
    private String content;
    private String username;
    private List<String> s3Urls;
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createdAt;
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime updatedAt;
    private Long likeCount;

    public PostResponseDto(Post post) {
        this.id = post.getId();
        this.title = post.getTitle();
        this.content = post.getContent();
        this.username = post.getUser().getName();
        this.createdAt = post.getCreatedAt();
        this.updatedAt = post.getUpdatedAt();
        this.likeCount = post.getLikeCount();
    }

    public PostResponseDto(Post post, List<String> s3Urls) {
        this.id = post.getId();
        this.title = post.getTitle();
        this.content = post.getContent();
        this.username = post.getUser().getName();
        this.createdAt = post.getCreatedAt();
        this.updatedAt = post.getUpdatedAt();
        this.s3Urls = s3Urls;
        this.likeCount = post.getLikeCount();
    }
}



// Post

package com.sparta.wildcard_newsfeed.domain.post.entity;

import com.sparta.wildcard_newsfeed.domain.comment.entity.Comment;
import com.sparta.wildcard_newsfeed.domain.common.TimeStampEntity;
import com.sparta.wildcard_newsfeed.domain.post.dto.PostRequestDto;
import com.sparta.wildcard_newsfeed.domain.user.entity.User;
import jakarta.persistence.*;
import lombok.*;

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

@Getter
// Todo 배포시 여기 @Setter 삭제
@Setter
@Entity
@NoArgsConstructor
public class Post extends TimeStampEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @Column(nullable = false)
    private String title;

    @Column(nullable = false)
    private String content;

    @Setter
    private Long likeCount;

    @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Comment> comments = new ArrayList<>();

    @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<PostMedia> postMedias = new ArrayList<>();

    public Post(PostRequestDto postRequestDto, User user) {
        this.user = user;
        this.title = postRequestDto.getTitle();
        this.content = postRequestDto.getContent();
        this.likeCount = 0L;
    }

    public void update(PostRequestDto postRequestDto) {
        this.title = postRequestDto.getTitle();
        this.content = postRequestDto.getContent();
    }

    public void setTestDateTime() {
        super.setDateTimeInit();
    }
}



// PostMedia

package com.sparta.wildcard_newsfeed.domain.post.entity;

import com.sparta.wildcard_newsfeed.domain.common.TimeStampEntity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@Entity
@NoArgsConstructor
public class PostMedia extends TimeStampEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id", nullable = false)
    private Post post;

    private String url;

    private String type;
}



// PostMediaRepository

package com.sparta.wildcard_newsfeed.domain.post.repository;

import com.sparta.wildcard_newsfeed.domain.post.entity.PostMedia;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface PostMediaRepository extends JpaRepository<PostMedia, Long> {
    List<PostMedia> findByPostId(Long postId);
}



// PostRepository

package com.sparta.wildcard_newsfeed.domain.post.repository;

import com.sparta.wildcard_newsfeed.domain.post.dto.PostPageResponseDto;
import com.sparta.wildcard_newsfeed.domain.post.entity.Post;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface PostRepository extends JpaRepository<Post, Long> {

    @Query(value = "select p.id as postId, u.id as userId, p.title, p.content, u.name, " +
            "p.created_at as createdAt, p.updated_at as updatedAt, " +
            "IF(c.count is null, 0, c.count) as likeCount " +
            "from (select id, count(*) as count " +
            "from (select a.id, a.title from post a " +
            "left join liked b on a.id = b.contents_id " +
            "where b.content_type = 'POST') a " +
            "group by a.id) c " +
            "right join post p on c.id = p.id " +
            "left join user u on u.id = p.user_id " +
            "where p.created_at between :startDate AND :endDate ", nativeQuery = true)
    Page<PostPageResponseDto> findPostPages(@Param("startDate") String startDate,
                                            @Param("endDate") String endDate,
                                            Pageable pageable);
}




// PostService

package com.sparta.wildcard_newsfeed.domain.post.service;

import com.sparta.wildcard_newsfeed.domain.file.service.FileService;
import com.sparta.wildcard_newsfeed.domain.post.dto.PostPageRequestDto;
import com.sparta.wildcard_newsfeed.domain.post.dto.PostPageResponseDto;
import com.sparta.wildcard_newsfeed.domain.post.dto.PostRequestDto;
import com.sparta.wildcard_newsfeed.domain.post.dto.PostResponseDto;
import com.sparta.wildcard_newsfeed.domain.post.entity.Post;
import com.sparta.wildcard_newsfeed.domain.post.entity.PostMedia;
import com.sparta.wildcard_newsfeed.domain.post.repository.PostMediaRepository;
import com.sparta.wildcard_newsfeed.domain.post.repository.PostRepository;
import com.sparta.wildcard_newsfeed.domain.user.entity.User;
import com.sparta.wildcard_newsfeed.domain.user.repository.UserRepository;
import com.sparta.wildcard_newsfeed.exception.customexception.FileException;
import com.sparta.wildcard_newsfeed.security.AuthenticationUser;
import com.sparta.wildcard_newsfeed.util.FileUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import java.time.LocalDate;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

@Slf4j
@Service
@RequiredArgsConstructor
public class PostService {

    private final PostRepository postRepository;
    private final UserRepository userRepository;
    private final PostMediaRepository postMediaRepository;
    private final FileService fileService;
    private final FileUtils fileUtils;

    @Transactional
    public PostResponseDto addPost(PostRequestDto postRequestDto, AuthenticationUser user) {
        User byUsercode = userRepository.findByUsercode(user.getUsername())
                .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));

        Post post = new Post(postRequestDto, byUsercode);
        postRepository.save(post);

        List<PostMedia> postMediaList = createPostMediaList(postRequestDto, post);
        postMediaRepository.saveAll(postMediaList);

        List<String> s3Urls = getS3UrlsFromPostMediaList(postMediaList);

        return new PostResponseDto(post, s3Urls);
    }

    public PostResponseDto findById(long id) {
        Post post = findPostById(id);

        List<String> s3Urls = getS3UrlsFromPostMediaList(postMediaRepository.findByPostId(id));

        return new PostResponseDto(post, s3Urls);
    }

    public List<PostResponseDto> findAll() {
        List<Post> postlist = postRepository.findAll();
        return postlist.stream()
                .sorted(Comparator.comparing(Post::getCreatedAt).reversed())
                .map(PostResponseDto::new)
                .toList();
    }

    @Transactional
    public PostResponseDto updatePost(PostRequestDto postRequestDto, Long postId, AuthenticationUser user) {
        Post post = findPostById(postId);

        validateUser(post, user);

        post.update(postRequestDto);
        postRepository.save(post);

        List<PostMedia> postMediaList = createPostMediaList(postRequestDto, post);
        post.getPostMedias().clear();
        postMediaRepository.saveAll(postMediaList);

        List<String> s3Urls = getS3UrlsFromPostMediaList(postMediaList);

        return new PostResponseDto(post, s3Urls);
    }

    @Transactional
    public void deletePost(Long postId, AuthenticationUser user) {
        Post post = findPostById(postId);

        validateUser(post, user);

        postRepository.delete(post);
    }

    private List<String> getS3UrlsFromPostMediaList(List<PostMedia> postMediaList) {
        List<String> s3Urls = new ArrayList<>();
        if (!postMediaList.isEmpty()) {
            s3Urls = postMediaList.stream().map(PostMedia::getUrl).toList();
        }
        return s3Urls;
    }

    private List<PostMedia> createPostMediaList(PostRequestDto postRequestDto, Post post) {
        List<PostMedia> postMediaList = new ArrayList<>();
        if (!postRequestDto.getFiles().isEmpty()) {
            if (postRequestDto.getFiles().size() > 5) {
                throw new FileException("한 게시물당 최대 5개까지만 저장 가능합니다.");
            }
            fileUtils.validFile(postRequestDto.getFiles());

            for (MultipartFile file : postRequestDto.getFiles()) {
                String s3Url = fileService.uploadFileToS3(file);
                PostMedia postMedia = new PostMedia();
                postMedia.setUrl(s3Url);
                postMedia.setPost(post);
                postMedia.setType(fileUtils.extractExtension(file.getOriginalFilename()));
                postMediaList.add(postMedia);
            }
        }
        return postMediaList;
    }

    private Post findPostById(long id) {
        return postRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 게시물이 존재하지 않습니다."));
    }

    private void validateUser(Post post, AuthenticationUser user) {
        if (!post.getUser().getUsercode().equals(user.getUsername())) {
            throw new IllegalArgumentException("작성자만 할 수 있습니다.");
        }
    }

    @Transactional
    public Page<PostPageResponseDto> getPostPage(PostPageRequestDto requestDto) {
        log.info(requestDto.toString());

        Sort.Direction direction = Sort.Direction.DESC; //ASC 오름차순 , DESC 내림차순
        //- 생성일자 기준 최신 - 좋아요 많은 순

        // --- 정렬 방식 ---
        //CREATE  or  LIKED
        String sortBy = "created_at";
        if (requestDto.getSortBy().equals("CREATE")) {
            sortBy = "createdAt";
        } else if (requestDto.getSortBy().equals("LIKED")) {
            sortBy = "likeCount";
        } else
            throw new IllegalArgumentException("정렬은 CREATE 또는 LIKED 만 입력 가능합니다.");

        Sort sort = Sort.by(direction, sortBy);
        Pageable pageable = PageRequest.of(requestDto.getPage() - 1, requestDto.getSize(), sort);

        Page<PostPageResponseDto> postList = null;

        //---날짜 부분 ---
        LocalDate lastDate = LocalDate.now();
        LocalDate firstDate = LocalDate.parse("2000-01-01");
        // null 이면 모든 날짜를 조회

        if (requestDto.getLastDate() != null && requestDto.getFirstDate() != null) {
            // 날짜 정보가 있으면 해당 날짜만 조회
            try {
                lastDate = LocalDate.parse(requestDto.getLastDate());
                firstDate = LocalDate.parse(requestDto.getFirstDate());
            } catch (Exception e) {
                throw new IllegalArgumentException("날짜 포맷이 정상적이지 않습니다.");
            }
        }

        postList = postRepository.findPostPages(firstDate.atStartOfDay().toString(), lastDate.atTime(LocalTime.MAX).toString(), pageable);

        if (postList.getTotalElements() <= 0) {
            log.error("페이지 없음");
            throw new IllegalArgumentException("페이지가 존재하지 않습니다.");
        }
        if (postList.getTotalPages() < requestDto.getPage()) {
            throw new IllegalArgumentException("유효한 페이지 번호가 아닙니다.");
        }

        return postList;
    }
}


  • token
// TokenController

package com.sparta.wildcard_newsfeed.domain.token.controller;

import com.sparta.wildcard_newsfeed.domain.common.CommonResponseDto;
import com.sparta.wildcard_newsfeed.domain.token.dto.TokenResponseDto;
import com.sparta.wildcard_newsfeed.domain.token.service.TokenService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import static com.sparta.wildcard_newsfeed.security.jwt.JwtConstants.ACCESS_TOKEN_HEADER;
import static com.sparta.wildcard_newsfeed.security.jwt.JwtConstants.REFRESH_TOKEN_HEADER;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/auth")
@Tag(name = "Token 컨트롤러", description = "Token API")
public class TokenController {

    private final TokenService tokenService;

    @PostMapping("/reissue")
    @Operation(summary = "토큰 재발급")
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "토큰 재발급 성공",
                    content = @Content(mediaType = "application/json",
                            schema = @Schema(implementation = CommonResponseDto.class)))
    })
    public ResponseEntity<CommonResponseDto> refreshTokenReissue(HttpServletRequest request) {
        log.info("access token 재발급");
        String refreshTokenHeader = tokenService.validateTokenExpire(request);

        TokenResponseDto responseDto = tokenService.getFindUser(refreshTokenHeader);

        CommonResponseDto commonResponseDto = CommonResponseDto.builder()
                .message(responseDto.getUsercode() + "님 재발급 성공")
                .statusCode(HttpStatus.OK.value())
                .build();

        return ResponseEntity.ok()
                .header(ACCESS_TOKEN_HEADER, responseDto.getAccessToken())
                .header(REFRESH_TOKEN_HEADER, responseDto.getRefreshToken())
                .body(commonResponseDto);

    }
}



// TokenResponseDto

package com.sparta.wildcard_newsfeed.domain.token.dto;

import com.sparta.wildcard_newsfeed.domain.user.dto.UserResponseFromTokenDto;
import com.sparta.wildcard_newsfeed.security.jwt.dto.TokenDto;
import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
public class TokenResponseDto {
    private String usercode;
    private String accessToken;
    private String refreshToken;

    public static TokenResponseDto of(UserResponseFromTokenDto findUser, TokenDto tokenDto) {
        return TokenResponseDto.builder()
                .usercode(findUser.getUsercode())
                .accessToken(tokenDto.getAccessToken())
                .refreshToken(tokenDto.getRefreshToken())
                .build();
    }
}



// TokenService

package com.sparta.wildcard_newsfeed.domain.token.service;

import com.sparta.wildcard_newsfeed.domain.token.dto.TokenResponseDto;
import com.sparta.wildcard_newsfeed.domain.user.dto.UserResponseFromTokenDto;
import com.sparta.wildcard_newsfeed.domain.user.service.UserService;
import com.sparta.wildcard_newsfeed.exception.customexception.TokenNotFoundException;
import com.sparta.wildcard_newsfeed.security.jwt.JwtUtil;
import com.sparta.wildcard_newsfeed.security.jwt.dto.TokenDto;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class TokenService {

    private final JwtUtil jwtUtil;
    private final UserService userService;

    public String validateTokenExpire(HttpServletRequest request) {
        String accessTokenHeader = jwtUtil.getAccessTokenFromHeader(request);
        String refreshTokenHeader = jwtUtil.getRefreshTokenFromHeader(request);

        //access 또는 refresh가 없는 경우
        if (accessTokenHeader == null || refreshTokenHeader == null) {
            throw new TokenNotFoundException("토큰을 찾을 수 없습니다.");
        }
        //refresh token이 유효하지 않은 경우
        if (!jwtUtil.validateToken(request, refreshTokenHeader)) {
            throw new TokenNotFoundException("유효하지 않은 토큰입니다.");
        }
        return refreshTokenHeader;
    }

    @Transactional
    public TokenResponseDto getFindUser(String refreshTokenHeader) {
        String usercode = jwtUtil.getUserInfoFromToken(refreshTokenHeader).getSubject();
        UserResponseFromTokenDto findUserDto = userService.findByUsercode(usercode);

        TokenDto tokenDto = jwtUtil.generateAccessTokenAndRefreshToken(findUserDto.getUsercode());
        String refreshTokenValue = tokenDto.getRefreshToken().substring(7);
        userService.updateRefreshToken(findUserDto.getUsercode(), refreshTokenValue);

        return TokenResponseDto.of(findUserDto, tokenDto);

    }
}

  • user
// EmailController

package com.sparta.wildcard_newsfeed.domain.user.controller;

import com.sparta.wildcard_newsfeed.domain.common.CommonResponseDto;
import com.sparta.wildcard_newsfeed.domain.post.dto.PostResponseDto;
import com.sparta.wildcard_newsfeed.domain.user.dto.UserEmailRequestDto;
import com.sparta.wildcard_newsfeed.domain.user.service.UserService;
import com.sparta.wildcard_newsfeed.security.AuthenticationUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/email")
@Tag(name = "Email 컨트롤러", description = "email API")
public class EmailController {

    private final UserService userService;

    @PostMapping("/verify")
    @Operation(summary = "이메일 인증")
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "이메일 인증 성공",
                    content = @Content(mediaType = "application/json",
                            schema = @Schema(implementation = CommonResponseDto.class)))
    })
    public ResponseEntity<CommonResponseDto<PostResponseDto>> verifyEmailAuth(
            @AuthenticationPrincipal AuthenticationUser loginUser,
            @Valid @RequestBody UserEmailRequestDto requestDto
    ) {
        userService.verifyAuthCode(loginUser, requestDto);

        return ResponseEntity.ok()
                .body(CommonResponseDto.<PostResponseDto>builder()
                        .statusCode(HttpStatus.OK.value())
                        .message("이메일 인증 성공")
                        .build());
    }
}



// UserController

package com.sparta.wildcard_newsfeed.domain.user.controller;

import com.sparta.wildcard_newsfeed.domain.common.CommonResponseDto;
import com.sparta.wildcard_newsfeed.domain.user.dto.UserRequestDto;
import com.sparta.wildcard_newsfeed.domain.user.dto.UserResponseDto;
import com.sparta.wildcard_newsfeed.domain.user.dto.UserSignupRequestDto;
import com.sparta.wildcard_newsfeed.domain.user.dto.UserSignupResponseDto;
import com.sparta.wildcard_newsfeed.domain.user.service.UserService;
import com.sparta.wildcard_newsfeed.security.AuthenticationUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.util.Map;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/user")
@Tag(name = "User 컨트롤러", description = "user API")
public class UserController {
    private final UserService userService;

    @PostMapping("/signup")
    @Operation(summary = "회원가입")
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "회원가입 성공",
                    content = @Content(mediaType = "application/json",
                            schema = @Schema(implementation = CommonResponseDto.class)))
    })
    public ResponseEntity<CommonResponseDto<UserSignupResponseDto>> signup(@Valid @RequestBody UserSignupRequestDto requestDto) {
        UserSignupResponseDto responseDto = userService.signup(requestDto);

        return ResponseEntity.ok()
                .body(CommonResponseDto.<UserSignupResponseDto>builder()
                        .statusCode(HttpStatus.OK.value())
                        .message("회원가입 성공")
                        .data(responseDto)
                        .build());
    }

    @DeleteMapping("/resign")
    @Operation(summary = "회원탈퇴")
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "회원탈퇴 성공",
                    content = @Content(mediaType = "application/json",
                            schema = @Schema(implementation = CommonResponseDto.class)))
    })
    public ResponseEntity<CommonResponseDto<UserSignupResponseDto>> resign(
            @AuthenticationPrincipal AuthenticationUser user,
            @Valid @RequestBody Map<String, String> map
    ) {
        userService.resign(user, map.get("password"));

        return ResponseEntity.ok()
                .body(CommonResponseDto.<UserSignupResponseDto>builder()
                        .statusCode(HttpStatus.OK.value())
                        .message("회원탈퇴 성공")
                        .build());
    }

    @GetMapping("/{userId}")
    @Operation(summary = "프로필 조회")
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "프로필 조회 성공",
                    content = @Content(mediaType = "application/json",
                            schema = @Schema(implementation = CommonResponseDto.class)))
    })
    public ResponseEntity<CommonResponseDto<UserResponseDto>> getUser(@PathVariable Long userId) {
        UserResponseDto userResponseDto = userService.findById(userId);

        return ResponseEntity.ok()
                .body(CommonResponseDto.<UserResponseDto>builder()
                        .statusCode(HttpStatus.OK.value())
                        .message("프로필 조회 성공")
                        .data(userResponseDto)
                        .build());
    }

    @PutMapping("/{userId}")
    @Operation(summary = "프로필 수정")
    @ApiResponses(value = {
            @ApiResponse(responseCode = "200", description = "프로필 수정 성공",
                    content = @Content(mediaType = "application/json",
                            schema = @Schema(implementation = CommonResponseDto.class)))
    })
    public ResponseEntity<CommonResponseDto<UserResponseDto>> updateUser(
            @AuthenticationPrincipal AuthenticationUser loginUser,
            @PathVariable Long userId,
            @Valid @RequestBody UserRequestDto requestDto
    ) {
        UserResponseDto userResponseDto = userService.updateUser(loginUser, userId, requestDto);

        return ResponseEntity.ok()
                .body(CommonResponseDto.<UserResponseDto>builder()
                        .statusCode(HttpStatus.OK.value())
                        .message("프로필 수정 성공")
                        .data(userResponseDto)
                        .build());
    }

    @PostMapping("/{userId}/profile-image")
    @Operation(summary = "프로필 사진 업로드")
    @ApiResponses(value = {
            @ApiResponse(responseCode = "200", description = "프로필 사진 업로드 성공",
                    content = @Content(mediaType = "application/json",
                            schema = @Schema(implementation = CommonResponseDto.class)))
    })
    public ResponseEntity<CommonResponseDto<String>> uploadProfileImage(
            @AuthenticationPrincipal AuthenticationUser loginUser,
            @PathVariable Long userId,
            @RequestParam MultipartFile file
    ) {
        String savedS3Url = userService.uploadProfileImage(loginUser, userId, file);

        return ResponseEntity.ok()
                .body(CommonResponseDto.<String>builder()
                        .statusCode(HttpStatus.OK.value())
                        .message("프로필 사진 업로드 성공")
                        .data(savedS3Url)
                        .build());
    }
}




// EmailHtmlConstant

package com.sparta.wildcard_newsfeed.domain.user.dto.emailtemplate;

public class EmailHtmlConstant {

    private static final String HEADER_TITLE = "<span style=\"font-weight: bold;font-size: 48px;\">" +
            "<span style=\"color:#ff8c00\">B15</span>\n" +
            "</span>\n";
    private static String TAIL = "<hr>\n" +
            "<br>\n" +
            "<span style=\"font-size:12px;\"> " +
            "      <br>감사합니다. <br> 오저먹 " +
            "</span>\n";

    public static String MAIL_BASIC_FORMAT = "<span style=\"font-family:Arial,sans-serif\">\n" +
            HEADER_TITLE +
            "%s" +
            TAIL +
            "</span>";
}




// EmailTemplate

package com.sparta.wildcard_newsfeed.domain.user.dto.emailtemplate;

import lombok.Getter;

import static com.sparta.wildcard_newsfeed.domain.user.dto.emailtemplate.EmailHtmlConstant.MAIL_BASIC_FORMAT;

@Getter
public enum EmailTemplate {

    AUTH_EMAIL("오저먹 이메일 인증 요청입니다.", MAIL_BASIC_FORMAT.formatted(
            "  <br>\n" +
                    "  <br>\n" +
                    "  <span style=\"font-size:18px;\">안녕하세요, 오저먹 계정 생성을 환영합니다! <br>요청하신 인증코드는 아래와 같습니다 <br>\n" +
                    "  <span style=\"font-size: 30px;color:#ff8c00;font-weight: bold\">%s</span>\n" +
                    "  <br>\n" +
                    "  <br>\n"
    ));

    private String sub;
    private String body;

    EmailTemplate(String sub, String body) {
        this.sub = sub;
        this.body = body;
    }

    public String formatBody(String authKey) {
        return body.formatted(authKey);
    }
}




// EmailSendEvent

package com.sparta.wildcard_newsfeed.domain.user.dto;

import lombok.Builder;
import lombok.Getter;
import lombok.ToString;

@Getter
@ToString
@Builder
public class EmailSendEvent {
    private String subject;
    private String body;
    private String to;

    public static EmailSendEvent of(String subject, String body, String to) {
        return EmailSendEvent.builder()
                .subject(subject)
                .body(body)
                .to(to)
                .build();
    }
}



// UserEmailRequestDto

package com.sparta.wildcard_newsfeed.domain.user.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public class UserEmailRequestDto {

    @NotBlank(message = "인증 번호를 입력해 주세요")
    @Schema(description = "이메일 인증번호", example = "")
    private String authCode;
}



// UserRequestDto

package com.sparta.wildcard_newsfeed.domain.user.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Getter;

@Getter
public class UserRequestDto {

    @Schema(description = "사용자 이름", example = "홍길동")
    private String name;

    @Schema(description = "사용자 Email", example = "aaa@gmail.com")
    @Pattern(regexp = "^[a-zA-Z0-9]+(?:\\.[a-zA-Z0-9]+)*@(?:[a-zA-Z]+\\.)+[a-zA-Z]{2,7}$", message = "이메일 형식이 올바르지 않습니다.")
    private String email;

    @Schema(description = "한 줄 소개", example = "안녕하세요!")
    private String introduce;

    @Schema(description = "현재 비밀번호", example = "currentPWD12@@")
    @Size(min = 10, message = "비밀번호는 최소 10글자 이상이어야 합니다.")
    @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&]).*$", message = "비밀번호는 대소문자 포함 영문 + 숫자 + 특수문자를 최소 1글자씩 포함해야 합니다.")
    private String password;

    @Schema(description = "바꿀 비밀번호", example = "changePWD77!!")
    @Size(min = 10, message = "비밀번호는 최소 10글자 이상이어야 합니다.")
    @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&]).*$", message = "비밀번호는 대소문자 포함 영문 + 숫자 + 특수문자를 최소 1글자씩 포함해야 합니다.")
    private String changePassword;

    public void encryptPassword(String encryptedPassword) {
        this.changePassword = encryptedPassword;
    }
}



// UserResponseDto

package com.sparta.wildcard_newsfeed.domain.user.dto;

import com.sparta.wildcard_newsfeed.domain.user.entity.User;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class UserResponseDto {

    @Schema(description = "사용자 ID")
    private String usercode;
    @Schema(description = "사용자 이름")
    private String name;
    @Schema(description = "한 줄 소개")
    private String introduce;
    @Schema(description = "사용자 Email")
    private String email;
    @Schema(description = "프로필 사진 주소")
    private String profileImageUrl;

    public UserResponseDto(User user) {
        this.usercode = user.getUsercode();
        this.name = user.getName();
        this.introduce = user.getIntroduce();
        this.email = user.getEmail();
        this.profileImageUrl = user.getProfileImageUrl();
    }
}




// UserResponseFromTokenDto

package com.sparta.wildcard_newsfeed.domain.user.dto;

import com.sparta.wildcard_newsfeed.domain.user.entity.User;
import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
public class UserResponseFromTokenDto {
    private String usercode;

    public static UserResponseFromTokenDto of(User user) {
        return UserResponseFromTokenDto.builder()
                .usercode(user.getUsercode())
                .build();
    }
}



// UserSignupRequestDto

package com.sparta.wildcard_newsfeed.domain.user.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.validator.constraints.Length;

@Getter
@NoArgsConstructor
public class UserSignupRequestDto {

    @Schema(description = "사용자 ID", example = "userid1234")
    @Pattern(regexp = "^[a-zA-Z0-9]+$", message = "대소문자 포함 영문 + 숫자만 입력해 주세요")
    @Size(min = 10, max = 20, message = "최소 10자 이상, 20자 이하로 입력해 주세요")
    @NotBlank(message = "아이디를 작성해주세요")
    private String usercode;

    @Schema(description = "사용자 이름", example = "currentPWD12@@")
    @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&]).*$", message = "비밀번호는 대소문자 포함 영문 + 숫자 + 특수문자를 최소 1글자씩 포함해 주세요")
    @Size(min = 10, message = "최소 10자 이상 입력해 주세요")
    @NotBlank(message = "비밀번호를 작성해주세요")
    private String password;

    @Schema(description = "사용자 이메일", example = "test@gmail.com")
    @Email(regexp = "^[a-zA-Z0-9+-\\_.]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$",
            message = "이메일 형식에 맞지 않습니다.")
    @NotBlank(message = "이메일의 입력 값이 없습니다.")
    @Length(message = "이메일 입력 범위를 초과하였습니다.")
    private String email;

}




// UserSignupResponseDto

package com.sparta.wildcard_newsfeed.domain.user.dto;

import com.sparta.wildcard_newsfeed.domain.user.entity.User;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class UserSignupResponseDto {
    private String usercode;
    private String email;

    public UserSignupResponseDto(User user) {
        usercode = user.getUsercode();
        email = user.getEmail();
    }
}



// AuthCodeHistory

package com.sparta.wildcard_newsfeed.domain.user.entity;

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

import java.time.LocalDateTime;

@Getter
@Entity

@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class AuthCodeHistory {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    private String autoCode;

    private LocalDateTime expireDate;

    @Builder
    public AuthCodeHistory(User user, String autoCode, LocalDateTime expireDate) {
        this.user = user;
        this.autoCode = autoCode;
        this.expireDate = expireDate;
    }
}



// User

package com.sparta.wildcard_newsfeed.domain.user.entity;

import com.sparta.wildcard_newsfeed.domain.common.TimeStampEntity;
import com.sparta.wildcard_newsfeed.domain.user.dto.UserRequestDto;
import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDateTime;

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User extends TimeStampEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String usercode; // 사용자 ID
    private String password;
    private String name; // 이름
    private String email;
    private String introduce;
    @Setter
    private String refreshToken;
    @Enumerated(EnumType.STRING)
    private UserStatusEnum userStatus; //회원상태코드
    private LocalDateTime authUserAt; //상태 변경 시간

    @Enumerated(EnumType.STRING)
    private UserRoleEnum userRoleEnum;

    @Setter
    private String profileImageUrl;

    @Builder
    public User(String usercode, String password, String name, String email, String introduce, UserStatusEnum userStatus, LocalDateTime authUserAt, UserRoleEnum userRoleEnum) {
        this.usercode = usercode;
        this.password = password;
        this.name = name;
        this.email = email;
        this.introduce = introduce;
        this.userStatus = userStatus;
        this.authUserAt = authUserAt;
        this.userRoleEnum = userRoleEnum;
    }

    /**
     * 가입 시 사용
     **/
    public User(String usercode, String password, String email) {
        this.usercode = usercode;
        this.password = password;
        this.name = usercode;
        this.email = email;
        //유저 이름의 기본 값은 사용자 ID
        this.userStatus = UserStatusEnum.UNAUTHORIZED;
        this.authUserAt = LocalDateTime.now();
        this.userRoleEnum = UserRoleEnum.USER;
        //가입 시 회원상태코드는 정상과 상태 변경 시간 적용
    }

    /**
     * 회원 탈퇴 시 사용
     **/
    public void setUserStatus(UserStatusEnum userStatus) {
        this.userStatus = userStatus;
        this.authUserAt = LocalDateTime.now();
    }

    public void update(UserRequestDto requestDto) {
        this.password = requestDto.getChangePassword() != null ? requestDto.getChangePassword() : this.password;
        this.name = requestDto.getName() != null ? requestDto.getName() : this.name;
        this.email = requestDto.getEmail() != null ? requestDto.getEmail() : this.email;
        this.introduce = requestDto.getIntroduce() != null ? requestDto.getIntroduce() : this.introduce;
    }

    public void updateUserStatus() {
        this.userStatus = UserStatusEnum.ENABLED;
        this.authUserAt = LocalDateTime.now();
    }
}



// UserRoleEnum

package com.sparta.wildcard_newsfeed.domain.user.entity;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum UserRoleEnum {
    USER("USER"),
    ADMIN("ADMIN");

    private final String roleValue;
}



// UserStatusEnum

package com.sparta.wildcard_newsfeed.domain.user.entity;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum UserStatusEnum {
    ENABLED("enable"),
    DISABLED("disable"),
    UNAUTHORIZED("unauthorized");
    private final String status;
}



// AuthCodeRepository

package com.sparta.wildcard_newsfeed.domain.user.repository;

import com.sparta.wildcard_newsfeed.domain.user.entity.AuthCodeHistory;
import com.sparta.wildcard_newsfeed.domain.user.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface AuthCodeRepository extends JpaRepository<AuthCodeHistory, Long> {
    Optional<AuthCodeHistory> findTop1ByUserAndAutoCodeOrderByExpireDateDesc(User user, String autoCode);
}



// UserRepository

package com.sparta.wildcard_newsfeed.domain.user.repository;

import com.sparta.wildcard_newsfeed.domain.user.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUsercode(String usercode);

    Optional<User> findByUsercodeOrEmail(String usercode, String email);
}




// AuthCodeService

package com.sparta.wildcard_newsfeed.domain.user.service;

import com.sparta.wildcard_newsfeed.domain.user.entity.AuthCodeHistory;
import com.sparta.wildcard_newsfeed.domain.user.entity.User;
import com.sparta.wildcard_newsfeed.domain.user.repository.AuthCodeRepository;
import com.sparta.wildcard_newsfeed.exception.customexception.AuthCodeExpireException;
import com.sparta.wildcard_newsfeed.exception.customexception.AuthCodeNoMatchException;
import com.sparta.wildcard_newsfeed.exception.customexception.AuthCodeNotFoundException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.UUID;

@Slf4j
@Primary
@Service
@RequiredArgsConstructor
public class AuthCodeService {

    private final AuthCodeRepository authCodeRepository;
    private static final Long MAX_EXPIRE_TIME = 180L;

    /**
     * 등록
     */
    @Transactional
    public AuthCodeHistory addAuthCode(User user) {
        AuthCodeHistory authCodeHistory = AuthCodeHistory.builder()
                .user(user)
                .autoCode(createAuthCode())
                .expireDate(LocalDateTime.now().plusSeconds(MAX_EXPIRE_TIME))
                .build();

        return authCodeRepository.save(authCodeHistory);
    }

    /**
     * 조회 및 검증
     */
    @Transactional(readOnly = true)
    public void findByAutoCode(User user, String authCode) {
        AuthCodeHistory findAuthCode = authCodeRepository.findTop1ByUserAndAutoCodeOrderByExpireDateDesc(user, authCode)
                .orElseThrow(AuthCodeNotFoundException::new);

        if (!authCode.equals(findAuthCode.getAutoCode())) {
            throw new AuthCodeNoMatchException();
        }

        if (!user.equals(findAuthCode.getUser())) {
            throw new AuthCodeNoMatchException();
        }

        if (findAuthCode.getExpireDate().isBefore(LocalDateTime.now())) {
            throw new AuthCodeExpireException();
        }
    }

    /**
     * Auto 생성
     */
    private String createAuthCode() {
        return UUID.randomUUID().toString();
    }
}



// MailService

package com.sparta.wildcard_newsfeed.domain.user.service;

import com.sparta.wildcard_newsfeed.domain.user.dto.EmailSendEvent;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Primary;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.event.TransactionalEventListener;

@Slf4j
@Primary
@Service
@RequiredArgsConstructor
public class MailService {

    private final JavaMailSender javaMailSender;

    private static final String SENDER_EMAIL = "b15wildcard@gmail.com";

    @Async
    @TransactionalEventListener
    public void sendEmail(EmailSendEvent emailSendEvent) {
        log.info("메일 전송 시도 ");
        try {
            MimeMessage mimeMessage = javaMailSender.createMimeMessage();
            MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage, false, "UTF-8");
            mimeMessageHelper.setFrom(SENDER_EMAIL);
            mimeMessageHelper.setTo(emailSendEvent.getTo());
            mimeMessageHelper.setSubject(emailSendEvent.getSubject());
            mimeMessageHelper.setText(emailSendEvent.getBody(), true);

            javaMailSender.send(mimeMessage);
        } catch (MessagingException e) {
            log.error("메일 전송 실패 ", e);
        }
    }
}



// UserService

package com.sparta.wildcard_newsfeed.domain.user.service;

import com.sparta.wildcard_newsfeed.domain.file.service.FileService;
import com.sparta.wildcard_newsfeed.domain.user.dto.*;
import com.sparta.wildcard_newsfeed.domain.user.entity.AuthCodeHistory;
import com.sparta.wildcard_newsfeed.domain.user.entity.User;
import com.sparta.wildcard_newsfeed.domain.user.entity.UserStatusEnum;
import com.sparta.wildcard_newsfeed.domain.user.repository.UserRepository;
import com.sparta.wildcard_newsfeed.exception.customexception.UserNotFoundException;
import com.sparta.wildcard_newsfeed.security.AuthenticationUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import java.util.Objects;

import static com.sparta.wildcard_newsfeed.domain.user.dto.emailtemplate.EmailTemplate.AUTH_EMAIL;

@Slf4j
@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
    private final AuthCodeService authCodeService;
    private final PasswordEncoder passwordEncoder;
    private final ApplicationEventPublisher eventPublisher;
    private final FileService fileService;

    @Transactional
    public UserSignupResponseDto signup(UserSignupRequestDto requestDto) {
        String usercode = requestDto.getUsercode();
        String email = requestDto.getEmail();

        userRepository.findByUsercodeOrEmail(usercode, email).ifPresent(u -> {
            throw new IllegalArgumentException("이미 가입한 아이디 또는 이메일이 있습니다.");
        });

        //Bcrypt 암호화
        String pwd = passwordEncoder.encode(requestDto.getPassword());
        User user = userRepository.save(new User(usercode, pwd, email));

        //autocode 생성 및 등록
        AuthCodeHistory authCodeHistory = authCodeService.addAuthCode(user);

        //메일 생성 후 전송
        eventPublisher.publishEvent(EmailSendEvent.of(AUTH_EMAIL.getSub(), AUTH_EMAIL.formatBody(authCodeHistory.getAutoCode()), user.getEmail()));

        return new UserSignupResponseDto(user);
    }

    @Transactional
    public void resign(AuthenticationUser user, String password) {
        String usercode = user.getUsername();

        User findUser = userRepository.findByUsercode(usercode)
                .orElseThrow(() -> new NullPointerException("해당하는 회원이 없습니다!!"));

        if (findUser.getUserStatus() == UserStatusEnum.DISABLED) {
            throw new IllegalArgumentException("이미 탈퇴한 사용자입니다.");
        }

        if (!passwordEncoder.matches(password, findUser.getPassword())) {
            throw new IllegalArgumentException("비밀번호가 일치하지 않습니다!!");
        }

        findUser.setUserStatus(UserStatusEnum.DISABLED);
    }

    @Transactional(readOnly = true)
    public UserResponseDto findById(Long userId) {
        User findUser = userRepository.findById(userId)
                .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));

        if (findUser.getUserStatus() == UserStatusEnum.DISABLED) {
            throw new IllegalArgumentException("이미 탈퇴한 사용자입니다.");
        }

        return new UserResponseDto(findUser);
    }

    @Transactional
    public UserResponseDto updateUser(AuthenticationUser loginUser, Long userId, UserRequestDto requestDto) {
        User findUser = userRepository.findById(userId)
                .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));

        if (findUser.getUserStatus() == UserStatusEnum.DISABLED) {
            throw new IllegalArgumentException("이미 탈퇴한 사용자입니다.");
        }

        if (!Objects.equals(findUser.getUsercode(), loginUser.getUsername())) {
            throw new IllegalArgumentException("사용자가 다릅니다.");
        }

        if (requestDto.getPassword() != null) {
            if (requestDto.getChangePassword() == null) {
                throw new IllegalArgumentException("변경할 비밀번호을 입력해 주세요.");
            }
        }
        if (requestDto.getChangePassword() != null) {
            if (requestDto.getPassword() == null) {
                throw new IllegalArgumentException("현재 비밀번호를 입력해 주세요.");
            }
        }
        if (requestDto.getPassword() != null && requestDto.getChangePassword() != null) {
            if (!passwordEncoder.matches(requestDto.getPassword(), loginUser.getPassword())
                    || !passwordEncoder.matches(requestDto.getPassword(), findUser.getPassword())) {
                throw new IllegalArgumentException("비밀번호가 일치하지 않습니다.");
            }
            if (requestDto.getPassword().equals(requestDto.getChangePassword())) {
                throw new IllegalArgumentException("변경하려는 비밀번호와 현재 비밀번호가 같습니다.");
            }
        }

        requestDto.encryptPassword(passwordEncoder.encode(requestDto.getChangePassword()));
        findUser.update(requestDto);

        User savedUser = userRepository.save(findUser);
        return new UserResponseDto(savedUser);
    }

    @Transactional(readOnly = true)
    public UserResponseFromTokenDto findByUsercode(String usercode) {
        User user = userRepository.findByUsercode(usercode).orElseThrow(UserNotFoundException::new);
        return UserResponseFromTokenDto.of(user);
    }

    @Transactional
    public void updateRefreshToken(String usercode, String refreshToken) {
        User user = userRepository.findByUsercode(usercode).orElseThrow(UserNotFoundException::new);
        user.setRefreshToken(refreshToken);
    }

    @Transactional
    public void verifyAuthCode(AuthenticationUser loginUser, UserEmailRequestDto requestDto) {
        User findUser = userRepository.findByUsercode(loginUser.getUsername()).orElseThrow(UserNotFoundException::new);

        authCodeService.findByAutoCode(findUser, requestDto.getAuthCode());
        findUser.updateUserStatus();
    }

    @Transactional
    public String uploadProfileImage(AuthenticationUser loginUser, Long userId, MultipartFile file) {
        User findUser = userRepository.findByUsercode(loginUser.getUsername())
                .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));

        if (!Objects.equals(findUser.getId(), userId)) {
            throw new IllegalArgumentException("사용자가 일치하지 않습니다.");
        }

        String s3Url = fileService.uploadFileToS3(file);
        log.info("S3에 저장한 파일 주소: {}", s3Url);
        findUser.setProfileImageUrl(s3Url);

        return s3Url;
    }
}


exception


// AuthCodeExpireException

package com.sparta.wildcard_newsfeed.exception.customexception;

import lombok.Getter;

@Getter
public class AuthCodeExpireException extends RuntimeException {
    public AuthCodeExpireException() {
        super("유효하지 않은 인증 번호입니다.");
    }
}



// AuthCodeNoMatchException

package com.sparta.wildcard_newsfeed.exception.customexception;

import lombok.Getter;

@Getter
public class AuthCodeNoMatchException extends RuntimeException {
    public AuthCodeNoMatchException() {
        super("일치하지 않는 인증 번호입니다.");
    }
}




// AuthCodeNotFoundException

package com.sparta.wildcard_newsfeed.exception.customexception;

import lombok.Getter;

@Getter
public class AuthCodeNotFoundException extends RuntimeException {
    public AuthCodeNotFoundException() {
        super("찾을 수 없는 인증 번호입니다.");
    }
}



// FileException

package com.sparta.wildcard_newsfeed.exception.customexception;

public class FileException extends RuntimeException {

    public FileException(String message) {
        super(message);
    }

    public FileException(String message, Throwable cause) {
        super(message, cause);
    }
}




// FileSizeExceededException

package com.sparta.wildcard_newsfeed.exception.customexception;

public class FileSizeExceededException extends RuntimeException {

    public FileSizeExceededException(String fileName, String extension, long currentSize, long maxSize) {
        super(createMessage(fileName, extension, currentSize, maxSize));
    }

    public static String createMessage(String fileName, String extension, long currentSize, long maxSize) {
        long decimal = currentSize % (1024 * 1024);
        long integerPart = currentSize / (1024 * 1024);
        maxSize /= (1024 * 1024);
        double decimalPart = (double) decimal / (1024 * 1024);
        double combinedSize = integerPart + decimalPart;
        double roundedSize = Math.round(combinedSize * 100.0) / 100.0;
        return String.format("%s의 확장자는 최대 %dMB까지 저장가능합니다. 문제 생긴 파일: %s(%.2fMB)",
                extension, maxSize, fileName, roundedSize);
    }
}



// TokenNotFoundException

package com.sparta.wildcard_newsfeed.exception.customexception;

import lombok.Getter;

@Getter
public class TokenNotFoundException extends RuntimeException {
    public TokenNotFoundException(String message) {
        super(message);
    }
}



// UserNotFoundException

package com.sparta.wildcard_newsfeed.exception.customexception;

import lombok.Getter;

@Getter
public class UserNotFoundException extends RuntimeException {
    public UserNotFoundException() {
        super("해당 유저를 찾을 수 없습니다.");
    }
}



// EmailExceptionControllerAdvice

package com.sparta.wildcard_newsfeed.exception;

import com.sparta.wildcard_newsfeed.domain.common.error.ErrorResponseDto;
import com.sparta.wildcard_newsfeed.exception.customexception.AuthCodeExpireException;
import com.sparta.wildcard_newsfeed.exception.customexception.AuthCodeNoMatchException;
import com.sparta.wildcard_newsfeed.exception.customexception.AuthCodeNotFoundException;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@Slf4j
@Getter
@Order(2)
@RestControllerAdvice
public class EmailExceptionControllerAdvice {

    /**
     * 유효하지 않은 인증 번호
     */
    @ExceptionHandler(AuthCodeExpireException.class)
    public ResponseEntity<ErrorResponseDto> authCodeExpireException(AuthCodeExpireException e) {
        log.info("AuthCodeExpireException {}", e.getClass().getSimpleName());
        log.error("{} ", e.getMessage());

        return ResponseEntity.status(HttpStatus.FORBIDDEN)
                .body(ErrorResponseDto.builder()
                        .statusCode(HttpStatus.FORBIDDEN.value())
                        .message(e.getMessage())
                        .build());
    }

    /**
     * 일치하지 않는 인증 번호
     */
    @ExceptionHandler(AuthCodeNoMatchException.class)
    public ResponseEntity<ErrorResponseDto> authCodeNoMatchException(AuthCodeNoMatchException e) {
        log.info("AuthCodeNoMatchException {}", e.getClass().getSimpleName());
        log.error("{} ", e.getClass().getSimpleName());

        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(ErrorResponseDto.builder()
                        .statusCode(HttpStatus.NOT_FOUND.value())
                        .message(e.getMessage())
                        .build());
    }

    /**
     * 찾을 수 없는 인증 번호
     */
    @ExceptionHandler(AuthCodeNotFoundException.class)
    public ResponseEntity<ErrorResponseDto> authCodeNotFoundException(AuthCodeNotFoundException e) {
        log.info("AuthCodeNotFoundException {}", e.getClass().getSimpleName());
        log.error("{} ", e.getClass().getSimpleName());

        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(ErrorResponseDto.builder()
                        .statusCode(HttpStatus.NOT_FOUND.value())
                        .message(e.getMessage())
                        .build());
    }
}



// ExceptionControllerAdvice

package com.sparta.wildcard_newsfeed.exception;

import com.sparta.wildcard_newsfeed.domain.common.error.ErrorResponseDto;
import com.sparta.wildcard_newsfeed.exception.customexception.FileException;
import com.sparta.wildcard_newsfeed.exception.customexception.FileSizeExceededException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;

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

@Slf4j
@Order(1)
@RestControllerAdvice
public class ExceptionControllerAdvice {

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ErrorResponseDto> illegalArgumentException(IllegalArgumentException e) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(ErrorResponseDto.builder()
                        .statusCode(HttpStatus.NOT_FOUND.value())
                        .message(e.getMessage())
                        .build());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponseDto> methodArgumentNotValidException(MethodArgumentNotValidException e) {
        List<String> errorMessageList = new ArrayList<>();
        e.getBindingResult().getAllErrors().forEach(v -> errorMessageList.add(v.getDefaultMessage()));

        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(ErrorResponseDto.builder()
                        .statusCode(HttpStatus.BAD_REQUEST.value())
                        .message(errorMessageList)
                        .build());
    }

    @ExceptionHandler(FileException.class)
    public ResponseEntity<ErrorResponseDto> fileException(FileException e) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(ErrorResponseDto.builder()
                        .statusCode(HttpStatus.BAD_REQUEST.value())
                        .message(e.getMessage())
                        .build());
    }

    @ExceptionHandler(MaxUploadSizeExceededException.class)
    public ResponseEntity<ErrorResponseDto> maxUploadSizeExceededException(MaxUploadSizeExceededException e) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(ErrorResponseDto.builder()
                        .statusCode(HttpStatus.BAD_REQUEST.value())
                        .message("동영상의 크기가 너무 큽니다. 최대 허용 크기는 200MB입니다.")
                        .build());
    }

    @ExceptionHandler(FileSizeExceededException.class)
    public ResponseEntity<ErrorResponseDto> fileSizeExceededException(FileSizeExceededException e) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(ErrorResponseDto.builder()
                        .statusCode(HttpStatus.BAD_REQUEST.value())
                        .message(e.getMessage())
                        .build());
    }
}



// TokenExceptionControllerAdvice

package com.sparta.wildcard_newsfeed.exception;

import com.sparta.wildcard_newsfeed.domain.common.error.ErrorResponseDto;
import com.sparta.wildcard_newsfeed.exception.customexception.TokenNotFoundException;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@Slf4j
@Getter
@Order(1)
@RestControllerAdvice
public class TokenExceptionControllerAdvice {

    /**
     * 토큰을 찾을 수 없는 경우
     */
    @ExceptionHandler(TokenNotFoundException.class)
    public ResponseEntity<ErrorResponseDto> illegalArgumentException(IllegalArgumentException e) {
        log.error("{} ", e.getMessage());
        return ResponseEntity.status(HttpStatus.FORBIDDEN)
                .body(ErrorResponseDto.builder()
                        .statusCode(HttpStatus.FORBIDDEN.value())
                        .message(e.getMessage())
                        .build());
    }
}


security


// AuthRequestDto

package com.sparta.wildcard_newsfeed.security.jwt.dto;

import lombok.Getter;

@Getter
public class AuthRequestDto {
    private String usercode;
    private String password;
}



// TokenDto

package com.sparta.wildcard_newsfeed.security.jwt.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
public class TokenDto {

    private String accessToken;
    private String refreshToken;
}




// JwtPropertiesEnum

package com.sparta.wildcard_newsfeed.security.jwt.enums;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum JwtPropertiesEnum {
    INVALID_TOKEN("유효하지 않는 JWT 서명 입니다."),
    EXPIRED_JWT_TOKEN("만료된 JWT token 입니다."),
    UNSUPPORTED_JWT_TOKEN("지원되지 않는 JWT 토큰 입니다."),
    JWT_CLAIMS_IS_EMPTY("잘못된 JWT 토큰 입니다.");

    private final String errorMessage;

    public static JwtPropertiesEnum fromJwtProperties(String fileExtension) {
        JwtPropertiesEnum jwtPropertiesEnum = INVALID_TOKEN; //Default message
        for (JwtPropertiesEnum value : values()) {
            if (value.getErrorMessage().equalsIgnoreCase(fileExtension)) {
                jwtPropertiesEnum = value;
            }
        }
        return jwtPropertiesEnum;
    }
}



// JwtAuthenticationEntryPoint

package com.sparta.wildcard_newsfeed.security.jwt;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.sparta.wildcard_newsfeed.domain.common.error.ErrorResponseDto;
import com.sparta.wildcard_newsfeed.security.jwt.enums.JwtPropertiesEnum;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Slf4j(topic = "인증 예외 필터")
@Component
@RequiredArgsConstructor
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper;

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        log.error("Jwt 인증 도중 예외 발생");
        String exception = (String) request.getAttribute("jwtException");

        JwtPropertiesEnum jwtPropertiesValue = JwtPropertiesEnum.fromJwtProperties(exception);
        String errorMessage = jwtPropertiesValue.getErrorMessage();

        int statusCode = HttpStatus.FORBIDDEN.value();

        ErrorResponseDto responseDto = ErrorResponseDto.builder()
                .message(errorMessage)
                .statusCode(statusCode)
                .build();
        String body = objectMapper.writeValueAsString(responseDto);

        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(statusCode);
        response.getWriter().write(body);
    }
}



// JwtAuthenticationFilter

package com.sparta.wildcard_newsfeed.security.jwt;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.sparta.wildcard_newsfeed.domain.common.CommonResponseDto;
import com.sparta.wildcard_newsfeed.domain.common.error.ErrorResponseDto;
import com.sparta.wildcard_newsfeed.domain.user.entity.User;
import com.sparta.wildcard_newsfeed.domain.user.repository.UserRepository;
import com.sparta.wildcard_newsfeed.exception.customexception.UserNotFoundException;
import com.sparta.wildcard_newsfeed.security.jwt.dto.AuthRequestDto;
import com.sparta.wildcard_newsfeed.security.jwt.dto.TokenDto;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import java.io.IOException;

import static com.sparta.wildcard_newsfeed.security.jwt.JwtConstants.ACCESS_TOKEN_HEADER;
import static com.sparta.wildcard_newsfeed.security.jwt.JwtConstants.REFRESH_TOKEN_HEADER;

@Slf4j(topic = "로그인 및 JWT 생성")
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    private final ObjectMapper objectMapper;
    private final JwtUtil jwtUtil;
    private final UserRepository userRepository;


    public JwtAuthenticationFilter(ObjectMapper objectMapper, JwtUtil jwtUtil, UserRepository userRepository
    ) {
        this.objectMapper = objectMapper;
        this.jwtUtil = jwtUtil;
        this.userRepository = userRepository;
        setFilterProcessesUrl("/api/v1/user/login");
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        try {
            AuthRequestDto requestDto = new ObjectMapper().readValue(request.getInputStream(), AuthRequestDto.class);

            return getAuthenticationManager().authenticate(
                    new UsernamePasswordAuthenticationToken(
                            requestDto.getUsercode(),
                            requestDto.getPassword(),
                            null
                    )
            );
        } catch (IOException e) {
            //TODO 로그인의 요청파라미터가 없는 경우
            log.error("attemptAuthentication 예외 발생 {} ", e.getMessage());
            throw new RuntimeException(e.getMessage());
        }
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException {
        log.info("로그인 성공 및 JWT 토큰 발행");
        User user = userRepository.findByUsercode(authResult.getName())
                .orElseThrow(UserNotFoundException::new);

        TokenDto tokenDto = jwtUtil.generateAccessTokenAndRefreshToken(user.getUsercode());
        String refreshTokenValue = tokenDto.getRefreshToken().substring(7);
        user.setRefreshToken(refreshTokenValue);
        userRepository.save(user);

        loginSuccessResponse(response, tokenDto);
    }


    private void loginSuccessResponse(HttpServletResponse response, TokenDto tokenDto) throws IOException {
        CommonResponseDto responseDto = CommonResponseDto.builder()
                .message("로그인 성공")
                .statusCode(HttpStatus.OK.value())
                .build();
        String body = objectMapper.writeValueAsString(responseDto);

        response.setStatus(HttpStatus.OK.value());
        response.setContentType("application/json;charset=UTF-8");
        response.addHeader(ACCESS_TOKEN_HEADER, tokenDto.getAccessToken());
        response.addHeader(REFRESH_TOKEN_HEADER, tokenDto.getRefreshToken());
        response.getWriter().write(body);
    }


    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException {
        log.error("unsuccessfulAuthentication | 로그인 실패");
        ErrorResponseDto errorResponseDto = ErrorResponseDto.builder()
                .statusCode(HttpStatus.UNAUTHORIZED.value())
                .message("로그인 실패하였습니다.")
                .build();

        String body = objectMapper.writeValueAsString(errorResponseDto);

        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(body);
    }
}



// JwtAuthorizationFilter

package com.sparta.wildcard_newsfeed.security.jwt;

import com.sparta.wildcard_newsfeed.domain.user.entity.User;
import com.sparta.wildcard_newsfeed.domain.user.repository.UserRepository;
import com.sparta.wildcard_newsfeed.exception.customexception.TokenNotFoundException;
import com.sparta.wildcard_newsfeed.exception.customexception.UserNotFoundException;
import com.sparta.wildcard_newsfeed.security.AuthenticationUserService;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Slf4j(topic = "JWT 검증 및 인가")
public class JwtAuthorizationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final AuthenticationUserService authenticationUserService;
    private final UserRepository userRepository;

    public JwtAuthorizationFilter(JwtUtil jwtUtil, AuthenticationUserService authenticationUserService, UserRepository userRepository) {
        this.jwtUtil = jwtUtil;
        this.authenticationUserService = authenticationUserService;
        this.userRepository = userRepository;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain filterChain) throws ServletException, IOException {
        String accessTokenValue = jwtUtil.getAccessTokenFromHeader(req);

        log.info("access token 검증");
        if (StringUtils.hasText(accessTokenValue) && jwtUtil.validateToken(req, accessTokenValue)) {
            log.info("refresh token 검증");

            String refreshTokenValue = jwtUtil.getRefreshTokenFromHeader(req);
            if (StringUtils.hasText(refreshTokenValue) && jwtUtil.validateToken(req, refreshTokenValue)) {
                String usercode = jwtUtil.getUserInfoFromToken(refreshTokenValue).getSubject();
                User findUser = userRepository.findByUsercode(usercode)
                        .orElseThrow(UserNotFoundException::new);

                if (isValidateUserAndToken(usercode, findUser, refreshTokenValue)) {
                    //access token 및 refresh token 검증 완료
                    log.info("Token 인증 완료");
                    Claims info = jwtUtil.getUserInfoFromToken(accessTokenValue);
                    setAuthentication(info.getSubject());
                }
            } else {
                log.error("유효하지 않는 Refersh Token");
                throw new TokenNotFoundException("토큰에 문제가 생김");
            }
        }
        filterChain.doFilter(req, res);
    }

    private boolean isValidateUserAndToken(String usercode, User findUser, String refreshTokenValue) {
        if (usercode.equals(findUser.getUsercode())
                && refreshTokenValue.equals(findUser.getRefreshToken())) {
            return true;
        }
        return false;
    }


    // 인증 처리
    public void setAuthentication(String username) {
        SecurityContext context = SecurityContextHolder.createEmptyContext();
        Authentication authentication = createAuthentication(username);
        context.setAuthentication(authentication);

        SecurityContextHolder.setContext(context);
    }

    // 인증 객체 생성
    private Authentication createAuthentication(String username) {
        UserDetails userDetails = authenticationUserService.loadUserByUsername(username);
        return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
    }
}



// JwtConstants

package com.sparta.wildcard_newsfeed.security.jwt;

public class JwtConstants {
    // Header KEY 값
    public static final String ACCESS_TOKEN_HEADER = "Authorization";
    public static final String REFRESH_TOKEN_HEADER = "Authorization-refresh";

    // Token 식별자
    public static final String BEARER_PREFIX = "Bearer ";
    // 토큰 만료시간
    public static final long ACCESS_TOKEN_TIME = 1000 * 60 * 30 * 60; // 30분
//    public static final long ACCESS_TOKEN_TIME = 1000 * 30; // 30초
    public static final long REFRESH_TOKEN_TIME = 1000 * 60 * 60 * 24 * 14; // 2주
//    public static final long REFRESH_TOKEN_TIME = 1000 * 60; // 60초
}



// JwtLogoutHandler

package com.sparta.wildcard_newsfeed.security.jwt;

import com.sparta.wildcard_newsfeed.domain.user.entity.User;
import com.sparta.wildcard_newsfeed.domain.user.repository.UserRepository;
import com.sparta.wildcard_newsfeed.exception.customexception.TokenNotFoundException;
import com.sparta.wildcard_newsfeed.exception.customexception.UserNotFoundException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtLogoutHandler implements LogoutHandler {

    private final JwtUtil jwtUtil;
    private final UserRepository userRepository;

    @Override
    public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        log.info("로그아웃 시도");

        String accessTokenValue = jwtUtil.getAccessTokenFromHeader(request);
        String refreshTokenValue = jwtUtil.getRefreshTokenFromHeader(request);

        if (accessTokenValue == null && refreshTokenValue == null) {
            log.error("로그아웃 시도 중 에러 발생");
            throw new TokenNotFoundException("토큰을 찾을 수 없습니다.");
        }
        String usercode = jwtUtil.getUserInfoFromToken(refreshTokenValue).getSubject();
        User findUser = userRepository.findByUsercode(usercode).orElseThrow(UserNotFoundException::new);
        findUser.setRefreshToken(null);
        userRepository.save(findUser);

        SecurityContextHolder.clearContext();
    }
}



// JwtLogoutSuccessHandler

package com.sparta.wildcard_newsfeed.security.jwt;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.sparta.wildcard_newsfeed.domain.common.CommonResponseDto;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
@Slf4j
@RequiredArgsConstructor
public class JwtLogoutSuccessHandler implements LogoutSuccessHandler {

    private final ObjectMapper objectMapper;

    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        log.info("로그아웃 성공");
        CommonResponseDto responseDto = CommonResponseDto.builder()
                .message("로그아웃 성공하였습니다.")
                .statusCode(HttpStatus.OK.value())
                .build();

        String body = objectMapper.writeValueAsString(responseDto);
        response.setContentType("text/html;charset=UTF-8");
        response.setStatus(HttpStatus.OK.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.getWriter().write(body);
    }
}



// JwtUtil

package com.sparta.wildcard_newsfeed.security.jwt;

import com.sparta.wildcard_newsfeed.security.jwt.dto.TokenDto;
import com.sparta.wildcard_newsfeed.security.jwt.enums.JwtPropertiesEnum;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import java.security.Key;
import java.util.Base64;
import java.util.Date;
import java.util.UUID;

import static com.sparta.wildcard_newsfeed.security.jwt.JwtConstants.*;

@Slf4j(topic = "JwtUtil")
@Component
public class JwtUtil {

    @Value("${jwt.secret-key}") // Base64 Encode 한 SecretKey
    private String secret_key;
    private Key key;
    private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

    @PostConstruct
    public void init() {
        byte[] bytes = Base64.getDecoder().decode(secret_key);
        key = Keys.hmacShaKeyFor(bytes);
    }

    public TokenDto generateAccessTokenAndRefreshToken(String username) {
        String accessToken = createAccessToken(username);
        String refreshToken = createRefreshToken(username);

        return TokenDto.builder()
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .build();
    }

    // access Token 생성
    public String createAccessToken(String userCode) {
        Date date = new Date();

        return BEARER_PREFIX +
                Jwts.builder()
                        .setId(UUID.randomUUID().toString()) // JWT ID 설정
                        .setSubject(userCode) // 사용자 식별자값(ID)
                        .setIssuedAt(new Date(date.getTime())) // 생성시간
                        .setExpiration(new Date(date.getTime() + ACCESS_TOKEN_TIME)) // 만료 시간
                        .setIssuedAt(date) // 발급일
                        .claim("tokenType", "access")
                        .signWith(key, signatureAlgorithm) // 암호화 알고리즘
                        .compact();
    }

    // refresh Token 생성
    public String createRefreshToken(String username) {
        Date date = new Date();

        return BEARER_PREFIX +
                Jwts.builder()
                        .setId(UUID.randomUUID().toString()) // JWT ID 설정
                        .setSubject(username) // 사용자 식별자값(ID)
                        .setIssuedAt(new Date(date.getTime())) // 생성시간
                        .setExpiration(new Date(date.getTime() + REFRESH_TOKEN_TIME)) // 만료 시간
                        .setIssuedAt(date) // 발급일
                        .claim("tokenType", "refresh")
                        .signWith(key, signatureAlgorithm) // 암호화 알고리즘
                        .compact();
    }

    // header 에서 access token
    public String getAccessTokenFromHeader(HttpServletRequest request) {
        String bearerToken = request.getHeader(ACCESS_TOKEN_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.substring(7);
        }
        return null;
    }

    // header 에서 refresh token
    public String getRefreshTokenFromHeader(HttpServletRequest request) {
        String bearerToken = request.getHeader(REFRESH_TOKEN_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.substring(7);
        }
        return null;
    }

    // 토큰 검증
    public boolean validateToken(HttpServletRequest request, String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (SecurityException | MalformedJwtException | SignatureException e) {
            log.error("Invalid JWT signature, 유효하지 않는 JWT 서명", e);
            request.setAttribute("jwtException", JwtPropertiesEnum.INVALID_TOKEN.getErrorMessage());
        } catch (ExpiredJwtException e) {
            log.error("Expired JWT token, 만료된 JWT token", e);
            request.setAttribute("jwtException", JwtPropertiesEnum.EXPIRED_JWT_TOKEN.getErrorMessage());
        } catch (UnsupportedJwtException e) {
            log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰", e);
            request.setAttribute("jwtException", JwtPropertiesEnum.UNSUPPORTED_JWT_TOKEN.getErrorMessage());
        } catch (IllegalArgumentException e) {
            log.error("JWT claims is empty, 잘못된 JWT 토큰", e);
            request.setAttribute("jwtException", JwtPropertiesEnum.JWT_CLAIMS_IS_EMPTY.getErrorMessage());
        }
        return false;
    }

    // 토큰에서 사용자 정보 가져오기
    public Claims getUserInfoFromToken(String token) {
        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
    }
}



// AuthenticationUser

package com.sparta.wildcard_newsfeed.security;

import com.sparta.wildcard_newsfeed.domain.user.entity.User;
import com.sparta.wildcard_newsfeed.domain.user.entity.UserRoleEnum;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.Collections;

@Slf4j
@Builder
@AllArgsConstructor
@RequiredArgsConstructor
public class AuthenticationUser implements UserDetails {

    /**
     * User Entity usercode
     */
    private final String usercode;
    /**
     * User Entity password
     */
    private final String password;
    /**
     * User Entity userRole
     */
    @Getter
    private UserRoleEnum userRoleEnum;

    @Override
    public String getPassword() {
        return this.password;
    }

    @Override
    public String getUsername() {
        return this.usercode;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + userRoleEnum.getRoleValue()));

    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }


    public static AuthenticationUser of(User user) {
        return AuthenticationUser.builder()
                .usercode(user.getUsercode())
                .password(user.getPassword())
                .userRoleEnum(user.getUserRoleEnum())
                .build();
    }
}



// AuthenticationUserService

package com.sparta.wildcard_newsfeed.security;

import com.sparta.wildcard_newsfeed.domain.user.entity.User;
import com.sparta.wildcard_newsfeed.domain.user.repository.UserRepository;
import com.sparta.wildcard_newsfeed.exception.customexception.UserNotFoundException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Slf4j(topic = "유저검증")
@Service
@RequiredArgsConstructor
public class AuthenticationUserService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsercode(username)
                .orElseThrow(UserNotFoundException::new);

        return AuthenticationUser.of(user);
    }
}

util


// FileExtensionEnum

package com.sparta.wildcard_newsfeed.util;

import lombok.Getter;

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

@Getter
public enum FileExtensionEnum {
    IMAGE("jpg", "jpeg", "png"),
    VIDEO("gif", "mp4", "avi");

    private final String[] extensions;

    FileExtensionEnum(String... extensions) {
        this.extensions = extensions;
    }

    public static List<String> getSupportedExtensions() {
        return Arrays.stream(values()).flatMap(ext -> Stream.of(ext.getExtensions())).toList();
    }

    public static String joiningAllExtensions() {
        return getSupportedExtensions().stream().collect(Collectors.joining(", ", "[", "]"));
    }
}



// FileUtils

package com.sparta.wildcard_newsfeed.util;

import com.sparta.wildcard_newsfeed.exception.customexception.FileException;
import com.sparta.wildcard_newsfeed.exception.customexception.FileSizeExceededException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;

@Slf4j
@Component
public class FileUtils {

    private final long IMAGE_MAX_SIZE = 10 * 1024L * 1024L; // 10MB
    private final long VIDEO_MAX_SIZE = 200 * 1024L * 1024L; // 200MB

    @Value("${spring.servlet.multipart.location}")
    private String uploadLocation;

    public String getAbsoluteUploadFolder() {
        File file = new File("");
        String currentAbsolutePath = file.getAbsoluteFile() + uploadLocation;
        Path path = Paths.get(currentAbsolutePath);
        if (!Files.exists(path)) {
            try {
                Files.createDirectories(path);
            } catch (IOException e) {
                throw new RuntimeException("사진을 업로드할 폴더를 생성할 수 없습니다.", e);
            }
        }
        return currentAbsolutePath;
    }

    public String createUuidFileName(String originalFileName) {
        String extension = extractExtension(originalFileName);
        return UUID.randomUUID() + "." + extension;
    }

    public String extractOriginalName(String originalFileName) {
        return originalFileName.substring(0, originalFileName.indexOf("."));
    }

    public String extractExtension(String originalFileName) {
        int point = originalFileName.lastIndexOf(".");
        return originalFileName.substring(point + 1);
    }

    public void validFile(List<MultipartFile> files) {
        for (MultipartFile file : files) {
            String originalFilename = file.getOriginalFilename();
            long size = file.getSize();

            String extension = extractExtension(originalFilename).toLowerCase();
            if (Arrays.asList(FileExtensionEnum.IMAGE.getExtensions()).contains(extension)) {
                if (size > IMAGE_MAX_SIZE) {
                    throw new FileSizeExceededException(originalFilename, extension, size, IMAGE_MAX_SIZE);
                }
            } else if (Arrays.asList(FileExtensionEnum.VIDEO.getExtensions()).contains(extension)) {
                if (size > VIDEO_MAX_SIZE) {
                    throw new FileSizeExceededException(originalFilename, extension, size, VIDEO_MAX_SIZE);
                }
            } else {
                throw new FileException("지원하지 않는 파일 확장자입니다. "
                        + FileExtensionEnum.joiningAllExtensions() + "의 확장자만 저장할 수 있습니다.");
            }
        }
    }
}




// S3FileUtils

package com.sparta.wildcard_newsfeed.util;

import com.amazonaws.services.s3.model.CannedAccessControlList;
import com.amazonaws.services.s3.model.PutObjectRequest;
import com.sparta.wildcard_newsfeed.config.S3Config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.io.File;

@Slf4j
@Component
@RequiredArgsConstructor
public class S3FileUtils {

    private final S3Config s3Config;

    @Value("${cloud.aws.s3.bucket}")
    private String bucket;

    public String uploadFile(String uuidFileName, File saveFile) {
        s3Config.amazonS3Client().putObject(
                new PutObjectRequest(bucket, uuidFileName, saveFile).withCannedAcl(CannedAccessControlList.PublicRead)
        );
        return s3Config.amazonS3Client().getUrl(bucket, uuidFileName).toString();
    }
}

yml setting

spring:
  datasource:
    url: jdbc:mysql://${DB_URL}/newsfeed
    username: ${DB_USER}
    password: ${DB_PASSWORD}
    driver-class-name: com.mysql.cj.jdbc.Driver
  sql:
    init:
      encoding: UTF-8

  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        #show_sql: true
        format_sql: true
        connection:
          CharSet: utf-8
          characterEncoding: utf-8
          useUnicode: true

logging.level:
  root: info
#  org.hibernate.SQL: debug
#  org.springframework.security: debug
#  org.springframework.web: debug

jwt:
  secret-key: ${JWT-SECRET-KEY}

server:
  port: ${SERVER_PORT}

---
spring:
  mail:
    host: smtp.gmail.com
    port: ${MAIL_PORT}
    username: ${MAIL_USERNAME}
    password: ${MAIL_PASSWORD}
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            enable: true
  servlet:
    multipart:
      maxFileSize: 300MB # 파일 하나의 최대 크기
      maxRequestSize: 1000MB  # 한 번에 최대 업로드 가능 용량
      location: /upload/

# S3
cloud:
  aws:
    s3:
      bucket: b15wildcard
    stack:
      auto: false
    region:
      static: ap-northeast-2
    credentials:
      access-key: ${S3_ACCESS_KEY}
      secret-key: ${S3_SECRET_KEY}
      
      
profile
야옹

0개의 댓글