diff --git a/.github/workflows/backend-build-test.yml b/.github/workflows/backend-build-test.yml
new file mode 100644
index 0000000..22778c7
--- /dev/null
+++ b/.github/workflows/backend-build-test.yml
@@ -0,0 +1,51 @@
+name: β
Backend Build Test
+run-name: ${{ github.actor }} is Build Test π
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+
+defaults:
+ run:
+ working-directory: ./backend-board
+
+permissions:
+ contents: read
+
+jobs:
+ build-test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-java@v4
+ with:
+ distribution: 'temurin'
+ java-version: '21'
+
+ - name: Make docker-compose.yml
+ run: echo "${{ secrets.DOCKER_COMPOSE_YML }}" | base64 -d > docker-compose.yml
+
+ - name: Run docker
+ run: docker compose -f "docker-compose.yml" up -d mysql-sandbox redis-sandbox
+
+ - name: Wait for MySQL
+ run: |
+ while ! docker exec mysql-sandbox mysqladmin ping -h localhost --silent; do
+ echo "Waiting for MySQL..."
+ sleep 2
+ done
+
+ - name: Make application.yml
+ run: |
+ mkdir -p ./src/main/resources
+ cd ./src/main/resources
+ echo "${{ secrets.APPLICATION_YML }}" | base64 -d > application.yml
+
+ - name: Test with Gradle
+ run: ./gradlew clean test
+
+ - name: Build with Gradle
+ run: ./gradlew clean build -x test
diff --git a/.github/workflows/backend-check-style.yml b/.github/workflows/backend-check-style.yml
new file mode 100644
index 0000000..b485a42
--- /dev/null
+++ b/.github/workflows/backend-check-style.yml
@@ -0,0 +1,30 @@
+name: π Backend Check Style
+run-name: ${{ github.actor }} is Check Style π
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+
+defaults:
+ run:
+ working-directory: ./backend-board
+
+permissions:
+ contents: read
+
+jobs:
+ check-style:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-java@v4
+ with:
+ distribution: 'temurin'
+ java-version: '21'
+ - name: Main checkstyle
+ run: ./gradlew --console verbose clean checkstyleMain
+ - name: οΈTest checkstyle
+ run: ./gradlew --console verbose clean checkstyleTest
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..038a0a8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+docker-compose.yml
+/nginx
\ No newline at end of file
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..13566b8
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/backend-board/.gitignore b/backend-board/.gitignore
index 9fde395..4347661 100644
--- a/backend-board/.gitignore
+++ b/backend-board/.gitignore
@@ -26,6 +26,7 @@ out/
!**/src/main/**/out/
!**/src/test/**/out/
/src/main/resources/application.yml
+/src/test/resources/application.yml
### NetBeans ###
/nbproject/private/
diff --git a/backend-board/build.gradle b/backend-board/build.gradle
index 43f4702..014ad9c 100644
--- a/backend-board/build.gradle
+++ b/backend-board/build.gradle
@@ -1,42 +1,57 @@
plugins {
- id 'java'
- id 'org.springframework.boot' version '3.4.4'
- id 'io.spring.dependency-management' version '1.1.7'
+ id 'java'
+ id 'org.springframework.boot' version '3.4.4'
+ id 'io.spring.dependency-management' version '1.1.7'
+ id 'checkstyle'
+}
+
+checkstyle {
+ maxWarnings = 0
+ configFile = file("${rootDir}/naver-checkstyle-rules.xml")
+ configProperties = ["suppressionFile": "${rootDir}/naver-checkstyle-suppressions.xml"]
+ toolVersion = "10.21.3"
}
group = 'com'
version = '0.0.1-SNAPSHOT'
java {
- toolchain {
- languageVersion = JavaLanguageVersion.of(21)
- }
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(21)
+ }
}
configurations {
- compileOnly {
- extendsFrom annotationProcessor
- }
+ compileOnly {
+ extendsFrom annotationProcessor
+ }
}
repositories {
- mavenCentral()
+ mavenCentral()
}
dependencies {
- //implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
- //implementation 'org.springframework.boot:spring-boot-starter-data-redis'
- implementation 'org.springframework.boot:spring-boot-starter-security'
- implementation 'org.springframework.boot:spring-boot-starter-validation'
- implementation 'org.springframework.boot:spring-boot-starter-web'
- compileOnly 'org.projectlombok:lombok'
- //runtimeOnly 'com.mysql:mysql-connector-j'
- annotationProcessor 'org.projectlombok:lombok'
- testImplementation 'org.springframework.boot:spring-boot-starter-test'
- testImplementation 'org.springframework.security:spring-security-test'
- testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+ implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
+ runtimeOnly 'com.mysql:mysql-connector-j'
+ implementation 'org.springframework.boot:spring-boot-starter-data-redis'
+ implementation 'org.springframework.boot:spring-boot-starter-security'
+ implementation 'org.springframework.boot:spring-boot-starter-validation'
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+ compileOnly 'org.projectlombok:lombok'
+ annotationProcessor 'org.projectlombok:lombok'
+ testCompileOnly 'org.projectlombok:lombok'
+ testAnnotationProcessor 'org.projectlombok:lombok'
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ testImplementation 'org.springframework.security:spring-security-test'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+ implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0'
}
tasks.named('test') {
- useJUnitPlatform()
+ useJUnitPlatform()
+}
+
+test {
+ jvmArgs = ["-javaagent:${configurations.testRuntimeClasspath.find { it.name.contains('mockito-core') }}"]
}
diff --git a/backend-board/naver-checkstyle-rules.xml b/backend-board/naver-checkstyle-rules.xml
new file mode 100644
index 0000000..a6e5eb3
--- /dev/null
+++ b/backend-board/naver-checkstyle-rules.xml
@@ -0,0 +1,440 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend-board/naver-checkstyle-suppressions.xml b/backend-board/naver-checkstyle-suppressions.xml
new file mode 100644
index 0000000..3f11e0c
--- /dev/null
+++ b/backend-board/naver-checkstyle-suppressions.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/backend-board/naver-intellij-formatter.xml b/backend-board/naver-intellij-formatter.xml
new file mode 100644
index 0000000..658fc65
--- /dev/null
+++ b/backend-board/naver-intellij-formatter.xml
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend-board/src/main/java/com/backendboard/domain/user/controller/UserController.java b/backend-board/src/main/java/com/backendboard/domain/user/controller/UserController.java
new file mode 100644
index 0000000..c060dd9
--- /dev/null
+++ b/backend-board/src/main/java/com/backendboard/domain/user/controller/UserController.java
@@ -0,0 +1,41 @@
+package com.backendboard.domain.user.controller;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.backendboard.domain.user.dto.JoinRequest;
+import com.backendboard.domain.user.dto.JoinResponse;
+import com.backendboard.domain.user.service.UserService;
+import com.backendboard.global.error.dto.ErrorResponse;
+
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+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.validation.Valid;
+import lombok.RequiredArgsConstructor;
+
+@Tag(name = "νμ", description = "νμ κ΄λ ¨ API")
+@RestController
+@RequiredArgsConstructor
+public class UserController {
+ private final UserService userService;
+
+ @ApiResponses({
+ @ApiResponse(responseCode = "201", description = "201 μ±κ³΅",
+ content = @Content(mediaType = "application/json", schema = @Schema(implementation = JoinResponse.class))),
+ @ApiResponse(responseCode = "UR100", description = "403 μμ΄λκ° μ€λ³΅μ
λλ€.",
+ content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(responseCode = "UR101", description = "403 λλ€μμ΄ μ€λ³΅μ
λλ€.",
+ content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))),
+ })
+ @PostMapping("/join")
+ public ResponseEntity join(@RequestBody @Valid JoinRequest request) {
+ JoinResponse response = userService.joinProcess(request);
+ return ResponseEntity.status(HttpStatus.CREATED).body(response);
+ }
+}
diff --git a/backend-board/src/main/java/com/backendboard/domain/user/dto/JoinRequest.java b/backend-board/src/main/java/com/backendboard/domain/user/dto/JoinRequest.java
new file mode 100644
index 0000000..2bc3e7f
--- /dev/null
+++ b/backend-board/src/main/java/com/backendboard/domain/user/dto/JoinRequest.java
@@ -0,0 +1,43 @@
+package com.backendboard.domain.user.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Schema(description = "νμκ°μ
μμ² DTO")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class JoinRequest {
+
+ @Schema(description = "λ‘κ·ΈμΈ μμ΄λ", example = "user")
+ @NotBlank
+ @Size(max = 30)
+ private String loginId;
+
+ @Schema(description = "λ‘κ·ΈμΈ λΉλ°λ²νΈ", example = "1234")
+ @NotBlank
+ @Size(max = 30)
+ private String password;
+
+ @Schema(description = "μ μ μ μ΄λ¦", example = "κ°μ")
+ @NotBlank
+ @Size(max = 30)
+ private String username;
+
+ @Schema(description = "λλ€μ", example = "λ°°κ³ νκ°μ")
+ @NotBlank
+ @Size(max = 30)
+ private String nickname;
+
+ @Builder
+ private JoinRequest(String loginId, String password, String username, String nickname) {
+ this.loginId = loginId;
+ this.password = password;
+ this.username = username;
+ this.nickname = nickname;
+ }
+}
diff --git a/backend-board/src/main/java/com/backendboard/domain/user/dto/JoinResponse.java b/backend-board/src/main/java/com/backendboard/domain/user/dto/JoinResponse.java
new file mode 100644
index 0000000..758b99d
--- /dev/null
+++ b/backend-board/src/main/java/com/backendboard/domain/user/dto/JoinResponse.java
@@ -0,0 +1,43 @@
+package com.backendboard.domain.user.dto;
+
+import com.backendboard.domain.user.entitiy.User;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Schema(description = "νμκ°μ
μλ΅ DTO")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class JoinResponse {
+ @Schema(description = "νμ κ³ μ λ²νΈ", example = "1")
+ private Long id;
+
+ @Schema(description = "λ‘κ·ΈμΈ μμ΄λ", example = "user")
+ private String loginId;
+
+ @Schema(description = "μ μ μ μ΄λ¦", example = "κ°μ")
+ private String username;
+
+ @Schema(description = "λλ€μ", example = "λ°°κ³ νκ°μ")
+ private String nickname;
+
+ @Builder
+ private JoinResponse(Long id, String loginId, String username, String nickname) {
+ this.id = id;
+ this.loginId = loginId;
+ this.username = username;
+ this.nickname = nickname;
+ }
+
+ public static JoinResponse toDto(User user) {
+ return builder()
+ .id(user.getId())
+ .loginId(user.getAuthUser().getUsername())
+ .username(user.getUsername())
+ .nickname(user.getNickname())
+ .build();
+ }
+}
diff --git a/backend-board/src/main/java/com/backendboard/domain/user/entitiy/AuthUser.java b/backend-board/src/main/java/com/backendboard/domain/user/entitiy/AuthUser.java
new file mode 100644
index 0000000..c0ba726
--- /dev/null
+++ b/backend-board/src/main/java/com/backendboard/domain/user/entitiy/AuthUser.java
@@ -0,0 +1,55 @@
+package com.backendboard.domain.user.entitiy;
+
+import com.backendboard.domain.user.entitiy.type.UserRole;
+import com.backendboard.global.entity.BaseEntity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class AuthUser extends BaseEntity {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(nullable = false, unique = true)
+ private String username;
+
+ @Column(nullable = false)
+ private String password;
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false, columnDefinition = "VARCHAR(100)")
+ private UserRole role;
+
+ private boolean status;
+
+ public AuthUser(String username, String password, UserRole role) {
+ this.username = username;
+ this.password = password;
+ this.role = role;
+ this.status = true;
+ }
+
+ public User createUser(String username, String nickname) {
+ return User.builder()
+ .username(username)
+ .nickname(nickname)
+ .authUser(this)
+ .build();
+ }
+
+ public void deactivate() {
+ this.status = false;
+ }
+}
diff --git a/backend-board/src/main/java/com/backendboard/domain/user/entitiy/User.java b/backend-board/src/main/java/com/backendboard/domain/user/entitiy/User.java
new file mode 100644
index 0000000..6f31544
--- /dev/null
+++ b/backend-board/src/main/java/com/backendboard/domain/user/entitiy/User.java
@@ -0,0 +1,42 @@
+package com.backendboard.domain.user.entitiy;
+
+import com.backendboard.global.entity.BaseEntity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.OneToOne;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class User extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(nullable = false)
+ private String username;
+
+ @Column(nullable = false, unique = true)
+ private String nickname;
+
+ @OneToOne
+ @JoinColumn(name = "auth_user_id")
+ private AuthUser authUser;
+
+ @Builder
+ private User(String username, String nickname, AuthUser authUser) {
+ this.username = username;
+ this.nickname = nickname;
+ this.authUser = authUser;
+ }
+}
diff --git a/backend-board/src/main/java/com/backendboard/domain/user/entitiy/type/UserRole.java b/backend-board/src/main/java/com/backendboard/domain/user/entitiy/type/UserRole.java
new file mode 100644
index 0000000..e84309a
--- /dev/null
+++ b/backend-board/src/main/java/com/backendboard/domain/user/entitiy/type/UserRole.java
@@ -0,0 +1,15 @@
+package com.backendboard.domain.user.entitiy.type;
+
+import lombok.Getter;
+
+@Getter
+public enum UserRole {
+ ADMIN("ROLE_ADMIN"),
+ USER("ROLE_USER");
+
+ private final String value;
+
+ UserRole(String value) {
+ this.value = value;
+ }
+}
diff --git a/backend-board/src/main/java/com/backendboard/domain/user/repository/AuthUserRepository.java b/backend-board/src/main/java/com/backendboard/domain/user/repository/AuthUserRepository.java
new file mode 100644
index 0000000..2911908
--- /dev/null
+++ b/backend-board/src/main/java/com/backendboard/domain/user/repository/AuthUserRepository.java
@@ -0,0 +1,11 @@
+package com.backendboard.domain.user.repository;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import com.backendboard.domain.user.entitiy.AuthUser;
+
+@Repository
+public interface AuthUserRepository extends JpaRepository {
+ boolean existsByUsername(String username);
+}
diff --git a/backend-board/src/main/java/com/backendboard/domain/user/repository/UserRepository.java b/backend-board/src/main/java/com/backendboard/domain/user/repository/UserRepository.java
new file mode 100644
index 0000000..be5447e
--- /dev/null
+++ b/backend-board/src/main/java/com/backendboard/domain/user/repository/UserRepository.java
@@ -0,0 +1,11 @@
+package com.backendboard.domain.user.repository;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import com.backendboard.domain.user.entitiy.User;
+
+@Repository
+public interface UserRepository extends JpaRepository {
+ boolean existsByNickname(String nickName);
+}
diff --git a/backend-board/src/main/java/com/backendboard/domain/user/service/UserService.java b/backend-board/src/main/java/com/backendboard/domain/user/service/UserService.java
new file mode 100644
index 0000000..d8ddc25
--- /dev/null
+++ b/backend-board/src/main/java/com/backendboard/domain/user/service/UserService.java
@@ -0,0 +1,8 @@
+package com.backendboard.domain.user.service;
+
+import com.backendboard.domain.user.dto.JoinRequest;
+import com.backendboard.domain.user.dto.JoinResponse;
+
+public interface UserService {
+ JoinResponse joinProcess(JoinRequest request);
+}
diff --git a/backend-board/src/main/java/com/backendboard/domain/user/service/UserServiceImpl.java b/backend-board/src/main/java/com/backendboard/domain/user/service/UserServiceImpl.java
new file mode 100644
index 0000000..6d8eaaa
--- /dev/null
+++ b/backend-board/src/main/java/com/backendboard/domain/user/service/UserServiceImpl.java
@@ -0,0 +1,51 @@
+package com.backendboard.domain.user.service;
+
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.backendboard.domain.user.dto.JoinRequest;
+import com.backendboard.domain.user.dto.JoinResponse;
+import com.backendboard.domain.user.entitiy.AuthUser;
+import com.backendboard.domain.user.entitiy.User;
+import com.backendboard.domain.user.entitiy.type.UserRole;
+import com.backendboard.domain.user.repository.AuthUserRepository;
+import com.backendboard.domain.user.repository.UserRepository;
+import com.backendboard.global.error.CustomError;
+import com.backendboard.global.error.CustomException;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+public class UserServiceImpl implements UserService {
+ private final UserRepository userRepository;
+ private final AuthUserRepository authUserRepository;
+ private final BCryptPasswordEncoder bCryptPasswordEncoder;
+
+ @Transactional
+ @Override
+ public JoinResponse joinProcess(JoinRequest request) {
+ validateDuplicationId(request.getLoginId());
+ validateDuplicationNickname(request.getNickname());
+
+ AuthUser authUser = new AuthUser(request.getLoginId(),
+ bCryptPasswordEncoder.encode(request.getPassword()), UserRole.USER);
+ User user = authUser.createUser(request.getUsername(), request.getNickname());
+ authUserRepository.save(authUser);
+ userRepository.save(user);
+ return JoinResponse.toDto(user);
+ }
+
+ public void validateDuplicationNickname(String nickname) {
+ if (userRepository.existsByNickname(nickname)) {
+ throw new CustomException(CustomError.USER_DUPLICATION_NICKNAME);
+ }
+ }
+
+ public void validateDuplicationId(String loginId) {
+ if (authUserRepository.existsByUsername(loginId)) {
+ throw new CustomException(CustomError.USER_DUPLICATION_ID);
+ }
+ }
+}
diff --git a/backend-board/src/main/java/com/backendboard/global/config/SecurityConfig.java b/backend-board/src/main/java/com/backendboard/global/config/SecurityConfig.java
new file mode 100644
index 0000000..e14fa6f
--- /dev/null
+++ b/backend-board/src/main/java/com/backendboard/global/config/SecurityConfig.java
@@ -0,0 +1,47 @@
+package com.backendboard.global.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
+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.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+
+import lombok.RequiredArgsConstructor;
+
+@Configuration
+@EnableWebSecurity
+@RequiredArgsConstructor
+public class SecurityConfig {
+ private final AuthenticationConfiguration authenticationConfiguration;
+
+ @Bean
+ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+ return http
+ .csrf(AbstractHttpConfigurer::disable)
+ .formLogin(AbstractHttpConfigurer::disable)
+ .authorizeHttpRequests((auth) -> auth
+ .requestMatchers("/login", "/join", "/swagger-ui/**", "/swagger-resources/**",
+ "/v3/api-docs/**")
+ .permitAll()
+ .anyRequest()
+ .authenticated())
+ .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .build();
+ }
+
+ @Bean
+ public BCryptPasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+
+ @Bean
+ public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
+ return configuration.getAuthenticationManager();
+ }
+}
diff --git a/backend-board/src/main/java/com/backendboard/global/config/SwaggerConfig.java b/backend-board/src/main/java/com/backendboard/global/config/SwaggerConfig.java
new file mode 100644
index 0000000..6c0a6b2
--- /dev/null
+++ b/backend-board/src/main/java/com/backendboard/global/config/SwaggerConfig.java
@@ -0,0 +1,29 @@
+package com.backendboard.global.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import io.swagger.v3.oas.models.Components;
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.info.Info;
+import io.swagger.v3.oas.models.security.SecurityScheme;
+
+@Configuration
+public class SwaggerConfig {
+ @Bean
+ public OpenAPI openAPI() {
+ SecurityScheme securityScheme = new SecurityScheme()
+ .type(SecurityScheme.Type.HTTP)
+ .scheme("bearer")
+ .bearerFormat("JWT")
+ .in(SecurityScheme.In.HEADER)
+ .name("Authorization");
+
+ return new OpenAPI()
+ .components(new Components().addSecuritySchemes("bearerAuth", securityScheme))
+ .info(new Info()
+ .title("μλλ°μ€ API λ¬Έμ")
+ .description("μλλ°μ€ API λͺ
μΈμ")
+ .version("1.0.0"));
+ }
+}
diff --git a/backend-board/src/main/java/com/backendboard/global/entity/BaseEntity.java b/backend-board/src/main/java/com/backendboard/global/entity/BaseEntity.java
new file mode 100644
index 0000000..5617c54
--- /dev/null
+++ b/backend-board/src/main/java/com/backendboard/global/entity/BaseEntity.java
@@ -0,0 +1,20 @@
+package com.backendboard.global.entity;
+
+import java.time.LocalDateTime;
+
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import jakarta.persistence.EntityListeners;
+import jakarta.persistence.MappedSuperclass;
+
+@MappedSuperclass
+@EntityListeners(AuditingEntityListener.class)
+public class BaseEntity {
+ @CreatedDate
+ private LocalDateTime createdAt;
+
+ @LastModifiedDate
+ private LocalDateTime lastModifiedAt;
+}
diff --git a/backend-board/src/main/java/com/backendboard/global/error/CustomError.java b/backend-board/src/main/java/com/backendboard/global/error/CustomError.java
new file mode 100644
index 0000000..45794eb
--- /dev/null
+++ b/backend-board/src/main/java/com/backendboard/global/error/CustomError.java
@@ -0,0 +1,21 @@
+package com.backendboard.global.error;
+
+import org.springframework.http.HttpStatus;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public enum CustomError {
+ //μΈμ¦ μ μ μλ¬
+ AUTH_USER_NOT_FOUND_ID(HttpStatus.NOT_FOUND, "AU100", "μ‘΄μ¬νμ§ μλ μμ΄λ μ
λλ€."),
+
+ //μ μ μλ¬
+ USER_DUPLICATION_ID(HttpStatus.FORBIDDEN, "UR100", "μ€λ³΅λ μμ΄λ μ
λλ€."),
+ USER_DUPLICATION_NICKNAME(HttpStatus.FORBIDDEN, "UR101", "μ€λ³΅λ λλ€μ μ
λλ€.");
+
+ private final HttpStatus status;
+ private final String code;
+ private final String message;
+}
diff --git a/backend-board/src/main/java/com/backendboard/global/error/CustomException.java b/backend-board/src/main/java/com/backendboard/global/error/CustomException.java
new file mode 100644
index 0000000..5b6f68b
--- /dev/null
+++ b/backend-board/src/main/java/com/backendboard/global/error/CustomException.java
@@ -0,0 +1,10 @@
+package com.backendboard.global.error;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public class CustomException extends RuntimeException {
+ private final CustomError error;
+}
diff --git a/backend-board/src/main/java/com/backendboard/global/error/controller/CustomExceptionHandler.java b/backend-board/src/main/java/com/backendboard/global/error/controller/CustomExceptionHandler.java
new file mode 100644
index 0000000..2e7c7b5
--- /dev/null
+++ b/backend-board/src/main/java/com/backendboard/global/error/controller/CustomExceptionHandler.java
@@ -0,0 +1,16 @@
+package com.backendboard.global.error.controller;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+
+import com.backendboard.global.error.CustomException;
+import com.backendboard.global.error.dto.ErrorResponse;
+
+@ControllerAdvice
+public class CustomExceptionHandler {
+ @ExceptionHandler(CustomException.class)
+ protected ResponseEntity handleCustomException(final CustomException exception) {
+ return ErrorResponse.toResponseEntity(exception);
+ }
+}
diff --git a/backend-board/src/main/java/com/backendboard/global/error/dto/ErrorResponse.java b/backend-board/src/main/java/com/backendboard/global/error/dto/ErrorResponse.java
new file mode 100644
index 0000000..34ee24e
--- /dev/null
+++ b/backend-board/src/main/java/com/backendboard/global/error/dto/ErrorResponse.java
@@ -0,0 +1,22 @@
+package com.backendboard.global.error.dto;
+
+import org.springframework.http.ResponseEntity;
+
+import com.backendboard.global.error.CustomException;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Schema(description = "μλ¬ μλ΅ DTO")
+@Getter
+@AllArgsConstructor
+public class ErrorResponse {
+ @Schema(description = "μλ¬ λ©μμ§", example = "μλ¬κ° λ°μνμ΅λλ€.")
+ private final String message;
+
+ public static ResponseEntity toResponseEntity(final CustomException exception) {
+ return ResponseEntity.status(exception.getError().getStatus())
+ .body(new ErrorResponse(exception.getError().getMessage()));
+ }
+}
diff --git a/backend-board/src/test/java/com/backendboard/domain/user/UserMockData.java b/backend-board/src/test/java/com/backendboard/domain/user/UserMockData.java
new file mode 100644
index 0000000..6cd18f4
--- /dev/null
+++ b/backend-board/src/test/java/com/backendboard/domain/user/UserMockData.java
@@ -0,0 +1,29 @@
+package com.backendboard.domain.user;
+
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.stereotype.Component;
+
+import com.backendboard.domain.user.dto.JoinRequest;
+import com.backendboard.domain.user.entitiy.AuthUser;
+import com.backendboard.domain.user.entitiy.User;
+import com.backendboard.domain.user.entitiy.type.UserRole;
+import com.backendboard.domain.user.repository.AuthUserRepository;
+import com.backendboard.domain.user.repository.UserRepository;
+
+import lombok.RequiredArgsConstructor;
+
+@Component
+@RequiredArgsConstructor
+public class UserMockData {
+ private final AuthUserRepository authUserRepository;
+ private final UserRepository userRepository;
+ private final BCryptPasswordEncoder bCryptPasswordEncoder;
+
+ public User createUserAndAuthUser(JoinRequest request) {
+ AuthUser authUser = new AuthUser(request.getLoginId(),
+ bCryptPasswordEncoder.encode(request.getPassword()), UserRole.USER);
+ User user = authUser.createUser(request.getUsername(), request.getNickname());
+ authUserRepository.save(authUser);
+ return userRepository.save(user);
+ }
+}
diff --git a/backend-board/src/test/java/com/backendboard/domain/user/controller/UserControllerTest.java b/backend-board/src/test/java/com/backendboard/domain/user/controller/UserControllerTest.java
new file mode 100644
index 0000000..fc82887
--- /dev/null
+++ b/backend-board/src/test/java/com/backendboard/domain/user/controller/UserControllerTest.java
@@ -0,0 +1,94 @@
+package com.backendboard.domain.user.controller;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.backendboard.domain.user.UserMockData;
+import com.backendboard.domain.user.dto.JoinRequest;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+class UserControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @Autowired
+ private UserMockData userMockData;
+
+ @Nested
+ @DisplayName("νμκ°μ
API ν
μ€νΈ")
+ class JoinTest {
+ private JoinRequest joinRequest;
+
+ @BeforeEach
+ void setUp() {
+ joinRequest = JoinRequest.builder()
+ .loginId("testId")
+ .password("1234")
+ .username("potato")
+ .nickname("testNick")
+ .build();
+ }
+
+ @Transactional
+ @Test
+ @DisplayName("μ±κ³΅ 201")
+ void success() throws Exception {
+ // given
+
+ // when & then
+ mockMvc.perform(post("/join")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(joinRequest)))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.loginId").value("testId"))
+ .andExpect(jsonPath("$.username").value("potato"))
+ .andExpect(jsonPath("$.nickname").value("testNick"));
+ }
+
+ @Transactional
+ @Test
+ @DisplayName("μμ΄λ μ€λ³΅ μ€ν¨ 403")
+ void duplicateIdFailure() throws Exception {
+ // given
+ userMockData.createUserAndAuthUser(joinRequest);
+
+ // when & then
+ mockMvc.perform(post("/join")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(joinRequest)))
+ .andExpect(status().isForbidden());
+ }
+
+ @Transactional
+ @Test
+ @DisplayName("λλ€μ μ€λ³΅ μ€ν¨ 403")
+ void duplicateNicknameFailure() throws Exception {
+ // given
+ userMockData.createUserAndAuthUser(joinRequest);
+
+ // when & then
+ mockMvc.perform(post("/join")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(joinRequest)))
+ .andExpect(status().isForbidden());
+ }
+
+ }
+}
diff --git a/backend-board/src/test/java/com/backendboard/domain/user/repository/AuthUserRepositoryTest.java b/backend-board/src/test/java/com/backendboard/domain/user/repository/AuthUserRepositoryTest.java
new file mode 100644
index 0000000..5240208
--- /dev/null
+++ b/backend-board/src/test/java/com/backendboard/domain/user/repository/AuthUserRepositoryTest.java
@@ -0,0 +1,39 @@
+package com.backendboard.domain.user.repository;
+
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
+import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
+
+import com.backendboard.domain.user.entitiy.AuthUser;
+import com.backendboard.domain.user.entitiy.type.UserRole;
+
+@DataJpaTest
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+class AuthUserRepositoryTest {
+ @Autowired
+ AuthUserRepository authUserRepository;
+
+ @Nested
+ @DisplayName("existsByUsername λ©μλ ν
μ€νΈ")
+ class ExistsByUsernameMethodTest {
+ @Test
+ @DisplayName("usernameμ΄ μ€λ³΅μ΄λ©΄ true λ°ν")
+ void returnsTrueWhenUsernameExists() {
+ //given
+ String username = "potato";
+ AuthUser authUser = new AuthUser(username, "1234", UserRole.USER);
+ authUserRepository.save(authUser);
+
+ //when
+ boolean exists = authUserRepository.existsByUsername(username);
+
+ //then
+ Assertions.assertThat(exists).isTrue();
+ }
+ }
+
+}
diff --git a/backend-board/src/test/java/com/backendboard/domain/user/service/UserServiceImplTest.java b/backend-board/src/test/java/com/backendboard/domain/user/service/UserServiceImplTest.java
new file mode 100644
index 0000000..76dd8c8
--- /dev/null
+++ b/backend-board/src/test/java/com/backendboard/domain/user/service/UserServiceImplTest.java
@@ -0,0 +1,153 @@
+package com.backendboard.domain.user.service;
+
+import static org.assertj.core.api.AssertionsForClassTypes.*;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+
+import com.backendboard.domain.user.dto.JoinRequest;
+import com.backendboard.domain.user.dto.JoinResponse;
+import com.backendboard.domain.user.entitiy.AuthUser;
+import com.backendboard.domain.user.entitiy.User;
+import com.backendboard.domain.user.entitiy.type.UserRole;
+import com.backendboard.domain.user.repository.AuthUserRepository;
+import com.backendboard.domain.user.repository.UserRepository;
+import com.backendboard.global.error.CustomError;
+import com.backendboard.global.error.CustomException;
+
+@ExtendWith(MockitoExtension.class)
+class UserServiceImplTest {
+ @Mock
+ private UserRepository userRepository;
+
+ @Mock
+ private AuthUserRepository authUserRepository;
+
+ @Mock
+ private BCryptPasswordEncoder bCryptPasswordEncoder;
+
+ @InjectMocks
+ private UserServiceImpl userService;
+
+ @Nested
+ @DisplayName("νμκ°μ
νλ‘μΈμ€ ν
μ€νΈ")
+ class JoinProcessTest {
+
+ private JoinRequest joinRequest;
+ private User user;
+ private AuthUser authUser;
+
+ @BeforeEach
+ void setUp() {
+ joinRequest = JoinRequest.builder()
+ .loginId("testId")
+ .password("1234")
+ .username("potato")
+ .nickname("testNick")
+ .build();
+ authUser = new AuthUser("potato", "encoded_password", UserRole.USER);
+ user = authUser.createUser("testId", "testNick");
+ }
+
+ @Test
+ @DisplayName("νμκ°μ
μμ² μ±κ³΅")
+ void success() {
+ // given
+ when(userRepository.existsByNickname(anyString())).thenReturn(false);
+ when(authUserRepository.existsByUsername(anyString())).thenReturn(false);
+ when(bCryptPasswordEncoder.encode(anyString())).thenReturn("encoded_password");
+
+ when(userRepository.save(any(User.class))).thenReturn(user);
+ when(authUserRepository.save(any(AuthUser.class))).thenReturn(authUser);
+
+ // when
+ JoinResponse response = userService.joinProcess(joinRequest);
+
+ // then
+ verify(userRepository).existsByNickname("testNick");
+ verify(authUserRepository).existsByUsername("testId");
+ verify(bCryptPasswordEncoder).encode("1234");
+ verify(userRepository).save(any(User.class));
+ verify(authUserRepository).save(any(AuthUser.class));
+
+ assertThat(response).isNotNull();
+ }
+
+ @Test
+ @DisplayName("μ€λ³΅λ μμ΄λκ° μμΌλ©΄ μμΈ λ°μ")
+ void throwsExceptionWhenIdDuplicated() {
+ // given
+ when(authUserRepository.existsByUsername(anyString())).thenReturn(true);
+
+ // when & then
+ CustomException exception = assertThrows(CustomException.class,
+ () -> userService.joinProcess(joinRequest));
+
+ assertThat(exception.getError()).isEqualTo(CustomError.USER_DUPLICATION_ID);
+ verify(authUserRepository).existsByUsername("testId");
+ verify(userRepository, never()).existsByNickname(anyString());
+ verify(authUserRepository, never()).save(any());
+ verify(userRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("μ€λ³΅λ λλ€μμ΄ μμΌλ©΄ μμΈ λ°μ")
+ void throwsExceptionWhenNicknameDuplicated() {
+ // given
+ when(authUserRepository.existsByUsername(anyString())).thenReturn(false);
+ when(userRepository.existsByNickname(anyString())).thenReturn(true);
+
+ // when & then
+ CustomException exception = assertThrows(CustomException.class,
+ () -> userService.joinProcess(joinRequest));
+
+ assertThat(exception.getError()).isEqualTo(CustomError.USER_DUPLICATION_NICKNAME);
+ verify(authUserRepository).existsByUsername("testId");
+ verify(userRepository).existsByNickname("testNick");
+ verify(authUserRepository, never()).save(any());
+ verify(userRepository, never()).save(any());
+ }
+ }
+
+ @Nested
+ @DisplayName("κ²μ¦ ν
μ€νΈ")
+ class ValidationTest {
+
+ @Test
+ @DisplayName("μ€λ³΅λ λλ€μ κ²μ¦ μ μμΈ λ°μ")
+ void validateDuplicationNickname() {
+ // given
+ String nickname = "testNick";
+ when(userRepository.existsByNickname(nickname)).thenReturn(true);
+
+ // when & then
+ CustomException exception = assertThrows(CustomException.class,
+ () -> userService.validateDuplicationNickname(nickname));
+
+ assertThat(exception.getError()).isEqualTo(CustomError.USER_DUPLICATION_NICKNAME);
+ }
+
+ @Test
+ @DisplayName("μ€λ³΅λ μμ΄λ κ²μ¦ μ μμΈ λ°μ")
+ void validateDuplicationId() {
+ // given
+ String loginId = "testId";
+ when(authUserRepository.existsByUsername(loginId)).thenReturn(true);
+
+ // when & then
+ CustomException exception = assertThrows(CustomException.class,
+ () -> userService.validateDuplicationId(loginId));
+
+ assertThat(exception.getError()).isEqualTo(CustomError.USER_DUPLICATION_ID);
+ }
+ }
+}