Spring MVC + mybatis

JIHYUN·2023년 8월 20일
0

spring

목록 보기
3/6

pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.itwill</groupId>
    <artifactId>spring2</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <packaging>war</packaging>
    <description>Spring MVC &amp; MyBatis Example</description>
    <!-- 프로젝트에서 사용하는 라이브러리(의존성) 이름/버전 설정 -->
    <dependencies>
        <!-- Java EE(Servlet/JSP, EL, action tag) -->
        <dependency>
            <groupId>jakarta.servlet</groupId>
            <artifactId>jakarta.servlet-api</artifactId>
            <version>6.0.0</version>
            <scope>provided</scope>
        </dependency>
        <!-- JSTL -->
        <dependency>
            <groupId>jakarta.servlet.jsp.jstl</groupId>
            <artifactId>jakarta.servlet.jsp.jstl-api</artifactId>
            <version>3.0.0</version>
        </dependency>
        <dependency>
            <groupId>org.glassfish.web</groupId>
            <artifactId>jakarta.servlet.jsp.jstl</artifactId>
            <version>3.0.1</version>
        </dependency>
        <!-- JUnit Test -->
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-engine</artifactId>
            <version>5.9.3</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <version>5.9.3</version>
            <scope>test</scope>
        </dependency>
        <!-- Log4j, Slf4j logging -->
        <dependency>
            <groupId>org.apache.logging.log4j</groupId>
            <artifactId>log4j-core</artifactId>
            <version>2.20.0</version>
        </dependency>
        <dependency>
            <groupId>org.apache.logging.log4j</groupId>
            <artifactId>log4j-api</artifactId>
            <version>2.20.0</version>
        </dependency>
        <dependency>
            <groupId>org.apache.logging.log4j</groupId>
            <artifactId>log4j-slf4j-impl</artifactId>
            <version>2.20.0</version>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
            <version>1.7.36</version>
        </dependency>
        <!-- Lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.26</version>
            <scope>compile</scope>
        </dependency>
        <!-- Spring Web MVC -->
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-core</artifactId>
            <version>6.0.9</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-context</artifactId>
            <version>6.0.9</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-webmvc</artifactId>
            <version>6.0.9</version>
        </dependency>
        <!-- Spring Test -->
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-test</artifactId>
            <version>6.0.9</version>
            <scope>test</scope>
        </dependency>
        <!-- Oracle JDBC -->
        <dependency>
            <groupId>com.oracle.database.jdbc</groupId>
            <artifactId>ojdbc11</artifactId>
            <version>23.2.0.0</version>
        </dependency>
        <!-- HikariCP : Connection Pool(Data Source) -->
        <dependency>
            <groupId>com.zaxxer</groupId>
            <artifactId>HikariCP</artifactId>
            <version>5.0.1</version>
        </dependency>
        <!-- MyBatis -->
        <dependency>
            <groupId>org.mybatis</groupId>
            <artifactId>mybatis</artifactId>
            <version>3.5.13</version>
        </dependency>
        <dependency>
            <groupId>org.mybatis</groupId>
            <artifactId>mybatis-spring</artifactId>
            <version>3.0.1</version>
        </dependency>
        <!-- Spring-JDBC -->
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-jdbc</artifactId>
            <version>6.0.9</version>
        </dependency>
        <!-- Spring Transaction -->
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-tx</artifactId>
            <version>6.0.9</version>
        </dependency>
        <!-- jackson-databind: REST 서비스에서 이용 -->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>2.15.1</version>
        </dependency>
    </dependencies>
    <!-- 프로젝트 빌드 플러그인 설정 -->
    <build>
        <plugins>
            <plugin>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <release>17</release>
                </configuration>
            </plugin>
            <plugin>
                <artifactId>maven-war-plugin</artifactId>
                <version>3.2.3</version>
            </plugin>
        </plugins>
    </build>
</project>

application-context

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:context="http://www.springframework.org/schema/context"
    xmlns:mybatis="http://mybatis.org/schema/mybatis-spring"
    xsi:schemaLocation="
        http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
        http://mybatis.org/schema/mybatis-spring http://mybatis.org/schema/mybatis-spring.xsd">

    <!-- bean definitions here -->
    <!-- HikariConfig hikariConfig = new HikariConfig(); -->
    <bean id="hikariConfig" class="com.zaxxer.hikari.HikariConfig">
        <!-- hikariConfig.setDriverClassName("...") 메서드 호출 -->
        <property name="driverClassName"
            value="oracle.jdbc.OracleDriver" />
        <property name="jdbcUrl"
            value="jdbc:oracle:thin:@localhost:1521:xe" />
        <property name="username" value="scott" />
        <property name="password" value="tiger" />
    </bean>

    <!-- HikariDataSource dataSource = new HikariDataSource(hikariConfig); -->
    <bean id="dataSource"
        class="com.zaxxer.hikari.HikariDataSource">
        <constructor-arg ref="hikariConfig" />
    </bean>

    <!-- SqlSessionFactoryBean 객체: Data Source(Connection Pool)을 이용해서 SQL 
        문장들을 실행하고 결과 처리를 수행하는 객체. -->
    <bean id="sessionFactory"
        class="org.mybatis.spring.SqlSessionFactoryBean">
        <property name="dataSource" ref="dataSource" />
        <property name="mapperLocations"
            value="classpath:/mappers/**/*.xml" />
    </bean>
    
    <!-- MyBatis 프레임워크에서 생성하고 관리하는 bean들을
        base-package와 그 하위 패키지에서 검색함. -->
    <mybatis:scan base-package="com.itwill.spring2.repository"/>

    <!-- Service 애너테이션이 설정된 객체들을 생성하고 관리. -->
    <context:component-scan base-package="com.itwill.spring2.service" />
</beans>

servlet-context

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:context="http://www.springframework.org/schema/context"
    xmlns:mybatis="http://mybatis.org/schema/mybatis-spring"
    xsi:schemaLocation="
        http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
        http://mybatis.org/schema/mybatis-spring http://mybatis.org/schema/mybatis-spring.xsd">

    <!-- bean definitions here -->
    <!-- HikariConfig hikariConfig = new HikariConfig(); -->
    <bean id="hikariConfig" class="com.zaxxer.hikari.HikariConfig">
        <!-- hikariConfig.setDriverClassName("...") 메서드 호출 -->
        <property name="driverClassName"
            value="oracle.jdbc.OracleDriver" />
        <property name="jdbcUrl"
            value="jdbc:oracle:thin:@localhost:1521:xe" />
        <property name="username" value="scott" />
        <property name="password" value="tiger" />
    </bean>

    <!-- HikariDataSource dataSource = new HikariDataSource(hikariConfig); -->
    <bean id="dataSource"
        class="com.zaxxer.hikari.HikariDataSource">
        <constructor-arg ref="hikariConfig" />
    </bean>

    <!-- SqlSessionFactoryBean 객체: Data Source(Connection Pool)을 이용해서 SQL 
        문장들을 실행하고 결과 처리를 수행하는 객체. -->
    <bean id="sessionFactory"
        class="org.mybatis.spring.SqlSessionFactoryBean">
        <property name="dataSource" ref="dataSource" />
        <property name="mapperLocations"
            value="classpath:/mappers/**/*.xml" />
    </bean>
    
    <!-- MyBatis 프레임워크에서 생성하고 관리하는 bean들을
        base-package와 그 하위 패키지에서 검색함. -->
    <mybatis:scan base-package="com.itwill.spring2.repository"/>

    <!-- Service 애너테이션이 설정된 객체들을 생성하고 관리. -->
    <context:component-scan base-package="com.itwill.spring2.service" />
</beans>

web.xml

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns="https://jakarta.ee/xml/ns/jakartaee"
    xmlns:web="http://xmlns.jcp.org/xml/ns/javaee"
    xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-app_5_0.xsd"
    id="WebApp_ID" version="5.0">

    <display-name>spring2</display-name>

    <!-- 스프링 컨텍스트 설정 정보들을 저장한 파일의 위치 -->
    <context-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>/WEB-INF/application-context.xml</param-value>
    </context-param>

    <!-- Filter 설정 -->
    <filter>
        <filter-name>encodingFilter</filter-name>
        <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
        <init-param>
            <param-name>encoding</param-name>
            <param-value>UTF-8</param-value>
        </init-param>
    </filter>
    <filter-mapping>
        <filter-name>encodingFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>


    <!-- Listener 설정 -->
    <listener>
        <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
    </listener>

    <!-- Servlet 설정 -->
    <servlet>
        <servlet-name>dispatcherServlet</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <init-param>
            <param-name>contextConfigLocation</param-name>
            <param-value>/WEB-INF/servlet-context.xml</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>
    <servlet-mapping>
        <servlet-name>dispatcherServlet</servlet-name>
        <url-pattern>/</url-pattern>
    </servlet-mapping>


</web-app>

resources

log4j2.xml

<?xml version="1.0" encoding="UTF-8"?>
<Configuration>
    <Appenders>
        <Console name="out" target="SYSTEM_OUT">
            <PatternLayout charset="UTF-8"
                pattern="%d{HH:mm:ss.SSS} %-5level %logger - %m%n" />
        </Console>
    </Appenders>
    <Loggers>
        <!-- org.springframe 패키지와 그 아래의 모든 패키지에서 info 이상의 로그를 출력 -->
        <Logger name="org.springframework" level="info" additivity="false">
            <AppenderRef ref="out" />
        </Logger>
        
        <!-- com.itwill.spring2.repository 패키지와 그 하위 패키지의 로그 레벌을 
            trace 이상으로 설정 -->
        <Logger name="com.itwill.spring2.repository" level="trace" additivity="false">
            <AppenderRef ref="out" />
        </Logger>
        
        <Root level="info" additivity="false">
            <AppenderRef ref="out" />
        </Root>
    </Loggers>
</Configuration>

mybatis-config.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE configuration
    PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
    "https://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>

</configuration>

resources > mappers

post-mapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
    PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
    "https://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.itwill.spring2.repository.PostRepository">
<!-- namespace의 값은 PostRepository 인터페이스가 있는 패키지 이름과 인터페이스 이름. -->
    
    <select id="selectWithReplyCount"
        resultType="com.itwill.spring2.dto.PostListDto">
        select P.ID, P.TITLE, P.AUTHOR, P.CREATED_TIME, count(R.ID) as RCNT
        from POSTS P left join REPLIES R
            on P.ID = R.POST_ID
        group by P.ID, P.TITLE, P.AUTHOR, P.CREATED_TIME
        order by P.ID desc
    </select>
    
    <!-- id의 값은 PostRepository 인터페이스에서 선언하는 메서드 이름. -->
    <insert id="insert">
        insert into POSTS (TITLE, CONTENT, AUTHOR, CREATED_TIME, MODIFIED_TIME)
        values (#{title}, #{content}, #{author}, systimestamp, systimestamp)
    </insert>
    
    <select id="selectOrderByIdDesc" resultType="com.itwill.spring2.domain.Post">
        select * from POSTS order by ID desc
    </select>
    
    <select id="selectById" resultType="com.itwill.spring2.domain.Post">
        select * from POSTS where ID = #{id}
    </select>
    
    <update id="updateTitleAndContent">
        update POSTS
        set TITLE = #{title}, CONTENT = #{content}, MODIFIED_TIME = systimestamp
        where ID = #{id}
    </update>
    
    <delete id="deleteById">
        delete from POSTS where ID = #{id}
    </delete>
</mapper>

reply-mapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
    PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
    "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.itwill.spring2.repository.ReplyRepository">
    <select id="selectReplyCountWithPostId"
        resultType="java.lang.Long">
        select count(ID) from REPLIES
        where POST_ID = #{post_id}
    </select>
    
    <select id="selectByPostId" 
        resultType="com.itwill.spring2.domain.Reply">
        select * from REPLIES
        where POST_ID = #{post_id}
        order by MODIFIED_TIME desc
    </select>
    
    <select id="selectById"
        resultType="com.itwill.spring2.domain.Reply">
        select * from REPLIES where ID = #{id}
    </select>
    
    <insert id="insert">
        insert into REPLIES (POST_ID, REPLY_TEXT, WRITER, CREATED_TIME, MODIFIED_TIME)
        values (#{post_id}, #{reply_text}, #{writer}, systimestamp, systimestamp)
    </insert>
    
    <update id="update">
        update REPLIES
        set REPLY_TEXT = #{reply_text}, MODIFIED_TIME = systimestamp
        where ID = #{id}
    </update>
    
    <delete id="delete">
        delete from REPLIES
        where ID = #{id}
    </delete>
</mapper>

test

jdbc > JdbcTest.java

package com.itwill.spring2.jdbc;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;

import lombok.extern.slf4j.Slf4j;
import oracle.jdbc.OracleDriver;

@Slf4j
@ExtendWith(SpringExtension.class) // Spring JUnit 테스트를 실행하는 메인 클래스.
@ContextConfiguration(
        locations = { "file:src/main/webapp/WEB-INF/application-context.xml" }
) // 스프링 컨텍스트 환경 설정 파일의 경로와 이름.
public class JdbcTest {

    @Test // JUnit 테스트 메서드
    public void testOjdbc() throws SQLException {
        // JDBC 1: JDBC 라이브러리를 등록
        DriverManager.registerDriver(new OracleDriver());
        log.info("Oracle JDBC 드라이버 등록 성공");
        
        // JDBC 2: Connection 객체 생성
        final String url = "jdbc:oracle:thin:@localhost:1521:xe";
        final String username = "scott";
        final String password = "tiger";
        
        Connection conn = DriverManager.getConnection(url, username, password);
        Assertions.assertNotNull(conn);
        //-> 단위 테스트 성공 조건: Connection 객체가 null이 아님.
        
        log.info("conn = {}", conn);
        
        // JDBC 3: 사용했던 리소스 해제
        conn.close();
        log.info("connection close 성공");
    }
    
}

datasource > HikariCPTest.java

의존성 주입(dependency injection), 제어의 역전(IoC: Inversion of Control):
전통적인 자바 개발에서는 객체를 사용하는 곳에서 생성자를 호출하고, 메서드를 이용.
스프링에서는 스프링 컨테이너가 필요한 객체들을 미리 메모리에 생성해 두고
필요한 곳에서 변수 선언과 애너테이션을 사용하면 스프링 컨테이너가 필요한 곳에 객체를 주입하는 개발 방식.

HikariConfig : super class
|__ HikariDataSource : sub class

다형성(polymorphism) 때문에 HikariConfig 타입에는 HikariConfig 객체와 HikariDataSource 객체를 모두 주입할 수 있다. application-context.xml에서 설정한 id 값을 이용해 특정 bean을 주입받고자 할 때에는 @Qualifier("id") 애너테이션을 사용하면 됨.

package com.itwill.spring2.datasource;

import java.sql.Connection;
import java.sql.SQLException;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@ExtendWith(SpringExtension.class)
@ContextConfiguration(
        locations = { "file:src/main/webapp/WEB-INF/application-context.xml" }
)
public class HikariCPTest {
    @Autowired // 스프링 컨테이너에서 (생성하고) 관리하는 bean을 변수에 자동 할당.
    @Qualifier("hikariConfig")
    private HikariConfig config;
    
    @Autowired
    private HikariDataSource ds;
    
    @Autowired
    private SqlSessionFactoryBean sessionFactory;
    
    @Test
    public void testSqlSession() {
        Assertions.assertNotNull(sessionFactory);
        log.info("session = {}", sessionFactory);
    }
    
    @Test
    public void testDataSource() throws SQLException {
        Assertions.assertNotNull(config);
        log.info("config = {}", config);
        
        Assertions.assertNotNull(ds);
        log.info("ds = {}", ds);
        
        Connection conn = ds.getConnection(); // Data Source에서 Connection을 빌려옴.
        Assertions.assertNotNull(conn);
        log.info("conn = {}", conn);
        
        conn.close(); // 사용했던 Connection을 Data Source에 반환.
        log.info("conn close 성공");
    }
    
}

RepositoryTest

StreamTest

package com.itwill.spring2.stream;

import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;

import org.junit.jupiter.api.Test;

// 스프링 컨텍스트(application-context.xml 또는 servlet-context.xml)를 사용하지 않는
// 단위 테스트에서는 @ExtendWith, @ContextConfiguration 애너테이션을 사용할 필요가 없음.
public class StreamTest {

    @Test
    public void test() {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7);
        System.out.println(numbers);
        
        // numbers에서 홀수들만 필터링한 결과:
        List<Integer> odds = numbers.stream()
                .filter((x) -> x % 2 == 1)
                .toList();
        System.out.println(odds);
        
        // numbers의 원소의 제곱들로 이루어진 리스트
        List<Integer> squares = numbers.stream()
                .map((x) -> x * x)
                .toList();
        System.out.println(squares);
        
        // numbers의 원소들 중 홀수들의 제곱
        List<Integer> oddSquares = numbers.stream()
                .filter((x) -> x % 2 == 1)
                .map((x) -> x * x)
                .toList();
        System.out.println(oddSquares);
        
        List<String> languages = Arrays.asList("Java", "SQL", "JavaScript");
        System.out.println(languages);
        
        // languages가 가지고 있는 문자열들의 길이를 원소로 갖는 리스트 
        List<Integer> lengths = languages.stream()
                .map(String::length) // (x) -> x.length()
                .toList();
        System.out.println(lengths);
        
        List<LocalDateTime> times = Arrays.asList(
                LocalDateTime.of(2023, 5, 23, 11, 30, 0),
                LocalDateTime.of(2023, 5, 24, 12, 30, 0),
                LocalDateTime.of(2023, 5, 25, 18, 00, 0)
        );
        System.out.println(times);
        
        // LocalDateTime 타입을 Timestamp 타입으로 변환
        List<Timestamp> timestamps = times.stream()
                .map(Timestamp::valueOf) // (x) -> Timestamp.valueOf(x)
                .toList();
        System.out.println(timestamps);
    }
    
}

domain

Post.java

package com.itwill.spring2.domain;

import java.time.LocalDateTime;

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

@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter
@Setter
@ToString
public class Post {
    
    private long id;
    private String title;
    private String content;
    private String author;
    private LocalDateTime created_time;
    private LocalDateTime modified_time;

}

Reply.java

package com.itwill.spring2.domain;

import java.time.LocalDateTime;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

// 댓글 테이블(REPLIES)에 저장되는 레코드(행 1개)를 표현하는 객체.
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Reply {
    
    private long id; // 댓글 아이디. primary key.
    private long post_id; // 댓글이 달린 포스트의 아이디. foreign key
    private String reply_text; // 댓글 내용.
    private String writer; // 댓글 작성자 아이디.
    private LocalDateTime created_time; // 댓글 작성 시간.
    private LocalDateTime modified_time; // 댓글 수정 시간.

}

repository

PostRepository.java

application-context.xml에서 scan하는 패키지에 있기 때문에 인터페이스를 구현하는 클래스가 MyBatis에 의해서 자동으로 만들어짐.
post-mapper.xml 파일에서 설정된 id와 메서드 이름이 같으면 해당 아이디의 SQL 문장을 실행하는 구현 메서드를 만들어줌.

package com.itwill.spring2.repository;

import java.util.List;

import com.itwill.spring2.domain.Post;
import com.itwill.spring2.dto.PostListDto;

public interface PostRepository {
    
    // 메서드 전체 이름: com.itwill.spring2.respository.PostRepository.insert
    int insert(Post post);
    List<Post> selectOrderByIdDesc();
    Post selectById(long id);
    int updateTitleAndContent(Post post);
    int deleteById(long id);
    List<PostListDto> selectWithReplyCount();

}

ReplyRepository.java

application-context.xml에서 scan하는 패키지에 있기 때문에 인터페이스를 구현하는 클래스가 MyBatis에 의해서 자동으로 만들어짐.
post-mapper.xml 파일에서 설정된 id와 메서드 이름이 같으면 해당 아이디의 SQL 문장을 실행하는 구현 메서드를 만들어줌.

package com.itwill.spring2.repository;

import java.util.List;

import com.itwill.spring2.domain.Post;
import com.itwill.spring2.dto.PostListDto;

public interface PostRepository {
    
    // 메서드 전체 이름: com.itwill.spring2.respository.PostRepository.insert
    int insert(Post post);
    List<Post> selectOrderByIdDesc();
    Post selectById(long id);
    int updateTitleAndContent(Post post);
    int deleteById(long id);
    List<PostListDto> selectWithReplyCount();

}

service

PostService.java 의존성 주입(DI: Dependency Injection):

  1. 필드에 의한 의존성 주입 - @Autowired 애너테이션 사용
  2. 생성자에 의한 의존성 주입:
  • (1) 필드를 final로 선언
  • (2) final 변수를 초기화할 수 있는 생성자를 작성
package com.itwill.spring2.service;

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

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import com.itwill.spring2.domain.Post;
import com.itwill.spring2.dto.PostCreateDto;
import com.itwill.spring2.dto.PostDetailDto;
import com.itwill.spring2.dto.PostListDto;
import com.itwill.spring2.dto.PostUpdateDto;
import com.itwill.spring2.repository.PostRepository;
import com.itwill.spring2.repository.ReplyRepository;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

// application-context.xml에서 <context:component-scan> 설정에서 
// com.itwill.spring2.service 패키지와 그 하위 패키지를 스캔(검색)하고 있음.
@Service //-> 스프링 컨테이너에서 서비스 컴포넌트 객체를 생성하고 관리(필요한 곳에 주입).
@RequiredArgsConstructor // 2. (2) final로 선언된 필드를 초기화하는 생성자.
@Slf4j
public class PostService {
    
//    @Autowired private PostRepository postRepository; // 1. 필드에 의한 의존성 주입
    
    private final PostRepository postRepository; // 2. (1) 생성자에 의한 의존성 주입
    private final ReplyRepository replyRepository;
    
    // 포스트 목록 페이지
    public List<PostListDto> read() {
        log.info("read()");
        
        return postRepository.selectWithReplyCount();
        
//        List<Post> list = postRepository.selectOrderByIdDesc();
        
//        List<PostListDto> result = new ArrayList<>();
//        for (Post p : list) {
//            PostListDto dto = PostListDto.fromEntity(p);
//            result.add(dto);
//        }
//        return result;
       
//        return list.stream().map(PostListDto::fromEntity).toList();
    }
    
    // 포스트 상세보기 페이지
    public PostDetailDto read(long id) {
        log.info("read(id={})", id);
        
        // DB POSTS 테이블에서 검색.
        Post entity = postRepository.selectById(id);
        // 검색한 내용을 DTO로 변환.
        PostDetailDto dto = PostDetailDto.fromEntity(entity);
        
        // DB REPLIES 테이블에서 댓글 개수를 검색.
        long count = replyRepository.selectReplyCountWithPostId(id);
        dto.setReplyCount(count);
        
        return dto;
    }
    
    // 새 포스트 작성 페이지
    public int create(PostCreateDto dto) {
        log.info("crate({})", dto);
        
        // PostCreateDto 타입을 Post 타입으로 변환해서
        // 리포지토리 계층의 메서드를 호출 - DB Insert.
        return postRepository.insert(dto.toEntity());
    }
    
    // 포스트 업데이트
    public int update(PostUpdateDto post) {
        log.info("update({})", post);
        
        return postRepository.updateTitleAndContent(post.toEntity());
    }
    
    // 포스트 삭제
    public int delete(long id) {
        log.info("delete(id={})", id);
        
        return postRepository.deleteById(id);
    }
    
}

ReplyService.java

package com.itwill.spring2.service;

import java.util.List;

import org.springframework.stereotype.Service;

import com.itwill.spring2.domain.Reply;
import com.itwill.spring2.dto.ReplyCreateDto;
import com.itwill.spring2.dto.ReplyReadDto;
import com.itwill.spring2.dto.ReplyUpdateDto;
import com.itwill.spring2.repository.ReplyRepository;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@RequiredArgsConstructor // final 필드를 초기화하는 생성자
@Service // 스프링 컨텍스트에 서비스 컴포넌트 객체로 등록
public class ReplyService {
    
    private final ReplyRepository replyRepository;

    public int create(ReplyCreateDto dto) {
        log.info("create(dto={})", dto);
        
        return replyRepository.insert(dto.toEntity());
    }
    
    public List<ReplyReadDto> read(long postId) {
        log.info("read(postId={})", postId);
        
        List<Reply> list = replyRepository.selectByPostId(postId);
        
        return list.stream().map(ReplyReadDto::fromEntity).toList();
    }
    
    public ReplyReadDto readById(long id) {
        log.info("readById(id={})", id);
        
        Reply entity = replyRepository.selectById(id);
        
        return ReplyReadDto.fromEntity(entity);
    }

    public int delete(long id) {
        log.info("delete(id={})", id);
        
        return replyRepository.delete(id);
    }

    public int update(long id, ReplyUpdateDto dto) {
        log.info("update(id={}, dto={})", id, dto);
        
        Reply entity = Reply.builder()
                .id(id)
                .reply_text(dto.getReplyText())
                .build();
        log.info("entity={}", entity);
        
        return replyRepository.update(entity);
    }
    
}

web

HomeController

package com.itwill.spring2.web;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@Controller
public class HomeController {
    
    @GetMapping("/")
    public String home() {
        log.info("home()");
        
        return "index";
    }
}

PostController

package com.itwill.spring2.web;

import java.util.List;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import com.itwill.spring2.dto.PostCreateDto;
import com.itwill.spring2.dto.PostDetailDto;
import com.itwill.spring2.dto.PostListDto;
import com.itwill.spring2.dto.PostUpdateDto;
import com.itwill.spring2.service.PostService;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j // 로그
@RequiredArgsConstructor // 생성자에 의한 의존성 주입
@RequestMapping("/post") // PostController 클래스의 메서드들은 요청 주소가 "/post"로 시작.
@Controller // DispatcherServlet에게 컨트롤로 컴포넌트로 등록.
public class PostController {
    
    private final PostService postService; // 생성자에 의한 의존성 주입.
    
    @GetMapping("/list") // GET 방식의 /post/list 요청 주소를 처리하는 메서드.
    public void list(Model model) {
        log.info("list()");
        
        // 컨트롤러는 서비스 계층의 메서드를 호출해서 서비스 기능을 수행.
        List<PostListDto> list = postService.read();
        
        // 뷰에 보여줄 데이터를 Model에 저장.
        model.addAttribute("posts", list);
        
        // 리턴 값이 없는 경우 뷰의 이름은 요청 주소와 같음.
        // /WEB-INF/views/post/list.jsp
    }
    
    @GetMapping("/create")
    public void create() {
        log.info("GET: create()");
    }
    
    @PostMapping("/create")
    public String create(PostCreateDto dto) {
        log.info("POST: create({})", dto);
        
        // 서비스 계층의 메서드를 호출 - 새 포스트 등록
        int result = postService.create(dto);
        log.info("포스트 등록 결과 = {}", result);
        
        // Post - Redirect - Get
        return "redirect:/post/list";
    }
    
    @GetMapping("/detail")
    public void detail(long id, Model model) {
        log.info("detail(id={})", id);
        
        // 서비스 계층의 메서드를 호출해서 화면에 보여줄 PostDetailDto를 가져옴.
        PostDetailDto dto = postService.read(id);
        
        // 뷰에 PostDetailDto를 전달.
        model.addAttribute("post", dto);
    }
    
    @GetMapping("/modify")
    public void modify(long id, Model model) {
        log.info("modify(id={})", id);
        
        PostDetailDto dto = postService.read(id);
        model.addAttribute("post", dto);
    }

    @PostMapping("/delete")
    public String delete(long id) {
        log.info("delete(id={})", id);
        
        int result = postService.delete(id);
        log.info("삭제 결과 = {}", result);
        
        return "redirect:/post/list";
    }
    
    @PostMapping("/update")
    public String update(PostUpdateDto dto) {
        log.info("update(dto={})", dto);
        
        int result = postService.update(dto);
        log.info("업데이트 결과 = {}", result);
        
        return "redirect:/post/list"; // "redirect:/post/detail?id=" + dto.getId();
    }
    
}

ReplyController

package com.itwill.spring2.web;

import java.util.List;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.itwill.spring2.dto.ReplyCreateDto;
import com.itwill.spring2.dto.ReplyReadDto;
import com.itwill.spring2.dto.ReplyUpdateDto;
import com.itwill.spring2.service.ReplyService;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/reply")
public class ReplyController {
    
    private final ReplyService replyService;
    
    @PostMapping
    public ResponseEntity<Integer> createReply(@RequestBody ReplyCreateDto dto) {
        log.info("createReply(dto={})", dto);
        
        int result = replyService.create(dto);
        
        return ResponseEntity.ok(result);
    }

    @GetMapping("/all/{postId}")
    public ResponseEntity<List<ReplyReadDto>> read(@PathVariable long postId) {
        log.info("read(postId={})", postId);
        
        List<ReplyReadDto> list = replyService.read(postId);
        log.info("# of replies = {}", list.size());
        
        return ResponseEntity.ok(list);
    }
    
    @DeleteMapping("/{id}")
    public ResponseEntity<Integer> deleteReply(@PathVariable long id) {
        log.info("deleteReply(id={})", id);
        
        int result = replyService.delete(id);
        
        return ResponseEntity.ok(result);
    }
    
    @GetMapping("/{id}")
    public ResponseEntity<ReplyReadDto> readById(@PathVariable long id) {
        log.info("readById(id={})", id);
        
        ReplyReadDto dto = replyService.readById(id);
        log.info("dto={}", dto);
        
        return ResponseEntity.ok(dto);
    }
    
    @PutMapping("/{id}")
    public ResponseEntity<Integer> updateReply(
            @PathVariable long id,
            @RequestBody ReplyUpdateDto dto) {
        log.info("updateReply(id={}, dto={})", id, dto);
        
        int result = replyService.update(id, dto);
        
        return ResponseEntity.ok(result);
    }
}

dto

  • PostCreateDto.java
  • PostDetailDto.java
  • PostListDto.java
  • PostUpdateDto.java
  • ReplyCreateDto.java
  • ReplyReadDto.java
  • ReplyUpdateDto.java

views

index.jsp bootstrap

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8" trimDirectiveWhitespaces="true" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <title>Spring 2</title>
        <link 
            href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" 
            rel="stylesheet" 
            integrity="sha384-KK94CHFLLe+nY2dmCWGMq91rCGa5gtU4mk92HdvYe+M/SXH301p5ILy+dN9+nJOZ" 
            crossorigin="anonymous">
    </head>
    <body>
    <div class="container-fluid">
        <header class="my-2 p-5 text-center text-bg-dark">
            <h1>메인 페이지</h1>
        </header>
        
        <nav class="navbar navbar-expand-lg bg-body-tertiary">
            <ul class="navbar-nav bg-light">
                <li class="nav-item">
                    <c:url var="postListPage" value="/post/list" />
                    <a class="nav-link" href="${ postListPage }">포스트 목록</a>
                </li>
            </ul>
        </nav>
        
        <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js" 
            integrity="sha384-ENjdO4Dr2bkBIFxQpeoTz1HIcje39Wm4jDKdf19U8gI4ddQ3GYNS7NTKfAdVQSZe" 
            crossorigin="anonymous"></script>
    </div>
    </body>
</html>

static > js

post-modify.js

/**
 * post-modify.js
 * /post/modify.jsp에서 사용
 */

 document.addEventListener('DOMContentLoaded', () => {
     const modifyForm = document.querySelector('#modifyForm');
     
     
     
     const btnDelete = document.querySelector('#btnDelete');
     btnDelete.addEventListener('click', () => {
         const check = confirm('정말 삭제할까요?');
         if(check){
             modifyForm.action = './delete'; //'delete' //폼 요청 주소
             modifyForm.method = 'post'; //폼 요청 방식
             modifyForm.submit(); // 폼 제출 -> 요청을 서버로 보냄.
         }
     })
     
     const btnUpdate = document.querySelector('#btnUpdate');
     btnUpdate.addEventListener('click', () =>{
         // 제목과 내용이 입력되어 있는 지 체크
         const titleInput = document.querySelector('input#title').value; //input에 입력된 값
         const content = document.querySelector('textarea#content').value; //textarea에 입력된 값
         if(title === ''||content ===''){
             alert('제목과 내용은 반드시 입력하세요.')
             return;
         }
         const check = confirm('변경 내용을 저장할까요?')
         if(check){
             modifyForm.action = './update';
             modifyForm.method = 'post';
             modifyForm.submit();
         }
     })
 });

reply.js

/**
 * reply.js
 * 댓글 등록, 목록 검색, 수정, 삭제
 * /post/detail.jsp에 포함.
 */

document.addEventListener('DOMContentLoaded', () => {
    // 댓글 개수 표시 영역(span)
    const replyCountSpan = document.querySelector('span#replyCount');
    // 댓글 목록 표시 영역(div)
    const replies = document.querySelector('div#replies');
    
    // 댓글 삭제 버튼의 이벤트 리스너 (콜백) 함수
    const deleteReply = (e) => {
        // console.log(e);
        console.log(e.target); // e.target: 이벤트가 발생한 타겟. 여기서는 삭제 버튼.
        
        if (!confirm('정말 삭제할까요?')) {
            return;
        }
        
        // 삭제할 댓글 아이디:
        const id = e.target.getAttribute('data-id');
        // 삭제 요청 URL
        const reqUrl = `/spring2/api/reply/${id}`;
        // 삭제 요청을 Ajax 방식으로 보냄.
        axios.delete(reqUrl)
            .then((response) => {
                console.log(response);
                alert('댓글 삭제 성공');
                getRepliesWithPostId(); // 댓글 목록 갱신.
            })
            .catch((error) => {
                console.log(error);
            });
        
    };
    
    // 댓글 수정 모달 객체 생성
    const modal = new bootstrap.Modal('div#replyUpdateModal', {backdrop: false});
    // 모달의 엘리먼트 찾기
    const modalInput = document.querySelector('input#modalReplyId');
    const modalTextarea = document.querySelector('textarea#modalReplyText');
    const modalBtnUpdate = document.querySelector('button#modalBtnUpdate');
    
    // 댓글 수정 버튼의 이벤트 리스너 (콜백) 함수 - 댓글 수정 모달을 보여주는 함수
    const showUpdateModal = (e) => {
        // console.log(e); // e: 이벤트 객체
        // console.log(e.target); // e.target: 이벤트가 발생한 타겟. 여기서는 수정 버튼.
        const id = e.target.getAttribute('data-id');
        const reqUrl = `/spring2/api/reply/${id}`;
        axios.get(reqUrl) // 서버로 GET 방식의 Ajax 요청을 보냄
            .then((response) => {
                // reponse에 포함된 data 객체에서 id, replyText 값을 찾음.
                const { id, replyText } = response.data;
                
                // id와 replyText를 모달의 input과 textarea에 씀.
                modalInput.value = id;
                modalTextarea.value = replyText;
                
                // 모달을 보여줌.
                modal.show();
            }) // 성공 응답이 왔을 때 실행할 콜백을 등록
            .catch((error) => console.log(error)); // 실패 응답이 왔을 때 실행할 콜백 등록.
    };
    
    const updateReply = (e) => {
        // 수정할 댓글 아이디
        const id = modalInput.value;
        // 수정할 댓글 내용
        const replyText = modalTextarea.value;
        // PUT 방식의 Ajax 요청을 보냄.
        const reqUrl = `/spring2/api/reply/${id}`;
        const data = { replyText }; // { key: value }, { replyText: replyText }
        // Ajax 요청에 대한 성공/실패 콜백 등록.
        axios.put(reqUrl, data)
            .then((response) => {
                alert(`댓글 업데이트 성공(${response.data})`);
                getRepliesWithPostId(); // 댓글 목록 업데이트
            })
            .catch((error) => console.log(error))
            .finally(() => modal.hide());
    };
    
    // 모달에서 [수정 내용 저장] 버튼 이벤트 리스너 등록.
    modalBtnUpdate.addEventListener('click', updateReply);
    
    // 댓글 목록 HTML을 작성하고 replies 영역에 추가하는 함수.
    // argument data: Ajax 요청의 응답으로 전달받은 데이터.
    const makeReplyElements = (data) => {
        // 댓글 개수 업데이트
        replyCountSpan.innerHTML = data.length; // 배열 길이(원소 개수)
        
        replies.innerHTML = ''; // <div>의 컨텐트를 지움.
        
        let htmlStr = '';
        // for (let i = 0; i < data.length; i++) {}
        // for (let x in data) {} -> 인덱스 iteration
        for (let reply of data) {
            console.log(reply);
            
            // Timestamp 타입 값을 날짜/시간 타입 문자열로 변환:
            const modified = new Date(reply.modifiedTime).toLocaleString();
            
            // 댓글 1개를 표시할 HTML 코드:
            htmlStr += `
            <div class="card">
                <div>
                    <span class="d-none">${reply.id}</span>
                    <span class="fw-bold">${reply.writer}</span>
                    <span class="text-secondary">${modified}</span>
                </div>
                <div>
                    ${reply.replyText}
                </div>
                <div>
                    <button class="btnDelete btn btn-outline-danger" data-id="${reply.id}">
                        삭제
                    </button>
                    <button class="btnModify btn btn-outline-success" data-id="${reply.id}">
                        수정
                    </button>
                </div>
            </div>
            `;
            
        }
        
        // 작성된 HTML 코드를 replies <div> 영역 안에 포함.
        replies.innerHTML = htmlStr;
        
        // 모든 삭제 버튼들을 찾아서 클릭 이벤트 리스너를 등록:
        const deleteButtons = document.querySelectorAll('button.btnDelete');
        for (let btn of deleteButtons) {
            btn.addEventListener('click', deleteReply);
        }
        
        // 모든 수정 버튼들을 찾아서 클릭 이벤트 리스너를 등록:
        const modifyButtons = document.querySelectorAll('button.btnModify');
        for (let btn of modifyButtons) {
            btn.addEventListener('click', showUpdateModal);
        }
        
    };
    
    const getRepliesWithPostId = async () => {
        // 댓글 목록을 요청하기 위한 포스트 번호(아이디)
        const postId = document.querySelector('input#id').value;
        // 댓글 목록을 요청할 URL
        const reqUrl = `/spring2/api/reply/all/${postId}`;
        
        // Ajax 요청을 보내고 응답을 기다림.
        try {
            const response = await axios.get(reqUrl);
            console.log(response);
            // 댓글 개수 업데이트 & 댓글 목록 보여주기
            makeReplyElements(response.data);
        } catch (error) {
            console.log(error);
        }
    };
    
    // 부트스트랩 Collapse 객체를 생성 - 초기 상태는 화면에서 안보이는 상태
    const bsCollapse = new bootstrap.Collapse('div#replyToggleDiv', {toggle: false});
    
    // 버튼 아이콘 이미지
    const toggleBtnIcon = document.querySelector('img#toggleBtnIcon');
    
    // 댓글 등록/목록 보이기/숨기기 토글 버튼에 이벤트 리스너를 등록
    const btnToggleReply = document.querySelector('button#btnToggleReply');
    btnToggleReply.addEventListener('click', () => {
        bsCollapse.toggle();
        
        if (toggleBtnIcon.alt === 'toggle-off') {
             toggleBtnIcon.src = '../static/assets/icons/toggle2-on.svg';
             toggleBtnIcon.alt = 'toggle-on';
             
             // 댓글 전체 목록을 서버에 요청하고, 응답이 오면 화면 갱신.
             getRepliesWithPostId();
        } else {
            toggleBtnIcon.src = '../static/assets/icons/toggle2-off.svg';
            toggleBtnIcon.alt = 'toggle-off';
            replies.innerHTML = '';
        }
    });
    
    // 댓글 등록 버튼
    const btnAddReply = document.querySelector('button#btnAddReply');
    
    const createReply = (e) => {
        const postId = document.querySelector('input#id').value;
        const replyText = document.querySelector('textarea#replyText').value;
        const writer = document.querySelector('input#writer').value;
        
        if (replyText === '') {
            alert('댓글 내용을 입력하세요.');
            return;
        }
        
        const data = { postId, replyText, writer, };
        
        axios.post('/spring2/api/reply', data) // POST 방식의 Ajax 요청 보냄.
            .then((response) => {
                alert(`댓글 등록 성공(${response.data})`);
                
                // 댓글 입력 창의 내용을 지움.
                document.querySelector('textarea#replyText').value = '';
                
                // 댓글 목록을 새로 고침.
                getRepliesWithPostId();
                
            }) // 성공 응답이 왔을 때 실행할 콜백 함수 등록
            .catch((error) => {
                console.log(error);
            }); // 에러 응답이 왔을 때 실행할 콜백 함수 등록
    };
    btnAddReply.addEventListener('click', createReply);
    
});
profile
🍋

0개의 댓글