땃쥐네

[토이프로젝트] 게시판 시스템(board-system) 13. 데이터베이스 접근기술 적용 본문

Project

[토이프로젝트] 게시판 시스템(board-system) 13. 데이터베이스 접근기술 적용

ttasjwi 2024. 11. 4. 20:30

지난 글에서 이메일/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

 

GitHub - ttasjwi/board-system

Contribute to ttasjwi/board-system development by creating an account on GitHub.

github.com

 

PR: https://github.com/ttasjwi/board-system/pull/37

 

Feature: (BRD-68) 데이터베이스 접근기술 설정 by ttasjwi · Pull Request #37 · ttasjwi/board-system

JIRA 티켓 BRD-68

github.com

 

Comments