diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000000..179fbb276b --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,37 @@ +name: SonarCloud +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] +jobs: + build: + name: Build and analyze + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + java-version: 11 + distribution: 'corretto' # Alternative distribution options are available + - name: Cache SonarCloud packages + uses: actions/cache@v3 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + - name: Cache Gradle packages + uses: actions/cache@v3 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} + restore-keys: ${{ runner.os }}-gradle + - name: Build and analyze + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: ./gradlew clean build codeCoverageReport sonar --info -x :study:build diff --git a/.gitignore b/.gitignore index 672d1adecf..1bcba66f9d 100644 --- a/.gitignore +++ b/.gitignore @@ -171,3 +171,5 @@ Temporary Items tomcat.* tomcat.*/** + +**/WEB-INF/classes/** diff --git a/README.md b/README.md index 721b81d472..5492d0f409 100644 --- a/README.md +++ b/README.md @@ -1 +1,17 @@ # JDBC 라이브러리 구현하기 + +- [x] UserDaoTest 통과시키기 + - [x] user findAll 구현하기 + - [x] user update 구현하기 + - [x] user findByAccount 구현하기 +- [x] UserDao 리팩터링 + - [x] 공통부분 분리 + - [ ] JdbcTemplate 구현하기 + - [x] 조회기능 구현 + - [x] 쓰기기능 구현 +- [x] UserDao를 JdbcTemplate을 사용하도록 리팩터링 + - [x] findByAccount + - [x] findById + - [x] findAll + - [x] update + - [x] insert diff --git a/app/build.gradle b/app/build.gradle index a94011f4db..00444e6f91 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,10 +1,11 @@ plugins { - id 'java' - id 'idea' + id "java" + id "idea" + id "jacoco" } -group 'org.example' -version '1.0-SNAPSHOT' +group "org.example" +version "1.0-SNAPSHOT" sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 @@ -14,27 +15,23 @@ repositories { } dependencies { - implementation project(':mvc') - implementation project(':jdbc') - - implementation 'org.springframework:spring-tx:5.3.23' - implementation 'org.springframework:spring-jdbc:5.3.23' - - implementation 'org.apache.tomcat.embed:tomcat-embed-core:10.1.0-M16' - implementation 'org.apache.tomcat.embed:tomcat-embed-jasper:10.1.0-M16' - implementation 'ch.qos.logback:logback-classic:1.2.10' - implementation 'org.apache.commons:commons-lang3:3.12.0' - implementation 'com.fasterxml.jackson.core:jackson-databind:2.13.4' - implementation 'com.h2database:h2:2.1.214' - - testImplementation 'org.assertj:assertj-core:3.22.0' - testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1' - testImplementation 'org.mockito:mockito-core:3.+' - testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1' -} - -test { - useJUnitPlatform() + implementation project(":mvc") + implementation project(":jdbc") + + implementation "org.springframework:spring-tx:5.3.23" + implementation "org.springframework:spring-jdbc:5.3.23" + + implementation "org.apache.tomcat.embed:tomcat-embed-core:10.1.13" + implementation "org.apache.tomcat.embed:tomcat-embed-jasper:10.1.13" + implementation "ch.qos.logback:logback-classic:1.2.12" + implementation "org.apache.commons:commons-lang3:3.13.0" + implementation "com.fasterxml.jackson.core:jackson-databind:2.15.2" + implementation "com.h2database:h2:2.2.220" + + testImplementation "org.assertj:assertj-core:3.24.2" + testImplementation "org.junit.jupiter:junit-jupiter-api:5.7.2" + testImplementation "org.mockito:mockito-core:5.4.0" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.7.2" } idea { @@ -46,6 +43,6 @@ idea { sourceSets { main { - java.destinationDirectory.set(file('src/main/webapp/WEB-INF/classes')) + java.destinationDirectory.set(file("src/main/webapp/WEB-INF/classes")) } } diff --git a/app/src/main/java/com/techcourse/AppWebApplicationInitializer.java b/app/src/main/java/com/techcourse/AppWebApplicationInitializer.java index a0ea9347c1..024510e26c 100644 --- a/app/src/main/java/com/techcourse/AppWebApplicationInitializer.java +++ b/app/src/main/java/com/techcourse/AppWebApplicationInitializer.java @@ -1,11 +1,11 @@ package com.techcourse; import jakarta.servlet.ServletContext; -import nextstep.mvc.DispatcherServlet; -import nextstep.mvc.controller.asis.ControllerHandlerAdapter; -import nextstep.mvc.controller.tobe.AnnotationHandlerMapping; -import nextstep.mvc.controller.tobe.HandlerExecutionHandlerAdapter; -import nextstep.web.WebApplicationInitializer; +import webmvc.org.springframework.web.servlet.mvc.DispatcherServlet; +import webmvc.org.springframework.web.servlet.mvc.asis.ControllerHandlerAdapter; +import webmvc.org.springframework.web.servlet.mvc.tobe.AnnotationHandlerMapping; +import webmvc.org.springframework.web.servlet.mvc.tobe.HandlerExecutionHandlerAdapter; +import web.org.springframework.web.WebApplicationInitializer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/app/src/main/java/com/techcourse/ManualHandlerMapping.java b/app/src/main/java/com/techcourse/ManualHandlerMapping.java index 3d14ca6be8..6119278f93 100644 --- a/app/src/main/java/com/techcourse/ManualHandlerMapping.java +++ b/app/src/main/java/com/techcourse/ManualHandlerMapping.java @@ -2,9 +2,9 @@ import com.techcourse.controller.*; import jakarta.servlet.http.HttpServletRequest; -import nextstep.mvc.HandlerMapping; -import nextstep.mvc.controller.asis.Controller; -import nextstep.mvc.controller.asis.ForwardController; +import webmvc.org.springframework.web.servlet.mvc.HandlerMapping; +import webmvc.org.springframework.web.servlet.mvc.asis.Controller; +import webmvc.org.springframework.web.servlet.mvc.asis.ForwardController; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/app/src/main/java/com/techcourse/controller/LoginController.java b/app/src/main/java/com/techcourse/controller/LoginController.java index 492e246fd2..2f1d2d7e25 100644 --- a/app/src/main/java/com/techcourse/controller/LoginController.java +++ b/app/src/main/java/com/techcourse/controller/LoginController.java @@ -4,11 +4,11 @@ import com.techcourse.repository.InMemoryUserRepository; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import nextstep.mvc.view.JspView; -import nextstep.mvc.view.ModelAndView; -import nextstep.web.annotation.Controller; -import nextstep.web.annotation.RequestMapping; -import nextstep.web.support.RequestMethod; +import webmvc.org.springframework.web.servlet.view.JspView; +import webmvc.org.springframework.web.servlet.ModelAndView; +import context.org.springframework.stereotype.Controller; +import web.org.springframework.web.bind.annotation.RequestMapping; +import web.org.springframework.web.bind.annotation.RequestMethod; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/app/src/main/java/com/techcourse/controller/LogoutController.java b/app/src/main/java/com/techcourse/controller/LogoutController.java index 9d1f099a98..4642fd9450 100644 --- a/app/src/main/java/com/techcourse/controller/LogoutController.java +++ b/app/src/main/java/com/techcourse/controller/LogoutController.java @@ -2,7 +2,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import nextstep.mvc.controller.asis.Controller; +import webmvc.org.springframework.web.servlet.mvc.asis.Controller; public class LogoutController implements Controller { diff --git a/app/src/main/java/com/techcourse/controller/RegisterController.java b/app/src/main/java/com/techcourse/controller/RegisterController.java index dcbbbc64e6..8c794c2d26 100644 --- a/app/src/main/java/com/techcourse/controller/RegisterController.java +++ b/app/src/main/java/com/techcourse/controller/RegisterController.java @@ -4,11 +4,11 @@ import com.techcourse.repository.InMemoryUserRepository; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import nextstep.mvc.view.JspView; -import nextstep.mvc.view.ModelAndView; -import nextstep.web.annotation.Controller; -import nextstep.web.annotation.RequestMapping; -import nextstep.web.support.RequestMethod; +import webmvc.org.springframework.web.servlet.view.JspView; +import webmvc.org.springframework.web.servlet.ModelAndView; +import context.org.springframework.stereotype.Controller; +import web.org.springframework.web.bind.annotation.RequestMapping; +import web.org.springframework.web.bind.annotation.RequestMethod; @Controller public class RegisterController { diff --git a/app/src/main/java/com/techcourse/controller/UserController.java b/app/src/main/java/com/techcourse/controller/UserController.java index 478c90eb06..d17bbcf0bb 100644 --- a/app/src/main/java/com/techcourse/controller/UserController.java +++ b/app/src/main/java/com/techcourse/controller/UserController.java @@ -3,11 +3,11 @@ import com.techcourse.repository.InMemoryUserRepository; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import nextstep.mvc.view.JsonView; -import nextstep.mvc.view.ModelAndView; -import nextstep.web.annotation.Controller; -import nextstep.web.annotation.RequestMapping; -import nextstep.web.support.RequestMethod; +import webmvc.org.springframework.web.servlet.view.JsonView; +import webmvc.org.springframework.web.servlet.ModelAndView; +import context.org.springframework.stereotype.Controller; +import web.org.springframework.web.bind.annotation.RequestMapping; +import web.org.springframework.web.bind.annotation.RequestMethod; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/app/src/main/java/com/techcourse/dao/UserDao.java b/app/src/main/java/com/techcourse/dao/UserDao.java index ee17c159c2..78b961f7b8 100644 --- a/app/src/main/java/com/techcourse/dao/UserDao.java +++ b/app/src/main/java/com/techcourse/dao/UserDao.java @@ -1,121 +1,66 @@ package com.techcourse.dao; import com.techcourse.domain.User; -import nextstep.jdbc.JdbcTemplate; +import java.util.ArrayList; +import java.util.List; +import javax.sql.DataSource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; - -import javax.sql.DataSource; -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.List; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.Mapper; public class UserDao { + private static final Mapper USER_MAPPER = (rs) -> new User( + rs.getLong(1), + rs.getString(2), + rs.getString(3), + rs.getString(4) + ); private static final Logger log = LoggerFactory.getLogger(UserDao.class); - private final DataSource dataSource; + private final JdbcTemplate jdbcTemplate; public UserDao(final DataSource dataSource) { - this.dataSource = dataSource; + this.jdbcTemplate = new JdbcTemplate(dataSource); } public UserDao(final JdbcTemplate jdbcTemplate) { - this.dataSource = null; + this.jdbcTemplate = jdbcTemplate; } public void insert(final User user) { final var sql = "insert into users (account, password, email) values (?, ?, ?)"; - - Connection conn = null; - PreparedStatement pstmt = null; - try { - conn = dataSource.getConnection(); - pstmt = conn.prepareStatement(sql); - - log.debug("query : {}", sql); - - pstmt.setString(1, user.getAccount()); - pstmt.setString(2, user.getPassword()); - pstmt.setString(3, user.getEmail()); - pstmt.executeUpdate(); - } catch (SQLException e) { - log.error(e.getMessage(), e); - throw new RuntimeException(e); - } finally { - try { - if (pstmt != null) { - pstmt.close(); - } - } catch (SQLException ignored) {} - - try { - if (conn != null) { - conn.close(); - } - } catch (SQLException ignored) {} - } + jdbcTemplate.executeUpdate(sql, + user.getAccount(), user.getPassword(), user.getEmail()); } public void update(final User user) { - // todo + final var sql = "update users set account = ?, password = ? , email = ? where id = ?"; + jdbcTemplate.executeUpdate(sql, + user.getAccount(), user.getPassword(), user.getEmail(), user.getId()); } public List findAll() { - // todo - return null; + final var sql = "select id, account, password, email from users"; + return jdbcTemplate.executeQuery(sql, (rs) -> { + final List users = new ArrayList<>(); + while (rs.next()) { + users.add(USER_MAPPER.map(rs)); + } + return users; + }); } public User findById(final Long id) { final var sql = "select id, account, password, email from users where id = ?"; - Connection conn = null; - PreparedStatement pstmt = null; - ResultSet rs = null; - try { - conn = dataSource.getConnection(); - pstmt = conn.prepareStatement(sql); - pstmt.setLong(1, id); - rs = pstmt.executeQuery(); - - log.debug("query : {}", sql); - - if (rs.next()) { - return new User( - rs.getLong(1), - rs.getString(2), - rs.getString(3), - rs.getString(4)); - } - return null; - } catch (SQLException e) { - log.error(e.getMessage(), e); - throw new RuntimeException(e); - } finally { - try { - if (rs != null) { - rs.close(); - } - } catch (SQLException ignored) {} - - try { - if (pstmt != null) { - pstmt.close(); - } - } catch (SQLException ignored) {} - - try { - if (conn != null) { - conn.close(); - } - } catch (SQLException ignored) {} - } + return jdbcTemplate.executeQuery(sql, USER_MAPPER, id); } public User findByAccount(final String account) { - // todo - return null; + final var sql = "select id, account, password, email from users where account = ?"; + + return jdbcTemplate.executeQuery(sql, USER_MAPPER, account); } } diff --git a/app/src/main/java/com/techcourse/dao/UserHistoryDao.java b/app/src/main/java/com/techcourse/dao/UserHistoryDao.java index a506912cf4..edb4338caa 100644 --- a/app/src/main/java/com/techcourse/dao/UserHistoryDao.java +++ b/app/src/main/java/com/techcourse/dao/UserHistoryDao.java @@ -1,7 +1,7 @@ package com.techcourse.dao; import com.techcourse.domain.UserHistory; -import nextstep.jdbc.JdbcTemplate; +import org.springframework.jdbc.core.JdbcTemplate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/app/src/test/java/com/techcourse/service/MockUserHistoryDao.java b/app/src/test/java/com/techcourse/service/MockUserHistoryDao.java index da3f2cee4b..2ee12b195f 100644 --- a/app/src/test/java/com/techcourse/service/MockUserHistoryDao.java +++ b/app/src/test/java/com/techcourse/service/MockUserHistoryDao.java @@ -2,8 +2,8 @@ import com.techcourse.dao.UserHistoryDao; import com.techcourse.domain.UserHistory; -import nextstep.jdbc.DataAccessException; -import nextstep.jdbc.JdbcTemplate; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; public class MockUserHistoryDao extends UserHistoryDao { diff --git a/app/src/test/java/com/techcourse/service/UserServiceTest.java b/app/src/test/java/com/techcourse/service/UserServiceTest.java index a22c3c7a82..255a0ebfe7 100644 --- a/app/src/test/java/com/techcourse/service/UserServiceTest.java +++ b/app/src/test/java/com/techcourse/service/UserServiceTest.java @@ -5,8 +5,8 @@ import com.techcourse.dao.UserHistoryDao; import com.techcourse.domain.User; import com.techcourse.support.jdbc.init.DatabasePopulatorUtils; -import nextstep.jdbc.DataAccessException; -import nextstep.jdbc.JdbcTemplate; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000000..fd90bc151f --- /dev/null +++ b/build.gradle @@ -0,0 +1,50 @@ +plugins { + id "java" + id "jacoco" + id "org.sonarqube" version "4.2.1.3168" +} + +subprojects { + apply plugin: 'org.sonarqube' + sonar { + properties { + property 'sonar.coverage.jacoco.xmlReportPaths', "$projectDir.parentFile.path/build/reports/jacoco/codeCoverageReport/codeCoverageReport.xml" + } + } + + tasks.withType(Test).configureEach { + maxParallelForks 3 + useJUnitPlatform() + } +} + +apply from: "$project.rootDir/sonar.gradle" + +// $ ./gradlew test codeCoverageReport +tasks.register("codeCoverageReport", JacocoReport) { + // If a subproject applies the 'jacoco' plugin, add the result it to the report + subprojects { subproject -> + subproject.plugins.withType(JacocoPlugin).configureEach { + subproject.tasks.matching({ t -> t.extensions.findByType(JacocoTaskExtension) }).configureEach { testTask -> + //the jacoco extension may be disabled for some projects + if (testTask.extensions.getByType(JacocoTaskExtension).isEnabled()) { + sourceSets subproject.sourceSets.main + executionData(testTask) + } else { + logger.warn('Jacoco extension is disabled for test task \'{}\' in project \'{}\'. this test task will be excluded from jacoco report.',testTask.getName(),subproject.getName()) + } + } + + subproject.tasks.matching({ t -> t.extensions.findByType(JacocoTaskExtension) }).forEach { + rootProject.tasks.codeCoverageReport.dependsOn(it) + } + } + } + + // enable the different report types (html, xml, csv) + reports { + xml.required = true + html.required = true + html.outputLocation = layout.buildDirectory.dir('jacocoHtml') + } +} diff --git a/jdbc/build.gradle b/jdbc/build.gradle index c5f680359c..0d591f4713 100644 --- a/jdbc/build.gradle +++ b/jdbc/build.gradle @@ -1,26 +1,24 @@ plugins { - id 'java' + id "java" + id "jacoco" } +sourceCompatibility = JavaVersion.VERSION_11 +targetCompatibility = JavaVersion.VERSION_11 + repositories { mavenCentral() } dependencies { - implementation 'org.springframework:spring-tx:5.3.23' - implementation 'org.springframework:spring-jdbc:5.3.23' - - implementation 'org.apache.commons:commons-lang3:3.12.0' - implementation 'ch.qos.logback:logback-classic:1.2.10' + implementation "org.reflections:reflections:0.10.2" + implementation "org.apache.commons:commons-lang3:3.13.0" + implementation "ch.qos.logback:logback-classic:1.2.12" - implementation 'org.reflections:reflections:0.10.2' - - testImplementation 'org.assertj:assertj-core:3.22.0' - testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1' - testImplementation 'org.mockito:mockito-core:3.+' - testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1' -} + testImplementation "org.assertj:assertj-core:3.24.2" + testImplementation "org.junit.jupiter:junit-jupiter-api:5.7.2" + testImplementation "org.mockito:mockito-core:5.4.0" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.7.2" -test { - useJUnitPlatform() + implementation "com.h2database:h2:2.2.220" } diff --git a/jdbc/src/main/java/nextstep/jdbc/JdbcTemplate.java b/jdbc/src/main/java/nextstep/jdbc/JdbcTemplate.java deleted file mode 100644 index 613a588a93..0000000000 --- a/jdbc/src/main/java/nextstep/jdbc/JdbcTemplate.java +++ /dev/null @@ -1,17 +0,0 @@ -package nextstep.jdbc; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import javax.sql.DataSource; - -public class JdbcTemplate { - - private static final Logger log = LoggerFactory.getLogger(JdbcTemplate.class); - - private final DataSource dataSource; - - public JdbcTemplate(final DataSource dataSource) { - this.dataSource = dataSource; - } -} diff --git a/jdbc/src/main/java/org/springframework/dao/DataAccessException.java b/jdbc/src/main/java/org/springframework/dao/DataAccessException.java new file mode 100644 index 0000000000..8f81f3ede9 --- /dev/null +++ b/jdbc/src/main/java/org/springframework/dao/DataAccessException.java @@ -0,0 +1,26 @@ +package org.springframework.dao; + +public class DataAccessException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public DataAccessException() { + super(); + } + + public DataAccessException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } + + public DataAccessException(String message, Throwable cause) { + super(message, cause); + } + + public DataAccessException(String message) { + super(message); + } + + public DataAccessException(Throwable cause) { + super(cause); + } +} diff --git a/jdbc/src/main/java/org/springframework/jdbc/CannotGetJdbcConnectionException.java b/jdbc/src/main/java/org/springframework/jdbc/CannotGetJdbcConnectionException.java new file mode 100644 index 0000000000..c5c3bcd010 --- /dev/null +++ b/jdbc/src/main/java/org/springframework/jdbc/CannotGetJdbcConnectionException.java @@ -0,0 +1,18 @@ +package org.springframework.jdbc; + +import java.sql.SQLException; + +public class CannotGetJdbcConnectionException extends RuntimeException { + + public CannotGetJdbcConnectionException(String msg) { + super(msg); + } + + public CannotGetJdbcConnectionException(String msg, SQLException ex) { + super(msg, ex); + } + + public CannotGetJdbcConnectionException(String msg, IllegalStateException ex) { + super(msg, ex); + } +} diff --git a/jdbc/src/main/java/org/springframework/jdbc/core/JdbcTemplate.java b/jdbc/src/main/java/org/springframework/jdbc/core/JdbcTemplate.java new file mode 100644 index 0000000000..b0f334ae78 --- /dev/null +++ b/jdbc/src/main/java/org/springframework/jdbc/core/JdbcTemplate.java @@ -0,0 +1,74 @@ +package org.springframework.jdbc.core; + +import static java.sql.Statement.RETURN_GENERATED_KEYS; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import javax.sql.DataSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class JdbcTemplate { + + private static final Logger log = LoggerFactory.getLogger(JdbcTemplate.class); + + private final DataSource dataSource; + + public JdbcTemplate(final DataSource dataSource) { + this.dataSource = dataSource; + } + + public Long executeUpdate(final String sql, final Object... parameters) { + try (final Connection connection = dataSource.getConnection(); + final PreparedStatement pstmt = connection.prepareStatement(sql, + RETURN_GENERATED_KEYS)) { + log.debug("query : {}", sql); + setPreparedStatement(pstmt, parameters); + pstmt.executeUpdate(); + return extractId(pstmt); + } catch (final SQLException e) { + log.error(e.getMessage(), e); + throw new RuntimeException(e); + } + } + + private Long extractId(final PreparedStatement pstmt) throws SQLException { + try (final ResultSet generatedKeys = pstmt.getGeneratedKeys()) { + if (generatedKeys.next()) { + return generatedKeys.getLong(1); + } + throw new SQLException("id를 찾을 수 없습니다."); + } + } + + private void setPreparedStatement( + final PreparedStatement pstmt, + final Object[] parameters + ) throws SQLException { + for (int index = 1; index <= parameters.length; index++) { + pstmt.setObject(index, parameters[index - 1]); + } + } + + public T executeQuery( + final String sql, + final Mapper mapper, + final Object... objects) { + + try (final Connection connection = dataSource.getConnection(); + final PreparedStatement pstmt = connection.prepareStatement(sql)) { + setPreparedStatement(pstmt, objects); + final ResultSet rs = pstmt.executeQuery(); + log.debug("query : {}", sql); + if (rs.next()) { + return mapper.map(rs); + } + return null; + } catch (SQLException e) { + log.error(e.getMessage(), e); + throw new RuntimeException(e); + } + } +} diff --git a/jdbc/src/main/java/org/springframework/jdbc/core/Mapper.java b/jdbc/src/main/java/org/springframework/jdbc/core/Mapper.java new file mode 100644 index 0000000000..98c9c57446 --- /dev/null +++ b/jdbc/src/main/java/org/springframework/jdbc/core/Mapper.java @@ -0,0 +1,9 @@ +package org.springframework.jdbc.core; + +import java.sql.ResultSet; +import java.sql.SQLException; + +public interface Mapper { + + T map(final ResultSet resultSet) throws SQLException; +} diff --git a/jdbc/src/main/java/org/springframework/jdbc/datasource/DataSourceUtils.java b/jdbc/src/main/java/org/springframework/jdbc/datasource/DataSourceUtils.java new file mode 100644 index 0000000000..3c40bfec52 --- /dev/null +++ b/jdbc/src/main/java/org/springframework/jdbc/datasource/DataSourceUtils.java @@ -0,0 +1,37 @@ +package org.springframework.jdbc.datasource; + +import org.springframework.jdbc.CannotGetJdbcConnectionException; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.SQLException; + +// 4단계 미션에서 사용할 것 +public abstract class DataSourceUtils { + + private DataSourceUtils() {} + + public static Connection getConnection(DataSource dataSource) throws CannotGetJdbcConnectionException { + Connection connection = TransactionSynchronizationManager.getResource(dataSource); + if (connection != null) { + return connection; + } + + try { + connection = dataSource.getConnection(); + TransactionSynchronizationManager.bindResource(dataSource, connection); + return connection; + } catch (SQLException ex) { + throw new CannotGetJdbcConnectionException("Failed to obtain JDBC Connection", ex); + } + } + + public static void releaseConnection(Connection connection, DataSource dataSource) { + try { + connection.close(); + } catch (SQLException ex) { + throw new CannotGetJdbcConnectionException("Failed to close JDBC Connection"); + } + } +} diff --git a/jdbc/src/main/java/org/springframework/transaction/support/TransactionSynchronizationManager.java b/jdbc/src/main/java/org/springframework/transaction/support/TransactionSynchronizationManager.java new file mode 100644 index 0000000000..715557fc66 --- /dev/null +++ b/jdbc/src/main/java/org/springframework/transaction/support/TransactionSynchronizationManager.java @@ -0,0 +1,23 @@ +package org.springframework.transaction.support; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.util.Map; + +public abstract class TransactionSynchronizationManager { + + private static final ThreadLocal> resources = new ThreadLocal<>(); + + private TransactionSynchronizationManager() {} + + public static Connection getResource(DataSource key) { + return null; + } + + public static void bindResource(DataSource key, Connection value) { + } + + public static Connection unbindResource(DataSource key) { + return null; + } +} diff --git a/jdbc/src/test/java/nextstep/jdbc/JdbcTemplateTest.java b/jdbc/src/test/java/nextstep/jdbc/JdbcTemplateTest.java deleted file mode 100644 index 040f689480..0000000000 --- a/jdbc/src/test/java/nextstep/jdbc/JdbcTemplateTest.java +++ /dev/null @@ -1,5 +0,0 @@ -package nextstep.jdbc; - -class JdbcTemplateTest { - -} \ No newline at end of file diff --git a/jdbc/src/test/java/org/springframework/jdbc/core/JdbcTemplateTest.java b/jdbc/src/test/java/org/springframework/jdbc/core/JdbcTemplateTest.java new file mode 100644 index 0000000000..13a246a4bd --- /dev/null +++ b/jdbc/src/test/java/org/springframework/jdbc/core/JdbcTemplateTest.java @@ -0,0 +1,73 @@ +package org.springframework.jdbc.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.test_supporter.DataSourceConfig; +import org.springframework.jdbc.core.test_supporter.DatabasePopulatorUtils; +import org.springframework.jdbc.core.test_supporter.User; +import org.springframework.jdbc.core.test_supporter.UserDao; + +class JdbcTemplateTest { + + private final JdbcTemplate jdbcTemplate = new JdbcTemplate(DataSourceConfig.getInstance()); + private final UserDao userDao = new UserDao(DataSourceConfig.getInstance()); + + @BeforeEach + void setUp() { + DatabasePopulatorUtils.execute(DataSourceConfig.getInstance()); + } + + @Test + @DisplayName("execute Query로 읽는 쿼리를 실행할 수 있다.") + void executeQuery() { + final User user = new User("hong-sile", "hong", "hong@teco.com"); + userDao.insert(user); + final String sql = "select id, account, password, email from users where id = ?"; + final Long id = 1L; + + final User actual = jdbcTemplate.executeQuery(sql, (rs) -> + new User( + rs.getLong(1), + rs.getString(2), + rs.getString(3), + rs.getString(4) + ) + , id); + + assertAll( + () -> assertThat(id) + .isEqualTo(actual.getId()), + () -> assertThat(actual) + .usingRecursiveComparison() + .ignoringFields("id") + .isEqualTo(user) + ); + } + + @Test + @DisplayName("execute로 쓰는 쿼리를 실행할 수 있다.") + void execute() { + final User user = new User("hong-sile", "hong", "hong@teco.com"); + final String sql = "insert into users (account, password, email) values (?, ?, ?)"; + + final Long id = jdbcTemplate.executeUpdate( + sql, + user.getAccount(), user.getPassword(), user.getEmail() + ); + + final User actual = userDao.findById(id); + + assertAll( + () -> assertThat(id) + .isEqualTo(actual.getId()), + () -> assertThat(actual) + .usingRecursiveComparison() + .ignoringFields("id") + .isEqualTo(user) + ); + } +} diff --git a/jdbc/src/test/java/org/springframework/jdbc/core/test_supporter/DataSourceConfig.java b/jdbc/src/test/java/org/springframework/jdbc/core/test_supporter/DataSourceConfig.java new file mode 100644 index 0000000000..a9d6d2d91b --- /dev/null +++ b/jdbc/src/test/java/org/springframework/jdbc/core/test_supporter/DataSourceConfig.java @@ -0,0 +1,26 @@ +package org.springframework.jdbc.core.test_supporter; + +import java.util.Objects; +import org.h2.jdbcx.JdbcDataSource; + +public class DataSourceConfig { + + private static javax.sql.DataSource INSTANCE; + + public static javax.sql.DataSource getInstance() { + if (Objects.isNull(INSTANCE)) { + INSTANCE = createJdbcDataSource(); + } + return INSTANCE; + } + + private static JdbcDataSource createJdbcDataSource() { + final var jdbcDataSource = new JdbcDataSource(); + jdbcDataSource.setUrl("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;"); + jdbcDataSource.setUser(""); + jdbcDataSource.setPassword(""); + return jdbcDataSource; + } + + private DataSourceConfig() {} +} diff --git a/jdbc/src/test/java/org/springframework/jdbc/core/test_supporter/DatabasePopulatorUtils.java b/jdbc/src/test/java/org/springframework/jdbc/core/test_supporter/DatabasePopulatorUtils.java new file mode 100644 index 0000000000..26423b84c0 --- /dev/null +++ b/jdbc/src/test/java/org/springframework/jdbc/core/test_supporter/DatabasePopulatorUtils.java @@ -0,0 +1,48 @@ +package org.springframework.jdbc.core.test_supporter; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import javax.sql.DataSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class DatabasePopulatorUtils { + + private static final Logger log = LoggerFactory.getLogger(DatabasePopulatorUtils.class); + + public static void execute(final DataSource dataSource) { + Connection connection = null; + Statement statement = null; + try { + final var url = DatabasePopulatorUtils.class.getClassLoader().getResource("schema.sql"); + final var file = new File(url.getFile()); + final var sql = Files.readString(file.toPath()); + connection = dataSource.getConnection(); + statement = connection.createStatement(); + statement.execute(sql); + } catch (NullPointerException | IOException | SQLException e) { + log.error(e.getMessage(), e); + } finally { + try { + if (statement != null) { + statement.close(); + } + } catch (SQLException ignored) { + } + + try { + if (connection != null) { + connection.close(); + } + } catch (SQLException ignored) { + } + } + } + + private DatabasePopulatorUtils() { + } +} diff --git a/jdbc/src/test/java/org/springframework/jdbc/core/test_supporter/User.java b/jdbc/src/test/java/org/springframework/jdbc/core/test_supporter/User.java new file mode 100644 index 0000000000..4d0c063c16 --- /dev/null +++ b/jdbc/src/test/java/org/springframework/jdbc/core/test_supporter/User.java @@ -0,0 +1,56 @@ +package org.springframework.jdbc.core.test_supporter; + +public class User { + + private Long id; + private final String account; + private String password; + private final String email; + + public User(long id, String account, String password, String email) { + this.id = id; + this.account = account; + this.password = password; + this.email = email; + } + + public User(String account, String password, String email) { + this.account = account; + this.password = password; + this.email = email; + } + + public boolean checkPassword(String password) { + return this.password.equals(password); + } + + public void changePassword(String password) { + this.password = password; + } + + public String getAccount() { + return account; + } + + public long getId() { + return id; + } + + public String getEmail() { + return email; + } + + public String getPassword() { + return password; + } + + @Override + public String toString() { + return "User{" + + "id=" + id + + ", account='" + account + '\'' + + ", email='" + email + '\'' + + ", password='" + password + '\'' + + '}'; + } +} diff --git a/jdbc/src/test/java/org/springframework/jdbc/core/test_supporter/UserDao.java b/jdbc/src/test/java/org/springframework/jdbc/core/test_supporter/UserDao.java new file mode 100644 index 0000000000..5c87f04661 --- /dev/null +++ b/jdbc/src/test/java/org/springframework/jdbc/core/test_supporter/UserDao.java @@ -0,0 +1,130 @@ +package org.springframework.jdbc.core.test_supporter; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import javax.sql.DataSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; + +public class UserDao { + + private static final Logger log = LoggerFactory.getLogger(UserDao.class); + + private final DataSource dataSource; + + public UserDao(final DataSource dataSource) { + this.dataSource = dataSource; + } + + public UserDao(final JdbcTemplate jdbcTemplate) { + this.dataSource = null; + } + + public void insert(final User user) { + final var sql = "insert into users (account, password, email) values (?, ?, ?)"; + + try (final Connection connection = dataSource.getConnection(); + final PreparedStatement pstmt = connection.prepareStatement(sql)) { + log.debug("query : {}", sql); + + pstmt.setString(1, user.getAccount()); + pstmt.setString(2, user.getPassword()); + pstmt.setString(3, user.getEmail()); + pstmt.executeUpdate(); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + public void update(final User user) { + final var sql = "update users set account = ?, password = ? , email = ? where id = ?"; + + try (final Connection connection = dataSource.getConnection(); + final PreparedStatement pstmt = connection.prepareStatement(sql)) { + log.debug("query : {}", sql); + + pstmt.setString(1, user.getAccount()); + pstmt.setString(2, user.getPassword()); + pstmt.setString(3, user.getEmail()); + pstmt.setLong(4, user.getId()); + pstmt.executeUpdate(); + } catch (final SQLException e) { + log.error(e.getMessage(), e); + throw new RuntimeException(e); + } + } + + public List findAll() { + final var sql = "select id, account, password, email from users"; + + try (final Connection connection = dataSource.getConnection(); + final PreparedStatement pstmt = connection.prepareStatement(sql)) { + final ResultSet rs = pstmt.executeQuery(); + + log.debug("query : {}", sql); + final List users = new ArrayList<>(); + if (rs.next()) { + final User user = extractUser(rs); + users.add(user); + } + return users; + } catch (SQLException e) { + log.error(e.getMessage(), e); + throw new RuntimeException(e); + } + } + + public User findById(final Long id) { + final var sql = "select id, account, password, email from users where id = ?"; + + try (final Connection connection = dataSource.getConnection(); + final PreparedStatement pstmt = connection.prepareStatement(sql)) { + pstmt.setLong(1, id); + final ResultSet rs = pstmt.executeQuery(); + + log.debug("query : {}", sql); + + if (rs.next()) { + return extractUser(rs); + } + return null; + } catch (SQLException e) { + log.error(e.getMessage(), e); + throw new RuntimeException(e); + } + } + + public User findByAccount(final String account) { + final var sql = "select id, account, password, email from users where account = ?"; + + try (final Connection connection = dataSource.getConnection(); + final PreparedStatement pstmt = connection.prepareStatement(sql)) { + pstmt.setString(1, account); + final ResultSet rs = pstmt.executeQuery(); + + log.debug("query : {}", sql); + + if (rs.next()) { + return extractUser(rs); + } + return null; + } catch (SQLException e) { + log.error(e.getMessage(), e); + throw new RuntimeException(e); + } + } + + private static User extractUser(final ResultSet rs) throws SQLException { + return new User( + rs.getLong(1), + rs.getString(2), + rs.getString(3), + rs.getString(4) + ); + } +} diff --git a/jdbc/src/test/resources/schema.sql b/jdbc/src/test/resources/schema.sql new file mode 100644 index 0000000000..ba235931f1 --- /dev/null +++ b/jdbc/src/test/resources/schema.sql @@ -0,0 +1,18 @@ +create table if not exists users ( + id bigint auto_increment, + account varchar(100) not null, + password varchar(100) not null, + email varchar(100) not null, + primary key(id) +); + +create table if not exists user_history ( + id bigint auto_increment, + user_id bigint not null, + account varchar(100) not null, + password varchar(100) not null, + email varchar(100) not null, + created_at datetime not null, + created_by varchar(100) not null, + primary key(id) +); diff --git a/mvc/build.gradle b/mvc/build.gradle index 7c7b100327..8cdd011952 100644 --- a/mvc/build.gradle +++ b/mvc/build.gradle @@ -1,5 +1,6 @@ plugins { - id 'java' + id "java" + id "jacoco" } sourceCompatibility = JavaVersion.VERSION_11 @@ -10,25 +11,21 @@ repositories { } dependencies { - implementation 'jakarta.servlet:jakarta.servlet-api:5.0.0' - implementation 'javax.servlet.jsp:javax.servlet.jsp-api:2.3.3' - implementation 'jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api:3.0.0' - implementation 'jakarta.annotation:jakarta.annotation-api:2.0.0' - annotationProcessor 'jakarta.annotation:jakarta.annotation-api:2.0.0' + implementation "jakarta.servlet:jakarta.servlet-api:5.0.0" + implementation "javax.servlet.jsp:javax.servlet.jsp-api:2.3.3" + implementation "jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api:3.0.0" + implementation "jakarta.annotation:jakarta.annotation-api:2.0.0" + annotationProcessor "jakarta.annotation:jakarta.annotation-api:2.0.0" - implementation 'org.reflections:reflections:0.10.2' + implementation "org.reflections:reflections:0.10.2" - implementation 'com.fasterxml.jackson.core:jackson-databind:2.13.4' + implementation "com.fasterxml.jackson.core:jackson-databind:2.15.2" - implementation 'org.apache.commons:commons-lang3:3.12.0' - implementation 'ch.qos.logback:logback-classic:1.2.10' + implementation "org.apache.commons:commons-lang3:3.13.0" + implementation "ch.qos.logback:logback-classic:1.2.12" - testImplementation 'org.assertj:assertj-core:3.22.0' - testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1' - testImplementation 'org.mockito:mockito-core:3.+' - testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1' -} - -test { - useJUnitPlatform() + testImplementation "org.assertj:assertj-core:3.24.2" + testImplementation "org.junit.jupiter:junit-jupiter-api:5.7.2" + testImplementation "org.mockito:mockito-core:5.4.0" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.7.2" } diff --git a/mvc/src/main/java/nextstep/web/annotation/Controller.java b/mvc/src/main/java/context/org/springframework/stereotype/Controller.java similarity index 87% rename from mvc/src/main/java/nextstep/web/annotation/Controller.java rename to mvc/src/main/java/context/org/springframework/stereotype/Controller.java index cb264235b4..ef7379b54a 100644 --- a/mvc/src/main/java/nextstep/web/annotation/Controller.java +++ b/mvc/src/main/java/context/org/springframework/stereotype/Controller.java @@ -1,4 +1,4 @@ -package nextstep.web.annotation; +package context.org.springframework.stereotype; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/mvc/src/main/java/core/org/springframework/util/ReflectionUtils.java b/mvc/src/main/java/core/org/springframework/util/ReflectionUtils.java new file mode 100644 index 0000000000..e89c1743f3 --- /dev/null +++ b/mvc/src/main/java/core/org/springframework/util/ReflectionUtils.java @@ -0,0 +1,38 @@ +package core.org.springframework.util; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Modifier; + +public abstract class ReflectionUtils { + + /** + * Obtain an accessible constructor for the given class and parameters. + * @param clazz the clazz to check + * @param parameterTypes the parameter types of the desired constructor + * @return the constructor reference + * @throws NoSuchMethodException if no such constructor exists + * @since 5.0 + */ + public static Constructor accessibleConstructor(Class clazz, Class... parameterTypes) + throws NoSuchMethodException { + + Constructor ctor = clazz.getDeclaredConstructor(parameterTypes); + makeAccessible(ctor); + return ctor; + } + + /** + * Make the given constructor accessible, explicitly setting it accessible + * if necessary. The {@code setAccessible(true)} method is only called + * when actually necessary, to avoid unnecessary conflicts. + * @param ctor the constructor to make accessible + * @see Constructor#setAccessible + */ + @SuppressWarnings("deprecation") + public static void makeAccessible(Constructor ctor) { + if ((!Modifier.isPublic(ctor.getModifiers()) || + !Modifier.isPublic(ctor.getDeclaringClass().getModifiers())) && !ctor.isAccessible()) { + ctor.setAccessible(true); + } + } +} diff --git a/mvc/src/main/java/nextstep/web/support/MediaType.java b/mvc/src/main/java/web/org/springframework/http/MediaType.java similarity index 76% rename from mvc/src/main/java/nextstep/web/support/MediaType.java rename to mvc/src/main/java/web/org/springframework/http/MediaType.java index f881e02174..3a31f51d33 100644 --- a/mvc/src/main/java/nextstep/web/support/MediaType.java +++ b/mvc/src/main/java/web/org/springframework/http/MediaType.java @@ -1,4 +1,4 @@ -package nextstep.web.support; +package web.org.springframework.http; public class MediaType { public static final String APPLICATION_JSON_UTF8_VALUE = "application/json;charset=UTF-8"; diff --git a/mvc/src/main/java/nextstep/web/NextstepServletContainerInitializer.java b/mvc/src/main/java/web/org/springframework/web/SpringServletContainerInitializer.java similarity index 76% rename from mvc/src/main/java/nextstep/web/NextstepServletContainerInitializer.java rename to mvc/src/main/java/web/org/springframework/web/SpringServletContainerInitializer.java index 3e79b34b6a..b0e900ad6e 100644 --- a/mvc/src/main/java/nextstep/web/NextstepServletContainerInitializer.java +++ b/mvc/src/main/java/web/org/springframework/web/SpringServletContainerInitializer.java @@ -1,5 +1,6 @@ -package nextstep.web; +package web.org.springframework.web; +import core.org.springframework.util.ReflectionUtils; import jakarta.servlet.ServletContainerInitializer; import jakarta.servlet.ServletContext; import jakarta.servlet.ServletException; @@ -10,7 +11,7 @@ import java.util.Set; @HandlesTypes(WebApplicationInitializer.class) -public class NextstepServletContainerInitializer implements ServletContainerInitializer { +public class SpringServletContainerInitializer implements ServletContainerInitializer { @Override public void onStartup(Set> webAppInitializerClasses, ServletContext servletContext) @@ -20,9 +21,10 @@ public void onStartup(Set> webAppInitializerClasses, ServletContext ser if (webAppInitializerClasses != null) { for (Class waiClass : webAppInitializerClasses) { try { - initializers.add((WebApplicationInitializer) waiClass.getDeclaredConstructor().newInstance()); - } catch (Throwable e) { - throw new ServletException("Failed to instantiate WebApplicationInitializer class", e); + initializers.add((WebApplicationInitializer) + ReflectionUtils.accessibleConstructor(waiClass).newInstance()); + } catch (Throwable ex) { + throw new ServletException("Failed to instantiate WebApplicationInitializer class", ex); } } } diff --git a/mvc/src/main/java/nextstep/web/WebApplicationInitializer.java b/mvc/src/main/java/web/org/springframework/web/WebApplicationInitializer.java similarity index 84% rename from mvc/src/main/java/nextstep/web/WebApplicationInitializer.java rename to mvc/src/main/java/web/org/springframework/web/WebApplicationInitializer.java index 60e6a89543..4b9b7d893d 100644 --- a/mvc/src/main/java/nextstep/web/WebApplicationInitializer.java +++ b/mvc/src/main/java/web/org/springframework/web/WebApplicationInitializer.java @@ -1,4 +1,4 @@ -package nextstep.web; +package web.org.springframework.web; import jakarta.servlet.ServletContext; import jakarta.servlet.ServletException; diff --git a/mvc/src/main/java/nextstep/web/annotation/PathVariable.java b/mvc/src/main/java/web/org/springframework/web/bind/annotation/PathVariable.java similarity index 82% rename from mvc/src/main/java/nextstep/web/annotation/PathVariable.java rename to mvc/src/main/java/web/org/springframework/web/bind/annotation/PathVariable.java index 4f2a9b50a5..ceefd548e1 100644 --- a/mvc/src/main/java/nextstep/web/annotation/PathVariable.java +++ b/mvc/src/main/java/web/org/springframework/web/bind/annotation/PathVariable.java @@ -1,4 +1,4 @@ -package nextstep.web.annotation; +package web.org.springframework.web.bind.annotation; import java.lang.annotation.*; diff --git a/mvc/src/main/java/nextstep/web/annotation/RequestMapping.java b/mvc/src/main/java/web/org/springframework/web/bind/annotation/RequestMapping.java similarity index 82% rename from mvc/src/main/java/nextstep/web/annotation/RequestMapping.java rename to mvc/src/main/java/web/org/springframework/web/bind/annotation/RequestMapping.java index bb8c9e6e7b..6a09dfa0c4 100644 --- a/mvc/src/main/java/nextstep/web/annotation/RequestMapping.java +++ b/mvc/src/main/java/web/org/springframework/web/bind/annotation/RequestMapping.java @@ -1,6 +1,4 @@ -package nextstep.web.annotation; - -import nextstep.web.support.RequestMethod; +package web.org.springframework.web.bind.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/mvc/src/main/java/nextstep/web/support/RequestMethod.java b/mvc/src/main/java/web/org/springframework/web/bind/annotation/RequestMethod.java similarity index 62% rename from mvc/src/main/java/nextstep/web/support/RequestMethod.java rename to mvc/src/main/java/web/org/springframework/web/bind/annotation/RequestMethod.java index 1f37f21d5a..1dd958bd23 100644 --- a/mvc/src/main/java/nextstep/web/support/RequestMethod.java +++ b/mvc/src/main/java/web/org/springframework/web/bind/annotation/RequestMethod.java @@ -1,4 +1,4 @@ -package nextstep.web.support; +package web.org.springframework.web.bind.annotation; public enum RequestMethod { GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE diff --git a/mvc/src/main/java/nextstep/web/annotation/RequestParam.java b/mvc/src/main/java/web/org/springframework/web/bind/annotation/RequestParam.java similarity index 82% rename from mvc/src/main/java/nextstep/web/annotation/RequestParam.java rename to mvc/src/main/java/web/org/springframework/web/bind/annotation/RequestParam.java index 2813247c4a..5b3be4cedc 100644 --- a/mvc/src/main/java/nextstep/web/annotation/RequestParam.java +++ b/mvc/src/main/java/web/org/springframework/web/bind/annotation/RequestParam.java @@ -1,4 +1,4 @@ -package nextstep.web.annotation; +package web.org.springframework.web.bind.annotation; import java.lang.annotation.*; diff --git a/mvc/src/main/java/nextstep/mvc/view/ModelAndView.java b/mvc/src/main/java/webmvc/org/springframework/web/servlet/ModelAndView.java similarity index 93% rename from mvc/src/main/java/nextstep/mvc/view/ModelAndView.java rename to mvc/src/main/java/webmvc/org/springframework/web/servlet/ModelAndView.java index cb172084b3..ff8e24553f 100644 --- a/mvc/src/main/java/nextstep/mvc/view/ModelAndView.java +++ b/mvc/src/main/java/webmvc/org/springframework/web/servlet/ModelAndView.java @@ -1,4 +1,4 @@ -package nextstep.mvc.view; +package webmvc.org.springframework.web.servlet; import java.util.Collections; import java.util.HashMap; diff --git a/mvc/src/main/java/nextstep/mvc/view/View.java b/mvc/src/main/java/webmvc/org/springframework/web/servlet/View.java similarity index 85% rename from mvc/src/main/java/nextstep/mvc/view/View.java rename to mvc/src/main/java/webmvc/org/springframework/web/servlet/View.java index f8cd1dba1a..ec335db0a0 100644 --- a/mvc/src/main/java/nextstep/mvc/view/View.java +++ b/mvc/src/main/java/webmvc/org/springframework/web/servlet/View.java @@ -1,4 +1,4 @@ -package nextstep.mvc.view; +package webmvc.org.springframework.web.servlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; diff --git a/mvc/src/main/java/nextstep/mvc/DispatcherServlet.java b/mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/DispatcherServlet.java similarity index 95% rename from mvc/src/main/java/nextstep/mvc/DispatcherServlet.java rename to mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/DispatcherServlet.java index e68ca5f32c..c8d74cf60b 100644 --- a/mvc/src/main/java/nextstep/mvc/DispatcherServlet.java +++ b/mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/DispatcherServlet.java @@ -1,10 +1,10 @@ -package nextstep.mvc; +package webmvc.org.springframework.web.servlet.mvc; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import nextstep.mvc.view.ModelAndView; +import webmvc.org.springframework.web.servlet.ModelAndView; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/mvc/src/main/java/nextstep/mvc/HandlerAdapter.java b/mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/HandlerAdapter.java similarity index 73% rename from mvc/src/main/java/nextstep/mvc/HandlerAdapter.java rename to mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/HandlerAdapter.java index ebecdd2e1f..4475d5a206 100644 --- a/mvc/src/main/java/nextstep/mvc/HandlerAdapter.java +++ b/mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/HandlerAdapter.java @@ -1,6 +1,6 @@ -package nextstep.mvc; +package webmvc.org.springframework.web.servlet.mvc; -import nextstep.mvc.view.ModelAndView; +import webmvc.org.springframework.web.servlet.ModelAndView; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; diff --git a/mvc/src/main/java/nextstep/mvc/HandlerAdapterRegistry.java b/mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/HandlerAdapterRegistry.java similarity index 91% rename from mvc/src/main/java/nextstep/mvc/HandlerAdapterRegistry.java rename to mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/HandlerAdapterRegistry.java index cbf1a9810a..bd960168db 100644 --- a/mvc/src/main/java/nextstep/mvc/HandlerAdapterRegistry.java +++ b/mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/HandlerAdapterRegistry.java @@ -1,4 +1,4 @@ -package nextstep.mvc; +package webmvc.org.springframework.web.servlet.mvc; import java.util.ArrayList; import java.util.List; diff --git a/mvc/src/main/java/nextstep/mvc/HandlerExecutor.java b/mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/HandlerExecutor.java similarity index 85% rename from mvc/src/main/java/nextstep/mvc/HandlerExecutor.java rename to mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/HandlerExecutor.java index abd7cba029..2ac3daddaf 100644 --- a/mvc/src/main/java/nextstep/mvc/HandlerExecutor.java +++ b/mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/HandlerExecutor.java @@ -1,8 +1,8 @@ -package nextstep.mvc; +package webmvc.org.springframework.web.servlet.mvc; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import nextstep.mvc.view.ModelAndView; +import webmvc.org.springframework.web.servlet.ModelAndView; public class HandlerExecutor { diff --git a/mvc/src/main/java/nextstep/mvc/HandlerMapping.java b/mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/HandlerMapping.java similarity index 76% rename from mvc/src/main/java/nextstep/mvc/HandlerMapping.java rename to mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/HandlerMapping.java index 9c1f61f239..667715f716 100644 --- a/mvc/src/main/java/nextstep/mvc/HandlerMapping.java +++ b/mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/HandlerMapping.java @@ -1,4 +1,4 @@ -package nextstep.mvc; +package webmvc.org.springframework.web.servlet.mvc; import jakarta.servlet.http.HttpServletRequest; diff --git a/mvc/src/main/java/nextstep/mvc/HandlerMappingRegistry.java b/mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/HandlerMappingRegistry.java similarity index 92% rename from mvc/src/main/java/nextstep/mvc/HandlerMappingRegistry.java rename to mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/HandlerMappingRegistry.java index 608fa6e86f..edc8430332 100644 --- a/mvc/src/main/java/nextstep/mvc/HandlerMappingRegistry.java +++ b/mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/HandlerMappingRegistry.java @@ -1,4 +1,4 @@ -package nextstep.mvc; +package webmvc.org.springframework.web.servlet.mvc; import jakarta.servlet.http.HttpServletRequest; import java.util.ArrayList; diff --git a/mvc/src/main/java/nextstep/mvc/controller/asis/Controller.java b/mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/asis/Controller.java similarity index 80% rename from mvc/src/main/java/nextstep/mvc/controller/asis/Controller.java rename to mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/asis/Controller.java index b0edabf3fb..bdd1fde780 100644 --- a/mvc/src/main/java/nextstep/mvc/controller/asis/Controller.java +++ b/mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/asis/Controller.java @@ -1,4 +1,4 @@ -package nextstep.mvc.controller.asis; +package webmvc.org.springframework.web.servlet.mvc.asis; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; diff --git a/mvc/src/main/java/nextstep/mvc/controller/asis/ControllerHandlerAdapter.java b/mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/asis/ControllerHandlerAdapter.java similarity index 70% rename from mvc/src/main/java/nextstep/mvc/controller/asis/ControllerHandlerAdapter.java rename to mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/asis/ControllerHandlerAdapter.java index 9faf37411f..6c02851b91 100644 --- a/mvc/src/main/java/nextstep/mvc/controller/asis/ControllerHandlerAdapter.java +++ b/mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/asis/ControllerHandlerAdapter.java @@ -1,10 +1,10 @@ -package nextstep.mvc.controller.asis; +package webmvc.org.springframework.web.servlet.mvc.asis; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import nextstep.mvc.HandlerAdapter; -import nextstep.mvc.view.JspView; -import nextstep.mvc.view.ModelAndView; +import webmvc.org.springframework.web.servlet.mvc.HandlerAdapter; +import webmvc.org.springframework.web.servlet.view.JspView; +import webmvc.org.springframework.web.servlet.ModelAndView; public class ControllerHandlerAdapter implements HandlerAdapter { diff --git a/mvc/src/main/java/nextstep/mvc/controller/asis/ForwardController.java b/mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/asis/ForwardController.java similarity index 89% rename from mvc/src/main/java/nextstep/mvc/controller/asis/ForwardController.java rename to mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/asis/ForwardController.java index ed0f08d940..cd8f1ef371 100644 --- a/mvc/src/main/java/nextstep/mvc/controller/asis/ForwardController.java +++ b/mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/asis/ForwardController.java @@ -1,4 +1,4 @@ -package nextstep.mvc.controller.asis; +package webmvc.org.springframework.web.servlet.mvc.asis; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; diff --git a/mvc/src/main/java/nextstep/mvc/controller/tobe/AnnotationHandlerMapping.java b/mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/tobe/AnnotationHandlerMapping.java similarity index 92% rename from mvc/src/main/java/nextstep/mvc/controller/tobe/AnnotationHandlerMapping.java rename to mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/tobe/AnnotationHandlerMapping.java index af90da57b1..5505b3eb2b 100644 --- a/mvc/src/main/java/nextstep/mvc/controller/tobe/AnnotationHandlerMapping.java +++ b/mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/tobe/AnnotationHandlerMapping.java @@ -1,9 +1,9 @@ -package nextstep.mvc.controller.tobe; +package webmvc.org.springframework.web.servlet.mvc.tobe; import jakarta.servlet.http.HttpServletRequest; -import nextstep.mvc.HandlerMapping; -import nextstep.web.annotation.RequestMapping; -import nextstep.web.support.RequestMethod; +import webmvc.org.springframework.web.servlet.mvc.HandlerMapping; +import web.org.springframework.web.bind.annotation.RequestMapping; +import web.org.springframework.web.bind.annotation.RequestMethod; import org.reflections.ReflectionUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/mvc/src/main/java/nextstep/mvc/controller/tobe/ControllerScanner.java b/mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/tobe/ControllerScanner.java similarity index 91% rename from mvc/src/main/java/nextstep/mvc/controller/tobe/ControllerScanner.java rename to mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/tobe/ControllerScanner.java index d96ff1f849..4ced894a3d 100644 --- a/mvc/src/main/java/nextstep/mvc/controller/tobe/ControllerScanner.java +++ b/mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/tobe/ControllerScanner.java @@ -1,6 +1,6 @@ -package nextstep.mvc.controller.tobe; +package webmvc.org.springframework.web.servlet.mvc.tobe; -import nextstep.web.annotation.Controller; +import context.org.springframework.stereotype.Controller; import org.reflections.Reflections; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/mvc/src/main/java/nextstep/mvc/controller/tobe/HandlerExecution.java b/mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/tobe/HandlerExecution.java similarity index 90% rename from mvc/src/main/java/nextstep/mvc/controller/tobe/HandlerExecution.java rename to mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/tobe/HandlerExecution.java index 2d4bceb977..22fa2dd98c 100644 --- a/mvc/src/main/java/nextstep/mvc/controller/tobe/HandlerExecution.java +++ b/mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/tobe/HandlerExecution.java @@ -1,8 +1,8 @@ -package nextstep.mvc.controller.tobe; +package webmvc.org.springframework.web.servlet.mvc.tobe; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import nextstep.mvc.view.ModelAndView; +import webmvc.org.springframework.web.servlet.ModelAndView; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/mvc/src/main/java/nextstep/mvc/controller/tobe/HandlerExecutionHandlerAdapter.java b/mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/tobe/HandlerExecutionHandlerAdapter.java similarity index 74% rename from mvc/src/main/java/nextstep/mvc/controller/tobe/HandlerExecutionHandlerAdapter.java rename to mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/tobe/HandlerExecutionHandlerAdapter.java index 8d10ec2324..071cb6dcf0 100644 --- a/mvc/src/main/java/nextstep/mvc/controller/tobe/HandlerExecutionHandlerAdapter.java +++ b/mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/tobe/HandlerExecutionHandlerAdapter.java @@ -1,9 +1,9 @@ -package nextstep.mvc.controller.tobe; +package webmvc.org.springframework.web.servlet.mvc.tobe; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import nextstep.mvc.HandlerAdapter; -import nextstep.mvc.view.ModelAndView; +import webmvc.org.springframework.web.servlet.mvc.HandlerAdapter; +import webmvc.org.springframework.web.servlet.ModelAndView; public class HandlerExecutionHandlerAdapter implements HandlerAdapter { diff --git a/mvc/src/main/java/nextstep/mvc/controller/tobe/HandlerKey.java b/mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/tobe/HandlerKey.java similarity index 86% rename from mvc/src/main/java/nextstep/mvc/controller/tobe/HandlerKey.java rename to mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/tobe/HandlerKey.java index 66f14a9deb..ce758bbdc1 100644 --- a/mvc/src/main/java/nextstep/mvc/controller/tobe/HandlerKey.java +++ b/mvc/src/main/java/webmvc/org/springframework/web/servlet/mvc/tobe/HandlerKey.java @@ -1,6 +1,6 @@ -package nextstep.mvc.controller.tobe; +package webmvc.org.springframework.web.servlet.mvc.tobe; -import nextstep.web.support.RequestMethod; +import web.org.springframework.web.bind.annotation.RequestMethod; import java.util.Objects; diff --git a/mvc/src/main/java/nextstep/mvc/view/JsonView.java b/mvc/src/main/java/webmvc/org/springframework/web/servlet/view/JsonView.java similarity index 89% rename from mvc/src/main/java/nextstep/mvc/view/JsonView.java rename to mvc/src/main/java/webmvc/org/springframework/web/servlet/view/JsonView.java index b24ab650fd..63c5684044 100644 --- a/mvc/src/main/java/nextstep/mvc/view/JsonView.java +++ b/mvc/src/main/java/webmvc/org/springframework/web/servlet/view/JsonView.java @@ -1,10 +1,11 @@ -package nextstep.mvc.view; +package webmvc.org.springframework.web.servlet.view; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.ServletOutputStream; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import nextstep.web.support.MediaType; +import web.org.springframework.http.MediaType; +import webmvc.org.springframework.web.servlet.View; import java.io.IOException; import java.util.Map; diff --git a/mvc/src/main/java/nextstep/mvc/view/JspView.java b/mvc/src/main/java/webmvc/org/springframework/web/servlet/view/JspView.java similarity index 92% rename from mvc/src/main/java/nextstep/mvc/view/JspView.java rename to mvc/src/main/java/webmvc/org/springframework/web/servlet/view/JspView.java index 63c65c9f6b..c61ee1846c 100644 --- a/mvc/src/main/java/nextstep/mvc/view/JspView.java +++ b/mvc/src/main/java/webmvc/org/springframework/web/servlet/view/JspView.java @@ -1,9 +1,10 @@ -package nextstep.mvc.view; +package webmvc.org.springframework.web.servlet.view; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import webmvc.org.springframework.web.servlet.View; import java.util.Map; import java.util.Objects; diff --git a/mvc/src/main/resources/META-INF/services/jakarta.servlet.ServletContainerInitializer b/mvc/src/main/resources/META-INF/services/jakarta.servlet.ServletContainerInitializer index b9002355ee..d98fc63525 100644 --- a/mvc/src/main/resources/META-INF/services/jakarta.servlet.ServletContainerInitializer +++ b/mvc/src/main/resources/META-INF/services/jakarta.servlet.ServletContainerInitializer @@ -1 +1 @@ -nextstep.web.NextstepServletContainerInitializer \ No newline at end of file +web.org.springframework.web.SpringServletContainerInitializer diff --git a/mvc/src/test/java/samples/TestController.java b/mvc/src/test/java/samples/TestController.java index 49d81be351..fbed040d78 100644 --- a/mvc/src/test/java/samples/TestController.java +++ b/mvc/src/test/java/samples/TestController.java @@ -2,11 +2,11 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import nextstep.mvc.view.JspView; -import nextstep.mvc.view.ModelAndView; -import nextstep.web.annotation.Controller; -import nextstep.web.annotation.RequestMapping; -import nextstep.web.support.RequestMethod; +import webmvc.org.springframework.web.servlet.view.JspView; +import webmvc.org.springframework.web.servlet.ModelAndView; +import context.org.springframework.stereotype.Controller; +import web.org.springframework.web.bind.annotation.RequestMapping; +import web.org.springframework.web.bind.annotation.RequestMethod; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/mvc/src/test/java/nextstep/mvc/controller/tobe/AnnotationHandlerMappingTest.java b/mvc/src/test/java/webmvc/org/springframework/web/servlet/mvc/tobe/AnnotationHandlerMappingTest.java similarity index 96% rename from mvc/src/test/java/nextstep/mvc/controller/tobe/AnnotationHandlerMappingTest.java rename to mvc/src/test/java/webmvc/org/springframework/web/servlet/mvc/tobe/AnnotationHandlerMappingTest.java index 110a45bab1..af8907e236 100644 --- a/mvc/src/test/java/nextstep/mvc/controller/tobe/AnnotationHandlerMappingTest.java +++ b/mvc/src/test/java/webmvc/org/springframework/web/servlet/mvc/tobe/AnnotationHandlerMappingTest.java @@ -1,4 +1,4 @@ -package nextstep.mvc.controller.tobe; +package webmvc.org.springframework.web.servlet.mvc.tobe; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; diff --git a/settings.gradle b/settings.gradle index f19833b340..df1d532345 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,5 +1,5 @@ rootProject.name = 'jwp-dashboard-jdbc' - include 'jdbc' include 'mvc' include 'app' +include 'study' diff --git a/sonar.gradle b/sonar.gradle new file mode 100644 index 0000000000..249f388452 --- /dev/null +++ b/sonar.gradle @@ -0,0 +1,11 @@ +apply plugin: "org.sonarqube" + +sonar { + properties { + property "sonar.projectKey", "woowacourse_jwp-dashboard-jdbc" + property "sonar.organization", "woowacourse" + property "sonar.host.url", "https://sonarcloud.io" + + property "sonar.exclusions", "study/**" + } +} diff --git a/study/build.gradle b/study/build.gradle new file mode 100644 index 0000000000..c7aff0460e --- /dev/null +++ b/study/build.gradle @@ -0,0 +1,40 @@ +plugins { + id "org.springframework.boot" version "2.7.4" + id "io.spring.dependency-management" version "1.0.11.RELEASE" + id "java" +} + +group "nextstep" +version "1.0-SNAPSHOT" + +sourceCompatibility = JavaVersion.VERSION_11 +targetCompatibility = JavaVersion.VERSION_11 + +repositories { + mavenCentral() +} + +dependencies { + implementation "org.springframework.boot:spring-boot-starter-web:2.7.14" + implementation "org.springframework.boot:spring-boot-starter-jdbc:2.7.14" + implementation "org.springframework.boot:spring-boot-starter-data-jpa:2.7.14" + + implementation "com.h2database:h2:2.2.220" + implementation "org.testcontainers:mysql:1.17.3" + + implementation "org.reflections:reflections:0.10.2" + implementation "ch.qos.logback:logback-classic:1.2.12" + implementation "org.apache.commons:commons-lang3:3.13.0" + + testImplementation "org.springframework.boot:spring-boot-starter-test:2.7.14" + testImplementation "org.assertj:assertj-core:3.24.2" + testImplementation "org.mockito:mockito-core:5.4.0" + testImplementation "org.junit.jupiter:junit-jupiter-engine:5.7.2" + testImplementation "org.junit.jupiter:junit-jupiter-api:5.7.2" + testImplementation "mysql:mysql-connector-java:8.0.30" +} + +test { + maxParallelForks 3 + useJUnitPlatform() +} diff --git a/study/src/main/java/aop/App.java b/study/src/main/java/aop/App.java new file mode 100644 index 0000000000..9af82bd63f --- /dev/null +++ b/study/src/main/java/aop/App.java @@ -0,0 +1,12 @@ +package aop; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class App { + + public static void main(String[] args) { + SpringApplication.run(App.class, args); + } +} diff --git a/jdbc/src/main/java/nextstep/jdbc/DataAccessException.java b/study/src/main/java/aop/DataAccessException.java similarity index 96% rename from jdbc/src/main/java/nextstep/jdbc/DataAccessException.java rename to study/src/main/java/aop/DataAccessException.java index ac0e0a3a3b..9fd069c493 100644 --- a/jdbc/src/main/java/nextstep/jdbc/DataAccessException.java +++ b/study/src/main/java/aop/DataAccessException.java @@ -1,7 +1,6 @@ -package nextstep.jdbc; +package aop; public class DataAccessException extends RuntimeException { - private static final long serialVersionUID = 1L; public DataAccessException() { diff --git a/study/src/main/java/aop/Transactional.java b/study/src/main/java/aop/Transactional.java new file mode 100644 index 0000000000..c9b91810c7 --- /dev/null +++ b/study/src/main/java/aop/Transactional.java @@ -0,0 +1,10 @@ +package aop; + +import java.lang.annotation.*; + +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +public @interface Transactional { +} diff --git a/study/src/main/java/aop/config/DataSourceConfig.java b/study/src/main/java/aop/config/DataSourceConfig.java new file mode 100644 index 0000000000..d64fc2ca2b --- /dev/null +++ b/study/src/main/java/aop/config/DataSourceConfig.java @@ -0,0 +1,21 @@ +package aop.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; + +import javax.sql.DataSource; + +@Configuration +public class DataSourceConfig { + + @Bean + public DataSource dataSource() { + return new EmbeddedDatabaseBuilder() + .setType(EmbeddedDatabaseType.H2) + .setName("test;DB_CLOSE_DELAY=-1;MODE=MYSQL;") + .addScript("classpath:schema.sql") + .build(); + } +} diff --git a/study/src/main/java/aop/domain/User.java b/study/src/main/java/aop/domain/User.java new file mode 100644 index 0000000000..fb8f4d80c6 --- /dev/null +++ b/study/src/main/java/aop/domain/User.java @@ -0,0 +1,56 @@ +package aop.domain; + +public class User { + + private Long id; + private final String account; + private String password; + private final String email; + + public User(long id, String account, String password, String email) { + this.id = id; + this.account = account; + this.password = password; + this.email = email; + } + + public User(String account, String password, String email) { + this.account = account; + this.password = password; + this.email = email; + } + + public boolean checkPassword(String password) { + return this.password.equals(password); + } + + public void changePassword(String password) { + this.password = password; + } + + public String getAccount() { + return account; + } + + public long getId() { + return id; + } + + public String getEmail() { + return email; + } + + public String getPassword() { + return password; + } + + @Override + public String toString() { + return "User{" + + "id=" + id + + ", account='" + account + '\'' + + ", email='" + email + '\'' + + ", password='" + password + '\'' + + '}'; + } +} diff --git a/study/src/main/java/aop/domain/UserHistory.java b/study/src/main/java/aop/domain/UserHistory.java new file mode 100644 index 0000000000..13403450ae --- /dev/null +++ b/study/src/main/java/aop/domain/UserHistory.java @@ -0,0 +1,59 @@ +package aop.domain; + +import java.time.LocalDateTime; + +public class UserHistory { + + private Long id; + + private final long userId; + private final String account; + private final String password; + private final String email; + + private final LocalDateTime createdAt; + + private final String createBy; + + public UserHistory(final User user, final String createBy) { + this(null, user.getId(), user.getAccount(), user.getPassword(), user.getEmail(), createBy); + } + + public UserHistory(final Long id, final long userId, final String account, final String password, final String email, final String createBy) { + this.id = id; + this.userId = userId; + this.account = account; + this.password = password; + this.email = email; + this.createdAt = LocalDateTime.now(); + this.createBy = createBy; + } + + public Long getId() { + return id; + } + + public long getUserId() { + return userId; + } + + public String getAccount() { + return account; + } + + public String getPassword() { + return password; + } + + public String getEmail() { + return email; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public String getCreateBy() { + return createBy; + } +} diff --git a/study/src/main/java/aop/repository/UserDao.java b/study/src/main/java/aop/repository/UserDao.java new file mode 100644 index 0000000000..c9463f7323 --- /dev/null +++ b/study/src/main/java/aop/repository/UserDao.java @@ -0,0 +1,51 @@ +package aop.repository; + +import aop.domain.User; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public class UserDao { + + private final JdbcTemplate jdbcTemplate; + + public UserDao(final JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public void insert(final User user) { + final var sql = "insert into users (account, password, email) values (?, ?, ?)"; + jdbcTemplate.update(sql, user.getAccount(), user.getPassword(), user.getEmail()); + } + + public void update(final User user) { + final var sql = "update users set account = ?, password = ?, email = ? where id = ?"; + jdbcTemplate.update(sql, user.getAccount(), user.getPassword(), user.getEmail(), user.getId()); + } + + public List findAll() { + final var sql = "select id, account, password, email from users"; + return jdbcTemplate.query(sql, createRowMapper()); + } + + public User findById(final Long id) { + final var sql = "select id, account, password, email from users where id = ?"; + return jdbcTemplate.queryForObject(sql, createRowMapper(), id); + } + + public User findByAccount(final String account) { + final var sql = "select id, account, password, email from users where account = ?"; + return jdbcTemplate.queryForObject(sql, createRowMapper(), account); + } + + private static RowMapper createRowMapper() { + return (final var rs, final var i) -> new User( + rs.getLong("id"), + rs.getString("account"), + rs.getString("password"), + rs.getString("email")); + } +} diff --git a/study/src/main/java/aop/repository/UserHistoryDao.java b/study/src/main/java/aop/repository/UserHistoryDao.java new file mode 100644 index 0000000000..7c5a28d050 --- /dev/null +++ b/study/src/main/java/aop/repository/UserHistoryDao.java @@ -0,0 +1,27 @@ +package aop.repository; + +import aop.domain.UserHistory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +public class UserHistoryDao { + + private final JdbcTemplate jdbcTemplate; + + public UserHistoryDao(final JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public void log(final UserHistory userHistory) { + final var sql = "insert into user_history (user_id, account, password, email, created_at, created_by) values (?, ?, ?, ?, ?, ?)"; + jdbcTemplate.update(sql, + userHistory.getUserId(), + userHistory.getAccount(), + userHistory.getPassword(), + userHistory.getEmail(), + userHistory.getCreatedAt(), + userHistory.getCreateBy() + ); + } +} diff --git a/study/src/main/java/aop/service/AppUserService.java b/study/src/main/java/aop/service/AppUserService.java new file mode 100644 index 0000000000..cfa4de3b4a --- /dev/null +++ b/study/src/main/java/aop/service/AppUserService.java @@ -0,0 +1,36 @@ +package aop.service; + +import aop.Transactional; +import aop.domain.User; +import aop.domain.UserHistory; +import aop.repository.UserDao; +import aop.repository.UserHistoryDao; + +public class AppUserService implements UserService { + + private final UserDao userDao; + private final UserHistoryDao userHistoryDao; + + public AppUserService(final UserDao userDao, final UserHistoryDao userHistoryDao) { + this.userDao = userDao; + this.userHistoryDao = userHistoryDao; + } + + @Transactional + public User findById(final long id) { + return userDao.findById(id); + } + + @Transactional + public void insert(final User user) { + userDao.insert(user); + } + + @Transactional + public void changePassword(final long id, final String newPassword, final String createBy) { + final var user = findById(id); + user.changePassword(newPassword); + userDao.update(user); + userHistoryDao.log(new UserHistory(user, createBy)); + } +} diff --git a/study/src/main/java/aop/service/TxUserService.java b/study/src/main/java/aop/service/TxUserService.java new file mode 100644 index 0000000000..f3c65ddf6e --- /dev/null +++ b/study/src/main/java/aop/service/TxUserService.java @@ -0,0 +1,48 @@ +package aop.service; + +import aop.DataAccessException; +import aop.domain.User; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.DefaultTransactionDefinition; + +public class TxUserService implements UserService { + + private final PlatformTransactionManager transactionManager; + private final UserService userService; + + public TxUserService(final PlatformTransactionManager transactionManager, final UserService userService) { + this.transactionManager = transactionManager; + this.userService = userService; + } + + @Override + public User findById(final long id) { + return userService.findById(id); + } + + @Override + public void insert(final User user) { + userService.insert(user); + } + + @Override + public void changePassword(final long id, final String newPassword, final String createBy) { + /* ===== 트랜잭션 영역 ===== */ + final var transactionStatus = transactionManager.getTransaction(new DefaultTransactionDefinition()); + + try { + /* ===== 트랜잭션 영역 ===== */ + + /* ===== 애플리케이션 영역 ===== */ + userService.changePassword(id, newPassword, createBy); + /* ===== 애플리케이션 영역 ===== */ + + /* ===== 트랜잭션 영역 ===== */ + } catch (RuntimeException e) { + transactionManager.rollback(transactionStatus); + throw new DataAccessException(e); + } + transactionManager.commit(transactionStatus); + /* ===== 트랜잭션 영역 ===== */ + } +} diff --git a/study/src/main/java/aop/service/UserService.java b/study/src/main/java/aop/service/UserService.java new file mode 100644 index 0000000000..8ce24e21b4 --- /dev/null +++ b/study/src/main/java/aop/service/UserService.java @@ -0,0 +1,12 @@ +package aop.service; + + +import aop.domain.User; + +public interface UserService { + + User findById(final long id); + void insert(final User user); + + void changePassword(final long id, final String newPassword, final String createBy); +} diff --git a/study/src/main/java/connectionpool/App.java b/study/src/main/java/connectionpool/App.java new file mode 100644 index 0000000000..3deb1fde16 --- /dev/null +++ b/study/src/main/java/connectionpool/App.java @@ -0,0 +1,12 @@ +package connectionpool; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class App { + + public static void main(String[] args) { + SpringApplication.run(App.class, args); + } +} diff --git a/study/src/main/java/connectionpool/DataSourceConfig.java b/study/src/main/java/connectionpool/DataSourceConfig.java new file mode 100644 index 0000000000..1d2c3e6b7d --- /dev/null +++ b/study/src/main/java/connectionpool/DataSourceConfig.java @@ -0,0 +1,34 @@ +package connectionpool; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.sql.DataSource; + +@Configuration +public class DataSourceConfig { + + public static final int MAXIMUM_POOL_SIZE = 5; + private static final String H2_URL = "jdbc:h2:./test;DB_CLOSE_DELAY=-1"; + private static final String USER = "sa"; + private static final String PASSWORD = ""; + + + @Bean + public DataSource hikariDataSource() { + final var hikariConfig = new HikariConfig(); + hikariConfig.setPoolName("gugu"); + hikariConfig.setJdbcUrl(H2_URL); + hikariConfig.setUsername(USER); + hikariConfig.setPassword(PASSWORD); + hikariConfig.setMaximumPoolSize(MAXIMUM_POOL_SIZE); + hikariConfig.setConnectionTestQuery("VALUES 1"); + hikariConfig.addDataSourceProperty("cachePrepStmts", "true"); + hikariConfig.addDataSourceProperty("prepStmtCacheSize", "250"); + hikariConfig.addDataSourceProperty("prepStmtCacheSqlLimit", "2048"); + + return new HikariDataSource(hikariConfig); + } +} diff --git a/study/src/main/java/transaction/App.java b/study/src/main/java/transaction/App.java new file mode 100644 index 0000000000..49b07ffd2e --- /dev/null +++ b/study/src/main/java/transaction/App.java @@ -0,0 +1,12 @@ +package transaction; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class App { + + public static void main(String[] args) { + SpringApplication.run(App.class, args); + } +} diff --git a/study/src/main/java/transaction/ConsumerWrapper.java b/study/src/main/java/transaction/ConsumerWrapper.java new file mode 100644 index 0000000000..be9d1b9678 --- /dev/null +++ b/study/src/main/java/transaction/ConsumerWrapper.java @@ -0,0 +1,24 @@ +package transaction; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.function.Consumer; + +public final class ConsumerWrapper { + + private static final Logger log = LoggerFactory.getLogger(ConsumerWrapper.class); + + public static Consumer accept(ThrowingConsumer consumer) { + return i -> { + try { + consumer.accept(i); + } catch (Exception e) { + log.error(e.getMessage(), e.getCause()); + throw new RuntimeException(e); + } + }; + } + + private ConsumerWrapper() {} +} diff --git a/study/src/main/java/transaction/DatabasePopulatorUtils.java b/study/src/main/java/transaction/DatabasePopulatorUtils.java new file mode 100644 index 0000000000..84195470d9 --- /dev/null +++ b/study/src/main/java/transaction/DatabasePopulatorUtils.java @@ -0,0 +1,46 @@ +package transaction; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.sql.DataSource; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; + +public class DatabasePopulatorUtils { + + private static final Logger log = LoggerFactory.getLogger(DatabasePopulatorUtils.class); + + public static void execute(final DataSource dataSource) { + Connection connection = null; + Statement statement = null; + try { + final var url = DatabasePopulatorUtils.class.getClassLoader().getResource("schema.sql"); + final var file = new File(url.getFile()); + final var sql = Files.readString(file.toPath()); + connection = dataSource.getConnection(); + statement = connection.createStatement(); + statement.execute(sql); + } catch (NullPointerException | IOException | SQLException e) { + log.error(e.getMessage(), e.getCause()); + } finally { + try { + if (statement != null) { + statement.close(); + } + } catch (SQLException ignored) {} + + try { + if (connection != null) { + connection.close(); + } + } catch (SQLException ignored) {} + } + } + + private DatabasePopulatorUtils() {} +} diff --git a/study/src/main/java/transaction/FunctionWrapper.java b/study/src/main/java/transaction/FunctionWrapper.java new file mode 100644 index 0000000000..7e1e35fff7 --- /dev/null +++ b/study/src/main/java/transaction/FunctionWrapper.java @@ -0,0 +1,24 @@ +package transaction; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.function.Function; + +public class FunctionWrapper { + + private static final Logger log = LoggerFactory.getLogger(FunctionWrapper.class); + + public static Function apply(ThrowingFunction function) { + return i -> { + try { + return function.apply(i); + } catch (Exception e) { + log.error(e.getMessage(), e.getCause()); + throw new RuntimeException(e); + } + }; + } + + private FunctionWrapper() {} +} diff --git a/study/src/main/java/transaction/RunnableWrapper.java b/study/src/main/java/transaction/RunnableWrapper.java new file mode 100644 index 0000000000..67515998d2 --- /dev/null +++ b/study/src/main/java/transaction/RunnableWrapper.java @@ -0,0 +1,22 @@ +package transaction; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class RunnableWrapper { + + private static final Logger log = LoggerFactory.getLogger(RunnableWrapper.class); + + public static Runnable accept(ThrowingRunnable runnable) { + return () -> { + try { + runnable.run(); + } catch (Exception e) { + log.error(e.getMessage(), e.getCause()); + throw new RuntimeException(e); + } + }; + } + + private RunnableWrapper() {} +} diff --git a/study/src/main/java/transaction/ThrowingConsumer.java b/study/src/main/java/transaction/ThrowingConsumer.java new file mode 100644 index 0000000000..655f65e01d --- /dev/null +++ b/study/src/main/java/transaction/ThrowingConsumer.java @@ -0,0 +1,6 @@ +package transaction; + +@FunctionalInterface +public interface ThrowingConsumer { + void accept(T t) throws E; +} diff --git a/study/src/main/java/transaction/ThrowingFunction.java b/study/src/main/java/transaction/ThrowingFunction.java new file mode 100644 index 0000000000..466bed0279 --- /dev/null +++ b/study/src/main/java/transaction/ThrowingFunction.java @@ -0,0 +1,6 @@ +package transaction; + +@FunctionalInterface +public interface ThrowingFunction { + R apply(T t) throws E; +} diff --git a/study/src/main/java/transaction/ThrowingRunnable.java b/study/src/main/java/transaction/ThrowingRunnable.java new file mode 100644 index 0000000000..b121bac040 --- /dev/null +++ b/study/src/main/java/transaction/ThrowingRunnable.java @@ -0,0 +1,6 @@ +package transaction; + +@FunctionalInterface +public interface ThrowingRunnable { + void run() throws E; +} diff --git a/study/src/main/resources/application.yml b/study/src/main/resources/application.yml new file mode 100644 index 0000000000..b9451e1013 --- /dev/null +++ b/study/src/main/resources/application.yml @@ -0,0 +1,18 @@ +spring: + jpa: + open-in-view: false + show-sql: true + generate-ddl: true + hibernate: + ddl-auto: create-drop # 주의! 로컬 테스트에서만 사용한다. + datasource: + hikari: + jdbc-url: jdbc:h2:./test;DB_CLOSE_DELAY=-1;MODE=MYSQL; # TRACE_LEVEL_SYSTEM_OUT=3; # h2에서 출력하는 트랜잭션 로그 확인할 때 사용 + username: sa + password: + +# 스프링에서 출력하는 트랜잭션 로그를 직접 보고 싶으면 아래 주석 해제 +#logging: +# level: +# org.springframework.transaction.interceptor: TRACE +# org.springframework.transaction.support: DEBUG diff --git a/study/src/main/resources/schema.sql b/study/src/main/resources/schema.sql new file mode 100644 index 0000000000..bf9c44ac95 --- /dev/null +++ b/study/src/main/resources/schema.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS users ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + account VARCHAR(100) NOT NULL, + password VARCHAR(100) NOT NULL, + email VARCHAR(100) NOT NULL +) ENGINE=INNODB; + +CREATE TABLE IF NOT EXISTS user_history ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + account VARCHAR(100) NOT NULL, + password VARCHAR(100) NOT NULL, + email VARCHAR(100) NOT NULL, + created_at DATETIME NOT NULL, + created_by VARCHAR(100) NOT NULL +) ENGINE=INNODB; diff --git a/study/src/test/java/aop/StubUserHistoryDao.java b/study/src/test/java/aop/StubUserHistoryDao.java new file mode 100644 index 0000000000..c74f14a29c --- /dev/null +++ b/study/src/test/java/aop/StubUserHistoryDao.java @@ -0,0 +1,19 @@ +package aop; + +import aop.domain.UserHistory; +import aop.repository.UserHistoryDao; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +public class StubUserHistoryDao extends UserHistoryDao { + + public StubUserHistoryDao(final JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + public void log(final UserHistory userHistory) { + throw new DataAccessException(); + } +} diff --git a/study/src/test/java/aop/stage0/Stage0Test.java b/study/src/test/java/aop/stage0/Stage0Test.java new file mode 100644 index 0000000000..079cc6b5a0 --- /dev/null +++ b/study/src/test/java/aop/stage0/Stage0Test.java @@ -0,0 +1,73 @@ +package aop.stage0; + +import aop.DataAccessException; +import aop.StubUserHistoryDao; +import aop.domain.User; +import aop.repository.UserDao; +import aop.repository.UserHistoryDao; +import aop.service.AppUserService; +import aop.service.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.PlatformTransactionManager; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +class Stage0Test { + + private static final Logger log = LoggerFactory.getLogger(Stage0Test.class); + + @Autowired + private UserDao userDao; + + @Autowired + private UserHistoryDao userHistoryDao; + + @Autowired + private StubUserHistoryDao stubUserHistoryDao; + + @Autowired + private PlatformTransactionManager platformTransactionManager; + + @BeforeEach + void setUp() { + final var user = new User("gugu", "password", "hkkang@woowahan.com"); + userDao.insert(user); + } + + @Test + void testChangePassword() { + final var appUserService = new AppUserService(userDao, userHistoryDao); + final UserService userService = null; + + final var newPassword = "qqqqq"; + final var createBy = "gugu"; + userService.changePassword(1L, newPassword, createBy); + + final var actual = userService.findById(1L); + + assertThat(actual.getPassword()).isEqualTo(newPassword); + } + + @Test + void testTransactionRollback() { + final var appUserService = new AppUserService(userDao, stubUserHistoryDao); + final UserService userService = null; + + final var newPassword = "newPassword"; + final var createBy = "gugu"; + assertThrows(DataAccessException.class, + () -> userService.changePassword(1L, newPassword, createBy)); + + final var actual = userService.findById(1L); + + assertThat(actual.getPassword()).isNotEqualTo(newPassword); + } +} diff --git a/study/src/test/java/aop/stage0/TransactionHandler.java b/study/src/test/java/aop/stage0/TransactionHandler.java new file mode 100644 index 0000000000..2aa8445aba --- /dev/null +++ b/study/src/test/java/aop/stage0/TransactionHandler.java @@ -0,0 +1,15 @@ +package aop.stage0; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; + +public class TransactionHandler implements InvocationHandler { + + /** + * @Transactional 어노테이션이 존재하는 메서드만 트랜잭션 기능을 적용하도록 만들어보자. + */ + @Override + public Object invoke(final Object proxy, final Method method, final Object[] args) throws Throwable { + return null; + } +} diff --git a/study/src/test/java/aop/stage1/Stage1Test.java b/study/src/test/java/aop/stage1/Stage1Test.java new file mode 100644 index 0000000000..113b2e7d03 --- /dev/null +++ b/study/src/test/java/aop/stage1/Stage1Test.java @@ -0,0 +1,68 @@ +package aop.stage1; + +import aop.DataAccessException; +import aop.StubUserHistoryDao; +import aop.domain.User; +import aop.repository.UserDao; +import aop.repository.UserHistoryDao; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.PlatformTransactionManager; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class Stage1Test { + + private static final Logger log = LoggerFactory.getLogger(Stage1Test.class); + + @Autowired + private UserDao userDao; + + @Autowired + private UserHistoryDao userHistoryDao; + + @Autowired + private StubUserHistoryDao stubUserHistoryDao; + + @Autowired + private PlatformTransactionManager platformTransactionManager; + + @BeforeEach + void setUp() { + final var user = new User("gugu", "password", "hkkang@woowahan.com"); + userDao.insert(user); + } + + @Test + void testChangePassword() { + final UserService userService = null; + + final var newPassword = "qqqqq"; + final var createBy = "gugu"; + userService.changePassword(1L, newPassword, createBy); + + final var actual = userService.findById(1L); + + assertThat(actual.getPassword()).isEqualTo(newPassword); + } + + @Test + void testTransactionRollback() { + final UserService userService = null; + + final var newPassword = "newPassword"; + final var createBy = "gugu"; + assertThrows(DataAccessException.class, + () -> userService.changePassword(1L, newPassword, createBy)); + + final var actual = userService.findById(1L); + + assertThat(actual.getPassword()).isNotEqualTo(newPassword); + } +} diff --git a/study/src/test/java/aop/stage1/TransactionAdvice.java b/study/src/test/java/aop/stage1/TransactionAdvice.java new file mode 100644 index 0000000000..03a03a84e5 --- /dev/null +++ b/study/src/test/java/aop/stage1/TransactionAdvice.java @@ -0,0 +1,15 @@ +package aop.stage1; + +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; + +/** + * 어드바이스(advice). 부가기능을 담고 있는 클래스 + */ +public class TransactionAdvice implements MethodInterceptor { + + @Override + public Object invoke(final MethodInvocation invocation) throws Throwable { + return null; + } +} diff --git a/study/src/test/java/aop/stage1/TransactionAdvisor.java b/study/src/test/java/aop/stage1/TransactionAdvisor.java new file mode 100644 index 0000000000..7abc27516c --- /dev/null +++ b/study/src/test/java/aop/stage1/TransactionAdvisor.java @@ -0,0 +1,27 @@ +package aop.stage1; + +import org.aopalliance.aop.Advice; +import org.springframework.aop.Pointcut; +import org.springframework.aop.PointcutAdvisor; + +/** + * 어드바이저(advisor). 포인트컷과 어드바이스를 하나씩 갖고 있는 객체. + * AOP의 애스팩트(aspect)에 해당되는 클래스다. + */ +public class TransactionAdvisor implements PointcutAdvisor { + + @Override + public Pointcut getPointcut() { + return null; + } + + @Override + public Advice getAdvice() { + return null; + } + + @Override + public boolean isPerInstance() { + return false; + } +} diff --git a/study/src/test/java/aop/stage1/TransactionPointcut.java b/study/src/test/java/aop/stage1/TransactionPointcut.java new file mode 100644 index 0000000000..29ff854890 --- /dev/null +++ b/study/src/test/java/aop/stage1/TransactionPointcut.java @@ -0,0 +1,19 @@ +package aop.stage1; + +import org.springframework.aop.support.StaticMethodMatcherPointcut; + +import java.lang.reflect.Method; + +/** + * 포인트컷(pointcut). 어드바이스를 적용할 조인 포인트를 선별하는 클래스. + * TransactionPointcut 클래스는 메서드를 대상으로 조인 포인트를 찾는다. + * + * 조인 포인트(join point). 어드바이스가 적용될 위치 + */ +public class TransactionPointcut extends StaticMethodMatcherPointcut { + + @Override + public boolean matches(final Method method, final Class targetClass) { + return false; + } +} diff --git a/study/src/test/java/aop/stage1/UserService.java b/study/src/test/java/aop/stage1/UserService.java new file mode 100644 index 0000000000..5a12b12f63 --- /dev/null +++ b/study/src/test/java/aop/stage1/UserService.java @@ -0,0 +1,36 @@ +package aop.stage1; + +import aop.Transactional; +import aop.domain.User; +import aop.domain.UserHistory; +import aop.repository.UserDao; +import aop.repository.UserHistoryDao; + +public class UserService { + + private final UserDao userDao; + private final UserHistoryDao userHistoryDao; + + public UserService(final UserDao userDao, final UserHistoryDao userHistoryDao) { + this.userDao = userDao; + this.userHistoryDao = userHistoryDao; + } + + @Transactional + public User findById(final long id) { + return userDao.findById(id); + } + + @Transactional + public void insert(final User user) { + userDao.insert(user); + } + + @Transactional + public void changePassword(final long id, final String newPassword, final String createBy) { + final var user = findById(id); + user.changePassword(newPassword); + userDao.update(user); + userHistoryDao.log(new UserHistory(user, createBy)); + } +} diff --git a/study/src/test/java/aop/stage2/AopConfig.java b/study/src/test/java/aop/stage2/AopConfig.java new file mode 100644 index 0000000000..0a6e7f124e --- /dev/null +++ b/study/src/test/java/aop/stage2/AopConfig.java @@ -0,0 +1,8 @@ +package aop.stage2; + +import org.springframework.context.annotation.Configuration; + +@Configuration +public class AopConfig { + +} diff --git a/study/src/test/java/aop/stage2/Stage2Test.java b/study/src/test/java/aop/stage2/Stage2Test.java new file mode 100644 index 0000000000..ae46641880 --- /dev/null +++ b/study/src/test/java/aop/stage2/Stage2Test.java @@ -0,0 +1,57 @@ +package aop.stage2; + +import aop.DataAccessException; +import aop.StubUserHistoryDao; +import aop.domain.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class Stage2Test { + + private static final Logger log = LoggerFactory.getLogger(Stage2Test.class); + + @Autowired + private UserService userService; + + @Autowired + private StubUserHistoryDao stubUserHistoryDao; + + @BeforeEach + void setUp() { + final var user = new User("gugu", "password", "hkkang@woowahan.com"); + userService.insert(user); + } + + @Test + void testChangePassword() { + final var newPassword = "qqqqq"; + final var createBy = "gugu"; + userService.changePassword(1L, newPassword, createBy); + + final var actual = userService.findById(1L); + + assertThat(actual.getPassword()).isEqualTo(newPassword); + } + + @Test + void testTransactionRollback() { + userService.setUserHistoryDao(stubUserHistoryDao); + + final var newPassword = "newPassword"; + final var createBy = "gugu"; + assertThrows(DataAccessException.class, + () -> userService.changePassword(1L, newPassword, createBy)); + + final var actual = userService.findById(1L); + + assertThat(actual.getPassword()).isNotEqualTo(newPassword); + } +} diff --git a/study/src/test/java/aop/stage2/UserService.java b/study/src/test/java/aop/stage2/UserService.java new file mode 100644 index 0000000000..d76731659d --- /dev/null +++ b/study/src/test/java/aop/stage2/UserService.java @@ -0,0 +1,42 @@ +package aop.stage2; + +import aop.Transactional; +import aop.domain.User; +import aop.domain.UserHistory; +import aop.repository.UserDao; +import aop.repository.UserHistoryDao; +import org.springframework.stereotype.Service; + +@Service +public class UserService { + + private final UserDao userDao; + private UserHistoryDao userHistoryDao; + + public UserService(final UserDao userDao, final UserHistoryDao userHistoryDao) { + this.userDao = userDao; + this.userHistoryDao = userHistoryDao; + } + + @Transactional + public User findById(final long id) { + return userDao.findById(id); + } + + @Transactional + public void insert(final User user) { + userDao.insert(user); + } + + @Transactional + public void changePassword(final long id, final String newPassword, final String createBy) { + final var user = findById(id); + user.changePassword(newPassword); + userDao.update(user); + userHistoryDao.log(new UserHistory(user, createBy)); + } + + public void setUserHistoryDao(final UserHistoryDao userHistoryDao) { + this.userHistoryDao = userHistoryDao; + } +} diff --git a/study/src/test/java/connectionpool/PoolingVsNoPoolingTest.java b/study/src/test/java/connectionpool/PoolingVsNoPoolingTest.java new file mode 100644 index 0000000000..20942cca11 --- /dev/null +++ b/study/src/test/java/connectionpool/PoolingVsNoPoolingTest.java @@ -0,0 +1,118 @@ +package connectionpool; + +import com.mysql.cj.jdbc.MysqlDataSource; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import com.zaxxer.hikari.util.ClockSource; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.utility.DockerImageName; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +/** + * pooling을 사용한 경우와 사용하지 않은 경우 트래픽이 얼마나 차이나는지 확인해보자. + * + * network bandwidth capture + * 터미널에 iftop를 설치하고 아래 명령어를 실행한 상태에서 테스트를 실행하자. + * $ sudo iftop -i lo0 -nf "host localhost" + * windows 사용자라면 wsl2를 사용하거나 다른 모니터링 툴을 찾아보자. + */ +class PoolingVsNoPoolingTest { + + private final Logger log = LoggerFactory.getLogger(PoolingVsNoPoolingTest.class); + + private static final int COUNT = 1000; + + private static MySQLContainer container; + + @BeforeAll + static void beforeAll() throws SQLException { + // TestContainer로 임시 MySQL을 실행한다. + container = new MySQLContainer<>(DockerImageName.parse("mysql:8.0.30")) + .withDatabaseName("test"); + container.start(); + + final var dataSource = createMysqlDataSource(); + + // 테스트에 사용할 users 테이블을 생성하고 데이터를 추가한다. + try (Connection conn = dataSource.getConnection()) { + conn.setAutoCommit(true); + try (Statement stmt = conn.createStatement()) { + stmt.execute("DROP TABLE IF EXISTS users;"); + stmt.execute("CREATE TABLE IF NOT EXISTS users (id INT AUTO_INCREMENT PRIMARY KEY, email VARCHAR(100) NOT NULL) ENGINE=INNODB;"); + stmt.executeUpdate("INSERT INTO users (email) VALUES ('hkkang@woowahan.com')"); + conn.setAutoCommit(false); + } + } + } + + @AfterAll + static void afterAll() { + container.stop(); + } + + @Test + void noPoling() throws SQLException { + final var dataSource = createMysqlDataSource(); + + long start = ClockSource.currentTime(); + connect(dataSource); + long end = ClockSource.currentTime(); + + // 테스트 결과를 확인한다. + log.info("Elapsed runtime: {}", ClockSource.elapsedDisplayString(start, end)); + } + + @Test + void pooling() throws SQLException { + final var config = new HikariConfig(); + config.setJdbcUrl(container.getJdbcUrl()); + config.setUsername(container.getUsername()); + config.setPassword(container.getPassword()); + config.setMinimumIdle(1); + config.setMaximumPoolSize(1); + config.setConnectionTimeout(1000); + config.setAutoCommit(false); + config.setReadOnly(false); + final var hikariDataSource = new HikariDataSource(config); + + long start = ClockSource.currentTime(); + connect(hikariDataSource); + long end = ClockSource.currentTime(); + + // 테스트 결과를 확인한다. + log.info("Elapsed runtime: {}", ClockSource.elapsedDisplayString(start, end)); + } + + private static void connect(DataSource dataSource) throws SQLException { + // COUNT만큼 DB 연결을 수행한다. + for (int i = 0; i < COUNT; i++) { + try (Connection connection = dataSource.getConnection()) { + try (Statement stmt = connection.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT * FROM users")) { + if (rs.next()) { + rs.getString(1).hashCode(); + } + } + } + } + } + + private static MysqlDataSource createMysqlDataSource() throws SQLException { + final var dataSource = new MysqlDataSource(); + dataSource.setUrl(container.getJdbcUrl()); + dataSource.setUser(container.getUsername()); + dataSource.setPassword(container.getPassword()); + dataSource.setConnectTimeout(1000); + return dataSource; + } +} diff --git a/study/src/test/java/connectionpool/stage0/Stage0Test.java b/study/src/test/java/connectionpool/stage0/Stage0Test.java new file mode 100644 index 0000000000..7eb3d7d87d --- /dev/null +++ b/study/src/test/java/connectionpool/stage0/Stage0Test.java @@ -0,0 +1,62 @@ +package connectionpool.stage0; + +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; + +import static org.assertj.core.api.Assertions.assertThat; + +class Stage0Test { + + private static final String H2_URL = "jdbc:h2:./test"; + private static final String USER = "sa"; + private static final String PASSWORD = ""; + + /** + * DriverManager + * JDBC 드라이버를 관리하는 가장 기본적인 방법. + * 커넥션 풀, 분산 트랜잭션을 지원하지 않아서 잘 사용하지 않는다. + * + * JDBC 4.0 이전에는 Class.forName 메서드를 사용하여 JDBC 드라이버를 직접 등록해야 했다. + * JDBC 4.0 부터 DriverManager가 적절한 JDBC 드라이버를 찾는다. + * + * Autoloading of JDBC drivers + * https://docs.oracle.com/javadb/10.8.3.0/ref/rrefjdbc4_0summary.html + */ + @Test + void driverManager() throws SQLException { + // Class.forName("org.h2.Driver"); // JDBC 4.0 부터 생략 가능 + // DriverManager 클래스를 활용하여 static 변수의 정보를 활용하여 h2 db에 연결한다. + try (final Connection connection = DriverManager.getConnection(H2_URL, USER, PASSWORD)) { + assertThat(connection.isValid(1)).isTrue(); + } + } + + /** + * DataSource + * 데이터베이스, 파일 같은 물리적 데이터 소스에 연결할 때 사용하는 인터페이스. + * 구현체는 각 vendor에서 제공한다. + * 테스트 코드의 JdbcDataSource 클래스는 h2에서 제공하는 클래스다. + * + * DirverManager가 아닌 DataSource를 사용하는 이유 + * - 애플리케이션 코드를 직접 수정하지 않고 properties로 디비 연결을 변경할 수 있다. + * - 커넥션 풀링(Connection pooling) 또는 분산 트랜잭션은 DataSource를 통해서 사용 가능하다. + * + * Using a DataSource Object to Make a Connection + * https://docs.oracle.com/en/java/javase/11/docs/api/java.sql/javax/sql/package-summary.html + */ + @Test + void dataSource() throws SQLException { + final JdbcDataSource dataSource = new JdbcDataSource(); + dataSource.setURL(H2_URL); + dataSource.setUser(USER); + dataSource.setPassword(PASSWORD); + + try (final var connection = dataSource.getConnection()) { + assertThat(connection.isValid(1)).isTrue(); + } + } +} diff --git a/study/src/test/java/connectionpool/stage1/Stage1Test.java b/study/src/test/java/connectionpool/stage1/Stage1Test.java new file mode 100644 index 0000000000..087485ff58 --- /dev/null +++ b/study/src/test/java/connectionpool/stage1/Stage1Test.java @@ -0,0 +1,82 @@ +package connectionpool.stage1; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import org.h2.jdbcx.JdbcConnectionPool; +import org.junit.jupiter.api.Test; + +import java.sql.SQLException; + +import static org.assertj.core.api.Assertions.assertThat; + +class Stage1Test { + + private static final String H2_URL = "jdbc:h2:./test;DB_CLOSE_DELAY=-1"; + private static final String USER = "sa"; + private static final String PASSWORD = ""; + + /** + * 커넥션 풀링(Connection Pooling)이란? + * DataSource 객체를 통해 미리 커넥션(Connection)을 만들어 두는 것을 의미한다. + * 새로운 커넥션을 생성하는 것은 많은 비용이 들기에 미리 커넥션을 만들어두면 성능상 이점이 있다. + * 커넥션 풀링에 미리 만들어둔 커넥션은 재사용 가능하다. + * + * h2에서 제공하는 JdbcConnectionPool를 다뤄보며 커넥션 풀에 대한 감을 잡아보자. + * + * Connection Pooling and Statement Pooling + * https://docs.oracle.com/en/java/javase/11/docs/api/java.sql/javax/sql/package-summary.html + */ + @Test + void testJdbcConnectionPool() throws SQLException { + final JdbcConnectionPool jdbcConnectionPool = JdbcConnectionPool.create(H2_URL, USER, PASSWORD); + + assertThat(jdbcConnectionPool.getActiveConnections()).isZero(); + try (final var connection = jdbcConnectionPool.getConnection()) { + assertThat(connection.isValid(1)).isTrue(); + assertThat(jdbcConnectionPool.getActiveConnections()).isEqualTo(1); + } + assertThat(jdbcConnectionPool.getActiveConnections()).isZero(); + + jdbcConnectionPool.dispose(); + } + + /** + * Spring Boot 2.0 부터 HikariCP를 기본 데이터 소스로 채택하고 있다. + * https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#data.sql.datasource.connection-pool + * Supported Connection Pools + * We prefer HikariCP for its performance and concurrency. If HikariCP is available, we always choose it. + * + * HikariCP 공식 문서를 참고하여 HikariCP를 설정해보자. + * https://github.com/brettwooldridge/HikariCP#rocket-initialization + * + * HikariCP 필수 설정 + * https://github.com/brettwooldridge/HikariCP#essentials + * + * HikariCP의 pool size는 몇으로 설정하는게 좋을까? + * https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing + * + * HikariCP를 사용할 때 적용하면 좋은 MySQL 설정 + * https://github.com/brettwooldridge/HikariCP/wiki/MySQL-Configuration + */ + @Test + void testHikariCP() { + final var hikariConfig = new HikariConfig(); + hikariConfig.setJdbcUrl(H2_URL); + hikariConfig.setUsername(USER); + hikariConfig.setPassword(PASSWORD); + hikariConfig.setMaximumPoolSize(5); + hikariConfig.addDataSourceProperty("cachePrepStmts", "true"); + hikariConfig.addDataSourceProperty("prepStmtCacheSize", "250"); + hikariConfig.addDataSourceProperty("prepStmtCacheSqlLimit", "2048"); + + final var dataSource = new HikariDataSource(hikariConfig); + final var properties = dataSource.getDataSourceProperties(); + + assertThat(dataSource.getMaximumPoolSize()).isEqualTo(5); + assertThat(properties.getProperty("cachePrepStmts")).isEqualTo("true"); + assertThat(properties.getProperty("prepStmtCacheSize")).isEqualTo("250"); + assertThat(properties.getProperty("prepStmtCacheSqlLimit")).isEqualTo("2048"); + + dataSource.close(); + } +} diff --git a/study/src/test/java/connectionpool/stage2/Stage2Test.java b/study/src/test/java/connectionpool/stage2/Stage2Test.java new file mode 100644 index 0000000000..2ed6b075b5 --- /dev/null +++ b/study/src/test/java/connectionpool/stage2/Stage2Test.java @@ -0,0 +1,84 @@ +package connectionpool.stage2; + +import com.zaxxer.hikari.HikariDataSource; +import com.zaxxer.hikari.pool.HikariPool; +import connectionpool.DataSourceConfig; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import javax.sql.DataSource; +import java.lang.reflect.Field; +import java.sql.Connection; + +import static com.zaxxer.hikari.util.UtilityElf.quietlySleep; +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class Stage2Test { + + private static final Logger log = LoggerFactory.getLogger(Stage2Test.class); + + /** + * spring boot에서 설정 파일인 application.yml를 사용하여 DataSource를 설정할 수 있다. + * 하지만 DataSource를 여러 개 사용하거나 세부 설정을 하려면 빈을 직접 생성하는 방법을 사용한다. + * DataSourceConfig 클래스를 찾아서 어떻게 빈으로 직접 생성하는지 확인해보자. + * 그리고 아래 DataSource가 직접 생성한 빈으로 주입 받았는지 getPoolName() 메서드로 확인해보자. + */ + @Autowired + private DataSource dataSource; + + @Test + void test() throws InterruptedException { + final var hikariDataSource = (HikariDataSource) dataSource; + final var hikariPool = getPool((HikariDataSource) dataSource); + + // 설정한 커넥션 풀 최대값보다 더 많은 스레드를 생성해서 동시에 디비에 접근을 시도하면 어떻게 될까? + final var threads = new Thread[20]; + for (int i = 0; i < threads.length; i++) { + threads[i] = new Thread(getConnection()); + } + + for (final var thread : threads) { + thread.start(); + } + + for (final var thread : threads) { + thread.join(); + } + + // 동시에 많은 요청이 몰려도 최대 풀 사이즈를 유지한다. + assertThat(hikariPool.getTotalConnections()).isEqualTo(DataSourceConfig.MAXIMUM_POOL_SIZE); + + // DataSourceConfig 클래스에서 직접 생성한 커넥션 풀. + assertThat(hikariDataSource.getPoolName()).isEqualTo("gugu"); + } + + // 데이터베이스에 연결만 하는 메서드. 커넥션 풀에 몇 개의 연결이 생기는지 확인하는 용도. + private Runnable getConnection() { + return () -> { + try { + log.info("Before acquire "); + try (Connection ignored = dataSource.getConnection()) { + log.info("After acquire "); + quietlySleep(500); // Thread.sleep(500)과 동일한 기능 + } + } catch (Exception e) { + } + }; + } + + // 학습 테스트를 위해 HikariPool을 추출 + public static HikariPool getPool(final HikariDataSource hikariDataSource) + { + try { + Field field = hikariDataSource.getClass().getDeclaredField("pool"); + field.setAccessible(true); + return (HikariPool) field.get(hikariDataSource); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/study/src/test/java/transaction/stage1/Stage1Test.java b/study/src/test/java/transaction/stage1/Stage1Test.java new file mode 100644 index 0000000000..8c29944d4e --- /dev/null +++ b/study/src/test/java/transaction/stage1/Stage1Test.java @@ -0,0 +1,265 @@ +package transaction.stage1; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.JdbcDatabaseContainer; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.utility.DockerImageName; +import transaction.DatabasePopulatorUtils; +import transaction.RunnableWrapper; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 격리 레벨(Isolation Level)에 따라 여러 사용자가 동시에 db에 접근했을 때 어떤 문제가 발생하는지 확인해보자. + * ❗phantom reads는 docker를 실행한 상태에서 테스트를 실행한다. + * ❗phantom reads는 MySQL로 확인한다. H2 데이터베이스에서는 발생하지 않는다. + * + * 참고 링크 + * https://en.wikipedia.org/wiki/Isolation_(database_systems) + * + * 각 테스트에서 어떤 현상이 발생하는지 직접 경험해보고 아래 표를 채워보자. + * + : 발생 + * - : 발생하지 않음 + * Read phenomena | Dirty reads | Non-repeatable reads | Phantom reads + * Isolation level | | | + * -----------------|-------------|----------------------|-------------- + * Read Uncommitted | | | + * Read Committed | | | + * Repeatable Read | | | + * Serializable | | | + */ +class Stage1Test { + + private static final Logger log = LoggerFactory.getLogger(Stage1Test.class); + private DataSource dataSource; + private UserDao userDao; + + private void setUp(final DataSource dataSource) { + this.dataSource = dataSource; + DatabasePopulatorUtils.execute(dataSource); + this.userDao = new UserDao(dataSource); + } + + /** + * 격리 수준에 따라 어떤 현상이 발생하는지 테스트를 돌려 직접 눈으로 확인하고 표를 채워보자. + * + : 발생 + * - : 발생하지 않음 + * Read phenomena | Dirty reads + * Isolation level | + * -----------------|------------- + * Read Uncommitted | + * Read Committed | + * Repeatable Read | + * Serializable | + */ + @Test + void dirtyReading() throws SQLException { + setUp(createH2DataSource()); + + // db에 새로운 연결(사용자A)을 받아와서 + final var connection = dataSource.getConnection(); + + // 트랜잭션을 시작한다. + connection.setAutoCommit(false); + + // db에 데이터를 추가하고 커밋하기 전에 + userDao.insert(connection, new User("gugu", "password", "hkkang@woowahan.com")); + + new Thread(RunnableWrapper.accept(() -> { + // db에 connection(사용자A)이 아닌 새로운 연결인 subConnection(사용자B)을 받아온다. + final var subConnection = dataSource.getConnection(); + + // 적절한 격리 레벨을 찾는다. + final int isolationLevel = Connection.TRANSACTION_NONE; + + // 트랜잭션 격리 레벨을 설정한다. + subConnection.setTransactionIsolation(isolationLevel); + + // ❗️gugu 객체는 connection에서 아직 커밋하지 않은 상태다. + // 격리 레벨에 따라 커밋하지 않은 gugu 객체를 조회할 수 있다. + // 사용자B가 사용자A가 커밋하지 않은 데이터를 조회하는게 적절할까? + final var actual = userDao.findByAccount(subConnection, "gugu"); + + // 트랜잭션 격리 레벨에 따라 아래 테스트가 통과한다. + // 어떤 격리 레벨일 때 다른 연결의 커밋 전 데이터를 조회할 수 있을지 찾아보자. + // 다른 격리 레벨은 어떤 결과가 나오는지 직접 확인해보자. + log.info("isolation level : {}, user : {}", isolationLevel, actual); + assertThat(actual).isNull(); + })).start(); + + sleep(0.5); + + // 롤백하면 사용자A의 user 데이터를 저장하지 않았는데 사용자B는 user 데이터가 있다고 인지한 상황이 된다. + connection.rollback(); + } + + /** + * 격리 수준에 따라 어떤 현상이 발생하는지 테스트를 돌려 직접 눈으로 확인하고 표를 채워보자. + * + : 발생 + * - : 발생하지 않음 + * Read phenomena | Non-repeatable reads + * Isolation level | + * -----------------|--------------------- + * Read Uncommitted | + * Read Committed | + * Repeatable Read | + * Serializable | + */ + @Test + void noneRepeatable() throws SQLException { + setUp(createH2DataSource()); + + // 테스트 전에 필요한 데이터를 추가한다. + userDao.insert(dataSource.getConnection(), new User("gugu", "password", "hkkang@woowahan.com")); + + // db에 새로운 연결(사용자A)을 받아와서 + final var connection = dataSource.getConnection(); + + // 트랜잭션을 시작한다. + connection.setAutoCommit(false); + + // 적절한 격리 레벨을 찾는다. + final int isolationLevel = Connection.TRANSACTION_NONE; + + // 트랜잭션 격리 레벨을 설정한다. + connection.setTransactionIsolation(isolationLevel); + + // 사용자A가 gugu 객체를 조회했다. + final var user = userDao.findByAccount(connection, "gugu"); + log.info("user : {}", user); + + new Thread(RunnableWrapper.accept(() -> { + // 사용자B가 새로 연결하여 + final var subConnection = dataSource.getConnection(); + + // 사용자A가 조회한 gugu 객체를 사용자B가 다시 조회했다. + final var anotherUser = userDao.findByAccount(subConnection, "gugu"); + + // ❗사용자B가 gugu 객체의 비밀번호를 변경했다.(subConnection은 auto commit 상태) + anotherUser.changePassword("qqqq"); + userDao.update(subConnection, anotherUser); + })).start(); + + sleep(0.5); + + // 사용자A가 다시 gugu 객체를 조회했다. + // 사용자B는 패스워드를 변경하고 아직 커밋하지 않았다. + final var actual = userDao.findByAccount(connection, "gugu"); + + // 트랜잭션 격리 레벨에 따라 아래 테스트가 통과한다. + // 각 격리 레벨은 어떤 결과가 나오는지 직접 확인해보자. + log.info("isolation level : {}, user : {}", isolationLevel, actual); + assertThat(actual.getPassword()).isEqualTo("password"); + + connection.rollback(); + } + + /** + * phantom read는 h2에서 발생하지 않는다. mysql로 확인해보자. + * 격리 수준에 따라 어떤 현상이 발생하는지 테스트를 돌려 직접 눈으로 확인하고 표를 채워보자. + * + : 발생 + * - : 발생하지 않음 + * Read phenomena | Phantom reads + * Isolation level | + * -----------------|-------------- + * Read Uncommitted | + * Read Committed | + * Repeatable Read | + * Serializable | + */ + @Test + void phantomReading() throws SQLException { + + // testcontainer로 docker를 실행해서 mysql에 연결한다. + final var mysql = new MySQLContainer<>(DockerImageName.parse("mysql:8.0.30")) + .withLogConsumer(new Slf4jLogConsumer(log)); + mysql.start(); + setUp(createMySQLDataSource(mysql)); + + // 테스트 전에 필요한 데이터를 추가한다. + userDao.insert(dataSource.getConnection(), new User("gugu", "password", "hkkang@woowahan.com")); + + // db에 새로운 연결(사용자A)을 받아와서 + final var connection = dataSource.getConnection(); + + // 트랜잭션을 시작한다. + connection.setAutoCommit(false); + + // 적절한 격리 레벨을 찾는다. + final int isolationLevel = Connection.TRANSACTION_NONE; + + // 트랜잭션 격리 레벨을 설정한다. + connection.setTransactionIsolation(isolationLevel); + + // 사용자A가 id로 범위를 조회했다. + userDao.findGreaterThan(connection, 1); + + new Thread(RunnableWrapper.accept(() -> { + // 사용자B가 새로 연결하여 + final var subConnection = dataSource.getConnection(); + + // 트랜잭션 시작 + subConnection.setAutoCommit(false); + + // 새로운 user 객체를 저장했다. + // id는 2로 저장된다. + userDao.insert(subConnection, new User("bird", "password", "bird@woowahan.com")); + + subConnection.commit(); + })).start(); + + sleep(0.5); + + // MySQL에서 팬텀 읽기를 시연하려면 update를 실행해야 한다. + // http://stackoverflow.com/questions/42794425/unable-to-produce-a-phantom-read/42796969#42796969 + userDao.updatePasswordGreaterThan(connection, "qqqq", 1); + + // 사용자A가 다시 id로 범위를 조회했다. + final var actual = userDao.findGreaterThan(connection, 1); + + // 트랜잭션 격리 레벨에 따라 아래 테스트가 통과한다. + // 각 격리 레벨은 어떤 결과가 나오는지 직접 확인해보자. + log.info("isolation level : {}, user : {}", isolationLevel, actual); + assertThat(actual).hasSize(1); + + connection.rollback(); + mysql.close(); + } + + private static DataSource createMySQLDataSource(final JdbcDatabaseContainer container) { + final var config = new HikariConfig(); + config.setJdbcUrl(container.getJdbcUrl()); + config.setUsername(container.getUsername()); + config.setPassword(container.getPassword()); + config.setDriverClassName(container.getDriverClassName()); + return new HikariDataSource(config); + } + + private static DataSource createH2DataSource() { + final var jdbcDataSource = new JdbcDataSource(); + // h2 로그를 확인하고 싶을 때 사용 +// jdbcDataSource.setUrl("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;TRACE_LEVEL_SYSTEM_OUT=3;MODE=MYSQL"); + jdbcDataSource.setUrl("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;MODE=MYSQL;"); + jdbcDataSource.setUser("sa"); + jdbcDataSource.setPassword(""); + return jdbcDataSource; + } + + private void sleep(double seconds) { + try { + TimeUnit.MILLISECONDS.sleep((long) (seconds * 1000)); + } catch (InterruptedException ignored) { + } + } +} diff --git a/study/src/test/java/transaction/stage1/User.java b/study/src/test/java/transaction/stage1/User.java new file mode 100644 index 0000000000..c2036720db --- /dev/null +++ b/study/src/test/java/transaction/stage1/User.java @@ -0,0 +1,56 @@ +package transaction.stage1; + +public class User { + + private Long id; + private final String account; + private String password; + private final String email; + + public User(long id, String account, String password, String email) { + this.id = id; + this.account = account; + this.password = password; + this.email = email; + } + + public User(String account, String password, String email) { + this.account = account; + this.password = password; + this.email = email; + } + + public boolean checkPassword(String password) { + return this.password.equals(password); + } + + public void changePassword(String password) { + this.password = password; + } + + public String getAccount() { + return account; + } + + public long getId() { + return id; + } + + public String getEmail() { + return email; + } + + public String getPassword() { + return password; + } + + @Override + public String toString() { + return "User{" + + "id=" + id + + ", account='" + account + '\'' + + ", email='" + email + '\'' + + ", password='" + password + '\'' + + '}'; + } +} diff --git a/study/src/test/java/transaction/stage1/UserDao.java b/study/src/test/java/transaction/stage1/UserDao.java new file mode 100644 index 0000000000..dad7915cba --- /dev/null +++ b/study/src/test/java/transaction/stage1/UserDao.java @@ -0,0 +1,66 @@ +package transaction.stage1; + +import transaction.stage1.jdbc.JdbcTemplate; +import transaction.stage1.jdbc.RowMapper; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.util.List; + +public class UserDao { + + // spring jdbc가 아닌 직접 구현한 JdbcTemplate을 사용한다. + private final JdbcTemplate jdbcTemplate; + + public UserDao(final DataSource dataSource) { + this.jdbcTemplate = new JdbcTemplate(dataSource); + } + + public void insert(final Connection connection, final User user) { + final var sql = "insert into users (account, password, email) values (?, ?, ?)"; + jdbcTemplate.update(connection, sql, user.getAccount(), user.getPassword(), user.getEmail()); + } + + public void update(final Connection connection, final User user) { + final var sql = "update users set account = ?, password = ?, email = ? where id = ?"; + jdbcTemplate.update(connection, sql, user.getAccount(), user.getPassword(), user.getEmail(), user.getId()); + } + + public void updatePasswordGreaterThan(final Connection connection, final String password, final long id) { + final var sql = "update users set password = ? where id >= ?"; + jdbcTemplate.update(connection, sql, password, id); + } + + public User findById(final Connection connection, final Long id) { + final var sql = "select id, account, password, email from users where id = ?"; + return jdbcTemplate.queryForObject(connection, sql, createRowMapper(), id); + } + + public User findByAccount(final Connection connection, final String account) { + final var sql = "select id, account, password, email from users where account = ?"; + return jdbcTemplate.queryForObject(connection, sql, createRowMapper(), account); + } + + public List findGreaterThan(final Connection connection, final long id) { + final var sql = "select id, account, password, email from users where id >= ?"; + return jdbcTemplate.query(connection, sql, createRowMapper(), id); + } + + public List findByAccountGreaterThan(final Connection connection, final String account) { + final var sql = "select id, account, password, email from users where account >= ?"; + return jdbcTemplate.query(connection, sql, createRowMapper(), account); + } + + public List findAll(final Connection connection) { + final var sql = "select id, account, password, email from users"; + return jdbcTemplate.query(connection, sql, createRowMapper()); + } + + private static RowMapper createRowMapper() { + return (final var rs) -> new User( + rs.getLong("id"), + rs.getString("account"), + rs.getString("password"), + rs.getString("email")); + } +} diff --git a/study/src/test/java/transaction/stage1/jdbc/DataAccessException.java b/study/src/test/java/transaction/stage1/jdbc/DataAccessException.java new file mode 100644 index 0000000000..aa427fd7fb --- /dev/null +++ b/study/src/test/java/transaction/stage1/jdbc/DataAccessException.java @@ -0,0 +1,25 @@ +package transaction.stage1.jdbc; + +public class DataAccessException extends RuntimeException { + private static final long serialVersionUID = 1L; + + public DataAccessException() { + super(); + } + + public DataAccessException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } + + public DataAccessException(String message, Throwable cause) { + super(message, cause); + } + + public DataAccessException(String message) { + super(message); + } + + public DataAccessException(Throwable cause) { + super(cause); + } +} diff --git a/study/src/test/java/transaction/stage1/jdbc/JdbcTemplate.java b/study/src/test/java/transaction/stage1/jdbc/JdbcTemplate.java new file mode 100644 index 0000000000..9d64be5a99 --- /dev/null +++ b/study/src/test/java/transaction/stage1/jdbc/JdbcTemplate.java @@ -0,0 +1,106 @@ +package transaction.stage1.jdbc; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +public class JdbcTemplate { + private static final Logger log = LoggerFactory.getLogger(JdbcTemplate.class); + private final DataSource dataSource; + + public JdbcTemplate(final DataSource dataSource) { + this.dataSource = dataSource; + } + + public void update(final Connection connection, final String sql, final PreparedStatementSetter pss) throws DataAccessException { + try (final var pstmt = connection.prepareStatement(sql)) { + pss.setParameters(pstmt); + pstmt.executeUpdate(); + } catch (SQLException e) { + throw new DataAccessException(e); + } + } + + public void update(final Connection connection, final String sql, final Object... parameters) { + update(connection, sql, createPreparedStatementSetter(parameters)); + } + + public void update(final String sql, final PreparedStatementSetter pss) throws DataAccessException { + try (final var conn = dataSource.getConnection(); final var pstmt = conn.prepareStatement(sql)) { + pss.setParameters(pstmt); + pstmt.executeUpdate(); + } catch (SQLException e) { + throw new DataAccessException(e); + } + } + + public void update(final String sql, final Object... parameters) { + update(sql, createPreparedStatementSetter(parameters)); + } + + public void update(final PreparedStatementCreator psc, final KeyHolder holder) { + try (final var conn = dataSource.getConnection(); final var ps = psc.createPreparedStatement(conn)) { + ps.executeUpdate(); + final var rs = ps.getGeneratedKeys(); + if (rs.next()) { + long generatedKey = rs.getLong(1); + log.debug("Generated Key:{}", generatedKey); + holder.setId(generatedKey); + } + rs.close(); + } catch (SQLException e) { + throw new DataAccessException(e); + } + } + + public T queryForObject(final Connection connection, final String sql, final RowMapper rm, final PreparedStatementSetter pss) { + final var list = query(connection, sql, rm, pss); + if (list.isEmpty()) { + return null; + } + return list.get(0); + } + + public T queryForObject(final Connection connection, final String sql, final RowMapper rm, final Object... parameters) { + return queryForObject(connection, sql, rm, createPreparedStatementSetter(parameters)); + } + + public List query(final Connection connection, final String sql, final RowMapper rm, final PreparedStatementSetter pss) throws DataAccessException { + try (final var pstmt = connection.prepareStatement(sql)) { + pss.setParameters(pstmt); + return mapResultSetToObject(rm, pstmt); + } catch (SQLException e) { + throw new DataAccessException(e); + } + } + + private List mapResultSetToObject(final RowMapper rm, final PreparedStatement pstmt) { + try (final var rs = pstmt.executeQuery()) { + final var list = new ArrayList(); + while (rs.next()) { + list.add(rm.mapRow(rs)); + } + return list; + } catch (SQLException e) { + throw new DataAccessException(e); + } + } + + public List query(final Connection connection, final String sql, final RowMapper rm, final Object... parameters) { + return query(connection, sql, rm, createPreparedStatementSetter(parameters)); + } + + private PreparedStatementSetter createPreparedStatementSetter(final Object... parameters) { + return pstmt -> { + for (int i = 0; i < parameters.length; i++) { + pstmt.setObject(i + 1, parameters[i]); + } + }; + } +} diff --git a/study/src/test/java/transaction/stage1/jdbc/KeyHolder.java b/study/src/test/java/transaction/stage1/jdbc/KeyHolder.java new file mode 100644 index 0000000000..04718bc3ef --- /dev/null +++ b/study/src/test/java/transaction/stage1/jdbc/KeyHolder.java @@ -0,0 +1,21 @@ +package transaction.stage1.jdbc; + +public class KeyHolder { + + private long id; + + public void setId(long id) { + this.id = id; + } + + public long getId() { + return id; + } + + @Override + public String toString() { + return "KeyHolder{" + + "id=" + id + + '}'; + } +} diff --git a/study/src/test/java/transaction/stage1/jdbc/PreparedStatementCreator.java b/study/src/test/java/transaction/stage1/jdbc/PreparedStatementCreator.java new file mode 100644 index 0000000000..6a7b625f1a --- /dev/null +++ b/study/src/test/java/transaction/stage1/jdbc/PreparedStatementCreator.java @@ -0,0 +1,9 @@ +package transaction.stage1.jdbc; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; + +public interface PreparedStatementCreator { + PreparedStatement createPreparedStatement(final Connection con) throws SQLException; +} diff --git a/study/src/test/java/transaction/stage1/jdbc/PreparedStatementSetter.java b/study/src/test/java/transaction/stage1/jdbc/PreparedStatementSetter.java new file mode 100644 index 0000000000..7b94832e83 --- /dev/null +++ b/study/src/test/java/transaction/stage1/jdbc/PreparedStatementSetter.java @@ -0,0 +1,8 @@ +package transaction.stage1.jdbc; + +import java.sql.PreparedStatement; +import java.sql.SQLException; + +public interface PreparedStatementSetter { + void setParameters(final PreparedStatement pstmt) throws SQLException; +} diff --git a/study/src/test/java/transaction/stage1/jdbc/RowMapper.java b/study/src/test/java/transaction/stage1/jdbc/RowMapper.java new file mode 100644 index 0000000000..5fbcd1d441 --- /dev/null +++ b/study/src/test/java/transaction/stage1/jdbc/RowMapper.java @@ -0,0 +1,9 @@ +package transaction.stage1.jdbc; + +import java.sql.ResultSet; +import java.sql.SQLException; + +@FunctionalInterface +public interface RowMapper { + T mapRow(final ResultSet rs) throws SQLException; +} diff --git a/study/src/test/java/transaction/stage2/FirstUserService.java b/study/src/test/java/transaction/stage2/FirstUserService.java new file mode 100644 index 0000000000..9a1d415c18 --- /dev/null +++ b/study/src/test/java/transaction/stage2/FirstUserService.java @@ -0,0 +1,136 @@ +package transaction.stage2; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Service +public class FirstUserService { + + private static final Logger log = LoggerFactory.getLogger(FirstUserService.class); + + private final UserRepository userRepository; + private final SecondUserService secondUserService; + + @Autowired + public FirstUserService(final UserRepository userRepository, + final SecondUserService secondUserService) { + this.userRepository = userRepository; + this.secondUserService = secondUserService; + } + + @Transactional(readOnly = true, propagation = Propagation.REQUIRED) + public List findAll() { + return userRepository.findAll(); + } + + @Transactional(propagation = Propagation.REQUIRED) + public Set saveFirstTransactionWithRequired() { + final var firstTransactionName = TransactionSynchronizationManager.getCurrentTransactionName(); + userRepository.save(User.createTest()); + logActualTransactionActive(); + + final var secondTransactionName = secondUserService.saveSecondTransactionWithRequired(); + + return of(firstTransactionName, secondTransactionName); + } + + @Transactional(propagation = Propagation.REQUIRED) + public Set saveFirstTransactionWithRequiredNew() { + final var firstTransactionName = TransactionSynchronizationManager.getCurrentTransactionName(); + userRepository.save(User.createTest()); + logActualTransactionActive(); + + final var secondTransactionName = secondUserService.saveSecondTransactionWithRequiresNew(); + + return of(firstTransactionName, secondTransactionName); + } + + @Transactional(propagation = Propagation.REQUIRED) + public Set saveAndExceptionWithRequiredNew() { + secondUserService.saveSecondTransactionWithRequiresNew(); + + userRepository.save(User.createTest()); + logActualTransactionActive(); + + throw new RuntimeException(); + } + +// @Transactional(propagation = Propagation.REQUIRED) + public Set saveFirstTransactionWithSupports() { + final var firstTransactionName = TransactionSynchronizationManager.getCurrentTransactionName(); + userRepository.save(User.createTest()); + logActualTransactionActive(); + + final var secondTransactionName = secondUserService.saveSecondTransactionWithSupports(); + + return of(firstTransactionName, secondTransactionName); + } + +// @Transactional(propagation = Propagation.REQUIRED) + public Set saveFirstTransactionWithMandatory() { + final var firstTransactionName = TransactionSynchronizationManager.getCurrentTransactionName(); + userRepository.save(User.createTest()); + logActualTransactionActive(); + + final var secondTransactionName = secondUserService.saveSecondTransactionWithMandatory(); + + return of(firstTransactionName, secondTransactionName); + } + + @Transactional(propagation = Propagation.REQUIRED) + public Set saveFirstTransactionWithNotSupported() { + final var firstTransactionName = TransactionSynchronizationManager.getCurrentTransactionName(); + userRepository.save(User.createTest()); + logActualTransactionActive(); + + final var secondTransactionName = secondUserService.saveSecondTransactionWithNotSupported(); + + return of(firstTransactionName, secondTransactionName); + } + + @Transactional(propagation = Propagation.REQUIRED) + public Set saveFirstTransactionWithNested() { + final var firstTransactionName = TransactionSynchronizationManager.getCurrentTransactionName(); + userRepository.save(User.createTest()); + logActualTransactionActive(); + + final var secondTransactionName = secondUserService.saveSecondTransactionWithNested(); + + return of(firstTransactionName, secondTransactionName); + } + + @Transactional(propagation = Propagation.REQUIRED) + public Set saveFirstTransactionWithNever() { + final var firstTransactionName = TransactionSynchronizationManager.getCurrentTransactionName(); + userRepository.save(User.createTest()); + logActualTransactionActive(); + + final var secondTransactionName = secondUserService.saveSecondTransactionWithNever(); + + return of(firstTransactionName, secondTransactionName); + } + + private Set of(final String firstTransactionName, final String secondTransactionName) { + return Stream.of(firstTransactionName, secondTransactionName) + .filter(transactionName -> !Objects.isNull(transactionName)) + .collect(Collectors.toSet()); + } + + private void logActualTransactionActive() { + final var currentTransactionName = TransactionSynchronizationManager.getCurrentTransactionName(); + final var actualTransactionActive = TransactionSynchronizationManager.isActualTransactionActive(); + final var emoji = actualTransactionActive ? "✅" : "❌"; + log.info("\n{} is Actual Transaction Active : {} {}", currentTransactionName, emoji, actualTransactionActive); + } +} diff --git a/study/src/test/java/transaction/stage2/SecondUserService.java b/study/src/test/java/transaction/stage2/SecondUserService.java new file mode 100644 index 0000000000..0d240fe854 --- /dev/null +++ b/study/src/test/java/transaction/stage2/SecondUserService.java @@ -0,0 +1,76 @@ +package transaction.stage2; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +@Service +public class SecondUserService { + + private static final Logger log = LoggerFactory.getLogger(SecondUserService.class); + + private final UserRepository userRepository; + + public SecondUserService(final UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Transactional(propagation = Propagation.REQUIRED) + public String saveSecondTransactionWithRequired() { + userRepository.save(User.createTest()); + logActualTransactionActive(); + return TransactionSynchronizationManager.getCurrentTransactionName(); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public String saveSecondTransactionWithRequiresNew() { + userRepository.save(User.createTest()); + logActualTransactionActive(); + return TransactionSynchronizationManager.getCurrentTransactionName(); + } + + @Transactional(propagation = Propagation.SUPPORTS) + public String saveSecondTransactionWithSupports() { + userRepository.save(User.createTest()); + logActualTransactionActive(); + return TransactionSynchronizationManager.getCurrentTransactionName(); + } + + @Transactional(propagation = Propagation.MANDATORY) + public String saveSecondTransactionWithMandatory() { + userRepository.save(User.createTest()); + logActualTransactionActive(); + return TransactionSynchronizationManager.getCurrentTransactionName(); + } + + @Transactional(propagation = Propagation.NOT_SUPPORTED) + public String saveSecondTransactionWithNotSupported() { + userRepository.save(User.createTest()); + logActualTransactionActive(); + return TransactionSynchronizationManager.getCurrentTransactionName(); + } + + @Transactional(propagation = Propagation.NESTED) + public String saveSecondTransactionWithNested() { + userRepository.save(User.createTest()); + logActualTransactionActive(); + return TransactionSynchronizationManager.getCurrentTransactionName(); + } + + @Transactional(propagation = Propagation.NEVER) + public String saveSecondTransactionWithNever() { + userRepository.save(User.createTest()); + logActualTransactionActive(); + return TransactionSynchronizationManager.getCurrentTransactionName(); + } + + private void logActualTransactionActive() { + final var currentTransactionName = TransactionSynchronizationManager.getCurrentTransactionName(); + final var actualTransactionActive = TransactionSynchronizationManager.isActualTransactionActive(); + final var emoji = actualTransactionActive ? "✅" : "❌"; + log.info("\n{} is Actual Transaction Active : {} {}", currentTransactionName, emoji, actualTransactionActive); + } +} diff --git a/study/src/test/java/transaction/stage2/Stage2Test.java b/study/src/test/java/transaction/stage2/Stage2Test.java new file mode 100644 index 0000000000..e522bf4365 --- /dev/null +++ b/study/src/test/java/transaction/stage2/Stage2Test.java @@ -0,0 +1,152 @@ +package transaction.stage2; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 트랜잭션 전파(Transaction Propagation)란? + * 트랜잭션의 경계에서 이미 진행 중인 트랜잭션이 있을 때 또는 없을 때 어떻게 동작할 것인가를 결정하는 방식을 말한다. + * + * FirstUserService 클래스의 메서드를 실행할 때 첫 번째 트랜잭션이 생성된다. + * SecondUserService 클래스의 메서드를 실행할 때 두 번째 트랜잭션이 어떻게 되는지 관찰해보자. + * + * https://docs.spring.io/spring-framework/docs/current/reference/html/data-access.html#tx-propagation + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class Stage2Test { + + private static final Logger log = LoggerFactory.getLogger(Stage2Test.class); + + @Autowired + private FirstUserService firstUserService; + + @Autowired + private UserRepository userRepository; + + @AfterEach + void tearDown() { + userRepository.deleteAll(); + } + + /** + * 생성된 트랜잭션이 몇 개인가? + * 왜 그런 결과가 나왔을까? + */ + @Test + void testRequired() { + final var actual = firstUserService.saveFirstTransactionWithRequired(); + + log.info("transactions : {}", actual); + assertThat(actual) + .hasSize(0) + .containsExactly(""); + } + + /** + * 생성된 트랜잭션이 몇 개인가? + * 왜 그런 결과가 나왔을까? + */ + @Test + void testRequiredNew() { + final var actual = firstUserService.saveFirstTransactionWithRequiredNew(); + + log.info("transactions : {}", actual); + assertThat(actual) + .hasSize(0) + .containsExactly(""); + } + + /** + * firstUserService.saveAndExceptionWithRequiredNew()에서 강제로 예외를 발생시킨다. + * REQUIRES_NEW 일 때 예외로 인한 롤백이 발생하면서 어떤 상황이 발생하는 지 확인해보자. + */ + @Test + void testRequiredNewWithRollback() { + assertThat(firstUserService.findAll()).hasSize(-1); + + assertThatThrownBy(() -> firstUserService.saveAndExceptionWithRequiredNew()) + .isInstanceOf(RuntimeException.class); + + assertThat(firstUserService.findAll()).hasSize(-1); + } + + /** + * FirstUserService.saveFirstTransactionWithSupports() 메서드를 보면 @Transactional이 주석으로 되어 있다. + * 주석인 상태에서 테스트를 실행했을 때와 주석을 해제하고 테스트를 실행했을 때 어떤 차이점이 있는지 확인해보자. + */ + @Test + void testSupports() { + final var actual = firstUserService.saveFirstTransactionWithSupports(); + + log.info("transactions : {}", actual); + assertThat(actual) + .hasSize(0) + .containsExactly(""); + } + + /** + * FirstUserService.saveFirstTransactionWithMandatory() 메서드를 보면 @Transactional이 주석으로 되어 있다. + * 주석인 상태에서 테스트를 실행했을 때와 주석을 해제하고 테스트를 실행했을 때 어떤 차이점이 있는지 확인해보자. + * SUPPORTS와 어떤 점이 다른지도 같이 챙겨보자. + */ + @Test + void testMandatory() { + final var actual = firstUserService.saveFirstTransactionWithMandatory(); + + log.info("transactions : {}", actual); + assertThat(actual) + .hasSize(0) + .containsExactly(""); + } + + /** + * 아래 테스트는 몇 개의 물리적 트랜잭션이 동작할까? + * FirstUserService.saveFirstTransactionWithNotSupported() 메서드의 @Transactional을 주석 처리하자. + * 다시 테스트를 실행하면 몇 개의 물리적 트랜잭션이 동작할까? + * + * 스프링 공식 문서에서 물리적 트랜잭션과 논리적 트랜잭션의 차이점이 무엇인지 찾아보자. + */ + @Test + void testNotSupported() { + final var actual = firstUserService.saveFirstTransactionWithNotSupported(); + + log.info("transactions : {}", actual); + assertThat(actual) + .hasSize(0) + .containsExactly(""); + } + + /** + * 아래 테스트는 왜 실패할까? + * FirstUserService.saveFirstTransactionWithNested() 메서드의 @Transactional을 주석 처리하면 어떻게 될까? + */ + @Test + void testNested() { + final var actual = firstUserService.saveFirstTransactionWithNested(); + + log.info("transactions : {}", actual); + assertThat(actual) + .hasSize(0) + .containsExactly(""); + } + + /** + * 마찬가지로 @Transactional을 주석처리하면서 관찰해보자. + */ + @Test + void testNever() { + final var actual = firstUserService.saveFirstTransactionWithNever(); + + log.info("transactions : {}", actual); + assertThat(actual) + .hasSize(0) + .containsExactly(""); + } +} diff --git a/study/src/test/java/transaction/stage2/User.java b/study/src/test/java/transaction/stage2/User.java new file mode 100644 index 0000000000..60d113b723 --- /dev/null +++ b/study/src/test/java/transaction/stage2/User.java @@ -0,0 +1,63 @@ +package transaction.stage2; + +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; + +@Entity(name = "users") +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String account; + private String password; + private String email; + + protected User() {} + + public User(String account, String password, String email) { + this.account = account; + this.password = password; + this.email = email; + } + + public boolean checkPassword(String password) { + return this.password.equals(password); + } + + public void changePassword(String password) { + this.password = password; + } + + public static User createTest() { + return new User("gugu", "password", "hkkang@woowahan.com"); + } + + public Long getId() { + return id; + } + + public String getAccount() { + return account; + } + + public String getPassword() { + return password; + } + + public String getEmail() { + return email; + } + + @Override + public String toString() { + return "User{" + + "id=" + id + + ", account='" + account + '\'' + + ", email='" + email + '\'' + + ", password='" + password + '\'' + + '}'; + } +} diff --git a/study/src/test/java/transaction/stage2/UserRepository.java b/study/src/test/java/transaction/stage2/UserRepository.java new file mode 100644 index 0000000000..61e329a3b0 --- /dev/null +++ b/study/src/test/java/transaction/stage2/UserRepository.java @@ -0,0 +1,6 @@ +package transaction.stage2; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { +}