diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml new file mode 100644 index 00000000..34f01d1f --- /dev/null +++ b/.github/workflows/deploy-dev.yml @@ -0,0 +1,36 @@ +name: Deploy to Dev Environment + +on: + workflow_dispatch: + push: + branches: + - develop + paths: + - "server/**" + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: 'recursive' + - name: Deploy + uses: dagger/dagger-for-github@v5 + env: + SSH_DEST: ${{ secrets.DEV_SSH_DEST }} + SSH_KEY: ${{ secrets.DEV_SSH_KEY }} + with: + version: 0.12.0 + verb: call + module: ./cicd + args: >- + deploy + --source-dir=server + --profile=dev + --ssh-dest=env:SSH_DEST + --ssh-key=env:SSH_KEY + --target-path=~/repo/makevook/vook-deployment/dev + --version=${{ github.sha }} + --command="make deploy-api" diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml new file mode 100644 index 00000000..a0c3547b --- /dev/null +++ b/.github/workflows/deploy-prod.yml @@ -0,0 +1,36 @@ +name: Deploy to Prod Environment + +on: + workflow_dispatch: + push: + branches: + - main + paths: + - "server/**" + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: 'recursive' + - name: Deploy + uses: dagger/dagger-for-github@v5 + env: + SSH_DEST: ${{ secrets.PROD_SSH_DEST }} + SSH_KEY: ${{ secrets.PROD_SSH_KEY }} + with: + version: 0.12.0 + verb: call + module: ./cicd + args: >- + deploy + --source-dir=server + --profile=prod + --ssh-dest=env:SSH_DEST + --ssh-key=env:SSH_KEY + --target-path=~/repo/makevook/vook-deployment/prod + --version=${{ github.sha }} + --command="make deploy-api" diff --git a/.github/workflows/deploy-stag.yml b/.github/workflows/deploy-stag.yml new file mode 100644 index 00000000..3b7fbbca --- /dev/null +++ b/.github/workflows/deploy-stag.yml @@ -0,0 +1,36 @@ +name: Deploy to Stag Environment + +on: + workflow_dispatch: + push: + branches: + - release + paths: + - "server/**" + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: 'recursive' + - name: Deploy + uses: dagger/dagger-for-github@v5 + env: + SSH_DEST: ${{ secrets.STAG_SSH_DEST }} + SSH_KEY: ${{ secrets.STAG_SSH_KEY }} + with: + version: 0.12.0 + verb: call + module: ./cicd + args: >- + deploy + --source-dir=server + --profile=stag + --ssh-dest=env:SSH_DEST + --ssh-key=env:SSH_KEY + --target-path=~/repo/makevook/vook-deployment/stag + --version=${{ github.sha }} + --command="make deploy-api" diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..04b28663 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "devenv/sql"] + path = devenv/sql + url = git@github.com:makevook/vook-sql.git +[submodule "api/src/test/resources/migrate/sql"] + path = api/src/test/resources/migrate/sql + url = git@github.com:makevook/vook-sql.git +[submodule "server/api/src/test/resources/migrate/sql"] + path = server/api/src/test/resources/migrate/sql + url = git@github.com:makevook/vook-sql.git diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..b5cbba11 --- /dev/null +++ b/Makefile @@ -0,0 +1,3 @@ +.PHONY:sql-update +sql-update: + git submodule update --remote --merge diff --git a/README.md b/README.md index daf55198..648df155 100644 --- a/README.md +++ b/README.md @@ -1 +1,28 @@ -# vook-server \ No newline at end of file +# Vook Server + +
+ +

+ + Vook Logo + +

+ +
+ +

드래그앤드롭으로 용어의 의미를 가장 쉽고 빠르게 찾는 방법

+ +

+ Vook Chrome Extension +

+ +

+ Diagram • + Schema +

+ +
diff --git a/cicd/.gitignore b/cicd/.gitignore new file mode 100644 index 00000000..123d4997 --- /dev/null +++ b/cicd/.gitignore @@ -0,0 +1,2 @@ +/secrets +/out diff --git a/cicd/Makefile b/cicd/Makefile new file mode 100644 index 00000000..5963b1b1 --- /dev/null +++ b/cicd/Makefile @@ -0,0 +1,46 @@ +VERSION := $(shell git describe --tags --always --dirty) + +.PHONY:deploy-dev +deploy-dev: + dagger call -v deploy \ + --source-dir=../server \ + --profile=dev \ + --ssh-dest=file:./secrets/dev/dest.txt \ + --ssh-key=file:./secrets/dev/ssh.key \ + --target-path=~/repo/makevook/vook-deployment/dev \ + --version=${VERSION} \ + --command="make deploy-api" + +.PHONY:deploy-stag +deploy-stag: + dagger call -v deploy \ + --source-dir=../server \ + --profile=stag \ + --ssh-dest=file:./secrets/stag/dest.txt \ + --ssh-key=file:./secrets/stag/ssh.key \ + --target-path=~/repo/makevook/vook-deployment/stag \ + --version=${VERSION} \ + --command="make deploy-api" + +.PHONY:deploy-prod +deploy-prod: + dagger call -v deploy \ + --source-dir=../server \ + --profile=prod \ + --ssh-dest=file:./secrets/prod/dest.txt \ + --ssh-key=file:./secrets/prod/ssh.key \ + --target-path=~/repo/makevook/vook-deployment/prod \ + --version=${VERSION} \ + --command="make deploy-api" + +.PHONY:build-jar +build-jar: + dagger call -v build-api-jar --dir=../server --test --sub-module=api export --path out/api.jar + +.PHONY:build-image +build-image: build-jar + dagger call -v build-api-image --jar-file=out/api.jar export --path out/api_linux_arm64.tar + +.PHONY:run-jar +run-jar: + (cd out && java -jar api.jar) diff --git a/cicd/dagger.json b/cicd/dagger.json new file mode 100644 index 00000000..8cac4153 --- /dev/null +++ b/cicd/dagger.json @@ -0,0 +1,28 @@ +{ + "name": "vook-server", + "sdk": "go", + "dependencies": [ + { + "name": "docker", + "source": "github.com/purpleclay/daggerverse/docker@43c1c55dadf15afc9ba401dc59e04baaa3802cca" + }, + { + "name": "dockerService", + "source": "github.com/aweris/daggerverse/docker@980888a31696001fe024dc38ff104368f4a1931d" + }, + { + "name": "java", + "source": "github.com/seungyeop-lee/daggerverse/java@a5e511cb5adec1bf0b67ef8856d4191e61ab4711" + }, + { + "name": "scp", + "source": "github.com/seungyeop-lee/daggerverse/scp@63f0f2d385768aa435474a9eec552750500899f2" + }, + { + "name": "ssh", + "source": "github.com/seungyeop-lee/daggerverse/ssh@63f0f2d385768aa435474a9eec552750500899f2" + } + ], + "source": "dagger", + "engineVersion": "v0.12.0" +} diff --git a/cicd/dagger/.gitattributes b/cicd/dagger/.gitattributes new file mode 100644 index 00000000..3a454933 --- /dev/null +++ b/cicd/dagger/.gitattributes @@ -0,0 +1,4 @@ +/dagger.gen.go linguist-generated +/internal/dagger/** linguist-generated +/internal/querybuilder/** linguist-generated +/internal/telemetry/** linguist-generated diff --git a/cicd/dagger/.gitignore b/cicd/dagger/.gitignore new file mode 100644 index 00000000..7ebabcc1 --- /dev/null +++ b/cicd/dagger/.gitignore @@ -0,0 +1,4 @@ +/dagger.gen.go +/internal/dagger +/internal/querybuilder +/internal/telemetry diff --git a/cicd/dagger/go.mod b/cicd/dagger/go.mod new file mode 100644 index 00000000..f3bc6359 --- /dev/null +++ b/cicd/dagger/go.mod @@ -0,0 +1,40 @@ +module dagger/vook-server + +go 1.22.2 + +require ( + github.com/99designs/gqlgen v0.17.49 + github.com/Khan/genqlient v0.7.0 + github.com/vektah/gqlparser/v2 v2.5.16 + go.opentelemetry.io/otel v1.27.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 + go.opentelemetry.io/otel/sdk v1.27.0 + go.opentelemetry.io/otel/trace v1.27.0 + golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa + golang.org/x/sync v0.7.0 + google.golang.org/grpc v1.64.0 +) + +require ( + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/sosodev/duration v1.3.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.0.0-20240518090000-14441aefdf88 + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.3.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0 // indirect + go.opentelemetry.io/otel/log v0.3.0 + go.opentelemetry.io/otel/metric v1.27.0 // indirect + go.opentelemetry.io/otel/sdk/log v0.3.0 + go.opentelemetry.io/proto/otlp v1.3.1 + golang.org/x/net v0.26.0 // indirect + golang.org/x/sys v0.21.0 // indirect + golang.org/x/text v0.16.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240520151616-dc85e6b867a5 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240515191416-fc5f0ca64291 // indirect + google.golang.org/protobuf v1.34.1 // indirect +) diff --git a/cicd/dagger/go.sum b/cicd/dagger/go.sum new file mode 100644 index 00000000..6fea81b9 --- /dev/null +++ b/cicd/dagger/go.sum @@ -0,0 +1,87 @@ +github.com/99designs/gqlgen v0.17.49 h1:b3hNGexHd33fBSAd4NDT/c3NCcQzcAVkknhN9ym36YQ= +github.com/99designs/gqlgen v0.17.49/go.mod h1:tC8YFVZMed81x7UJ7ORUwXF4Kn6SXuucFqQBhN8+BU0= +github.com/Khan/genqlient v0.7.0 h1:GZ1meyRnzcDTK48EjqB8t3bcfYvHArCUUvgOwpz1D4w= +github.com/Khan/genqlient v0.7.0/go.mod h1:HNyy3wZvuYwmW3Y7mkoQLZsa/R5n5yIRajS1kPBvSFM= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= +github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/vektah/gqlparser/v2 v2.5.16 h1:1gcmLTvs3JLKXckwCwlUagVn/IlV2bwqle0vJ0vy5p8= +github.com/vektah/gqlparser/v2 v2.5.16/go.mod h1:1lz1OeCqgQbQepsGxPVywrjdBHW2T08PUS3pJqepRww= +go.opentelemetry.io/otel v1.27.0 h1:9BZoF3yMK/O1AafMiQTVu0YDj5Ea4hPhxCs7sGva+cg= +go.opentelemetry.io/otel v1.27.0/go.mod h1:DMpAK8fzYRzs+bi3rS5REupisuqTheUlSZJ1WnZaPAQ= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.0.0-20240518090000-14441aefdf88 h1:oM0GTNKGlc5qHctWeIGTVyda4iFFalOzMZ3Ehj5rwB4= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.0.0-20240518090000-14441aefdf88/go.mod h1:JGG8ebaMO5nXOPnvKEl+DiA4MGwFjCbjsxT1WHIEBPY= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.3.0 h1:ccBrA8nCY5mM0y5uO7FT0ze4S0TuFcWdDB2FxGMTjkI= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.3.0/go.mod h1:/9pb6634zi2Lk8LYg9Q0X8Ar6jka4dkFOylBLbVQPCE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0 h1:R9DE4kQ4k+YtfLI2ULwX82VtNQ2J8yZmA7ZIF/D+7Mc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0/go.mod h1:OQFyQVrDlbe+R7xrEyDr/2Wr67Ol0hRUgsfA+V5A95s= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 h1:qFffATk0X+HD+f1Z8lswGiOQYKHRlzfmdJm0wEaVrFA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0/go.mod h1:MOiCmryaYtc+V0Ei+Tx9o5S1ZjA7kzLucuVuyzBZloQ= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 h1:QY7/0NeRPKlzusf40ZE4t1VlMKbqSNT7cJRYzWuja0s= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0/go.mod h1:HVkSiDhTM9BoUJU8qE6j2eSWLLXvi1USXjyd2BXT8PY= +go.opentelemetry.io/otel/log v0.3.0 h1:kJRFkpUFYtny37NQzL386WbznUByZx186DpEMKhEGZs= +go.opentelemetry.io/otel/log v0.3.0/go.mod h1:ziCwqZr9soYDwGNbIL+6kAvQC+ANvjgG367HVcyR/ys= +go.opentelemetry.io/otel/metric v1.27.0 h1:hvj3vdEKyeCi4YaYfNjv2NUje8FqKqUY8IlF0FxV/ik= +go.opentelemetry.io/otel/metric v1.27.0/go.mod h1:mVFgmRlhljgBiuk/MP/oKylr4hs85GZAylncepAX/ak= +go.opentelemetry.io/otel/sdk v1.27.0 h1:mlk+/Y1gLPLn84U4tI8d3GNJmGT/eXe3ZuOXN9kTWmI= +go.opentelemetry.io/otel/sdk v1.27.0/go.mod h1:Ha9vbLwJE6W86YstIywK2xFfPjbWlCuwPtMkKdz/Y4A= +go.opentelemetry.io/otel/sdk/log v0.3.0 h1:GEjJ8iftz2l+XO1GF2856r7yYVh74URiF9JMcAacr5U= +go.opentelemetry.io/otel/sdk/log v0.3.0/go.mod h1:BwCxtmux6ACLuys1wlbc0+vGBd+xytjmjajwqqIul2g= +go.opentelemetry.io/otel/trace v1.27.0 h1:IqYb813p7cmbHk0a5y6pD5JPakbVfftRXABGt5/Rscw= +go.opentelemetry.io/otel/trace v1.27.0/go.mod h1:6RiD1hkAprV4/q+yd2ln1HG9GoPx39SuvvstaLBl+l4= +go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= +go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= +golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +google.golang.org/genproto/googleapis/api v0.0.0-20240520151616-dc85e6b867a5 h1:P8OJ/WCl/Xo4E4zoe4/bifHpSmmKwARqyqE4nW6J2GQ= +google.golang.org/genproto/googleapis/api v0.0.0-20240520151616-dc85e6b867a5/go.mod h1:RGnPtTG7r4i8sPlNyDeikXF99hMM+hN6QMm4ooG9g2g= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240515191416-fc5f0ca64291 h1:AgADTJarZTBqgjiUzRgfaBchgYB3/WFTC80GPwsMcRI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240515191416-fc5f0ca64291/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= +google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= +google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cicd/dagger/main.go b/cicd/dagger/main.go new file mode 100644 index 00000000..e1652111 --- /dev/null +++ b/cicd/dagger/main.go @@ -0,0 +1,183 @@ +package main + +import ( + "context" + "dagger/vook-server/internal/dagger" + "errors" + "fmt" + "strings" +) + +type VookServer struct{} + +func (v *VookServer) BuildApiJar( + ctx context.Context, + // 빌드 대상의 디렉토리 + dir *dagger.Directory, + // +optional + test bool, + // +optional + subModule string, +) (*dagger.File, error) { + c := dag.Java(). + Init(). + WithGradleCache(). + WithDir(dir). + Container() + + if test { + var testCommand []string + if subModule == "" { + testCommand = []string{"./gradlew", "test"} + } else { + testCommand = []string{"./gradlew", fmt.Sprintf(":%s:test", subModule)} + } + + _, err := c. + With(dag.DockerService().WithCacheVolume("docker-var/lib/docker").BindAsService). + WithExec(testCommand). + Sync(ctx) + if err != nil { + return nil, errors.New("test fail:" + err.Error()) + } + } + + var bootJarCommand []string + if subModule == "" { + bootJarCommand = []string{"./gradlew", "bootJar"} + } else { + bootJarCommand = []string{"./gradlew", fmt.Sprintf(":%s:bootJar", subModule)} + } + + jarFile := c. + WithExec(bootJarCommand). + File("jar/api.jar") + + return jarFile, nil +} + +func (v *VookServer) BuildApiImage( + // jar 파일 + jarFile *dagger.File, + // profile + // +optional + profile []string, +) *dagger.File { + if profile == nil { + profile = []string{"default"} + } + + dockerfile := ` +FROM eclipse-temurin:21-jre + +WORKDIR /app + +COPY app.jar app.jar + +ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=` + strings.Join(profile, ",") + `", "app.jar"] +` + sourceDir := dag.Directory(). + WithFile("app.jar", jarFile). + WithNewFile("Dockerfile", dockerfile) + + return dag.Docker(). + Build(sourceDir, dagger.DockerBuildOpts{ + Platform: []dagger.Platform{"linux/arm64"}, + }). + Save(dagger.DockerBuildSaveOpts{ + Name: "api", + }). + File("api_linux_arm64.tar") +} + +func (v *VookServer) SendImage( + ctx context.Context, + sshDest *dagger.Secret, + sshKey *dagger.Secret, + path string, + imageTar *dagger.File, +) error { + sshDestText, err := sshDest.Plaintext(ctx) + if err != nil { + return err + } + + _, err = dag.Scp(). + Config(strings.TrimSpace(sshDestText)). + WithIdentityFile(sshKey). + FileToRemote(imageTar, dagger.ScpCommanderFileToRemoteOpts{ + Target: path, + }). + Sync(ctx) + if err != nil { + return err + } + + return nil +} + +func (v *VookServer) Apply( + ctx context.Context, + destination *dagger.Secret, + sshKey *dagger.Secret, + imageTar *dagger.File, + path string, + version string, + command string, +) error { + destinationText, err := destination.Plaintext(ctx) + if err != nil { + return err + } + + filename, err := imageTar.Name(ctx) + if err != nil { + return err + } + + _, err = dag.SSH(). + Config(strings.TrimSpace(destinationText)). + WithIdentityFile(sshKey). + Command( + fmt.Sprintf(` +cd %s +API_FILENAME=%s API_VERSION=%s %s +`, path, filename, version, command), + ). + Sync(ctx) + if err != nil { + return err + } + + return nil +} + +func (v *VookServer) Deploy( + ctx context.Context, + sourceDir *dagger.Directory, + profile string, + sshDest *dagger.Secret, + sshKey *dagger.Secret, + targetPath string, + version string, + command string, +) error { + jarFile, err := v.BuildApiJar(ctx, sourceDir, true, "api") + if err != nil { + return err + } + + imageTar := v.BuildApiImage(jarFile, []string{"default", profile}) + + err = v.SendImage(ctx, sshDest, sshKey, targetPath, imageTar) + if err != nil { + return err + } + + err = v.Apply(ctx, sshDest, sshKey, imageTar, targetPath, version, command) + if err != nil { + return err + } + + return nil +} diff --git a/devenv/Makefile b/devenv/Makefile new file mode 100644 index 00000000..1481badb --- /dev/null +++ b/devenv/Makefile @@ -0,0 +1,15 @@ +.PHONY:up +up: + docker compose up -d --build + +.PHONY:down +down: + docker compose down + +.PHONY:log +log: + docker compose logs -f + +.PHONY:clean +clean: + docker compose down -v diff --git a/devenv/compose.yml b/devenv/compose.yml new file mode 100644 index 00000000..52409e56 --- /dev/null +++ b/devenv/compose.yml @@ -0,0 +1,58 @@ +services: + db: + build: + context: . + dockerfile: mariadb.Dockerfile + volumes: + - ./db/initdb.d:/docker-entrypoint-initdb.d + - db-data:/var/lib/mysql + environment: + MYSQL_PORT: 3306 + MYSQL_ROOT_PASSWORD: rootPw + MYSQL_DATABASE: vook + MYSQL_USER: user + MYSQL_PASSWORD: userPw + TZ: Asia/Seoul + ports: + - "3307:3306" + mem_limit: 300m + restart: unless-stopped + healthcheck: + test: [ "CMD", "healthcheck.sh", "--connect", "--innodb_initialized" ] + start_period: 3s + start_interval: 2s + interval: 1m + timeout: 5s + retries: 3 + db-init: + image: migrate/migrate:v4.17.1 + volumes: + - ./sql:/sql + command: + - -source=file:///sql + - -database=mysql://user:userPw@tcp(db:3306)/vook + - up + depends_on: + db: + condition: service_healthy + meilisearch: + image: getmeili/meilisearch:v1.9.0 + volumes: + - meili_data:/meili_data + ports: + - "7700:7700" + environment: + - MEILI_ENV=development + - MEILI_MASTER_KEY=aSampleMasterKey + mem_limit: 1000m + restart: unless-stopped + redis: + image: redis:7.2.5 + ports: + - "6379:6379" + mem_limit: 100m + restart: unless-stopped + +volumes: + db-data: { } + meili_data: { } diff --git a/devenv/db/conf.d/my.cnf b/devenv/db/conf.d/my.cnf new file mode 100644 index 00000000..c6314654 --- /dev/null +++ b/devenv/db/conf.d/my.cnf @@ -0,0 +1,11 @@ +[client] +default-character-set = utf8mb4 + +[mysql] +default-character-set = utf8mb4 + +[mysqld] +character-set-client-handshake = FALSE +character-set-server = utf8mb4 +collation-server = utf8mb4_unicode_ci +lower_case_table_names = 1 diff --git a/devenv/db/initdb.d/.gitkeep b/devenv/db/initdb.d/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/devenv/mariadb.Dockerfile b/devenv/mariadb.Dockerfile new file mode 100644 index 00000000..0c53909c --- /dev/null +++ b/devenv/mariadb.Dockerfile @@ -0,0 +1,5 @@ +FROM mariadb:10.11.8 + +# windows에서 volume mount 할 경우, 파일 권한이 777로 변경되는 문제가 있어서 아래와 같은 작업을 추가 함 +COPY db/conf.d/my.cnf /etc/mysql/conf.d/my.cnf +RUN chmod 644 /etc/mysql/conf.d/my.cnf diff --git a/devenv/sql b/devenv/sql new file mode 160000 index 00000000..4704fdcc --- /dev/null +++ b/devenv/sql @@ -0,0 +1 @@ +Subproject commit 4704fdcc9389d9a790e692b7a82d579e3aa4d243 diff --git a/docs/.tbls.yml b/docs/.tbls.yml new file mode 100644 index 00000000..1aec44b5 --- /dev/null +++ b/docs/.tbls.yml @@ -0,0 +1,6 @@ +dsn: mariadb://user:userPw@localhost:3307/vook + +docPath: schema + +er: + format: mermaid diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000..56f7150d --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,3 @@ +.PHONY:tbls +tbls: + tbls doc --rm-dist diff --git a/docs/diagram/README.md b/docs/diagram/README.md new file mode 100644 index 00000000..ca0fdd59 --- /dev/null +++ b/docs/diagram/README.md @@ -0,0 +1,23 @@ +# Diagram + +> Generated by [Spring Modulith](https://spring.io/projects/spring-modulith) + +## Overview + +![Overview](components.png) + +## User Web + +![User Web](module-vook.server.api.web.user.png) + +## Term Web + +![Term Web](module-vook.server.api.web.term.png) + +## Vocabulary Web + +![Vocabulary Web](module-vook.server.api.web.vocabulary.png) + +## Demo Web + +![Demo Web](module-vook.server.api.web.demo.png) diff --git a/docs/diagram/components.png b/docs/diagram/components.png new file mode 100644 index 00000000..3f4bfcdc Binary files /dev/null and b/docs/diagram/components.png differ diff --git a/docs/diagram/module-vook.server.api.web.demo.png b/docs/diagram/module-vook.server.api.web.demo.png new file mode 100644 index 00000000..85a8099e Binary files /dev/null and b/docs/diagram/module-vook.server.api.web.demo.png differ diff --git a/docs/diagram/module-vook.server.api.web.term.png b/docs/diagram/module-vook.server.api.web.term.png new file mode 100644 index 00000000..7c5750da Binary files /dev/null and b/docs/diagram/module-vook.server.api.web.term.png differ diff --git a/docs/diagram/module-vook.server.api.web.user.png b/docs/diagram/module-vook.server.api.web.user.png new file mode 100644 index 00000000..038b6bf4 Binary files /dev/null and b/docs/diagram/module-vook.server.api.web.user.png differ diff --git a/docs/diagram/module-vook.server.api.web.vocabulary.png b/docs/diagram/module-vook.server.api.web.vocabulary.png new file mode 100644 index 00000000..84494228 Binary files /dev/null and b/docs/diagram/module-vook.server.api.web.vocabulary.png differ diff --git a/docs/introduction/240622/README.md b/docs/introduction/240622/README.md new file mode 100644 index 00000000..49790bf6 --- /dev/null +++ b/docs/introduction/240622/README.md @@ -0,0 +1,207 @@ +--- +theme: uncover +class: + - lead +marp: true +style: | + section { + font-size: 25px; + } + section strong { + background-color: yellow; + padding: 2px; + } + section h2 { + margin: 15px 0 15px; + } +--- + +![bg center:50% 50%](./assets/logo.svg) + +--- + +# 용어의 의미를 가장 쉽고 빠르게 찾는 방법 + +--- + +### Target + +용어집을 만들고 사용하길 원하는 +개인을 위한 Utility 서비스 + +### Main Problem + +원하는 결과를 찾기 어려움 + +### How to Solve + +**드래그앤드롭**으로 쉽게 찾기 + +![bg right:58% 90%](./assets/drag-and-drop.png) + +--- + +# 프로젝트 구조 - 레이아웃 + +![w:850](./assets/layout.png) + +--- + +# 프로젝트 구조 - root (tree) + +``` +. +├── api # Spring API 서버 +├── cicd # dagger 모듈 +├── devenv # 개발에 필요한 외부 의존성 (docker) +└── docs # 관련 문서 +``` + +--- + +# 프로젝트 구조 - api main package (tree) + +``` +. +└── vook + └── server + └── api + ├── app # 앱 파트 + │   ├── common # 앱 파트 공통 + │   ├── contexts # 도메인 별 비즈니스 로직 + │   ├── crosscontext # 2개 이상의 도메인이 협력하는 비즈니스 로직 + │   └── infra # 외부 연동 어뎁터 + ├── config # 서버 설정 (@Configuration) + ├── devhelper # 개발 편의성을 위한 컴포넌트 모음 (운영에서는 미사용 될 로직) + ├── helper # 헬퍼 함수 + └── web # 웹 파트 + ├── auth # 인증, 인가 + ├── common # 웹 파트 공통 + ├── routes # 웹 라우터 (@RestController) + └── swagger # Swagger 공통 설정 +``` + +--- + +# 프로젝트 구조 - api main package (layout) + +![w:700](./assets/dep.png) + +--- + +## 도입 기술 - Swagger Schema 재사용 + +```java +// api/src/main/java/vook/server/api/web/swagger/GlobalOpenApiCustomizerImpl.java +public class GlobalOpenApiCustomizerImpl implements GlobalOpenApiCustomizer { + @Override + public void customise(OpenAPI openApi) { + openApi.getComponents() + .addSchemas(getKey(ComponentRefConsts.Schema.COMMON_API_RESPONSE), new Schema>() + .addProperty("code", new StringSchema().description("결과 코드")).addRequiredItem("code") + .addProperty("result", new Schema<>()) + ) + ... + } +} +``` + +```java +// api/src/main/java/vook/server/api/web/routes/vocabulary/VocabularyApi.java +public interface VocabularyApi { + ... + @ApiResponses(value = { + @ApiResponse( + responseCode = "400", + content = @Content( + mediaType = "application/json", + schema = @Schema(ref = ComponentRefConsts.Schema.COMMON_API_RESPONSE), + ... + ) + ), + }) + CommonApiResponse createVocabulary(VookLoginUser user, VocabularyCreateRequest request); +} +``` + +--- + +## 도입 기술 - Swagger Annotation 분리 + +```java +// api/src/main/java/vook/server/api/web/routes/health/HealthApi.java +@Tag(name = "health") +public interface HealthApi { + + @Operation( + summary = "서버 상태 확인", + description = """ + - 서버의 상태를 체크하는 API입니다.""") + @ApiResponse( + responseCode = "200", + content = @Content( + mediaType = "text/plain", + examples = @ExampleObject(name = "성공", value = "OK") + ) + ) + String health(); +} +``` + +```java +// api/src/main/java/vook/server/api/web/routes/health/HealthController.java +@RestController +@RequestMapping("/health") +public class HealthController implements HealthApi { + + @GetMapping + public String health() { + return "OK"; + } +} +``` + +--- + +## 도입 기술 - Testcontainer + +```java +// api/src/test/java/vook/server/api/testhelper/IntegrationTestBase.java +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public abstract class IntegrationTestBase { + + @ServiceConnection + protected static final MariaDBContainer mariaDBContainer = new MariaDBContainer<>("mariadb:10.11.8") + .withDatabaseName("example") + .withUsername("user") + .withPassword("userPw") + .withConfigurationOverride("db/conf") + .withTmpFs(Map.of("/var/lib/mysql", "rw")); + + protected static final MeilisearchContainer meilisearchContainer = new MeilisearchContainer("getmeili/meilisearch:v1.8.3"); + + static { + mariaDBContainer.start(); + meilisearchContainer.start(); + } + + ... + + @Configuration + public static class TestConfig { + + @Bean + public MeilisearchProperties meilisearchProperties() { + MeilisearchProperties meilisearchProperties = new MeilisearchProperties(); + meilisearchProperties.setHost(meilisearchContainer.getHostUrl()); + meilisearchProperties.setApiKey(meilisearchContainer.getMasterKey()); + return meilisearchProperties; + } + } +} +``` +--- + +## 도입 기술 - Dagger + +![w:750](./assets/dagger.png) diff --git a/docs/introduction/240622/README.pdf b/docs/introduction/240622/README.pdf new file mode 100644 index 00000000..41f5f36b Binary files /dev/null and b/docs/introduction/240622/README.pdf differ diff --git a/docs/introduction/240622/assets/dagger.png b/docs/introduction/240622/assets/dagger.png new file mode 100644 index 00000000..83c5b002 Binary files /dev/null and b/docs/introduction/240622/assets/dagger.png differ diff --git a/docs/introduction/240622/assets/dep.png b/docs/introduction/240622/assets/dep.png new file mode 100644 index 00000000..34ee7374 Binary files /dev/null and b/docs/introduction/240622/assets/dep.png differ diff --git a/docs/introduction/240622/assets/drag-and-drop.png b/docs/introduction/240622/assets/drag-and-drop.png new file mode 100644 index 00000000..353199e8 Binary files /dev/null and b/docs/introduction/240622/assets/drag-and-drop.png differ diff --git a/docs/introduction/240622/assets/layout.png b/docs/introduction/240622/assets/layout.png new file mode 100644 index 00000000..13a5c5be Binary files /dev/null and b/docs/introduction/240622/assets/layout.png differ diff --git a/docs/introduction/240622/assets/logo.svg b/docs/introduction/240622/assets/logo.svg new file mode 100644 index 00000000..7e8e296c --- /dev/null +++ b/docs/introduction/240622/assets/logo.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/docs/introduction/240622/diagram.drawio b/docs/introduction/240622/diagram.drawio new file mode 100644 index 00000000..04e38e19 --- /dev/null +++ b/docs/introduction/240622/diagram.drawio @@ -0,0 +1,336 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/schema/README.md b/docs/schema/README.md new file mode 100644 index 00000000..7e7f4964 --- /dev/null +++ b/docs/schema/README.md @@ -0,0 +1,104 @@ +# vook + +## Tables + +| Name | Columns | Comment | Type | +| ---- | ------- | ------- | ---- | +| [demo_term](demo_term.md) | 5 | 데모 용어 | BASE TABLE | +| [demo_term_synonym](demo_term_synonym.md) | 3 | 데모 용어 동의어 | BASE TABLE | +| [schema_migrations](schema_migrations.md) | 2 | | BASE TABLE | +| [social_user](social_user.md) | 5 | 소셜 사용자 | BASE TABLE | +| [template_term](template_term.md) | 5 | 템플릿 용어 | BASE TABLE | +| [template_vocabulary](template_vocabulary.md) | 2 | 템플릿 용어집 | BASE TABLE | +| [term](term.md) | 8 | 용어 | BASE TABLE | +| [users](users.md) | 9 | 사용자 | BASE TABLE | +| [user_info](user_info.md) | 6 | 사용자 정보 | BASE TABLE | +| [vocabulary](vocabulary.md) | 6 | 용어집 | BASE TABLE | + +## Relations + +```mermaid +erDiagram + +"demo_term_synonym" }o--|| "demo_term" : "FOREIGN KEY (demo_term_id) REFERENCES demo_term (id)" +"social_user" }o--o| "users" : "FOREIGN KEY (user_id) REFERENCES users (id)" +"template_term" }o--|| "template_vocabulary" : "FOREIGN KEY (template_vocabulary_id) REFERENCES template_vocabulary (id)" +"term" }o--|| "vocabulary" : "FOREIGN KEY (vocabulary_id) REFERENCES vocabulary (id)" +"user_info" |o--o| "users" : "FOREIGN KEY (user_id) REFERENCES users (id)" + +"demo_term" { + bigint_20_ id PK + varchar_100_ term + varchar_2000_ meaning + datetime_6_ created_at + datetime_6_ updated_at +} +"demo_term_synonym" { + bigint_20_ id PK + varchar_100_ synonym + bigint_20_ demo_term_id FK +} +"schema_migrations" { + bigint_20_ version PK + tinyint_1_ dirty +} +"social_user" { + bigint_20_ id PK + varchar_255_ provider + varchar_255_ provider_user_id + bigint_20_ user_id FK + datetime_6_ created_at +} +"template_term" { + bigint_20_ id PK + varchar_100_ term + varchar_2000_ meaning + varchar_255_ synonym + bigint_20_ template_vocabulary_id FK +} +"template_vocabulary" { + bigint_20_ id PK + varchar_20_ type +} +"term" { + bigint_20_ id PK + varchar_255_ uid + varchar_100_ term + varchar_2000_ meaning + varchar_255_ synonym + bigint_20_ vocabulary_id FK + datetime_6_ created_at + datetime_6_ updated_at +} +"users" { + bigint_20_ id PK + varchar_255_ uid + varchar_255_ email + varchar_30_ status + datetime_6_ registered_at + bit_1_ onboarding_completed + datetime_6_ onboarding_completed_at + datetime_6_ withdrawn_at + datetime_6_ last_updated_at +} +"user_info" { + bigint_20_ id PK + varchar_10_ nickname + bit_1_ marketing_email_opt_in + varchar_20_ funnel + varchar_20_ job + bigint_20_ user_id FK +} +"vocabulary" { + bigint_20_ id PK + varchar_255_ uid + varchar_20_ name + varchar_255_ user_uid + datetime_6_ created_at + datetime_6_ updated_at +} +``` + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/docs/schema/demo_term.md b/docs/schema/demo_term.md new file mode 100644 index 00000000..683b01ce --- /dev/null +++ b/docs/schema/demo_term.md @@ -0,0 +1,68 @@ +# demo_term + +## Description + +데모 용어 + +
+Table Definition + +```sql +CREATE TABLE `demo_term` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID', + `term` varchar(100) NOT NULL COMMENT '용어', + `meaning` varchar(2000) NOT NULL COMMENT '뜻', + `created_at` datetime(6) DEFAULT NULL COMMENT '생성일시', + `updated_at` datetime(6) DEFAULT NULL COMMENT '수정일시', + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=[Redacted by tbls] DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='데모 용어' +``` + +
+ +## Columns + +| Name | Type | Default | Nullable | Extra Definition | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | ---------------- | -------- | ------- | ------- | +| id | bigint(20) | | false | auto_increment | [demo_term_synonym](demo_term_synonym.md) | | ID | +| term | varchar(100) | | false | | | | 용어 | +| meaning | varchar(2000) | | false | | | | 뜻 | +| created_at | datetime(6) | NULL | true | | | | 생성일시 | +| updated_at | datetime(6) | NULL | true | | | | 수정일시 | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| PRIMARY | PRIMARY KEY | PRIMARY KEY (id) | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| PRIMARY | PRIMARY KEY (id) USING BTREE | + +## Relations + +```mermaid +erDiagram + +"demo_term_synonym" }o--|| "demo_term" : "FOREIGN KEY (demo_term_id) REFERENCES demo_term (id)" + +"demo_term" { + bigint_20_ id PK + varchar_100_ term + varchar_2000_ meaning + datetime_6_ created_at + datetime_6_ updated_at +} +"demo_term_synonym" { + bigint_20_ id PK + varchar_100_ synonym + bigint_20_ demo_term_id FK +} +``` + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/docs/schema/demo_term_synonym.md b/docs/schema/demo_term_synonym.md new file mode 100644 index 00000000..8e83d56d --- /dev/null +++ b/docs/schema/demo_term_synonym.md @@ -0,0 +1,68 @@ +# demo_term_synonym + +## Description + +데모 용어 동의어 + +
+Table Definition + +```sql +CREATE TABLE `demo_term_synonym` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID', + `synonym` varchar(100) NOT NULL COMMENT '동의어', + `demo_term_id` bigint(20) NOT NULL COMMENT '데모 용어 ID', + PRIMARY KEY (`id`), + KEY `fk_demo_term_synonym_demo_term` (`demo_term_id`), + CONSTRAINT `fk_demo_term_synonym_demo_term` FOREIGN KEY (`demo_term_id`) REFERENCES `demo_term` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=[Redacted by tbls] DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='데모 용어 동의어' +``` + +
+ +## Columns + +| Name | Type | Default | Nullable | Extra Definition | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | ---------------- | -------- | ------- | ------- | +| id | bigint(20) | | false | auto_increment | | | ID | +| synonym | varchar(100) | | false | | | | 동의어 | +| demo_term_id | bigint(20) | | false | | | [demo_term](demo_term.md) | 데모 용어 ID | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| fk_demo_term_synonym_demo_term | FOREIGN KEY | FOREIGN KEY (demo_term_id) REFERENCES demo_term (id) | +| PRIMARY | PRIMARY KEY | PRIMARY KEY (id) | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| fk_demo_term_synonym_demo_term | KEY fk_demo_term_synonym_demo_term (demo_term_id) USING BTREE | +| PRIMARY | PRIMARY KEY (id) USING BTREE | + +## Relations + +```mermaid +erDiagram + +"demo_term_synonym" }o--|| "demo_term" : "FOREIGN KEY (demo_term_id) REFERENCES demo_term (id)" + +"demo_term_synonym" { + bigint_20_ id PK + varchar_100_ synonym + bigint_20_ demo_term_id FK +} +"demo_term" { + bigint_20_ id PK + varchar_100_ term + varchar_2000_ meaning + datetime_6_ created_at + datetime_6_ updated_at +} +``` + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/docs/schema/schema.json b/docs/schema/schema.json new file mode 100644 index 00000000..04f59f5b --- /dev/null +++ b/docs/schema/schema.json @@ -0,0 +1 @@ +{"name":"vook","desc":"","tables":[{"name":"demo_term","type":"BASE TABLE","comment":"데모 용어","columns":[{"name":"id","type":"bigint(20)","nullable":false,"default":null,"comment":"ID","extra_def":"auto_increment"},{"name":"term","type":"varchar(100)","nullable":false,"default":null,"comment":"용어"},{"name":"meaning","type":"varchar(2000)","nullable":false,"default":null,"comment":"뜻"},{"name":"created_at","type":"datetime(6)","nullable":true,"default":"NULL","comment":"생성일시"},{"name":"updated_at","type":"datetime(6)","nullable":true,"default":"NULL","comment":"수정일시"}],"indexes":[{"name":"PRIMARY","def":"PRIMARY KEY (id) USING BTREE","table":"demo_term","columns":["id"],"comment":""}],"constraints":[{"name":"PRIMARY","type":"PRIMARY KEY","def":"PRIMARY KEY (id)","table":"demo_term","referenced_table":null,"columns":["id"],"referenced_columns":null,"comment":""}],"triggers":[],"def":"CREATE TABLE `demo_term` (\n `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',\n `term` varchar(100) NOT NULL COMMENT '용어',\n `meaning` varchar(2000) NOT NULL COMMENT '뜻',\n `created_at` datetime(6) DEFAULT NULL COMMENT '생성일시',\n `updated_at` datetime(6) DEFAULT NULL COMMENT '수정일시',\n PRIMARY KEY (`id`)\n) ENGINE=InnoDB AUTO_INCREMENT=[Redacted by tbls] DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='데모 용어'"},{"name":"demo_term_synonym","type":"BASE TABLE","comment":"데모 용어 동의어","columns":[{"name":"id","type":"bigint(20)","nullable":false,"default":null,"comment":"ID","extra_def":"auto_increment"},{"name":"synonym","type":"varchar(100)","nullable":false,"default":null,"comment":"동의어"},{"name":"demo_term_id","type":"bigint(20)","nullable":false,"default":null,"comment":"데모 용어 ID"}],"indexes":[{"name":"fk_demo_term_synonym_demo_term","def":"KEY fk_demo_term_synonym_demo_term (demo_term_id) USING BTREE","table":"demo_term_synonym","columns":["demo_term_id"],"comment":""},{"name":"PRIMARY","def":"PRIMARY KEY (id) USING BTREE","table":"demo_term_synonym","columns":["id"],"comment":""}],"constraints":[{"name":"fk_demo_term_synonym_demo_term","type":"FOREIGN KEY","def":"FOREIGN KEY (demo_term_id) REFERENCES demo_term (id)","table":"demo_term_synonym","referenced_table":"demo_term","columns":["demo_term_id"],"referenced_columns":["id"],"comment":""},{"name":"PRIMARY","type":"PRIMARY KEY","def":"PRIMARY KEY (id)","table":"demo_term_synonym","referenced_table":null,"columns":["id"],"referenced_columns":null,"comment":""}],"triggers":[],"def":"CREATE TABLE `demo_term_synonym` (\n `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',\n `synonym` varchar(100) NOT NULL COMMENT '동의어',\n `demo_term_id` bigint(20) NOT NULL COMMENT '데모 용어 ID',\n PRIMARY KEY (`id`),\n KEY `fk_demo_term_synonym_demo_term` (`demo_term_id`),\n CONSTRAINT `fk_demo_term_synonym_demo_term` FOREIGN KEY (`demo_term_id`) REFERENCES `demo_term` (`id`)\n) ENGINE=InnoDB AUTO_INCREMENT=[Redacted by tbls] DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='데모 용어 동의어'"},{"name":"schema_migrations","type":"BASE TABLE","comment":"","columns":[{"name":"version","type":"bigint(20)","nullable":false,"default":null,"comment":""},{"name":"dirty","type":"tinyint(1)","nullable":false,"default":null,"comment":""}],"indexes":[{"name":"PRIMARY","def":"PRIMARY KEY (version) USING BTREE","table":"schema_migrations","columns":["version"],"comment":""}],"constraints":[{"name":"PRIMARY","type":"PRIMARY KEY","def":"PRIMARY KEY (version)","table":"schema_migrations","referenced_table":null,"columns":["version"],"referenced_columns":null,"comment":""}],"triggers":[],"def":"CREATE TABLE `schema_migrations` (\n `version` bigint(20) NOT NULL,\n `dirty` tinyint(1) NOT NULL,\n PRIMARY KEY (`version`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"},{"name":"social_user","type":"BASE TABLE","comment":"소셜 사용자","columns":[{"name":"id","type":"bigint(20)","nullable":false,"default":null,"comment":"ID","extra_def":"auto_increment"},{"name":"provider","type":"varchar(255)","nullable":true,"default":"NULL","comment":"제공자"},{"name":"provider_user_id","type":"varchar(255)","nullable":true,"default":"NULL","comment":"제공자 사용자 ID"},{"name":"user_id","type":"bigint(20)","nullable":true,"default":"NULL","comment":"사용자 ID"},{"name":"created_at","type":"datetime(6)","nullable":true,"default":"NULL","comment":"생성일시"}],"indexes":[{"name":"fk_social_user_users","def":"KEY fk_social_user_users (user_id) USING BTREE","table":"social_user","columns":["user_id"],"comment":""},{"name":"PRIMARY","def":"PRIMARY KEY (id) USING BTREE","table":"social_user","columns":["id"],"comment":""}],"constraints":[{"name":"fk_social_user_users","type":"FOREIGN KEY","def":"FOREIGN KEY (user_id) REFERENCES users (id)","table":"social_user","referenced_table":"users","columns":["user_id"],"referenced_columns":["id"],"comment":""},{"name":"PRIMARY","type":"PRIMARY KEY","def":"PRIMARY KEY (id)","table":"social_user","referenced_table":null,"columns":["id"],"referenced_columns":null,"comment":""}],"triggers":[],"def":"CREATE TABLE `social_user` (\n `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',\n `provider` varchar(255) DEFAULT NULL COMMENT '제공자',\n `provider_user_id` varchar(255) DEFAULT NULL COMMENT '제공자 사용자 ID',\n `user_id` bigint(20) DEFAULT NULL COMMENT '사용자 ID',\n `created_at` datetime(6) DEFAULT NULL COMMENT '생성일시',\n PRIMARY KEY (`id`),\n KEY `fk_social_user_users` (`user_id`),\n CONSTRAINT `fk_social_user_users` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='소셜 사용자'"},{"name":"template_term","type":"BASE TABLE","comment":"템플릿 용어","columns":[{"name":"id","type":"bigint(20)","nullable":false,"default":null,"comment":"ID","extra_def":"auto_increment"},{"name":"term","type":"varchar(100)","nullable":false,"default":null,"comment":"용어"},{"name":"meaning","type":"varchar(2000)","nullable":false,"default":null,"comment":"뜻"},{"name":"synonym","type":"varchar(255)","nullable":true,"default":"NULL","comment":"동의어"},{"name":"template_vocabulary_id","type":"bigint(20)","nullable":false,"default":null,"comment":"템플릿 용어집 ID"}],"indexes":[{"name":"fk_template_term_template_vocabulary","def":"KEY fk_template_term_template_vocabulary (template_vocabulary_id) USING BTREE","table":"template_term","columns":["template_vocabulary_id"],"comment":""},{"name":"PRIMARY","def":"PRIMARY KEY (id) USING BTREE","table":"template_term","columns":["id"],"comment":""}],"constraints":[{"name":"fk_template_term_template_vocabulary","type":"FOREIGN KEY","def":"FOREIGN KEY (template_vocabulary_id) REFERENCES template_vocabulary (id)","table":"template_term","referenced_table":"template_vocabulary","columns":["template_vocabulary_id"],"referenced_columns":["id"],"comment":""},{"name":"PRIMARY","type":"PRIMARY KEY","def":"PRIMARY KEY (id)","table":"template_term","referenced_table":null,"columns":["id"],"referenced_columns":null,"comment":""}],"triggers":[],"def":"CREATE TABLE `template_term` (\n `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',\n `term` varchar(100) NOT NULL COMMENT '용어',\n `meaning` varchar(2000) NOT NULL COMMENT '뜻',\n `synonym` varchar(255) DEFAULT NULL COMMENT '동의어',\n `template_vocabulary_id` bigint(20) NOT NULL COMMENT '템플릿 용어집 ID',\n PRIMARY KEY (`id`),\n KEY `fk_template_term_template_vocabulary` (`template_vocabulary_id`),\n CONSTRAINT `fk_template_term_template_vocabulary` FOREIGN KEY (`template_vocabulary_id`) REFERENCES `template_vocabulary` (`id`)\n) ENGINE=InnoDB AUTO_INCREMENT=[Redacted by tbls] DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='템플릿 용어'"},{"name":"template_vocabulary","type":"BASE TABLE","comment":"템플릿 용어집","columns":[{"name":"id","type":"bigint(20)","nullable":false,"default":null,"comment":"ID","extra_def":"auto_increment"},{"name":"type","type":"varchar(20)","nullable":false,"default":null,"comment":"템플릿 용어집 타입"}],"indexes":[{"name":"PRIMARY","def":"PRIMARY KEY (id) USING BTREE","table":"template_vocabulary","columns":["id"],"comment":""},{"name":"uk_template_vocabulary_type","def":"UNIQUE KEY uk_template_vocabulary_type (type) USING BTREE","table":"template_vocabulary","columns":["type"],"comment":""}],"constraints":[{"name":"PRIMARY","type":"PRIMARY KEY","def":"PRIMARY KEY (id)","table":"template_vocabulary","referenced_table":null,"columns":["id"],"referenced_columns":null,"comment":""},{"name":"uk_template_vocabulary_type","type":"UNIQUE","def":"UNIQUE KEY uk_template_vocabulary_type (type)","table":"template_vocabulary","referenced_table":null,"columns":["type"],"referenced_columns":null,"comment":""}],"triggers":[],"def":"CREATE TABLE `template_vocabulary` (\n `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',\n `type` varchar(20) NOT NULL COMMENT '템플릿 용어집 타입',\n PRIMARY KEY (`id`),\n UNIQUE KEY `uk_template_vocabulary_type` (`type`)\n) ENGINE=InnoDB AUTO_INCREMENT=[Redacted by tbls] DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='템플릿 용어집'"},{"name":"term","type":"BASE TABLE","comment":"용어","columns":[{"name":"id","type":"bigint(20)","nullable":false,"default":null,"comment":"ID","extra_def":"auto_increment"},{"name":"uid","type":"varchar(255)","nullable":true,"default":"NULL","comment":"UID"},{"name":"term","type":"varchar(100)","nullable":false,"default":null,"comment":"용어"},{"name":"meaning","type":"varchar(2000)","nullable":false,"default":null,"comment":"뜻"},{"name":"synonym","type":"varchar(255)","nullable":true,"default":"NULL","comment":"동의어"},{"name":"vocabulary_id","type":"bigint(20)","nullable":false,"default":null,"comment":"용어집 ID"},{"name":"created_at","type":"datetime(6)","nullable":true,"default":"NULL","comment":"생성일시"},{"name":"updated_at","type":"datetime(6)","nullable":true,"default":"NULL","comment":"수정일시"}],"indexes":[{"name":"fk_term_vocabulary","def":"KEY fk_term_vocabulary (vocabulary_id) USING BTREE","table":"term","columns":["vocabulary_id"],"comment":""},{"name":"PRIMARY","def":"PRIMARY KEY (id) USING BTREE","table":"term","columns":["id"],"comment":""}],"constraints":[{"name":"fk_term_vocabulary","type":"FOREIGN KEY","def":"FOREIGN KEY (vocabulary_id) REFERENCES vocabulary (id)","table":"term","referenced_table":"vocabulary","columns":["vocabulary_id"],"referenced_columns":["id"],"comment":""},{"name":"PRIMARY","type":"PRIMARY KEY","def":"PRIMARY KEY (id)","table":"term","referenced_table":null,"columns":["id"],"referenced_columns":null,"comment":""}],"triggers":[],"def":"CREATE TABLE `term` (\n `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',\n `uid` varchar(255) DEFAULT NULL COMMENT 'UID',\n `term` varchar(100) NOT NULL COMMENT '용어',\n `meaning` varchar(2000) NOT NULL COMMENT '뜻',\n `synonym` varchar(255) DEFAULT NULL COMMENT '동의어',\n `vocabulary_id` bigint(20) NOT NULL COMMENT '용어집 ID',\n `created_at` datetime(6) DEFAULT NULL COMMENT '생성일시',\n `updated_at` datetime(6) DEFAULT NULL COMMENT '수정일시',\n PRIMARY KEY (`id`),\n KEY `fk_term_vocabulary` (`vocabulary_id`),\n CONSTRAINT `fk_term_vocabulary` FOREIGN KEY (`vocabulary_id`) REFERENCES `vocabulary` (`id`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='용어'"},{"name":"users","type":"BASE TABLE","comment":"사용자","columns":[{"name":"id","type":"bigint(20)","nullable":false,"default":null,"comment":"ID","extra_def":"auto_increment"},{"name":"uid","type":"varchar(255)","nullable":true,"default":"NULL","comment":"UID"},{"name":"email","type":"varchar(255)","nullable":true,"default":"NULL","comment":"이메일"},{"name":"status","type":"varchar(30)","nullable":false,"default":null,"comment":"상태"},{"name":"registered_at","type":"datetime(6)","nullable":true,"default":"NULL","comment":"가입 일시"},{"name":"onboarding_completed","type":"bit(1)","nullable":false,"default":null,"comment":"온보딩 완료 여부"},{"name":"onboarding_completed_at","type":"datetime(6)","nullable":true,"default":"NULL","comment":"온보딩 완료 일시"},{"name":"withdrawn_at","type":"datetime(6)","nullable":true,"default":"NULL","comment":"탈퇴 일시"},{"name":"last_updated_at","type":"datetime(6)","nullable":true,"default":"NULL","comment":"마지막 수정 일시"}],"indexes":[{"name":"PRIMARY","def":"PRIMARY KEY (id) USING BTREE","table":"users","columns":["id"],"comment":""},{"name":"uk_users_email","def":"UNIQUE KEY uk_users_email (email) USING BTREE","table":"users","columns":["email"],"comment":""}],"constraints":[{"name":"PRIMARY","type":"PRIMARY KEY","def":"PRIMARY KEY (id)","table":"users","referenced_table":null,"columns":["id"],"referenced_columns":null,"comment":""},{"name":"uk_users_email","type":"UNIQUE","def":"UNIQUE KEY uk_users_email (email)","table":"users","referenced_table":null,"columns":["email"],"referenced_columns":null,"comment":""}],"triggers":[],"def":"CREATE TABLE `users` (\n `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',\n `uid` varchar(255) DEFAULT NULL COMMENT 'UID',\n `email` varchar(255) DEFAULT NULL COMMENT '이메일',\n `status` varchar(30) NOT NULL COMMENT '상태',\n `registered_at` datetime(6) DEFAULT NULL COMMENT '가입 일시',\n `onboarding_completed` bit(1) NOT NULL COMMENT '온보딩 완료 여부',\n `onboarding_completed_at` datetime(6) DEFAULT NULL COMMENT '온보딩 완료 일시',\n `withdrawn_at` datetime(6) DEFAULT NULL COMMENT '탈퇴 일시',\n `last_updated_at` datetime(6) DEFAULT NULL COMMENT '마지막 수정 일시',\n PRIMARY KEY (`id`),\n UNIQUE KEY `uk_users_email` (`email`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='사용자'"},{"name":"user_info","type":"BASE TABLE","comment":"사용자 정보","columns":[{"name":"id","type":"bigint(20)","nullable":false,"default":null,"comment":"ID","extra_def":"auto_increment"},{"name":"nickname","type":"varchar(10)","nullable":false,"default":null,"comment":"닉네임"},{"name":"marketing_email_opt_in","type":"bit(1)","nullable":true,"default":"NULL","comment":"마케팅 이메일 수신 여부"},{"name":"funnel","type":"varchar(20)","nullable":true,"default":"NULL","comment":"유입 경로"},{"name":"job","type":"varchar(20)","nullable":true,"default":"NULL","comment":"직업"},{"name":"user_id","type":"bigint(20)","nullable":true,"default":"NULL","comment":"사용자 ID"}],"indexes":[{"name":"PRIMARY","def":"PRIMARY KEY (id) USING BTREE","table":"user_info","columns":["id"],"comment":""},{"name":"uk_user_info_user_id","def":"UNIQUE KEY uk_user_info_user_id (user_id) USING BTREE","table":"user_info","columns":["user_id"],"comment":""}],"constraints":[{"name":"fk_user_info_users","type":"FOREIGN KEY","def":"FOREIGN KEY (user_id) REFERENCES users (id)","table":"user_info","referenced_table":"users","columns":["user_id"],"referenced_columns":["id"],"comment":""},{"name":"PRIMARY","type":"PRIMARY KEY","def":"PRIMARY KEY (id)","table":"user_info","referenced_table":null,"columns":["id"],"referenced_columns":null,"comment":""},{"name":"uk_user_info_user_id","type":"UNIQUE","def":"UNIQUE KEY uk_user_info_user_id (user_id)","table":"user_info","referenced_table":null,"columns":["user_id"],"referenced_columns":null,"comment":""}],"triggers":[],"def":"CREATE TABLE `user_info` (\n `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',\n `nickname` varchar(10) NOT NULL COMMENT '닉네임',\n `marketing_email_opt_in` bit(1) DEFAULT NULL COMMENT '마케팅 이메일 수신 여부',\n `funnel` varchar(20) DEFAULT NULL COMMENT '유입 경로',\n `job` varchar(20) DEFAULT NULL COMMENT '직업',\n `user_id` bigint(20) DEFAULT NULL COMMENT '사용자 ID',\n PRIMARY KEY (`id`),\n UNIQUE KEY `uk_user_info_user_id` (`user_id`),\n CONSTRAINT `fk_user_info_users` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='사용자 정보'"},{"name":"vocabulary","type":"BASE TABLE","comment":"용어집","columns":[{"name":"id","type":"bigint(20)","nullable":false,"default":null,"comment":"ID","extra_def":"auto_increment"},{"name":"uid","type":"varchar(255)","nullable":true,"default":"NULL","comment":"UID"},{"name":"name","type":"varchar(20)","nullable":false,"default":null,"comment":"이름"},{"name":"user_uid","type":"varchar(255)","nullable":false,"default":null,"comment":"사용자 UID"},{"name":"created_at","type":"datetime(6)","nullable":true,"default":"NULL","comment":"생성일시"},{"name":"updated_at","type":"datetime(6)","nullable":true,"default":"NULL","comment":"수정일시"}],"indexes":[{"name":"PRIMARY","def":"PRIMARY KEY (id) USING BTREE","table":"vocabulary","columns":["id"],"comment":""}],"constraints":[{"name":"PRIMARY","type":"PRIMARY KEY","def":"PRIMARY KEY (id)","table":"vocabulary","referenced_table":null,"columns":["id"],"referenced_columns":null,"comment":""}],"triggers":[],"def":"CREATE TABLE `vocabulary` (\n `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',\n `uid` varchar(255) DEFAULT NULL COMMENT 'UID',\n `name` varchar(20) NOT NULL COMMENT '이름',\n `user_uid` varchar(255) NOT NULL COMMENT '사용자 UID',\n `created_at` datetime(6) DEFAULT NULL COMMENT '생성일시',\n `updated_at` datetime(6) DEFAULT NULL COMMENT '수정일시',\n PRIMARY KEY (`id`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='용어집'"}],"relations":[{"table":"demo_term_synonym","columns":["demo_term_id"],"cardinality":"Zero or more","parent_table":"demo_term","parent_columns":["id"],"parent_cardinality":"Exactly one","def":"FOREIGN KEY (demo_term_id) REFERENCES demo_term (id)","virtual":false},{"table":"social_user","columns":["user_id"],"cardinality":"Zero or more","parent_table":"users","parent_columns":["id"],"parent_cardinality":"Zero or one","def":"FOREIGN KEY (user_id) REFERENCES users (id)","virtual":false},{"table":"template_term","columns":["template_vocabulary_id"],"cardinality":"Zero or more","parent_table":"template_vocabulary","parent_columns":["id"],"parent_cardinality":"Exactly one","def":"FOREIGN KEY (template_vocabulary_id) REFERENCES template_vocabulary (id)","virtual":false},{"table":"term","columns":["vocabulary_id"],"cardinality":"Zero or more","parent_table":"vocabulary","parent_columns":["id"],"parent_cardinality":"Exactly one","def":"FOREIGN KEY (vocabulary_id) REFERENCES vocabulary (id)","virtual":false},{"table":"user_info","columns":["user_id"],"cardinality":"Zero or one","parent_table":"users","parent_columns":["id"],"parent_cardinality":"Zero or one","def":"FOREIGN KEY (user_id) REFERENCES users (id)","virtual":false}],"functions":[],"driver":{"name":"mariadb","database_version":"10.11.8-MariaDB-ubu2204","meta":{"dict":{"Functions":"Stored procedures and functions"}}}} diff --git a/docs/schema/schema_migrations.md b/docs/schema/schema_migrations.md new file mode 100644 index 00000000..692eb0a2 --- /dev/null +++ b/docs/schema/schema_migrations.md @@ -0,0 +1,51 @@ +# schema_migrations + +## Description + +
+Table Definition + +```sql +CREATE TABLE `schema_migrations` ( + `version` bigint(20) NOT NULL, + `dirty` tinyint(1) NOT NULL, + PRIMARY KEY (`version`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +``` + +
+ +## Columns + +| Name | Type | Default | Nullable | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| version | bigint(20) | | false | | | | +| dirty | tinyint(1) | | false | | | | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| PRIMARY | PRIMARY KEY | PRIMARY KEY (version) | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| PRIMARY | PRIMARY KEY (version) USING BTREE | + +## Relations + +```mermaid +erDiagram + + +"schema_migrations" { + bigint_20_ version PK + tinyint_1_ dirty +} +``` + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/docs/schema/social_user.md b/docs/schema/social_user.md new file mode 100644 index 00000000..b931f8f4 --- /dev/null +++ b/docs/schema/social_user.md @@ -0,0 +1,78 @@ +# social_user + +## Description + +소셜 사용자 + +
+Table Definition + +```sql +CREATE TABLE `social_user` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID', + `provider` varchar(255) DEFAULT NULL COMMENT '제공자', + `provider_user_id` varchar(255) DEFAULT NULL COMMENT '제공자 사용자 ID', + `user_id` bigint(20) DEFAULT NULL COMMENT '사용자 ID', + `created_at` datetime(6) DEFAULT NULL COMMENT '생성일시', + PRIMARY KEY (`id`), + KEY `fk_social_user_users` (`user_id`), + CONSTRAINT `fk_social_user_users` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='소셜 사용자' +``` + +
+ +## Columns + +| Name | Type | Default | Nullable | Extra Definition | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | ---------------- | -------- | ------- | ------- | +| id | bigint(20) | | false | auto_increment | | | ID | +| provider | varchar(255) | NULL | true | | | | 제공자 | +| provider_user_id | varchar(255) | NULL | true | | | | 제공자 사용자 ID | +| user_id | bigint(20) | NULL | true | | | [users](users.md) | 사용자 ID | +| created_at | datetime(6) | NULL | true | | | | 생성일시 | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| fk_social_user_users | FOREIGN KEY | FOREIGN KEY (user_id) REFERENCES users (id) | +| PRIMARY | PRIMARY KEY | PRIMARY KEY (id) | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| fk_social_user_users | KEY fk_social_user_users (user_id) USING BTREE | +| PRIMARY | PRIMARY KEY (id) USING BTREE | + +## Relations + +```mermaid +erDiagram + +"social_user" }o--o| "users" : "FOREIGN KEY (user_id) REFERENCES users (id)" + +"social_user" { + bigint_20_ id PK + varchar_255_ provider + varchar_255_ provider_user_id + bigint_20_ user_id FK + datetime_6_ created_at +} +"users" { + bigint_20_ id PK + varchar_255_ uid + varchar_255_ email + varchar_30_ status + datetime_6_ registered_at + bit_1_ onboarding_completed + datetime_6_ onboarding_completed_at + datetime_6_ withdrawn_at + datetime_6_ last_updated_at +} +``` + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/docs/schema/template_term.md b/docs/schema/template_term.md new file mode 100644 index 00000000..b710371f --- /dev/null +++ b/docs/schema/template_term.md @@ -0,0 +1,71 @@ +# template_term + +## Description + +템플릿 용어 + +
+Table Definition + +```sql +CREATE TABLE `template_term` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID', + `term` varchar(100) NOT NULL COMMENT '용어', + `meaning` varchar(2000) NOT NULL COMMENT '뜻', + `synonym` varchar(255) DEFAULT NULL COMMENT '동의어', + `template_vocabulary_id` bigint(20) NOT NULL COMMENT '템플릿 용어집 ID', + PRIMARY KEY (`id`), + KEY `fk_template_term_template_vocabulary` (`template_vocabulary_id`), + CONSTRAINT `fk_template_term_template_vocabulary` FOREIGN KEY (`template_vocabulary_id`) REFERENCES `template_vocabulary` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=[Redacted by tbls] DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='템플릿 용어' +``` + +
+ +## Columns + +| Name | Type | Default | Nullable | Extra Definition | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | ---------------- | -------- | ------- | ------- | +| id | bigint(20) | | false | auto_increment | | | ID | +| term | varchar(100) | | false | | | | 용어 | +| meaning | varchar(2000) | | false | | | | 뜻 | +| synonym | varchar(255) | NULL | true | | | | 동의어 | +| template_vocabulary_id | bigint(20) | | false | | | [template_vocabulary](template_vocabulary.md) | 템플릿 용어집 ID | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| fk_template_term_template_vocabulary | FOREIGN KEY | FOREIGN KEY (template_vocabulary_id) REFERENCES template_vocabulary (id) | +| PRIMARY | PRIMARY KEY | PRIMARY KEY (id) | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| fk_template_term_template_vocabulary | KEY fk_template_term_template_vocabulary (template_vocabulary_id) USING BTREE | +| PRIMARY | PRIMARY KEY (id) USING BTREE | + +## Relations + +```mermaid +erDiagram + +"template_term" }o--|| "template_vocabulary" : "FOREIGN KEY (template_vocabulary_id) REFERENCES template_vocabulary (id)" + +"template_term" { + bigint_20_ id PK + varchar_100_ term + varchar_2000_ meaning + varchar_255_ synonym + bigint_20_ template_vocabulary_id FK +} +"template_vocabulary" { + bigint_20_ id PK + varchar_20_ type +} +``` + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/docs/schema/template_vocabulary.md b/docs/schema/template_vocabulary.md new file mode 100644 index 00000000..9d8f75b3 --- /dev/null +++ b/docs/schema/template_vocabulary.md @@ -0,0 +1,64 @@ +# template_vocabulary + +## Description + +템플릿 용어집 + +
+Table Definition + +```sql +CREATE TABLE `template_vocabulary` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID', + `type` varchar(20) NOT NULL COMMENT '템플릿 용어집 타입', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_template_vocabulary_type` (`type`) +) ENGINE=InnoDB AUTO_INCREMENT=[Redacted by tbls] DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='템플릿 용어집' +``` + +
+ +## Columns + +| Name | Type | Default | Nullable | Extra Definition | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | ---------------- | -------- | ------- | ------- | +| id | bigint(20) | | false | auto_increment | [template_term](template_term.md) | | ID | +| type | varchar(20) | | false | | | | 템플릿 용어집 타입 | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| PRIMARY | PRIMARY KEY | PRIMARY KEY (id) | +| uk_template_vocabulary_type | UNIQUE | UNIQUE KEY uk_template_vocabulary_type (type) | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| PRIMARY | PRIMARY KEY (id) USING BTREE | +| uk_template_vocabulary_type | UNIQUE KEY uk_template_vocabulary_type (type) USING BTREE | + +## Relations + +```mermaid +erDiagram + +"template_term" }o--|| "template_vocabulary" : "FOREIGN KEY (template_vocabulary_id) REFERENCES template_vocabulary (id)" + +"template_vocabulary" { + bigint_20_ id PK + varchar_20_ type +} +"template_term" { + bigint_20_ id PK + varchar_100_ term + varchar_2000_ meaning + varchar_255_ synonym + bigint_20_ template_vocabulary_id FK +} +``` + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/docs/schema/term.md b/docs/schema/term.md new file mode 100644 index 00000000..3e365e3a --- /dev/null +++ b/docs/schema/term.md @@ -0,0 +1,84 @@ +# term + +## Description + +용어 + +
+Table Definition + +```sql +CREATE TABLE `term` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID', + `uid` varchar(255) DEFAULT NULL COMMENT 'UID', + `term` varchar(100) NOT NULL COMMENT '용어', + `meaning` varchar(2000) NOT NULL COMMENT '뜻', + `synonym` varchar(255) DEFAULT NULL COMMENT '동의어', + `vocabulary_id` bigint(20) NOT NULL COMMENT '용어집 ID', + `created_at` datetime(6) DEFAULT NULL COMMENT '생성일시', + `updated_at` datetime(6) DEFAULT NULL COMMENT '수정일시', + PRIMARY KEY (`id`), + KEY `fk_term_vocabulary` (`vocabulary_id`), + CONSTRAINT `fk_term_vocabulary` FOREIGN KEY (`vocabulary_id`) REFERENCES `vocabulary` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='용어' +``` + +
+ +## Columns + +| Name | Type | Default | Nullable | Extra Definition | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | ---------------- | -------- | ------- | ------- | +| id | bigint(20) | | false | auto_increment | | | ID | +| uid | varchar(255) | NULL | true | | | | UID | +| term | varchar(100) | | false | | | | 용어 | +| meaning | varchar(2000) | | false | | | | 뜻 | +| synonym | varchar(255) | NULL | true | | | | 동의어 | +| vocabulary_id | bigint(20) | | false | | | [vocabulary](vocabulary.md) | 용어집 ID | +| created_at | datetime(6) | NULL | true | | | | 생성일시 | +| updated_at | datetime(6) | NULL | true | | | | 수정일시 | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| fk_term_vocabulary | FOREIGN KEY | FOREIGN KEY (vocabulary_id) REFERENCES vocabulary (id) | +| PRIMARY | PRIMARY KEY | PRIMARY KEY (id) | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| fk_term_vocabulary | KEY fk_term_vocabulary (vocabulary_id) USING BTREE | +| PRIMARY | PRIMARY KEY (id) USING BTREE | + +## Relations + +```mermaid +erDiagram + +"term" }o--|| "vocabulary" : "FOREIGN KEY (vocabulary_id) REFERENCES vocabulary (id)" + +"term" { + bigint_20_ id PK + varchar_255_ uid + varchar_100_ term + varchar_2000_ meaning + varchar_255_ synonym + bigint_20_ vocabulary_id FK + datetime_6_ created_at + datetime_6_ updated_at +} +"vocabulary" { + bigint_20_ id PK + varchar_255_ uid + varchar_20_ name + varchar_255_ user_uid + datetime_6_ created_at + datetime_6_ updated_at +} +``` + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/docs/schema/user_info.md b/docs/schema/user_info.md new file mode 100644 index 00000000..184d7836 --- /dev/null +++ b/docs/schema/user_info.md @@ -0,0 +1,82 @@ +# user_info + +## Description + +사용자 정보 + +
+Table Definition + +```sql +CREATE TABLE `user_info` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID', + `nickname` varchar(10) NOT NULL COMMENT '닉네임', + `marketing_email_opt_in` bit(1) DEFAULT NULL COMMENT '마케팅 이메일 수신 여부', + `funnel` varchar(20) DEFAULT NULL COMMENT '유입 경로', + `job` varchar(20) DEFAULT NULL COMMENT '직업', + `user_id` bigint(20) DEFAULT NULL COMMENT '사용자 ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_info_user_id` (`user_id`), + CONSTRAINT `fk_user_info_users` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='사용자 정보' +``` + +
+ +## Columns + +| Name | Type | Default | Nullable | Extra Definition | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | ---------------- | -------- | ------- | ------- | +| id | bigint(20) | | false | auto_increment | | | ID | +| nickname | varchar(10) | | false | | | | 닉네임 | +| marketing_email_opt_in | bit(1) | NULL | true | | | | 마케팅 이메일 수신 여부 | +| funnel | varchar(20) | NULL | true | | | | 유입 경로 | +| job | varchar(20) | NULL | true | | | | 직업 | +| user_id | bigint(20) | NULL | true | | | [users](users.md) | 사용자 ID | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| fk_user_info_users | FOREIGN KEY | FOREIGN KEY (user_id) REFERENCES users (id) | +| PRIMARY | PRIMARY KEY | PRIMARY KEY (id) | +| uk_user_info_user_id | UNIQUE | UNIQUE KEY uk_user_info_user_id (user_id) | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| PRIMARY | PRIMARY KEY (id) USING BTREE | +| uk_user_info_user_id | UNIQUE KEY uk_user_info_user_id (user_id) USING BTREE | + +## Relations + +```mermaid +erDiagram + +"user_info" |o--o| "users" : "FOREIGN KEY (user_id) REFERENCES users (id)" + +"user_info" { + bigint_20_ id PK + varchar_10_ nickname + bit_1_ marketing_email_opt_in + varchar_20_ funnel + varchar_20_ job + bigint_20_ user_id FK +} +"users" { + bigint_20_ id PK + varchar_255_ uid + varchar_255_ email + varchar_30_ status + datetime_6_ registered_at + bit_1_ onboarding_completed + datetime_6_ onboarding_completed_at + datetime_6_ withdrawn_at + datetime_6_ last_updated_at +} +``` + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/docs/schema/users.md b/docs/schema/users.md new file mode 100644 index 00000000..2a5bee21 --- /dev/null +++ b/docs/schema/users.md @@ -0,0 +1,94 @@ +# users + +## Description + +사용자 + +
+Table Definition + +```sql +CREATE TABLE `users` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID', + `uid` varchar(255) DEFAULT NULL COMMENT 'UID', + `email` varchar(255) DEFAULT NULL COMMENT '이메일', + `status` varchar(30) NOT NULL COMMENT '상태', + `registered_at` datetime(6) DEFAULT NULL COMMENT '가입 일시', + `onboarding_completed` bit(1) NOT NULL COMMENT '온보딩 완료 여부', + `onboarding_completed_at` datetime(6) DEFAULT NULL COMMENT '온보딩 완료 일시', + `withdrawn_at` datetime(6) DEFAULT NULL COMMENT '탈퇴 일시', + `last_updated_at` datetime(6) DEFAULT NULL COMMENT '마지막 수정 일시', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_users_email` (`email`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='사용자' +``` + +
+ +## Columns + +| Name | Type | Default | Nullable | Extra Definition | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | ---------------- | -------- | ------- | ------- | +| id | bigint(20) | | false | auto_increment | [social_user](social_user.md) [user_info](user_info.md) | | ID | +| uid | varchar(255) | NULL | true | | | | UID | +| email | varchar(255) | NULL | true | | | | 이메일 | +| status | varchar(30) | | false | | | | 상태 | +| registered_at | datetime(6) | NULL | true | | | | 가입 일시 | +| onboarding_completed | bit(1) | | false | | | | 온보딩 완료 여부 | +| onboarding_completed_at | datetime(6) | NULL | true | | | | 온보딩 완료 일시 | +| withdrawn_at | datetime(6) | NULL | true | | | | 탈퇴 일시 | +| last_updated_at | datetime(6) | NULL | true | | | | 마지막 수정 일시 | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| PRIMARY | PRIMARY KEY | PRIMARY KEY (id) | +| uk_users_email | UNIQUE | UNIQUE KEY uk_users_email (email) | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| PRIMARY | PRIMARY KEY (id) USING BTREE | +| uk_users_email | UNIQUE KEY uk_users_email (email) USING BTREE | + +## Relations + +```mermaid +erDiagram + +"social_user" }o--o| "users" : "FOREIGN KEY (user_id) REFERENCES users (id)" +"user_info" |o--o| "users" : "FOREIGN KEY (user_id) REFERENCES users (id)" + +"users" { + bigint_20_ id PK + varchar_255_ uid + varchar_255_ email + varchar_30_ status + datetime_6_ registered_at + bit_1_ onboarding_completed + datetime_6_ onboarding_completed_at + datetime_6_ withdrawn_at + datetime_6_ last_updated_at +} +"social_user" { + bigint_20_ id PK + varchar_255_ provider + varchar_255_ provider_user_id + bigint_20_ user_id FK + datetime_6_ created_at +} +"user_info" { + bigint_20_ id PK + varchar_10_ nickname + bit_1_ marketing_email_opt_in + varchar_20_ funnel + varchar_20_ job + bigint_20_ user_id FK +} +``` + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/docs/schema/vocabulary.md b/docs/schema/vocabulary.md new file mode 100644 index 00000000..71c7360d --- /dev/null +++ b/docs/schema/vocabulary.md @@ -0,0 +1,76 @@ +# vocabulary + +## Description + +용어집 + +
+Table Definition + +```sql +CREATE TABLE `vocabulary` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID', + `uid` varchar(255) DEFAULT NULL COMMENT 'UID', + `name` varchar(20) NOT NULL COMMENT '이름', + `user_uid` varchar(255) NOT NULL COMMENT '사용자 UID', + `created_at` datetime(6) DEFAULT NULL COMMENT '생성일시', + `updated_at` datetime(6) DEFAULT NULL COMMENT '수정일시', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='용어집' +``` + +
+ +## Columns + +| Name | Type | Default | Nullable | Extra Definition | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | ---------------- | -------- | ------- | ------- | +| id | bigint(20) | | false | auto_increment | [term](term.md) | | ID | +| uid | varchar(255) | NULL | true | | | | UID | +| name | varchar(20) | | false | | | | 이름 | +| user_uid | varchar(255) | | false | | | | 사용자 UID | +| created_at | datetime(6) | NULL | true | | | | 생성일시 | +| updated_at | datetime(6) | NULL | true | | | | 수정일시 | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| PRIMARY | PRIMARY KEY | PRIMARY KEY (id) | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| PRIMARY | PRIMARY KEY (id) USING BTREE | + +## Relations + +```mermaid +erDiagram + +"term" }o--|| "vocabulary" : "FOREIGN KEY (vocabulary_id) REFERENCES vocabulary (id)" + +"vocabulary" { + bigint_20_ id PK + varchar_255_ uid + varchar_20_ name + varchar_255_ user_uid + datetime_6_ created_at + datetime_6_ updated_at +} +"term" { + bigint_20_ id PK + varchar_255_ uid + varchar_100_ term + varchar_2000_ meaning + varchar_255_ synonym + bigint_20_ vocabulary_id FK + datetime_6_ created_at + datetime_6_ updated_at +} +``` + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/poc/spring-data-redis/.gitignore b/poc/spring-data-redis/.gitignore new file mode 100644 index 00000000..c2065bc2 --- /dev/null +++ b/poc/spring-data-redis/.gitignore @@ -0,0 +1,37 @@ +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/ diff --git a/poc/spring-data-redis/build.gradle b/poc/spring-data-redis/build.gradle new file mode 100644 index 00000000..ff77c60d --- /dev/null +++ b/poc/spring-data-redis/build.gradle @@ -0,0 +1,44 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.3.1' + id 'io.spring.dependency-management' version '1.1.5' +} + +group = 'vook.server.poc' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + // spring + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // testcontainers + testImplementation 'org.springframework.boot:spring-boot-testcontainers' + testImplementation 'org.testcontainers:junit-jupiter' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/poc/spring-data-redis/gradle/wrapper/gradle-wrapper.jar b/poc/spring-data-redis/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..e6441136 Binary files /dev/null and b/poc/spring-data-redis/gradle/wrapper/gradle-wrapper.jar differ diff --git a/poc/spring-data-redis/gradle/wrapper/gradle-wrapper.properties b/poc/spring-data-redis/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..a4413138 --- /dev/null +++ b/poc/spring-data-redis/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/poc/spring-data-redis/gradlew b/poc/spring-data-redis/gradlew new file mode 100755 index 00000000..b740cf13 --- /dev/null +++ b/poc/spring-data-redis/gradlew @@ -0,0 +1,249 @@ +#!/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. +# + +############################################################################## +# +# 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 "${APP_HOME:-./}" > /dev/null && pwd -P ) || 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/poc/spring-data-redis/gradlew.bat b/poc/spring-data-redis/gradlew.bat new file mode 100644 index 00000000..25da30db --- /dev/null +++ b/poc/spring-data-redis/gradlew.bat @@ -0,0 +1,92 @@ +@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 + +@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/poc/spring-data-redis/settings.gradle b/poc/spring-data-redis/settings.gradle new file mode 100644 index 00000000..0bf55c03 --- /dev/null +++ b/poc/spring-data-redis/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'spring-data-redis' diff --git a/poc/spring-data-redis/src/main/java/vook/server/poc/springdataredis/SpringDataRedisApplication.java b/poc/spring-data-redis/src/main/java/vook/server/poc/springdataredis/SpringDataRedisApplication.java new file mode 100644 index 00000000..a84062f6 --- /dev/null +++ b/poc/spring-data-redis/src/main/java/vook/server/poc/springdataredis/SpringDataRedisApplication.java @@ -0,0 +1,12 @@ +package vook.server.poc.springdataredis; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SpringDataRedisApplication { + + public static void main(String[] args) { + SpringApplication.run(SpringDataRedisApplication.class, args); + } +} diff --git a/poc/spring-data-redis/src/main/java/vook/server/poc/springdataredis/config/RedisConfig.java b/poc/spring-data-redis/src/main/java/vook/server/poc/springdataredis/config/RedisConfig.java new file mode 100644 index 00000000..38e57f24 --- /dev/null +++ b/poc/spring-data-redis/src/main/java/vook/server/poc/springdataredis/config/RedisConfig.java @@ -0,0 +1,27 @@ +package vook.server.poc.springdataredis.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + // RedisTemplate는 직렬화 구현체를 넣지 않으면 기본적으로 JdkSerializationRedisSerializer를 사용한다. + // 디버깅 및 다른 언어와의 호환성을 위해 직렬화 방식을 변경한다. + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); + + return template; + } +} diff --git a/poc/spring-data-redis/src/main/java/vook/server/poc/springdataredis/model/User.java b/poc/spring-data-redis/src/main/java/vook/server/poc/springdataredis/model/User.java new file mode 100644 index 00000000..d0aab0f1 --- /dev/null +++ b/poc/spring-data-redis/src/main/java/vook/server/poc/springdataredis/model/User.java @@ -0,0 +1,20 @@ +package vook.server.poc.springdataredis.model; + +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; +import vook.server.poc.springdataredis.values.Redis; + +import java.util.List; + +@RedisHash(Redis.USER_KEY) +public record User( + @Id + String id, + + List allowedVocabularies, + + @TimeToLive() + Long timeToLive +) { +} diff --git a/poc/spring-data-redis/src/main/java/vook/server/poc/springdataredis/usecases/DefaultTimeToLiveSupplier.java b/poc/spring-data-redis/src/main/java/vook/server/poc/springdataredis/usecases/DefaultTimeToLiveSupplier.java new file mode 100644 index 00000000..cf93af3b --- /dev/null +++ b/poc/spring-data-redis/src/main/java/vook/server/poc/springdataredis/usecases/DefaultTimeToLiveSupplier.java @@ -0,0 +1,11 @@ +package vook.server.poc.springdataredis.usecases; + +import org.springframework.stereotype.Component; + +@Component +public class DefaultTimeToLiveSupplier implements TimeToLiveSupplier { + @Override + public long get() { + return 3600L; + } +} diff --git a/poc/spring-data-redis/src/main/java/vook/server/poc/springdataredis/usecases/TimeToLiveSupplier.java b/poc/spring-data-redis/src/main/java/vook/server/poc/springdataredis/usecases/TimeToLiveSupplier.java new file mode 100644 index 00000000..a936745f --- /dev/null +++ b/poc/spring-data-redis/src/main/java/vook/server/poc/springdataredis/usecases/TimeToLiveSupplier.java @@ -0,0 +1,5 @@ +package vook.server.poc.springdataredis.usecases; + +public interface TimeToLiveSupplier { + long get(); +} diff --git a/poc/spring-data-redis/src/main/java/vook/server/poc/springdataredis/usecases/UseRedisRepository.java b/poc/spring-data-redis/src/main/java/vook/server/poc/springdataredis/usecases/UseRedisRepository.java new file mode 100644 index 00000000..83fcb8c5 --- /dev/null +++ b/poc/spring-data-redis/src/main/java/vook/server/poc/springdataredis/usecases/UseRedisRepository.java @@ -0,0 +1,28 @@ +package vook.server.poc.springdataredis.usecases; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import vook.server.poc.springdataredis.model.User; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class UseRedisRepository { + + private final UserRepository userRepository; + private final TimeToLiveSupplier timeToLiveSupplier; + + public User createUser(String id, List accessVocabularies) { + return userRepository.save(new User(id, accessVocabularies, timeToLiveSupplier.get())); + } + + public User getUser(String id) { + return userRepository.findById(id).orElse(null); + } + + public void deleteUser(String id) { + userRepository.deleteById(id); + } + +} diff --git a/poc/spring-data-redis/src/main/java/vook/server/poc/springdataredis/usecases/UseRedisTemplate.java b/poc/spring-data-redis/src/main/java/vook/server/poc/springdataredis/usecases/UseRedisTemplate.java new file mode 100644 index 00000000..ced8cd0e --- /dev/null +++ b/poc/spring-data-redis/src/main/java/vook/server/poc/springdataredis/usecases/UseRedisTemplate.java @@ -0,0 +1,31 @@ +package vook.server.poc.springdataredis.usecases; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import vook.server.poc.springdataredis.model.User; +import vook.server.poc.springdataredis.values.Redis; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class UseRedisTemplate { + + private final RedisTemplate redisTemplate; + private final TimeToLiveSupplier timeToLiveSupplier; + + public User createUser(String id, List accessVocabularies) { + User user = new User(id, accessVocabularies, timeToLiveSupplier.get()); + redisTemplate.opsForHash().put(Redis.USER_KEY, id, user); + return user; + } + + public User getKEY(String id) { + return (User) redisTemplate.opsForHash().get(Redis.USER_KEY, id); + } + + public void deleteUser(String id) { + redisTemplate.opsForHash().delete(Redis.USER_KEY, id); + } +} diff --git a/poc/spring-data-redis/src/main/java/vook/server/poc/springdataredis/usecases/UserRepository.java b/poc/spring-data-redis/src/main/java/vook/server/poc/springdataredis/usecases/UserRepository.java new file mode 100644 index 00000000..4254fe35 --- /dev/null +++ b/poc/spring-data-redis/src/main/java/vook/server/poc/springdataredis/usecases/UserRepository.java @@ -0,0 +1,7 @@ +package vook.server.poc.springdataredis.usecases; + +import org.springframework.data.repository.CrudRepository; +import vook.server.poc.springdataredis.model.User; + +public interface UserRepository extends CrudRepository { +} diff --git a/poc/spring-data-redis/src/main/java/vook/server/poc/springdataredis/values/Redis.java b/poc/spring-data-redis/src/main/java/vook/server/poc/springdataredis/values/Redis.java new file mode 100644 index 00000000..a0737546 --- /dev/null +++ b/poc/spring-data-redis/src/main/java/vook/server/poc/springdataredis/values/Redis.java @@ -0,0 +1,5 @@ +package vook.server.poc.springdataredis.values; + +public class Redis { + public static final String USER_KEY = "User"; +} diff --git a/poc/spring-data-redis/src/main/resources/application.yml b/poc/spring-data-redis/src/main/resources/application.yml new file mode 100644 index 00000000..e69de29b diff --git a/poc/spring-data-redis/src/test/java/vook/server/poc/springdataredis/testhelper/IntegrationTestBase.java b/poc/spring-data-redis/src/test/java/vook/server/poc/springdataredis/testhelper/IntegrationTestBase.java new file mode 100644 index 00000000..14c9142b --- /dev/null +++ b/poc/spring-data-redis/src/test/java/vook/server/poc/springdataredis/testhelper/IntegrationTestBase.java @@ -0,0 +1,9 @@ +package vook.server.poc.springdataredis.testhelper; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; + +@Import(TestcontainersConfiguration.class) +@SpringBootTest +public abstract class IntegrationTestBase { +} diff --git a/poc/spring-data-redis/src/test/java/vook/server/poc/springdataredis/testhelper/TestcontainersConfiguration.java b/poc/spring-data-redis/src/test/java/vook/server/poc/springdataredis/testhelper/TestcontainersConfiguration.java new file mode 100644 index 00000000..339d6e6d --- /dev/null +++ b/poc/spring-data-redis/src/test/java/vook/server/poc/springdataredis/testhelper/TestcontainersConfiguration.java @@ -0,0 +1,18 @@ +package vook.server.poc.springdataredis.testhelper; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +@TestConfiguration(proxyBeanMethods = false) +class TestcontainersConfiguration { + + @Bean + @ServiceConnection(name = "redis") + GenericContainer redisContainer() { + return new GenericContainer<>(DockerImageName.parse("redis:latest")).withExposedPorts(6379); + } + +} diff --git a/poc/spring-data-redis/src/test/java/vook/server/poc/springdataredis/usecases/UseRedisRepositoryTTLTest.java b/poc/spring-data-redis/src/test/java/vook/server/poc/springdataredis/usecases/UseRedisRepositoryTTLTest.java new file mode 100644 index 00000000..1e79bca1 --- /dev/null +++ b/poc/spring-data-redis/src/test/java/vook/server/poc/springdataredis/usecases/UseRedisRepositoryTTLTest.java @@ -0,0 +1,53 @@ +package vook.server.poc.springdataredis.usecases; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import vook.server.poc.springdataredis.testhelper.IntegrationTestBase; + +import java.util.List; + +import static java.lang.Thread.sleep; +import static org.assertj.core.api.Assertions.assertThat; + +class UseRedisRepositoryTTLTest extends IntegrationTestBase { + + @Autowired + UseRedisRepository useRedisRepository; + + @Autowired + UserRepository userRepository; + + @AfterEach + void tearDown() { + userRepository.deleteAll(); + } + + @Test + @DisplayName("TTL 적용 확인") + void ttl() throws InterruptedException { + // given + String id = "1"; + + // when + useRedisRepository.createUser(id, List.of("a", "b")); + + // then + assertThat(userRepository.findById(id)).isNotEmpty(); + + sleep(1_000); + + assertThat(userRepository.findById(id)).isEmpty(); + } + + @TestConfiguration + public static class TestConfig { + @Bean + public TimeToLiveSupplier timeToLiveSupplier() { + return () -> 1L; + } + } +} diff --git a/poc/spring-data-redis/src/test/java/vook/server/poc/springdataredis/usecases/UseRedisRepositoryTest.java b/poc/spring-data-redis/src/test/java/vook/server/poc/springdataredis/usecases/UseRedisRepositoryTest.java new file mode 100644 index 00000000..9308c47a --- /dev/null +++ b/poc/spring-data-redis/src/test/java/vook/server/poc/springdataredis/usecases/UseRedisRepositoryTest.java @@ -0,0 +1,76 @@ +package vook.server.poc.springdataredis.usecases; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import vook.server.poc.springdataredis.model.User; +import vook.server.poc.springdataredis.testhelper.IntegrationTestBase; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class UseRedisRepositoryTest extends IntegrationTestBase { + + @Autowired + UseRedisRepository useRedisRepository; + + @Autowired + UserRepository userRepository; + + @AfterEach + void tearDown() { + userRepository.deleteAll(); + } + + @Test + @DisplayName("유저 생성 - 성공") + void createUser() { + // given + String id = "1"; + List accessVocabularies = List.of("a", "b"); + + // when + User user = useRedisRepository.createUser(id, accessVocabularies); + + // then + assertThat(user).isNotNull(); + assertThat(user.id()).isEqualTo(id); + assertThat(user.allowedVocabularies()).containsExactlyInAnyOrderElementsOf(accessVocabularies); + } + + @Test + @DisplayName("유저 조회 - 성공") + void getUser() { + // given + String id = "1"; + List accessVocabularies = List.of("a", "b"); + useRedisRepository.createUser(id, accessVocabularies); + + // when + User user = useRedisRepository.getUser(id); + + // then + assertThat(user).isNotNull(); + assertThat(user.id()).isEqualTo(id); + assertThat(user.allowedVocabularies()).containsExactlyInAnyOrderElementsOf(accessVocabularies); + } + + @Test + @DisplayName("유저 삭제 - 성공") + void deleteUser() { + // given + String id = "1"; + List accessVocabularies = List.of("a", "b"); + useRedisRepository.createUser(id, accessVocabularies); + + // when + useRedisRepository.deleteUser(id); + + // then + User user = useRedisRepository.getUser(id); + assertThat(user).isNull(); + } + +} diff --git a/poc/spring-data-redis/src/test/java/vook/server/poc/springdataredis/usecases/UseRedisTemplateTest.java b/poc/spring-data-redis/src/test/java/vook/server/poc/springdataredis/usecases/UseRedisTemplateTest.java new file mode 100644 index 00000000..13c022ea --- /dev/null +++ b/poc/spring-data-redis/src/test/java/vook/server/poc/springdataredis/usecases/UseRedisTemplateTest.java @@ -0,0 +1,91 @@ +package vook.server.poc.springdataredis.usecases; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import vook.server.poc.springdataredis.model.User; +import vook.server.poc.springdataredis.testhelper.IntegrationTestBase; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class UseRedisTemplateTest extends IntegrationTestBase { + + @Autowired + UseRedisTemplate useRedisTemplate; + + @Autowired + RedisTemplate redisTemplate; + + @AfterEach + void tearDown() { + Set keys = redisTemplate.keys("*"); + redisTemplate.delete(keys); + } + + @Test + @DisplayName("유저 생성 - 성공") + void createUser() { + // given + String id = "1"; + List accessVocabularies = List.of("a", "b"); + + // when + User user = useRedisTemplate.createUser(id, accessVocabularies); + + // then + assertThat(user).isNotNull(); + assertThat(user.id()).isEqualTo(id); + assertThat(user.allowedVocabularies()).containsExactlyInAnyOrderElementsOf(accessVocabularies); + } + + @Test + @DisplayName("유저 조회 - 성공") + void getUser() { + // given + String id = "1"; + List accessVocabularies = List.of("a", "b"); + useRedisTemplate.createUser(id, accessVocabularies); + + // when + User user = useRedisTemplate.getKEY(id); + + // then + assertThat(user).isNotNull(); + assertThat(user.id()).isEqualTo(id); + assertThat(user.allowedVocabularies()).containsExactlyInAnyOrderElementsOf(accessVocabularies); + } + + @Test + @DisplayName("유저 삭제 - 성공") + void deleteUser() { + // given + String id = "1"; + List accessVocabularies = List.of("a", "b"); + useRedisTemplate.createUser(id, accessVocabularies); + + // when + useRedisTemplate.deleteUser(id); + + // then + assertThat(useRedisTemplate.getKEY(id)).isNull(); + } + + @Test + @DisplayName("디버깅을 위한 테스트") + void valueCheck() { + useRedisTemplate.createUser("1", List.of("a", "b")); + + // redis에 존재하는 모든 key를 얻는다. + Set keys = redisTemplate.keys("*"); + assertThat(keys).hasSize(1); + keys.forEach(k -> { + System.out.println("key: " + k); + System.out.println("value: " + redisTemplate.opsForHash().values(k)); + }); + } +} diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 00000000..c2065bc2 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,37 @@ +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/ diff --git a/server/api/build.gradle b/server/api/build.gradle new file mode 100644 index 00000000..29e4bc52 --- /dev/null +++ b/server/api/build.gradle @@ -0,0 +1,54 @@ +ext { + springModulithVersion = "1.2.1" +} + +dependencies { + // spring + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + testImplementation('org.springframework.boot:spring-boot-starter-test') { + exclude group: 'com.vaadin.external.google', module: 'android-json' + } + + // spring modulith + implementation 'org.springframework.modulith:spring-modulith-starter-core' + testImplementation 'org.springframework.modulith:spring-modulith-starter-test' + + // lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + + // mariadb + runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' + + // meilisearch + implementation 'com.meilisearch.sdk:meilisearch-java:0.12.0' + + // swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:2.5.0' + + // QueryDsl + implementation "com.querydsl:querydsl-jpa:5.0.0:jakarta" + annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + + // testcontainer + testImplementation 'org.springframework.boot:spring-boot-testcontainers' + testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'org.testcontainers:mariadb' +} + +dependencyManagement { + imports { + mavenBom "org.springframework.modulith:spring-modulith-bom:$springModulithVersion" + } +} diff --git a/server/api/http/demo.http b/server/api/http/demo.http new file mode 100644 index 00000000..e7a6f4ba --- /dev/null +++ b/server/api/http/demo.http @@ -0,0 +1,26 @@ +### DB, Meilisearch 초기화 +POST http://localhost:8080/init + +### 용어집 리스트 +GET http://localhost:8080/demo/glossaries + +> {% + let devGlossaryUid = response.body.result.filter((item) => item.name === '개발')[0].uid; + console.log(devGlossaryUid); + client.global.set('uid', devGlossaryUid); +%} + +### 용어집 내 용어 리스트 +GET http://localhost:8080/demo/glossaries/{{uid}}/terms + +### 용어 검색 +POST http://localhost:8080/demo/glossaries/{{uid}}/terms/search +Content-Type: application/json + +{ + "glossaryUid": "{{uid}}", + "query": "하이브리드앱", + "withFormat": true, + "highlightPreTag": "", + "highlightPostTag": "" +} diff --git a/server/api/http/init.http b/server/api/http/init.http new file mode 100644 index 00000000..6da2bc2e --- /dev/null +++ b/server/api/http/init.http @@ -0,0 +1,2 @@ +### DB, Meilisearch 초기화 +POST http://localhost:8080/init diff --git a/server/api/src/main/java/vook/server/api/ApiApplication.java b/server/api/src/main/java/vook/server/api/ApiApplication.java new file mode 100644 index 00000000..ed08f091 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/ApiApplication.java @@ -0,0 +1,15 @@ +package vook.server.api; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.modulith.Modulithic; + +@Modulithic(systemName = "Vook API", useFullyQualifiedModuleNames = true) +@SpringBootApplication +public class ApiApplication { + + public static void main(String[] args) { + SpringApplication.run(ApiApplication.class, args); + } + +} diff --git a/server/api/src/main/java/vook/server/api/config/JpaConfig.java b/server/api/src/main/java/vook/server/api/config/JpaConfig.java new file mode 100644 index 00000000..119d3612 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/config/JpaConfig.java @@ -0,0 +1,9 @@ +package vook.server.api.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaConfig { +} diff --git a/server/api/src/main/java/vook/server/api/config/QuerydslConfig.java b/server/api/src/main/java/vook/server/api/config/QuerydslConfig.java new file mode 100644 index 00000000..e1066f08 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/config/QuerydslConfig.java @@ -0,0 +1,19 @@ +package vook.server.api.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QuerydslConfig { + + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/server/api/src/main/java/vook/server/api/config/RedisConfig.java b/server/api/src/main/java/vook/server/api/config/RedisConfig.java new file mode 100644 index 00000000..9c90786a --- /dev/null +++ b/server/api/src/main/java/vook/server/api/config/RedisConfig.java @@ -0,0 +1,23 @@ +package vook.server.api.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); + return template; + } +} diff --git a/server/api/src/main/java/vook/server/api/config/SecurityConfig.java b/server/api/src/main/java/vook/server/api/config/SecurityConfig.java new file mode 100644 index 00000000..c96ece14 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/config/SecurityConfig.java @@ -0,0 +1,97 @@ +package vook.server.api.config; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +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.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter; +import org.springframework.security.web.SecurityFilterChain; +import vook.server.api.web.common.auth.app.TokenService; +import vook.server.api.web.common.auth.jwt.JWTFilter; +import vook.server.api.web.common.auth.oauth2.LoginSuccessHandler; +import vook.server.api.web.common.auth.oauth2.VookOAuth2UserService; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + @Value("${service.oauth2.loginFailUrl}") + private String loginFailUrl; + + private final VookOAuth2UserService oAuth2UserService; + private final LoginSuccessHandler loginSuccessHandler; + private final TokenService tokenService; + private final ClientRegistrationRepository clientRegistrationRepository; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + disableDefaultSecurity(http); + + http.authorizeHttpRequests(c -> c + .requestMatchers("/user/**").authenticated() + .requestMatchers("/vocabularies/**").authenticated() + .requestMatchers("/terms/**").authenticated() + .anyRequest().permitAll() + ); + + http.oauth2Login(c -> c + .userInfoEndpoint(ec -> ec + .userService(oAuth2UserService) + ) + .authorizationEndpoint(ec -> ec + .authorizationRequestResolver(authorizationRequestResolver(clientRegistrationRepository)) + ) + .successHandler(loginSuccessHandler) + .failureHandler((request, response, exception) -> response.sendRedirect(loginFailUrl)) + ); + + http.addFilterAfter(new JWTFilter(tokenService), OAuth2LoginAuthenticationFilter.class); + + http.exceptionHandling(e -> e + // 인증되지 않은 사용자의 요청 처리 + .authenticationEntryPoint((request, response, authException) -> response.setStatus(HttpServletResponse.SC_UNAUTHORIZED)) + // 인가되지 않은 사용자의 요청 처리 + .accessDeniedHandler((request, response, accessDeniedException) -> response.setStatus(HttpServletResponse.SC_FORBIDDEN)) + ); + + return http.build(); + } + + private static void disableDefaultSecurity(HttpSecurity http) throws Exception { + //csrf 비활성화 + http.csrf(AbstractHttpConfigurer::disable); + + //HTTP Basic 인증 방식 비활성화 + http.httpBasic(AbstractHttpConfigurer::disable); + + //Spring Security 세션 사용 비활성화 + http.sessionManagement(c -> c + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ); + } + + /** + * OAuth2 인증 요청시 consent 확인을 강제하는 옵션 추가를 위해 사용 + */ + private static OAuth2AuthorizationRequestResolver authorizationRequestResolver(ClientRegistrationRepository clientRegistrationRepository) { + DefaultOAuth2AuthorizationRequestResolver authorizationRequestResolver = new DefaultOAuth2AuthorizationRequestResolver( + clientRegistrationRepository, + OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI + ); + authorizationRequestResolver.setAuthorizationRequestCustomizer(c -> c + .additionalParameters(params -> { + params.put("prompt", "consent"); + })); + return authorizationRequestResolver; + } +} diff --git a/server/api/src/main/java/vook/server/api/config/SwaggerConfig.java b/server/api/src/main/java/vook/server/api/config/SwaggerConfig.java new file mode 100644 index 00000000..2f2cee0a --- /dev/null +++ b/server/api/src/main/java/vook/server/api/config/SwaggerConfig.java @@ -0,0 +1,72 @@ +package vook.server.api.config; + +import io.swagger.v3.oas.models.OpenAPI; +import org.springdoc.core.customizers.GlobalOpenApiCustomizer; +import org.springdoc.core.customizers.GlobalOperationCustomizer; +import org.springdoc.core.customizers.OpenApiBuilderCustomizer; +import org.springdoc.core.customizers.ServerBaseUrlCustomizer; +import org.springdoc.core.properties.SpringDocConfigProperties; +import org.springdoc.core.providers.JavadocProvider; +import org.springdoc.core.service.OpenAPIService; +import org.springdoc.core.service.SecurityService; +import org.springdoc.core.utils.PropertyResolverUtils; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import vook.server.api.web.common.swagger.GlobalOpenApiCustomizerImpl; +import vook.server.api.web.common.swagger.GlobalOperationCustomizerImpl; + +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.function.Supplier; + +@Configuration +public class SwaggerConfig { + + @Bean + public GlobalOpenApiCustomizer globalOpenApiCustomizer() { + return new GlobalOpenApiCustomizerImpl(); + } + + @Bean + public GlobalOperationCustomizer globalOperationCustomizer(Supplier openAPISupplier) { + return new GlobalOperationCustomizerImpl(openAPISupplier); + } + + // GlobalOperationCustomizer에서 OpenAPI가 가지고 있는 Contents에 접근해야 될 필요가 생김 + // GlobalOpenApiCustomizer는 GlobalOperationCustomizer 보다 나중에 호출되므로 GlobalOpenApiCustomizer에서 GlobalOperationCustomizer로 OpenAPI를 전달하는 것을 불가능 + // OpenAPIService.build에서 최초로 OpenAPI를 생성하므로 (AbstractOpenApiResource.java:336), OpenAPI 생성 후 OpenAPI를 제공하는 기능을 추가한 커스텀 클래스를 대신 만들어 사용 + @Bean + @Lazy(value = false) + public OpenAPIServiceWithRegistration openAPIServiceWithRegistration( + Optional openAPI, + SecurityService securityParser, + SpringDocConfigProperties springDocConfigProperties, PropertyResolverUtils propertyResolverUtils, + Optional> openApiBuilderCustomisers, + Optional> serverBaseUrlCustomisers, Optional javadocProvider + ) { + return new OpenAPIServiceWithRegistration(openAPI, securityParser, springDocConfigProperties, propertyResolverUtils, openApiBuilderCustomisers, serverBaseUrlCustomisers, javadocProvider); + } + + public static class OpenAPIServiceWithRegistration extends OpenAPIService implements Supplier { + + private OpenAPI openAPI; + + public OpenAPIServiceWithRegistration(Optional openAPI, SecurityService securityParser, SpringDocConfigProperties springDocConfigProperties, PropertyResolverUtils propertyResolverUtils, Optional> openApiBuilderCustomizers, Optional> serverBaseUrlCustomizers, Optional javadocProvider) { + super(openAPI, securityParser, springDocConfigProperties, propertyResolverUtils, openApiBuilderCustomizers, serverBaseUrlCustomizers, javadocProvider); + } + + @Override + public OpenAPI build(Locale locale) { + OpenAPI openAPI = super.build(locale); + this.openAPI = openAPI; + return openAPI; + } + + @Override + public OpenAPI get() { + return this.openAPI; + } + } +} diff --git a/server/api/src/main/java/vook/server/api/config/TimeZoneConfig.java b/server/api/src/main/java/vook/server/api/config/TimeZoneConfig.java new file mode 100644 index 00000000..2c7ee3a0 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/config/TimeZoneConfig.java @@ -0,0 +1,17 @@ +package vook.server.api.config; + +import jakarta.annotation.PostConstruct; +import org.springframework.context.annotation.Configuration; + +import java.util.TimeZone; + +@Configuration +public class TimeZoneConfig { + + public static final String DEFAULT_TIME_ZONE = "UTC"; + + @PostConstruct + public void init() { + TimeZone.setDefault(TimeZone.getTimeZone(DEFAULT_TIME_ZONE)); + } +} diff --git a/server/api/src/main/java/vook/server/api/config/package-info.java b/server/api/src/main/java/vook/server/api/config/package-info.java new file mode 100644 index 00000000..02af49c2 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/config/package-info.java @@ -0,0 +1,9 @@ +@ApplicationModule( + type = ApplicationModule.Type.OPEN, + allowedDependencies = { + "vook.server.api.web.common" + } +) +package vook.server.api.config; + +import org.springframework.modulith.ApplicationModule; diff --git a/server/api/src/main/java/vook/server/api/devhelper/app/InitService.java b/server/api/src/main/java/vook/server/api/devhelper/app/InitService.java new file mode 100644 index 00000000..68cc45bd --- /dev/null +++ b/server/api/src/main/java/vook/server/api/devhelper/app/InitService.java @@ -0,0 +1,120 @@ +package vook.server.api.devhelper.app; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vook.server.api.domain.demo.model.DemoTerm; +import vook.server.api.domain.demo.model.DemoTermRepository; +import vook.server.api.domain.demo.model.DemoTermSynonymRepository; +import vook.server.api.domain.template_vocabulary.logic.TemplateVocabularyLogic; +import vook.server.api.domain.template_vocabulary.logic.dto.TemplateVocabularyCreateCommand; +import vook.server.api.domain.template_vocabulary.model.TemplateTermRepository; +import vook.server.api.domain.template_vocabulary.model.TemplateVocabularyRepository; +import vook.server.api.domain.template_vocabulary.model.TemplateVocabularyType; +import vook.server.api.domain.user.model.social_user.SocialUserRepository; +import vook.server.api.domain.user.model.user.UserRepository; +import vook.server.api.domain.user.model.user_info.UserInfoRepository; +import vook.server.api.domain.vocabulary.model.term.TermRepository; +import vook.server.api.infra.search.demo.MeilisearchDemoTermSearchService; +import vook.server.api.infra.vocabulary.cache.UserVocabularyCacheRepository; +import vook.server.api.infra.vocabulary.jpa.VocabularyJpaRepository; + +import java.util.Arrays; +import java.util.List; + +@Service +@Transactional +@RequiredArgsConstructor +public class InitService { + + private final DemoTermRepository demoTermRepository; + private final DemoTermSynonymRepository demoTermSynonymRepository; + private final TemplateTermRepository templateTermRepository; + private final TemplateVocabularyRepository templateVocabularyRepository; + private final TermRepository termRepository; + private final VocabularyJpaRepository vocabularyJpaRepository; + private final UserVocabularyCacheRepository userVocabularyCacheRepository; + private final UserInfoRepository userInfoRepository; + private final SocialUserRepository socialUserRepository; + private final UserRepository userRepository; + + private final TestTermsLoader testTermsLoader; + private final MeilisearchDemoTermSearchService searchService; + + private final TemplateVocabularyLogic templateVocabularyLogic; + + public void init() { + deleteAll(); + + // 데모 용어집 + List demoTerms = testTermsLoader.getTerms( + "classpath:init/데모.tsv", + InitService::convertToDemoTerm + ); + demoTermRepository.saveAll(demoTerms); + + searchService.init(); + searchService.addTerms(demoTerms); + + // 템플릿 용어집 + createTemplateVocabulary(TemplateVocabularyType.DEVELOPMENT, "classpath:init/템플릿용어집-개발.tsv"); + createTemplateVocabulary(TemplateVocabularyType.MARKETING, "classpath:init/템플릿용어집-마케팅.tsv"); + createTemplateVocabulary(TemplateVocabularyType.DESIGN, "classpath:init/템플릿용어집-디자인.tsv"); + createTemplateVocabulary(TemplateVocabularyType.GENERAL_OFFICE, "classpath:init/템플릿용어집-일반사무.tsv"); + } + + private void createTemplateVocabulary(TemplateVocabularyType type, String location) { + templateVocabularyLogic.create(new TemplateVocabularyCreateCommand( + type, + testTermsLoader.getTerms(location, InitService::convertToTemplateTerm) + )); + } + + private void deleteAll() { + // 데모 용어 + demoTermSynonymRepository.deleteAllInBatch(); + demoTermRepository.deleteAllInBatch(); + + // 템플릿 용어집 + templateTermRepository.deleteAllInBatch(); + templateVocabularyRepository.deleteAllInBatch(); + + // 용어집 + termRepository.deleteAllInBatch(); + vocabularyJpaRepository.deleteAllInBatch(); + userVocabularyCacheRepository.deleteAll(); + + // 사용자 + userInfoRepository.deleteAllInBatch(); + socialUserRepository.deleteAllInBatch(); + userRepository.deleteAllInBatch(); + + // 검색 엔진 + searchService.clearAll(); + } + + public static List convertToDemoTerm(List input) { + return input.stream() + .map(t -> { + DemoTerm term = DemoTerm.forCreateOf(t.getTerm(), t.getMeaning()); + String[] synonymArray = t.getSynonyms().split("//n"); + Arrays.stream(synonymArray) + .map(String::trim) + .forEach(term::addSynonym); + return term; + }) + .toList(); + } + + public static List convertToTemplateTerm(List input) { + return input.stream() + .map(t -> new TemplateVocabularyCreateCommand.Term( + t.getTerm(), + t.getMeaning(), + Arrays.stream(t.getSynonyms().split(",")) + .map(String::trim) + .toList() + )) + .toList(); + } +} diff --git a/server/api/src/main/java/vook/server/api/devhelper/app/TestTermsLoader.java b/server/api/src/main/java/vook/server/api/devhelper/app/TestTermsLoader.java new file mode 100644 index 00000000..c3d8f082 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/devhelper/app/TestTermsLoader.java @@ -0,0 +1,42 @@ +package vook.server.api.devhelper.app; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.ResourceLoader; +import org.springframework.stereotype.Component; +import vook.server.api.devhelper.helper.CsvReader; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class TestTermsLoader { + + private final ResourceLoader resourceLoader; + + public List getTerms(String location, Converter converter) { + try { + // file로 바로 접근 할 경우, IDE에서는 접근 가능하나, jar로 패키징 후 실행 시에는 접근 불가능 + // ref) https://velog.io/@haron/트러블슈팅-Spring-IDE-에서-되는데-배포하면-안-돼요 + InputStream tsvFileInputStream = resourceLoader.getResource(location).getInputStream(); + CsvReader tsvReader = new CsvReader("\t"); + List rawTerms = tsvReader.readValue(tsvFileInputStream, RawTerm.class); + return converter.convert(rawTerms); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Getter + public static class RawTerm { + private String term; + private String synonyms; + private String meaning; + } + + public interface Converter { + List convert(List rawTerm); + } +} diff --git a/server/api/src/main/java/vook/server/api/devhelper/helper/CsvReader.java b/server/api/src/main/java/vook/server/api/devhelper/helper/CsvReader.java new file mode 100644 index 00000000..2fbc1423 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/devhelper/helper/CsvReader.java @@ -0,0 +1,91 @@ +package vook.server.api.devhelper.helper; + +import lombok.NoArgsConstructor; + +import java.io.*; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +@NoArgsConstructor +public class CsvReader { + + private String DELIMITER = ","; + + public CsvReader(String delimiter) { + this.DELIMITER = delimiter; + } + + public List readValue(InputStream inputStream, Class clazz) { + try { + return readValue(new InputStreamReader(inputStream), clazz); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public List readValue(File file, Class clazz) { + try { + return readValue(new FileReader(file), clazz); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public List readValue(String string, Class clazz) { + try { + return readValue(new StringReader(string), clazz); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private List readValue(Reader reader, Class clazz) throws IOException { + List records = new ArrayList<>(); + try (BufferedReader br = new BufferedReader(reader)) { + String line; + List fieldNames = getFieldNames(br.readLine()); + while ((line = br.readLine()) != null) { + records.add(createInstance(clazz, fieldNames, line)); + } + } + return records; + } + + private List getFieldNames(String line) { + return Arrays.asList(line.split(DELIMITER)); + } + + private T createInstance(Class clazz, List fieldNames, String line) { + String[] values = parseLine(line); + T instance; + try { + instance = clazz.getDeclaredConstructor().newInstance(); + for (int i = 0; i < fieldNames.size(); i++) { + Field field = clazz.getDeclaredField(fieldNames.get(i)); + field.setAccessible(true); + field.set(instance, values[i]); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + return instance; + } + + private String[] parseLine(String line) { + return Arrays.stream(line.split(DELIMITER)) + .map(String::trim) + .map(v -> processValue(v, v.startsWith("\"") && v.endsWith("\""))) + .toArray(String[]::new); + } + + private String processValue(String value, boolean inQuotes) { + if (inQuotes) { + return value + .substring(1, value.length() - 1) //앞 뒤 " 제거 + .replaceAll("\\\\n", "\n"); // \n -> 개행문자로 변환 + } + return value; + } +} diff --git a/server/api/src/main/java/vook/server/api/devhelper/init/LocalInit.java b/server/api/src/main/java/vook/server/api/devhelper/init/LocalInit.java new file mode 100644 index 00000000..e626cfd5 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/devhelper/init/LocalInit.java @@ -0,0 +1,30 @@ +package vook.server.api.devhelper.init; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import vook.server.api.devhelper.app.InitService; +import vook.server.api.domain.demo.model.DemoTermRepository; + +@Slf4j +@Profile("local") +@Component +@RequiredArgsConstructor +public class LocalInit { + + private final DemoTermRepository demoTermRepository; + private final InitService initService; + + @PostConstruct + public void init() { + if (demoTermRepository.count() > 0) { + return; + } + + initService.init(); + + log.info("초기화 완료"); + } +} diff --git a/server/api/src/main/java/vook/server/api/devhelper/web/routes/init/InitApi.java b/server/api/src/main/java/vook/server/api/devhelper/web/routes/init/InitApi.java new file mode 100644 index 00000000..c8a82543 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/devhelper/web/routes/init/InitApi.java @@ -0,0 +1,15 @@ +package vook.server.api.devhelper.web.routes.init; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import vook.server.api.web.common.response.CommonApiResponse; + +@Tag(name = "init", description = "초기화 API") +public interface InitApi { + + @Operation( + summary = "데이터 초기화", + description = "모든 데이터를 삭제하고, 데모용 데이터를 생성시킨 상태로 초기화 시킵니다." + ) + CommonApiResponse init(); +} diff --git a/server/api/src/main/java/vook/server/api/devhelper/web/routes/init/InitController.java b/server/api/src/main/java/vook/server/api/devhelper/web/routes/init/InitController.java new file mode 100644 index 00000000..9471ecbf --- /dev/null +++ b/server/api/src/main/java/vook/server/api/devhelper/web/routes/init/InitController.java @@ -0,0 +1,24 @@ +package vook.server.api.devhelper.web.routes.init; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import vook.server.api.devhelper.app.InitService; +import vook.server.api.web.common.response.CommonApiResponse; + +@Profile({"local", "dev", "stag", "prod"}) +@RestController +@RequestMapping("/init") +@RequiredArgsConstructor +public class InitController implements InitApi { + + private final InitService initService; + + @PostMapping + public CommonApiResponse init() { + initService.init(); + return CommonApiResponse.ok(); + } +} diff --git a/server/api/src/main/java/vook/server/api/domain/common/exception/DomainException.java b/server/api/src/main/java/vook/server/api/domain/common/exception/DomainException.java new file mode 100644 index 00000000..f2274ea0 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/common/exception/DomainException.java @@ -0,0 +1,12 @@ +package vook.server.api.domain.common.exception; + +import lombok.NoArgsConstructor; +import vook.server.api.globalcommon.exception.AppException; + +@NoArgsConstructor +public class DomainException extends AppException { + + public DomainException(RuntimeException cause) { + super(cause); + } +} diff --git a/server/api/src/main/java/vook/server/api/domain/common/model/BaseEntity.java b/server/api/src/main/java/vook/server/api/domain/common/model/BaseEntity.java new file mode 100644 index 00000000..925074d1 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/common/model/BaseEntity.java @@ -0,0 +1,24 @@ +package vook.server.api.domain.common.model; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; +} diff --git a/server/api/src/main/java/vook/server/api/domain/common/model/Synonym.java b/server/api/src/main/java/vook/server/api/domain/common/model/Synonym.java new file mode 100644 index 00000000..0237dd3e --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/common/model/Synonym.java @@ -0,0 +1,29 @@ +package vook.server.api.domain.common.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.util.Arrays; +import java.util.List; + +@Embeddable +public class Synonym { + + private static final String SYNONYM_DELIMITER = ":,:"; + + @Column(length = 4000) + private String synonym; + + public static Synonym from(List input) { + Synonym result = new Synonym(); + result.synonym = String.join(SYNONYM_DELIMITER, input); + return result; + } + + public List synonyms() { + if (synonym == null || synonym.isEmpty()) { + return List.of(); + } + return Arrays.stream(synonym.split(SYNONYM_DELIMITER)).toList(); + } +} diff --git a/server/api/src/main/java/vook/server/api/domain/common/package-info.java b/server/api/src/main/java/vook/server/api/domain/common/package-info.java new file mode 100644 index 00000000..885641fb --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/common/package-info.java @@ -0,0 +1,7 @@ +@ApplicationModule( + type = ApplicationModule.Type.OPEN, + displayName = "Domain Common" +) +package vook.server.api.domain.common; + +import org.springframework.modulith.ApplicationModule; diff --git a/server/api/src/main/java/vook/server/api/domain/demo/logic/DemoLogic.java b/server/api/src/main/java/vook/server/api/domain/demo/logic/DemoLogic.java new file mode 100644 index 00000000..b0792056 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/demo/logic/DemoLogic.java @@ -0,0 +1,17 @@ +package vook.server.api.domain.demo.logic; + +import lombok.RequiredArgsConstructor; +import vook.server.api.domain.demo.logic.dto.DemoTermSearchCommand; +import vook.server.api.domain.demo.logic.dto.DemoTermSearchResult; +import vook.server.api.globalcommon.annotation.DomainLogic; + +@DomainLogic +@RequiredArgsConstructor +public class DemoLogic { + + private final DemoTermSearchService searchService; + + public DemoTermSearchResult searchTerm(DemoTermSearchCommand params) { + return searchService.search(params); + } +} diff --git a/server/api/src/main/java/vook/server/api/domain/demo/logic/DemoTermSearchService.java b/server/api/src/main/java/vook/server/api/domain/demo/logic/DemoTermSearchService.java new file mode 100644 index 00000000..cd0f4cc5 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/demo/logic/DemoTermSearchService.java @@ -0,0 +1,8 @@ +package vook.server.api.domain.demo.logic; + +import vook.server.api.domain.demo.logic.dto.DemoTermSearchCommand; +import vook.server.api.domain.demo.logic.dto.DemoTermSearchResult; + +public interface DemoTermSearchService { + DemoTermSearchResult search(DemoTermSearchCommand params); +} diff --git a/server/api/src/main/java/vook/server/api/domain/demo/logic/dto/DemoTermSearchCommand.java b/server/api/src/main/java/vook/server/api/domain/demo/logic/dto/DemoTermSearchCommand.java new file mode 100644 index 00000000..7f738019 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/demo/logic/dto/DemoTermSearchCommand.java @@ -0,0 +1,38 @@ +package vook.server.api.domain.demo.logic.dto; + +import com.meilisearch.sdk.SearchRequest; +import lombok.Builder; +import org.springframework.util.StringUtils; + +import java.util.List; + +@Builder +public record DemoTermSearchCommand( + String query, + boolean withFormat, + String highlightPreTag, + String highlightPostTag, + List sort +) { + + private static final String DEFAULT_HIGHLIGHT_PRE_TAG = ""; + private static final String DEFAULT_HIGHLIGHT_POST_TAG = ""; + + public SearchRequest buildSearchRequest() { + SearchRequest.SearchRequestBuilder builder = SearchRequest.builder(); + if (withFormat) { + builder.attributesToHighlight(new String[]{"*"}); + builder.highlightPreTag(StringUtils.hasText(highlightPreTag) ? highlightPreTag : DEFAULT_HIGHLIGHT_PRE_TAG); + builder.highlightPostTag(StringUtils.hasText(highlightPostTag) ? highlightPostTag : DEFAULT_HIGHLIGHT_POST_TAG); + } + + if (sort != null && !sort.isEmpty()) { + builder.sort(sort.toArray(new String[0])); + } + + return builder + .q(query) + .limit(Integer.MAX_VALUE) + .build(); + } +} diff --git a/server/api/src/main/java/vook/server/api/domain/demo/logic/dto/DemoTermSearchResult.java b/server/api/src/main/java/vook/server/api/domain/demo/logic/dto/DemoTermSearchResult.java new file mode 100644 index 00000000..a9144410 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/demo/logic/dto/DemoTermSearchResult.java @@ -0,0 +1,21 @@ +package vook.server.api.domain.demo.logic.dto; + +import com.meilisearch.sdk.model.Searchable; + +import java.util.ArrayList; +import java.util.HashMap; + +public record DemoTermSearchResult( + String query, + int processingTimeMs, + ArrayList> hits +) { + + public static DemoTermSearchResult from(Searchable search) { + return new DemoTermSearchResult( + search.getQuery(), + search.getProcessingTimeMs(), + search.getHits() + ); + } +} diff --git a/server/api/src/main/java/vook/server/api/domain/demo/model/DemoTerm.java b/server/api/src/main/java/vook/server/api/domain/demo/model/DemoTerm.java new file mode 100644 index 00000000..0840f36a --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/demo/model/DemoTerm.java @@ -0,0 +1,50 @@ +package vook.server.api.domain.demo.model; + +import jakarta.persistence.*; +import lombok.Getter; +import vook.server.api.domain.common.model.BaseEntity; + +import java.util.ArrayList; +import java.util.List; + +/** + * 데모 용어 + */ +@Getter +@Entity +@Table(name = "demo_term") +public class DemoTerm extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * 용어 이름 + */ + @Column(length = 100, nullable = false) + private String term; + + /** + * 용어 의미 + */ + @Column(length = 2000, nullable = false) + private String meaning; + + @OneToMany(mappedBy = "demoTerm", fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval = true) + private List synonyms = new ArrayList<>(); + + public static DemoTerm forCreateOf( + String term, + String meaning + ) { + DemoTerm result = new DemoTerm(); + result.term = term; + result.meaning = meaning; + return result; + } + + public void addSynonym(String synonym) { + this.synonyms.add(DemoTermSynonym.forCreateOf(synonym, this)); + } +} diff --git a/server/api/src/main/java/vook/server/api/domain/demo/model/DemoTermRepository.java b/server/api/src/main/java/vook/server/api/domain/demo/model/DemoTermRepository.java new file mode 100644 index 00000000..80a528bd --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/demo/model/DemoTermRepository.java @@ -0,0 +1,6 @@ +package vook.server.api.domain.demo.model; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DemoTermRepository extends JpaRepository { +} diff --git a/server/api/src/main/java/vook/server/api/domain/demo/model/DemoTermSynonym.java b/server/api/src/main/java/vook/server/api/domain/demo/model/DemoTermSynonym.java new file mode 100644 index 00000000..4234d4aa --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/demo/model/DemoTermSynonym.java @@ -0,0 +1,37 @@ +package vook.server.api.domain.demo.model; + +import jakarta.persistence.*; +import lombok.Getter; + +/** + * 데모 용어 동의어 + */ +@Getter +@Entity +@Table(name = "demo_term_synonym") +public class DemoTermSynonym { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * 동의어 + */ + @Column(length = 100, nullable = false) + private String synonym; + + @ManyToOne + @JoinColumn(name = "demo_term_id", nullable = false) + private DemoTerm demoTerm; + + static DemoTermSynonym forCreateOf( + String synonym, + DemoTerm demoTerm + ) { + DemoTermSynonym result = new DemoTermSynonym(); + result.synonym = synonym; + result.demoTerm = demoTerm; + return result; + } +} diff --git a/server/api/src/main/java/vook/server/api/domain/demo/model/DemoTermSynonymRepository.java b/server/api/src/main/java/vook/server/api/domain/demo/model/DemoTermSynonymRepository.java new file mode 100644 index 00000000..be7ab58a --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/demo/model/DemoTermSynonymRepository.java @@ -0,0 +1,6 @@ +package vook.server.api.domain.demo.model; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DemoTermSynonymRepository extends JpaRepository { +} diff --git a/server/api/src/main/java/vook/server/api/domain/demo/package-info.java b/server/api/src/main/java/vook/server/api/domain/demo/package-info.java new file mode 100644 index 00000000..af991749 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/demo/package-info.java @@ -0,0 +1,10 @@ +@ApplicationModule( + type = ApplicationModule.Type.OPEN, + displayName = "Demo Domain", + allowedDependencies = { + "vook.server.api.domain.common" + } +) +package vook.server.api.domain.demo; + +import org.springframework.modulith.ApplicationModule; diff --git a/server/api/src/main/java/vook/server/api/domain/template_vocabulary/logic/TemplateVocabularyLogic.java b/server/api/src/main/java/vook/server/api/domain/template_vocabulary/logic/TemplateVocabularyLogic.java new file mode 100644 index 00000000..52516ccf --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/template_vocabulary/logic/TemplateVocabularyLogic.java @@ -0,0 +1,27 @@ +package vook.server.api.domain.template_vocabulary.logic; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import vook.server.api.domain.template_vocabulary.logic.dto.TemplateVocabularyCreateCommand; +import vook.server.api.domain.template_vocabulary.model.*; +import vook.server.api.globalcommon.annotation.DomainLogic; + +import java.util.List; + +@DomainLogic +@RequiredArgsConstructor +public class TemplateVocabularyLogic { + + private final TemplateVocabularyRepository vocabularyRepository; + private final TemplateTermRepository termRepository; + + public void create(@Valid TemplateVocabularyCreateCommand command) { + TemplateVocabulary vocabulary = vocabularyRepository.save(command.toVocabulary()); + termRepository.saveAll(command.toTerms(vocabulary)); + } + + public List getTermsByType(@Valid TemplateVocabularyType type) { + TemplateVocabulary vocabulary = vocabularyRepository.findByType(type).orElseThrow(); + return termRepository.findByTemplateVocabulary(vocabulary); + } +} diff --git a/server/api/src/main/java/vook/server/api/domain/template_vocabulary/logic/dto/TemplateVocabularyCreateCommand.java b/server/api/src/main/java/vook/server/api/domain/template_vocabulary/logic/dto/TemplateVocabularyCreateCommand.java new file mode 100644 index 00000000..ba9da26b --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/template_vocabulary/logic/dto/TemplateVocabularyCreateCommand.java @@ -0,0 +1,35 @@ +package vook.server.api.domain.template_vocabulary.logic.dto; + +import vook.server.api.domain.template_vocabulary.model.TemplateTerm; +import vook.server.api.domain.template_vocabulary.model.TemplateVocabulary; +import vook.server.api.domain.template_vocabulary.model.TemplateVocabularyType; + +import java.util.List; + +public record TemplateVocabularyCreateCommand( + TemplateVocabularyType type, + List terms +) { + + public TemplateVocabulary toVocabulary() { + return TemplateVocabulary.forCreateOf(type); + } + + public List toTerms(TemplateVocabulary vocabulary) { + return terms.stream() + .map(term -> TemplateTerm.forCreateOf( + term.term(), + term.meaning(), + term.synonyms(), + vocabulary + )) + .toList(); + } + + public record Term( + String term, + String meaning, + List synonyms + ) { + } +} diff --git a/server/api/src/main/java/vook/server/api/domain/template_vocabulary/model/TemplateTerm.java b/server/api/src/main/java/vook/server/api/domain/template_vocabulary/model/TemplateTerm.java new file mode 100644 index 00000000..b7f2ab35 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/template_vocabulary/model/TemplateTerm.java @@ -0,0 +1,59 @@ +package vook.server.api.domain.template_vocabulary.model; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import vook.server.api.domain.common.model.Synonym; + +import java.util.List; + +@Getter +@Entity +@Table(name = "template_term") +public class TemplateTerm { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * 용어 이름 + */ + @Column(length = 100, nullable = false) + private String term; + + /** + * 용어 의미 + */ + @Column(length = 2000, nullable = false) + private String meaning; + + /** + * 동의어 + */ + @Getter(AccessLevel.PRIVATE) + @Column(length = 1100) + private Synonym synonym; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "template_vocabulary_id", nullable = false) + private TemplateVocabulary templateVocabulary; + + public static TemplateTerm forCreateOf( + String term, + String meaning, + List synonyms, + TemplateVocabulary templateVocabulary + ) { + TemplateTerm result = new TemplateTerm(); + result.term = term; + result.meaning = meaning; + result.synonym = Synonym.from(synonyms); + result.templateVocabulary = templateVocabulary; + return result; + } + + public List getSynonyms() { + return this.synonym.synonyms(); + } +} diff --git a/server/api/src/main/java/vook/server/api/domain/template_vocabulary/model/TemplateTermRepository.java b/server/api/src/main/java/vook/server/api/domain/template_vocabulary/model/TemplateTermRepository.java new file mode 100644 index 00000000..0195af9e --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/template_vocabulary/model/TemplateTermRepository.java @@ -0,0 +1,9 @@ +package vook.server.api.domain.template_vocabulary.model; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface TemplateTermRepository extends JpaRepository { + List findByTemplateVocabulary(TemplateVocabulary templateVocabulary); +} diff --git a/server/api/src/main/java/vook/server/api/domain/template_vocabulary/model/TemplateVocabulary.java b/server/api/src/main/java/vook/server/api/domain/template_vocabulary/model/TemplateVocabulary.java new file mode 100644 index 00000000..e6a1c723 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/template_vocabulary/model/TemplateVocabulary.java @@ -0,0 +1,29 @@ +package vook.server.api.domain.template_vocabulary.model; + +import jakarta.persistence.*; +import lombok.Getter; + +@Getter +@Entity +@Table(name = "template_vocabulary") +public class TemplateVocabulary { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * 용어집 이름 + */ + @Column(nullable = false, unique = true, columnDefinition = "varchar(20)") + @Enumerated(EnumType.STRING) + private TemplateVocabularyType type; + + public static TemplateVocabulary forCreateOf( + TemplateVocabularyType type + ) { + TemplateVocabulary result = new TemplateVocabulary(); + result.type = type; + return result; + } +} diff --git a/server/api/src/main/java/vook/server/api/domain/template_vocabulary/model/TemplateVocabularyRepository.java b/server/api/src/main/java/vook/server/api/domain/template_vocabulary/model/TemplateVocabularyRepository.java new file mode 100644 index 00000000..7521bab1 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/template_vocabulary/model/TemplateVocabularyRepository.java @@ -0,0 +1,9 @@ +package vook.server.api.domain.template_vocabulary.model; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface TemplateVocabularyRepository extends JpaRepository { + Optional findByType(TemplateVocabularyType type); +} diff --git a/server/api/src/main/java/vook/server/api/domain/template_vocabulary/model/TemplateVocabularyType.java b/server/api/src/main/java/vook/server/api/domain/template_vocabulary/model/TemplateVocabularyType.java new file mode 100644 index 00000000..4cee5b92 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/template_vocabulary/model/TemplateVocabularyType.java @@ -0,0 +1,23 @@ +package vook.server.api.domain.template_vocabulary.model; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TemplateVocabularyType { + + // 개발 용어집 + DEVELOPMENT("개발: 비개발자를 위한 개발 용어집"), + + // 마케팅 용어집 + MARKETING("마케팅: 마케팅 실무 용어집"), + + // 디자인 용어집 + DESIGN("디자인: 개발자를 위한 디자인 용어집"), + + // 일반 사무 용어집 + GENERAL_OFFICE("실무: IT 실무 용어집"); + + private final String vocabularyName; +} diff --git a/server/api/src/main/java/vook/server/api/domain/template_vocabulary/package-info.java b/server/api/src/main/java/vook/server/api/domain/template_vocabulary/package-info.java new file mode 100644 index 00000000..d1e19cd9 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/template_vocabulary/package-info.java @@ -0,0 +1,10 @@ +@ApplicationModule( + type = ApplicationModule.Type.OPEN, + displayName = "Template Vocabulary Domain", + allowedDependencies = { + "vook.server.api.domain.common" + } +) +package vook.server.api.domain.template_vocabulary; + +import org.springframework.modulith.ApplicationModule; diff --git a/server/api/src/main/java/vook/server/api/domain/user/exception/AlreadyOnboardingException.java b/server/api/src/main/java/vook/server/api/domain/user/exception/AlreadyOnboardingException.java new file mode 100644 index 00000000..88a10d21 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/user/exception/AlreadyOnboardingException.java @@ -0,0 +1,6 @@ +package vook.server.api.domain.user.exception; + +import vook.server.api.domain.common.exception.DomainException; + +public class AlreadyOnboardingException extends DomainException { +} diff --git a/server/api/src/main/java/vook/server/api/domain/user/exception/AlreadyRegisteredException.java b/server/api/src/main/java/vook/server/api/domain/user/exception/AlreadyRegisteredException.java new file mode 100644 index 00000000..9b734b9f --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/user/exception/AlreadyRegisteredException.java @@ -0,0 +1,6 @@ +package vook.server.api.domain.user.exception; + +import vook.server.api.domain.common.exception.DomainException; + +public class AlreadyRegisteredException extends DomainException { +} diff --git a/server/api/src/main/java/vook/server/api/domain/user/exception/NotOnboardingException.java b/server/api/src/main/java/vook/server/api/domain/user/exception/NotOnboardingException.java new file mode 100644 index 00000000..fbfc0966 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/user/exception/NotOnboardingException.java @@ -0,0 +1,6 @@ +package vook.server.api.domain.user.exception; + +import vook.server.api.domain.common.exception.DomainException; + +public class NotOnboardingException extends DomainException { +} diff --git a/server/api/src/main/java/vook/server/api/domain/user/exception/NotReadyToOnboardingException.java b/server/api/src/main/java/vook/server/api/domain/user/exception/NotReadyToOnboardingException.java new file mode 100644 index 00000000..c77da37f --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/user/exception/NotReadyToOnboardingException.java @@ -0,0 +1,6 @@ +package vook.server.api.domain.user.exception; + +import vook.server.api.domain.common.exception.DomainException; + +public class NotReadyToOnboardingException extends DomainException { +} diff --git a/server/api/src/main/java/vook/server/api/domain/user/exception/NotRegisteredException.java b/server/api/src/main/java/vook/server/api/domain/user/exception/NotRegisteredException.java new file mode 100644 index 00000000..d3013639 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/user/exception/NotRegisteredException.java @@ -0,0 +1,6 @@ +package vook.server.api.domain.user.exception; + +import vook.server.api.domain.common.exception.DomainException; + +public class NotRegisteredException extends DomainException { +} diff --git a/server/api/src/main/java/vook/server/api/domain/user/exception/NotWithdrawnUserException.java b/server/api/src/main/java/vook/server/api/domain/user/exception/NotWithdrawnUserException.java new file mode 100644 index 00000000..8fab4911 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/user/exception/NotWithdrawnUserException.java @@ -0,0 +1,6 @@ +package vook.server.api.domain.user.exception; + +import vook.server.api.domain.common.exception.DomainException; + +public class NotWithdrawnUserException extends DomainException { +} diff --git a/server/api/src/main/java/vook/server/api/domain/user/exception/UserNotFoundException.java b/server/api/src/main/java/vook/server/api/domain/user/exception/UserNotFoundException.java new file mode 100644 index 00000000..cd067e8a --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/user/exception/UserNotFoundException.java @@ -0,0 +1,6 @@ +package vook.server.api.domain.user.exception; + +import vook.server.api.domain.common.exception.DomainException; + +public class UserNotFoundException extends DomainException { +} diff --git a/server/api/src/main/java/vook/server/api/domain/user/exception/WithdrawnUserException.java b/server/api/src/main/java/vook/server/api/domain/user/exception/WithdrawnUserException.java new file mode 100644 index 00000000..f51ea51f --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/user/exception/WithdrawnUserException.java @@ -0,0 +1,6 @@ +package vook.server.api.domain.user.exception; + +import vook.server.api.domain.common.exception.DomainException; + +public class WithdrawnUserException extends DomainException { +} diff --git a/server/api/src/main/java/vook/server/api/domain/user/logic/UserLogic.java b/server/api/src/main/java/vook/server/api/domain/user/logic/UserLogic.java new file mode 100644 index 00000000..36b59c00 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/user/logic/UserLogic.java @@ -0,0 +1,90 @@ +package vook.server.api.domain.user.logic; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.RequiredArgsConstructor; +import vook.server.api.domain.user.exception.UserNotFoundException; +import vook.server.api.domain.user.model.social_user.SocialUser; +import vook.server.api.domain.user.model.social_user.SocialUserFactory; +import vook.server.api.domain.user.model.social_user.SocialUserRepository; +import vook.server.api.domain.user.model.user.User; +import vook.server.api.domain.user.model.user.UserFactory; +import vook.server.api.domain.user.model.user.UserRepository; +import vook.server.api.domain.user.model.user_info.UserInfo; +import vook.server.api.domain.user.model.user_info.UserInfoFactory; +import vook.server.api.domain.user.model.user_info.UserInfoRepository; +import vook.server.api.globalcommon.annotation.DomainLogic; + +import java.util.Optional; + +@DomainLogic +@RequiredArgsConstructor +public class UserLogic { + + private final UserFactory userFactory; + private final SocialUserFactory socialUserFactory; + private final UserInfoFactory userInfoFactory; + private final UserRepository repository; + private final SocialUserRepository socialUserRepository; + private final UserInfoRepository userInfoRepository; + + public Optional findByProvider(@NotBlank String provider, @NotBlank String providerUserId) { + return socialUserRepository.findByProviderAndProviderUserId(provider, providerUserId); + } + + public SocialUser signUpFromSocial(@NotNull @Valid UserSignUpFromSocialCommand command) { + User user = repository + .findByEmail(command.email()) + .orElseGet(() -> repository.save(command.toNewUser(userFactory))); + + return socialUserRepository.save(command.toSocialUser(socialUserFactory, user)); + } + + public User getByUid(@NotBlank String uid) { + return getUserByUid(uid); + } + + public void register(@NotNull @Valid UserRegisterCommand command) { + User user = getUserByUid(command.userUid()); + UserInfo userInfo = userInfoFactory.createForRegisterOf( + command.nickname(), + command.marketingEmailOptIn(), + user + ); + userInfoRepository.save(userInfo); + } + + public void onboarding(@NotNull @Valid UserOnboardingCommand command) { + User user = getUserByUid(command.userUid()); + user.onboarding(command.funnel(), command.job()); + } + + public void updateInfo( + @NotBlank String uid, + @NotBlank @Size(min = 1, max = 10) String nickname + ) { + User user = getUserByUid(uid); + user.update(nickname); + } + + public void withdraw(@NotBlank String uid) { + User user = getUserByUid(uid); + user.withdraw(); + } + + public void reRegister(@NotNull @Valid UserRegisterCommand command) { + User user = getUserByUid(command.userUid()); + user.reRegister(command.nickname(), command.marketingEmailOptIn()); + } + + public void validateCompletedUserByUid(@NotBlank String uid) { + User user = getUserByUid(uid); + user.validateRegisterProcessCompleted(); + } + + private User getUserByUid(String uid) { + return repository.findByUid(uid).orElseThrow(UserNotFoundException::new); + } +} diff --git a/server/api/src/main/java/vook/server/api/domain/user/logic/UserOnboardingCommand.java b/server/api/src/main/java/vook/server/api/domain/user/logic/UserOnboardingCommand.java new file mode 100644 index 00000000..490547ec --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/user/logic/UserOnboardingCommand.java @@ -0,0 +1,16 @@ +package vook.server.api.domain.user.logic; + +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; +import vook.server.api.domain.user.model.user_info.Funnel; +import vook.server.api.domain.user.model.user_info.Job; + +@Builder +public record UserOnboardingCommand( + @NotBlank + String userUid, + + Funnel funnel, + Job job +) { +} diff --git a/server/api/src/main/java/vook/server/api/domain/user/logic/UserRegisterCommand.java b/server/api/src/main/java/vook/server/api/domain/user/logic/UserRegisterCommand.java new file mode 100644 index 00000000..a2702ec7 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/user/logic/UserRegisterCommand.java @@ -0,0 +1,20 @@ +package vook.server.api.domain.user.logic; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; + +@Builder +public record UserRegisterCommand( + @NotBlank + String userUid, + + @NotBlank + @Size(min = 1, max = 10) + String nickname, + + @NotNull + Boolean marketingEmailOptIn +) { +} diff --git a/server/api/src/main/java/vook/server/api/domain/user/logic/UserSignUpFromSocialCommand.java b/server/api/src/main/java/vook/server/api/domain/user/logic/UserSignUpFromSocialCommand.java new file mode 100644 index 00000000..c68c141a --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/user/logic/UserSignUpFromSocialCommand.java @@ -0,0 +1,25 @@ +package vook.server.api.domain.user.logic; + +import jakarta.validation.constraints.Email; +import lombok.Builder; +import vook.server.api.domain.user.model.social_user.SocialUser; +import vook.server.api.domain.user.model.social_user.SocialUserFactory; +import vook.server.api.domain.user.model.user.User; +import vook.server.api.domain.user.model.user.UserFactory; + +@Builder +public record UserSignUpFromSocialCommand( + String provider, + String providerUserId, + + @Email + String email +) { + public User toNewUser(UserFactory factory) { + return factory.createForSignUpFromSocialOf(email); + } + + public SocialUser toSocialUser(SocialUserFactory factory, User user) { + return factory.createForNewOf(provider, providerUserId, user); + } +} diff --git a/server/api/src/main/java/vook/server/api/domain/user/model/social_user/DefaultSocialUserFactory.java b/server/api/src/main/java/vook/server/api/domain/user/model/social_user/DefaultSocialUserFactory.java new file mode 100644 index 00000000..b48a308b --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/user/model/social_user/DefaultSocialUserFactory.java @@ -0,0 +1,26 @@ +package vook.server.api.domain.user.model.social_user; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import vook.server.api.domain.user.model.user.User; +import vook.server.api.globalcommon.annotation.ModelFactory; + +@ModelFactory +public class DefaultSocialUserFactory implements SocialUserFactory { + + @Override + public SocialUser createForNewOf( + @NotEmpty String provider, + @NotEmpty String providerUserId, + @NotNull User user + ) { + SocialUser socialUser = SocialUser.builder() + .provider(provider) + .providerUserId(providerUserId) + .user(user) + .build(); + + user.addSocialUser(socialUser); + return socialUser; + } +} diff --git a/server/api/src/main/java/vook/server/api/domain/user/model/social_user/SocialUser.java b/server/api/src/main/java/vook/server/api/domain/user/model/social_user/SocialUser.java new file mode 100644 index 00000000..bea87bd4 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/user/model/social_user/SocialUser.java @@ -0,0 +1,35 @@ +package vook.server.api.domain.user.model.social_user; + +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import vook.server.api.domain.user.model.user.User; + +import java.time.LocalDateTime; + +@Getter +@Entity +@Table(name = "social_user") +@EntityListeners(AuditingEntityListener.class) +@Builder(access = AccessLevel.PACKAGE) +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PACKAGE) +public class SocialUser { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String provider; + + private String providerUserId; + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; + + @ManyToOne + @JoinColumn(name = "user_id") + private User user; +} diff --git a/server/api/src/main/java/vook/server/api/domain/user/model/social_user/SocialUserFactory.java b/server/api/src/main/java/vook/server/api/domain/user/model/social_user/SocialUserFactory.java new file mode 100644 index 00000000..fc3c13b5 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/user/model/social_user/SocialUserFactory.java @@ -0,0 +1,14 @@ +package vook.server.api.domain.user.model.social_user; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import vook.server.api.domain.user.model.user.User; + +public interface SocialUserFactory { + + SocialUser createForNewOf( + @NotEmpty String provider, + @NotEmpty String providerUserId, + @NotNull User user + ); +} diff --git a/server/api/src/main/java/vook/server/api/domain/user/model/social_user/SocialUserRepository.java b/server/api/src/main/java/vook/server/api/domain/user/model/social_user/SocialUserRepository.java new file mode 100644 index 00000000..a3138c01 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/user/model/social_user/SocialUserRepository.java @@ -0,0 +1,9 @@ +package vook.server.api.domain.user.model.social_user; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface SocialUserRepository extends JpaRepository { + Optional findByProviderAndProviderUserId(String provider, String providerUserId); +} diff --git a/server/api/src/main/java/vook/server/api/domain/user/model/user/DefaultUserFactory.java b/server/api/src/main/java/vook/server/api/domain/user/model/user/DefaultUserFactory.java new file mode 100644 index 00000000..ed9dfcf9 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/user/model/user/DefaultUserFactory.java @@ -0,0 +1,23 @@ +package vook.server.api.domain.user.model.user; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotEmpty; +import vook.server.api.globalcommon.annotation.ModelFactory; + +import java.util.ArrayList; +import java.util.UUID; + +@ModelFactory +public class DefaultUserFactory implements UserFactory { + + @Override + public User createForSignUpFromSocialOf(@NotEmpty @Email String email) { + return User.builder() + .uid(UUID.randomUUID().toString()) + .email(email) + .status(UserStatus.SOCIAL_LOGIN_COMPLETED) + .onboardingCompleted(false) + .socialUsers(new ArrayList<>()) + .build(); + } +} diff --git a/server/api/src/main/java/vook/server/api/domain/user/model/user/User.java b/server/api/src/main/java/vook/server/api/domain/user/model/user/User.java new file mode 100644 index 00000000..2bdaad8f --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/user/model/user/User.java @@ -0,0 +1,131 @@ +package vook.server.api.domain.user.model.user; + +import jakarta.persistence.*; +import lombok.*; +import vook.server.api.domain.user.exception.*; +import vook.server.api.domain.user.model.social_user.SocialUser; +import vook.server.api.domain.user.model.user_info.Funnel; +import vook.server.api.domain.user.model.user_info.Job; +import vook.server.api.domain.user.model.user_info.UserInfo; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Getter +@Entity +@Table(name = "users") +@Builder(access = AccessLevel.PACKAGE) +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PACKAGE) +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String uid; + + @Column(unique = true) + private String email; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, columnDefinition = "varchar(30)") + private UserStatus status; + + @Column(nullable = false) + private Boolean onboardingCompleted; + + private LocalDateTime registeredAt; + + private LocalDateTime onboardingCompletedAt; + + private LocalDateTime lastUpdatedAt; + + private LocalDateTime withdrawnAt; + + @Builder.Default + @OneToMany(mappedBy = "user", fetch = FetchType.LAZY) + private List socialUsers = new ArrayList<>(); + + @OneToOne(mappedBy = "user") + private UserInfo userInfo; + + public void addSocialUser(SocialUser socialUser) { + socialUsers.add(socialUser); + } + + public void register(UserInfo userInfo) { + this.status = UserStatus.REGISTERED; + this.userInfo = userInfo; + this.registeredAt = LocalDateTime.now(); + } + + public void onboarding(Funnel funnel, Job job) { + validateOnboardingProcessReady(); + + this.onboardingCompleted = true; + this.userInfo.addOnboardingInfo(funnel, job); + this.onboardingCompletedAt = LocalDateTime.now(); + } + + public void update(String nickname) { + validateRegisterProcessCompleted(); + + this.userInfo.update(nickname); + this.lastUpdatedAt = LocalDateTime.now(); + } + + public void withdraw() { + if (this.status == UserStatus.WITHDRAWN) { + return; + } + + this.status = UserStatus.WITHDRAWN; + this.withdrawnAt = LocalDateTime.now(); + } + + public void reRegister(String nickname, Boolean marketingEmailOptIn) { + validateReRegisterProcessReady(); + + this.status = UserStatus.REGISTERED; + this.onboardingCompleted = false; + this.userInfo.reRegister(nickname, marketingEmailOptIn); + this.registeredAt = LocalDateTime.now(); + this.lastUpdatedAt = null; + this.withdrawnAt = null; + } + + public void validateRegisterProcessReady() { + if (status == UserStatus.REGISTERED) { + throw new AlreadyRegisteredException(); + } + if (status == UserStatus.WITHDRAWN) { + throw new WithdrawnUserException(); + } + } + + public void validateOnboardingProcessReady() { + if (status != UserStatus.REGISTERED) { + throw new NotReadyToOnboardingException(); + } + if (this.onboardingCompleted) { + throw new AlreadyOnboardingException(); + } + } + + public void validateRegisterProcessCompleted() { + if (status != UserStatus.REGISTERED) { + throw new NotRegisteredException(); + } + if (!this.onboardingCompleted) { + throw new NotOnboardingException(); + } + } + + public void validateReRegisterProcessReady() { + if (status != UserStatus.WITHDRAWN) { + throw new NotWithdrawnUserException(); + } + } +} diff --git a/server/api/src/main/java/vook/server/api/domain/user/model/user/UserFactory.java b/server/api/src/main/java/vook/server/api/domain/user/model/user/UserFactory.java new file mode 100644 index 00000000..46b552ab --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/user/model/user/UserFactory.java @@ -0,0 +1,8 @@ +package vook.server.api.domain.user.model.user; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotEmpty; + +public interface UserFactory { + User createForSignUpFromSocialOf(@NotEmpty @Email String email); +} diff --git a/server/api/src/main/java/vook/server/api/domain/user/model/user/UserRepository.java b/server/api/src/main/java/vook/server/api/domain/user/model/user/UserRepository.java new file mode 100644 index 00000000..40b34613 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/user/model/user/UserRepository.java @@ -0,0 +1,13 @@ +package vook.server.api.domain.user.model.user; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); + + @Query("SELECT u FROM User u LEFT JOIN FETCH u.userInfo WHERE u.uid = :uid") + Optional findByUid(String uid); +} diff --git a/server/api/src/main/java/vook/server/api/domain/user/model/user/UserStatus.java b/server/api/src/main/java/vook/server/api/domain/user/model/user/UserStatus.java new file mode 100644 index 00000000..678a130f --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/user/model/user/UserStatus.java @@ -0,0 +1,7 @@ +package vook.server.api.domain.user.model.user; + +public enum UserStatus { + SOCIAL_LOGIN_COMPLETED, // 소셜로그인 완료됨 + REGISTERED, // 가입 됨 + WITHDRAWN // 탈퇴 됨 +} diff --git a/server/api/src/main/java/vook/server/api/domain/user/model/user_info/DefaultUserInfoFactory.java b/server/api/src/main/java/vook/server/api/domain/user/model/user_info/DefaultUserInfoFactory.java new file mode 100644 index 00000000..31df12b3 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/user/model/user_info/DefaultUserInfoFactory.java @@ -0,0 +1,28 @@ +package vook.server.api.domain.user.model.user_info; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import vook.server.api.domain.user.model.user.User; +import vook.server.api.globalcommon.annotation.ModelFactory; + +@ModelFactory +public class DefaultUserInfoFactory implements UserInfoFactory { + + @Override + public UserInfo createForRegisterOf( + @NotEmpty String nickname, + @NotNull Boolean marketingEmailOptIn, + @NotNull User user + ) { + user.validateRegisterProcessReady(); + + UserInfo userInfo = UserInfo.builder() + .nickname(nickname) + .marketingEmailOptIn(marketingEmailOptIn) + .user(user) + .build(); + user.register(userInfo); + + return userInfo; + } +} diff --git a/server/api/src/main/java/vook/server/api/domain/user/model/user_info/Funnel.java b/server/api/src/main/java/vook/server/api/domain/user/model/user_info/Funnel.java new file mode 100644 index 00000000..4c822902 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/user/model/user_info/Funnel.java @@ -0,0 +1,24 @@ +package vook.server.api.domain.user.model.user_info; + +public enum Funnel { + //X + X, + + //페이스북 + FACEBOOK, + + //링크드인 + LINKEDIN, + + //인스타그램 + INSTAGRAM, + + //네이버 블로그 + NAVER_BLOG, + + //친구/지인 추천 + RECOMMENDATION, + + //기타 + OTHER +} diff --git a/server/api/src/main/java/vook/server/api/domain/user/model/user_info/Job.java b/server/api/src/main/java/vook/server/api/domain/user/model/user_info/Job.java new file mode 100644 index 00000000..2a1a29de --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/user/model/user_info/Job.java @@ -0,0 +1,24 @@ +package vook.server.api.domain.user.model.user_info; + +public enum Job { + //기획자 + PLANNER, + + //디자이너 + DESIGNER, + + //개발자 + DEVELOPER, + + //마케터 + MARKETER, + + //CEO + CEO, + + //HR + HR, + + //기타 + OTHER +} diff --git a/server/api/src/main/java/vook/server/api/domain/user/model/user_info/UserInfo.java b/server/api/src/main/java/vook/server/api/domain/user/model/user_info/UserInfo.java new file mode 100644 index 00000000..12068a17 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/user/model/user_info/UserInfo.java @@ -0,0 +1,52 @@ +package vook.server.api.domain.user.model.user_info; + +import jakarta.persistence.*; +import lombok.*; +import vook.server.api.domain.user.model.user.User; + +@Getter +@Entity +@Table(name = "user_info") +@Builder(access = AccessLevel.PACKAGE) +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PACKAGE) +public class UserInfo { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(length = 10, nullable = false) + private String nickname; + + private Boolean marketingEmailOptIn; + + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "varchar(20)") + private Funnel funnel; + + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "varchar(20)") + private Job job; + + @OneToOne + @JoinColumn(name = "user_id") + private User user; + + public void addOnboardingInfo(Funnel funnel, Job job) { + this.funnel = funnel; + this.job = job; + } + + public void update(String nickname) { + this.nickname = nickname; + } + + public void reRegister(String nickname, Boolean marketingEmailOptIn) { + this.nickname = nickname; + this.marketingEmailOptIn = marketingEmailOptIn; + + funnel = null; + job = null; + } +} diff --git a/server/api/src/main/java/vook/server/api/domain/user/model/user_info/UserInfoFactory.java b/server/api/src/main/java/vook/server/api/domain/user/model/user_info/UserInfoFactory.java new file mode 100644 index 00000000..b6faad2f --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/user/model/user_info/UserInfoFactory.java @@ -0,0 +1,14 @@ +package vook.server.api.domain.user.model.user_info; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import vook.server.api.domain.user.model.user.User; + +public interface UserInfoFactory { + + UserInfo createForRegisterOf( + @NotEmpty String nickname, + @NotNull Boolean marketingEmailOptIn, + @NotNull User user + ); +} diff --git a/server/api/src/main/java/vook/server/api/domain/user/model/user_info/UserInfoRepository.java b/server/api/src/main/java/vook/server/api/domain/user/model/user_info/UserInfoRepository.java new file mode 100644 index 00000000..c77c03f1 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/user/model/user_info/UserInfoRepository.java @@ -0,0 +1,6 @@ +package vook.server.api.domain.user.model.user_info; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserInfoRepository extends JpaRepository { +} diff --git a/server/api/src/main/java/vook/server/api/domain/user/package-info.java b/server/api/src/main/java/vook/server/api/domain/user/package-info.java new file mode 100644 index 00000000..2689ce62 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/user/package-info.java @@ -0,0 +1,10 @@ +@ApplicationModule( + type = ApplicationModule.Type.OPEN, + displayName = "User Domain", + allowedDependencies = { + "vook.server.api.domain.common" + } +) +package vook.server.api.domain.user; + +import org.springframework.modulith.ApplicationModule; diff --git a/server/api/src/main/java/vook/server/api/domain/vocabulary/exception/TermLimitExceededException.java b/server/api/src/main/java/vook/server/api/domain/vocabulary/exception/TermLimitExceededException.java new file mode 100644 index 00000000..0638f719 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/vocabulary/exception/TermLimitExceededException.java @@ -0,0 +1,6 @@ +package vook.server.api.domain.vocabulary.exception; + +import vook.server.api.domain.common.exception.DomainException; + +public class TermLimitExceededException extends DomainException { +} diff --git a/server/api/src/main/java/vook/server/api/domain/vocabulary/exception/TermNotFoundException.java b/server/api/src/main/java/vook/server/api/domain/vocabulary/exception/TermNotFoundException.java new file mode 100644 index 00000000..54f713cf --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/vocabulary/exception/TermNotFoundException.java @@ -0,0 +1,6 @@ +package vook.server.api.domain.vocabulary.exception; + +import vook.server.api.domain.common.exception.DomainException; + +public class TermNotFoundException extends DomainException { +} diff --git a/server/api/src/main/java/vook/server/api/domain/vocabulary/exception/VocabularyLimitExceededException.java b/server/api/src/main/java/vook/server/api/domain/vocabulary/exception/VocabularyLimitExceededException.java new file mode 100644 index 00000000..84bb84ae --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/vocabulary/exception/VocabularyLimitExceededException.java @@ -0,0 +1,6 @@ +package vook.server.api.domain.vocabulary.exception; + +import vook.server.api.domain.common.exception.DomainException; + +public class VocabularyLimitExceededException extends DomainException { +} diff --git a/server/api/src/main/java/vook/server/api/domain/vocabulary/exception/VocabularyNotFoundException.java b/server/api/src/main/java/vook/server/api/domain/vocabulary/exception/VocabularyNotFoundException.java new file mode 100644 index 00000000..7c073c53 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/vocabulary/exception/VocabularyNotFoundException.java @@ -0,0 +1,6 @@ +package vook.server.api.domain.vocabulary.exception; + +import vook.server.api.domain.common.exception.DomainException; + +public class VocabularyNotFoundException extends DomainException { +} diff --git a/server/api/src/main/java/vook/server/api/domain/vocabulary/exception/VocabularyTermNotFoundException.java b/server/api/src/main/java/vook/server/api/domain/vocabulary/exception/VocabularyTermNotFoundException.java new file mode 100644 index 00000000..52d52cbe --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/vocabulary/exception/VocabularyTermNotFoundException.java @@ -0,0 +1,6 @@ +package vook.server.api.domain.vocabulary.exception; + +import vook.server.api.domain.common.exception.DomainException; + +public class VocabularyTermNotFoundException extends DomainException { +} diff --git a/server/api/src/main/java/vook/server/api/domain/vocabulary/logic/term/TermCreateAllCommand.java b/server/api/src/main/java/vook/server/api/domain/vocabulary/logic/term/TermCreateAllCommand.java new file mode 100644 index 00000000..e887aa29 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/vocabulary/logic/term/TermCreateAllCommand.java @@ -0,0 +1,31 @@ +package vook.server.api.domain.vocabulary.logic.term; + +import lombok.Builder; +import vook.server.api.domain.vocabulary.model.term.Term; +import vook.server.api.domain.vocabulary.model.term.TermFactory; + +import java.util.List; + +@Builder +public record TermCreateAllCommand( + String vocabularyUid, + List termInfos +) { + + public List toEntity(TermFactory termFactory) { + return termFactory.createForBatchCreate(new TermFactory.CreateForBatchCommand( + vocabularyUid, + termInfos.stream() + .map(termInfo -> new TermFactory.TermInfo(termInfo.term(), termInfo.meaning(), termInfo.synonyms())) + .toList() + )); + } + + @Builder + public record TermInfo( + String term, + String meaning, + List synonyms + ) { + } +} diff --git a/server/api/src/main/java/vook/server/api/domain/vocabulary/logic/term/TermCreateCommand.java b/server/api/src/main/java/vook/server/api/domain/vocabulary/logic/term/TermCreateCommand.java new file mode 100644 index 00000000..33df4f2c --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/vocabulary/logic/term/TermCreateCommand.java @@ -0,0 +1,21 @@ +package vook.server.api.domain.vocabulary.logic.term; + +import lombok.Builder; +import vook.server.api.domain.vocabulary.model.term.Term; +import vook.server.api.domain.vocabulary.model.term.TermFactory; + +import java.util.List; + +@Builder +public record TermCreateCommand( + String vocabularyUid, + String term, + String meaning, + List synonyms +) { + public Term toEntity(TermFactory termFactory) { + return termFactory.create( + new TermFactory.CreateCommand(vocabularyUid, new TermFactory.TermInfo(term, meaning, synonyms)) + ); + } +} diff --git a/server/api/src/main/java/vook/server/api/domain/vocabulary/logic/term/TermLogic.java b/server/api/src/main/java/vook/server/api/domain/vocabulary/logic/term/TermLogic.java new file mode 100644 index 00000000..7a518537 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/vocabulary/logic/term/TermLogic.java @@ -0,0 +1,81 @@ +package vook.server.api.domain.vocabulary.logic.term; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import vook.server.api.domain.vocabulary.exception.TermNotFoundException; +import vook.server.api.domain.vocabulary.model.term.Term; +import vook.server.api.domain.vocabulary.model.term.TermFactory; +import vook.server.api.domain.vocabulary.model.term.TermRepository; +import vook.server.api.globalcommon.annotation.DomainLogic; + +import java.util.List; + +@DomainLogic +@RequiredArgsConstructor +public class TermLogic { + + private final TermFactory termFactory; + private final TermRepository termRepository; + private final SearchManagementService searchManagementService; + + public Term create(@NotNull TermCreateCommand command) { + Term term = command.toEntity(termFactory); + Term saved = termRepository.save(term); + searchManagementService.save(saved); + return saved; + } + + public void createAll(@NotNull TermCreateAllCommand command) { + List terms = command.toEntity(termFactory); + termRepository.saveAll(terms); + searchManagementService.saveAll(terms); + } + + public Term getByUid(@NotBlank String uid) { + return getTermByUid(uid); + } + + public void update(@NotNull TermUpdateCommand serviceCommand) { + Term term = getTermByUid(serviceCommand.uid()); + Term updateTerm = serviceCommand.toEntity(termFactory); + term.update(updateTerm); + searchManagementService.update(term); + } + + public void delete(@NotBlank String uid) { + Term term = getTermByUid(uid); + term.getVocabulary().removeTerm(term); + termRepository.delete(term); + searchManagementService.delete(term); + } + + public void batchDelete( + @Valid + @NotEmpty + List<@NotBlank String> termUids + ) { + List terms = termRepository.findByUidIn(termUids); + terms.forEach(term -> term.getVocabulary().removeTerm(term)); + termRepository.deleteAll(terms); + searchManagementService.deleteAll(terms); + } + + private Term getTermByUid(String uid) { + return termRepository.findByUid(uid).orElseThrow(TermNotFoundException::new); + } + + public interface SearchManagementService { + void save(Term term); + + void update(Term term); + + void delete(Term term); + + void saveAll(List terms); + + void deleteAll(List terms); + } +} diff --git a/server/api/src/main/java/vook/server/api/domain/vocabulary/logic/term/TermUpdateCommand.java b/server/api/src/main/java/vook/server/api/domain/vocabulary/logic/term/TermUpdateCommand.java new file mode 100644 index 00000000..967de2dc --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/vocabulary/logic/term/TermUpdateCommand.java @@ -0,0 +1,22 @@ +package vook.server.api.domain.vocabulary.logic.term; + +import lombok.Builder; +import vook.server.api.domain.vocabulary.model.term.Term; +import vook.server.api.domain.vocabulary.model.term.TermFactory; + +import java.util.List; + +@Builder +public record TermUpdateCommand( + String uid, + String term, + String meaning, + List synonyms +) { + + public Term toEntity(TermFactory termFactory) { + return termFactory.createForUpdate( + new TermFactory.UpdateCommand(new TermFactory.TermInfo(term, meaning, synonyms)) + ); + } +} diff --git a/server/api/src/main/java/vook/server/api/domain/vocabulary/logic/vocabulary/VocabularyCreateCommand.java b/server/api/src/main/java/vook/server/api/domain/vocabulary/logic/vocabulary/VocabularyCreateCommand.java new file mode 100644 index 00000000..cce1d50b --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/vocabulary/logic/vocabulary/VocabularyCreateCommand.java @@ -0,0 +1,18 @@ +package vook.server.api.domain.vocabulary.logic.vocabulary; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import vook.server.api.domain.vocabulary.model.vocabulary.UserUid; + +@Builder +public record VocabularyCreateCommand( + @NotBlank + @Size(min = 1, max = 20) + String name, + + @NotNull + UserUid userUid +) { +} diff --git a/server/api/src/main/java/vook/server/api/domain/vocabulary/logic/vocabulary/VocabularyDeleteCommand.java b/server/api/src/main/java/vook/server/api/domain/vocabulary/logic/vocabulary/VocabularyDeleteCommand.java new file mode 100644 index 00000000..d95a69dd --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/vocabulary/logic/vocabulary/VocabularyDeleteCommand.java @@ -0,0 +1,11 @@ +package vook.server.api.domain.vocabulary.logic.vocabulary; + +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; + +@Builder +public record VocabularyDeleteCommand( + @NotBlank + String vocabularyUid +) { +} diff --git a/server/api/src/main/java/vook/server/api/domain/vocabulary/logic/vocabulary/VocabularyLogic.java b/server/api/src/main/java/vook/server/api/domain/vocabulary/logic/vocabulary/VocabularyLogic.java new file mode 100644 index 00000000..8bb60365 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/vocabulary/logic/vocabulary/VocabularyLogic.java @@ -0,0 +1,67 @@ +package vook.server.api.domain.vocabulary.logic.vocabulary; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import vook.server.api.domain.vocabulary.exception.VocabularyLimitExceededException; +import vook.server.api.domain.vocabulary.exception.VocabularyNotFoundException; +import vook.server.api.domain.vocabulary.model.term.Term; +import vook.server.api.domain.vocabulary.model.term.TermRepository; +import vook.server.api.domain.vocabulary.model.vocabulary.UserUid; +import vook.server.api.domain.vocabulary.model.vocabulary.Vocabulary; +import vook.server.api.domain.vocabulary.model.vocabulary.VocabularyRepository; +import vook.server.api.globalcommon.annotation.DomainLogic; + +import java.util.List; + +@DomainLogic +@RequiredArgsConstructor +public class VocabularyLogic { + + private final VocabularyRepository repository; + private final TermRepository termRepository; + private final SearchManagementService searchService; + + public List findAllBy(@NotNull UserUid userUid) { + return repository.findAllByUserUid(userUid); + } + + public List findAllUidsBy(@NotNull UserUid userUid) { + return repository.findAllUidsByUserUid(userUid); + } + + public Vocabulary create(@Valid VocabularyCreateCommand command) { + UserUid userUid = command.userUid(); + if (repository.findAllByUserUid(userUid).size() >= 3) { + throw new VocabularyLimitExceededException(); + } + + Vocabulary saved = repository.save(Vocabulary.forCreateOf(command.name(), userUid)); + searchService.save(saved); + return saved; + } + + public void update(@Valid VocabularyUpdateCommand command) { + Vocabulary vocabulary = repository.findByUid(command.vocabularyUid()).orElseThrow(VocabularyNotFoundException::new); + vocabulary.update(command.name()); + } + + public void delete(@NotBlank String vocabularyUid) { + Vocabulary vocabulary = repository.findByUid(vocabularyUid).orElseThrow(VocabularyNotFoundException::new); + List termUids = termRepository.findByVocabulary(vocabulary).stream().map(Term::getUid).toList(); + termRepository.deleteAllByUids(termUids); + repository.delete(vocabulary); + searchService.delete(vocabulary); + } + + public Vocabulary getByUid(@NotBlank String vocabularyUid) { + return repository.findByUid(vocabularyUid).orElseThrow(VocabularyNotFoundException::new); + } + + public interface SearchManagementService { + void save(Vocabulary saved); + + void delete(Vocabulary vocabulary); + } +} diff --git a/server/api/src/main/java/vook/server/api/domain/vocabulary/logic/vocabulary/VocabularyUpdateCommand.java b/server/api/src/main/java/vook/server/api/domain/vocabulary/logic/vocabulary/VocabularyUpdateCommand.java new file mode 100644 index 00000000..5f4a1f94 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/vocabulary/logic/vocabulary/VocabularyUpdateCommand.java @@ -0,0 +1,16 @@ +package vook.server.api.domain.vocabulary.logic.vocabulary; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Builder; + +@Builder +public record VocabularyUpdateCommand( + @NotBlank + String vocabularyUid, + + @NotBlank + @Size(min = 1, max = 20) + String name +) { +} diff --git a/server/api/src/main/java/vook/server/api/domain/vocabulary/model/term/DefaultTermFactory.java b/server/api/src/main/java/vook/server/api/domain/vocabulary/model/term/DefaultTermFactory.java new file mode 100644 index 00000000..1303d6f2 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/vocabulary/model/term/DefaultTermFactory.java @@ -0,0 +1,78 @@ +package vook.server.api.domain.vocabulary.model.term; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import vook.server.api.domain.common.model.Synonym; +import vook.server.api.domain.vocabulary.exception.TermLimitExceededException; +import vook.server.api.domain.vocabulary.exception.VocabularyNotFoundException; +import vook.server.api.domain.vocabulary.model.vocabulary.Vocabulary; +import vook.server.api.domain.vocabulary.model.vocabulary.VocabularyRepository; +import vook.server.api.globalcommon.annotation.ModelFactory; + +import java.util.List; +import java.util.UUID; + +@ModelFactory +@RequiredArgsConstructor +public class DefaultTermFactory implements TermFactory { + + private static final int MAX_VOCABULARY_SIZE = 100; + + private final VocabularyRepository vocabularyRepository; + + @Override + public Term create(@Valid @NotNull CreateCommand request) { + Vocabulary vocabulary = getVocabulary(request.vocabularyUid()); + int termCount = vocabulary.termCount(); + if (termCount >= MAX_VOCABULARY_SIZE) { + throw new TermLimitExceededException(); + } + + Term term = Term.builder() + .uid(UUID.randomUUID().toString()) + .term(request.termInfo().term()) + .meaning(request.termInfo().meaning()) + .synonym(Synonym.from(request.termInfo().synonyms())) + .vocabulary(vocabulary) + .build(); + vocabulary.addTerm(term); + + return term; + } + + @Override + public List createForBatchCreate(@Valid @NotNull CreateForBatchCommand request) { + Vocabulary vocabulary = getVocabulary(request.vocabularyUid()); + int savedCount = vocabulary.termCount(); + int count = request.termInfos().size(); + if (savedCount + count > MAX_VOCABULARY_SIZE) { + throw new TermLimitExceededException(); + } + + return request.termInfos().stream() + .map(termInfo -> Term.builder() + .uid(UUID.randomUUID().toString()) + .term(termInfo.term()) + .meaning(termInfo.meaning()) + .synonym(Synonym.from(termInfo.synonyms())) + .vocabulary(vocabulary) + .build() + ) + .peek(vocabulary::addTerm) + .toList(); + } + + @Override + public Term createForUpdate(@Valid @NotNull UpdateCommand request) { + return Term.builder() + .term(request.termInfo().term()) + .meaning(request.termInfo().meaning()) + .synonym(Synonym.from(request.termInfo().synonyms())) + .build(); + } + + private Vocabulary getVocabulary(String vocabularyUid) { + return vocabularyRepository.findByUid(vocabularyUid).orElseThrow(VocabularyNotFoundException::new); + } +} diff --git a/server/api/src/main/java/vook/server/api/domain/vocabulary/model/term/Term.java b/server/api/src/main/java/vook/server/api/domain/vocabulary/model/term/Term.java new file mode 100644 index 00000000..1e1b4037 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/vocabulary/model/term/Term.java @@ -0,0 +1,56 @@ +package vook.server.api.domain.vocabulary.model.term; + +import jakarta.persistence.*; +import lombok.*; +import vook.server.api.domain.common.model.BaseEntity; +import vook.server.api.domain.common.model.Synonym; +import vook.server.api.domain.vocabulary.model.vocabulary.Vocabulary; + +import java.util.List; + +@Getter +@Entity +@Table(name = "term") +@Builder(access = AccessLevel.PACKAGE) +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PACKAGE) +public class Term extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String uid; + + /** + * 용어 이름 + */ + @Column(length = 100, nullable = false) + private String term; + + /** + * 용어 의미 + */ + @Column(length = 2000, nullable = false) + private String meaning; + + /** + * 동의어 + */ + @Getter(AccessLevel.PRIVATE) + private Synonym synonym; + + @ManyToOne + @JoinColumn(name = "vocabulary_id", nullable = false) + private Vocabulary vocabulary; + + public void update(Term term) { + this.term = term.getTerm(); + this.meaning = term.getMeaning(); + this.synonym = term.getSynonym(); + } + + public List getSynonyms() { + return synonym.synonyms(); + } +} diff --git a/server/api/src/main/java/vook/server/api/domain/vocabulary/model/term/TermFactory.java b/server/api/src/main/java/vook/server/api/domain/vocabulary/model/term/TermFactory.java new file mode 100644 index 00000000..8556df99 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/vocabulary/model/term/TermFactory.java @@ -0,0 +1,65 @@ +package vook.server.api.domain.vocabulary.model.term; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public interface TermFactory { + + Term create(@NotNull @Valid CreateCommand request); + + record CreateCommand( + @NotNull + String vocabularyUid, + + @Valid + @NotNull + TermInfo termInfo + ) { + } + + List createForBatchCreate(@NotNull @Valid CreateForBatchCommand request); + + record CreateForBatchCommand( + @NotNull + String vocabularyUid, + + @Valid + List<@Valid @NotNull TermInfo> termInfos + ) { + } + + Term createForUpdate(@NotNull @Valid UpdateCommand request); + + record UpdateCommand( + @Valid + @NotNull + TermInfo termInfo + ) { + } + + record TermInfo( + @NotBlank + @Size(min = 1, max = 100) + String term, + + @NotBlank + @Size(min = 1, max = 2000) + String meaning, + + @NotNull + List synonyms + ) { + @AssertTrue(message = "동의어는 콤마(,)포함 2000자 이내로 입력해주세요.") + boolean isSynonymsSizeValid() { + if (synonyms == null) { + return true; + } + return String.join(",", synonyms).length() <= 2000; + } + } +} diff --git a/server/api/src/main/java/vook/server/api/domain/vocabulary/model/term/TermRepository.java b/server/api/src/main/java/vook/server/api/domain/vocabulary/model/term/TermRepository.java new file mode 100644 index 00000000..02703435 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/vocabulary/model/term/TermRepository.java @@ -0,0 +1,22 @@ +package vook.server.api.domain.vocabulary.model.term; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import vook.server.api.domain.vocabulary.model.vocabulary.Vocabulary; + +import java.util.List; +import java.util.Optional; + +public interface TermRepository extends JpaRepository { + Optional findByUid(String uid); + + List findByUidIn(List termUids); + + List findByVocabulary(Vocabulary vocabulary); + + @Modifying + @Query("delete from Term t where t.uid in :uids") + void deleteAllByUids(@Param("uids") List termUids); +} diff --git a/server/api/src/main/java/vook/server/api/domain/vocabulary/model/vocabulary/UserUid.java b/server/api/src/main/java/vook/server/api/domain/vocabulary/model/vocabulary/UserUid.java new file mode 100644 index 00000000..6425a334 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/vocabulary/model/vocabulary/UserUid.java @@ -0,0 +1,14 @@ +package vook.server.api.domain.vocabulary.model.vocabulary; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@EqualsAndHashCode +@NoArgsConstructor +@AllArgsConstructor +public class UserUid { + private String value; +} diff --git a/server/api/src/main/java/vook/server/api/domain/vocabulary/model/vocabulary/Vocabulary.java b/server/api/src/main/java/vook/server/api/domain/vocabulary/model/vocabulary/Vocabulary.java new file mode 100644 index 00000000..8937719e --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/vocabulary/model/vocabulary/Vocabulary.java @@ -0,0 +1,74 @@ +package vook.server.api.domain.vocabulary.model.vocabulary; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import org.hibernate.annotations.Formula; +import vook.server.api.domain.common.model.BaseEntity; +import vook.server.api.domain.vocabulary.model.term.Term; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Getter +@Entity +@Table(name = "vocabulary") +public class Vocabulary extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String uid; + + /** + * 용어집 이름 + */ + @Column(length = 20, nullable = false) + private String name; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "user_uid", nullable = false)) + private UserUid userUid; + + @OneToMany(mappedBy = "vocabulary", fetch = FetchType.LAZY) + private List terms = new ArrayList<>(); + + @Getter(AccessLevel.NONE) + @Formula("(SELECT COUNT(t.id) FROM term t WHERE t.vocabulary_id = id)") + private int termCount; + + public static Vocabulary forCreateOf( + String name, + UserUid userUid + ) { + Vocabulary result = new Vocabulary(); + result.uid = UUID.randomUUID().toString(); + result.name = name; + result.userUid = userUid; + return result; + } + + public boolean isValidOwner(UserUid userUid) { + return this.userUid.equals(userUid); + } + + public void update(String name) { + this.name = name; + } + + public void addTerm(Term term) { + this.terms.add(term); + termCount++; + } + + public int termCount() { + return this.termCount; + } + + public void removeTerm(Term term) { + this.terms.remove(term); + termCount--; + } +} diff --git a/server/api/src/main/java/vook/server/api/domain/vocabulary/model/vocabulary/VocabularyRepository.java b/server/api/src/main/java/vook/server/api/domain/vocabulary/model/vocabulary/VocabularyRepository.java new file mode 100644 index 00000000..e272da90 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/vocabulary/model/vocabulary/VocabularyRepository.java @@ -0,0 +1,16 @@ +package vook.server.api.domain.vocabulary.model.vocabulary; + +import java.util.List; +import java.util.Optional; + +public interface VocabularyRepository { + List findAllByUserUid(UserUid userUid); + + List findAllUidsByUserUid(UserUid userUid); + + Optional findByUid(String vocabularyUid); + + Vocabulary save(Vocabulary vocabulary); + + void delete(Vocabulary vocabulary); +} diff --git a/server/api/src/main/java/vook/server/api/domain/vocabulary/package-info.java b/server/api/src/main/java/vook/server/api/domain/vocabulary/package-info.java new file mode 100644 index 00000000..d03d726b --- /dev/null +++ b/server/api/src/main/java/vook/server/api/domain/vocabulary/package-info.java @@ -0,0 +1,10 @@ +@ApplicationModule( + type = ApplicationModule.Type.OPEN, + displayName = "Vocabulary Domain", + allowedDependencies = { + "vook.server.api.domain.common" + } +) +package vook.server.api.domain.vocabulary; + +import org.springframework.modulith.ApplicationModule; diff --git a/server/api/src/main/java/vook/server/api/globalcommon/annotation/DomainLogic.java b/server/api/src/main/java/vook/server/api/globalcommon/annotation/DomainLogic.java new file mode 100644 index 00000000..2816a913 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/globalcommon/annotation/DomainLogic.java @@ -0,0 +1,18 @@ +package vook.server.api.globalcommon.annotation; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Service +@Validated +@Transactional +public @interface DomainLogic { +} diff --git a/server/api/src/main/java/vook/server/api/globalcommon/annotation/ModelFactory.java b/server/api/src/main/java/vook/server/api/globalcommon/annotation/ModelFactory.java new file mode 100644 index 00000000..1ce82f05 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/globalcommon/annotation/ModelFactory.java @@ -0,0 +1,16 @@ +package vook.server.api.globalcommon.annotation; + +import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Component +@Validated +public @interface ModelFactory { +} diff --git a/server/api/src/main/java/vook/server/api/globalcommon/annotation/UseCase.java b/server/api/src/main/java/vook/server/api/globalcommon/annotation/UseCase.java new file mode 100644 index 00000000..c2c45174 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/globalcommon/annotation/UseCase.java @@ -0,0 +1,18 @@ +package vook.server.api.globalcommon.annotation; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Service +@Validated +@Transactional +public @interface UseCase { +} diff --git a/server/api/src/main/java/vook/server/api/globalcommon/exception/AppException.java b/server/api/src/main/java/vook/server/api/globalcommon/exception/AppException.java new file mode 100644 index 00000000..127792da --- /dev/null +++ b/server/api/src/main/java/vook/server/api/globalcommon/exception/AppException.java @@ -0,0 +1,13 @@ +package vook.server.api.globalcommon.exception; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public abstract class AppException extends RuntimeException { + + public AppException(RuntimeException cause) { + super(cause); + } +} diff --git a/server/api/src/main/java/vook/server/api/globalcommon/exception/ExceptionConvertAdvisor.java b/server/api/src/main/java/vook/server/api/globalcommon/exception/ExceptionConvertAdvisor.java new file mode 100644 index 00000000..3510754f --- /dev/null +++ b/server/api/src/main/java/vook/server/api/globalcommon/exception/ExceptionConvertAdvisor.java @@ -0,0 +1,25 @@ +package vook.server.api.globalcommon.exception; + +import jakarta.validation.ConstraintViolationException; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; + +@Aspect +@Component +public class ExceptionConvertAdvisor { + + @Around(""" + @within(vook.server.api.globalcommon.annotation.DomainLogic) || + @within(vook.server.api.globalcommon.annotation.UseCase) || + @within(vook.server.api.globalcommon.annotation.ModelFactory) + """) + public Object convertException(ProceedingJoinPoint joinPoint) throws Throwable { + try { + return joinPoint.proceed(); + } catch (ConstraintViolationException e) { + throw new ParameterValidateException(e); + } + } +} diff --git a/server/api/src/main/java/vook/server/api/globalcommon/exception/ParameterValidateException.java b/server/api/src/main/java/vook/server/api/globalcommon/exception/ParameterValidateException.java new file mode 100644 index 00000000..1eddf5a7 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/globalcommon/exception/ParameterValidateException.java @@ -0,0 +1,12 @@ +package vook.server.api.globalcommon.exception; + +import jakarta.validation.ConstraintViolationException; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +public class ParameterValidateException extends AppException { + + public ParameterValidateException(ConstraintViolationException cause) { + super(cause); + } +} diff --git a/server/api/src/main/java/vook/server/api/globalcommon/helper/jwt/JWTHelper.java b/server/api/src/main/java/vook/server/api/globalcommon/helper/jwt/JWTHelper.java new file mode 100644 index 00000000..8c1c324f --- /dev/null +++ b/server/api/src/main/java/vook/server/api/globalcommon/helper/jwt/JWTHelper.java @@ -0,0 +1,16 @@ +package vook.server.api.globalcommon.helper.jwt; + +public abstract class JWTHelper { + protected static T run(CheckedSupplier supplier) { + try { + return supplier.get(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @FunctionalInterface + protected interface CheckedSupplier { + T get() throws Exception; + } +} diff --git a/server/api/src/main/java/vook/server/api/globalcommon/helper/jwt/JWTReader.java b/server/api/src/main/java/vook/server/api/globalcommon/helper/jwt/JWTReader.java new file mode 100644 index 00000000..6a92cc98 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/globalcommon/helper/jwt/JWTReader.java @@ -0,0 +1,56 @@ +package vook.server.api.globalcommon.helper.jwt; + +import com.nimbusds.jose.JWSVerifier; +import com.nimbusds.jose.crypto.MACVerifier; +import com.nimbusds.jwt.SignedJWT; + +import java.nio.charset.StandardCharsets; +import java.util.Date; + +public class JWTReader extends JWTHelper { + + private JWSVerifier verifier; + private SignedJWT signedJWT; + + static JWTReader of(String secret, String token) { + return run(() -> { + JWTReader reader = new JWTReader(); + byte[] secretBytes = secret.getBytes(StandardCharsets.UTF_8); + reader.verifier = new MACVerifier(secretBytes); + reader.signedJWT = SignedJWT.parse(token); + return reader; + }); + } + + public void validate() { + run(() -> { + if (!signedJWT.verify(verifier)) { + throw new IllegalArgumentException("JWT의 서명이 올바르지 않습니다."); + } + + Date expirationTime = signedJWT.getJWTClaimsSet().getExpirationTime(); + if (expirationTime.before(new Date())) { + throw new IllegalArgumentException("JWT가 만료되었습니다."); + } + + return null; + }); + } + + public String getClaim(String claimName) { + return run(() -> signedJWT.getJWTClaimsSet().getStringClaim(claimName)); + } + + public static class Builder { + + private final String secret; + + public Builder(String jwtSecret) { + this.secret = jwtSecret; + } + + public JWTReader build(String token) { + return JWTReader.of(secret, token); + } + } +} diff --git a/server/api/src/main/java/vook/server/api/globalcommon/helper/jwt/JWTWriter.java b/server/api/src/main/java/vook/server/api/globalcommon/helper/jwt/JWTWriter.java new file mode 100644 index 00000000..dc6e8190 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/globalcommon/helper/jwt/JWTWriter.java @@ -0,0 +1,76 @@ +package vook.server.api.globalcommon.helper.jwt; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSSigner; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class JWTWriter extends JWTHelper { + + private static final Long DEFAULT_EXPIRED_MS = 1000L * 60 * 30; // 30 minutes + + private JWSSigner signer; + private Long expiredMs; + private final Map claims = new HashMap<>(); + + static JWTWriter of(String secret) { + return run(() -> { + JWTWriter writer = new JWTWriter(); + byte[] secretBytes = secret.getBytes(StandardCharsets.UTF_8); + writer.signer = new MACSigner(secretBytes); + return writer; + }); + } + + public JWTWriter withExpiredMs(Long expiredMs) { + this.expiredMs = expiredMs; + return this; + } + + public JWTWriter withClaim(String name, String value) { + claims.put(name, value); + return this; + } + + public String jwtString() { + return run(() -> { + JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder() + .issueTime(new Date()) + .expirationTime(new Date(System.currentTimeMillis() + (expiredMs != null ? expiredMs : DEFAULT_EXPIRED_MS))); + claims.forEach(builder::claim); + JWTClaimsSet claimsSet = builder.build(); + + SignedJWT signedJWT = new SignedJWT( + new JWSHeader(JWSAlgorithm.HS256), + claimsSet + ); + + signedJWT.sign(signer); + + return signedJWT.serialize(); + }); + } + + public static class Builder { + + private final String secret; + + public Builder(String jwtSecret) { + this.secret = jwtSecret; + } + + public JWTWriter build() { + return JWTWriter.of(secret); + } + } +} diff --git a/server/api/src/main/java/vook/server/api/globalcommon/helper/querydsl/QuerydslHelper.java b/server/api/src/main/java/vook/server/api/globalcommon/helper/querydsl/QuerydslHelper.java new file mode 100644 index 00000000..011f4745 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/globalcommon/helper/querydsl/QuerydslHelper.java @@ -0,0 +1,37 @@ +package vook.server.api.globalcommon.helper.querydsl; + +import com.querydsl.core.types.Order; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.EntityPathBase; +import com.querydsl.core.types.dsl.PathBuilder; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class QuerydslHelper { + public static > List> toOrderSpecifiers( + EntityPathBase qClass, + Pageable pageable, + OrderSpecifier... defaultOrderSpecifier + ) { + List> result = new ArrayList<>(); + + if (pageable.getSort().isSorted()) { + PathBuilder pathBuilder = new PathBuilder<>(qClass.getType(), qClass.getMetadata()); + for (Sort.Order o : pageable.getSort()) { + OrderSpecifier order = new OrderSpecifier( + o.isAscending() ? Order.ASC : Order.DESC, + pathBuilder.get(o.getProperty()) + ); + result.add(order); + } + } else { + Collections.addAll(result, defaultOrderSpecifier); + } + + return result; + } +} diff --git a/server/api/src/main/java/vook/server/api/infra/package-info.java b/server/api/src/main/java/vook/server/api/infra/package-info.java new file mode 100644 index 00000000..e78c3469 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/infra/package-info.java @@ -0,0 +1,12 @@ +@ApplicationModule( + type = ApplicationModule.Type.OPEN, + displayName = "Infra", + allowedDependencies = { + "vook.server.api.domain.demo", + "vook.server.api.domain.vocabulary", + "vook.server.api.web.term", + } +) +package vook.server.api.infra; + +import org.springframework.modulith.ApplicationModule; diff --git a/server/api/src/main/java/vook/server/api/infra/search/common/MeilisearchProperties.java b/server/api/src/main/java/vook/server/api/infra/search/common/MeilisearchProperties.java new file mode 100644 index 00000000..557c19d9 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/infra/search/common/MeilisearchProperties.java @@ -0,0 +1,15 @@ +package vook.server.api.infra.search.common; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Setter +@Getter +@Component +@ConfigurationProperties(prefix = "service.meilisearch") +public class MeilisearchProperties { + private String host; + private String apiKey; +} diff --git a/server/api/src/main/java/vook/server/api/infra/search/common/MeilisearchService.java b/server/api/src/main/java/vook/server/api/infra/search/common/MeilisearchService.java new file mode 100644 index 00000000..e3a196f9 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/infra/search/common/MeilisearchService.java @@ -0,0 +1,67 @@ +package vook.server.api.infra.search.common; + +import com.meilisearch.sdk.Client; +import com.meilisearch.sdk.Config; +import com.meilisearch.sdk.Index; +import com.meilisearch.sdk.model.IndexesQuery; +import com.meilisearch.sdk.model.Results; +import com.meilisearch.sdk.model.TaskInfo; +import com.meilisearch.sdk.model.TypoTolerance; + +import java.util.Arrays; + +public abstract class MeilisearchService { + + protected final Client client; + + protected MeilisearchService(MeilisearchProperties properties) { + this.client = new Client(new Config(properties.getHost(), properties.getApiKey())); + } + + protected void clearAllByPrefix(String uidPrefix) { + Results indexes = client.getIndexes(new IndexesQuery() {{ + setLimit(Integer.MAX_VALUE); + }}); + Arrays.stream(indexes.getResults()) + .map(Index::getUid) + .filter(uid -> uid.startsWith(uidPrefix)) + .forEach(client::deleteIndex); + } + + protected void createIndex(String indexUid, String primaryKeyName) { + TaskInfo indexCreateTask = client.createIndex(indexUid, primaryKeyName); + client.waitForTask(indexCreateTask.getTaskUid()); + + // 용어, 동의어, 뜻에 대해서만 검색 + TaskInfo updateSearchableTask = client.index(indexUid).updateSearchableAttributesSettings(new String[]{ + "term", + "synonyms", + "meaning" + }); + client.waitForTask(updateSearchableTask.getTaskUid()); + + // 용어, 동의어, 뜻, 생성일시에 대해 정렬 가능 + TaskInfo updateSortableTask = client.index(indexUid).updateSortableAttributesSettings(new String[]{ + "term", + "synonyms", + "meaning", + "createdAt" + }); + client.waitForTask(updateSortableTask.getTaskUid()); + + client.index(indexUid).updateRankingRulesSettings(new String[]{ + "sort", + "words", + "typo", + "proximity", + "attribute", + "exactness" + }); + + // 오타 용인을 비활성화 하여도 띄어쓰기에 대해서는 검색이 됨으로 비활성화 함 + TypoTolerance typoTolerance = new TypoTolerance(); + typoTolerance.setEnabled(false); + TaskInfo updateTypoTask = client.index(indexUid).updateTypoToleranceSettings(typoTolerance); + client.waitForTask(updateTypoTask.getTaskUid()); + } +} diff --git a/server/api/src/main/java/vook/server/api/infra/search/demo/MeilisearchDemoTermSearchService.java b/server/api/src/main/java/vook/server/api/infra/search/demo/MeilisearchDemoTermSearchService.java new file mode 100644 index 00000000..00f0e631 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/infra/search/demo/MeilisearchDemoTermSearchService.java @@ -0,0 +1,85 @@ +package vook.server.api.infra.search.demo; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.meilisearch.sdk.Index; +import com.meilisearch.sdk.SearchRequest; +import com.meilisearch.sdk.model.Searchable; +import com.meilisearch.sdk.model.TaskInfo; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.stereotype.Service; +import vook.server.api.domain.demo.logic.DemoTermSearchService; +import vook.server.api.domain.demo.logic.dto.DemoTermSearchCommand; +import vook.server.api.domain.demo.logic.dto.DemoTermSearchResult; +import vook.server.api.domain.demo.model.DemoTerm; +import vook.server.api.domain.demo.model.DemoTermSynonym; +import vook.server.api.infra.search.common.MeilisearchProperties; +import vook.server.api.infra.search.common.MeilisearchService; + +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class MeilisearchDemoTermSearchService extends MeilisearchService implements DemoTermSearchService { + + private static final String DEMO_TERMS_INDEX_UID = "demo-terms"; + + private final ObjectMapper objectMapper; + + public MeilisearchDemoTermSearchService(MeilisearchProperties properties, ObjectMapper objectMapper) { + super(properties); + this.objectMapper = objectMapper; + } + + public void clearAll() { + clearAllByPrefix(DEMO_TERMS_INDEX_UID); + } + + public void init() { + createIndex(DEMO_TERMS_INDEX_UID, "id"); + } + + public void addTerms(List terms) { + Index index = client.index(DEMO_TERMS_INDEX_UID); + TaskInfo taskInfo = index.addDocuments(getDocuments(terms)); + client.waitForTask(taskInfo.getTaskUid()); + } + + public DemoTermSearchResult search(DemoTermSearchCommand params) { + SearchRequest searchRequest = params.buildSearchRequest(); + Searchable search = this.client.getIndex(DEMO_TERMS_INDEX_UID).search(searchRequest); + return DemoTermSearchResult.from(search); + } + + private String getDocuments(List terms) { + try { + return objectMapper.writeValueAsString(Document.from(terms)); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + @Getter + @AllArgsConstructor + public static class Document { + private Long id; + private String term; + private String synonyms; + private String meaning; + private String createdAt; + + public static List from(List terms) { + return terms.stream() + .map(w -> new Document( + w.getId(), + w.getTerm(), + w.getSynonyms().stream().map(DemoTermSynonym::getSynonym).collect(Collectors.joining("\n")), + w.getMeaning(), + w.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + )) + .toList(); + } + } +} diff --git a/server/api/src/main/java/vook/server/api/infra/search/vocabulary/MeilisearchVocabularySearchService.java b/server/api/src/main/java/vook/server/api/infra/search/vocabulary/MeilisearchVocabularySearchService.java new file mode 100644 index 00000000..e9e3bfbe --- /dev/null +++ b/server/api/src/main/java/vook/server/api/infra/search/vocabulary/MeilisearchVocabularySearchService.java @@ -0,0 +1,224 @@ +package vook.server.api.infra.search.vocabulary; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.meilisearch.sdk.Index; +import com.meilisearch.sdk.MultiSearchRequest; +import com.meilisearch.sdk.exceptions.MeilisearchApiException; +import com.meilisearch.sdk.model.MultiSearchResult; +import com.meilisearch.sdk.model.Results; +import com.meilisearch.sdk.model.TaskInfo; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import vook.server.api.domain.vocabulary.logic.term.TermLogic; +import vook.server.api.domain.vocabulary.logic.vocabulary.VocabularyLogic; +import vook.server.api.domain.vocabulary.model.term.Term; +import vook.server.api.domain.vocabulary.model.vocabulary.Vocabulary; +import vook.server.api.infra.search.common.MeilisearchProperties; +import vook.server.api.infra.search.common.MeilisearchService; +import vook.server.api.web.term.usecase.SearchTermUseCase; + +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static com.meilisearch.sdk.IndexSearchRequest.IndexSearchRequestBuilder; +import static com.meilisearch.sdk.IndexSearchRequest.builder; + +@Service +public class MeilisearchVocabularySearchService + extends + MeilisearchService + implements + VocabularyLogic.SearchManagementService, + TermLogic.SearchManagementService, + SearchTermUseCase.SearchService { + + private final ObjectMapper objectMapper; + + public MeilisearchVocabularySearchService(MeilisearchProperties properties, ObjectMapper objectMapper) { + super(properties); + this.objectMapper = objectMapper; + } + + public void clearAll() { + clearAllByPrefix(""); + } + + public boolean isIndexExists(String indexUid) { + try { + client.getIndex(indexUid); + return true; + } catch (MeilisearchApiException e) { + if (e.getCode().equals("index_not_found")) { + return false; + } else { + throw e; + } + } + } + + public boolean isDocumentExists(String indexUid, String documentUid) { + try { + client.getIndex(indexUid).getRawDocument(documentUid); + return true; + } catch (MeilisearchApiException e) { + if (e.getCode().equals("document_not_found")) { + return false; + } else { + throw e; + } + } + } + + public Map getDocument(String indexUid, String documentUid) { + return client.getIndex(indexUid).getDocument(documentUid, Map.class); + } + + @Override + public void save(Vocabulary saved) { + createIndex(saved.getUid(), "uid"); + } + + @Override + public void delete(Vocabulary vocabulary) { + TaskInfo taskInfo = client.deleteIndex(vocabulary.getUid()); + client.waitForTask(taskInfo.getTaskUid()); + } + + @Override + public void save(Term term) { + saveOrReplaceTerm(term); + } + + @Override + public void update(Term term) { + saveOrReplaceTerm(term); + } + + @Override + public void delete(Term term) { + Index index = client.index(term.getVocabulary().getUid()); + TaskInfo taskInfo = index.deleteDocument(term.getUid()); + client.waitForTask(taskInfo.getTaskUid()); + } + + private void saveOrReplaceTerm(Term term) { + Index index = client.index(term.getVocabulary().getUid()); + TaskInfo taskInfo = index.addDocuments(getDocument(term)); + client.waitForTask(taskInfo.getTaskUid()); + } + + private String getDocument(Term term) { + return toJsonString(Document.from(term)); + } + + @Override + public void saveAll(List terms) { + if (terms.isEmpty()) { + return; + } + Index index = client.index(terms.getFirst().getVocabulary().getUid()); + TaskInfo taskInfo = index.addDocuments(getDocuments(terms)); + client.waitForTask(taskInfo.getTaskUid()); + } + + @Override + public void deleteAll(List terms) { + if (terms.isEmpty()) { + return; + } + + Map> vocabularyTermMap = terms.stream().collect(Collectors.groupingBy(Term::getVocabulary)); + vocabularyTermMap.forEach((vocabulary, vocabularyTerms) -> { + Index index = client.index(vocabulary.getUid()); + TaskInfo taskInfo = index.deleteDocuments(terms.stream().map(Term::getUid).toList()); + client.waitForTask(taskInfo.getTaskUid()); + }); + } + + private String getDocuments(List terms) { + return toJsonString(Document.from(terms)); + } + + private String toJsonString(Object object) { + try { + return objectMapper.writeValueAsString(object); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + @Override + public Result search(Params params) { + MultiSearchRequest request = new RequestBuilder(params).buildMultiSearchRequest(); + Results results = this.client.multiSearch(request); + return new ResultBuilder(results).build(); + } + + private record RequestBuilder(Params params) { + + private static final String DEFAULT_HIGHLIGHT_PRE_TAG = ""; + private static final String DEFAULT_HIGHLIGHT_POST_TAG = ""; + + public MultiSearchRequest buildMultiSearchRequest() { + IndexSearchRequestBuilder builder = builder(); + if (params.withFormat()) { + builder.attributesToHighlight(new String[]{"*"}); + builder.highlightPreTag(StringUtils.hasText(params.highlightPreTag()) ? params.highlightPreTag() : DEFAULT_HIGHLIGHT_PRE_TAG); + builder.highlightPostTag(StringUtils.hasText(params.highlightPostTag()) ? params.highlightPostTag() : DEFAULT_HIGHLIGHT_POST_TAG); + } + builder.limit(Integer.MAX_VALUE); + + MultiSearchRequest request = new MultiSearchRequest(); + params.vocabularyUids().forEach(uid -> { + params.queries().forEach(query -> { + request.addQuery(builder.indexUid(uid).q(query).build()); + }); + }); + return request; + } + } + + private record ResultBuilder( + Results results + ) { + public Result build() { + return new Result( + Arrays.stream(results.getResults()) + .map(result -> new Result.Record(result.getIndexUid(), result.getQuery(), result.getHits())) + .toList() + ); + } + } + + @Getter + @AllArgsConstructor + public static class Document { + private String uid; + private String term; + private String synonyms; + private String meaning; + private String createdAt; + + public static List from(List terms) { + return terms.stream() + .map(Document::from) + .toList(); + } + + public static Document from(Term term) { + return new Document( + term.getUid(), + term.getTerm(), + String.join(",", term.getSynonyms()), + term.getMeaning(), + term.getCreatedAt().format(DateTimeFormatter.ISO_DATE_TIME) + ); + } + } +} diff --git a/server/api/src/main/java/vook/server/api/infra/term/JpaTermSearchRepository.java b/server/api/src/main/java/vook/server/api/infra/term/JpaTermSearchRepository.java new file mode 100644 index 00000000..b9dcca2b --- /dev/null +++ b/server/api/src/main/java/vook/server/api/infra/term/JpaTermSearchRepository.java @@ -0,0 +1,43 @@ +package vook.server.api.infra.term; + +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.stereotype.Repository; +import vook.server.api.domain.vocabulary.model.term.QTerm; +import vook.server.api.domain.vocabulary.model.term.Term; +import vook.server.api.globalcommon.helper.querydsl.QuerydslHelper; +import vook.server.api.web.term.usecase.RetrieveTermUseCase; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class JpaTermSearchRepository implements RetrieveTermUseCase.TermSearchService { + + private final JPAQueryFactory queryFactory; + + @Override + public Page findAllBy(String vocabularyUid, Pageable pageable) { + QTerm term = QTerm.term1; + + JPAQuery dataQuery = queryFactory + .selectFrom(term) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .where(term.vocabulary.uid.eq(vocabularyUid)); + + QuerydslHelper.toOrderSpecifiers(term, pageable).forEach(dataQuery::orderBy); + List result = dataQuery.fetch(); + + JPAQuery countQuery = queryFactory + .select(term.count()) + .from(term) + .where(term.vocabulary.uid.eq(vocabularyUid)); + + return PageableExecutionUtils.getPage(result, pageable, countQuery::fetchOne); + } +} diff --git a/server/api/src/main/java/vook/server/api/infra/vocabulary/DefaultVocabularyRepository.java b/server/api/src/main/java/vook/server/api/infra/vocabulary/DefaultVocabularyRepository.java new file mode 100644 index 00000000..26a913d3 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/infra/vocabulary/DefaultVocabularyRepository.java @@ -0,0 +1,74 @@ +package vook.server.api.infra.vocabulary; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import vook.server.api.domain.vocabulary.model.vocabulary.UserUid; +import vook.server.api.domain.vocabulary.model.vocabulary.Vocabulary; +import vook.server.api.domain.vocabulary.model.vocabulary.VocabularyRepository; +import vook.server.api.infra.vocabulary.cache.UserVocabularyCache; +import vook.server.api.infra.vocabulary.cache.UserVocabularyCacheRepository; +import vook.server.api.infra.vocabulary.jpa.VocabularyJpaRepository; + +import java.util.List; +import java.util.Optional; + +@Repository +@Transactional +@RequiredArgsConstructor +public class DefaultVocabularyRepository implements VocabularyRepository { + + private final VocabularyJpaRepository jpaRepository; + private final UserVocabularyCacheRepository cacheRepository; + + @Override + public List findAllByUserUid(UserUid userUid) { + return jpaRepository.findAllByUserUid(userUid); + } + + @Override + public List findAllUidsByUserUid(UserUid userUid) { + return cacheRepository.findById(userUid.getValue()) + .orElseGet(() -> { + List vocabularyUids = jpaRepository.findAllByUserUid(userUid) + .stream() + .map(Vocabulary::getUid) + .toList(); + + return cacheRepository.save(new UserVocabularyCache(userUid.getValue(), vocabularyUids)); + }) + .vocabularyUids(); + } + + @Override + public Optional findByUid(String vocabularyUid) { + return jpaRepository.findByUid(vocabularyUid); + } + + @Override + public Vocabulary save(Vocabulary vocabulary) { + Vocabulary saved = jpaRepository.save(vocabulary); + + Optional cacheOptional = cacheRepository.findById(vocabulary.getUserUid().getValue()); + cacheOptional.ifPresentOrElse( + cache -> { + cache.vocabularyUids().add(vocabulary.getUid()); + cacheRepository.save(cache); + }, + () -> cacheRepository.save(new UserVocabularyCache(vocabulary.getUserUid().getValue(), List.of(vocabulary.getUid()))) + ); + + return saved; + } + + @Override + public void delete(Vocabulary vocabulary) { + jpaRepository.delete(vocabulary); + + Optional cache = cacheRepository.findById(vocabulary.getUserUid().getValue()); + cache.ifPresent(c -> { + c.vocabularyUids().remove(vocabulary.getUid()); + cacheRepository.save(c); + }); + } +} diff --git a/server/api/src/main/java/vook/server/api/infra/vocabulary/cache/UserVocabularyCache.java b/server/api/src/main/java/vook/server/api/infra/vocabulary/cache/UserVocabularyCache.java new file mode 100644 index 00000000..51e68881 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/infra/vocabulary/cache/UserVocabularyCache.java @@ -0,0 +1,32 @@ +package vook.server.api.infra.vocabulary.cache; + +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +import java.util.ArrayList; +import java.util.List; + +@RedisHash("user_vocabulary") +public record UserVocabularyCache( + @Id + String userUid, + + List vocabularyUids, + + @TimeToLive + Long ttl +) { + public UserVocabularyCache { + if (vocabularyUids == null) { + vocabularyUids = new ArrayList<>(); + } + } + + public UserVocabularyCache( + String userUid, + List vocabularyUids + ) { + this(userUid, vocabularyUids, 3600L * 24 * 7); + } +} diff --git a/server/api/src/main/java/vook/server/api/infra/vocabulary/cache/UserVocabularyCacheRepository.java b/server/api/src/main/java/vook/server/api/infra/vocabulary/cache/UserVocabularyCacheRepository.java new file mode 100644 index 00000000..291c1a83 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/infra/vocabulary/cache/UserVocabularyCacheRepository.java @@ -0,0 +1,6 @@ +package vook.server.api.infra.vocabulary.cache; + +import org.springframework.data.keyvalue.repository.KeyValueRepository; + +public interface UserVocabularyCacheRepository extends KeyValueRepository { +} diff --git a/server/api/src/main/java/vook/server/api/infra/vocabulary/jpa/VocabularyJpaRepository.java b/server/api/src/main/java/vook/server/api/infra/vocabulary/jpa/VocabularyJpaRepository.java new file mode 100644 index 00000000..5510e1c8 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/infra/vocabulary/jpa/VocabularyJpaRepository.java @@ -0,0 +1,14 @@ +package vook.server.api.infra.vocabulary.jpa; + +import org.springframework.data.jpa.repository.JpaRepository; +import vook.server.api.domain.vocabulary.model.vocabulary.UserUid; +import vook.server.api.domain.vocabulary.model.vocabulary.Vocabulary; + +import java.util.List; +import java.util.Optional; + +public interface VocabularyJpaRepository extends JpaRepository { + List findAllByUserUid(UserUid userUid); + + Optional findByUid(String vocabularyUid); +} diff --git a/server/api/src/main/java/vook/server/api/policy/PolicyException.java b/server/api/src/main/java/vook/server/api/policy/PolicyException.java new file mode 100644 index 00000000..73c96646 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/policy/PolicyException.java @@ -0,0 +1,6 @@ +package vook.server.api.policy; + +import vook.server.api.globalcommon.exception.AppException; + +public class PolicyException extends AppException { +} diff --git a/server/api/src/main/java/vook/server/api/policy/VocabularyPolicy.java b/server/api/src/main/java/vook/server/api/policy/VocabularyPolicy.java new file mode 100644 index 00000000..946825fd --- /dev/null +++ b/server/api/src/main/java/vook/server/api/policy/VocabularyPolicy.java @@ -0,0 +1,28 @@ +package vook.server.api.policy; + +import org.springframework.stereotype.Component; +import vook.server.api.domain.vocabulary.model.vocabulary.UserUid; +import vook.server.api.domain.vocabulary.model.vocabulary.Vocabulary; + +import java.util.List; + +@Component +public class VocabularyPolicy { + + public void validateOwner(String userUid, Vocabulary vocabulary) throws NotValidVocabularyOwnerException { + if (!vocabulary.isValidOwner(new UserUid(userUid))) { + throw new NotValidVocabularyOwnerException(); + } + } + + public void validateOwner(List userVocabularyUids, List targetVocabularyUids) { + targetVocabularyUids.forEach(uid -> { + if (!userVocabularyUids.contains(uid)) { + throw new NotValidVocabularyOwnerException(); + } + }); + } + + public static class NotValidVocabularyOwnerException extends PolicyException { + } +} diff --git a/server/api/src/main/java/vook/server/api/web/auth/AuthApi.java b/server/api/src/main/java/vook/server/api/web/auth/AuthApi.java new file mode 100644 index 00000000..6d6d92b8 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/auth/AuthApi.java @@ -0,0 +1,32 @@ +package vook.server.api.web.auth; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.headers.Header; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.ResponseEntity; +import vook.server.api.web.common.auth.data.AuthValues; + +@Tag(name = "auth", description = "인증 관련 API") +public interface AuthApi { + + @Operation( + summary = "토큰 갱신", + description = """ + 리프레시 토큰을 이용하여 엑세스 토큰과 리프레시 토큰을 갱신합니다. + 리프레시 토큰은 최상위 Description의 Authorzation 항목을 참고하세요.""" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "성공", + headers = { + @Header(name = AuthValues.AUTHORIZATION_HEADER, description = "갱신된 엑세스 토큰"), + @Header(name = AuthValues.REFRESH_AUTHORIZATION_HEADER, description = "갱신된 리프레시 토큰") + } + ), + }) + ResponseEntity refreshToken(String refresh, HttpServletResponse response); +} diff --git a/server/api/src/main/java/vook/server/api/web/auth/AuthRestController.java b/server/api/src/main/java/vook/server/api/web/auth/AuthRestController.java new file mode 100644 index 00000000..91ed87a2 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/auth/AuthRestController.java @@ -0,0 +1,32 @@ +package vook.server.api.web.auth; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import vook.server.api.web.common.auth.app.TokenService; +import vook.server.api.web.common.auth.data.AuthValues; +import vook.server.api.web.common.auth.data.GeneratedToken; + +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class AuthRestController implements AuthApi { + + private final TokenService tokenService; + + @Override + @GetMapping("/refresh") + public ResponseEntity refreshToken( + @RequestHeader(AuthValues.REFRESH_AUTHORIZATION_HEADER) String refresh, + HttpServletResponse response + ) { + GeneratedToken token = tokenService.refreshToken(refresh); + response.setHeader(AuthValues.AUTHORIZATION_HEADER, token.getAccessToken()); + response.setHeader(AuthValues.REFRESH_AUTHORIZATION_HEADER, token.getRefreshToken()); + return ResponseEntity.ok().build(); + } +} diff --git a/server/api/src/main/java/vook/server/api/web/auth/package-info.java b/server/api/src/main/java/vook/server/api/web/auth/package-info.java new file mode 100644 index 00000000..98ebc82b --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/auth/package-info.java @@ -0,0 +1,10 @@ +@ApplicationModule( + type = ApplicationModule.Type.OPEN, + displayName = "Auth Web", + allowedDependencies = { + "vook.server.api.web.common" + } +) +package vook.server.api.web.auth; + +import org.springframework.modulith.ApplicationModule; diff --git a/server/api/src/main/java/vook/server/api/web/common/auth/app/JWTHelperProvider.java b/server/api/src/main/java/vook/server/api/web/common/auth/app/JWTHelperProvider.java new file mode 100644 index 00000000..7cd4d03f --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/common/auth/app/JWTHelperProvider.java @@ -0,0 +1,31 @@ +package vook.server.api.web.common.auth.app; + +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import vook.server.api.globalcommon.helper.jwt.JWTReader; +import vook.server.api.globalcommon.helper.jwt.JWTWriter; + +@Component +public class JWTHelperProvider { + + @Value("${service.jwt.secret}") + private String jwtSecret; + + private JWTWriter.Builder jwtWriterBuilder; + private JWTReader.Builder jwtReaderBuilder; + + @PostConstruct + public void init() { + jwtWriterBuilder = new JWTWriter.Builder(jwtSecret); + jwtReaderBuilder = new JWTReader.Builder(jwtSecret); + } + + public JWTWriter writer() { + return jwtWriterBuilder.build(); + } + + public JWTReader reader(String token) { + return jwtReaderBuilder.build(token); + } +} diff --git a/server/api/src/main/java/vook/server/api/web/common/auth/app/TokenService.java b/server/api/src/main/java/vook/server/api/web/common/auth/app/TokenService.java new file mode 100644 index 00000000..bd424982 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/common/auth/app/TokenService.java @@ -0,0 +1,66 @@ +package vook.server.api.web.common.auth.app; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import vook.server.api.globalcommon.helper.jwt.JWTReader; +import vook.server.api.web.common.auth.data.GeneratedToken; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TokenService { + + @Value("${service.oauth2.accessTokenExpiredMinute}") + private Integer accessTokenExpiredMinute; + + @Value("${service.oauth2.refreshTokenExpiredMinute}") + private Integer refreshTokenExpiredMinute; + + private final JWTHelperProvider jwtHelperProvider; + + public GeneratedToken generateToken(String uid) { + String access = buildAccessToken(uid); + String refresh = buildRefreshToken(uid); + return GeneratedToken.of(access, refresh); + } + + public String validateAndGetUid(String token) { + JWTReader reader = jwtHelperProvider.reader(token); + reader.validate(); + return reader.getClaim("userUid"); + } + + public GeneratedToken refreshToken(String refreshToken) { + JWTReader jwtReader = jwtHelperProvider.reader(refreshToken); + jwtReader.validate(); + + if (!"refresh".equals(jwtReader.getClaim("category"))) { + throw new IllegalArgumentException("유효하지 않은 리프레시 토큰입니다."); + } + + String uid = jwtReader.getClaim("userUid"); + + String access = buildAccessToken(uid); + String refresh = buildRefreshToken(uid); + + return GeneratedToken.of(access, refresh); + } + + private String buildAccessToken(String uid) { + return jwtHelperProvider.writer() + .withExpiredMs(1000L * 60 * accessTokenExpiredMinute) + .withClaim("category", "access") + .withClaim("userUid", uid) + .jwtString(); + } + + private String buildRefreshToken(String uid) { + return jwtHelperProvider.writer() + .withExpiredMs(1000L * 60 * refreshTokenExpiredMinute) + .withClaim("category", "refresh") + .withClaim("userUid", uid) + .jwtString(); + } +} diff --git a/server/api/src/main/java/vook/server/api/web/common/auth/data/AuthValues.java b/server/api/src/main/java/vook/server/api/web/common/auth/data/AuthValues.java new file mode 100644 index 00000000..1228232b --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/common/auth/data/AuthValues.java @@ -0,0 +1,6 @@ +package vook.server.api.web.common.auth.data; + +public class AuthValues { + public static final String AUTHORIZATION_HEADER = "Authorization"; + public static final String REFRESH_AUTHORIZATION_HEADER = "X-Refresh-Authorization"; +} diff --git a/server/api/src/main/java/vook/server/api/web/common/auth/data/GeneratedToken.java b/server/api/src/main/java/vook/server/api/web/common/auth/data/GeneratedToken.java new file mode 100644 index 00000000..28c4fe6a --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/common/auth/data/GeneratedToken.java @@ -0,0 +1,17 @@ +package vook.server.api.web.common.auth.data; + +import lombok.Getter; + +@Getter +public class GeneratedToken { + + private String accessToken; + private String refreshToken; + + public static GeneratedToken of(String accessToken, String refreshToken) { + GeneratedToken generatedToken = new GeneratedToken(); + generatedToken.accessToken = accessToken; + generatedToken.refreshToken = refreshToken; + return generatedToken; + } +} diff --git a/server/api/src/main/java/vook/server/api/web/common/auth/data/VookLoginUser.java b/server/api/src/main/java/vook/server/api/web/common/auth/data/VookLoginUser.java new file mode 100644 index 00000000..1a778b86 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/common/auth/data/VookLoginUser.java @@ -0,0 +1,46 @@ +package vook.server.api.web.common.auth.data; + +import lombok.Getter; +import lombok.ToString; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; +import vook.server.api.domain.user.model.social_user.SocialUser; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +@Getter +@ToString +public class VookLoginUser implements OAuth2User { + + private String uid; + + public static VookLoginUser of( + String uid + ) { + VookLoginUser user = new VookLoginUser(); + user.uid = uid; + return user; + } + + public static VookLoginUser from(SocialUser user) { + return VookLoginUser.of(user.getUser().getUid()); + } + + @Override + public Map getAttributes() { + return Map.of(); + } + + @Override + public Collection getAuthorities() { + return List.of(); + } + + @Override + public String getName() { + return this.uid; + } + +} diff --git a/server/api/src/main/java/vook/server/api/web/common/auth/jwt/JWTFilter.java b/server/api/src/main/java/vook/server/api/web/common/auth/jwt/JWTFilter.java new file mode 100644 index 00000000..9a7cc297 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/common/auth/jwt/JWTFilter.java @@ -0,0 +1,65 @@ +package vook.server.api.web.common.auth.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; +import vook.server.api.web.common.auth.app.TokenService; +import vook.server.api.web.common.auth.data.AuthValues; +import vook.server.api.web.common.auth.data.VookLoginUser; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +public class JWTFilter extends OncePerRequestFilter { + + private final TokenService tokenService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + String token = getAccessToken(request); + if (token == null) { + filterChain.doFilter(request, response); + return; + } + + OAuth2User oAuth2User; + try { + oAuth2User = VookLoginUser.of(tokenService.validateAndGetUid(token)); + } catch (Exception e) { + log.error("JWT validation failed", e); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + //스프링 시큐리티 인증 토큰 생성 + UsernamePasswordAuthenticationToken authToken = UsernamePasswordAuthenticationToken.authenticated(oAuth2User, null, oAuth2User.getAuthorities()); + + //세션에 사용자 등록 + SecurityContextHolder.getContext().setAuthentication(authToken); + + filterChain.doFilter(request, response); + } + + private String getAccessToken(HttpServletRequest request) { + String AuthorizationHeaderValue = request.getHeader(AuthValues.AUTHORIZATION_HEADER); + if (AuthorizationHeaderValue == null) { + return null; + } + + String token = AuthorizationHeaderValue.replaceFirst("Bearer", "").trim(); + if (!StringUtils.hasText(token)) { + return null; + } + + return token; + } +} diff --git a/server/api/src/main/java/vook/server/api/web/common/auth/oauth2/LoginSuccessHandler.java b/server/api/src/main/java/vook/server/api/web/common/auth/oauth2/LoginSuccessHandler.java new file mode 100644 index 00000000..168199a3 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/common/auth/oauth2/LoginSuccessHandler.java @@ -0,0 +1,42 @@ +package vook.server.api.web.common.auth.oauth2; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; +import vook.server.api.web.common.auth.app.TokenService; +import vook.server.api.web.common.auth.data.GeneratedToken; +import vook.server.api.web.common.auth.data.VookLoginUser; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class LoginSuccessHandler implements AuthenticationSuccessHandler { + + @Value("${service.oauth2.tokenNoticeUrl}") + private String tokenNoticeUrl; + + private final TokenService tokenService; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { + VookLoginUser oAuth2User = (VookLoginUser) authentication.getPrincipal(); + GeneratedToken token = tokenService.generateToken(oAuth2User.getUid()); + + //TODO: query parameter로 token을 전달하는 방식은 보안에 취약, 추후 code를 이용해 토큰을 교환하는 방식으로 변경 필요 + response.sendRedirect(buildRedirectUrl(token)); + } + + private String buildRedirectUrl(GeneratedToken token) { + return UriComponentsBuilder.fromUriString(tokenNoticeUrl) + .queryParam("access", token.getAccessToken()) + .queryParam("refresh", token.getRefreshToken()) + .build() + .toUriString(); + } +} diff --git a/server/api/src/main/java/vook/server/api/web/common/auth/oauth2/OAuth2GoogleResponse.java b/server/api/src/main/java/vook/server/api/web/common/auth/oauth2/OAuth2GoogleResponse.java new file mode 100644 index 00000000..abeb21f4 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/common/auth/oauth2/OAuth2GoogleResponse.java @@ -0,0 +1,27 @@ +package vook.server.api.web.common.auth.oauth2; + +import java.util.Map; + +public class OAuth2GoogleResponse implements OAuth2Response { + + private final Map attribute; + + public OAuth2GoogleResponse(Map attribute) { + this.attribute = attribute; + } + + @Override + public String getProvider() { + return "google"; + } + + @Override + public String getProviderId() { + return attribute.get("sub").toString(); + } + + @Override + public String getEmail() { + return attribute.get("email").toString(); + } +} diff --git a/server/api/src/main/java/vook/server/api/web/common/auth/oauth2/OAuth2Response.java b/server/api/src/main/java/vook/server/api/web/common/auth/oauth2/OAuth2Response.java new file mode 100644 index 00000000..27c35dd8 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/common/auth/oauth2/OAuth2Response.java @@ -0,0 +1,13 @@ +package vook.server.api.web.common.auth.oauth2; + +public interface OAuth2Response { + + //제공자 (Ex. naver, google, ...) + String getProvider(); + + //제공자에서 발급해주는 아이디(번호) + String getProviderId(); + + //이메일 + String getEmail(); +} diff --git a/server/api/src/main/java/vook/server/api/web/common/auth/oauth2/VookOAuth2UserService.java b/server/api/src/main/java/vook/server/api/web/common/auth/oauth2/VookOAuth2UserService.java new file mode 100644 index 00000000..dae1543f --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/common/auth/oauth2/VookOAuth2UserService.java @@ -0,0 +1,57 @@ +package vook.server.api.web.common.auth.oauth2; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vook.server.api.domain.user.logic.UserLogic; +import vook.server.api.domain.user.logic.UserSignUpFromSocialCommand; +import vook.server.api.domain.user.model.social_user.SocialUser; +import vook.server.api.web.common.auth.data.VookLoginUser; + +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class VookOAuth2UserService extends DefaultOAuth2UserService { + + private final UserLogic userLogic; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = super.loadUser(userRequest); + log.info("OAuth2User: {}", oAuth2User); + + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + OAuth2Response oAuth2Response = toOAuth2Response(registrationId, oAuth2User); + if (oAuth2Response == null) { + log.info("Unsupported registrationId: {}", registrationId); + return null; + } + + return userLogic.findByProvider(oAuth2Response.getProvider(), oAuth2Response.getProviderId()) + .map(VookLoginUser::from) + .orElseGet(() -> signUpUser(oAuth2Response)); + } + + private static OAuth2Response toOAuth2Response(String registrationId, OAuth2User oAuth2User) { + return switch (registrationId) { + case "google" -> new OAuth2GoogleResponse(oAuth2User.getAttributes()); + default -> null; + }; + } + + private VookLoginUser signUpUser(OAuth2Response oAuth2Response) { + UserSignUpFromSocialCommand command = UserSignUpFromSocialCommand.builder() + .provider(oAuth2Response.getProvider()) + .providerUserId(oAuth2Response.getProviderId()) + .email(oAuth2Response.getEmail()) + .build(); + SocialUser saved = userLogic.signUpFromSocial(command); + return VookLoginUser.from(saved); + } +} diff --git a/server/api/src/main/java/vook/server/api/web/common/package-info.java b/server/api/src/main/java/vook/server/api/web/common/package-info.java new file mode 100644 index 00000000..60d328fc --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/common/package-info.java @@ -0,0 +1,10 @@ +@ApplicationModule( + type = ApplicationModule.Type.OPEN, + displayName = "Web Common", + allowedDependencies = { + "vook.server.api.domain.user", + } +) +package vook.server.api.web.common; + +import org.springframework.modulith.ApplicationModule; diff --git a/server/api/src/main/java/vook/server/api/web/common/response/ApiResponseCode.java b/server/api/src/main/java/vook/server/api/web/common/response/ApiResponseCode.java new file mode 100644 index 00000000..0088ce61 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/common/response/ApiResponseCode.java @@ -0,0 +1,38 @@ +package vook.server.api.web.common.response; + +public interface ApiResponseCode { + + String code(); + + enum Ok implements ApiResponseCode { + + SUCCESS; + + @Override + public String code() { + return this.name(); + } + } + + enum BadRequest implements ApiResponseCode { + + INVALID_PARAMETER, + VIOLATION_BUSINESS_RULE, + ; + + @Override + public String code() { + return this.name(); + } + } + + enum ServerError implements ApiResponseCode { + + UNHANDLED_ERROR; + + @Override + public String code() { + return this.name(); + } + } +} diff --git a/server/api/src/main/java/vook/server/api/web/common/response/CommonApiException.java b/server/api/src/main/java/vook/server/api/web/common/response/CommonApiException.java new file mode 100644 index 00000000..a3acd1d2 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/common/response/CommonApiException.java @@ -0,0 +1,46 @@ +package vook.server.api.web.common.response; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class CommonApiException extends RuntimeException { + + private final ApiResponseCode code; + private final int statusCode; + private final String message; + + CommonApiException(ApiResponseCode code, int statusCode, Throwable cause) { + this(code, statusCode, cause, null); + } + + CommonApiException(ApiResponseCode code, int statusCode, Throwable cause, String message) { + super(code.code(), cause); + this.code = code; + this.statusCode = statusCode; + this.message = message; + } + + public CommonApiResponse response() { + if (message == null) { + return CommonApiResponse.noResult(code); + } else { + return CommonApiResponse.withResult(code, message); + } + } + + public int statusCode() { + return statusCode; + } + + public static CommonApiException badRequest(ApiResponseCode code, Throwable cause) { + return new CommonApiException(code, 400, cause); + } + + public static CommonApiException badRequest(ApiResponseCode code, Throwable cause, String message) { + return new CommonApiException(code, 400, cause, message); + } + + public static CommonApiException serverError(ApiResponseCode code, Throwable cause) { + return new CommonApiException(code, 500, cause); + } +} diff --git a/server/api/src/main/java/vook/server/api/web/common/response/CommonApiResponse.java b/server/api/src/main/java/vook/server/api/web/common/response/CommonApiResponse.java new file mode 100644 index 00000000..b3d7dfbf --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/common/response/CommonApiResponse.java @@ -0,0 +1,37 @@ +package vook.server.api.web.common.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Getter +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CommonApiResponse { + + @Schema(description = "결과 코드", requiredMode = Schema.RequiredMode.REQUIRED, example = "SUCCESS") + private String code; + private T result; + + public static CommonApiResponse ok() { + return noResult(ApiResponseCode.Ok.SUCCESS); + } + + public static CommonApiResponse okWithResult(T result) { + CommonApiResponse response = ok(); + response.result = result; + return response; + } + + public static CommonApiResponse noResult(ApiResponseCode code) { + CommonApiResponse response = new CommonApiResponse<>(); + response.code = code.code(); + return response; + } + + public static CommonApiResponse withResult(ApiResponseCode code, T result) { + CommonApiResponse response = new CommonApiResponse<>(); + response.code = code.code(); + response.result = result; + return response; + } +} diff --git a/server/api/src/main/java/vook/server/api/web/common/response/GlobalRestControllerAdvice.java b/server/api/src/main/java/vook/server/api/web/common/response/GlobalRestControllerAdvice.java new file mode 100644 index 00000000..5da8cd2c --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/common/response/GlobalRestControllerAdvice.java @@ -0,0 +1,49 @@ +package vook.server.api.web.common.response; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageConversionException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import vook.server.api.globalcommon.exception.AppException; + +@Slf4j +@RestControllerAdvice +public class GlobalRestControllerAdvice { + + @ExceptionHandler(AppException.class) + public ResponseEntity handleAppException(AppException e) { + log.error(e.getMessage(), e); + String contents = e.getClass().getSimpleName().replace("Exception", ""); + CommonApiException badRequest = CommonApiException.badRequest(ApiResponseCode.BadRequest.VIOLATION_BUSINESS_RULE, e, contents); + return ResponseEntity.status(badRequest.statusCode()).body(badRequest.response()); + } + + @ExceptionHandler(CommonApiException.class) + public ResponseEntity handleCommonApiException(CommonApiException e) { + log.error(e.getMessage(), e); + return ResponseEntity.status(e.statusCode()).body(e.response()); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + log.debug(e.getMessage(), e); + CommonApiException badRequest = CommonApiException.badRequest(ApiResponseCode.BadRequest.INVALID_PARAMETER, e); + return ResponseEntity.status(badRequest.statusCode()).body(badRequest.response()); + } + + @ExceptionHandler(HttpMessageConversionException.class) + public ResponseEntity handleHttpMessageConversionException(HttpMessageConversionException e) { + log.debug(e.getMessage(), e); + CommonApiException badRequest = CommonApiException.badRequest(ApiResponseCode.BadRequest.INVALID_PARAMETER, e); + return ResponseEntity.status(badRequest.statusCode()).body(badRequest.response()); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception e) { + log.error(e.getMessage(), e); + CommonApiException serverError = CommonApiException.serverError(ApiResponseCode.ServerError.UNHANDLED_ERROR, e); + return ResponseEntity.status(serverError.statusCode()).body(serverError.response()); + } +} diff --git a/server/api/src/main/java/vook/server/api/web/common/swagger/ComponentRefConsts.java b/server/api/src/main/java/vook/server/api/web/common/swagger/ComponentRefConsts.java new file mode 100644 index 00000000..ed986697 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/common/swagger/ComponentRefConsts.java @@ -0,0 +1,14 @@ +package vook.server.api.web.common.swagger; + +/** + * OpenAPI ComponentRef 상수 + */ +public class ComponentRefConsts { + + public static class Example { + public static final String SUCCESS = "#/components/examples/Success"; + public static final String INVALID_PARAMETER = "#/components/examples/InvalidParameter"; + public static final String UNHANDLED_ERROR = "#/components/examples/UnhandledError"; + public static final String VIOLATION_BUSINESS_RULE = "#/components/examples/ViolationBusinessRule"; + } +} diff --git a/server/api/src/main/java/vook/server/api/web/common/swagger/GlobalOpenApiCustomizerImpl.java b/server/api/src/main/java/vook/server/api/web/common/swagger/GlobalOpenApiCustomizerImpl.java new file mode 100644 index 00000000..ac068f22 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/common/swagger/GlobalOpenApiCustomizerImpl.java @@ -0,0 +1,50 @@ +package vook.server.api.web.common.swagger; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.examples.Example; +import org.springdoc.core.customizers.GlobalOpenApiCustomizer; +import vook.server.api.web.common.response.ApiResponseCode; + +public class GlobalOpenApiCustomizerImpl implements GlobalOpenApiCustomizer { + @Override + public void customise(OpenAPI openApi) { + applyCommonApiResponseSchema(openApi); + } + + private static void applyCommonApiResponseSchema(OpenAPI openApi) { + openApi.getComponents() + .addExamples(getKey(ComponentRefConsts.Example.SUCCESS), new Example() + .description("성공") + .value(String.format(""" + { + "code": "%s" + }""", ApiResponseCode.Ok.SUCCESS.code())) + ) + .addExamples(getKey(ComponentRefConsts.Example.INVALID_PARAMETER), new Example() + .description("유효하지 않은 파라미터") + .value(String.format(""" + { + "code": "%s" + }""", ApiResponseCode.BadRequest.INVALID_PARAMETER.code())) + ) + .addExamples(getKey(ComponentRefConsts.Example.VIOLATION_BUSINESS_RULE), new Example() + .description("비즈니스 규칙 위반") + .value(String.format(""" + { + "code": "%s", + "result": "규칙 위반 내용" + }""", ApiResponseCode.BadRequest.VIOLATION_BUSINESS_RULE.code())) + ) + .addExamples(getKey(ComponentRefConsts.Example.UNHANDLED_ERROR), new Example() + .description("처리되지 않은 서버 에러") + .value(String.format(""" + { + "code": "%s" + }""", ApiResponseCode.ServerError.UNHANDLED_ERROR.code())) + ); + } + + private static String getKey(String refString) { + return refString.substring(refString.lastIndexOf('/') + 1); + } +} diff --git a/server/api/src/main/java/vook/server/api/web/common/swagger/GlobalOperationCustomizerImpl.java b/server/api/src/main/java/vook/server/api/web/common/swagger/GlobalOperationCustomizerImpl.java new file mode 100644 index 00000000..135f3e23 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/common/swagger/GlobalOperationCustomizerImpl.java @@ -0,0 +1,140 @@ +package vook.server.api.web.common.swagger; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.examples.Example; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.customizers.GlobalOperationCustomizer; +import org.springdoc.core.utils.SpringDocAnnotationsUtils; +import org.springframework.web.method.HandlerMethod; +import vook.server.api.web.common.response.CommonApiResponse; +import vook.server.api.web.common.swagger.annotation.IncludeBadRequestResponse; +import vook.server.api.web.common.swagger.annotation.IncludeOkResponse; + +import java.util.HashMap; +import java.util.List; +import java.util.function.Supplier; + +@RequiredArgsConstructor +public class GlobalOperationCustomizerImpl implements GlobalOperationCustomizer { + + private final Supplier openAPISupplier; + + @Override + public Operation customize(Operation operation, HandlerMethod handlerMethod) { + applyOkApiResponse(operation, handlerMethod); //200 + applyBadRequestApiResponse(operation, handlerMethod); //400 + applyUnauthorizedApiResponse(operation); //401 + applyInternalServerErrorApiResponse(operation); //500 + return operation; + } + + private void applyOkApiResponse(Operation operation, HandlerMethod handlerMethod) { + IncludeOkResponse methodAnnotation = handlerMethod.getMethodAnnotation(IncludeOkResponse.class); + if (methodAnnotation == null) { + return; + } + + ApiResponse apiResponse = operation.getResponses().computeIfAbsent( + "200", + k -> new ApiResponse().description("OK") + ); + + if (apiResponse.getContent() == null) { + apiResponse.setContent(new Content()); + } else { + // SpringDoc이 기본으로 제공하는 Content 제거 + apiResponse.getContent().remove("*/*"); + } + + Class type = methodAnnotation.implementation(); + if (type == null || type == Void.class) { + // Content가 존재하지 않으면 기본 성공 응답 추가 + apiResponse.getContent().computeIfAbsent("application/json", k -> new MediaType() + .schema(SpringDocAnnotationsUtils.resolveSchemaFromType(CommonApiResponse.class, openAPISupplier.get().getComponents(), null)) + .addExamples("성공", new Example().$ref(ComponentRefConsts.Example.SUCCESS))); + } else { + Schema schema = SpringDocAnnotationsUtils.resolveSchemaFromType(type, openAPISupplier.get().getComponents(), null); + apiResponse.getContent().computeIfAbsent("application/json", k -> new MediaType().schema(schema)); + } + } + + private void applyBadRequestApiResponse(Operation operation, HandlerMethod handlerMethod) { + IncludeBadRequestResponse methodAnnotation = handlerMethod.getMethodAnnotation(IncludeBadRequestResponse.class); + if (methodAnnotation == null) { + return; + } + + ApiResponse apiResponse = operation.getResponses().computeIfAbsent( + "400", + k -> new ApiResponse().description("Bad Request") + ); + + if (apiResponse.getContent() == null) { + apiResponse.setContent(new Content()); + } + + MediaType jsonType = apiResponse.getContent().computeIfAbsent("application/json", k -> new MediaType()); + + if (jsonType.getSchema() == null) { + jsonType.setSchema(getCommonApiResponse()); + } + + if (jsonType.getExamples() == null) { + jsonType.setExamples(new HashMap<>()); + } + + for (IncludeBadRequestResponse.Kind kind : methodAnnotation.value()) { + kind.applyExample(jsonType); + } + } + + private void applyUnauthorizedApiResponse(Operation operation) { + List security = operation.getSecurity(); + if (security == null) { + return; + } + + security.forEach(sr -> { + if (sr.containsKey("AccessToken")) { + operation.getResponses().computeIfAbsent( + "401", + k -> new ApiResponse().description("Unauthorized") + ); + } + }); + } + + private void applyInternalServerErrorApiResponse(Operation operation) { + ApiResponse apiResponse = operation.getResponses().computeIfAbsent( + "500", + k -> new ApiResponse().description("Internal Server Error") + ); + + if (apiResponse.getContent() == null) { + apiResponse.setContent(new Content()); + } + + MediaType jsonType = apiResponse.getContent().computeIfAbsent("application/json", k -> new MediaType()); + + if (jsonType.getSchema() == null) { + jsonType.setSchema(getCommonApiResponse()); + } + + if (jsonType.getExamples() == null) { + jsonType.setExamples(new HashMap<>()); + } + + jsonType.getExamples().put("처리되지 않은 서버 에러", new Example().$ref(ComponentRefConsts.Example.UNHANDLED_ERROR)); + } + + private Schema getCommonApiResponse() { + return SpringDocAnnotationsUtils.resolveSchemaFromType(CommonApiResponse.class, openAPISupplier.get().getComponents(), null); + } + +} diff --git a/server/api/src/main/java/vook/server/api/web/common/swagger/OpenApiDefinition.java b/server/api/src/main/java/vook/server/api/web/common/swagger/OpenApiDefinition.java new file mode 100644 index 00000000..d1b66db1 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/common/swagger/OpenApiDefinition.java @@ -0,0 +1,43 @@ +package vook.server.api.web.common.swagger; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import io.swagger.v3.oas.annotations.security.SecuritySchemes; +import io.swagger.v3.oas.annotations.servers.Server; +import org.springframework.context.annotation.Configuration; +import vook.server.api.web.common.auth.data.AuthValues; + +@OpenAPIDefinition( + info = @Info( + title = "Vook Server API", + version = "0.1", + description = """ + Vook 서버 API 문서입니다. + + ## Authorization (토큰 획득 방법) + + - [구글 로그인](/oauth2/authorization/google)을 통해 로그인 한 후, + redirect URL에 포함된 accessToken을 사용합니다. refreshToken은 토큰 갱신 때 사용합니다. + + ## URL 정보 + + - 구글 로그인: ${service.baseUrl}/oauth2/authorization/google + - 로그인 성공 콜백: ${service.oauth2.tokenNoticeUrl} + - 로그인 실패 (혹은 취소): ${service.oauth2.loginFailUrl}"""), + servers = {@Server(url = "${service.baseUrl}")} +) +@SecuritySchemes({ + @SecurityScheme( + name = "AccessToken", + description = "JWT 인증 토큰", + type = SecuritySchemeType.APIKEY, + in = SecuritySchemeIn.HEADER, + paramName = AuthValues.AUTHORIZATION_HEADER + ) +}) +@Configuration +public class OpenApiDefinition { +} diff --git a/server/api/src/main/java/vook/server/api/web/common/swagger/annotation/IncludeBadRequestResponse.java b/server/api/src/main/java/vook/server/api/web/common/swagger/annotation/IncludeBadRequestResponse.java new file mode 100644 index 00000000..0a05b637 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/common/swagger/annotation/IncludeBadRequestResponse.java @@ -0,0 +1,50 @@ +package vook.server.api.web.common.swagger.annotation; + +import io.swagger.v3.oas.models.examples.Example; +import io.swagger.v3.oas.models.media.MediaType; +import lombok.RequiredArgsConstructor; +import vook.server.api.web.common.swagger.ComponentRefConsts; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface IncludeBadRequestResponse { + + /** + * @return {@link Kind} 응답 내용 종류 + */ + Kind[] value() default {}; + + /** + * {@link IncludeBadRequestResponse} 응답 내용 + */ + @RequiredArgsConstructor + enum Kind { + + INVALID_PARAMETER(new ExampleInfo( + "유효하지 않은 파라미터", + new Example().$ref(ComponentRefConsts.Example.INVALID_PARAMETER) + )), + + VIOLATION_BUSINESS_RULE(new ExampleInfo( + "비즈니스 규칙 위반", + new Example().$ref(ComponentRefConsts.Example.VIOLATION_BUSINESS_RULE) + )); + + private final ExampleInfo exampleInfo; + + public void applyExample(MediaType mediaType) { + mediaType.getExamples().put(exampleInfo.name, exampleInfo.example); + } + } + + record ExampleInfo( + String name, + Example example + ) { + } +} diff --git a/server/api/src/main/java/vook/server/api/web/common/swagger/annotation/IncludeOkResponse.java b/server/api/src/main/java/vook/server/api/web/common/swagger/annotation/IncludeOkResponse.java new file mode 100644 index 00000000..0a345d12 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/common/swagger/annotation/IncludeOkResponse.java @@ -0,0 +1,16 @@ +package vook.server.api.web.common.swagger.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface IncludeOkResponse { + + /** + * @return {@link Class} 응답 Schema 클래스 + */ + Class implementation() default Void.class; +} diff --git a/server/api/src/main/java/vook/server/api/web/demo/DemoApi.java b/server/api/src/main/java/vook/server/api/web/demo/DemoApi.java new file mode 100644 index 00000000..b3c15de0 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/demo/DemoApi.java @@ -0,0 +1,19 @@ +package vook.server.api.web.demo; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import vook.server.api.web.common.response.CommonApiResponse; +import vook.server.api.web.common.swagger.annotation.IncludeOkResponse; +import vook.server.api.web.demo.reqres.SearchTermRequest; +import vook.server.api.web.demo.reqres.SearchTermResponse; + +@Tag(name = "demo", description = "VooK 데모용 API") +public interface DemoApi { + + @Operation(summary = "용어 검색") + @IncludeOkResponse(implementation = SearchApiTermResponse.class) + CommonApiResponse searchTerm(SearchTermRequest request); + + class SearchApiTermResponse extends CommonApiResponse { + } +} diff --git a/server/api/src/main/java/vook/server/api/web/demo/DemoRestController.java b/server/api/src/main/java/vook/server/api/web/demo/DemoRestController.java new file mode 100644 index 00000000..79a7716e --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/demo/DemoRestController.java @@ -0,0 +1,30 @@ +package vook.server.api.web.demo; + +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import vook.server.api.domain.demo.logic.DemoLogic; +import vook.server.api.domain.demo.logic.dto.DemoTermSearchResult; +import vook.server.api.web.common.response.CommonApiResponse; +import vook.server.api.web.demo.reqres.SearchTermRequest; +import vook.server.api.web.demo.reqres.SearchTermResponse; + +@RestController +@RequestMapping("/demo") +@RequiredArgsConstructor +public class DemoRestController implements DemoApi { + + private final DemoLogic service; + + @Override + @PostMapping("/terms/search") + public CommonApiResponse searchTerm( + @RequestBody SearchTermRequest request + ) { + DemoTermSearchResult searchResult = service.searchTerm(request.toSearchParam()); + SearchTermResponse result = SearchTermResponse.from(searchResult); + return CommonApiResponse.okWithResult(result); + } +} diff --git a/server/api/src/main/java/vook/server/api/web/demo/package-info.java b/server/api/src/main/java/vook/server/api/web/demo/package-info.java new file mode 100644 index 00000000..76df280d --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/demo/package-info.java @@ -0,0 +1,11 @@ +@ApplicationModule( + type = ApplicationModule.Type.OPEN, + displayName = "Demo Web", + allowedDependencies = { + "vook.server.api.web.common", + "vook.server.api.domain.demo", + } +) +package vook.server.api.web.demo; + +import org.springframework.modulith.ApplicationModule; diff --git a/server/api/src/main/java/vook/server/api/web/demo/reqres/SearchTermRequest.java b/server/api/src/main/java/vook/server/api/web/demo/reqres/SearchTermRequest.java new file mode 100644 index 00000000..440f2b99 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/demo/reqres/SearchTermRequest.java @@ -0,0 +1,43 @@ +package vook.server.api.web.demo.reqres; + +import io.swagger.v3.oas.annotations.media.Schema; +import vook.server.api.domain.demo.logic.dto.DemoTermSearchCommand; + +import java.util.List; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +public record SearchTermRequest( + @Schema(description = "검색 쿼리, 빈 문자열을 보낼경우 모든 데이터가 반환된다.", requiredMode = REQUIRED, example = "하이브리드앱") + String query, + + @Schema(description = "포맷 적용 여부", defaultValue = "false") + boolean withFormat, + + @Schema(description = "하이라이트 시작 태그, 포맷 적용 여부가 true일 때만 적용 됨", defaultValue = "") + String highlightPreTag, + + @Schema(description = "하이라이트 종료 태그, 포맷 적용 여부가 true일 때만 적용 됨", defaultValue = "") + String highlightPostTag, + + @Schema( + description = "정렬 정보, null이면 관련도 기준으로 정렬 됨", + allowableValues = { + "term:asc", "term:desc", + "synonyms:asc", "synonyms:desc", + "meaning:asc", "meaning:desc", + "createdAt:asc", "createdAt:desc" + } + ) + List sort +) { + public DemoTermSearchCommand toSearchParam() { + return DemoTermSearchCommand.builder() + .query(query) + .withFormat(withFormat) + .highlightPreTag(highlightPreTag) + .highlightPostTag(highlightPostTag) + .sort(sort) + .build(); + } +} diff --git a/server/api/src/main/java/vook/server/api/web/demo/reqres/SearchTermResponse.java b/server/api/src/main/java/vook/server/api/web/demo/reqres/SearchTermResponse.java new file mode 100644 index 00000000..2bad5d79 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/demo/reqres/SearchTermResponse.java @@ -0,0 +1,42 @@ +package vook.server.api.web.demo.reqres; + +import lombok.Builder; +import vook.server.api.domain.demo.logic.dto.DemoTermSearchResult; + +import java.util.List; +import java.util.Map; + +@Builder +public record SearchTermResponse( + String query, + List hits +) { + public static SearchTermResponse from(DemoTermSearchResult searchResult) { + return SearchTermResponse.builder() + .query(searchResult.query()) + .hits(searchResult.hits().stream().map(document -> { + Object formatted = document.get("_formatted"); + if (formatted instanceof Map formattedDocument) { + return Document.from(formattedDocument); + } else { + return Document.from(document); + } + }).toList()) + .build(); + } + + @Builder + public record Document( + String term, + String synonyms, + String meaning + ) { + public static Document from(Map document) { + return Document.builder() + .term((String) document.get("term")) + .synonyms((String) document.get("synonyms")) + .meaning((String) document.get("meaning")) + .build(); + } + } +} diff --git a/server/api/src/main/java/vook/server/api/web/health/HealthApi.java b/server/api/src/main/java/vook/server/api/web/health/HealthApi.java new file mode 100644 index 00000000..bdfdde3b --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/health/HealthApi.java @@ -0,0 +1,24 @@ +package vook.server.api.web.health; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "health") +public interface HealthApi { + + @Operation( + summary = "서버 상태 확인", + description = """ + - 서버의 상태를 체크하는 API입니다.""") + @ApiResponse( + responseCode = "200", + content = @Content( + mediaType = "text/plain", + examples = @ExampleObject(name = "성공", value = "OK") + ) + ) + String health(); +} diff --git a/server/api/src/main/java/vook/server/api/web/health/HealthController.java b/server/api/src/main/java/vook/server/api/web/health/HealthController.java new file mode 100644 index 00000000..61ff68d1 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/health/HealthController.java @@ -0,0 +1,15 @@ +package vook.server.api.web.health; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/health") +public class HealthController implements HealthApi { + + @GetMapping + public String health() { + return "OK"; + } +} diff --git a/server/api/src/main/java/vook/server/api/web/health/package-info.java b/server/api/src/main/java/vook/server/api/web/health/package-info.java new file mode 100644 index 00000000..7675796b --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/health/package-info.java @@ -0,0 +1,8 @@ +@ApplicationModule( + type = ApplicationModule.Type.OPEN, + displayName = "Health Web", + allowedDependencies = {} +) +package vook.server.api.web.health; + +import org.springframework.modulith.ApplicationModule; diff --git a/server/api/src/main/java/vook/server/api/web/term/TermApi.java b/server/api/src/main/java/vook/server/api/web/term/TermApi.java new file mode 100644 index 00000000..3d282a1d --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/term/TermApi.java @@ -0,0 +1,123 @@ +package vook.server.api.web.term; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.Pageable; +import vook.server.api.web.common.auth.data.VookLoginUser; +import vook.server.api.web.common.response.CommonApiResponse; +import vook.server.api.web.common.swagger.annotation.IncludeBadRequestResponse; +import vook.server.api.web.common.swagger.annotation.IncludeOkResponse; +import vook.server.api.web.term.reqres.*; + +import java.util.List; + +import static vook.server.api.web.common.swagger.annotation.IncludeBadRequestResponse.Kind.INVALID_PARAMETER; +import static vook.server.api.web.common.swagger.annotation.IncludeBadRequestResponse.Kind.VIOLATION_BUSINESS_RULE; + +@Tag(name = "term", description = "용어 API") +public interface TermApi { + + @Operation( + summary = "용어 추가", + security = { + @SecurityRequirement(name = "AccessToken") + }, + description = """ + ## 비즈니스 규칙 위반 내용 + + - VocabularyNotFound: 사용자의 용어집 중 해당 ID의 용어집이 존재하지 않는 경우 + - TermLimitExceeded: 사용자의 용어집에 용어를 추가할 수 있는 제한을 초과한 경우 + - NotValidVocabularyOwner: 용어를 추가하려는 용어집에 대한 권한이 없는 경우""" + ) + @IncludeOkResponse(implementation = TermApiCreateResponse.class) + @IncludeBadRequestResponse({INVALID_PARAMETER, VIOLATION_BUSINESS_RULE}) + CommonApiResponse create(VookLoginUser user, TermCreateRequest request); + + class TermApiCreateResponse extends CommonApiResponse { + } + + @Operation( + summary = "용어 조회", + security = { + @SecurityRequirement(name = "AccessToken") + }, + description = """ + ## 허용하는 sort 키워드 + term, synonym, meaning, createdAt + + 사용 예시 + - term,asc + - synonym,desc + - meaning,desc + - createdAt,asc + + ## 비즈니스 규칙 위반 내용 + - VocabularyNotFound: 사용자의 용어집 중 해당 ID의 용어집이 존재하지 않는 경우""" + ) + @IncludeOkResponse(implementation = TermApiRetrieveResponse.class) + @IncludeBadRequestResponse(VIOLATION_BUSINESS_RULE) + CommonApiResponse> retrieve(VookLoginUser user, @ParameterObject Pageable pageable, String vocabularyUid); + + class TermApiRetrieveResponse extends CommonApiResponse { + } + + @Operation( + summary = "용어 수정", + security = { + @SecurityRequirement(name = "AccessToken") + }, + description = """ + ## 비즈니스 규칙 위반 내용 + - TermNotFound: 삭제하려는 용어가 존재하지 않는 경우 + - NotValidVocabularyOwner: 조회하려는 용어가 속해있는 용어집에 대한 권한이 없는 경우""" + ) + @IncludeOkResponse + @IncludeBadRequestResponse({INVALID_PARAMETER, VIOLATION_BUSINESS_RULE}) + CommonApiResponse update(VookLoginUser user, String termUid, TermUpdateRequest request); + + @Operation( + summary = "용어 삭제", + security = { + @SecurityRequirement(name = "AccessToken") + }, + description = """ + ## 비즈니스 규칙 위반 내용 + - TermNotFound: 삭제하려는 용어가 존재하지 않는 경우 + - NotValidVocabularyOwner: 삭제하려는 용어가 속해있는 용어집에 대한 권한이 없는 경우""" + ) + @IncludeOkResponse + @IncludeBadRequestResponse(VIOLATION_BUSINESS_RULE) + CommonApiResponse delete(VookLoginUser user, String termUid); + + @Operation( + summary = "용어 다중 삭제", + security = { + @SecurityRequirement(name = "AccessToken") + }, + description = """ + ## 비즈니스 규칙 위반 내용 + - TermNotFound: 삭제하려는 용어가 존재하지 않는 경우 + - NotValidVocabularyOwner: 삭제하려는 용어가 속해있는 용어집에 대한 권한이 없는 경우""" + ) + @IncludeOkResponse + @IncludeBadRequestResponse({INVALID_PARAMETER, VIOLATION_BUSINESS_RULE}) + CommonApiResponse batchDelete(VookLoginUser user, TermBatchDeleteRequest request); + + @Operation( + summary = "용어 검색", + security = { + @SecurityRequirement(name = "AccessToken") + }, + description = """ + ## 비즈니스 규칙 위반 내용 + - NotValidVocabularyOwnerException: 검색 요청한 용어집 UID에 대한 권한이 없는 경우""" + ) + @IncludeOkResponse(implementation = TermApiSearchResponse.class) + @IncludeBadRequestResponse({INVALID_PARAMETER, VIOLATION_BUSINESS_RULE}) + CommonApiResponse search(VookLoginUser user, TermSearchRequest request); + + class TermApiSearchResponse extends CommonApiResponse { + } +} diff --git a/server/api/src/main/java/vook/server/api/web/term/TermRestController.java b/server/api/src/main/java/vook/server/api/web/term/TermRestController.java new file mode 100644 index 00000000..38883b38 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/term/TermRestController.java @@ -0,0 +1,98 @@ +package vook.server.api.web.term; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import vook.server.api.web.common.auth.data.VookLoginUser; +import vook.server.api.web.common.response.CommonApiResponse; +import vook.server.api.web.term.reqres.*; +import vook.server.api.web.term.usecase.*; + +import java.util.List; + +@Slf4j +@RestController +@RequestMapping("/terms") +@RequiredArgsConstructor +public class TermRestController implements TermApi { + + private final CreateTermUseCase createTermUseCase; + private final RetrieveTermUseCase retrieveTermUseCase; + private final UpdateTermUseCase updateTermUseCase; + private final DeleteTermUseCase deleteTermUseCase; + private final BatchDeleteTermUseCase batchDeleteTermUseCase; + private final SearchTermUseCase searchTermUseCase; + + @Override + @PostMapping + public CommonApiResponse create( + @AuthenticationPrincipal VookLoginUser user, + @Validated @RequestBody TermCreateRequest request + ) { + var command = request.toCommand(user); + var result = createTermUseCase.execute(command); + var response = TermCreateResponse.from(result); + return CommonApiResponse.okWithResult(response); + } + + @Override + @GetMapping + public CommonApiResponse> retrieve( + @AuthenticationPrincipal VookLoginUser user, + @PageableDefault(size = Integer.MAX_VALUE) Pageable pageable, + @RequestParam String vocabularyUid + ) { + var command = new RetrieveTermUseCase.Command(user.getUid(), vocabularyUid, pageable); + var result = retrieveTermUseCase.execute(command); + var response = TermResponse.from(result); + return CommonApiResponse.okWithResult(response); + } + + @Override + @PutMapping("/{termUid}") + public CommonApiResponse update( + @AuthenticationPrincipal VookLoginUser user, + @PathVariable String termUid, + @Validated @RequestBody TermUpdateRequest request + ) { + var command = request.toCommand(user, termUid); + updateTermUseCase.execute(command); + return CommonApiResponse.ok(); + } + + @Override + @DeleteMapping("/{termUid}") + public CommonApiResponse delete( + @AuthenticationPrincipal VookLoginUser user, + @PathVariable String termUid + ) { + var command = new DeleteTermUseCase.Command(user.getUid(), termUid); + deleteTermUseCase.execute(command); + return CommonApiResponse.ok(); + } + + @Override + @PostMapping("/batch-delete") + public CommonApiResponse batchDelete( + @AuthenticationPrincipal VookLoginUser user, + @Validated @RequestBody TermBatchDeleteRequest request + ) { + batchDeleteTermUseCase.execute(request.toCommand(user)); + return CommonApiResponse.ok(); + } + + @Override + @PostMapping("/search") + public CommonApiResponse search( + @AuthenticationPrincipal VookLoginUser user, + @Validated @RequestBody TermSearchRequest request + ) { + SearchTermUseCase.Result result = searchTermUseCase.execute(request.toCommand(user)); + TermSearchResponse response = TermSearchResponse.from(result); + return CommonApiResponse.okWithResult(response); + } +} diff --git a/server/api/src/main/java/vook/server/api/web/term/package-info.java b/server/api/src/main/java/vook/server/api/web/term/package-info.java new file mode 100644 index 00000000..ee391506 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/term/package-info.java @@ -0,0 +1,12 @@ +@ApplicationModule( + type = ApplicationModule.Type.OPEN, + displayName = "Term Web", + allowedDependencies = { + "vook.server.api.web.common", + "vook.server.api.domain.user", + "vook.server.api.domain.vocabulary", + } +) +package vook.server.api.web.term; + +import org.springframework.modulith.ApplicationModule; diff --git a/server/api/src/main/java/vook/server/api/web/term/reqres/TermBatchDeleteRequest.java b/server/api/src/main/java/vook/server/api/web/term/reqres/TermBatchDeleteRequest.java new file mode 100644 index 00000000..58217c2e --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/term/reqres/TermBatchDeleteRequest.java @@ -0,0 +1,18 @@ +package vook.server.api.web.term.reqres; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import vook.server.api.web.common.auth.data.VookLoginUser; +import vook.server.api.web.term.usecase.BatchDeleteTermUseCase; + +import java.util.List; + +public record TermBatchDeleteRequest( + @Valid + @NotEmpty + List<@NotEmpty String> termUids +) { + public BatchDeleteTermUseCase.Command toCommand(VookLoginUser user) { + return new BatchDeleteTermUseCase.Command(user.getUid(), termUids); + } +} diff --git a/server/api/src/main/java/vook/server/api/web/term/reqres/TermCreateRequest.java b/server/api/src/main/java/vook/server/api/web/term/reqres/TermCreateRequest.java new file mode 100644 index 00000000..77de3d16 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/term/reqres/TermCreateRequest.java @@ -0,0 +1,33 @@ +package vook.server.api.web.term.reqres; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import vook.server.api.web.common.auth.data.VookLoginUser; +import vook.server.api.web.term.usecase.CreateTermUseCase; + +import java.util.List; + +public record TermCreateRequest( + @NotBlank + String vocabularyUid, + + @NotBlank + @Size(min = 1, max = 100) + String term, + + @NotBlank + @Size(min = 1, max = 2000) + String meaning, + + List synonyms +) { + public CreateTermUseCase.Command toCommand(VookLoginUser loginUser) { + return new CreateTermUseCase.Command( + loginUser.getUid(), + vocabularyUid, + term, + meaning, + synonyms == null ? List.of() : synonyms + ); + } +} diff --git a/server/api/src/main/java/vook/server/api/web/term/reqres/TermCreateResponse.java b/server/api/src/main/java/vook/server/api/web/term/reqres/TermCreateResponse.java new file mode 100644 index 00000000..65d7733e --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/term/reqres/TermCreateResponse.java @@ -0,0 +1,15 @@ +package vook.server.api.web.term.reqres; + +import lombok.Builder; +import vook.server.api.web.term.usecase.CreateTermUseCase; + +@Builder +public record TermCreateResponse( + String uid +) { + public static TermCreateResponse from(CreateTermUseCase.Result term) { + return TermCreateResponse.builder() + .uid(term.uid()) + .build(); + } +} diff --git a/server/api/src/main/java/vook/server/api/web/term/reqres/TermResponse.java b/server/api/src/main/java/vook/server/api/web/term/reqres/TermResponse.java new file mode 100644 index 00000000..08e1f424 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/term/reqres/TermResponse.java @@ -0,0 +1,33 @@ +package vook.server.api.web.term.reqres; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Builder; +import vook.server.api.web.term.usecase.RetrieveTermUseCase; + +import java.time.LocalDateTime; +import java.util.List; + +@Builder +public record TermResponse( + String termUid, + String term, + String meaning, + List synonyms, + + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") + LocalDateTime createdAt +) { + public static List from(RetrieveTermUseCase.Result result) { + return result.terms().stream().map(TermResponse::from).toList(); + } + + public static TermResponse from(RetrieveTermUseCase.Result.Tuple term) { + return TermResponse.builder() + .termUid(term.termUid()) + .term(term.term()) + .meaning(term.meaning()) + .synonyms(term.synonyms()) + .createdAt(term.createdAt()) + .build(); + } +} diff --git a/server/api/src/main/java/vook/server/api/web/term/reqres/TermSearchRequest.java b/server/api/src/main/java/vook/server/api/web/term/reqres/TermSearchRequest.java new file mode 100644 index 00000000..dbeda8bf --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/term/reqres/TermSearchRequest.java @@ -0,0 +1,36 @@ +package vook.server.api.web.term.reqres; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import vook.server.api.web.common.auth.data.VookLoginUser; +import vook.server.api.web.term.usecase.SearchTermUseCase; + +import java.util.List; + +public record TermSearchRequest( + @Valid + @NotEmpty + List<@NotBlank String> vocabularyUids, + + @Valid + @NotEmpty + List<@NotBlank String> queries, + + Boolean withFormat, + + String highlightPreTag, + + String highlightPostTag +) { + public SearchTermUseCase.Command toCommand(VookLoginUser loginUser) { + return SearchTermUseCase.Command.builder() + .userUid(loginUser.getUid()) + .vocabularyUids(vocabularyUids) + .queries(queries) + .withFormat(withFormat != null && withFormat) + .highlightPreTag(highlightPreTag) + .highlightPostTag(highlightPostTag) + .build(); + } +} diff --git a/server/api/src/main/java/vook/server/api/web/term/reqres/TermSearchResponse.java b/server/api/src/main/java/vook/server/api/web/term/reqres/TermSearchResponse.java new file mode 100644 index 00000000..94069e1d --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/term/reqres/TermSearchResponse.java @@ -0,0 +1,53 @@ +package vook.server.api.web.term.reqres; + +import lombok.Builder; +import vook.server.api.web.term.usecase.SearchTermUseCase; + +import java.util.List; + +@Builder +public record TermSearchResponse( + List records +) { + public static TermSearchResponse from(SearchTermUseCase.Result result) { + return TermSearchResponse.builder() + .records(Record.from(result.records())) + .build(); + } + + public record Record( + String vocabularyUid, + String query, + List hits + ) { + + public static List from(List resultRecords) { + return resultRecords.stream() + .map(resultRecord -> new Record(resultRecord.vocabularyUid(), resultRecord.query(), Term.from(resultRecord.hits()))) + .toList(); + } + } + + @Builder + public record Term( + String uid, + String term, + String meaning, + String synonyms + ) { + private static List from(List terms) { + return terms.stream() + .map(Term::from) + .toList(); + } + + private static Term from(SearchTermUseCase.Result.Term term) { + return Term.builder() + .uid(term.uid()) + .term(term.term()) + .meaning(term.meaning()) + .synonyms(term.synonyms()) + .build(); + } + } +} diff --git a/server/api/src/main/java/vook/server/api/web/term/reqres/TermUpdateRequest.java b/server/api/src/main/java/vook/server/api/web/term/reqres/TermUpdateRequest.java new file mode 100644 index 00000000..42d74b7b --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/term/reqres/TermUpdateRequest.java @@ -0,0 +1,30 @@ +package vook.server.api.web.term.reqres; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import vook.server.api.web.common.auth.data.VookLoginUser; +import vook.server.api.web.term.usecase.UpdateTermUseCase; + +import java.util.List; + +public record TermUpdateRequest( + @NotBlank + @Size(min = 1, max = 100) + String term, + + @NotBlank + @Size(min = 1, max = 2000) + String meaning, + + List synonyms +) { + public UpdateTermUseCase.Command toCommand(VookLoginUser loginUser, String termUid) { + return new UpdateTermUseCase.Command( + loginUser.getUid(), + termUid, + term, + meaning, + synonyms == null ? List.of() : synonyms + ); + } +} diff --git a/server/api/src/main/java/vook/server/api/web/term/usecase/BatchDeleteTermUseCase.java b/server/api/src/main/java/vook/server/api/web/term/usecase/BatchDeleteTermUseCase.java new file mode 100644 index 00000000..145d3835 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/term/usecase/BatchDeleteTermUseCase.java @@ -0,0 +1,38 @@ +package vook.server.api.web.term.usecase; + +import lombok.RequiredArgsConstructor; +import vook.server.api.domain.user.logic.UserLogic; +import vook.server.api.domain.vocabulary.logic.term.TermLogic; +import vook.server.api.domain.vocabulary.model.term.Term; +import vook.server.api.globalcommon.annotation.UseCase; +import vook.server.api.policy.VocabularyPolicy; + +import java.util.List; + +@UseCase +@RequiredArgsConstructor +public class BatchDeleteTermUseCase { + + private final UserLogic userLogic; + private final VocabularyPolicy vocabularyPolicy; + private final TermLogic termLogic; + + public void execute(Command command) { + validate(command.userUid(), command.termUids()); + termLogic.batchDelete(command.termUids()); + } + + private void validate(String userUid, List termUids) { + userLogic.validateCompletedUserByUid(userUid); + termUids.forEach(termUid -> { + Term term = termLogic.getByUid(termUid); + vocabularyPolicy.validateOwner(userUid, term.getVocabulary()); + }); + } + + public record Command( + String userUid, + List termUids + ) { + } +} diff --git a/server/api/src/main/java/vook/server/api/web/term/usecase/CreateTermUseCase.java b/server/api/src/main/java/vook/server/api/web/term/usecase/CreateTermUseCase.java new file mode 100644 index 00000000..6922265c --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/term/usecase/CreateTermUseCase.java @@ -0,0 +1,59 @@ +package vook.server.api.web.term.usecase; + +import lombok.RequiredArgsConstructor; +import vook.server.api.domain.user.logic.UserLogic; +import vook.server.api.domain.vocabulary.logic.term.TermCreateCommand; +import vook.server.api.domain.vocabulary.logic.term.TermLogic; +import vook.server.api.domain.vocabulary.logic.vocabulary.VocabularyLogic; +import vook.server.api.domain.vocabulary.model.term.Term; +import vook.server.api.domain.vocabulary.model.vocabulary.Vocabulary; +import vook.server.api.globalcommon.annotation.UseCase; +import vook.server.api.policy.VocabularyPolicy; + +import java.util.List; + +@UseCase +@RequiredArgsConstructor +public class CreateTermUseCase { + + private final UserLogic userLogic; + private final VocabularyLogic vocabularyLogic; + private final TermLogic termLogic; + private final VocabularyPolicy vocabularyPolicy; + + public Result execute(Command command) { + userLogic.validateCompletedUserByUid(command.userUid()); + + Vocabulary vocabulary = vocabularyLogic.getByUid(command.vocabularyUid()); + vocabularyPolicy.validateOwner(command.userUid(), vocabulary); + + Term term = termLogic.create(command.toTermCreateCommand()); + + return Result.from(term); + } + + public record Command( + String userUid, + String vocabularyUid, + String term, + String meaning, + List synonyms + ) { + public TermCreateCommand toTermCreateCommand() { + return TermCreateCommand.builder() + .vocabularyUid(vocabularyUid) + .term(term) + .meaning(meaning) + .synonyms(synonyms) + .build(); + } + } + + public record Result( + String uid + ) { + public static Result from(Term term) { + return new Result(term.getUid()); + } + } +} diff --git a/server/api/src/main/java/vook/server/api/web/term/usecase/DeleteTermUseCase.java b/server/api/src/main/java/vook/server/api/web/term/usecase/DeleteTermUseCase.java new file mode 100644 index 00000000..4c9cbe17 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/term/usecase/DeleteTermUseCase.java @@ -0,0 +1,32 @@ +package vook.server.api.web.term.usecase; + +import lombok.RequiredArgsConstructor; +import vook.server.api.domain.user.logic.UserLogic; +import vook.server.api.domain.vocabulary.logic.term.TermLogic; +import vook.server.api.domain.vocabulary.model.term.Term; +import vook.server.api.globalcommon.annotation.UseCase; +import vook.server.api.policy.VocabularyPolicy; + +@UseCase +@RequiredArgsConstructor +public class DeleteTermUseCase { + + private final UserLogic userLogic; + private final VocabularyPolicy vocabularyPolicy; + private final TermLogic termLogic; + + public void execute(Command command) { + userLogic.validateCompletedUserByUid(command.userUid()); + + Term term = termLogic.getByUid(command.termUid()); + vocabularyPolicy.validateOwner(command.userUid(), term.getVocabulary()); + + termLogic.delete(command.termUid()); + } + + public record Command( + String userUid, + String termUid + ) { + } +} diff --git a/server/api/src/main/java/vook/server/api/web/term/usecase/RetrieveTermUseCase.java b/server/api/src/main/java/vook/server/api/web/term/usecase/RetrieveTermUseCase.java new file mode 100644 index 00000000..f0725bd0 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/term/usecase/RetrieveTermUseCase.java @@ -0,0 +1,69 @@ +package vook.server.api.web.term.usecase; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import vook.server.api.domain.user.logic.UserLogic; +import vook.server.api.domain.vocabulary.logic.vocabulary.VocabularyLogic; +import vook.server.api.domain.vocabulary.model.term.Term; +import vook.server.api.domain.vocabulary.model.vocabulary.Vocabulary; +import vook.server.api.globalcommon.annotation.UseCase; +import vook.server.api.policy.VocabularyPolicy; + +import java.time.LocalDateTime; +import java.util.List; + +@UseCase +@RequiredArgsConstructor +public class RetrieveTermUseCase { + + private final UserLogic userLogic; + private final VocabularyLogic vocabularyLogic; + private final VocabularyPolicy vocabularyPolicy; + private final TermSearchService termSearchService; + + public Result execute(Command command) { + userLogic.validateCompletedUserByUid(command.userUid()); + + Vocabulary vocabulary = vocabularyLogic.getByUid(command.vocabularyUid()); + vocabularyPolicy.validateOwner(command.userUid(), vocabulary); + + Page termPage = termSearchService.findAllBy(command.vocabularyUid(), command.pageable()); + Page tuplePage = termPage.map(Result.Tuple::from); + return new Result(tuplePage); + } + + public record Command( + String userUid, + String vocabularyUid, + Pageable pageable + ) { + } + + public record Result( + Page terms + ) { + public record Tuple( + String termUid, + String term, + String meaning, + List synonyms, + LocalDateTime createdAt + ) { + + public static Tuple from(Term term) { + return new Tuple( + term.getUid(), + term.getTerm(), + term.getMeaning(), + term.getSynonyms(), + term.getCreatedAt() + ); + } + } + } + + public interface TermSearchService { + Page findAllBy(String vocabularyUid, Pageable params); + } +} diff --git a/server/api/src/main/java/vook/server/api/web/term/usecase/SearchTermUseCase.java b/server/api/src/main/java/vook/server/api/web/term/usecase/SearchTermUseCase.java new file mode 100644 index 00000000..637c6041 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/term/usecase/SearchTermUseCase.java @@ -0,0 +1,143 @@ +package vook.server.api.web.term.usecase; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import lombok.Builder; +import lombok.RequiredArgsConstructor; +import vook.server.api.domain.vocabulary.logic.vocabulary.VocabularyLogic; +import vook.server.api.domain.vocabulary.model.vocabulary.UserUid; +import vook.server.api.globalcommon.annotation.UseCase; +import vook.server.api.policy.VocabularyPolicy; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@UseCase +@RequiredArgsConstructor +public class SearchTermUseCase { + + private final VocabularyLogic vocabularyLogic; + private final VocabularyPolicy vocabularyPolicy; + private final SearchService searchService; + + public Result execute(@Valid Command command) { + List userVocabularyUids = vocabularyLogic.findAllUidsBy(new UserUid(command.userUid())); + vocabularyPolicy.validateOwner(userVocabularyUids, command.vocabularyUids()); + + SearchService.Result result = searchService.search(command.toSearchParams()); + return SearchTermUseCase.Result.from(result); + } + + @Builder + public record Command( + @NotBlank + String userUid, + + @Valid + @NotEmpty + List<@NotBlank String> vocabularyUids, + + @Valid + @NotEmpty + List<@NotBlank String> queries, + + boolean withFormat, + String highlightPreTag, + String highlightPostTag + ) { + public SearchService.Params toSearchParams() { + return SearchService.Params.builder() + .vocabularyUids(vocabularyUids) + .queries(queries) + .withFormat(withFormat) + .highlightPreTag(highlightPreTag) + .highlightPostTag(highlightPostTag) + .build(); + } + } + + @Builder + public record Result( + List records + ) { + public static Result from(SearchService.Result input) { + return Result.builder() + .records(Record.from(input.records())) + .build(); + } + + public record Record( + String vocabularyUid, + String query, + List hits + ) { + public static List from(List input) { + List result = new ArrayList<>(); + for (SearchService.Result.Record record : input) { + result.add(new Record(record.vocabularyUid(), record.query(), Term.from(record))); + } + return result; + } + } + + @Builder + public record Term( + String uid, + String term, + String meaning, + String synonyms + ) { + private static List from(SearchService.Result.Record record) { + return record.hits().stream() + .map(hit -> { + Object formatted = hit.get("_formatted"); + if (formatted instanceof Map formattedDocument) { + return formattedDocument; + } else { + return hit; + } + }) + .map(Term::from) + .toList(); + } + + public static Term from(Map hit) { + return Term.builder() + .uid((String) hit.get("uid")) + .term((String) hit.get("term")) + .meaning((String) hit.get("meaning")) + .synonyms((String) hit.get("synonyms")) + .build(); + } + } + } + + public interface SearchService { + Result search(Params params); + + @Builder + record Params( + List vocabularyUids, + List queries, + boolean withFormat, + String highlightPreTag, + String highlightPostTag + ) { + } + + @Builder + record Result( + List records + ) { + public record Record( + String vocabularyUid, + String query, + ArrayList> hits + ) { + } + } + } +} diff --git a/server/api/src/main/java/vook/server/api/web/term/usecase/UpdateTermUseCase.java b/server/api/src/main/java/vook/server/api/web/term/usecase/UpdateTermUseCase.java new file mode 100644 index 00000000..fdb50f21 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/term/usecase/UpdateTermUseCase.java @@ -0,0 +1,46 @@ +package vook.server.api.web.term.usecase; + +import lombok.RequiredArgsConstructor; +import vook.server.api.domain.user.logic.UserLogic; +import vook.server.api.domain.vocabulary.logic.term.TermLogic; +import vook.server.api.domain.vocabulary.logic.term.TermUpdateCommand; +import vook.server.api.domain.vocabulary.model.term.Term; +import vook.server.api.globalcommon.annotation.UseCase; +import vook.server.api.policy.VocabularyPolicy; + +import java.util.List; + +@UseCase +@RequiredArgsConstructor +public class UpdateTermUseCase { + + private final UserLogic userLogic; + private final VocabularyPolicy vocabularyPolicy; + private final TermLogic termLogic; + + public void execute(Command command) { + userLogic.validateCompletedUserByUid(command.userUid()); + + Term term = termLogic.getByUid(command.termUid()); + vocabularyPolicy.validateOwner(command.userUid(), term.getVocabulary()); + + termLogic.update(command.toServiceCommand()); + } + + public record Command( + String userUid, + String termUid, + String term, + String meaning, + List synonyms + ) { + public TermUpdateCommand toServiceCommand() { + return TermUpdateCommand.builder() + .uid(termUid) + .term(term) + .meaning(meaning) + .synonyms(synonyms) + .build(); + } + } +} diff --git a/server/api/src/main/java/vook/server/api/web/user/UserApi.java b/server/api/src/main/java/vook/server/api/web/user/UserApi.java new file mode 100644 index 00000000..38402db4 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/user/UserApi.java @@ -0,0 +1,109 @@ +package vook.server.api.web.user; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import vook.server.api.web.common.auth.data.VookLoginUser; +import vook.server.api.web.common.response.CommonApiResponse; +import vook.server.api.web.common.swagger.annotation.IncludeBadRequestResponse; +import vook.server.api.web.common.swagger.annotation.IncludeOkResponse; +import vook.server.api.web.user.reqres.UserInfoResponse; +import vook.server.api.web.user.reqres.UserOnboardingRequest; +import vook.server.api.web.user.reqres.UserRegisterRequest; +import vook.server.api.web.user.reqres.UserUpdateInfoRequest; + +import static vook.server.api.web.common.swagger.annotation.IncludeBadRequestResponse.Kind.INVALID_PARAMETER; +import static vook.server.api.web.common.swagger.annotation.IncludeBadRequestResponse.Kind.VIOLATION_BUSINESS_RULE; + +@Tag(name = "user", description = "사용자 관련 API") +public interface UserApi { + + @Operation( + summary = "사용자 정보", + security = { + @SecurityRequirement(name = "AccessToken") + } + ) + @IncludeOkResponse(implementation = UserApiUserInfoResponse.class) + CommonApiResponse userInfo(VookLoginUser user); + + class UserApiUserInfoResponse extends CommonApiResponse { + } + + @Operation( + summary = "회원 가입", + security = { + @SecurityRequirement(name = "AccessToken") + }, + description = """ + ## 비즈니스 규칙 위반 내용 + - AlreadyRegistered: 이미 회원가입이 완료된 유저가 해당 API를 호출 할 경우 + - WithdrawnUser: 탈퇴한 유저가 해당 API를 호출 할 경우""" + ) + @IncludeOkResponse + @IncludeBadRequestResponse({INVALID_PARAMETER, VIOLATION_BUSINESS_RULE}) + CommonApiResponse register(VookLoginUser user, UserRegisterRequest request); + + @Operation( + summary = "온보딩", + security = { + @SecurityRequirement(name = "AccessToken") + }, + description = """ + ## 호출 시나리오 + - 회원가입이 완료된 유저가 온보딩 프로세스의 마지막 페이지에서 '시작하기' 버튼을 클릭했을 때 호출됩니다. + - '건너뛰기' 버튼을 클릭 할 경우 해당 온보딩 프로세스 페이지의 선택 값을 null로 합니다. + - 온보딩 프로세스의 마지막 페이지에서 '건너뛰기' 버튼을 클릭했을 때도 이 API를 호출합니다. + + ## 비즈니스 규칙 위반 내용 + - NotReadyToOnboarding: 회원 가입이 완료되지 않은 유저가 해당 API를 호출 할 경우 + - AlreadyOnboarding: 이미 온보딩이 완료된 유저가 해당 API를 호출 할 경우""" + ) + @IncludeOkResponse + @IncludeBadRequestResponse(VIOLATION_BUSINESS_RULE) + CommonApiResponse onboarding(VookLoginUser user, UserOnboardingRequest request); + + @Operation( + summary = "사용자 정보 수정", + security = { + @SecurityRequirement(name = "AccessToken") + }, + description = """ + ## 비즈니스 규칙 위반 내용 + - NotRegistered: 가입하지 않은 유저가 해당 API를 호출 할 경우""" + ) + @IncludeOkResponse + @IncludeBadRequestResponse({INVALID_PARAMETER, VIOLATION_BUSINESS_RULE}) + CommonApiResponse updateInfo(VookLoginUser user, UserUpdateInfoRequest request); + + @Operation( + summary = "회원 탈퇴", + security = { + @SecurityRequirement(name = "AccessToken") + }, + description = "탈퇴된 회원에 대한 요청은 무시됩니다." + ) + @IncludeOkResponse + CommonApiResponse withdraw(VookLoginUser user); + + @Operation( + summary = "회원 재가입", + security = { + @SecurityRequirement(name = "AccessToken") + }, + description = """ + ## 수행 내용 + - 유저의 상태를 탈퇴에서 가입으로 변경하고, 과거 온보딩 정보를 삭제합니다. + + ## 호출 시나리오 + - 탈퇴한 유저가 가입 페이지에서 '가입' 버튼을 클릭했을 때 호출됩니다. + - 이 API를 호출 한 후, 온보딩 API를 호출하여 온보딩을 다시 진행해야 합니다. + + ## 비즈니스 규칙 위반 내용 + - NotWithdrawnUser: 탈퇴하지 않은 유저가 해당 API를 호출 할 경우 + """ + ) + @IncludeOkResponse + @IncludeBadRequestResponse({INVALID_PARAMETER, VIOLATION_BUSINESS_RULE}) + CommonApiResponse reRegister(VookLoginUser user, UserRegisterRequest request); +} diff --git a/server/api/src/main/java/vook/server/api/web/user/UserRestController.java b/server/api/src/main/java/vook/server/api/web/user/UserRestController.java new file mode 100644 index 00000000..9928faec --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/user/UserRestController.java @@ -0,0 +1,87 @@ +package vook.server.api.web.user; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import vook.server.api.domain.user.logic.UserLogic; +import vook.server.api.domain.user.model.user.User; +import vook.server.api.web.common.auth.data.VookLoginUser; +import vook.server.api.web.common.response.CommonApiResponse; +import vook.server.api.web.user.reqres.UserInfoResponse; +import vook.server.api.web.user.reqres.UserOnboardingRequest; +import vook.server.api.web.user.reqres.UserRegisterRequest; +import vook.server.api.web.user.reqres.UserUpdateInfoRequest; +import vook.server.api.web.user.usecase.OnboardingUserUseCase; +import vook.server.api.web.user.usecase.WithdrawUserUseCase; + +@Slf4j +@RestController +@RequestMapping("/user") +@RequiredArgsConstructor +public class UserRestController implements UserApi { + + private final UserLogic userLogic; + private final OnboardingUserUseCase onboardingUserUseCase; + private final WithdrawUserUseCase withdrawUserUseCase; + + @Override + @GetMapping("/info") + public CommonApiResponse userInfo( + @AuthenticationPrincipal VookLoginUser loginUser + ) { + User user = userLogic.getByUid(loginUser.getUid()); + UserInfoResponse response = UserInfoResponse.from(user); + return CommonApiResponse.okWithResult(response); + } + + @Override + @PostMapping("/register") + public CommonApiResponse register( + @AuthenticationPrincipal VookLoginUser loginUser, + @Validated @RequestBody UserRegisterRequest request + ) { + userLogic.register(request.toCommand(loginUser.getUid())); + return CommonApiResponse.ok(); + } + + @Override + @PostMapping("/onboarding") + public CommonApiResponse onboarding( + @AuthenticationPrincipal VookLoginUser loginUser, + @RequestBody UserOnboardingRequest request + ) { + onboardingUserUseCase.execute(request.toCommand(loginUser.getUid())); + return CommonApiResponse.ok(); + } + + @Override + @PutMapping("/info") + public CommonApiResponse updateInfo( + @AuthenticationPrincipal VookLoginUser loginUser, + @Validated @RequestBody UserUpdateInfoRequest request + ) { + userLogic.updateInfo(loginUser.getUid(), request.nickname().trim()); + return CommonApiResponse.ok(); + } + + @Override + @PostMapping("/withdraw") + public CommonApiResponse withdraw( + @AuthenticationPrincipal VookLoginUser loginUser + ) { + withdrawUserUseCase.execute(new WithdrawUserUseCase.Command(loginUser.getUid())); + return CommonApiResponse.ok(); + } + + @Override + @PostMapping("/re-register") + public CommonApiResponse reRegister( + @AuthenticationPrincipal VookLoginUser user, + @Validated @RequestBody UserRegisterRequest request + ) { + userLogic.reRegister(request.toCommand(user.getUid())); + return CommonApiResponse.ok(); + } +} diff --git a/server/api/src/main/java/vook/server/api/web/user/package-info.java b/server/api/src/main/java/vook/server/api/web/user/package-info.java new file mode 100644 index 00000000..09f57fe5 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/user/package-info.java @@ -0,0 +1,13 @@ +@ApplicationModule( + type = ApplicationModule.Type.OPEN, + displayName = "User Web", + allowedDependencies = { + "vook.server.api.web.common", + "vook.server.api.domain.user", + "vook.server.api.domain.vocabulary", + "vook.server.api.domain.template_vocabulary" + } +) +package vook.server.api.web.user; + +import org.springframework.modulith.ApplicationModule; diff --git a/server/api/src/main/java/vook/server/api/web/user/reqres/UserInfoResponse.java b/server/api/src/main/java/vook/server/api/web/user/reqres/UserInfoResponse.java new file mode 100644 index 00000000..7082709c --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/user/reqres/UserInfoResponse.java @@ -0,0 +1,24 @@ +package vook.server.api.web.user.reqres; + +import lombok.Builder; +import vook.server.api.domain.user.model.user.User; +import vook.server.api.domain.user.model.user.UserStatus; + +@Builder +public record UserInfoResponse( + String uid, + String email, + UserStatus status, + Boolean onboardingCompleted, + String nickname +) { + public static UserInfoResponse from(User user) { + return UserInfoResponse.builder() + .uid(user.getUid()) + .email(user.getEmail()) + .status(user.getStatus()) + .onboardingCompleted(user.getOnboardingCompleted()) + .nickname(user.getUserInfo() != null ? user.getUserInfo().getNickname() : null) + .build(); + } +} diff --git a/server/api/src/main/java/vook/server/api/web/user/reqres/UserOnboardingRequest.java b/server/api/src/main/java/vook/server/api/web/user/reqres/UserOnboardingRequest.java new file mode 100644 index 00000000..e021c4d5 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/user/reqres/UserOnboardingRequest.java @@ -0,0 +1,14 @@ +package vook.server.api.web.user.reqres; + +import vook.server.api.domain.user.model.user_info.Funnel; +import vook.server.api.domain.user.model.user_info.Job; +import vook.server.api.web.user.usecase.OnboardingUserUseCase; + +public record UserOnboardingRequest( + Funnel funnel, + Job job +) { + public OnboardingUserUseCase.Command toCommand(String uid) { + return new OnboardingUserUseCase.Command(uid, funnel, job); + } +} diff --git a/server/api/src/main/java/vook/server/api/web/user/reqres/UserRegisterRequest.java b/server/api/src/main/java/vook/server/api/web/user/reqres/UserRegisterRequest.java new file mode 100644 index 00000000..83c590d8 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/user/reqres/UserRegisterRequest.java @@ -0,0 +1,28 @@ +package vook.server.api.web.user.reqres; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import vook.server.api.domain.user.logic.UserRegisterCommand; + +public record UserRegisterRequest( + @NotBlank + String nickname, + + @NotNull + @AssertTrue + @Schema(allowableValues = {"true"}) + Boolean requiredTermsAgree, + + @NotNull + Boolean marketingEmailOptIn +) { + public UserRegisterCommand toCommand(String userUid) { + return UserRegisterCommand.builder() + .userUid(userUid) + .nickname(nickname.trim()) + .marketingEmailOptIn(marketingEmailOptIn) + .build(); + } +} diff --git a/server/api/src/main/java/vook/server/api/web/user/reqres/UserUpdateInfoRequest.java b/server/api/src/main/java/vook/server/api/web/user/reqres/UserUpdateInfoRequest.java new file mode 100644 index 00000000..af529ef9 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/user/reqres/UserUpdateInfoRequest.java @@ -0,0 +1,9 @@ +package vook.server.api.web.user.reqres; + +import jakarta.validation.constraints.NotBlank; + +public record UserUpdateInfoRequest( + @NotBlank + String nickname +) { +} diff --git a/server/api/src/main/java/vook/server/api/web/user/usecase/OnboardingUserUseCase.java b/server/api/src/main/java/vook/server/api/web/user/usecase/OnboardingUserUseCase.java new file mode 100644 index 00000000..f395ed47 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/user/usecase/OnboardingUserUseCase.java @@ -0,0 +1,83 @@ +package vook.server.api.web.user.usecase; + +import lombok.RequiredArgsConstructor; +import vook.server.api.domain.template_vocabulary.logic.TemplateVocabularyLogic; +import vook.server.api.domain.template_vocabulary.model.TemplateTerm; +import vook.server.api.domain.template_vocabulary.model.TemplateVocabularyType; +import vook.server.api.domain.user.logic.UserLogic; +import vook.server.api.domain.user.logic.UserOnboardingCommand; +import vook.server.api.domain.user.model.user_info.Funnel; +import vook.server.api.domain.user.model.user_info.Job; +import vook.server.api.domain.vocabulary.logic.term.TermCreateAllCommand; +import vook.server.api.domain.vocabulary.logic.term.TermLogic; +import vook.server.api.domain.vocabulary.logic.vocabulary.VocabularyCreateCommand; +import vook.server.api.domain.vocabulary.logic.vocabulary.VocabularyLogic; +import vook.server.api.domain.vocabulary.model.vocabulary.UserUid; +import vook.server.api.domain.vocabulary.model.vocabulary.Vocabulary; +import vook.server.api.globalcommon.annotation.UseCase; + +import java.util.List; + +@UseCase +@RequiredArgsConstructor +public class OnboardingUserUseCase { + + private final UserLogic userLogic; + private final VocabularyLogic vocabularyLogic; + private final TemplateVocabularyLogic templateVocabularyLogic; + private final TermLogic termLogic; + + public void execute(Command command) { + userLogic.onboarding(command.toOnboardingCommand()); + + userLogic.validateCompletedUserByUid(command.userUid()); + + TemplateVocabularyType vocabularyType = vocabularyTypeFrom(command.job()); + Vocabulary vocabulary = vocabularyLogic.create( + VocabularyCreateCommand.builder() + .name(vocabularyType.getVocabularyName()) + .userUid(new UserUid(command.userUid())) + .build() + ); + + List terms = templateVocabularyLogic.getTermsByType(vocabularyType); + termLogic.createAll( + TermCreateAllCommand.builder() + .vocabularyUid(vocabulary.getUid()) + .termInfos( + terms.stream() + .map(term -> TermCreateAllCommand.TermInfo.builder() + .term(term.getTerm()) + .meaning(term.getMeaning()) + .synonyms(term.getSynonyms()) + .build()) + .toList() + ) + .build() + ); + } + + private TemplateVocabularyType vocabularyTypeFrom(Job job) { + return switch (job) { + case PLANNER, DESIGNER -> TemplateVocabularyType.DEVELOPMENT; + case MARKETER -> TemplateVocabularyType.MARKETING; + case DEVELOPER -> TemplateVocabularyType.DESIGN; + case CEO, HR, OTHER -> TemplateVocabularyType.GENERAL_OFFICE; + case null -> TemplateVocabularyType.GENERAL_OFFICE; + }; + } + + public record Command( + String userUid, + Funnel funnel, + Job job + ) { + public UserOnboardingCommand toOnboardingCommand() { + return UserOnboardingCommand.builder() + .userUid(userUid) + .funnel(funnel) + .job(job) + .build(); + } + } +} diff --git a/server/api/src/main/java/vook/server/api/web/user/usecase/WithdrawUserUseCase.java b/server/api/src/main/java/vook/server/api/web/user/usecase/WithdrawUserUseCase.java new file mode 100644 index 00000000..be56f7de --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/user/usecase/WithdrawUserUseCase.java @@ -0,0 +1,30 @@ +package vook.server.api.web.user.usecase; + +import lombok.RequiredArgsConstructor; +import vook.server.api.domain.user.logic.UserLogic; +import vook.server.api.domain.vocabulary.logic.vocabulary.VocabularyLogic; +import vook.server.api.domain.vocabulary.model.vocabulary.UserUid; +import vook.server.api.globalcommon.annotation.UseCase; + +@UseCase +@RequiredArgsConstructor +public class WithdrawUserUseCase { + + private final UserLogic userLogic; + private final VocabularyLogic vocabularyLogic; + + public void execute(Command command) { + userLogic.validateCompletedUserByUid(command.userUid()); + + userLogic.withdraw(command.userUid()); + + vocabularyLogic.findAllBy(new UserUid(command.userUid())).forEach(v -> { + vocabularyLogic.delete(v.getUid()); + }); + } + + public record Command( + String userUid + ) { + } +} diff --git a/server/api/src/main/java/vook/server/api/web/vocabulary/VocabularyApi.java b/server/api/src/main/java/vook/server/api/web/vocabulary/VocabularyApi.java new file mode 100644 index 00000000..f6075f0c --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/vocabulary/VocabularyApi.java @@ -0,0 +1,76 @@ +package vook.server.api.web.vocabulary; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import vook.server.api.web.common.auth.data.VookLoginUser; +import vook.server.api.web.common.response.CommonApiResponse; +import vook.server.api.web.common.swagger.annotation.IncludeBadRequestResponse; +import vook.server.api.web.common.swagger.annotation.IncludeOkResponse; +import vook.server.api.web.vocabulary.reqres.VocabularyCreateRequest; +import vook.server.api.web.vocabulary.reqres.VocabularyResponse; +import vook.server.api.web.vocabulary.reqres.VocabularyUpdateRequest; + +import java.util.List; + +import static vook.server.api.web.common.swagger.annotation.IncludeBadRequestResponse.Kind.INVALID_PARAMETER; +import static vook.server.api.web.common.swagger.annotation.IncludeBadRequestResponse.Kind.VIOLATION_BUSINESS_RULE; + +@Tag(name = "vocabulary", description = "용어집 API") +public interface VocabularyApi { + + @Operation( + summary = "용어집 조회", + security = { + @SecurityRequirement(name = "AccessToken") + } + ) + @IncludeOkResponse(implementation = VocabularyApiVocabulariesResponse.class) + CommonApiResponse> vocabularies(VookLoginUser user); + + class VocabularyApiVocabulariesResponse extends CommonApiResponse> { + } + + @Operation( + summary = "용어집 생성", + security = { + @SecurityRequirement(name = "AccessToken") + }, + description = """ + ## 비즈니스 규칙 위반 내용 + - VocabularyLimitExceeded: 사용자의 용어집 생성 제한을 초과하여 용어집을 생성할 수 없는 경우 (3개 초과)""" + ) + @IncludeOkResponse + @IncludeBadRequestResponse({INVALID_PARAMETER, VIOLATION_BUSINESS_RULE}) + CommonApiResponse createVocabulary(VookLoginUser user, VocabularyCreateRequest request); + + @Operation( + summary = "용어집 수정", + security = { + @SecurityRequirement(name = "AccessToken") + }, + description = """ + ## 비즈니스 규칙 위반 내용 + - VocabularyNotFound: 사용자의 용어집 중 해당 ID의 용어집이 존재하지 않는 경우""" + ) + @IncludeOkResponse + @IncludeBadRequestResponse({INVALID_PARAMETER, VIOLATION_BUSINESS_RULE}) + CommonApiResponse updateVocabulary( + VookLoginUser user, + String vocabularyUid, + VocabularyUpdateRequest request + ); + + @Operation( + summary = "용어집 삭제", + security = { + @SecurityRequirement(name = "AccessToken") + }, + description = """ + ## 비즈니스 규칙 위반 내용 + - VocabularyNotFound: 사용자의 용어집 중 해당 ID의 용어집이 존재하지 않는 경우""" + ) + @IncludeOkResponse + @IncludeBadRequestResponse(VIOLATION_BUSINESS_RULE) + CommonApiResponse deleteVocabulary(VookLoginUser user, String vocabularyUid); +} diff --git a/server/api/src/main/java/vook/server/api/web/vocabulary/VocabularyRestController.java b/server/api/src/main/java/vook/server/api/web/vocabulary/VocabularyRestController.java new file mode 100644 index 00000000..ffd41e18 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/vocabulary/VocabularyRestController.java @@ -0,0 +1,73 @@ +package vook.server.api.web.vocabulary; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import vook.server.api.web.common.auth.data.VookLoginUser; +import vook.server.api.web.common.response.CommonApiResponse; +import vook.server.api.web.vocabulary.reqres.VocabularyCreateRequest; +import vook.server.api.web.vocabulary.reqres.VocabularyResponse; +import vook.server.api.web.vocabulary.reqres.VocabularyUpdateRequest; +import vook.server.api.web.vocabulary.usecase.CreateVocabularyUseCase; +import vook.server.api.web.vocabulary.usecase.DeleteVocabularyUseCase; +import vook.server.api.web.vocabulary.usecase.RetrieveVocabularyUseCase; +import vook.server.api.web.vocabulary.usecase.UpdateVocabularyUseCase; + +import java.util.List; + +@RestController +@RequestMapping("/vocabularies") +@RequiredArgsConstructor +public class VocabularyRestController implements VocabularyApi { + + private final RetrieveVocabularyUseCase retrieveVocabulary; + private final CreateVocabularyUseCase createVocabulary; + private final UpdateVocabularyUseCase updateVocabulary; + private final DeleteVocabularyUseCase deleteVocabulary; + + @Override + @GetMapping + public CommonApiResponse> vocabularies( + @AuthenticationPrincipal VookLoginUser user + ) { + var command = new RetrieveVocabularyUseCase.Command(user.getUid()); + var result = retrieveVocabulary.execute(command); + List response = VocabularyResponse.from(result); + return CommonApiResponse.okWithResult(response); + } + + @Override + @PostMapping + public CommonApiResponse createVocabulary( + @AuthenticationPrincipal VookLoginUser user, + @Validated @RequestBody VocabularyCreateRequest request + ) { + var command = new CreateVocabularyUseCase.Command(user.getUid(), request.name()); + createVocabulary.execute(command); + return CommonApiResponse.ok(); + } + + @Override + @PutMapping("/{vocabularyUid}") + public CommonApiResponse updateVocabulary( + @AuthenticationPrincipal VookLoginUser user, + @PathVariable String vocabularyUid, + @Validated @RequestBody VocabularyUpdateRequest request + ) { + var command = new UpdateVocabularyUseCase.Command(user.getUid(), vocabularyUid, request.name()); + updateVocabulary.execute(command); + return CommonApiResponse.ok(); + } + + @Override + @DeleteMapping("/{vocabularyUid}") + public CommonApiResponse deleteVocabulary( + @AuthenticationPrincipal VookLoginUser user, + @PathVariable String vocabularyUid + ) { + var command = new DeleteVocabularyUseCase.Command(user.getUid(), vocabularyUid); + deleteVocabulary.execute(command); + return CommonApiResponse.ok(); + } +} diff --git a/server/api/src/main/java/vook/server/api/web/vocabulary/package-info.java b/server/api/src/main/java/vook/server/api/web/vocabulary/package-info.java new file mode 100644 index 00000000..628bf670 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/vocabulary/package-info.java @@ -0,0 +1,12 @@ +@ApplicationModule( + type = ApplicationModule.Type.OPEN, + displayName = "Vocabulary Web", + allowedDependencies = { + "vook.server.api.web.common", + "vook.server.api.domain.user", + "vook.server.api.domain.vocabulary" + } +) +package vook.server.api.web.vocabulary; + +import org.springframework.modulith.ApplicationModule; diff --git a/server/api/src/main/java/vook/server/api/web/vocabulary/reqres/VocabularyCreateRequest.java b/server/api/src/main/java/vook/server/api/web/vocabulary/reqres/VocabularyCreateRequest.java new file mode 100644 index 00000000..f48476b2 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/vocabulary/reqres/VocabularyCreateRequest.java @@ -0,0 +1,11 @@ +package vook.server.api.web.vocabulary.reqres; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record VocabularyCreateRequest( + @NotBlank + @Size(min = 1, max = 20) + String name +) { +} diff --git a/server/api/src/main/java/vook/server/api/web/vocabulary/reqres/VocabularyResponse.java b/server/api/src/main/java/vook/server/api/web/vocabulary/reqres/VocabularyResponse.java new file mode 100644 index 00000000..c47631e8 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/vocabulary/reqres/VocabularyResponse.java @@ -0,0 +1,32 @@ +package vook.server.api.web.vocabulary.reqres; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Builder; +import vook.server.api.web.vocabulary.usecase.RetrieveVocabularyUseCase; + +import java.time.LocalDateTime; +import java.util.List; + +@Builder +public record VocabularyResponse( + String uid, + String name, + Integer termCount, + + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") + LocalDateTime createdAt +) { + + public static List from(RetrieveVocabularyUseCase.Result vocabularies) { + return vocabularies.vocabularies().stream().map(VocabularyResponse::from).toList(); + } + + public static VocabularyResponse from(RetrieveVocabularyUseCase.Result.Tuple vocabulary) { + return VocabularyResponse.builder() + .uid(vocabulary.uid()) + .name(vocabulary.name()) + .termCount(vocabulary.termCount()) + .createdAt(vocabulary.createdAt()) + .build(); + } +} diff --git a/server/api/src/main/java/vook/server/api/web/vocabulary/reqres/VocabularyUpdateRequest.java b/server/api/src/main/java/vook/server/api/web/vocabulary/reqres/VocabularyUpdateRequest.java new file mode 100644 index 00000000..b8b1c5c7 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/vocabulary/reqres/VocabularyUpdateRequest.java @@ -0,0 +1,11 @@ +package vook.server.api.web.vocabulary.reqres; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record VocabularyUpdateRequest( + @NotBlank + @Size(min = 1, max = 20) + String name +) { +} diff --git a/server/api/src/main/java/vook/server/api/web/vocabulary/usecase/CreateVocabularyUseCase.java b/server/api/src/main/java/vook/server/api/web/vocabulary/usecase/CreateVocabularyUseCase.java new file mode 100644 index 00000000..9e4d15d3 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/vocabulary/usecase/CreateVocabularyUseCase.java @@ -0,0 +1,32 @@ +package vook.server.api.web.vocabulary.usecase; + +import lombok.RequiredArgsConstructor; +import vook.server.api.domain.user.logic.UserLogic; +import vook.server.api.domain.vocabulary.logic.vocabulary.VocabularyCreateCommand; +import vook.server.api.domain.vocabulary.logic.vocabulary.VocabularyLogic; +import vook.server.api.domain.vocabulary.model.vocabulary.UserUid; +import vook.server.api.globalcommon.annotation.UseCase; + +@UseCase +@RequiredArgsConstructor +public class CreateVocabularyUseCase { + + private final UserLogic userLogic; + private final VocabularyLogic vocabularyLogic; + + public void execute(Command command) { + userLogic.validateCompletedUserByUid(command.userUid()); + + vocabularyLogic.create(VocabularyCreateCommand.builder() + .name(command.name()) + .userUid(new UserUid(command.userUid())) + .build() + ); + } + + public record Command( + String userUid, + String name + ) { + } +} diff --git a/server/api/src/main/java/vook/server/api/web/vocabulary/usecase/DeleteVocabularyUseCase.java b/server/api/src/main/java/vook/server/api/web/vocabulary/usecase/DeleteVocabularyUseCase.java new file mode 100644 index 00000000..3b997e87 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/vocabulary/usecase/DeleteVocabularyUseCase.java @@ -0,0 +1,32 @@ +package vook.server.api.web.vocabulary.usecase; + +import lombok.RequiredArgsConstructor; +import vook.server.api.domain.user.logic.UserLogic; +import vook.server.api.domain.vocabulary.logic.vocabulary.VocabularyLogic; +import vook.server.api.domain.vocabulary.model.vocabulary.Vocabulary; +import vook.server.api.globalcommon.annotation.UseCase; +import vook.server.api.policy.VocabularyPolicy; + +@UseCase +@RequiredArgsConstructor +public class DeleteVocabularyUseCase { + + private final UserLogic userLogic; + private final VocabularyLogic vocabularyLogic; + private final VocabularyPolicy vocabularyPolicy; + + public void execute(Command command) { + userLogic.validateCompletedUserByUid(command.userUid()); + + Vocabulary vocabulary = vocabularyLogic.getByUid(command.vocabularyUid()); + vocabularyPolicy.validateOwner(command.userUid(), vocabulary); + + vocabularyLogic.delete(command.vocabularyUid()); + } + + public record Command( + String userUid, + String vocabularyUid + ) { + } +} diff --git a/server/api/src/main/java/vook/server/api/web/vocabulary/usecase/RetrieveVocabularyUseCase.java b/server/api/src/main/java/vook/server/api/web/vocabulary/usecase/RetrieveVocabularyUseCase.java new file mode 100644 index 00000000..96399269 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/vocabulary/usecase/RetrieveVocabularyUseCase.java @@ -0,0 +1,56 @@ +package vook.server.api.web.vocabulary.usecase; + +import lombok.RequiredArgsConstructor; +import vook.server.api.domain.user.logic.UserLogic; +import vook.server.api.domain.vocabulary.logic.vocabulary.VocabularyLogic; +import vook.server.api.domain.vocabulary.model.vocabulary.UserUid; +import vook.server.api.domain.vocabulary.model.vocabulary.Vocabulary; +import vook.server.api.globalcommon.annotation.UseCase; + +import java.time.LocalDateTime; +import java.util.List; + +@UseCase +@RequiredArgsConstructor +public class RetrieveVocabularyUseCase { + + private final UserLogic userLogic; + private final VocabularyLogic vocabularyLogic; + + public Result execute(Command command) { + userLogic.validateCompletedUserByUid(command.userUid()); + + List vocabularies = vocabularyLogic.findAllBy(new UserUid(command.userUid())); + List tupleList = vocabularies + .stream() + .map(Result.Tuple::from) + .toList(); + return new Result(tupleList); + } + + public record Command( + String userUid + ) { + } + + public record Result( + List vocabularies + ) { + public record Tuple( + String uid, + String name, + Integer termCount, + LocalDateTime createdAt + ) { + + public static Tuple from(Vocabulary vocabulary) { + return new Tuple( + vocabulary.getUid(), + vocabulary.getName(), + vocabulary.termCount(), + vocabulary.getCreatedAt() + ); + } + } + } +} diff --git a/server/api/src/main/java/vook/server/api/web/vocabulary/usecase/UpdateVocabularyUseCase.java b/server/api/src/main/java/vook/server/api/web/vocabulary/usecase/UpdateVocabularyUseCase.java new file mode 100644 index 00000000..b8565c86 --- /dev/null +++ b/server/api/src/main/java/vook/server/api/web/vocabulary/usecase/UpdateVocabularyUseCase.java @@ -0,0 +1,41 @@ +package vook.server.api.web.vocabulary.usecase; + +import lombok.RequiredArgsConstructor; +import vook.server.api.domain.user.logic.UserLogic; +import vook.server.api.domain.vocabulary.logic.vocabulary.VocabularyLogic; +import vook.server.api.domain.vocabulary.logic.vocabulary.VocabularyUpdateCommand; +import vook.server.api.domain.vocabulary.model.vocabulary.Vocabulary; +import vook.server.api.globalcommon.annotation.UseCase; +import vook.server.api.policy.VocabularyPolicy; + +@UseCase +@RequiredArgsConstructor +public class UpdateVocabularyUseCase { + + private final UserLogic userLogic; + private final VocabularyLogic vocabularyLogic; + private final VocabularyPolicy vocabularyPolicy; + + public void execute(Command command) { + userLogic.validateCompletedUserByUid(command.userUid()); + + Vocabulary vocabulary = vocabularyLogic.getByUid(command.vocabularyUid()); + vocabularyPolicy.validateOwner(command.userUid(), vocabulary); + + vocabularyLogic.update(command.toServiceCommand()); + } + + public record Command( + String userUid, + String vocabularyUid, + String name + ) { + public VocabularyUpdateCommand toServiceCommand() { + return VocabularyUpdateCommand + .builder() + .vocabularyUid(vocabularyUid) + .name(name) + .build(); + } + } +} diff --git a/server/api/src/main/resources/application-local.yml b/server/api/src/main/resources/application-local.yml new file mode 100644 index 00000000..e8613856 --- /dev/null +++ b/server/api/src/main/resources/application-local.yml @@ -0,0 +1,41 @@ +spring: + datasource: + driver-class-name: org.mariadb.jdbc.Driver + url: jdbc:mariadb://localhost:3307/vook + username: user + password: userPw + jpa: + hibernate: + ddl-auto: validate + show-sql: true + properties: + hibernate: + highlight_sql: false + format_sql: true + security: + oauth2: + client: + registration: + google: + client-name: google + client-id: ${OAUTH2_GOOGLE_CLIENT_ID} + client-secret: ${OAUTH2_GOOGLE_CLIENT_SECRET} + redirect-uri: http://localhost:8080/login/oauth2/code/google + authorization-grant-type: authorization_code + scope: + - profile + - email +server: + port: 8080 +service: + baseUrl: http://localhost:8080 + meilisearch: + host: http://localhost:7700 + apiKey: aSampleMasterKey + jwt: + secret: vmfhaltmskdlstkfkdgodyroqkfwkdba + oauth2: + tokenNoticeUrl: http://localhost:3000/auth/token + loginFailUrl: http://localhost:3000/auth?error + accessTokenExpiredMinute: 30 # 30 minutes + refreshTokenExpiredMinute: 10080 # 60 * 24 * 7 == 1 week diff --git a/server/api/src/main/resources/application-modulith.yml b/server/api/src/main/resources/application-modulith.yml new file mode 100644 index 00000000..e2501d14 --- /dev/null +++ b/server/api/src/main/resources/application-modulith.yml @@ -0,0 +1,3 @@ +spring: + modulith: + detection-strategy: explicitly-annotated diff --git a/server/api/src/main/resources/application.yml b/server/api/src/main/resources/application.yml new file mode 100644 index 00000000..6a04a945 --- /dev/null +++ b/server/api/src/main/resources/application.yml @@ -0,0 +1,14 @@ +spring: + profiles: + default: local + group: + local: local, modulith + default: default, modulith + jpa: + properties: + hibernate.jdbc.time_zone: UTC +springdoc: + swagger-ui: + path: /swagger-ui.html + api-docs: + path: /api-docs diff --git "a/server/api/src/main/resources/init/\353\215\260\353\252\250.tsv" "b/server/api/src/main/resources/init/\353\215\260\353\252\250.tsv" new file mode 100644 index 00000000..56ed03b0 --- /dev/null +++ "b/server/api/src/main/resources/init/\353\215\260\353\252\250.tsv" @@ -0,0 +1,76 @@ +term synonyms meaning +SDK Software Development Kit 특정 플랫폼이나 운영체재를 위한 앱을 만드는데 필요한 도구와 코드 모음 SDK는 앱 개발을 쉽고 빠르게 만드는 도구로 개발자가 처음부터 모든 것을 스스로 구축할 필요가 없어 시간과 노력을 절약할 수 있습니다. 예) 개발자가 지도를 앱에 추가하고 싶다면 구글 지도 SDK를 사용할 수 있습니다. 이 SDK에는 지도 표시, 사용자 위치 추적, 경로 검색 등의 기능을 위한 코드와 도구가 포함되어 있습니다. +API Application Programming Interface 프로그램이 서로 통신하고 협력할 수 있는 필수적인 연결 도구 API는 크게 데이터 공유, 기능 활용, 시스템 통합의 역할을 수행합니다. 데이터 공유 : 다른 프로그램의 데이터를 제공합니다. 가령, 날씨 앱은 API로 기상청의 날씨 데이터를 가져올 수 있습니다. 기능 활용 : 다른 프로그램의 기능을 활용할 수 있도록 합니다. 가령, 음악 앱은 API로 스포티파이 등의 음악 스트리밍 서비스의 음악을 재생할 수 있습니다. 시스템 통합 : 서로 다른 시스템을 연결해 함께 작동하도록 합니다. 가령, 온라인 쇼핑몰은 API로 주문 처리, 결제 처리 및 배송 추적을 위한 다른 시스템과 통합할 수 있습니다. +IDE Integrated Development Environment 통합 개발 환경의 약자로 개발자가 소프트웨어를 빠르고 쉽게 개발하도록 돕는 도구 IDE는 코딩, 컴파일, 디버깅 및 테스트 등 소프트웨어 개발 프로세스의 모든 단계를 위한 다양한 기능을 제공합니다. +In-app browser 인앱 브라우저 모바일 앱 혹은 데스크톱 앱 내 내장된 웹 브라우저 별도의 웹 브라우저 앱을 실행하지 않아도 앱에서 웹페이지를 볼 수 있도록 합니다. +External browser 익스터널 브라우저 익스터널 브라우저는 별도의 앱으로 설치되는 웹 브라우저입니다. 대표적으로 크롬, 사파리, 파이어폭스, 엣지 등이 있습니다. +CDC Change Data Capture 데이터베이스에 변경된 데이터를 실시간으로 추적하고 캡처하는 기술 데이터베이스에 삽입, 수정, 삭제 등의 변경 작업이 발생할 때마다 해당 변경사항을 기록하고 다른 시스템이나 프로세스에 알려줍니다. CDC는 데이터베이스의 로그 파일을 모니터링하거나 트리거를 사용해 데이터 변경사항을 감지합니다. 변경사항이 감지되면 CDC는 해당 변경 사항을 메시지 형식으로 포장하고 이를 큐에 저장합니다. +Streaming 스트리밍 미디어 파일을 인터넷을 통해 실시간으로 전송하고 재생하는 기술 이전에는 음악이나 영상을 다운로드한 후에 재생할 수 있었다면 스트리밍 기술로 파일을 다운로드하지 않고도 바로 듣거나 재생할 수 있게 되었습니다. 스트리밍에는 라이브 스트리밍과 온디맨드 스트리밍으로 나눌 수 있습니다. 라이브 스트리밍은 실시간으로 방송하고 시청하는 방식이며, 온디맨드 스트리밍은 이미 녹화된 음악, 동영상, 게임 등을 선택해 시청하는 방식입니다. +컴파일러 Compiler 개발자가 작성한 소스 코드를 컴퓨터가 이해하고 실행할 수 있는 기계어로 변환하는 소프트웨어 도구 컴파일러는 대표적으로 개발자의 소스 코드를 읽고 분석하며 소스 코드의 문법적 오류를 확인하고 오류 메시지를 출력합니다. 또한 생성된 기계어를 실행 가능한 파일 형식으로 저장합니다. +클라이언트 Client 클라이언트는 사용자 컴퓨터에 설치된 소프트웨어로 서버에 요청을 보내고 서버로부터 응답을 받습니다. 웹 브라우저, 이메일 클라이언트, 파일 공유 프로그램 등이 클라이언트에 속합니다. +서버 Server 서버는 네트워크에 연결된 컴퓨터로 클라이언트의 요청을 받아 처리하고 결과를 응답으로 보냅니다. 웹 서버, 데이터 베이스 서버, 메일 서버 등이 서버에 속합니다. +프론트엔드 Front-end 사용자가 직접 보고 상호 작용하는 웹페이지의 눈에 보이는 부분을 담당합니다. 웹 브라우저에서 보이는 모든 요소 (텍스트, 이미지, 영상, 버튼 등)와 사용자의 상호 작용(클릭, 스크롤, 입력)이 프론트엔드에 속합니다. +백엔드 Back-end 사용자가 직접 볼 수 없는 웹페이지의 서버를 담당합니다. 데이터베이스 관리, 서버 로직 처리, 사용자 인증, 보안 등 웹페이지의 핵심 기능을 구현합니다. +디버그 Debug 프로그램에서 발생하는 오류나 문제를 찾아 해결하는 과정 디버깅은 프로그램의 품질을 높이고 사용자에게 안정적인 경험을 제공합니다. 디버깅 과정에서 발생하는 오류를 정확히 재현해보고 코드를 분석해 원인을 파악합니다. 오류의 원인을 파악한 후 코드를 수정하여 오류를 해결하고 테스트를 통해 오류가 해결되었는지 확인합니다. 해결 후에는 디버깅 과정에서 발생한 오류, 오류 원인, 해결 방법 등을 문서화합니다. +빌드 Build 소스 코드를 실행 가능한 파일로 변환하는 과정 먼저 컴파일러로 소스 코드를 기계어로 변환합니다. 컴파일된 객체 파일을 연결하고 의존하는 라이브러리를 추가해 실행 가능한 파일을 만듭니다. 실행 가능한 파일과 기타 필요한 파일을 하나의 패키지로 묶습니다. 컴파일은 소스 코드를 기계어로 변환하는 과정이며 빌드 과정의 한 단계인 반면, 빌드는 소스 코드를 실행 가능한 파일로 변환하는 전체적인 과정을 의미합니다. +배포 Deployment 완성된 코드를 실제 사용 환경에 설치하고 실행 가능한 형태로 만드는 과정 배포 방법에는 수동 배포, 자동 배포, 컨테이너 배포 등이 있습니다. 수동 배포는 개발자가 직접 서버에 연결해 배포 작업을 수행하는 방식입니다. 자동 배포는 CI/CD 도구를 사용해 배포 과정을 자동화 하는 방식입니다. 컨테이너 배포는 Docker와 같은 컨테이너 기술을 사용해 배포하는 방식입니다. +반응형 웹 Responsive Website 웹사이트가 다양한 기기의 화면 크기와 해상도에 맞게 자동으로 디자인 및 레이아웃을 조정하도록 돕는 웹 디자인 접근 방식입니다. +SSO Single Sign On 사용자가 여러 시스템이나 애플리케이션에 한 번의 로그인 만으로 액세스할 수 있도록 하는 인증 방식 사용자는 시스템마다 별도 계정을 만들고 로그인 할 필요 없이 하나의 아이디와 비밀번호만 사용해 여러 시스템에 로그인할 수 있습니다. SSO는 다음과 같은 단계를 거쳐 작동합니다. 1. 사용자는 하나의 시스템에 로그인합니다. 2. 사용자 인증 정보는 중앙 인증 서버에 전송됩니다. 3. 중앙 인증 서버는 사용자 인증 정보를 검사하고 유효하면 인증 토큰을 생성합니다. 4. 인증 토큰은 사용자의 브라우저에 저장됩니다. 5. 사용자가 다른 시스템에 액세스하려고 할 때 브라우저는 저장된 인증 토큰을 자동으로 전송합니다. 6. 중앙 인증 서버는 인증 토큰을 검사하고 유효하면 사용자를 자동으로 로그인시킵니다. +SaaS Software as a Service 소프트웨어를 서비스로 제공하는 모델 SaaS 서비스 제공업체는 웹 브라우저를 통해 사용자가 액세스 할 수 있는 소프트웨어 애플리케이션을 개발, 운영 및 유지 관리합니다. 사용자는 별도의 소프트웨어를 설치하거나 하드웨어를 구매할 필요 없이 인터넷 연결만 있으면 서비스를 이용할 수 있습니다. +PasS Platform as a Service 소프트웨어 개발 및 배포를 위한 플랫폼을 서비스로 제공하는 모델 PaaS 플랫폼은 개발자들이 웹 애플리케이션을 구축, 테스트, 배포 및 관리하는 데 필요한 모든 기능과 도구를 제공합니다. 개발자는 PaaS 플랫폼을 사용하여 핵심적인 개발 작업에 집중할 수 있으며, 서버 관리, 운영 체제 관리, 네트워킹 등의 인프라 관리 작업은 PaaS 플랫폼 제공업체가 담당합니다. +IaaS Infrastructure as a Service 컴퓨팅, 스토리지, 네트워킹과 같은 기반 IT 인프라를 서비스로 제공하는 모델 IaaS 서비스 제공업체는 사용자에게 가상 머신, 서버, 스토리지, 네트워킹 장비 등을 제공하며, 사용자는 이러한 자원을 자유롭게 사용하여 원하는 시스템 및 애플리케이션을 구축할 수 있습니다. +SOAP API Simple Object Access Protocol API 웹 서비스를 위한 표준 프로토콜 XML 기반 메시지 형식을 사용하여 네트워크를 통해 분산된 응용 프로그램 간 통신을 지원합니다. SOAP API는 표준화된 웹 서비스 프로토콜이지만, 복잡하고 느리고 비효율적인 단점이 있습니다. 이러한 단점을 해결하기 위해 REST API, JSON API와 같은 다른 웹 서비스 프로토콜들이 등장했습니다. +RPC Remote Procedure Call 네트워크를 통해 원격 시스템에 있는 함수를 마치 로컬 시스템에 있는 함수처럼 호출하는 기술 개발자는 호출되는 함수가 다른 시스템에 있는지 여부를 확인하지 않고 함수를 호출할 수 있습니다. RPC는 분산 시스템에서 서로 다른 시스템 간 통신과 자원 공유를 용이하게 만들며, 클라이언트 - 서버 애플리케이션 개발에 많이 사용됩니다. +웹소캣 Websocket API 웹 클라이언트와 웹 서버 간에 지속적인 양방향 실시간 통신을 가능하게 하는 API 기존의 HTTP 기반 웹 요청과 달리 WebSocket은 한 번의 TCP 연결을 통해 클라이언트와 서버 간에 지속적으로 데이터를 주고받을 수 있으므로, 채팅, 게임, 실시간 데이터 갱신 등 실시간으로 데이터 송수신이 필요한 애플리케이션에 적합합니다. +REST API Representational State Transfer API REST 아키텍처 스타일의 디자인 원칙을 준수하는 API REST API를 RESTful API라고 불리기도 합니다. REST API는 일관되고 이해하기 쉬운 인터페이스를 제공하며, 다양한 플랫폼과 프로그래밍 언어에서 쉽게 사용할 수 있는 장점이 있습니다. REST API는 HTTP method를 사용하여 자원에 대한 작업을 수행합니다. 일반적으로 사용되는 HTTP method는 다음과 같습니다. GET: 자원을 조회합니다. POST: 자원을 생성합니다. PUT: 자원을 업데이트합니다. DELETE: 자원을 삭제합니다. +HTTP Hypertext Transfer Protocol//n하이퍼텍스트 전송 프로토콜 웹 브라우저와 웹 서버 간의 통신을 위한 기본 프로토콜 웹사이트 방문 시 사용자가 입력한 URL을 요청하고, 웹 서버는 요청에 맞는 웹 페이지나 다른 데이터를 응답으로 전송하는 데 사용됩니다. HTTP는 기본적으로 데이터를 평문으로 전송하기 때문에 도청이나 위변조 위험이 있어 최근에는 HTTPS를 사용합니다. +HTTPS Hypertext Transfer Protocol Secure HTTP에 SSL/TLS 프로토콜을 추가하여 안전하게 데이터를 전송하도록 보안 강화한 프로토콜입니다. HTTPS는 웹 브라우저와 웹 서버 간 모든 통신을 암호화해 도청이나 위변조를 방지합니다. 웹 서버의 신원을 인증해 위조 사이트로부터 사용자를 보호하며, 데이터 전송 과정에서 데이터가 변경되지 않았는지 확인합니다. +SSL Secure Sockets Layer//n보안 소켓 계층 웹 브라우저와 웹 서버 간의 통신을 암호화하여 보안하는 프로토콜 SSL은 현재 TLS(Transport Layer Security)로 이름이 변경되었지만, 여전히 SSL이라는 용어가 널리 사용되고 있습니다. +TLS 전송 계층 보안 인터넷 상의 두 시스템 간의 통신을 보호하는 보안 프로토콜 데이터 암호화, 신원 인증, 데이터 무결성 보장 기능을 제공하여 사용자의 개인정보, 로그인 정보, 금융 정보 등을 안전하게 보호합니다. 이전에 사용되었던 SSL(Secure Sockets Layer) 프로토콜의 후속 버전입니다. +프레임워크 Framework 소프트웨어 개발 과정을 돕는 도구와 라이브러리의 집합 프레임워크는 개발자가 애플리케이션의 기본 구조 및 핵심 기능을 빠르고 쉽게 구축할 수 있도록 기본적인 뼈대를 제공합니다. 대표적으로 웹에선 Django, Ruby on Rails 등이 있으며, 모바일에선 React Native, Flutter 등이 있습니다. +라이브러리 Library 라이브러리는 개발자가 특정 기능이나 작업을 수행할 수 있도록 재사용 가능한 코드 모듈을 제공합니다. 가령 NumPy, Pandas 등 데이터 처리 관련 라이브러리는 복잡한 데이터 분석 및 처리 작업을 수행하는 데 도움을 줍니다. +플러그인 Plug-in 기존 소프트웨어에 새로운 기능을 추가하거나 기존 기능을 확장하는 소프트웨어 구성 요소 일반적으로 독립적인 프로그램으로 작동하며, 호환되는 호스트 프로그램에 설치해야 합니다. 플러그인은 다양한 분야에서 사용되며, 웹 브라우저, 그래픽 편집 프로그램, 비디오 편집 프로그램, 게임 엔진 등에서 흔히 볼 수 있습니다. +파라미터 인자//nParameter 함수는 특정 작업을 수행하도록 설계된 코드 블록입니다. 함수를 호출할 때 원하는 결과를 얻도록 함수에 데이터를 전달해야 합니다. 이 데이터를 파라미터라고 부릅니다. +Argument 인자값 인자값은 함수 호출 시 실제 값을 의미하며, 파라미터에 전달됩니다. 인자값은 다양한 형태의 데이터, 숫자, 문자열, 리스트, 객체 등을 포함할 수 있습니다. 함수는 전달된 인자값을 사용하여 계산을 수행하거나 작업을 처리합니다. +AJAX Asynchronous Javascript and XML 비동기 JavaScript 및 XML의 약자로, 웹 페이지를 부분적으로 다시 로드하지 않고도 서버와 데이터를 주고받을 수 있는 웹 개발 기술 AJAX를 사용하면 사용자가 웹 페이지를 새로고침하지 않고도 데이터를 업데이트하고 새로운 콘텐츠를 로드할 수 있어 웹사이트의 사용자 경험을 크게 향상시킬 수 있습니다. 대표적으로 검색창에 입력하는 단어에 대한 자동 완성 기능, 무한 스크롤, 실시간 데이터 업데이트 등에 활용됩니다. +멀티스레드 Multi-thread 하나의 프로그램에서 동시에 여러 개의 작업을 수행하는 프로그래밍 방식입니다. 멀티스레드를 사용하면 컴퓨터의 여러 개의 CPU 코어를 효율적으로 활용하여 프로그램 성능을 향상시킬 수 있습니다. +렌더링 Rendering 렌더링은 2D 또는 3D 모델을 기반으로 이미지 또는 영상 컴퓨터 그래픽스, 영화 제작, 비디오 게임, 건축 시각화 등 다양한 분야에서 활용됩니다. 렌더링 과정은 모델 정보를 시각적 표현으로 변환하는 복잡한 계산 과정을 포함하며, 빛, 색상, 질감, 그림자 등 다양한 요소를 고려하여 현실감 넘치는 이미지 또는 영상을 생성합니다. +샌드박스 SandBox 실제 환경에 영향을 미치지 않고 새로운 소프트웨어나 기능을 테스트할 수 있는 가상의 공간입니다. 샌드박스는 개발자가 버그를 찾고 코드를 개선할 수 있는 안전한 환경을 제공합니다. +데이터 레이크 Data lake 구조화된 또는 반구조화된, 혹은 구조화되지 않은 방대한 양의 데이터를 저장하는 저장소 데이터 레이크는 기존의 데이터 웨어하우스와는 다릅니다. 데이터 웨어하우스는 일반적으로 미리 정의된 스키마를 사용하여 구조화된 데이터를 저장하는 반면, 데이터 레이크는 스키마 없이 데이터를 저장할 수 있습니다. 즉, 데이터 레이크에는 모든 유형의 데이터를 저장할 수 있으며, 데이터가 저장된 후에도 데이터 스키마를 변경할 수 있습니다. +데브옵스 DevOps 데브옵스(DevOps)는 소프트웨어 개발(Development)과 운영(Operations)을 하나의 통합된 프로세스로 연결하여 소프트웨어를 빠르게, 안정적으로, 그리고 효율적으로 제공하는 문화와 관행들의 집합 데브옵스는 개발팀과 운영팀 간의 협업을 강조하며, 자동화, 지속적인 통합 및 배포, 모니터링 등을 통해 소프트웨어 개발 및 제공 프로세스를 개선하는 데 중점을 둡니다. +CI/CD Continuous Integration/Continuous Delivery//n지속적인 통합/지속적인 배포 소프트웨어 개발 프로세스를 자동화하여 소프트웨어를 빠르고 안정적으로 제공하는 방법 CI/CD는 다음 두 단계로 구성됩니다. - 지속적인 통합(Continuous Integration, CI): 개발자가 코드를 변경할 때마다 코드를 자동으로 통합하고 테스트하는 프로세스입니다. CI는 개발 중에 발생하는 버그를 빠르게 발견하고 해결하는 데 도움이 됩니다. - 지속적인 배포(Continuous Delivery, CD): 테스트를 통과한 코드를 자동으로 배포 환경에 배포하는 프로세스입니다. CD는 소프트웨어를 빠르게 출시하고 업데이트하는 데 도움이 됩니다. +파싱 Parsing 컴퓨터 과학에서 특정 형식으로 구성된 데이터를 분석하고 해석하는 과정 파싱은 주로 텍스트 기반 데이터, 프로그래밍 언어 소스 코드, XML 문서 등을 처리하는 데 사용됩니다. 파싱 과정에서 데이터의 구조를 이해하고 의미 있는 정보를 추출합니다. +핑 Ping 컴퓨터 네트워크에서 두 장치 간의 연결성을 테스트하는 데 사용되는 도구 핑은 다음과 같은 다양한 상황에서 사용됩니다. - 네트워크 문제 진단: 네트워크 연결이 끊겨 있거나 속도가 느린 경우 핑을 사용하여 문제의 원인을 파악할 수 있습니다. - 웹 사이트 성능 테스트: 웹 사이트에 연결하는 데 걸리는 시간을 측정하는 데 핑을 사용할 수 있습니다. - 새로운 네트워크 장치 설정 확인: 새로 설치한 네트워크 장치가 올바르게 작동하는지 확인하는 데 핑을 사용할 수 있습니다. +SRE Site Reliability Engineering 소프트웨어 시스템의 안정성, 확장성, 및 성능을 유지하는 것을 담당하는 엔지니어링 분야 SRE는 전통적인 소프트웨어 개발 및 운영 팀의 역할을 결합하여 소프트웨어를 서비스로 제공하는 데 필요한 모든 측면을 관리합니다. +SSH Secure Shell Protocol 네트워크를 통해 두 컴퓨터 간의 안전한 연결을 제공하는 프로토콜 SSH는 다음과 같은 다양한 목적으로 사용됩니다. - 원격 컴퓨터 관리: 시스템 관리자는 SSH를 사용하여 원격 컴퓨터에 로그인하고 관리 작업을 수행할 수 있습니다. - 파일 전송: 사용자는 SSH를 사용하여 두 컴퓨터 간에 파일을 안전하게 전송할 수 있습니다. - 애플리케이션 실행: 사용자는 SSH를 사용하여 원격 컴퓨터에서 애플리케이션을 실행할 수 있습니다. - 포트 포워딩: 사용자는 SSH를 사용하여 한 컴퓨터의 포트를 다른 컴퓨터의 포트로 전달할 수 있습니다. +성능 테스트 BMT//nBench Marking Test 한 가지 시스템이나 제품의 성능을 객관적으로 측정하고 비교하기 위한 테스트 BMT는 일반적으로 동일한 조건에서 서로 다른 시스템 또는 제품을 테스트하여 어떤 시스템 또는 제품이 더 우수한 성능을 제공하는지를 확인하는 데 사용됩니다. +gRPC Google RPC//nGoogle Remote Procedure Call RPC(Remote Procedure Call)는 네트워크를 통해 원격 시스템에 있는 프로시저를 호출하는 기술입니다. RPC는 마치 로컬 시스템에서 함수를 호출하는 것처럼 원격 시스템의 프로시저를 호출할 수 있도록 해줍니다. gRPC는 Google에서 개발한 오픈 소스 RPC 프레임워크입니다. +NAS Network Attached Storage 네트워크를 통해 여러 사용자가 파일 저장 및 공유를 할 수 있도록 하는 장치 NAS는 다양한 용도로 사용됩니다. 개인 용도로는 사진, 음악, 비디오 등의 개인 파일을 저장하고 공유하는 데 사용할 수 있습니다. 사업 용도로는 문서, 스프레드시트, 프레젠테이션과 같은 업무 파일을 저장하고 공유하는 데 사용할 수 있습니다. 또한 NAS는 데이터 백업, 파일 스트리밍, 미디어 서버 등으로도 사용할 수 있습니다. +CDN Contents Delivery Network 웹 콘텐츠를 사용자에게 더 빠르고 안정적으로 제공하기 위해 지리적으로 분산된 네트워크를 사용하는 기술 웹사이트, 이미지, 동영상, 스트리밍 미디어 등 다양한 콘텐츠를 빠르게 전송하는 데 효과적으로 사용됩니다. +DNS Domain Name System 인터넷 주소록이라고도 불리는 시스템으로, 도메인 이름(예: www.example.com)을 IP 주소(예: 192.0.2.44)로 변환하는 역할을 합니다. 쉽게 말해, 사람이 쉽게 기억할 수 있는 도메인 이름을 컴퓨터가 이해할 수 있는 IP 주소로 변환하는 시스템이라고 볼 수 있습니다. +엔드포인트 Endpoint 네트워크에서 데이터를 주고받을 수 있는 논리적 또는 물리적 위치 간단히 말해서, 엔드포인트는 네트워크 상에서 연결하고 상호 작용할 수 있는 문과 같습니다. 각 엔드포인트는 고유한 IP 주소 또는 식별자를 가지고 있으며, 네트워크를 통해 다른 엔드포인트와 통신할 수 있습니다. +Request 요청 엔드포인트가 다른 엔드포인트에게 데이터나 작업을 수행하도록 요청하는 메시지 +Response 응답 요청에 대한 응답으로 엔드포인트에서 다른 엔드포인트로 전송하는 메시지 +JSON JavaSript Object Notation Javascript에서 사용하는 객체 정의 방법 +포트 Port 컴퓨터 또는 네트워크 장치에서 특정 애플리케이션이나 서비스를 식별하는 데 사용되는 논리적 번호 포트는 여러 애플리케이션이 동시에 같은 컴퓨터에서 실행될 수 있도록 하는 가상 통로와 같습니다. 각 포트는 고유한 번호를 가지고 있으며, 0에서 65535까지의 범위를 사용할 수 있습니다. +프로토콜 Protocol 두 시스템 간의 통신 방식을 정의하는 규칙 집합 프로토콜은 두 시스템이 서로 어떻게 대화해야 하는지에 대한 약속과 같습니다. 프로토콜에선 전송되는 데이터 형식과 데이터 전송 방식 그리고 오류 처리에 대해 정의합니다. +로드 밸런싱 Load Balancing 네트워크 트래픽을 여러 서버에 분산하여 처리하는 기술 로드 밸런싱을 통해 서버 부하를 줄이고, 성능을 향상시키며, 사용자 가용성을 높일 수 있습니다. 가령 온라인 쇼핑몰의 경우 높은 트래픽을 처리하고 고객에게 빠른 응답 속도를 제고하기 위해 사용합니다. 온라인 게임의 경우 많은 플레이어를 동시에 처리하고 게임 지연을 줄이기 위해 사용합니다. +가상화 Virtualization 하나의 컴퓨터를 여러 개의 가상 컴퓨터처럼 사용할 수 있도록 하는 기술 +데이터베이스 Database 구조화된 형태로 데이터를 저장하고 관리하는 시스템 데이터를 체계적으로 정리하고, 쉽게 찾고 사용할 수 있도록 하는 도구라고 생각하면 됩니다. +테이블 Table 테이블은 행과 열로 구성된 데이터 구조로, 데이터베이스에서 데이터를 저장하는 기본 단위 각 행은 하나의 데이터 레코드를 나타내고, 각 열은 레코드의 특정 속성을 나타냅니다. 테이블은 마치 스프레드시트와 유사한 형태로 데이터를 보여주고, 행과 열을 사용하여 원하는 데이터를 쉽게 찾고 조작할 수 있도록 합니다. +기본 키 Primary Key 테이블 내의 각 레코드를 고유하게 식별하는 열 또는 열 집합 다른 열의 값이 중복될 수 있는 반면, 기본 키 값은 항상 고유하고 null 값을 허용하지 않습니다. 기본 키는 데이터 무결성을 유지하고 중복된 데이터를 방지하는 데 중요한 역할을 합니다. +외래 키 Foreign Key 한 테이블의 열을 다른 테이블의 기본 키 열과 연결하는 관계 외래키는 두 테이블 간의 데이터 관계를 정의하고 데이터 무결성을 유지하는 데 도움이 됩니다. 예를 들어, '고객' 테이블에 '주소' 테이블의 외래키 열이 있다면, '고객' 테이블의 각 레코드는 '주소' 테이블의 고유한 레코드를 참조하게 됩니다. +인덱스 Index 테이블의 특정 열에 대한 검색 속도를 향상시키는 데이터 구조 책의 색인과 유사하게, 인덱스는 데이터베이스 시스템이 테이블 내의 특정 레코드를 빠르게 찾도록 도와줍니다. 자주 검색되는 열에 인덱스를 생성하면 데이터 검색 속도를 크게 향상시킬 수 있으며, 데이터베이스 성능을 개선하는 데 효과적인 방법입니다. +SQL Structured Query Language 관계형 데이터베이스에서 데이터를 조작하는 표준 언어 데이터베이스에 데이터를 저장하고, 검색하고, 삭제하고, 수정하는 데 사용하는 데이터베이스 프로그래밍 언어라고 생각하면 됩니다. +정규화 Normalization 데이터베이스의 데이터 구조를 체계적으로 조직하여 데이터 중복을 최소화하고, 데이터 무결성을 유지하며, 데이터베이스의 효율성을 높이는 프로세스 데이터베이스 정규화는 데이터 중복을 최소화하고 데이터 무결성을 유지하는 목적으로 사용됩니다. 또한 데이터베이스 구조를 최적화해 데이터 검색 및 조작 속도를 높이고 데이터베이스 관리 작업을 용이하게 합니다. +트랜잭션 Transaction 데이터베이스와 같은 시스템의 상태를 일관되게 변경하는 작업 단위 트랜잭션은 ACID 특성을 만족하도록 설계되어 데이터 무결성을 유지합니다. ACID는 트랜잭션의 핵심 특성을 나타내는 약자입니다. - 원자성 (Atomicity): 트랜잭션은 하나의 작업 단위로 실행되며, 트랜잭션이 완료되거나 실패할 때까지 부분적인 작업이 수행되지 않습니다. - 일관성 (Consistency): 트랜잭션이 완료되면 데이터베이스는 항상 일관된 상태를 유지합니다. 트랜잭션 중간에 데이터베이스 상태가 변경되지 않습니다. - 격리성 (Isolation): 동시에 실행되는 여러 트랜잭션은 서로 영향을 미치지 않습니다. 각 트랜잭션은 서로 독립적으로 실행되는 것처럼 작동합니다. - 지속성 (Durability): 트랜잭션이 성공적으로 완료되면, 그 결과는 영구적으로 저장됩니다. 시스템 장애나 데이터 손실에도 영향을 받지 않습니다. +컨벤션 Convention 일관되고 읽기 쉬운 코드 베이스 유지를 위해 개발자가 따르는 합의된 규칙 +클래스 Class 객체의 청사진 역할을 하는 설계 요소 붕어빵 틀 자체를 클래스라고 생각하면 됩니다. 붕어빵 틀은 붕어빵의 속성(팥, 깨, 모양 등)과 행동(먹는 것)을 정의하고 있으며, 이 틀을 사용하여 여러 개의 붕어빵(객체)를 만들 수 있습니다. +객체 Object 데이터와 그 데이터를 처리하는 메서드를 묶어 놓은 기본 단위 붕어빵 틀을 이용하여 만든 붕어빵 하나하나를 객체라고 생각하면 됩니다. 각 붕어빵은 틀에서 정의된 속성(팥, 깨, 모양 등)과 행동(먹는 것)을 가지고 있습니다. +객체 지향 프로그래밍 OOP 객체라는 기본 단위를 사용하여 프로그램을 설계하고 작성하는 프로그래밍 패러다임 객체는 데이터(속성)와 행동(메서드)을 가지고 있으며, 서로 상호작용하여 프로그램을 구성합니다. +CSS Cascading Style Sheets HTML 문서의 스타일을 정의하는 데 사용되는 스타일 시트 언어 HTML 문서가 웹 페이지의 구조를 담당한다면, CSS는 웹 페이지의 디자인을 담당합니다. +레거시 Regacy 오래된 기술로 개발되었지만, 여전히 사용되고 있는 컴퓨터 시스템, 소프트웨어, 하드웨어를 의미합니다. +트러블 슈팅 Trouble Shooting 시스템이나 장치에서 발생하는 문제를 진단하고 해결하는 과정 트러블 슈팅의 경우 문제의 근본 원인을 파악하고 해결 방안을 모색하는 데 초점을 맞춥니다. 문제의 증상만 해결하는 것이 아니라, 발생 원인을 제거하여 동일한 문제가 다시 발생하지 않도록 방지하는 것을 목표로 합니다. 반면 디버깅 코드는 시스템에서 오류를 찾고 수정하는 데 초점을 맞춥니다. 코드 실행 과정을 단계별로 검사하고, 오류가 발생하는 부분을 정확히 식별하여 코드를 수정하거나 버그를 제거하는 것을 목표로 합니다. +네이티브 앱 Native App 특정 모바일 운영 체제(예: iOS, Android)를 위해 자체 프로그래밍 언어(예: Swift, Java)로 개발됩니다. 일반적으로 하이브리드 앱보다 빠르고 응답성이 뛰어납니다. 네이티브 앱은 운영 체제와 직접 통합되기 때문에 하드웨어 가속 및 기타 최적화 기능을 활용할 수 있습니다. +하이브리드 앱 Hibrid App 웹 기술(예: HTML, CSS, JavaScript)을 사용하여 빌드되고, 기본적으로 웹 뷰 앱으로 작동하며, 운영 체제별 래퍼를 통해 각 플랫폼에 맞게 패키징됩니다. 웹 기술 기반으로 개발되었기 때문에 플랫폼별 업데이트나 버그 수정 없이도 유지 관리가 비교적 용이합니다. +스플래시 Splash 컴퓨터 프로그램, 웹사이트 또는 모바일 앱을 시작할 때 잠깐 나타나는 로고나 이미지 로딩 화면이라고도 불리며, 프로그램이나 앱이 로드되고 사용자 인터페이스가 준비될 때까지 대기 시간을 채우는 역할을 합니다. diff --git "a/server/api/src/main/resources/init/\355\205\234\355\224\214\353\246\277\354\232\251\354\226\264\354\247\221-\352\260\234\353\260\234.tsv" "b/server/api/src/main/resources/init/\355\205\234\355\224\214\353\246\277\354\232\251\354\226\264\354\247\221-\352\260\234\353\260\234.tsv" new file mode 100644 index 00000000..b7ab1e2e --- /dev/null +++ "b/server/api/src/main/resources/init/\355\205\234\355\224\214\353\246\277\354\232\251\354\226\264\354\247\221-\352\260\234\353\260\234.tsv" @@ -0,0 +1,76 @@ +term synonyms meaning +가상화 Virtualization "하나의 컴퓨터를 여러 개의 가상 컴퓨터처럼 사용할 수 있도록 하는 기술" +객체 Object "데이터와 그 데이터를 처리하는 메서드를 묶어 놓은 기본 단위\n붕어빵 틀을 이용하여 만든 붕어빵 하나하나를 객체라고 생각하면 됩니다. 각 붕어빵은 틀에서 정의된 속성(팥, 깨, 모양 등)과 행동(먹는 것)을 가지고 있습니다." +객체 지향 프로그래밍 OOP "객체라는 기본 단위를 사용하여 프로그램을 설계하고 작성하는 프로그래밍 패러다임\n객체는 데이터(속성)와 행동(메서드)을 가지고 있으며, 서로 상호작용하여 프로그램을 구성합니다." +기본 키 Primary Key "테이블 내의 각 레코드를 고유하게 식별하는 열 또는 열 집합\n다른 열의 값이 중복될 수 있는 반면, 기본 키 값은 항상 고유하고 null 값을 허용하지 않습니다. 기본 키는 데이터 무결성을 유지하고 중복된 데이터를 방지하는 데 중요한 역할을 합니다." +네이티브 앱 Native App "특정 모바일 운영 체제(예: iOS, Android)를 위해 자체 프로그래밍 언어(예: Swift, Java)로 개발됩니다. 일반적으로 하이브리드 앱보다 빠르고 응답성이 뛰어납니다. 네이티브 앱은 운영 체제와 직접 통합되기 때문에 하드웨어 가속 및 기타 최적화 기능을 활용할 수 있습니다." +데브옵스 DevOps "데브옵스(DevOps)는 소프트웨어 개발(Development)과 운영(Operations)을 하나의 통합된 프로세스로 연결하여 소프트웨어를 빠르게, 안정적으로, 그리고 효율적으로 제공하는 문화와 관행들의 집합\n데브옵스는 개발팀과 운영팀 간의 협업을 강조하며, 자동화, 지속적인 통합 및 배포, 모니터링 등을 통해 소프트웨어 개발 및 제공 프로세스를 개선하는 데 중점을 둡니다." +데이터 레이크 Data lake "구조화된 또는 반구조화된, 혹은 구조화되지 않은 방대한 양의 데이터를 저장하는 저장소\n데이터 레이크는 기존의 데이터 웨어하우스와는 다릅니다. 데이터 웨어하우스는 일반적으로 미리 정의된 스키마를 사용하여 구조화된 데이터를 저장하는 반면, 데이터 레이크는 스키마 없이 데이터를 저장할 수 있습니다. 즉, 데이터 레이크에는 모든 유형의 데이터를 저장할 수 있으며, 데이터가 저장된 후에도 데이터 스키마를 변경할 수 있습니다." +데이터베이스 Database "구조화된 형태로 데이터를 저장하고 관리하는 시스템\n데이터를 체계적으로 정리하고, 쉽게 찾고 사용할 수 있도록 하는 도구라고 생각하면 됩니다. " +디버그 Debug "프로그램에서 발생하는 오류나 문제를 찾아 해결하는 과정\n디버깅은 프로그램의 품질을 높이고 사용자에게 안정적인 경험을 제공합니다.\n디버깅 과정에서 발생하는 오류를 정확히 재현해보고 코드를 분석해 원인을 파악합니다. 오류의 원인을 파악한 후 코드를 수정하여 오류를 해결하고 테스트를 통해 오류가 해결되었는지 확인합니다. 해결 후에는 디버깅 과정에서 발생한 오류, 오류 원인, 해결 방법 등을 문서화합니다." +라이브러리 Library "라이브러리는 개발자가 특정 기능이나 작업을 수행할 수 있도록 재사용 가능한 코드 모듈을 제공합니다. 가령 NumPy, Pandas 등 데이터 처리 관련 라이브러리는 복잡한 데이터 분석 및 처리 작업을 수행하는 데 도움을 줍니다." +레거시 Regacy "오래된 기술로 개발되었지만, 여전히 사용되고 있는 컴퓨터 시스템, 소프트웨어, 하드웨어를 의미합니다." +렌더링 Rendering "렌더링은 2D 또는 3D 모델을 기반으로 이미지 또는 영상\n컴퓨터 그래픽스, 영화 제작, 비디오 게임, 건축 시각화 등 다양한 분야에서 활용됩니다. 렌더링 과정은 모델 정보를 시각적 표현으로 변환하는 복잡한 계산 과정을 포함하며, 빛, 색상, 질감, 그림자 등 다양한 요소를 고려하여 현실감 넘치는 이미지 또는 영상을 생성합니다." +로드 밸런싱 Load Balancing "네트워크 트래픽을 여러 서버에 분산하여 처리하는 기술\n로드 밸런싱을 통해 서버 부하를 줄이고, 성능을 향상시키며, 사용자 가용성을 높일 수 있습니다. 가령 온라인 쇼핑몰의 경우 높은 트래픽을 처리하고 고객에게 빠른 응답 속도를 제고하기 위해 사용합니다. 온라인 게임의 경우 많은 플레이어를 동시에 처리하고 게임 지연을 줄이기 위해 사용합니다." +멀티스레드 Multi-thread "하나의 프로그램에서 동시에 여러 개의 작업을 수행하는 프로그래밍 방식입니다. 멀티스레드를 사용하면 컴퓨터의 여러 개의 CPU 코어를 효율적으로 활용하여 프로그램 성능을 향상시킬 수 있습니다." +반응형 웹 Responsive Website "웹사이트가 다양한 기기의 화면 크기와 해상도에 맞게 자동으로 디자인 및 레이아웃을 조정하도록 돕는 웹 디자인 접근 방식입니다." +배포 Deployment "완성된 코드를 실제 사용 환경에 설치하고 실행 가능한 형태로 만드는 과정\n배포 방법에는 수동 배포, 자동 배포, 컨테이너 배포 등이 있습니다. 수동 배포는 개발자가 직접 서버에 연결해 배포 작업을 수행하는 방식입니다. 자동 배포는 CI/CD 도구를 사용해 배포 과정을 자동화 하는 방식입니다. 컨테이너 배포는 Docker와 같은 컨테이너 기술을 사용해 배포하는 방식입니다." +백엔드 Back-end "사용자가 직접 볼 수 없는 웹페이지의 서버를 담당합니다. 데이터베이스 관리, 서버 로직 처리, 사용자 인증, 보안 등 웹페이지의 핵심 기능을 구현합니다." +빌드 Build "소스 코드를 실행 가능한 파일로 변환하는 과정\n먼저 컴파일러로 소스 코드를 기계어로 변환합니다. 컴파일된 객체 파일을 연결하고 의존하는 라이브러리를 추가해 실행 가능한 파일을 만듭니다. 실행 가능한 파일과 기타 필요한 파일을 하나의 패키지로 묶습니다. 컴파일은 소스 코드를 기계어로 변환하는 과정이며 빌드 과정의 한 단계인 반면, 빌드는 소스 코드를 실행 가능한 파일로 변환하는 전체적인 과정을 의미합니다." +샌드박스 SandBox "실제 환경에 영향을 미치지 않고 새로운 소프트웨어나 기능을 테스트할 수 있는 가상의 공간입니다. 샌드박스는 개발자가 버그를 찾고 코드를 개선할 수 있는 안전한 환경을 제공합니다." +서버 Server "서버는 네트워크에 연결된 컴퓨터로 클라이언트의 요청을 받아 처리하고 결과를 응답으로 보냅니다. 웹 서버, 데이터 베이스 서버, 메일 서버 등이 서버에 속합니다." +성능 테스트 BMT,Bench Marking Test "한 가지 시스템이나 제품의 성능을 객관적으로 측정하고 비교하기 위한 테스트\nBMT는 일반적으로 동일한 조건에서 서로 다른 시스템 또는 제품을 테스트하여 어떤 시스템 또는 제품이 더 우수한 성능을 제공하는지를 확인하는 데 사용됩니다." +스플래시 Splash "컴퓨터 프로그램, 웹사이트 또는 모바일 앱을 시작할 때 잠깐 나타나는 로고나 이미지\n로딩 화면이라고도 불리며, 프로그램이나 앱이 로드되고 사용자 인터페이스가 준비될 때까지 대기 시간을 채우는 역할을 합니다." +엔드포인트 Endpoint "네트워크에서 데이터를 주고받을 수 있는 논리적 또는 물리적 위치\n간단히 말해서, 엔드포인트는 네트워크 상에서 연결하고 상호 작용할 수 있는 문과 같습니다. 각 엔드포인트는 고유한 IP 주소 또는 식별자를 가지고 있으며, 네트워크를 통해 다른 엔드포인트와 통신할 수 있습니다." +외래 키 Foreign Key "한 테이블의 열을 다른 테이블의 기본 키 열과 연결하는 관계\n외래키는 두 테이블 간의 데이터 관계를 정의하고 데이터 무결성을 유지하는 데 도움이 됩니다. 예를 들어, '고객' 테이블에 '주소' 테이블의 외래키 열이 있다면, '고객' 테이블의 각 레코드는 '주소' 테이블의 고유한 레코드를 참조하게 됩니다." +웹소캣 Websocket API "웹 클라이언트와 웹 서버 간에 지속적인 양방향 실시간 통신을 가능하게 하는 API\n기존의 HTTP 기반 웹 요청과 달리 WebSocket은 한 번의 TCP 연결을 통해 클라이언트와 서버 간에 지속적으로 데이터를 주고받을 수 있으므로, 채팅, 게임, 실시간 데이터 갱신 등 실시간으로 데이터 송수신이 필요한 애플리케이션에 적합합니다." +인덱스 Index "테이블의 특정 열에 대한 검색 속도를 향상시키는 데이터 구조\n책의 색인과 유사하게, 인덱스는 데이터베이스 시스템이 테이블 내의 특정 레코드를 빠르게 찾도록 도와줍니다. 자주 검색되는 열에 인덱스를 생성하면 데이터 검색 속도를 크게 향상시킬 수 있으며, 데이터베이스 성능을 개선하는 데 효과적인 방법입니다." +정규화 Normalization "데이터베이스의 데이터 구조를 체계적으로 조직하여 데이터 중복을 최소화하고, 데이터 무결성을 유지하며, 데이터베이스의 효율성을 높이는 프로세스\n데이터베이스 정규화는 데이터 중복을 최소화하고 데이터 무결성을 유지하는 목적으로 사용됩니다. 또한 데이터베이스 구조를 최적화해 데이터 검색 및 조작 속도를 높이고 데이터베이스 관리 작업을 용이하게 합니다." +컨벤션 Convention "일관되고 읽기 쉬운 코드 베이스 유지를 위해 개발자가 따르는 합의된 규칙" +컴파일러 Compiler "개발자가 작성한 소스 코드를 컴퓨터가 이해하고 실행할 수 있는 기계어로 변환하는 소프트웨어 도구\n컴파일러는 대표적으로 개발자의 소스 코드를 읽고 분석하며 소스 코드의 문법적 오류를 확인하고 오류 메시지를 출력합니다. 또한 생성된 기계어를 실행 가능한 파일 형식으로 저장합니다." +클라이언트 Client "클라이언트는 사용자 컴퓨터에 설치된 소프트웨어로 서버에 요청을 보내고 서버로부터 응답을 받습니다.\n웹 브라우저, 이메일 클라이언트, 파일 공유 프로그램 등이 클라이언트에 속합니다." +클래스 Class "객체의 청사진 역할을 하는 설계 요소\n붕어빵 틀 자체를 클래스라고 생각하면 됩니다. 붕어빵 틀은 붕어빵의 속성(팥, 깨, 모양 등)과 행동(먹는 것)을 정의하고 있으며, 이 틀을 사용하여 여러 개의 붕어빵(객체)를 만들 수 있습니다." +테이블 Table "테이블은 행과 열로 구성된 데이터 구조로, 데이터베이스에서 데이터를 저장하는 기본 단위\n각 행은 하나의 데이터 레코드를 나타내고, 각 열은 레코드의 특정 속성을 나타냅니다. 테이블은 마치 스프레드시트와 유사한 형태로 데이터를 보여주고, 행과 열을 사용하여 원하는 데이터를 쉽게 찾고 조작할 수 있도록 합니다." +트랜잭션 Transaction "데이터베이스와 같은 시스템의 상태를 일관되게 변경하는 작업 단위\n트랜잭션은 ACID 특성을 만족하도록 설계되어 데이터 무결성을 유지합니다. ACID는 트랜잭션의 핵심 특성을 나타내는 약자입니다.\n- 원자성 (Atomicity): 트랜잭션은 하나의 작업 단위로 실행되며, 트랜잭션이 완료되거나 실패할 때까지 부분적인 작업이 수행되지 않습니다.\n- 일관성 (Consistency): 트랜잭션이 완료되면 데이터베이스는 항상 일관된 상태를 유지합니다. 트랜잭션 중간에 데이터베이스 상태가 변경되지 않습니다.\n- 격리성 (Isolation): 동시에 실행되는 여러 트랜잭션은 서로 영향을 미치지 않습니다. 각 트랜잭션은 서로 독립적으로 실행되는 것처럼 작동합니다.\n- 지속성 (Durability): 트랜잭션이 성공적으로 완료되면, 그 결과는 영구적으로 저장됩니다. 시스템 장애나 데이터 손실에도 영향을 받지 않습니다." +트러블 슈팅 Trouble Shooting "시스템이나 장치에서 발생하는 문제를 진단하고 해결하는 과정\n트러블 슈팅의 경우 문제의 근본 원인을 파악하고 해결 방안을 모색하는 데 초점을 맞춥니다. 문제의 증상만 해결하는 것이 아니라, 발생 원인을 제거하여 동일한 문제가 다시 발생하지 않도록 방지하는 것을 목표로 합니다.\n반면 디버깅 코드는 시스템에서 오류를 찾고 수정하는 데 초점을 맞춥니다. 코드 실행 과정을 단계별로 검사하고, 오류가 발생하는 부분을 정확히 식별하여 코드를 수정하거나 버그를 제거하는 것을 목표로 합니다." +파라미터 인자,Parameter "함수는 특정 작업을 수행하도록 설계된 코드 블록입니다. 함수를 호출할 때 원하는 결과를 얻도록 함수에 데이터를 전달해야 합니다. 이 데이터를 파라미터라고 부릅니다." +파싱 Parsing "컴퓨터 과학에서 특정 형식으로 구성된 데이터를 분석하고 해석하는 과정\n파싱은 주로 텍스트 기반 데이터, 프로그래밍 언어 소스 코드, XML 문서 등을 처리하는 데 사용됩니다. 파싱 과정에서 데이터의 구조를 이해하고 의미 있는 정보를 추출합니다." +포트 Port "컴퓨터 또는 네트워크 장치에서 특정 애플리케이션이나 서비스를 식별하는 데 사용되는 논리적 번호\n포트는 여러 애플리케이션이 동시에 같은 컴퓨터에서 실행될 수 있도록 하는 가상 통로와 같습니다. 각 포트는 고유한 번호를 가지고 있으며, 0에서 65535까지의 범위를 사용할 수 있습니다." +프레임워크 Framework "소프트웨어 개발 과정을 돕는 도구와 라이브러리의 집합\n프레임워크는 개발자가 애플리케이션의 기본 구조 및 핵심 기능을 빠르고 쉽게 구축할 수 있도록 기본적인 뼈대를 제공합니다. 대표적으로 웹에선 Django, Ruby on Rails 등이 있으며, 모바일에선 React Native, Flutter 등이 있습니다." +프로토콜 Protocol "두 시스템 간의 통신 방식을 정의하는 규칙 집합\n프로토콜은 두 시스템이 서로 어떻게 대화해야 하는지에 대한 약속과 같습니다. 프로토콜에선 전송되는 데이터 형식과 데이터 전송 방식 그리고 오류 처리에 대해 정의합니다." +프론트엔드 Front-end "사용자가 직접 보고 상호 작용하는 웹페이지의 눈에 보이는 부분을 담당합니다. 웹 브라우저에서 보이는 모든 요소 (텍스트, 이미지, 영상, 버튼 등)와 사용자의 상호 작용(클릭, 스크롤, 입력)이 프론트엔드에 속합니다." +플러그인 Plug-in "기존 소프트웨어에 새로운 기능을 추가하거나 기존 기능을 확장하는 소프트웨어 구성 요소\n일반적으로 독립적인 프로그램으로 작동하며, 호환되는 호스트 프로그램에 설치해야 합니다. 플러그인은 다양한 분야에서 사용되며, 웹 브라우저, 그래픽 편집 프로그램, 비디오 편집 프로그램, 게임 엔진 등에서 흔히 볼 수 있습니다." +핑 Ping "컴퓨터 네트워크에서 두 장치 간의 연결성을 테스트하는 데 사용되는 도구\n핑은 다음과 같은 다양한 상황에서 사용됩니다.\n- 네트워크 문제 진단: 네트워크 연결이 끊겨 있거나 속도가 느린 경우 핑을 사용하여 문제의 원인을 파악할 수 있습니다.\n- 웹 사이트 성능 테스트: 웹 사이트에 연결하는 데 걸리는 시간을 측정하는 데 핑을 사용할 수 있습니다.\n- 새로운 네트워크 장치 설정 확인: 새로 설치한 네트워크 장치가 올바르게 작동하는지 확인하는 데 핑을 사용할 수 있습니다." +하이브리드 앱 Hibrid App "웹 기술(예: HTML, CSS, JavaScript)을 사용하여 빌드되고, 기본적으로 웹 뷰 앱으로 작동하며, 운영 체제별 래퍼를 통해 각 플랫폼에 맞게 패키징됩니다. 웹 기술 기반으로 개발되었기 때문에 플랫폼별 업데이트나 버그 수정 없이도 유지 관리가 비교적 용이합니다." +AJAX Asynchronous Javascript and XML "비동기 JavaScript 및 XML의 약자로, 웹 페이지를 부분적으로 다시 로드하지 않고도 서버와 데이터를 주고받을 수 있는 웹 개발 기술\nAJAX를 사용하면 사용자가 웹 페이지를 새로고침하지 않고도 데이터를 업데이트하고 새로운 콘텐츠를 로드할 수 있어 웹사이트의 사용자 경험을 크게 향상시킬 수 있습니다. 대표적으로 검색창에 입력하는 단어에 대한 자동 완성 기능, 무한 스크롤, 실시간 데이터 업데이트 등에 활용됩니다." +API Application Programming Interface "프로그램이 서로 통신하고 협력할 수 있는 필수적인 연결 도구\nAPI는 크게 데이터 공유, 기능 활용, 시스템 통합의 역할을 수행합니다.\n데이터 공유 : 다른 프로그램의 데이터를 제공합니다. 가령, 날씨 앱은 API로 기상청의 날씨 데이터를 가져올 수 있습니다.\n기능 활용 : 다른 프로그램의 기능을 활용할 수 있도록 합니다. 가령, 음악 앱은 API로 스포티파이 등의 음악 스트리밍 서비스의 음악을 재생할 수 있습니다.\n시스템 통합 : 서로 다른 시스템을 연결해 함께 작동하도록 합니다. 가령, 온라인 쇼핑몰은 API로 주문 처리, 결제 처리 및 배송 추적을 위한 다른 시스템과 통합할 수 있습니다." +Argument 인자값 "인자값은 함수 호출 시 실제 값을 의미하며, 파라미터에 전달됩니다. 인자값은 다양한 형태의 데이터, 숫자, 문자열, 리스트, 객체 등을 포함할 수 있습니다. 함수는 전달된 인자값을 사용하여 계산을 수행하거나 작업을 처리합니다." +CDC Change Data Capture "데이터베이스에 변경된 데이터를 실시간으로 추적하고 캡처하는 기술\n데이터베이스에 삽입, 수정, 삭제 등의 변경 작업이 발생할 때마다 해당 변경사항을 기록하고 다른 시스템이나 프로세스에 알려줍니다. CDC는 데이터베이스의 로그 파일을 모니터링하거나 트리거를 사용해 데이터 변경사항을 감지합니다. 변경사항이 감지되면 CDC는 해당 변경 사항을 메시지 형식으로 포장하고 이를 큐에 저장합니다." +CDN Contents Delivery Network "웹 콘텐츠를 사용자에게 더 빠르고 안정적으로 제공하기 위해 지리적으로 분산된 네트워크를 사용하는 기술\n웹사이트, 이미지, 동영상, 스트리밍 미디어 등 다양한 콘텐츠를 빠르게 전송하는 데 효과적으로 사용됩니다." +CI/CD Continuous Integration/Continuous Delivery,지속적인 통합/지속적인 배포 "소프트웨어 개발 프로세스를 자동화하여 소프트웨어를 빠르고 안정적으로 제공하는 방법\nCI/CD는 다음 두 단계로 구성됩니다.\n- 지속적인 통합(Continuous Integration, CI): 개발자가 코드를 변경할 때마다 코드를 자동으로 통합하고 테스트하는 프로세스입니다. CI는 개발 중에 발생하는 버그를 빠르게 발견하고 해결하는 데 도움이 됩니다.\n- 지속적인 배포(Continuous Delivery, CD): 테스트를 통과한 코드를 자동으로 배포 환경에 배포하는 프로세스입니다. CD는 소프트웨어를 빠르게 출시하고 업데이트하는 데 도움이 됩니다." +CSS Cascading Style Sheets "HTML 문서의 스타일을 정의하는 데 사용되는 스타일 시트 언어\nHTML 문서가 웹 페이지의 구조를 담당한다면, CSS는 웹 페이지의 디자인을 담당합니다." +DNS Domain Name System "인터넷 주소록이라고도 불리는 시스템으로, 도메인 이름(예: www.example.com)을 IP 주소(예: 192.0.2.44)로 변환하는 역할을 합니다. 쉽게 말해, 사람이 쉽게 기억할 수 있는 도메인 이름을 컴퓨터가 이해할 수 있는 IP 주소로 변환하는 시스템이라고 볼 수 있습니다." +External browser 익스터널 브라우저 "익스터널 브라우저는 별도의 앱으로 설치되는 웹 브라우저입니다. 대표적으로 크롬, 사파리, 파이어폭스, 엣지 등이 있습니다." +gRPC Google RPC,Google Remote Procedure Call "RPC(Remote Procedure Call)는 네트워크를 통해 원격 시스템에 있는 프로시저를 호출하는 기술입니다. RPC는 마치 로컬 시스템에서 함수를 호출하는 것처럼 원격 시스템의 프로시저를 호출할 수 있도록 해줍니다. gRPC는 Google에서 개발한 오픈 소스 RPC 프레임워크입니다." +HTTP Hypertext Transfer Protocol,하이퍼텍스트 전송 프로토콜 "웹 브라우저와 웹 서버 간의 통신을 위한 기본 프로토콜\n웹사이트 방문 시 사용자가 입력한 URL을 요청하고, 웹 서버는 요청에 맞는 웹 페이지나 다른 데이터를 응답으로 전송하는 데 사용됩니다. HTTP는 기본적으로 데이터를 평문으로 전송하기 때문에 도청이나 위변조 위험이 있어 최근에는 HTTPS를 사용합니다." +HTTPS Hypertext Transfer Protocol Secure " HTTP에 SSL/TLS 프로토콜을 추가하여 안전하게 데이터를 전송하도록 보안 강화한 프로토콜입니다. HTTPS는 웹 브라우저와 웹 서버 간 모든 통신을 암호화해 도청이나 위변조를 방지합니다. 웹 서버의 신원을 인증해 위조 사이트로부터 사용자를 보호하며, 데이터 전송 과정에서 데이터가 변경되지 않았는지 확인합니다." +IaaS Infrastructure as a Service "컴퓨팅, 스토리지, 네트워킹과 같은 기반 IT 인프라를 서비스로 제공하는 모델\nIaaS 서비스 제공업체는 사용자에게 가상 머신, 서버, 스토리지, 네트워킹 장비 등을 제공하며, 사용자는 이러한 자원을 자유롭게 사용하여 원하는 시스템 및 애플리케이션을 구축할 수 있습니다." +IDE Integrated Development Environment "통합 개발 환경의 약자로 개발자가 소프트웨어를 빠르고 쉽게 개발하도록 돕는 도구\nIDE는 코딩, 컴파일, 디버깅 및 테스트 등 소프트웨어 개발 프로세스의 모든 단계를 위한 다양한 기능을 제공합니다." +In-app browser 인앱 브라우저 "모바일 앱 혹은 데스크톱 앱 내 내장된 웹 브라우저\n별도의 웹 브라우저 앱을 실행하지 않아도 앱에서 웹페이지를 볼 수 있도록 합니다." +JSON JavaSript Object Notation "Javascript에서 사용하는 객체 정의 방법" +NAS Network Attached Storage "네트워크를 통해 여러 사용자가 파일 저장 및 공유를 할 수 있도록 하는 장치\nNAS는 다양한 용도로 사용됩니다. 개인 용도로는 사진, 음악, 비디오 등의 개인 파일을 저장하고 공유하는 데 사용할 수 있습니다. 사업 용도로는 문서, 스프레드시트, 프레젠테이션과 같은 업무 파일을 저장하고 공유하는 데 사용할 수 있습니다. 또한 NAS는 데이터 백업, 파일 스트리밍, 미디어 서버 등으로도 사용할 수 있습니다." +PasS Platform as a Service "소프트웨어 개발 및 배포를 위한 플랫폼을 서비스로 제공하는 모델\nPaaS 플랫폼은 개발자들이 웹 애플리케이션을 구축, 테스트, 배포 및 관리하는 데 필요한 모든 기능과 도구를 제공합니다. 개발자는 PaaS 플랫폼을 사용하여 핵심적인 개발 작업에 집중할 수 있으며, 서버 관리, 운영 체제 관리, 네트워킹 등의 인프라 관리 작업은 PaaS 플랫폼 제공업체가 담당합니다." +Request 요청 "엔드포인트가 다른 엔드포인트에게 데이터나 작업을 수행하도록 요청하는 메시지" +Response 응답 "요청에 대한 응답으로 엔드포인트에서 다른 엔드포인트로 전송하는 메시지" +REST API Representational State Transfer API "REST 아키텍처 스타일의 디자인 원칙을 준수하는 API\nREST API를 RESTful API라고 불리기도 합니다. REST API는 일관되고 이해하기 쉬운 인터페이스를 제공하며, 다양한 플랫폼과 프로그래밍 언어에서 쉽게 사용할 수 있는 장점이 있습니다.\nREST API는 HTTP method를 사용하여 자원에 대한 작업을 수행합니다. 일반적으로 사용되는 HTTP method는 다음과 같습니다.\n\nGET: 자원을 조회합니다.\nPOST: 자원을 생성합니다.\nPUT: 자원을 업데이트합니다.\nDELETE: 자원을 삭제합니다." +RPC Remote Procedure Call "네트워크를 통해 원격 시스템에 있는 함수를 마치 로컬 시스템에 있는 함수처럼 호출하는 기술\n개발자는 호출되는 함수가 다른 시스템에 있는지 여부를 확인하지 않고 함수를 호출할 수 있습니다. RPC는 분산 시스템에서 서로 다른 시스템 간 통신과 자원 공유를 용이하게 만들며, 클라이언트 - 서버 애플리케이션 개발에 많이 사용됩니다." +SaaS Software as a Service "소프트웨어를 서비스로 제공하는 모델\nSaaS 서비스 제공업체는 웹 브라우저를 통해 사용자가 액세스 할 수 있는 소프트웨어 애플리케이션을 개발, 운영 및 유지 관리합니다. 사용자는 별도의 소프트웨어를 설치하거나 하드웨어를 구매할 필요 없이 인터넷 연결만 있으면 서비스를 이용할 수 있습니다." +SDK Software Development Kit "특정 플랫폼이나 운영체재를 위한 앱을 만드는데 필요한 도구와 코드 모음\nSDK는 앱 개발을 쉽고 빠르게 만드는 도구로 개발자가 처음부터 모든 것을 스스로 구축할 필요가 없어 시간과 노력을 절약할 수 있습니다.\n예) 개발자가 지도를 앱에 추가하고 싶다면 구글 지도 SDK를 사용할 수 있습니다. 이 SDK에는 지도 표시, 사용자 위치 추적, 경로 검색 등의 기능을 위한 코드와 도구가 포함되어 있습니다." +SOAP API Simple Object Access Protocol API "웹 서비스를 위한 표준 프로토콜\nXML 기반 메시지 형식을 사용하여 네트워크를 통해 분산된 응용 프로그램 간 통신을 지원합니다. SOAP API는 표준화된 웹 서비스 프로토콜이지만, 복잡하고 느리고 비효율적인 단점이 있습니다. 이러한 단점을 해결하기 위해 REST API, JSON API와 같은 다른 웹 서비스 프로토콜들이 등장했습니다." +SQL Structured Query Language "관계형 데이터베이스에서 데이터를 조작하는 표준 언어\n데이터베이스에 데이터를 저장하고, 검색하고, 삭제하고, 수정하는 데 사용하는 데이터베이스 프로그래밍 언어라고 생각하면 됩니다." +SRE Site Reliability Engineering "소프트웨어 시스템의 안정성, 확장성, 및 성능을 유지하는 것을 담당하는 엔지니어링 분야\nSRE는 전통적인 소프트웨어 개발 및 운영 팀의 역할을 결합하여 소프트웨어를 서비스로 제공하는 데 필요한 모든 측면을 관리합니다." +SSH Secure Shell Protocol "네트워크를 통해 두 컴퓨터 간의 안전한 연결을 제공하는 프로토콜\nSSH는 다음과 같은 다양한 목적으로 사용됩니다.\n- 원격 컴퓨터 관리: 시스템 관리자는 SSH를 사용하여 원격 컴퓨터에 로그인하고 관리 작업을 수행할 수 있습니다.\n- 파일 전송: 사용자는 SSH를 사용하여 두 컴퓨터 간에 파일을 안전하게 전송할 수 있습니다.\n- 애플리케이션 실행: 사용자는 SSH를 사용하여 원격 컴퓨터에서 애플리케이션을 실행할 수 있습니다.\n- 포트 포워딩: 사용자는 SSH를 사용하여 한 컴퓨터의 포트를 다른 컴퓨터의 포트로 전달할 수 있습니다." +SSL Secure Sockets Layer,보안 소켓 계층 "웹 브라우저와 웹 서버 간의 통신을 암호화하여 보안하는 프로토콜\nSSL은 현재 TLS(Transport Layer Security)로 이름이 변경되었지만, 여전히 SSL이라는 용어가 널리 사용되고 있습니다." +SSO Single Sign On "사용자가 여러 시스템이나 애플리케이션에 한 번의 로그인 만으로 액세스할 수 있도록 하는 인증 방식\n사용자는 시스템마다 별도 계정을 만들고 로그인 할 필요 없이 하나의 아이디와 비밀번호만 사용해 여러 시스템에 로그인할 수 있습니다.\nSSO는 다음과 같은 단계를 거쳐 작동합니다.\n1. 사용자는 하나의 시스템에 로그인합니다.\n2. 사용자 인증 정보는 중앙 인증 서버에 전송됩니다.\n3. 중앙 인증 서버는 사용자 인증 정보를 검사하고 유효하면 인증 토큰을 생성합니다.\n4. 인증 토큰은 사용자의 브라우저에 저장됩니다.\n5. 사용자가 다른 시스템에 액세스하려고 할 때 브라우저는 저장된 인증 토큰을 자동으로 전송합니다.\n6. 중앙 인증 서버는 인증 토큰을 검사하고 유효하면 사용자를 자동으로 로그인시킵니다." +Streaming 스트리밍 "미디어 파일을 인터넷을 통해 실시간으로 전송하고 재생하는 기술\n이전에는 음악이나 영상을 다운로드한 후에 재생할 수 있었다면 스트리밍 기술로 파일을 다운로드하지 않고도 바로 듣거나 재생할 수 있게 되었습니다.\n스트리밍에는 라이브 스트리밍과 온디맨드 스트리밍으로 나눌 수 있습니다. 라이브 스트리밍은 실시간으로 방송하고 시청하는 방식이며, 온디맨드 스트리밍은 이미 녹화된 음악, 동영상, 게임 등을 선택해 시청하는 방식입니다." +TLS 전송 계층 보안 "인터넷 상의 두 시스템 간의 통신을 보호하는 보안 프로토콜\n데이터 암호화, 신원 인증, 데이터 무결성 보장 기능을 제공하여 사용자의 개인정보, 로그인 정보, 금융 정보 등을 안전하게 보호합니다. 이전에 사용되었던 SSL(Secure Sockets Layer) 프로토콜의 후속 버전입니다." diff --git "a/server/api/src/main/resources/init/\355\205\234\355\224\214\353\246\277\354\232\251\354\226\264\354\247\221-\353\224\224\354\236\220\354\235\270.tsv" "b/server/api/src/main/resources/init/\355\205\234\355\224\214\353\246\277\354\232\251\354\226\264\354\247\221-\353\224\224\354\236\220\354\235\270.tsv" new file mode 100644 index 00000000..323f7dbe --- /dev/null +++ "b/server/api/src/main/resources/init/\355\205\234\355\224\214\353\246\277\354\232\251\354\226\264\354\247\221-\353\224\224\354\236\220\354\235\270.tsv" @@ -0,0 +1,70 @@ +term synonyms meaning +그리드 시스템 Grid System "디자인 요소들을 정렬하고 배치하기 위한 격자 형태의 구조입니다. 쉽게 말해, 디자인을 할 때 안 보이는 선들을 이용하여 디자인 요소들을 가지런히 정리하는 것이라고 생각하면 됩니다. 그리드 시스템을 사용하면 일관성, 효율성, 가독성이 좋아집니다. 디자인 요소들이 일정한 규칙에 따라 배치되므로 디자인 전체에 통일감을 줍니다. 디자인 요소들의 위치를 쉽게 파악하고 수정할 수 있어 작업 시간을 단축할 수 있습니다. 콘텐츠를 논리적으로 배치하여 사용자가 정보를 쉽게 이해할 수 있도록 돕습니다." +네비게이션 Navigation "웹사이트나 앱에서 사용자가 원하는 정보나 기능을 찾아 이동할 수 있도록 돕는 시스템입니다. 웹사이트나 앱의 구조를 보여주고, 사용자가 현재 위치를 파악하며 다른 페이지나 화면으로 이동할 수 있도록 돕는 역할을 합니다." +다이얼 Dial,Knob "사용자가 값을 조절하거나 선택할 수 있도록 둥근 형태로 디자인된 UI 요소입니다. 슬라이더와 비슷한 기능을 하지만, 둥근 형태로 회전하며 값을 조절하는 것이 특징입니다." +데이트 피커 Date Picker "앱이나 웹사이트에서 사용자가 여러 옵션 중 하나를 선택할 수 있도록 제공되는 드롭다운 메뉴 형태의 UI 요소입니다. 제한된 공간에서 여러 옵션을 표시하고 선택할 수 있게 해주는 것이 특징입니다." +드롭다운 메뉴 Drop-down List "웹사이트나 앱에서 클릭하거나 마우스를 올려놓으면 여러 개의 하위 메뉴가 나타나는 메뉴 형식입니다. 공간을 효율적으로 사용하고, 관련된 메뉴 항목들을 그룹으로 묶어 보여주는 데 유용합니다." +디자인 시스템 Design System "웹사이트나 앱 디자인에 사용되는 컬러, 폰트, 레이아웃, UI 요소 등 디자인 요소들을 표준화하고 재사용 가능하도록 만든 시스템입니다. 쉽게 말해, 디자인 팀이 사용하는 "디자인 레고 블록"과 같습니다. 각 블록은 버튼, 입력 필드, 아이콘 등 특정 UI 요소를 나타내고, 이 블록들을 조합하여 웹사이트나 앱을 디자인합니다." +디폴트 Default "컴포넌트의 기본 상태값. 유저가 아무 행동도 하지 않은 첫 화면에서 보여지는 상태를 의미합니다." +딥링크 Deep Link "웹 페이지 링크처럼 앱의 특정 화면이나 콘텐츠로 바로 연결되는 링크입니다. 일반적인 링크는 웹 브라우저를 열고 웹 페이지를 표시하지만, 딥링크는 앱을 실행하고 특정 화면으로 바로 이동시킵니다." +라디오 버튼 Radio Button "여러 개의 선택지 중에서 단 하나만 선택할 수 있도록 하는 UI 요소입니다. 동그란 버튼 모양으로 되어 있으며, 선택하면 버튼 안에 점이 표시되고, 다른 버튼을 선택하면 이전에 선택된 버튼의 점은 사라집니다." +랜딩 페이지 Landing Page "사용자가 광고, 검색 결과, 이메일 등 외부 링크를 클릭하여 웹사이트에 처음 방문하게 되는 특정 페이지입니다. 랜딩 페이지는 사용자의 관심을 끌고 특정 행동(구매, 회원가입, 문의 등)을 유도하도록 설계됩니다." +로딩 스피너 Loading Spinner "화면 중앙에 무한으로 돌아가는 컴포넌트. 콘텐츠가 로딩되는 동안 사용자에게 진행 상황을 알리기 위한 UI 요소입니다." +리본 메뉴 Ribbon Menu "드롭다운 메뉴를 보완하기 위해 만들어진 메뉴. 툴바에 탭을 접목시킨 형태로 탭을 번갈아 가며 다양한 메뉴를 확인할 수 있습니다." +마이크로카피 Microcopy "웹사이트나 앱에서 사용되는 짧은 문구나 단어를 의미합니다. 버튼 레이블, 에러 메시지, 안내 문구, 툴팁 등 사용자 인터페이스(UI) 곳곳에 등장하며, 사용자에게 정보를 제공하고 행동을 유도하는 역할을 합니다." +마진 Margin "디자인 요소 주변의 여백을 의미합니다. 즉, 디자인 요소와 다른 요소 사이의 간격 또는 디자인 요소와 화면 가장자리 사이의 간격을 말합니다." +멘탈모델 Mental Model "사용자가 세상이나 특정 제품, 서비스에 대해 가지고 있는 생각, 이해, 믿음 등을 의미합니다. 사용자는 자신의 경험, 지식, 문화 등을 바탕으로 멘탈 모델을 형성하며, 이는 사용자의 행동, 판단, 의사 결정에 영향을 미칩니다." +모달 Modal "웹사이트나 앱 화면 위에 겹쳐서 나타나는 창입니다. 사용자의 주의를 집중시키고 특정 작업을 완료하도록 유도하기 위해 사용됩니다. 모달 창이 나타나면 배경 화면은 흐려지거나 어두워지고, 사용자는 모달 창을 닫거나 완료하기 전까지 다른 작업을 할 수 없습니다." +목업 Mockup "디자인의 시각적인 모습을 실제 제품처럼 보이도록 만든 정적인 모델입니다. 웹사이트, 앱, 제품 등의 디자인을 실제 크기나 축소된 크기로 제작하여 디자인의 레이아웃, 색상, 이미지, 폰트 등을 확인하고 검토하는 데 사용됩니다." +반응형 디자인 Responsive Design "다양한 화면 크기와 해상도를 가진 기기(PC, 스마트폰, 태블릿 등)에서 웹사이트나 앱이 최적화된 형태로 보이도록 디자인하는 방법입니다. 즉, 하나의 디자인을 만들어도 어떤 기기에서든 사용자가 콘텐츠를 편리하게 이용할 수 있도록 하는 디자인 방식입니다." +브레드크럼 Breadcrumb "웹사이트나 앱에서 사용자가 현재 페이지의 위치를 파악하고 상위 페이지로 쉽게 이동할 수 있도록 돕는 탐색 기능입니다. 동화 '헨젤과 그레텔'에서 헨젤과 그레텔이 숲 속에서 길을 잃지 않기 위해 빵 부스러기를 떨어뜨려 표시한 것처럼, 브레드크럼은 사용자가 웹사이트나 앱 내에서 자신의 위치를 파악하고 이전 페이지로 쉽게 돌아갈 수 있도록 돕는 역할을 합니다." +사이트맵 Sitemap "웹사이트나 앱을 구성하는 모든 페이지의 목록과 계층 구조를 보여주는 지도입니다. 웹사이트의 콘텐츠를 체계적으로 정리하고, 사용자와 검색 엔진이 웹사이트의 구조를 쉽게 파악할 수 있도록 돕는 역할을 합니다." +숫자 컨트롤러 스피너 Spinner "우측에 up, down 버튼으로 숫자를 조절하거나 편집 필트에서 직접 숫자를 입력해서 값을 변경할 수 있는 컴포넌트입니다." +스낵바 Snack bar "앱 화면 하단에 잠깐 나타났다 사라지는 간단한 메시지 표시 UI 요소입니다. 사용자의 행동에 대한 피드백이나 간단한 알림을 전달하는 데 사용됩니다. 팝업과 비슷하지만, 팝업보다 더 가볍고 일시적인 알림 방식입니다. 팝업은 사용자의 작업을 방해할 수 있지만, 스낵바는 화면 하단에 잠깐 나타났다 사라지므로 사용자의 작업 흐름을 방해하지 않습니다" +스켈레톤 Skeleton "웹사이트나 앱의 레이아웃과 콘텐츠 구조를 간략하게 보여주는 뼈대 역할을 하는 디자인입니다. 실제 콘텐츠를 채우기 전에 페이지의 틀을 잡고, 각 요소의 배치와 크기를 미리 확인하는 데 사용됩니다. 로딩 시간 동안 스켈레톤 화면을 보여주어 사용자에게 시각적인 피드백을 제공하고, 콘텐츠가 로딩될 때까지 기다리는 동안 지루함을 덜 느끼게 합니다." +스플레시 Splash "앱 실행 시 가장 처음 나타나는 화면입니다. 보통 앱 로고나 브랜드 이미지를 보여주며, 앱의 첫인상을 결정하는 중요한 요소입니다. 앱이 실행되는 동안 필요한 데이터를 로딩하는 시간 동안 사용자에게 시각적인 피드백을 제공하여 앱이 멈춘 것이 아니라는 것을 알려주는 역할도 합니다." +슬라이더 Slider " 막대나 원형 트랙을 따라 사용자가 값을 조절할 수 있도록 하는 UI 요소입니다. 음량 조절, 화면 밝기 조절, 사진 필터 강도 조절 등 다양한 용도로 사용됩니다." +알럿 Alert "웹사이트나 앱에서 사용자에게 중요한 정보나 경고 메시지를 전달하는 팝업 창입니다. 사용자의 작업을 잠시 중단시키고 메시지를 읽도록 유도하며, 보통 확인 버튼을 클릭해야 사라집니다." +액티브 Active "버튼이 눌리는 순간, 눌렀음을 표시해주는 상태입니다. 대부분 색상을 더 진하거나 연하게 조절하는 형태로 많이 적용합니다." +어피니티 다이어그램 Affinity Diagram "브레인스토밍이나 사용자 조사 등을 통해 얻은 다양한 아이디어나 데이터를 서로 관련성이 있는 것끼리 묶어 시각적으로 표현하는 도구입니다. 복잡하고 방대한 정보를 체계적으로 정리하고, 숨겨진 패턴이나 문제점을 발견하는 데 도움을 줍니다." +엔드 유저 End User "제품, 서비스, 시스템 또는 디자인의 최종 사용자를 의미합니다. 즉, 개발자나 디자이너가 아닌 실제로 제품이나 서비스를 사용하는 사람을 말합니다." +온보딩 Onboarding "새로운 사용자가 웹사이트, 앱, 제품 또는 서비스를 처음 사용할 때 쉽고 빠르게 적응하도록 돕는 과정입니다. 사용자에게 제품이나 서비스의 주요 기능을 소개하고, 사용 방법을 안내하며, 긍정적인 첫인상을 심어주는 것을 목표로 합니다." +온스크린 키보드 OSK,On Screen Keyboard,가상 키보드 "컴퓨터 화면에 표시되는 가상 키보드입니다. 마우스, 터치스크린, 스타일러스 펜 등을 사용하여 화면을 터치하거나 클릭하여 입력할 수 있습니다. 물리적인 키보드가 없거나 사용하기 어려운 상황에서 유용하게 사용됩니다." +와이어프레임 Wireframe "웹사이트나 앱의 뼈대를 구성하는 설계 도면입니다. 페이지의 레이아웃, 콘텐츠 구성, 기능 배치 등을 시각적으로 보여주지만, 실제 디자인 요소(색상, 이미지, 폰트 등)는 포함하지 않습니다." +위젯 Widget "앱이나 웹사이트, 바탕 화면 등에 배치되어 특정 기능을 수행하거나 정보를 표시하는 작은 UI 요소입니다. 사용자가 앱을 실행하지 않고도 빠르게 정보를 확인하거나 간단한 작업을 수행할 수 있도록 돕습니다." +유저 플로우 User Flow "사용자가 웹사이트나 앱을 이용하면서 거치는 과정을 순서대로 나타낸 것입니다. 사용자가 특정 목표를 달성하기 위해 어떤 단계를 거치는지 시각적으로 보여줍니다." +유저 플로우 User flow "사용자가 웹사이트나 앱을 이용하면서 거치는 과정을 순서대로 나타낸 것입니다. 사용자가 특정 목표를 달성하기 위해 어떤 단계를 거치는지 시각적으로 보여줍니다. 즉, 사용자가 서비스를 이용하면서 어떤 경로로 이동하고 어떤 행동을 하는지 보여주는 지도와 같습니다." +인지적 부하 Cognitive load "사용자가 정보를 처리하고 이해하는 데 필요한 정신적인 노력의 양을 의미합니다. 웹사이트나 앱을 사용할 때 너무 많은 정보, 복잡한 인터페이스, 어려운 용어 등은 사용자의 인지적 부하를 높여 피로감을 유발하고 사용성을 저해할 수 있습니다." +입력 필드 Input Field,인풋필드 "사용자가 웹사이트나 앱에서 정보를 입력할 수 있도록 제공되는 빈칸 형태의 UI 요소입니다. 텍스트, 숫자, 이메일 주소, 비밀번호 등 다양한 형태의 정보를 입력받을 수 있습니다." +체크박스 Checkbox "사용자가 여러 항목 중에서 하나 이상을 선택할 수 있도록 하는 UI 요소입니다. 네모 상자 모양으로 되어 있으며, 선택하면 체크 표시(✓)가 나타나고, 선택 해제하면 체크 표시가 사라집니다." +카드 소팅 Card sorting "사용자가 웹사이트나 앱의 콘텐츠를 어떻게 분류하고 구성하는지 이해하기 위한 사용자 조사 방법입니다. 다양한 콘텐츠 항목을 적은 카드를 사용자에게 제공하고, 사용자가 이 카드들을 자신만의 방식으로 분류하고 그룹화하도록 합니다." +캐러셀 Carousel "웹사이트나 앱에서 여러 개의 이미지, 콘텐츠 또는 카드를 슬라이드 형태로 보여주는 UI 요소입니다. 제한된 공간에서 다양한 콘텐츠를 효과적으로 보여주고, 사용자의 흥미를 유발하는 데 사용됩니다." +케이스 스터디 Case Study "특정 개인, 집단, 조직, 프로젝트 등의 사례를 심층적으로 분석하여 문제 해결 과정, 성공 요인, 실패 원인 등을 파악하는 연구 방법입니다. 실제 사례를 통해 이론적인 지식을 현실에 적용하고, 문제 해결 능력을 향상시키는 데 목적이 있습니다." +코치마크 Coach mark "앱이나 웹사이트에서 사용자가 특정 기능이나 UI 요소를 처음 사용하거나 중요한 기능을 놓치지 않도록 안내하는 시각적인 도움말입니다. 보통 반투명한 레이어나 말풍선 형태로 나타나며, 특정 영역을 강조하거나 간단한 설명 텍스트를 제공합니다." +탭 Tab "웹사이트나 앱에서 여러 개의 콘텐츠 영역을 구분하고 사용자가 원하는 영역을 선택하여 볼 수 있도록 하는 UI 요소입니다. 여러 개의 탭 버튼이 나열되어 있으며, 각 탭 버튼을 클릭하면 해당 탭에 연결된 콘텐츠 영역이 활성화됩니다." +토스트 Toast "앱이나 웹사이트 화면에 잠깐 나타났다 사라지는 작은 알림 메시지입니다. 사용자의 행동에 대한 피드백을 제공하거나 간단한 정보를 알려주는 데 사용됩니다." +툴팁 Tooltip "웹사이트나 앱에서 사용자가 특정 UI 요소에 마우스 커서를 올려놓거나 터치했을 때 나타나는 작은 설명 상자입니다. 해당 요소에 대한 추가 정보를 제공하거나, 기능 설명, 사용 방법 등을 안내하는 데 사용됩니다." +팝업 Pop-up "웹사이트나 앱 화면 위에 갑자기 나타나는 작은 창입니다. 사용자의 주의를 끌거나 특정 정보를 전달하기 위해 사용됩니다. 팝업은 사용자의 동의 없이 나타나는 경우가 많아 사용자 경험을 해칠 수 있다는 비판도 받습니다." +페르소나 Persona "제품이나 서비스의 주요 사용자 그룹을 대표하는 가상의 인물입니다. 사용자 조사를 통해 얻은 데이터를 바탕으로 만들어지며, 이름, 나이, 직업, 성격, 관심사, 목표, 행동 패턴 등 구체적인 특징을 가지고 있습니다." +페이지네이션 Pagenation "웹사이트나 앱에서 많은 양의 콘텐츠를 여러 페이지로 나누어 보여주는 방식입니다. 사용자가 다음 페이지나 이전 페이지로 이동하거나 특정 페이지 번호를 클릭하여 원하는 콘텐츠를 찾을 수 있도록 돕습니다." +페인포인트 Pain-points "사용자가 제품, 서비스, 또는 특정 상황에서 겪는 불편함, 어려움, 문제점 등을 의미합니다. 즉, 사용자에게 '고통'을 주는 지점이라고 할 수 있습니다." +포커스 그룹 Focus group,FGI "특정 주제에 대해 토론하고 의견을 나누기 위해 모인 소규모 그룹입니다. 일반적으로 6~10명의 참가자와 숙련된 진행자(모더레이터)가 함께하며, 참가자들은 자유롭게 자신의 경험, 생각, 의견 등을 공유합니다." +푸터 Footer "웹사이트나 문서의 맨 아래에 위치하는 영역입니다. 주로 웹사이트나 문서에 대한 추가 정보를 제공하거나, 사용자가 다른 페이지로 이동할 수 있도록 링크를 제공하는 역할을 합니다. 웹사이트에선 주로 회사 소개, 연락처 정보, 이용 약관, 개인정보 처리방침, 소셜 미디어 링크 등을 포함합니다." +프로그레스 바 Progress Bar "작업의 진행 상황을 시각적으로 보여주는 UI 요소입니다. 파일 다운로드, 업로드, 설치, 로딩 등 시간이 걸리는 작업이 진행되는 동안 사용자에게 현재 진행 상황과 남은 시간을 알려줍니다.\n프로그레스 바의 종류는 4가지로 구분할 수 있습니다.\n막대형 프로그레스 바 (Bar Progress Bar): 가장 일반적인 형태로, 막대가 점점 차오르면서 진행 상황을 표시합니다.\n원형 프로그레스 바 (Circular Progress Bar): 원형으로 진행 상황을 표시하며, 주로 로딩 시간이 짧은 작업에 사용됩니다.\n텍스트 프로그레스 바 (Text Progress Bar): 텍스트로 진행 상황을 표시하며, 주로 용량이 큰 파일 다운로드 시 사용됩니다.\n미정 프로그레스 바 (Indeterminate Progress Bar): 작업 완료까지 걸리는 시간을 예측할 수 없는 경우, 움직이는 막대나 점 등을 통해 작업이 진행 중임을 알려줍니다." +프로토타입 Prototype "디자인의 인터랙션과 기능을 실제로 작동하는 것처럼 구현한 동적인 모델입니다. 웹사이트, 앱, 제품 등의 디자인을 실제로 사용해 보는 것처럼 경험하고 테스트하는 데 사용됩니다." +플레이스홀더 Placeholder "웹사이트나 앱의 입력 필드에 사용자가 입력하기 전에 미리 표시되는 힌트 텍스트입니다. 입력 필드의 용도나 입력해야 할 정보의 형식을 안내하여 사용자가 쉽게 정보를 입력할 수 있도록 돕습니다." +피츠의 법칙 Fitt's Law "사용자가 특정 목표물을 선택하는 데 걸리는 시간은 목표물까지의 거리와 목표물의 크기에 따라 달라진다는 법칙입니다. 즉, 목표물이 멀리 있거나 작을수록 선택하는 데 더 많은 시간이 걸리고, 목표물이 가까이 있거나 클수록 선택하는 데 더 적은 시간이 걸립니다." +헤더 Header "웹사이트나 앱 화면의 맨 위에 위치하는 영역입니다. 주로 웹사이트나 앱의 로고, 메뉴, 검색창, 로그인 버튼 등 주요 기능을 포함하며, 사용자가 웹사이트나 앱을 탐색하고 이용하는 데 필요한 정보를 제공합니다." +호버 Hover,마우스 오버,Mouse Over "웹사이트나 앱에서 마우스 커서를 특정 요소 위에 올려놓았을 때 발생하는 상호작용 효과입니다. 버튼, 링크, 이미지 등 다양한 요소에 적용되어 시각적인 변화를 주어 사용자에게 피드백을 제공하고 클릭을 유도합니다." +히트맵 Heat Maps "웹사이트나 앱 화면에서 사용자의 시선이나 클릭, 탭 등의 상호작용이 집중되는 영역을 색상으로 시각화하여 보여주는 분석 도구입니다." +힉스의 법칙 Hick's Law "사용자가 선택할 수 있는 옵션의 수가 많아질수록 결정하는 데 걸리는 시간이 증가한다는 법칙입니다. 즉, 선택지가 많아질수록 사용자는 더 많은 시간과 노력을 들여 결정해야 하므로 사용자 경험에 부정적인 영향을 줄 수 있습니다." +CTA Call To Action "사용자가 웹사이트나 앱에서 특정 행동을 하도록 유도하는 버튼, 링크 또는 메시지를 의미합니다. 즉, 사용자에게 무엇을 해야 하는지 알려주고 클릭하거나 탭 하도록 유도하는 요소입니다." +CX Customer Experience "고객이 제품, 서비스, 브랜드와 상호작용하면서 느끼는 총체적인 경험을 의미합니다. 단순히 제품이나 서비스의 기능적인 측면뿐만 아니라, 고객이 상호작용하는 모든 과정에서 느끼는 감정, 인상, 기억 등을 포괄하는 개념입니다." +Dimmed 딤드 처리 " 웹사이트나 앱에서 특정 영역을 어둡게 처리하는 것을 의미합니다. 주로 팝업 창이나 모달 창 등을 띄울 때 배경 화면을 어둡게 하여 사용자의 집중도를 높이고 팝업 내용에 집중하도록 유도하는 데 사용됩니다." +Disabled "Enabled와 반대되는 개념. 특정 GUI 컨트롤이 일반적으로 조작할 수 없는 상태를 의미합니다. 현재 실행할 수 없는 메뉴 항목, 조작해도 의미 없는 컨트롤은 디스에이블되었다고 말할 수 있습니다." +Fidelity 충실도 "디자인이나 프로토타입이 실제 제품과 얼마나 유사한지를 나타내는 정도입니다. 즉, 디자인의 완성도를 의미하며, 디자인의 세부 사항, 기능, 상호 작용 등이 실제 제품과 얼마나 가까운지를 보여줍니다. Low-fidelity (낮은 충실도)는 손으로 그린 스케치, 간단한 와이어프레임, 흑백 목업 등 초기 단계의 디자인을 의미합니다. 디자인의 핵심 개념과 구조를 빠르게 파악하고 아이디어를 검증하는 데 사용됩니다. High-fidelity (높은 충실도)는 실제 제품과 거의 유사한 수준의 디자인을 의미합니다. 실제 색상, 이미지, 인터랙션 등을 포함하며, 사용성 테스트나 최종 디자인 검토에 활용됩니다." +GUI Grapic User Interface "컴퓨터 프로그램을 그림, 아이콘, 버튼 등 시각적인 요소를 이용하여 조작할 수 있도록 만든 화면입니다. 마우스나 터치스크린 등을 이용하여 직관적으로 프로그램을 사용할 수 있게 해줍니다." +HCI Human Computer Interaction "람과 컴퓨터가 상호작용하는 방식을 연구하고 디자인하는 분야입니다. 컴퓨터 시스템을 사용자가 편리하고 효율적으로 사용할 수 있도록 사용자의 행동, 인지, 감정 등을 고려하여 인터페이스를 디자인하고 개발하는 것을 목표로 합니다." +IA Inforamtion Architecture,정보구조도 "웹사이트나 앱의 콘텐츠를 체계적으로 구성하고 분류하는 것을 의미합니다. 사용자가 원하는 정보를 쉽고 빠르게 찾을 수 있도록 콘텐츠를 논리적으로 구조화하고 연결하는 작업입니다." +PPI Pixels Per Inch "1인치 안에 들어가는 픽셀의 개수를 의미합니다. 즉, 화면의 픽셀 밀도를 나타내는 단위입니다. PPI가 높을수록 더 많은 픽셀이 1인치 안에 들어가기 때문에 이미지나 텍스트가 더욱 선명하고 세밀하게 표현됩니다." +UT Usability Testing "실제 사용자가 디자인된 제품이나 서비스를 사용해보면서 문제점이나 개선점을 발견하는 과정입니다. 사용자의 행동, 반응, 의견 등을 수집하여 디자인의 사용성, 효율성, 만족도 등을 평가하고 개선하는 데 활용됩니다." diff --git "a/server/api/src/main/resources/init/\355\205\234\355\224\214\353\246\277\354\232\251\354\226\264\354\247\221-\353\247\210\354\274\200\355\214\205.tsv" "b/server/api/src/main/resources/init/\355\205\234\355\224\214\353\246\277\354\232\251\354\226\264\354\247\221-\353\247\210\354\274\200\355\214\205.tsv" new file mode 100644 index 00000000..848db46a --- /dev/null +++ "b/server/api/src/main/resources/init/\355\205\234\355\224\214\353\246\277\354\232\251\354\226\264\354\247\221-\353\247\210\354\274\200\355\214\205.tsv" @@ -0,0 +1,59 @@ +term synonyms meaning +고객 구매 여정 Consumer Decision Journey,CDJ "고객이 제품/서비스를 인지하고 구매를 결정하기까지 거치는 모든 과정을 말합니다. 고객 구매 여정은 단순히 제품을 구매하는 행위를 넘어, 고객이 브랜드와 상호작용하는 모든 경험을 포함합니다. 마케터는 각 단계별로 고객의 니즈를 파악하고 적절한 마케팅 전략을 수립하여 고객이 최종 구매까지 이어지도록 돕는 역할을 합니다." +고객 페르소나 Customer Persona "타겟 고객을 대표하는 가상의 인물을 설정하여, 그들의 특징, 행동, 니즈 등을 구체화한 것입니다." +네이티브 광고 Native Advertising "광고처럼 보이지 않고 콘텐츠처럼 자연스럽게 녹아든 광고 형태를 말합니다. 주변 콘텐츠와 유사한 형식과 디자인으로 제작되어 사용자 경험을 해치지 않으면서 광고 메시지를 전달합니다." +네트워크 광고 Network Advertising "여러 웹사이트나 앱에 동시에 광고를 노출하는 방식을 말합니다. 광고 네트워크 플랫폼을 통해 다양한 매체에 광고를 게재하여 많은 사람들에게 광고를 노출하고 클릭을 유도합니다." +노출수 Impression "광고 또는 콘텐츠가 사용자에게 노출된 횟수를 의미합니다. 광고가 사용자의 화면에 나타나거나 로드될 때마다 1회 노출로 계산됩니다. 사용자가 광고를 클릭했는지 여부는 중요하지 않으며, 단순히 광고가 보여진 횟수만을 나타냅니다." +도달률 Reach "광고 또는 콘텐츠가 최소 한 번 이상 노출된 고유한 사용자 수의 비율을 의미합니다. 노출수와 달리, 동일한 사용자에게 여러 번 노출된 경우에도 한 명으로 계산됩니다. 즉, 광고가 얼마나 다양한 사람들에게 도달했는지를 나타내는 지표입니다." +디스플레이 광고 DA,Display Advertising "웹사이트, 앱, 소셜 미디어 등 다양한 온라인 채널에 이미지, 텍스트, 동영상 등 시각적인 요소를 활용하여 노출되는 광고 형태입니다. 배너 광고, 네이티브 광고, 동영상 광고 등이 디스플레이 광고에 포함됩니다." +랜딩 페이지 Landing Page "사용자가 광고, 이메일, 검색 결과 등을 클릭하고 처음 도착하는 웹페이지를 말합니다. 특정 목표(제품 구매, 회원가입, 정보 수집 등)를 달성하도록 유도하는 페이지로, 일반 웹페이지와 달리 불필요한 정보를 제거하고 핵심 메시지와 행동 유도 버튼을 강조하는 것이 특징입니다." +롤링배너 광고 Rolling Banner Advertising "하나의 광고 영역에 여러 개의 광고가 번갈아 가면서 노출되는 디스플레이 광고 형태입니다. 일정 시간 간격으로 또는 사용자의 특정 행동 (페이지 스크롤, 새로고침 등)에 따라 다른 광고가 나타납니다." +리드 Lead "잠재 고객으로, 제품이나 서비스에 관심을 보이거나 구매 가능성이 있는 사람을 말합니다. 아직 실제 고객은 아니지만, 마케팅 활동을 통해 잠재 고객을 발굴하고 육성하여 최종적으로 구매 고객으로 전환시키는 것이 목표입니다." +리치미디어 광고 Rich Media Advertising "기존의 정적인 배너 광고와 달리, 동영상, 오디오, 애니메이션, 인터랙티브 요소 등 다양한 멀티미디어를 활용하여 사용자의 참여를 유도하는 광고 형태입니다. 사용자의 클릭, 마우스 오버, 드래그 등 상호작용을 통해 광고 효과를 극대화하는 것이 특징입니다." +리타게팅 광고 Retargeting Advertising "웹사이트나 앱에 방문했거나 특정 행동을 한 사용자에게 다시 광고를 노출하는 마케팅 기법입니다. 사용자의 쿠키 정보를 활용하여, 다른 웹사이트나 앱을 방문했을 때 이전에 관심을 보였던 제품/서비스 광고를 다시 보여주는 방식입니다." +리텐션 Retention "기존 고객이 제품/서비스를 계속해서 이용하는 정도를 나타내는 지표입니다. 고객 유지율이라고도 하며, 고객을 얼마나 오랫동안 유지하고 있는지를 측정하여 마케팅 전략의 효과를 평가하는 데 사용됩니다." +배너 광고 Banner Advertising "웹사이트나 앱에 이미지 또는 텍스트 형태로 노출되는 디스플레이 광고의 한 종류입니다. 주로 웹페이지 상단, 하단, 측면 등에 위치하며," +서드파티 데이터 3rd-party data "데이터를 직접 수집하지 않고, 외부 데이터 제공 업체로부터 구매하거나 협력을 통해 얻는 데이터를 말합니다. 주로 다양한 웹사이트나 앱에서 사용자의 행동 정보, 관심사, 인구통계학적 정보 등을 수집하여 분석하고 가공한 데이터입니다." +세그멘테이션 Segmentation "전체 시장을 공통된 특징을 가진 더 작은 그룹으로 나누는 과정을 말합니다. 연령, 성별, 지역, 관심사, 소득 수준, 구매 행동 등 다양한 기준을 활용하여 세분화된 고객 그룹을 만들고, 각 그룹에 맞는 맞춤형 마케팅 전략을 수립하는 데 활용됩니다." +세컨드 파티 데이터 Second-party data " 다른 기업이나 조직이 직접 수집한 퍼스트파티 데이터를 파트너십 또는 제휴를 통해 공유받아 활용하는 데이터를 말합니다. 즉, 자사가 직접 수집한 데이터는 아니지만, 신뢰할 수 있는 파트너로부터 얻은 데이터이기 때문에 높은 품질과 관련성을 기대할 수 있습니다." +시즈널 키워드 Seasonal Keyword "특정 계절이나 시기에 검색량이 증가하는 키워드를 말합니다. 계절 변화, 공휴일, 기념일, 이벤트 등 특정 시기에 대한 사람들의 관심과 수요를 반영하여 검색량이 급증하는 키워드를 의미합니다." +언드 미디어 Earned Media "기업이 직접 비용을 지불하지 않고, 자발적인 입소문, 뉴스 기사, 소셜 미디어 공유, 리뷰 등을 통해 얻는 미디어 노출을 말합니다. 즉, 기업의 제품/서비스나 콘텐츠가 훌륭하여 사람들이 자발적으로 공유하고 언급함으로써 얻는 홍보 효과입니다." +오가닉트래픽 Organic Traffic "검색 엔진 (구글, 네이버 등)에서 검색 결과를 통해 웹사이트나 앱으로 유입되는 방문자를 말합니다. 광고 비용을 지불하지 않고 자연 검색 결과를 통해 유입되는 트래픽이기 때문에 '자연 유입'이라고도 합니다." +온드 미디어 Owned Media "기업이 소유하고 직접 관리하는 미디어 채널을 말합니다. 웹사이트, 블로그, 소셜 미디어 계정, 이메일 뉴스레터, 모바일 앱 등 기업이 콘텐츠를 제작하고 배포하는 모든 채널이 온드 미디어에 해당합니다." +이탈률 Bounce Rate "웹사이트나 앱에 방문한 사용자가 단 하나의 페이지만 보고 아무런 상호작용 없이 떠나는 비율을 말합니다. 즉, 다른 페이지로 이동하거나 클릭, 스크롤 등의 행동을 하지 않고 즉시 웹사이트를 떠나는 경우를 의미합니다." +인텐트 마케팅 Intent Marketing "잠재 고객의 구매 의도를 파악하고, 그 의도에 맞는 정보나 제품/서비스를 제공하는 마케팅 전략입니다. 사용자가 검색하는 키워드, 방문하는 웹사이트, 소셜 미디어 활동 등을 분석하여 잠재 고객의 관심사와 니즈를 파악하고, 적절한 시점에 맞춤형 마케팅 메시지를 전달하는 것을 목표로 합니다." +체류시간 DT,Duration Time "사용자가 검색 결과를 클릭하여 웹사이트에 방문한 후, 다시 검색 결과 페이지로 돌아가기까지 해당 웹사이트에 머무는 시간을 의미합니다. 즉, 사용자가 웹사이트에서 콘텐츠를 얼마나 오래 소비하는지를 나타내는 지표입니다." +키워드 광고 SA,Search Advertising "사용자가 검색 엔진에 특정 키워드를 입력했을 때, 검색 결과 페이지에 노출되는 광고입니다. 광고주는 자신이 원하는 키워드를 선택하고 입찰에 참여하여 광고 순위를 결정합니다. 클릭당 비용 (CPC) 방식으로 과금되며, 사용자가 광고를 클릭할 때마다 비용이 발생합니다." +타겟팅 Targeting Advertising,target-marketing,타겟 마케팅 "마케팅 메시지를 특정 고객층에게 집중적으로 전달하는 전략입니다. 불특정 다수에게 광고를 노출하는 대신, 제품/서비스에 관심을 가질 가능성이 높은 잠재 고객을 선별하여 광고 효율을 높이는 것을 목표로 합니다." +퍼널 Funnel "잠재 고객이 제품/서비스를 인지하고 구매하기까지의 단계를 깔때기 모양으로 시각화한 모델입니다. 넓은 입구로 시작하여 점점 좁아지는 깔때기처럼, 많은 잠재 고객이 유입되어 최종 구매까지 이어지는 과정을 나타냅니다." +퍼스트 파티 데이터 First-party data "기업이 직접 웹사이트, 앱, CRM 시스템, 설문 조사 등을 통해 수집한 고객 데이터를 말합니다. 즉, 고객이 기업과 직접 상호작용하면서 생성된 데이터로, 고객의 행동, 선호도, 구매 이력 등 다양한 정보를 포함합니다." +페이드 미디어 Paid Media "기업이 비용을 지불하고 광고를 게재하는 모든 미디어 채널을 말합니다. 검색 광고, 디스플레이 광고, 소셜 미디어 광고, 인플루언서 마케팅, 스폰서십 등 다양한 형태로 존재하며, 즉각적인 노출과 트래픽 증가를 목표로 합니다." +포지셔닝 Positioning "소비자의 마음속에 제품이나 브랜드를 특정 이미지로 인식시키는 활동입니다. 경쟁 제품과 차별화되는 이미지를 구축하여 소비자에게 매력적인 선택지로 자리 잡는 것을 목표로 합니다." +AARRR 해적 지표 "스타트업의 성장 단계를 5단계로 나누어 각 단계별 핵심 지표를 측정하고 분석하는 프레임워크입니다. 스타트업이 성장하기 위해 필요한 핵심 요소를 파악하고 개선하는 데 도움을 줍니다. Dave McClure라는 투자자가 스타트업들이 '허영 지표'에 빠지는 것을 방지하기 위해 AARRR을 고안했다고 합니다. 각 단계의 앞 글자를 따서 AARRR이라고 부르며, 해적들이 사용하는 용어 같다고 해서 "해적 지표"라고도 불립니다. AARRR의 5단계에는 Acquisition (획득), Activation(활성화), Retention(유지), Referral(추천), Revenue(매출)의 단계로 나뉩니다." +CAC Customer Acquisition Cost,고객 획득 비용 "새로운 고객 한 명을 얻기 위해 기업이 지출하는 평균 비용을 말합니다. 마케팅 및 영업 활동에 사용된 모든 비용을 특정 기간 동안 확보한 신규 고객 수로 나누어 계산합니다." +CPA Cost per Action,행동당 비용 "광고를 통해 사용자가 특정 행동 (구매, 회원가입, 앱 설치 등)을 완료했을 때 발생하는 비용을 말합니다. 광고주는 원하는 행동을 정의하고, 해당 행동이 발생할 때마다 비용을 지불합니다. CPA는 광고의 실질적인 성과를 측정하고 평가하는 데 유용한 지표입니다." +CPC Cost per Click,클릭 단가 "광고가 한 번 클릭될 때마다 광고주가 지불하는 비용을 말합니다. 주로 검색 광고나 디스플레이 광고에서 사용되는 과금 방식으로, 사용자가 광고를 클릭할 때마다 광고 플랫폼에 미리 설정된 금액을 지불하게 됩니다." +CPI Cost per Install,설치당 비용 "앱 광고를 통해 사용자가 앱을 한 번 설치할 때마다 광고주가 지불하는 비용을 말합니다. 주로 모바일 앱 마케팅에서 사용되는 과금 방식으로, 앱 설치를 목표로 하는 캠페인에서 효과를 측정하는 주요 지표입니다." +CPL Cost per Lead,리드당 비용 "광고를 통해 잠재 고객 (리드)을 한 명 확보하는 데 드는 평균 비용을 말합니다. 잠재 고객은 제품/서비스에 관심을 보이거나 구매 가능성이 있는 사람으로, 회원가입, 상담 신청, 이벤트 참여 등 다양한 방법으로 확보될 수 있습니다." +CPM Cost per Mile,1000회 노출당 비용 "광고가 1,000회 노출될 때마다 광고주가 지불하는 비용을 말합니다. 여기서 노출은 광고가 사용자의 화면에 표시되는 것을 의미하며, 사용자가 실제로 광고를 클릭했는지 여부는 중요하지 않습니다. 주로 브랜드 인지도를 높이거나 대규모 캠페인을 진행할 때 사용되는 과금 방식입니다." +CPV Cost per View,조회당 비용 "동영상 광고가 유효하게 조회될 때마다 광고주가 지불하는 비용을 말합니다. 여기서 유효 조회는 광고가 일정 시간 이상 재생되거나 사용자가 광고와 상호작용하는 경우를 의미합니다. 일반적으로 30초 이상 시청하거나 짧은 동영상의 경우 전체를 시청하는 경우, 또는 클릭 등의 상호작용이 발생하는 경우 유효 조회로 간주됩니다." +CRM Customer Relationship Management,고객 관계 관리 "기업이 고객과의 관계를 관리하고 향상시키기 위해 사용하는 전략 및 시스템을 말합니다. 고객 데이터를 수집, 분석, 활용하여 고객과의 관계를 강화하고, 궁극적으로 매출 증대와 수익성 향상을 목표로 합니다." +CTA Call to Action "사용자가 웹사이트, 앱, 광고 등에서 특정 행동을 하도록 유도하는 버튼, 링크 또는 메시지를 말합니다. "지금 구매하기", "무료 체험 신청", "자세히 알아보기" 등의 문구와 함께 눈에 띄는 디자인으로 제작되어 사용자의 클릭을 유도합니다." +CTR Click Through Rate,클릭률 "광고 또는 링크가 노출된 횟수 대비 클릭된 횟수의 비율을 말합니다. 즉, 광고를 본 사람 중 몇 명이 실제로 광고를 클릭했는지를 나타내는 지표입니다. 클릭률은 퍼센트 (%)로 표시되며, 광고 효과를 측정하고 평가하는 데 중요한 역할을 합니다." +CVR Conversion Rate,전환율 "웹사이트나 앱, 광고 등에 방문한 사용자 중에서 특정 목표 행동을 완료한 비율을 말합니다. 목표 행동은 기업이 설정한 마케팅 목표에 따라 달라질 수 있으며, 일반적으로 구매, 회원가입, 상담 신청, 앱 설치 등이 해당됩니다. 전환율은 마케팅 캠페인의 효과를 측정하고 평가하는 핵심 지표입니다." +DAU Daily Active Users,일일 활성 사용자 "하루 동안 웹사이트, 앱, 또는 플랫폼에 접속하여 활동한 순 사용자 수를 의미합니다. 일반적으로 24시간 동안 특정 행동 (로그인, 콘텐츠 조회, 구매 등)을 한 사용자를 집계하여 측정합니다. 즉, 앱이나 웹 서비스가 얼마나 활발하게 이용되고 있는지를 나타내는 지표입니다." +KPI Key Perfomance Indicator,핵심 성과 지표 "기업이나 조직의 목표 달성 정도를 측정하는 데 사용되는 핵심적인 지표입니다. 즉, 기업이나 부서의 성과를 평가하고, 전략의 효과를 측정하며, 개선이 필요한 부분을 파악하는 데 도움을 주는 핵심적인 측정 기준입니다." +LTV Life Time Value,고객 생애 가치 "고객이 특정 기업의 제품이나 서비스를 이용하는 전체 기간 동안 기업에게 가져다줄 것으로 예상되는 총 수익을 말합니다. 즉, 고객 한 명의 가치를 나타내는 지표로, 고객 획득 비용 (CAC)과 함께 마케팅 전략의 효율성을 평가하는 데 중요한 역할을 합니다." +MAU Monthly Active User,월간 활성 사용자 "한 달 동안 웹사이트, 앱, 또는 플랫폼에 접속하여 활동한 순 사용자 수를 의미합니다. 일반적으로 30일 동안 특정 행동 (로그인, 콘텐츠 조회, 구매 등)을 한 사용자를 집계하여 측정합니다. 즉, 앱이나 웹 서비스가 한 달 동안 얼마나 활발하게 이용되고 있는지를 나타내는 지표입니다." +MQL Marketing Qualified Lead,마케팅 적격 리드 "마케팅 활동을 통해 잠재 고객 (리드) 중에서 제품이나 서비스에 대한 관심과 구매 의향을 보인 사람들을 말합니다. 일반적인 리드보다 구매 가능성이 높다고 판단되어 영업 부서에 넘겨져 추가적인 관리를 받는 단계에 있는 잠재 고객입니다." +MRR Money Recurring Revenue,월간 반복 매출 "구독 기반 비즈니스 모델을 운영하는 기업이 매달 반복적으로 얻는 수익을 의미합니다. 월간 구독료, 추가 서비스 이용료 등 매달 정기적으로 발생하는 수익을 합산하여 계산하며, 일회성 수익은 제외됩니다." +OKR Objective Key Results "기업, 팀 또는 개인이 설정한 목표(Objective)와 그 목표를 달성하기 위한 구체적인 핵심 결과(Key Results)를 의미합니다. OKR은 목표를 명확하게 설정하고, 측정 가능한 결과를 통해 진행 상황을 추적하며, 목표 달성을 위한 집중과 동기를 부여하는 데 도움을 주는 목표 관리 프레임워크입니다." +PV Page View,페이지 뷰 "웹사이트 또는 앱의 특정 페이지가 조회된 횟수를 의미합니다. 사용자가 웹사이트나 앱 내에서 페이지를 이동할 때마다 PV가 1씩 증가합니다. 같은 사용자가 같은 페이지를 여러 번 조회하더라도 PV는 계속 증가하며, 중복 조회도 포함하여 계산됩니다." +ROAS Return on Advertising Spend,로아스,광고 투자 수익률 "고에 투자한 비용 대비 얻은 수익을 나타내는 지표입니다. 즉, 광고를 통해 얼마나 많은 매출을 올렸는지를 보여주는 성과 측정 지표입니다. ROAS는 퍼센트 (%) 또는 배수로 표시되며, 광고 캠페인의 효율성을 평가하고 최적화하는 데 사용됩니다." +ROI Return On Investment,투자 수익률 "투자한 비용 대비 얻은 수익을 나타내는 지표입니다. 즉, 특정 마케팅 활동이나 사업에 투자한 비용 대비 얼마나 많은 이익을 얻었는지를 평가하는 데 사용되는 지표입니다. ROI는 퍼센트 (%) 또는 배수로 표시되며, 투자 효율성을 분석하고 개선하는 데 도움을 줍니다." +RTB Real Time Bidding,실시간 광고 입찰 시스템 "온라인 광고 지면을 실시간 경매 방식으로 사고파는 자동화된 광고 구매 방식입니다. 사용자가 웹사이트나 앱에 접속하는 순간, 광고 지면이 경매에 부쳐지고, 여러 광고주들이 해당 지면에 광고를 노출시키기 위해 입찰 경쟁을 벌입니다. 가장 높은 가격을 제시한 광고주가 해당 지면을 낙찰받아 광고를 노출시키게 됩니다. 이 모든 과정은 사용자가 웹페이지를 로딩하는 짧은 시간 안에 이루어집니다." +SQL Sales Qualified Lead,영업 적격 리드 "마케팅 활동을 통해 발굴된 잠재 고객 (리드) 중에서 영업 부서의 판단에 따라 실제 구매 가능성이 높다고 판단된 고객을 의미합니다. 즉, MQL (Marketing Qualified Lead) 단계를 거쳐 영업 부서의 검증을 통과한 잠재 고객으로, 영업 활동을 통해 실제 고객으로 전환될 가능성이 높은 단계에 있는 고객입니다." +USP Unique Selling Point,판매 가치 제안 "경쟁사와 차별화되는 자사 제품이나 서비스만의 독특하고 매력적인 특징 또는 강점을 의미합니다. 즉, 소비자가 다른 제품/서비스 대신 우리 제품/서비스를 선택해야 하는 이유를 명확하게 제시하는 것입니다." +UTM Urchin Tracking Module "웹사이트 링크에 특정 정보를 담은 코드를 추가하여 마케팅 캠페인의 성과를 추적하는 시스템입니다. 즉, 광고, 이메일, 소셜 미디어 등 다양한 마케팅 채널을 통해 유입되는 트래픽의 출처, 매체, 캠페인 등을 구분하고 분석하는 데 사용됩니다." +UV Unique Visitor,페이지 순방문자 수 "특정 기간 동안 웹사이트나 앱을 방문한 중복되지 않은 방문자 수를 의미합니다. 동일한 사용자가 여러 번 방문하더라도 한 명의 순 방문자로 계산됩니다. 주로 쿠키, IP 주소, 사용자 계정 정보 등을 활용하여 방문자를 식별하고 중복 방문을 제거합니다." +VOC Voice of Customer,고객의 소리 "고객이 제품, 서비스, 브랜드에 대해 가지는 의견, 생각, 불만, 요구사항 등을 총칭하는 말입니다. 즉, 고객이 직접적으로 표현하는 피드백을 의미하며, 설문조사, 인터뷰, 리뷰, 소셜 미디어 게시글, 고객센터 문의 등 다양한 채널을 통해 수집됩니다." diff --git "a/server/api/src/main/resources/init/\355\205\234\355\224\214\353\246\277\354\232\251\354\226\264\354\247\221-\354\235\274\353\260\230\354\202\254\353\254\264.tsv" "b/server/api/src/main/resources/init/\355\205\234\355\224\214\353\246\277\354\232\251\354\226\264\354\247\221-\354\235\274\353\260\230\354\202\254\353\254\264.tsv" new file mode 100644 index 00000000..ff845ce1 --- /dev/null +++ "b/server/api/src/main/resources/init/\355\205\234\355\224\214\353\246\277\354\232\251\354\226\264\354\247\221-\354\235\274\353\260\230\354\202\254\353\254\264.tsv" @@ -0,0 +1,75 @@ +term synonyms meaning +데모데이 Demoday "스타트업이 투자자들 앞에서 자신의 서비스나 제품을 발표하고 투자 유치를 목표로 하는 행사입니다. 마치 쇼케이스나 경연 대회처럼 생각하면 쉽습니다. 스타트업에게는 힘들게 개발한 서비스를 뽐내고 투자를 받을 수 있는 기회이며, 투자자에게는 유망한 스타트업을 발굴하고 투자할 수 있는 기회의 장입니다." +디루션 Dilution "스타트업 용어에서 '디루션(Dilution)'은 기존 주주들의 지분율이 낮아지는 현상을 의미합니다. 희석이라고도 합니다. 스타트업이 성장하면서 추가 투자를 유치하거나 신주를 발행할 때 발생합니다." +디벨롭 Develop "아이디어를 구체화하여 실제 제품이나 서비스로 만들어내는 과정입니다. 시장 조사, 기획, 디자인, 개발, 테스트 등 다양한 단계를 거치며 제품/서비스의 완성도를 높여갑니다." +리드 타임 Lead Time "리드 타임은 어떤 일을 시작해서 완료하기까지 걸리는 시간을 의미합니다. 쉽게 말해 소요 시간이라고 할 수 있죠. 스타트업에서는 주로 제품 개발이나 서비스 출시와 관련된 리드 타임을 중요하게 생각합니다." +레버리지 Leverage "레버리지는 지렛대 효과를 의미합니다. 작은 힘으로 큰 결과를 얻는 것을 비유적으로 표현하는 말입니다. 스타트업에서는 자본 레버리지와 운영 레버리지, 두 가지 측면에서 레버리지를 활용합니다. 자본 레버리지는 타인 자본(대출금, 투자금 등)을 이용하여 자기 자본 수익률을 높이는 전략입니다. 적은 자기 자본으로 큰 규모의 사업을 운영할 수 있게 해주는 지렛대 역할을 합니다. 운영 레버리지는 고정 비용을 활용하여 매출 증가에 따른 이익 증가폭을 키우는 전략입니다. 매출이 증가할수록 고정 비용 부담이 줄어들어 이익이 더 크게 증가하는 효과를 얻을 수 있습니다." +렙업 Wrap-up "Wrap-up은 스타트업 맥락에서 주로 다음과 같은 의미로 사용됩니다.\n1. 회의나 미팅의 마무리: 회의 내용을 요약하고 다음 단계를 정리하는 시간\n2. 프로젝트 종료: 프로젝트 결과를 정리하고 평가하는 단계\n3. 핵심 내용 요약: 회의, 보고서, 발표 등의 핵심 내용을 간략하게 정리\n4. 결론 도출: 논의된 내용을 바탕으로 결론을 내리는 것을 의미합니다." +롱테일의 법칙 Long tail "롱테일 법칙은 다수의 비인기 상품(80%)의 총 판매량이 소수의 인기 상품(20%)의 총 판매량을 넘어서는 현상을 말합니다. 쉽게 말해, '잘 팔리는 소수'보다 '덜 팔리는 다수'가 더 큰 가치를 창출하는 현상입니다." +린 Lean "불필요한 낭비를 줄이고 효율성을 극대화하여 빠르게 제품을 출시하고 고객 피드백을 통해 지속적으로 개선해나가는 경영 방식입니다." +마일스톤 Milestone "스타트업에서 마일스톤은 중요한 목표나 단계를 의미합니다. 마치 등산할 때 정상까지 가는 길에 중요한 지점을 표시해 놓은 이정표와 같다고 볼 수 있습니다. 스타트업은 마일스톤을 설정하고 달성함으로써, 목표를 구체화하고 성과를 측정하며, 동기 부여를 얻고, 투자 유치에도 도움을 받을 수 있습니다." +백로그 Backlog "스타트업에서 백로그는 개발해야 할 기능이나 개선해야 할 작업 목록을 의미합니다. 즉, 아직 완료되지 않은 해야 할 일 목록이라고 생각하면 쉽습니다. 백로그는 우선순위에 따라 정렬되어 있으며, 개발팀은 백로그를 참고하여 작업 계획을 세우고 진행합니다." +밸류에이션 Valuation "스타트업 밸류에이션은 기업의 가치를 평가하는 과정입니다. 간단히 말해, "이 스타트업은 얼마의 가치가 있을까?"를 숫자로 나타내는 것입니다. 밸류에이션은 투자 유치, 인수합병, 스톡옵션 부여 등 다양한 상황에서 중요한 역할을 합니다." +밸류체인 Value Chain "밸류체인은 제품이나 서비스가 아이디어 단계에서 시작하여 고객에게 전달되기까지의 모든 과정을 의미합니다. 각 단계마다 가치가 더해지는 일련의 활동들을 연결한 것이죠. 밸류체인 분석을 통해 기업은 각 단계에서 어떻게 가치를 창출하고 비용을 절감할 수 있는지 파악하여 경쟁 우위를 확보할 수 있습니다." +벤처 캐피탈 VC,Venture Capital "벤처 캐피탈은 혁신적인 기술이나 아이디어를 가진 신생 기업(스타트업)에 투자하는 금융 기관입니다. 높은 성장 가능성을 가진 스타트업에 자금을 지원하고 경영 자문, 네트워킹 등 다양한 지원을 제공하여 기업의 성장을 돕는 역할을 합니다." +사일로 Silo "스타트업에서 사일로는 부서 간, 팀 간의 소통 부족으로 발생하는 고립 현상을 의미합니다. 마치 곡식을 저장하는 silos처럼 각 팀이 서로 단절되어 정보 공유나 협업이 원활하지 않은 상태를 비유적으로 표현하는 말입니다." +스쿼드 Squad "특정 목표 달성을 위해 구성된 작고 독립적인 팀을 의미합니다. 각 스쿼드는 제품 개발, 마케팅, 디자인 등 특정 분야에 대한 전문성을 갖춘 구성원들로 이루어지며, 자율적으로 의사 결정하고 책임을 지는 구조로 운영됩니다." +스크럼 Scrum "스크럼은 복잡하고 변화가 많은 프로젝트를 관리하기 위한 애자일(Agile) 개발 방법론 중 하나입니다. 럭비 경기에서 선수들이 뭉쳐서 앞으로 나아가는 모습에서 유래한 용어로, 팀원들이 협력하여 목표를 달성하는 데 중점을 둡니다." +스프린트 Sprint "스프린트는 애자일(Agile) 개발 방법론, 특히 스크럼(Scrum)에서 사용되는 용어로, 짧고 집중적인 개발 주기를 의미합니다. 일반적으로 1주에서 4주 정도의 기간 동안 팀은 특정 목표를 설정하고 해당 목표를 달성하기 위해 노력합니다. 스프린트는 빠른 피드백과 지속적인 개선을 통해 제품이나 서비스의 가치를 높이는 데 중점을 둡니다." +시드 Seed "스타트업의 초기 단계에 이루어지는 첫 번째 공식적인 투자 유치 단계입니다. 씨앗(seed)이라는 이름처럼, 스타트업이 사업을 시작하고 성장하는 데 필요한 초기 자금을 지원하는 역할을 합니다." +시리즈 A Series A "스타트업 투자는 기업의 성장 단계에 따라 시드(Seed), 시리즈 A, 시리즈 B, 시리즈 C 등으로 나뉘는데, 시리즈 A는 시드 투자 이후 본격적인 성장을 위한 자금을 확보하는 단계를 의미합니다." +시리즈 B Series B "시리즈 B 투자는 스타트업이 시리즈 A 투자 이후 본격적인 성장을 가속화하기 위해 받는 투자 단계입니다. 이 단계에서는 제품/서비스가 시장에서 어느 정도 검증되었고, 수익 모델도 어느 정도 확립된 상태입니다." +시리즈 C Series C "시리즈 C 투자는 스타트업이 시리즈 B 투자 이후 기업 규모를 더욱 확장하고 시장 지배력을 강화하기 위해 받는 투자 단계입니다. 이 단계에서는 이미 안정적인 수익 모델을 확보하고 급격한 성장을 경험하고 있는 경우가 많습니다." +아젠다 Agenda "회의나 미팅에서 논의할 주제 또는 안건 목록을 의미합니다. 회의 목표를 명확히 하고 효율적으로 진행하기 위해 미리 아젠다를 작성하고 공유하는 것이 중요합니다." +애자일 Agile "애자일은 소프트웨어 개발 방법론 중 하나로, 변화에 유연하게 대응하고 빠르게 결과물을 만들어내는 것에 중점을 둡니다. 전통적인 개발 방법론인 워터폴(Waterfall) 모델과 달리, 애자일은 짧은 주기로 개발하고 테스트하며, 고객 피드백을 반영하여 지속적으로 개선해 나가는 방식입니다." +액션 아이템 Action Item "액션 아이템은 회의, 미팅, 프로젝트 등에서 논의된 내용을 바탕으로 실제로 실행해야 할 구체적인 과제를 의미합니다. 즉, 말로만 끝나는 것이 아니라 실제 행동으로 옮겨야 할 항목이죠. 액션 아이템은 담당자, 마감 기한, 구체적인 내용 등을 명확히 정의하여 실행 가능성을 높이는 것이 중요합니다." +어레인지 Arrange "- 투자 유치: 투자자와의 미팅, 투자 조건 협상, 투자 계약 체결 등 투자 유치와 관련된 제반 사항을 준비하고 조율하는 것을 의미합니다. 예를 들어, "투자 유치를 위해 벤처 캐피탈과 미팅 일정을 어레인지하고 있다"와 같이 사용할 수 있습니다.\n- 파트너십 체결: 다른 기업과의 협력 관계 구축, 제휴 계약 체결 등 파트너십과 관련된 제반 사항을 준비하고 조율하는 것을 의미합니다. 예를 들어, "해외 진출을 위해 현지 파트너사와 파트너십을 어레인지하고 있다"와 같이 사용할 수 있습니다.\n- 인력 채용: 채용 공고 작성, 후보자 인터뷰, 채용 조건 협상 등 인력 채용과 관련된 제반 사항을 준비하고 조율하는 것을 의미합니다. 예를 들어, "핵심 개발 인력을 채용하기 위해 헤드헌터와 어레인지하고 있다"와 같이 사용할 수 있습니다.\n- 업무 조정: 팀원 간 업무 분담, 일정 조율, 자원 배분 등 업무와 관련된 제반 사항을 정리하고 조정하는 것을 의미합니다. 예를 들어, "다음 스프린트를 위한 업무를 어레인지하고 있다"와 같이 사용할 수 있습니다.\n- 데이터 정리: 수집된 데이터를 분석 가능한 형태로 가공하고 정리하는 것을 의미합니다. 예를 들어, "사용자 설문 조사 결과를 어레인지하여 보고서를 작성하고 있다"와 같이 사용할 수 있습니다." +얼라인 Align "팀 또는 조직 구성원들의 개별 목표를 공동의 목표와 일치시키는 과정입니다. 모든 구성원이 같은 방향으로 나아가도록 하는 것이죠." +엑셀러레이터 Accelerator "액셀러레이터는 초기 단계 스타트업을 발굴하여 짧은 기간 동안 집중적으로 멘토링, 교육, 네트워킹 등을 지원하고 투자하는 기관입니다. 스타트업이 빠르게 성장 궤도에 오를 수 있도록 돕는 '성장 촉진제' 역할을 한다고 생각하시면 됩니다." +엑싯 Exit "스타트업 엑싯은 투자자가 투자금을 회수하는 것을 의미하며, 스타트업 생태계의 선순환 구조를 완성하는 중요한 과정입니다. 엑싯은 스타트업의 성장과 발전을 의미하는 동시에 투자자에게는 투자 수익을 실현하는 기회를 제공합니다." +엔젤투자자 Angel Investor "엔젤 투자자는 개인 자격으로 초기 단계 스타트업에 투자하는 사람을 말합니다. 마치 천사(angel)처럼 자금이 부족한 스타트업에 날개를 달아주는 역할을 합니다. 엔젤 투자자는 단순히 자금 지원뿐만 아니라 경험과 노하우를 바탕으로 멘토링, 네트워킹 등 다양한 지원을 제공하기도 합니다." +오프보딩 Off-boarding "퇴사하는 직원이 회사를 원활하게 떠날 수 있도록 돕는 절차를 의미합니다. 단순히 퇴사 처리를 하는 것뿐만 아니라, 퇴사 직원의 지식과 경험을 회사에 남기고, 회사 자산을 안전하게 회수하며, 긍정적인 퇴사 경험을 제공하는 데 초점을 맞춥니다." +오픈베타 OBT,Open Beta "오픈 베타는 정식 출시 전에 제품이나 서비스를 불특정 다수에게 공개하여 테스트하는 단계를 의미합니다. 제한된 인원을 대상으로 진행하는 클로즈 베타(Closed Beta)와 달리, 오픈 베타는 누구나 참여할 수 있습니다." +온보딩 On-boarding "신규 입사자가 회사에 잘 적응하고 빠르게 업무에 기여할 수 있도록 지원하는 모든 과정을 의미합니다. 단순히 회사 정보를 전달하는 것을 넘어, 신규 입사자가 회사 문화에 익숙해지고 동료들과 관계를 형성하며, 자신의 역할과 책임을 이해하도록 돕는 포괄적인 활동입니다." +인큐베이터 Incubator "스타트업 생태계에서 인큐베이터는 초기 단계 스타트업의 성장을 지원하는 기관 또는 시설을 의미합니다. 마치 병아리가 알에서 부화하도록 돕는 인큐베이터처럼, 스타트업이 성공적으로 사업을 시작하고 성장할 수 있도록 다양한 지원을 제공합니다." +칸반 보드 Kanban Board "작업 흐름을 시각적으로 표현하는 도구입니다. 칸반(Kanban)은 일본어로 "간판" 또는 "표지판"을 의미하며, 칸반 보드는 이러한 간판들을 활용하여 작업의 진행 상황을 한눈에 파악할 수 있도록 돕습니다." +캡 테이블 Cap Table "스타트업의 자본 구성을 보여주는 표입니다. 즉, 누가 회사의 주식을 얼마나 소유하고 있는지를 상세하게 기록한 문서라고 할 수 있습니다." +컨버터블 노트 Convertible Note,전환사채 "투자자가 스타트업에 빌려준 돈을 특정 조건에 따라 주식으로 전환할 수 있는 권리가 포함된 채권입니다. 초기 스타트업 투자에 많이 활용되는 방식으로, 기업 가치 평가가 어려운 초기 단계에 투자를 유치하고, 추후 기업 가치가 상승했을 때 투자자에게 이익을 제공하는 장점이 있습니다." +클로즈베타 CBT,Closed Beta "클로즈 베타는 제품이나 서비스를 정식 출시하기 전에 제한된 인원에게만 공개하여 테스트하는 단계입니다. 오픈 베타와 달리, 클로즈 베타는 특정 조건을 만족하는 사용자 또는 초대받은 사용자만 참여할 수 있습니다." +킥오프 Kick-off "프로젝트나 새로운 사업, 캠페인 등을 시작하는 첫 회의를 의미합니다. 마치 축구 경기에서 공을 차서 경기를 시작하는 것처럼, 킥오프 회의를 통해 프로젝트의 시작을 알리고 팀원들의 의지를 다지는 자리입니다." +타운홀 Townhall "타운홀 미팅은 기업의 경영진과 직원들이 한자리에 모여 자유롭게 소통하는 회의입니다. 마을 광장(town hall)에서 주민들이 모여 토론하던 전통에서 유래한 방식으로, 투명한 정보 공유와 의견 수렴을 통해 조직 문화를 개선하고 구성원들의 참여를 독려하는 데 목적이 있습니다." +텀시트 Term Sheet "텀시트는 스타트업이 투자자로부터 투자를 유치할 때, 투자 조건에 대한 기본적인 합의 내용을 담은 문서입니다. 투자 계약의 기본적인 조건들을 간략하게 정리한 것으로, 법적 구속력은 없지만, 본격적인 투자 계약 협상 전에 양측의 의견을 조율하고 합의점을 찾는 데 중요한 역할을 합니다." +테스크 Task,과업 "특정 목표를 달성하기 위해 수행해야 할 개별적인 작업을 의미합니다. 프로젝트를 구성하는 가장 작은 단위라고 할 수 있으며, 각 테스크는 담당자, 마감 기한, 구체적인 내용 등을 포함합니다." +팀빌딩 Team-Building "팀원들 간의 협력, 소통, 신뢰를 강화하고 팀워크를 향상시키기 위한 다양한 활동을 의미합니다. 스타트업에서는 특히 중요한데, 작은 규모의 팀이 빠르게 성장하고 변화하는 환경에 적응하려면 팀원 간의 끈끈한 유대감과 협력이 필수적이기 때문입니다." +펀드레이징 Fundraising "스타트업에서 펀드레이징은 외부 투자자로부터 자금을 조달하는 활동을 의미합니다. 스타트업은 펀드레이징을 통해 사업 확장, 제품 개발, 마케팅 등에 필요한 자금을 확보하고 성장을 가속화할 수 있습니다." +포스트 머니 Post Money "스타트업이 투자 유치 후 평가되는 기업 가치를 의미합니다. 즉, 투자금이 회사에 유입된 후의 회사 가치를 말합니다." +프리 머니 Pre Money "프리 머니는 스타트업이 투자 유치 전에 평가되는 기업 가치를 의미합니다. 즉, 투자금이 회사에 유입되기 전의 회사 가치를 말합니다. 프리 머니 밸류에이션은 스타트업의 현재 상태와 미래 성장 가능성을 고려하여 결정됩니다." +플립 Frip "국내 창업 기업이 본사를 해외로 이전하는 것을 의미합니다." +피봇 Pivot "사업 방향이나 전략을 수정하는 것을 의미합니다. 마치 체조 선수가 회전하듯, 시장 상황이나 고객 피드백에 맞춰 유연하게 대응하는 것입니다. 피벗은 단순한 변화가 아니라, 기존 아이디어나 비즈니스 모델의 핵심 요소를 유지하면서 새로운 방향으로 전환하는 것을 의미합니다." +피치덱 Pitch Deck "피치덱은 스타트업이 투자자에게 자신의 사업 아이템을 소개하고 투자를 유치하기 위해 만드는 프레젠테이션 자료입니다. 짧은 시간 안에 투자자의 관심을 끌고 투자를 유도해야 하므로, 핵심 내용을 간결하고 명확하게 전달하는 것이 중요합니다." +As-Is "As-Is는 현재 상태를 의미합니다. 주로 문제 정의, 개선 프로젝트, 시장 조사 등에서 사용되며, 현재 시스템, 프로세스, 상황 등을 분석하고 파악하는 데 사용됩니다. As-Is 분석을 통해 문제점이나 개선점을 발견하고, To-Be(개선된 목표 상태)를 설정하여 효과적인 전략을 수립할 수 있습니다." +ASAP As Soon As Possible "가능한 한 빨리'라는 의미입니다. 스타트업 환경에서는 빠른 실행과 의사 결정이 중요하기 때문에 ASAP은 자주 사용되는 표현입니다." +BEP Break-Even Point,손익분기점 "총수입과 총비용이 같아져 이익도 손실도 발생하지 않는 매출 수준을 나타냅니다. 즉, BEP를 넘어서는 매출을 달성해야 스타트업이 이익을 내기 시작합니다." +BM Business model,비즈니스 모델 "기업이 어떻게 가치를 창출하고 수익을 얻는지 보여주는 핵심적인 설계도입니다. 비즈니스 모델은 스타트업의 성공과 실패를 좌우하는 중요한 요소이므로, 탄탄하고 지속 가능한 비즈니스 모델을 구축하는 것이 필수적입니다." +CC Carbon Copy,참조 "이메일을 보낼 때 주 수신자 외에 참조로 다른 사람을 추가하는 기능입니다. 참조된 사람은 메일 내용을 볼 수 있지만, 직접적인 답변 대상은 아닙니다.\n회의에 참석하지 않지만, 회의 내용을 알아야 하거나 관련 정보를 공유해야 할 사람을 참조로 추가하는 것을 의미합니다. 회의록을 공유하거나 후속 조치를 위해 필요한 경우에 사용됩니다." +due date 마감일,데드라인,납기일 "특정 업무나 프로젝트를 완료해야 하는 시점을 의미합니다. 마감일, 데드라인이라고도 하며, 프로젝트 일정 관리, 업무 효율성 증대, 팀 협업 등에 중요한 역할을 합니다." +F/U Follow up "이전에 논의되었거나 진행되었던 일에 대한 후속 조치 또는 추가적인 연락을 의미합니다." +FW 포워드,Forward "이메일: 받은 이메일을 다른 사람에게 다시 보내는 기능입니다.\n회의 내용 공유: 회의에 참석하지 못한 사람에게 회의 내용을 전달하는 것을 의미합니다.\n정보 공유: 특정 정보나 자료를 다른 사람에게 알려주는 것을 의미합니다." +FYI For Your Information,FYR,For your reference,참고 "받는 사람에게 특별한 행동이나 답변을 요구하지 않고 단순히 정보를 제공하는 목적으로 사용됩니다." +IPO 기업공개,Intial Public Offering "기업이 처음으로 주식을 발행하여 일반 대중에게 공개적으로 매각하는 것을 의미합니다. 즉, 비상장 기업이 상장 기업으로 전환되는 과정이죠. 스타트업 입장에서는 대규모 자금 조달의 기회이며, 투자자에게는 투자금 회수 및 수익 실현의 기회가 됩니다." +IR Investor Relations,투자자 관계 "기업이 투자자와의 관계를 관리하고 소통하는 활동을 의미합니다. 투자자에게 기업 정보를 투명하게 공개하고, 투자자의 의견을 경청하며, 신뢰 관계를 구축하는 것이 목표입니다. 스타트업의 경우, IR은 투자 유치, 기업 가치 제고, 기업 이미지 관리 등에 중요한 역할을 합니다." +J 커브 J curve "초기 투자 후 시간이 지남에 따라 스타트업의 수익률이 변화하는 추세를 나타내는 그래프입니다. J 커브는 초기에는 투자 비용, 연구 개발 비용 등으로 인해 수익률이 감소하다가, 어느 시점부터 제품/서비스가 시장에 안착하고 매출이 증가하면서 수익률이 급격히 상승하는 모습을 보입니다." +KPI Key Performance Indicator,핵심 성과 지표 "기업이나 팀의 목표 달성 정도를 측정하는 핵심적인 지표를 의미합니다. 즉, 성과를 객관적으로 평가하고 개선하기 위한 척도라고 할 수 있습니다. KPI는 스타트업의 성장과 발전을 위해 매우 중요한 역할을 합니다." +M&A Merger and Acquisitions,인수합병 "두 개 이상의 기업이 합쳐져 하나의 기업이 되는 과정을 의미합니다. M&A는 스타트업 생태계에서 매우 중요한 역할을 하며, 기업의 성장과 발전을 위한 전략적인 선택이 될 수 있습니다." +MECE Mutually Exclusive and Collective Exhaustive ""상호 배타적이며 전체적으로 포괄하는" 개념을 의미합니다. 즉, 어떤 집합을 구성하는 요소들이 서로 중복되지 않으면서(Mutually Exclusive), 전체를 빠짐없이 포함(Collectively Exhaustive)하는 것을 말합니다." +MVP Minimum Viable Product "핵심 기능만 갖춘 초기 버전의 제품이나 서비스를 의미합니다. 즉, 완벽하지 않더라도 고객에게 가치를 제공하고, 시장 반응을 확인하기 위한 최소한의 기능을 갖춘 제품을 뜻합니다. 린 스타트업(Lean Startup) 방법론의 핵심 개념 중 하나입니다." +OKR Object & Key Results,목표와 핵심 결과 "조직, 팀, 개인의 목표를 설정하고 추적하는 목표 관리 프레임워크입니다. 야심찬 목표(Objectives)를 설정하고, 그 목표를 달성하기 위한 구체적인 핵심 결과(Key Results)를 정의하여 성과를 측정하고 관리합니다." +PMF Product Market Fit,제품 시장 적합성 "제품이나 서비스가 목표 시장의 고객 니즈를 충족시키고, 고객에게 충분한 가치를 제공하는 상태를 의미합니다. 스타트업이 성공하기 위한 가장 중요한 요소 중 하나로, PMF를 달성해야 지속 가능한 성장을 이룰 수 있습니다." +PoC Proof of Concept,개념 증명 "새로운 아이디어나 기술이 실제로 구현 가능하고 시장에서 성공할 가능성이 있는지 검증하는 단계입니다." +R&R Role & Responsibility "프로젝트나 업무 수행에 필요한 각 팀원의 역할과 책임을 명확하게 정의하는 것을 의미합니다." +RE Reply "Re(답장) 또는 Regarding(관련하여)의 약자로 사용됩니다. 기존 이메일에 대한 답장을 보낼 때 제목 앞에 Re:를 붙여 어떤 이메일에 대한 답장인지 명확하게 표시합니다. 특정 주제에 대해 이야기하고 싶을 때 Re:를 사용하여 주제를 명확하게 전달합니다." +TF Task Force,테스크포스 "특정 목표 달성을 위해 다양한 부서나 팀에서 선발된 인원으로 구성되는 임시 조직입니다. 프로젝트 기반으로 운영되며, 목표 달성 후 해체되는 경우가 많습니다." +To-Be "개선되거나 변화될 미래의 목표 상태를 의미합니다. 주로 문제 해결, 프로젝트 계획, 비즈니스 전략 수립 등에서 사용되며, 현재 상태(As-Is) 분석 결과를 바탕으로 이상적인 미래 상태를 설정하는 데 사용됩니다. To-Be는 스타트업이 목표를 달성하고 성장하기 위한 방향을 제시하는 역할을 합니다." +VP Vice President, "일반적으로 특정 분야(예: 엔지니어링, 마케팅, 영업)를 총괄하는 임원을 의미합니다." +WBR Weekly Business Review,주간 실적 분석 "매주 정기적으로 진행되는 회의로, 팀 또는 회사의 주요 성과 지표(KPI)를 검토하고, 문제점을 파악하며, 개선 방안을 논의하는 자리입니다." +WBS Work Breakdown Structure,작업 명세 구조도 "프로젝트의 전체 범위를 더 작고 관리 가능한 작업 단위로 분할하는 계층적인 구조를 의미합니다. 프로젝트의 목표를 달성하기 위해 필요한 모든 작업을 체계적으로 정리하고, 각 작업의 상호 관계를 시각적으로 보여주는 도구입니다." diff --git a/server/api/src/test/java/vook/server/api/ApiApplicationTest.java b/server/api/src/test/java/vook/server/api/ApiApplicationTest.java new file mode 100644 index 00000000..6c6f61c9 --- /dev/null +++ b/server/api/src/test/java/vook/server/api/ApiApplicationTest.java @@ -0,0 +1,17 @@ +package vook.server.api; + +import org.junit.jupiter.api.Test; +import org.springframework.modulith.core.ApplicationModules; +import org.springframework.modulith.docs.Documenter; + +public class ApiApplicationTest { + + @Test + void modulithTest() { + ApplicationModules modules = ApplicationModules.of(ApiApplication.class); + modules.forEach(System.out::println); + modules.verify(); + + new Documenter(modules).writeDocumentation(); + } +} diff --git a/server/api/src/test/java/vook/server/api/devhelper/helper/CsvReaderTest.java b/server/api/src/test/java/vook/server/api/devhelper/helper/CsvReaderTest.java new file mode 100644 index 00000000..3d65049e --- /dev/null +++ b/server/api/src/test/java/vook/server/api/devhelper/helper/CsvReaderTest.java @@ -0,0 +1,113 @@ +package vook.server.api.devhelper.helper; + +import lombok.Getter; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; + +class CsvReaderTest { + + @Test + @DisplayName("TSV Reader - 문자열 변환") + void readValueString() { + // given + String csvString = """ + term\tsynonyms\tdescription + Java\t자바\t자바는 객체지향 프로그래밍 언어이다. + Spring\t스프링\t스프링은 자바 기반의 프레임워크이다. + """; + + CsvReader reader = new CsvReader("\t"); + + // when + List entity = reader.readValue(csvString, Term.class); + + // then + assertThat(entity).hasSize(2); + assertThat(entity).extracting("term", "synonyms", "description") + .containsExactly( + tuple("Java", "자바", "자바는 객체지향 프로그래밍 언어이다."), + tuple("Spring", "스프링", "스프링은 자바 기반의 프레임워크이다.") + ); + } + + @Test + @DisplayName("TSV Reader - 파일 변환") + void readValueFile() { + // given + ClassLoader classLoader = getClass().getClassLoader(); + File file = new File(classLoader.getResource("tsvreader/test.tsv").getFile()); + + CsvReader reader = new CsvReader("\t"); + + // when + List entity = reader.readValue(file, Term.class); + + // then + assertThat(entity).hasSize(2); + assertThat(entity).extracting("term", "synonyms", "description") + .containsExactly( + tuple("Java", "자바", "자바는 객체지향 프로그래밍 언어이다."), + tuple("Spring", "스프링", "스프링은 자바 기반의 프레임워크이다.") + ); + } + + @Test + @DisplayName("TSV Reader - InputStream 변환") + void readValueInputStream() throws IOException { + // given + InputStream inputStream = new ClassPathResource("tsvreader/test.tsv").getInputStream(); + + CsvReader reader = new CsvReader("\t"); + + // when + List entity = reader.readValue(inputStream, Term.class); + + // then + assertThat(entity).hasSize(2); + assertThat(entity).extracting("term", "synonyms", "description") + .containsExactly( + tuple("Java", "자바", "자바는 객체지향 프로그래밍 언어이다."), + tuple("Spring", "스프링", "스프링은 자바 기반의 프레임워크이다.") + ); + } + + @Test + @DisplayName("TSV Reader - 개행처리") + void readValueWithNewLine() { + // given + String csvString = """ + term\tsynonyms\tdescription + "Ja\\nva"\t"자\\n바"\t"자바는 객체지향 \\n프로그래밍 언어이다." + Spr\\ning\t"스프\\n링"\t스프링은 자바 기반의 프레임워크이다. + """; + + CsvReader reader = new CsvReader("\t"); + + // when + List entity = reader.readValue(csvString, Term.class); + + // then + assertThat(entity).hasSize(2); + assertThat(entity).extracting("term", "synonyms", "description") + .containsExactlyInAnyOrder( + tuple("Ja\nva", "자\n바", "자바는 객체지향 \n프로그래밍 언어이다."), + tuple("Spr\\ning", "스프\n링", "스프링은 자바 기반의 프레임워크이다.") + ); + } + + @Getter + static class Term { + private String term; + private String synonyms; + private String description; + } +} diff --git a/server/api/src/test/java/vook/server/api/domain/demo/logic/DemoLogicTest.java b/server/api/src/test/java/vook/server/api/domain/demo/logic/DemoLogicTest.java new file mode 100644 index 00000000..aeca3237 --- /dev/null +++ b/server/api/src/test/java/vook/server/api/domain/demo/logic/DemoLogicTest.java @@ -0,0 +1,66 @@ +package vook.server.api.domain.demo.logic; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; +import vook.server.api.devhelper.app.InitService; +import vook.server.api.devhelper.app.TestTermsLoader; +import vook.server.api.domain.demo.logic.dto.DemoTermSearchCommand; +import vook.server.api.domain.demo.logic.dto.DemoTermSearchResult; +import vook.server.api.domain.demo.model.DemoTerm; +import vook.server.api.domain.demo.model.DemoTermRepository; +import vook.server.api.infra.search.demo.MeilisearchDemoTermSearchService; +import vook.server.api.testhelper.IntegrationTestBase; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@Transactional +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DemoLogicTest extends IntegrationTestBase { + @Autowired + private DemoLogic demoLogic; + + @Autowired + private TestTermsLoader testTermsLoader; + @Autowired + private DemoTermRepository demoTermRepository; + @Autowired + private MeilisearchDemoTermSearchService demoTermSearchService; + + @BeforeAll + void beforeAll() { + List terms = testTermsLoader.getTerms( + "classpath:init/demo.tsv", + InitService::convertToDemoTerm + ); + demoTermRepository.saveAll(terms); + demoTermSearchService.init(); + demoTermSearchService.addTerms(terms); + } + + @AfterAll + void afterAll() { + demoTermSearchService.clearAll(); + } + + @Test + void searchTerm() { + DemoTermSearchCommand params = DemoTermSearchCommand.builder() + .query("하이브리드앱") + .withFormat(false) + .highlightPreTag("") + .highlightPostTag("") + .build(); + + DemoTermSearchResult result = demoLogic.searchTerm(params); + + assertThat(result).isNotNull(); + assertThat(result.query()).isEqualTo("하이브리드앱"); + assertThat(result.hits()).isNotEmpty(); + } +} diff --git a/server/api/src/test/java/vook/server/api/domain/template_vocabulary/logic/TemplateVocabularyLogicTest.java b/server/api/src/test/java/vook/server/api/domain/template_vocabulary/logic/TemplateVocabularyLogicTest.java new file mode 100644 index 00000000..f34d0839 --- /dev/null +++ b/server/api/src/test/java/vook/server/api/domain/template_vocabulary/logic/TemplateVocabularyLogicTest.java @@ -0,0 +1,85 @@ +package vook.server.api.domain.template_vocabulary.logic; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; +import vook.server.api.domain.template_vocabulary.logic.dto.TemplateVocabularyCreateCommand; +import vook.server.api.domain.template_vocabulary.model.*; +import vook.server.api.testhelper.IntegrationTestBase; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@Transactional +class TemplateVocabularyLogicTest extends IntegrationTestBase { + + @Autowired + TemplateVocabularyLogic service; + + @Autowired + TemplateVocabularyRepository vocabularyRepository; + @Autowired + TemplateTermRepository termRepository; + + @Test + @DisplayName("템플릿 용어집 생성 - 정상") + void create() { + // given + TemplateVocabularyCreateCommand command = new TemplateVocabularyCreateCommand( + TemplateVocabularyType.DEVELOPMENT, + List.of( + new TemplateVocabularyCreateCommand.Term("term1", "meaning1", List.of("synonym1")), + new TemplateVocabularyCreateCommand.Term("term2", "meaning2", List.of("synonym2")) + ) + ); + + // when + service.create(command); + + // then + List vocabularies = vocabularyRepository.findAll(); + assertThat(vocabularies).hasSize(1); + + TemplateVocabulary vocabulary = vocabularies.getFirst(); + assertThat(vocabulary.getId()).isNotNull(); + assertThat(vocabulary.getType()).isEqualTo(TemplateVocabularyType.DEVELOPMENT); + + List terms = termRepository.findByTemplateVocabulary(vocabulary); + assertThat(terms).hasSize(2); + assertThat(terms.get(0).getTerm()).isEqualTo("term1"); + assertThat(terms.get(0).getMeaning()).isEqualTo("meaning1"); + assertThat(terms.get(0).getSynonyms()).isEqualTo(List.of("synonym1")); + assertThat(terms.get(1).getTerm()).isEqualTo("term2"); + assertThat(terms.get(1).getMeaning()).isEqualTo("meaning2"); + assertThat(terms.get(1).getSynonyms()).isEqualTo(List.of("synonym2")); + } + + @Test + @DisplayName("템플릿 용어집 내 용어 조회 - 정상") + void getTermsByType() { + // given + TemplateVocabularyCreateCommand command = new TemplateVocabularyCreateCommand( + TemplateVocabularyType.DEVELOPMENT, + List.of( + new TemplateVocabularyCreateCommand.Term("term1", "meaning1", List.of("synonym1", "synonym2")), + new TemplateVocabularyCreateCommand.Term("term2", "meaning2", List.of("synonym3", "synonym4")) + ) + ); + service.create(command); + + // when + List terms = service.getTermsByType(TemplateVocabularyType.DEVELOPMENT); + + // then + assertThat(terms).hasSize(2); + assertThat(terms.get(0).getTerm()).isEqualTo("term1"); + assertThat(terms.get(0).getMeaning()).isEqualTo("meaning1"); + assertThat(terms.get(0).getSynonyms()).isEqualTo(List.of("synonym1", "synonym2")); + assertThat(terms.get(1).getTerm()).isEqualTo("term2"); + assertThat(terms.get(1).getMeaning()).isEqualTo("meaning2"); + assertThat(terms.get(1).getSynonyms()).isEqualTo(List.of("synonym3", "synonym4")); + } + +} diff --git a/server/api/src/test/java/vook/server/api/domain/user/logic/UserLogicTest.java b/server/api/src/test/java/vook/server/api/domain/user/logic/UserLogicTest.java new file mode 100644 index 00000000..fc1a1af4 --- /dev/null +++ b/server/api/src/test/java/vook/server/api/domain/user/logic/UserLogicTest.java @@ -0,0 +1,434 @@ +package vook.server.api.domain.user.logic; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; +import vook.server.api.domain.user.exception.*; +import vook.server.api.domain.user.model.user.User; +import vook.server.api.domain.user.model.user.UserStatus; +import vook.server.api.domain.user.model.user_info.Funnel; +import vook.server.api.domain.user.model.user_info.Job; +import vook.server.api.globalcommon.exception.ParameterValidateException; +import vook.server.api.testhelper.IntegrationTestBase; +import vook.server.api.testhelper.creator.TestUserCreator; + +import java.util.Collection; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@Transactional +class UserLogicTest extends IntegrationTestBase { + + @Autowired + UserLogic service; + + @Autowired + TestUserCreator testUserCreator; + + @TestFactory + @DisplayName("사용자 정보 조회(provider) - 실패; 파라미터 룰 위반") + Collection findByProvider_ParameterError() { + return List.of( + DynamicTest.dynamicTest("provider가 누락된 경우", () -> { + assertThatThrownBy(() -> service.findByProvider(null, "providerUserId")) + .isInstanceOf(ParameterValidateException.class); + }), + DynamicTest.dynamicTest("provider가 빈 문자열인 경우", () -> { + assertThatThrownBy(() -> service.findByProvider("", "providerUserId")) + .isInstanceOf(ParameterValidateException.class); + }), + DynamicTest.dynamicTest("providerUserId가 누락된 경우", () -> { + assertThatThrownBy(() -> service.findByProvider("provider", null)) + .isInstanceOf(ParameterValidateException.class); + }), + DynamicTest.dynamicTest("providerUserId가 빈 문자열인 경우", () -> { + assertThatThrownBy(() -> service.findByProvider("provider", "")) + .isInstanceOf(ParameterValidateException.class); + }) + ); + } + + @TestFactory + @DisplayName("소셜에서 회원가입 - 실패; 파라미터 룰 위반") + Collection signUpFromSocial_ParameterError() { + return List.of( + DynamicTest.dynamicTest("provider가 누락된 경우", () -> { + assertThatThrownBy(() -> service.signUpFromSocial(new UserSignUpFromSocialCommand(null, "providerUserId", "email"))) + .isInstanceOf(ParameterValidateException.class); + }), + DynamicTest.dynamicTest("provider가 빈 문자인 경우", () -> { + assertThatThrownBy(() -> service.signUpFromSocial(new UserSignUpFromSocialCommand("", "providerUserId", "email"))) + .isInstanceOf(ParameterValidateException.class); + }), + DynamicTest.dynamicTest("providerUserId가 누락된 경우", () -> { + assertThatThrownBy(() -> service.signUpFromSocial(new UserSignUpFromSocialCommand("provider", null, "email"))) + .isInstanceOf(ParameterValidateException.class); + }), + DynamicTest.dynamicTest("providerUserId가 빈 문자열인 경우", () -> { + assertThatThrownBy(() -> service.signUpFromSocial(new UserSignUpFromSocialCommand("provider", "", "email"))) + .isInstanceOf(ParameterValidateException.class); + }), + DynamicTest.dynamicTest("email이 누락된 경우", () -> { + assertThatThrownBy(() -> service.signUpFromSocial(new UserSignUpFromSocialCommand("provider", "providerUserId", null))) + .isInstanceOf(ParameterValidateException.class); + }), + DynamicTest.dynamicTest("email이 빈 문자열인 경우", () -> { + assertThatThrownBy(() -> service.signUpFromSocial(new UserSignUpFromSocialCommand("provider", "providerUserId", ""))) + .isInstanceOf(ParameterValidateException.class); + }), + DynamicTest.dynamicTest("email이 이메일 형식이 아닌 경우", () -> { + assertThatThrownBy(() -> service.signUpFromSocial(new UserSignUpFromSocialCommand("provider", "providerUserId", "email"))) + .isInstanceOf(ParameterValidateException.class); + }) + ); + } + + @Test + @DisplayName("사용자 정보 조회(uid) - 정상; 회원가입 전 사용자") + void getByUid1() { + // given + User unregisteredUser = testUserCreator.createUnregisteredUser(); + + // when + var user = service.getByUid(unregisteredUser.getUid()); + + // then + assertThat(user).isNotNull(); + assertThat(user.getUid()).isEqualTo(unregisteredUser.getUid()); + assertThat(user.getEmail()).isEqualTo(unregisteredUser.getEmail()); + assertThat(user.getUserInfo()).isNull(); + assertThat(user.getStatus()).isEqualTo(UserStatus.SOCIAL_LOGIN_COMPLETED); + assertThat(user.getOnboardingCompleted()).isFalse(); + } + + @Test + @DisplayName("사용자 정보 조회(uid) - 정상; 회원가입 후 사용자") + void getByUid2() { + // given + User registeredUser = testUserCreator.createRegisteredUser(); + + // when + var user = service.getByUid(registeredUser.getUid()); + + // then + assertThat(user).isNotNull(); + assertThat(user.getUid()).isEqualTo(registeredUser.getUid()); + assertThat(user.getEmail()).isEqualTo(registeredUser.getEmail()); + assertThat(user.getUserInfo().getNickname()).isEqualTo(registeredUser.getUserInfo().getNickname()); + assertThat(user.getStatus()).isEqualTo(UserStatus.REGISTERED); + assertThat(user.getOnboardingCompleted()).isFalse(); + } + + @Test + @DisplayName("사용자 정보 조회(uid) - 정상; 온보딩 완료 사용자") + void getByUid3() { + // given + User completedOnboardingUser = testUserCreator.createCompletedOnboardingUser(); + + // when + var user = service.getByUid(completedOnboardingUser.getUid()); + + // then + assertThat(user).isNotNull(); + assertThat(user.getUid()).isEqualTo(completedOnboardingUser.getUid()); + assertThat(user.getEmail()).isEqualTo(completedOnboardingUser.getEmail()); + assertThat(user.getUserInfo().getNickname()).isEqualTo(completedOnboardingUser.getUserInfo().getNickname()); + assertThat(user.getStatus()).isEqualTo(UserStatus.REGISTERED); + assertThat(user.getOnboardingCompleted()).isTrue(); + } + + @TestFactory + @DisplayName("사용자 정보 조회(uid) - 실패; 파라미터 룰 위반") + Collection getByUid_ParameterError() { + return List.of( + DynamicTest.dynamicTest("유저 UID가 누락된 경우", () -> { + assertThatThrownBy(() -> service.getByUid(null)) + .isInstanceOf(ParameterValidateException.class); + }), + DynamicTest.dynamicTest("유저 UID가 빈 문자열인 경우", () -> { + assertThatThrownBy(() -> service.getByUid("")) + .isInstanceOf(ParameterValidateException.class); + }) + ); + } + + @Test + @DisplayName("회원 가입 - 정상") + void register1() { + // given + User unregisteredUser = testUserCreator.createUnregisteredUser(); + + UserRegisterCommand command = UserRegisterCommand.builder() + .userUid(unregisteredUser.getUid()) + .nickname("nickname") + .marketingEmailOptIn(true) + .build(); + + // when + service.register(command); + + // then + User user = service.getByUid(unregisteredUser.getUid()); + assertThat(user.getStatus()).isEqualTo(UserStatus.REGISTERED); + assertThat(user.getOnboardingCompleted()).isFalse(); + assertThat(user.getRegisteredAt()).isNotNull(); + assertThat(user.getUserInfo()).isNotNull(); + assertThat(user.getUserInfo().getNickname()).isEqualTo(command.nickname()); + assertThat(user.getUserInfo().getMarketingEmailOptIn()).isEqualTo(command.marketingEmailOptIn()); + } + + @Test + @DisplayName("회원 가입 - 에러; 이미 가입된 유저") + void registerError1() { + // given + User registeredUser = testUserCreator.createRegisteredUser(); + + UserRegisterCommand command = UserRegisterCommand.builder() + .userUid(registeredUser.getUid()) + .nickname("nickname") + .marketingEmailOptIn(true) + .build(); + + // when + assertThatThrownBy(() -> service.register(command)) + .isInstanceOf(AlreadyRegisteredException.class); + } + + @Test + @DisplayName("회원 가입 - 에러; 탈퇴한 유저") + void registerError2() { + // given + User withdrawnUser = testUserCreator.createWithdrawnUser(); + + var command = UserRegisterCommand.builder() + .userUid(withdrawnUser.getUid()) + .nickname("nickname") + .marketingEmailOptIn(true) + .build(); + + // when + assertThatThrownBy(() -> service.register(command)) + .isInstanceOf(WithdrawnUserException.class); + } + + @TestFactory + @DisplayName("회원 가입 - 에러; 파라미터 룰 위반") + Collection register_ParameterError() { + return List.of( + DynamicTest.dynamicTest("유저 UID가 누락된 경우", () -> { + assertThatThrownBy(() -> service.register(new UserRegisterCommand(null, "nickname", true))) + .isInstanceOf(ParameterValidateException.class); + }), + DynamicTest.dynamicTest("유저 UID가 빈 문자열인 경우", () -> { + assertThatThrownBy(() -> service.register(new UserRegisterCommand("", "nickname", true))) + .isInstanceOf(ParameterValidateException.class); + }), + DynamicTest.dynamicTest("닉네임이 누락된 경우", () -> { + assertThatThrownBy(() -> service.register(new UserRegisterCommand("uid", null, true))) + .isInstanceOf(ParameterValidateException.class); + }), + DynamicTest.dynamicTest("닉네임이 빈 문자열인 경우", () -> { + assertThatThrownBy(() -> service.register(new UserRegisterCommand("uid", "", true))) + .isInstanceOf(ParameterValidateException.class); + }), + DynamicTest.dynamicTest("닉네임 길이가 제한을 넘긴 경우", () -> { + assertThatThrownBy(() -> service.register(new UserRegisterCommand("uid", "12345678901", true))) + .isInstanceOf(ParameterValidateException.class); + }), + DynamicTest.dynamicTest("마케팅 이메일 수신 동의가 누락된 경우", () -> { + assertThatThrownBy(() -> service.register(new UserRegisterCommand("uid", "nickname", null))) + .isInstanceOf(ParameterValidateException.class); + }) + ); + } + + @Test + @DisplayName("온보딩 완료 - 정상") + void onboarding1() { + // given + User registeredUser = testUserCreator.createRegisteredUser(); + + var command = UserOnboardingCommand.builder() + .userUid(registeredUser.getUid()) + .funnel(Funnel.OTHER) + .job(Job.OTHER) + .build(); + + // when + service.onboarding(command); + + // then + User user = service.getByUid(registeredUser.getUid()); + assertThat(user.getStatus()).isEqualTo(UserStatus.REGISTERED); + assertThat(user.getOnboardingCompleted()).isTrue(); + assertThat(user.getOnboardingCompletedAt()).isNotNull(); + assertThat(user.getUserInfo()).isNotNull(); + assertThat(user.getUserInfo().getFunnel()).isEqualTo(command.funnel()); + assertThat(user.getUserInfo().getJob()).isEqualTo(command.job()); + } + + @Test + @DisplayName("온보딩 완료 - 에러; 미 가입 유저") + void onboardingError1() { + // given + User unregisteredUser = testUserCreator.createUnregisteredUser(); + + var command = UserOnboardingCommand.builder() + .userUid(unregisteredUser.getUid()) + .funnel(Funnel.OTHER) + .job(Job.OTHER) + .build(); + + // when + assertThatThrownBy(() -> service.onboarding(command)) + .isInstanceOf(NotReadyToOnboardingException.class); + } + + @Test + @DisplayName("온보딩 완료 - 에러; 이미 온보딩 완료된 유저") + void onboardingError2() { + // given + User completedOnboardingUser = testUserCreator.createCompletedOnboardingUser(); + + var command = UserOnboardingCommand.builder() + .userUid(completedOnboardingUser.getUid()) + .funnel(Funnel.OTHER) + .job(Job.OTHER) + .build(); + + // when + assertThatThrownBy(() -> service.onboarding(command)) + .isInstanceOf(AlreadyOnboardingException.class); + } + + @TestFactory + @DisplayName("온보딩 완료 - 에러; 파라미터 룰 위반") + Collection onboarding_ParameterError() { + return List.of( + DynamicTest.dynamicTest("유저 UID가 누락된 경우", () -> { + assertThatThrownBy(() -> service.onboarding(new UserOnboardingCommand(null, Funnel.OTHER, Job.OTHER))) + .isInstanceOf(ParameterValidateException.class); + }), + DynamicTest.dynamicTest("유저 UID가 빈 문자열인 경우", () -> { + assertThatThrownBy(() -> service.onboarding(new UserOnboardingCommand("", Funnel.OTHER, Job.OTHER))) + .isInstanceOf(ParameterValidateException.class); + }) + ); + } + + @Test + @DisplayName("사용자 정보 수정 - 정상") + void updateInfo1() { + // given + User registeredUser = testUserCreator.createCompletedOnboardingUser(); + + // when + service.updateInfo(registeredUser.getUid(), "newNick"); + + // then + User user = service.getByUid(registeredUser.getUid()); + assertThat(user.getUserInfo().getNickname()).isEqualTo("newNick"); + assertThat(user.getLastUpdatedAt()).isNotNull(); + } + + @Test + @DisplayName("사용자 정보 수정 - 에러; 미 가입 유저") + void updateInfoError1() { + // given + User unregisteredUser = testUserCreator.createUnregisteredUser(); + + // when + assertThatThrownBy(() -> service.updateInfo(unregisteredUser.getUid(), "newNick")) + .isInstanceOf(NotRegisteredException.class); + } + + @TestFactory + @DisplayName("사용자 정보 수정 - 에러; 파라미터 룰 위반") + Collection updateInfo_ParameterError() { + return List.of( + DynamicTest.dynamicTest("유저 UID가 누락된 경우", () -> { + assertThatThrownBy(() -> service.updateInfo(null, "newNickname")) + .isInstanceOf(ParameterValidateException.class); + }), + DynamicTest.dynamicTest("유저 UID가 빈 문자열인 경우", () -> { + assertThatThrownBy(() -> service.updateInfo("", "newNickname")) + .isInstanceOf(ParameterValidateException.class); + }), + DynamicTest.dynamicTest("닉네임이 누락된 경우", () -> { + assertThatThrownBy(() -> service.updateInfo("uid", null)) + .isInstanceOf(ParameterValidateException.class); + }), + DynamicTest.dynamicTest("닉네임이 빈 문자열인 경우", () -> { + assertThatThrownBy(() -> service.updateInfo("uid", "")) + .isInstanceOf(ParameterValidateException.class); + }), + DynamicTest.dynamicTest("닉네임 길이가 제한을 넘긴 경우", () -> { + assertThatThrownBy(() -> service.updateInfo("uid", "12345678901")) + .isInstanceOf(ParameterValidateException.class); + }) + ); + } + + @Test + @DisplayName("탈퇴 - 정상") + void withdraw1() { + // given + User registeredUser = testUserCreator.createRegisteredUser(); + + // when + service.withdraw(registeredUser.getUid()); + + // then + User user = service.getByUid(registeredUser.getUid()); + assertThat(user.getStatus()).isEqualTo(UserStatus.WITHDRAWN); + assertThat(user.getWithdrawnAt()).isNotNull(); + } + + @TestFactory + @DisplayName("탈퇴 - 에러; 파라미터 룰 위반") + Collection withdraw_ParameterError() { + return List.of( + DynamicTest.dynamicTest("유저 UID가 누락된 경우", () -> { + assertThatThrownBy(() -> service.withdraw(null)) + .isInstanceOf(ParameterValidateException.class); + }), + DynamicTest.dynamicTest("유저 UID가 빈 문자열인 경우", () -> { + assertThatThrownBy(() -> service.withdraw("")) + .isInstanceOf(ParameterValidateException.class); + }) + ); + } + + @Test + @DisplayName("재가입 - 정상") + void reRegister1() { + // given + User withdrawnUser = testUserCreator.createWithdrawnUser(); + + UserRegisterCommand command = UserRegisterCommand.builder() + .userUid(withdrawnUser.getUid()) + .nickname("reRegister") + .marketingEmailOptIn(false) + .build(); + + // when + service.reRegister(command); + + // then + User user = service.getByUid(withdrawnUser.getUid()); + assertThat(user.getStatus()).isEqualTo(UserStatus.REGISTERED); + assertThat(user.getOnboardingCompleted()).isFalse(); + assertThat(user.getRegisteredAt()).isNotNull(); + assertThat(user.getWithdrawnAt()).isNull(); + assertThat(user.getUserInfo()).isNotNull(); + assertThat(user.getUserInfo().getNickname()).isEqualTo(command.nickname()); + assertThat(user.getUserInfo().getMarketingEmailOptIn()).isEqualTo(command.marketingEmailOptIn()); + assertThat(user.getUserInfo().getJob()).isNull(); + assertThat(user.getUserInfo().getFunnel()).isNull(); + } +} diff --git a/server/api/src/test/java/vook/server/api/domain/user/model/social_user/SocialUserFactoryTest.java b/server/api/src/test/java/vook/server/api/domain/user/model/social_user/SocialUserFactoryTest.java new file mode 100644 index 00000000..a37e34ff --- /dev/null +++ b/server/api/src/test/java/vook/server/api/domain/user/model/social_user/SocialUserFactoryTest.java @@ -0,0 +1,70 @@ +package vook.server.api.domain.user.model.social_user; + +import jakarta.validation.ConstraintViolationException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; +import vook.server.api.domain.user.model.user.User; + +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +@SpringBootTest(classes = DefaultSocialUserFactory.class) +@ContextConfiguration(classes = ValidationAutoConfiguration.class) +class SocialUserFactoryTest { + + @Autowired + SocialUserFactory factory; + + @Test + @DisplayName("소셜 유저 생성; 정상") + void createForNewOf() { + // given + User user = mock(User.class); + + // when + SocialUser socialUser = factory.createForNewOf("provider", "providerUserId", user); + + // then + assertThat(socialUser.getProvider()).isEqualTo("provider"); + assertThat(socialUser.getProviderUserId()).isEqualTo("providerUserId"); + assertThat(socialUser.getUser()).isEqualTo(user); + + verify(user, times(1)).addSocialUser(socialUser); + } + + @TestFactory + @DisplayName("소셜 유저 생성; 예외 - 소셜 유서 생성 시 유효성 검사 실패") + Stream createForNewOfFail() { + return Stream.of( + DynamicTest.dynamicTest("provider가 null", () -> { + assertThatThrownBy(() -> factory.createForNewOf(null, "providerUserId", new User())) + .isInstanceOf(ConstraintViolationException.class); + }), + DynamicTest.dynamicTest("provider가 빈 문자열", () -> { + assertThatThrownBy(() -> factory.createForNewOf("", "providerUserId", new User())) + .isInstanceOf(ConstraintViolationException.class); + }), + DynamicTest.dynamicTest("providerUserId가 null", () -> { + assertThatThrownBy(() -> factory.createForNewOf("provider", null, new User())) + .isInstanceOf(ConstraintViolationException.class); + }), + DynamicTest.dynamicTest("providerUserId가 빈 문자열", () -> { + assertThatThrownBy(() -> factory.createForNewOf("provider", "", new User())) + .isInstanceOf(ConstraintViolationException.class); + }), + DynamicTest.dynamicTest("user가 null", () -> { + assertThatThrownBy(() -> factory.createForNewOf("provider", "providerUserId", null)) + .isInstanceOf(ConstraintViolationException.class); + }) + ); + } +} diff --git a/server/api/src/test/java/vook/server/api/domain/user/model/user/UserFactoryTest.java b/server/api/src/test/java/vook/server/api/domain/user/model/user/UserFactoryTest.java new file mode 100644 index 00000000..e369d26f --- /dev/null +++ b/server/api/src/test/java/vook/server/api/domain/user/model/user/UserFactoryTest.java @@ -0,0 +1,57 @@ +package vook.server.api.domain.user.model.user; + +import jakarta.validation.ConstraintViolationException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; + +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest(classes = DefaultUserFactory.class) +@ContextConfiguration(classes = ValidationAutoConfiguration.class) +class UserFactoryTest { + + @Autowired + UserFactory factory; + + @Test + @DisplayName("유저 생성; 정상") + void createForSignUpFromSocialOf() { + // when + User user = factory.createForSignUpFromSocialOf("abc@example.com"); + + // then + assertThat(user.getUid()).isNotNull(); + assertThat(user.getEmail()).isEqualTo("abc@example.com"); + assertThat(user.getStatus()).isEqualTo(UserStatus.SOCIAL_LOGIN_COMPLETED); + assertThat(user.getOnboardingCompleted()).isFalse(); + assertThat(user.getSocialUsers()).isEmpty(); + } + + @TestFactory + @DisplayName("유저 생성; 예외 - 유저 생성 시 유효성 검사 실패") + Stream createForSignUpFromSocialOfFail() { + return Stream.of( + DynamicTest.dynamicTest("email이 null", () -> { + assertThatThrownBy(() -> factory.createForSignUpFromSocialOf(null)) + .isInstanceOf(ConstraintViolationException.class); + }), + DynamicTest.dynamicTest("email이 빈 문자열", () -> { + assertThatThrownBy(() -> factory.createForSignUpFromSocialOf("")) + .isInstanceOf(ConstraintViolationException.class); + }), + DynamicTest.dynamicTest("email이 이메일 형식이 아님", () -> { + assertThatThrownBy(() -> factory.createForSignUpFromSocialOf("abc")) + .isInstanceOf(ConstraintViolationException.class); + }) + ); + } +} diff --git a/server/api/src/test/java/vook/server/api/domain/user/model/user_info/UserInfoFactoryTest.java b/server/api/src/test/java/vook/server/api/domain/user/model/user_info/UserInfoFactoryTest.java new file mode 100644 index 00000000..3916a451 --- /dev/null +++ b/server/api/src/test/java/vook/server/api/domain/user/model/user_info/UserInfoFactoryTest.java @@ -0,0 +1,66 @@ +package vook.server.api.domain.user.model.user_info; + +import jakarta.validation.ConstraintViolationException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; +import vook.server.api.domain.user.model.user.User; + +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +@SpringBootTest(classes = DefaultUserInfoFactory.class) +@ContextConfiguration(classes = ValidationAutoConfiguration.class) +class UserInfoFactoryTest { + + @Autowired + UserInfoFactory factory; + + @Test + @DisplayName("유저 정보 생성; 정상") + void createForRegisterOf() { + // given + User user = mock(User.class); + + // when + UserInfo userInfo = factory.createForRegisterOf("nickname", true, user); + + // then + assertThat(userInfo.getNickname()).isEqualTo("nickname"); + assertThat(userInfo.getMarketingEmailOptIn()).isTrue(); + assertThat(userInfo.getUser()).isEqualTo(user); + + verify(user, times(1)).validateRegisterProcessReady(); + } + + @TestFactory + @DisplayName("유저 정보 생성; 예외 - 유저 정보 생성 시 유효성 검사 실패") + Stream createForRegisterOfFail2() { + return Stream.of( + DynamicTest.dynamicTest("nickname이 null", () -> { + assertThatThrownBy(() -> factory.createForRegisterOf(null, true, mock(User.class))) + .isInstanceOf(ConstraintViolationException.class); + }), + DynamicTest.dynamicTest("nickname이 빈 문자열", () -> { + assertThatThrownBy(() -> factory.createForRegisterOf("", true, mock(User.class))) + .isInstanceOf(ConstraintViolationException.class); + }), + DynamicTest.dynamicTest("marketingEmailOptIn이 null", () -> { + assertThatThrownBy(() -> factory.createForRegisterOf("nickname", null, mock(User.class))) + .isInstanceOf(ConstraintViolationException.class); + }), + DynamicTest.dynamicTest("user가 null", () -> { + assertThatThrownBy(() -> factory.createForRegisterOf("nickname", true, null)) + .isInstanceOf(ConstraintViolationException.class); + }) + ); + } +} diff --git a/server/api/src/test/java/vook/server/api/domain/vocabulary/logic/term/TermLogicTest.java b/server/api/src/test/java/vook/server/api/domain/vocabulary/logic/term/TermLogicTest.java new file mode 100644 index 00000000..dbe06bfc --- /dev/null +++ b/server/api/src/test/java/vook/server/api/domain/vocabulary/logic/term/TermLogicTest.java @@ -0,0 +1,222 @@ +package vook.server.api.domain.vocabulary.logic.term; + +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; +import vook.server.api.domain.user.model.user.User; +import vook.server.api.domain.vocabulary.exception.TermNotFoundException; +import vook.server.api.domain.vocabulary.model.term.Term; +import vook.server.api.domain.vocabulary.model.term.TermRepository; +import vook.server.api.domain.vocabulary.model.vocabulary.Vocabulary; +import vook.server.api.globalcommon.exception.ParameterValidateException; +import vook.server.api.infra.search.vocabulary.MeilisearchVocabularySearchService; +import vook.server.api.testhelper.IntegrationTestBase; +import vook.server.api.testhelper.creator.TestUserCreator; +import vook.server.api.testhelper.creator.TestVocabularyCreator; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@Transactional +class TermLogicTest extends IntegrationTestBase { + + @Autowired + TermLogic service; + + @Autowired + TestUserCreator userCreator; + @Autowired + TestVocabularyCreator vocabularyCreator; + + @Autowired + MeilisearchVocabularySearchService searchService; + @Autowired + TermRepository termRepository; + + @AfterEach + void tearDown() { + searchService.clearAll(); + } + + @Test + @DisplayName("용어 생성 - 성공") + void create() { + // given + User user = userCreator.createCompletedOnboardingUser(); + Vocabulary vocabulary = vocabularyCreator.createVocabulary(user); + + TermCreateCommand command = TermCreateCommand.builder() + .vocabularyUid(vocabulary.getUid()) + .term("용어") + .meaning("용어 설명") + .synonyms(List.of("동의어1", "동의어2")) + .build(); + + // when + Term term = service.create(command); + + // then + assertNotNull(term); + assertEquals("용어", term.getTerm()); + assertEquals("용어 설명", term.getMeaning()); + assertEquals(2, term.getSynonyms().size()); + + assertThat(searchService.isDocumentExists(vocabulary.getUid(), term.getUid())).isTrue(); + Map document = searchService.getDocument(vocabulary.getUid(), term.getUid()); + assertEquals("용어", document.get("term")); + assertEquals("용어 설명", document.get("meaning")); + assertEquals("동의어1,동의어2", document.get("synonyms")); + } + + @TestFactory + @DisplayName("용어 생성 - 실패; 파라미터 룰 위반") + Collection create_ParameterError() { + return List.of( + DynamicTest.dynamicTest("용어 이름이 누락 된 경우", () -> { + TermCreateCommand command = TermCreateCommand.builder() + .meaning("용어 설명") + .synonyms(List.of("동의어1", "동의어2")) + .build(); + + assertThatThrownBy(() -> service.create(command)).isInstanceOf(ParameterValidateException.class); + }), + DynamicTest.dynamicTest("용어 이름이 제한을 넘는 경우", () -> { + TermCreateCommand command = TermCreateCommand.builder() + .term("a".repeat(101)) + .meaning("용어 설명") + .synonyms(List.of("동의어1", "동의어2")) + .build(); + + assertThatThrownBy(() -> service.create(command)).isInstanceOf(ParameterValidateException.class); + }), + DynamicTest.dynamicTest("용어 뜻이 누락 된 경우", () -> { + TermCreateCommand command = TermCreateCommand.builder() + .term("용어") + .synonyms(List.of("동의어1", "동의어2")) + .build(); + + assertThatThrownBy(() -> service.create(command)).isInstanceOf(ParameterValidateException.class); + }), + DynamicTest.dynamicTest("용어 뜻이 제한을 넘는 경우", () -> { + TermCreateCommand command = TermCreateCommand.builder() + .term("용어") + .meaning("a".repeat(2001)) + .synonyms(List.of("동의어1", "동의어2")) + .build(); + + assertThatThrownBy(() -> service.create(command)).isInstanceOf(ParameterValidateException.class); + }), + DynamicTest.dynamicTest("동의어가 누락 된 경우", () -> { + TermCreateCommand command = TermCreateCommand.builder() + .term("용어") + .meaning("용어 설명") + .build(); + + assertThatThrownBy(() -> service.create(command)).isInstanceOf(ParameterValidateException.class); + }) + ); + } + + @Test + @DisplayName("용어 뭉치 생성 - 성공") + void createAll() { + // given + User user = userCreator.createCompletedOnboardingUser(); + Vocabulary vocabulary = vocabularyCreator.createVocabulary(user); + + TermCreateAllCommand command = TermCreateAllCommand.builder() + .vocabularyUid(vocabulary.getUid()) + .termInfos(List.of( + TermCreateAllCommand.TermInfo.builder() + .term("용어1") + .meaning("용어 설명1") + .synonyms(List.of("동의어1", "동의어2")) + .build(), + TermCreateAllCommand.TermInfo.builder() + .term("용어2") + .meaning("용어 설명2") + .synonyms(List.of("동의어3", "동의어4")) + .build() + )) + .build(); + + // when + service.createAll(command); + + // then + List terms = termRepository.findAll(); + assertEquals(2, terms.size()); + assertEquals("용어1", terms.get(0).getTerm()); + assertEquals("용어 설명1", terms.get(0).getMeaning()); + assertEquals(2, terms.get(0).getSynonyms().size()); + assertEquals("용어2", terms.get(1).getTerm()); + assertEquals("용어 설명2", terms.get(1).getMeaning()); + assertEquals(2, terms.get(1).getSynonyms().size()); + + assertThat(searchService.isDocumentExists(vocabulary.getUid(), terms.get(0).getUid())).isTrue(); + Map document1 = searchService.getDocument(vocabulary.getUid(), terms.get(0).getUid()); + assertEquals("용어1", document1.get("term")); + assertEquals("용어 설명1", document1.get("meaning")); + assertEquals("동의어1,동의어2", document1.get("synonyms")); + + assertThat(searchService.isDocumentExists(vocabulary.getUid(), terms.get(1).getUid())).isTrue(); + Map document2 = searchService.getDocument(vocabulary.getUid(), terms.get(1).getUid()); + assertEquals("용어2", document2.get("term")); + assertEquals("용어 설명2", document2.get("meaning")); + assertEquals("동의어3,동의어4", document2.get("synonyms")); + } + + @Test + @DisplayName("용어 수정 - 성공") + void update() { + // given + User user = userCreator.createCompletedOnboardingUser(); + Vocabulary vocabulary = vocabularyCreator.createVocabulary(user); + Term term = vocabularyCreator.createTerm(vocabulary); + + TermUpdateCommand command = TermUpdateCommand.builder() + .uid(term.getUid()) + .term("수정된 용어") + .meaning("수정된 용어 설명") + .synonyms(List.of("수정된 동의어1", "수정된 동의어2")) + .build(); + + // when + service.update(command); + + // then + Term updated = service.getByUid(term.getUid()); + assertEquals("수정된 용어", updated.getTerm()); + assertEquals("수정된 용어 설명", updated.getMeaning()); + assertEquals(2, updated.getSynonyms().size()); + + assertThat(searchService.isDocumentExists(vocabulary.getUid(), term.getUid())).isTrue(); + Map document = searchService.getDocument(vocabulary.getUid(), term.getUid()); + assertEquals("수정된 용어", document.get("term")); + assertEquals("수정된 용어 설명", document.get("meaning")); + assertEquals("수정된 동의어1,수정된 동의어2", document.get("synonyms")); + } + + @Test + @DisplayName("용어 삭제 - 성공") + void delete() { + // given + User user = userCreator.createCompletedOnboardingUser(); + Vocabulary vocabulary = vocabularyCreator.createVocabulary(user); + Term term = vocabularyCreator.createTerm(vocabulary); + assertThat(searchService.isDocumentExists(vocabulary.getUid(), term.getUid())).isTrue(); + + // when + service.delete(term.getUid()); + + // then + assertThatThrownBy(() -> service.getByUid(term.getUid())).isInstanceOf(TermNotFoundException.class); + assertThat(searchService.isDocumentExists(vocabulary.getUid(), term.getUid())).isFalse(); + } +} diff --git a/server/api/src/test/java/vook/server/api/domain/vocabulary/logic/vocabulary/VocabularyLogicTest.java b/server/api/src/test/java/vook/server/api/domain/vocabulary/logic/vocabulary/VocabularyLogicTest.java new file mode 100644 index 00000000..7fdcbbd0 --- /dev/null +++ b/server/api/src/test/java/vook/server/api/domain/vocabulary/logic/vocabulary/VocabularyLogicTest.java @@ -0,0 +1,98 @@ +package vook.server.api.domain.vocabulary.logic.vocabulary; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; +import vook.server.api.domain.user.model.user.User; +import vook.server.api.domain.vocabulary.exception.VocabularyNotFoundException; +import vook.server.api.domain.vocabulary.model.term.Term; +import vook.server.api.domain.vocabulary.model.term.TermRepository; +import vook.server.api.domain.vocabulary.model.vocabulary.UserUid; +import vook.server.api.domain.vocabulary.model.vocabulary.Vocabulary; +import vook.server.api.infra.search.vocabulary.MeilisearchVocabularySearchService; +import vook.server.api.testhelper.IntegrationTestBase; +import vook.server.api.testhelper.creator.TestUserCreator; +import vook.server.api.testhelper.creator.TestVocabularyCreator; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@Transactional +class VocabularyLogicTest extends IntegrationTestBase { + + @Autowired + VocabularyLogic service; + + @Autowired + TestUserCreator userCreator; + @Autowired + TestVocabularyCreator vocabularyCreator; + @Autowired + MeilisearchVocabularySearchService searchService; + @Autowired + TermRepository termRepository; + + @AfterEach + void tearDown() { + searchService.clearAll(); + } + + @Test + @DisplayName("단어장 생성 - 성공") + void create() { + // given + User user = userCreator.createCompletedOnboardingUser(); + + VocabularyCreateCommand command = VocabularyCreateCommand.builder() + .name("단어장 이름") + .userUid(new UserUid(user.getUid())) + .build(); + + // when + Vocabulary vocabulary = service.create(command); + + // then + assertThat(vocabulary.getId()).isNotNull(); + assertThat(vocabulary.getUid()).isNotNull(); + assertThat(vocabulary.getName()).isEqualTo("단어장 이름"); + assertThat(vocabulary.getUserUid()).isEqualTo(new UserUid(user.getUid())); + + assertThat(searchService.isIndexExists(vocabulary.getUid())).isTrue(); + } + + @Test + @DisplayName("단어장 삭제 - 성공") + void delete() { + // given + User user = userCreator.createCompletedOnboardingUser(); + Vocabulary vocabulary = vocabularyCreator.createVocabulary(user); + + // when + service.delete(vocabulary.getUid()); + + // then + assertThatThrownBy(() -> service.getByUid(vocabulary.getUid())).isInstanceOf(VocabularyNotFoundException.class); + assertThat(searchService.isIndexExists(vocabulary.getUid())).isFalse(); + } + + @Test + @DisplayName("단어장 삭제 - 성공; 단어들도 같이 삭제") + void delete_with_terms() { + // given + User user = userCreator.createCompletedOnboardingUser(); + Vocabulary vocabulary = vocabularyCreator.createVocabulary(user); + Term term1 = vocabularyCreator.createTerm(vocabulary); + Term term2 = vocabularyCreator.createTerm(vocabulary); + + // when + service.delete(vocabulary.getUid()); + + // then + assertThatThrownBy(() -> service.getByUid(vocabulary.getUid())).isInstanceOf(VocabularyNotFoundException.class); + assertThat(termRepository.findByUid(term1.getUid())).isEmpty(); + assertThat(termRepository.findByUid(term2.getUid())).isEmpty(); + assertThat(searchService.isIndexExists(vocabulary.getUid())).isFalse(); + } +} diff --git a/server/api/src/test/java/vook/server/api/domain/vocabulary/model/term/TermFactoryTest.java b/server/api/src/test/java/vook/server/api/domain/vocabulary/model/term/TermFactoryTest.java new file mode 100644 index 00000000..26c1aee6 --- /dev/null +++ b/server/api/src/test/java/vook/server/api/domain/vocabulary/model/term/TermFactoryTest.java @@ -0,0 +1,383 @@ +package vook.server.api.domain.vocabulary.model.term; + +import jakarta.validation.ConstraintViolationException; +import org.assertj.core.api.ObjectAssert; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ContextConfiguration; +import vook.server.api.domain.vocabulary.exception.TermLimitExceededException; +import vook.server.api.domain.vocabulary.exception.VocabularyNotFoundException; +import vook.server.api.domain.vocabulary.model.vocabulary.Vocabulary; +import vook.server.api.domain.vocabulary.model.vocabulary.VocabularyRepository; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.DynamicTest.dynamicTest; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@SpringBootTest(classes = DefaultTermFactory.class) +@ContextConfiguration(classes = ValidationAutoConfiguration.class) +class TermFactoryTest { + + @MockBean + private VocabularyRepository vocabularyRepository; + + @Autowired + private TermFactory factory; + + @Test + @DisplayName("용어 생성; 정상") + void create() { + // given + Vocabulary vocabulary = mock(Vocabulary.class); + when(vocabulary.termCount()).thenReturn(0); + when(vocabularyRepository.findByUid(anyString())).thenReturn(Optional.of(vocabulary)); + + // when + Term term = factory.create( + new TermFactory.CreateCommand( + "vocabularyUid", + new TermFactory.TermInfo("term", "meaning", List.of("synonym")) + ) + ); + + // then + assertTermForCreate(term, vocabulary, "term", "meaning", List.of("synonym")); + } + + @Test + @DisplayName("용어 생성; 예외 - uid에 해당하는 용어집이 없는 경우") + void createFail1() { + // given + when(vocabularyRepository.findByUid(anyString())).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> factory.create( + new TermFactory.CreateCommand( + "vocabularyUid", + new TermFactory.TermInfo("term", "meaning", List.of("synonym")) + ) + )).isInstanceOf(VocabularyNotFoundException.class); + } + + @Test + @DisplayName("용어 생성; 예외 - 용어집의 용어 개수 초과") + void createFail2() { + // given + Vocabulary vocabulary = mock(Vocabulary.class); + when(vocabulary.termCount()).thenReturn(100); + when(vocabularyRepository.findByUid(anyString())).thenReturn(Optional.of(vocabulary)); + + // when & then + assertThatThrownBy(() -> factory.create( + new TermFactory.CreateCommand( + "vocabularyUid", + new TermFactory.TermInfo("term", "meaning", List.of("synonym")) + ) + )).isInstanceOf(TermLimitExceededException.class); + } + + @TestFactory + @DisplayName("용어 생성; 예외 - 용어 생성 시 유효성 검사 실패") + Stream createFail3() { + return Stream.of( + dynamicTest("용어가 없는 경우", () -> { + assertThatThrownBy(() -> factory.create( + new TermFactory.CreateCommand( + "vocabularyUid", + new TermFactory.TermInfo("", "meaning", List.of("synonym")) + ) + )).isInstanceOf(ConstraintViolationException.class); + }), + dynamicTest("용어가 100자 초과인 경우", () -> { + assertThatThrownBy(() -> factory.create( + new TermFactory.CreateCommand( + "vocabularyUid", + new TermFactory.TermInfo("a".repeat(101), "meaning", List.of("synonym")) + ) + )).isInstanceOf(ConstraintViolationException.class); + }), + dynamicTest("의미가 없는 경우", () -> { + assertThatThrownBy(() -> factory.create( + new TermFactory.CreateCommand( + "vocabularyUid", + new TermFactory.TermInfo("term", "", List.of("synonym")) + ) + )).isInstanceOf(ConstraintViolationException.class); + }), + dynamicTest("의미가 2000자 초과인 경우", () -> { + assertThatThrownBy(() -> factory.create( + new TermFactory.CreateCommand( + "vocabularyUid", + new TermFactory.TermInfo("term", "a".repeat(2001), List.of("synonym")) + ) + )).isInstanceOf(ConstraintViolationException.class); + }), + dynamicTest("동의어가 null인 경우", () -> { + assertThatThrownBy(() -> factory.create( + new TermFactory.CreateCommand( + "vocabularyUid", + new TermFactory.TermInfo("term", "meaning", null) + ) + )).isInstanceOf(ConstraintViolationException.class); + }), + dynamicTest("동의어가 합쳤을 때 콤마 포함 2000자가 넘는 경우", () -> { + assertThatThrownBy(() -> factory.create( + new TermFactory.CreateCommand( + "vocabularyUid", + new TermFactory.TermInfo("term", "meaning", List.of("a".repeat(2001))) + ) + )).isInstanceOf(ConstraintViolationException.class); + + assertThatThrownBy(() -> factory.create( + new TermFactory.CreateCommand( + "vocabularyUid", + new TermFactory.TermInfo("term", "meaning", List.of("a".repeat(1000), "b".repeat(1000))) + ) + )).isInstanceOf(ConstraintViolationException.class); + }) + ); + } + + @Test + @DisplayName("배치 생성을 위한 용어 생성; 정상") + void createForBatchCreate() { + // given + Vocabulary vocabulary = mock(Vocabulary.class); + when(vocabulary.termCount()).thenReturn(0); + when(vocabularyRepository.findByUid(anyString())).thenReturn(Optional.of(vocabulary)); + + // when + List terms = factory.createForBatchCreate( + new TermFactory.CreateForBatchCommand( + "vocabularyUid", + List.of( + new TermFactory.TermInfo("term1", "meaning1", List.of("synonym1")), + new TermFactory.TermInfo("term2", "meaning2", List.of("synonym2")) + ) + ) + ); + + // then + assertThat(terms).hasSize(2); + assertTermForCreate(terms.get(0), vocabulary, "term1", "meaning1", List.of("synonym1")); + assertTermForCreate(terms.get(1), vocabulary, "term2", "meaning2", List.of("synonym2")); + } + + @Test + @DisplayName("배치 생성을 위한 용어 생성; 예외 - uid에 해당하는 용어집이 없는 경우") + void createForBatchCreateFail1() { + // given + when(vocabularyRepository.findByUid(anyString())).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> factory.createForBatchCreate( + new TermFactory.CreateForBatchCommand( + "vocabularyUid", + List.of( + new TermFactory.TermInfo("term1", "meaning1", List.of("synonym1")), + new TermFactory.TermInfo("term2", "meaning2", List.of("synonym2")) + ) + ) + )).isInstanceOf(VocabularyNotFoundException.class); + } + + @Test + @DisplayName("배치 생성을 위한 용어 생성; 예외 - 용어집의 용어 개수 초과") + void createForBatchCreateFail2() { + // given + Vocabulary vocabulary = mock(Vocabulary.class); + when(vocabulary.termCount()).thenReturn(99); + when(vocabularyRepository.findByUid(anyString())).thenReturn(Optional.of(vocabulary)); + + // when & then + assertThatThrownBy(() -> factory.createForBatchCreate( + new TermFactory.CreateForBatchCommand( + "vocabularyUid", + List.of( + new TermFactory.TermInfo("term1", "meaning1", List.of("synonym1")), + new TermFactory.TermInfo("term2", "meaning2", List.of("synonym2")) + ) + ) + )).isInstanceOf(TermLimitExceededException.class); + } + + @TestFactory + @DisplayName("배치 생성을 위한 용어 생성; 예외 - 용어 생성 시 유효성 검사 실패") + Stream createForBatchCreateFail3() { + return Stream.of( + dynamicTest("용어가 없는 경우", () -> { + assertThatThrownBy(() -> factory.createForBatchCreate( + new TermFactory.CreateForBatchCommand( + "vocabularyUid", + List.of( + new TermFactory.TermInfo("", "meaning1", List.of("synonym1")), + new TermFactory.TermInfo("term2", "meaning2", List.of("synonym2")) + ) + ) + )).isInstanceOf(ConstraintViolationException.class); + }), + dynamicTest("용어가 100자 초과인 경우", () -> { + assertThatThrownBy(() -> factory.createForBatchCreate( + new TermFactory.CreateForBatchCommand( + "vocabularyUid", + List.of( + new TermFactory.TermInfo("a".repeat(101), "meaning1", List.of("synonym1")), + new TermFactory.TermInfo("term2", "meaning2", List.of("synonym2")) + ) + ) + )).isInstanceOf(ConstraintViolationException.class); + }), + dynamicTest("의미가 없는 경우", () -> { + assertThatThrownBy(() -> factory.createForBatchCreate( + new TermFactory.CreateForBatchCommand( + "vocabularyUid", + List.of( + new TermFactory.TermInfo("term1", "", List.of("synonym1")), + new TermFactory.TermInfo("term2", "meaning2", List.of("synonym2")) + ) + ) + )).isInstanceOf(ConstraintViolationException.class); + }), + dynamicTest("의미가 2000자 초과인 경우", () -> { + assertThatThrownBy(() -> factory.createForBatchCreate( + new TermFactory.CreateForBatchCommand( + "vocabularyUid", + List.of( + new TermFactory.TermInfo("term1", "a".repeat(2001), List.of("synonym1")), + new TermFactory.TermInfo("term2", "meaning2", List.of("synonym2")) + ) + ) + )).isInstanceOf(ConstraintViolationException.class); + }), + dynamicTest("동의어가 null인 경우", () -> { + assertThatThrownBy(() -> factory.createForBatchCreate( + new TermFactory.CreateForBatchCommand( + "vocabularyUid", + List.of( + new TermFactory.TermInfo("term1", "meaning2", null), + new TermFactory.TermInfo("term2", "meaning2", List.of()) + ) + ) + )).isInstanceOf(ConstraintViolationException.class); + }), + dynamicTest("동의어가 합쳤을 때 콤마 포함 2000자가 넘는 경우", () -> { + assertThatThrownBy(() -> factory.createForBatchCreate( + new TermFactory.CreateForBatchCommand( + "vocabularyUid", + List.of( + new TermFactory.TermInfo("term1", "meaning1", List.of("a".repeat(2001))), + new TermFactory.TermInfo("term2", "meaning2", List.of("synonym2")) + ) + ) + )).isInstanceOf(ConstraintViolationException.class); + + assertThatThrownBy(() -> factory.createForBatchCreate( + new TermFactory.CreateForBatchCommand( + "vocabularyUid", + List.of( + new TermFactory.TermInfo("term1", "meaning1", List.of("a".repeat(1000), "b".repeat(1000))), + new TermFactory.TermInfo("term2", "meaning2", List.of("synonym2")) + ) + ) + )).isInstanceOf(ConstraintViolationException.class); + }) + ); + } + + @Test + @DisplayName("용어 수정을 위한 용어 생성; 정상") + void createForUpdate() { + // when + Term term = factory.createForUpdate( + new TermFactory.UpdateCommand( + new TermFactory.TermInfo("term", "meaning", List.of("synonym")) + ) + ); + + // then + ObjectAssert termAssert = assertThat(term); + termAssert.isNotNull(); + termAssert.extracting(Term::getTerm).isEqualTo("term"); + termAssert.extracting(Term::getMeaning).isEqualTo("meaning"); + termAssert.extracting(Term::getSynonyms).isEqualTo(List.of("synonym")); + } + + @TestFactory + @DisplayName("용어 수정을 위한 용어 생성; 예외 - 용어 생성 시 유효성 검사 실패") + Stream createForUpdateFail1() { + return Stream.of( + dynamicTest("용어가 없는 경우", () -> { + assertThatThrownBy(() -> factory.createForUpdate( + new TermFactory.UpdateCommand( + new TermFactory.TermInfo("", "meaning", List.of("synonym")) + ) + )).isInstanceOf(ConstraintViolationException.class); + }), + dynamicTest("용어가 100자 초과인 경우", () -> { + assertThatThrownBy(() -> factory.createForUpdate( + new TermFactory.UpdateCommand( + new TermFactory.TermInfo("a".repeat(101), "meaning", List.of("synonym")) + ) + )).isInstanceOf(ConstraintViolationException.class); + }), + dynamicTest("의미가 없는 경우", () -> { + assertThatThrownBy(() -> factory.createForUpdate( + new TermFactory.UpdateCommand( + new TermFactory.TermInfo("term", "", List.of("synonym")) + ) + )).isInstanceOf(ConstraintViolationException.class); + }), + dynamicTest("의미가 2000자 초과인 경우", () -> { + assertThatThrownBy(() -> factory.createForUpdate( + new TermFactory.UpdateCommand( + new TermFactory.TermInfo("term", "a".repeat(2001), List.of("synonym")) + ) + )).isInstanceOf(ConstraintViolationException.class); + }), + dynamicTest("동의어가 null인 경우", () -> { + assertThatThrownBy(() -> factory.createForUpdate( + new TermFactory.UpdateCommand( + new TermFactory.TermInfo("term", "meaning", null) + ) + )).isInstanceOf(ConstraintViolationException.class); + }), + dynamicTest("동의어가 합쳤을 때 콤마 포함 2000자가 넘는 경우", () -> { + assertThatThrownBy(() -> factory.createForUpdate( + new TermFactory.UpdateCommand( + new TermFactory.TermInfo("term", "meaning", List.of("a".repeat(2001))) + ) + )).isInstanceOf(ConstraintViolationException.class); + + assertThatThrownBy(() -> factory.createForUpdate( + new TermFactory.UpdateCommand( + new TermFactory.TermInfo("term", "meaning", List.of("a".repeat(1000), "b".repeat(1000))) + ) + )).isInstanceOf(ConstraintViolationException.class); + }) + ); + } + + private void assertTermForCreate(Term target, Vocabulary vocabulary, String term, String meaning, List synonyms) { + ObjectAssert termAssert = assertThat(target); + termAssert.isNotNull(); + termAssert.extracting(Term::getId).isNull(); + termAssert.extracting(Term::getUid).isNotNull(); + termAssert.extracting(Term::getTerm).isEqualTo(term); + termAssert.extracting(Term::getMeaning).isEqualTo(meaning); + termAssert.extracting(Term::getSynonyms).isEqualTo(synonyms); + termAssert.extracting(Term::getVocabulary).isEqualTo(vocabulary); + } +} diff --git a/server/api/src/test/java/vook/server/api/infra/vocabulary/DefaultVocabularyRepositoryTest.java b/server/api/src/test/java/vook/server/api/infra/vocabulary/DefaultVocabularyRepositoryTest.java new file mode 100644 index 00000000..93533747 --- /dev/null +++ b/server/api/src/test/java/vook/server/api/infra/vocabulary/DefaultVocabularyRepositoryTest.java @@ -0,0 +1,88 @@ +package vook.server.api.infra.vocabulary; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; +import vook.server.api.domain.vocabulary.model.vocabulary.UserUid; +import vook.server.api.domain.vocabulary.model.vocabulary.Vocabulary; +import vook.server.api.infra.vocabulary.cache.UserVocabularyCache; +import vook.server.api.infra.vocabulary.cache.UserVocabularyCacheRepository; +import vook.server.api.testhelper.IntegrationTestBase; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@Transactional +class DefaultVocabularyRepositoryTest extends IntegrationTestBase { + + @Autowired + DefaultVocabularyRepository defaultVocabularyRepository; + + @Autowired + UserVocabularyCacheRepository userVocabularyCacheRepository; + + @AfterEach + void tearDown() { + userVocabularyCacheRepository.deleteAll(); + } + + @Test + @DisplayName("용어집 생성 - 정상") + void save() { + // given + String name = "용어집1"; + UserUid userUid = new UserUid("user-uid"); + Vocabulary vocabulary = Vocabulary.forCreateOf(name, userUid); + + // when + Vocabulary saved = defaultVocabularyRepository.save(vocabulary); + + // then + assertThat(saved.getId()).isNotNull(); + assertThat(saved.getUid()).isNotNull(); + assertThat(saved.getName()).isEqualTo(name); + assertThat(saved.getUserUid()).isEqualTo(userUid); + + UserVocabularyCache cache = userVocabularyCacheRepository.findById(userUid.getValue()).orElseThrow(); + assertThat(cache.vocabularyUids()).containsExactly(saved.getUid()); + } + + @Test + @DisplayName("용어집 삭제 - 정상") + void delete() { + // given + String name = "용어집1"; + UserUid userUid = new UserUid("user-uid"); + Vocabulary saved = defaultVocabularyRepository.save(Vocabulary.forCreateOf(name, userUid)); + + UserVocabularyCache savedCache = userVocabularyCacheRepository.findById(userUid.getValue()).orElseThrow(); + assertThat(savedCache.vocabularyUids()).containsExactly(saved.getUid()); + + // when + defaultVocabularyRepository.delete(saved); + + // then + assertThat(defaultVocabularyRepository.findByUid(saved.getUid())).isEmpty(); + + UserVocabularyCache deletedCache = userVocabularyCacheRepository.findById(userUid.getValue()).orElseThrow(); + assertThat(deletedCache.vocabularyUids()).isEmpty(); + } + + @Test + @DisplayName("유저 ID에 따른 접근 가능한 용어집 목록 조회 - 정상") + void findAllByUserUid() { + // given + UserUid userUid = new UserUid("user-uid"); + Vocabulary vocabulary1 = defaultVocabularyRepository.save(Vocabulary.forCreateOf("용어집1", userUid)); + Vocabulary vocabulary2 = defaultVocabularyRepository.save(Vocabulary.forCreateOf("용어집2", userUid)); + + // when + List vocabularyUids = defaultVocabularyRepository.findAllUidsByUserUid(userUid); + + // then + assertThat(vocabularyUids).containsExactlyInAnyOrder(vocabulary1.getUid(), vocabulary2.getUid()); + } +} diff --git a/server/api/src/test/java/vook/server/api/policy/VocabularyPolicyTest.java b/server/api/src/test/java/vook/server/api/policy/VocabularyPolicyTest.java new file mode 100644 index 00000000..f25b0a17 --- /dev/null +++ b/server/api/src/test/java/vook/server/api/policy/VocabularyPolicyTest.java @@ -0,0 +1,46 @@ +package vook.server.api.policy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class VocabularyPolicyTest { + + private final VocabularyPolicy vocabularyPolicy = new VocabularyPolicy(); + + @Test + @DisplayName("소유권 검증 - 성공") + void validateOwnerSuccess() { + List userVocabularyUids = List.of( + "245d8a9a-6272-4b8c-8daa-0f79bdc78495", + "004b83a6-2b47-45f4-8d99-3299a8f1a055" + ); + + List targetVocabularyUids = List.of( + "245d8a9a-6272-4b8c-8daa-0f79bdc78495", + "004b83a6-2b47-45f4-8d99-3299a8f1a055" + ); + + vocabularyPolicy.validateOwner(userVocabularyUids, targetVocabularyUids); + } + + @Test + @DisplayName("소유권 검증 - 실패; 유저의 단어장 소유권과 대상 단어장 소유권의 ID가 1자만 다를 경우에도 예외를 발생시킨다.") + void validateOwner() { + List userVocabularyUids = List.of( + "245d8a9a-6272-4b8c-8daa-0f79bdc78495", + "004b83a6-2b47-45f4-8d99-3299a8f1a055" + ); + + List targetVocabularyUids = List.of( + "245d8a9a-6272-4b8c-8daa-0f79bdc78495", + "004b83a6-2b47-45f4-8d99-3299a8f1a054" + ); + + assertThatThrownBy(() -> vocabularyPolicy.validateOwner(userVocabularyUids, targetVocabularyUids)) + .isInstanceOf(VocabularyPolicy.NotValidVocabularyOwnerException.class); + } +} diff --git a/server/api/src/test/java/vook/server/api/testhelper/HttpEntityBuilder.java b/server/api/src/test/java/vook/server/api/testhelper/HttpEntityBuilder.java new file mode 100644 index 00000000..6396a655 --- /dev/null +++ b/server/api/src/test/java/vook/server/api/testhelper/HttpEntityBuilder.java @@ -0,0 +1,24 @@ +package vook.server.api.testhelper; + +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; + +public class HttpEntityBuilder { + + private Object body; + private final HttpHeaders headers = new HttpHeaders(); + + public HttpEntityBuilder header(String key, String value) { + headers.add(key, value); + return this; + } + + public HttpEntityBuilder body(Object body) { + this.body = body; + return this; + } + + public HttpEntity build() { + return new HttpEntity<>(body, headers); + } +} diff --git a/server/api/src/test/java/vook/server/api/testhelper/IntegrationTestBase.java b/server/api/src/test/java/vook/server/api/testhelper/IntegrationTestBase.java new file mode 100644 index 00000000..4870bf52 --- /dev/null +++ b/server/api/src/test/java/vook/server/api/testhelper/IntegrationTestBase.java @@ -0,0 +1,24 @@ +package vook.server.api.testhelper; + +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.context.annotation.Import; + +import java.util.TimeZone; + +import static vook.server.api.config.TimeZoneConfig.DEFAULT_TIME_ZONE; + +@Import(value = TestcontainersConfiguration.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public abstract class IntegrationTestBase { + + @Autowired + protected TestRestTemplate rest; + + @BeforeEach + void init() { + TimeZone.setDefault(TimeZone.getTimeZone(DEFAULT_TIME_ZONE)); + } +} diff --git a/server/api/src/test/java/vook/server/api/testhelper/MeilisearchContainer.java b/server/api/src/test/java/vook/server/api/testhelper/MeilisearchContainer.java new file mode 100644 index 00000000..a2c36e69 --- /dev/null +++ b/server/api/src/test/java/vook/server/api/testhelper/MeilisearchContainer.java @@ -0,0 +1,34 @@ +package vook.server.api.testhelper; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +public class MeilisearchContainer extends GenericContainer { + + private static final String DEFAULT_IMAGE_NAME = "getmeili/meilisearch:latest"; + + public MeilisearchContainer() { + this(DEFAULT_IMAGE_NAME); + } + + public MeilisearchContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public MeilisearchContainer(DockerImageName dockerImageName) { + super(dockerImageName); + this.addExposedPort(7700); + this.addEnv("MEILI_MASTER_KEY", "aMasterKey"); + } + + public String getMasterKey() { + return this.getEnvMap().get("MEILI_MASTER_KEY"); + } + + /** + * Meilisearch에 접속하기 위한 URL을 반환한다. + */ + public String getHostUrl() { + return "http://" + getHost() + ":" + getMappedPort(7700); + } +} diff --git a/server/api/src/test/java/vook/server/api/testhelper/TestcontainersConfiguration.java b/server/api/src/test/java/vook/server/api/testhelper/TestcontainersConfiguration.java new file mode 100644 index 00000000..9e54a3df --- /dev/null +++ b/server/api/src/test/java/vook/server/api/testhelper/TestcontainersConfiguration.java @@ -0,0 +1,75 @@ +package vook.server.api.testhelper; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.MariaDBContainer; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; +import vook.server.api.infra.search.common.MeilisearchProperties; + +import java.util.Map; + +@Testcontainers(parallel = true) +@TestConfiguration(proxyBeanMethods = false) +class TestcontainersConfiguration { + + @Bean + @ServiceConnection + MariaDbWithMigrate mariaDBContainer() { + return new MariaDbWithMigrate<>("mariadb:10.11.8") + .withDatabaseName("example") + .withUsername("user") + .withPassword("userPw") + .withConfigurationOverride("db/conf") + .withTmpFs(Map.of("/var/lib/mysql", "rw")); + } + + public static class MariaDbWithMigrate> extends MariaDBContainer { + public MariaDbWithMigrate(String dockerImageName) { + super(dockerImageName); + } + + @Override + public void start() { + super.start(); + migrateContainer(this).start(); + } + + private GenericContainer migrateContainer(MariaDBContainer db) { + return new GenericContainer<>(DockerImageName.parse("migrate/migrate:v4.17.1")) + .withCopyFileToContainer( + MountableFile.forClasspathResource("migrate/sql/"), + "/sql" + ) + .withCommand( + "-source=file:///sql", + "-database=mysql://%s:%s@tcp(%s:3306)/%s".formatted( + db.getUsername(), db.getPassword(), db.getContainerInfo().getNetworkSettings().getIpAddress(), db.getDatabaseName() + ), + "up" + ); + } + } + + @Bean + MeilisearchContainer meilisearchContainer() { + return new MeilisearchContainer("getmeili/meilisearch:v1.9.0"); + } + + @Bean + public MeilisearchProperties meilisearchProperties(MeilisearchContainer meilisearchContainer) { + MeilisearchProperties meilisearchProperties = new MeilisearchProperties(); + meilisearchProperties.setHost(meilisearchContainer.getHostUrl()); + meilisearchProperties.setApiKey(meilisearchContainer.getMasterKey()); + return meilisearchProperties; + } + + @Bean + @ServiceConnection(name = "redis") + GenericContainer redisContainer() { + return new GenericContainer<>(DockerImageName.parse("redis:7.2.5")).withExposedPorts(6379); + } +} diff --git a/server/api/src/test/java/vook/server/api/testhelper/creator/TestTemplateVocabularyCreator.java b/server/api/src/test/java/vook/server/api/testhelper/creator/TestTemplateVocabularyCreator.java new file mode 100644 index 00000000..9e38cc26 --- /dev/null +++ b/server/api/src/test/java/vook/server/api/testhelper/creator/TestTemplateVocabularyCreator.java @@ -0,0 +1,38 @@ +package vook.server.api.testhelper.creator; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import vook.server.api.domain.template_vocabulary.logic.TemplateVocabularyLogic; +import vook.server.api.domain.template_vocabulary.logic.dto.TemplateVocabularyCreateCommand; +import vook.server.api.domain.template_vocabulary.model.TemplateVocabularyType; + +import java.util.List; + +@Component +@Transactional +@RequiredArgsConstructor +public class TestTemplateVocabularyCreator { + + private final TemplateVocabularyLogic vocabularyService; + + public void createTemplateVocabulary() { + createTemplateVocabulary(TemplateVocabularyType.DEVELOPMENT); + createTemplateVocabulary(TemplateVocabularyType.DESIGN); + createTemplateVocabulary(TemplateVocabularyType.MARKETING); + createTemplateVocabulary(TemplateVocabularyType.GENERAL_OFFICE); + } + + private void createTemplateVocabulary(TemplateVocabularyType type) { + String prefix = type.name(); + vocabularyService.create( + new TemplateVocabularyCreateCommand( + type, + List.of( + new TemplateVocabularyCreateCommand.Term(prefix + "1", prefix + "뜻1", List.of(prefix + "동의어1", prefix + "동의어2")), + new TemplateVocabularyCreateCommand.Term(prefix + "2", prefix + "뜻2", List.of(prefix + "동의어3", prefix + "동의어4")) + ) + ) + ); + } +} diff --git a/server/api/src/test/java/vook/server/api/testhelper/creator/TestUserCreator.java b/server/api/src/test/java/vook/server/api/testhelper/creator/TestUserCreator.java new file mode 100644 index 00000000..9167de40 --- /dev/null +++ b/server/api/src/test/java/vook/server/api/testhelper/creator/TestUserCreator.java @@ -0,0 +1,72 @@ +package vook.server.api.testhelper.creator; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import vook.server.api.domain.user.logic.UserLogic; +import vook.server.api.domain.user.logic.UserOnboardingCommand; +import vook.server.api.domain.user.logic.UserRegisterCommand; +import vook.server.api.domain.user.logic.UserSignUpFromSocialCommand; +import vook.server.api.domain.user.model.social_user.SocialUser; +import vook.server.api.domain.user.model.user.User; +import vook.server.api.domain.user.model.user_info.Funnel; +import vook.server.api.domain.user.model.user_info.Job; +import vook.server.api.web.common.auth.app.TokenService; +import vook.server.api.web.common.auth.data.GeneratedToken; + +import java.util.concurrent.atomic.AtomicInteger; + +@Component +@Transactional +@RequiredArgsConstructor +public class TestUserCreator { + + private final UserLogic userLogic; + private final TokenService tokenService; + private final AtomicInteger userCounter = new AtomicInteger(0); + + public User createUnregisteredUser() { + SocialUser user = userLogic.signUpFromSocial( + UserSignUpFromSocialCommand.builder() + .provider("testProvider") + .providerUserId("testProviderUserId" + userCounter.getAndIncrement()) + .email("testEmail" + userCounter.getAndIncrement() + "@test.com") + .build() + ); + return user.getUser(); + } + + public User createRegisteredUser() { + User user = createUnregisteredUser(); + userLogic.register( + UserRegisterCommand.builder() + .userUid(user.getUid()) + .nickname("testNick") + .marketingEmailOptIn(true) + .build() + ); + return userLogic.getByUid(user.getUid()); + } + + public User createCompletedOnboardingUser() { + User user = createRegisteredUser(); + userLogic.onboarding( + UserOnboardingCommand.builder() + .userUid(user.getUid()) + .funnel(Funnel.OTHER) + .job(Job.OTHER) + .build() + ); + return userLogic.getByUid(user.getUid()); + } + + public User createWithdrawnUser() { + User user = createCompletedOnboardingUser(); + userLogic.withdraw(user.getUid()); + return userLogic.getByUid(user.getUid()); + } + + public GeneratedToken createToken(User user) { + return tokenService.generateToken(user.getUid()); + } +} diff --git a/server/api/src/test/java/vook/server/api/testhelper/creator/TestVocabularyCreator.java b/server/api/src/test/java/vook/server/api/testhelper/creator/TestVocabularyCreator.java new file mode 100644 index 00000000..8efabe13 --- /dev/null +++ b/server/api/src/test/java/vook/server/api/testhelper/creator/TestVocabularyCreator.java @@ -0,0 +1,76 @@ +package vook.server.api.testhelper.creator; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import vook.server.api.domain.user.model.user.User; +import vook.server.api.domain.vocabulary.logic.term.TermCreateAllCommand; +import vook.server.api.domain.vocabulary.logic.term.TermCreateCommand; +import vook.server.api.domain.vocabulary.logic.term.TermLogic; +import vook.server.api.domain.vocabulary.logic.vocabulary.VocabularyCreateCommand; +import vook.server.api.domain.vocabulary.logic.vocabulary.VocabularyLogic; +import vook.server.api.domain.vocabulary.model.term.Term; +import vook.server.api.domain.vocabulary.model.vocabulary.UserUid; +import vook.server.api.domain.vocabulary.model.vocabulary.Vocabulary; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +@Component +@Transactional +@RequiredArgsConstructor +public class TestVocabularyCreator { + + private final VocabularyLogic vocabularyLogic; + private final TermLogic termLogic; + private final AtomicInteger vocabularyNameCounter = new AtomicInteger(0); + private final AtomicInteger termNameCounter = new AtomicInteger(0); + + public Vocabulary createVocabulary(User user) { + int count = vocabularyNameCounter.getAndIncrement(); + return createVocabulary(user, String.valueOf(count)); + } + + public Vocabulary createVocabulary(User user, String suffix) { + return vocabularyLogic.create( + VocabularyCreateCommand.builder() + .name("testVocabulary" + suffix) + .userUid(new UserUid(user.getUid())) + .build() + ); + } + + public Term createTerm(Vocabulary vocabulary) { + int count = termNameCounter.getAndIncrement(); + return createTerm(vocabulary, String.valueOf(count)); + } + + public Term createTerm(Vocabulary vocabulary, String suffix) { + return termLogic.create(TermCreateCommand.builder() + .vocabularyUid(vocabulary.getUid()) + .term("testTerm" + suffix) + .meaning("testMeaning" + suffix) + .synonyms(List.of("testSynonymA" + suffix, "testSynonymB" + suffix)) + .build()); + } + + public void createTerms(Vocabulary vocabulary, List termInfos) { + termLogic.createAll(TermCreateAllCommand.builder() + .vocabularyUid(vocabulary.getUid()) + .termInfos(termInfos.stream() + .map(termInfo -> new TermCreateAllCommand.TermInfo( + termInfo.term(), + termInfo.meaning(), + termInfo.synonyms() + )) + .toList()) + .build()); + } + + public record TermInfo( + String term, + String meaning, + List synonyms + ) { + } +} diff --git a/server/api/src/test/java/vook/server/api/web/term/usecase/BatchDeleteTermUseCaseTest.java b/server/api/src/test/java/vook/server/api/web/term/usecase/BatchDeleteTermUseCaseTest.java new file mode 100644 index 00000000..39e0b26a --- /dev/null +++ b/server/api/src/test/java/vook/server/api/web/term/usecase/BatchDeleteTermUseCaseTest.java @@ -0,0 +1,50 @@ +package vook.server.api.web.term.usecase; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; +import vook.server.api.domain.vocabulary.model.term.TermRepository; +import vook.server.api.testhelper.IntegrationTestBase; +import vook.server.api.testhelper.creator.TestUserCreator; +import vook.server.api.testhelper.creator.TestVocabularyCreator; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + + +@Transactional +class BatchDeleteTermUseCaseTest extends IntegrationTestBase { + + @Autowired + BatchDeleteTermUseCase batchDeleteTermUseCase; + + @Autowired + TestUserCreator testUserCreator; + @Autowired + TestVocabularyCreator testVocabularyCreator; + @Autowired + TermRepository termRepository; + + @Test + @DisplayName("용어 다중 삭제 - 정상") + void execute() { + // given + var user = testUserCreator.createCompletedOnboardingUser(); + var vocabulary = testVocabularyCreator.createVocabulary(user); + var term1 = testVocabularyCreator.createTerm(vocabulary); + var term2 = testVocabularyCreator.createTerm(vocabulary); + + // when + batchDeleteTermUseCase.execute(new BatchDeleteTermUseCase.Command( + user.getUid(), + List.of(term1.getUid(), term2.getUid()) + )); + + // then + assertThat(termRepository.findAll()).isEmpty(); + assertThat(vocabulary.termCount()).isEqualTo(0); + } + +} diff --git a/server/api/src/test/java/vook/server/api/web/term/usecase/CreateTermUseCaseTest.java b/server/api/src/test/java/vook/server/api/web/term/usecase/CreateTermUseCaseTest.java new file mode 100644 index 00000000..0092eb31 --- /dev/null +++ b/server/api/src/test/java/vook/server/api/web/term/usecase/CreateTermUseCaseTest.java @@ -0,0 +1,218 @@ +package vook.server.api.web.term.usecase; + +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; +import vook.server.api.domain.user.model.user.User; +import vook.server.api.domain.vocabulary.exception.TermLimitExceededException; +import vook.server.api.domain.vocabulary.exception.VocabularyNotFoundException; +import vook.server.api.domain.vocabulary.model.term.Term; +import vook.server.api.domain.vocabulary.model.term.TermFactory; +import vook.server.api.domain.vocabulary.model.term.TermRepository; +import vook.server.api.domain.vocabulary.model.vocabulary.Vocabulary; +import vook.server.api.globalcommon.exception.ParameterValidateException; +import vook.server.api.policy.VocabularyPolicy; +import vook.server.api.testhelper.IntegrationTestBase; +import vook.server.api.testhelper.creator.TestUserCreator; +import vook.server.api.testhelper.creator.TestVocabularyCreator; +import vook.server.api.web.common.auth.data.VookLoginUser; + +import java.util.List; +import java.util.function.Function; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@Transactional +class CreateTermUseCaseTest extends IntegrationTestBase { + + @Autowired + CreateTermUseCase useCase; + + @Autowired + TestUserCreator testUserCreator; + @Autowired + TestVocabularyCreator testVocabularyCreator; + @Autowired + TermRepository termRepository; + @Autowired + TermFactory termFactory; + @Autowired + EntityManager em; + + @Test + @DisplayName("용어 생성 - 정상") + void execute() { + // given + User user = testUserCreator.createCompletedOnboardingUser(); + VookLoginUser vookLoginUser = VookLoginUser.of(user.getUid()); + Vocabulary vocabulary = testVocabularyCreator.createVocabulary(user); + + var command = new CreateTermUseCase.Command( + vookLoginUser.getUid(), + vocabulary.getUid(), + "테스트 용어", + "테스트 뜻", + List.of("동의어1", "동의어2") + ); + + // when + var result = useCase.execute(command); + + // then + assertThat(result.uid()).isNotNull(); + + Term term = termRepository.findByUid(result.uid()).orElseThrow(); + assertThat(term.getTerm()).isEqualTo(command.term()); + assertThat(term.getMeaning()).isEqualTo(command.meaning()); + assertThat(term.getSynonyms()).containsExactlyInAnyOrderElementsOf(command.synonyms()); + assertThat(term.getVocabulary().termCount()).isEqualTo(1); + } + + @Test + @DisplayName("용어 생성 - 정상; 동의어가 없어도 용어 생성이 가능") + void executeSuccessWithoutSynonyms() { + // given + User user = testUserCreator.createCompletedOnboardingUser(); + VookLoginUser vookLoginUser = VookLoginUser.of(user.getUid()); + Vocabulary vocabulary = testVocabularyCreator.createVocabulary(user); + + var command = new CreateTermUseCase.Command( + vookLoginUser.getUid(), + vocabulary.getUid(), + "테스트 용어", + "테스트 뜻", + List.of() + ); + + // when + var result = useCase.execute(command); + + // then + assertThat(result.uid()).isNotNull(); + + Term term = termRepository.findByUid(result.uid()).orElseThrow(); + assertThat(term.getTerm()).isEqualTo(command.term()); + assertThat(term.getMeaning()).isEqualTo(command.meaning()); + assertThat(term.getSynonyms()).isEmpty(); + assertThat(term.getVocabulary().termCount()).isEqualTo(1); + } + + @Test + @DisplayName("용어 생성 - 실패; 용어집이 존재하지 않는 경우") + void executeError1() { + // given + User user = testUserCreator.createCompletedOnboardingUser(); + VookLoginUser vookLoginUser = VookLoginUser.of(user.getUid()); + + var command = new CreateTermUseCase.Command( + vookLoginUser.getUid(), + "not-exist", + "테스트 용어", + "테스트 뜻", + List.of("동의어1", "동의어2") + ); + + // when + assertThatThrownBy(() -> useCase.execute(command)) + .isInstanceOf(VocabularyNotFoundException.class); + } + + @Test + @DisplayName("용어 생성 - 실패; 용어집에 용어를 추가할 수 있는 제한을 초과한 경우 (100개)") + void executeError2() { + // given + User user = testUserCreator.createCompletedOnboardingUser(); + VookLoginUser vookLoginUser = VookLoginUser.of(user.getUid()); + Vocabulary vocabulary = testVocabularyCreator.createVocabulary(user); + + termRepository.saveAll( + IntStream.range(0, 100) + .mapToObj(i -> termFactory.create( + new TermFactory.CreateCommand( + vocabulary.getUid(), + new TermFactory.TermInfo("테스트 용어" + i, "테스트 뜻" + i, List.of())) + ) + ) + .toList() + ); + em.flush(); + em.clear(); + + var command = new CreateTermUseCase.Command( + vookLoginUser.getUid(), + vocabulary.getUid(), + "테스트 용어", + "테스트 뜻", + List.of("동의어1", "동의어2") + ); + + // when + assertThatThrownBy(() -> useCase.execute(command)) + .isInstanceOf(TermLimitExceededException.class); + } + + @Test + @DisplayName("용어 생성 - 실패; 사용자가 용어집 소유자가 아닌 경우") + void executeError3() { + // given + User user = testUserCreator.createCompletedOnboardingUser(); + Vocabulary vocabulary = testVocabularyCreator.createVocabulary(user); + + User anotherUser = testUserCreator.createCompletedOnboardingUser(); + + var command = new CreateTermUseCase.Command( + anotherUser.getUid(), + vocabulary.getUid(), + "테스트 용어", + "테스트 뜻", + List.of("동의어1", "동의어2") + ); + + // when + assertThatThrownBy(() -> useCase.execute(command)) + .isInstanceOf(VocabularyPolicy.NotValidVocabularyOwnerException.class); + } + + @TestFactory + @DisplayName("용어 생성 - 실패; 동의어들의 길이가 콤마로 연결 했을 때 2000자를 초과하는 경우") + Stream executeError4() { + // given + User user = testUserCreator.createCompletedOnboardingUser(); + VookLoginUser vookLoginUser = VookLoginUser.of(user.getUid()); + Vocabulary vocabulary = testVocabularyCreator.createVocabulary(user); + + Function, CreateTermUseCase.Command> createCommand = synonyms -> new CreateTermUseCase.Command( + vookLoginUser.getUid(), + vocabulary.getUid(), + "테스트 용어", + "테스트 뜻", + synonyms + ); + + return Stream.of( + DynamicTest.dynamicTest("동의어 1개 2000자 (O)", () -> { + var command = createCommand.apply(List.of("a".repeat(2000))); + useCase.execute(command); + }), + DynamicTest.dynamicTest("동의어 1개 2001자 (X)", () -> { + var command = createCommand.apply(List.of("a".repeat(2001))); + assertThatThrownBy(() -> useCase.execute(command)).isInstanceOf(ParameterValidateException.class); + }), + DynamicTest.dynamicTest("동의어 1개 1000자, 다른 1개 999자 (콤마 포함 2000자) (O)", () -> { + var command = createCommand.apply(List.of("a".repeat(1000), "a".repeat(999))); + useCase.execute(command); + }), + DynamicTest.dynamicTest("동의어 2개 각 1000자 (콤마 포함 2001자) (X)", () -> { + var command = createCommand.apply(List.of("a".repeat(1000), "a".repeat(1000))); + assertThatThrownBy(() -> useCase.execute(command)).isInstanceOf(ParameterValidateException.class); + }) + ); + } +} diff --git a/server/api/src/test/java/vook/server/api/web/term/usecase/DeleteTermUseCaseTest.java b/server/api/src/test/java/vook/server/api/web/term/usecase/DeleteTermUseCaseTest.java new file mode 100644 index 00000000..75e0ec94 --- /dev/null +++ b/server/api/src/test/java/vook/server/api/web/term/usecase/DeleteTermUseCaseTest.java @@ -0,0 +1,65 @@ +package vook.server.api.web.term.usecase; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; +import vook.server.api.domain.vocabulary.exception.TermNotFoundException; +import vook.server.api.domain.vocabulary.model.term.TermRepository; +import vook.server.api.testhelper.IntegrationTestBase; +import vook.server.api.testhelper.creator.TestUserCreator; +import vook.server.api.testhelper.creator.TestVocabularyCreator; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@Transactional +class DeleteTermUseCaseTest extends IntegrationTestBase { + + @Autowired + DeleteTermUseCase useCase; + + @Autowired + TestUserCreator testUserCreator; + @Autowired + TestVocabularyCreator testVocabularyCreator; + @Autowired + TermRepository termRepository; + + @Test + @DisplayName("용어 삭제 - 정상") + void execute() { + // given + var user = testUserCreator.createCompletedOnboardingUser(); + var vocabulary = testVocabularyCreator.createVocabulary(user); + var term = testVocabularyCreator.createTerm(vocabulary); + + var command = new DeleteTermUseCase.Command( + user.getUid(), + term.getUid() + ); + + // when + useCase.execute(command); + + // then + assertThat(termRepository.findByUid(term.getUid())).isEmpty(); + assertThat(vocabulary.termCount()).isEqualTo(0); + } + + @Test + @DisplayName("용어 삭제 - 용어가 없는 경우") + void executeError1() { + // given + var user = testUserCreator.createCompletedOnboardingUser(); + + var command = new DeleteTermUseCase.Command( + user.getUid(), + "존재하지 않는 용어 uid" + ); + + // when & then + assertThatThrownBy(() -> useCase.execute(command)) + .isInstanceOf(TermNotFoundException.class); + } +} diff --git a/server/api/src/test/java/vook/server/api/web/term/usecase/RetrieveTermUseCaseTest.java b/server/api/src/test/java/vook/server/api/web/term/usecase/RetrieveTermUseCaseTest.java new file mode 100644 index 00000000..e08258cc --- /dev/null +++ b/server/api/src/test/java/vook/server/api/web/term/usecase/RetrieveTermUseCaseTest.java @@ -0,0 +1,215 @@ +package vook.server.api.web.term.usecase; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.transaction.annotation.Transactional; +import vook.server.api.domain.user.model.user.User; +import vook.server.api.domain.vocabulary.model.term.Term; +import vook.server.api.domain.vocabulary.model.term.TermRepository; +import vook.server.api.domain.vocabulary.model.vocabulary.Vocabulary; +import vook.server.api.testhelper.IntegrationTestBase; +import vook.server.api.testhelper.creator.TestUserCreator; +import vook.server.api.testhelper.creator.TestVocabularyCreator; + +import java.util.Collection; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@Transactional +class RetrieveTermUseCaseTest extends IntegrationTestBase { + + @Autowired + RetrieveTermUseCase useCase; + + @Autowired + TestUserCreator testUserCreator; + @Autowired + TestVocabularyCreator testVocabularyCreator; + @Autowired + TermRepository termRepository; + + @Test + @DisplayName("용어 조회 - 정상; 기본") + void execute() { + // given + User user = testUserCreator.createCompletedOnboardingUser(); + Vocabulary vocabulary = testVocabularyCreator.createVocabulary(user); + Term term = testVocabularyCreator.createTerm(vocabulary); + + RetrieveTermUseCase.Command command = new RetrieveTermUseCase.Command( + user.getUid(), + vocabulary.getUid(), + PageRequest.ofSize(Integer.MAX_VALUE) + ); + + // when + RetrieveTermUseCase.Result result = useCase.execute(command); + + // then + assertThat(result.terms().getContent()).hasSize(1); + assertThat(result.terms().getContent().getFirst().termUid()).isEqualTo(term.getUid()); + assertThat(result.terms().getContent().getFirst().term()).isEqualTo(term.getTerm()); + assertThat(result.terms().getContent().getFirst().meaning()).isEqualTo(term.getMeaning()); + assertThat(result.terms().getContent().getFirst().synonyms()).containsExactlyInAnyOrderElementsOf(term.getSynonyms()); + } + + @TestFactory + @DisplayName("용어 조회 - 정상; 정렬") + Collection executeSort() { + // given + User user = testUserCreator.createCompletedOnboardingUser(); + Vocabulary vocabulary = testVocabularyCreator.createVocabulary(user); + Term term1 = testVocabularyCreator.createTerm(vocabulary, "1"); + Term term2 = testVocabularyCreator.createTerm(vocabulary, "2"); + Term term3 = testVocabularyCreator.createTerm(vocabulary, "3"); + + return List.of( + DynamicTest.dynamicTest("용어 이름 오름차순", () -> { + // when + RetrieveTermUseCase.Result result = useCase.execute(new RetrieveTermUseCase.Command( + user.getUid(), + vocabulary.getUid(), + PageRequest.of(0, Integer.MAX_VALUE, Sort.Direction.ASC, "term") + )); + + // then + assertThat(result.terms().getContent()).hasSize(3); + assertThat(result.terms().getContent().get(0).termUid()).isEqualTo(term1.getUid()); + assertThat(result.terms().getContent().get(1).termUid()).isEqualTo(term2.getUid()); + assertThat(result.terms().getContent().get(2).termUid()).isEqualTo(term3.getUid()); + }), + DynamicTest.dynamicTest("용어 이름 내림차순", () -> { + // when + RetrieveTermUseCase.Result result = useCase.execute(new RetrieveTermUseCase.Command( + user.getUid(), + vocabulary.getUid(), + PageRequest.of(0, Integer.MAX_VALUE, Sort.Direction.DESC, "term") + )); + + // then + assertThat(result.terms().getContent()).hasSize(3); + assertThat(result.terms().getContent().get(0).termUid()).isEqualTo(term3.getUid()); + assertThat(result.terms().getContent().get(1).termUid()).isEqualTo(term2.getUid()); + assertThat(result.terms().getContent().get(2).termUid()).isEqualTo(term1.getUid()); + }), + DynamicTest.dynamicTest("뜻 오름차순", () -> { + // when + RetrieveTermUseCase.Result result = useCase.execute(new RetrieveTermUseCase.Command( + user.getUid(), + vocabulary.getUid(), + PageRequest.of(0, Integer.MAX_VALUE, Sort.Direction.ASC, "meaning") + )); + + // then + assertThat(result.terms().getContent()).hasSize(3); + assertThat(result.terms().getContent().get(0).termUid()).isEqualTo(term1.getUid()); + assertThat(result.terms().getContent().get(1).termUid()).isEqualTo(term2.getUid()); + assertThat(result.terms().getContent().get(2).termUid()).isEqualTo(term3.getUid()); + }), + DynamicTest.dynamicTest("뜻 내림차순", () -> { + // when + RetrieveTermUseCase.Result result = useCase.execute(new RetrieveTermUseCase.Command( + user.getUid(), + vocabulary.getUid(), + PageRequest.of(0, Integer.MAX_VALUE, Sort.Direction.DESC, "meaning") + )); + + // then + assertThat(result.terms().getContent()).hasSize(3); + assertThat(result.terms().getContent().get(0).termUid()).isEqualTo(term3.getUid()); + assertThat(result.terms().getContent().get(1).termUid()).isEqualTo(term2.getUid()); + assertThat(result.terms().getContent().get(2).termUid()).isEqualTo(term1.getUid()); + }), + DynamicTest.dynamicTest("생성 일차 오름차순", () -> { + // when + RetrieveTermUseCase.Result result = useCase.execute(new RetrieveTermUseCase.Command( + user.getUid(), + vocabulary.getUid(), + PageRequest.of(0, Integer.MAX_VALUE, Sort.Direction.ASC, "createdAt") + )); + + // then + assertThat(result.terms().getContent()).hasSize(3); + assertThat(result.terms().getContent().get(0).termUid()).isEqualTo(term1.getUid()); + assertThat(result.terms().getContent().get(1).termUid()).isEqualTo(term2.getUid()); + assertThat(result.terms().getContent().get(2).termUid()).isEqualTo(term3.getUid()); + }), + DynamicTest.dynamicTest("생성 일차 내림차순", () -> { + // when + RetrieveTermUseCase.Result result = useCase.execute(new RetrieveTermUseCase.Command( + user.getUid(), + vocabulary.getUid(), + PageRequest.of(0, Integer.MAX_VALUE, Sort.Direction.DESC, "createdAt") + )); + + // then + assertThat(result.terms().getContent()).hasSize(3); + assertThat(result.terms().getContent().get(0).termUid()).isEqualTo(term3.getUid()); + assertThat(result.terms().getContent().get(1).termUid()).isEqualTo(term2.getUid()); + assertThat(result.terms().getContent().get(2).termUid()).isEqualTo(term1.getUid()); + }), + DynamicTest.dynamicTest("동의어 오름차순", () -> { + // when + RetrieveTermUseCase.Result result = useCase.execute(new RetrieveTermUseCase.Command( + user.getUid(), + vocabulary.getUid(), + PageRequest.of(0, Integer.MAX_VALUE, Sort.Direction.ASC, "synonym") + )); + + // then + assertThat(result.terms().getContent()).hasSize(3); + assertThat(result.terms().getContent().get(0).termUid()).isEqualTo(term1.getUid()); + assertThat(result.terms().getContent().get(1).termUid()).isEqualTo(term2.getUid()); + assertThat(result.terms().getContent().get(2).termUid()).isEqualTo(term3.getUid()); + }), + DynamicTest.dynamicTest("동의어 내림차순", () -> { + // when + RetrieveTermUseCase.Result result = useCase.execute(new RetrieveTermUseCase.Command( + user.getUid(), + vocabulary.getUid(), + PageRequest.of(0, Integer.MAX_VALUE, Sort.Direction.DESC, "synonym") + )); + + // then + assertThat(result.terms().getContent()).hasSize(3); + assertThat(result.terms().getContent().get(0).termUid()).isEqualTo(term3.getUid()); + assertThat(result.terms().getContent().get(1).termUid()).isEqualTo(term2.getUid()); + assertThat(result.terms().getContent().get(2).termUid()).isEqualTo(term1.getUid()); + }) + ); + } + + @Test + @DisplayName("용어 조회 - 정상; 2개 이상의 용어집에서 검색") + void executeMultipleVocabularies() { + // given + User user = testUserCreator.createCompletedOnboardingUser(); + Vocabulary vocabulary1 = testVocabularyCreator.createVocabulary(user); + testVocabularyCreator.createTerm(vocabulary1); + Vocabulary vocabulary2 = testVocabularyCreator.createVocabulary(user); + Term term2 = testVocabularyCreator.createTerm(vocabulary2); + + RetrieveTermUseCase.Command command = new RetrieveTermUseCase.Command( + user.getUid(), + vocabulary2.getUid(), + PageRequest.ofSize(Integer.MAX_VALUE) + ); + + // when + RetrieveTermUseCase.Result result = useCase.execute(command); + + // then + assertThat(result.terms().getContent()).hasSize(1); + assertThat(result.terms().getContent().getFirst().termUid()).isEqualTo(term2.getUid()); + assertThat(result.terms().getContent().getFirst().term()).isEqualTo(term2.getTerm()); + assertThat(result.terms().getContent().getFirst().meaning()).isEqualTo(term2.getMeaning()); + assertThat(result.terms().getContent().getFirst().synonyms()).containsExactlyInAnyOrderElementsOf(term2.getSynonyms()); + } + +} diff --git a/server/api/src/test/java/vook/server/api/web/term/usecase/SearchTermUseCaseTest.java b/server/api/src/test/java/vook/server/api/web/term/usecase/SearchTermUseCaseTest.java new file mode 100644 index 00000000..96a35567 --- /dev/null +++ b/server/api/src/test/java/vook/server/api/web/term/usecase/SearchTermUseCaseTest.java @@ -0,0 +1,132 @@ +package vook.server.api.web.term.usecase; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; +import vook.server.api.domain.user.model.user.User; +import vook.server.api.domain.vocabulary.model.vocabulary.Vocabulary; +import vook.server.api.policy.VocabularyPolicy; +import vook.server.api.testhelper.IntegrationTestBase; +import vook.server.api.testhelper.creator.TestUserCreator; +import vook.server.api.testhelper.creator.TestVocabularyCreator; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static vook.server.api.testhelper.creator.TestVocabularyCreator.TermInfo; + +@Transactional +class SearchTermUseCaseTest extends IntegrationTestBase { + + @Autowired + SearchTermUseCase searchTermUseCase; + + @Autowired + TestUserCreator testUserCreator; + @Autowired + TestVocabularyCreator vocaCreator; + + @Test + @DisplayName("용어 검색 - 정상") + void searchTerms() { + // given + User user = testUserCreator.createCompletedOnboardingUser(); + Vocabulary vocabulary1 = vocaCreator.createVocabulary(user); + vocaCreator.createTerm(vocabulary1, "하이브리드앱"); + vocaCreator.createTerm(vocabulary1, "네이티브앱"); + Vocabulary vocabulary2 = vocaCreator.createVocabulary(user); + vocaCreator.createTerm(vocabulary2, "하이브리드웹"); + vocaCreator.createTerm(vocabulary2, "네이티브웹"); + + SearchTermUseCase.Command command = SearchTermUseCase.Command.builder() + .userUid(user.getUid()) + .vocabularyUids(List.of(vocabulary1.getUid(), vocabulary2.getUid())) + .queries(List.of("하이브리드")) + .build(); + + // when + SearchTermUseCase.Result result = searchTermUseCase.execute(command); + + // then + assertThat(result.records()) + .isNotEmpty() + .satisfiesExactlyInAnyOrder( + term -> { + assertThat(term.vocabularyUid()).isEqualTo(vocabulary1.getUid()); + assertThat(term.query()).isEqualTo("하이브리드"); + assertThat(term.hits()).hasSize(1); + assertThat(term.hits().getFirst().term()).contains("하이브리드앱"); + assertThat(term.hits().getFirst().meaning()).contains("하이브리드앱"); + assertThat(term.hits().getFirst().synonyms()).contains("하이브리드앱"); + }, + term -> { + assertThat(term.vocabularyUid()).isEqualTo(vocabulary2.getUid()); + assertThat(term.query()).isEqualTo("하이브리드"); + assertThat(term.hits()).hasSize(1); + assertThat(term.hits().getFirst().term()).contains("하이브리드웹"); + assertThat(term.hits().getFirst().meaning()).contains("하이브리드웹"); + assertThat(term.hits().getFirst().synonyms()).contains("하이브리드웹"); + } + ); + } + + @Test + @DisplayName("용어 검색 - 실패; 사용자가 소유한 용어집이 아닌 경우") + void searchTerms_userDoesNotOwnVocabulary() { + // given + User user = testUserCreator.createCompletedOnboardingUser(); + vocaCreator.createVocabulary(user); + vocaCreator.createVocabulary(user); + + User anotherUser = testUserCreator.createCompletedOnboardingUser(); + Vocabulary anotherVocabulary = vocaCreator.createVocabulary(anotherUser); + + SearchTermUseCase.Command command = SearchTermUseCase.Command.builder() + .userUid(user.getUid()) + .vocabularyUids(List.of(anotherVocabulary.getUid())) + .queries(List.of("하이브리드")) + .build(); + + // when, then + assertThatThrownBy(() -> searchTermUseCase.execute(command)) + .isInstanceOf(VocabularyPolicy.NotValidVocabularyOwnerException.class); + } + + @Test + @DisplayName("용어 검색 - 검색 쿼리내 단어가 몇 개이던, 첫번째 단어 기준으로 검색이 된다.") + void searchTerms_queryIsTooLong() { + // given + User user = testUserCreator.createCompletedOnboardingUser(); + Vocabulary voca = vocaCreator.createVocabulary(user); + vocaCreator.createTerms(voca, List.of( + new TermInfo("비빔밥", "비빔밥", List.of("비빔밥")), + new TermInfo("김치찌개", "김치찌개", List.of("김치찌개")), + new TermInfo("불고기", "불고기", List.of("불고기")) + )); + + SearchTermUseCase.Command command = SearchTermUseCase.Command.builder() + .userUid(user.getUid()) + .vocabularyUids(List.of(voca.getUid())) + .queries(List.of("김치찌개와 비빔밥, 그리고 불고기")) + .build(); + + // when + SearchTermUseCase.Result result = searchTermUseCase.execute(command); + + // then + assertThat(result.records()) + .isNotEmpty() + .satisfiesExactlyInAnyOrder( + term -> { + assertThat(term.vocabularyUid()).isEqualTo(voca.getUid()); + assertThat(term.hits()).hasSize(1); + assertThat(term.query()).isEqualTo("김치찌개와 비빔밥, 그리고 불고기"); + assertThat(term.hits().getFirst().term()).contains("김치찌개"); + assertThat(term.hits().getFirst().meaning()).contains("김치찌개"); + assertThat(term.hits().getFirst().synonyms()).contains("김치찌개"); + } + ); + } +} diff --git a/server/api/src/test/java/vook/server/api/web/term/usecase/UpdateTermUseCaseTest.java b/server/api/src/test/java/vook/server/api/web/term/usecase/UpdateTermUseCaseTest.java new file mode 100644 index 00000000..1f57c48f --- /dev/null +++ b/server/api/src/test/java/vook/server/api/web/term/usecase/UpdateTermUseCaseTest.java @@ -0,0 +1,104 @@ +package vook.server.api.web.term.usecase; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; +import vook.server.api.domain.user.model.user.User; +import vook.server.api.domain.vocabulary.exception.TermNotFoundException; +import vook.server.api.domain.vocabulary.model.term.Term; +import vook.server.api.domain.vocabulary.model.term.TermRepository; +import vook.server.api.domain.vocabulary.model.vocabulary.Vocabulary; +import vook.server.api.policy.VocabularyPolicy; +import vook.server.api.testhelper.IntegrationTestBase; +import vook.server.api.testhelper.creator.TestUserCreator; +import vook.server.api.testhelper.creator.TestVocabularyCreator; + +import java.util.ArrayList; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +@Transactional +class UpdateTermUseCaseTest extends IntegrationTestBase { + + @Autowired + UpdateTermUseCase useCase; + + @Autowired + TestUserCreator testUserCreator; + @Autowired + TestVocabularyCreator testVocabularyCreator; + @Autowired + TermRepository termRepository; + + @Test + @DisplayName("용어 수정 - 정상") + void execute() { + // given + User user = testUserCreator.createCompletedOnboardingUser(); + var vocabulary = testVocabularyCreator.createVocabulary(user); + var term = testVocabularyCreator.createTerm(vocabulary); + + UpdateTermUseCase.Command command = new UpdateTermUseCase.Command( + user.getUid(), + term.getUid(), + "수정된 용어", + "수정된 뜻", + new ArrayList<>() + ); + + // when + useCase.execute(command); + + // then + var updatedTerm = termRepository.findByUid(term.getUid()).orElseThrow(); + assertThat(updatedTerm.getTerm()).isEqualTo(command.term()); + assertThat(updatedTerm.getMeaning()).isEqualTo(command.meaning()); + assertThat(updatedTerm.getSynonyms()).isEmpty(); + } + + @Test + @DisplayName("용어 수정 - 실패; 용어가 존재하지 않는 경우") + void executeError1() { + // given + User user = testUserCreator.createCompletedOnboardingUser(); + + UpdateTermUseCase.Command command = new UpdateTermUseCase.Command( + user.getUid(), + UUID.randomUUID().toString(), + "수정된 용어", + "수정된 뜻", + new ArrayList<>() + ); + + // when, then + assertThatThrownBy(() -> useCase.execute(command)) + .isInstanceOf(TermNotFoundException.class); + } + + @Test + @DisplayName("용어 수정 - 실패; 사용자가 용어가 소속된 용어집 소유자가 아닌 경우") + void executeError2() { + // given + User user = testUserCreator.createCompletedOnboardingUser(); + Vocabulary vocabulary = testVocabularyCreator.createVocabulary(user); + Term term = testVocabularyCreator.createTerm(vocabulary); + + User anotherUser = testUserCreator.createCompletedOnboardingUser(); + + UpdateTermUseCase.Command command = new UpdateTermUseCase.Command( + anotherUser.getUid(), + term.getUid(), + "수정된 용어", + "수정된 뜻", + new ArrayList<>() + ); + + // when, then + assertThatThrownBy(() -> useCase.execute(command)) + .isInstanceOf(VocabularyPolicy.NotValidVocabularyOwnerException.class); + } +} diff --git a/server/api/src/test/java/vook/server/api/web/user/usecase/OnboardingUserUseCaseTest.java b/server/api/src/test/java/vook/server/api/web/user/usecase/OnboardingUserUseCaseTest.java new file mode 100644 index 00000000..9df401f0 --- /dev/null +++ b/server/api/src/test/java/vook/server/api/web/user/usecase/OnboardingUserUseCaseTest.java @@ -0,0 +1,58 @@ +package vook.server.api.web.user.usecase; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; +import vook.server.api.domain.template_vocabulary.model.TemplateVocabularyType; +import vook.server.api.domain.user.model.user.UserRepository; +import vook.server.api.domain.user.model.user_info.Funnel; +import vook.server.api.domain.user.model.user_info.Job; +import vook.server.api.domain.vocabulary.model.term.TermRepository; +import vook.server.api.domain.vocabulary.model.vocabulary.UserUid; +import vook.server.api.domain.vocabulary.model.vocabulary.VocabularyRepository; +import vook.server.api.testhelper.IntegrationTestBase; +import vook.server.api.testhelper.creator.TestTemplateVocabularyCreator; +import vook.server.api.testhelper.creator.TestUserCreator; + +import static org.assertj.core.api.Assertions.assertThat; + +@Transactional +class OnboardingUserUseCaseTest extends IntegrationTestBase { + + @Autowired + OnboardingUserUseCase useCase; + + @Autowired + TestTemplateVocabularyCreator testTemplateVocabularyCreator; + @Autowired + TestUserCreator testUserCreator; + @Autowired + UserRepository userRepository; + @Autowired + VocabularyRepository vocabularyRepository; + @Autowired + TermRepository termRepository; + + @Test + @DisplayName("유저 온보딩 - 정상") + void onboardingUser() { + // given + testTemplateVocabularyCreator.createTemplateVocabulary(); + var user = testUserCreator.createRegisteredUser(); + var command = new OnboardingUserUseCase.Command(user.getUid(), Funnel.FACEBOOK, Job.DEVELOPER); + + // when + useCase.execute(command); + + // then + var savedUser = userRepository.findByUid(user.getUid()).orElseThrow(); + assertThat(savedUser.getOnboardingCompleted()).isTrue(); + + var vocabularies = vocabularyRepository.findAllByUserUid(new UserUid(savedUser.getUid())); + assertThat(vocabularies).hasSize(1); + var vocabulary = vocabularies.getFirst(); + assertThat(vocabulary.getName()).isEqualTo(TemplateVocabularyType.DESIGN.getVocabularyName()); + assertThat(vocabulary.getTerms()).isNotEmpty(); + } +} diff --git a/server/api/src/test/java/vook/server/api/web/user/usecase/WithdrawUserUseCaseTest.java b/server/api/src/test/java/vook/server/api/web/user/usecase/WithdrawUserUseCaseTest.java new file mode 100644 index 00000000..54323815 --- /dev/null +++ b/server/api/src/test/java/vook/server/api/web/user/usecase/WithdrawUserUseCaseTest.java @@ -0,0 +1,61 @@ +package vook.server.api.web.user.usecase; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; +import vook.server.api.domain.user.model.user.UserRepository; +import vook.server.api.domain.user.model.user.UserStatus; +import vook.server.api.domain.vocabulary.model.term.TermRepository; +import vook.server.api.domain.vocabulary.model.vocabulary.UserUid; +import vook.server.api.domain.vocabulary.model.vocabulary.Vocabulary; +import vook.server.api.domain.vocabulary.model.vocabulary.VocabularyRepository; +import vook.server.api.testhelper.IntegrationTestBase; +import vook.server.api.testhelper.creator.TestUserCreator; +import vook.server.api.testhelper.creator.TestVocabularyCreator; + +import static org.assertj.core.api.Assertions.assertThat; + +@Transactional +class WithdrawUserUseCaseTest extends IntegrationTestBase { + + @Autowired + WithdrawUserUseCase withdrawUserUseCase; + + @Autowired + TestUserCreator testUserCreator; + @Autowired + TestVocabularyCreator testVocabularyCreator; + + @Autowired + UserRepository userRepository; + @Autowired + VocabularyRepository vocabularyRepository; + @Autowired + TermRepository termRepository; + + @Test + @DisplayName("유저 탈퇴 - 정상") + void withdrawUser() { + // given + var user = testUserCreator.createCompletedOnboardingUser(); + Vocabulary vocabulary = testVocabularyCreator.createVocabulary(user); + testVocabularyCreator.createTerm(vocabulary); + testVocabularyCreator.createTerm(vocabulary); + + var command = new WithdrawUserUseCase.Command(user.getUid()); + + // when + withdrawUserUseCase.execute(command); + + // then + var savedUser = userRepository.findByUid(user.getUid()).orElseThrow(); + assertThat(savedUser.getStatus()).isEqualTo(UserStatus.WITHDRAWN); + + var savedVocabularies = vocabularyRepository.findAllByUserUid(new UserUid(savedUser.getUid())); + assertThat(savedVocabularies).isEmpty(); + + var savedTerms = termRepository.findAll(); + assertThat(savedTerms).isEmpty(); + } +} diff --git a/server/api/src/test/java/vook/server/api/web/vocabulary/usecase/CreateVocabularyUseCaseTest.java b/server/api/src/test/java/vook/server/api/web/vocabulary/usecase/CreateVocabularyUseCaseTest.java new file mode 100644 index 00000000..444c495f --- /dev/null +++ b/server/api/src/test/java/vook/server/api/web/vocabulary/usecase/CreateVocabularyUseCaseTest.java @@ -0,0 +1,70 @@ +package vook.server.api.web.vocabulary.usecase; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; +import vook.server.api.domain.user.model.user.User; +import vook.server.api.domain.vocabulary.exception.VocabularyLimitExceededException; +import vook.server.api.domain.vocabulary.model.vocabulary.UserUid; +import vook.server.api.domain.vocabulary.model.vocabulary.Vocabulary; +import vook.server.api.domain.vocabulary.model.vocabulary.VocabularyRepository; +import vook.server.api.testhelper.IntegrationTestBase; +import vook.server.api.testhelper.creator.TestUserCreator; +import vook.server.api.testhelper.creator.TestVocabularyCreator; +import vook.server.api.web.common.auth.data.VookLoginUser; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@Transactional +class CreateVocabularyUseCaseTest extends IntegrationTestBase { + + @Autowired + CreateVocabularyUseCase useCase; + + @Autowired + TestUserCreator testUserCreator; + @Autowired + TestVocabularyCreator testVocabularyCreator; + @Autowired + VocabularyRepository vocabularyRepository; + + @Test + @DisplayName("용어집 생성 - 정상") + void createVocabulary() { + // given + User user = testUserCreator.createCompletedOnboardingUser(); + VookLoginUser vookLoginUser = VookLoginUser.of(user.getUid()); + + // when + var command = new CreateVocabularyUseCase.Command(vookLoginUser.getUid(), "testVocabulary"); + useCase.execute(command); + + // then + List vocabularies = vocabularyRepository.findAllByUserUid(new UserUid(user.getUid())); + assertThat(vocabularies).hasSize(1); + assertThat(vocabularies.getFirst().getName()).isEqualTo("testVocabulary"); + } + + @Test + @DisplayName("용어집 생성 - 실패; 용어집이 이미 3개 존재하는 경우") + void createVocabularyError1() { + // given + User user = testUserCreator.createCompletedOnboardingUser(); + VookLoginUser vookLoginUser = VookLoginUser.of(user.getUid()); + + // 용어집 3개 생성 + testVocabularyCreator.createVocabulary(user); + testVocabularyCreator.createVocabulary(user); + testVocabularyCreator.createVocabulary(user); + + // when + assertThatThrownBy(() -> { + var command = new CreateVocabularyUseCase.Command(vookLoginUser.getUid(), "testVocabulary"); + useCase.execute(command); + }).isInstanceOf(VocabularyLimitExceededException.class); + } +} diff --git a/server/api/src/test/java/vook/server/api/web/vocabulary/usecase/DeleteVocabularyUseCaseTest.java b/server/api/src/test/java/vook/server/api/web/vocabulary/usecase/DeleteVocabularyUseCaseTest.java new file mode 100644 index 00000000..bd0f65be --- /dev/null +++ b/server/api/src/test/java/vook/server/api/web/vocabulary/usecase/DeleteVocabularyUseCaseTest.java @@ -0,0 +1,79 @@ +package vook.server.api.web.vocabulary.usecase; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; +import vook.server.api.domain.user.model.user.User; +import vook.server.api.domain.vocabulary.exception.VocabularyNotFoundException; +import vook.server.api.domain.vocabulary.model.vocabulary.Vocabulary; +import vook.server.api.domain.vocabulary.model.vocabulary.VocabularyRepository; +import vook.server.api.policy.VocabularyPolicy; +import vook.server.api.testhelper.IntegrationTestBase; +import vook.server.api.testhelper.creator.TestUserCreator; +import vook.server.api.testhelper.creator.TestVocabularyCreator; +import vook.server.api.web.common.auth.data.VookLoginUser; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@Transactional +class DeleteVocabularyUseCaseTest extends IntegrationTestBase { + + @Autowired + DeleteVocabularyUseCase useCase; + + @Autowired + TestUserCreator testUserCreator; + @Autowired + TestVocabularyCreator testVocabularyCreator; + @Autowired + VocabularyRepository vocabularyRepository; + + @Test + @DisplayName("용어집 삭제 - 정상") + void deleteVocabulary() { + // given + User user = testUserCreator.createCompletedOnboardingUser(); + VookLoginUser vookLoginUser = VookLoginUser.of(user.getUid()); + Vocabulary vocabulary = testVocabularyCreator.createVocabulary(user); + + // when + var command = new DeleteVocabularyUseCase.Command(vookLoginUser.getUid(), vocabulary.getUid()); + useCase.execute(command); + + // then + assertThat(vocabularyRepository.findByUid(vocabulary.getUid())).isEmpty(); + } + + @Test + @DisplayName("용어집 삭제 - 실패; 해당 용어집이 존재하지 않는 경우") + void deleteVocabularyError1() { + // given + User user = testUserCreator.createCompletedOnboardingUser(); + VookLoginUser vookLoginUser = VookLoginUser.of(user.getUid()); + testVocabularyCreator.createVocabulary(user); + + // when + assertThatThrownBy(() -> { + var command = new DeleteVocabularyUseCase.Command(vookLoginUser.getUid(), "nonExistentUid"); + useCase.execute(command); + }).isInstanceOf(VocabularyNotFoundException.class); + } + + @Test + @DisplayName("용어집 삭제 - 실패; 해당 용어집이 다른 사용자의 것인 경우") + void deleteVocabularyError2() { + // given + User user = testUserCreator.createCompletedOnboardingUser(); + User otherUser = testUserCreator.createCompletedOnboardingUser(); + VookLoginUser vookLoginUser = VookLoginUser.of(user.getUid()); + Vocabulary vocabulary = testVocabularyCreator.createVocabulary(otherUser); + + // when + assertThatThrownBy(() -> { + var command = new DeleteVocabularyUseCase.Command(vookLoginUser.getUid(), vocabulary.getUid()); + useCase.execute(command); + }).isInstanceOf(VocabularyPolicy.NotValidVocabularyOwnerException.class); + } +} diff --git a/server/api/src/test/java/vook/server/api/web/vocabulary/usecase/RetrieveVocabularyUseCaseTest.java b/server/api/src/test/java/vook/server/api/web/vocabulary/usecase/RetrieveVocabularyUseCaseTest.java new file mode 100644 index 00000000..f0424319 --- /dev/null +++ b/server/api/src/test/java/vook/server/api/web/vocabulary/usecase/RetrieveVocabularyUseCaseTest.java @@ -0,0 +1,52 @@ +package vook.server.api.web.vocabulary.usecase; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; +import vook.server.api.domain.user.model.user.User; +import vook.server.api.domain.vocabulary.model.vocabulary.Vocabulary; +import vook.server.api.domain.vocabulary.model.vocabulary.VocabularyRepository; +import vook.server.api.testhelper.IntegrationTestBase; +import vook.server.api.testhelper.creator.TestUserCreator; +import vook.server.api.testhelper.creator.TestVocabularyCreator; +import vook.server.api.web.common.auth.data.VookLoginUser; +import vook.server.api.web.vocabulary.reqres.VocabularyResponse; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@Transactional +class RetrieveVocabularyUseCaseTest extends IntegrationTestBase { + + @Autowired + RetrieveVocabularyUseCase useCase; + + @Autowired + TestUserCreator testUserCreator; + @Autowired + TestVocabularyCreator testVocabularyCreator; + @Autowired + VocabularyRepository vocabularyRepository; + + @Test + @DisplayName("용어집 조회 - 정상") + void vocabularies() { + // given + User user = testUserCreator.createCompletedOnboardingUser(); + VookLoginUser vookLoginUser = VookLoginUser.of(user.getUid()); + Vocabulary vocabulary = testVocabularyCreator.createVocabulary(user); + + // when + var command = new RetrieveVocabularyUseCase.Command(vookLoginUser.getUid()); + var result = useCase.execute(command); + List vocabularies = VocabularyResponse.from(result); + + // then + assertThat(vocabularies).hasSize(1); + assertThat(vocabularies.getFirst().uid()).isEqualTo(vocabulary.getUid()); + assertThat(vocabularies.getFirst().name()).isEqualTo(vocabulary.getName()); + assertThat(vocabularies.getFirst().createdAt()).isEqualTo(vocabulary.getCreatedAt()); + } +} diff --git a/server/api/src/test/java/vook/server/api/web/vocabulary/usecase/UpdateVocabularyUseCaseTest.java b/server/api/src/test/java/vook/server/api/web/vocabulary/usecase/UpdateVocabularyUseCaseTest.java new file mode 100644 index 00000000..8330b37f --- /dev/null +++ b/server/api/src/test/java/vook/server/api/web/vocabulary/usecase/UpdateVocabularyUseCaseTest.java @@ -0,0 +1,80 @@ +package vook.server.api.web.vocabulary.usecase; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; +import vook.server.api.domain.user.model.user.User; +import vook.server.api.domain.vocabulary.exception.VocabularyNotFoundException; +import vook.server.api.domain.vocabulary.model.vocabulary.Vocabulary; +import vook.server.api.domain.vocabulary.model.vocabulary.VocabularyRepository; +import vook.server.api.policy.VocabularyPolicy; +import vook.server.api.testhelper.IntegrationTestBase; +import vook.server.api.testhelper.creator.TestUserCreator; +import vook.server.api.testhelper.creator.TestVocabularyCreator; +import vook.server.api.web.common.auth.data.VookLoginUser; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@Transactional +class UpdateVocabularyUseCaseTest extends IntegrationTestBase { + + @Autowired + UpdateVocabularyUseCase useCase; + + @Autowired + TestUserCreator testUserCreator; + @Autowired + TestVocabularyCreator testVocabularyCreator; + @Autowired + VocabularyRepository vocabularyRepository; + + @Test + @DisplayName("용어집 수정 - 정상") + void updateVocabulary() { + // given + User user = testUserCreator.createCompletedOnboardingUser(); + VookLoginUser vookLoginUser = VookLoginUser.of(user.getUid()); + Vocabulary vocabulary = testVocabularyCreator.createVocabulary(user); + + // when + var command = new UpdateVocabularyUseCase.Command(vookLoginUser.getUid(), vocabulary.getUid(), "updatedName"); + useCase.execute(command); + + // then + Vocabulary updatedVocabulary = vocabularyRepository.findByUid(vocabulary.getUid()).orElseThrow(); + assertThat(updatedVocabulary.getName()).isEqualTo("updatedName"); + } + + @Test + @DisplayName("용어집 수정 - 실패; 해당 용어집이 존재하지 않는 경우") + void updateVocabularyError1() { + // given + User user = testUserCreator.createCompletedOnboardingUser(); + VookLoginUser vookLoginUser = VookLoginUser.of(user.getUid()); + testVocabularyCreator.createVocabulary(user); + + // when + assertThatThrownBy(() -> { + var command = new UpdateVocabularyUseCase.Command(vookLoginUser.getUid(), "nonExistentUid", "updatedName"); + useCase.execute(command); + }).isInstanceOf(VocabularyNotFoundException.class); + } + + @Test + @DisplayName("용어집 수정 - 실패; 해당 용어집이 다른 사용자의 것인 경우") + void updateVocabularyError2() { + // given + User user = testUserCreator.createCompletedOnboardingUser(); + User otherUser = testUserCreator.createCompletedOnboardingUser(); + VookLoginUser vookLoginUser = VookLoginUser.of(user.getUid()); + Vocabulary vocabulary = testVocabularyCreator.createVocabulary(otherUser); + + // when + assertThatThrownBy(() -> { + var command = new UpdateVocabularyUseCase.Command(vookLoginUser.getUid(), vocabulary.getUid(), "updatedName"); + useCase.execute(command); + }).isInstanceOf(VocabularyPolicy.NotValidVocabularyOwnerException.class); + } +} diff --git a/server/api/src/test/java/vook/server/learningtest/StringTest.java b/server/api/src/test/java/vook/server/learningtest/StringTest.java new file mode 100644 index 00000000..fa4255a8 --- /dev/null +++ b/server/api/src/test/java/vook/server/learningtest/StringTest.java @@ -0,0 +1,22 @@ +package vook.server.learningtest; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class StringTest { + + @Test + @DisplayName("문자열에 startWith를 빈 문자열을 넣을 경우 무조건 true를 반환한다.") + void startWithEmptyString() { + // given + String str = "hello"; + + // when + boolean result = str.startsWith(""); + + // then + assertThat(result).isTrue(); + } +} diff --git a/server/api/src/test/resources/application.yml b/server/api/src/test/resources/application.yml new file mode 100644 index 00000000..08cfaae6 --- /dev/null +++ b/server/api/src/test/resources/application.yml @@ -0,0 +1,41 @@ +spring: + jpa: + hibernate: + ddl-auto: validate + show-sql: true + properties: + hibernate: + format_sql: true + defer-datasource-initialization: true + sql: + init: + mode: always + security: + oauth2: + client: + registration: + google: + client-name: google + client-id: 888756350502-376ig1il0g9mdb8vbs1nj8u291lh4hl3.apps.googleusercontent.com + client-secret: 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 + redirect-uri: http://localhost:8080/login/oauth2/code/google + authorization-grant-type: authorization_code + scope: + - profile + - email + profiles: + include: modulith +logging: + level: + org: + hibernate: + orm: + jdbc: TRACE +service: + jwt: + secret: vmfhaltmskdlstkfkdgodyroqkfwkdba + oauth2: + tokenNoticeUrl: http://localhost:3000/auth/token + loginFailUrl: http://localhost:3000/auth?error + accessTokenExpiredMinute: 30 # 30 minutes + refreshTokenExpiredMinute: 10080 # 60 * 24 * 7 == 1 week diff --git a/server/api/src/test/resources/db/conf/my.cnf b/server/api/src/test/resources/db/conf/my.cnf new file mode 100644 index 00000000..c6314654 --- /dev/null +++ b/server/api/src/test/resources/db/conf/my.cnf @@ -0,0 +1,11 @@ +[client] +default-character-set = utf8mb4 + +[mysql] +default-character-set = utf8mb4 + +[mysqld] +character-set-client-handshake = FALSE +character-set-server = utf8mb4 +collation-server = utf8mb4_unicode_ci +lower_case_table_names = 1 diff --git a/server/api/src/test/resources/init/demo.tsv b/server/api/src/test/resources/init/demo.tsv new file mode 100644 index 00000000..56ed03b0 --- /dev/null +++ b/server/api/src/test/resources/init/demo.tsv @@ -0,0 +1,76 @@ +term synonyms meaning +SDK Software Development Kit 특정 플랫폼이나 운영체재를 위한 앱을 만드는데 필요한 도구와 코드 모음 SDK는 앱 개발을 쉽고 빠르게 만드는 도구로 개발자가 처음부터 모든 것을 스스로 구축할 필요가 없어 시간과 노력을 절약할 수 있습니다. 예) 개발자가 지도를 앱에 추가하고 싶다면 구글 지도 SDK를 사용할 수 있습니다. 이 SDK에는 지도 표시, 사용자 위치 추적, 경로 검색 등의 기능을 위한 코드와 도구가 포함되어 있습니다. +API Application Programming Interface 프로그램이 서로 통신하고 협력할 수 있는 필수적인 연결 도구 API는 크게 데이터 공유, 기능 활용, 시스템 통합의 역할을 수행합니다. 데이터 공유 : 다른 프로그램의 데이터를 제공합니다. 가령, 날씨 앱은 API로 기상청의 날씨 데이터를 가져올 수 있습니다. 기능 활용 : 다른 프로그램의 기능을 활용할 수 있도록 합니다. 가령, 음악 앱은 API로 스포티파이 등의 음악 스트리밍 서비스의 음악을 재생할 수 있습니다. 시스템 통합 : 서로 다른 시스템을 연결해 함께 작동하도록 합니다. 가령, 온라인 쇼핑몰은 API로 주문 처리, 결제 처리 및 배송 추적을 위한 다른 시스템과 통합할 수 있습니다. +IDE Integrated Development Environment 통합 개발 환경의 약자로 개발자가 소프트웨어를 빠르고 쉽게 개발하도록 돕는 도구 IDE는 코딩, 컴파일, 디버깅 및 테스트 등 소프트웨어 개발 프로세스의 모든 단계를 위한 다양한 기능을 제공합니다. +In-app browser 인앱 브라우저 모바일 앱 혹은 데스크톱 앱 내 내장된 웹 브라우저 별도의 웹 브라우저 앱을 실행하지 않아도 앱에서 웹페이지를 볼 수 있도록 합니다. +External browser 익스터널 브라우저 익스터널 브라우저는 별도의 앱으로 설치되는 웹 브라우저입니다. 대표적으로 크롬, 사파리, 파이어폭스, 엣지 등이 있습니다. +CDC Change Data Capture 데이터베이스에 변경된 데이터를 실시간으로 추적하고 캡처하는 기술 데이터베이스에 삽입, 수정, 삭제 등의 변경 작업이 발생할 때마다 해당 변경사항을 기록하고 다른 시스템이나 프로세스에 알려줍니다. CDC는 데이터베이스의 로그 파일을 모니터링하거나 트리거를 사용해 데이터 변경사항을 감지합니다. 변경사항이 감지되면 CDC는 해당 변경 사항을 메시지 형식으로 포장하고 이를 큐에 저장합니다. +Streaming 스트리밍 미디어 파일을 인터넷을 통해 실시간으로 전송하고 재생하는 기술 이전에는 음악이나 영상을 다운로드한 후에 재생할 수 있었다면 스트리밍 기술로 파일을 다운로드하지 않고도 바로 듣거나 재생할 수 있게 되었습니다. 스트리밍에는 라이브 스트리밍과 온디맨드 스트리밍으로 나눌 수 있습니다. 라이브 스트리밍은 실시간으로 방송하고 시청하는 방식이며, 온디맨드 스트리밍은 이미 녹화된 음악, 동영상, 게임 등을 선택해 시청하는 방식입니다. +컴파일러 Compiler 개발자가 작성한 소스 코드를 컴퓨터가 이해하고 실행할 수 있는 기계어로 변환하는 소프트웨어 도구 컴파일러는 대표적으로 개발자의 소스 코드를 읽고 분석하며 소스 코드의 문법적 오류를 확인하고 오류 메시지를 출력합니다. 또한 생성된 기계어를 실행 가능한 파일 형식으로 저장합니다. +클라이언트 Client 클라이언트는 사용자 컴퓨터에 설치된 소프트웨어로 서버에 요청을 보내고 서버로부터 응답을 받습니다. 웹 브라우저, 이메일 클라이언트, 파일 공유 프로그램 등이 클라이언트에 속합니다. +서버 Server 서버는 네트워크에 연결된 컴퓨터로 클라이언트의 요청을 받아 처리하고 결과를 응답으로 보냅니다. 웹 서버, 데이터 베이스 서버, 메일 서버 등이 서버에 속합니다. +프론트엔드 Front-end 사용자가 직접 보고 상호 작용하는 웹페이지의 눈에 보이는 부분을 담당합니다. 웹 브라우저에서 보이는 모든 요소 (텍스트, 이미지, 영상, 버튼 등)와 사용자의 상호 작용(클릭, 스크롤, 입력)이 프론트엔드에 속합니다. +백엔드 Back-end 사용자가 직접 볼 수 없는 웹페이지의 서버를 담당합니다. 데이터베이스 관리, 서버 로직 처리, 사용자 인증, 보안 등 웹페이지의 핵심 기능을 구현합니다. +디버그 Debug 프로그램에서 발생하는 오류나 문제를 찾아 해결하는 과정 디버깅은 프로그램의 품질을 높이고 사용자에게 안정적인 경험을 제공합니다. 디버깅 과정에서 발생하는 오류를 정확히 재현해보고 코드를 분석해 원인을 파악합니다. 오류의 원인을 파악한 후 코드를 수정하여 오류를 해결하고 테스트를 통해 오류가 해결되었는지 확인합니다. 해결 후에는 디버깅 과정에서 발생한 오류, 오류 원인, 해결 방법 등을 문서화합니다. +빌드 Build 소스 코드를 실행 가능한 파일로 변환하는 과정 먼저 컴파일러로 소스 코드를 기계어로 변환합니다. 컴파일된 객체 파일을 연결하고 의존하는 라이브러리를 추가해 실행 가능한 파일을 만듭니다. 실행 가능한 파일과 기타 필요한 파일을 하나의 패키지로 묶습니다. 컴파일은 소스 코드를 기계어로 변환하는 과정이며 빌드 과정의 한 단계인 반면, 빌드는 소스 코드를 실행 가능한 파일로 변환하는 전체적인 과정을 의미합니다. +배포 Deployment 완성된 코드를 실제 사용 환경에 설치하고 실행 가능한 형태로 만드는 과정 배포 방법에는 수동 배포, 자동 배포, 컨테이너 배포 등이 있습니다. 수동 배포는 개발자가 직접 서버에 연결해 배포 작업을 수행하는 방식입니다. 자동 배포는 CI/CD 도구를 사용해 배포 과정을 자동화 하는 방식입니다. 컨테이너 배포는 Docker와 같은 컨테이너 기술을 사용해 배포하는 방식입니다. +반응형 웹 Responsive Website 웹사이트가 다양한 기기의 화면 크기와 해상도에 맞게 자동으로 디자인 및 레이아웃을 조정하도록 돕는 웹 디자인 접근 방식입니다. +SSO Single Sign On 사용자가 여러 시스템이나 애플리케이션에 한 번의 로그인 만으로 액세스할 수 있도록 하는 인증 방식 사용자는 시스템마다 별도 계정을 만들고 로그인 할 필요 없이 하나의 아이디와 비밀번호만 사용해 여러 시스템에 로그인할 수 있습니다. SSO는 다음과 같은 단계를 거쳐 작동합니다. 1. 사용자는 하나의 시스템에 로그인합니다. 2. 사용자 인증 정보는 중앙 인증 서버에 전송됩니다. 3. 중앙 인증 서버는 사용자 인증 정보를 검사하고 유효하면 인증 토큰을 생성합니다. 4. 인증 토큰은 사용자의 브라우저에 저장됩니다. 5. 사용자가 다른 시스템에 액세스하려고 할 때 브라우저는 저장된 인증 토큰을 자동으로 전송합니다. 6. 중앙 인증 서버는 인증 토큰을 검사하고 유효하면 사용자를 자동으로 로그인시킵니다. +SaaS Software as a Service 소프트웨어를 서비스로 제공하는 모델 SaaS 서비스 제공업체는 웹 브라우저를 통해 사용자가 액세스 할 수 있는 소프트웨어 애플리케이션을 개발, 운영 및 유지 관리합니다. 사용자는 별도의 소프트웨어를 설치하거나 하드웨어를 구매할 필요 없이 인터넷 연결만 있으면 서비스를 이용할 수 있습니다. +PasS Platform as a Service 소프트웨어 개발 및 배포를 위한 플랫폼을 서비스로 제공하는 모델 PaaS 플랫폼은 개발자들이 웹 애플리케이션을 구축, 테스트, 배포 및 관리하는 데 필요한 모든 기능과 도구를 제공합니다. 개발자는 PaaS 플랫폼을 사용하여 핵심적인 개발 작업에 집중할 수 있으며, 서버 관리, 운영 체제 관리, 네트워킹 등의 인프라 관리 작업은 PaaS 플랫폼 제공업체가 담당합니다. +IaaS Infrastructure as a Service 컴퓨팅, 스토리지, 네트워킹과 같은 기반 IT 인프라를 서비스로 제공하는 모델 IaaS 서비스 제공업체는 사용자에게 가상 머신, 서버, 스토리지, 네트워킹 장비 등을 제공하며, 사용자는 이러한 자원을 자유롭게 사용하여 원하는 시스템 및 애플리케이션을 구축할 수 있습니다. +SOAP API Simple Object Access Protocol API 웹 서비스를 위한 표준 프로토콜 XML 기반 메시지 형식을 사용하여 네트워크를 통해 분산된 응용 프로그램 간 통신을 지원합니다. SOAP API는 표준화된 웹 서비스 프로토콜이지만, 복잡하고 느리고 비효율적인 단점이 있습니다. 이러한 단점을 해결하기 위해 REST API, JSON API와 같은 다른 웹 서비스 프로토콜들이 등장했습니다. +RPC Remote Procedure Call 네트워크를 통해 원격 시스템에 있는 함수를 마치 로컬 시스템에 있는 함수처럼 호출하는 기술 개발자는 호출되는 함수가 다른 시스템에 있는지 여부를 확인하지 않고 함수를 호출할 수 있습니다. RPC는 분산 시스템에서 서로 다른 시스템 간 통신과 자원 공유를 용이하게 만들며, 클라이언트 - 서버 애플리케이션 개발에 많이 사용됩니다. +웹소캣 Websocket API 웹 클라이언트와 웹 서버 간에 지속적인 양방향 실시간 통신을 가능하게 하는 API 기존의 HTTP 기반 웹 요청과 달리 WebSocket은 한 번의 TCP 연결을 통해 클라이언트와 서버 간에 지속적으로 데이터를 주고받을 수 있으므로, 채팅, 게임, 실시간 데이터 갱신 등 실시간으로 데이터 송수신이 필요한 애플리케이션에 적합합니다. +REST API Representational State Transfer API REST 아키텍처 스타일의 디자인 원칙을 준수하는 API REST API를 RESTful API라고 불리기도 합니다. REST API는 일관되고 이해하기 쉬운 인터페이스를 제공하며, 다양한 플랫폼과 프로그래밍 언어에서 쉽게 사용할 수 있는 장점이 있습니다. REST API는 HTTP method를 사용하여 자원에 대한 작업을 수행합니다. 일반적으로 사용되는 HTTP method는 다음과 같습니다. GET: 자원을 조회합니다. POST: 자원을 생성합니다. PUT: 자원을 업데이트합니다. DELETE: 자원을 삭제합니다. +HTTP Hypertext Transfer Protocol//n하이퍼텍스트 전송 프로토콜 웹 브라우저와 웹 서버 간의 통신을 위한 기본 프로토콜 웹사이트 방문 시 사용자가 입력한 URL을 요청하고, 웹 서버는 요청에 맞는 웹 페이지나 다른 데이터를 응답으로 전송하는 데 사용됩니다. HTTP는 기본적으로 데이터를 평문으로 전송하기 때문에 도청이나 위변조 위험이 있어 최근에는 HTTPS를 사용합니다. +HTTPS Hypertext Transfer Protocol Secure HTTP에 SSL/TLS 프로토콜을 추가하여 안전하게 데이터를 전송하도록 보안 강화한 프로토콜입니다. HTTPS는 웹 브라우저와 웹 서버 간 모든 통신을 암호화해 도청이나 위변조를 방지합니다. 웹 서버의 신원을 인증해 위조 사이트로부터 사용자를 보호하며, 데이터 전송 과정에서 데이터가 변경되지 않았는지 확인합니다. +SSL Secure Sockets Layer//n보안 소켓 계층 웹 브라우저와 웹 서버 간의 통신을 암호화하여 보안하는 프로토콜 SSL은 현재 TLS(Transport Layer Security)로 이름이 변경되었지만, 여전히 SSL이라는 용어가 널리 사용되고 있습니다. +TLS 전송 계층 보안 인터넷 상의 두 시스템 간의 통신을 보호하는 보안 프로토콜 데이터 암호화, 신원 인증, 데이터 무결성 보장 기능을 제공하여 사용자의 개인정보, 로그인 정보, 금융 정보 등을 안전하게 보호합니다. 이전에 사용되었던 SSL(Secure Sockets Layer) 프로토콜의 후속 버전입니다. +프레임워크 Framework 소프트웨어 개발 과정을 돕는 도구와 라이브러리의 집합 프레임워크는 개발자가 애플리케이션의 기본 구조 및 핵심 기능을 빠르고 쉽게 구축할 수 있도록 기본적인 뼈대를 제공합니다. 대표적으로 웹에선 Django, Ruby on Rails 등이 있으며, 모바일에선 React Native, Flutter 등이 있습니다. +라이브러리 Library 라이브러리는 개발자가 특정 기능이나 작업을 수행할 수 있도록 재사용 가능한 코드 모듈을 제공합니다. 가령 NumPy, Pandas 등 데이터 처리 관련 라이브러리는 복잡한 데이터 분석 및 처리 작업을 수행하는 데 도움을 줍니다. +플러그인 Plug-in 기존 소프트웨어에 새로운 기능을 추가하거나 기존 기능을 확장하는 소프트웨어 구성 요소 일반적으로 독립적인 프로그램으로 작동하며, 호환되는 호스트 프로그램에 설치해야 합니다. 플러그인은 다양한 분야에서 사용되며, 웹 브라우저, 그래픽 편집 프로그램, 비디오 편집 프로그램, 게임 엔진 등에서 흔히 볼 수 있습니다. +파라미터 인자//nParameter 함수는 특정 작업을 수행하도록 설계된 코드 블록입니다. 함수를 호출할 때 원하는 결과를 얻도록 함수에 데이터를 전달해야 합니다. 이 데이터를 파라미터라고 부릅니다. +Argument 인자값 인자값은 함수 호출 시 실제 값을 의미하며, 파라미터에 전달됩니다. 인자값은 다양한 형태의 데이터, 숫자, 문자열, 리스트, 객체 등을 포함할 수 있습니다. 함수는 전달된 인자값을 사용하여 계산을 수행하거나 작업을 처리합니다. +AJAX Asynchronous Javascript and XML 비동기 JavaScript 및 XML의 약자로, 웹 페이지를 부분적으로 다시 로드하지 않고도 서버와 데이터를 주고받을 수 있는 웹 개발 기술 AJAX를 사용하면 사용자가 웹 페이지를 새로고침하지 않고도 데이터를 업데이트하고 새로운 콘텐츠를 로드할 수 있어 웹사이트의 사용자 경험을 크게 향상시킬 수 있습니다. 대표적으로 검색창에 입력하는 단어에 대한 자동 완성 기능, 무한 스크롤, 실시간 데이터 업데이트 등에 활용됩니다. +멀티스레드 Multi-thread 하나의 프로그램에서 동시에 여러 개의 작업을 수행하는 프로그래밍 방식입니다. 멀티스레드를 사용하면 컴퓨터의 여러 개의 CPU 코어를 효율적으로 활용하여 프로그램 성능을 향상시킬 수 있습니다. +렌더링 Rendering 렌더링은 2D 또는 3D 모델을 기반으로 이미지 또는 영상 컴퓨터 그래픽스, 영화 제작, 비디오 게임, 건축 시각화 등 다양한 분야에서 활용됩니다. 렌더링 과정은 모델 정보를 시각적 표현으로 변환하는 복잡한 계산 과정을 포함하며, 빛, 색상, 질감, 그림자 등 다양한 요소를 고려하여 현실감 넘치는 이미지 또는 영상을 생성합니다. +샌드박스 SandBox 실제 환경에 영향을 미치지 않고 새로운 소프트웨어나 기능을 테스트할 수 있는 가상의 공간입니다. 샌드박스는 개발자가 버그를 찾고 코드를 개선할 수 있는 안전한 환경을 제공합니다. +데이터 레이크 Data lake 구조화된 또는 반구조화된, 혹은 구조화되지 않은 방대한 양의 데이터를 저장하는 저장소 데이터 레이크는 기존의 데이터 웨어하우스와는 다릅니다. 데이터 웨어하우스는 일반적으로 미리 정의된 스키마를 사용하여 구조화된 데이터를 저장하는 반면, 데이터 레이크는 스키마 없이 데이터를 저장할 수 있습니다. 즉, 데이터 레이크에는 모든 유형의 데이터를 저장할 수 있으며, 데이터가 저장된 후에도 데이터 스키마를 변경할 수 있습니다. +데브옵스 DevOps 데브옵스(DevOps)는 소프트웨어 개발(Development)과 운영(Operations)을 하나의 통합된 프로세스로 연결하여 소프트웨어를 빠르게, 안정적으로, 그리고 효율적으로 제공하는 문화와 관행들의 집합 데브옵스는 개발팀과 운영팀 간의 협업을 강조하며, 자동화, 지속적인 통합 및 배포, 모니터링 등을 통해 소프트웨어 개발 및 제공 프로세스를 개선하는 데 중점을 둡니다. +CI/CD Continuous Integration/Continuous Delivery//n지속적인 통합/지속적인 배포 소프트웨어 개발 프로세스를 자동화하여 소프트웨어를 빠르고 안정적으로 제공하는 방법 CI/CD는 다음 두 단계로 구성됩니다. - 지속적인 통합(Continuous Integration, CI): 개발자가 코드를 변경할 때마다 코드를 자동으로 통합하고 테스트하는 프로세스입니다. CI는 개발 중에 발생하는 버그를 빠르게 발견하고 해결하는 데 도움이 됩니다. - 지속적인 배포(Continuous Delivery, CD): 테스트를 통과한 코드를 자동으로 배포 환경에 배포하는 프로세스입니다. CD는 소프트웨어를 빠르게 출시하고 업데이트하는 데 도움이 됩니다. +파싱 Parsing 컴퓨터 과학에서 특정 형식으로 구성된 데이터를 분석하고 해석하는 과정 파싱은 주로 텍스트 기반 데이터, 프로그래밍 언어 소스 코드, XML 문서 등을 처리하는 데 사용됩니다. 파싱 과정에서 데이터의 구조를 이해하고 의미 있는 정보를 추출합니다. +핑 Ping 컴퓨터 네트워크에서 두 장치 간의 연결성을 테스트하는 데 사용되는 도구 핑은 다음과 같은 다양한 상황에서 사용됩니다. - 네트워크 문제 진단: 네트워크 연결이 끊겨 있거나 속도가 느린 경우 핑을 사용하여 문제의 원인을 파악할 수 있습니다. - 웹 사이트 성능 테스트: 웹 사이트에 연결하는 데 걸리는 시간을 측정하는 데 핑을 사용할 수 있습니다. - 새로운 네트워크 장치 설정 확인: 새로 설치한 네트워크 장치가 올바르게 작동하는지 확인하는 데 핑을 사용할 수 있습니다. +SRE Site Reliability Engineering 소프트웨어 시스템의 안정성, 확장성, 및 성능을 유지하는 것을 담당하는 엔지니어링 분야 SRE는 전통적인 소프트웨어 개발 및 운영 팀의 역할을 결합하여 소프트웨어를 서비스로 제공하는 데 필요한 모든 측면을 관리합니다. +SSH Secure Shell Protocol 네트워크를 통해 두 컴퓨터 간의 안전한 연결을 제공하는 프로토콜 SSH는 다음과 같은 다양한 목적으로 사용됩니다. - 원격 컴퓨터 관리: 시스템 관리자는 SSH를 사용하여 원격 컴퓨터에 로그인하고 관리 작업을 수행할 수 있습니다. - 파일 전송: 사용자는 SSH를 사용하여 두 컴퓨터 간에 파일을 안전하게 전송할 수 있습니다. - 애플리케이션 실행: 사용자는 SSH를 사용하여 원격 컴퓨터에서 애플리케이션을 실행할 수 있습니다. - 포트 포워딩: 사용자는 SSH를 사용하여 한 컴퓨터의 포트를 다른 컴퓨터의 포트로 전달할 수 있습니다. +성능 테스트 BMT//nBench Marking Test 한 가지 시스템이나 제품의 성능을 객관적으로 측정하고 비교하기 위한 테스트 BMT는 일반적으로 동일한 조건에서 서로 다른 시스템 또는 제품을 테스트하여 어떤 시스템 또는 제품이 더 우수한 성능을 제공하는지를 확인하는 데 사용됩니다. +gRPC Google RPC//nGoogle Remote Procedure Call RPC(Remote Procedure Call)는 네트워크를 통해 원격 시스템에 있는 프로시저를 호출하는 기술입니다. RPC는 마치 로컬 시스템에서 함수를 호출하는 것처럼 원격 시스템의 프로시저를 호출할 수 있도록 해줍니다. gRPC는 Google에서 개발한 오픈 소스 RPC 프레임워크입니다. +NAS Network Attached Storage 네트워크를 통해 여러 사용자가 파일 저장 및 공유를 할 수 있도록 하는 장치 NAS는 다양한 용도로 사용됩니다. 개인 용도로는 사진, 음악, 비디오 등의 개인 파일을 저장하고 공유하는 데 사용할 수 있습니다. 사업 용도로는 문서, 스프레드시트, 프레젠테이션과 같은 업무 파일을 저장하고 공유하는 데 사용할 수 있습니다. 또한 NAS는 데이터 백업, 파일 스트리밍, 미디어 서버 등으로도 사용할 수 있습니다. +CDN Contents Delivery Network 웹 콘텐츠를 사용자에게 더 빠르고 안정적으로 제공하기 위해 지리적으로 분산된 네트워크를 사용하는 기술 웹사이트, 이미지, 동영상, 스트리밍 미디어 등 다양한 콘텐츠를 빠르게 전송하는 데 효과적으로 사용됩니다. +DNS Domain Name System 인터넷 주소록이라고도 불리는 시스템으로, 도메인 이름(예: www.example.com)을 IP 주소(예: 192.0.2.44)로 변환하는 역할을 합니다. 쉽게 말해, 사람이 쉽게 기억할 수 있는 도메인 이름을 컴퓨터가 이해할 수 있는 IP 주소로 변환하는 시스템이라고 볼 수 있습니다. +엔드포인트 Endpoint 네트워크에서 데이터를 주고받을 수 있는 논리적 또는 물리적 위치 간단히 말해서, 엔드포인트는 네트워크 상에서 연결하고 상호 작용할 수 있는 문과 같습니다. 각 엔드포인트는 고유한 IP 주소 또는 식별자를 가지고 있으며, 네트워크를 통해 다른 엔드포인트와 통신할 수 있습니다. +Request 요청 엔드포인트가 다른 엔드포인트에게 데이터나 작업을 수행하도록 요청하는 메시지 +Response 응답 요청에 대한 응답으로 엔드포인트에서 다른 엔드포인트로 전송하는 메시지 +JSON JavaSript Object Notation Javascript에서 사용하는 객체 정의 방법 +포트 Port 컴퓨터 또는 네트워크 장치에서 특정 애플리케이션이나 서비스를 식별하는 데 사용되는 논리적 번호 포트는 여러 애플리케이션이 동시에 같은 컴퓨터에서 실행될 수 있도록 하는 가상 통로와 같습니다. 각 포트는 고유한 번호를 가지고 있으며, 0에서 65535까지의 범위를 사용할 수 있습니다. +프로토콜 Protocol 두 시스템 간의 통신 방식을 정의하는 규칙 집합 프로토콜은 두 시스템이 서로 어떻게 대화해야 하는지에 대한 약속과 같습니다. 프로토콜에선 전송되는 데이터 형식과 데이터 전송 방식 그리고 오류 처리에 대해 정의합니다. +로드 밸런싱 Load Balancing 네트워크 트래픽을 여러 서버에 분산하여 처리하는 기술 로드 밸런싱을 통해 서버 부하를 줄이고, 성능을 향상시키며, 사용자 가용성을 높일 수 있습니다. 가령 온라인 쇼핑몰의 경우 높은 트래픽을 처리하고 고객에게 빠른 응답 속도를 제고하기 위해 사용합니다. 온라인 게임의 경우 많은 플레이어를 동시에 처리하고 게임 지연을 줄이기 위해 사용합니다. +가상화 Virtualization 하나의 컴퓨터를 여러 개의 가상 컴퓨터처럼 사용할 수 있도록 하는 기술 +데이터베이스 Database 구조화된 형태로 데이터를 저장하고 관리하는 시스템 데이터를 체계적으로 정리하고, 쉽게 찾고 사용할 수 있도록 하는 도구라고 생각하면 됩니다. +테이블 Table 테이블은 행과 열로 구성된 데이터 구조로, 데이터베이스에서 데이터를 저장하는 기본 단위 각 행은 하나의 데이터 레코드를 나타내고, 각 열은 레코드의 특정 속성을 나타냅니다. 테이블은 마치 스프레드시트와 유사한 형태로 데이터를 보여주고, 행과 열을 사용하여 원하는 데이터를 쉽게 찾고 조작할 수 있도록 합니다. +기본 키 Primary Key 테이블 내의 각 레코드를 고유하게 식별하는 열 또는 열 집합 다른 열의 값이 중복될 수 있는 반면, 기본 키 값은 항상 고유하고 null 값을 허용하지 않습니다. 기본 키는 데이터 무결성을 유지하고 중복된 데이터를 방지하는 데 중요한 역할을 합니다. +외래 키 Foreign Key 한 테이블의 열을 다른 테이블의 기본 키 열과 연결하는 관계 외래키는 두 테이블 간의 데이터 관계를 정의하고 데이터 무결성을 유지하는 데 도움이 됩니다. 예를 들어, '고객' 테이블에 '주소' 테이블의 외래키 열이 있다면, '고객' 테이블의 각 레코드는 '주소' 테이블의 고유한 레코드를 참조하게 됩니다. +인덱스 Index 테이블의 특정 열에 대한 검색 속도를 향상시키는 데이터 구조 책의 색인과 유사하게, 인덱스는 데이터베이스 시스템이 테이블 내의 특정 레코드를 빠르게 찾도록 도와줍니다. 자주 검색되는 열에 인덱스를 생성하면 데이터 검색 속도를 크게 향상시킬 수 있으며, 데이터베이스 성능을 개선하는 데 효과적인 방법입니다. +SQL Structured Query Language 관계형 데이터베이스에서 데이터를 조작하는 표준 언어 데이터베이스에 데이터를 저장하고, 검색하고, 삭제하고, 수정하는 데 사용하는 데이터베이스 프로그래밍 언어라고 생각하면 됩니다. +정규화 Normalization 데이터베이스의 데이터 구조를 체계적으로 조직하여 데이터 중복을 최소화하고, 데이터 무결성을 유지하며, 데이터베이스의 효율성을 높이는 프로세스 데이터베이스 정규화는 데이터 중복을 최소화하고 데이터 무결성을 유지하는 목적으로 사용됩니다. 또한 데이터베이스 구조를 최적화해 데이터 검색 및 조작 속도를 높이고 데이터베이스 관리 작업을 용이하게 합니다. +트랜잭션 Transaction 데이터베이스와 같은 시스템의 상태를 일관되게 변경하는 작업 단위 트랜잭션은 ACID 특성을 만족하도록 설계되어 데이터 무결성을 유지합니다. ACID는 트랜잭션의 핵심 특성을 나타내는 약자입니다. - 원자성 (Atomicity): 트랜잭션은 하나의 작업 단위로 실행되며, 트랜잭션이 완료되거나 실패할 때까지 부분적인 작업이 수행되지 않습니다. - 일관성 (Consistency): 트랜잭션이 완료되면 데이터베이스는 항상 일관된 상태를 유지합니다. 트랜잭션 중간에 데이터베이스 상태가 변경되지 않습니다. - 격리성 (Isolation): 동시에 실행되는 여러 트랜잭션은 서로 영향을 미치지 않습니다. 각 트랜잭션은 서로 독립적으로 실행되는 것처럼 작동합니다. - 지속성 (Durability): 트랜잭션이 성공적으로 완료되면, 그 결과는 영구적으로 저장됩니다. 시스템 장애나 데이터 손실에도 영향을 받지 않습니다. +컨벤션 Convention 일관되고 읽기 쉬운 코드 베이스 유지를 위해 개발자가 따르는 합의된 규칙 +클래스 Class 객체의 청사진 역할을 하는 설계 요소 붕어빵 틀 자체를 클래스라고 생각하면 됩니다. 붕어빵 틀은 붕어빵의 속성(팥, 깨, 모양 등)과 행동(먹는 것)을 정의하고 있으며, 이 틀을 사용하여 여러 개의 붕어빵(객체)를 만들 수 있습니다. +객체 Object 데이터와 그 데이터를 처리하는 메서드를 묶어 놓은 기본 단위 붕어빵 틀을 이용하여 만든 붕어빵 하나하나를 객체라고 생각하면 됩니다. 각 붕어빵은 틀에서 정의된 속성(팥, 깨, 모양 등)과 행동(먹는 것)을 가지고 있습니다. +객체 지향 프로그래밍 OOP 객체라는 기본 단위를 사용하여 프로그램을 설계하고 작성하는 프로그래밍 패러다임 객체는 데이터(속성)와 행동(메서드)을 가지고 있으며, 서로 상호작용하여 프로그램을 구성합니다. +CSS Cascading Style Sheets HTML 문서의 스타일을 정의하는 데 사용되는 스타일 시트 언어 HTML 문서가 웹 페이지의 구조를 담당한다면, CSS는 웹 페이지의 디자인을 담당합니다. +레거시 Regacy 오래된 기술로 개발되었지만, 여전히 사용되고 있는 컴퓨터 시스템, 소프트웨어, 하드웨어를 의미합니다. +트러블 슈팅 Trouble Shooting 시스템이나 장치에서 발생하는 문제를 진단하고 해결하는 과정 트러블 슈팅의 경우 문제의 근본 원인을 파악하고 해결 방안을 모색하는 데 초점을 맞춥니다. 문제의 증상만 해결하는 것이 아니라, 발생 원인을 제거하여 동일한 문제가 다시 발생하지 않도록 방지하는 것을 목표로 합니다. 반면 디버깅 코드는 시스템에서 오류를 찾고 수정하는 데 초점을 맞춥니다. 코드 실행 과정을 단계별로 검사하고, 오류가 발생하는 부분을 정확히 식별하여 코드를 수정하거나 버그를 제거하는 것을 목표로 합니다. +네이티브 앱 Native App 특정 모바일 운영 체제(예: iOS, Android)를 위해 자체 프로그래밍 언어(예: Swift, Java)로 개발됩니다. 일반적으로 하이브리드 앱보다 빠르고 응답성이 뛰어납니다. 네이티브 앱은 운영 체제와 직접 통합되기 때문에 하드웨어 가속 및 기타 최적화 기능을 활용할 수 있습니다. +하이브리드 앱 Hibrid App 웹 기술(예: HTML, CSS, JavaScript)을 사용하여 빌드되고, 기본적으로 웹 뷰 앱으로 작동하며, 운영 체제별 래퍼를 통해 각 플랫폼에 맞게 패키징됩니다. 웹 기술 기반으로 개발되었기 때문에 플랫폼별 업데이트나 버그 수정 없이도 유지 관리가 비교적 용이합니다. +스플래시 Splash 컴퓨터 프로그램, 웹사이트 또는 모바일 앱을 시작할 때 잠깐 나타나는 로고나 이미지 로딩 화면이라고도 불리며, 프로그램이나 앱이 로드되고 사용자 인터페이스가 준비될 때까지 대기 시간을 채우는 역할을 합니다. diff --git a/server/api/src/test/resources/migrate/sql b/server/api/src/test/resources/migrate/sql new file mode 160000 index 00000000..4704fdcc --- /dev/null +++ b/server/api/src/test/resources/migrate/sql @@ -0,0 +1 @@ +Subproject commit 4704fdcc9389d9a790e692b7a82d579e3aa4d243 diff --git a/server/api/src/test/resources/tsvreader/test.tsv b/server/api/src/test/resources/tsvreader/test.tsv new file mode 100644 index 00000000..c425b401 --- /dev/null +++ b/server/api/src/test/resources/tsvreader/test.tsv @@ -0,0 +1,3 @@ +term synonyms description +Java 자바 자바는 객체지향 프로그래밍 언어이다. +Spring 스프링 스프링은 자바 기반의 프레임워크이다. diff --git a/server/build.gradle b/server/build.gradle new file mode 100644 index 00000000..63aa5e58 --- /dev/null +++ b/server/build.gradle @@ -0,0 +1,41 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.3.1' + id 'io.spring.dependency-management' version '1.1.5' +} + +subprojects { + apply plugin: 'java' + apply plugin: 'org.springframework.boot' + apply plugin: 'io.spring.dependency-management' +} + +allprojects { + group = 'vook' + version = '0.0.1-SNAPSHOT' + + java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } + } + + configurations { + compileOnly { + extendsFrom annotationProcessor + } + } + + repositories { + mavenCentral() + } + + bootJar { + destinationDirectory = file(rootDir.absolutePath + '/jar') + archiveFileName = "${project.name}.jar" + } + + tasks.named('test') { + useJUnitPlatform() + } +} diff --git a/server/gradle/wrapper/gradle-wrapper.jar b/server/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..e6441136 Binary files /dev/null and b/server/gradle/wrapper/gradle-wrapper.jar differ diff --git a/server/gradle/wrapper/gradle-wrapper.properties b/server/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..b82aa23a --- /dev/null +++ b/server/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/server/gradlew b/server/gradlew new file mode 100755 index 00000000..1aa94a42 --- /dev/null +++ b/server/gradlew @@ -0,0 +1,249 @@ +#!/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. +# + +############################################################################## +# +# 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/subprojects/plugins/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 "${APP_HOME:-./}" > /dev/null && pwd -P ) || 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/server/gradlew.bat b/server/gradlew.bat new file mode 100644 index 00000000..25da30db --- /dev/null +++ b/server/gradlew.bat @@ -0,0 +1,92 @@ +@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 + +@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/server/settings.gradle b/server/settings.gradle new file mode 100644 index 00000000..d62455c6 --- /dev/null +++ b/server/settings.gradle @@ -0,0 +1,3 @@ +rootProject.name = 'server' + +include 'api'