SpringMVC 게시판 프로젝트 만들기 #1 (DB, JPA, QueryDSL, Thymeleaf 세팅)

eora21·2023년 5월 29일
0

Spring

목록 보기
1/1

SpringMVC 프로젝트 생성 및 구성하는 법에 대해 정리하며 복습해 보겠습니다.
SpringBoot와는 차이가 다소 있습니다.
InteliJ, Tomcat, maven, Java 11 기준입니다.
해당 코드들은 Github에 업로드해 두었습니다.

프로젝트

만들어 볼 프로젝트는 CRUD를 다 접해볼 수 있는 게시판으로 하겠습니다.

테이블

테이블로는 사용자, 권한, 게시글 3개만 사용합니다.

사용자

사용자 속성으로는 사용자번호, 로그인 아이디, 비밀번호, 이름, 프로필 이미지, 권한이 있습니다.

권한

권한 속성으로는 권한번호, 권한명이 있습니다.

게시글

게시글 속성으로는 글번호, 제목, 본문, 작성자, 작성시간, 수정시간, 조회수가 있습니다.

요구사항

로그인

  • 로그인하지 않고 다른 기능을 사용할 수 없습니다.
  • 로그인 관련한 기능은 Spring Security를 적용합니다.

사용자

  • Admin을 제외하고는 해당 테이블 데이터에 접근할 수 없습니다.
  • 사용자 등록, 수정, 삭제, 조회가 이루어져야 합니다.
  • 사용자의 프로필 이미지가 존재하지 않을 시 디폴트 이미지를 출력합니다.

게시글

  • 게시글 등록, 수정, 삭제, 조회가 이루어져야 합니다.
  • 게시글 조회수는 해당 글을 클릭하여 본문을 조회한다면 증가합니다.
  • 본문 조회 시 조회 기록이 24시간동안 유지됩니다. 조회 기록이 이미 있는 경우, 같은 글을 조회하여도 조회수는 증가하지 않습니다.
  • 본문 조회 기록은 쿠키를 이용합니다.

Guest

  • 로그인한 사용자가 Member라면, 게시글 목록 페이지로 이동합니다.
  • Guest는 오직 조회만 할 수 있습니다.

Member

  • 로그인한 사용자가 Member라면, 게시글 목록 페이지로 이동합니다.
  • Member는 게시글을 작성할 수 있습니다.
  • 본인이 작성한 게시글일 때만 수정, 삭제가 가능합니다.

Admin

  • 로그인한 사용자가 Admin이라면, 사용자 목록 페이지로 이동합니다.
  • Admin은 어떤 게시글이든 삭제가 가능합니다.

기타

  • 사용자 목록, 게시글 목록은 페이징을 고려해야 합니다.
  • BindingResult를 사용하여 예외사항을 처리해야 합니다.
  • 사이트 방문자 수는 사이트를 이용하면 증가합니다.
  • 사이트 방문 시 방문 기록이 1시간동안 유지됩니다. 방문 기록이 이미 있는 경우, 사이트를 이용하여도 방문자수는 증가하지 않습니다.
  • 사이트 방문 기록은 쿠키를 이용합니다.
  • 사이트 방문자 수와 로그인한 사용자 수를 모든 화면에서 표시할 수 있도록 합니다.
  • 한글과 영어 변경을 할 수 있도록 합니다.

프로젝트 생성

Servlet만 추가하고, 필요한 구성이 있을 때마다 pom.xml에 추가하도록 하겠습니다.

configuration -> edit에 들어가 편의를 위해 Application context를 /로 두겠습니다(톰캣 실행버튼 우측 ...에 있습니다).

빌드 후 정상적으로 동작했다면 HelloServletindex.jsp를 제거하겠습니다.

pom.xml을 보면 11버전이 아닌 8버전으로 생성되어 있을 수 있습니다. 11로 수정해주시면 되겠습니다.

SpringMVC

설정

SpringMVC를 적용하기 위해 dependency를 추가하겠습니다.

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-framework-bom</artifactId>
            <version>5.3.27</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-webmvc</artifactId>
</dependency>

정상적으로 추가가 되었다면 AbstractAnnotationConfigDispatcherServletInitializer를 사용해 보겠습니다.
상속받을 Initializer 클래스와 Config 클래스들을 만들어봅시다.

basePackages를 사용할 것이기에 패키지 구조를 첨부하였습니다.

import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Controller;

@Configuration
@ComponentScan(basePackages = "com.example.spring_mvc", excludeFilters = @ComponentScan.Filter(Controller.class))
public class RootWebAppConfig {

}

컨트롤러를 제외한 모든 컴포넌트를 스캔하도록 하겠습니다.

import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;

@Configuration
@ComponentScan(basePackages = "com.example.spring_mvc.domain.**.controller")
public class ServletWebAppConfig {

}

오직 컨트롤러에 해당하는 컴포넌트만 스캔하도록 하겠습니다.

import com.example.spring_mvc.config.RootWebAppConfig;
import com.example.spring_mvc.config.ServletWebAppConfig;
import org.springframework.web.servlet.support.AbstractAnnotationConfigDispatcherServletInitializer;

public class WebAppInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {
    @Override
    protected Class<?>[] getRootConfigClasses() {
        return new Class[] {RootWebAppConfig.class};
    }

    @Override
    protected Class<?>[] getServletConfigClasses() {
        return new Class[] {ServletWebAppConfig.class};
    }

    @Override
    protected String[] getServletMappings() {
        return new String[] {"/"};
    }
}

AbstractAnnotationConfigDispatcherServletInitializer가 어떤 역할을 하는지 궁금하시다면 해당 글을 참조해주세요.

테스트 컨트롤러 작성

세팅이 제대로 되어 있는지 확인하기 위해, 테스트 컨트롤러 하나를 작성하겠습니다.

package com.example.spring_mvc.domain.board.controller;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TestBoardController {

    @GetMapping("/")
    @ResponseStatus(HttpStatus.OK)
    public String getTest() {
        return "TEST";
    }
}

ServletWebAppConfig를 통해 스캔되어야 하므로, board.controller 패키지에 클래스를 추가하였습니다.
제대로 화면이 뜨는지 확인합시다(만약 이전에 삭제한 파일의 화면이 보인다면 maven clean 후 재실행해 보세요).

DB

설정

CRUD가 제대로 이루어지는 지 알아보기 위해, 테이블을 생성하고 데이터를 삽입해보도록 하겠습니다.
편의를 위해 DB는 H2를 사용하겠습니다.

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <version>2.1.212</version>
</dependency>

추후 DB를 변경할 수도 있으니, properties를 통해 설정값을 주는 걸로 하겠습니다.
resources 내에 db.properties 파일을 작성합시다.

db.driverClassName=org.h2.Driver
db.url=jdbc:h2:~/spring_mvc;DATABASE_TO_UPPER=false;MODE=LEGACY;
db.userName=sa
db.password=

효율적인 연결을 위해 RootWebAppConfig에 DataSource를 설정하겠습니다.

우선 dbcp2를 추가하여 DataSource를 사용 가능케 합시다.

<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-dbcp2</artifactId>
    <version>2.9.0</version>
</dependency>

생성자, getter 등을 편하게 작성하기 위한 lombok도 추가하겠습니다.

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.24</version>
</dependency>

DataSource

RootWebConfig에 DataSource를 작성합시다.

@Configuration
@RequiredArgsConstructor
@PropertySource("classpath:db.properties")
@ComponentScan(basePackages = "com.example.spring_mvc.domain", excludeFilters = @ComponentScan.Filter(Controller.class))
public class RootWebAppConfig {

    private final Environment env;
    
    @Bean
    public DataSource dataSource() {
        BasicDataSource dataSource = new BasicDataSource();
        dataSource.setDriverClassName(env.getProperty("db.driverClassName"));
        dataSource.setUrl(env.getProperty("db.url"));
        dataSource.setUsername(env.getProperty("db.userName"));
        dataSource.setPassword(env.getProperty("db.password"));

        dataSource.setInitialSize(10);
        dataSource.setMaxTotal(10);
        dataSource.setMinIdle(10);
        dataSource.setMaxIdle(10);

        dataSource.setMaxWaitMillis(1000);

        dataSource.setTestOnBorrow(true);
        dataSource.setTestOnReturn(true);
        dataSource.setTestWhileIdle(true);

        return dataSource;
    }
}

RequiredArgsConstructor를 통해 env를 DI해주었습니다. 덕분에 간편하게 properties를 적용하였습니다.

Query

프로젝트를 실행하면 알아서 쿼리를 동작시켜 미리 DB에 값을 넣어보도록 할까요? (JPA 설정을 통해 엔티티에 맞는 테이블 구조를 갖출 수도 있지만, ERD가 미리 구성되어있다는 상황을 가정하기 위해 설정해보겠습니다.)

-- CREATE SCHEMA IF NOT EXISTS `spring_mvc` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- USE `spring_mvc`;

DROP TABLE IF EXISTS `boards`;
DROP TABLE IF EXISTS `users`;
DROP TABLE IF EXISTS `roles`;

CREATE TABLE IF NOT EXISTS `roles` (
    `role_id` INT NOT NULL AUTO_INCREMENT,
    `role_name` VARCHAR(20) UNIQUE NOT NULL,
    PRIMARY KEY (`role_id`)
);

CREATE TABLE IF NOT EXISTS `users` (
    `user_id` BIGINT NOT NULL AUTO_INCREMENT,
    `user_login_id` VARCHAR(100) UNIQUE NOT NULL,
    `user_password` VARCHAR(100) NOT NULL,
    `user_name` VARCHAR(20) NOT NULL,
    `user_profile_image` VARCHAR(200) DEFAULT NULL,
    `user_role_id` INT NOT NULL DEFAULT 3,
    PRIMARY KEY (`user_id`),
    FOREIGN KEY (`user_role_id`) REFERENCES `roles` (`role_id`)
);

CREATE TABLE IF NOT EXISTS `boards` (
    `board_id` BIGINT NOT NULL AUTO_INCREMENT,
    `title` VARCHAR(100) NOT NULL,
    `content` TEXT,
    `user_id` BIGINT NOT NULL,
    `create_date_time` DATETIME NOT NULL DEFAULT now(),
    `update_date_time` DATETIME NOT NULL DEFAULT now(),
    `view_count` INT NOT NULL DEFAULT 0,
    PRIMARY KEY (`board_id`),
    FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`)
);

INSERT INTO `roles` (`role_name`) values ('ROLE_ADMIN');
INSERT INTO `roles` (`role_name`) values ('ROLE_MEMBER');
INSERT INTO `roles` (`role_name`) values ('ROLE_GUEST');

INSERT INTO `users` (`user_login_id`, `user_password`, `user_name`, `user_role_id`) values ('admin', 'admin', '어드민', 1);
INSERT INTO `users` (`user_login_id`, `user_password`, `user_name`, `user_role_id`) values ('member', 'member', '멤버', 2);
INSERT INTO `users` (`user_login_id`, `user_password`, `user_name`, `user_role_id`) values ('guest', 'guest', '게스트', 3);

INSERT INTO `boards` (`title`, `content`, `user_id`) values ('첫번째 게시글', '첫번째 게시글 내용', 1);
INSERT INTO `boards` (`title`, `content`, `user_id`) values ('두번째 게시글', '두번째 게시글 내용', 2);
INSERT INTO `boards` (`title`, `content`, `user_id`) values ('세번째 게시글', '세번째 게시글 내용', 3);

해당 쿼리를 resources 밑의 scripts/query.dsl에 작성하도록 하겠습니다.

빌드 시 query 자동 수행

프로젝트가 빌드될 때 해당 쿼리가 동작하도록 설정하려면 DataSourceInitializer를 작성해주어야 합니다(H2에서는 INIT=RUNSCRIPT 명령어를 통해 구현할 수 있지만, 다른 DB로 교체 시에도 원할하게 작성되기 위해 설정하겠습니다).

설정

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-jdbc</artifactId>
</dependency>

db.properties에 해당 쿼리 경로를 작성해줍시다.

db.initQuery=/scripts/query.sql

DataSourceInitializer

DataSourceInitializer를 작성하겠습니다.

@Bean
public DataSourceInitializer dataSourceInitializer(DataSource dataSource) {
    ResourceDatabasePopulator resourceDatabasePopulator = new ResourceDatabasePopulator();
    resourceDatabasePopulator.addScript(new ClassPathResource(
            Objects.requireNonNull(env.getProperty("db.initQuery"))));
    DataSourceInitializer dataSourceInitializer = new DataSourceInitializer();
    dataSourceInitializer.setDataSource(dataSource);
    dataSourceInitializer.setDatabasePopulator(resourceDatabasePopulator);
    return dataSourceInitializer;
}

확인

톰캣을 실행 및 종료시킨 후, H2에 들어가 제대로 쿼리가 동작했는지 확인해봅시다.

제대로 입력이 된 것을 확인하였습니다.

JPA

해당하는 게시판을 제대로 구현하려면, 엔티티들의 연관관계가 제대로 맺혀 있어야 합니다.
DB에 작성된 연관관계를 따라 JPA를 잘 구성해보도록 합시다.

설정

<dependencyManagement>
    <dependencies>
      
      ...
      
        <dependency>
            <groupId>org.springframework.data</groupId>
            <artifactId>spring-data-bom</artifactId>
            <version>2021.2.0</version>
            <scope>import</scope>
            <type>pom</type>
        </dependency>
      
      ...
      
    </dependencies>
</dependencyManagement>
<dependency>
    <groupId>org.springframework.data</groupId>
    <artifactId>spring-data-jpa</artifactId>
</dependency>

<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-core</artifactId>
    <version>5.6.0.Final</version>
</dependency>

xml에 내용을 작성한 후 config 파일을 생성해줍시다.

@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(basePackages = "com.example.spring_mvc.domain.**.repository")
public class JpaConfig {

    @Bean
    public PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) {
        JpaTransactionManager transactionManager = new JpaTransactionManager();
        transactionManager.setEntityManagerFactory(entityManagerFactory);

        return transactionManager;
    }

    @Bean
    public LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) {
        LocalContainerEntityManagerFactoryBean emf = new LocalContainerEntityManagerFactoryBean();
        emf.setDataSource(dataSource);
        emf.setPackagesToScan("com.example.spring_mvc.domain.**.entity");
        emf.setJpaVendorAdapter(jpaVendorAdapters());
        emf.setJpaProperties(jpaProperties());

        return emf;
    }

    private JpaVendorAdapter jpaVendorAdapters() {
        HibernateJpaVendorAdapter hibernateJpaVendorAdapter = new HibernateJpaVendorAdapter();
        hibernateJpaVendorAdapter.setDatabase(Database.H2);

        return hibernateJpaVendorAdapter;
    }

    private Properties jpaProperties() {
        Properties jpaProperties = new Properties();
        jpaProperties.setProperty("hibernate.show_sql", "true");
        jpaProperties.setProperty("hibernate.format_sql", "true");
        jpaProperties.setProperty("hibernate.use_sql_comments", "true");
        jpaProperties.setProperty("hibernate.globally_quoted_identifiers", "true");
        jpaProperties.setProperty("hibernate.temp.use_jdbc_metadata_defaults", "false");
        jpaProperties.setProperty("hibernate.highlight_sql", "true");

        return jpaProperties;
    }
}

Transaction이 일어날 때 EntityManagerFactory에서 EntityManager를 생성하는데, 해당 연결을 H2 DB용으로 설정 및 sql query를 어떻게 보여줄 지 세팅하는 코드입니다.

Entity -> Json 매핑을 위해 jackson 라이브러리 추가 및 ServletWebAppConfig에 @EnableMvc를 적용해줍시다(적용하지 않는다면 406 에러가 발생할 겁니다).

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.13.4.1</version>
</dependency>
@EnableWebMvc
@Configuration
@ComponentScan(basePackages = "com.example.spring_mvc.domain.**.controller")
public class ServletWebAppConfig {

}

Entity

역할, 사용자, 게시글에 대한 엔티티들을 작성해봅시다.

Role

@Entity
@Getter
@Table(name = "roles")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Role {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "role_id")
    private Integer id;

    @Enumerated(EnumType.STRING)
    @Column(name = "role_name")
    private RoleName roleName;

    public enum RoleName {
        ROLE_ADMIN, ROLE_MEMBER, ROLE_GUEST
    }
}

역할에 대한 값들은 이미 정해져 있습니다. Enum을 사용하면 손쉽게 검증할 수 있습니다.

User

@Entity
@Getter
@Table(name = "users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "user_id")
    private Long id;

    @Column(name = "user_login_id")
    private String loginId;

    @Setter
    @Column(name = "user_password")
    private String password;

    @Setter
    @Column(name = "user_name")
    private String name;

    @Setter
    @Column(name = "user_profile_image")
    private String profileImage;

    @Setter
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_role_id")
    private Role role;
}

Board

@Entity
@Getter
@Table(name = "boards")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Board {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "board_id")
    private Long id;

    private String title;

    private String content;

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

    @Column(name = "create_date_time")
    private LocalDateTime createDateTime;

    @Column(name = "update_date_time")
    private LocalDateTime updateDateTime;

    @Column(name = "view_count")
    private int viewCount;

    public void updateBoard(String title, String content) {
        this.title = title;
        this.content = content;
        this.updateDateTime = LocalDateTime.now();
    }

    public void increaseViewCount() {
        this.viewCount++;
    }
}

User에서는 변경 가능한 값들을 @Setter로 설정했으나, Board에서는 좀 더 명확하게끔 메서드로 작성하였습니다.

Component 테스트

간단하게 Controller, Service, Repository를 만들고 제대로 실행이 되는지 테스트해봅시다.
모든 엔티티에 대해 테스트해보면 좋겠으나, 현재 OSIV가 꺼진 상태 + FetchType.LAZY를 걸어놓았으므로 Board와 User는 proxy 에러가 발생합니다.
가장 간단한 Role에 대해 작성해봅시다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/role")
public class RoleController {
    private final RoleService roleService;

    @GetMapping
    @ResponseStatus(HttpStatus.OK)
    public Role test() {
        return roleService.getRole(1);
    }
}
@Service
@RequiredArgsConstructor
public class RoleService {
    private final RoleRepository roleRepository;

	@Transactional(readOnly = true)
    public Role getRole(int id) {
        return roleRepository.findById(id)
                .orElseThrow(IllegalArgumentException::new);
    }
}
public interface RoleRepository extends JpaRepository<Role, Integer> {
}

잘 출력됩니다.

QueryDSL

사용자 전체 목록을 받아와 제공한다고 생각해봅시다.
사용자가 지닌 필드 중에서 비밀번호와 프로필 이미지까지 제공할 필요가 있을까요?
프로필 이미지야 제공할 수 있다곤 하지만, 비밀번호를 노출시키는 건 상당히 위험합니다.
JPA를 통해 모든 사용자 데이터를 받아온 후에 원하는 데이터들만 선별해서 반환할 수 있지만, 쿼리문 작성 시에 원하는 필드만 요청하고 받아온다면(Projection) 훨씬 효율적이면서도 오남용을 막을 수 있습니다.

QueryDSL을 통해 위와 같은 상황을 해결해봅시다.

설정

<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-apt</artifactId>
    <version>5.0.0</version>
</dependency>

<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-jpa</artifactId>
    <version>5.0.0</version>
</dependency>
<build>
    <plugins>
        
      	...
      
        <plugin>
            <groupId>com.mysema.maven</groupId>
            <artifactId>apt-maven-plugin</artifactId>
            <version>1.1.3</version>
            <configuration>
                <processor>com.querydsl.apt.jpa.JPAAnnotationProcessor</processor>
            </configuration>
            <executions>
                <execution>
                    <phase>generate-sources</phase>
                    <goals>
                        <goal>process</goal>
                    </goals>
                    <configuration>
                        <outputDirectory>target/generated-sources/annotations</outputDirectory>
                    </configuration>
                </execution>
            </executions>
        </plugin>
      
      	...
      
    </plugins>
</build>

더불어, 기존 query문과 비슷하면서도 손쉽게 사용할 수 있도록 JPAQueryFactory를 빈으로 생성하겠습니다(해당 과정이 없어도 QueryDSL을 사용할 수 있으나, select문이 아닌 from문부터 작성해야 합니다).

@Bean
public JPAQueryFactory jpaQueryFactory(EntityManager em) {
    return new JPAQueryFactory(em);
}

Page도 사용해야 하므로 RootWebAppConfig에 어노테이션을 추가해줍시다.

...
@EnableSpringDataWebSupport
public class RootWebAppConfig {
	...

작성

사용자 목록을 받아오는 로직에 대해 작성해보도록 하겠습니다.

DTO

@Getter
public class UsersResponseDto {
    private final Long id;
    private final String name;
    private final Role.RoleName roleName;

    @QueryProjection
    public UsersResponseDto(Long id, String name, Role.RoleName roleName) {
        this.id = id;
        this.name = name;
        this.roleName = roleName;
    }
}

전체 사용자 필드 중 로그인id, 이름, 역할만 보여주기로 하겠습니다.
해당하는 필드들을 작성한 후, 생성자에 @QueryProjection을 붙여 QClass가 생성되도록 하였습니다.

Maven Compile을 수행시키면 해당 Dto와 모든 Entity에 대한 QClass가 생성될 것입니다(만약 생성되지 않는다면 clean 후 Compile을 몇 번 반복해보시고, 그래도 되지 않는다면 세팅을 다시 한 번 확인해보시길 바랍니다).

Controller

@RestController
@RequiredArgsConstructor
@RequestMapping("/user")
public class UserController {
    private final UserService userService;

    @GetMapping
    public Page<UsersResponseDto> getUsersPage(Pageable pageable) {
        return userService.getUsersPage(pageable);
    }
}

Service

@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;

	@Transactional(readOnly = true)
    public Page<UsersResponseDto> getUsersPage(Pageable pageable) {
        return userRepository.getUsersPage(pageable);
    }
}

Repository

public interface UserRepository extends JpaRepository<User, Long>, UserRepositoryCustom {
}

Pageable을 이용하는 기본적인 Component Hirachy를 작성했습니다.

RepositoryCustom

public interface UserRepositoryCustom {
    Page<UsersResponseDto> getUsersPage(Pageable pageable);
}

QueryDSL은 네이밍 규칙이 있습니다. 사용할 메서드들을 정의한 인터페이스는 ${JPARepsitoryName}Custom으로, 해당 메서드들을 구현한 클래스는 ${JPARepsitoryName}Impl로 작성해주셔야 합니다.

RepositoryImpl

@RequiredArgsConstructor
public class UserRepositoryImpl implements UserRepositoryCustom {
    private final JPAQueryFactory queryFactory;

    @Override
    public Page<UsersResponseDto> getUsersPage(Pageable pageable) {
        List<UsersResponseDto> content = getContent(pageable);
        long total = getTotal();
        return new PageImpl<>(content, pageable, total);
    }

    private List<UsersResponseDto> getContent(Pageable pageable) {
        return queryFactory
                .select(new QUsersResponseDto(
                        user.id,
                        user.name,
                        role.roleName)
                )
                .from(user)
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();
    }

    private Long getTotal() {
        return queryFactory
                .select(user.count())
                .from(user)
                .fetchOne();
    }
}

@QueryProjection으로 생성한 QClass를 생성하며 프로젝션을 수행하였습니다.
또한 Page를 생성하기 위해 튜플의 갯수를 얻어오는 count 쿼리도 작성하였습니다.
fetchResults를 사용하면 손쉽게 두 개의 쿼리를 동작시켜 주지만, 현재는 Deprecated되었기 때문에 직접 작성하였습니다.
이후 PageImpl을 반환하여 Page를 사용할 수 있도록 하였습니다.

쿼리를 확인해보시면, select문을 통해 필요한 필드만 지정된 것을 확인하실 수 있습니다.

그러나 Join쪽을 살펴보면 cross join이 발생한 것을 알 수 있습니다.
user가 role을 지니고 있기에 Join을 생략하였으나, 이러한 경우 Cross Join이 실행되기 때문입니다. 자세한 건 해당 글의 4번항목을 참조하시기 바랍니다.

innerJoin으로 변경해보도록 하겠습니다.

private List<UsersResponseDto> getContent(Pageable pageable) {
    return queryFactory
            .select(new QUsersResponseDto(
                    user.id,
                    user.name,
                    role.roleName)
            )
            .from(user)
            .innerJoin(user.role, role)
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();
}

innerJoin이 제대로 작동된 것을 확인할 수 있습니다.

Json 결과가 복잡하게 나올 것을 알기에 Json Formatter 확장 프로그램을 설치해서 예쁘게 볼 수 있도록 하겠습니다.

원하는 결과가 나왔군요.

Thymeleaf

결과를 클라이언트에게 전하기 위해서는 View가 필요합니다.
FE, BE로 나눠 작업한다면 좋겠지만 사정이 녹록치 않으니 직접 화면을 구성해봅시다.
어디까지나 결과만을 전달할 것이기에, 디자인 작업(CSS)은 하지 않겠습니다.

설정

<dependency>
    <groupId>org.thymeleaf</groupId>
    <artifactId>thymeleaf-spring5</artifactId>
    <version>3.0.15.RELEASE</version>
</dependency>

dependency를 추가해준 후, ServleWebAppConfig에 Thymeleaf를 위한 설정을 작성해줍시다.

@EnableWebMvc
@Configuration
@ComponentScan(basePackages = "com.example.spring_mvc.domain.**.controller")
public class ServletWebAppConfig implements WebMvcConfigurer, ApplicationContextAware {

    private ApplicationContext applicationContext;

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        this.applicationContext = applicationContext;
    }

    @Bean
    public ThymeleafViewResolver thymeleafViewResolver() {
        ThymeleafViewResolver thymeleafViewResolver = new ThymeleafViewResolver();
        thymeleafViewResolver.setTemplateEngine(springTemplateEngine());
        thymeleafViewResolver.setCharacterEncoding("UTF-8");
        thymeleafViewResolver.setOrder(1);
        thymeleafViewResolver.setViewNames(new String[]{"*"});
        return thymeleafViewResolver;
    }

    public SpringTemplateEngine springTemplateEngine() {
        SpringTemplateEngine templateEngine = new SpringTemplateEngine();
        templateEngine.setTemplateResolver(springResourceTemplateResolver());
        return templateEngine;
    }

    public SpringResourceTemplateResolver springResourceTemplateResolver() {
        SpringResourceTemplateResolver springResourceTemplateResolver = new SpringResourceTemplateResolver();
        springResourceTemplateResolver.setApplicationContext(applicationContext);
        springResourceTemplateResolver.setCharacterEncoding("UTF-8");
        springResourceTemplateResolver.setPrefix("/WEB-INF/views/");
        springResourceTemplateResolver.setSuffix(".html");
        springResourceTemplateResolver.setTemplateMode(TemplateMode.HTML);
        return springResourceTemplateResolver;
    }

    @Override
    public void configureViewResolvers(ViewResolverRegistry registry) {
        registry.viewResolver(thymeleafViewResolver());
    }
}

리소스 지정, 관리 등을 위해 ApplicationContext를 가져와 SpringResourceTemplateResolver에 설정하였습니다.

테스트

제일 처음에 만들었던 TestClass를 활용해봅시다.

@Controller
public class TestClass {
    @GetMapping("/")
    @ResponseStatus(HttpStatus.OK)
    public String getTest() {
        return "test";
    }
}

RestController에서 Controller로 전환하였습니다.

위쪽 설정에서 정의한 대로, src/main/webapp/WEB-INF/views 밑에 test.html 파일을 추가해줍시다.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Test</title>
</head>
<body>
<h1>TEST</h1>
</body>
</html>

실행 후 확인해봅시다.

Thymeleaf가 적용되었습니다.

View 작성하기

설정을 끝냈으니 View 하나를 직접 구성해보면서 Thymeleaf를 사용해봅시다.
QueryDSL을 활용하여 사용자 전체 목록을 페이징했었던 결과, 기억하시나요? 해당 데이터를 View로 전달하면서 사용법을 살펴봅시다.

우선 UserController@RestController@Controller로 변경시켜준 후, View에 데이터를 넘겨야합니다.

@Controller
@RequiredArgsConstructor
@RequestMapping("/user")
public class UserController {
    private final UserService userService;

    @GetMapping
    public String getUsersPage(Model model, Pageable pageable) {
        Page<UsersResponseDto> usersPage = userService.getUsersPage(pageable);
        model.addAttribute("usersPage", usersPage);
        return "users";
    }
}

그 후 페이지를 작성합시다.
src/main/webapp/WEB-INF/views/users.html로 작성하시면 되겠습니다.

<!DOCTYPE html>
<html lang="en"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Users</title>
</head>
<body>
<h1>사용자 목록</h1>

<table>
    <tr>
        <th>이름</th>
        <th>권한</th>
    </tr>
    <tr th:each="user : ${usersPage.content}">
        <td th:text="${user.name}"></td>
        <td th:text="${user.roleName}"></td>
    </tr>
</table>
<div th:if="${#bools.isFalse(usersPage.first)}">
    <a th:href="@{/user(size=${usersPage.size}, page=${usersPage.number - 1})}">이전 페이지</a>
</div>
<div th:if="${#bools.isFalse(usersPage.last)}">
    <a th:href="@{/user(size=${usersPage.size}, page=${usersPage.number + 1})}">다음 페이지</a>
</div>
</body>
</html>

결과를 확인해봅시다.

사용자들의 이름과 권한이 잘 나오고 있습니다.
하지만 사용자가 너무 적은 나머지, 페이징이 제대로 되는지 잘 모르겠네요.
파라미터를 제공해 보겠습니다.

잘 작동하고 있습니다.

여기까지 Spring Security를 제외한 기본적인 설정들을 마쳤습니다.
다음 글부터는 요구사항에 맞게 개발을 진행해보도록 하겠습니다.

profile
나누며 타오르는 프로그래머, 타프입니다.

0개의 댓글