코드 훔쳐보는 변태 코더
춤 좋아하는 백엔드 개발자(였으면 좋겠다)
스프링 (3)
JPA - 프록시객체와 즉시로딩/지연로딩

프록시 ?

엔티티를 조회할 때 연관된 엔티티들이 항상 사용되는 것은 아니다.

예제 8.3 회원과 팀 정보를 출력하는 비즈니스 로직
public void printUserAndTeam(String memberId) {
    Member member = em.find(Member.class, memberId);
    Team team = member.getTeam();
    System.out.println("회원 이름: " + member.getUsername());
    System.out.println("소속팀: "   + team.getName());
  }

-알라딘 eBook <자바 ORM 표준 JPA 프로그래밍> (김영한 지음) 중에서

해당 코드에서는 멤버 아이디로 엔티티를 호출하면 member 엔티티와 연관된 team 엔티티까지 같이 가져온다.

 

하지만 이 코드에서 팀에대한 출력은 없이 회원 정보만 출력한다면 ? ⇒ 팀 데이터베이스까지 함께 조회해버리니 효율적이지 않다.

해당 문제를 해결하기 위해 엔티티가 실제 사용될 때 까지 데이터베이스 조회를 지연하는 방법을 제공하는데 이것을 지연로딩 이라고 한다. → 말그대로 해당 코드에서 member.getTeam() 메소드가 호출되지 않는다면 팀에대한 데이터베이스 조회는 하지 않는다.

 

지연 로딩 기능을 사용하려면 실제 엔티티 객체 대신에 데이터베이스 조회를 지연할 수 있는 가짜 객체가 필요한데 이것을 프록시 객체라고 한다.

‘Proxy. 대리(행위)나 대리권, 대리 투표, 대리인 등 을 뜻한다.’

프록시 기초 🤔

엔티티를 실제 사용하는 시점까지 데이터베이스 조회를 미루고 싶다면 getReference() 메소드를 사용하면 된다.

해당 메소드를 호출할 때 JPA는 데이터베이스를 조회하지 않고 실제 엔티티 객체도 생성하지 안히는다. 대신 접근을 위임한 프록시 객체를 반환한다.

  • 프록시의 특징
    • 프록시는 실제 클래스와 겉 모양이 같다. 사용하는 입장에서는 구분하지 않고 사용하면 된다.
    • 프록시 객체는 실체 객체에 대한 참조를 보관한다. 그리고 객체의 메소드를 호출하면 프록시 객체는 실제 객체의 메소드를 호출한다.
    • 객체는 처음 사용할 때 한 번만 초기화된다.
    • 객체를 초기화 한다고 프록시 객체가 실제 엔티티로 바뀌는 것은 아니다. 실제 엔티티에 접근 할 수 있는것
    • 원본 엔티티를 상속받은 객체이므로 타입 체크시에 주의해야한다.
    • 영속성 컨텍스트에 찾는 엔티티가 이미 있다면 DB를 조회하지 않고 실제 엔티티를 반환한다.
    • 준영속 상태의 프록시를 초기화 하면 문제가 발생한다.
  • 프록시 객체의 초기화
    • 프록시 객체는 실제로 사용될 때 데이터베이스를 조회해 실제 엔티티 객체를 생성하는데, 이것을 프록시 객체의 초기화 라고 한다. ⇒ 사용될때 실제 객체를 생성하기 때문
      • 메소드 호출→ 초기화 요청→ 영속성 컨텍스트에 엔티티 생성 요청 → DB조회→ 영속성 컨텍스트가 DB를 조회해서 실제 엔티티 생성 / 해당 순서로 프록시 객체의 초기화가 이뤄진다.
  • 프록시와 식별자
    • 프록시 객체는 식별자 값을 보관하기 때문에 식별자 값을 조회하는 메소드를 호출해도 프록시를 초기화하지 않는다.
    • 프록시는 연관관계를 설정할 때 유용하게 사용할 수 있다. → 식별자 값만 사용하기 때문에 데이터베이스 접근 횟수를 줄일 수 있다.
  • 프록시 확인
    • JPA가 제공하는 PersistenceUnitUtil.isLoaded(Object entity) 메소드를 사용하면 인스턴스의 초기화 여부를 확인할 수 있다. ⇒ 인스턴스의 초기화 여부가 분명해야 하는 경우가 있을까 ? 🤔
      • 극각의 성능을 내려면 초기화 여부까지 따져가며 데이터베이스 조회 횟수를 줄이는 방법도 있을거같다.. 🤔

즉시 로딩과 지연 로딩 🤔

JPA는 개발자가 조회 시점을 선택할 수 있도록 두가지 방법을 제공한다.

  • 즉시 로딩
    • 엔티티를 조회할 때 연관된 엔티티도 함께 조회한다.
    • 설정방법 : @ManyToOne (fetch = FetchType.EAGER)
    • 즉시 로딩을 최적화 하기 위해서 조인 쿼리를 사용하기도 한다. → 조인쿼리를 사용하면 쿼리 한번으로 두 엔티티를 모두 조회한다. (외래 키에 NOT NULL 제약 조건을 설정하면 값이 있는것을 보장하기 때문에 내부 조인을 사용할 수 있다. nullable=false 를 설정하면 기본으로 설정되있는 외부조인 대신에 내부조인을 사용한다.)
      • 선택적 관계 → 외부 조인, 필수 관계 → 내부 조인
  • 지연 로딩
    • 연관 엔티티를 실제 사용하는 시점에 DB를 조회한다.
    • 설정 방법 : @ManyToOne (fetch = FetchType.LAZY)
  • 정리
    • 대부분의 애플리케이션 로직에서 연관관계가 맺어져있는 엔티티를 같이 사용한다면 join 을 이용해서 한번에 조회하는것이 더 효율적이다.

지연 로딩 활용 🤔

지연 로딩을 어떻게 활용하면 좋을까? 본인이 기존에 진행하던 프로젝트로 예를 들어보았다.

@Entity
public class Article extends AuditingFields{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) // 프라이머리 키
    private Long id;
    //@setter 가 붙은 값이 입력값, 없으면 자동
    @Setter @Column(nullable = false) String title; //n
    // ull 이 아닌 값을 컬럼에 저장 함
    @Setter
    @JoinColumn(name = "userId")
    @ManyToOne(optional = false,fetch = FetchType.EAGER)
    private UserAccount userAccount; // 유저 정보 (ID)

    @Setter @Column(nullable = false,length = 10000) private String content;

    @OrderBy("createdAt")  //id 순서
    @OneToMany(mappedBy = "article", cascade = CascadeType.ALL, fetch = FetchType.LAZY) //양방향 관계 (article이 주체)
    @ToString.Exclude //과부하 발생 예방
    private final Set<ArticleComment> articleComments = new LinkedHashSet<>();

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

해당 테이블에서 연관관계는 UserAccount ,ArticleComment 와 ArticleHashtag 에 맺어져있다.

해시태그 엔티티는 단독으로 관리되지 않기 때문에 Article 엔티티에 연관관계를 직접 맺어주었고, 마찬가지로 게시글을 작성하거나 댓글을 작성할때 무조건 계정 정보가 들어가고, 댓글 또한 단독으로 관리되지 않기때문에 맺어주었다.

  • UserAccount
    • 게시글이 불러와질때마다 단순히 게시글에 nickname 컬럼이 생성되어있거나 하지 않고 외래 키로 UserAccount 엔티티의 정보를 바로 참조해 출력하기 때문에 EAGER 로 설정해주었다.
  • ArticleComment
    • 댓글은 단독으로 관리되지 않지만, 게시글과 댓글을 한번에 가져오는게 아닌 따로 비동기 통신으로 댓글을 관리한다. 하지만 연관관계는 맺을 수 밖에 없는 상황이기 때문에 키만 참조하고 한번에 조회할 필요가 없기때문에 LAZY로 설정해주었다.
  • ArticleHashtag
    • ArticleHashtag 는 중간 테이블 엔티티가 따로 존재한다. 해당 부분에 지연로딩 즉시로딩을 명시해줬는데, Articled은 LAZY, Hashtag도 LAZY를 명시해주었다.
      • 어차피 게시글을 조회하면 영속성 컨텍스트에서 관리되기때문에 db를 조회해오지 않는다.
      • hashtag 를 즉시로딩 해버리면 해당 해시태그가 등록된 게시글까지 다 불러올것 같다는 생각에 LAZY를 주었다.

이런식으로 각각 엔티티를 조회하는 부분이 달라서 데이터베이스가 무조건 따로 조회되는 경우 말고는 조회 횟수를 신경써가며 즉시로딩, 지연로딩을 활용해주면 좋다.

프록시와 컬렉션 래퍼 🤔

  • 하이버네이트는 엔티티를 영속 상태로 만들 때 컬렉션이 있으면 해당 컬렉션을 추척하고 관리할 목적으로 원본 컬렉션을 하이버네이트가 제공하는 내장 컬렉션으로 변경하는데, 이것을 컬렉션 래퍼라고 한다.
  • 컬렉션을 조회하는 메소드를 호출해도 컬렉션은 초기화 되지 않고, 컬렉션에서 실제 데이터를 조회할 때 데이터베이스를 조회해서 초기화한다.

JPA 기본 Fetch 전략 🤔

  • fetch 속성의 기본 설정값은 다음과 같다.
    • @ManyToOne, @OneToOne : 즉시로딩
    • @OneToMany, @ManyToMany : 지연로딩
  • 추천되는 방법은 모든 연관관계에 지연 로딩을 사용하는것이다.
    • 위에 적용한 로딩 방식을 죄다 지연로딩으로 변경해야겠다. (ㅡ,.ㅡ)
    • 꼭 필요한 곳에만 즉시 로딩을 사용하도록 최적화하면 된다.

영속성 전이 : CASCADE 🤔

  • 연관된 엔티티도 함께 영속 상태로 만들고 싶다면 영속성 전이 기능을 사용하면 된다.
    • 부모 엔티티를 저장할 때 자식 엔티티도 함께 저장할 수 있다.
  • cascade = CascadeType.PERSIST 옵션을 적용하면 부모와 자식 엔티티를 한 번에 영속화 할 수 있다.
  • 영속성 전이는 엔티티를 삭제할때도 부모엔티티만 삭제했을때 자식 엔티티까지 함께 삭제해준다.

고아 객체 🤔

  • JPA는 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능을 제공하는데 이것을 고아 객체 제거라고 한다.
  • 고아 객체 제거는 참조가 엔티티는 다른 곳에서 참조하지 않는 고아 객체로 보고 삭제하는 기능이다.
  • orphanRemoval 은 @OneToOne, @OneToMany.에만 사용할 수 있다. (삭제한 엔티티를 다른곳에서도 참조하면 문제가 생기기 때문이다.)

마무리

느낀점 🤔

  • 그동안 fetch = FetchType.LAZY 이게 뭔지 전혀 모르고 사용했었는데 지연로딩이라는걸 드디어 알게되었습니다….
  • 중간에 즉시 로딩과 지연로딩을 각각 맞는 상황에 명시하는걸 보고 제 프로젝트에 적용해보았으나 결국엔 확실한 상황 말고는 지연로딩이 좋다는걸 뒤늦게 보고 말았습니다.
    • 지연 로딩을 지원한다는게 결국에는 한번 데이터를 가져올때 최대한 알짜배기만 골라온다는 느낌이라고 단번에 이해가 되었습니다.
  • 양방향 관계에 있어서 영속성 전이를 관리할 수 있으면 좋을텐데, 아직까지는 완전히 이해가 되지는 않은 느낌입니다.
    • 즉시로딩과 지연로딩을 공부한 후 중간 엔티티가 각 엔티티의 부모 엔티티라고 봐야하는것인지.. 헷갈렸습니다.
    • 생각해보니 제 프로젝트에서는 중간 엔티티를 삭제해버리면 각각의 엔티티도 삭제되도록 되있던거 같은데, 해당 부분을 다시 생각하고 구조를 바꿔도 될 것 같습니다 😀
  • 프록시 객체에 대한 개념을 여기서 완벽히 이해한것 같습니다.
    • 여기서 한번 더 getReferenceById 와 findById 의 차이를 짚고 넘어가야 할 것 같습니다.
  • #패스트캠퍼스 #국비지원교육 #메가바이트스쿨 #MegabyteSchool #개발자취업부트캠프 #내일배움카드
  Comments,     Trackbacks
22-12-05~22-12-12 개발공부 회고록

서론

주간 목표 

  • 스프링부트 프로젝트 기능 구현 ⭕️
  • 알고리즘 자유롭게 공부 후 문제 풀이 ⭕️
  • 이펙티브 자바 / JPA 책 스터디 ⭕️ 

 


본론

알고리즘 자유롭게 공부 후 문제 풀이

드디어 첫 토이프로젝트가 마무리 되고, 알고리즘 공부를 다시 시작했습니다.

 

이번주에 진행한 알고리즘 파트는 링크드리스트, 해시, 힙 자료구조와 우선순위 큐 입니다.

 

문제는 해시/힙 자료구조 위주로 풀이했으며, 링크드리스트 에 대해서 요약정리도 진행했습니다.

 

 

 

링크드 리스트

https://velog.io/@brince/Linked-List-%EC%A7%81%EC%A0%91-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EA%B3%A0-%EC%9B%90%EB%A6%AC-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0

 

Linked List 직접 구현해보고 원리 이해하기

링크드 리스트

velog.io

 

링크드리스트 같은 경우에는 일단 기본적으로 배열과 큰 차이점을 갖고 있습니다.

링크드 리스트는 말그대로 연결 리스트 인데요, 각각 인덱스의 원소끼리 서로 체인으로 연결되어있어서 중간에 데이터를 삽입/삭제 시에 기존에 연결된 체인을 끊고 새로 연결한다 정도로 쉽게 이해할 수 있는데요,

 

기존엔 이정도로만 익혔지만 이번에 공부하면서 새로 알게된 부분은 링크드 리스트는 각각의 노드에 데이터와 포인터로 이루어져 데이터에는 데이터가 저장되고, 포인터에는 다음 데이터의 주소를 저장하는 형식으로 이루어져있다는걸 알게 되었습니다.

 

예를들어 3개의 노드가가 연결리스트 안에 존재한다면, 마지막 노드의 포인터는 null이고, 새로 삽입을 할때 해당 포인터에 다음 노드를 참조시키는 것으로 데이터를 삽입/삭제 합니다.

 

마찬가지로 링크드리스트를 직접 구현해보고 기존에는 단순히 시간이 좀 걸린다~ 정도로만 알고있었지만 자료를 넣고 빼고 하는데 좀 복잡하구나.. 를 알게되었습니다.

 

스프링부트 프로젝트 기능 구현

 

이번 한주는 개인적으로 프로젝트에 시간을 많이 투자했던 한 주 였습니다.

 

이번주에 진행했던 것들은 게시판 프로젝트에 계정 엔티티에 이미지 입출력을 적용해 프로필 이미지를 변경하는 기능을 구현하는것과,

 

그것을 응용해 단순 파일만 업로드하고 출력하는게 아닌 JPA Repository로 파일 DB저장소를 만들고 저장/삭제/조회 를 구현하고

 

또 그것을 응용해 게시판에 외부 에디터 / 뷰어를 적용해 이미지 입출력까지 진행후 리팩토링까지 진행해보았습니다.

 

 

 

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

사용기술 스프링부트 제이쿼리 타임리프 살짝 서론 디테일하게 파일을 저장하는 레포지토리를 만들어서 해당 레포지토리에서 파일을 꺼내오는 것이 아닌 파일 입출력을 스프링 부트에서 어떻

codinghentai.tistory.com

 

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

서론 🙋‍♂️ 스프링부트로 프로필 이미지를 변경해보자 🤔 (파일 입출력)를 응용해서 파일을 업로드하고 해당 파일의 경로와 이름, 확장자 그리고 사이즈까지 저장하는 Jpa Repository 를 생성

codinghentai.tistory.com

 

 

스프링부트 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

 

 

스프링부트 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 이미지 업

codinghentai.tistory.com


마무리

  • 알차게 보낸 한 주 였던것 같습니다. 알고리즘을 오랜만에 다시 공부하게 되었는데 확실히 전보다 이해가 더 잘되는 기분입니다. 꾸준히 코딩을 하는 습관을 들이는게 중요한것같다고 한번 더 느끼게 되었습니다.
  • 이펙티브 자바를 공부하면서 애를 먹었습니다. 정리할정도로 그것들을 활용할 길이 적었기 때문입니다. 공부하는 내용의 대부분이 직접 지금 당장 사용할 일들이 없는 레거시 코드였습니다. 마찬가지로 앞으로는 모든 파트를 공부하기 보다는 힘든 부분은 넘기고 다음에 도움이 필요할때 참고해야겠다고 다짐하게 되었습니다.
  • 이제 기본적인 스프링 프로젝트를 진행할때 크게 겪는 문제는 없는것 같은 느낌입니다. 클린코드와 리팩토링에 대해서 공부를 시작하면 좋을거같다는 생각이 들었습니다 🤔

 

  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