diff --git a/.idea/workspace.xml b/.idea/workspace.xml
new file mode 100644
index 000000000..f20050a26
--- /dev/null
+++ b/.idea/workspace.xml
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1623055909457
+
+
+ 1623055909457
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/BE/.gitignore b/BE/.gitignore
new file mode 100644
index 000000000..c2065bc26
--- /dev/null
+++ b/BE/.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/BE/build.gradle b/BE/build.gradle
new file mode 100644
index 000000000..eca3f5795
--- /dev/null
+++ b/BE/build.gradle
@@ -0,0 +1,64 @@
+plugins {
+ id 'org.springframework.boot' version '2.5.0'
+ id 'io.spring.dependency-management' version '1.0.11.RELEASE'
+ id 'java'
+}
+
+group = 'com.codesquad'
+version = '0.0.1-SNAPSHOT'
+sourceCompatibility = '1.8'
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+
+ // spring boot basic
+ implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+ developmentOnly 'org.springframework.boot:spring-boot-devtools'
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+
+ // data source
+ runtimeOnly 'mysql:mysql-connector-java'
+
+ // jjwt
+ implementation 'io.jsonwebtoken:jjwt-api:0.10.8'
+ runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.10.8'
+
+ // jasypt
+ implementation 'com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.0'
+
+ // lombok
+ compileOnly 'org.projectlombok:lombok:1.18.20'
+ annotationProcessor 'org.projectlombok:lombok:1.18.20'
+
+ // validation
+ implementation 'org.springframework.boot:spring-boot-starter-validation'
+
+ // aws
+ implementation platform('com.amazonaws:aws-java-sdk-bom:1.12.1')
+ implementation 'com.amazonaws:aws-java-sdk-s3'
+
+ // querydsl
+ implementation 'com.querydsl:querydsl-core'
+ implementation 'com.querydsl:querydsl-jpa'
+
+ annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jpa" // querydsl JPAAnnotationProcessor 사용 지정
+ annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
+ annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
+
+ // h2
+ runtimeOnly 'com.h2database:h2'
+
+}
+
+clean {
+ delete file('src/main/generated') // 인텔리제이 Annotation processor 생성물 생성위치
+}
+
+test {
+ useJUnitPlatform()
+}
+
diff --git a/BE/build.sh b/BE/build.sh
new file mode 100644
index 000000000..8d9f05d5d
--- /dev/null
+++ b/BE/build.sh
@@ -0,0 +1,4 @@
+#!/bin/bash
+./gradlew build jar -x test
+aws s3 cp build/libs/issue-tracker-0.0.1-SNAPSHOT.jar s3://codesquad-issue-tracker-team7/issue-tracker.jar
+echo "Done!"
diff --git a/BE/gradle/wrapper/gradle-wrapper.jar b/BE/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 000000000..e708b1c02
Binary files /dev/null and b/BE/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/BE/gradle/wrapper/gradle-wrapper.properties b/BE/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 000000000..0f80bbf51
--- /dev/null
+++ b/BE/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/BE/gradlew b/BE/gradlew
new file mode 100644
index 000000000..4f906e0c8
--- /dev/null
+++ b/BE/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or 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 UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# 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"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# 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
+ ;;
+ 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"
+ which java >/dev/null 2>&1 || 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
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/BE/gradlew.bat b/BE/gradlew.bat
new file mode 100644
index 000000000..107acd32c
--- /dev/null
+++ b/BE/gradlew.bat
@@ -0,0 +1,89 @@
+@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=.
+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%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+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%"=="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!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/BE/settings.gradle b/BE/settings.gradle
new file mode 100644
index 000000000..2c9d98170
--- /dev/null
+++ b/BE/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = 'issue-tracker'
diff --git a/BE/src/main/java/com/codesquad/issuetracker/IssueTrackerApplication.java b/BE/src/main/java/com/codesquad/issuetracker/IssueTrackerApplication.java
new file mode 100644
index 000000000..e6ece4dfb
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/IssueTrackerApplication.java
@@ -0,0 +1,15 @@
+package com.codesquad.issuetracker;
+
+import com.ulisesbocchio.jasyptspringboot.annotation.EnableEncryptableProperties;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+@EnableEncryptableProperties
+public class IssueTrackerApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(IssueTrackerApplication.class, args);
+ }
+
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/auth/AuthorizationExtractor.java b/BE/src/main/java/com/codesquad/issuetracker/auth/AuthorizationExtractor.java
new file mode 100644
index 000000000..2e8debf63
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/auth/AuthorizationExtractor.java
@@ -0,0 +1,26 @@
+package com.codesquad.issuetracker.auth;
+
+import org.springframework.stereotype.Component;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.Enumeration;
+import java.util.Optional;
+
+@Component
+public class AuthorizationExtractor {
+ public static final String AUTHORIZATION = "Authorization";
+
+ // HTTP 요청으로부터 JWT토큰을 추출함
+ public Optional extract(HttpServletRequest request, String type) {
+ Enumeration headers = request.getHeaders(AUTHORIZATION);
+ while (headers.hasMoreElements()) {
+ String value = headers.nextElement();
+ String valueType = value.split(" ")[0];
+
+ if (valueType.equals(type)) {
+ return Optional.of(value.substring(type.length()).trim());
+ }
+ }
+ return Optional.empty();
+ }
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/auth/BearerAuthInterceptor.java b/BE/src/main/java/com/codesquad/issuetracker/auth/BearerAuthInterceptor.java
new file mode 100644
index 000000000..5eea1f612
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/auth/BearerAuthInterceptor.java
@@ -0,0 +1,53 @@
+package com.codesquad.issuetracker.auth;
+
+import com.codesquad.issuetracker.domain.user.User;
+import com.codesquad.issuetracker.auth.exception.TokenEmptyException;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+@Component
+@Slf4j
+public class BearerAuthInterceptor implements HandlerInterceptor {
+
+ private final String TOKEN_TYPE = "Bearer";
+ private final String USER = "user";
+
+ private AuthorizationExtractor authorizationExtractor;
+ private JwtProvider jwtProvider;
+
+ public BearerAuthInterceptor(AuthorizationExtractor authorizationExtractor, JwtProvider jwtProvider) {
+ this.authorizationExtractor = authorizationExtractor;
+ this.jwtProvider = jwtProvider;
+ }
+
+ @Override
+ public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
+ request.setAttribute(USER, getUserFromJWT(request));
+ return true;
+ }
+
+ private String validateAndReturnJWT(HttpServletRequest request) {
+ String jwtToken = authorizationExtractor
+ .extract(request, TOKEN_TYPE)
+ .orElseThrow(TokenEmptyException::new);
+
+ if (!jwtProvider.validateToken(jwtToken)) {
+ throw new IllegalArgumentException("유효하지 않은 토큰입니다");
+ }
+ return jwtToken;
+ }
+
+ private User getUserFromJWT(HttpServletRequest request) {
+ final String jwtToken = validateAndReturnJWT(request);
+ final User user = jwtProvider.getUser(jwtToken);
+
+ log.debug("User info from JWT : {}", user);
+
+ return user;
+ }
+
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/auth/JwtProvider.java b/BE/src/main/java/com/codesquad/issuetracker/auth/JwtProvider.java
new file mode 100644
index 000000000..1a535621b
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/auth/JwtProvider.java
@@ -0,0 +1,73 @@
+package com.codesquad.issuetracker.auth;
+
+import com.codesquad.issuetracker.domain.user.User;
+import com.codesquad.issuetracker.auth.serialize.JacksonDeserializer;
+import com.codesquad.issuetracker.auth.serialize.JacksonSerializer;
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.SignatureAlgorithm;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.util.Base64;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+@Component
+@Slf4j
+public class JwtProvider {
+
+ private static final String USER_CLAIM_KEY = "user";
+
+ private final long validityInMilliseconds;
+ private final String secretKey;
+
+ public JwtProvider(@Value("${security.jwt.token.secret-key}") String secretKey,
+ @Value("${security.jwt.token.expire-length}") long validityInMilliseconds) {
+ this.secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
+ this.validityInMilliseconds = validityInMilliseconds;
+ }
+
+ public String createJwt(User user) {
+ Claims claims = Jwts.claims()
+ .setSubject(user.getLoginId());
+
+ Date now = new Date();
+ Date validity = new Date(now.getTime() + validityInMilliseconds); // 유효시간 (지금 + 유효기간)
+ log.debug("now: {}", now);
+ log.debug("validity: {}", validity);
+
+ return Jwts.builder()
+ .setClaims(claims)
+ .claim("user", user)
+ .serializeToJsonWith(JacksonSerializer.getInstance())
+ .setIssuedAt(now)
+ .setExpiration(validity)
+ .signWith(SignatureAlgorithm.HS256, secretKey)
+ .compact();
+ }
+
+ public User getUser(String token) {
+ return (User) getClaims(token).get(USER_CLAIM_KEY);
+ }
+
+ public boolean validateToken(String token) {
+ return !getClaims(token)
+ .getExpiration()
+ .before(new Date());
+ }
+
+ private Claims getClaims(String token) {
+ Map deserialziedType = new HashMap<>();
+ deserialziedType.put(USER_CLAIM_KEY, User.class);
+ return Jwts.parser()
+ .setSigningKey(secretKey)
+ .deserializeJsonWith(
+ new JacksonDeserializer(deserialziedType)
+ )
+ .parseClaimsJws(token)
+ .getBody();
+ }
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/auth/UserPlatform.java b/BE/src/main/java/com/codesquad/issuetracker/auth/UserPlatform.java
new file mode 100644
index 000000000..4b7991c17
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/auth/UserPlatform.java
@@ -0,0 +1,18 @@
+package com.codesquad.issuetracker.auth;
+
+import com.codesquad.issuetracker.auth.exception.InvalidPlatformException;
+
+public enum UserPlatform {
+ WEB,
+ IOS;
+
+ public static UserPlatform create(String platform) {
+ switch (platform.toLowerCase()) {
+ case "web" :
+ return WEB;
+ case "ios" :
+ return IOS;
+ }
+ throw new InvalidPlatformException("유효하지 않은 플랫폼입니다");
+ }
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/auth/controller/GitHubLoginController.java b/BE/src/main/java/com/codesquad/issuetracker/auth/controller/GitHubLoginController.java
new file mode 100644
index 000000000..a8dc618d8
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/auth/controller/GitHubLoginController.java
@@ -0,0 +1,37 @@
+package com.codesquad.issuetracker.auth.controller;
+
+import com.codesquad.issuetracker.auth.UserPlatform;
+import com.codesquad.issuetracker.auth.domain.JwtAuthenticationInfo;
+import com.codesquad.issuetracker.auth.service.LoginService;
+import com.codesquad.issuetracker.response.ApiResponse;
+import com.codesquad.issuetracker.auth.response.GitHubUserResponse;
+import com.codesquad.issuetracker.auth.service.GitHubOauthService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/login")
+public class GitHubLoginController {
+
+ private Logger logger = LoggerFactory.getLogger(GitHubLoginController.class);
+
+ private final GitHubOauthService gitHubOauthService;
+ private final LoginService loginService;
+
+ public GitHubLoginController(GitHubOauthService gitHubOauthService, LoginService loginService) {
+ this.gitHubOauthService = gitHubOauthService;
+ this.loginService = loginService;
+ }
+
+
+ @GetMapping("/github")
+ public ApiResponse githubLogin(@RequestParam String code, @RequestHeader("User-Platform") String platform) {
+ logger.debug("web code : {} ", code);
+ logger.debug("platform : {} ", platform);
+
+ JwtAuthenticationInfo jwtAuth = gitHubOauthService.login(code, UserPlatform.create(platform));
+ GitHubUserResponse gitHubUserResponse = loginService.signIn(jwtAuth);
+ return ApiResponse.ok(gitHubUserResponse);
+ }
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/auth/controller/LoginController.java b/BE/src/main/java/com/codesquad/issuetracker/auth/controller/LoginController.java
new file mode 100644
index 000000000..2426b4445
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/auth/controller/LoginController.java
@@ -0,0 +1,32 @@
+package com.codesquad.issuetracker.auth.controller;
+
+import com.codesquad.issuetracker.auth.UserPlatform;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+@Controller
+@RequestMapping("/login")
+public class LoginController {
+
+ private final String GITHUB_URL = "https://github.com/login/oauth/authorize";
+ private final String CLIENT_ID_WEB;
+ private final String CLIENT_ID_IOS;
+
+ public LoginController(@Value("github.client.id.web") String webClientId,
+ @Value("github.client.id.ios") String iOSClientId) {
+ CLIENT_ID_WEB = webClientId;
+ CLIENT_ID_IOS = iOSClientId;
+ }
+
+ @GetMapping
+ public String login(@RequestHeader("User-Platform") String platform) {
+ UserPlatform userPlatform = UserPlatform.create(platform);
+ if (userPlatform == UserPlatform.WEB) {
+ return "redirect:" + GITHUB_URL + "?client_id=" + CLIENT_ID_WEB;
+ }
+ return "redirect:" + GITHUB_URL + "?client_id=" + CLIENT_ID_IOS;
+ }
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/auth/domain/GitHubUser.java b/BE/src/main/java/com/codesquad/issuetracker/auth/domain/GitHubUser.java
new file mode 100644
index 000000000..42460961f
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/auth/domain/GitHubUser.java
@@ -0,0 +1,16 @@
+package com.codesquad.issuetracker.auth.domain;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import lombok.ToString;
+
+@NoArgsConstructor
+@Getter
+@Setter
+@ToString
+public class GitHubUser {
+
+ private String loginId;
+ private String name;
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/auth/domain/JwtAuthenticationInfo.java b/BE/src/main/java/com/codesquad/issuetracker/auth/domain/JwtAuthenticationInfo.java
new file mode 100644
index 000000000..61413df55
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/auth/domain/JwtAuthenticationInfo.java
@@ -0,0 +1,19 @@
+package com.codesquad.issuetracker.auth.domain;
+
+import lombok.Getter;
+
+@Getter
+public class JwtAuthenticationInfo {
+
+ private GitHubUser user;
+ private String tokenType;
+
+ private JwtAuthenticationInfo(GitHubUser user, String tokenType) {
+ this.user = user;
+ this.tokenType = tokenType;
+ }
+
+ public static JwtAuthenticationInfo create(GitHubUser user, String tokenType) {
+ return new JwtAuthenticationInfo(user, tokenType);
+ }
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/auth/exception/InvalidPlatformException.java b/BE/src/main/java/com/codesquad/issuetracker/auth/exception/InvalidPlatformException.java
new file mode 100644
index 000000000..3f7d2cbf0
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/auth/exception/InvalidPlatformException.java
@@ -0,0 +1,12 @@
+package com.codesquad.issuetracker.auth.exception;
+
+public class InvalidPlatformException extends RuntimeException {
+
+ public InvalidPlatformException(String message) {
+ super(message);
+ }
+
+ public InvalidPlatformException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/auth/exception/TokenEmptyException.java b/BE/src/main/java/com/codesquad/issuetracker/auth/exception/TokenEmptyException.java
new file mode 100644
index 000000000..e576bce28
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/auth/exception/TokenEmptyException.java
@@ -0,0 +1,10 @@
+package com.codesquad.issuetracker.auth.exception;
+
+public class TokenEmptyException extends RuntimeException {
+
+ private static final String TOKEN_EMPTY = "토큰이 존재하지 않습니다";
+
+ public TokenEmptyException() {
+ super(TOKEN_EMPTY);
+ }
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/auth/request/AccessTokenRequest.java b/BE/src/main/java/com/codesquad/issuetracker/auth/request/AccessTokenRequest.java
new file mode 100644
index 000000000..b1773d20b
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/auth/request/AccessTokenRequest.java
@@ -0,0 +1,36 @@
+package com.codesquad.issuetracker.auth.request;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public class AccessTokenRequest {
+
+ @JsonProperty("client_id")
+ private final String clientId;
+
+ @JsonProperty("client_secret")
+ private final String clientSecret;
+
+ private final String code;
+
+ public AccessTokenRequest(String clientId, String clientSecret, String code) {
+ this.clientId = clientId;
+ this.clientSecret = clientSecret;
+ this.code = code;
+ }
+
+ public static AccessTokenRequest create(String clientId, String clientSecret, String code) {
+ return new AccessTokenRequest(clientId, clientSecret, code);
+ }
+
+ public String getClientId() {
+ return clientId;
+ }
+
+ public String getClientSecret() {
+ return clientSecret;
+ }
+
+ public String getCode() {
+ return code;
+ }
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/auth/response/AccessTokenResponse.java b/BE/src/main/java/com/codesquad/issuetracker/auth/response/AccessTokenResponse.java
new file mode 100644
index 000000000..82072de77
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/auth/response/AccessTokenResponse.java
@@ -0,0 +1,41 @@
+package com.codesquad.issuetracker.auth.response;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonSetter;
+
+public class AccessTokenResponse {
+
+ @JsonProperty("access_token")
+ private String accessToken;
+
+ private String scope;
+
+ @JsonProperty("token_type")
+ private String tokenType;
+
+ public AccessTokenResponse() {}
+
+ public String getAccessToken() {
+ return accessToken;
+ }
+
+ public String getScope() {
+ return scope;
+ }
+
+ public String getTokenType() {
+ return tokenType;
+ }
+
+ public void setAccessToken(String accessToken) {
+ this.accessToken = accessToken;
+ }
+
+ public void setScope(String scope) {
+ this.scope = scope;
+ }
+
+ public void setTokenType(String tokenType) {
+ this.tokenType = tokenType;
+ }
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/auth/response/GitHubUserResponse.java b/BE/src/main/java/com/codesquad/issuetracker/auth/response/GitHubUserResponse.java
new file mode 100644
index 000000000..b3048d0ab
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/auth/response/GitHubUserResponse.java
@@ -0,0 +1,33 @@
+package com.codesquad.issuetracker.auth.response;
+
+import com.codesquad.issuetracker.domain.user.User;
+import com.codesquad.issuetracker.domain.user.response.UserResponse;
+
+public class GitHubUserResponse {
+
+ private String jwt;
+ private UserResponse user;
+ private String type;
+
+ public GitHubUserResponse(String jwt, UserResponse user, String type) {
+ this.jwt = jwt;
+ this.user = user;
+ this.type = type;
+ }
+
+ public static GitHubUserResponse create(String jwt, User user, String type) {
+ return new GitHubUserResponse(jwt, UserResponse.create(user), type);
+ }
+
+ public String getJwt() {
+ return jwt;
+ }
+
+ public UserResponse getUser() {
+ return user;
+ }
+
+ public String getType() {
+ return type;
+ }
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/auth/serialize/JacksonDeserializer.java b/BE/src/main/java/com/codesquad/issuetracker/auth/serialize/JacksonDeserializer.java
new file mode 100644
index 000000000..359e1e0f4
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/auth/serialize/JacksonDeserializer.java
@@ -0,0 +1,77 @@
+package com.codesquad.issuetracker.auth.serialize;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.deser.std.UntypedObjectDeserializer;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import io.jsonwebtoken.io.DeserializationException;
+import io.jsonwebtoken.io.Deserializer;
+import io.jsonwebtoken.lang.Assert;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Map;
+
+public class JacksonDeserializer implements Deserializer {
+
+ private final Class returnType;
+ private final ObjectMapper objectMapper;
+
+ public JacksonDeserializer() throws NoSuchFieldException, IllegalAccessException {
+ this((ObjectMapper) JacksonSerializer.class
+ .getDeclaredField("DEFAULT_OBJECT_MAPPER").get(null));
+ }
+
+ public JacksonDeserializer(ObjectMapper objectMapper) {
+ this(objectMapper, (Class) Object.class);
+ }
+
+ private JacksonDeserializer(ObjectMapper objectMapper, Class returnType) {
+ this.objectMapper = objectMapper;
+ this.returnType = returnType;
+ }
+
+ public JacksonDeserializer(Map claimTypeMap) {
+ this(new ObjectMapper());
+ Assert.notNull(claimTypeMap, "Claim type map cannot be null.");
+ // register a new Deserializer
+ SimpleModule module = new SimpleModule();
+ module.addDeserializer(Object.class, new MappedTypeDeserializer(Collections.unmodifiableMap(claimTypeMap)));
+ objectMapper.registerModule(module);
+ }
+
+ @Override
+ public T deserialize(byte[] bytes) throws DeserializationException {
+ try {
+ return readValue(bytes);
+ } catch (IOException e) {
+ String msg = "Unable to deserialize bytes into a " + returnType.getName() + " instance: " + e.getMessage();
+ throw new DeserializationException(msg, e);
+ }
+ }
+
+ protected T readValue(byte[] bytes) throws IOException {
+ return objectMapper.readValue(bytes, returnType);
+ }
+
+ private static class MappedTypeDeserializer extends UntypedObjectDeserializer {
+
+ private final Map claimTypeMap;
+
+ private MappedTypeDeserializer(Map claimTypeMap) {
+ super(null, null);
+ this.claimTypeMap = claimTypeMap;
+ }
+
+ @Override
+ public Object deserialize(JsonParser parser, DeserializationContext context) throws IOException {
+ String name = parser.currentName();
+ if (claimTypeMap != null && name != null && claimTypeMap.containsKey(name)) {
+ Class type = claimTypeMap.get(name);
+ return parser.readValueAsTree().traverse(parser.getCodec()).readValueAs(type);
+ }
+ return super.deserialize(parser, context);
+ }
+ }
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/auth/serialize/JacksonSerializer.java b/BE/src/main/java/com/codesquad/issuetracker/auth/serialize/JacksonSerializer.java
new file mode 100644
index 000000000..1b684a0e6
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/auth/serialize/JacksonSerializer.java
@@ -0,0 +1,38 @@
+package com.codesquad.issuetracker.auth.serialize;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.jsonwebtoken.io.SerializationException;
+import io.jsonwebtoken.io.Serializer;
+
+public class JacksonSerializer implements Serializer {
+
+ static final ObjectMapper DEFAULT_OBJECT_MAPPER = new ObjectMapper();
+
+ private final ObjectMapper objectMapper;
+
+ private JacksonSerializer() {
+ this(DEFAULT_OBJECT_MAPPER);
+ }
+
+ private JacksonSerializer(ObjectMapper objectMapper) {
+ this.objectMapper = objectMapper;
+ }
+
+ public static JacksonSerializer getInstance(){
+ return new JacksonSerializer();
+ }
+
+ public byte[] serialize(T t) throws SerializationException {
+ try {
+ return writeValueAsBytes(t);
+ } catch (JsonProcessingException e) {
+ String msg = "Unable to serialize object: " + e.getMessage();
+ throw new SerializationException(msg, e);
+ }
+ }
+
+ protected byte[] writeValueAsBytes(T t) throws JsonProcessingException {
+ return this.objectMapper.writeValueAsBytes(t);
+ }
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/auth/service/GitHubOauthService.java b/BE/src/main/java/com/codesquad/issuetracker/auth/service/GitHubOauthService.java
new file mode 100644
index 000000000..86b9bf033
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/auth/service/GitHubOauthService.java
@@ -0,0 +1,75 @@
+package com.codesquad.issuetracker.auth.service;
+
+import com.codesquad.issuetracker.auth.UserPlatform;
+import com.codesquad.issuetracker.auth.domain.GitHubUser;
+import com.codesquad.issuetracker.auth.domain.JwtAuthenticationInfo;
+import com.codesquad.issuetracker.auth.request.AccessTokenRequest;
+import com.codesquad.issuetracker.auth.response.AccessTokenResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.core.env.Environment;
+import org.springframework.http.RequestEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.Optional;
+
+@Service
+@Slf4j
+public class GitHubOauthService {
+
+
+ private static final String GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
+ private static final String GITHUB_USER_URL = "https://api.github.com/user";
+
+ private final String gitHubClientId;
+ private final String gitHubClientSecrets;
+
+ private final String gitHubClientIdIOS;
+ private final String gitHubClientSecretsIos;
+
+ public GitHubOauthService(Environment environment) {
+ this.gitHubClientId = environment.getProperty("github.client.id");
+ this.gitHubClientSecrets = environment.getProperty("github.client.secrets");
+ this.gitHubClientIdIOS = environment.getProperty("github.client.id.ios");
+ this.gitHubClientSecretsIos = environment.getProperty("github.client.secrets.ios");
+ }
+
+ public JwtAuthenticationInfo login(String code, UserPlatform platform) {
+ if (platform == UserPlatform.WEB) {
+ return authorize(gitHubClientId, gitHubClientSecrets, code);
+ }
+ return authorize(gitHubClientIdIOS, gitHubClientSecretsIos, code);
+ }
+
+ public JwtAuthenticationInfo authorize(String clientId, String clientSecret, String code) {
+ AccessTokenResponse accessTokenResponse = accessToken(clientId, clientSecret, code)
+ .orElseThrow(IllegalArgumentException::new);
+ log.debug("Access token : {}", accessTokenResponse.getAccessToken());
+
+ GitHubUser user = getUserInfo(accessTokenResponse.getAccessToken()).orElseThrow(IllegalArgumentException::new);
+ return JwtAuthenticationInfo.create(user, accessTokenResponse.getTokenType());
+ }
+
+ private Optional accessToken(String clientId, String clientSecrets, String code) {
+ RequestEntity accessTokenRequestEntity = RequestEntity.post(GITHUB_ACCESS_TOKEN_URL) // 보낼 request를 만듦
+ .header("Accept", "application/json")// 받아올 리턴 값을 json형식으로 설정
+ .body(AccessTokenRequest.create(clientId, clientSecrets, code));
+
+ return Optional.ofNullable(
+ new RestTemplate()
+ .exchange(accessTokenRequestEntity, AccessTokenResponse.class)
+ .getBody()); // 액세스토큰 받아오기
+ }
+
+ private Optional getUserInfo(String accessToken) {
+ RequestEntity githubUserInfoRequestEntity = RequestEntity.get(GITHUB_USER_URL)// body에 아무것도 보내지 않으므로 Void로 설정
+ .header("Accept", "application/json")
+ .header("Authorization", "token " + accessToken)
+ .build();
+
+ return Optional.ofNullable(
+ new RestTemplate()
+ .exchange(githubUserInfoRequestEntity, GitHubUser.class)
+ .getBody());
+ }
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/auth/service/LoginService.java b/BE/src/main/java/com/codesquad/issuetracker/auth/service/LoginService.java
new file mode 100644
index 000000000..73016685c
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/auth/service/LoginService.java
@@ -0,0 +1,40 @@
+package com.codesquad.issuetracker.auth.service;
+
+import com.codesquad.issuetracker.auth.JwtProvider;
+import com.codesquad.issuetracker.auth.domain.GitHubUser;
+import com.codesquad.issuetracker.auth.domain.JwtAuthenticationInfo;
+import com.codesquad.issuetracker.auth.response.GitHubUserResponse;
+import com.codesquad.issuetracker.domain.user.User;
+import com.codesquad.issuetracker.domain.user.UserRepository;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.Optional;
+
+@Slf4j
+public class LoginService {
+
+ private JwtProvider jwtProvider;
+
+ private UserRepository userRepository;
+
+ public LoginService(JwtProvider jwtProvider, UserRepository userRepository) {
+ this.jwtProvider = jwtProvider;
+ this.userRepository = userRepository;
+ }
+
+ private Optional signUp(GitHubUser gitHubUser) {
+ log.debug("github user : {}", gitHubUser);
+ if (!userRepository.findByLoginId(gitHubUser.getLoginId()).isPresent()) {
+ User user = User.githubUserToUser(gitHubUser);
+ log.debug("User : {} ", user);
+ userRepository.save(user);
+ }
+ return userRepository.findByLoginId(gitHubUser.getLoginId());
+ }
+
+ public GitHubUserResponse signIn(JwtAuthenticationInfo jwtAuthenticationInfo) {
+ User user = signUp(jwtAuthenticationInfo.getUser()).orElseThrow(IllegalArgumentException::new);
+ String jwt = jwtProvider.createJwt(user);
+ return GitHubUserResponse.create(jwt, user, jwtAuthenticationInfo.getTokenType());
+ }
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/config/AwsConfig.java b/BE/src/main/java/com/codesquad/issuetracker/config/AwsConfig.java
new file mode 100644
index 000000000..59ac0ca02
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/config/AwsConfig.java
@@ -0,0 +1,42 @@
+package com.codesquad.issuetracker.config;
+
+import com.amazonaws.auth.AWSCredentials;
+import com.amazonaws.auth.AWSStaticCredentialsProvider;
+import com.amazonaws.auth.BasicAWSCredentials;
+import com.amazonaws.services.s3.AmazonS3;
+import com.amazonaws.services.s3.AmazonS3ClientBuilder;
+import com.codesquad.issuetracker.s3.domain.S3Client;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class AwsConfig {
+
+ private final String accessKey;
+ private final String secretKey;
+ private final String region;
+ private final String bucket;
+
+ public AwsConfig(@Value("${cloud.aws.s3.access-key}") String accessKey,
+ @Value("${cloud.aws.s3.secret-key}") String secretKey,
+ @Value("${cloud.aws.s3.region}") String region,
+ @Value("${cloud.aws.s3.bucket}") String bucket) {
+ this.accessKey = accessKey;
+ this.secretKey = secretKey;
+ this.region = region;
+ this.bucket = bucket;
+ }
+
+ @Bean
+ public S3Client s3Client() {
+ final AWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey);
+ final AmazonS3 amazonS3 = AmazonS3ClientBuilder
+ .standard()
+ .withRegion(region)
+ .withCredentials(new AWSStaticCredentialsProvider(awsCredentials))
+ .build();
+
+ return S3Client.create(amazonS3, region, bucket);
+ }
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/config/CorsConfig.java b/BE/src/main/java/com/codesquad/issuetracker/config/CorsConfig.java
new file mode 100644
index 000000000..8f943866a
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/config/CorsConfig.java
@@ -0,0 +1,16 @@
+package com.codesquad.issuetracker.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.CorsRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+@Configuration
+public class CorsConfig implements WebMvcConfigurer {
+
+ @Override
+ public void addCorsMappings(CorsRegistry registry) {
+ registry.addMapping("/**")
+ .allowedOrigins("http://localhost:3000", "https://3.35.137.242");
+ }
+
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/config/JasyptConfig.java b/BE/src/main/java/com/codesquad/issuetracker/config/JasyptConfig.java
new file mode 100644
index 000000000..22d6804c0
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/config/JasyptConfig.java
@@ -0,0 +1,29 @@
+package com.codesquad.issuetracker.config;
+
+import com.ulisesbocchio.jasyptspringboot.annotation.EncryptablePropertySource;
+import org.jasypt.encryption.StringEncryptor;
+import org.jasypt.encryption.pbe.PooledPBEStringEncryptor;
+import org.jasypt.encryption.pbe.config.SimpleStringPBEConfig;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+@EncryptablePropertySource(name = "EncryptedProperties", value = "classpath:application-jasypt.properties")
+public class JasyptConfig {
+
+ @Value("${jasypt.encryptor.password}")
+ private String encryptKey;
+
+ @Bean("jasyptStringEncryptor")
+ public StringEncryptor stringEncryptor() {
+ PooledPBEStringEncryptor encryptor = new PooledPBEStringEncryptor();
+ SimpleStringPBEConfig config = new SimpleStringPBEConfig();
+ config.setPassword(encryptKey);
+ config.setAlgorithm("PBEWithMD5AndDES");
+ config.setPoolSize("1");
+ encryptor.setConfig(config);
+ return encryptor;
+ }
+
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/config/JwtConfig.java b/BE/src/main/java/com/codesquad/issuetracker/config/JwtConfig.java
new file mode 100644
index 000000000..3e20b9e5c
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/config/JwtConfig.java
@@ -0,0 +1,25 @@
+package com.codesquad.issuetracker.config;
+
+import com.codesquad.issuetracker.auth.BearerAuthInterceptor;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+@Configuration
+public class JwtConfig implements WebMvcConfigurer {
+
+ private final BearerAuthInterceptor bearerAuthInterceptor;
+
+ public JwtConfig(BearerAuthInterceptor bearerAuthInterceptor) {
+ this.bearerAuthInterceptor = bearerAuthInterceptor;
+ }
+
+ @Override
+ public void addInterceptors(InterceptorRegistry registry) {
+
+ registry.addInterceptor(bearerAuthInterceptor)
+ .addPathPatterns("/issue/**")
+ .addPathPatterns("/comment/**")
+ .addPathPatterns("/milestone/**");
+ }
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/config/QueryDslConfig.java b/BE/src/main/java/com/codesquad/issuetracker/config/QueryDslConfig.java
new file mode 100644
index 000000000..3c1116c2c
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/config/QueryDslConfig.java
@@ -0,0 +1,20 @@
+package com.codesquad.issuetracker.config;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import javax.persistence.EntityManager;
+import javax.persistence.PersistenceContext;
+
+@Configuration
+public class QueryDslConfig {
+
+ @PersistenceContext
+ private EntityManager entityManager;
+
+ @Bean
+ public JPAQueryFactory jpaQueryFactory() {
+ return new JPAQueryFactory(entityManager);
+ }
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/domain/assignee/Assignee.java b/BE/src/main/java/com/codesquad/issuetracker/domain/assignee/Assignee.java
new file mode 100644
index 000000000..904e81ded
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/domain/assignee/Assignee.java
@@ -0,0 +1,51 @@
+package com.codesquad.issuetracker.domain.assignee;
+
+import com.codesquad.issuetracker.domain.assignee.request.AssigneeRequest;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import javax.persistence.*;
+
+@Entity
+public class Assignee {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+ @JoinColumn(name = "issue_id")
+ private Long issueId;
+ @JsonProperty("user_id")
+ private Long userId;
+
+ public Assignee() {
+ }
+
+ public Assignee(Long issueId, Long userId) {
+ this.issueId = issueId;
+ this.userId = userId;
+ }
+
+ private Assignee(Long id, Long issueId, Long userId) {
+ this(issueId, userId);
+ this.id = id;
+ }
+
+ public Assignee create(Long id, Long issueId, Long userId) {
+ return new Assignee(id, issueId, userId);
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public Long getIssueId() {
+ return issueId;
+ }
+
+ public Long getUserId() {
+ return userId;
+ }
+
+ public static Assignee assigneeRequestToassignee(Long issueId, AssigneeRequest assigneeRequest) {
+ return new Assignee(issueId, assigneeRequest.getUserId());
+ }
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/domain/assignee/AssigneeRepository.java b/BE/src/main/java/com/codesquad/issuetracker/domain/assignee/AssigneeRepository.java
new file mode 100644
index 000000000..f0af527c9
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/domain/assignee/AssigneeRepository.java
@@ -0,0 +1,14 @@
+package com.codesquad.issuetracker.domain.assignee;
+
+import com.codesquad.issuetracker.domain.assignee.Assignee;
+import org.springframework.data.repository.CrudRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.Set;
+
+@Repository
+public interface AssigneeRepository extends CrudRepository {
+ Set findAssigneesByIssueId(Long issueId);
+
+ void deleteAssigneesByIssueId(Long issueId);
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/domain/assignee/request/AssigneeRequest.java b/BE/src/main/java/com/codesquad/issuetracker/domain/assignee/request/AssigneeRequest.java
new file mode 100644
index 000000000..ef5b798e4
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/domain/assignee/request/AssigneeRequest.java
@@ -0,0 +1,18 @@
+package com.codesquad.issuetracker.domain.assignee.request;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Getter
+@Setter
+@NoArgsConstructor
+@AllArgsConstructor
+public class AssigneeRequest {
+
+ @JsonProperty("user_id")
+ private Long userId;
+
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/domain/assignee/response/AssigneeForIssueResponse.java b/BE/src/main/java/com/codesquad/issuetracker/domain/assignee/response/AssigneeForIssueResponse.java
new file mode 100644
index 000000000..d62a146e4
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/domain/assignee/response/AssigneeForIssueResponse.java
@@ -0,0 +1,32 @@
+package com.codesquad.issuetracker.domain.assignee.response;
+
+import com.codesquad.issuetracker.domain.assignee.Assignee;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+
+public class AssigneeForIssueResponse {
+
+ @JsonIgnore
+ private Long id;
+ private Long userId;
+
+ public AssigneeForIssueResponse() {
+
+ }
+
+ public AssigneeForIssueResponse(Long id, Long userId) {
+ this.id = id;
+ this.userId = userId;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public Long getUserId() {
+ return userId;
+ }
+
+ public static AssigneeForIssueResponse assigneeToAssigneeForIssueResponse(Assignee assignee) {
+ return new AssigneeForIssueResponse(assignee.getId(), assignee.getUserId());
+ }
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/domain/comment/Comment.java b/BE/src/main/java/com/codesquad/issuetracker/domain/comment/Comment.java
new file mode 100644
index 000000000..ffb8b9fde
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/domain/comment/Comment.java
@@ -0,0 +1,37 @@
+package com.codesquad.issuetracker.domain.comment;
+
+import com.codesquad.issuetracker.domain.user.User;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.*;
+
+import javax.persistence.*;
+
+import java.time.LocalDateTime;
+
+@Getter
+@Entity
+@NoArgsConstructor
+@AllArgsConstructor
+public class Comment {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Setter
+ private String content;
+
+ @JsonProperty("created_at")
+ private LocalDateTime createdAt;
+
+ @JsonProperty("issue_id")
+ private Long issueId;
+
+ @OneToOne
+ @JoinColumn(name="user_id")
+ private User user;
+
+ public static Comment create(Long id, String content, LocalDateTime createdAt, Long issueId, User user) {
+ return new Comment(id, content, createdAt, issueId, user);
+ }
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/domain/comment/CommentRepository.java b/BE/src/main/java/com/codesquad/issuetracker/domain/comment/CommentRepository.java
new file mode 100644
index 000000000..bb9eb2504
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/domain/comment/CommentRepository.java
@@ -0,0 +1,11 @@
+package com.codesquad.issuetracker.domain.comment;
+
+import com.codesquad.issuetracker.domain.comment.Comment;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+public interface CommentRepository extends JpaRepository {
+
+ List getCommentsByIssueId(Long issueId);
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/domain/comment/controller/CommentController.java b/BE/src/main/java/com/codesquad/issuetracker/domain/comment/controller/CommentController.java
new file mode 100644
index 000000000..8346341ff
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/domain/comment/controller/CommentController.java
@@ -0,0 +1,50 @@
+package com.codesquad.issuetracker.domain.comment.controller;
+
+import com.codesquad.issuetracker.domain.user.User;
+import com.codesquad.issuetracker.domain.comment.request.CommentRequest;
+import com.codesquad.issuetracker.domain.comment.request.EditedCommentRequest;
+import com.codesquad.issuetracker.response.ApiResponse;
+import com.codesquad.issuetracker.domain.comment.service.CommentService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.*;
+
+
+@RestController
+@RequestMapping("/comment")
+@Slf4j
+public class CommentController {
+
+ private final CommentService commentService;
+
+ public CommentController(CommentService commentService) {
+ this.commentService = commentService;
+ }
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ public ApiResponse createComment(@RequestBody CommentRequest commentRequest, @RequestAttribute User user) {
+
+ log.debug("Comment Request from User : {}", commentRequest);
+
+ return ApiResponse.ok(commentService.create(commentRequest.create(user)));
+ }
+
+ @GetMapping("/{issueId}")
+ public ApiResponse getComments(@PathVariable Long issueId) {
+ return ApiResponse.ok(commentService.getComments(issueId));
+ }
+
+ @PutMapping("/{commentId}")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ public void editComment(@PathVariable Long commentId, @RequestBody EditedCommentRequest content, @RequestAttribute User user) {
+ commentService.editComment(commentId, content, user);
+ }
+
+ @DeleteMapping("/{commentId}")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ public void deleteComment(@PathVariable Long commentId, @RequestAttribute User user) {
+ commentService.removeComment(commentId, user);
+ }
+
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/domain/comment/request/CommentRequest.java b/BE/src/main/java/com/codesquad/issuetracker/domain/comment/request/CommentRequest.java
new file mode 100644
index 000000000..f76dcc508
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/domain/comment/request/CommentRequest.java
@@ -0,0 +1,32 @@
+package com.codesquad.issuetracker.domain.comment.request;
+
+import com.codesquad.issuetracker.domain.comment.Comment;
+import com.codesquad.issuetracker.domain.user.User;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+@Getter
+public class CommentRequest {
+
+ @JsonProperty("issue_id")
+ private Long issueId;
+
+ private String content;
+
+ @JsonProperty("created_at")
+ @JsonFormat(pattern = "YYYY-MM-dd HH:mm:ss")
+ private LocalDateTime createdAt;
+
+ public CommentRequest(Long issueId, String content) {
+ this.issueId = issueId;
+ this.content = content;
+ this.createdAt = LocalDateTime.now();
+ }
+
+ public Comment create(User user) {
+ return Comment.create(null, content, createdAt, issueId, user);
+ }
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/domain/comment/request/EditedCommentRequest.java b/BE/src/main/java/com/codesquad/issuetracker/domain/comment/request/EditedCommentRequest.java
new file mode 100644
index 000000000..51ccb9c83
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/domain/comment/request/EditedCommentRequest.java
@@ -0,0 +1,14 @@
+package com.codesquad.issuetracker.domain.comment.request;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@AllArgsConstructor
+@NoArgsConstructor
+public class EditedCommentRequest {
+
+ private String content;
+
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/domain/comment/response/CommentResponse.java b/BE/src/main/java/com/codesquad/issuetracker/domain/comment/response/CommentResponse.java
new file mode 100644
index 000000000..6e9fbd430
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/domain/comment/response/CommentResponse.java
@@ -0,0 +1,35 @@
+package com.codesquad.issuetracker.domain.comment.response;
+
+import com.codesquad.issuetracker.domain.comment.Comment;
+import com.codesquad.issuetracker.domain.user.response.UserResponse;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+@Getter
+@AllArgsConstructor
+public class CommentResponse {
+
+ private final Long id;
+
+ private String content;
+
+ @JsonProperty("created_at")
+ @JsonFormat(pattern = "yyyy-MM-dd kk:mm:ss")
+ private final LocalDateTime createdAt;
+
+ private UserResponse author;
+
+ public static CommentResponse from(Comment comment) {
+ return new CommentResponse(
+ comment.getId(),
+ comment.getContent(),
+ comment.getCreatedAt(),
+ UserResponse.create(comment.getUser())
+ );
+ }
+
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/domain/comment/service/CommentService.java b/BE/src/main/java/com/codesquad/issuetracker/domain/comment/service/CommentService.java
new file mode 100644
index 000000000..50fbecf52
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/domain/comment/service/CommentService.java
@@ -0,0 +1,51 @@
+package com.codesquad.issuetracker.domain.comment.service;
+
+import com.codesquad.issuetracker.domain.comment.Comment;
+import com.codesquad.issuetracker.domain.comment.CommentRepository;
+import com.codesquad.issuetracker.domain.comment.request.EditedCommentRequest;
+import com.codesquad.issuetracker.domain.comment.response.CommentResponse;
+import com.codesquad.issuetracker.domain.user.User;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Service
+@Transactional(readOnly = true)
+public class CommentService {
+
+ private final CommentRepository commentRepository;
+
+ public CommentService(CommentRepository commentRepository) {
+ this.commentRepository = commentRepository;
+ }
+
+ @Transactional
+ public CommentResponse create(Comment comment) {
+ return CommentResponse.from(commentRepository.save(comment));
+ }
+
+ public List getComments(Long issueId) {
+ return commentRepository.getCommentsByIssueId(issueId).stream()
+ .map(CommentResponse::from)
+ .collect(Collectors.toList());
+ }
+
+ @Transactional
+ public void editComment(Long commentId, EditedCommentRequest content, User loginUser) {
+ Comment comment = commentRepository.getById(commentId);
+ loginUser.validateUser(comment.getUser());
+
+ comment.setContent(content.getContent());
+ commentRepository.save(comment);
+ }
+
+ @Transactional
+ public void removeComment(Long commentId, User loginUser) {
+ Comment comment = commentRepository.getById(commentId);
+ loginUser.validateUser(comment.getUser());
+
+ commentRepository.deleteById(commentId);
+ }
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/domain/issue/Issue.java b/BE/src/main/java/com/codesquad/issuetracker/domain/issue/Issue.java
new file mode 100644
index 000000000..8805191f1
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/domain/issue/Issue.java
@@ -0,0 +1,76 @@
+package com.codesquad.issuetracker.domain.issue;
+
+import com.codesquad.issuetracker.domain.issue.request.IssueRequest;
+import com.codesquad.issuetracker.domain.user.User;
+import com.codesquad.issuetracker.domain.assignee.Assignee;
+import com.codesquad.issuetracker.domain.comment.Comment;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.ToString;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import javax.persistence.*;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+@Getter
+@Setter
+@ToString
+@Entity
+public class Issue {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ private String title;
+ private String content;
+
+ @JsonProperty("status")
+ private boolean isOpen;
+
+ @JsonProperty("created_at")
+ @DateTimeFormat(pattern = "yyyy-MM-dd kk:mm:ss")
+ private LocalDateTime createdAt;
+
+ @OneToOne
+ private User user;
+
+ @JsonProperty("milestone_id")
+ private Long milestoneId;
+
+ @OneToMany(mappedBy = "issue")
+ private List issueLabels = new ArrayList<>();
+
+ @OneToMany
+ private List assignees = new ArrayList<>();
+
+ @OneToMany
+ private List comments = new ArrayList<>();
+
+ public Issue() {
+ }
+
+ public Issue(Long id, String title, String content, boolean isOpen, LocalDateTime createdAt, Long milestoneId, User user) {
+ this(title, content, isOpen, createdAt, user, milestoneId);
+ this.id = id;
+
+ }
+
+ public Issue(String title, String content, boolean isOpen, LocalDateTime createdAt, User user, Long milestoneId) {
+ this.title = title;
+ this.content = content;
+ this.isOpen = isOpen;
+ this.createdAt = createdAt;
+ this.user = user;
+ this.milestoneId = milestoneId;
+ }
+
+ public static Issue issueRequestToIssue(IssueRequest issueRequest) {
+ return new Issue(issueRequest.getTitle(), issueRequest.getContent(), true,
+ issueRequest.getCreatedAt(), issueRequest.getUser(), issueRequest.getMilestoneId());
+ }
+
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/domain/issue/IssueFilter.java b/BE/src/main/java/com/codesquad/issuetracker/domain/issue/IssueFilter.java
new file mode 100644
index 000000000..7693c4ff5
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/domain/issue/IssueFilter.java
@@ -0,0 +1,20 @@
+package com.codesquad.issuetracker.domain.issue;
+
+
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Getter
+@Setter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class IssueFilter {
+
+ private String title;
+ private Long commenter;
+ private Boolean status;
+ private Long author;
+ private Long assignee;
+
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/domain/issue/IssueLabel.java b/BE/src/main/java/com/codesquad/issuetracker/domain/issue/IssueLabel.java
new file mode 100644
index 000000000..11a8c7c61
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/domain/issue/IssueLabel.java
@@ -0,0 +1,46 @@
+package com.codesquad.issuetracker.domain.issue;
+
+import com.codesquad.issuetracker.domain.label.Label;
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.persistence.*;
+import javax.persistence.JoinColumn;
+
+@Entity
+@Table(name = "issue_label")
+@Getter
+@Setter
+
+public class IssueLabel {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne
+ @JoinColumn(name = "issue_id")
+ private Issue issue;
+
+ @ManyToOne
+ @JoinColumn(name = "label_id")
+ private Label label;
+
+ public IssueLabel(Issue issue, Label label) {
+ this.issue = issue;
+ this.label = label;
+ }
+
+ public IssueLabel(Long id, Issue issue, Label label) {
+ this(issue, label);
+ this.id = id;
+ }
+
+ public IssueLabel() {
+
+ }
+
+ public static IssueLabel issueToIssueLabel(Issue issue, Label label) {
+ return new IssueLabel(issue, label);
+ }
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/domain/issue/IssueLabelRepository.java b/BE/src/main/java/com/codesquad/issuetracker/domain/issue/IssueLabelRepository.java
new file mode 100644
index 000000000..f4a18200e
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/domain/issue/IssueLabelRepository.java
@@ -0,0 +1,20 @@
+package com.codesquad.issuetracker.domain.issue;
+
+import com.codesquad.issuetracker.domain.issue.IssueLabel;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.CrudRepository;
+import org.springframework.data.repository.query.Param;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+public interface IssueLabelRepository extends CrudRepository {
+ @Query("SELECT il.label.id FROM IssueLabel il WHERE il.issue.id = :issueId")
+ List findIssueLabelsLabelIdByIssueId(@Param("issueId") Long issueId);
+
+ @Transactional
+ @Modifying
+ @Query("DELETE FROM IssueLabel il WHERE il.issue.id = :issueId AND il.label.id = :labelId")
+ void deleteIssueLabelByIssueIdAndLabelId(@Param("issueId") Long issueId, @Param("labelId") Long labelId);
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/domain/issue/IssueRepository.java b/BE/src/main/java/com/codesquad/issuetracker/domain/issue/IssueRepository.java
new file mode 100644
index 000000000..0ce987fea
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/domain/issue/IssueRepository.java
@@ -0,0 +1,26 @@
+package com.codesquad.issuetracker.domain.issue;
+
+import com.codesquad.issuetracker.domain.issue.Issue;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.CrudRepository;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+@Repository
+public interface IssueRepository extends CrudRepository {
+
+ List getIssuesByStatusFalse();
+
+ List getIssuesByStatusTrue();
+
+ @Query("SELECT new Issue(i.id, i.title, i.content, i.status, i.createdAt, i.milestoneId, i.user) FROM Issue i " +
+ "LEFT JOIN Assignee a ON a.issueId = i.id WHERE a.userId = :userId")
+ List getIssuesByAssigneeUserId(@Param("userId") Long userId);
+
+ @Query("SELECT new Issue(i.id, i.title, i.content, i.status, i.createdAt, i.milestoneId, i.user) FROM Issue i " +
+ "LEFT JOIN Comment c ON c.issueId = i.id WHERE c.user.id = :userId")
+ List getIssuesByCommentUserId(@Param("userId") Long userId);
+
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/domain/issue/IssueRepositorySupport.java b/BE/src/main/java/com/codesquad/issuetracker/domain/issue/IssueRepositorySupport.java
new file mode 100644
index 000000000..b4b93572d
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/domain/issue/IssueRepositorySupport.java
@@ -0,0 +1,102 @@
+package com.codesquad.issuetracker.domain.issue;
+
+import com.querydsl.core.types.Predicate;
+import com.querydsl.jpa.impl.JPAQuery;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+import static com.codesquad.issuetracker.domain.assignee.QAssignee.assignee;
+import static com.codesquad.issuetracker.domain.issue.QIssue.issue;
+import static com.codesquad.issuetracker.domain.comment.QComment.comment;
+
+@Repository
+public class IssueRepositorySupport extends QuerydslRepositorySupport {
+
+ private final JPAQueryFactory queryFactory;
+
+ public IssueRepositorySupport(JPAQueryFactory queryFactory) {
+ super(Issue.class);
+ this.queryFactory = queryFactory;
+ }
+
+ public List getFilteredIssues(IssueFilter issueFilter) {
+ JPAQuery query = queryFactory
+ .selectFrom(issue)
+ .where(getPredicate(issueFilter));
+
+ Long assigneeUserId = issueFilter.getAssignee();
+ if (assigneeUserId != null) {
+ query.innerJoin(assignee)
+ .on(assignee.issueId.eq(issue.id))
+ .where(assignee.userId.eq(assigneeUserId));
+ }
+
+ Long commenterUserId = issueFilter.getCommenter();
+ if (commenterUserId != null) {
+ query.innerJoin(comment)
+ .on(comment.issueId.eq(issue.id))
+ .where(comment.user.id.eq(commenterUserId));
+ }
+
+ return query.fetch();
+
+ }
+
+ private Predicate getPredicate(IssueFilter filter) {
+ return new PredicateBuilder(issue.isNotNull())
+ .containsAnd(issue.title, filter.getTitle())
+ .isBooleanAnd(issue.isOpen, filter.getStatus())
+ .isLongEqualAnd(issue.user.id, filter.getAuthor())
+ .build();
+ }
+
+ public List findByTitle(String title) {
+ return queryFactory
+ .selectFrom(issue)
+ .where(issue.title.contains(title))
+ .fetch();
+ }
+
+ public List findByStatusTrue() {
+ return queryFactory
+ .selectFrom(issue)
+ .where(issue.isOpen.isTrue())
+ .fetch();
+ }
+
+ public List findByStatusFalse() {
+ return queryFactory
+ .selectFrom(issue)
+ .where(issue.isOpen.isFalse())
+ .fetch();
+ }
+
+ public List findByAuthor(Long userId) {
+ return queryFactory
+ .selectFrom(issue)
+ .where(issue.user.id.eq(userId))
+ .fetch();
+ }
+
+ public List findByAssignee(Long userId) {
+ return queryFactory
+ .selectFrom(issue)
+ .innerJoin(assignee)
+ .on(assignee.issueId.eq(issue.id))
+ .where(assignee.userId.eq(userId))
+ .fetch();
+ }
+
+ public List findByCommentUserId(Long userId) {
+ return queryFactory
+ .selectFrom(issue)
+ .innerJoin(comment)
+ .on(comment.issueId.eq(issue.id))
+ .where(comment.user.id.eq(userId))
+ .fetch();
+ }
+
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/domain/issue/PredicateBuilder.java b/BE/src/main/java/com/codesquad/issuetracker/domain/issue/PredicateBuilder.java
new file mode 100644
index 000000000..4bc6ce0fa
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/domain/issue/PredicateBuilder.java
@@ -0,0 +1,42 @@
+package com.codesquad.issuetracker.domain.issue;
+
+import com.querydsl.core.types.dsl.BooleanExpression;
+import com.querydsl.core.types.dsl.BooleanPath;
+import com.querydsl.core.types.dsl.NumberPath;
+import com.querydsl.core.types.dsl.StringPath;
+
+public class PredicateBuilder {
+
+ private final BooleanExpression predicate;
+
+ public PredicateBuilder(BooleanExpression predicate) {
+ this.predicate = predicate;
+ }
+
+ public PredicateBuilder containsAnd(StringPath qValue, String rValue) {
+ if (rValue != null) {
+ return new PredicateBuilder(predicate.and(qValue.containsIgnoreCase(rValue)));
+ }
+ return this;
+ }
+
+ public PredicateBuilder isBooleanAnd(BooleanPath qValue, Boolean rValue) {
+ if (rValue != null) {
+ return (rValue) ? new PredicateBuilder(predicate.and(qValue.isTrue()))
+ : new PredicateBuilder(predicate.and(qValue.isFalse()));
+ }
+ return this;
+ }
+
+ public PredicateBuilder isLongEqualAnd(NumberPath qValue, Long rValue) {
+ if (rValue != null) {
+ return new PredicateBuilder(predicate.and(qValue.eq(rValue)));
+ }
+ return this;
+ }
+
+ public BooleanExpression build() {
+ return predicate;
+ }
+
+}
\ No newline at end of file
diff --git a/BE/src/main/java/com/codesquad/issuetracker/domain/issue/controller/IssueController.java b/BE/src/main/java/com/codesquad/issuetracker/domain/issue/controller/IssueController.java
new file mode 100644
index 000000000..bbbcdfdc3
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/domain/issue/controller/IssueController.java
@@ -0,0 +1,110 @@
+package com.codesquad.issuetracker.domain.issue.controller;
+
+import com.codesquad.issuetracker.domain.issue.IssueFilter;
+import com.codesquad.issuetracker.domain.issue.request.IssueRequest;
+import com.codesquad.issuetracker.domain.user.User;
+import com.codesquad.issuetracker.response.ApiResponse;
+import com.codesquad.issuetracker.domain.issue.service.IssueService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+@Slf4j
+@RestController
+@RequestMapping("/issue")
+public class IssueController {
+
+ private final IssueService issueService;
+
+ public IssueController(IssueService issueService) {
+ this.issueService = issueService;
+ }
+
+ @GetMapping("/open")
+ public ApiResponse getOpenedIssues() {
+ return ApiResponse.ok(issueService.getOpenedIssues());
+ }
+
+ @GetMapping("/close")
+ public ApiResponse getClosedIssues() {
+ return ApiResponse.ok(issueService.getClosedIssues());
+ }
+
+ @GetMapping("/title")
+ public ApiResponse getIssueByTitle(@RequestParam String title) {
+ return ApiResponse.ok(issueService.getIssuesByTitle(title));
+ }
+
+ @GetMapping("/author")
+ public ApiResponse getIssueByAuthor(@RequestParam Long authorId) {
+ return ApiResponse.ok(issueService.getIssuesByAuthor(authorId));
+ }
+
+ @GetMapping("/assignee")
+ public ApiResponse getIssueByAssignee(@RequestParam Long assigneeId) {
+ return ApiResponse.ok(issueService.getIssuesByAssignee(assigneeId));
+ }
+
+ @GetMapping("/comment")
+ public ApiResponse getIssueByComment(@RequestParam Long commentAuthorId) {
+ return ApiResponse.ok(issueService.getIssuesByComment(commentAuthorId));
+ }
+
+ @GetMapping("/{issueId}")
+ public ApiResponse getIssue(@PathVariable Long issueId) {
+ return ApiResponse.ok(issueService.getIssue(issueId));
+ }
+
+ @GetMapping("/filter")
+ public ApiResponse getFilteredIssues(IssueFilter issueFilter) {
+ return ApiResponse.ok(issueService.getFilteredIssues(issueFilter));
+ }
+
+ @PostMapping
+ public ApiResponse createIssue(@RequestBody IssueRequest issueRequest) {
+ return ApiResponse.ok(issueService.addIssue(issueRequest));
+ }
+
+ @PutMapping("/{issueId}/title")
+ public ApiResponse editTitle(@PathVariable Long issueId, @RequestBody IssueRequest issueRequest, @RequestAttribute User user) {
+ issueService.updateTitle(issueId, issueRequest, user);
+ return ApiResponse.ok();
+ }
+
+ @PutMapping("/{issueId}/content")
+ public ApiResponse editContent(@PathVariable Long issueId, @RequestBody IssueRequest issueRequest, @RequestAttribute User user) {
+ issueService.updateContent(issueId, issueRequest, user);
+ return ApiResponse.ok();
+ }
+
+ @PutMapping("/{issueId}/status")
+ public ApiResponse editStatus(@PathVariable Long issueId, @RequestBody IssueRequest issueRequest, @RequestAttribute User user) {
+ issueService.updateStatus(issueId, issueRequest, user);
+ return ApiResponse.ok();
+ }
+
+ @PutMapping("/{issueId}/milestone")
+ public ApiResponse editMilestone(@PathVariable Long issueId, @RequestBody IssueRequest issueRequest, @RequestAttribute User user) {
+ issueService.updateMilestone(issueId, issueRequest, user);
+ return ApiResponse.ok();
+ }
+
+ @PutMapping("/{issueId}/label")
+ public ApiResponse editLabel(@PathVariable Long issueId, @RequestBody IssueRequest issueRequest, @RequestAttribute User user) {
+ issueService.updateLabel(issueId, issueRequest, user);
+ return ApiResponse.ok();
+ }
+
+ @PutMapping("/{issueId}/assignee")
+ public ApiResponse editAssignee(@PathVariable Long issueId, @RequestBody IssueRequest issueRequest, @RequestAttribute User user) {
+ issueService.updateAssignee(issueId, issueRequest, user);
+ return ApiResponse.ok();
+ }
+
+ @DeleteMapping("/{issueId}")
+ public ApiResponse deleteIssue(@PathVariable Long issueId, @RequestAttribute User user) {
+ issueService.deleteIssue(issueId, user);
+ return ApiResponse.ok();
+ }
+
+
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/domain/issue/request/IssueRequest.java b/BE/src/main/java/com/codesquad/issuetracker/domain/issue/request/IssueRequest.java
new file mode 100644
index 000000000..ab62373d5
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/domain/issue/request/IssueRequest.java
@@ -0,0 +1,38 @@
+package com.codesquad.issuetracker.domain.issue.request;
+
+import com.codesquad.issuetracker.domain.assignee.request.AssigneeRequest;
+import com.codesquad.issuetracker.domain.user.User;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+public class IssueRequest {
+
+ private String title;
+ private String content;
+ private boolean status;
+
+ @JsonProperty("created_at")
+ @JsonFormat(pattern = "yyyy-MM-dd kk:mm:ss")
+ private LocalDateTime createdAt;
+
+ @JsonProperty("user")
+ private User user;
+
+ @JsonProperty("milestone_id")
+ private Long milestoneId;
+
+ @JsonProperty("label_id_list")
+ private ArrayList labelList = new ArrayList<>();
+
+ @JsonProperty("assignee_list")
+ private ArrayList assigneeList = new ArrayList<>();
+}
diff --git a/BE/src/main/java/com/codesquad/issuetracker/domain/issue/response/IssueResponse.java b/BE/src/main/java/com/codesquad/issuetracker/domain/issue/response/IssueResponse.java
new file mode 100644
index 000000000..94127e44e
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/domain/issue/response/IssueResponse.java
@@ -0,0 +1,42 @@
+package com.codesquad.issuetracker.domain.issue.response;
+
+import com.codesquad.issuetracker.domain.assignee.response.AssigneeForIssueResponse;
+import com.codesquad.issuetracker.domain.comment.response.CommentResponse;
+import com.codesquad.issuetracker.domain.label.response.LabelResponse;
+import com.codesquad.issuetracker.domain.milestone.response.MilestoneForIssueResponse;
+import com.codesquad.issuetracker.domain.user.response.UserResponse;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+import java.util.Set;
+
+@AllArgsConstructor
+@Getter
+public class IssueResponse {
+
+ private final Long id;
+ private final String title;
+ private final String content;
+ private final boolean status;
+
+ @JsonProperty("created_at")
+ @JsonFormat(pattern = "yyyy-MM-dd kk:mm:ss")
+ private final LocalDateTime createdAt;
+
+ private final UserResponse author;
+
+ private final MilestoneForIssueResponse milestone;
+
+ @JsonProperty("label_list")
+ private Set labelList;
+
+ @JsonProperty("assignee_list")
+ private Set assigneeList;
+
+ @JsonProperty("comment_list")
+ private Set commentList;
+}
+
diff --git a/BE/src/main/java/com/codesquad/issuetracker/domain/issue/service/IssueService.java b/BE/src/main/java/com/codesquad/issuetracker/domain/issue/service/IssueService.java
new file mode 100644
index 000000000..48eb55a5d
--- /dev/null
+++ b/BE/src/main/java/com/codesquad/issuetracker/domain/issue/service/IssueService.java
@@ -0,0 +1,234 @@
+package com.codesquad.issuetracker.domain.issue.service;
+
+import com.codesquad.issuetracker.domain.assignee.Assignee;
+import com.codesquad.issuetracker.domain.assignee.response.AssigneeForIssueResponse;
+import com.codesquad.issuetracker.domain.assignee.AssigneeRepository;
+import com.codesquad.issuetracker.domain.comment.Comment;
+import com.codesquad.issuetracker.domain.comment.CommentRepository;
+import com.codesquad.issuetracker.domain.comment.response.CommentResponse;
+import com.codesquad.issuetracker.domain.issue.*;
+import com.codesquad.issuetracker.domain.issue.request.IssueRequest;
+import com.codesquad.issuetracker.domain.issue.response.IssueResponse;
+import com.codesquad.issuetracker.domain.label.Label;
+import com.codesquad.issuetracker.domain.label.LabelRepository;
+import com.codesquad.issuetracker.domain.label.response.LabelResponse;
+import com.codesquad.issuetracker.domain.milestone.Milestone;
+import com.codesquad.issuetracker.domain.milestone.response.MilestoneForIssueResponse;
+import com.codesquad.issuetracker.domain.milestone.MilestoneRepository;
+import com.codesquad.issuetracker.domain.user.User;
+import com.codesquad.issuetracker.domain.user.response.UserResponse;
+import com.codesquad.issuetracker.exception.NoSuchIssueException;
+import com.codesquad.issuetracker.domain.assignee.request.AssigneeRequest;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.*;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+public class IssueService {
+
+ private final IssueRepository issueRepository;
+ private final IssueRepositorySupport issueRepositorySupport;
+ private final LabelRepository labelRepository;
+ private final IssueLabelRepository issueLabelRepository;
+ private final MilestoneRepository milestoneRepository;
+ private final AssigneeRepository assigneeRepository;
+ private final CommentRepository commentRepository;
+
+ public IssueService(IssueRepository issueRepository, IssueRepositorySupport issueRepositorySupport,
+ LabelRepository labelRepository,
+ IssueLabelRepository issueLabelRepository, MilestoneRepository milestoneRepository,
+ AssigneeRepository assigneeRepository, CommentRepository commentRepository) {
+ this.issueRepository = issueRepository;
+ this.issueRepositorySupport = issueRepositorySupport;
+ this.labelRepository = labelRepository;
+ this.issueLabelRepository = issueLabelRepository;
+ this.milestoneRepository = milestoneRepository;
+ this.assigneeRepository = assigneeRepository;
+ this.commentRepository = commentRepository;
+ }
+
+
+ public List getFilteredIssues(IssueFilter issueFilter) {
+ return convertToResponse.apply(issueRepositorySupport.getFilteredIssues(issueFilter));
+ }
+
+ public List getOpenedIssues() {
+ return convertToResponse.apply(issueRepositorySupport.findByStatusTrue());
+ }
+
+ public List getClosedIssues() {
+ return convertToResponse.apply(issueRepositorySupport.findByStatusFalse());
+ }
+
+ public List getIssuesByTitle(String title) {
+ return convertToResponse.apply(issueRepositorySupport.findByTitle(title));
+ }
+
+ public List getIssuesByAuthor(Long authorId) {
+ return convertToResponse.apply(issueRepositorySupport.findByAuthor(authorId));
+ }
+
+ public List getIssuesByAssignee(Long userId) {
+ return convertToResponse.apply(issueRepositorySupport.findByAssignee(userId));
+ }
+
+ public List getIssuesByComment(Long userId) {
+ return convertToResponse.apply(issueRepositorySupport.findByCommentUserId(userId));
+ }
+
+ private Function, List> convertToResponse =
+ issues -> issues.stream()
+ .map(this::issueToIssueResponse)
+ .collect(Collectors.toList());
+
+
+ public IssueResponse getIssue(Long issueId) {
+ Issue issue = issueRepository.findById(issueId).orElseThrow(NoSuchIssueException::new);
+ return issueToIssueResponse(issue);
+ }
+
+ public IssueResponse addIssue(IssueRequest issueRequest) {
+ Issue savedIssue = issueRepository.save(Issue.issueRequestToIssue(issueRequest));
+ List labelIdList = issueRequest.getLabelList();
+ for (Long labelId : labelIdList) {
+ issueLabelRepository.save(new IssueLabel(savedIssue, labelRepository.findById(labelId).orElseThrow(NoSuchElementException::new)));
+ }
+ List assigneeRequestList = issueRequest.getAssigneeList();
+ for (AssigneeRequest assigneeRequest : assigneeRequestList) {
+ Assignee assignee = new Assignee(savedIssue.getId(), assigneeRequest.getUserId());
+ assigneeRepository.save(assignee);
+ }
+ return issueToIssueResponse(savedIssue);
+ }
+
+ public void updateTitle(Long issueId, IssueRequest issueRequest, User loginUser) {
+ Issue updateIssue = issueRepository.findById(issueId).orElseThrow(NoSuchIssueException::new);
+ loginUser.validateUser(updateIssue.getUser());
+
+ updateIssue.setTitle(issueRequest.getTitle());
+ issueRepository.save(updateIssue);
+ }
+
+ @Transactional
+ public void updateAssignee(Long issueId, IssueRequest issueRequest, User loginUser) {
+ Issue updateIssue = issueRepository.findById(issueId).orElseThrow(NoSuchIssueException::new);
+ loginUser.validateUser(updateIssue.getUser());
+
+ assigneeRepository.deleteAssigneesByIssueId(issueId);
+ ArrayList assigneeList = issueRequest.getAssigneeList();
+ for (AssigneeRequest assigneeRequest : assigneeList) {
+ assigneeRepository.save(Assignee.assigneeRequestToassignee(issueId, assigneeRequest));
+ }
+ }
+
+ public void updateContent(Long issueId, IssueRequest issueRequest, User loginUser) {
+ Issue updateIssue = issueRepository.findById(issueId).orElseThrow(NoSuchIssueException::new);
+ loginUser.validateUser(updateIssue.getUser());
+
+ updateIssue.setContent(issueRequest.getContent());
+ issueRepository.save(updateIssue);
+ }
+
+ public void updateStatus(Long issueId, IssueRequest issueRequest, User loginUser) {
+ Issue updateIssue = issueRepository.findById(issueId).orElseThrow(NoSuchIssueException::new);
+ loginUser.validateUser(updateIssue.getUser());
+
+ updateIssue.setOpen(issueRequest.isStatus());
+ issueRepository.save(updateIssue);
+ }
+
+ @Transactional
+ public void updateLabel(Long issueId, IssueRequest issueRequest, User loginUser) {
+ Issue updateIssue = issueRepository.findById(issueId).orElseThrow(NoSuchIssueException::new);
+ loginUser.validateUser(updateIssue.getUser());
+
+ List labelIdsBeEdited = issueLabelRepository.findIssueLabelsLabelIdByIssueId(issueId); // 원본
+ ArrayList labelIdsToEdit = issueRequest.getLabelList(); // 요청 (수정할것)
+ for (Long labelId : labelIdsToEdit) {
+ if (labelIdsBeEdited.contains(labelId)) { // 원본에 요청항목이 존재하면 넘어감 (추가/삭제 필요 x)
+ continue;
+ }
+ if (!labelIdsBeEdited.contains(labelId)) { // 원본에 수정할 항목이 없으면 추가함
+ IssueLabel issueLabel = IssueLabel.issueToIssueLabel(issueRepository.findById(issueId).orElseThrow(NoSuchIssueException::new),
+ labelRepository.findById(labelId).orElseThrow(NoSuchElementException::new));
+ issueLabelRepository.save(issueLabel);
+ }
+ }
+ for (int i = 0; i < labelIdsBeEdited.size(); i++) { // 수정 항목 중 원본에만 있는 것은 삭제함
+ if (!labelIdsToEdit.contains(labelIdsBeEdited.get(i))) {
+ issueLabelRepository.deleteIssueLabelByIssueIdAndLabelId(issueId, labelIdsBeEdited.get(i));
+ }
+ }
+ }
+
+ public void updateMilestone(Long issueId, IssueRequest issueRequest, User loginUser) {
+ Issue updateIssue = issueRepository.findById(issueId).orElseThrow(NoSuchIssueException::new);
+ loginUser.validateUser(updateIssue.getUser());
+
+ updateIssue.setMilestoneId(issueRequest.getMilestoneId());
+ issueRepository.save(updateIssue);
+ }
+
+ public void deleteIssue(Long issueId, User loginUser) {
+ Issue issueToDelete = issueRepository.findById(issueId).orElseThrow(NoSuchIssueException::new);
+ loginUser.validateUser(issueToDelete.getUser());
+
+ issueRepository.deleteById(issueId);
+ }
+
+ private Set