새소식

Java

[SpringBoot & JPA] 게시판 직접 구현해보기 [1]

  • -
728x90
게시판 서비스, SpringBoot 를 더한!


⛅️ Intro

Spring에 대해 공부하고 더 깊은 이해를 경험하고 싶어 토이 프로젝트를 진행하기로 결심하고 직접 구현해봤다. 이러한 과정 속에서 배우고 느낀 것들을 공유하고 기록하고 싶어 글로 작성하기로 했다. 프로젝트의 흐름에 맞게 설명하고 첫 프로젝트이고 이 글을 읽는 Spring 입문자들을 위해 최대한 세세히 설명을 더한다.

 

📃 기능 설계 

 

회원

- 로그인

- 회원가입

 

게시판

- 게시글/댓글 CRUD, Spring Security 를 사용하여 조회/수정/삭제에 대한 권한 체크

- Spring Data JPA + Querydsl 을 사용한 다양한 타입에 대한 검색 및 정렬 기능 (제목, 유저의 아이디 해시태그)

- 게시글 & 해시태그 리스트 조회, 페이지네이션 기능

 

..

..

💻 개발 환경

  • Intellij IDEA Ultimate
  • Java 17
  • Gradle 8.3
  • Spring Boot 2.7

🎲 Stack

  • Spring Boot Actuator
  • Spring Web
  • Spring Data JPA
  • Rest Repositories
  • Rest Repositories HAL Explorer
  • Thymeleaf
  • Spring Security
  • H2 Database
  • MySQL Driver
  • Lombok
  • Spring Boot DevTools
  • Spring Configuration Processor

그 외

  • QueryDSL 5.0.0
  • Bootstrap 5.2.0-Beta1
  • Heroku

데이터 모델링

게시글과 댓글, 그리고 유저에 대한 엔티티를 설계해보자. 게시글과 댓글은 서로 일대다 관계이고, 게시글 & 댓글은 유저와 다대일 관계로 설계했다. 또한 게시글이 삭제될 때 해당 글의 댓글을 전부삭제하기 위해 cascade 를 사용했다. 

 

Article

게시글 엔티티

@Getter
@ToString(callSuper = true)
// INDEX 전략
@Table(indexes = {
        @Index(columnList = "title"),
        @Index(columnList = "hashtag"),
        @Index(columnList = "createdAt"),
        @Index(columnList = "modifiedAt")
})

@Entity
public class Article extends AuditingFields {
    // 기본 키 생성을 DB에 위임합니다. (AUTO_INCREMENT)
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Setter
    @ManyToOne(optional = false)
    @JoinColumn(name = "userId")
    private UserAccount userAccount;

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

    @Setter private String hashtag;

    // 양방향 바인딩
    @ToString.Exclude // 순환 참조!
    @OrderBy("createdAt DESC") // 정렬 기준
    @OneToMany(mappedBy = "article", cascade = CascadeType.ALL)
    private final Set<ArticleComment> articleComments = new LinkedHashSet<>();

    protected Article() {}

    private Article(UserAccount userAccount, String title, String content, String hashtag) {
        this.userAccount = userAccount;
        this.title = title;
        this.content = content;
        this.hashtag = hashtag;
    }

    // Factory Mehtod
    public static Article of(UserAccount userAccount, String title, String content, String hashtag) {
        return new Article(userAccount, title, content, hashtag);
    }
}

 

- @Id

해당 필드가 엔티티의 기본 키임을 나타낸다.

 

- @GeneratedValue(strategy = GenerationType.IDENTITY)

기본 키 생성을 DB에 위임한다. (AUTO_INCREMENT)

 

- @ToString(callSuper = true)

클래스 인스턴스의 문자열 표현을 생성하기 위해 자동으로 toString 메소드를 구현한다. callSuper = true 옵션을 통해 부모 클래스의 toString 도 호출하여 결과에 포함시킨다.

  • ⭐️ 양방향으로 매핑된 엔티티를 그대로 조회하는 경우 서로의 정보를 순회하다가 stackoverflow 가 발생할 수 있기 때문에 @ToString.Exclude 를 통해 순환참조를 방지해줬다.

 

- @Table(indexes = {})

@Table 은 엔티티 클래스를 데이터베이스 테이블에 매핑할 때 사용되며, 여기에 추가적으로 indexes 속성을 사용하여 해당 테이블의 인덱스를 정의할 수 있다. @Index 어노테이션을 사용하여 테이블의 인덱스를 정의.

 

- ⭐️ @OneToMany(mappedBy = "article", cascade = CascadeType.ALL)

일대다 관계를 정의한다.

  • mappedBy : 이 속성은 연관관계의 주인이 아닌 반대편에서 해당 관계를 매핑하는 필드의 이름을 지정한다. 여기서 "article"은 연관된 다른 엔티티에서 이 엔티티를 참조하는 필드 이름을 의미한다. 즉, Article 엔티티와 연관된 다른 엔티티 내에 article이라는 필드가 있으며, 이 필드를 통해 Article 엔티티와의 관계가 매핑된다.
  • cascade = CascadeType.ALL : cascade 속성은 현재 연산이 관계된 엔티티에도 전파되어야 하는 방식을 정의한다. CascadeType.ALL은 모든 종류의 영속성 연산(생성, 삭제, 업데이트 등)이 부모 엔티티에서 자식 엔티티로 전파되어야 함을 의미한다. 예를 들어, Article 엔티티를 삭제하면, 그와 연관된 모든 자식 엔티티도 함께 삭제되도록 설계한다.

ArticleComment

댓글 엔티티

@Getter
@ToString(callSuper = true)
// INDEX 전략
@Table(indexes = {
        @Index(columnList = "content"),
        @Index(columnList = "createdAt"),
        @Index(columnList = "modifiedAt")
})
@Entity
public class ArticleComment extends AuditingFields {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Setter
    @ManyToOne(optional = false)
    @JoinColumn(name = "userId")
    private UserAccount userAccount;

    // 게시글 (ID)
    @Setter @ManyToOne(optional = false) private Article article;
    // 본문
    @Setter @Column(nullable = false, length = 500) private String content;

    protected ArticleComment() {}

    private ArticleComment(Article article, UserAccount userAccount, String content) {
        this.userAccount = userAccount;
        this.article = article;
        this.content = content;
    }

    public static ArticleComment of(Article article, UserAccount userAccount, String content) {
        return new ArticleComment(article,userAccount,content);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof ArticleComment that)) return false;
        return this.getId() != null && this.getId().equals(this.getId());
    }

    @Override
    public int hashCode() {
        return Objects.hash(this.getId());
    }
}

 

- @ManyToOne(optional = false)

JPA 어노테이션으로, 현재 엔티티와 다른 엔티티 간의 다대일(N:1) 관계를 정의한다. 여기서 @ManyToOne 어노테이션이 적용된 article 필드는, 현재 엔티티가 다수이고, Article 엔티티가 하나임을 나타낸다. 즉, 여러 개의 현재 엔티티 인스턴스가 하나의 Article 엔티티 인스턴스와 연관될 수 있음을 의미한다.


optional = false 속성을 통해 이 관계가 필수적임을 나타냈다. 즉, 관련된 Article 엔티티 없이는 현재 엔티티를 저장할 수 없다. 또한 데이터베이스의 외래키 제약조건에서 NOT NULL 제약을 생성했다. 일대다 관계를 정의한다.

UserAccount

사용자 엔티티

@Getter
@ToString(callSuper = true)
@Table(indexes = {
        @Index(columnList = "email", unique = true),
        @Index(columnList = "createdAt"),
        @Index(columnList = "createdBy")
})
@Entity
public class UserAccount extends AuditingFields {
    @Id
    @Column(length = 50)
    private String userId;

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

    @Setter @Column(length = 100) private String email;
    @Setter @Column(length = 100) private String nickname;
    @Setter private String memo;


    protected UserAccount() {}

    private UserAccount(String userId, String userPassword, String email, String nickname, String memo) {
        this.userId = userId;
        this.userPassword = userPassword;
        this.email = email;
        this.nickname = nickname;
        this.memo = memo;
    }

    public static UserAccount of(String userId, String userPassword, String email, String nickname, String memo) {
        return UserAccount.of(userId, userPassword, email, nickname, memo, null);
    }

    public static UserAccount of(String userId, String userPassword, String email, String nickname, String memo, String createdBy) {
        return new UserAccount(userId, userPassword, email, nickname, memo);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof UserAccount that)) return false;
        return this.getUserId() != null && this.getUserId().equals(that.getUserId());
    }

    @Override
    public int hashCode() {
        return Objects.hash(this.getUserId());
    }
}

 

JPA Auditing

이 세개의 엔티티에 공통으로 필요한 필드가 있다. 수정/삭제 시간, 수정/삭제한 사람에 대한 필드이다. 이를 AuditingFields 를 통해 자동화를 구현했다.

@Getter
@ToString
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
public abstract class AuditingFields {
    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
    @CreatedDate
    @Column(nullable = false, updatable = false)
    // 생성일시
    private LocalDateTime createdAt;

    @CreatedBy
    @Column(nullable = false, updatable = false, length = 100)
    // 생성자
    private String createdBy;

    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
    @LastModifiedDate
    @Column(nullable = false)
    // 수정일시
    private LocalDateTime modifiedAt;

    @LastModifiedBy
    @Column(nullable = false, length = 100)
    // 수정자
    private String modifiedBy;
}

 

@EntityListeners(AuditingEntityListener.class)와 @MappedSuperclass는 JPA(Java Persistence API)에서 사용되는 어노테이션들로, 엔티티의 생명주기 이벤트를 처리하고, 공통 매핑 정보를 상속받기 위한 목적으로 사용했다.

 

- @EntityListeners(AuditingEntityListener.class)

  • @EntityListeners 어노테이션은 엔티티에 대한 이벤트 리스너를 지정하는 데 사용된다. AuditingEntityListener.class를 값으로 설정함으로써, Spring Data JPA의 Auditing 기능을 엔티티에 적용하게 된다. 이는 엔티티가 생성되거나 수정될 때 자동으로 타임스탬프(예: 생성 시간, 수정 시간)를 기록하거나, 엔티티를 생성하거나 수정한 사용자의 정보를 기록하는 등의 작업을 자동화하기 위해 사용된다.
  • AuditingEntityListener는 엔티티의 생명주기 이벤트가 발생할 때마다 자동으로 호출되어, 엔티티의 Auditing 정보를 업데이트한다. 

- @MappedSuperclass

  • @MappedSuperclass 어노테이션은 특정 클래스가 엔티티가 아니라, 다른 엔티티 클래스들에 공통 매핑 정보(필드나 메서드)를 제공하기 위한 상위 클래스임을 나타낸다. 이 어노테이션으로 표시된 클래스는 직접적으로 데이터베이스 테이블과 매핑되지 않는다. 대신, 이를 상속받는 엔티티 클래스들이 상위 클래스의 매핑 정보를 상속받아 사용할 수 있다.

이렇게 간편한 Spring Data JPA의 Auditing 기능을 사용하기 위해서는 추가로 설정이 필요하다.

 

JpaConfig.java

// JpaAuditing 기능 활성화
@EnableJpaAuditing
@Configuration
public class JpaConfig {
	..
    ..
    ..
}

 

Spring Data JPA의 Auditing 기능을 활성화하기 위해, @EnableJpaAuditing 어노테이션을 Spring Boot의 메인 애플리케이션 클래스나 @Configuration이 붙은 Java 설정 클래스에 추가해야 해서 따로 JpaConfig 를 생성해서 기능을 활성화했다.

 

Repository 설계

기본적으로 Spring Data Jpa 를 사용해 CRUD 를 구현했고, QueryDSL 을 추가로 사용하여 필요한 기능들에 대한 메서드를 정의했다.

 

ArticleRepository

@RepositoryRestResource
public interface ArticleRepository extends
        JpaRepository<Article, Long>,
        ArticleRepositoryCustom, // queryDsl
        QuerydslPredicateExecutor<Article>,
        QuerydslBinderCustomizer<QArticle> {

    // Containing => 부분 검색
    Page<Article> findByTitleContaining(String title, Pageable pageable);
    Page<Article> findByContentContaining(String content, Pageable pageable);
    Page<Article> findByUserAccount_UserIdContaining(String userId, Pageable pageable);
    Page<Article> findByUserAccount_NicknameContaining(String nickname, Pageable pageable);
    Page<Article> findByHashtag(String hashtag, Pageable pageable);

    void deleteByIdAndUserAccount_UserId(Long articleId, String userId);

    @Override
    default void customize(QuerydslBindings bindings, QArticle root) {

        bindings.including(
                root.title,
                root.content,
                root.hashtag,
                root.createdAt,
                root.createdBy)
        ;

        bindings.bind(root.title).first(StringExpression::containsIgnoreCase);
        bindings.bind(root.content).first(StringExpression::containsIgnoreCase);
        bindings.bind(root.hashtag).first(StringExpression::containsIgnoreCase);
        bindings.bind(root.createdAt).first(DateTimeExpression::eq); // 시분초까지 동일하게 입력
        bindings.bind(root.createdBy).first(StringExpression::containsIgnoreCase);
    }
}

 

 Spring Data JPA와 Spring Data REST를 사용하여 리포지토리 계층을 구현하는 방법을 사용했다. 

 

- @RepositoryRestResource

Spring Data JPA + Spring Data REST 구성 시에 Controller, Service 단 구성없이 Repository 구성만으로 쉽게 REST API 를 사용할 수 있다는 이점 때문에 해당 기술을 사용했다. 이 Annotation은 레포지토리 인터페이스에 붙여서, Spring Data REST가 이 리포지토리를 REST 리소스로 자동으로 노출하도록 한다. 이를 통해 클라이언트는 HTTP를 통해 이 리포지토리의 기능(예: 엔티티의 CRUD)을 사용할 수 있게 된다.

 

- JPA Repository 상속

JpaRepository<{Entity}, {Entity의 PK TYPE}> 의 형식을 사용하면 쉽게 Spring Data JPA 를 사용할 수 있다.

 


⭐️ Querydsl 

Querydsl 파트를 설명하기 이전에 설정해줘야 할 부분이 있어 설명하고 진행하겠다.

[build.gradle]
// queryDSL 설정
implementation "com.querydsl:querydsl-jpa"
implementation "com.querydsl:querydsl-core"
implementation "com.querydsl:querydsl-collections"
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jpa" // querydsl JPAAnnotationProcessor 사용 지정
annotationProcessor "jakarta.annotation:jakarta.annotation-api" // java.lang.NoClassDefFoundError (javax.annotation.Generated) 대응 코드
annotationProcessor "jakarta.persistence:jakarta.persistence-api" // java.lang.NoClassDefFoundError (javax.annotation.Entity) 대응 코드

..
..
..

// Querydsl 설정부
def generated = 'src/main/generated'

// querydsl QClass 파일 생성 위치를 지정
tasks.withType(JavaCompile) {
	options.getGeneratedSourceOutputDirectory().set(file(generated))
}

// java source set 에 querydsl QClass 위치 추가
sourceSets {
	main.java.srcDirs += [ generated ]
}

// gradle clean 시에 QClass 디렉토리 삭제
clean {
	delete file(generated)
}

Querydsl 관련 의존성을 추가해줘야 한다. 또한 Querydsl 은 Qclass 를 사용하는데 이를 저장할 위치를 지정해 주어야 한다. 근데 그냥 엔티티를 사용해도 될 것 같은데 왜 Qclass 를 생성해서 사용해야 할까?? 라는 마음으로 찾아본 내용을 공유한다.

 

JPA_APT(JPAAnnotationProcessorTool)가 @Enttiy 와 같은 특정 어노테이션을 찾고 해당 클래스를 분석해서 QClass를 만들어 준다.

APT 란 ? Annotation 이 있는 기존코드를 바탕으로 새로운 코드와 새로운 파일들을 만들 수 있고, 이들을 이용한 클래스에서 compile 하는 기능도 지원해준다. 쉬운 예시로는 Lombok의 @Getter, @Setter가 있다. 해당 어노테이션을 사용하는 경우 apt가 컴파일 시점에 해당 어노테이션을 기준으로 getter 와 setter를 만들어 주기 때문에 코드를 작성하지 않고 사용이 가능해진다.

QClass 란? 엔티티 클래스의 메타 정보를 담고 있는 클래스로, Querydsl은 이를 이용하여 타입 안정성(Type safe)을 보장하면서 쿼리를 작성할 수 있게 된다. QClass는 엔티티 클래스와 대응되며  엔티티의 속성을 나타내고 있다. 이러한 QClass를 사용하여 쿼리를 작성하면 엔티티 속성을 직접 참조하고 조합하여 쿼리를 구성할 수 있다. QClass를 사용하면 컴파일 시점에 오류를 확인할 수 있고, IDE의 자동완성 기능을 활용하여 쿼리 작성을 보다 편리하게 할 수 있다.

 

즉, QClass는 엔티티 속성을 정적인 방식으로 표현하므로 IDE의 자동 완성 기능을 활용할 수 있고, 속성 이름을 직접 기억하거나 확인하지 않아도 된다는 장점을 가지고 있고. QClass는 엔티티 속성의 타입을 정확하게 표현하므로, 타입에 맞지 않는 연산이나 비교를 시도하면 컴파일러가 오류를 감지할 수 있다. 쿼리 작성을 위한 편의성과 안전성을 제공을 해주면서 유지보수의 편의성 및 실수 방지를 하지 않도록 해주기 때문에 사용하는 것이 아닌가 ? 라고 감히 추측해본다.


- Querydsl 인터페이스 상속

검색에 대한 세부적인 규칙을 재구성하기 위해 사용했다.

[1] QuerydslPredicateExecutor<Article>QuerydslBinderCustomizer<QArticle> 를 구현함으로써 Querydsl을 사용한 복잡한 쿼리 생성과 검색 조건의 커스터마이징을 가능하게 해줬다.

[2] QuerydslPredicateExecutorQuerydsl Predicate를 사용하여 필터링 검색을 할 수 있는 메서드를 제공한다.

즉, QuerydslPredicateExecutor<Article>은 Article 엔티티 안에 있는 모든 필드에 대한 기본 검색 기능을 추가해준다는 의미이다.

[3] QuerydslBinderCustomizer는 검색 조건 바인딩을 커스터마이징할 수 있게 해주는 메서드 customize를 구현했다.

 

- customize 

검색 조건의 커스터마이징 파트이다. customize 메서드는 QuerydslBinderCustomizer 인터페이스의 일부로, 검색 조건의 커스터마이징을 가능하게 합다.

 

// 리스팅을 하지 않은 프로퍼티는 검색에서 제외시키는 옵션 / default는 false
bindings.excludeUnlistedProperties(true);

// 해당 리스트를 검색에 포함
bindings.including(
        root.title,
        root.content,
        root.hashtag,
        root.createdAt,
        root.createdBy)
;

이 메서드 내에서는 검색에 사용될 엔티티의 속성들을 선택적으로 포함시키거나 제외시키는 설정을 할 수 있다. bindings.excludeUnlistedProperties(true) 를 통해 명시적으로 바인딩되지 않은 속성들을 검색에서 제외하도록 설정했다.

 

[exact match rule 수정]
StringExpression::likeIgnoreCase
: like '${v}'
StringExpression::containsIgnoreCase
: like '%${v}%', 부분 검색

bindings.bind(root.title).first(StringExpression::containsIgnoreCase) 와 같은 바인딩 설정을 통해, 검색 시 대소문자를 구분하지 않고 해당 필드가 입력 값을 포함하는지 여부를 기준으로 필터링하게 설계했다. 

 

ArticleCommentRepository

댓글 Repository, 게시글 Repository 와 매우 유사하므로 설명은 생략한다.

@RepositoryRestResource
public interface ArticleCommentRepository extends
        JpaRepository<ArticleComment, Long>,
        QuerydslPredicateExecutor<ArticleComment>,
        QuerydslBinderCustomizer<QArticleComment> {

    List<ArticleComment> findByArticle_Id(Long articleId);

    void deleteByIdAndUserAccount_UserId(Long articleCommentId, String userId);

    @Override
    default void customize(QuerydslBindings bindings, QArticleComment root) {

        bindings.excludeUnlistedProperties(true);

        // 해당 리스트를 검색에 포함
        bindings.including(
                root.content,
                root.createdAt,
                root.createdBy)
        ;

        bindings.bind(root.content).first(StringExpression::containsIgnoreCase);
        bindings.bind(root.createdAt).first(DateTimeExpression::eq); // 시분초까지 동일하게 입력
        bindings.bind(root.createdBy).first(StringExpression::containsIgnoreCase);
    }
}

 

 

UserAccountRepository

@RepositoryRestResource
public interface UserAccountRepository extends JpaRepository<UserAccount, String> {
}

 

글이 너무 길어져서 Service 에 대한 설명은 다음 글에서 이어 작성 : )

728x90
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.