diff --git a/.github/workflows/backend_pr_bot.yml b/.github/workflows/backend_pr_bot.yml
new file mode 100644
index 00000000..2b7ecaa4
--- /dev/null
+++ b/.github/workflows/backend_pr_bot.yml
@@ -0,0 +1,67 @@
+name: Java Spring Build Checker
+
+on:
+ pull_request:
+ paths:
+ - 'backend/src/**'
+ - 'backend/build.gradle'
+ branches:
+ - backend
+ workflow_dispatch:
+ inputs:
+ comment:
+ description: '수동 trigger 사유'
+ default: 'ex) 서버 설정 변경 등'
+
+permissions:
+ checks: write
+ pull-requests: write
+
+jobs:
+ build:
+ name: Build check
+ runs-on: ubuntu-latest
+ env:
+ working-directory: backend
+ steps:
+ - name: Checkout the code
+ uses: actions/checkout@v3
+
+ - name: Cache Gradle packages
+ uses: actions/cache@v3
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
+ restore-keys: |
+ ${{ runner.os }}-gradle-
+ - name: Set up JDK 17
+ uses: actions/setup-java@v3
+ with:
+ java-version: '17'
+ distribution: 'corretto'
+ cache: 'gradle'
+
+ - name: Setup MySQL
+ uses: mirromutth/mysql-action@v1.1
+ with:
+ mysql database: 'techpick_db'
+ mysql user: ${{ secrets.DOCKER_MYSQL_USERNAME }}
+ mysql password: ${{ secrets.DOCKER_MYSQL_PASSWORD }}
+
+# - name: Make application.yaml
+# run: |
+# cd ./src/main/resources
+# touch ./application.yaml
+# echo "${{ secrets.APPLICATION_YAML }}" >> ./application.yaml
+# shell: bash
+# working-directory: ${{ env.working-directory }}
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+ working-directory: ${{ env.working-directory }}
+
+ - name: Build with Gradle
+ run: ./gradlew clean build --debug --exclude-task test
+ working-directory: ${{ env.working-directory }}
\ No newline at end of file
diff --git a/.github/workflows/test-server-deploy.yml b/.github/workflows/test-server-deploy.yml
new file mode 100644
index 00000000..91f2aa52
--- /dev/null
+++ b/.github/workflows/test-server-deploy.yml
@@ -0,0 +1,122 @@
+
+# Reference
+# - (1) https://docs.docker.com/build/ci/github-actions/#examples
+# - (2) https://github.com/Kernel360/E2E2-LOOK-US/blob/deploy/.github/workflows/deploy.yml
+
+name: Tech-pick V2 Testing Server Deploy
+
+on:
+ push:
+ branches:
+ - "backend-v2" # backend v2 브랜치
+env:
+ api-version: 'v2' # 브랜치 명과 반드시 동일할 것!!!
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ -
+ # 저장소 Checkout
+ name: Checkout source code
+ uses: actions/checkout@v4
+ -
+ # Gradle 실행 권한 부여
+ name: Grant execute permission to gradlew
+ run: chmod +x ./backend/gradlew
+ -
+ # JDK 설치
+ name: Set up JDK corretto:17
+ uses: actions/setup-java@v4
+ with:
+ java-version: '17'
+ distribution: 'corretto'
+ -
+ # Spring boot application 빌드
+ name: Build with gradle
+ run: |
+ cd ./backend
+ ./gradlew clean build -x test
+ -
+ # Docker image 빌드
+ name: Build docker image
+ run: |
+ cd ./backend
+ docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/techpick:${{ env.api-version }}-${{ github.sha }} .
+ -
+ # Docker hub 로그인
+ name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+ -
+ # Docker hub 업로드
+ name: Publish to docker hub
+ run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/techpick:${{ env.api-version }}-${{ github.sha }}
+ -
+ # 서버 ssh 접속 후 방금 올린 이미지 pull 받고 실행
+ name: Deploy on Test-Server
+ uses: appleboy/ssh-action@master
+ with:
+ host: minlife.me # test home server
+ port: 4242
+ username: root # root user
+ password: ${{ secrets.SSH_TEST_SERVER_KYEU_PASSWORD }}
+ script: |
+
+ echo "login docker hub for private repository access ..."
+ echo ${{ secrets.DOCKERHUB_TOKEN }} | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin
+
+ echo "docker - pulling..."
+ docker pull ${{ secrets.DOCKERHUB_USERNAME }}/techpick:${{ env.api-version }}-${{ github.sha }}
+
+ echo "docker - changing image name and tag ..."
+ docker tag ${{ secrets.DOCKERHUB_USERNAME }}/techpick:${{ env.api-version }}-${{ github.sha }} techpick:${{ env.api-version }}-staging
+
+ cd /home/project/techpick
+
+ echo "create .env file with github repository's ENV setting ..."
+
+ cd /home/project/techpick/database
+ rm -rf .env
+ touch .env
+ echo "DOCKER_MYSQL_USERNAME=${{ secrets.DOCKER_MYSQL_USERNAME }}" >> .env
+ echo "DOCKER_MYSQL_PASSWORD=${{ secrets.DOCKER_MYSQL_PASSWORD }}" >> .env
+
+ cd /home/project/techpick/${{ env.api-version }}
+ rm -rf .env
+ touch .env
+
+ echo "DOCKER_MYSQL_USERNAME=${{ secrets.DOCKER_MYSQL_USERNAME }}" >> .env
+ echo "DOCKER_MYSQL_PASSWORD=${{ secrets.DOCKER_MYSQL_PASSWORD }}" >> .env
+ echo "DOCKER_MYSQL_DATABASE=${{ secrets.DOCKER_MYSQL_DATABASE }}_${{ env.api-version }}" >> .env
+ echo "DOCKER_MYSQL_URL=jdbc:mysql://techpick-mysql:3306/${{ secrets.DOCKER_MYSQL_DATABASE }}_${{ env.api-version }}?createDatabaseIfNotExist=true" >> .env
+ echo "JWT_SECRET=${{ secrets.JWT_SECRET }}" >> .env
+ echo "JWT_ISSUER=${{ secrets.JWT_ISSUER }}" >> .env
+ echo "GOOGLE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }}" >> .env
+ echo "GOOGLE_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }}" >> .env
+ echo "KAKAO_CLIENT_ID=${{ secrets.KAKAO_CLIENT_ID }}" >> .env
+ echo "KAKAO_CLIENT_SECRET=${{ secrets.KAKAO_CLIENT_SECRET }}" >> .env
+ echo "NAVER_CLIENT_ID=${{ secrets.NAVER_CLIENT_ID }}" >> .env
+ echo "NAVER_CLIENT_SECRET=${{ secrets.NAVER_CLIENT_SECRET }}" >> .env
+ echo "TECHPICK_BASE_URL=https://${{ env.api-version }}.${{ secrets.TECHPICK_DOMAIN }}" >> .env
+
+ docker-compose down
+ docker compose up -d
+
+ echo "docker - pruning images that passed 24h ..."
+ docker image prune -af --filter "until=24h"
+ -
+ # 배포 Action 실행 결과 깃허브 알림
+ name: Discord Webhook Action
+ uses: sarisia/actions-status-discord@v1.14.7
+ if: always()
+ with:
+ webhook: ${{ secrets.DISCORD_WEBHOOK_URL }}
+ status: ${{ job.status }}
+ title: "Test API ${{ env.api-version }} Deployment Action Result"
+ description: "테스트 API ${{ env.api-version }} 서버 배포 완료"
+ color: 0xff91a4
+ url: "https://github.com/sarisia/actions-status-discord"
+ username: GitHub Actions
diff --git a/backend/.env.sample b/backend/.env.sample
new file mode 100644
index 00000000..858071c3
--- /dev/null
+++ b/backend/.env.sample
@@ -0,0 +1,18 @@
+DOCKER_MYSQL_USERNAME=root
+DOCKER_MYSQL_PASSWORD=1234
+DOCKER_MYSQL_DATABASE=TECHPICK_DB
+DOCKER_MYSQL_URL=jdbc:mysql://localhost:3306/TECHPICK_DB?createDatabaseIfNotExist=true
+
+TECHPICK_BASE_URL=localhost:8080
+
+JWT_ISSUER=TECHPICK
+JWT_SECRET=
+
+GOOGLE_CLIENT_ID=
+GOOGLE_CLIENT_SECRET=
+
+KAKAO_CLIENT_ID=
+KAKAO_CLIENT_SECRET=
+
+NAVER_CLIENT_ID=
+NAVER_CLIENT_SECRET=
diff --git a/backend/.gitignore b/backend/.gitignore
new file mode 100644
index 00000000..4f088d4c
--- /dev/null
+++ b/backend/.gitignore
@@ -0,0 +1,44 @@
+*.env
+data/mysql-data
+
+HELP.md
+.gradle
+build/
+!gradle/wrapper/gradle-wrapper.jar
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+bin/
+!**/src/main/**/bin/
+!**/src/test/**/bin/
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+out/
+!**/src/main/**/out/
+!**/src/test/**/out/
+ ### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+
+### VS Code ###
+.vscode/
+
+.DS_Store
+._.DS_Store
+**/.DS_Store
+**/._.DS_Store
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 00000000..11171eec
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,13 @@
+FROM amazoncorretto:17
+
+LABEL authors="TechPick"
+
+ARG JAR_FILE=build/libs/*.jar
+
+WORKDIR /app
+
+COPY ${JAR_FILE} /app/app.jar
+
+EXPOSE 8080
+
+ENTRYPOINT ["java", "-jar", "-Duser.timezone=Asia/Seoul", "/app/app.jar"]
diff --git a/backend/build.gradle b/backend/build.gradle
new file mode 100644
index 00000000..422e1ecb
--- /dev/null
+++ b/backend/build.gradle
@@ -0,0 +1,77 @@
+plugins {
+ id 'java'
+ id 'org.springframework.boot' version '3.3.3'
+ id 'io.spring.dependency-management' version '1.1.6'
+}
+
+group = 'kernel360'
+version = '0.0.1-SNAPSHOT'
+
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(17)
+ }
+}
+
+configurations {
+ compileOnly {
+ extendsFrom annotationProcessor
+ }
+}
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ // spring boot
+ implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+ implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.17.2'
+ implementation 'org.springframework.retry:spring-retry'
+ implementation 'org.springframework.boot:spring-boot-starter-validation'
+
+ runtimeOnly 'com.mysql:mysql-connector-j'
+
+ // lombok annotation
+ compileOnly 'org.projectlombok:lombok'
+ annotationProcessor 'org.projectlombok:lombok'
+ testCompileOnly 'org.projectlombok:lombok'
+ testAnnotationProcessor 'org.projectlombok:lombok'
+
+ // Sql logging formatter
+ // reference: https://www.baeldung.com/java-p6spy-intercept-sql-logging
+ implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.2' //이쁘게
+
+ // logback logger
+ implementation 'ch.qos.logback:logback-classic:1.4.12'
+ implementation 'org.slf4j:slf4j-api:2.0.3'
+
+ // test environment
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+ testRuntimeOnly 'com.mysql:mysql-connector-j'
+
+ // springdoc swagger dependency
+ implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0'
+
+ // spring security and oauth client
+ implementation 'org.springframework.boot:spring-boot-starter-security'
+ implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
+ testImplementation 'org.springframework.security:spring-security-test'
+
+ // jwt
+ implementation 'io.jsonwebtoken:jjwt:0.9.1'
+ implementation 'javax.xml.bind:jaxb-api:2.3.1' //xml 문서와 자바 객체 간 매핑을 자동화
+
+ // Thymeleaf 테스트 혹은 관리자에서 추후에 사용하기 위해서 추가
+ implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
+
+ // map struct
+ implementation 'org.mapstruct:mapstruct:1.5.3.Final'
+ annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.3.Final'
+}
+
+tasks.named('test') {
+ useJUnitPlatform()
+}
diff --git a/backend/data/mysql-files/data.sql b/backend/data/mysql-files/data.sql
new file mode 100644
index 00000000..e2328c9f
--- /dev/null
+++ b/backend/data/mysql-files/data.sql
@@ -0,0 +1,9 @@
+-- BULK INSERT (STATIC DATA)
+
+LOAD DATA INFILE '/var/lib/mysql-files/develop/rss_blog.csv'
+ INTO TABLE rss_blog
+ FIELDS TERMINATED BY ','
+ LINES TERMINATED BY '\n'
+ IGNORE 1 LINES
+ (@source_name, @url)
+ SET rss_feed_url = @url, created_at = now(), updated_at = now();
diff --git a/backend/data/mysql-files/rss_blog.csv b/backend/data/mysql-files/rss_blog.csv
new file mode 100644
index 00000000..dfdd59e3
--- /dev/null
+++ b/backend/data/mysql-files/rss_blog.csv
@@ -0,0 +1,26 @@
+source_name,url
+카카오페이,https://tech.kakaopay.com/rss
+NHN Cloud,https://meetup.toast.com/rss
+데브시스터즈,https://tech.devsisters.com/rss.xml
+뱅크샐러드,https://blog.banksalad.com/rss.xml
+마켓컬리,https://helloworld.kurly.com/feed
+Gmarket,https://dev.gmarket.com/rss
+무신사,https://medium.com/feed/musinsa-tech
+여기어때,https://techblog.gccompany.co.kr/feed
+올리브영,https://oliveyoung.tech/rss.xml
+AWS,https://aws.amazon.com/ko/blogs/korea/feed/
+Toss,https://toss.tech/rss.xml
+우아한형제들,https://techblog.woowahan.com/feed/
+카카오,https://tech.kakao.com/posts/feed
+라인,https://techblog.lycorp.co.jp/ko/feed/index.xml
+SK플래닛,https://techtopic.skplanet.com/rss
+스마일게이트,https://smilegate.ai/feed/
+29CM,https://medium.com/feed/29cm
+CJ OnStyle,https://medium.com/feed/cj-onstyle
+브랜디,https://labs.brandi.co.kr/feed
+넷마블,https://netmarble.engineering/rss
+11번가,https://11st-tech.github.io/rss/
+원티드,https://medium.com/feed/wantedjobs
+인프랩,https://tech.inflab.com/rss.xml
+티빙,https://medium.com/feed/tving-team
+리디,https://ridicorp.com/story-category/tech-blog/feed/
\ No newline at end of file
diff --git a/backend/docker-compose.yaml b/backend/docker-compose.yaml
new file mode 100644
index 00000000..530ab130
--- /dev/null
+++ b/backend/docker-compose.yaml
@@ -0,0 +1,34 @@
+# 참고 설정
+# https://github.com/Kernel360/E2E2-TALKKA/blob/develop/docker-compose.yaml
+# https://github.com/Kernel360/E2E2-LOOK-US/blob/develop/backend/docker-compose.yaml
+
+# Local 테스트 전용 Compose 입니다.
+# 실행시 앱 이미지를 로컬에 빌드합니다. (배포 버전에서는 빌드하지 않고, 도커 허브에서 가져옴)
+
+# name: techpick-dev
+
+networks:
+ techpick-network:
+ driver: bridge
+
+services:
+ techpick-mysql:
+ image: mysql:8.0
+ container_name: techpick-mysql
+ ports:
+ - "3306:3306"
+ env_file:
+ - .env
+ environment:
+ - MYSQL_ROOT_PASSWORD=${DOCKER_MYSQL_PASSWORD}
+ - MYSQL_DATABASE=${DOCKER_MYSQL_DATABASE}
+ - TZ=Asia/Seoul
+ volumes:
+ - ./data/develop/mysql-data:/var/lib/mysql
+ - ./data/develop/mysql-files:/var/lib/mysql-files/develop
+ command:
+ - --character-set-server=utf8mb4
+ - --collation-server=utf8mb4_unicode_ci
+ - --lower_case_table_names=1
+ networks:
+ - techpick-network
\ No newline at end of file
diff --git a/backend/gradle/wrapper/gradle-wrapper.jar b/backend/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 00000000..a4b76b95
Binary files /dev/null and b/backend/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/backend/gradle/wrapper/gradle-wrapper.properties b/backend/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..0aaefbca
--- /dev/null
+++ b/backend/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/backend/gradlew b/backend/gradlew
new file mode 100755
index 00000000..f5feea6d
--- /dev/null
+++ b/backend/gradlew
@@ -0,0 +1,252 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
+' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/backend/gradlew.bat b/backend/gradlew.bat
new file mode 100644
index 00000000..9d21a218
--- /dev/null
+++ b/backend/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/backend/project-setup/README.md b/backend/project-setup/README.md
new file mode 100644
index 00000000..9deb3a81
--- /dev/null
+++ b/backend/project-setup/README.md
@@ -0,0 +1,21 @@
+### 팀 개발 환경 가이드
+
+##### (0) JDK 버전
+- JDK : `amazoncorretto:17`
+
+##### (1) 포매터 설정
+- IntelliJ 설정
+ - Code Formatter 설정
+ - ./formatter 에 위치한 `naver-intellij-formatter.xml` 파일을 이용하여 설정
+ - Settings -> CodeStyle -> Java -> Import Scheme
+ - CheckStyle 설정
+ - Plugin CheckStyle 설치
+ - ./formatter 에 위치한 `naver-checkstyle-rules.xml` 파일을 이용하여 설정
+ - suppression file 의 경우 `./.idea/naver-checkstyle-suppresssions.xml` 지정
+ - Format-on-save 기능 활성화
+ - Settings -> Tools -> Actions on Save -> Reformat code 체크
+
+##### (2) Env 설정
+1. `.env.sample` 파일로`.env`을 생성 합니다.
+2. `run configuration` 설정에 아래와 같이 `.env` 파일을 추가 합니다.
+![env-setup.png](env_setup_tutorial.png)
diff --git a/backend/project-setup/env_setup_tutorial.png b/backend/project-setup/env_setup_tutorial.png
new file mode 100644
index 00000000..3686f3a6
Binary files /dev/null and b/backend/project-setup/env_setup_tutorial.png differ
diff --git a/backend/project-setup/naver-checkstyle-rules.xml b/backend/project-setup/naver-checkstyle-rules.xml
new file mode 100644
index 00000000..2b160504
--- /dev/null
+++ b/backend/project-setup/naver-checkstyle-rules.xml
@@ -0,0 +1,439 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/project-setup/naver-checkstyle-supperssions.xml b/backend/project-setup/naver-checkstyle-supperssions.xml
new file mode 100644
index 00000000..a7b6fd1d
--- /dev/null
+++ b/backend/project-setup/naver-checkstyle-supperssions.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/backend/project-setup/naver-intellij-formatter.xml b/backend/project-setup/naver-intellij-formatter.xml
new file mode 100644
index 00000000..78981540
--- /dev/null
+++ b/backend/project-setup/naver-intellij-formatter.xml
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/project-setup/rss_setting.md b/backend/project-setup/rss_setting.md
new file mode 100644
index 00000000..22b0d139
--- /dev/null
+++ b/backend/project-setup/rss_setting.md
@@ -0,0 +1,11 @@
+### docker mysql 접속
+
+1. docker exec -it techpick-mysql sh
+2. mysql -u root -p
+3. show databases;
+4. use techpick_db;
+5. show tables;
+
+### mysql에서 data.sql 파일 실행 및 rss csv 파일 insert
+
+6. source /var/lib/mysql-files/develop/data.sql;
\ No newline at end of file
diff --git a/backend/settings.gradle b/backend/settings.gradle
new file mode 100644
index 00000000..0f5036dc
--- /dev/null
+++ b/backend/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = 'backend'
diff --git a/backend/src/main/java/kernel360/techpick/TechpickApplication.java b/backend/src/main/java/kernel360/techpick/TechpickApplication.java
new file mode 100644
index 00000000..56429f65
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/TechpickApplication.java
@@ -0,0 +1,13 @@
+package kernel360.techpick;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class TechpickApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(TechpickApplication.class, args);
+ }
+
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/config/JpaAuditingConfig.java b/backend/src/main/java/kernel360/techpick/core/config/JpaAuditingConfig.java
new file mode 100644
index 00000000..bb57a2ac
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/config/JpaAuditingConfig.java
@@ -0,0 +1,9 @@
+package kernel360.techpick.core.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+
+@Configuration
+@EnableJpaAuditing
+public class JpaAuditingConfig {
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/config/OAuth2AttributeConfigProvider.java b/backend/src/main/java/kernel360/techpick/core/config/OAuth2AttributeConfigProvider.java
new file mode 100644
index 00000000..ba3999c5
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/config/OAuth2AttributeConfigProvider.java
@@ -0,0 +1,21 @@
+package kernel360.techpick.core.config;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import lombok.Getter;
+
+@Component
+@ConfigurationProperties("oauth2-attribute-config-provider")
+@Getter // yaml 값을 getter 로 주입..
+public class OAuth2AttributeConfigProvider {
+
+ private final Map> attributeConfig = new HashMap<>();
+
+ public Map getAttributeConfig(String providerId) {
+ return attributeConfig.get(providerId);
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/config/P6SpySqlLoggerConfig.java b/backend/src/main/java/kernel360/techpick/core/config/P6SpySqlLoggerConfig.java
new file mode 100644
index 00000000..32cb24ff
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/config/P6SpySqlLoggerConfig.java
@@ -0,0 +1,64 @@
+package kernel360.techpick.core.config;
+
+import java.sql.SQLException;
+import java.util.Locale;
+
+import static org.springframework.util.StringUtils.hasText;
+
+import org.hibernate.engine.jdbc.internal.FormatStyle;
+import org.springframework.context.annotation.Profile;
+import org.springframework.stereotype.Component;
+
+import com.p6spy.engine.common.ConnectionInformation;
+import com.p6spy.engine.event.JdbcEventListener;
+import com.p6spy.engine.logging.Category;
+import com.p6spy.engine.spy.P6SpyOptions;
+import com.p6spy.engine.spy.appender.MessageFormattingStrategy;
+
+/**
+ * Jdbc가 DB Connection을 얻은 이후에 로깅 포맷을 P6SpyOptions가 가로채도록 하는 Bean입니다.
+ */
+@Profile({"default", "local", "dev"}) // WARN: Do not use in production mode.
+@Component
+public class P6SpySqlLoggerConfig extends JdbcEventListener implements MessageFormattingStrategy {
+
+ @Override
+ public void onAfterGetConnection(ConnectionInformation connectionInformation, SQLException e) {
+ P6SpyOptions.getActiveInstance().setLogMessageFormat(this.getClass().getName());
+ }
+
+ @Override
+ public String formatMessage(int connectionId, String now, long elapsed, String category,
+ String prepared, String sql, String url) {
+ return highlight(format(category, sql));
+ }
+
+ private String highlight(String sql) {
+ return FormatStyle.HIGHLIGHT.getFormatter().format(sql);
+ }
+
+ private String format(String category, String sql) {
+ if (hasText(sql) && isStatement(category)) {
+ if (isDdl(trim(sql))) {
+ return FormatStyle.DDL.getFormatter().format(sql);
+ }
+ return FormatStyle.BASIC.getFormatter().format(sql);
+ }
+ return sql;
+ }
+
+ private static boolean isDdl(String trimmedSql) {
+ return trimmedSql.startsWith("create")
+ || trimmedSql.startsWith("alter")
+ || trimmedSql.startsWith("drop")
+ || trimmedSql.startsWith("comment");
+ }
+
+ private static String trim(String sql) {
+ return sql.trim().toLowerCase(Locale.ROOT);
+ }
+
+ private static boolean isStatement(String category) {
+ return Category.STATEMENT.getName().equals(category);
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/config/PasswordEncoderConfig.java b/backend/src/main/java/kernel360/techpick/core/config/PasswordEncoderConfig.java
new file mode 100644
index 00000000..6d9754d9
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/config/PasswordEncoderConfig.java
@@ -0,0 +1,13 @@
+package kernel360.techpick.core.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+
+@Configuration
+public class PasswordEncoderConfig {
+ @Bean
+ public BCryptPasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/config/RestTemplateConfig.java b/backend/src/main/java/kernel360/techpick/core/config/RestTemplateConfig.java
new file mode 100644
index 00000000..436a895b
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/config/RestTemplateConfig.java
@@ -0,0 +1,23 @@
+package kernel360.techpick.core.config;
+
+import java.time.Duration;
+
+import org.springframework.boot.web.client.RestTemplateBuilder;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter;
+import org.springframework.web.client.RestTemplate;
+
+@Configuration
+public class RestTemplateConfig {
+
+ @Bean
+ public RestTemplate restTemplate(RestTemplateBuilder builder) {
+ return builder
+ .additionalMessageConverters(new MappingJackson2XmlHttpMessageConverter())
+ .defaultHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
+ .setConnectTimeout(Duration.ofSeconds(5))
+ .setReadTimeout(Duration.ofSeconds(5))
+ .build();
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/config/SchedulingConfig.java b/backend/src/main/java/kernel360/techpick/core/config/SchedulingConfig.java
new file mode 100644
index 00000000..5c7ec403
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/config/SchedulingConfig.java
@@ -0,0 +1,9 @@
+package kernel360.techpick.core.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+@Configuration
+@EnableScheduling
+public class SchedulingConfig {
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/config/SecurityConfig.java b/backend/src/main/java/kernel360/techpick/core/config/SecurityConfig.java
new file mode 100644
index 00000000..64b794fa
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/config/SecurityConfig.java
@@ -0,0 +1,77 @@
+package kernel360.techpick.core.config;
+
+import java.util.List;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.CorsConfigurationSource;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+
+import lombok.RequiredArgsConstructor;
+
+@Configuration
+@EnableWebSecurity
+@RequiredArgsConstructor
+public class SecurityConfig {
+
+ @Value("${api.base-url}")
+ private String baseUrl;
+
+ @Bean
+ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+ // TODO: 이후 설정 추가 필요
+ http
+ .csrf(AbstractHttpConfigurer::disable)
+ .cors(cors -> cors.configurationSource(corsConfigurationSource()))
+ .httpBasic(AbstractHttpConfigurer::disable)
+ .formLogin(AbstractHttpConfigurer::disable)
+ .logout(AbstractHttpConfigurer::disable)
+ .sessionManagement(management -> management.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .authorizeHttpRequests(
+ authRequest -> authRequest
+ .anyRequest().permitAll()
+ )
+ .oauth2Login(
+ oauth -> oauth
+ .authorizationEndpoint(authorization -> authorization
+ .baseUri("/api/login")
+ // /* 붙이면 안됨
+ )
+ .redirectionEndpoint(
+ redirection -> redirection
+ .baseUri("/api/login/oauth2/code/*")
+ // 반드시 /* 으로 {registrationId}를 받아야 함 스프링 시큐리티의 문제!!
+ // https://github.com/spring-projects/spring-security/issues/13251
+ )
+ // .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2Service))
+ // .successHandler(oAuth2SuccessHandler)
+ )
+ ;
+ return http.build();
+ }
+
+ @Bean
+ public CorsConfigurationSource corsConfigurationSource() {
+ CorsConfiguration config = new CorsConfiguration();
+
+ config.setAllowCredentials(true);
+ config.setAllowedOrigins(List.of(
+ baseUrl, /* from env */
+ "https://local.minlife.me:3000" /* Frontend Local */,
+ "chrome-extension://nijonkmmpngclkmeddmgjgdhjefmnmbm" /* Chrome Extension */
+ ));
+ config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
+ config.setAllowedHeaders(List.of("*"));
+ config.setExposedHeaders(List.of("*"));
+ UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+ source.registerCorsConfiguration("/**", config);
+ return source;
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/config/SwaggerConfig.java b/backend/src/main/java/kernel360/techpick/core/config/SwaggerConfig.java
new file mode 100644
index 00000000..a60bf1d2
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/config/SwaggerConfig.java
@@ -0,0 +1,54 @@
+package kernel360.techpick.core.config;
+
+import java.util.List;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import io.swagger.v3.oas.models.Components;
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.info.Info;
+import io.swagger.v3.oas.models.security.SecurityScheme;
+import io.swagger.v3.oas.models.servers.Server;
+
+@Configuration
+public class SwaggerConfig {
+
+ @Value("${api.base-url}")
+ private String baseUrl;
+
+ @Bean
+ public OpenAPI openAPI() {
+ return new OpenAPI()
+ .info(apiInfo())
+ .components(new Components()
+ .addSecuritySchemes("basicAuth", securityScheme())
+ )
+ .servers(List.of(getServer()));
+ }
+
+ private Info apiInfo() {
+ return new Info()
+ .title("TechPick API")
+ .description("TechPick API 명세서")
+ .version("1.0.0");
+ }
+
+ /**
+ * Swagger Security 설정 추가
+ * Authentication 방식을 OpenAPI 에 추가
+ */
+ private SecurityScheme securityScheme() {
+ return new SecurityScheme()
+ .type(SecurityScheme.Type.APIKEY)
+ .name("access_token")
+ .in(SecurityScheme.In.COOKIE);
+ }
+
+ private Server getServer() {
+ // TODO: AWS배포 이후 local prod에 따라 다른 url 적용하도록 리팩토링 필요
+ // 현재는 홈서버 반환
+ return new Server().url(baseUrl);
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/exception/base/ApiErrorBody.java b/backend/src/main/java/kernel360/techpick/core/exception/base/ApiErrorBody.java
new file mode 100644
index 00000000..25791ab3
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/exception/base/ApiErrorBody.java
@@ -0,0 +1,6 @@
+package kernel360.techpick.core.exception.base;
+
+public record ApiErrorBody(
+ String code,
+ String message
+) {}
diff --git a/backend/src/main/java/kernel360/techpick/core/exception/base/ApiErrorCode.java b/backend/src/main/java/kernel360/techpick/core/exception/base/ApiErrorCode.java
new file mode 100644
index 00000000..12b863fa
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/exception/base/ApiErrorCode.java
@@ -0,0 +1,20 @@
+package kernel360.techpick.core.exception.base;
+
+import org.springframework.http.HttpStatus;
+
+import kernel360.techpick.core.exception.level.ErrorLevel;
+
+public interface ApiErrorCode {
+
+ String getCode();
+
+ String getMessage();
+
+ HttpStatus getHttpStatus();
+
+ ErrorLevel getErrorLevel();
+
+ default String convertCodeToString(ApiErrorCode errorCode) {
+ return String.format("[ 에러 코드 %s : %s ]", errorCode.getCode(), errorCode.getMessage());
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/exception/base/ApiErrorResponse.java b/backend/src/main/java/kernel360/techpick/core/exception/base/ApiErrorResponse.java
new file mode 100644
index 00000000..e8063e4d
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/exception/base/ApiErrorResponse.java
@@ -0,0 +1,26 @@
+package kernel360.techpick.core.exception.base;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+
+public class ApiErrorResponse extends ResponseEntity {
+
+ private ApiErrorResponse(ApiErrorCode apiErrorCode) {
+ super(
+ new ApiErrorBody(apiErrorCode.getCode(), apiErrorCode.getMessage()),
+ apiErrorCode.getHttpStatus()
+ );
+ }
+
+ private ApiErrorResponse(String code, String message, HttpStatus status) {
+ super(new ApiErrorBody(code, message), status);
+ }
+
+ public static ApiErrorResponse of(ApiErrorCode apiErrorCode) {
+ return new ApiErrorResponse(apiErrorCode);
+ }
+
+ public static ApiErrorResponse UNKNOWN_SERVER_ERROR() {
+ return new ApiErrorResponse("UNKNOWN", "미확인 서버 에러", HttpStatus.INTERNAL_SERVER_ERROR);
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/exception/base/ApiException.java b/backend/src/main/java/kernel360/techpick/core/exception/base/ApiException.java
new file mode 100644
index 00000000..1ad30181
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/exception/base/ApiException.java
@@ -0,0 +1,19 @@
+package kernel360.techpick.core.exception.base;
+
+public abstract class ApiException extends RuntimeException {
+
+ private final ApiErrorCode errorCode;
+
+ protected ApiException(ApiErrorCode errorCode) {
+ super(errorCode.toString());
+ this.errorCode = errorCode;
+ }
+
+ public final ApiErrorCode getApiErrorCode() {
+ return errorCode;
+ }
+
+ public final void handleErrorByLevel() {
+ errorCode.getErrorLevel().handleError(this);
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/exception/base/ApiExceptionHandler.java b/backend/src/main/java/kernel360/techpick/core/exception/base/ApiExceptionHandler.java
new file mode 100644
index 00000000..2ee48ae0
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/exception/base/ApiExceptionHandler.java
@@ -0,0 +1,35 @@
+package kernel360.techpick.core.exception.base;
+
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import kernel360.techpick.core.exception.level.ErrorLevel;
+import lombok.extern.slf4j.Slf4j;
+
+@RestControllerAdvice
+@Slf4j
+public class ApiExceptionHandler {
+
+ /**
+ * ApiException 에서 잡지 못한 예외는
+ * 5xx 코드 오류 입니다.
+ */
+ @ExceptionHandler(Exception.class)
+ public ApiErrorResponse handleGlobalException(Exception exception) {
+
+ ErrorLevel.MUST_NEVER_HAPPEN().handleError(exception);
+
+ return ApiErrorResponse.UNKNOWN_SERVER_ERROR();
+ }
+
+ /**
+ * ApiException 을 공통 Response 형태로 변환 합니다.
+ */
+ @ExceptionHandler(ApiException.class)
+ public ApiErrorResponse handleApiException(ApiException exception) {
+
+ exception.handleErrorByLevel();
+
+ return ApiErrorResponse.of(exception.getApiErrorCode());
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/exception/level/ErrorLevel.java b/backend/src/main/java/kernel360/techpick/core/exception/level/ErrorLevel.java
new file mode 100644
index 00000000..e9b4ba97
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/exception/level/ErrorLevel.java
@@ -0,0 +1,39 @@
+package kernel360.techpick.core.exception.level;
+
+import kernel360.techpick.core.exception.base.ApiException;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+public abstract class ErrorLevel {
+
+ /**
+ * (1) 정상 예외 이며, 따로, 대응 하지 않아도 되는 예외.
+ * - 서버가 잘못된 사용자 요청을 잘 처리한 경우
+ */
+ public static ErrorLevel CAN_HAPPEN() {
+ return new NormalErrorLevel();
+ }
+
+ /**
+ * (2) 대응 하지 않아도 되지만, 예의 주시 해야 하는 예외.
+ * - 정상 운영은 되지만, 애초에 그 요청이 발생 해선 안되는 경우. Ex. 프론트 엔드 버그
+ */
+ public static ErrorLevel SHOULD_NOT_HAPPEN() {
+ return new WarningErrorLevel();
+ }
+
+ /**
+ * (3) 즉시 대응이 필요한 예외
+ * - 운영환경 에서 절대 발생해선 안되며, 발생 시 서버를 즉시 종료해야 하는 경우.
+ */
+ public static ErrorLevel MUST_NEVER_HAPPEN() {
+ return new FatalErrorLevel();
+ }
+
+ /* Must be implemented per error level */
+ public abstract void handleError(ApiException exception);
+
+ public final void handleError(Exception exception) {
+ log.error(exception.getMessage(), exception);
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/exception/level/FatalErrorLevel.java b/backend/src/main/java/kernel360/techpick/core/exception/level/FatalErrorLevel.java
new file mode 100644
index 00000000..4e495241
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/exception/level/FatalErrorLevel.java
@@ -0,0 +1,13 @@
+package kernel360.techpick.core.exception.level;
+
+import kernel360.techpick.core.exception.base.ApiException;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+public class FatalErrorLevel extends ErrorLevel {
+
+ @Override
+ public void handleError(ApiException exception) {
+ log.error(exception.getMessage(), exception);
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/exception/level/NormalErrorLevel.java b/backend/src/main/java/kernel360/techpick/core/exception/level/NormalErrorLevel.java
new file mode 100644
index 00000000..2a80fce4
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/exception/level/NormalErrorLevel.java
@@ -0,0 +1,13 @@
+package kernel360.techpick.core.exception.level;
+
+import kernel360.techpick.core.exception.base.ApiException;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+public class NormalErrorLevel extends ErrorLevel {
+
+ @Override
+ public void handleError(ApiException exception) {
+ log.info(exception.getMessage());
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/exception/level/WarningErrorLevel.java b/backend/src/main/java/kernel360/techpick/core/exception/level/WarningErrorLevel.java
new file mode 100644
index 00000000..aa49c773
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/exception/level/WarningErrorLevel.java
@@ -0,0 +1,13 @@
+package kernel360.techpick.core.exception.level;
+
+import kernel360.techpick.core.exception.base.ApiException;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+public class WarningErrorLevel extends ErrorLevel {
+
+ @Override
+ public void handleError(ApiException exception) {
+ log.warn(exception.getMessage(), exception);
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/model/common/BaseEntity.java b/backend/src/main/java/kernel360/techpick/core/model/common/BaseEntity.java
new file mode 100644
index 00000000..ac69a0bb
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/model/common/BaseEntity.java
@@ -0,0 +1,28 @@
+package kernel360.techpick.core.model.common;
+
+import java.time.LocalDateTime;
+
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.EntityListeners;
+import jakarta.persistence.MappedSuperclass;
+import lombok.Getter;
+
+@Getter
+@MappedSuperclass
+@EntityListeners(AuditingEntityListener.class)
+public abstract class BaseEntity {
+
+ // 생성 시간 자동 부여
+ @CreatedDate
+ @Column(name = "created_at", updatable = false, nullable = false)
+ protected LocalDateTime createdAt;
+
+ // 수정 시간 자동 부여
+ @LastModifiedDate
+ @Column(name = "updated_at", nullable = false)
+ protected LocalDateTime updatedAt;
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/model/folder/Folder.java b/backend/src/main/java/kernel360/techpick/core/model/folder/Folder.java
new file mode 100644
index 00000000..62e6243f
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/model/folder/Folder.java
@@ -0,0 +1,95 @@
+package kernel360.techpick.core.model.folder;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+import org.hibernate.annotations.OnDelete;
+import org.hibernate.annotations.OnDeleteAction;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Convert;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import kernel360.techpick.core.model.common.BaseEntity;
+import kernel360.techpick.core.model.user.User;
+import kernel360.techpick.core.util.OrderConverter;
+import kernel360.techpick.feature.domain.folder.exception.ApiFolderException;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Table(name = "folder")
+@Entity
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Folder extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private Long id;
+
+ @Column(name = "name", nullable = false)
+ private String name;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "folder_type", nullable = false)
+ private FolderType folderType;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @OnDelete(action = OnDeleteAction.CASCADE) // 부모 폴더가 삭제 되면 자식 폴더 또한 삭제
+ @JoinColumn(name = "parent_folder_id")
+ private Folder parentFolder;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id", nullable = false)
+ private User user;
+
+ // 폴더에 속한 자식 folder id들을 공백으로 분리된 String으로 변환하여 db에 저장
+ // ex) [6,3,2,23,1] -> "6 3 2 23 1"
+ @Convert(converter = OrderConverter.class)
+ @Column(name = "child_folder_order", columnDefinition = "longblob", nullable = false)
+ private List childFolderOrderList;
+
+ // 폴더에 속한 pick id들을 공백으로 분리된 String으로 변환하여 db에 저장
+ // ex) [6,3,2,23,1] -> "6 3 2 23 1"
+ @Convert(converter = OrderConverter.class)
+ @Column(name = "pick_order", columnDefinition = "longblob", nullable = false)
+ private List childPickOrderList;
+
+ public void updateChildPickOrder(Long pickId, int destination) {
+ childPickOrderList.remove(pickId);
+ childPickOrderList.add(destination, pickId);
+ }
+
+ public void removeChildPickOrder(Long pickId) {
+ this.childPickOrderList.remove(pickId);
+ }
+
+ @Builder
+ private Folder(
+ String name,
+ FolderType folderType,
+ Folder parentFolder,
+ User user,
+ List childFolderOrderList,
+ List childPickOrderList
+ ) {
+ this.name = name;
+ this.folderType = folderType;
+ this.parentFolder = parentFolder;
+ this.user = user;
+ this.childFolderOrderList = childFolderOrderList;
+ this.childPickOrderList = childPickOrderList;
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/model/folder/FolderRepository.java b/backend/src/main/java/kernel360/techpick/core/model/folder/FolderRepository.java
new file mode 100644
index 00000000..c01f4494
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/model/folder/FolderRepository.java
@@ -0,0 +1,6 @@
+package kernel360.techpick.core.model.folder;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface FolderRepository extends JpaRepository {
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/model/folder/FolderType.java b/backend/src/main/java/kernel360/techpick/core/model/folder/FolderType.java
new file mode 100644
index 00000000..377dac5a
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/model/folder/FolderType.java
@@ -0,0 +1,30 @@
+package kernel360.techpick.core.model.folder;
+
+import java.util.EnumSet;
+
+public enum FolderType {
+
+ UNCLASSIFIED("미분류 폴더"),
+ RECYCLE_BIN("휴지통 폴더"),
+ ROOT("루트 폴더"),
+ GENERAL("일반 폴더"),
+ ;
+
+ private final String label;
+
+ FolderType(String label) {
+ this.label = label;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+
+ public static EnumSet getBasicFolderTypes() {
+ return EnumSet.of(UNCLASSIFIED, RECYCLE_BIN, ROOT);
+ }
+
+ public static EnumSet getUnclassifiedFolderTypes() {
+ return EnumSet.of(UNCLASSIFIED);
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/model/link/Link.java b/backend/src/main/java/kernel360/techpick/core/model/link/Link.java
new file mode 100644
index 00000000..f548d955
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/model/link/Link.java
@@ -0,0 +1,71 @@
+package kernel360.techpick.core.model.link;
+
+import java.time.LocalDateTime;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Table(name = "link")
+@Entity
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Link {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private Long id;
+
+ // URL
+ // TODO: VARCHAR 최대 크기를 몇으로 할지 토의 필요합니다.
+ // The index key prefix length limit is 3072 bytes for InnoDB -> VARCHAR(1000) + utf8 = 4000byte
+ // 일단 medium 기준 가장 길었던 url 320 글자의 약 2배인 VARCHAR(600)으로 변경
+ // Techpick 노션 기술 부채에 VARCHAR, TEXT 부분 참고.
+ @Column(name = "url", nullable = false, columnDefinition = "VARCHAR(600)", unique = true)
+ private String url;
+
+ @Column(name = "title", columnDefinition = "VARCHAR(100)")
+ private String title;
+
+ @Column(name = "description", columnDefinition = "VARCHAR(600)")
+ private String description;
+
+ @Column(name = "imageUrl", columnDefinition = "VARCHAR(600)")
+ private String imageUrl;
+
+ @Column(name = "invalidatedAt_at")
+ private LocalDateTime invalidatedAt;
+
+ public Link updateMetadata(String title, String description, String imageUrl) {
+ this.title = title;
+ this.description = description;
+ this.imageUrl = imageUrl;
+ return this;
+ }
+
+ public Link markAsInvalid() {
+ this.invalidatedAt = LocalDateTime.now();
+ return this;
+ }
+
+ public boolean isValidLink() {
+ return (this.invalidatedAt == null);
+ }
+
+ @Builder
+ private Link(String url, String title, String description, String imageUrl, LocalDateTime invalidatedAt) {
+ this.url = url;
+ this.title = title;
+ this.description = description;
+ this.imageUrl = imageUrl;
+ this.invalidatedAt = invalidatedAt;
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/model/link/LinkRepository.java b/backend/src/main/java/kernel360/techpick/core/model/link/LinkRepository.java
new file mode 100644
index 00000000..107ddab6
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/model/link/LinkRepository.java
@@ -0,0 +1,10 @@
+package kernel360.techpick.core.model.link;
+
+import java.util.Optional;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface LinkRepository extends JpaRepository {
+
+ Optional findByUrl(String url);
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/model/pick/Pick.java b/backend/src/main/java/kernel360/techpick/core/model/pick/Pick.java
new file mode 100644
index 00000000..f67f878b
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/model/pick/Pick.java
@@ -0,0 +1,102 @@
+package kernel360.techpick.core.model.pick;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.hibernate.annotations.OnDelete;
+import org.hibernate.annotations.OnDeleteAction;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Convert;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import kernel360.techpick.core.model.common.BaseEntity;
+import kernel360.techpick.core.model.folder.Folder;
+import kernel360.techpick.core.model.link.Link;
+import kernel360.techpick.core.model.user.User;
+import kernel360.techpick.core.util.OrderConverter;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Table(name = "pick")
+@Entity
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Pick extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private Long id;
+
+ // 사용자
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id", nullable = false)
+ private User user;
+
+ // 북마크 대상 링크
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "link_id", nullable = false)
+ private Link link;
+
+ // 부모 폴더가 삭제되면 자식픽 또한 삭제됨, OnDelete 옵션을 위해 FK필요
+ @ManyToOne(fetch = FetchType.LAZY)
+ @OnDelete(action = OnDeleteAction.CASCADE)
+ @JoinColumn(name = "parent_folder_id", nullable = false)
+ private Folder parentFolder;
+
+ // 사용자가 수정 가능한 Pick 제목. 기본값은 원문 제목과 동일
+ @Column(name = "title", nullable = false)
+ private String title = "";
+
+ // 픽에 속한 tag id들을 공백으로 분리된 String으로 변환하여 db에 저장. Ex) [6,3,2,23,1] -> "6 3 2 23 1"
+ @Convert(converter = OrderConverter.class)
+ @Column(name = "tag_order", columnDefinition = "longblob", nullable = false)
+ private List tagOrder = new ArrayList<>();
+
+ // 사용자가 링크에 대해 남기는 메모 (update시 null과 ""를 구분하기 위함)
+ @Column(name = "memo", nullable = false)
+ private String memo = "";
+
+ @Builder
+ private Pick(User user, Link link, Folder parentFolder, String title, List tagOrder, String memo) {
+ this.user = user;
+ this.link = link;
+ this.parentFolder = parentFolder;
+ this.title = title;
+ this.tagOrder = tagOrder;
+ this.memo = memo;
+ }
+
+ public Pick updateTagOrder(List tagOrder) {
+ if (tagOrder == null) return this;
+ this.tagOrder = tagOrder;
+ return this;
+ }
+
+ public Pick updateParentFolder(Folder parentFolder) {
+ if (parentFolder == null) return this;
+ this.parentFolder = parentFolder;
+ return this;
+ }
+
+ public Pick updateTitle(String title) {
+ if (title == null) return this;
+ this.title = title;
+ return this;
+ }
+
+ public Pick updateMemo(String memo) {
+ if (memo == null) return this;
+ this.memo = memo;
+ return this;
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/model/pick/PickRepository.java b/backend/src/main/java/kernel360/techpick/core/model/pick/PickRepository.java
new file mode 100644
index 00000000..a1c35570
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/model/pick/PickRepository.java
@@ -0,0 +1,12 @@
+package kernel360.techpick.core.model.pick;
+
+import java.util.Optional;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import kernel360.techpick.core.model.link.Link;
+import kernel360.techpick.core.model.user.User;
+
+public interface PickRepository extends JpaRepository {
+ Optional findByUserAndLink(User user, Link link);
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/model/pick/PickTag.java b/backend/src/main/java/kernel360/techpick/core/model/pick/PickTag.java
new file mode 100644
index 00000000..e1d661bc
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/model/pick/PickTag.java
@@ -0,0 +1,48 @@
+package kernel360.techpick.core.model.pick;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import kernel360.techpick.core.model.tag.Tag;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Table(name = "pick_tag")
+@Entity
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class PickTag {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private Long id;
+
+ // 사용자 FK
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "pick_id", nullable = false)
+ private Pick pick;
+
+ // 사용자 정의 태그 FK
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "tag_id", nullable = false)
+ private Tag tag;
+
+ // TODO: 엔티티 사용자가 정적 팩토리 메소드로 필요한 함수를 구현 하세요
+
+ private PickTag(Pick pick, Tag tag) {
+ this.pick = pick;
+ this.tag = tag;
+ }
+
+ public static PickTag of(Pick pick, Tag tag) {
+ return new PickTag(pick, tag);
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/model/pick/PickTagRepository.java b/backend/src/main/java/kernel360/techpick/core/model/pick/PickTagRepository.java
new file mode 100644
index 00000000..2e99a654
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/model/pick/PickTagRepository.java
@@ -0,0 +1,10 @@
+package kernel360.techpick.core.model.pick;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface PickTagRepository extends JpaRepository {
+
+ void deleteByPick(Pick pick);
+
+ void deleteByTagId(Long tagId);
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/model/rss/RssBlogRepository.java b/backend/src/main/java/kernel360/techpick/core/model/rss/RssBlogRepository.java
new file mode 100644
index 00000000..f45983d1
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/model/rss/RssBlogRepository.java
@@ -0,0 +1,6 @@
+package kernel360.techpick.core.model.rss;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface RssBlogRepository extends JpaRepository {
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/model/rss/RssRawData.java b/backend/src/main/java/kernel360/techpick/core/model/rss/RssRawData.java
new file mode 100644
index 00000000..10c2d2bf
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/model/rss/RssRawData.java
@@ -0,0 +1,84 @@
+package kernel360.techpick.core.model.rss;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import kernel360.techpick.core.model.common.BaseEntity;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Table(name = "rss_raw_data")
+@Entity
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class RssRawData extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private Long id;
+
+ // 제목
+ @Column(name = "title")
+ private String title;
+
+ // 원문 주소
+ // TODO: VARCHAR 최대 크기를 몇으로 할지 토의 필요합니다.
+ // medium 기준 가장 길었던 url 320 글자의 약 2배인 VARCHAR(600)으로 변경
+ @Column(name = "url", columnDefinition = "VARCHAR(600)")
+ private String url;
+
+ @Column(name = "guid")
+ private String guid;
+
+ // 작성 일자
+ @Column(name = "published_at")
+ private String publishedAt;
+
+ // 요약 설명
+ @Column(name = "description", columnDefinition = "longblob") // nullable
+ private String description;
+
+ // 작성자
+ @Column(name = "creator") // nullable
+ private String creator;
+
+ // 복수 카테고리를 쉼표(,)로 구분 (ex. "Foo,Bar,Baz")
+ @Column(name = "joined_category") // nullable
+ private String joinedCategory;
+
+ @Column(name = "rssSupportingBlogId")
+ private Long rssSupportingBlogId;
+
+ // TODO: 엔티티 사용자가 정적 팩토리 메소드로 필요한 함수를 구현 하세요
+
+ private RssRawData(
+ String title,
+ String url,
+ String guid,
+ String publishedAt,
+ String description,
+ String creator,
+ String joinedCategory,
+ Long rssSupportingBlogId
+ ) {
+ this.title = title;
+ this.url = url;
+ this.guid = guid;
+ this.publishedAt = publishedAt;
+ this.description = description;
+ this.creator = creator;
+ this.joinedCategory = joinedCategory;
+ this.rssSupportingBlogId = rssSupportingBlogId;
+ }
+
+ public static RssRawData create(String title, String url, String guid, String publishedAt, String description,
+ String creator,
+ String joinedCategory, Long rssSupportingBlogId) {
+ return new RssRawData(title, url, guid, publishedAt, description, creator, joinedCategory, rssSupportingBlogId);
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/model/rss/RssRawDataRepository.java b/backend/src/main/java/kernel360/techpick/core/model/rss/RssRawDataRepository.java
new file mode 100644
index 00000000..96ec2607
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/model/rss/RssRawDataRepository.java
@@ -0,0 +1,6 @@
+package kernel360.techpick.core.model.rss;
+
+import org.springframework.data.repository.CrudRepository;
+
+public interface RssRawDataRepository extends CrudRepository {
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/model/rss/RssSupportingBlog.java b/backend/src/main/java/kernel360/techpick/core/model/rss/RssSupportingBlog.java
new file mode 100644
index 00000000..8d0b351d
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/model/rss/RssSupportingBlog.java
@@ -0,0 +1,37 @@
+package kernel360.techpick.core.model.rss;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import kernel360.techpick.core.model.common.BaseEntity;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Table(name = "rss_blog")
+@Entity
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class RssSupportingBlog extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private Long id;
+
+ // Rss 피드 주소
+ @Column(name = "rss_feed_url", nullable = false, unique = true)
+ private String rssFeedUrl;
+
+ // TODO: 엔티티 사용자가 정적 팩토리 메소드로 필요한 함수를 구현 하세요
+ private RssSupportingBlog(String rssFeedUrl) {
+ this.rssFeedUrl = rssFeedUrl;
+ }
+
+ public static RssSupportingBlog create(String rssFeedUrl) {
+ return new RssSupportingBlog(rssFeedUrl);
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/model/tag/Tag.java b/backend/src/main/java/kernel360/techpick/core/model/tag/Tag.java
new file mode 100644
index 00000000..03c0400d
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/model/tag/Tag.java
@@ -0,0 +1,58 @@
+package kernel360.techpick.core.model.tag;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import kernel360.techpick.core.model.user.User;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Table(name = "tag")
+@Entity
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Tag {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private Long id;
+
+ // 태그명
+ @Column(name = "name", nullable = false)
+ private String name;
+
+ // 프론트가 쓸 컬러 넘버 (숫자 - 색상 매핑은 프론트에서 처리, 무조건 처리)
+ @Column(name = "color_number", nullable = false)
+ private Integer colorNumber;
+
+ // 사용자 FK
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id", nullable = false)
+ private User user;
+
+ @Builder
+ private Tag(String name, Integer colorNumber, User user) {
+ this.name = name;
+ this.colorNumber = colorNumber;
+ this.user = user;
+ }
+
+ // TODO: 엔티티 사용자가 정적 팩토리 메소드로 필요한 함수를 구현 하세요
+
+ public void updateTagName(String name) {
+ this.name = name;
+ }
+
+ public void updateColorNumber(Integer colorNumber) {
+ this.colorNumber = colorNumber;
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/model/tag/TagRepository.java b/backend/src/main/java/kernel360/techpick/core/model/tag/TagRepository.java
new file mode 100644
index 00000000..9e041995
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/model/tag/TagRepository.java
@@ -0,0 +1,12 @@
+package kernel360.techpick.core.model.tag;
+
+import java.util.List;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface TagRepository extends JpaRepository {
+
+ boolean existsByUserIdAndName(Long userId, String name);
+
+ List findAllByUserId(Long userId);
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/model/user/Role.java b/backend/src/main/java/kernel360/techpick/core/model/user/Role.java
new file mode 100644
index 00000000..a287642d
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/model/user/Role.java
@@ -0,0 +1,7 @@
+package kernel360.techpick.core.model.user;
+
+public enum Role {
+ ROLE_GUEST, // 로그인 안한 사용자
+ ROLE_USER,
+ ROLE_ADMIN,
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/model/user/SocialType.java b/backend/src/main/java/kernel360/techpick/core/model/user/SocialType.java
new file mode 100644
index 00000000..6dc12c42
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/model/user/SocialType.java
@@ -0,0 +1,25 @@
+package kernel360.techpick.core.model.user;
+
+// 로그인 타입
+public enum SocialType {
+ GOOGLE("google"),
+ KAKAO("kakao"),
+ NAVER("naver"),
+
+ ;
+
+ private final String providerId;
+
+ SocialType(String providerId) {
+ this.providerId = providerId;
+ }
+
+ public static SocialType providerIdOf(String providerId) throws IllegalArgumentException {
+ for (SocialType socialType : SocialType.values()) {
+ if (socialType.providerId.equals(providerId)) {
+ return socialType;
+ }
+ }
+ throw new IllegalArgumentException();
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/model/user/User.java b/backend/src/main/java/kernel360/techpick/core/model/user/User.java
new file mode 100644
index 00000000..f945fca1
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/model/user/User.java
@@ -0,0 +1,116 @@
+package kernel360.techpick.core.model.user;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+import org.hibernate.annotations.SQLDelete;
+import org.hibernate.annotations.SQLRestriction;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Convert;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import kernel360.techpick.core.model.common.BaseEntity;
+import kernel360.techpick.core.util.OrderConverter;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@SQLDelete(sql = "UPDATE user SET deleted_at = CURRENT_TIMESTAMP WHERE user_id=?")
+@SQLRestriction("deleted_at IS NULL")
+@Table(name = "user")
+@Entity
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class User extends BaseEntity /* implements UserDetails --> 시큐리티 도입시 추가 */ {
+
+ private static final String SOCIAL_USER_HAS_NO_PASSWORD = null;
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private Long id;
+
+ // 닉네임 (없으면 랜덤 생성 - Ex. "노래하는피치#145")
+ @Column(name = "nickname", nullable = false /*, unique = true */)
+ private String nickname;
+
+ // 이메일 (WARN: 두 소셜 로그인이 같은 이메일을 가질 수 있음)
+ @Column(name = "email", nullable = false)
+ private String email;
+
+ // 유저 권한
+ @Column(name = "role", nullable = false)
+ @Enumerated(EnumType.STRING)
+ private Role role;
+
+ // 비밀번호 (소셜 로그인 사용자는 null)
+ @Column(name = "password") // nullable
+ private String password;
+
+ // 소셜 제공자 (null일 경우 자체 가입 회원)
+ @Enumerated(EnumType.STRING)
+ @Column(name = "social_provider") // nullable
+ private SocialType socialProvider;
+
+ // 소셜 제공자 Id
+ @Column(name = "social_provider_id") // nullable
+ private String socialProviderId;
+
+ // Soft Delete - 삭제 시간
+ @Column(name = "deleted_at") // nullable
+ private LocalDateTime deletedAt;
+
+ // 유저의 tag id들을 공백으로 분리된 String으로 변환하여 db에 저장
+ // ex) [6,3,2,23,1] -> "6 3 2 23 1"
+ @Convert(converter = OrderConverter.class)
+ @Column(name = "tag_order", columnDefinition = "longblob", nullable = false)
+ private List tagOrderList = new ArrayList<>();
+
+ public void updateTagOrderList(List tagOrderList) {
+ this.tagOrderList = tagOrderList;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof User user)) {
+ return false;
+ }
+ return Objects.equals(id, user.id);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(id);
+ }
+
+ private User(
+ SocialType socialProvider,
+ String socialProviderId,
+ String nickname,
+ String password,
+ String email,
+ Role role,
+ List tagOrderList
+ ) {
+ this.socialProviderId = socialProviderId;
+ this.socialProvider = socialProvider;
+ this.nickname = nickname;
+ this.password = password;
+ this.email = email;
+ this.role = role;
+ this.tagOrderList = tagOrderList;
+ }
+
+ // TODO: 엔티티 사용자가 정적 팩토리 메소드로 필요한 함수를 구현 하세요
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/model/user/UserRepository.java b/backend/src/main/java/kernel360/techpick/core/model/user/UserRepository.java
new file mode 100644
index 00000000..8041e1c6
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/model/user/UserRepository.java
@@ -0,0 +1,6 @@
+package kernel360.techpick.core.model.user;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface UserRepository extends JpaRepository {
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/util/CookieUtil.java b/backend/src/main/java/kernel360/techpick/core/util/CookieUtil.java
new file mode 100644
index 00000000..f856d842
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/util/CookieUtil.java
@@ -0,0 +1,71 @@
+package kernel360.techpick.core.util;
+
+import java.util.Base64;
+
+import org.springframework.http.ResponseCookie;
+import org.springframework.util.SerializationUtils;
+
+import jakarta.servlet.http.Cookie;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+public class CookieUtil {
+
+ //요청값(이름,값,만료기간)을 바탕으로 쿠키추가
+ public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) {
+ ResponseCookie responseCookie = ResponseCookie.from(name, value)
+ .maxAge(maxAge)
+ .path("/")
+ .httpOnly(true)
+ .secure(true)
+ .domain("minlife.me")
+ // .sameSite("None")
+ .build();
+ response.addHeader("Set-Cookie", responseCookie.toString());
+
+ // 로그인 확인용 쿠키 (techPickLogin = true) 추가
+ ResponseCookie techPickLoginCookie = ResponseCookie.from("techPickLogin", "true")
+ .maxAge(maxAge)
+ .path("/")
+ .secure(true)
+ .domain("minlife.me")
+ // .sameSite("None")
+ .build();
+ response.addHeader("Set-Cookie", techPickLoginCookie.toString());
+
+ }
+
+ //쿠키의 이름을 입력받아 쿠키 삭제
+ public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) {
+ Cookie[] cookies = request.getCookies();
+
+ if (cookies == null) {
+ return;
+ }
+
+ for (Cookie cookie : cookies) {
+ if (name.equals(cookie.getName())) {
+ cookie.setValue("");
+ cookie.setPath("/");
+ cookie.setMaxAge(0);
+ cookie.setHttpOnly(true);
+ response.addCookie(cookie);
+ }
+ }
+ }
+
+ //객체를 직렬화해 쿠키의 값으로 변환
+ public static String serialize(Object obj) {
+ return Base64.getUrlEncoder()
+ .encodeToString(SerializationUtils.serialize(obj));
+ }
+
+ //쿠키를 역직렬화해 객체로 변환
+ public static T deserialize(Cookie cookie, Class cls) {
+ return cls.cast(
+ SerializationUtils.deserialize(
+ Base64.getUrlDecoder().decode(cookie.getValue())
+ )
+ );
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/util/JwtUtil.java b/backend/src/main/java/kernel360/techpick/core/util/JwtUtil.java
new file mode 100644
index 00000000..5128cee2
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/util/JwtUtil.java
@@ -0,0 +1,69 @@
+package kernel360.techpick.core.util;
+
+import java.time.Duration;
+import java.util.Date;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Header;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.SignatureAlgorithm;
+import kernel360.techpick.core.model.user.Role;
+import kernel360.techpick.core.model.user.User;
+
+// 패키지 위치에 대한 고민 필요
+@Component
+public class JwtUtil {
+
+ @Value("${spring.jwt.issuer}")
+ private String issuer;
+ @Value("${spring.jwt.secret}")
+ private String secret;
+
+ /*
+ userId만 토큰에 포함됨
+ */
+ public String getToken(User user, Duration expiry) {
+ Date now = new Date();
+ Date expiryDate = new Date(now.getTime() + expiry.toMillis());
+
+ return Jwts.builder()
+ .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
+ .setIssuer(issuer)
+ .setIssuedAt(now)
+ .setExpiration(expiryDate)
+ .claim("id", user.getId())
+ // TODO: role enum name? value? 변경 필요
+ .claim("role", user.getRole())
+ .signWith(SignatureAlgorithm.HS256, secret)
+ .compact();
+ }
+
+ public boolean isValidToken(String token) {
+ try {
+ Jwts.parser()
+ .setSigningKey(secret)
+ .parseClaimsJws(token);
+ return true;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ public Role getRole(String token) {
+ return Role.valueOf(getClaims(token).get("role", String.class));
+ }
+
+ public Long getUserId(String token) {
+ return getClaims(token).get("id", Long.class);
+ }
+
+ private Claims getClaims(String token) {
+ return Jwts.parser()
+ .setSigningKey(secret)
+ .parseClaimsJws(token)
+ .getBody();
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/core/util/OrderConverter.java b/backend/src/main/java/kernel360/techpick/core/util/OrderConverter.java
new file mode 100644
index 00000000..654e9253
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/core/util/OrderConverter.java
@@ -0,0 +1,31 @@
+package kernel360.techpick.core.util;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.StringTokenizer;
+
+import jakarta.persistence.AttributeConverter;
+import jakarta.persistence.Converter;
+
+@Converter
+public class OrderConverter implements AttributeConverter, String> {
+
+ @Override
+ public String convertToDatabaseColumn(List idList) {
+ StringBuilder sb = new StringBuilder();
+ for (Long id : idList) {
+ sb.append(id).append(" ");
+ }
+ return sb.toString().trim();
+ }
+
+ @Override
+ public List convertToEntityAttribute(String s) {
+ List idList = new ArrayList<>();
+ StringTokenizer st = new StringTokenizer(s);
+ while (st.hasMoreTokens()) {
+ idList.add(Long.parseLong(st.nextToken()));
+ }
+ return idList;
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/api/folder/controller/FolderApiController.java b/backend/src/main/java/kernel360/techpick/feature/api/folder/controller/FolderApiController.java
new file mode 100644
index 00000000..2b34be40
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/api/folder/controller/FolderApiController.java
@@ -0,0 +1,12 @@
+package kernel360.techpick.feature.api.folder.controller;
+
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import lombok.RequiredArgsConstructor;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/folders")
+public class FolderApiController implements FolderApiSpecification {
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/api/folder/controller/FolderApiSpecification.java b/backend/src/main/java/kernel360/techpick/feature/api/folder/controller/FolderApiSpecification.java
new file mode 100644
index 00000000..54deca04
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/api/folder/controller/FolderApiSpecification.java
@@ -0,0 +1,58 @@
+package kernel360.techpick.feature.api.folder.controller;
+
+public interface FolderApiSpecification {
+
+ // [ 최초 필수 폴더 정보 획득 (폴더가 닫힌 상태 임을 가정) ]
+ // 클라이언트에서 예외 발생시, 모두 강제로 폴더가 닫히도록 유도
+ //
+ // GET api/folders/
+ // -------------------- No Request Body
+ // -------------------- Response Body = [
+ // type, --> root | unclassified | recycle
+ // folder_id,
+ // name,
+ // ]
+
+ // [ 폴더의 자식 정보 획득 ]
+ // 이때, 폴더의 이름 + 자식 구조 1 depth 까지만 결과를 반환.
+ // 클라이언트에서 펼처진 폴더에 대한 GET 요청을 연쇄적으로 날릴 것.
+ // GET api/folders/children/{folder_id}
+ // -------------------- No Request Body
+ // -------------------- Response Body = { List of "depth-1 child" folder (id & name) / pick (id & title) }
+
+ // [ 폴더 정보 수정 ]
+ // PATCH api/folders/data/{folder_id}
+ // -------------------- Request Body = {
+ // name : nullable --> null일 경우 아무 것도 안하며, 빈 문자열일 경우 예외 발생
+ // }
+ // -------------------- No Response Body
+
+ // [ 폴더 위치 수정 - 값이 null일 때 "생성"과 다르게 적용되는 점 주의 ]
+ // PATCH api/folders/location/{folder_id}/location
+ // -------------------- Request Body = {
+ // parent_folder_id: nullable --> 있다면 해당 폴더로 소속되게, 없으면 변경 없음
+ // order_idx : nullable --> 0이면 폴더중 최상단 위치, 없으면 변경 없음
+ // }
+ // -------------------- No Response Body
+ // * 이때, parent_folder_id가 휴지통에 있는 id거나 휴지통 id 일 수 있다.
+ // * 휴지통으로 이동하는 것은 삭제로 처리하지 않는다.
+
+ // [ 폴더 생성 ]
+ // POST api/folders/
+ // -------------------- Request Body = {
+ // data: {
+ // name : --> 빈 문자열이거나, null일 경우 예외 발생
+ // },
+ // location: {
+ // parent_folder_id: nullable --> 있다면 해당 폴더로 소속되게, 없으면 root로
+ // order_idx : nullable --> 0이면 폴더중 최상단 위치, 없으면 제일 마지막.
+ // }
+ // }
+ //
+ // -------------------- Response Body = { created_folder_id }
+
+ // [폴더 삭제 - 휴지통으로 부터 삭제]
+ // DELETE api/folders/{folder_id}
+ // -------------------- no request body
+ // -------------------- no response body
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/api/folder/dto/FolderApiMapper.java b/backend/src/main/java/kernel360/techpick/feature/api/folder/dto/FolderApiMapper.java
new file mode 100644
index 00000000..710dbf54
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/api/folder/dto/FolderApiMapper.java
@@ -0,0 +1,13 @@
+package kernel360.techpick.feature.api.folder.dto;
+
+import org.mapstruct.InjectionStrategy;
+import org.mapstruct.Mapper;
+import org.mapstruct.ReportingPolicy;
+
+@Mapper(
+ componentModel = "spring",
+ injectionStrategy = InjectionStrategy.CONSTRUCTOR,
+ unmappedTargetPolicy = ReportingPolicy.ERROR
+)
+public interface FolderApiMapper {
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/api/folder/dto/FolderApiRequest.java b/backend/src/main/java/kernel360/techpick/feature/api/folder/dto/FolderApiRequest.java
new file mode 100644
index 00000000..0ae92502
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/api/folder/dto/FolderApiRequest.java
@@ -0,0 +1,9 @@
+package kernel360.techpick.feature.api.folder.dto;
+
+public class FolderApiRequest {
+
+ public record Create() {}
+ public record Read() {}
+ public record Update() {}
+ public record Delete() {}
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/api/folder/dto/FolderApiResponse.java b/backend/src/main/java/kernel360/techpick/feature/api/folder/dto/FolderApiResponse.java
new file mode 100644
index 00000000..d571d096
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/api/folder/dto/FolderApiResponse.java
@@ -0,0 +1,9 @@
+package kernel360.techpick.feature.api.folder.dto;
+
+public class FolderApiResponse {
+
+ public record Create() {}
+ public record Read() {}
+ public record Update() {}
+ public record Delete() {}
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/api/pick/controller/PickApiController.java b/backend/src/main/java/kernel360/techpick/feature/api/pick/controller/PickApiController.java
new file mode 100644
index 00000000..5cb25ef4
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/api/pick/controller/PickApiController.java
@@ -0,0 +1,12 @@
+package kernel360.techpick.feature.api.pick.controller;
+
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import lombok.RequiredArgsConstructor;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/picks")
+public class PickApiController implements PickApiSpecification {
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/api/pick/controller/PickApiSpecification.java b/backend/src/main/java/kernel360/techpick/feature/api/pick/controller/PickApiSpecification.java
new file mode 100644
index 00000000..63276ea6
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/api/pick/controller/PickApiSpecification.java
@@ -0,0 +1,53 @@
+package kernel360.techpick.feature.api.pick.controller;
+
+public interface PickApiSpecification {
+
+ // [ 내가 해당 링크를 픽한 적이 있는지 확인 ]
+ // GET api/picks?link={url}
+ // -------------------- no request body
+ // -------------------- Response Body = {
+ // pick_id: nullable (null == 픽 안함)
+ // }
+
+ // [ 나의 픽 상세 조회 ]
+ // GET api/picks/{pick_id}
+ // -------------------- Request Body = X
+ // -------------------- Response Body = { parent_folder_id, 태그, 제목 ... 등등 }
+
+ // [ 나의 픽 정보 수정 - null이 아닌 값만 업데이트 ]
+ // PATCH api/picks/data/{pick_id}
+ // -------------------- Request Body = {
+ // memo: nullable,
+ // title: nullable,
+ // tags: nullable
+ // }
+
+ // [ 나의 픽 위치 수정 - 값이 null일 때 "생성"과 다르게 적용되는 점 주의 ]
+ // PATCH api/picks/location/{pick_id}
+ // -------------------- Request Body = {
+ // parent_folder_id: nullable --> 있다면 해당 폴더로 소속되게, 없으면 변경 없음
+ // order_idx : 호출 된 이상, null일 수 없음!
+ // }
+
+ // [ 나의 픽 생성 ]
+ // POST api/picks/
+ // -------------------- Request Body = {
+ // data: {
+ // title: nullable, --> null일 경우 빈 문자열로 설정
+ // memo: nullable, --> null일 경우 빈 문자열로 설정
+ // tags: nullable, --> null일 경우 빈 태그로 설정
+ // url: , --> null일 경우 예외 발생
+ // ...
+ // },
+ // location: {
+ // parent_folder_id: nullable --> 있다면 해당 폴더로 소속되게, 없으면 unclassified로
+ // order_idx : nullable --> 0이면 픽들 중 최상단 위치, 없으면 제일 마지막.
+ // }
+ // }
+ // -------------------- Response Body = { created_pick_id }
+
+ // [ 나의 픽 삭제 조회 ]
+ // GET api/picks/{pick_id}
+ // -------------------- no request body
+ // -------------------- no response body
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/api/pick/dto/PickApiMapper.java b/backend/src/main/java/kernel360/techpick/feature/api/pick/dto/PickApiMapper.java
new file mode 100644
index 00000000..c1044cad
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/api/pick/dto/PickApiMapper.java
@@ -0,0 +1,13 @@
+package kernel360.techpick.feature.api.pick.dto;
+
+import org.mapstruct.InjectionStrategy;
+import org.mapstruct.Mapper;
+import org.mapstruct.ReportingPolicy;
+
+@Mapper(
+ componentModel = "spring",
+ injectionStrategy = InjectionStrategy.CONSTRUCTOR,
+ unmappedTargetPolicy = ReportingPolicy.ERROR
+)
+public interface PickApiMapper {
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/api/pick/dto/PickApiRequest.java b/backend/src/main/java/kernel360/techpick/feature/api/pick/dto/PickApiRequest.java
new file mode 100644
index 00000000..38e6127b
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/api/pick/dto/PickApiRequest.java
@@ -0,0 +1,9 @@
+package kernel360.techpick.feature.api.pick.dto;
+
+public class PickApiRequest {
+
+ public record Create() {}
+ public record Read() {}
+ public record Update() {}
+ public record Delete() {}
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/api/pick/dto/PickApiResponse.java b/backend/src/main/java/kernel360/techpick/feature/api/pick/dto/PickApiResponse.java
new file mode 100644
index 00000000..07d92dc9
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/api/pick/dto/PickApiResponse.java
@@ -0,0 +1,9 @@
+package kernel360.techpick.feature.api.pick.dto;
+
+public class PickApiResponse {
+
+ public record Create() {}
+ public record Read() {}
+ public record Update() {}
+ public record Delete() {}
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/api/tag/controller/TagApiController.java b/backend/src/main/java/kernel360/techpick/feature/api/tag/controller/TagApiController.java
new file mode 100644
index 00000000..a122628b
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/api/tag/controller/TagApiController.java
@@ -0,0 +1,12 @@
+package kernel360.techpick.feature.api.tag.controller;
+
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import lombok.RequiredArgsConstructor;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/tags")
+public class TagApiController implements TagApiSpecification {
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/api/tag/controller/TagApiSpecification.java b/backend/src/main/java/kernel360/techpick/feature/api/tag/controller/TagApiSpecification.java
new file mode 100644
index 00000000..0e3f072c
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/api/tag/controller/TagApiSpecification.java
@@ -0,0 +1,34 @@
+package kernel360.techpick.feature.api.tag.controller;
+
+public interface TagApiSpecification {
+
+ // [ 나의 태그 리스트 조회 ]
+ // GET api/tags/
+ // -------------------- Request Body = X
+ // -------------------- Response Body = { 태그 리스트 }
+
+ // [ 나의 태그 정보 수정 ]
+ // PUT api/tags/
+ // -------------------- Request Body =
+ // [
+ // {
+ // "tagId": 0,
+ // "tagName": "string",
+ // "tagIdList": 0,
+ // "colorNumber": 0
+ // }
+ // ]
+
+ // [ 나의 태그 생성 ]
+ // POST api/tags/
+ // {
+ // "tagName": "string",
+ // "colorNumber": 0
+ // }
+
+ // [ 나의 태그 삭제 ]
+ // DELETE api/tags/{tag_id}
+ // -------------------- no request body
+ // -------------------- no response body
+
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/api/tag/dto/TagApiMapper.java b/backend/src/main/java/kernel360/techpick/feature/api/tag/dto/TagApiMapper.java
new file mode 100644
index 00000000..3b806340
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/api/tag/dto/TagApiMapper.java
@@ -0,0 +1,30 @@
+package kernel360.techpick.feature.api.tag.dto;
+
+import org.mapstruct.InjectionStrategy;
+import org.mapstruct.Mapper;
+import org.mapstruct.ReportingPolicy;
+
+import kernel360.techpick.feature.domain.tag.dto.TagCommand;
+import kernel360.techpick.feature.domain.tag.dto.TagResult;
+
+@Mapper(
+ componentModel = "spring",
+ injectionStrategy = InjectionStrategy.CONSTRUCTOR,
+ unmappedTargetPolicy = ReportingPolicy.ERROR
+)
+public interface TagApiMapper {
+
+ TagCommand.Read toReadCommand(TagApiRequest.Read request);
+
+ TagCommand.Create toCreateCommand(TagApiRequest.Create request);
+
+ TagCommand.Update toUpdateCommand(TagApiRequest.Update request);
+
+ TagCommand.Delete toDeleteCommand(TagApiRequest.Delete request);
+
+ TagApiResponse.Read toReadResponse(TagResult result);
+
+ TagApiResponse.Create toCreateResponse(TagResult result);
+
+ TagApiResponse.Update toUpdateResponse(TagResult result);
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/api/tag/dto/TagApiRequest.java b/backend/src/main/java/kernel360/techpick/feature/api/tag/dto/TagApiRequest.java
new file mode 100644
index 00000000..ced64753
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/api/tag/dto/TagApiRequest.java
@@ -0,0 +1,27 @@
+package kernel360.techpick.feature.api.tag.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+
+public class TagApiRequest {
+
+ public record Create(
+ @NotBlank String name,
+ @NotNull Integer colorNumber) {
+ }
+
+ public record Read(
+ @NotNull Long tagId) {
+ }
+
+ public record Update(
+ @NotNull Long tagId,
+ @NotEmpty String name,
+ @NotNull Integer colorNumber) {
+ }
+
+ public record Delete(
+ @NotNull Long tagId) {
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/api/tag/dto/TagApiResponse.java b/backend/src/main/java/kernel360/techpick/feature/api/tag/dto/TagApiResponse.java
new file mode 100644
index 00000000..44a4011a
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/api/tag/dto/TagApiResponse.java
@@ -0,0 +1,25 @@
+package kernel360.techpick.feature.api.tag.dto;
+
+public class TagApiResponse {
+
+ public record Create(
+ Long id,
+ String name,
+ String colorNumber
+ ) {
+ }
+
+ public record Read(
+ Long id,
+ String name,
+ String colorNumber
+ ) {
+ }
+
+ public record Update(
+ Long id,
+ String name,
+ String colorNumber
+ ) {
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/application/FolderFacade.java b/backend/src/main/java/kernel360/techpick/feature/application/FolderFacade.java
new file mode 100644
index 00000000..686fd772
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/application/FolderFacade.java
@@ -0,0 +1,4 @@
+package kernel360.techpick.feature.application;
+
+public class FolderFacade {
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/application/PickFacade.java b/backend/src/main/java/kernel360/techpick/feature/application/PickFacade.java
new file mode 100644
index 00000000..c14b4c11
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/application/PickFacade.java
@@ -0,0 +1,4 @@
+package kernel360.techpick.feature.application;
+
+public class PickFacade {
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/application/TagFacade.java b/backend/src/main/java/kernel360/techpick/feature/application/TagFacade.java
new file mode 100644
index 00000000..ba678a69
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/application/TagFacade.java
@@ -0,0 +1,4 @@
+package kernel360.techpick.feature.application;
+
+public class TagFacade {
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/folder/dto/FolderCommand.java b/backend/src/main/java/kernel360/techpick/feature/domain/folder/dto/FolderCommand.java
new file mode 100644
index 00000000..8891112f
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/folder/dto/FolderCommand.java
@@ -0,0 +1,9 @@
+package kernel360.techpick.feature.domain.folder.dto;
+
+public class FolderCommand {
+
+ public record Create() {}
+ public record Read() {}
+ public record Update() {}
+ public record Delete() {}
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/folder/dto/FolderMapper.java b/backend/src/main/java/kernel360/techpick/feature/domain/folder/dto/FolderMapper.java
new file mode 100644
index 00000000..4999a2cf
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/folder/dto/FolderMapper.java
@@ -0,0 +1,13 @@
+package kernel360.techpick.feature.domain.folder.dto;
+
+import org.mapstruct.InjectionStrategy;
+import org.mapstruct.Mapper;
+import org.mapstruct.ReportingPolicy;
+
+@Mapper(
+ componentModel = "spring",
+ injectionStrategy = InjectionStrategy.CONSTRUCTOR,
+ unmappedTargetPolicy = ReportingPolicy.ERROR
+)
+public interface FolderMapper {
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/folder/dto/FolderResult.java b/backend/src/main/java/kernel360/techpick/feature/domain/folder/dto/FolderResult.java
new file mode 100644
index 00000000..cc5df198
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/folder/dto/FolderResult.java
@@ -0,0 +1,9 @@
+package kernel360.techpick.feature.domain.folder.dto;
+
+public class FolderResult {
+
+ public record Create() {}
+ public record Read() {}
+ public record Update() {}
+ public record Delete() {}
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/folder/exception/ApiFolderErrorCode.java b/backend/src/main/java/kernel360/techpick/feature/domain/folder/exception/ApiFolderErrorCode.java
new file mode 100644
index 00000000..0b92808b
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/folder/exception/ApiFolderErrorCode.java
@@ -0,0 +1,65 @@
+package kernel360.techpick.feature.domain.folder.exception;
+
+import org.springframework.http.HttpStatus;
+
+import kernel360.techpick.core.exception.base.ApiErrorCode;
+import kernel360.techpick.core.exception.level.ErrorLevel;
+
+public enum ApiFolderErrorCode implements ApiErrorCode {
+
+ /**
+ * Folder Error Code (FO)
+ */
+ FOLDER_NOT_FOUND
+ ("FO-000", HttpStatus.BAD_REQUEST, "존재하지 않는 폴더", ErrorLevel.SHOULD_NOT_HAPPEN()),
+ FOLDER_ALREADY_EXIST
+ ("FO-001", HttpStatus.BAD_REQUEST, "이미 존재하는 폴더 이름", ErrorLevel.CAN_HAPPEN()),
+ FOLDER_ACCESS_DENIED
+ ("FO-002", HttpStatus.FORBIDDEN, "접근할 수 없는 폴더", ErrorLevel.SHOULD_NOT_HAPPEN()),
+ BASIC_FOLDER_CANNOT_CHANGED
+ ("FO-003", HttpStatus.BAD_REQUEST, "기본폴더는 변경(수정/삭제/이동)할 수 없음", ErrorLevel.MUST_NEVER_HAPPEN()),
+ CANNOT_DELETE_FOLDER_NOT_IN_RECYCLE_BIN
+ ("FO-004", HttpStatus.BAD_REQUEST, "휴지통 안에 있는 폴더만 삭제할 수 있음", ErrorLevel.MUST_NEVER_HAPPEN()),
+ INVALID_FOLDER_TYPE
+ ("F0-005", HttpStatus.NOT_IMPLEMENTED, "미구현 폴더 타입에 대한 서비스 요청", ErrorLevel.MUST_NEVER_HAPPEN()),
+ BASIC_FOLDER_ALREADY_EXISTS
+ ("F0-006", HttpStatus.NOT_ACCEPTABLE, "기본 폴더는 1개만 존재할 수 있음.", ErrorLevel.MUST_NEVER_HAPPEN()),
+ INVALID_MOVE_OPERATION
+ ("F0-007", HttpStatus.NOT_ACCEPTABLE, "잘못된 이동 행위", ErrorLevel.SHOULD_NOT_HAPPEN()),
+ ;
+
+ private final String code;
+
+ private final HttpStatus httpStatus;
+
+ private final String errorMessage;
+
+ private final ErrorLevel errorLevel;
+
+ ApiFolderErrorCode(String code, HttpStatus status, String message, ErrorLevel errorLevel) {
+ this.code = code;
+ this.httpStatus = status;
+ this.errorMessage = message;
+ this.errorLevel = errorLevel;
+ }
+
+ @Override
+ public String getCode() {
+ return this.code;
+ }
+
+ @Override
+ public String getMessage() {
+ return this.errorMessage;
+ }
+
+ @Override
+ public HttpStatus getHttpStatus() {
+ return this.httpStatus;
+ }
+
+ @Override
+ public ErrorLevel getErrorLevel() {
+ return this.errorLevel;
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/folder/exception/ApiFolderException.java b/backend/src/main/java/kernel360/techpick/feature/domain/folder/exception/ApiFolderException.java
new file mode 100644
index 00000000..cb076dcc
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/folder/exception/ApiFolderException.java
@@ -0,0 +1,46 @@
+package kernel360.techpick.feature.domain.folder.exception;
+
+import kernel360.techpick.core.exception.base.ApiErrorCode;
+import kernel360.techpick.core.exception.base.ApiException;
+
+public class ApiFolderException extends ApiException {
+
+ private ApiFolderException(ApiErrorCode errorCode) {
+ super(errorCode);
+ }
+
+ /**
+ * TODO: Implement static factory method
+ */
+ public static ApiFolderException FOLDER_NOT_FOUND() {
+ return new ApiFolderException(ApiFolderErrorCode.FOLDER_NOT_FOUND);
+ }
+
+ public static ApiFolderException FOLDER_ALREADY_EXIST() {
+ return new ApiFolderException(ApiFolderErrorCode.FOLDER_ALREADY_EXIST);
+ }
+
+ public static ApiFolderException FOLDER_ACCESS_DENIED() {
+ return new ApiFolderException(ApiFolderErrorCode.FOLDER_ACCESS_DENIED);
+ }
+
+ public static ApiFolderException BASIC_FOLDER_CANNOT_CHANGED() {
+ return new ApiFolderException(ApiFolderErrorCode.BASIC_FOLDER_CANNOT_CHANGED);
+ }
+
+ public static ApiFolderException CANNOT_DELETE_FOLDER_NOT_IN_RECYCLE_BIN() {
+ return new ApiFolderException(ApiFolderErrorCode.CANNOT_DELETE_FOLDER_NOT_IN_RECYCLE_BIN);
+ }
+
+ public static ApiFolderException INVALID_FOLDER_TYPE() {
+ return new ApiFolderException(ApiFolderErrorCode.INVALID_FOLDER_TYPE);
+ }
+
+ public static ApiFolderException BASIC_FOLDER_ALREADY_EXISTS() {
+ return new ApiFolderException(ApiFolderErrorCode.BASIC_FOLDER_ALREADY_EXISTS);
+ }
+
+ public static ApiFolderException INVALID_PICK_MOVE_OPERATION() {
+ return new ApiFolderException(ApiFolderErrorCode.INVALID_MOVE_OPERATION);
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/folder/service/FolderService.java b/backend/src/main/java/kernel360/techpick/feature/domain/folder/service/FolderService.java
new file mode 100644
index 00000000..b9176182
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/folder/service/FolderService.java
@@ -0,0 +1,4 @@
+package kernel360.techpick.feature.domain.folder.service;
+
+public interface FolderService {
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/folder/service/FolderServiceImpl.java b/backend/src/main/java/kernel360/techpick/feature/domain/folder/service/FolderServiceImpl.java
new file mode 100644
index 00000000..0acd7f3f
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/folder/service/FolderServiceImpl.java
@@ -0,0 +1,10 @@
+package kernel360.techpick.feature.domain.folder.service;
+
+import org.springframework.stereotype.Service;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+public class FolderServiceImpl implements FolderService {
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/link/dto/LinkCommand.java b/backend/src/main/java/kernel360/techpick/feature/domain/link/dto/LinkCommand.java
new file mode 100644
index 00000000..6ef29576
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/link/dto/LinkCommand.java
@@ -0,0 +1,9 @@
+package kernel360.techpick.feature.domain.link.dto;
+
+public class LinkCommand {
+
+ public record Create() {}
+ public record Read() {}
+ public record Update() {}
+ public record Delete() {}
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/link/dto/LinkInfo.java b/backend/src/main/java/kernel360/techpick/feature/domain/link/dto/LinkInfo.java
new file mode 100644
index 00000000..8c3c715c
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/link/dto/LinkInfo.java
@@ -0,0 +1,8 @@
+package kernel360.techpick.feature.domain.link.dto;
+
+public record LinkInfo(
+ String url,
+ String title,
+ String description,
+ String imageUrl
+) {}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/link/dto/LinkMapper.java b/backend/src/main/java/kernel360/techpick/feature/domain/link/dto/LinkMapper.java
new file mode 100644
index 00000000..58412fc0
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/link/dto/LinkMapper.java
@@ -0,0 +1,21 @@
+package kernel360.techpick.feature.domain.link.dto;
+
+import org.mapstruct.InjectionStrategy;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.ReportingPolicy;
+
+import kernel360.techpick.core.model.link.Link;
+
+@Mapper(
+ componentModel = "spring",
+ injectionStrategy = InjectionStrategy.CONSTRUCTOR,
+ unmappedTargetPolicy = ReportingPolicy.ERROR
+)
+public interface LinkMapper {
+
+ @Mapping(target = "invalidatedAt", ignore = true)
+ Link of(LinkInfo linkInfo);
+
+ LinkInfo of(Link link);
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/link/exception/ApiLinkErrorCode.java b/backend/src/main/java/kernel360/techpick/feature/domain/link/exception/ApiLinkErrorCode.java
new file mode 100644
index 00000000..c260c54c
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/link/exception/ApiLinkErrorCode.java
@@ -0,0 +1,67 @@
+package kernel360.techpick.feature.domain.link.exception;
+
+import org.springframework.http.HttpStatus;
+
+import kernel360.techpick.core.exception.base.ApiErrorCode;
+import kernel360.techpick.core.exception.level.ErrorLevel;
+
+public enum ApiLinkErrorCode implements ApiErrorCode {
+
+ /**
+ * Link Error Code (LI)
+ */
+ LINK_NOT_FOUND
+ ("LI-000", HttpStatus.NOT_FOUND, "존재하지 않는 링크", ErrorLevel.SHOULD_NOT_HAPPEN()),
+
+ LINK_HAS_PICKS
+ ("LI-001", HttpStatus.BAD_REQUEST, "링크를 픽한 사람이 존재", ErrorLevel.SHOULD_NOT_HAPPEN()),
+
+ LINK_ALREADY_EXIST
+ ("LI-002", HttpStatus.BAD_REQUEST, "이미 존재하는 링크(URL)", ErrorLevel.CAN_HAPPEN()),
+
+ ;
+
+ // ------------------------------------------------------------
+ // 하단 코드는 모든 ApiErrorCode 들에 반드시 포함되야 합니다.
+ // 새로운 ErrorCode 구현시 복사 붙여넣기 해주세요.
+
+ private final String code;
+
+ private final HttpStatus httpStatus;
+
+ private final String errorMessage;
+
+ private final ErrorLevel errorLevel;
+
+ ApiLinkErrorCode(String code, HttpStatus status, String message, ErrorLevel errorLevel) {
+ this.code = code;
+ this.httpStatus = status;
+ this.errorMessage = message;
+ this.errorLevel = errorLevel;
+ }
+
+ @Override
+ public String getCode() {
+ return this.code;
+ }
+
+ @Override
+ public String getMessage() {
+ return this.errorMessage;
+ }
+
+ @Override
+ public HttpStatus getHttpStatus() {
+ return this.httpStatus;
+ }
+
+ @Override
+ public ErrorLevel getErrorLevel() {
+ return this.errorLevel;
+ }
+
+ @Override
+ public String toString() {
+ return convertCodeToString(this);
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/link/exception/ApiLinkException.java b/backend/src/main/java/kernel360/techpick/feature/domain/link/exception/ApiLinkException.java
new file mode 100644
index 00000000..3b7fd744
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/link/exception/ApiLinkException.java
@@ -0,0 +1,26 @@
+package kernel360.techpick.feature.domain.link.exception;
+
+import kernel360.techpick.core.exception.base.ApiErrorCode;
+import kernel360.techpick.core.exception.base.ApiException;
+
+public class ApiLinkException extends ApiException {
+
+ private ApiLinkException(ApiErrorCode errorCode) {
+ super(errorCode);
+ }
+
+ /**
+ * TODO: Implement static factory method
+ */
+ public static ApiLinkException LINK_NOT_FOUND() {
+ return new ApiLinkException(ApiLinkErrorCode.LINK_NOT_FOUND);
+ }
+
+ public static ApiLinkException LINK_HAS_PICKS() {
+ return new ApiLinkException(ApiLinkErrorCode.LINK_HAS_PICKS);
+ }
+
+ public static ApiLinkException LINK_ALREADY_EXISTS() {
+ return new ApiLinkException(ApiLinkErrorCode.LINK_ALREADY_EXIST);
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/link/service/LinkService.java b/backend/src/main/java/kernel360/techpick/feature/domain/link/service/LinkService.java
new file mode 100644
index 00000000..fe3666ce
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/link/service/LinkService.java
@@ -0,0 +1,4 @@
+package kernel360.techpick.feature.domain.link.service;
+
+public interface LinkService {
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/link/service/LinkServiceImpl.java b/backend/src/main/java/kernel360/techpick/feature/domain/link/service/LinkServiceImpl.java
new file mode 100644
index 00000000..4668ba62
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/link/service/LinkServiceImpl.java
@@ -0,0 +1,10 @@
+package kernel360.techpick.feature.domain.link.service;
+
+import org.springframework.stereotype.Service;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+public class LinkServiceImpl implements LinkService {
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/pick/dto/PickCommand.java b/backend/src/main/java/kernel360/techpick/feature/domain/pick/dto/PickCommand.java
new file mode 100644
index 00000000..fdb6d2af
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/pick/dto/PickCommand.java
@@ -0,0 +1,18 @@
+package kernel360.techpick.feature.domain.pick.dto;
+
+import java.util.List;
+
+import kernel360.techpick.feature.domain.link.dto.LinkInfo;
+
+public class PickCommand {
+
+ public record Read(Long pickId) {}
+
+ public record Create(String title, String memo, List tagOrder, Long parentFolderId, LinkInfo linkInfo) {}
+
+ public record Update(Long pickId, String title, String memo, List tagIdList) {}
+
+ public record Move(Long pickId, Long parentFolderId, int orderIdx) {}
+
+ public record Delete(Long pickId) {}
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/pick/dto/PickMapper.java b/backend/src/main/java/kernel360/techpick/feature/domain/pick/dto/PickMapper.java
new file mode 100644
index 00000000..37d164bb
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/pick/dto/PickMapper.java
@@ -0,0 +1,34 @@
+package kernel360.techpick.feature.domain.pick.dto;
+
+import org.mapstruct.InjectionStrategy;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.ReportingPolicy;
+
+import kernel360.techpick.core.model.folder.Folder;
+import kernel360.techpick.core.model.link.Link;
+import kernel360.techpick.core.model.pick.Pick;
+import kernel360.techpick.core.model.user.User;
+
+@Mapper(
+ componentModel = "spring",
+ injectionStrategy = InjectionStrategy.CONSTRUCTOR,
+ unmappedTargetPolicy = ReportingPolicy.ERROR
+)
+public interface PickMapper {
+
+ @Mapping(source = "pick.link", target = "linkInfo")
+ PickResult toCreateResult(Pick pick);
+
+ @Mapping(source = "pick.link", target = "linkInfo")
+ PickResult toReadResult(Pick pick);
+
+ @Mapping(source = "pick.link", target = "linkInfo")
+ PickResult toUpdateResult(Pick pick);
+
+ @Mapping(source = "pick.link", target = "linkInfo")
+ PickResult toMoveResult(Pick pick);
+
+ @Mapping(source = "command.title", target = "title")
+ Pick toEntity(PickCommand.Create command, User user, Folder parentFolder, Link link);
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/pick/dto/PickResult.java b/backend/src/main/java/kernel360/techpick/feature/domain/pick/dto/PickResult.java
new file mode 100644
index 00000000..016cd9c9
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/pick/dto/PickResult.java
@@ -0,0 +1,13 @@
+package kernel360.techpick.feature.domain.pick.dto;
+
+import java.util.List;
+
+import kernel360.techpick.feature.domain.link.dto.LinkInfo;
+
+public record PickResult(
+ Long id,
+ String title,
+ String memo,
+ LinkInfo linkInfo,
+ List tagOrder
+) {}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/pick/exception/ApiPickErrorCode.java b/backend/src/main/java/kernel360/techpick/feature/domain/pick/exception/ApiPickErrorCode.java
new file mode 100644
index 00000000..45f56d0b
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/pick/exception/ApiPickErrorCode.java
@@ -0,0 +1,61 @@
+package kernel360.techpick.feature.domain.pick.exception;
+
+import org.springframework.http.HttpStatus;
+
+import kernel360.techpick.core.exception.base.ApiErrorCode;
+import kernel360.techpick.core.exception.level.ErrorLevel;
+
+public enum ApiPickErrorCode implements ApiErrorCode {
+
+ /**
+ * Pick Error Code (PK)
+ */
+ PICK_NOT_FOUND
+ ("PK-000", HttpStatus.NOT_FOUND, "존재하지 않는 Pick", ErrorLevel.CAN_HAPPEN()),
+ PICK_ALREADY_EXIST
+ ("PK-001", HttpStatus.BAD_REQUEST, "이미 존재하는 Pick", ErrorLevel.CAN_HAPPEN()),
+ PICK_UNAUTHORIZED_ACCESS
+ ("PK-002", HttpStatus.UNAUTHORIZED, "잘못된 Pick 접근, 다른 사용자의 Pick에 접근", ErrorLevel.SHOULD_NOT_HAPPEN()),
+
+ ;
+
+ private final String code;
+
+ private final HttpStatus httpStatus;
+
+ private final String errorMessage;
+
+ private final ErrorLevel logLevel;
+
+ ApiPickErrorCode(String code, HttpStatus status, String message, ErrorLevel errorLevel) {
+ this.code = code;
+ this.httpStatus = status;
+ this.errorMessage = message;
+ this.logLevel = errorLevel;
+ }
+
+ @Override
+ public String getCode() {
+ return this.code;
+ }
+
+ @Override
+ public String getMessage() {
+ return this.errorMessage;
+ }
+
+ @Override
+ public HttpStatus getHttpStatus() {
+ return this.httpStatus;
+ }
+
+ @Override
+ public ErrorLevel getErrorLevel() {
+ return this.logLevel;
+ }
+
+ @Override
+ public String toString() {
+ return convertCodeToString(this);
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/pick/exception/ApiPickException.java b/backend/src/main/java/kernel360/techpick/feature/domain/pick/exception/ApiPickException.java
new file mode 100644
index 00000000..fc9506d0
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/pick/exception/ApiPickException.java
@@ -0,0 +1,23 @@
+package kernel360.techpick.feature.domain.pick.exception;
+
+import kernel360.techpick.core.exception.base.ApiErrorCode;
+import kernel360.techpick.core.exception.base.ApiException;
+
+public class ApiPickException extends ApiException {
+
+ private ApiPickException(ApiErrorCode errorCode) {
+ super(errorCode);
+ }
+
+ public static ApiPickException PICK_NOT_FOUND() {
+ throw new ApiPickException(ApiPickErrorCode.PICK_NOT_FOUND);
+ }
+
+ public static ApiPickException PICK_MUST_BE_UNIQUE_FOR_A_URL() {
+ throw new ApiPickException(ApiPickErrorCode.PICK_ALREADY_EXIST);
+ }
+
+ public static ApiPickException PICK_UNAUTHORIZED_ACCESS() {
+ throw new ApiPickException(ApiPickErrorCode.PICK_UNAUTHORIZED_ACCESS);
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/pick/service/PickService.java b/backend/src/main/java/kernel360/techpick/feature/domain/pick/service/PickService.java
new file mode 100644
index 00000000..63446049
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/pick/service/PickService.java
@@ -0,0 +1,17 @@
+package kernel360.techpick.feature.domain.pick.service;
+
+import kernel360.techpick.feature.domain.pick.dto.PickCommand;
+import kernel360.techpick.feature.domain.pick.dto.PickResult;
+
+public interface PickService {
+
+ PickResult getPick(PickCommand.Read command);
+
+ PickResult saveNewPick(PickCommand.Create command);
+
+ PickResult updatePick(PickCommand.Update command);
+
+ PickResult movePick(PickCommand.Move command);
+
+ void deletePick(PickCommand.Delete command);
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/pick/service/PickServiceImpl.java b/backend/src/main/java/kernel360/techpick/feature/domain/pick/service/PickServiceImpl.java
new file mode 100644
index 00000000..591fb926
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/pick/service/PickServiceImpl.java
@@ -0,0 +1,88 @@
+package kernel360.techpick.feature.domain.pick.service;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import kernel360.techpick.core.model.folder.Folder;
+import kernel360.techpick.feature.domain.pick.dto.PickCommand;
+import kernel360.techpick.feature.domain.pick.dto.PickMapper;
+import kernel360.techpick.feature.domain.pick.dto.PickResult;
+import kernel360.techpick.feature.infrastructure.folder.reader.FolderReader;
+import kernel360.techpick.feature.infrastructure.link.writer.LinkWriter;
+import kernel360.techpick.feature.infrastructure.pick.reader.PickReader;
+import kernel360.techpick.feature.infrastructure.pick.writer.PickWriter;
+import kernel360.techpick.feature.infrastructure.user.reader.UserReader;
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+public class PickServiceImpl implements PickService {
+ private final UserReader userReader;
+ private final FolderReader folderReader;
+ private final LinkWriter linkWriter;
+ private final PickReader pickReader;
+ private final PickWriter pickWriter;
+ private final PickMapper pickMapper;
+
+ @Override
+ @Transactional(readOnly = true)
+ public PickResult getPick(PickCommand.Read command) {
+ var user = userReader.readCurrentUser();
+ var pick = pickReader.readPick(user, command.pickId());
+ return pickMapper.toReadResult(pick);
+ }
+
+ @Override
+ @Transactional
+ public PickResult saveNewPick(PickCommand.Create command) {
+ var user = userReader.readCurrentUser();
+ var folder = folderReader.readFolder(user, command.parentFolderId());
+ var link = linkWriter.writeLink(command.linkInfo());
+ var pick = pickWriter.writePick(pickMapper.toEntity(command, user, folder, link));
+ return pickMapper.toCreateResult(pick);
+ }
+
+ @Override
+ @Transactional
+ public PickResult updatePick(PickCommand.Update command) {
+ var user = userReader.readCurrentUser();
+ var pick = pickReader.readPick(user, command.pickId())
+ .updateMemo(command.memo())
+ .updateTagOrder(command.tagIdList())
+ .updateTitle(command.title());
+ return pickMapper.toUpdateResult(pick);
+ }
+
+ @Override
+ @Transactional
+ public PickResult movePick(PickCommand.Move command) {
+ var user = userReader.readCurrentUser();
+ var pick = pickReader.readPick(user, command.pickId());
+ var originalParentFolder = pick.getParentFolder();
+
+ if (isParentFolderNotChanged(command, originalParentFolder)) {
+ originalParentFolder.updateChildPickOrder(command.pickId(), command.orderIdx());
+ return pickMapper.toMoveResult(pick);
+ }
+ // if moving to another folder
+ originalParentFolder.removeChildPickOrder(command.pickId());
+ var newParentFolder = folderReader.readFolder(user, command.parentFolderId());
+ newParentFolder.updateChildPickOrder(command.pickId(), command.orderIdx());
+ pick.updateParentFolder(newParentFolder);
+ return pickMapper.toMoveResult(pick);
+ }
+
+ @Override
+ @Transactional
+ public void deletePick(PickCommand.Delete command) {
+ var user = userReader.readCurrentUser();
+ var pick = pickReader.readPick(user, command.pickId());
+ var folder = pick.getParentFolder();
+ folder.removeChildPickOrder(command.pickId());
+ pickWriter.removePick(pick);
+ }
+
+ private boolean isParentFolderNotChanged(PickCommand.Move command, Folder originalFolder) {
+ return (command.parentFolderId() == null || originalFolder.getId().equals(command.parentFolderId()));
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/rss/dto/RssCommand.java b/backend/src/main/java/kernel360/techpick/feature/domain/rss/dto/RssCommand.java
new file mode 100644
index 00000000..4cfbd7a0
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/rss/dto/RssCommand.java
@@ -0,0 +1,9 @@
+package kernel360.techpick.feature.domain.rss.dto;
+
+public class RssCommand {
+
+ public record Create() {}
+ public record Read() {}
+ public record Update() {}
+ public record Delete() {}
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/rss/dto/RssMapper.java b/backend/src/main/java/kernel360/techpick/feature/domain/rss/dto/RssMapper.java
new file mode 100644
index 00000000..5496c9df
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/rss/dto/RssMapper.java
@@ -0,0 +1,13 @@
+package kernel360.techpick.feature.domain.rss.dto;
+
+import org.mapstruct.InjectionStrategy;
+import org.mapstruct.Mapper;
+import org.mapstruct.ReportingPolicy;
+
+@Mapper(
+ componentModel = "spring",
+ injectionStrategy = InjectionStrategy.CONSTRUCTOR,
+ unmappedTargetPolicy = ReportingPolicy.ERROR
+)
+public interface RssMapper {
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/rss/dto/RssResult.java b/backend/src/main/java/kernel360/techpick/feature/domain/rss/dto/RssResult.java
new file mode 100644
index 00000000..201109ac
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/rss/dto/RssResult.java
@@ -0,0 +1,9 @@
+package kernel360.techpick.feature.domain.rss.dto;
+
+public class RssResult {
+
+ public record Create() {}
+ public record Read() {}
+ public record Update() {}
+ public record Delete() {}
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/rss/exception/ApiRssErrorCode.java b/backend/src/main/java/kernel360/techpick/feature/domain/rss/exception/ApiRssErrorCode.java
new file mode 100644
index 00000000..665d4dbd
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/rss/exception/ApiRssErrorCode.java
@@ -0,0 +1,63 @@
+package kernel360.techpick.feature.domain.rss.exception;
+
+import org.springframework.http.HttpStatus;
+
+import kernel360.techpick.core.exception.base.ApiErrorCode;
+import kernel360.techpick.core.exception.level.ErrorLevel;
+
+public enum ApiRssErrorCode implements ApiErrorCode {
+
+ /**
+ * Rss Error Code (RS)
+ */
+ RSS_NOT_FOUND("RS-000", HttpStatus.NOT_FOUND, "RSS 피드 렌더링 오류 또는 RSS 지원하지 않는 사이트", ErrorLevel.SHOULD_NOT_HAPPEN());
+
+ // ------------------------------------------------------------
+ // 하단 코드는 모든 ApiErrorCode 들에 반드시 포함되야 합니다.
+ // 새로운 ErrorCode 구현시 복사 붙여넣기 해주세요.
+
+ private final String code;
+
+ private final HttpStatus httpStatus;
+
+ private final String errorMessage;
+
+ private final ErrorLevel errorLevel;
+
+ ApiRssErrorCode(String code, HttpStatus status, String message, ErrorLevel errorLevel) {
+ this.code = code;
+ this.httpStatus = status;
+ this.errorMessage = message;
+ this.errorLevel = errorLevel;
+ }
+
+ @Override
+ public String getCode() {
+ return this.code;
+ }
+
+ @Override
+ public String getMessage() {
+ return this.errorMessage;
+ }
+
+ @Override
+ public HttpStatus getHttpStatus() {
+ return this.httpStatus;
+ }
+
+ @Override
+ public ErrorLevel getErrorLevel() {
+ return this.errorLevel;
+ }
+
+ @Override
+ public String convertCodeToString(ApiErrorCode errorCode) {
+ return ApiErrorCode.super.convertCodeToString(errorCode);
+ }
+
+ @Override
+ public String toString() {
+ return convertCodeToString(this);
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/rss/exception/ApiRssException.java b/backend/src/main/java/kernel360/techpick/feature/domain/rss/exception/ApiRssException.java
new file mode 100644
index 00000000..e2aa4b74
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/rss/exception/ApiRssException.java
@@ -0,0 +1,16 @@
+package kernel360.techpick.feature.domain.rss.exception;
+
+import kernel360.techpick.core.exception.base.ApiErrorCode;
+import kernel360.techpick.core.exception.base.ApiException;
+
+public class ApiRssException extends ApiException {
+
+ private ApiRssException(ApiErrorCode errorCode) {
+ super(errorCode);
+ }
+
+ public static ApiRssException RSS_NOT_FOUND() {
+ return new ApiRssException(ApiRssErrorCode.RSS_NOT_FOUND);
+ }
+
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/rss/service/RssService.java b/backend/src/main/java/kernel360/techpick/feature/domain/rss/service/RssService.java
new file mode 100644
index 00000000..fbdaace0
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/rss/service/RssService.java
@@ -0,0 +1,4 @@
+package kernel360.techpick.feature.domain.rss.service;
+
+public interface RssService {
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/rss/service/RssServiceImpl.java b/backend/src/main/java/kernel360/techpick/feature/domain/rss/service/RssServiceImpl.java
new file mode 100644
index 00000000..2e099608
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/rss/service/RssServiceImpl.java
@@ -0,0 +1,10 @@
+package kernel360.techpick.feature.domain.rss.service;
+
+import org.springframework.stereotype.Service;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+public class RssServiceImpl implements RssService {
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/tag/dto/TagCommand.java b/backend/src/main/java/kernel360/techpick/feature/domain/tag/dto/TagCommand.java
new file mode 100644
index 00000000..0613ef53
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/tag/dto/TagCommand.java
@@ -0,0 +1,34 @@
+package kernel360.techpick.feature.domain.tag.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+
+public class TagCommand {
+
+ public record Create(
+ String name,
+ Integer colorNumber) {
+ }
+
+ public record Read(
+ Long tagId) {
+ }
+
+ public record Update(
+ Long tagId,
+ String name,
+ Integer colorNumber) {
+ }
+
+ public record Move(
+ Long tagId,
+ int orderIdx
+ ) {
+ }
+
+ public record Delete(
+ Long tagId
+ ) {
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/tag/dto/TagMapper.java b/backend/src/main/java/kernel360/techpick/feature/domain/tag/dto/TagMapper.java
new file mode 100644
index 00000000..b1f21f46
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/tag/dto/TagMapper.java
@@ -0,0 +1,22 @@
+package kernel360.techpick.feature.domain.tag.dto;
+
+import org.mapstruct.InjectionStrategy;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.ReportingPolicy;
+
+import kernel360.techpick.core.model.tag.Tag;
+import kernel360.techpick.core.model.user.User;
+
+@Mapper(
+ componentModel = "spring",
+ injectionStrategy = InjectionStrategy.CONSTRUCTOR,
+ unmappedTargetPolicy = ReportingPolicy.ERROR
+)
+public interface TagMapper {
+
+ @Mapping(source = "tag.user.id", target = "userId")
+ TagResult toResult(Tag tag);
+
+ Tag toEntity(TagCommand.Create create, User user);
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/tag/dto/TagResult.java b/backend/src/main/java/kernel360/techpick/feature/domain/tag/dto/TagResult.java
new file mode 100644
index 00000000..aac5911f
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/tag/dto/TagResult.java
@@ -0,0 +1,9 @@
+package kernel360.techpick.feature.domain.tag.dto;
+
+public record TagResult(
+ Long id,
+ String name,
+ Integer colorNumber,
+ Long userId
+) {
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/tag/exception/ApiTagErrorCode.java b/backend/src/main/java/kernel360/techpick/feature/domain/tag/exception/ApiTagErrorCode.java
new file mode 100644
index 00000000..ca82a35e
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/tag/exception/ApiTagErrorCode.java
@@ -0,0 +1,72 @@
+package kernel360.techpick.feature.domain.tag.exception;
+
+import org.springframework.http.HttpStatus;
+
+import kernel360.techpick.core.exception.base.ApiErrorCode;
+import kernel360.techpick.core.exception.level.ErrorLevel;
+
+public enum ApiTagErrorCode implements ApiErrorCode {
+
+ /**
+ * Tag Error Code (TG)
+ */
+ TAG_NOT_FOUND
+ ("TG-000", HttpStatus.BAD_REQUEST, "존재하지 않는 태그", ErrorLevel.CAN_HAPPEN()),
+
+ TAG_ALREADY_EXIST
+ ("TG-001", HttpStatus.BAD_REQUEST, "이미 존재하는 태그", ErrorLevel.CAN_HAPPEN()),
+
+ TAG_INVALID_NAME
+ ("TG-002", HttpStatus.BAD_REQUEST, "유효하지 않은 태그 이름", ErrorLevel.CAN_HAPPEN()),
+
+ UNAUTHORIZED_TAG_ACCESS
+ ("TG-003", HttpStatus.UNAUTHORIZED, "잘못된 태그 접근", ErrorLevel.SHOULD_NOT_HAPPEN()),
+
+ TAG_INVALID_ORDER
+ ("TG-004", HttpStatus.BAD_REQUEST, "유효하지 않은 태그 순서", ErrorLevel.SHOULD_NOT_HAPPEN()),
+ ;
+
+ // ------------------------------------------------------------
+ // 하단 코드는 모든 ApiErrorCode 들에 반드시 포함되야 합니다.
+ // 새로운 ErrorCode 구현시 복사 붙여넣기 해주세요.
+
+ private final String code;
+
+ private final HttpStatus httpStatus;
+
+ private final String errorMessage;
+
+ private final ErrorLevel logLevel;
+
+ ApiTagErrorCode(String code, HttpStatus status, String message, ErrorLevel errorLevel) {
+ this.code = code;
+ this.httpStatus = status;
+ this.errorMessage = message;
+ this.logLevel = errorLevel;
+ }
+
+ @Override
+ public String getCode() {
+ return this.code;
+ }
+
+ @Override
+ public String getMessage() {
+ return this.errorMessage;
+ }
+
+ @Override
+ public HttpStatus getHttpStatus() {
+ return this.httpStatus;
+ }
+
+ @Override
+ public ErrorLevel getErrorLevel() {
+ return this.logLevel;
+ }
+
+ @Override
+ public String toString() {
+ return convertCodeToString(this);
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/tag/exception/ApiTagException.java b/backend/src/main/java/kernel360/techpick/feature/domain/tag/exception/ApiTagException.java
new file mode 100644
index 00000000..a37d0428
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/tag/exception/ApiTagException.java
@@ -0,0 +1,36 @@
+package kernel360.techpick.feature.domain.tag.exception;
+
+import kernel360.techpick.core.exception.base.ApiErrorCode;
+import kernel360.techpick.core.exception.base.ApiException;
+
+public class ApiTagException extends ApiException {
+
+ private ApiTagException(ApiErrorCode errorCode) {
+ super(errorCode);
+ }
+
+ /**
+ * TODO: Implement static factory method
+ */
+
+ public static ApiTagException TAG_NOT_FOUND() {
+ return new ApiTagException(ApiTagErrorCode.TAG_NOT_FOUND);
+ }
+
+ public static ApiTagException TAG_ALREADY_EXIST() {
+ return new ApiTagException(ApiTagErrorCode.TAG_ALREADY_EXIST);
+ }
+
+ public static ApiTagException TAG_INVALID_NAME() {
+ return new ApiTagException(ApiTagErrorCode.TAG_INVALID_NAME);
+ }
+
+ public static ApiTagException UNAUTHORIZED_TAG_ACCESS() {
+ return new ApiTagException(ApiTagErrorCode.UNAUTHORIZED_TAG_ACCESS);
+ }
+
+ public static ApiTagException TAG_INVALID_ORDER() {
+ return new ApiTagException(ApiTagErrorCode.TAG_INVALID_ORDER);
+ }
+
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/tag/service/TagService.java b/backend/src/main/java/kernel360/techpick/feature/domain/tag/service/TagService.java
new file mode 100644
index 00000000..82ea0459
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/tag/service/TagService.java
@@ -0,0 +1,21 @@
+package kernel360.techpick.feature.domain.tag.service;
+
+import java.util.List;
+
+import kernel360.techpick.feature.domain.tag.dto.TagCommand;
+import kernel360.techpick.feature.domain.tag.dto.TagResult;
+
+public interface TagService {
+
+ TagResult readTag(TagCommand.Read command);
+
+ List readUserTagList();
+
+ TagResult createTag(TagCommand.Create command);
+
+ TagResult updateTag(TagCommand.Update command);
+
+ void moveUserTag(TagCommand.Move command);
+
+ void deleteTag(TagCommand.Delete command);
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/tag/service/TagServiceImpl.java b/backend/src/main/java/kernel360/techpick/feature/domain/tag/service/TagServiceImpl.java
new file mode 100644
index 00000000..83bf8a8a
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/tag/service/TagServiceImpl.java
@@ -0,0 +1,82 @@
+package kernel360.techpick.feature.domain.tag.service;
+
+import java.util.List;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import kernel360.techpick.core.model.tag.Tag;
+import kernel360.techpick.core.model.user.User;
+import kernel360.techpick.feature.domain.tag.dto.TagCommand;
+import kernel360.techpick.feature.domain.tag.dto.TagMapper;
+import kernel360.techpick.feature.domain.tag.dto.TagResult;
+import kernel360.techpick.feature.domain.tag.exception.ApiTagException;
+import kernel360.techpick.feature.infrastructure.pick.writer.PickWriter;
+import kernel360.techpick.feature.infrastructure.tag.reader.TagReader;
+import kernel360.techpick.feature.infrastructure.tag.writer.TagWriter;
+import kernel360.techpick.feature.infrastructure.user.reader.UserReader;
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+public class TagServiceImpl implements TagService {
+
+ private final TagReader tagReader;
+ private final TagWriter tagWriter;
+ private final TagMapper tagMapper;
+ private final PickWriter pickWriter;
+ private final UserReader userReader;
+
+ @Override
+ @Transactional(readOnly = true)
+ public TagResult readTag(TagCommand.Read command) {
+ Long userId = userReader.readCurrentUserId();
+ Tag tag = tagReader.readTag(userId, command.tagId());
+ return tagMapper.toResult(tag);
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public List readUserTagList() {
+ Long userId = userReader.readCurrentUserId();
+ List tagList = tagReader.readTagList(userId);
+ return tagList.stream().map(tagMapper::toResult).toList();
+ }
+
+ @Override
+ @Transactional
+ public TagResult createTag(TagCommand.Create command) {
+ User user = userReader.readCurrentUser();
+ Tag tag = tagMapper.toEntity(command, user);
+ tagWriter.writeTag(tag);
+ return tagMapper.toResult(tag);
+ }
+
+ @Override
+ @Transactional
+ public TagResult updateTag(TagCommand.Update command) {
+ Long userId = userReader.readCurrentUserId();
+ Tag tag = tagReader.readTag(userId, command.tagId());
+ tag.updateTagName(command.name());
+ tag.updateColorNumber(command.colorNumber());
+ return tagMapper.toResult(tag);
+ }
+
+ @Override
+ @Transactional
+ public void moveUserTag(TagCommand.Move command) {
+ User user = userReader.readCurrentUser();
+ List userTagOrderList = user.getTagOrderList();
+
+ userTagOrderList.remove(command.tagId());
+ userTagOrderList.add(command.orderIdx(), command.tagId());
+ user.updateTagOrderList(userTagOrderList);
+ }
+
+ @Override
+ @Transactional
+ public void deleteTag(TagCommand.Delete command) {
+ tagWriter.removeTag(command.tagId());
+ pickWriter.removePickTag(command.tagId());
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/user/dto/UserCommand.java b/backend/src/main/java/kernel360/techpick/feature/domain/user/dto/UserCommand.java
new file mode 100644
index 00000000..18bbec6e
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/user/dto/UserCommand.java
@@ -0,0 +1,9 @@
+package kernel360.techpick.feature.domain.user.dto;
+
+public class UserCommand {
+
+ public record Create() {}
+ public record Read() {}
+ public record Update() {}
+ public record Delete() {}
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/user/dto/UserMapper.java b/backend/src/main/java/kernel360/techpick/feature/domain/user/dto/UserMapper.java
new file mode 100644
index 00000000..09e2d095
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/user/dto/UserMapper.java
@@ -0,0 +1,13 @@
+package kernel360.techpick.feature.domain.user.dto;
+
+import org.mapstruct.InjectionStrategy;
+import org.mapstruct.Mapper;
+import org.mapstruct.ReportingPolicy;
+
+@Mapper(
+ componentModel = "spring",
+ injectionStrategy = InjectionStrategy.CONSTRUCTOR,
+ unmappedTargetPolicy = ReportingPolicy.ERROR
+)
+public interface UserMapper {
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/user/exception/ApiUserErrorCode.java b/backend/src/main/java/kernel360/techpick/feature/domain/user/exception/ApiUserErrorCode.java
new file mode 100644
index 00000000..19cbdc56
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/user/exception/ApiUserErrorCode.java
@@ -0,0 +1,61 @@
+package kernel360.techpick.feature.domain.user.exception;
+
+import org.springframework.http.HttpStatus;
+
+import kernel360.techpick.core.exception.base.ApiErrorCode;
+import kernel360.techpick.core.exception.level.ErrorLevel;
+
+public enum ApiUserErrorCode implements ApiErrorCode {
+
+ /**
+ * User Error Code (U)
+ * */
+ USER_NOT_FOUND
+ ("U-000", HttpStatus.BAD_REQUEST, "사용자 없음", ErrorLevel.CAN_HAPPEN()),
+
+ ;
+
+ // ------------------------------------------------------------
+ // 하단 코드는 모든 ApiErrorCode 들에 반드시 포함되야 합니다.
+ // 새로운 ErrorCode 구현시 복사 붙여넣기 해주세요.
+
+ private final String code;
+
+ private final HttpStatus httpStatus;
+
+ private final String errorMessage;
+
+ private final ErrorLevel logLevel;
+
+ ApiUserErrorCode(String code, HttpStatus status, String message, ErrorLevel errorLevel) {
+ this.code = code;
+ this.httpStatus = status;
+ this.errorMessage = message;
+ this.logLevel = errorLevel;
+ }
+
+ @Override
+ public String getCode() {
+ return this.code;
+ }
+
+ @Override
+ public String getMessage() {
+ return this.errorMessage;
+ }
+
+ @Override
+ public HttpStatus getHttpStatus() {
+ return this.httpStatus;
+ }
+
+ @Override
+ public ErrorLevel getErrorLevel() {
+ return this.logLevel;
+ }
+
+ @Override
+ public String toString() {
+ return convertCodeToString(this);
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/user/exception/ApiUserException.java b/backend/src/main/java/kernel360/techpick/feature/domain/user/exception/ApiUserException.java
new file mode 100644
index 00000000..c9b3a2dc
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/user/exception/ApiUserException.java
@@ -0,0 +1,18 @@
+package kernel360.techpick.feature.domain.user.exception;
+
+import kernel360.techpick.core.exception.base.ApiErrorCode;
+import kernel360.techpick.core.exception.base.ApiException;
+
+public class ApiUserException extends ApiException {
+
+ private ApiUserException(ApiErrorCode errorCode) {
+ super(errorCode);
+ }
+
+ /**
+ * TODO: Implement static factory method
+ * */
+ public static ApiUserException USER_NOT_FOUND() {
+ return new ApiUserException(ApiUserErrorCode.USER_NOT_FOUND);
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/user/service/UserService.java b/backend/src/main/java/kernel360/techpick/feature/domain/user/service/UserService.java
new file mode 100644
index 00000000..1cd34552
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/user/service/UserService.java
@@ -0,0 +1,4 @@
+package kernel360.techpick.feature.domain.user.service;
+
+public interface UserService {
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/user/service/UserServiceImpl.java b/backend/src/main/java/kernel360/techpick/feature/domain/user/service/UserServiceImpl.java
new file mode 100644
index 00000000..578064dc
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/user/service/UserServiceImpl.java
@@ -0,0 +1,10 @@
+package kernel360.techpick.feature.domain.user.service;
+
+import org.springframework.stereotype.Service;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+public class UserServiceImpl implements UserService {
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/user/util/NameGenerator.java b/backend/src/main/java/kernel360/techpick/feature/domain/user/util/NameGenerator.java
new file mode 100644
index 00000000..92dfe71a
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/user/util/NameGenerator.java
@@ -0,0 +1,5 @@
+package kernel360.techpick.feature.domain.user.util;
+
+public interface NameGenerator {
+ String generateName();
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/user/util/SimpleNameGenerator.java b/backend/src/main/java/kernel360/techpick/feature/domain/user/util/SimpleNameGenerator.java
new file mode 100644
index 00000000..5f046db7
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/domain/user/util/SimpleNameGenerator.java
@@ -0,0 +1,8 @@
+package kernel360.techpick.feature.domain.user.util;
+
+public class SimpleNameGenerator implements NameGenerator {
+ @Override
+ public String generateName() {
+ return "RANDOM_NICKNAME";
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/folder/reader/FolderReader.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/folder/reader/FolderReader.java
new file mode 100644
index 00000000..7c75d492
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/folder/reader/FolderReader.java
@@ -0,0 +1,9 @@
+package kernel360.techpick.feature.infrastructure.folder.reader;
+
+import kernel360.techpick.core.model.folder.Folder;
+import kernel360.techpick.core.model.user.User;
+
+public interface FolderReader {
+
+ Folder readFolder(User user, Long folderId);
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/folder/reader/FolderReaderImpl.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/folder/reader/FolderReaderImpl.java
new file mode 100644
index 00000000..1bd0e201
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/folder/reader/FolderReaderImpl.java
@@ -0,0 +1,25 @@
+package kernel360.techpick.feature.infrastructure.folder.reader;
+
+import org.apache.commons.lang3.ObjectUtils;
+import org.springframework.stereotype.Component;
+
+import kernel360.techpick.core.model.folder.Folder;
+import kernel360.techpick.core.model.folder.FolderRepository;
+import kernel360.techpick.core.model.user.User;
+import kernel360.techpick.feature.domain.folder.exception.ApiFolderException;
+import lombok.RequiredArgsConstructor;
+
+@Component
+@RequiredArgsConstructor
+public class FolderReaderImpl implements FolderReader {
+ FolderRepository folderRepository;
+
+ @Override
+ public Folder readFolder(User user, Long folderId) {
+ Folder folder = folderRepository.findById(folderId).orElseThrow(ApiFolderException::FOLDER_NOT_FOUND);
+ if (ObjectUtils.notEqual(folder.getUser(), user)) {
+ throw ApiFolderException.FOLDER_ACCESS_DENIED();
+ }
+ return folder;
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/folder/writer/FolderWriter.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/folder/writer/FolderWriter.java
new file mode 100644
index 00000000..2d90d704
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/folder/writer/FolderWriter.java
@@ -0,0 +1,4 @@
+package kernel360.techpick.feature.infrastructure.folder.writer;
+
+public interface FolderWriter {
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/folder/writer/FolderWriterImpl.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/folder/writer/FolderWriterImpl.java
new file mode 100644
index 00000000..89946d6c
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/folder/writer/FolderWriterImpl.java
@@ -0,0 +1,4 @@
+package kernel360.techpick.feature.infrastructure.folder.writer;
+
+public class FolderWriterImpl implements FolderWriter {
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/link/reader/LinkReader.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/link/reader/LinkReader.java
new file mode 100644
index 00000000..26b0a0ac
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/link/reader/LinkReader.java
@@ -0,0 +1,8 @@
+package kernel360.techpick.feature.infrastructure.link.reader;
+
+import kernel360.techpick.core.model.link.Link;
+
+public interface LinkReader {
+
+ Link read(String url);
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/link/reader/LinkReaderImpl.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/link/reader/LinkReaderImpl.java
new file mode 100644
index 00000000..4721f9f4
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/link/reader/LinkReaderImpl.java
@@ -0,0 +1,19 @@
+package kernel360.techpick.feature.infrastructure.link.reader;
+
+import org.springframework.stereotype.Component;
+
+import kernel360.techpick.core.model.link.Link;
+import kernel360.techpick.core.model.link.LinkRepository;
+import kernel360.techpick.feature.domain.link.exception.ApiLinkException;
+import lombok.RequiredArgsConstructor;
+
+@Component
+@RequiredArgsConstructor
+public class LinkReaderImpl implements LinkReader {
+ private final LinkRepository linkRepository;
+
+ @Override
+ public Link read(String url) {
+ return linkRepository.findByUrl(url).orElseThrow(ApiLinkException::LINK_NOT_FOUND);
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/link/writer/LinkWriter.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/link/writer/LinkWriter.java
new file mode 100644
index 00000000..e1f26348
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/link/writer/LinkWriter.java
@@ -0,0 +1,9 @@
+package kernel360.techpick.feature.infrastructure.link.writer;
+
+import kernel360.techpick.core.model.link.Link;
+import kernel360.techpick.feature.domain.link.dto.LinkInfo;
+
+public interface LinkWriter {
+
+ Link writeLink(LinkInfo linkInfo);
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/link/writer/LinkWriterImpl.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/link/writer/LinkWriterImpl.java
new file mode 100644
index 00000000..7f7d7730
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/link/writer/LinkWriterImpl.java
@@ -0,0 +1,33 @@
+package kernel360.techpick.feature.infrastructure.link.writer;
+
+import java.util.Optional;
+
+import org.springframework.stereotype.Component;
+
+import jakarta.transaction.Transactional;
+import kernel360.techpick.core.model.link.Link;
+import kernel360.techpick.core.model.link.LinkRepository;
+import kernel360.techpick.core.model.pick.Pick;
+import kernel360.techpick.core.model.pick.PickRepository;
+import kernel360.techpick.feature.domain.link.dto.LinkInfo;
+import kernel360.techpick.feature.domain.link.dto.LinkMapper;
+import kernel360.techpick.feature.domain.pick.exception.ApiPickException;
+import lombok.RequiredArgsConstructor;
+
+@Component
+@RequiredArgsConstructor
+public class LinkWriterImpl implements LinkWriter {
+ private final LinkRepository linkRepository;
+ private final LinkMapper linkMapper;
+
+ @Override
+ @Transactional
+ public Link writeLink(LinkInfo info) {
+ Optional link = linkRepository.findByUrl(info.url());
+ if (link.isPresent()) {
+ link.get().updateMetadata(info.title(), info.description(), info.imageUrl());
+ return link.get();
+ }
+ return linkRepository.save(linkMapper.of(info));
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/pick/reader/PickReader.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/pick/reader/PickReader.java
new file mode 100644
index 00000000..78b86992
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/pick/reader/PickReader.java
@@ -0,0 +1,9 @@
+package kernel360.techpick.feature.infrastructure.pick.reader;
+
+import kernel360.techpick.core.model.pick.Pick;
+import kernel360.techpick.core.model.user.User;
+
+public interface PickReader {
+
+ Pick readPick(User user, Long pickId);
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/pick/reader/PickReaderImpl.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/pick/reader/PickReaderImpl.java
new file mode 100644
index 00000000..5311cdfe
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/pick/reader/PickReaderImpl.java
@@ -0,0 +1,25 @@
+package kernel360.techpick.feature.infrastructure.pick.reader;
+
+import org.apache.commons.lang3.ObjectUtils;
+import org.springframework.stereotype.Component;
+
+import kernel360.techpick.core.model.pick.Pick;
+import kernel360.techpick.core.model.pick.PickRepository;
+import kernel360.techpick.core.model.user.User;
+import kernel360.techpick.feature.domain.pick.exception.ApiPickException;
+import lombok.RequiredArgsConstructor;
+
+@Component
+@RequiredArgsConstructor
+public class PickReaderImpl implements PickReader {
+ private final PickRepository pickRepository;
+
+ @Override
+ public Pick readPick(User user, Long pickId) {
+ Pick pick = pickRepository.findById(pickId).orElseThrow(ApiPickException::PICK_NOT_FOUND);
+ if (ObjectUtils.notEqual(user, pick.getUser())) {
+ throw ApiPickException.PICK_UNAUTHORIZED_ACCESS();
+ }
+ return pick;
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/pick/writer/PickWriter.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/pick/writer/PickWriter.java
new file mode 100644
index 00000000..07fe8224
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/pick/writer/PickWriter.java
@@ -0,0 +1,13 @@
+package kernel360.techpick.feature.infrastructure.pick.writer;
+
+import kernel360.techpick.core.model.pick.Pick;
+import kernel360.techpick.feature.domain.pick.exception.ApiPickException;
+
+public interface PickWriter {
+
+ Pick writePick(Pick pick) throws ApiPickException;
+
+ void removePick(Pick pick);
+
+ void removePickTag(Long tagId);
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/pick/writer/PickWriterImpl.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/pick/writer/PickWriterImpl.java
new file mode 100644
index 00000000..655c2e68
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/pick/writer/PickWriterImpl.java
@@ -0,0 +1,49 @@
+package kernel360.techpick.feature.infrastructure.pick.writer;
+
+import org.apache.commons.lang3.ObjectUtils;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import kernel360.techpick.core.model.pick.Pick;
+import kernel360.techpick.core.model.pick.PickRepository;
+import kernel360.techpick.core.model.pick.PickTag;
+import kernel360.techpick.core.model.pick.PickTagRepository;
+import kernel360.techpick.core.model.tag.Tag;
+import kernel360.techpick.core.model.tag.TagRepository;
+import kernel360.techpick.feature.domain.pick.exception.ApiPickException;
+import kernel360.techpick.feature.domain.tag.exception.ApiTagException;
+import lombok.RequiredArgsConstructor;
+
+@Component
+@RequiredArgsConstructor
+public class PickWriterImpl implements PickWriter {
+ private final PickRepository pickRepository;
+ private final PickTagRepository pickTagRepository;
+ private final TagRepository tagRepository;
+
+ @Override
+ public Pick writePick(Pick pick) throws ApiPickException {
+ pickRepository.findByUserAndLink(pick.getUser(), pick.getLink())
+ .ifPresent((__) -> {
+ throw ApiPickException.PICK_MUST_BE_UNIQUE_FOR_A_URL();
+ });
+ Pick savedPick = pickRepository.save(pick);
+
+ for (Long tagId : pick.getTagOrder()) {
+ Tag tag = tagRepository.findById(tagId).orElseThrow(ApiPickException::PICK_SET_WITH_UNEXISTING_TAG);
+ pickTagRepository.save(PickTag.of(savedPick, tag));
+ }
+ return savedPick;
+ }
+
+ @Override
+ public void removePick(Pick pick) {
+ pickRepository.delete(pick);
+ pickTagRepository.deleteByPick(pick);
+ }
+
+ @Override
+ public void removePickTag(Long tagId) {
+ pickTagRepository.deleteByTagId(tagId);
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/tag/reader/TagReader.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/tag/reader/TagReader.java
new file mode 100644
index 00000000..42d52186
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/tag/reader/TagReader.java
@@ -0,0 +1,14 @@
+package kernel360.techpick.feature.infrastructure.tag.reader;
+
+import java.util.List;
+
+import kernel360.techpick.core.model.tag.Tag;
+
+public interface TagReader {
+
+ Tag readTag(Long userId, Long tagId);
+
+ List readTagList(Long userId);
+
+ boolean checkDuplicateTagName(Long userId, String name);
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/tag/reader/TagReaderImpl.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/tag/reader/TagReaderImpl.java
new file mode 100644
index 00000000..becf3304
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/tag/reader/TagReaderImpl.java
@@ -0,0 +1,39 @@
+package kernel360.techpick.feature.infrastructure.tag.reader;
+
+import java.util.List;
+
+import org.apache.commons.lang3.ObjectUtils;
+import org.springframework.stereotype.Component;
+
+import kernel360.techpick.core.model.tag.Tag;
+import kernel360.techpick.core.model.tag.TagRepository;
+import kernel360.techpick.feature.domain.tag.exception.ApiTagException;
+import lombok.RequiredArgsConstructor;
+
+@Component
+@RequiredArgsConstructor
+public class TagReaderImpl implements TagReader {
+
+ private TagRepository tagRepository;
+
+ @Override
+ public Tag readTag(Long userId, Long tagId) {
+ Tag tag = tagRepository.findById(tagId).orElseThrow(ApiTagException::TAG_NOT_FOUND);
+
+ if (ObjectUtils.notEqual(tag.getUser().getId(), userId)) {
+ throw ApiTagException.UNAUTHORIZED_TAG_ACCESS();
+ }
+
+ return tag;
+ }
+
+ @Override
+ public List readTagList(Long userId) {
+ return tagRepository.findAllByUserId(userId);
+ }
+
+ @Override
+ public boolean checkDuplicateTagName(Long userId, String name) {
+ return tagRepository.existsByUserIdAndName(userId, name);
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/tag/writer/TagWriter.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/tag/writer/TagWriter.java
new file mode 100644
index 00000000..591766f5
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/tag/writer/TagWriter.java
@@ -0,0 +1,10 @@
+package kernel360.techpick.feature.infrastructure.tag.writer;
+
+import kernel360.techpick.core.model.tag.Tag;
+
+public interface TagWriter {
+
+ Tag writeTag(Tag tag);
+
+ void removeTag(Long tagId);
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/tag/writer/TagWriterImpl.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/tag/writer/TagWriterImpl.java
new file mode 100644
index 00000000..d7fe5373
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/tag/writer/TagWriterImpl.java
@@ -0,0 +1,32 @@
+package kernel360.techpick.feature.infrastructure.tag.writer;
+
+import org.springframework.stereotype.Component;
+
+import kernel360.techpick.core.model.tag.Tag;
+import kernel360.techpick.core.model.tag.TagRepository;
+import kernel360.techpick.feature.domain.tag.exception.ApiTagException;
+import lombok.RequiredArgsConstructor;
+
+@Component
+@RequiredArgsConstructor
+public class TagWriterImpl implements TagWriter {
+
+ private final TagRepository tagRepository;
+
+ @Override
+ public Tag writeTag(Tag tag) {
+ validateDuplicateTagName(tag.getUser().getId(), tag.getName());
+ return tagRepository.save(tag);
+ }
+
+ @Override
+ public void removeTag(Long tagId) {
+ tagRepository.deleteById(tagId);
+ }
+
+ private void validateDuplicateTagName(Long userId, String name) throws ApiTagException {
+ if (tagRepository.existsByUserIdAndName(userId, name)) {
+ throw ApiTagException.TAG_ALREADY_EXIST();
+ }
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/user/reader/UserReader.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/user/reader/UserReader.java
new file mode 100644
index 00000000..9cc91526
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/user/reader/UserReader.java
@@ -0,0 +1,12 @@
+package kernel360.techpick.feature.infrastructure.user.reader;
+
+import kernel360.techpick.core.model.user.User;
+
+public interface UserReader {
+
+ User readUser(Long userId);
+
+ User readCurrentUser();
+
+ Long readCurrentUserId();
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/user/reader/UserReaderImpl.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/user/reader/UserReaderImpl.java
new file mode 100644
index 00000000..d2921007
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/user/reader/UserReaderImpl.java
@@ -0,0 +1,33 @@
+package kernel360.techpick.feature.infrastructure.user.reader;
+
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Component;
+
+import kernel360.techpick.core.model.user.User;
+import kernel360.techpick.core.model.user.UserRepository;
+import kernel360.techpick.feature.domain.user.exception.ApiUserException;
+import lombok.RequiredArgsConstructor;
+
+@Component
+@RequiredArgsConstructor
+public class UserReaderImpl implements UserReader {
+ UserRepository userRepository;
+
+ @Override
+ public User readUser(Long userId) {
+ return userRepository.findById(userId).orElseThrow(ApiUserException::USER_NOT_FOUND);
+ }
+
+ @Override
+ public User readCurrentUser() {
+ var authentication = SecurityContextHolder.getContext().getAuthentication();
+ long userId = (long)authentication.getPrincipal();
+ return userRepository.findById(userId).orElseThrow(ApiUserException::USER_NOT_FOUND);
+ }
+
+ @Override
+ public Long readCurrentUserId() {
+ var authentication = SecurityContextHolder.getContext().getAuthentication();
+ return (long)authentication.getPrincipal();
+ }
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/user/writer/UserWriter.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/user/writer/UserWriter.java
new file mode 100644
index 00000000..ace0ebd2
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/user/writer/UserWriter.java
@@ -0,0 +1,4 @@
+package kernel360.techpick.feature.infrastructure.user.writer;
+
+public interface UserWriter {
+}
diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/user/writer/UserWriterImpl.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/user/writer/UserWriterImpl.java
new file mode 100644
index 00000000..ac3a2417
--- /dev/null
+++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/user/writer/UserWriterImpl.java
@@ -0,0 +1,4 @@
+package kernel360.techpick.feature.infrastructure.user.writer;
+
+public class UserWriterImpl implements UserWriter {
+}
diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml
new file mode 100644
index 00000000..2aa2d519
--- /dev/null
+++ b/backend/src/main/resources/application.yaml
@@ -0,0 +1,44 @@
+# 참고 설정
+# https://github.com/Kernel360/E2E2-TALKKA/blob/develop/server/src/main/resources/application.yaml
+# https://github.com/kimminkyeu/E2E2-LOOK-US/blob/develop/backend/src/main/resources/application.yml
+
+spring:
+ application:
+ name: techpick-server
+ output:
+ ansi:
+ enabled: always
+ sql:
+ init:
+ mode: never # schema.sql 실행시 always 키고 실행하시면 됩니다. option: never, always
+
+ jpa:
+ properties:
+ hibernate:
+ format_sql: false
+ show_sql: false
+ hibernate:
+ ddl-auto: update # 이 부분 설정 주의 필요
+ datasource:
+ url: ${DOCKER_MYSQL_URL}
+ username: ${DOCKER_MYSQL_USERNAME}
+ password: ${DOCKER_MYSQL_PASSWORD}
+ driver-class-name: com.mysql.cj.jdbc.Driver
+ jwt:
+ secret:
+ ${JWT_SECRET}
+ issuer:
+ ${JWT_ISSUER}
+ # OAuth2 관련 설정
+ config:
+ import: classpath:oauth2-config.yaml
+
+springdoc:
+ swagger-ui:
+ disable-swagger-default-url: true
+ api-docs:
+ path: /api-docs
+ show-actuator: true
+
+api:
+ base-url: ${TECHPICK_BASE_URL}
diff --git a/backend/src/main/resources/logback-spring.xml b/backend/src/main/resources/logback-spring.xml
new file mode 100644
index 00000000..161b6928
--- /dev/null
+++ b/backend/src/main/resources/logback-spring.xml
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${CONSOLE_LOG_PATTERN}
+ ${CONSOLE_LOG_CHARSET}
+
+
+
+
+
+
+
+
+ 100MB
+
+ ${LOG_PATH}/${DATE_DIR}/application.%i.log
+
+ 20GB
+
+ 60
+
+
+
+ [%d{yyyy-MM-dd HH:mm:ss}:%-3relative][%thread] %-5level %logger{35}[%line] - %msg%n
+
+
+
+
+
+
+
+
+ 100MB
+
+ ${LOG_PATH}/${DATE_DIR}/sql.%i.log
+
+ 20GB
+
+ 60
+
+
+
+ [%d{yyyy-MM-dd HH:mm:ss}:%-3relative][%thread] %-5level %logger{35}[%line] - %msg%n
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/src/main/resources/oauth2-config.yaml b/backend/src/main/resources/oauth2-config.yaml
new file mode 100644
index 00000000..008bfe0a
--- /dev/null
+++ b/backend/src/main/resources/oauth2-config.yaml
@@ -0,0 +1,51 @@
+spring:
+ security:
+ oauth2:
+ client:
+ registration:
+ google:
+ client-id: ${GOOGLE_CLIENT_ID}
+ client-secret: ${GOOGLE_CLIENT_SECRET}
+ redirect-uri: ${TECHPICK_BASE_URL}/api/login/oauth2/code/google
+ scope:
+ - email
+ naver:
+ client-id: ${NAVER_CLIENT_ID}
+ client-secret: ${NAVER_CLIENT_SECRET}
+ client-name: Naver
+ redirect-uri: ${TECHPICK_BASE_URL}/api/login/oauth2/code/naver
+ authorization-grant-type: authorization_code
+ scope:
+ - email
+ kakao:
+ client-id: ${KAKAO_CLIENT_ID}
+ client-secret: ${KAKAO_CLIENT_SECRET}
+ client-name: Kakao
+ redirect-uri: ${TECHPICK_BASE_URL}/api/login/oauth2/code/kakao
+ client-authentication-method: client_secret_post
+ authorization-grant-type: authorization_code
+ scope:
+ - account_email
+ provider:
+ kakao:
+ authorization-uri: https://kauth.kakao.com/oauth/authorize
+ token-uri: https://kauth.kakao.com/oauth/token
+ user-info-uri: https://kapi.kakao.com/v2/user/me
+ user-name-attribute: id
+ naver:
+ authorization-uri: https://nid.naver.com/oauth2.0/authorize
+ token-uri: https://nid.naver.com/oauth2.0/token
+ user-info-uri: https://openapi.naver.com/v1/nid/me
+ user-name-attribute: response
+
+oauth2-attribute-config-provider:
+ attributeConfig:
+ google:
+ name: "sub"
+ email: "email"
+ kakao:
+ name: "id"
+ email: "email"
+ naver:
+ name: "id"
+ email: "email"
diff --git a/backend/src/main/resources/static/favicon.ico b/backend/src/main/resources/static/favicon.ico
new file mode 100644
index 00000000..996c3406
Binary files /dev/null and b/backend/src/main/resources/static/favicon.ico differ
diff --git a/backend/src/test/java/domain/pick/PickTest.java b/backend/src/test/java/domain/pick/PickTest.java
new file mode 100644
index 00000000..de54519f
--- /dev/null
+++ b/backend/src/test/java/domain/pick/PickTest.java
@@ -0,0 +1,7 @@
+package domain.pick;
+
+// TEST FOR PICK
+// 1. Implement Pick Fixture
+public class PickTest {
+
+}
diff --git a/backend/src/test/java/kernel360/techpick/TechpickApplicationTests.java b/backend/src/test/java/kernel360/techpick/TechpickApplicationTests.java
new file mode 100644
index 00000000..e08791e4
--- /dev/null
+++ b/backend/src/test/java/kernel360/techpick/TechpickApplicationTests.java
@@ -0,0 +1,11 @@
+package kernel360.techpick;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+
+@SpringBootTest
+class TechpickApplicationTests {
+
+ @Test
+ void contextLoads() {}
+}
\ No newline at end of file
diff --git a/backend/src/test/resources/application-test.yaml b/backend/src/test/resources/application-test.yaml
new file mode 100644
index 00000000..f97c90c2
--- /dev/null
+++ b/backend/src/test/resources/application-test.yaml
@@ -0,0 +1,17 @@
+spring:
+ config:
+ activate:
+ on-profile: test
+ import: file:.env[.properties]
+ datasource:
+ url: ${DOCKER_MYSQL_TEST_URL}
+ username: ${DOCKER_MYSQL_TEST_USERNAME}
+ password: ${DOCKER_MYSQL_TEST_PASSWORD}
+ driver-class-name: com.mysql.cj.jdbc.Driver
+ jpa:
+ properties:
+ hibernate:
+ format_sql: false
+ show_sql: true
+ hibernate:
+ ddl-auto: create-drop # 테스트용 db는 매 실행마다 초기화 되어야함
\ No newline at end of file