2. 개발 경험

 포트폴리오에 모든 코드를 제공 할 수 없습니다. 

 양해 부탁드립니다. 감사합니다. 

요구사항과 유스케이스에 부합하는 비즈니스 로직이 담긴 API를 우선적으로 개발 해 시스템의 핵심 기능을 구현하고

화면에서는 서버로부터 전달된 데이터를 사용자 편의성을 고려한 액션 로직을 활용해 최종적으로 제공합니다.

Jira를 활용해 에픽 단위로 스크럼 형식의 개발을 진행하고, Git을 이용해 형상 관리를 철저히 수행합니다.

AWS를 통한 CI/CD를 통해 지속적인 통합 및 배포를 실시합니다.


(1) 연세대학교 선발평가 시스템


Gradle & Spring Boot 기반 멀티모듈 프로젝트





기간

2021.07 - 2021.12


주요 역할 및 담당

■ 업무분석, 유스케이스 작성, 화면설계, FE/BE, 운영 및 유지보수


주요 내용과 프로젝트 주안점

Domain Driven한 서버 아키텍쳐를 구성해 시스템의 안정성과 확장성을 보장.

/**
 * Domain에 핵심 비즈니스 로직이 집중되므로 유지보수가 용이하고 상대적으로 가벼운 컨트롤러와 서비스
 * Service단에서 Transaction을 readOnly와 mutable로 분리
 * 자주 조회되는 데이터는 캐싱을 통해 조회 성능을 향상
 * QueryDSL을 사용한 자유로운 select 구성
**/

// MemberController

@Tag(name = "MemberController", description = "사용자 컨트롤러")
@RestController
@RequestMapping("/api/users")
class CommonInfoController(
        private val memberQueryService: MemberQueryService

) {
    @Operation(summary = "교수/교직원 검색")
    @GetMapping("/paged")
    fun searchMembers(@RequestParam("role", required = true) role: Role,
                      @RequestParam("campusCode", required = true) campusCode: String,
                      @RequestParam("collegeCode", required = false) collegeCode: String?,
                      @RequestParam("deptCode", required = false) deptCode: String?,
                      @RequestParam("name", required = false) name: String?,
                      @ParameterObject pageable: Pageable
    ): ResponseEntity<Page<MemberOut>> {
        return ResponseEntity.ok(
                memberQueryService.searchMembers(
                        role,
                        campusCode,
                        collegeCode,
                        deptCode,
                        name,
                        pageable)
        )
    }
}

// MemberQueryService

@Service
@Transactional(readOnly = true)
class CommonInfoQueryService(
        private val memberRepository: MemberRepository
) {
    @Cacheable(cacheNames = ["members"])
    fun searchMembers(role: Role,
                      campusCode: String,
                      collegeCode: String?,
                      deptCode: String?,
                      name: String?,
                      pageable: Pageable): Page<MemberOut> {
        return memberRepository.searchMembers(
                role,
                campusCode,
                collegeCode,
                deptCode,
                name,
                pageable)
                .map { MemberOut.fromEntity(it) }
    }
}

// DTO

data class MemberOut(
        val oid: Long,
        val campusName: String?,
        val collegeName: String?,
        val deptCode: String?,
        val deptName: String?,
        val userId: String,
        val name: String,
        val email: String,
) {
    
    // companion object를 사용함으로서 DTO를 통한 데이터 In/Out에 비즈니스 로직을 담아 낼 수 있음
    companion object {

        fun fromEntity(e: Member): MemberOut {
            return MemberOut(
                    oid = e.oid!!,
                    campusName = e.campusName(),
                    collegeName = e.collegeName(),
                    deptCode = e.deptCode(),
                    deptName = e.deptName(),
                    userId = e.userId,
                    name = e.name(),
                    email = e.email(),
            )
        }
    }
}

// MemberRepositoryCustom

interface MemberRepositoryCustom {

    fun searchMembers(
            role: Role,
            campusCode: String,
            collegeCode: String?,
            deptCode: String?,
            name: String?,
            pageable: Pageable
    ): Page<Member>
    
}

// MemberRepositoryImpl

class MemberRepositoryImpl : QuerydslRepositorySupport(Member::class.java), MemberRepositoryCustom {

    private val qMember = QMember.member

    override fun searchMembers(
            role: Role,
            campusCode: String,
            collegeCode: String?,
            deptCode: String?,
            name: String?,
            pageable: Pageable
    ): Page<Member> {
        val result = from(qMember)
                .where(
                        qMember.deleted.isFalse,
                        qMember.role.eq(role.name),
                        qMember.campusCode.eq(campusCode),
                        eqCollegeCode(collegeCode),
                        eqDeptCode(deptCode),
                        containsName(name),
                )
                .fetchAll()
        val pagedResult = querydsl!!.applyPagination(pageable, result).fetch() ?: emptyList()
        return PageableExecutionUtils.getPage(pagedResult, pageable) { result.fetchCount() }
    }

    private fun eqCollegeCode(collegeCode: String?): BooleanExpression? {
        return if (collegeCode == null) null
        else qMember.collegeCode.eq(collegeCode)
    }

    private fun eqDeptCode(deptCode: String?): BooleanExpression? {
        return if (deptCode == null) null
        else qMember.deptCode.eq(deptCode)
    }

    private fun containsName(name: String?): BooleanExpression? {
        return if (name == null) null
        else qMember.name.contains(name)
    }
    
}

S3 클라우드 스토리지를 활용해 제출 파일을 PDF로 조회하며 평가를 수행하고

평가 내용은 POI 모듈을 통해 엑셀로 업/다운로드 -> 평가 결과표를 Birt 모듈을 통해 PDF로 다운로드


Frontend

/**
 * Frontend AWS S3 Bucket Download
 * **/

<template>
  <div class="col col-9">
    <div class="height-limiter iframe-90vh">
      <pdf-viewer :filePath="pdfURL" />
    </div>
  </div>
</template>

<script>
import { s3 } from '@/wrapper/s3'

export default {
  data: () => ({
    pdfURL: ''
  }),
  methods: {
    
    // Promise 객체 처리 위해 async ~ await
    async _getFilePath (fileKey) {
      this.pdfURL = await s3.downUrl(fileKey)
    }
  }
}
</script>
// S3 Module

const config = {
    accessKeyId: process.env.VUE_APP_AWS_ACCESS_KEY_ID,
    secretAccessKey: process.env.VUE_APP_AWS_SECRET_ACCESS_KEY,
    region: process.env.VUE_APP_AWS_REGION
}

const AWSS3 = new AWS.S3(config)

const params = {
    Bucket: process.env.VUE_APP_AWS_BUCKET
}

// fileKey를 받아 S3에서 객체 다운로드
export const s3 = {
    downUrl (fileKey, Expires = 60 * 5) {
        let downParm = {
            ...params,
            Key: decodeURI(fileKey),
            Expires
        }
        return new Promise((resolve, reject) => {
            AWSS3.getSignedUrl('getObject', downParm, (err, url) => {
                err ? reject(err) : resolve(url)
            })
        })
    }
}

Server Side

/**
 * Server Side AWS S3 Bucket Upload / Download
 * **/

// FileRepository
interface FileRepository {

    fun uploadToS3(fileRequest: FileRequest): FileResponse

    fun getFileFromFileRepository(fileKey: String): FileWrapper
    
}

// FileRepositoryImpl

@Service
@Transactional
class S3FileRepositoryImpl(
        
        @Value("\${aws.s3.bucketName}")
        private val bucketName: String,

        @Value("\${aws.s3.URL}")
        private val s3URL: String,

        @Autowired
        private val amazonS3: AmazonS3
        
) : FileRepository {

    override fun uploadToS3(fileRequest: FileRequest): FileResponse {
        try {
            val request = createPutObjectRequest(fileRequest)
            val tm = createTransferManager()
            val upload = tm.upload(request)
            upload.waitForCompletion()
        } catch (e: Exception) {
            throw S3UploadException(
                    "FileRequest",
                    fileRequest.fileName
            )
        }
        return FileResponse(
                fileRequest.fileName, fileRequest.originalFileName, fileRequest.fileSize,
                fileRequest.fileDirPath, fileRequest.getFileKey())
    }

    // AWS S3 Bucket에 안정적으로 파일을 전송 할 수 있도록 TransferManager 호출
    private fun createTransferManager(): TransferManager {
        return TransferManagerBuilder
                .standard()
                .withS3Client(amazonS3)
                .withMultipartUploadThreshold((5 * 1024 * 1025).toLong())
                .build()
    }
    
    // AWS SDK를 사용해 Amazon S3 버킷에 객체 업로드
    private fun createPutObjectRequest(fileRequest: FileRequest): PutObjectRequest {
        return PutObjectRequest(
                bucketName, fileRequest.getFileKey(),
                fileRequest.file
        )
                .withCannedAcl(CannedAccessControlList.Private)
                .withMetadata(createObjectMetadata(fileRequest))
    }

    // Front에서 filekey를 받아 Repo에서 파일을 탐색 후 return
    override fun getFileFromFileRepository(fileKey: String): FileWrapper {
        var s3Object = try {
            amazonS3!!.getObject(
                    GetObjectRequest(
                            bucketName,
                            fileKey
                    )
            )
        } catch (e: AmazonS3Exception) {
            throw S3GetDataException("fileKey", fileKey)
        }
        return FileWrapper(
                s3Object!!.objectContent,
                createFileMeta(s3Object.objectMetadata)
        )
    }

    private fun createFileMeta(`object`: Any): FileMeta {
        var fileMeta: FileMeta? = null
        if (`object` is ObjectMetadata) {
            val metadata = `object`
            fileMeta = FileMeta(metadata.contentType, metadata.contentEncoding,
                    metadata.eTag,metadata.lastModified.toString() ,metadata.contentLength)
        }
        return fileMeta!!
    }
}

■ 비밀번호 분실 및 특정 알림이 필요한 경우 SES 서비스를 활용해 메일링 서비스를 제공.


// MemberCommandService

@Service
@Transactional
class MemberCommandService(
        
        @Autowired
        private var resourceLoader: ResourceLoader,

        @Value("\${front.url}")
        private val frontUrl: String,

        private val mailService: MailService,

        private val jwtGenerator: JwtGenerator,
) {
    // 미리 정해진 HTML 템플릿에 Collection으로 내용 전달
    fun sendEmail(userId: String, email: String) {
        val dto =  MailDto(listOf(email), "비밀번호 찾기 안내 메일입니다. This is Your password.",
                "find-password.html", Map.of("url", getModifyPwdUrl(userId, email)) as kotlin.collections.Map<String, String>, resourceLoader)

        mailService.send(dto)
    }

    // 개인정보 노출방지를 위해 Hashcode 적용 한 Url로 사용자 화면을 이동시킴
    private fun getModifyPwdUrl(userId: String, email: String): String {
        val expireDate = LocalDateTime.now().plusMinutes(30).format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
        return frontUrl + "/user/modify-pwd/" + userId + "/" + expireDate + "/" + getHashcode(userId, email)
    }

    private fun getHashcode(userId: String, email: String): String? {
        // create Hashcode
    }
    
}

// SESMailService

class SESMailServiceImpl : MailService {

    @Autowired
    private val sesClient: AmazonSimpleEmailServiceAsync? = null

    @Value("\${aws.ses.sendMail}")

    // AWS 가이드에 따라 SES 메일 전송
    fun sendMail(mailDto: MailDto, isAsync: Boolean) {
        val emailRequest: SendEmailRequest = EmailRequestDto(mailDto) //ses전송 공통객체 만들기
        val mailList: List<String> = mailDto.to
        val listSize = mailList.size
        val MAIL_MAX_SIZE = 50 //ses는 한번에 50개씩 메일 전송 가능
        var firstMailIndex = 0
        var lastMailIndex = 0
        var maxSize = if (listSize < MAIL_MAX_SIZE) listSize else MAIL_MAX_SIZE
        while (lastMailIndex < listSize) {
            lastMailIndex = firstMailIndex + maxSize
            val addr: List<String> = ArrayList(mailList.subList(firstMailIndex, lastMailIndex))
            val destination = Destination()
            destination.setToAddresses(addr)
            emailRequest.withDestination(destination)
            if (isAsync) sesClient!!.sendEmailAsync(emailRequest, SesAsyncHandler())
            else sesClient!!.sendEmail(emailRequest)
            maxSize = if (listSize - lastMailIndex < MAIL_MAX_SIZE) listSize - lastMailIndex else MAIL_MAX_SIZE
            firstMailIndex = lastMailIndex
        }
    }

    fun EmailRequestDto(mailDto: MailDto): SendEmailRequest {
        return SendEmailRequest()
                .withSource(sendMail)
                .withMessage(newMessage(mailDto))
    }
}

기술스택

  • Frontend: Vue.js, JavaScript, HTML/CSS(SCSS)
  • Backend: Spring Boot, JPA(Query DSL), Kotlin, Java
  • DB: mySQL,
  • Server: AWS EC2
  • CI/CD : AWS CodeBuild, AWS Pipeline, AWS EB / S3
  • Version Control : AWS CodeCommit

성과

■ 서지로 평가 및 관리되던 연세대학교의 평가 업무를 시스템으로 전환.

■ (주)에이펙스소프트의 입학지원 시스템인 Gradnet의 지원 및 모집정보를 활용해 연세대학교의 입학지원 내용을 제공하고 평가 업무를 지원.

■ 학교 및 입학담당자에게 통계 자료를 제공.


개발경험

이 프로젝트를 통해 Vue Framework를 학습하고 화면 개발을 담당할 수 있는 역량을 키우게 되었으며, DDD 기반의 CQRS 패러다임이 적용된 멀티모듈 프로젝트에 대한 경험을 쌓을 수 있었습니다.

처음 시작한 프로젝트로서 어려움이 많았지만, 새로운 기술과 도전에 대한 즐거움을 경험하며 프로젝트를 성공적으로 완료했습니다.

(2) (주)에이펙스소프트 자산관리 시스템


Gradle & Spring Boot 기반 멀티모듈 프로젝트







기간

2022.03 - 2022.05


주요 역할 및 담당

■ 설계(화면, 유스케이스 등), DB 재설계, FE / BE


주요 내용과 프로젝트 주안점

■ 사내 메신저인 잔디와 연동하여 비품구매 요청이나 개인정보 변경정보는 관리자에게 알림 서비스.

■ 구글 드라이브와 연동해 물품 검수시 생성된 PDF 파일을 시스템<-> 구글 드라이브에서 CRUD.

■ JPA Entity Manager Factory의 분리를 통해 서로다른 DB에서 데이터를 양방향으로 CRUD 할 수 있도록 하는 Two Page Commit 학습 및 적용.

Server Side

/**
 * JPA 설정 클래스
 * 2개의 빈은 디비 테이블에 createdAt, createdBy 등 audit 정보를 자동으로 저장하는데 사용
 * 
 * @Primary 어노테이션으로 n개의 DB중 어떤 DB를 main으로 Entity Manager Factory에 지정 할 지 선언 
 */
@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
    entityManagerFactoryRef = "primaryEntityManagerFactory",
    transactionManagerRef = "primaryTransactionManager",
    basePackages = ["com.example.project.*.*.*.repository"]
)
@EnableJpaAuditing(dateTimeProviderRef = "auditingDateTimeProvider")
class PrimaryJpaConfig () {

    @Bean
    fun auditingDateTimeProvider() = DateTimeProvider {
        Optional.of(LocalDateTime.now())
    }

    @Bean
    fun auditorAwareProvider() = AuditorAware {
        val authentication = SecurityContextHolder.getContext().authentication
        if (authentication == null) {
            Optional.empty()
        } else when (val principal: Any = authentication.principal) {
            is String -> Optional.of(principal)
            is AuthUser -> Optional.of(principal.memberId)
            else -> Optional.empty()
        }
    }

    @Primary
    @Bean
    @ConfigurationProperties("primary.datasource")
    fun primaryDataSourceProperties(): DataSourceProperties? {
        return DataSourceProperties()
    }

    @Primary
    @Bean
    @ConfigurationProperties("primary.datasource.configuration")
    fun primaryDataSource(@Qualifier("primaryDataSourceProperties") dataSourceProperties: DataSourceProperties): DataSource? {
        return dataSourceProperties.initializeDataSourceBuilder().type(HikariDataSource::class.java).build()
    }

    @Primary
    @Bean
    fun primaryEntityManagerFactory(
        builder: EntityManagerFactoryBuilder,
        @Qualifier("primaryDataSource") dataSource: DataSource?
    ): LocalContainerEntityManagerFactoryBean? {
        return builder
            .dataSource(dataSource)
            .packages("com.example.project.*.*.domain")
            .persistenceUnit("primaryEntityManager")
            .build()
    }

    @Primary
    @Bean
    fun primaryTransactionManager(@Qualifier("primaryEntityManagerFactory") entityManagerFactory: EntityManagerFactory?): PlatformTransactionManager? {
        return JpaTransactionManager(entityManagerFactory!!)
    }

}
/**
 * QueryDSL도 Main과 Sub를 각각 지정 해 줘야함
 * **/
@Repository
abstract class PrimaryQueryDslSupport(domainClass: Class<*>) : QuerydslRepositorySupport(domainClass) {

    private var queryFactory: JPAQueryFactory by Delegates.notNull()

    @PersistenceContext(unitName = "primaryEntityManager")
    override fun setEntityManager(entityManager: EntityManager) {
        super.setEntityManager(entityManager)
        this.queryFactory = JPAQueryFactory(entityManager)
    }
}

기술스택

  • Frontend: Vue.js, JavaScript, HTML/CSS(SCSS)
  • Backend: Spring Boot, JPA(Query DSL), Kotlin
  • DB: mySQL
  • Server: AWS EC2
  • CI/CD : Jenkins, Docker
  • Version Control : AWS CodeCommit

성과

■ 구글 드라이브 및 서지로 관리하던 사내물품 및 자산정보를 시스템으로 관리 할 수 있도록 전환

■ 사내 메신저(잔디)와 연동해 비품구매요청 및 개인정보 변동 등 갱신되는 정보를 관리자에게 알림하는 서비스 제공

■ 시스템을 통해 검수 확인서/자산정보 작성 및 PDF 다운로드 기능 제공


개발경험

이 프로젝트를 통해 엔터티 설계와 멀티모듈 프로젝트를 구성하는 경험을 쌓았으며,

회사 내부의 자산 및 물품관리를 효율적으로 진행 할 수 있도록 기여하였습니다.

(3) 대학원 모집요강 설정 시스템


Gradle & Spring Gateway 기반 MSA 프로젝트







기간

2022.06 - 2022.12


주요 역할 및 담당

■ 설계(화면, 유스케이스 등), DB 재설계, FE / BE


주요 내용과 프로젝트 주안점

■ Recursive한 데이터를 CRUD 할 수 있는 트리구조의 DB 설계 및 비즈니스 로직 / 객체지향 컴포넌트 활용.


Frontend - Recursive Component


<!-- Parent Component -->
<template>
  
  <!-- 자기 자신을 재귀적으로 계속 호출해 Tree 구조를 만드는 RecursiveComponent를 호출 -->
  <div
      v-for="(node, index) in tree" :key="index">
    <RecursiveComponent
        :node="node">
    </RecursiveComponent>
  </div>
</template>
<script>
import RecursiveComponent from '@/components/global/recursive-component.vue'
export default {
  name: 'my-component',
  components: { RecursiveComponent },
  data: () => ({
    tree: [
      {
        leaves: []
      }
    ],
  }),
  mounted () {
    // mount 될 때 트리구조의 데이터를 RecursiveComponent에 props로 전달
  }
}
</script>

<!-- RecursiveComponent -->
<template>
  <!--...(중략)...-->
  <div>
    <!-- 자기 자신을 계속 호출하면서 tree 구조를 만들어 낸다 -->
    <recursive-component
        v-for="(child, childIndex) in node.leaves"
        :key="childIndex"
        :node="child"
    />
    <!--...(중략)...-->
  </div>
</template>

Recursive한 데이터 구조를 Front에 전달

@Service
@Transactional(readOnly = true)
class RecrPartLevelQueryService (

        private val recrPartLevelRepository: RecrPartLevelRepository,
        private val evalDeptPartRepository: EvalDeptPartRepository

) {

    /**
     * 서비스는 단순 호출만 담당하고
     * DTO에서 자식 노드들을 계속 불러와 DTO에 담는다.
     * **/
    
    fun getAllRecrPartLevelByTree(
            memberOid: Long,
            recrSchlCode: String,
            enterYear: String,
            recrPartSeq: Int): List<RecrPartLevelTreeOut> {
        SecurityUtil.checkMemberOid(memberOid)
        if (recrSchlCode.isBlank()) throw BasicRuntimeException(MessageUtil.getMessage("NO_SCHL_CODE"))
        if (enterYear.isBlank()) throw BasicRuntimeException(MessageUtil.getMessage("NO_ENTER_YEAR"))
        val recrPartLevelParents = recrPartLevelRepository.getTopNodesByRecrParts(recrSchlCode, enterYear, recrPartSeq)
        return recrPartLevelParents.map { RecrPartLevelTreeOut.fromEntity(it) }
    }
}


// DTO

data class RecrPartLevelTreeOut (
        val recrPartLevelNo: String,
        val parentRecrPartLevel: RecrPartLevelSimpleOut?,
        val level: Int,
        val lastYn: Boolean,
        val partCode: PartCodeOut,
        val applPartNo: Int?,
        val leaves: List<RecrPartLevelTreeOut>
) {
    companion object {
        fun fromEntity(e: RecrPartLevel): RecrPartLevelTreeOut {
            return RecrPartLevelTreeOut(
                    recrPartLevelNo = e.recrPartLevelNo(),
                    parentRecrPartLevel =
                    if (e.parentRecrPartLevel() == null) null
                    else RecrPartLevelSimpleOut.fromEntity(e.parentRecrPartLevel()!!),
                    level = e.level(),
                    lastYn = e.lastYn(),
                    partCode = PartCodeOut(
                            code = e.partCode(),
                            partKorName = e.partKorName(),
                            partEngName = e.partEngName()
                    ),
                    applPartNo = e.applPartNo(),
                    
                    // 자식노드들을 불러 올 때 자기 자신을 다시 map으로 변환해 호출하도록 fromEntity 메서드를 다시 활용
                    leaves = e.subLevel().map { fromEntity(it) }
            )
        }
    }
}

/**
 * Entity를 아래와 같이 구성해야
 * 자기 자신을 참조하는 재귀구조의 데이터 형태가 된다.
 * **/
@Entity
class RecrPartLevel (

        @Id
        val recrPartLevelNo: String,

        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumns(
                JoinColumn(
                        name = "recr_schl_code",
                        referencedColumnName = "recr_schl_code",
                        insertable = false,
                        updatable = false
                ),
                JoinColumn(
                        name = "enter_year",
                        referencedColumnName = "enter_year",
                        insertable = false,
                        updatable = false
                ),
                JoinColumn(
                        name = "recr_part_seq",
                        referencedColumnName = "recr_part_seq",
                        insertable = false,
                        updatable = false
                )
        )
        private val recrPartId: RecrPart,
        
        /**
         * ManyToOne / OneToMany 양방향 참조가 하나의 엔터티에서 이루어져야 한다.
         * **/

        // 자기 자신을 참조하기 위한 부모노드
        @ManyToOne
        @JoinColumn(
                name = "parent_recr_part_level_no",
        )
        val parentRecrPartLevel: RecrPartLevel?,

        // 자식노드
        @OneToMany(
                mappedBy = "parentRecrPartLevel",
                targetEntity = RecrPartLevel::class,
                fetch = FetchType.LAZY,
                cascade = [CascadeType.ALL],
                orphanRemoval = true
        )
        private val subLevel: MutableList<RecrPartLevel> = mutableListOf(),

        // 레벨
        val level: Int,
        
        // 마지막 노드 Yn
        val lastYn: Boolean
)

Recursive한 데이터를 풀어낼 때는 DFS를 사용

fun dfs (
        recrPartLevel: RecrPartLevel, // tree 구조의 level
        korName: String?, // 이외 다른 파라미터
        engName: String?,
        evalApplId: String?,
        schlReciYn: Boolean?,
        applId: String?,
        pageable: Pageable
): Page<ApplOut> {
    
    // return 해 줄 빈 배열
    val leaves: MutableList<Appl> = mutableListOf()

    // 한 번 방문 한 트리를 담아 낼 배열
    val visited: MutableList<RecrPartLevel> = mutableListOf()
    
    // queue에서 add / pop을 통해 while문을 실행
    val queue: Queue<RecrPartLevel> = LinkedList()
    queue.add(recrPartLevel)

    // queue가 다 비어있을 때 까지 실행
    while (queue.isNotEmpty()) {
        
        // 현재 node를 하나 꺼내온 후 방문 한 배열에 추가
        val currentNode = queue.poll()
        visited.add(currentNode)

        // 현재 노드의 자식 노드들
        val subLevel = currentNode.subLevel()
        
        // 자식 노드들이 없을 경우에만 return
        if (subLevel.isNotEmpty()) for (sub in subLevel) queue.add(sub)
    }
    
    // while 문이 끝나고 다 풀어진 트리구조의 데이터를 원하는 비즈니스 로직을 통해 처리
    for (v in visited) {
        if (v.lastYn()) {
            applRepository.getListByApplPartNoAndParams(
                    v.applPartNo()!!,
                    korName,
                    engName,
                    evalApplId,
                    schlReciYn,
                    applId
            ).forEach { leaves.add(it) }
        }
    }
    return ListToPageConverter.pageFromListAndPageable(leaves.map { ApplOut.fromEntity(it) }, pageable)
}

■ 단일 서비스에서 생성된 데이터는 모든 연결된 서비스가 API를 통해 공유할 수 있도록 MSA 설계 및 아키텍쳐를 구성.

■ 사용자 정보는 Gradnet 서비스와의 연동을 통해 단일 데이터베이스에서 관리되며, 인증 및 권한 부여 서비스는 별도로 구축해 MSA 환경에서 JWT를 활용한 토큰 인증 절차를 유지


기술스택

  • Frontend: Vue.js, JavaScript, HTML/CSS(SCSS)
  • Backend: Spring Boot, JPA(Query DSL), Kotlin, Java
  • DB: mySQL
  • Server: AWS EC2
  • CI/CD : AWS CodeBuild, AWS Pipeline, AWS EB / S3
  • Version Control : AWS CodeCommit

성과

■ 모든 대학원의 다양한 모집요강을 설정하고, (주)에이펙스소프트의 입학지원 시스템인 Gradnet과 선발평가 시스템인 ApplyNow를 효과적으로 연동할 수 있도록 설계

■ 각 대학원들마다 각자 다른 입학전형 및 지원정보를 하나의 플랫폼에서 구성 가능하도록 유연한 서비스 제공

■ (주)에이펙스소프트의 입학지원 시스템인 Gradnet에 등록된 학교 및 사용자 정보, 모집정보, 지원정보 등을 (주)에이펙스소프트의 다른 시스템들과 연동


개발경험

MSA를 구축하고 운영하는 과정에서 어려움이 있었지만, 다양한 참고 자료 및 이전 경험을 통해 프로젝트 초기 구축에 도움이 되었습니다.

이 프로젝트를 통해 MSA 아키텍처의 구축 방법 및 다양한 서비스 간 상호 작용 원리를 숙지하고, 복잡한 환경에서도 효율적으로 서비스를 제공하는 능력을 향상시키게 되었습니다

(4) Applynow 선발평가 시스템


Gradle & Spring Boot 기반 멀티모듈 프로젝트







기간

2023.07 - 2023.11


주요 역할 및 담당

■ 업무분석, DB 재설계, 화면 및 API 재설계, 레거시 코드 리팩토링 및 MSA 서비스 연결, FE / BE, 운영 및 유지보수


주요 내용과 프로젝트 주안점


■ 모든 대학원의 요구사항과 비즈니스 로직을 충족시킬 수 있도록 ERD와 화면 등을 재설계.

■ 각 학교만의 서로 다른 비즈니스 로직이나 화면 요구사항을 반영하기 위해 Vue Framework에서 컴포넌트 단위로 분기처리하거나 서버에서 학교정보를 기준으로 API를 분리해 유연성을 제고.


fun getPagedApplDoc(
            recrPartLevelNo: String,
            korName: String?,
            engName: String?,
            evalApplId: String?,
            schlReciYn: Boolean?,
            applId: String?,
            pageable: Pageable
    ): Page<ApplOut> {

        val recrPartLevel = recrPartLevelRepository.getById(recrPartLevelNo)

    
    /**
     * when 구문을 통해 기본 method가 정의되어 있고
     * 특정 조건에 해당하는 파라미터가 보내졌을 때 각자 다른 Domain을 호출
     * **/
        when {
            applId.isNullOrBlank() && evalApplId.isNullOrBlank() && schlReciYn == null-> {
                return dfs(recrPartLevel, korName, engName, evalApplId, schlReciYn, applId, pageable)
            }
            !applId.isNullOrBlank() && !evalApplId.isNullOrBlank() || schlReciYn != null-> {
                return ListToPageConverter.pageFromListAndPageable(
                        applRepository.getListByApplIdAndEvalApplIdAndSchlCode(applId, evalApplId, schlReciYn, recrPartLevel.recrPartId().recrSchlCode())
                                .map { ApplOut.fromEntity(it) }, pageable
                )
            }
            !applId.isNullOrBlank() -> {
                return ListToPageConverter.pageFromListAndPageable(
                        applRepository.getListByApplIdAndSchlCode(applId, recrPartLevel.recrPartId().recrSchlCode(), schlReciYn)
                                .map { ApplOut.fromEntity(it) }, pageable
                )
            }
            !evalApplId.isNullOrBlank() -> {
                return ListToPageConverter.pageFromListAndPageable(
                        applRepository.getListByEvalApplIdAndSchlCode(evalApplId, recrPartLevel.recrPartId().recrSchlCode(), schlReciYn)
                                .map { ApplOut.fromEntity(it) }, pageable
                )
            }
            else -> {
                return dfs(recrPartLevel, korName, engName, evalApplId, schlReciYn, applId, pageable)
            }
        }
    }


■ Zoom API를 연동해 대면 면접 프로토타입 개발

// Controller

@Tag(name = "zoom api 컨트롤러")
@RestController
@RequestMapping("/api/zoom")
class ZoomController (

        private val zoomQueryService: ZoomQueryService
) {

    @Operation(summary = "zoom token 발급")
    @GetMapping("/access-token")
    fun getAccessToken() = ResponseEntity.ok(zoomQueryService.getAccessToken())

    @Operation(summary = "zoom meeting url 발급")
    @PostMapping("/meetings")
    fun getMeetings(
            @RequestParam("accessToken") accessToken: String
    ) = ResponseEntity.ok(zoomQueryService.getMeetings(accessToken))

}

// Service

@Service
@Transactional(readOnly = true)
class ZoomQueryService (

        @Value("\${zoom.oauth.endPoint}")
        private val ZOOM_OAUTH_ENDPOINT: String,

        @Value("\${zoom.oauth.accountId}")
        private val ZOOM_ACCOUNT_ID: String,

        @Value("\${zoom.oauth.clientId}")
        private val ZOOM_CLIENT_ID: String,

        @Value("\${zoom.oauth.clientSecret}")
        private val ZOOM_CLIENT_SECRET: String,

        @Value("\${zoom.oauth.userId}")
        private val ZOOM_USER_ID: String,

        ) {
    
    fun getAccessToken(): String {
        
        val authHeader = "Basic ${getBase64EncodedCredentials()}"

        val headers = HttpHeaders()
        headers.set("Authorization", authHeader)
        headers.contentType = MediaType.APPLICATION_FORM_URLENCODED

        val body: MultiValueMap<String, String> = LinkedMultiValueMap()
        body.add("grant_type", "account_credentials")
        body.add("account_id", ZOOM_ACCOUNT_ID)

        val requestEntity = HttpEntity(body, headers)
        val responseEntity: ResponseEntity<Map<*, *>> = RestTemplate().postForEntity(ZOOM_OAUTH_ENDPOINT, requestEntity, Map::class.java)

        return responseEntity.body!!["access_token"].toString()
    }

    // 회의실 url을 Front에 전달 후, 지원자의 email로 회의실 링크 전달
    fun getMeetings(accessToken: String): String {

        val headers = HttpHeaders()
        headers.set("Authorization", "Bearer $accessToken")
        headers.contentType = MediaType.APPLICATION_JSON

        val requestEntity = HttpEntity({}, headers)
        
        /**
         * zoom host로 post요청을 통해 ResponseBody중 url만 획득
         * 다른 Res가 필요 할 경우 DTO로 변환 후 return 필요
         * **/
        val responseEntity: ResponseEntity<Map<*, *>> = RestTemplate().postForEntity("https://api.zoom.us/v2/users/$ZOOM_USER_ID/meetings", requestEntity, Map::class.java)
        
        return responseEntity.body!!["join_url"].toString()
    }

    private fun getBase64EncodedCredentials(): String {
        
        val credentials = "$ZOOM_CLIENT_ID:$ZOOM_CLIENT_SECRET"
        return String(java.util.Base64.getEncoder().encode(credentials.toByteArray()))
        
    }
}

기술스택

  • Frontend: Vue.js, JavaScript, HTML/CSS(SCSS)
  • Backend: Spring Boot, JPA(Query DSL), Kotlin, Java
  • DB: mySQL
  • Server: AWS EC2
  • CI/CD : AWS CodeBuild, AWS Pipeline, AWS EB / S3
  • Version Control : AWS CodeCommit

성과

■ (주)에이펙스소프트의 입학지원 시스템인 Gradnet의 지원 정보 및 모집 정보와 연계해 여러 대학원의 입학지원 내용을 제공하고 평가 업무를 지원.

■ 특정 대학원만 사용 가능했던 서비스를 재설계 및 리팩토링해 여러 대학원이 사용할 수 있는 유연한 플랫폼으로 개발.

■ 학교 및 입학담당자에게 통계 자료를 제공.


개발경험

이 프로젝트를 통해 다양한 요구사항을 수용할 수 있는 플랫폼을 어떻게 설계하고 개발해야 하는지의 경험을 쌓을 수 있었으며, 시스템의 확장성 및 유연성에 대한 고민을 통해 프로젝트에 적용 할 수 있었습니다

(5) 건프라리스트(개인)


안드로이드 스튜디오를 통한 웹앱 프로젝트

웹버전 바로가기

안드로이드 다운로드







기간

2023.08 - 2023.08


주요 역할 및 담당

■ 개발의 모든 과정


주요 내용과 프로젝트 주안점

■ 반응형 웹으로 개발되어 다양한 해상도와 환경에서 시스템을 사용.

■ 안드로이드 웹뷰를 통해서도 배포되었기 때문에 모바일 환경에서도 어플로서 시스템 사용 가능

public class MainActivity extends AppCompatActivity {
    
    // 안드로이드 웹뷰 설정

    private WebView webView;
    private final String url = "https://hello-gunpla-list.xyz";
    private long backPressedTime = 0;

    @SuppressLint("SetJavaScriptEnabled")
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        /**
         * 인터넷 연결이 없을 때 알림
         * **/
        if(!isNetworkAvailable(this)){
            Toast toast = Toast.makeText(this, "인터넷 연결을 확인해주세요.", Toast.LENGTH_LONG);
            toast.setGravity(Gravity.CENTER, Gravity.CENTER_HORIZONTAL, Gravity.CENTER_VERTICAL);
            toast.show();

            ActivityCompat.finishAffinity(this);
        }

        // 캡쳐 방지
//        getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE);

        // 세로모드로 고정 + Manifest.xml portrait 추가 해야함
        setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);

        webView = findViewById(R.id.gunpla_list_app);

        // 웹뷰에서 자바 스크립트로 빌드 된 화면을 렌더링 할 수 있도록 설정
        webView.getSettings().setJavaScriptEnabled(true);
        webView.getSettings().setDomStorageEnabled(true);

        // 웹뷰가 동작할 때 호환되는 브라우저 설정 - Front 개발 환경에 맞게 Chrome으로 설정함
        webView.setWebChromeClient(new WebChromeClient());

        // 웹뷰 로드 시 캐시 및 히스토리를 삭제 해 웹 업데이트를 반영하도록 함
        webView.clearCache(true);
        webView.clearHistory();
        webView.loadUrl(url);

    }

    // 네트워크가 없을 때
    private Boolean isNetworkAvailable(MainActivity application) {
        ConnectivityManager connectivityManager = (ConnectivityManager) application.getSystemService(Context.CONNECTIVITY_SERVICE);
        Network nw = connectivityManager.getActiveNetwork();
        if (nw == null) return false;
        NetworkCapabilities actNw = connectivityManager.getNetworkCapabilities(nw);
        return actNw != null && (actNw.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || actNw.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || actNw.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) || actNw.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH));
    }

    @Override
    public boolean onKeyDown(int keyCode, KeyEvent event) {

        long tempTime = System.currentTimeMillis();
        long intervalTime = tempTime - backPressedTime;

        if ((keyCode == KeyEvent.KEYCODE_BACK)) {

            // 로그인 혹은 메인 화면에서 뒤로가기 클릭 시 앱 종료 토스트 발생
            if(webView.getUrl().equals(url + "/#/") ||
                    webView.getUrl().equals(url + "/#/login") ||
                    webView.getUrl().equals(url + "/#/user/gunpla-list")
            ) {
                long FINISH_INTERNAL_TIME = 2000;
                if (0 <= intervalTime && FINISH_INTERNAL_TIME >= intervalTime) finish();
                else {
                    backPressedTime = tempTime;
                    Toast.makeText(getApplicationContext(), "한번 더 누르면 앱이 종료됩니다.", Toast.LENGTH_SHORT).show();
                }
            } else {
                webView.goBack();
            }
            return true;
        }

        return super.onKeyDown(keyCode, event);
    }
}

■ 월별 구매내역 및 해당 월에 대한 상세 구매내역, 제작 진도 등의 다양한 통계정보 제공

■ 게임적 요소인 업적 시스템 도입


기술스택

  • Frontend: Vue.js, JavaScript, HTML/CSS(SCSS)
  • Backend: Spring Boot, JPA(Query DSL), Kotlin, Java
  • DB: mySQL
  • Server: AWS EC2
  • CI/CD : AWS CodeBuild, AWS Pipeline, AWS EB / S3
  • Version Control : AWS CodeCommit

성과

■ 엑셀로 작업했던 구매내역 및 수집내역 등을 시스템으로 전환

■ 월별통계 및 프라모델 제작 진도 확인

■ 가계부를 작성 할 때 마다 달성되는 다양한 업적 시스템 제공


개발경험

아직은 단순 CRUD와 통계 정보만 제공하지만 추후 사용자들의 의견을 반영해 여러 요구사항을 만족시키고, 가계부 정보를 엑셀로 다운받을 수 있는 등 다양한 기능을 추가하면서 지속적인 시스템 운영을 해 나갈 예정입니다.

(6) 프레임워크 마이그레이션



git clone 및 둘러보기


기간

2023.11 - 2023.11


주요 내용과 마이그레이션 주안점

■ Spring Boot 및 일부 모듈 버전 업

  • Spring boot ver 2.5.4 -> 2.7.7
  • Gradle ver 6.9.1 -> 7.4.1
  • JAVA ver 11.0.15.1
  • Kotlin ver 1.5.21 -> 1.6.21
  • JPA ver 1.5.21 -> 1.6.21
  • QueryDSL ver 4.4.0 -> 5.0.0

■ QueryDsl과 GraphQl 모두 적용

QueryDsl

Java 언어를 위한 SQL 쿼리 생성 라이브러리, 데이터베이스와 상호작용할 때, 문자열 기반의 SQL 쿼리보다 더욱 안전하고 유연한 방식으로 쿼리를 작성할 수 있게 해줍니다.

또한 코드 기반으로 쿼리를 작성하며 컴파일 시점에서 오타나 잘못된 접근을 방지하고 다양한 데이터베이스에 대한 지원을 제공하며, 여러 종류의 프로젝트에서 유연하게 활용할 수 있습니다.

JPA나 Hibernate와 같은 ORM(Object-Relational Mapping) 프레임워크와 함께 사용되며, 객체 지향적인 방식으로 쿼리를 작성할 수 있도록 도와줍니다.

GraphQl

GraphQL은 페이스북에서 개발한 쿼리 언어로서, 데이터를 관리하는 유연하고 효율적인 방법을 제공하는 쿼리 언어와 런타임 환경입니다.

RESTful API와는 다르게 클라이언트가 필요한 데이터를 직접 명시할 수 있고, 이로써 필요한 정보만 정확히 가져올 수 있으며, 하나의 엔드포인트 만을 가지기 때문에 여러 요청을 하나로 통합할 수 있습니다.

한계점

GraphQl은 @SchemaMapping 방식과 Resolver 방식으로 나뉘는데, 멀티모듈 환경의 코틀린 스프링부트에서 SchemaMapping은 정상 작동하지 않음을 확인(23.11.19), 따라서 Resolver 방식으로 개발을 진행했습니다.

SchemaMapping은 기본적으로 SpringBoot 2.7.x 이상 버전부터 지원하고 Resolver가 내장되어 있으나 Mutation Query는 잘 작동하지만 Query 요청 시 return 값이 blank가 전달되는 것을 확인했습니다.

디렉토리 구조

├── api
│   ├── config
│   ├── controller
│   │   ├── restapi * QueryDsl과 ResponseEntity를 사용하는 기존의 RESTAPI 호출방식
│   │   ├── graphql * GraphQl을 사용하는 호출방식
│   ├── dto
│   ├── filter
│   ├── intercepter
│   ├── service
│   │   ├── command
│   │   ├── query
│   │   ├── firebase
├── *.graphqls
├── buildSrc
├── common
│   ├── domain
│   │   ├── _common
│   │   ├── config
│   │   ├── converter
│   │   ├── exception
│   │   ├── model
│   │   ├── repository
│───├── lib
│   │   ├── config
│   │   ├── error
│   │   ├── security
│   │   │   ├── jwt
│───│───├── utils
plugins {
    val kotlinVersion = "1.6.21"

    id("org.springframework.boot") version "2.7.7"
    id("io.spring.dependency-management") version "1.0.15.RELEASE"
    kotlin("jvm")
    kotlin("plugin.spring") version kotlinVersion
    kotlin("plugin.jpa") version kotlinVersion
}

// QueryDsl
implementation("com.querydsl:querydsl-jpa:5.0.0")
kapt("com.querydsl:querydsl-apt:5.0.0:jpa")

// GraphQl
implementation("com.graphql-java-kickstart:graphql-spring-boot-starter:11.0.0")
implementation("com.graphql-java-kickstart:playground-spring-boot-starter:11.0.0")
/**
 * Url: http://localhost:8080/graphql
 * Https Method: POST
 * Header: 'Content-Type : application/json'
 * **/
@Controller
@Transactional
class UserGraphController (

    private val userRepository: UserRepository,
    private val passwordEncoder: PasswordEncoder

        ) : GraphQLQueryResolver, GraphQLMutationResolver {

    /**
    {
    "query": "{ findAllUsers { oid userId name email } }"
    }
     **/
    fun findAllUsers(): List<User> = userRepository.findAll()

    /**
    {
        "query": "query ($userId: String!) { getByUserId(userId: $userId) { oid, userId, name, email } }",
            "variables": {
                "userId": "userId"
            }
    }
     **/
    fun getByUserId(
        @RequestParam userId: String
    ) = userRepository.getByUserId(userId)

    /**
    {
        "query": "mutation ($signUpIn: SignUpIn!) { createUser(signUpIn: $signUpIn) { oid, userId, name, email } }",
            "variables": {
                "signUpIn": {
                    "userId": "newUserID",
                    "name": "New User",
                    "email": "newuser@example.com",
                    "password": "NewPassword"
            }
        }
    }
    **/

    fun createUser(
        @RequestBody signUpIn: SignUpIn
    ) = userRepository.save(signUpIn.toEntity(passwordEncoder))

    /**
    {
        "query": "mutation ($userOid: ID!) { deleteUserByUserOid(userOid: $userOid) }",
            "variables": {
                "userOid": "Some UserOid"
            }
    }
     **/
    fun deleteUserByUserOid(
        @RequestParam userOid: Long
    ) = userRepository.delete(userRepository.getByOid(userOid))

    /**
    {
        "query": "mutation updateUserByUserOid($userOid: ID!, $userUpdateIn: UserUpdateIn!) { updateUserByUserOid(userOid: $userOid, userUpdateIn: $userUpdateIn) { oid, userId, name, email } }",
            "variables": {
                "userOid": "1",
                "userUpdateIn": {
                    "name": "NewName",
                    "email": "newemail@example.com"
            }
        }
    }
    **/
    fun updateUserByUserOid(
        @RequestParam userOid: Long,
        @RequestBody userUpdateIn: UserUpdateIn
    ) {
        val user = userRepository.getByOid(userOid)
        user.updateWith(User.NewValue(
            name = userUpdateIn.name,
            email = userUpdateIn.email
        ))
    }
}
# user.graphqls

type User {
    oid: ID!
    userId: String!
    name: String!
    email: String!
}

enum Role {
    ROLE_USER
    ROLE_MANAGER
    ROLE_SYS_ADMIN
}

enum Status {
    ACTIVE
    WITHDRAW
    INACTIVE
}

input SignUpIn {
    userId: String!
    name: String!
    email: String!
    password: String!
}

input UserUpdateIn {
    name: String!
    email: String!
}

type Query {
    findAllUsers: [User!],
    getByUserId(userId: String!): User!
}

type Mutation {
    createUser(signUpIn: SignUpIn): User,
    deleteUserByUserOid(userOid: ID!): Boolean,
    updateUserByUserOid(userOid: ID!, userUpdateIn: UserUpdateIn!): Boolean
}


성과

■ 사내 프레임워크의 핵심 기능과 의존관계를 해치지 않으면서 안정성 있는 프레임워크 최신화

■ QueryDSL의 동적 쿼리 방식과 GraphQl을 동시에 사용 할 수 있도록 마이그레이션


개발경험

라이브러리 의존성과 기능을 해치지 않으면서 마이그레이션하는 과정은 생소하고 어려웠지만, 이러한 작업을 통해 더욱 유연하고 모든 요청에 대한 적절한 응답을 제공하는 서버를 개발한 데 자부심을 느꼈습니다.

Frontend / Backend 디렉토리


Frontend - Vue.js

├── api
│   ├── *.js				# composion api 사용 
│   ├── download.js			# s3 파일다운로드
├── assets
│   ├── css
│   ├── img
│   ├── lang
│   │   ├── general			# 라벨 다국어
│   │   ├── veeValidate		# validation 사용 다국어
├── components
│   ├── global
│   │   ├── index.js
│   │   ├── *.vue
│   ├── layout   			# 헤더,사이드바,푸터
│   ├── nav-language.vue	# 다국어 선택
├── kindergarten			# 인증,인가처리
│   ├── governesses
│   ├── perimeters
│   ├── childs.js
├── mixins
│   ├── global
│   │   ├── index.js
│   │   ├── *.js
│   ├── *.js
├── plugins
│   ├── index.js
│   ├── i18n.js				# 다국어 설정
│   ├── vue-validate.js		# validation
│   ├── vue3-cookies.js		# cookie 설정
├── router
│   ├── index.js			# 라우터 설정
│   ├── routes.js			# view와 url 연결
├── store
│   ├── errors.js			# 에러토스트(최상단)
│   ├── i18n.js				# 다국어	
│   ├── loading.js		
│   ├── users.js			# 회원(로그인,로그아웃,회원가입)
├── utils
│   ├── *.js
├── views
│   ├── *.vue
├── wrapper					# 기존 플러그인들을 wrapper로 재정의
│   ├── ajax.js				# axios
│   ├── sweet-alert.js		# sweetAlert 
│   ├── s3.js				# aws s3 업로드 ,다운로드
├── App.vue
├── main.js

Backend - Spring Boot


├── api
│   ├── config 				# JPA / Spring Security 등 설정
│   ├── controller  		# User 권한에 따라 컨트롤러를 분리 
│   ├── dto   				# IN / OUT Domain 변환 
│   ├── exception   		 
│   ├── filter   			# http Req, Res 를 여러번 읽을 수 있도록 ContentCaching Wrapper 사용
│   ├── handler   			# 공통 예외처리 등 핸들러 
│   ├── intercepter   		# 인터셉터 
│   ├── service   			# CQRS 원칙에 맞는 Service 
│   │   ├── query           
│   │   ├── command         
│   ├── util   				# 이외 유틸성 클래스
├── buildSrc                # kapt 적용
├── common                  
│   ├── domain              # 핵심 비즈니스가 담긴 Domain
│   │   ├── config
│   │   ├── converter       # paging 등 객체 <-> 모델 converter
│   │   ├── exception       
│   │   ├── model           # Entity
│   │   ├── projection      # Projection Entity
│   │   ├── repository      
├── lib   			        # 이외 유틸성 라이브러리
│   ├── aws                 # AWS
│   ├── security            # Spring Security, JWT..
│   ├── *                   # 이외 라이브러리
Last Updated: 2/28/2024, 11:02:53 AM