일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- 프로그래머스
- 스프링
- Spring
- 데이터베이스
- 도커
- 오블완
- docker
- 소셜로그인
- 국제화
- springsecurity
- 재갱신
- springdataredis
- java
- JIRA
- springsecurityoauth2client
- 토이프로젝트
- 트랜잭션
- 스프링시큐리티
- 스프링부트
- 파이썬
- 백준
- 메시지
- githubactions
- AWS
- oauth2
- yaml-resource-bundle
- CI/CD
- 액세스토큰
- 리프레시토큰
- 티스토리챌린지
- Today
- Total
땃쥐네
[토이프로젝트] 게시판 시스템(board-system) 13. 데이터베이스 접근기술 적용 본문
지난 글에서 이메일/username/닉네임 유효성 검사 API를 작성했는데, 데이터베이스 연동을 하지 않았죠.
이번 글에서는 데이터베이스 연동을 해보겠습니다.
1. 로컬 MySQL 설정
우선 로컬의 개발환경을 먼저 구성해보겠습니다.
docker pull mysql:8.0.40-debian;
도커를 통해 mysql 이미지를 풀해오고
mkdir -p ./tmp
echo "FROM mysql:8.0.40-debian" > ./tmp/Dockerfile
echo "RUN apt-get update && apt-get install -y locales" >> ./tmp/Dockerfile
echo "RUN localedef -f UTF-8 -i ko_KR ko_KR.UTF-8" >> ./tmp/Dockerfile
echo "ENV LC_ALL ko_KR.UTF-8" >> ./tmp/Dockerfile
docker build -t mysql:ttasjwi ./tmp/
rm -rf ./tmp
커스텀한 도커 이미지를 만듭니다.
기본 mysql 컨테이너에서 로케일 정보를 ko_KR.UTF-8 로 설정했습니다.
$ docker images;
REPOSITORY TAG IMAGE ID CREATED SIZE
mysql ttasjwi 44234f27d1cc 5 minutes ago 649MB
mysql 8.0.40-debian 93f1a2fa0508 2 weeks ago 610MB
로컬 환경 기준, 커스텀한 mysql 이미지가 새로 생성됐습니다
mkdir -p ~/docker/mysql-compose
vim ~/docker/mysql-compose/docker-compose-mysql.yml
경로를 하나 생성하고 docker compose 파일을 작성해보겠습니다
version: "3.8"
services:
mysql:
image: mysql:ttasjwi
container_name: mysql
ports:
- "3306:3306"
environment:
- TZ=Asia/Seoul
- MYSQL_ROOT_PASSWORD=db1004
restart: unless-stopped
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
volumes:
- C:\Users\ttasjwi\data\mysql\data:/var/lib/mysql
- C:\Users\ttasjwi\data\mysql\conf:/etc/mysql/conf.d
- C:\Users\ttasjwi\data\mysql\initdb:/docker-entrypoint-initdb.d
도커 컴포즈 설정을 작성 합니다.
1. 이미지는 위에서 만든 mysql:ttasjwi 를 썼습니다
2. 컨테이너명은 편하게 쓸 수 있도록 mysql로 지었습니다.
3. 포트는 외부-내부 를 3306 포트로 바인딩했습니다. 외부포트 3306은 내부 포트 3306으로 연결됩니다.
4. environment 설정을 통해 시간대를 Asia/Seoul 로. 루트 사용자 비밀번호를 편의상 db1004 로 설정했어요.
5. restart 옵션을 unless-stopped 로 해두면 로컬 컴퓨터가 켜질 때(윈도우는 도커데스크탑 실행 시) 자동으로 컨테이너가 실행됩니다.
6. charset, collation 설정을 utf8mb4, utf8mb4_unicode_ci 로 합니다.
7. volumes 를 통해 내부의 데이터들을 외부의 원하는 폴더로 연결합니다.
docker compose -f ~/docker/mysql-compose/docker-compose-mysql.yml up -d
이렇게 작성된 도커 컴포즈 파일을 실행합니다.
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
3bc07b0d9a5d mysql:ttasjwi "docker-entrypoint.s…" 7 seconds ago Up 6 seconds 0.0.0.0:3306->3306/tcp, 33060/tcp mysql
MySQL 컨테이너가 잘 띄워졌습니다.
docker exec -it mysql bash
mysql -uroot -p
mysql 컨테이너로 들어가 root로 접속합니다.
아까 기입한 패스워드를 입력하면 됩니다.
CREATE USER 'board_user_local'@'%' IDENTIFIED BY 'db1004';
CREATE USER 'board_user_test'@'%' IDENTIFIED BY 'db1004';
로컬 사용자, 테스트 사용자를 생성하고
GRANT ALL ON *.* TO 'board_user_local'@'%';
GRANT ALL ON *.* TO 'board_user_test'@'%';
FLUSH PRIVILEGES;
모든 권한을 부여합니다.
mysql> create database board_db_local;
Query OK, 1 row affected (0.00 sec)
mysql> create database board_db_test;
Query OK, 1 row affected (0.00 sec)
로컬 DB, 테스트 DB를 하나씩 만들었습니다.
2. BuildSrc
2.1 Dependencies
enum class Dependencies(
private val groupId: String,
private val artifactId: String,
private val version: String? = null,
private val classifier: String? = null,
) {
// kotlin
KOTLIN_JACKSON(groupId = "com.fasterxml.jackson.module", artifactId = "jackson-module-kotlin"),
KOTLIN_REFLECT(groupId = "org.jetbrains.kotlin", artifactId = "kotlin-reflect"),
KOTLIN_LOGGING(groupId = "io.github.oshai", artifactId = "kotlin-logging", version = "7.0.0"),
// spring
SPRING_BOOT_STARTER(groupId = "org.springframework.boot", artifactId = "spring-boot-starter"),
SPRING_BOOT_WEB(groupId = "org.springframework.boot", artifactId = "spring-boot-starter-web"),
SPRING_BOOT_DATA_JPA(groupId = "org.springframework.boot", artifactId = "spring-boot-starter-data-jpa"),
SPRING_BOOT_TEST(groupId = "org.springframework.boot", artifactId = "spring-boot-starter-test"),
// p6spy
P6SPY_DATASOURCE_DECORATOR(groupId = "com.github.gavlyukovskiy", artifactId = "p6spy-spring-boot-starter", version = "1.9.2"),
// mysql
MYSQL_DRIVER(groupId = "com.mysql", artifactId = "mysql-connector-j"),
// yaml message
YAML_RESOURCE_BUNDLE(groupId = "dev.akkinoc.util", artifactId = "yaml-resource-bundle", version = "2.13.0"),
// email-format-check
COMMONS_VALIDATOR(groupId="commons-validator", artifactId ="commons-validator" , version="1.9.0");
val fullName: String
get() {
if (version == null) {
return "$groupId:$artifactId"
}
if (classifier == null) {
return "$groupId:$artifactId:$version"
}
return "$groupId:$artifactId:$version:$classifier"
}
}
의존성으로 spring_data_jpa, mysql, p6spy 를 추가했습니다.
1. spring_boot_data_jpa : jdbc, jpa 및 jpa구현체 hibernate 등을 포함한 스프링부트 스타터입니다.
2. mysql_driver: mysql 에 접근할 수 있도록 하는 드라이버 구현체가 이 라이브러리에 있습니다.
3. p6spy_datasource_decorator: datasource 를 한 단계 더 데코레이팅하여, 중간단계에서 작성되는 로그를 포맷팅하기 편하게 만들어주는 라이브러리입니다. 구체적인 파라미터를 로그에 직접 넣어서 볼 수 있게 해줍니다.
2.2 Plugins
enum class Plugins(
val id: String,
val version: String = "",
) {
KOTLIN_JVM(id = "org.jetbrains.kotlin.jvm", version = "1.9.25"),
KOTLIN_SPRING(id = "org.jetbrains.kotlin.plugin.spring", version = "1.9.25"),
KOTLIN_JPA(id = "org.jetbrains.kotlin.plugin.jpa", version = "1.9.25"),
SPRING_BOOT(id = "org.springframework.boot", version = "3.3.4"),
SPRING_DEPENDENCY_MANAGEMENT(id = "io.spring.dependency-management", version = "1.1.6"),
JAVA_TEST_FIXTURES(id = "java-test-fixtures", version = ""),
}
kotlin_jpa 플러그인을 추가했습니다.
jpa 는 우리가 작성한 jpa 엔티티 코드들을 기반으로 상속한 클래스를 많이 만듭니다. 그런데 이런 상속 클래스를 만들려면 생성자가 열려있어야하고, 상속을 할 수 있도록 open 키워드 등을 달아야죠.
이런 작업을 개발자가 수동으로 할 필요없도록 kotlin_jpa 플러그인을 의존성 추가해주면 됩니다.
3. container 모듈 설정
# container/src/main/resources/application.yml
spring.application.name: board-system
spring:
profiles:
active: local
group:
local: local
blue: blue, production
green: green, production
config:
import:
- db-config.yml
- deploy-config.yml
- message-config.yml
application.yml 설정을 변경해보겠습니다.
group 부분과 config.import 부분이 변경됐습니다.
local 프로필로 실행될 경우 local 그룹의 설정을 사용하고
blue, green 프로필로 실행될 경우 blue/green 및 production 의 설정으로 실행하도록 했습니다.
그리고 db-config.yml 을 설정으로 포함합니다.
# container/src/test/resources/application.yml
spring.application.name: board-system-test
spring:
profiles:
active: test
config:
import:
- db-config.yml
- deploy-config.yml
- message-config.yml
---
container 의 test 소스셋 application.yml에서도 db-config.yml 을 설정으로 포함하도록 했습니다.
4. external-db 모듈 설정
external-db 모듈을 설정해보겠습니다.
4.1 build.gradle.kts
plugins {
id(Plugins.KOTLIN_JPA.id) version Plugins.KOTLIN_JPA.version
}
dependencies {
implementation(Dependencies.SPRING_BOOT_DATA_JPA.fullName)
runtimeOnly(Dependencies.MYSQL_DRIVER.fullName)
implementation(Dependencies.P6SPY_DATASOURCE_DECORATOR.fullName)
implementation(project(":board-system-domain:domain-core"))
implementation(project(":board-system-domain:domain-member"))
implementation(project(":board-system-application:application-core"))
testImplementation(testFixtures(project(":board-system-domain:domain-core")))
testImplementation(testFixtures(project(":board-system-domain:domain-member")))
}
kotlin-jpa 플러그인 , spring-data-jpa, mysql, p6spy_decorator 의존성을 추가했습니다.
4.2 db-config.yml
# external/external-db/src/main/resources/application.yml
spring:
jpa:
open-in-view: false
---
spring:
config:
activate:
on-profile: local
import: db-config-local.yml
---
spring:
config:
activate:
on-profile: test
import: db-config-test.yml
---
spring:
config:
activate:
on-profile: production
import: file:/app/config/board-system/db-config-production.yml
---
db-config.yml 입니다.
1. open-in-view 를 false 로 하면 트랜잭션 바깥에까지 영속성 컨텍스트가 전달되지 않도록 강제됩니다.
2. 각 프로필에 맞게 설정파일을 import 시킵니다.
4.3 db-config-local.yml, db-config-test.yml, db-config-production.yml
spring:
config:
activate:
on-profile: local
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/board_db_local?serverTimezone=Asia/Seoul&characterEncoding=UTF-8
username: board_user_local
password: db1004
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
dialect: org.hibernate.dialect.MySQLDialect
logging:
level:
p6spy: info
decorator:
datasource:
p6spy:
enable-logging: true
db-config-local.yml 은 로컬 프로필 실행 시(main 클래스를 로컬에서 실행) 설정파일입니다.
외부에 유출되어도 별 상관 없으니 간단하게 설정을 구성했습니다.
개발 초기단계인지라 ddl-auto 은 true 로 해놨습니다. (애플리케이션 구동 시 jpq entity 를 기반으로 ddl 을 실행해줌)
spring:
config:
activate:
on-profile: test
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/board_db_test?serverTimezone=Asia/Seoul&characterEncoding=UTF-8
username: board_user_test
password: db1004
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
dialect: org.hibernate.dialect.MySQLDialect
logging:
level:
p6spy: info
decorator:
datasource:
p6spy:
enable-logging: true
db-config-test.yml 은 테스트 프로필 실행 시(로컬 테스트 또는 github actions 테스트 환경) 설정파일입니다.
ddl-auto 은 true 로 해놨습니다.
spring:
config:
activate:
on-profile: production
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://(rds주소):3306/board_db?serverTimezone=Asia/Seoul&characterEncoding=UTF-8
username: (rds사용자이름)
password: (rds비밀번호)
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
dialect: org.hibernate.dialect.MySQLDialect
logging:
level:
p6spy: info
decorator:
datasource:
p6spy:
enable-logging: true
---
그리고 프로덕션 설정입니다. 실제 프로덕션에서 사용할 설정인데요.
rds 사용자 이름, 비밀번호 등 민감한 정보가 포함되어집니다.
실제 프로덕션 환경에서는 sql에 대해 로그 남기는 것은 성능상 손해가 큰지라 보통 잘 안 남기는 것으로 알고있는데
일단 개발 초기단계이므로 남기기로 했습니다.
개발 초기단계라서 ddl-auto 역시 true 로 해놨습니다.
4.4 테스트 소스셋 application.yml
spring.application.name: board-system-db-test
spring:
config:
activate:
on-profile: test
---
spring:
config:
activate:
on-profile: test
import: db-config.yml
external-db 의 테스트 설정 application.yml 입니다.
db-config 을 가져오고 현재 프로필을 test 로 지정합니다.
5. CI/CD 설정
5.1 .gitignore
### secret properties files ###
board-system-api/api-deploy/src/main/resources/deploy-config-blue.yml
board-system-api/api-deploy/src/main/resources/deploy-config-green.yml
board-system-external/external-db/src/main/resources/db-config-production.yml
.gitignore 에는 external-db의 프로덕션 설정파일을 ignore 처리합니다.
5.2 빌드 테스트 설정파일 / 배포 설정파일
name: Build-Test
on:
pull_request:
branches:
- master
jobs:
Build-Test:
runs-on: ubuntu-latest
env:
DB_NAME: board_db_test
DB_ROOT_PASSWORD: root
DB_TEST_USERNAME: board_user_test
DB_TEST_USER_PASSWORD: db1004
steps:
- name: Check Out (체크 아웃)
uses: actions/checkout@v4
- name: 데이터베이스 설정
run: |
sudo /etc/init.d/mysql start
mysql -e "CREATE DATABASE ${{ env.DB_NAME }};" -uroot -p${{ env.DB_ROOT_PASSWORD }}
mysql -e "CREATE user '${{ env.DB_TEST_USERNAME }}'@'%' IDENTIFIED BY '${{ env.DB_TEST_USER_PASSWORD }}';" -uroot -p${{ env.DB_ROOT_PASSWORD }}
mysql -e "GRANT ALL ON *.* TO '${{ env.DB_TEST_USERNAME }}'@'%';" -uroot -p${{ env.DB_ROOT_PASSWORD }}
mysql -e "FLUSH PRIVILEGES;" -uroot -p${{ env.DB_ROOT_PASSWORD }}
- name: Setup Java Version (Java 21 설정)
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
# 생략
빌드테스트 설정파일에서는 MYSQL 설정을 앞에 추가하였습니다.
위의 스크립트를 추가해주면 GitHub Actions 의 ubuntu 컨테이너 내에서, MySQL이 있는데 해당 MYSQL 을 실행하고 설정을 추가로 실행해줍니다.
name: Deploy
on:
push:
branches:
- master
jobs:
Deploy:
runs-on: ubuntu-latest
env:
DB_NAME: board_db_test
DB_ROOT_PASSWORD: root
DB_TEST_USERNAME: board_user_test
DB_TEST_USER_PASSWORD: db1004
steps:
- name: 체크 아웃
uses: actions/checkout@v4
- name: 데이터베이스 설정
run: |
sudo /etc/init.d/mysql start
mysql -e "CREATE DATABASE ${{ env.DB_NAME }};" -uroot -p${{ env.DB_ROOT_PASSWORD }}
mysql -e "CREATE user '${{ env.DB_TEST_USERNAME }}'@'%' IDENTIFIED BY '${{ env.DB_TEST_USER_PASSWORD }}';" -uroot -p${{ env.DB_ROOT_PASSWORD }}
mysql -e "GRANT ALL ON *.* TO '${{ env.DB_TEST_USERNAME }}'@'%';" -uroot -p${{ env.DB_ROOT_PASSWORD }}
mysql -e "FLUSH PRIVILEGES;" -uroot -p${{ env.DB_ROOT_PASSWORD }}
- name: Java 21 설정
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
# 생략
- name: 도커 이미지 풀링 및 컨테이너 실행
run: |
ssh board-ec2 << 'EOF'
set -e
# 필요한 프로필 파일을 서버로 복사합니다.
echo "${{ secrets.DB_CONFIG_PRODUCTION_YML }}" | base64 --decode > /home/ec2-user/config/board-system/db-config-production.yml
if [ "${{ env.TARGET_UPSTREAM }}" = "blue" ]; then
echo "${{ secrets.DEPLOY_CONFIG_BLUE_YML }}" | base64 --decode > /home/ec2-user/config/board-system/deploy-config-blue.yml
else
echo "${{ secrets.DEPLOY_CONFIG_GREEN_YML }}" | base64 --decode > /home/ec2-user/config/board-system/deploy-config-green.yml
fi
docker pull ${{ secrets.DOCKERHUB_USERNAME }}/board-be:latest
docker compose -f docker-compose-${{ env.TARGET_UPSTREAM }}.yml up -d
EOF
- name: 새로 실행한 서버 컨테이너 헬스 체크
uses: jtalk/url-health-check-action@v3
with:
url: http://${{ secrets.LIVE_SERVER_IP }}:${{ env.STOPPED_PORT }}/api/v1/deploy/health-check
max-attempts: 3
retry-delay: 10s
# 생략
배포 스크립트에서도 MYSQL 설정에 대한 내용은 같은데
실제 배포 시, 프로덕션 설정이 ec2 서버에 복사될 수 있도록 하는 설정을 추가합니다.
5.3 GitHub Secrets
db-config-production.yml 파일의 내용은 base64 인코딩 한뒤 그대로 github 리포지토리 secrets 에 저장했습니다.
5.4 EC2 Docker Compose 파일
version: "3.8"
services:
green:
image: ttasjwi/board-be:latest
container_name: green
ports:
- "8082:8080"
environment:
- PROFILES=green
volumes:
- /home/ec2-user/config/board-system/:/app/config/board-system/
version: "3.8"
services:
blue:
image: ttasjwi/board-be:latest
container_name: blue
ports:
- "8081:8080"
environment:
- PROFILES=blue
volumes:
- /home/ec2-user/config/board-system/:/app/config/board-system/
~
EC2 컨테이너의 Docker Compose 파일도 약간 수정했습니다.
외부 경로 /home/ec2-user/config/board-system/ 을 내부 경로 /app/config/board-system 으로 연결합니다.
6. 기능 구현
본격적으로 데이터베이스 접근기술을 사용하여 기능을 구현해보겠습니다.
6.1 트랜잭션
package com.ttasjwi.board.system.core.application
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class TransactionRunnerImpl : TransactionRunner {
@Transactional
override fun <T> run(function: () -> T): T {
return function.invoke()
}
@Transactional(readOnly = true)
override fun <T> runReadOnly(function: () -> T): T {
return function.invoke()
}
}
TransactionRunnerImpl 입니다.
복잡한 트랜잭션 처리를 스프링의 @Transactional 을 사용하여 편리하게 트랜잭션 기능을 구현합니다.
이 어노테이션을 작성하면 스프링이 내부적으로 AOP 를 통해, TransactionRunnerImpl 의 코드를 변형한 프록시를 스프링 빈으로 대체합니다.
이 코드를 작성하면 내부적으로 복잡하게 개발자가 작성해야할 트랜잭션 처리를 간소화시킬 수 있어요.
물론 Transactional 어노테이션에서는 다른 부가적인 기능들이 더 있긴한데(롤백 설정, 전파설정, ...) 저는 잘 안 쓰는 기능들이여서 위의 기능들만을 사용했습니다.
6.2 Jpa
데이터베이스 접근기술로는 Spring Data Jpa를 사용했습니다.
이 Jpa 기술을 사용한 코드는 애플리케이션/도메인 로직과 분리되어 작성되어있다는 것이 특징입니다.
6.2.1 JpaMember
package com.ttasjwi.board.system.member.domain.external.db.jpa
import com.ttasjwi.board.system.member.domain.model.Member
import jakarta.persistence.*
import java.time.ZonedDateTime
@Entity
@Table(name= "member")
class JpaMember (
@Id
@Column(name = "member_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null,
@Column(name = "email")
var email: String,
@Column(name = "password")
var password: String,
@Column(name = "username")
var username: String,
@Column(name = "nickname")
var nickname: String,
@Column(name = "role")
var role: String,
@Column(name = "registered_at")
val registeredAt: ZonedDateTime,
) {
companion object {
fun from(member: Member): JpaMember {
return JpaMember(
id = member.id?.value,
email = member.email.value,
password = member.password.value,
username = member.username.value,
nickname = member.nickname.value,
role = member.role.name,
registeredAt = member.registeredAt,
)
}
}
fun restore(): Member {
return Member.restoreDomain(
id = this.id!!,
email = this.email,
password = this.password,
username = this.username,
nickname = this.nickname,
roleName = this.role,
registeredAt = this.registeredAt
)
}
}
JpaEntity 입니다.
Member 도메인에서 JpaMember 를 매핑하는 메서드(JpaMember.from), 그리고 회원도메인 객체로 복원하는 restoreDomain 메서드를 작성해뒀어요.
Jpa 기술 매핑 코드 작성만을 집중했어요.
6.2.2 JpaMemberRepository
package com.ttasjwi.board.system.member.domain.external.db.jpa
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
interface JpaMemberRepository : JpaRepository<JpaMember, Long> {
@Query("SELECT m FROM JpaMember m WHERE m.email = :email")
fun findByEmailOrNull(@Param("email") email: String): JpaMember?
fun existsByEmail(email: String): Boolean
@Query("SELECT m FROM JpaMember m WHERE m.username = :username")
fun findByUsernameOrNull(@Param("username") username: String): JpaMember?
fun existsByUsername(username: String): Boolean
@Query("SELECT m FROM JpaMember m WHERE m.nickname = :nickname")
fun findByNicknameOrNull(@Param("nickname") nickname: String): JpaMember?
fun existsByNickname(nickname: String): Boolean
}
JpaMemberRepository 입니다.
JpaRepository 인터페이스를 상속한 인터페이스를 만들어두면 스프링이 알아서 구현체 빈을 만들어 등록해주기 때문에 빈 등록 코드는 따로 작성하지 않았습니다.
6.2.3 MemberStorage
package com.ttasjwi.board.system.member.domain.external.db
import com.ttasjwi.board.system.member.domain.external.db.jpa.JpaMember
import com.ttasjwi.board.system.member.domain.external.db.jpa.JpaMemberRepository
import com.ttasjwi.board.system.member.domain.model.*
import com.ttasjwi.board.system.member.domain.service.MemberAppender
import com.ttasjwi.board.system.member.domain.service.MemberFinder
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Component
@Component
class MemberStorage(
private val jpaMemberRepository: JpaMemberRepository
) : MemberAppender, MemberFinder {
override fun save(member: Member): Member {
val jpaMember = JpaMember.from(member)
jpaMemberRepository.save(jpaMember)
if (member.id == null) {
val id = MemberId.restore(jpaMember.id!!)
member.initId(id)
}
return member
}
override fun findByIdOrNull(id: MemberId): Member? {
return jpaMemberRepository.findByIdOrNull(id.value)?.restoreDomain()
}
override fun existsById(id: MemberId): Boolean {
return jpaMemberRepository.existsById(id.value)
}
override fun findByEmailOrNull(email: Email): Member? {
return jpaMemberRepository.findByEmailOrNull(email.value)?.restoreDomain()
}
override fun existsByEmail(email: Email): Boolean {
return jpaMemberRepository.existsByEmail(email.value)
}
override fun findByUsernameOrNull(username: Username): Member? {
return jpaMemberRepository.findByUsernameOrNull(username.value)?.restoreDomain()
}
override fun existsByUsername(username: Username): Boolean {
return jpaMemberRepository.existsByUsername(username.value)
}
override fun findByNicknameOrNull(nickname: Nickname): Member? {
return jpaMemberRepository.findByNicknameOrNull(nickname.value)?.restoreDomain()
}
override fun existsByNickname(nickname: Nickname): Boolean {
return jpaMemberRepository.existsByNickname(nickname.value)
}
}
그리고 기존의 MemberStorage 는 내부적으로 jpaMemberRepository 를 의존하여 처리를 위임시킵니다.
6.3 로깅 설정
구글링을 하다가 P6SPY 포맷 설정을 예쁘게 잘 만들어두신 분의 글을 발견해서 해당 코드를 거의 그대로 사용했습니다.
package com.ttasjwi.board.system.core.logging
import com.p6spy.engine.logging.Category
import com.p6spy.engine.spy.appender.MessageFormattingStrategy
import org.hibernate.engine.jdbc.internal.FormatStyle
import org.springframework.stereotype.Component
@Component
class P6SpyFormattingStrategy : MessageFormattingStrategy {
companion object {
private const val NEW_LINE = "\n"
private const val TAP = "\t"
private val DDL_COMMANDS = listOf("create", "alter", "drop", "comment")
}
override fun formatMessage(
connectionId: Int,
now: String,
elapsed: Long,
category: String,
prepared: String,
sql: String,
url: String
): String {
if (sql.trim().isEmpty()) {
return formatByCommand(category)
}
return formatBySql(sql, category) + getAdditionalMessages(elapsed)
}
private fun formatByCommand(category: String): String {
return (NEW_LINE
+ "Execute Command : "
+ NEW_LINE
+ NEW_LINE
+ TAP
+ category
+ NEW_LINE
+ NEW_LINE
+ "----------------------------------------------------------------------------------------------------")
}
private fun formatBySql(sql: String, category: String): String {
return if (isStatementDDL(sql, category)) {
(NEW_LINE
+ "Execute DDL : "
+ NEW_LINE
+ FormatStyle.DDL
.formatter
.format(sql))
} else {
(NEW_LINE
+ "Execute DML : "
+ NEW_LINE
+ FormatStyle.BASIC
.formatter
.format(sql))
}
}
private fun getAdditionalMessages(elapsed: Long): String {
return (NEW_LINE
+ NEW_LINE
+ String.format("Execution Time: %s ms", elapsed) + NEW_LINE
+ "----------------------------------------------------------------------------------------------------")
}
private fun isStatementDDL(sql: String, category: String): Boolean {
return isStatement(category) && isDDL(sql.trim().lowercase())
}
private fun isStatement(category: String): Boolean {
return Category.STATEMENT.name == category
}
private fun isDDL(lowerSql: String): Boolean {
return DDL_COMMANDS.any { lowerSql.startsWith(it) }
}
}
포맷팅을 담당하는 전략입니다. P6SPY 내부적으로 이 설정 파일을 참조하여 SQL 메시지를 커스텀하게 포맷팅해줘요.
package com.ttasjwi.board.system.core.logging
import com.p6spy.engine.common.ConnectionInformation
import com.p6spy.engine.event.JdbcEventListener
import com.p6spy.engine.spy.P6SpyOptions
import org.springframework.stereotype.Component
import java.sql.SQLException
@Component
class P6SpyEventListener : JdbcEventListener() {
override fun onAfterGetConnection(connectionInformation: ConnectionInformation?, e: SQLException?) {
P6SpyOptions.getActiveInstance().logMessageFormat = P6SpyFormattingStrategy::class.java.getName()
}
}
커넥션 획득 시에도 P6SPY 포맷팅이 적용될 수 있도록 P6Spy 이벤트 리스너 설정을 추가로 작성합니다.
7. 테스트코드
테스트 코드 쪽은 그렇게 수정할 내용이 많지는 않습니다.
package com.ttasjwi.board.system
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class DbTestApplication
fun main(args: Array<String>) {
runApplication<DbTestApplication>(*args)
}
우선 모듈내의 스프링부트 실행을 위해 스프링부트 실행 클래스를 하나 만들어두고
@SpringBootTest
@ActiveProfiles(value = ["test"])
@Transactional
@DisplayName("MemberStorage 테스트")
class MemberStorageTest @Autowired constructor(
private var memberStorage: MemberStorage
) {
@SpringBootTest
@ActiveProfiles(value = ["test"])
@DisplayName("TransactionManagerImpl 테스트")
class TransactionRunnerImplTest @Autowired constructor(
private val transactionRunner: TransactionRunner
) {
기존 테스트들은 스프링부트 테스트로 변경합니다.
기존에 테스트코드를 작성해둔 것이 그대로 성공하기만 하면 됩니다.
8. 실행
실제 Main 클래스를 실행해서 로컬에서 실행해보면
HikariPool : HikariPool-1 - Added connection com.mysql.cj.jdbc.ConnectionImpl@4148ce43
HikariDataSource : HikariPool-1 - Start completed.
JtaPlatformInitiator : HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration)
2024-11-04T20:16:03.855+09:00 INFO 1612 --- [board-system] [ main] p6spy :
Execute DDL :
drop table if exists member
Execution Time: 18 ms
----------------------------------------------------------------------------------------------------
2024-11-04T20:16:03.896+09:00 INFO 1612 --- [board-system] [ main] p6spy :
Execute DDL :
create table member (
member_id bigint not null auto_increment,
registered_at datetime(6),
email varchar(255),
nickname varchar(255),
password varchar(255),
role varchar(255),
username varchar(255),
primary key (member_id)
) engine=InnoDB
Execution Time: 28 ms
----------------------------------------------------------------------------------------------------
P6SPY 를 통해 데이터베이스 접근관련 로직들(커넥션 사용)이 포맷팅되고
jpa의 ddl-auto 기능이 잘 동작하여 회원 테이블이 생성되는 것을 볼 수 있습니다.
그리고 실제 API를 호출해보면
2024-11-04T20:18:58.674+09:00 INFO 1612 --- [board-system] [nio-8080-exec-3] s.m.a.s.EmailAvailableApplicationService : 이메일이 우리 서비스에서 사용 가능한 지 확인합니다.
2024-11-04T20:18:58.674+09:00 INFO 1612 --- [board-system] [nio-8080-exec-3] c.t.b.s.m.a.m.EmailAvailableQueryMapper : 요청이 유효한지 확인합니다.
2024-11-04T20:18:58.674+09:00 INFO 1612 --- [board-system] [nio-8080-exec-3] c.t.b.s.m.a.m.EmailAvailableQueryMapper : 입력으로 전달된 이메일이 확인됐습니다. (email = ttasjwi920@gmail.com)
2024-11-04T20:18:58.884+09:00 INFO 1612 --- [board-system] [nio-8080-exec-3] p6spy :
Execute DML :
select
jm1_0.member_id
from
member jm1_0
where
jm1_0.email='ttasjwi920@gmail.com'
limit
1
Execution Time: 8 ms
----------------------------------------------------------------------------------------------------
2024-11-04T20:18:58.896+09:00 INFO 1612 --- [board-system] [nio-8080-exec-3] c.t.b.s.m.a.p.EmailAvailableProcessor : 사용 가능한 이메일입니다. (email = ttasjwi920@gmail.com)
2024-11-04T20:18:58.899+09:00 INFO 1612 --- [board-system] [nio-8080-exec-3] p6spy :
Execute Command :
commit
----------------------------------------------------------------------------------------------------
2024-11-04T20:18:58.901+09:00 INFO 1612 --- [board-system] [nio-8080-exec-3] s.m.a.s.EmailAvailableApplicationService : 이메일 사용가능여부 확인을 마쳤습니다.
Jpa를 통한 SQL 실행 및 포맷팅이 의도한대로 동작하는 것을 볼 수 있는데
여기서 눈여겨 볼 부분은 p6spy 가 작동하여, 구체 파라미터가 무엇인지 SQL 문에 대입되어 보여집니다.
Jpa에서 제공되는 포맷팅을 사용할 경우 구체 파라미터 자리에 "?"가 작성되거든요.
9. 배포
실제 빌드테스트도 의도한 대로 잘 동작하고
배포도 무사히 성공했습니다.
이제 우리 서버는 데이터베이스와 연동하여 작동하는 서버가 됐어요.
뒤에 이어지는 글에서는 마저 다른 기능을 추가적으로 구현해보겠습니다.
github 리포지토리: https://github.com/ttasjwi/board-system
PR: https://github.com/ttasjwi/board-system/pull/37
'Project' 카테고리의 다른 글
[토이프로젝트] 게시판 시스템(board-system) 15. 이메일 인증 API 구현 (0) | 2024.11.13 |
---|---|
[토이프로젝트] 게시판 시스템(board-system) 14. 인증 이메일 발송 API 구현 (0) | 2024.11.13 |
[토이프로젝트] 게시판 시스템(board-system) 12. 이메일/사용자 아이디/닉네임 사용가능 여부 확인 API 구현 (0) | 2024.10.31 |
[토이프로젝트] 게시판 시스템(board-system) 11. 멀티모듈과 테스트 픽스쳐 중복문제 (0) | 2024.10.29 |
[토이프로젝트] 게시판 시스템(board-system) 10. 애플리케이션 내부 아키텍처 설계 (0) | 2024.10.28 |