코드 훔쳐보는 변태 코더
춤 좋아하는 백엔드 개발자(였으면 좋겠다)
Spring (7)
내가 기억하기 쉬우라고 작성한 단위테스트

단위테스트는 어떻게 진행될까?

  • 사람마다 다를 수 있으나 (?) 기본적으론 gwt (given & when & then)으로 진행된다.
    • Given ⇒ 어떠한 상황이 주어진다.
    • When ⇒ 어떠한 행동을 한다면
    • Then ⇒ 어떠한 행동이 돌아온다.
  • 단순히 이렇게 기억하면 쉽다.

사용되는 라이브러리

  • 보통 테스트는 비즈니스 로직 단위테스트, 컨트롤러 단위테스트, 리포지토리 단위테스트 이렇게 이뤄지는것 같다.
    • 비즈니스 로직 단위테스트에는 MockitoExtension을 사용한다.
    • 컨틀롤러 단위테스트에는 SpringBootTest 어노테이션을 달고 MockMvc 를 객체로 사용한다.
    • 리포지토리 단위테스트에는 DataJPATest 어노테이션을 달고 테스트한다.
  • 단순히 하나하나가 어떠한 행동들을 지원하는지는 나중에 알아보고, 어떻게 테스트해야지 성공적인 결과를 가져올 수 있는지 일단 행동해보자.

비즈니스 로직 단위테스트

테스트 클래스 생성

단순히 테스트 폴더에 테스트 클래스를 생성한다.

spring boot starter test 디펜던시를 추가하면 자동으로 junit 라이브러리까지 사용이 가능하다.

@DisplayName("비즈니스 로직 - 게시글")
@ExtendWith(MockitoExtension.class)
class ArticleServiceTest {

테스트를 원하는 클래스에 MockitoExtension 을 상속받아주고 어떠한 테스트를 진행할 것인지 @DisplayName으로 명시해 준다.

@InjectMocks private ArticleService sut;
@Mock private ArticleRepository articleRepository;

핵심이 되는 서비스클래스를 @InjectMocks 어노테이션을 사용하여 필드변수로 선언해 준다.

sut는 System Under Test 테스트 대상 시스템을 뜻한다.

그리고 해당 서비스 클래스에 주입되어야 하는 Repository 들을 @Mock 어노테이션을 사용해 주입시켜 준다.

(주축이 되는 객체에 주입되어야 하는 객체들을 하나라도 주입시키지 않으면 오류가 발생한다.)

테스트에 사용되는 메서드

  • given → 테스트가 진행되는 목업 객체가 가 어떠한 메서드를 호출할 때의 결과를 가정할 수 있다.
    • 리턴값이 있다면 목업 객체를, 예외가 발생해야 한다면 예외를 던져주는 등 상황을 지정할 수 있다.
      • 보통 테스트 대상 시스템의 비즈니스 로직 속 호출되는 메서드들에 대한 리턴값을 지정하는데 쓰인다.
  • when & then → 해당 비즈니스 로직을 호출했을 시에 어떠한 결과가 나와야 하는지를 명시한다.
    • when에는 예외가 발생되어야 하는 상황이나 메서드를 호출한다.
    • then 에는 AssertJ 라이브러리를 활용해 assertThat 메서드로 검증을 진행하거나 then() 메소드로 어떠한 메서드를 호출을 했는지 확인할 수 있다.

비즈니스 로직 단위테스트 코드 작성

  • 단위테스트 메서드의 네이밍은 given 주어진 것_when 어떠한 행동을 할 때_then 어떠한 것을 하거나 반납한다. 이런 느낌으로 작성해도 좋고, @DisplayName 어노테이션을 사용하여 따로 상황만 명시해줘도 된다.
  • 어떠한 행동을 했을 때 대상이 되는 엔티티에 변경사항이 존재하는지, 혹은 엔티티매니저가 어떠한 행동을 실행했는지 에 대한 상황을 가정 후 테스트를 진행할 수 있다.
  • 예외가 발생할 때는 Throwable 클래스의 정적 메서드인 catchThrowable()를 이용하여 람다식으로 예외를 캐치해 준다.
@Test
    @DisplayName("댓글 단건 조회시 없는 댓글을 조회하면 예외가 발생한다$")
    void givenNotExistArticleCommentId_whenGetAnArticleComment_thenThrowsException() {
        //given
        given(articleCommentRepository.findById(any())).willReturn(Optional.empty());

        //when
        Throwable throwable = catchThrowable(() -> sut.getArticleComment(1L));

        //then
        then(articleCommentRepository).should().findById(any());
        assertThat(throwable).isInstanceOf(EntityNotFoundException.class);
    }
  • 이때의 생각해볼 수 있는 상황들은 무엇이 있을까? 바로 단위테스트를 진행하는 목업 객체의 역할분담이다.
    • 사실 data repository 테스트를 따로 진행한 후 비즈니스 로직 테스트는 단순히 then에 해당 비즈니스 로직이 호출되었는지 를 확인할 수 있다.
    • 하지만 위에 작성해둔 코드로 실행시켜도 문제는 없다. 단순히 해당 계층의 테스트인데 다른 계층까지 점령해도 되느냐의 차이인 것 같다.
    • 더 세분화한다면 Data Repository 테스트 / 비즈니스 로직 테스트 / MVC테스트 이렇게 세분화하여 진행할 수 있지만 결국엔 비즈니스 로직에서는 해당 비즈니스 로직을 호출할 때 모든 repository 메서드를 호출할 것이며, MVC 테스트를 진행할 때도 리퀘스트를 보내면 컨트롤러가 비즈니스 로직을 호출하게 될 것이다.
      • 이에 대한 고민을 할 필요가 있으나, 현재 상황에서는 repository 자체에 여러 커스텀 메서드 (쿼리 dsl을 활용하거나 jpql을 활용해 직접 쿼리문으로 조회하는 메서드)가 존재하지 않기 때문에 비즈니스 로직에서 데이터계층까지 테스트하는 식으로 작성했다.

예외가 발생하지 않는 정상적인 상황에서의 테스트

@Test
    @DisplayName("getArticleCommet() - 댓글 단건 조회")
    void givenArticleCommentId_whenGetAnArticleComment_thenGetsArticleComment() {
        //given
        ArticleComment articleComment = createArticleComment(createArticle(createUserAccount()),createUserAccount());
        given(articleCommentRepository.findById(any())).willReturn(Optional.of(articleComment));

        //when
        ArticleComment.ArticleCommentDto articleCommentDto = sut.getArticleComment(articleComment.getId());

        //then
        then(articleCommentRepository).should().findById(any());
        assertThat(articleCommentDto).isNotNull();
    }
  • getArticleComment 메서드의 내용물로는 articleCommentRepository 가 findById로 댓글을 조회한 후 존재한다면 해당 댓글을, 존재하지 않는다면 EntityNotFoundException을 발생시키도록 되어있다.
  • given으로 상황을 만들어준다. 모키토는 여러 가지 메서드를 제공해주는데, 그중 any() 는 말그대로 어떠한 상황에서든지 willReturn() 메소드의 매개값을 리턴해준다.
  • when은 해당 시점을 뜻한다. 어떠한 메소드를 호출할 때 then에서 검증을 진행한다.
  • then에서는 assertThat() 메서드로 어떠한 엔티티를 영속시켰을 때 해당 레포지토리에 저장된 데이터의 수가 +1 이 되었는지 , 혹은 해당 엔티티 객체의 멤버변수에 변동사항이 있는지 까지 테스트가 가능하다.

이렇게만 작성하면 성공적인 테스트 결과를 얻을 수 있다.

 

이렇게 여러 상황을 지정할 수 있다.


컨트롤러 단위테스트

테스트 클래스 생성

컨트롤러 테스트도 마찬가지로 새 패키지를 생성해 테스트가 진행되는 클래스의 이름뒤에 test를 붙여서 클래스를 새로 생성한다.

여기에 쓰이는 어노테이션은

  • @AutoConfigureMockMvc
  • @SpringBootTest
  • *@Import*(*SecurityConfig*. class)

이렇게 어노테이션을 달아주면 된다.

스프링 시큐리티를 사용 중에 컨트롤러 테스트를 진행하면 시큐리티 설정도 같이 가져와야지 허튼짓을 하지 않고 테스트를 마무리 지을 수 있다.

private final MockMvc mvc;
    @MockBean
    private final SaveFileService saveFileService;
    private final ObjectMapper objectMapper;

    public SaveFileControllerTest(@Autowired MockMvc mvc, @Autowired SaveFileService saveFileService, @Autowired ObjectMapper objectMapper) {
        this.mvc = mvc;
        this.saveFileService = saveFileService;
        this.objectMapper = objectMapper;
    }

이렇게 필드변수로 MockMvc 객체를 주입해 주고, 본인의 입맛에 맞게 주입할 객체를 선언해주면 된다.

생성자에 꼭 Autowired 어노테이션을 달아주자.. 여기에 달아놓지 않으면 오류가 발생한다..

테스트에 사용되는 메서드

  • given → 비즈니스 로직 테스트와 마찬가지로 뷰에서 요청이 들어왔을 때 호출되는 메서드에 대한 리턴값이나 상황을 지정해줄 수 있다.
  • perform
    • get → GET 요청을 할 때 사용한다.
    • post → POST 요청을 할 때 사용한다. 위에 있는 objectMapper 객체로 dto를 json 형태로 변환해서 매개값으로 전달할 수 있다.
      • contentType → 데이터를 어떠한 타입으로 전송하는지를 명시한다.
      • cotent → 전달하는 데이터를 목업 데이터로 전송한다.
    • put → PUT 요청을 할 때 사용한다. 제공하는 메서드는 post와 같다.
    • delete → DELETE 요청을 할 때 사용한다.
      • 마찬가지로 delete 요청을 보내기 위해 전달되어야 하는 데이터들을 contentType과 content로 정의해 전달할 수 있다.
    • andExpect / andDo → 해당 요청을 보내면 어떠한 응답이 와야 하는지를 명시한다.
      • view → 어떠한 페이지로 이동이 된다든가.. 하는지를 명시한다.
      • model → 어떠한 모델이 attribute 되었는지를 명시한다.
  • 해당 메서드만 적절히 잘 사용하면 모든 테스트를 실패 없이 끝낼 수 있다.

컨트롤러 테스트 코드 작성

이전에 신경 써야 하는 부분이 있다.

  • 스프링 시큐리티를 적용한 프로젝트인데, 시큐리티 콘텍스트에 담긴 인증정보를 가져오는 메서드가 존재한다거나, authenticatedPrincipal 어노테이션을 사용하는 메소드가 존재할 때의 상황이다.
  • 이때를 대비해 @BeforeEach 어노테이션을 사용한 메서드를 선언해 각 테스트메서드 별로 호출 전 상황을 잡아줄 수 있다.
    • 계정을 생성해 놓는다던지, 글을 등록해놓는다던지 할 수 있다.
  • 상황을 잡아줬다면 @WithUserDetails 같은 @With~~ 어노테이션을 사용하여 해당 문제를 해결할 수 있다.
  • 만약 커스텀 필터를 구현하여 토큰을 사용하거나 하는 상황에서는 따로 어노테이션과 그에 대한 설정 클래스를 생성해 구현할 수 있다.
    • Spring Securit Test 디펜던시를 추가해야 사용이 가능하니 꼭 추가하자.
@DisplayName("[view][GET] 게시글 페이지 ")
    @Test
    public void givenNothing_whenRequestingArticlesView_thenReturnsArticlesView() throws Exception {
        //given
        given(articleService.searchArticles(eq(null), eq(null), any(Pageable.class))).willReturn(Page.empty());
        //when & then
        mvc.perform(get("/articles")).andExpect(status().isOk())
                .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
                .andExpect(view().name("articles/index"))
                .andExpect(model().attributeExists("articles"));
        then(articleService).should().searchArticles(eq(null), eq(null), any(Pageable.class));
    }
  • 기본적인 GET 요청을 보냈을 때의 테스트코드이다.
  • @DisplayName에 마찬가지로 테스트 상황을 명시한다.
    • 이때 의문점은 메서드의 이름에 given 에 해당 메소드 필드 속 given 메소드의 내용을 명시해야 하는지, 아니면 실제 상황을 명시해야 하는지를 아직 정확히 이해하지 못했다.
  • perform() 메서드를 호출해 어떠한 url로 어떠한 요청을 보냈을 때, 어떠한 응답이 올 때 어떠한 것을 반환하는지를 명시해 준다.
  • 본인은 then에 해당 url로 요청을 보냈을 때 어떠한 비즈니스 로직이 호출되는지를 명시해 주었다.
    • 해당 부분도 솔직히 정답은 없을 거라 본다.. 각자 프로젝트를 진행할 때 정해둔 규칙에 따라서 작성하면 될 것 같다.
@DisplayName("[view][POST] 게시글 등록 ")
    @Test
    @WithUserDetails("test")
    public void givenArticleInfo_whenSavingArticle_thenSavesArticle() throws Exception {
        //given
        Article.ArticleRequest articleRequest = Article.ArticleRequest.builder()
                .title("haha421")
                .content("haha412")
                .fileIds("")
                .build();
        Article article = createArticle(createUserAccount());
        given(saveFileService.getFileDtosFromRequestsFileIds(any())).willReturn(new HashSet<>());
        given(articleService.saveArticle(any(), any())).willReturn(Article.ArticleDto.from(article));

        //when & then
        mvc.perform(post("/articles")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(mapper.writeValueAsString(articleRequest)))
                .andExpect(status().isOk())
                .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_PLAIN));
        then(saveFileService).should().getFileDtosFromRequestsFileIds(any());
    }
  • POST / PUT 요청을 보낼 때의 테스트코드이다.
  • ObjectMapper 객체를 활용해 json으로 변환 후 전달 할 수 있다.
  • given 에는 마찬가지로 해당 url로 요청을 보냈을 때 호출되는 비즈니스 로직 속 또 다른 메서드까지 반환값을 지정해줄 수 있다.
@DisplayName("[view][DELETE] 로그인이 되어있지 않은 상태로 게시글을 삭제하면 BAD_REQUEST를 반환한다.")
    @Test
    public void givenArticleId_whenTryingToDELETEArticle_thenReturnBadRequest() throws Exception {
        Map<String, String> articleId = Map.of("articleId", "1");
        //when&then
        mvc.perform(delete("/articles").contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(articleId)))
                .andExpect(status().isBadRequest());
    }
  • DELETE 요청을 보냈을 때, 예외가 발생하는 상황을 담은 테스트코드이다.

 

이렇게 각 REST API 별로 상황을 만들어서 테스트가 가능하다.

 


마무리

테스트코드 작성하는 법을 배워보진 않았지만, 어쩌다 보니 규칙을 알게 되어 제 나름대로 정리해본 글입니다.

 

제가 직접 실행해보았을 때의 각각의 역할은 어느 정도 알게 되었지만, 해당 메서드들의 원리는 아직 알지 못합니다.

 

조언해주시거나 고쳐야 할 부분이 존재한다면 댓글 꼭 달아주세요! 감사합니다.

 

  Comments,     Trackbacks
스프링부트 + 타임리프로 동기식 댓글수정 기능 구현하기

혼자서 토이 프로젝트로 게시판을 구현하고 있었는데,

유독 막혔던 부분이 있었다.

바로 댓글 수정 기능이었다.

혼자서 뚝딱 만들어가고 있던건 아니고 참고했던 강의, 책등이 있었는데

거기서 공통적으로 패스했던 부분이 댓글수정 이었다.

게시글 수정 같은 경우에는 동기 식으로 폼을 세터로 기존에 존재했던 내용들을 지정해준 뒤에 객체로 전달하면 폼안에 내용이 그대로 들어있는 상태로 편하게 수정 할 수 있었는데

댓글 같은 경우에는 동기식으로 수정을 구현하기 어려웠고, 내가 알고있는것들 안에서 해결 할 수 있는 방법을 찾고 싶었으나 , 대체적으로 다들 Ajax 를 활용하여 비동기식으로 댓글 기능을 구현 하셨어서.. 내가 당장 쏙 빼먹을만한 무언가를 찾기가 힘들었다.

시작해보기

댓글 리퀘스트 Dto 작성

일단 나는 댓글저장,수정 같은 경우에는 Dto 자체를 넘겨주지 않고 입력 수정용 RequestDto 를 따로 만들어서 넘겨주었다.

public record ArticleCommentRequest( //JPA BUDDY 를 이용해 DTO 생성
        Long articleId
        ,
        String content) implements Serializable {

    public static ArticleCommentRequest of(Long articleId, String content) {
        return new ArticleCommentRequest(articleId, content);
    }


    public ArticleCommentDto toDto(UserAccountDto userAccountDto) { //로그인 세션
        return ArticleCommentDto.of(
                articleId,      
                userAccountDto,
                content
        );
    }

댓글 입력과 수정 삭제 등등은 모두 로그인 된 상태에서만 동작하기 때문에, 현재 로그인 되어있는 계정의 정보를 집어 넣어준다.

계정 Dto 작성

public record BoardPrincipal(
        String username,
        String password,
        Collection<? extends GrantedAuthority> authorities,
        String email,
        String nickname,
        String memo
)  implements UserDetails {
    public static BoardPrincipal of(String username, String password, String email, String nickname, String memo) {
       
        Set<UserAccountRole> roleTypes = Set.of(UserAccountRole.USER);

        return new BoardPrincipal(
                username,
                password,
                roleTypes.stream()
                        .map(UserAccountRole::getValue)
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toUnmodifiableSet())
                ,
                email,
                nickname,
                memo
        );
    }
    public static BoardPrincipal from(UserAccountDto dto) {
        return BoardPrincipal.of(
                dto.userId(),
                dto.userPassword(),
                dto.email(),
                dto.nickname(),
                dto.memo()
        );
    }

    public UserAccountDto toDto() {
        return UserAccountDto.of(username,password, email, nickname, memo);
    }

UserDetails 인터페이스를 구현한 세션용 dto 를 만든다.
저장 데이터는 계정 테이블에 존재하는 컬럼들을 넣어주면 된다. (저 RoleType은 당장 필요 없을듯..)

@RequiredArgsConstructor
@Transactional
@Service
public class UserSecurityService implements UserDetailsService {
    private final UserAccountRepository userAccountRepository;

    @Override
    public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
            Optional<UserAccount> _account = userAccountRepository.findById(userId);
            if (_account.isEmpty()) {
                throw new UsernameNotFoundException("사용자를 찾을수 없습니다.");
            }
            UserAccount account = _account.get();
            List<GrantedAuthority> authorities = new ArrayList<>();
            if ("admin".equals(userId)) {
                authorities.add(new SimpleGrantedAuthority(UserAccountRole.ADMIN.getValue()));
            } else {
                authorities.add(new SimpleGrantedAuthority(UserAccountRole.USER.getValue()));
            }
        PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        return new BoardPrincipal(account.getUserId(), account.getUserPassword(), authorities, account.getEmail(), account.getNickname(), account.getMemo());
        }

저 BoardPrincipal 없이도 단순히 UserDetailsService 구현으로 반환값을 new User 로 주면 될거같긴한데.. 안해봐서 모르겠다. (이 클래스는 로그인 되었을때 로그인된 계정 정보를 넘겨주는 역할을 한다)

컨트롤러와 서비스 클래스 작성

 @GetMapping("/{articleId}")
    public String article(@PathVariable Long articleId, ModelMap map , ArticleCommentRequest dto,
                          CommentForm commentForm){
        ArticleWithCommentResponse article =  ArticleWithCommentResponse.from(articleService.getArticle(articleId));
        map.addAttribute("article",article);
        map.addAttribute("articleComments", article.articleCommentsResponse());
        map.addAttribute("dto",dto);
        return "articles/detail";
    }
    

댓글은 보통 한 페이지에 댓글만 달랑 있는게 아니라 게시글 밑에 댓글이 존재하니 Get 메소드로 게시글 하나를 조회할때 리퀘스트 Dto 도 같이 넘겨준다.
코멘트 폼은 @Valid 전용으로 넣어준 값인데.. 활용이 되는듯 안되는듯 (에러가 생기면 전 페이지로 돌아가고 문구가 출력되어야 하는데 되돌아가기만 한다.)

    @PreAuthorize("isAuthenticated()")
    @PostMapping("/{articleId}")
    public String writeArticleComment(@PathVariable Long articleId,ArticleCommentRequest dto,
                                      @Valid CommentForm commentForm,
                                      BindingResult bindingResult,
                                      @AuthenticationPrincipal BoardPrincipal principal){
        if (bindingResult.hasErrors()) {
            return "redirect:/articles/"+articleId;
        }
        articleCommentService.saveArticleComment(dto.toDto(principal.toDto()));
        return "redirect:/articles/"+articleId;
    } //현재 접속한 사용자의 정보를 받아와 DTO로 넘겨주고 , 댓글을 작성함
    
    
    @PreAuthorize("isAuthenticated()")
    @PostMapping("/update/{articleId}/{articleCommentId}")
    public String updateArticleComment(@PathVariable Long articleCommentId
            ,@PathVariable Long articleId
            ,@Valid CommentForm commentForm, BindingResult bindingResult,ArticleCommentRequest request){
        if (bindingResult.hasErrors()) {
            return "redirect:/articles/"+articleId;
        }
        articleCommentService.updateArticleComment(articleCommentId,request.content());
        return "redirect:/articles/"+articleId;
    }

post 메소드로 댓글을 저장할때 쓸 메소드를 선언해준다. 댓글이 달리고 바로 보고있던 게시글을 다시 출력하게 게시글 아이디와 , 검증용 BindingResult 도 추가해준다.
@AuthenticationPrincipal 어노테이션을 붙여서 UserDetails 서비스에서 아까 리턴해준 값들을 들고온다.

수정은 단순히 입력값만 String 값으로 넣어준다.

    public void saveArticleComment(ArticleCommentDto dto) {
        try {
            Article article = articleRepository.getReferenceById(dto.articleId());
            UserAccount userAccount = userAccountRepository.findById(dto.userAccountDto().userId()).orElseThrow();
            ArticleComment articleComment = ArticleComment.of(article,userAccount,dto.content());
            articleCommentRepository.save(articleComment);
        } catch (EntityNotFoundException e) {
            log.warn("댓글 저장 실패. 댓글 작성에 필요한 정보를 찾을 수 없습니다 - {}", e.getLocalizedMessage());
        }
    }
    
    public void updateArticleComment(Long id,String content) {
        try {
            ArticleComment articleComment = articleCommentRepository.getReferenceById(id);
            if (content != null) { articleComment.setContent(content);}
        } catch (EntityNotFoundException e) {
            log.warn("댓글 업데이트 실패. 게시글을 찾을 수 없습니다 - dto: {}", e.getLocalizedMessage());
        }
    }

ArticleCommentRequest 에서 변환된 Dto에서 엔티티를 뽑아내서 댓글 Repository 에 저장 혹은 기존에 있던 댓글을 뽑아내와서 수정한다.

솔직히 여기까지는 큰 문제가 없다.

타임리프로 뷰에서 기능을 구현하는게 가장 골치아프다고 해야되나 ..

댓글 작성 뷰 작성과 기능 구현

<p class="comment">댓글</p>
        <div>
            <p class="comment2" sec:authorize="isAnonymous()">로그인 후 댓글을 작성할 수 있습니다.</p>
        </div>
        <div>
            <form sec:authorize="isAuthenticated()"
                  th:action="@{|/articles/comments/${article.id}|}" th:object="${dto}" method="post">
                <div class="input-group" style="width:auto">

                    <label for="content" class="form-label mt-4" hidden>댓글 작성</label>
                    <input type="text" th:field="*{content}" class="form-control" id="content" name="content"
                           placeholder="1자에서 100자 이내로 입력해주세요.">
                    <div class="field-error" th:errors="*{content}">
                        오류2
                    </div>
                    <button type="submit" class="btn btn-outline-dark">작성</button>
                    <br>
                </div>
            </form>

순서대로 설명해보자면 .. 로그인 되어있지 않은 상태에서는 로그인 후에 댓글을 작성할 수 있다는 문구를 출력해준다.

로그인 되어있는 상태라면, 댓글 작성 url 을 포스트 메소드로 걸어놓고, 내용 입력 태그에 타임리프 문법으로 content 라는 값에 입력할 필드라는 th:field 를 입력해준다.

 

(그 밑 th:errors 는 Bingding Results 가 에러를 가질때 에러 문구를 출력할 자리인데.. 댓글폼은 동작을 안하더라 ㅠㅠ)

여기까지도 딱히 큰 문제는 없다.. 진짜 문제는 댓글 수정 ..

 

댓글 수정 같은 경우에는 , 타임리프로 each 문을 돌려서, 모델맵으로 넘겨준 댓글 리스트에서 댓글을 하나씩 뽑아와서 출력하게 했고, 그 댓글 닉네임 출력부분 옆에, 로그인 상태이고 댓글 작성자가 로그인된 아이디와 같다면 수정 삭제 버튼이 출력되도록 했다.

 

내가 구현하고 싶었던건 , 수정 버튼을 누르면 댓글 하나가 내용이 폼 입력칸으로 바뀌면서 기존에 입력되있던 값을 갖고와서 원하는부분만 수정하는쪽으로 하고싶었지만,

 

그렇게 하려면 Jquery 를 이용해서 비동기식으로 다들 하시는거 같았다. 근데 나에겐 너무 복잡했고 이미 동기식으로 처리를 다 해놓은 나는 그렇게 하려면 바꿔야할게 너무 많았다고 생각했다.

 

그래서 생각했던게, 부트스트랩 콜랩스를 이용해서 수정 버튼을 누르면 입력칸이 밑으로 뾰로롱~ 펼쳐져서 수정을 하면 좋겠거니~ 라고 생각했다.

그래서 이렇게까지 구현을 했었다.

근데 이렇게 하니까 문제점이 뭘까 ..?

내가 수정이 가능한 모든 댓글에 입력칸이 등장했다...........

문제가 뭘까 하고 온갖 방법을 시도해보았었다. 하지만 결국엔 해결을 못하고 있다가

th:id 라는 문법이 있다는걸 발견했다.

 

그리고 가장 큰 문제는 내가 타임리프 문법속 each 문에 다른것들과 똑같이 콜랩스를 적용시켜도 콜랩스는 그 각각 칸으로 적용되는게 아닌 딱 그 태그 하나만 실행이 되는것 같았다. 결론적으론 프론트 문제였다.

 

온갖 검색을 동원한 결과..

<button th:attr="data-bs-target=|#c${articleComment.id}">
...
<div class="collapse" th:id="c + ${articleComment.id}">

이런 식으로 각 댓글 div 마다 댓글id 를 고유값으로 입력하게 하여서 one to many 에서 one to one 으로 변경을 해주었다.

그 결과 이렇게 각각 칸마다 수정을 할 수 있게 콜랩스 기능이 동작했다!

마무리

타임리프로 이렇게 댓글 수정을 구현 하는 글은 찾지 못했고,

도저히 못해결 하겠어서 고민하다가.. 스택오버플로우에 나처럼 비슷하게 콜랩스를 각 객체마다 넣고싶은데, 그게 안돼서 고민하시던 몇몇분들의 글을 참고해서 해결해보았다.

수정 관련 html 소스는 길고 또 너무 드러워서 .. (가독성이 안좋음..) 개인 기록용으로 글을 남기지만 혹시 필요한 분이 계시면 언제든 도와드리니 댓글 남겨주세용

  Comments,     Trackbacks
스프링부트 + JPA 양방향 맵핑 해시태그 기능으로 구현하기

해시태그

해시태그 하는 걸 생각하면 무엇이 가장 먼저 떠오를까?

그렇다. 인스타그램이나 트위터의 해시태그 기능이 떠오를 것이다. (마찬가지로 슛폼 콘텐츠 등.. 굉장히 여러 곳에서 사용되는 기능이다.)

해시태그를 구현할때 , 단순히 그냥 한 해시태그를 등록하게 설계해 검색으로 해당 해시태그가 등록된 글을 찾을 수 있게 구현을 할 순 있다.

하지만 단건으로 등록을 한다면?

해시태그의 본질은

내가 해당 주제에 대해서 검색을 했을 때, 해당 주제에 대해 쓰인 모든 글을 볼 수 있어야 하고, 마찬가지로 한 가지 주제가 아니라면 해시태그는 두 개 이상이 등록이 가능해야 한다.

단순히 기능 구현만을 위한 목적이라면 단건으로 등록해 게시글 엔티티에서 해시태그를 관리하게 도메인을 설계할 순 있다.

단건으로 설계를 해볼까?

단건으로 설계할 때는 굉장히 간단하다.

단순히 게시글 (Article) 엔티티에 varchar(50) 정도 되는 해시태그 칼럼을 배정해주면 된다.


이미지 출처 - 해시태그 구현하기 / juna-dev.log
이런식으로 하나만 등록해 구현하게 할 수 있다.

이 경우에 우리가 흔히 생각하는 해시태그의 기본 기능인 해당 해시태그가 등록된 게시글을 검색할 수 있다.

하지만 이게 맞을까?

해시태그는 말 그대로해당 주제에 대해서 단어나 문장별로 정리를 해주는 중간 역할이라고 볼 수 있다.


(퍼온 예제이다. 정국님 생일 축하드려요..)

이렇게 어떻게 보면 게시글 <-> 해시태그 가 서로 양방향 관계를 가진다고 볼 수 있다.

JPA에서는 이런 상황들을 위해 OneToOne부터 ManyToMany까지 다양한 양방향 맵핑을 지원해준다.

설계를 바꿔서 연관관계를 재설계해보자.

기존에 설계했던 도메인은 게시글에 하나의 해시태그 가 들어가는 구조였다.

이제 새로 해야 할 것은 다른 Hashtag 테이블을 만들어 Article 테이블과 연관관계를 설정해주는 것이다.

이렇게 단순 기능 구현을 위한 hashtag 엔티티를 새로 생성해주고,

따로 엔티티는 아니지만 JoinTable로 각각 게시글 , 해시태그 엔티티에 서로의 id 값을 FK로 잡을 수 있는 양방향 맵핑 테이블을 설정해준다.

@Entity
@Getter
@ToString
public class hashtag {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Setter
    @Column(nullable = false)
    private String hashtag;

    @ToString.Exclude
    @ManyToMany(mappedBy = "hashtags")
    private Set<Article> articles = new LinkedHashSet<>();

이렇게 해시태그 도메인을 생성해주고, 한 해시태그에 여러 개의 게시글이 담겨올 수 있으니 HashSet으로 게시글을 담아주도록 ManyToMany 관계를 맺어준다.
(ToString.Exclude 는 무한 참조를 방지하기 위한 어노테이션이다.)

    @ToString.Exclude
    @JoinTable(
            name = "article_hashtag",
            joinColumns = @JoinColumn(name = "articleId"),
            inverseJoinColumns = @JoinColumn(name = "hashtagId")
    )
    @ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
    private Set<hashtag> hashtags = new LinkedHashSet<>();

마찬가지로 게시글 엔티티에도 해시태그들을 모아 올 수 있게 ManyToMany로 해시태그를 담을 HashSet를 선언해주고 맵핑을 해준다.

@JoinTable은 JPA의 연관관계 맵핑을 위한 어노테이션으로 엔티티가 아닌 별도의 테이블을 생성해줘 연관관계를 맺을 수 있게 도와준다.
(해당 어노테이션 속 name 은 테이블 이름, joinColumns는 각각 연관관계를 맺을 테이블의 요소를 입력해주면 된다.)

여기서 cascad로 영속성 전이 설정을 해주지 않으면 SQL오류가 발생하니 꼭 설정해주자.

Dto 수정하기

기존 Dto 같은 경우에는 게시글에 Hashtag String으로 있기 때문에, 안건으로 게시글을 작성하거나 Response로 받아올 때 String으로 받아오도록 되어있었다.

그렇기 때문에 이제는 hashtag Set을 담아오도록 수정해야 한다.

그전에 HashtagRepository를 생성해주고, JpaRepository를 Extends 해준 뒤에, HashtagDto 부터 만들어보자.

HashtagDto 작성

public record HashtagDto(Long id, String hashtag) {
        public static HashtagDto of(String hashtag) {
            return new HashtagDto(null, hashtag);
        }

        public static HashtagDto of(Long id, String hashtag) {
            return new HashtagDto(id, hashtag);
        }

        public static HashtagDto from(Hashtag entity) {
            return new HashtagDto(
                    entity.getId(),
                    entity.getHashtag()
            );
        }
        
        public Hashtag toEntity() {
            return Hashtag.of(
                    id,
                    hashtag
            );
        }

필요에 따라서 record로 작성할 수 있고, 세터가 필요하다면 class 로 작성할 수 있다.

record 로 편하게 작성하는 쉬운 방법이 있다.

바로 유료 플러그인인 JpaBuddy 를 활용하는 것이다.

JpaBuddy

유료 플러그인이지만 한 달 동안 무료체험이 가능하다. (나 같은 경우에는 필요하다면 유료로 결제할 마음도 있을 만큼 유용하다.)


이렇게 인텔리제이상에 새로 만들기 탭에 강아지 모양 메뉴가 새로 생긴다. 온갖 기능이 있지만 지금 내 레벨에서는 Dto 생성할 때 활용하기 굉장히 좋았다.

이렇게 엔티티를 설정하면 레코드 형식으로 Dto를 생성해준다.

연관된 Dto들 수정해주기

이제 HashtagDto까지 생성해주었으니, 게시글을 등록할 때 전달해줄 RequestDto와 출력할 때 필요한 Response Dto들을 수정해준다.

이경우에는 크게 수정할 것들 없이 기존에 String Hashtag 이렇게 되어있는 것들을 HashtagDto 타입을 가진 HashSet으로 변경해주면 된다.

게시글을 등록할 때 사용하는 RequestDto 같은 경우에는, 여러 가지 방안을 생각할 필요가 있다.

해시태그 복수 등록

단순히 하나만 등록한다고 하면, #안녕 이런 식으로 등록할 수 있지만, 복수 등록할 때는 #안녕 #안녕하 #안녕하세 #안녕하세요 이렇게 입력을 했을 때 4개의 해시태그를 등록할 수 있도록 설계하면 좋을 것이다.

그래서 RequestDto 같은 경우에는 세터를 사용할 수 있게 레코드 형식이 아닌 클래스로 작성을 해줌으로써, String Hashtag를 입력받아오고, StringTokenizer로 '#' 별로 토큰을 생성해 HashtagDto.of(st.nextToken()) 을 활용했고, replaceAll로 공백을 제거해준 뒤에 입력시켜주었다.

비즈니스 로직 설계

해시태그 같은 경우에는 따로 해시태그를 사용하는 곳이 대부분 게시글에서 사용된다고 생각했기 때문에, 따로 hashtagService를 만들어주지 않고 ArticleService에 hashtag 관련 로직들을 설계해주었다.

이 경우에는 사실 ManyToMany로 임시 중간 테이블이 생성되는 상태이기 때문에 , 단순히 따로 HashtagService를 생성해서 해시태그를 저장해 줄 필요 없이 이미 맵핑이 되어있는 상태이기 때문에, 게시글만 저장해도 자동으로 hashtag까지 저장이 된다.

하지만 괜찮을까?

이렇게 순서대로 따라 하다 보면, 오류가 생긴다.

바로 중간 임시 테이블 (게시글이 post고 태그가 tag 면 post_tag라는 테이블이 자동으로 생성될 것이다.) 이 각각 연결관계마다 고유 id를 갖고 있지 않기 때문에 오류가 발생할 것이다.

대표적으로 한 게시물에 해시태그를 두 개 이상 등록하려 시도했을 때 오류가 발생할 것이고, 설계 자체가 잘못되었다고 생각하면 될 것 같다. (글쓴이가 어디서 글 보고 이게 잘못된 설계라고 생각하고 온 게 아니라 글쓴이가 직접 시행착오 겪어보니 구조 자체가 이상했었다..)

해시태그의 본질은 내가 여러 해시태그를 등록했을 때 그 해시태그가 등록된 게시물을 조회할 수 있어야 하는데 복수 등록에 오류가 발생한 는 게 가장 컸고

그다음으로 중간 관계 맵핑이 엔티티가 아닌 ManyToMany 임시 테이블이다 보니, 게시글을 삭제할 때 해시태그도 같이 삭제된다는 게 크다.

거기에 ManyToMany 관계를 맺어주는 것은 실무에서 사용하기엔 한계가 있다는 글을 자주 보았다.

Spring-boot JPA @ManyToMany 실무에서 사용하면 안 되는 이유

바로 위의 이유와 비슷한 것 같다. 세밀하게 테이블을 다룰 방법이 없기 때문에, ManyToMany를 없애고 중간 엔티티를 따로 만들어줘서 맵핑을 관리하도록 하는 방향으로 선택했다.

 

중간 엔티티 만들어주기

기존에 게시글에선 Hashtag타입이 담긴 hashset, 해시태그에선 Article타입이 담긴 Articles로 ManyToMany 관계를 맺어주는 구조에서,

해당 부분을 지우고, ArticleHashtag 엔티티를 생성해줘 해당 엔티티 필드에 Article과 Hashtag를 @JoinColumn으로 각각 게시글과 해시태그의 PK를 참조하는 방식으로 관계를 맺어주었다.

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(cascade = CascadeType.ALL)
    @JoinColumn(name = "article_id")
    private Article article;

    @ManyToOne(cascade = CascadeType.ALL)
    @JoinColumn(name = "hashtag_id")
    private Hashtag hashtag;

영속성 설정을 꼭 해주자. 중간 엔티티는 게시글과 해시태그에서 참조를 받기 때문에 ManyToOne으로 설정해주면 된다.

각각 엔티티 연관관계를 수정해주고, Dto 또한 수정해주자

    @OneToMany(mappedBy = "article")
    @ToString.Exclude 
    @Setter
    private Set<ArticleHashtag> hashtags = new HashSet<>();
    @OneToMany(mappedBy = "hashtag")
    @ToString.Exclude
    private Set<ArticleHashtag> articles = new HashSet<>();

이렇게 기존에 각각 해시태그와 게시글 타입의 HashSet에서 중간 엔티티 타입을 넣어주고 관계를 맺게 해 준다. 중간 엔티티에서 각각의 엔티티를 참조하므로 OneToMany 관계가 된다.

그리고 필요에 따라서 Dto를 수정해주어야 한다. 중간 관계 엔티티를 따로 생성해주었기 때문에, 게시글을 불러올때 따로 hashtagDto 를 가져오도록 구성할 필요 없이

비즈니스 로직에 중간관계 엔티티에서 게시글 아이디를 입력하면 게시글과 해시태그를 같이 가져오도록 추가시킨 후에 map에 해시태그를 addattribute 하는 방식으로 설계하면 된다.

리스폰스 Dto에 불필요하게 껴있는 hashtagDto 타입의 hashSet들을 지워주면 된다.

비즈니스 로직 재설계

일단 게시글을 등록하는 것부터 수정해주자.

 public void saveArticle(ArticleDto dto, Set<HashtagDto> hashtagDto) {
        Article article =articleRepository.save(dto.toEntity());
        for (HashtagDto hashtag : hashtagDto) {
                Hashtag hashtag1= hashtagRepository.findByHashtag(hashtag.hashtag())
                .orElseGet(()-> hashtagRepository.save(hashtag.toEntity()));
                articlehashtagrepository.save(ArticleHashtag.of(article,hashtag1));
        }
    }

ArticleRequest는 게시글 내용과 문자열 타입의 Hashtag를 입력했을 때, 게시글은 Dto로 가져와주고, Hashtag는 '#'별로 나눠서 Set에 추가해 HashtagDto가 담긴 HashSet으로 반환해준다.

사실 웃긴 건 기존에 내가 작성했던 코드는 이러했다.

ㅋㅋ(할 말 잃음) 이렇게 작성하니 영속성 관련 오류가 발생했었는데 위의 코드로 수정하니 오류가 사라졌다.

일단 게시글 먼저 저장해주고 맵핑을 해주면 된다.

hashtag를 반복문을 통해 이미 존재하는 해시태그라면 해당 엔티티를 가져오고, 존재하지 않는다면 저장한 후 반환시켜 그것을 중간 엔티티에 각각 저장한 후에 중간 엔티티까지 레포지토리에 저장하면

게시글과 해시태그가 등록이 되고, 그 각각 엔티티들을 맵핑한 중간 엔티티가 저장되어 양호한 관계를 맺게 된다.

컨트롤러 수정해주기

게시글을 등록할 때 게시글 DTO와 해시태그 DTO 셋을 가져오도록 변경했기 때문에 컨트롤러 또한 리퀘스트 Dto에서 해시태그 셋과 게시글 Dto를 뽑아 매개 값으로 입력하도록 변경해주어야 한다.

    @PostMapping("/post")
    public String articleSave(@Valid ArticleForm articleForm,BindingResult bindingResult,
        ArticleRequest dto,
        @AuthenticationPrincipal BoardPrincipal boardPrincipal) {
            if(bindingResult.hasErrors()){
                return "articles/post/article_form";
            }
        articleService.saveArticle(dto.toDto(boardPrincipal.toDto()),dto.getHashtags());
        return "redirect:/articles";
    }
    

해시태그 등록을 해보자

이렇게 도메인과 비즈니스 로직을 재설계하고 컨트롤러까지 해당 구조에 맞게 변경을 해주었다면, 내가 직접 뷰에서 입력을 했을 때 DB들이 잘 들어가는지 직접 실행해보자

이렇게 입력하고 저장을 해보자.

잘 저장되는 모습이다. (리스폰스 Dto가 수정된 후 뷰 템플릿 수정은 단순히 해당 해시태그가 출력되는 부분에 th:each로 값을 넣어주면 된다.)

중간 테이블에도 저장이 잘 된다.

이젠 복수 저장을 해볼까?

이렇게 안녕부터 안녕하세요 까지 총 4개의 해시태그를 등록했다.

정상적으로 등록이 되는 모습이다.

이렇게 중간 엔티티 또한 하나의 게시글에 4개의 해시태그가 등록됨으로써 총 4개의 PK가 생성된 모습이다.

게시글 수정/삭제 구현

게시글을 수정하고 삭제할 때는 단순하다. 중간 엔티티를 삭제하지말고 각각 게시글 id와 해시태그 id 값을 null로 변경한 후에 수정할 때는 변경 값을 먼저 저장하고, 또 새 엔티티를 저장해주면 된다.

삭제 시에는 단순하게 중간 엔티티 값을 null로 변경한 후에 게시글을 삭제하면 된다.

또한 수정할 때도 기존에 입력되었던 해시태그 값도 #해시태그 #해시태그 1 같은 패턴으로 받아오면 보기 편하기 때문에 컨트롤러에서 StringBuilder 같은 것을 활용하여 각각 해시태그들을 불러온 후에 #와 공백을 붙여서 addattribute 해주면 된다.

이렇게 해당 글의 수정 버튼을 누르면 값을 완전히 받아오고

이렇게 변경하고 입력하면

수정이 된다.

중간 테이블에는 기존 값들이 null로 변경된 후 새로운 해시태그와 관계가 맺어진 모습이다.

마치며

해시태그 기능 구현은 단순히 방법 정도만 제시해주고 내가 직접 구현할 때 참고할만한 자료가 크게 없었다.

해당 기능을 구현한 글들이 어느 분의 개발 실력 향상에 도움이 되었으면 하는 마음과 내가 해결했던 문제들을 기록하고자 하는 마음에 글을 작성하게 되었다.

 

  Comments,     Trackbacks
스프링 부트+ JPA 로 파일 입출력을 쉽게 구현해보자 (Spring Data JPA)

서론 🙋‍♂️

  • 스프링부트로 프로필 이미지를 변경해보자 🤔 (파일 입출력)를 응용해서 파일을 업로드하고 해당 파일의 경로와 이름, 확장자 그리고 사이즈까지 저장하는 Jpa Repository 를 생성해 어플리케이션 내의 파일을 관리합니다.
  • 파일을 관리할 수 있는 유틸리티 클래스를 생성해서 편리하게 가공할 수 있도록 구현합니다.

본론 🙆🏻‍♂️

전체적인 흐름은 이렇습니다.

  • 파일 엔티티 클래스 생성하기
  • 파일 Dto 생성하기
  • 파일 Repository 생성하기
  • 파일 유틸리티 클래스 생성후 정적 유틸리티 메소드 선언하기
  • 서비스 클래스 생성 후, 기본적인 CRUD 테스트 코드 작성하기
  • 각각 파일 입출력이 필요한 엔티티 서비스 계층에서 활용해보기
  • 기본적인 파일 저장 방식은 commons-io 디펜던시를 추가하여 사용하며, DB에는 파일 자체를 저장하지 않고 경로와 이름을 저장해 출력은 바이트 어레이로 경로를 참조해 출력시킵니다.

파일 엔티티 클래스 생성하고 관련된 Dto까지 생성하기 💁🏻

yaml 파일에 commons-io 관련 설정을 입력해주고, 파일이 저장될 경로를 지정해주세요. (해당 파일 자체를 DB에 저장하지 않고 경로만 참조합니다.)

(해당 글은 Spring Data Jpa 를 사용합니다.)

@Entity
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString
public class SaveFile extends AuditingFields{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Setter
    @Column(nullable = false)
    private String fileName;
    @Setter
    @Column(nullable = false)
    private String filePath ;
    @Setter
    @Column(nullable = false)
    private String fileType;
    @Setter
    @Column(nullable = false)
    private Long fileSize;
    @Setter
    @Column(nullable = false)
    private String uploadUser;

기본적으로 사용될 필드변수들을 선언해줍니다.

해당 엔티티는 기존에 설정한 파일 경로를 참조해 파일을 불러와서 출력할 예정입니다.

@Builder
    public  record FileDto (
            Long id,
            String fileName,
            String filePath,
            String fileType,
            Long fileSize,
            String uploadUser,
						LocalDateTime createdAt,
            String createdBy,
            LocalDateTime modifiedAt,
            String modifiedBy
    )  {
        public static FileDto from(SaveFile saveFile) {
            return new FileDto(
                    saveFile.getId(),
                    saveFile.getFileName(),
                    saveFile.getFilePath(),
                    saveFile.getFileType(),
                    saveFile.getFileSize(),
                    saveFile.getUploadUser(),
                    saveFile.getCreatedAt(),
                    saveFile.getCreatedBy(),
                    saveFile.getModifiedAt(),
                    saveFile.getModifiedBy()
            );}
        public SaveFile toEntity() {
            return SaveFile.builder()
                    .id(id)
                    .fileName(fileName)
                    .filePath(filePath)
                    .fileType(fileType)
                    .fileSize(fileSize)
                    .uploadUser(uploadUser)
                    .build();
        }
    }

FileDto는 레코드 형식으로 생성 주었습니다. 저 같은 경우에는 Dto가 늘어남에 따라서 엔티티 클래스 안에 정적 클래스로 생성해주어서 한 도메인이 담당하는 엔티티 클래스와 Dto 클래스들을 묶어서 관리하고 있습니다.

File 엔티티 클래스와 Dto 클래스 필드에 선언된 변수가 다른 이유는 Audititng Fields 인터페이스를 생성해서 각 엔티티 클래스속 공통적으로 들어가는 필드를 따로 분리해서 관리할 수 있게 해 줬습니다.

JPA Auditing 기능이란? <- 해당 글을 참고하시면 좋을 것 같습니다 :D\

JpaRepository 생성하고 서비스 클래스 생성하기 💁🏻

@RepositoryRestResource
public interface SaveFileRepository extends JpaRepository<SaveFile, Long> {
    public SaveFile findByFileName(String fileName);
}

JpaRepository를 상속받는 인터페이스를 생성해줍니다.
(@RepositoryRestResource 같은 경우는 Spring Data Rest 디펜던시를 추가하면 사용이 가능한데, 따로 API를 구현하지 않고도 REST API를 디펜던시가 구현해주어서 사용할 수 있게 해주는 어노테이션입니다.하지만 여기서는 사용하지 않습니다.)

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

    private final SaveFileRepository fileRepository;

    public SaveFile.FileDto getFile(Long fileId) {
        log.info("getFile() fileId: {}", fileId);
        return fileRepository.findById(fileId).map(SaveFile.FileDto::from).orElseThrow(()-> new EntityNotFoundException("파일이 없습니다 - fileId: " + fileId));
    }
    public void deleteFile(Long fileId) {
        log.info("deleteFile() fileId: {}", fileId);
        fileRepository.deleteById(fileId);
    }
    public SaveFile.FileDto saveFile(SaveFile.FileDto saveFile) {
        log.info("saveFile() saveFile: {}", saveFile);
        return SaveFile.FileDto.from(fileRepository.save(saveFile.toEntity()));
    }
}

마찬가지로 JpaRepository를 생성했으니 비즈니스 로직을 설계할 차례입니다.
테스트 코드를 작성하면서 메서드를 작성 할 수 있으나 , 기본적인 추가 삭제 조회 정도만 구현하면 되기때문에 패스해도록 하겠습니다

File 유틸리티 클래스 생성하기 💁🏻

저번 글에서는 MultipartFile 인터페이스를 사용해서 파일을 받아왔고, 파일 객체로 변환하는 작업을 모두 메소드 안에서 일일이 처리했기 때문에 이번에는 유틸리티 클래스를 생성해 관리하도록 편의성을 부여해줍니다.

public class FileUtil {
    private FileUtil() {
    }
    @Value("${com.example.upload.path.profileImg}")
    public static String uploadPath;


	//확장자를 가져오는 메소드
    public  static String getExtension(String fileName) {
        return fileName.substring(fileName.lastIndexOf(".") + 1);
    }
    //파일 이름만을 가져오는 메소드
    public static String getFileName(String fileName) {
        return fileName.substring(0, fileName.lastIndexOf("."));
    }
	//UUDI값을 파일이름에 붙여줘서 가져와주는 메소드
    public static String getFileNameWithUUID(String fileName) {
        return UUID.randomUUID().toString() + "_" + fileName;
    }
    //정적 팩토리 메소드
    public  static File  createFile(String uploadPath, String fileName) {
        return new File(uploadPath, fileName);
    }

    public static File getMultipartFileToFile(MultipartFile multipartFile) throws IOException {
        File file = new File(uploadPath,getFileNameWithUUID(multipartFile.getOriginalFilename()));
        multipartFile.transferTo(file);
        return file;
    }

    public static File getFileFromFileDomain(SaveFile.FileDto fileDto) {
        return new File(fileDto.filePath());
    }


    public static void deleteFile(SaveFile.FileDto profileImg) {
        File file = getFileFromFileDomain(profileImg);
        if (file.exists()) {
            file.delete();
        }
    }
    //멀티파트파일을 파일객체로 변환후 FileDto로 리턴해주는 메소드 
    public static SaveFile.FileDto getFileDtoFromMultiPartFile(MultipartFile multipartFile, String uploadUser) throws IOException {
        File file = getMultipartFileToFile(multipartFile);
        String fileName = file.getName();
        String fileType = getExtension(Objects.requireNonNull(multipartFile.getOriginalFilename()));
        Long fileSize = multipartFile.getSize();
        return SaveFile.FileDto.builder()
                .fileName(fileName)
                .filePath("yourpath"+fileName)
                .fileType(fileType)
                .fileSize(fileSize)
                .uploadUser(uploadUser)
                .build();// TODO: 경로가 자꾸 null 로 입력되기 때문에 해결 방안을 찾아야함.
    }

맨 아래 메서드를 가장 많이 사용하게 될 것 같은데요 ,저같은 경우는 다른 메소드에서는 경로를 정상적으로 참조하는 반면에 해당 메소드는 null값으로 입력이 되어서 임시방편으로 메소드 내에 경로를 다시 선언해주었습니다.

해당 유틸리티 클래스로 각각 파일을 전달받는 컨트롤러에서 멀티파트 파일을 프런트에서 받아오면 FileDto 로 변환 후에 저장 후 필요한 객체에 Set 하는 방식으로 이용합니다.

컨트롤러에서 활용해보기 💁🏻

    @Setter
    @ToString.Exclude
    @ManyToOne
    @JoinColumn(name = "profile_img_id")
    @Nullable
    private SaveFile profileImg;

저는 유저 엔티티 필드 변수 속 단순 path와 name으로 선언해두었던 프로필 파일을 엔티티를 변수로 갖도록 변경해주었습니다.

    @PostMapping("/signup")
    public ResponseEntity<?> signup(@RequestPart("signupDto") @Valid UserAccount.SignupDto signupDto, BindingResult bindingResult
    ,@RequestPart(value = "imgFile",required = false) MultipartFile imgFile) throws IOException {
        if (bindingResult.hasErrors()) {
            return new ResponseEntity<>(ControllerUtil.getErrors(bindingResult), HttpStatus.BAD_REQUEST);
        }
        else if(!signupDto.getPassword1().equals(signupDto.getPassword2())){
            bindingResult.addError(new FieldError("userCreateForm","password2","비밀번호가 일치하지 않습니다."));
            return new ResponseEntity<>(ControllerUtil.getErrors(bindingResult), HttpStatus.BAD_REQUEST);
        }
        if(imgFile==null){
            userService.saveUserAccountWithoutProfile(signupDto);
        }
        else {
            userService.saveUserAccount(signupDto, fileService.saveFile(FileUtil.getFileDtoFromMultiPartFile(imgFile,signupDto.getUserId())));
        }
        return new ResponseEntity<>("success", HttpStatus.OK);
    }

기존에 작성해둔 메서드를 수정합니다. multipartFile 자체를 매개 값으로 전달했었으나, 이제는 유틸리티 클래스의 도움을 받아서 fileDto 로 변환 후jpaRepository에 저장 후에 식별자를 매개 값으로 넘겨주게 됩니다.

    public void saveUserAccount(UserAccount.SignupDto user, SaveFile.FileDto fileDto) throws IOException {
        String password = user.getPassword1();
        UserAccount account = userAccountRepository.save(user.toEntity());
        account.setUserPassword(new BCryptPasswordEncoder().encode(password));
        account.setProfileImg(fileDto.toEntity());
    }

그렇게 전달받은 file을 단순히 계정 엔티티에 set 해주면 됩니다.

출력해보기 💁🏻

이제 DB에 해당 엔티티를 참조할 수 있도록 구현해놨으니 출력하는 것도 구현해야 합니다.

    @GetMapping("/accounts/{username}")
    public ResponseEntity<?> getProfileImg (@PathVariable String username) throws IOException {
        File profileImg = FileUtil.getFileFromFileDomain(userService.getUserAccount(username).profileImg());
        byte[] imageByteArray = IOUtils.toByteArray(new FileInputStream(profileImg));
        return new ResponseEntity<>(imageByteArray, HttpStatus.OK);
    }

여기서도 마찬가지로 FileUtil 클래스를 이용해 엔티티를 파일 객체로 만들어줍니다.
그리고 IOUtils 클래스가 제공하는 바이트 어레이 변환 메서드를 이용해 바이트 배열로 변환해주고 객체로 전달해줍니다.

이제 이미지 출력이 필요한 부분에 src 태그에 해당 경로를 집어넣으면 프로필 사진이 출력되게 됩니다!

시연 화면 💁🏻

아무 사진 하나만 회원 가입할 때 업로드해줍니다.

이렇게 가입이 완료되면 이전과는 다르게 파일 경로와 이름이 DB에 저장되는 것이 아닌 외래 키로 다른 테이블 식별자를 참조하게 됩니다.

이렇게 파일 저장용 테이블이 만들어져서 업로드 유저와 함께 사이즈, 확장자, 그리고 경로 이름이 칼럼 값으로 들어가게 됩니다.

자 이렇게 업로드한 프로필 사진이 정상적으로 출력이 됩니다. (사진은 제 셀카예요;;)

마찬가지로 해당 로직을 응용해서 수정도 할 수 있습니다.

수정을 하게 될 경우엔 기존에 존재하던 파일을 삭제하고 새로 저장된 파일 엔티티를 참조하는 방식으로 구현했습니다.

; 자 이렇게 다른 이미지에서 또다시 원래 이미지로 변경하고 수정을 하게 되면

기존에 등록된 프로필 이미지는 삭제됩니다! 물론 deleteFile 메서드도 호출했으니 로컬에서도 삭제됩니다.


마무리 💆🏻‍♂️

  • 생각보다 어려워 보였던 파일 입출력을 쉽게 구현해보았습니다.
  • fileRepository 의존성 주입을 다른 클래스에서 해도 되나가 가장 큰 고민거리였습니다.
  • 해당 고민은 fileservice 를 파일 입출력이 필요한 컨트롤러에서 주입받아 이용하도록 해결했습니다.
  • 바이트 배열로 전달하는것 외에 더 효율적인 출력방법이 있을까요 ? 저는 아직 모르겠습니다 ;(
  • A(프로필 이미지 단건 업로드) -> B (단건 참조) 까지 했으니 다음 목표는 C 게시글에 이미지 여러건 첨부 후 출력 으로 잡으면 되겠습니다! 🎅🏽

(해당 글은 블로그 이전으로 인하여 붙여 넣기 된 글입니다.)

 

  Comments,     Trackbacks
스프링부트로 프로필 이미지를 변경해보자 🤔 (파일 입출력)
 

사용기술

  • 스프링부트
  • 제이쿼리
  • 타임리프 살짝

서론

디테일하게 파일을 저장하는 레포지토리를 만들어서 해당 레포지토리에서 파일을 꺼내오는 것이 아닌 파일 입출력을 스프링 부트에서 어떻게 구현할 수 있는지에 대한 궁금증으로 인해

단순히 account 엔티티에 프로필 이미지 경로와 이름 변수를 선언해 단건으로 구현해보기로 했습니다.

해당 글에서 작성한 코드들은 다른분들의 글을 많이 참고했음을 알려드립니다.
$ 참고글


본론

전체적인 흐름은 이렇습니다.

프로필 이미지 필드 변수가 존재하지 않는 계정 엔티티에 프로필 이미지용 경로/ 이름 변수 선언해주기
👇🏻
엔티티에 필드변수가 추가되었으니 관련된 웹 계층/서비스 계층 테스트 코드 작성 후 다듬기
👉🏻 프로필 이미지가 업로드가 되었을 때 안되었을 때두 가지 케이스로 살펴보자.
👉 스프링 시큐리티에서 리소스에 대한 접근을 허용하도록 코드를 추가해주어야 한다.
👇🏻
앞단에서 제이쿼리로 파일 업로드받아서 뒷단으로 넘겨주기
👇🏻
타임리 프로 해당 프로필 이미지 출력하기

계정 엔티티 수정하기

지금 상황은 게시판 홈페이지에 프로필 이미지 관련 기능이 하나도 구현되어있지 않은 상황이므로 무에서 유를 창조해야 합니다. 👀

일단 파일 업로드 관련 디펜던시를 build.gradle 에 추가해주고 어느 경로에 업로드된 파일을 저장해줄지 yaml파일에 지정해줍니다.

dependencies {
	implementation('commons-io:commons-io:2.11.0')
    }
    multipart:
      max-file-size: 10MB
      max-request-size: 10MB
      location: /Users/brinc/Desktop
      enabled: true
com.example.upload.path.profileImg : /Users/brinc/Desktop 

절대 경로로 작성해야 합니다.

그리고 기존에 생성돼있는 계정 엔티티에 이미지 파일의 경로와 이름을 필드 변수로 선언해줍니다.

...
    @Setter private String profileImgName;
    @Setter private String profileImgPath;
...

계정 엔티티와 연관된 Dto에도 파일 경로를 추가해줄 수 있지 않을까? 하는 생각이 있었는데, 여러 시도 결과 @RequestPart로 회원가입 리퀘스트 Dto와 파일을 따로 요청받도록 했습니다.

제 실력선에서는 프런트단에서 업로드 시에 폼 데이터를 하나의 Dto로 전달받을 때 파일은 파일로, 양식은 json 타입으로 전달받는 방법을 찾지 못했습니다 😿

컨트롤러 내 회원가입 메서드 수정하기

이제 디펜던시와 엔티티도 수정을 해주었고, 뷰는 단순히 태그만 추가해줘도 되기 때문에 컨트롤러 내에 있는 메서드부터 수정해줍니다.

   @PostMapping("/signup")
    public ResponseEntity<?> signup(@RequestPart("signupDto") @Valid UserAccount.SignupDto signupDto, BindingResult bindingResult
    ,@RequestPart(value = "imgFile",required = false) MultipartFile imgFile) throws IOException {
        if (bindingResult.hasErrors()) {
            return new ResponseEntity<>(controllerUtil.getErrors(bindingResult), HttpStatus.BAD_REQUEST);
        }
        else if(!signupDto.getPassword1().equals(signupDto.getPassword2())){
            bindingResult.addError(new FieldError("userCreateForm","password2","비밀번호가 일치하지 않습니다."));
            return new ResponseEntity<>(controllerUtil.getErrors(bindingResult), HttpStatus.BAD_REQUEST);
        }// -----추가된 부분-------
        if(imgFile==null){
            userService.saveUserAccountWithoutProfile(signupDto);
        }
        else {
            userService.saveUserAccount(signupDto, imgFile);
        }
        return new ResponseEntity<>("success", HttpStatus.OK);
    }

제 목적은 최대한 기존의 코드의 큰 변경이 없이 기능만 추가할 수 있는 걸 원했기 때문에, 회원가입 관련 메서드만 수정해주면 되었습니다.

기존의 코드에서 이미지 파일이 존재한다면 디폴트 이미지로 프로필 이미지를 설정하도록 했고, 존재한다면 이미지 파일을 같이 받아와서 서비스 계층으로 넘겨주도록 코드를 추가했습니다.

비즈니스 로직 수정하기

이제 컨트롤러에서 서비스 계층으로 전달하는 값이 추가되었으니, 해당 값을 또 가공해서 디비에 반영해야 하니 메서드를 수정해야 합니다.

    public void saveUserAccount(UserAccount.SignupDto user,MultipartFile imgFile) throws IOException {
        String password = user.getPassword1();
        UserAccount account = userAccountRepository.save(user.toEntity());
        account.setUserPassword(new BCryptPasswordEncoder().encode(password));
        // ----- 추가된 부분 -----
        UUID uuid = UUID.randomUUID();
        String fileName = uuid.toString() + "_" + imgFile.getOriginalFilename();
        File profileImg=  new File(uploadPath,fileName);
        imgFile.transferTo(profileImg);
        account.setProfileImgName(fileName);
        account.setProfileImgPath(uploadPath+"/"+fileName);
    }

파일을 저장할 때는 랜덤 한 UUID를 생성해 이름에 부여합니다. (같은 이름을 가진 파일을 업로드할 때 오류가 생기면 안 되겠죠?)
그리고 UUID를 붙여서 새로 지정한 이미지 파일 이름과 경로를 기준으로 파일 객체를 생성합니다.

그리고 뷰에서 전달받은 파일을 multipartfile 클래스가 제공하는 transferTo 메서드로 파일 객체로 변환해줍니다.

그리고 간단하게 해당 엔티티의 프로필 이미지 경로와 이름을 업데이트해주면 됩니다!

회원가입 폼에 이미지 파일 업로드 태그 추가하기

이제 대충 뒷단은 구현했으니 앞단을 수정할 차례입니다.

<form method="post" style="width:700px"  enctype="multipart/form-data">
  <p class="register">회원가입</p>
  <label for="imgFile">프로필 사진</label>
    <input type="file" name="imgFile" id="imgFile"  accept=".jpg, .png" class="form-control">
    ...

단순히 기존에 작성돼있던 폼에 enctype을 추가해서 폼 데이터로 전송할 것을 명시해주고, 프로필 사진을 업로드해야 할 부분에 input 태그로 type을 file로 지정해주면 됩니다.

accept 에는 확장자를 입력하면 해당 확장자가 아닐 경우에 업로드를 하지 못하게 해 줍니다.

그리고 해당 값을 제이쿼리를 이용해서 컨트롤러로 전송합니다.

function signupCheck() {
    var formData = new FormData();
    formData.append("imgFile", $("#imgFile")[0].files[0]);
    var data = {
        info: {
            userId: $("#userId").val(),
            password1: $("#password1").val(),
            password2: $("#password2").val(),
            nickname: $("#nickname").val(),
            email: $("#email").val(),
            memo: $("#memo").val()
        }
    }
    formData.append(
        "signupDto",
        new Blob([JSON.stringify(data.info)], { type: "application/json" })
    );
    console.log(formData);
    //위에서 만든 오브젝트를 json 타입으로 바꾼다.
    $.ajax({
        type : 'POST',
        url : '/signup',
        data : formData,
        processData : false,
        contentType : false,
        success: function ( ){
            alert("회원가입 성공");
        },

저는 기존에 작성해뒀던 스크립트를 수정해서 사용했습니다.
formData 객체를 새로 생성해서 사용합니다.
객체의 첫 인덱스에는 이미지 파일을 담아주고, 두 번째 인덱스에 입력했던 값들을 json형태로 변환해서 담아줍니다.

키값에 입력되는 이름이 RequestPart 어노테이션의 이름에 들어갈 값과 같아야 합니다.

컨트롤러에 프로필 이미지를 가져와줄 메서드를 선언하자

프런트에서 해결해야 할 부분이 하나 더 남았는데, GET 메서드로 프로필 이미지를 바이트 배열로 전달받아야 합니다.

단순히 src태그에 경로를 붙여버리면, 빌드 전에 이미 존재하던 파일이 아닌 이상 읽어오지 못합니다.

    @GetMapping("/accounts/{username}")
    public ResponseEntity<?> getProfileImg (@PathVariable String username) throws IOException {
        UserAccount.UserAccountDto accountDto = userService.getUserAccount(username);
        InputStream inputStream = new FileInputStream(accountDto.profileImgPath());
        byte[] imageByteArray = IOUtils.toByteArray(inputStream);
        inputStream.close();
        return new ResponseEntity<>(imageByteArray, HttpStatus.OK);
    }

inputstream으로 파일을 읽어와서 바이트 배열로 전달해줍니다.

그리고 해당 프로필 이미지가 출력되어야 하는 부분에는 타임리프 문법인 th:src를 사용해서 이미지를 출력해줄 겁니다.

<img id="imgId" th:src="@{/accounts/}+${accountDto.userId}" width="100" height="100"  alt="첨부이미지" th:if="${accountDto.profileImgPath() != null}" />

동작하는지 확인해보기

파일을 업로드해줍니다. accept에 png와 jpg 만 받도록 명시해놨기 때문에 해당 파일 외의 파일들은 업로드하지 못합니다.

이렇게 새로 생성한 계정에 yaml 파일에 지정해둔 경로에 이미지가 업로드된 모습을 볼 수 있습니다. (파일을 업로드하지 않을 때는 단순히 같은 메서드에 default 이미지의 경로와 이름을 대입해주면 됩니다 🤓)

제가 임시로 만들어둔 페이지입니다. 이미지를 잘 출력하는 모습을 볼 수 있습니다

마찬가지로 이렇게 비슷한 패턴으로 프로필 수정 페이지를 구현해서 프로필 이미지를 변경할 수도 있습니다.

잘 변경이 되네요 😎


마치며

  • 입출력 스트림을 활용해보고 싶어서 시도해보았으나, 딥한 원리를 이해하지 못하고 구현한 느낌이 들어서 게시글 이미지/파일 첨부 기능 구현시에 좀더 딥하게 공부해볼 예정입니다.
  • 해당 기능을 구현하면서 겪었던 오류는 절대경로를 제대로 지정하지 않아 발생한 오류와, 이미지 파일 출력을 단순 절대경로로 출력하려고 할때 없는 파일로 인식이 되어 발생한 오류 이렇게 총 두가지 입니다.
  • 해당 기능을 구현할때 좀 더 살펴보면 좋은것은 MultipartFile 인터페이스가 어떠한 기능을 제공해주고 어떻게 돌아가는지 공식 문서를 참고하면 좋을 것 같습니다.
  • 단순히 파일 자체를 dto 로 전달받아 업로드 하는 방식도 있습니다만 저는 제이쿼리로 기존의 회원가입 폼을 만들었기 때문에 제이쿼리로 전달하는 방법을 찾아보았습니다.
  • 제이쿼리 스크립트 속 blob 은 [Javascript] API 활용 - Blob 해당 글을 참고하시면 좋을것 같습니다 :)

(해당 글은 블로그 이전으로 인하여 붙여넣기 된 글입니다.)

  Comments,     Trackbacks
스프링부트 Toast UI 에디터 / 뷰어 적용 + 이미지 입출력 -2

https://codinghentai.tistory.com/2

 

스프링부트 Toast UI 에디터 / 뷰어 적용 + 이미지 입출력 -1

참고한 게시글 : https://velog.io/@io_/Toast-UI-Editor-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C Toast UI Editor 이미지 업로드 Spring Boots를 사용해 Toast UI Editor에 이미지 업로드하기!!!! 어느날 미래에서 삽질

codinghentai.tistory.com

해당 글과 이어집니다.

 


저번 글에서 비동기 통신으로 즉각적으로 이미지를 업로드/붙여 넣기를 하면 이미지를 서버에 업로드 후 db에 저장, 그리고 출력까지 해오는 로직을 구현했었는데요,

 

이제부터는 게시글과 파일의 연관관계를 디테일하게 설정해 잉여파일까지 관리할 수 있는 로직을 구현해봅니다.

 

게시글 등록시에 중간 관계 엔티티 영속해주기

어떠한 기능을 구현하고자 할 때 가장 1차원적으로 생각해보는 게 큰 도움이 되는 것 같습니다.

지금 프로젝트의 구조는 게시글을 등록할때 리퀘스트 dto를 전달하는 방식으로 글을 등록했기 때문에,

 

단순히 파일 업로드 후 리스폰스 엔티티로 객체를 반환해준다면, 게시글 등록시에 리퀘스트 dto에 반환된 객체의 id값을 같이 받아오면 되겠구나! 하고 생각했습니다.

 

                    success: function(data) {
                        //console.log('ajax 이미지 업로드 성공');
                        url += data.fileName;
                        fileIds += data.id + ",";
                        // callback : 에디터(마크다운 편집기)에 표시할 텍스트, 뷰어에는 imageUrl 주소에 저장된 사진으로 나옴
                        // 형식 : ![대체 텍스트](주소)
                        callback(url, '사진 대체 텍스트 입력');
                    },

이렇게 에디터의 success 부분에 받아온 객체의 id를 문자열 변수에 append 하는 형태로 구현후에 같이 전달해줍니다.

 

그리고 비즈니스 로직을 수정해줄 건데요, ArticleSaveFile 엔티티 클래스는 게시글과 파일의 연관관계를 위해서 생성했기 때문에 repository는 게시글과 파일의 서비스 클래스에 둘 다 주입해줄 겁니다.

 

 

그전에 SaveFile 서비스에서 비즈니스 로직을 구현해주어야 합니다. 받아온 fileIds로 file set을 반환해주는 로직을 구현합니다.

 

    public Set<SaveFile.SaveFileDto> getFileDtosFromRequestsFileIds(Article.ArticleRequest dto) {
        StringTokenizer st = new StringTokenizer(dto.getFileIds(), ",");
        Set<SaveFile.SaveFileDto> saveFileDtos = new HashSet<>();
        while(st.hasMoreTokens()){
            Long fileId = Long.valueOf(st.nextToken());
            log.info("fileId: {}", fileId);
            saveFileDtos.add(saveFileRepository.findById(fileId).map(SaveFile.SaveFileDto::from).orElseThrow(() -> new EntityNotFoundException("파일이 없습니다 - fileId: " + fileId)));
        }
        return saveFileDtos;
    }

이렇게 HashSet을 생성해 StringTokenizer로 잘라서 dto로 가져와서 set에 담아 반환해줍니다.

 

이제 게시글 컨트롤러에서 리퀘스트 Dto 에 담긴 파일 아이디로 파일 dto를 가져와 같이 비즈니스 로직으로 넘겨줍니다.

 

    @ResponseBody
    @PostMapping
    public ResponseEntity<String> saveArticle(@RequestBody @Valid Article.ArticleRequest dto, BindingResult bindingResult,
                                              @AuthenticationPrincipal UserAccount.BoardPrincipal boardPrincipal) {
        if (bindingResult.hasErrors()) {
            return new ResponseEntity<>(ErrorMessages.ENTITY_NOT_VALID, HttpStatus.BAD_REQUEST);
        }
        if (boardPrincipal == null) {
            return new ResponseEntity<>(ErrorMessages.ACCESS_TOKEN_NOT_FOUND, HttpStatus.BAD_REQUEST);
        }
        Set<SaveFile.SaveFileDto> saveFileDtos = saveFileService.getFileDtosFromRequestsFileIds(dto);
        saveFileService.deleteUnuploadedFilesFromArticleContent(dto.getContent(), dto.getFileIds());
        return new ResponseEntity<>(articleService.saveArticle(dto.toDto(boardPrincipal.toDto()), Hashtag.HashtagDto.from(dto.getHashtag()), saveFileDtos).id().toString(), HttpStatus.OK);
    }
    public Article.ArticleDto saveArticle(Article.ArticleDto dto, List<Hashtag.HashtagDto> hashtagDtos, Set<SaveFile.SaveFileDto> saveFileDtos) {
        Article article =articleRepository.save(dto.toEntity());
        for (Hashtag.HashtagDto hashtag : hashtagDtos) {
                Hashtag hashtag1= hashtagRepository.findByHashtag(hashtag.hashtag()).orElseGet(()-> hashtagRepository.save(hashtag.toEntity()));
                articlehashtagrepository.save(ArticleHashtag.of(article,hashtag1));
        }
        for (SaveFile.SaveFileDto saveFileDto : saveFileDtos) {
            if(dto.content().contains(saveFileDto.fileName())){
                articleSaveFileRepository.save(ArticleSaveFile.of(article, saveFileDto.toEntity()));
            }
        }
        return Article.ArticleDto.from(article);
    }

저는 해시태그도 양방향 관계를 중간 엔티티로 맺어주어서 등록하도록 했는데요, 파일도 마찬가지로 똑같은 로직으로 구현했습니다.

 

이렇게 구현하면 게시글에 이미지가 첨부되었을 때 중간 테이블에서 외래 키를 참조하여 데이터를 생성하게 됩니다.

 

중간 엔티티에 맵핑이 된 모습(1에 2,3 이렇게 생성되는게 아닙니다 :()

 

하지만 게시글을 등록할 시점에 게시글 내용에 이미지가 존재하지 않는다면?(지워진 상태라면?)

위에 코드에서 각각의 파일 이름이 게시글의 내용에 포함되지 않으면 맵핑을 하지 않는 방향으로 구현할 수 있는데요,

 

그렇게 해버리면 단순히 파일만 업로드되고 서버상에 잔여 파일이 남고 DB에도 유령 데이터가 존재하게 됩니다.

 

저는 그래서 파일 엔티티를 관리하는 비즈니스 로직에 게시글의 콘텐츠와 각 파일의 이름을 비교해 삭제해버리는 로직을 구현해보았습니다.

 -> 해당 로직을 응용해서 게시글 내에 '#'를 포함해 작성한다면 해시태그를 자동으로 등록하도록 하는 로직도 구현할 수 있지 않을까? 하는 생각도 들었습니다. 🤔

 

    public void deleteUnuploadedFilesFromArticleContent(String content,String fileIds){
        if(Objects.isNull(content) || Objects.isNull(fileIds) || fileIds.equals("")){
            return;
        }
        for(String fileId : fileIds.split(",")){
            if(!content.contains(saveFileRepository.getReferenceById(Long.parseLong(fileId)).getFileName())){
                deleteFile(Long.parseLong(fileId));
            }
        }
    }

이렇게 파일 서비스에 비즈니스 로직을 구현해주었는데요, delete를 담당하는 로직 자체에서 서버에 남아있는 파일까지 삭제해버리기 때문에 글 내용과 파일 아이디를 전달하면 작업할 수 있도록 구현해주었습니다.

 

이렇게 구현해주면 글을 등록할 시에 글 내용에 이미지 파일이 존재하지 않는다면 서버에 잔여 파일도 존재하지 않게 되고 쓸데없는 데이터를 입력하지도 않게 됩니다 🥸

 

시연 화면

 

이렇게 에디터에 이미지를 붙여 넣기 해주고 삭제 후 업로드해봅니다.

 

2번 아이디로 게시글이 등록이 되었다.
이미지를 내용에 포함시키지 않자 연관관계도 맺어지지 않는다!
그리고 마찬가지로 파일 테이블에도 데이터가 삽입되지 않았다.

이렇게 포함을 시키지 않아도 잔여 이미지가 남지 않게 됩니다. 

 

수정과 삭제의 경우엔 어떻게 대처를 할 수 있을까?

 

수정 같은 경우에는 글에 이미지가 포함되지 않았을 경우에 삭제해버리는 로직을 비슷하게 구현하면 됩니다.

다만 게시글을 등록할 때는 단순 즉각적으로 전달받은 업로드된 이미지의 id를 참조하여 삭제하면 됐지만

 

수정 같은 경우에는 파일 엔티티나 게시글 엔티티 자체가 각각 엔티티 자체에 서로의 엔티티를 참조하지 않고 중간 테이블이 존재하는 구조이기 때문에

 

수정 페이지 뷰에 기존에 게시글에 등록돼있던 fileId를 addattribute로 전달하고, 이미지를 업로드할 때마다 append 하는 방식으로 id를 받아와 마찬가지로 게시글에 포함이 안된 이미지는 삭제 후에 수정하는 방식으로 구현했습니다.

 

    public static String fileIdsToString(Set<SaveFile.SaveFileDto> saveFiles){
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < saveFiles.size(); i++) {
            sb.append(saveFiles.stream().toList().get(i).id()).append(",");
        }
        return  sb.toString();
    }

저는 이렇게 정적 유틸 메서드를 컨트롤러 유틸 클래스에 선언하고 호출했습니다.  

 

    @ResponseBody
    @PutMapping
    public ResponseEntity<?> updateArticle(
            @RequestBody @Valid Article.ArticleRequest articleRequest,
            BindingResult bindingResult,
            @AuthenticationPrincipal UserAccount.BoardPrincipal boardPrincipal) {
        try {
            if (bindingResult.hasErrors()) {
                return new ResponseEntity<>(ControllerUtil.getErrors(bindingResult), HttpStatus.BAD_REQUEST);
            }
            if (!Objects.equals(articleService.getWriterFromArticle(articleRequest.getArticleId()), boardPrincipal.username())) {
                return new ResponseEntity<>(ErrorMessages.ACCOUNT_NOT_MATCH, HttpStatus.BAD_REQUEST);
            }
            Set<SaveFile.SaveFileDto> saveFileDtos = saveFileService.getFileDtosFromRequestsFileIds(articleRequest);
            saveFileService.deleteUnuploadedFilesFromArticleContent(articleRequest.getContent(), Objects.requireNonNull(articleRequest.getFileIds()));
            articleService.updateArticle(articleRequest.getArticleId(), articleRequest, Hashtag.HashtagDto.from(articleRequest.getHashtag()), saveFileDtos);
            return new ResponseEntity<>("articleUpdating Success", HttpStatus.OK);
        }
        catch (EntityNotFoundException e){
            return new ResponseEntity<>(ErrorMessages.ENTITY_NOT_FOUND, HttpStatus.NOT_FOUND);
        }
    }

저는 이렇게 현재 등록된 이미지와 새로 등록된 이미지 id를 받아와서 내용에 포함되어있지 않으면 파일을 삭제하고 수정하는 방식으로 구현해 잔여 파일을 처리해주었습니다 💆🏻‍♂️

 

삭제 같은 경우에도 마찬가지로 중간 관계 엔티티에서 id를 가져온후 중간관계 엔티티를 삭제하고 파일 엔티티를 게시글 엔티티 id를 토대로 불러와 삭제를 진행하고 게시글 엔티티를 삭제하도록 구현했습니다 :D

 


마치며

  • 해당 기능을 구현하면서 처음으로 성능에 대한 고민을 할 수 있었습니다. (반복적인 쿼리 실행을 줄이기 위해 노력했습니다.)
  • 해당 기능을 구현하면서 가장 많이 했던 고민은 역시나 역할 분담이었습니다. 파일 엔티티는 어떻게 보면 지금 프로젝트상 단독으로 처리되지 못하고 참조가 되는 구조였기 때문에 고민을 하게 되었습니다.
  • 이번 기능 구현도 기본적인 에디터 옵션 사용을 제외한 나머지는 제가 직접 구현하게 되었습니다. 이제는 전체적인 프로젝트의 흐름을 어느 정도 머릿속으로 그릴 수 있게 된 것 같아서 기쁩니다 :D

 

해당 코드는 제 토이 프로젝트에서 작성한 코드들이며 구현 글들은 프로젝트의 문서화를 위해 작성한 글들입니다. 태클은 언제나 환영합니다!

  Comments,     Trackbacks
스프링부트 Toast UI 에디터 / 뷰어 적용 + 이미지 입출력 -1

참고한 게시글 : https://velog.io/@io_/Toast-UI-Editor-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C

 

Toast UI Editor 이미지 업로드

Spring Boots를 사용해 Toast UI Editor에 이미지 업로드하기!!!! 어느날 미래에서 삽질하고 있을 나를 위해...

velog.io

서론

  • 제이쿼리를 이용해 게시판 프로젝트에 Toast UI 에디터 / 뷰어를 적용시킵니다.
  • 이미지 파일 업로드 기능을 구현합니다. (에디터의 hooks 옵션을 통해 이미지를 비동기통신으로 업로드 하고 출력합니다.)
  • JPA로 게시글과 파일 사이의 맵핑용 엔티티 클래스를 생성해 양방향 관계를 맺어줍니다.
  • 게시글 등록시에 글에 첨부되지 않은 이미 서버에 업로드된 이미지 파일을 삭제하고 첨부된 이미지와 게시글을 양방향 관계로 맺어주는 로직을 구현해봅니다.
  • 게시글 수정시에 글에서 지워진 이미지나 마찬가지로 서버에 업로드되었지만 첨부되지 않은 잉여 이미지파일을 삭제해주고 연결관계를 끊어주는 로직을 구현합니다.
  • 게시글 삭제시에 등록되있던 이미지까지 통째로 삭제해주는 로직을 구현합니다.

본론

벨로그 글을 작성할 때 , 이미지를 등록하거나 복사 후 붙여넣기 할때 어떻게 업로드가 되고 첨부가 될까요?

제 깃허브 프로필 사진을 캡처 후 붙여 넣기를 해보겠습니다

이렇게 같은 이미지를 여러 번 붙여 넣기 해도 매번 새로 파일이 업로드되고 바로 게시글에서 조회가 가능한데요,

해당 로직의 흐름은 이런 것 같습니다..

  • 게시글에 이미지를 붙여 넣기 혹은 업로드 시에 비동기 통신으로 컨트롤러로 해당 이미지를 전송
  • 전송된 이미지는 서버에 저장되고, DB에 이미지의 정보 저장 후 해당 파일 이름+@ 랜덤 문자열을 반환
  • 앞단은 전달받은 파일 이름으로 GET method 로 이미지 출력을 요청
  • 뒷단은 이미지 파일을 바이트 배열로 전달해 작성 폼에서 바로 이미지 출력

만약 이미지를 붙여 넣기 혹은 업로드 후에 해당 글에서 다시 이미지를 삭제 후 업로드하거나, 게시글이 삭제되었거나 수정 시에도 이미지가 해당 글에서 지워진다면 남은 잉여 파일은 어떻게 처리할까?🤔

가 해당 글의 핵심이 될 것 같습니다. 🤔

게시판 프로젝트에 Toast UI 에디터/뷰어 적용시키기 💁🏻

기존에 제가 진행하던 토이 프로젝트의 글 작성과 뷰어는 단순히 textarea 태그와 타임리프의 th:text 문법을 이용해서 작성/출력을 했었는데요,

게시판에 에디터를 적용시켜보면 얼마나 깔끔해 보일까? 혹은 이것도 어떻게 보면 open api를 활용하는 것과 비슷하다고 생각하여 적용시켜보게 되었습니다.

저는 프런트를 전혀 알지 못하기 때문에 제이쿼리로 진행했습니다.

    <!-- TOAST UI Editor CDN(JS) -->
    <script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>
    <!-- TOAST UI Editor CDN(CSS) -->
    <link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css" />

적용을 시킬 작성용 폼과 게시글 출력을 시킬 html 파일의 head에 저것들을 다 넣어줍니다.

    const editor = new toastui.Editor({
        el: document.querySelector('#editor1'),
        initialEditType: 'markdown',
        previewStyle: 'vertical',
        height: '500px',
        initialValue: '내용을 입력해주세요',
        events: {
            change: function () {
                console.log(editor.getMarkdown());
            }
        },
        hooks: {
            addImageBlobHook: (blob, callback) => {
                // blob : Java Script 파일 객체
                //console.log(blob);

                const formData = new FormData();
                formData.append('file', blob);

                let url = '/files/';
                $.ajax({
                    type: 'POST',
                    enctype: 'multipart/form-data',
                    url: '/files/upload',
                    data: formData,
                    dataType: 'json',
                    processData: false,
                    contentType: false,
                    cache: false,
                    timeout: 600000,
                    success: function(data) {
                        //console.log('ajax 이미지 업로드 성공');
                        url += data.fileName;
                        fileIds += data.id + ",";
                        // callback : 에디터(마크다운 편집기)에 표시할 텍스트, 뷰어에는 imageUrl 주소에 저장된 사진으로 나옴
                        // 형식 : ![대체 텍스트](주소)
                        callback(url, '사진 대체 텍스트 입력');
                    },
                    error: function(e) {
                        //console.log('ajax 이미지 업로드 실패');
                        //console.log(e.abort([statusText]));

                        callback('image_load_fail', '사진 대체 텍스트 입력');
                    }
                });
            }
        }
    });

출처 :Toast UI Editor 이미지 업로드 - io_
해당 코드는 io_님 블로그에서 긁어왔습니다.

 

Toast UI Editor 이미지 업로드

Spring Boots를 사용해 Toast UI Editor에 이미지 업로드하기!!!! 어느날 미래에서 삽질하고 있을 나를 위해...

velog.io

에디터로 별다른 코드를 추가하지 않고 이미지를 업로드하거나 붙여 넣기 하면 에디터에 몇천 자+@ 의 base64형식으로 이미지 파일이 입력되는데요, 에디터 자체에 hooks 옵션이 존재합니다.

hooks 옵션은 해당 base64 형식의 이미지를 바로 처리하고 에디터에 리턴해줍니다.

    $(document).ready(function() {
        const viewer = toastui.Editor.factory({
            el: document.querySelector("#articleContent"),
            viewer: true,
            height: "500px",
            initialValue: document.getElementById("content").value,
        });
    }
    );

해당 코드는 게시글을 조회하는 뷰 script 태그에 붙여 넣으면 됩니다.

뷰어를 따로 또 적용시키는 이유는 , 에디터에서 지원하는 기능들이 단순 html 태그로는 출력이 되지 않는 경우가 있기 때문에 뷰어도 적용해주었습니다.

게시판에서 이미지를 업로드하면 불필요한 장문의 글자를 가로 채주는 기능이 구현됐으니 이제 이미지/파일을 업로드하고 받아오는 로직을 구현해야겠죠? 🤔

파일 컨트롤러 / 서비스 생성하기

연관관계 맵핑용 중간 엔티티 클래스 생성하기

기존에 File 엔티티 클래스는 작성이 되어있다는 가정하에, 게시글 엔티티와 파일 엔티티를 연결해줄 중간 엔티티 클래스를 생성해줍니다.

@Entity
@Getter
@NoArgsConstructor
public class ArticleSaveFile {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Setter
    @OneToOne
    private Article article;

    @Setter
    @ManyToOne
    private SaveFile saveFile;

    private ArticleSaveFile(Long id, Article article, SaveFile saveFile) {
        this.id = id;
        this.article = article;
        this.saveFile = saveFile;
    }

    public static ArticleSaveFile of (Article article, SaveFile saveFile) {
        return new ArticleSaveFile(null, article, saveFile);
    }
}

해당 맵핑은 한 게시글에 여러 개의 파일이 첨부될 수 있기 때문에, 단순 게시글 엔티티와 파일 엔티티에 연관관계를 설정해주지 않고 중간 엔티티 클래스를 생성해 맵핑을 해주었습니다.

그리고 맵핑 엔티티를 저장할 리포지토리도 따로 생성해주었는데요, 여기서 고민할 수 있는 부분은 두 가지였습니다.

  • 파일 엔티티는 단순히 혼자서 업로드되고 삭제될 수 없는 것 같은데 파일 서비스가 단독적으로 동작할 수 있게 해도 될까?
    • 해당 고민은 파일 엔티티는 파일 저장소를 구현하는 프로젝트가 아닌 이상 단독으로 다뤄질 일이 없다고 생각했기 때문에 파일 CRUD가 필요한 컨트롤러에 주입해주는 것으로 타협했습니다.
      • 그러면 애초에 다른 비즈니스 로직에서 파일 리포지토리를 사용하면 되지 않나? 에 대한 고민은 지금 프로젝트는 서비스 클래스 단위로 트랜잭션이 이뤄지기 때문에 컨트롤러에서 파일 서비스로 파일을 다룰 수 있도록 역할을 분담해주었습니다.
  • 이런 중간 엔티티용 서비스와 컨트롤러도 만들어서 역할을 분리해야 할까?
    • 연관관계 맵핑 엔티티는 마찬가지로 각각의 엔티티가 존재하지 않으면 존재할 이유도 없기 때문에, 각각 비즈니스 로직에 리포지토리를 주입해 사용하도록 했습니다.

파일 엔티티 비즈니스 로직 설계하기

파일을 등록/조회/삭제 처리를 해줄 비즈니스 로직을 설계해줍니다.

@Slf4j
@RequiredArgsConstructor
@Transactional
@Service
public class SaveFileService {
    private final SaveFileRepository saveFileRepository;
    private final ArticleSaveFileRepository articleSaveFileRepository;

    @Transactional(readOnly = true)
    public SaveFile.SaveFileDto getFile(Long fileId) {
        log.info("getFile() fileId: {}", fileId);
        return saveFileRepository.findById(fileId).map(SaveFile.SaveFileDto::from).orElseThrow(()-> new EntityNotFoundException("파일이 없습니다 - fileId: " + fileId));
    }
    
    @Transactional(readOnly = true)
    public SaveFile.SaveFileDto getFileByFileName(String fileName) {
        log.info("getFile() fileId: {}", fileName);
        return saveFileRepository.findByFileName(fileName).toDto();
    }

    public void deleteFile(Long fileId) {
        log.info("deleteFile() fileId: {}", fileId);
        File file = new File(saveFileRepository.getReferenceById(fileId).getFilePath());
        if (file.exists()) {
            if(file.delete()){
                log.info("파일삭제 성공");
            }
        }
        articleSaveFileRepository.deleteBySaveFileId(fileId);
        saveFileRepository.deleteById(fileId);
    }

    public SaveFile.SaveFileDto saveFile(SaveFile.SaveFileDto saveFile) {
        log.info("saveFile() saveFile: {}", saveFile);
        return SaveFile.SaveFileDto.from(saveFileRepository.save(saveFile.toEntity()));
    }

일단 해당 파일들을 저장/삭제/조회하는 로직만 구현해주었는데요, 파일 업로드 시에 db가 아닌 서버에 저장되는 로직은 유틸 클래스를 생성해서 정적 메서드로 처리하도록 했습니다.

    public static File getMultipartFileToFile(MultipartFile multipartFile) throws IOException {
        File file = new File(uploadPath,getFileNameWithUUID(multipartFile.getOriginalFilename()));
        multipartFile.transferTo(file);
        return file;
    }

이렇게 해당 메서드를 선언하여 서버에 저장하고, 또 따로 File 타입을 (해당 File타입은 엔티티가 아닙니다) File엔티티 dto 로 변환해주는 메소드를 선언해 편의성을 챙겼습니다.

 

여기서 신경 쓸건 파일이 생성되지 않거나 제대로 된 파일을 전달받지 못해서 비어있는 파일 정보를 가지고 db에 저장하는 걸 방지하기 위해 해당 메서드들은 파일 컨트롤러에서 호출하도록 구현했습니다.

 

파일 컨트롤러 생성하기

비즈니스 로직을 구현해주었으니 이제 앞단에서 전달받은 파일을 어떻게 처리할지를 컨트롤러에서 구현해줍니다.

@RestController
@Slf4j
@RequiredArgsConstructor
public class SaveFileController {
    private final SaveFileService saveFileService;

    @PostMapping("/files")
    public ResponseEntity<?> uploadFile(@RequestPart("file")MultipartFile file, @AuthenticationPrincipal UserAccount.BoardPrincipal principal) throws IOException {
        try{
        SaveFile.SaveFileDto fileDto = saveFileService.saveFile(FileUtil.getFileDtoFromMultiPartFile(file,principal!=null ? principal.getUsername() : "anonymous"));
        return new ResponseEntity<>(fileDto, HttpStatus.OK);
        }catch (Exception e){
            log.error("uploadFile() error: {}", e.getMessage());
            return new ResponseEntity<>(ErrorMessages.FILE_UPLOAD_FAIL, HttpStatus.INTERNAL_SERVER_ERROR);
        }
    }

    @GetMapping("/files/{fileName}")
    public ResponseEntity<?> getFile(@PathVariable String fileName) throws IOException {
        try{
        File file = FileUtil.getFileFromSaveFile(saveFileService.getFileByFileName(fileName));
        byte[] imgArray = IOUtils.toByteArray(new FileInputStream(file));
        return new ResponseEntity<>(imgArray,HttpStatus.OK);
    }
        catch (EntityNotFoundException e){
            return new ResponseEntity<>(ErrorMessages.NOT_FOUND,HttpStatus.NOT_FOUND);
        }
    }
}

파일이 비어있다면(그럴리는 없지만) IOException 이 발생할 것이기 때문에 해당 예외 처리를 컨트롤러에서 진행해줍니다.

 

하지만 이미지가 없다고 출력이 안되면 안 되기 때문에 catch 문으로 에러 메시지를 반환해줍니다.

 

이미지 업로드 후 db 반영 결과 조회하기

 

이제 이미지를 업로드/붙여 넣기 를 하면 즉시 작성 폼에는 이미지 출력용 url과 대체 텍스트가 입력됩니다.

 

맥북 배경화면을 캡쳐해서 붙여넣기 해보았습니다.

이렇게 이미지를 붙여넣기 하면 그 즉시 POST 메서드로 비동기 통신을 하게 되는데요 , 

 

이렇게 바로 DB에 반영이 됩니다.

 

이제 여기서 파일 컨트롤러와 게시글 컨트롤러는 분리되어있는데 게시글 서비스 클래스에서 연관관계를 맺을 수 있을까?

에 대한 로직 구현이 필요합니다.

 

해당 내용은 글이 길어졌기 때문에 다음 글에서 설명하도록 하겠습니다 :)

  Comments,     Trackbacks