티스토리 뷰

스프링 부트로 프로젝트를 진행할 때 필요한 기술들이 많아지면서 기본 틀을 잡는데도 많은 공수가 들어가며 환경설정에 너무 많은 시간이 들게 되었다. 이에 미리 프로젝트를 위한 구조를 만들고 실행 가능한 이미지를 만들어서 도커에 올려 놓으려고 한다.

 

진행할 목차에 대해 간단히 정리하면

  1. 필요 라이브러리 설치
  2. Spring Data JPA, Spring Data Envars, QueryDSL 적용
  3. API 사용 및 테스트를 위한 기본 설정
    • REST API / GraphQL (DGS)
  4. JWT 사용을 위한 기본 설정
    • 로그인 / 로그아웃 구현
  5. FLYWAY를 통한 기본 테이블 생성 및 데이터 삽입
  6. Redis를 이용한 캐시 AOP 적용
  7. 도커 이미지 생성 후 업로드

위와 같은 순서로 적용해보고자 한다.

 

사용 기술

언어 / 프레임워크

  • Kotlin 1.6
  • Spring Boot 2.7 + Java 17
  • Spring Data Jpa
  • Spring Data Envars
  • Spring Security + JWT

API

  • QueryDsl
  • Graphql + DGS
  • REST API + Swagger

DB

  • MySql
  • Flyway
  • Spring Boot Redis

TEST

  • Mockk
  • Kotest

UTIL

  • Docker

일단 start.spring.io에서 프로젝트를 먼저 만들고 필요한 라이브러리는 maven repository 사이트에서 추가적으로 받아주도록 하자.

build.gradel.kts

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    id("org.springframework.boot") version "2.7.8"
    id("io.spring.dependency-management") version "1.0.15.RELEASE"
    kotlin("jvm") version "1.6.21"
    kotlin("plugin.spring") version "1.6.21"
    kotlin("plugin.jpa") version "1.6.21"
    kotlin("kapt") version "1.6.10"
    id("org.flywaydb.flyway") version "7.8.2"
    id("com.google.cloud.tools.jib") version "3.1.2"
    id("com.ewerk.gradle.plugins.querydsl") version "1.0.10"
    id("com.netflix.dgs.codegen") version "5.2.0" apply true
}

group = "hackathon"
version = "0.0.1"
java.sourceCompatibility = JavaVersion.VERSION_17

allOpen {
    annotation("javax.persistence.Entity")
    annotation("javax.persistence.MappedSuperclass")
    annotation("javax.persistence.Embeddable")
}

repositories {
    mavenCentral()
}

val swaggerVersion = "3.0.0"
val queryDslVersion = "5.0.0"
val dgsVersion = "4.9.16"

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-security")
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.data:spring-data-envers")

    // QUERY_DSL
    implementation("com.querydsl:querydsl-jpa")
    implementation("com.querydsl:querydsl-apt")

    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")

    // AWS
    implementation("com.amazonaws:aws-java-sdk:1.12.394")

    // JWT
    implementation("io.jsonwebtoken:jjwt-api:0.11.2")
    runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.2")
    runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.2")

    // GSON
    implementation("com.google.code.gson:gson")

    // SWAGGER
    implementation("io.springfox:springfox-boot-starter:${swaggerVersion}")

    // DB, FLYWAY
    implementation("org.flywaydb:flyway-core")
    compileOnly("org.flywaydb:flyway-mysql")
    runtimeOnly("com.h2database:h2")
    runtimeOnly("com.mysql:mysql-connector-j")

    // QueryDSL
    implementation("com.querydsl:querydsl-jpa:$queryDslVersion")
    kapt("com.querydsl:querydsl-apt:$queryDslVersion:jpa")
    kapt("org.springframework.boot:spring-boot-configuration-processor")

    // DGS
    implementation(platform("com.netflix.graphql.dgs:graphql-dgs-platform-dependencies:$dgsVersion"))
    runtimeOnly("com.netflix.graphql.dgs:graphql-dgs-spring-boot-starter:$dgsVersion")
    implementation("com.netflix.graphql.dgs:graphql-dgs-extended-scalars")

    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("org.springframework:spring-webflux")
    testImplementation("org.springframework.graphql:spring-graphql-test")
    testImplementation("org.springframework.security:spring-security-test")

    // MOCKK
    testImplementation("io.mockk:mockk:1.13.3")

    // TEST
    testImplementation("io.kotest:kotest-runner-junit5:4.6.3")
    testImplementation("io.kotest:kotest-assertions-core:4.6.3")
}

tasks.withType<KotlinCompile> {
    kotlinOptions {
        freeCompilerArgs = listOf("-Xjsr305=strict")
        jvmTarget = "17"
    }
}

tasks.withType<Test> {
    useJUnitPlatform()
}

jib {
    from {
        image = "cimg/openjdk:17.0"
    }
    to {
        image = "peerfund/backend"
        tags = mutableSetOf("latest")
    }
    container {
        jvmFlags = mutableListOf("-Xms2048m", "-Xmx2048m")
    }
}

val querydslDir = "$buildDir/generated/querydsl"

querydsl {
    library = "com.querydsl:querydsl-apt"
    jpa = true
    querydslSourcesDir = querydslDir
}

flyway {
    url = "jdbc:mysql://localhost:3306/peerfund?useUnicode=yes&characterEncoding=UTF-8&serverTimezone=UTC"
    user = "root"
    password = "root"
}

tasks.withType<com.netflix.graphql.dgs.codegen.gradle.GenerateJavaTask> {
    schemaPaths = mutableListOf("${projectDir}/src/main/resources/schema/schema.graphqls")
//    packageName = "hackathon.peerfund.generated"
    generateDataTypes = true
    snakeCaseConstantNames = true
    language = "kotlin"
    generateKotlinNullableClasses = false
    typeMapping = mutableMapOf()
}

 

application.yml

server:
  port: 9090

spring:
  h2:
    console:
      enabled: true

  graphql:
    graphiql.enabled: true
    schema.printer.enabled: true

  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/peerfund?serverTimezone=UTC&characterEncoding=UTF-8
    username: root
    password: root

  mvc:
    pathmatch:
      matching-strategy: ant_path_matcher

  jpa:
    hibernate:
      ddl-auto: update
    open-in-view: false
    properties:
      org:
        hibernate:
          format_sql: true
          envers:
            revision_field_name: rev_id
            audit_table_suffix: _histories
            modified_flag_suffix: _modified
            store_data_at_delete: true
      hibernate:
        default_batch_fetch_size: 100
        show_sql: true
        format_sql: true
    show-sql: false
    generate-ddl: false

  flyway:
    baseline-on-migrate: true
    url: jdbc:mysql://localhost:3306/peerfund?serverTimezone=UTC&characterEncoding=UTF-8
    baseline-version: 1
    user: root
    password: root

  profiles:
    active: local

logging.level:
  org.hibernate.SQL: debug

일단 위 필요한 라이브러리를 모두 구성해주었다. 이제 해당 라이브러리 기술들은 사용할 수는 있게 되었다. 다음 단계를 진행해보자.

 


도메인 생성, 수정 등 이력 관리를 위한 설정

우선적으로 도메인 객체에 대한 생성, 수정 등에 대한 이력관리는 필수적이므로 BaseEntity, BaseTimeEntity를 생성해서 이를 도메인 객체들이 상속받도록 하자. 또한 Envars를 사용하면 자동으로 이력관리 테이블을 만들어 주므로 이것도 적용을 미리 해놓도록 하자.

 

필요한 라이브러리는 받아놨으므로 도메인 최상위에 두고 생성일, 수정일, 생성자, 수정자를 갖는 BaseEntity, BaseTimeEntity를 만들고, 이를 주입해주기 위한 JpaAudit 설정파일도 만든다.

BaseEntity / BaseTimeEntity

@MappedSuperclass
@EntityListeners(AuditingEntityListener::class)
@Audited(withModifiedFlag = true)
class BaseEntity : BaseTimeEntity() {
    @CreatedBy
    @LastModifiedBy
    var auditor: String = ""
}

@MappedSuperclass
@EntityListeners(AuditingEntityListener::class)
@Audited(withModifiedFlag = true)
class BaseTimeEntity {
    @CreatedDate
    @Column(updatable = false)
    var createdDate: LocalDateTime? = null

    @LastModifiedDate
    var updatedDate: LocalDateTime? = null
}

typealias ID = String

Audit Configuration 파일

@Configuration
@EnableJpaAuditing(dateTimeProviderRef = "auditingDateTimeProvider")
class AuditConfiguration {
    @Bean
    fun auditorAware(): AuditorAware<*> {
        return AuditorAwareImpl()
    }

    @Bean
    fun auditingDateTimeProvider(): DateTimeProvider {
        return DateTimeProvider { Optional.of(OffsetDateTime.now()) }
    }
}

class AuditorAwareImpl : AuditorAware<String> {

    companion object {
        private const val DEFAULT_AUDITOR = "DEFAULT_AUDITOR"
    }

    override fun getCurrentAuditor(): Optional<String> {
        if (RequestContextHolder.getRequestAttributes() == null) {
            return Optional.of(DEFAULT_AUDITOR)
        }

        val request = (RequestContextHolder.getRequestAttributes() as ServletRequestAttributes).request
        val auditor = request.getAttribute("auditor")

        return if (auditor == null) {
            Optional.of(DEFAULT_AUDITOR)
        } else {
            Optional.of(auditor as String)
        }
    }
}

@EnableJpaAuditing를 필수적으로 넣어줘야 한다. AOP로 넘어오는 request에서 값을 추출해서 Servlet의 Attribute에 넣어준것을 꺼내서 반환해주면 된다.

AuditorAspect 파일

@Component
@Aspect
class AuditorAspect {
    @Before("execution(* hackathon.peerfund.*.*.*Fetcher.*(..)) &&" + "args(input)")
    fun setAuditor(input: Any) {
        val req: HttpServletRequest = (RequestContextHolder.getRequestAttributes() as ServletRequestAttributes).request
        val auditor = input.javaClass.kotlin.memberProperties.find { it.name == "auditor" }?.get(input) as String?
        if (auditor != null) {
            req.setAttribute("auditor", auditor)
        }
    }
}

AOP로 request의 body에 "audiotor"에 해당하는 필드와 값이 들어있다면, RequestContextHolder에 접근해서 attribute에 key-value로 값을 넣어준다.

getCurrentAuditor()에서 Attribute에 접근해 "auditor"라는 키-값을 추출한 후 반환해주면 BaseEntity의 auditor에 사용될 것이다.

Member.Entity

이제 Envars로 이력관리를 할 도메인에 @Audited를 달아주고, BaseEntity or BaseTimeEntity를 상속받게 한다.

@Entity
@Audited(withModifiedFlag = true)
class Member(
    @Column(nullable = false) val username: String,
    @Column(nullable = false) val password: String,
    @Column(nullable = false) var profileName: String,
    @Column(nullable = false) var roleListString: String,
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long = 0L,
) : BaseEntity(), Serializable {

    fun getRoleTypeList(): List<RoleType> {
        return roleListString
            .split(",")
            .map { it.trim() }
            .mapNotNull { RoleType.find(it) }
    }

    fun addUserRole(role: RoleType) {
        this.roleListString += "${role.name},"
    }
}

Role.Entity & RoleType

@Entity
@Audited
class Role(
    @Column(nullable = false) @Enumerated(value = EnumType.STRING) val roleType: RoleType,
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long = 0L,
)
---
enum class RoleType {
    ROLE_USER, ROLE_ADMIN;

    companion object {
        fun find(roleName: String): RoleType? {
            return RoleType.values().firstOrNull { it.name == roleName }
        }
    }
}

생성/수정을 위한 API 작성

이제 해당 도메인의 생성과 수정을 위한 API를 뚫어보도록 하자.

@DgsComponent
class MemberFetcher(
    private val memberService: MemberService,
) {
    @DgsQuery
    fun getMember(
        @InputArgument(name = "member_id") memberId: ID
    ): MemberResponse {
        return memberService.findMemberById(memberId.toLong())
    }

    @DgsQuery
    fun getAllMemberList(): List<MemberResponse> {
        return memberService.findAllMember()
    }

    @DgsMutation
    fun sighUpMember(
        @InputArgument input: CreateMemberInput
    ): Long {
        return memberService.create(input)
    }

    @DgsMutation
    fun updateProfileName(
        @InputArgument input: UpdateProfileNameInput
    ): Long {
        return memberService.updateProfileName(input)
    }
}

조회, 생성, 수정 을 위한 API를 생성해줬다.

 

❗️여기서 잠깐❗️ 왜 Graphql을 DGS를 이용해서 사용하는지 알아보자.


GraphQL + DGS

Spring Graphql이 제공되고 있지만 일단 아직 부족한 점이 많은데, DGS의 경우 애노테이션 기반 개발이 가능하다. 아래와 같이 Controller에 해당하는 Fetch 클래스에 @DgsComponent를 달아주고, Query, Mutation 역할을 하는 함수에 맞는 애노테이션을 달아주면 된다.

 

참고로 DGS를 이용해 개발을 할 때는 "Schema 우선 개발" 방법을 따라서 해야한다. 이게 뭔말이가 하면

input, response로 사용하는 dto를 먼저 data class로 생성하는게 아니라, schema에 우선적으로 정의를 해야 한다는 의미이다.

API / Schema

이렇게 Schema를 우선적으로 정의하고 난 후 DGS 플러그인을 이용하면 schema를 기반으로 input, response에 해당하는 dto 클래스를 자동으로 생성해준다. (놀랍지 않은가? 😆)

또한 이렇게 생성된 API와 Schema는 DGS가 서로 매핑되어서 DGS 아이콘을 누르면 서로 이동이 가능하다. 이부분도 정말 매력적인 부분중 하나이다.

codegen / 생성된 input dto class


다시 본론으로 돌아와서, 이제 API Fetcher도 만들어줬으니 실제 조회, 생성, 업데이트 테스트를 진행해보자.

Member Service

@Service
class MemberService(
    private val memberRepository: MemberRepository,
    private val memberCreator: MemberCreator,
    private val roleService: RoleService,
) {
    @Transactional(readOnly = true)
    fun findMemberById(memberId: Long): MemberResponse {
        val member = memberRepository.findMemberById(memberId)
            ?: throw DataNotFoundException(ITEM_NOT_EXIST, "회원이 존재하지 않습니다.")

        return toResponse(member)
    }

    @Transactional(readOnly = true)
    fun findAllMember(): List<MemberResponse> {
        val memberList = memberRepository.findAllMember()
        if (memberList.isEmpty()) {
            return emptyList()
        }
        return memberList.map { toResponse(it) }
    }

    @Transactional
    fun create(input: CreateMemberInput): Long {
        validate(input)
        val member = doCreate(input)
        return member.id
    }

    private fun validate(input: CreateMemberInput) {
        input
            .apply { checkDuplicateUsername(this) }
            .apply { checkPassword(this) }
    }

    private fun checkDuplicateUsername(input: CreateMemberInput): Boolean {
        try {
            check(memberRepository.findMemberByUsername(input.username) == null)
            return true
        } catch (e: Exception) {
            throw ParameterInvalidException(
                ErrorCode.PARAMETER_INVALID, "동일한 계정이 존재합니다."
            )
        }
    }

    private fun checkPassword(input: CreateMemberInput): Boolean {
        val password = input.password
        // TODO: ^(?=.*[a-zA-Z])(?=.*\d)(?=.*\W).{8,20}$
        val pattern = Pattern.compile("^([a-z]){8,20}$")
        return try {
            check(pattern.matcher(password).matches())
            true
        } catch (e: Exception) {
            throw ParameterInvalidException(
                ErrorCode.PARAMETER_INVALID, "패스워드는 영문자와 특수문자를 포함하여 8자 이상 20자 이하로 이뤄져야 합니다."
            )
        }
    }

    private fun doCreate(input: CreateMemberInput): Member {
        val member = memberCreator.createMember(input)
        val role = roleService.findRole(RoleType.ROLE_USER)

        member.addUserRole(role.roleType)
        return memberRepository.save(member)
    }

    @Transactional
    fun updateProfileName(input: UpdateProfileNameInput): Long {
        val member = memberRepository
            .findMemberById(input.id.toLong())
            ?: throw DataNotFoundException(ITEM_NOT_EXIST, "회원이 존재하지 않습니다.")

        member.profileName = input.new_profile_name
        return member.id
    }

    private fun toResponse(member: Member): MemberResponse {
        return MemberResponse(
            id = member.id.toString(),
            username = member.username,
            password = member.password,
            profile_name = member.profileName,
            role_list_string = member.roleListString,
            auditor = member.auditor
        )
    }
}

Member Creator

@Component
class MemberCreator(
    private val passwordEncoder: BCryptPasswordEncoder,
) {
    fun createMember(input: CreateMemberInput): Member {
        val encodePassword = passwordEncoder.encode(input.password)
        return Member(
            username = input.username,
            password = encodePassword,
            profileName = input.profile_name,
            roleListString = ""
        )
    }
}

Member Repository

@Repository
class MemberRepository(
    memberJpaRepository: MemberJpaRepository
) : QuerydslRepositorySupport(Member::class.java), MemberJpaRepository by memberJpaRepository {

    @PersistenceContext
    override fun setEntityManager(entityManager: EntityManager) {
        super.setEntityManager(entityManager)
    }

    fun findMemberById(id: Long): Member? {
        return from(member)
            .where(member.id.eq(id))
            .fetchOne()
    }

    fun findMemberByUsername(username: String): Member? {
        return from(member)
            .where(member.username.eq(username))
            .fetchOne()
    }

    fun findAllMember(): List<Member> {
        return from(member)
            .orderBy(member.dateCreated.desc())
            .fetch()
    }
}

회원 생성 요청

회원 전체 조회 요청

회원 이름 변경 요청

이력관리테이블

  • Envars에서 관리하는 이력관리테이블이 자동으로 생성되고 추가가되는것을 확인할 수 있다.
  • 수정된 컬럼만 옆에 true로 체크가 된다.
  • auditor에 reuqest로 들어온 이름이 잘 들어가는 것을 볼 수 있다.
    • aop로 해당 리퀘스트에서 이름을 추출 ->getCurrentAuditor에서 반환한 값이 들어가는 것.

Flyway

위와 같이 envars도 적용하면서 Flyway를 통한 ddl 형상관리를 위해서 jpa.hibernate.ddl-auto: update로 설정해두었다. flyway는 기본적으로 src/main/resources/db/migration 하위에 있는 sql 파일을 대상으로 migration이 이뤄진다. 이때 파일명 형식은 "V{number}__{naming}.sql"로 작성해야한다.

필자의 경우 이렇다

V2_init_table.sql 의 경우 아래와 같이 작성되어있다. 실행 전 먼저 gradle -> flyway -> flyway clean & flyway migrate를 실행시키면 된다.

실행 후 db를 보면 flyway 히스토리 테이블이 생성되고 만들었던 flyway 파일이 추가된 것을 볼 수 있다. 실제로 테이블도 추가가 되었고 role도 들어있는것을 확인할 수 있다.

이후 애플리케이션을 실행하면 spring data jpa envars 설정으로 인해 @Audited가 들어간 엔티티를 대상으로 history 테이블이 자동적으로 생성된다. 이때 ddl-auto: validate or none인 경우 생성되지 않으니 주의하자. 또한 create인 경우 drop 명령이 발생하므로 주의하자.

히스토리 테이블이 생성된다

이렇게

  • Spring Data Envars를 통한 도메인에 대한 생성, 수정을 위한 이력관리 적용
  • GraphQL + DGS를 통한 API 처리
  • Spring Data JPA & QueryDLS을 통한 DB 쿼리
  • Flyway를 통한 테이블 형상관리

를 적용하였다.

 

다음 포스트에서는 JWT 설정을 해보고 이를 통해 로그인/로그아웃을 구현해보도록 하겠다.

 

반응형
Comments
반응형
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday