diff --git a/.github/workflows/open-api-code-review.yml b/.github/workflows/open-api-code-review.yml
new file mode 100644
index 000000000..c35fd3472
--- /dev/null
+++ b/.github/workflows/open-api-code-review.yml
@@ -0,0 +1,19 @@
+name: Code Review
+
+permissions:
+ contents: read
+ pull-requests: write
+
+on:
+ pull_request:
+ types: [ opened ]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: anc95/ChatGPT-CodeReview@main
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ OPENAI_API_KEY: ${{ secrets.OPEN_API_KEY }}
+ LANGUAGE: Korean
diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/service/StorageService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/service/StorageService.java
new file mode 100644
index 000000000..979ca9174
--- /dev/null
+++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/service/StorageService.java
@@ -0,0 +1,19 @@
+package kr.co.pennyway.api.apis.storage.service;
+
+import kr.co.pennyway.infra.client.aws.s3.AwsS3Provider;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.net.URI;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class StorageService {
+ private final AwsS3Provider awsS3Provider;
+
+ public URI getPresignedUrl(String type, String ext, String userId, String chatroomId) {
+ return awsS3Provider.generatedPresignedUrl(type, ext, userId, chatroomId);
+ }
+}
diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceTokenRegisterService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceTokenRegisterService.java
new file mode 100644
index 000000000..4a173cb38
--- /dev/null
+++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceTokenRegisterService.java
@@ -0,0 +1,47 @@
+package kr.co.pennyway.api.apis.users.service;
+
+import kr.co.pennyway.domain.domains.device.domain.DeviceToken;
+import kr.co.pennyway.domain.domains.device.service.DeviceTokenService;
+import kr.co.pennyway.domain.domains.user.domain.User;
+import kr.co.pennyway.domain.domains.user.exception.UserErrorCode;
+import kr.co.pennyway.domain.domains.user.exception.UserErrorException;
+import kr.co.pennyway.domain.domains.user.service.UserService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Optional;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class DeviceTokenRegisterService {
+ private final UserService userService;
+ private final DeviceTokenService deviceTokenService;
+
+ @Transactional
+ public DeviceToken execute(Long userId, String token) {
+ User user = userService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND));
+
+ return getOrCreateDevice(user, token);
+ }
+
+ /**
+ * 사용자의 디바이스 토큰을 가져오거나 생성한다.
+ *
+ * 이미 등록된 디바이스 토큰인 경우 마지막 로그인 시간을 갱신한다.
+ */
+ private DeviceToken getOrCreateDevice(User user, String token) {
+ Optional deviceToken = deviceTokenService.readDeviceByUserIdAndToken(user.getId(), token);
+
+ if (deviceToken.isPresent()) {
+ DeviceToken device = deviceToken.get();
+ device.activate();
+ device.updateLastSignedInAt();
+ return device;
+ } else {
+ return deviceTokenService.createDevice(DeviceToken.of(token, user));
+ }
+ }
+}
diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/JwtClaimsParserUtil.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/JwtClaimsParserUtil.java
new file mode 100644
index 000000000..2109b844d
--- /dev/null
+++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/JwtClaimsParserUtil.java
@@ -0,0 +1,35 @@
+package kr.co.pennyway.api.common.security.jwt;
+
+import kr.co.pennyway.infra.common.jwt.JwtClaims;
+
+import java.util.function.Function;
+
+public class JwtClaimsParserUtil {
+ /**
+ * JwtClaims에서 key에 해당하는 값을 반환하는 메서드
+ *
+ * @return key에 해당하는 값이 없거나, 타입이 일치하지 않을 경우 null을 반환한다.
+ */
+ @SuppressWarnings("unchecked")
+ public static T getClaimsValue(JwtClaims claims, String key, Class type) {
+ Object value = claims.getClaims().get(key);
+ if (value != null && type.isAssignableFrom(value.getClass())) {
+ return (T) value;
+ }
+ return null;
+ }
+
+ /**
+ * JwtClaims에서 valueConverter를 이용하여 key에 해당하는 값을 반환하는 메서드
+ *
+ * @param valueConverter : String 타입의 값을 T 타입으로 변환하는 함수
+ * @return key에 해당하는 값이 없을 경우 null을 반환한다.
+ */
+ public static T getClaimsValue(JwtClaims claims, String key, Function valueConverter) {
+ Object value = claims.getClaims().get(key);
+ if (value != null) {
+ return valueConverter.apply((String) value);
+ }
+ return null;
+ }
+}
diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/service/NameUpdateServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/service/NameUpdateServiceTest.java
new file mode 100644
index 000000000..32036b723
--- /dev/null
+++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/service/NameUpdateServiceTest.java
@@ -0,0 +1,82 @@
+package kr.co.pennyway.api.apis.users.service;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import kr.co.pennyway.api.apis.auth.service.PhoneVerificationService;
+import kr.co.pennyway.api.config.ExternalApiDBTestConfig;
+import kr.co.pennyway.api.config.fixture.UserFixture;
+import kr.co.pennyway.domain.common.redis.phone.PhoneCodeService;
+import kr.co.pennyway.domain.config.JpaConfig;
+import kr.co.pennyway.domain.domains.user.domain.User;
+import kr.co.pennyway.domain.domains.user.exception.UserErrorCode;
+import kr.co.pennyway.domain.domains.user.exception.UserErrorException;
+import kr.co.pennyway.domain.domains.user.service.UserService;
+import kr.co.pennyway.infra.client.aws.s3.AwsS3Provider;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+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 org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.transaction.annotation.Transactional;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.springframework.test.util.AssertionErrors.assertEquals;
+
+@ExtendWith(MockitoExtension.class)
+@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create")
+@ContextConfiguration(classes = {JpaConfig.class, UserProfileUpdateService.class, UserService.class})
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+public class NameUpdateServiceTest extends ExternalApiDBTestConfig {
+ @Autowired
+ private UserService userService;
+
+ @Autowired
+ private UserProfileUpdateService userProfileUpdateService;
+
+ @MockBean
+ private AwsS3Provider awsS3Provider;
+
+ @MockBean
+ private PhoneVerificationService phoneVerificationService;
+
+ @MockBean
+ private PhoneCodeService phoneCodeService;
+
+ @MockBean
+ private JPAQueryFactory queryFactory;
+
+ @Test
+ @Transactional
+ @DisplayName("사용자가 삭제된 유저인 경우 NOT_FOUND 에러를 반환한다.")
+ void updateNameWhenUserIsDeleted() {
+ // given
+ String newName = "양재서";
+ User originUser = UserFixture.GENERAL_USER.toUser();
+ userService.createUser(originUser);
+ userService.deleteUser(originUser);
+
+ // when - then
+ UserErrorException ex = assertThrows(UserErrorException.class, () -> userProfileUpdateService.updateName(originUser.getId(), newName));
+ assertEquals("삭제된 사용자인 경우 Not Found를 반환한다.", UserErrorCode.NOT_FOUND, ex.getBaseErrorCode());
+ }
+
+ @Test
+ @Transactional
+ @DisplayName("사용자의 이름이 성공적으로 변경된다.")
+ void updateName() {
+ // given
+ User originUser = UserFixture.GENERAL_USER.toUser();
+ userService.createUser(originUser);
+ String newName = "양재서";
+
+ // when
+ userProfileUpdateService.updateName(originUser.getId(), newName);
+
+ // then
+ User updatedUser = userService.readUser(originUser.getId()).orElseThrow();
+ assertEquals("사용자 이름이 변경되어 있어야 한다.", newName, updatedUser.getName());
+ }
+}
diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenRegisterServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenRegisterServiceTest.java
new file mode 100644
index 000000000..4c99a9cd6
--- /dev/null
+++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenRegisterServiceTest.java
@@ -0,0 +1,115 @@
+package kr.co.pennyway.api.apis.users.usecase;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import kr.co.pennyway.api.apis.users.dto.DeviceTokenDto;
+import kr.co.pennyway.api.apis.users.service.DeviceTokenRegisterService;
+import kr.co.pennyway.api.config.ExternalApiDBTestConfig;
+import kr.co.pennyway.api.config.fixture.DeviceTokenFixture;
+import kr.co.pennyway.api.config.fixture.UserFixture;
+import kr.co.pennyway.domain.config.JpaConfig;
+import kr.co.pennyway.domain.domains.device.domain.DeviceToken;
+import kr.co.pennyway.domain.domains.device.service.DeviceTokenService;
+import kr.co.pennyway.domain.domains.user.domain.User;
+import kr.co.pennyway.domain.domains.user.service.UserService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+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 org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.transaction.annotation.Transactional;
+
+import static org.springframework.test.util.AssertionErrors.*;
+
+@ExtendWith(MockitoExtension.class)
+@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create")
+@ContextConfiguration(classes = {JpaConfig.class, DeviceTokenRegisterService.class, UserService.class, DeviceTokenService.class})
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+public class DeviceTokenRegisterServiceTest extends ExternalApiDBTestConfig {
+ @Autowired
+ private UserService userService;
+
+ @Autowired
+ private DeviceTokenService deviceTokenService;
+
+ @Autowired
+ private DeviceTokenRegisterService deviceTokenRegisterService;
+
+ @MockBean
+ private JPAQueryFactory queryFactory;
+
+ private User requestUser;
+
+ @BeforeEach
+ void setUp() {
+ requestUser = userService.createUser(UserFixture.GENERAL_USER.toUser());
+ }
+
+ @Test
+ @Transactional
+ @DisplayName("[1] token 등록 요청이 들어왔을 때, 새로운 디바이스 토큰을 등록한다.")
+ void registerNewDevice() {
+ // given
+ DeviceTokenDto.RegisterReq request = DeviceTokenFixture.INIT.toRegisterReq();
+
+ // when
+ DeviceToken response = deviceTokenRegisterService.execute(requestUser.getId(), request.token());
+
+ // then
+ deviceTokenService.readDeviceByUserIdAndToken(requestUser.getId(), request.token()).ifPresentOrElse(
+ device -> {
+ assertEquals("요청한 디바이스 토큰과 동일해야 한다.", response.getToken(), device.getToken());
+ assertEquals("디바이스 ID가 일치해야 한다.", response.getId(), device.getId());
+ assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId()));
+ System.out.println("device = " + device);
+ },
+ () -> fail("신규 디바이스가 등록되어 있어야 한다.")
+ );
+ }
+
+ @Test
+ @Transactional
+ @DisplayName("[2] token에 대한 활성화 디바이스 토큰이 이미 존재하는 경우 기존 데이터를 반환한다.")
+ void registerNewDeviceWhenDeviceIsAlreadyExists() {
+ // given
+ DeviceToken originDeviceToken = DeviceTokenFixture.INIT.toDevice(requestUser);
+ deviceTokenService.createDevice(originDeviceToken);
+ DeviceTokenDto.RegisterReq request = DeviceTokenFixture.INIT.toRegisterReq();
+
+ // when
+ DeviceToken response = deviceTokenRegisterService.execute(requestUser.getId(), request.token());
+
+ // then
+ deviceTokenService.readDeviceByUserIdAndToken(requestUser.getId(), request.token()).ifPresentOrElse(
+ device -> {
+ assertEquals("요청한 디바이스 토큰과 동일해야 한다.", response.getToken(), device.getToken());
+ assertEquals("디바이스 ID가 일치해야 한다.", originDeviceToken.getId(), device.getId());
+ assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId()));
+ assertTrue("디바이스가 활성화 상태여야 한다.", device.getActivated());
+ System.out.println("device = " + device);
+ },
+ () -> fail("신규 디바이스가 등록되어 있어야 한다.")
+ );
+ }
+
+ @Test
+ @Transactional
+ @DisplayName("[3] token 등록 요청이 들어왔을 때, 활성화되지 않은 디바이스 토큰이 존재하는 경우 토큰을 활성화 상태로 변경한다.")
+ void registerNewDeviceWhenDeviceIsNotActivated() {
+ // given
+ DeviceToken originDeviceToken = DeviceTokenFixture.INIT.toDevice(requestUser);
+ originDeviceToken.deactivate();
+ deviceTokenService.createDevice(originDeviceToken);
+ DeviceTokenDto.RegisterReq request = DeviceTokenFixture.INIT.toRegisterReq();
+
+ // when
+ DeviceToken response = deviceTokenRegisterService.execute(requestUser.getId(), request.token());
+
+ // then
+ assertTrue("디바이스가 활성화 상태여야 한다.", response.getActivated());
+ }
+}
diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenUnregisterServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenUnregisterServiceTest.java
new file mode 100644
index 000000000..07f02b0fb
--- /dev/null
+++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenUnregisterServiceTest.java
@@ -0,0 +1,83 @@
+package kr.co.pennyway.api.apis.users.usecase;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import kr.co.pennyway.api.apis.users.service.DeviceTokenUnregisterService;
+import kr.co.pennyway.api.config.ExternalApiDBTestConfig;
+import kr.co.pennyway.api.config.fixture.DeviceTokenFixture;
+import kr.co.pennyway.api.config.fixture.UserFixture;
+import kr.co.pennyway.domain.config.JpaConfig;
+import kr.co.pennyway.domain.domains.device.domain.DeviceToken;
+import kr.co.pennyway.domain.domains.device.exception.DeviceTokenErrorCode;
+import kr.co.pennyway.domain.domains.device.exception.DeviceTokenErrorException;
+import kr.co.pennyway.domain.domains.device.service.DeviceTokenService;
+import kr.co.pennyway.domain.domains.user.domain.User;
+import kr.co.pennyway.domain.domains.user.service.UserService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+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 org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.transaction.annotation.Transactional;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.springframework.test.util.AssertionErrors.assertEquals;
+import static org.springframework.test.util.AssertionErrors.assertFalse;
+
+@ExtendWith(MockitoExtension.class)
+@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create")
+@ContextConfiguration(classes = {JpaConfig.class, DeviceTokenUnregisterService.class, UserService.class, DeviceTokenService.class})
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+public class DeviceTokenUnregisterServiceTest extends ExternalApiDBTestConfig {
+ @Autowired
+ private UserService userService;
+
+ @Autowired
+ private DeviceTokenService deviceTokenService;
+
+ @Autowired
+ private DeviceTokenUnregisterService deviceTokenUnregisterService;
+
+ @MockBean
+ private JPAQueryFactory queryFactory;
+
+ private User requestUser;
+
+ @BeforeEach
+ void setUp() {
+ requestUser = userService.createUser(UserFixture.GENERAL_USER.toUser());
+ }
+
+ @Test
+ @Transactional
+ @DisplayName("사용자 ID와 origin token에 매칭되는 활성 디바이스가 존재하는 경우 디바이스를 비활성화한다.")
+ void unregisterDevice() {
+ // given
+ DeviceToken deviceToken = DeviceTokenFixture.INIT.toDevice(requestUser);
+ deviceTokenService.createDevice(deviceToken);
+
+ // when
+ deviceTokenUnregisterService.execute(requestUser.getId(), deviceToken.getToken());
+
+ // then
+ DeviceToken deletedDevice = deviceTokenService.readDeviceByUserIdAndToken(requestUser.getId(), deviceToken.getToken()).get();
+ assertFalse("디바이스가 비활성화 되어있어야 한다.", deletedDevice.isActivated());
+ }
+
+ @Test
+ @Transactional
+ @DisplayName("사용자 ID와 token에 매칭되는 디바이스가 존재하지 않는 경우 NOT_FOUND_DEVICE 에러를 반환한다.")
+ void unregisterDeviceWhenDeviceIsNotExists() {
+ // given
+ DeviceToken deviceToken = DeviceTokenFixture.INIT.toDevice(requestUser);
+ deviceTokenService.createDevice(deviceToken);
+
+ // when - then
+ DeviceTokenErrorException ex = assertThrows(DeviceTokenErrorException.class, () -> deviceTokenUnregisterService.execute(requestUser.getId(), "notExistsToken"));
+ assertEquals("디바이스 토큰이 존재하지 않으면 Not Found를 반환한다.", DeviceTokenErrorCode.NOT_FOUND_DEVICE, ex.getBaseErrorCode());
+ }
+}
diff --git a/pennyway-batch/Dockerfile b/pennyway-batch/Dockerfile
new file mode 100644
index 000000000..c9bf20fb6
--- /dev/null
+++ b/pennyway-batch/Dockerfile
@@ -0,0 +1,8 @@
+FROM openjdk:17
+ARG JAR_FILE=./build/libs/*.jar
+COPY ${JAR_FILE} app.jar
+
+ARG PROFILE=dev
+ENV PROFILE=${PROFILE}
+
+ENTRYPOINT ["java","-jar","/app.jar","--spring.profiles.active=${PROFILE}","-Djava.security.egd=file:/dev/./urandom","-Duser.timezone=Asia/Seoul"]
\ No newline at end of file
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/DomainPackageLocation.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/DomainPackageLocation.java
new file mode 100644
index 000000000..8144c4cb0
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/DomainPackageLocation.java
@@ -0,0 +1,4 @@
+package kr.co.pennyway.domain;
+
+public interface DomainPackageLocation {
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisCacheManager.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisCacheManager.java
new file mode 100644
index 000000000..11783e25a
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisCacheManager.java
@@ -0,0 +1,13 @@
+package kr.co.pennyway.domain.common.annotation;
+
+import org.springframework.beans.factory.annotation.Qualifier;
+
+import java.lang.annotation.*;
+
+@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD,
+ ElementType.TYPE, ElementType.ANNOTATION_TYPE})
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+@Qualifier("domainRedisCacheManager")
+public @interface DomainRedisCacheManager {
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisConnectionFactory.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisConnectionFactory.java
new file mode 100644
index 000000000..12c232a3b
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisConnectionFactory.java
@@ -0,0 +1,13 @@
+package kr.co.pennyway.domain.common.annotation;
+
+import org.springframework.beans.factory.annotation.Qualifier;
+
+import java.lang.annotation.*;
+
+@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD,
+ ElementType.TYPE, ElementType.ANNOTATION_TYPE})
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+@Qualifier("redisCacheConnectionFactory")
+public @interface DomainRedisConnectionFactory {
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisTemplate.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisTemplate.java
new file mode 100644
index 000000000..5e8a4edf3
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisTemplate.java
@@ -0,0 +1,13 @@
+package kr.co.pennyway.domain.common.annotation;
+
+import org.springframework.beans.factory.annotation.Qualifier;
+
+import java.lang.annotation.*;
+
+@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD,
+ ElementType.TYPE, ElementType.ANNOTATION_TYPE})
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+@Qualifier("domainRedisTemplate")
+public @interface DomainRedisTemplate {
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/CallTransaction.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/CallTransaction.java
new file mode 100644
index 000000000..05bdd3e42
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/CallTransaction.java
@@ -0,0 +1,7 @@
+package kr.co.pennyway.domain.common.aop;
+
+import org.aspectj.lang.ProceedingJoinPoint;
+
+public interface CallTransaction {
+ Object proceed(ProceedingJoinPoint joinPoint) throws Throwable;
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/CallTransactionFactory.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/CallTransactionFactory.java
new file mode 100644
index 000000000..77c7ed50b
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/CallTransactionFactory.java
@@ -0,0 +1,13 @@
+package kr.co.pennyway.domain.common.aop;
+
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+public class CallTransactionFactory {
+ private final RedissonCallNewTransaction redissonCallNewTransaction;
+ private final RedissonCallSameTransaction redissonCallSameTransaction;
+
+ public CallTransaction getCallTransaction(boolean isNewTransaction) {
+ return isNewTransaction ? redissonCallNewTransaction : redissonCallSameTransaction;
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/DistributedLockAspect.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/DistributedLockAspect.java
new file mode 100644
index 000000000..a3b76c745
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/DistributedLockAspect.java
@@ -0,0 +1,56 @@
+package kr.co.pennyway.domain.common.aop;
+
+import kr.co.pennyway.domain.common.redisson.DistributedLock;
+import kr.co.pennyway.domain.common.util.CustomSpringELParser;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
+
+import java.lang.reflect.Method;
+
+/**
+ * {@link DistributedLock} 어노테이션을 사용한 메소드에 대한 분산 락 처리를 위한 AOP
+ */
+@Slf4j
+@Aspect
+@RequiredArgsConstructor
+public class DistributedLockAspect {
+ private static final String REDISSON_LOCK_PREFIX = "LOCK:";
+
+ private final RedissonClient redissonClient;
+ private final CallTransactionFactory callTransactionFactory;
+
+ @Around("@annotation(kr.co.pennyway.domain.common.redisson.DistributedLock)")
+ public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
+ MethodSignature signature = (MethodSignature) joinPoint.getSignature();
+ Method method = signature.getMethod();
+ DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);
+
+ String key = REDISSON_LOCK_PREFIX + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key());
+ RLock rLock = redissonClient.getLock(key);
+
+ try {
+ boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
+ if (!available) {
+ return false;
+ }
+ log.info("{} : Redisson Lock 진입 : {} {}", Thread.currentThread().getId(), method.getName(), key);
+
+ return callTransactionFactory.getCallTransaction(distributedLock.needNewTransaction()).proceed(joinPoint);
+ } catch (InterruptedException e) {
+ throw new InterruptedException("Failed to acquire lock: " + key);
+ } finally {
+ try {
+ log.info("{} : Redisson Lock 해제 : {} {}", Thread.currentThread().getId(), method.getName(), key);
+ rLock.unlock();
+ } catch (IllegalMonitorStateException ignored) {
+ log.error("Redisson lock is already unlocked: {} {}", method.getName(), key);
+ }
+ }
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallNewTransaction.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallNewTransaction.java
new file mode 100644
index 000000000..ba28b682e
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallNewTransaction.java
@@ -0,0 +1,19 @@
+package kr.co.pennyway.domain.common.aop;
+
+import lombok.RequiredArgsConstructor;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+@RequiredArgsConstructor
+public class RedissonCallNewTransaction implements CallTransaction {
+ /**
+ * 다른 트랜잭션이 실행 중인 경우에도 새로운 트랜잭션을 생성하여 이 메서드를 실행한다.
+ * 동시성 환경에서 데이터 정합성을 보장하기 위해 트랜잭션 커밋 이후 락이 해제된다.
+ */
+ @Override
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public Object proceed(ProceedingJoinPoint joinPoint) throws Throwable {
+ return joinPoint.proceed();
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallSameTransaction.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallSameTransaction.java
new file mode 100644
index 000000000..af6148eea
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallSameTransaction.java
@@ -0,0 +1,19 @@
+package kr.co.pennyway.domain.common.aop;
+
+import lombok.RequiredArgsConstructor;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+@RequiredArgsConstructor
+public class RedissonCallSameTransaction implements CallTransaction {
+ /**
+ * 기존 트랜잭션 내에서 이 메서드를 실행하며, 새로운 트랜잭션을 생성하지 않는다.
+ * 트랜잭션이 활성화되어 있지 않으면 예외를 발생시킨다.
+ */
+ @Override
+ @Transactional(propagation = Propagation.MANDATORY, timeout = 2)
+ public Object proceed(ProceedingJoinPoint joinPoint) throws Throwable {
+ return joinPoint.proceed();
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/AbstractLegacyEnumAttributeConverter.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/AbstractLegacyEnumAttributeConverter.java
new file mode 100644
index 000000000..09cafe044
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/AbstractLegacyEnumAttributeConverter.java
@@ -0,0 +1,48 @@
+package kr.co.pennyway.domain.common.converter;
+
+import jakarta.persistence.AttributeConverter;
+import kr.co.pennyway.domain.common.util.LegacyEnumValueConvertUtil;
+import lombok.Getter;
+import org.springframework.util.StringUtils;
+
+@Getter
+public class AbstractLegacyEnumAttributeConverter & LegacyCommonType> implements AttributeConverter {
+ /**
+ * 대상 Enum 클래스 {@link Class} 객체
+ */
+ private final Class targetEnumClass;
+
+ /**
+ * nullable = false
면, 변환할 값이 null로 들어왔을 때 예외를 발생시킨다.
+ * nullable = true
면, 변환할 값이 null로 들어왔을 때 예외 없이 실행하며,
+ * legacy code로 변환 시엔 빈 문자열("")로 변환한다.
+ */
+ private final boolean nullable;
+
+ /**
+ * nullable = false
일 때 출력할 오류 메시지에서 enum에 대한 설명을 위해 Enum의 설명적 이름을 받는다.
+ */
+ private final String enumName;
+
+ public AbstractLegacyEnumAttributeConverter(Class targetEnumClass, boolean nullable, String enumName) {
+ this.targetEnumClass = targetEnumClass;
+ this.nullable = nullable;
+ this.enumName = enumName;
+ }
+
+ @Override
+ public String convertToDatabaseColumn(E attribute) {
+ if (!nullable && attribute == null) {
+ throw new IllegalArgumentException(String.format("%s을(를) null로 변환할 수 없습니다.", enumName));
+ }
+ return LegacyEnumValueConvertUtil.toLegacyCode(attribute);
+ }
+
+ @Override
+ public E convertToEntityAttribute(String dbData) {
+ if (!nullable && !StringUtils.hasText(dbData)) {
+ throw new IllegalArgumentException(String.format("%s(이)가 DB에 null 혹은 Empty로(%s) 저장되어 있습니다.", enumName, dbData));
+ }
+ return LegacyEnumValueConvertUtil.ofLegacyCode(targetEnumClass, dbData);
+ }
+}
\ No newline at end of file
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/AnnouncementConverter.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/AnnouncementConverter.java
new file mode 100644
index 000000000..6c995aa96
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/AnnouncementConverter.java
@@ -0,0 +1,13 @@
+package kr.co.pennyway.domain.common.converter;
+
+import jakarta.persistence.Converter;
+import kr.co.pennyway.domain.domains.notification.type.Announcement;
+
+@Converter
+public class AnnouncementConverter extends AbstractLegacyEnumAttributeConverter {
+ private static final String ENUM_NAME = "공지 타입";
+
+ public AnnouncementConverter() {
+ super(Announcement.class, false, ENUM_NAME);
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/LegacyCommonType.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/LegacyCommonType.java
new file mode 100644
index 000000000..3b251147e
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/LegacyCommonType.java
@@ -0,0 +1,10 @@
+package kr.co.pennyway.domain.common.converter;
+
+public interface LegacyCommonType {
+ /**
+ * Legacy Super System 공통 코드를 반환한다.
+ *
+ * @return String 공통 코드
+ */
+ String getCode();
+}
\ No newline at end of file
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/NoticeTypeConverter.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/NoticeTypeConverter.java
new file mode 100644
index 000000000..0653382d9
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/NoticeTypeConverter.java
@@ -0,0 +1,13 @@
+package kr.co.pennyway.domain.common.converter;
+
+import jakarta.persistence.Converter;
+import kr.co.pennyway.domain.domains.notification.type.NoticeType;
+
+@Converter
+public class NoticeTypeConverter extends AbstractLegacyEnumAttributeConverter {
+ private static final String ENUM_NAME = "알림 타입";
+
+ public NoticeTypeConverter() {
+ super(NoticeType.class, false, ENUM_NAME);
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/ProfileVisibilityConverter.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/ProfileVisibilityConverter.java
new file mode 100644
index 000000000..cf2f42639
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/ProfileVisibilityConverter.java
@@ -0,0 +1,13 @@
+package kr.co.pennyway.domain.common.converter;
+
+import jakarta.persistence.Converter;
+import kr.co.pennyway.domain.domains.user.type.ProfileVisibility;
+
+@Converter
+public class ProfileVisibilityConverter extends AbstractLegacyEnumAttributeConverter {
+ private static final String ENUM_NAME = "프로필 공개 범위";
+
+ public ProfileVisibilityConverter() {
+ super(ProfileVisibility.class, false, ENUM_NAME);
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/ProviderConverter.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/ProviderConverter.java
new file mode 100644
index 000000000..1ef15c1c6
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/ProviderConverter.java
@@ -0,0 +1,13 @@
+package kr.co.pennyway.domain.common.converter;
+
+import jakarta.persistence.Converter;
+import kr.co.pennyway.domain.domains.oauth.type.Provider;
+
+@Converter
+public class ProviderConverter extends AbstractLegacyEnumAttributeConverter {
+ private static final String ENUM_NAME = "제공자";
+
+ public ProviderConverter() {
+ super(Provider.class, false, ENUM_NAME);
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/QuestionCategoryConverter.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/QuestionCategoryConverter.java
new file mode 100644
index 000000000..d40a102f3
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/QuestionCategoryConverter.java
@@ -0,0 +1,13 @@
+package kr.co.pennyway.domain.common.converter;
+
+import jakarta.persistence.Converter;
+import kr.co.pennyway.domain.domains.question.domain.QuestionCategory;
+
+@Converter
+public class QuestionCategoryConverter extends AbstractLegacyEnumAttributeConverter {
+ private static final String ENUM_NAME = "문의 카테고리";
+
+ public QuestionCategoryConverter() {
+ super(QuestionCategory.class, false, ENUM_NAME);
+ }
+}
\ No newline at end of file
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/RoleConverter.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/RoleConverter.java
new file mode 100644
index 000000000..ee6f5da55
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/RoleConverter.java
@@ -0,0 +1,13 @@
+package kr.co.pennyway.domain.common.converter;
+
+import jakarta.persistence.Converter;
+import kr.co.pennyway.domain.domains.user.type.Role;
+
+@Converter
+public class RoleConverter extends AbstractLegacyEnumAttributeConverter {
+ private static final String ENUM_NAME = "유저 권한";
+
+ public RoleConverter() {
+ super(Role.class, false, ENUM_NAME);
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/SpendingCategoryConverter.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/SpendingCategoryConverter.java
new file mode 100644
index 000000000..b9e567b88
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/SpendingCategoryConverter.java
@@ -0,0 +1,13 @@
+package kr.co.pennyway.domain.common.converter;
+
+import jakarta.persistence.Converter;
+import kr.co.pennyway.domain.domains.spending.type.SpendingCategory;
+
+@Converter
+public class SpendingCategoryConverter extends AbstractLegacyEnumAttributeConverter {
+ private static final String ENUM_NAME = "지출 카테고리";
+
+ public SpendingCategoryConverter() {
+ super(SpendingCategory.class, false, ENUM_NAME);
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/EnablePennywayDomainConfig.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/EnablePennywayDomainConfig.java
new file mode 100644
index 000000000..4867a3cf3
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/EnablePennywayDomainConfig.java
@@ -0,0 +1,15 @@
+package kr.co.pennyway.domain.common.importer;
+
+import org.springframework.context.annotation.Import;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+@Import(PennywayDomainConfigImportSelector.class)
+public @interface EnablePennywayDomainConfig {
+ PennywayDomainConfigGroup[] value();
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfig.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfig.java
new file mode 100644
index 000000000..0c06696a0
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfig.java
@@ -0,0 +1,7 @@
+package kr.co.pennyway.domain.common.importer;
+
+/**
+ * Pennyway Domain의 Configurations를 나타내는 Marker Interface
+ */
+public interface PennywayDomainConfig {
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfigGroup.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfigGroup.java
new file mode 100644
index 000000000..391e6483b
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfigGroup.java
@@ -0,0 +1,13 @@
+package kr.co.pennyway.domain.common.importer;
+
+import kr.co.pennyway.domain.config.RedissonConfig;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public enum PennywayDomainConfigGroup {
+ REDISSON(RedissonConfig.class);
+
+ private final Class extends PennywayDomainConfig> configClass;
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfigImportSelector.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfigImportSelector.java
new file mode 100644
index 000000000..01add00bf
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfigImportSelector.java
@@ -0,0 +1,24 @@
+package kr.co.pennyway.domain.common.importer;
+
+import kr.co.pennyway.common.util.MapUtils;
+import org.springframework.context.annotation.DeferredImportSelector;
+import org.springframework.core.type.AnnotationMetadata;
+import org.springframework.lang.NonNull;
+
+import java.util.Arrays;
+import java.util.Map;
+
+public class PennywayDomainConfigImportSelector implements DeferredImportSelector {
+ @NonNull
+ @Override
+ public String[] selectImports(@NonNull AnnotationMetadata metadata) {
+ return Arrays.stream(getGroups(metadata))
+ .map(v -> v.getConfigClass().getName())
+ .toArray(String[]::new);
+ }
+
+ private PennywayDomainConfigGroup[] getGroups(AnnotationMetadata metadata) {
+ Map attributes = metadata.getAnnotationAttributes(EnablePennywayDomainConfig.class.getName());
+ return (PennywayDomainConfigGroup[]) MapUtils.getObject(attributes, "value", new PennywayDomainConfigGroup[]{});
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/model/DateAuditable.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/model/DateAuditable.java
new file mode 100644
index 000000000..3ae674cca
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/model/DateAuditable.java
@@ -0,0 +1,24 @@
+package kr.co.pennyway.domain.common.model;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.EntityListeners;
+import jakarta.persistence.MappedSuperclass;
+import lombok.Getter;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import java.time.LocalDateTime;
+
+@Getter
+@EntityListeners(AuditingEntityListener.class)
+@MappedSuperclass
+public abstract class DateAuditable {
+ @CreatedDate
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+
+ @LastModifiedDate
+ @Column(name = "updated_at", nullable = false)
+ private LocalDateTime updatedAt;
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/RedisPackageLocation.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/RedisPackageLocation.java
new file mode 100644
index 000000000..5b017dbfa
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/RedisPackageLocation.java
@@ -0,0 +1,4 @@
+package kr.co.pennyway.domain.common.redis;
+
+public interface RedisPackageLocation {
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenToken.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenToken.java
new file mode 100644
index 000000000..84dc4cd93
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenToken.java
@@ -0,0 +1,42 @@
+package kr.co.pennyway.domain.common.redis.forbidden;
+
+import lombok.Builder;
+import lombok.Getter;
+import org.springframework.data.annotation.Id;
+import org.springframework.data.redis.core.RedisHash;
+import org.springframework.data.redis.core.TimeToLive;
+
+@Getter
+@RedisHash("forbiddenToken")
+public class ForbiddenToken {
+ @Id
+ private final String accessToken;
+ private final Long userId;
+ @TimeToLive
+ private final long ttl;
+
+ @Builder
+ private ForbiddenToken(String accessToken, Long userId, long ttl) {
+ this.accessToken = accessToken;
+ this.userId = userId;
+ this.ttl = ttl;
+ }
+
+ public static ForbiddenToken of(String accessToken, Long userId, long ttl) {
+ return new ForbiddenToken(accessToken, userId, ttl);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof ForbiddenToken that)) return false;
+ return accessToken.equals(that.accessToken) && userId.equals(that.userId);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = accessToken.hashCode();
+ result = ((1 << 5) - 1) * result + userId.hashCode();
+ return result;
+ }
+}
\ No newline at end of file
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenTokenRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenTokenRepository.java
new file mode 100644
index 000000000..83db477af
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenTokenRepository.java
@@ -0,0 +1,6 @@
+package kr.co.pennyway.domain.common.redis.forbidden;
+
+import org.springframework.data.repository.CrudRepository;
+
+public interface ForbiddenTokenRepository extends CrudRepository {
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenTokenService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenTokenService.java
new file mode 100644
index 000000000..12ea5d41b
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenTokenService.java
@@ -0,0 +1,43 @@
+package kr.co.pennyway.domain.common.redis.forbidden;
+
+import kr.co.pennyway.common.annotation.DomainService;
+import lombok.AccessLevel;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+
+@Slf4j
+@RequiredArgsConstructor(access = AccessLevel.PROTECTED)
+@DomainService
+public class ForbiddenTokenService {
+ private final ForbiddenTokenRepository forbiddenTokenRepository;
+
+ /**
+ * 토큰을 블랙 리스트에 등록합니다.
+ *
+ * @param accessToken String : 블랙 리스트에 등록할 액세스 토큰
+ * @param userId Long : 블랙 리스트에 등록할 유저 아이디
+ * @param expiresAt LocalDateTime : 블랙 리스트에 등록할 토큰의 만료 시간 (등록할 access token의 만료시간을 추출한 값)
+ */
+ public void createForbiddenToken(String accessToken, Long userId, LocalDateTime expiresAt) {
+ final LocalDateTime now = LocalDateTime.now();
+ final long timeToLive = Duration.between(now, expiresAt).toSeconds();
+
+ log.info("forbidden token ttl : {}", timeToLive);
+
+ ForbiddenToken forbiddenToken = ForbiddenToken.of(accessToken, userId, timeToLive);
+ forbiddenTokenRepository.save(forbiddenToken);
+ log.info("forbidden token registered. about User : {}", forbiddenToken.getUserId());
+ }
+
+ /**
+ * 토큰이 블랙 리스트에 등록되어 있는지 확인합니다.
+ *
+ * @return : 블랙 리스트에 등록되어 있으면 true, 아니면 false
+ */
+ public boolean isForbidden(String accessToken) {
+ return forbiddenTokenRepository.existsById(accessToken);
+ }
+}
\ No newline at end of file
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneCodeKeyType.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneCodeKeyType.java
new file mode 100644
index 000000000..6be3174cf
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneCodeKeyType.java
@@ -0,0 +1,27 @@
+package kr.co.pennyway.domain.common.redis.phone;
+
+import kr.co.pennyway.domain.domains.oauth.type.Provider;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public enum PhoneCodeKeyType {
+ SIGN_UP("signUp"),
+ OAUTH_SIGN_UP_KAKAO("oauthSignUp:kakao"),
+ OAUTH_SIGN_UP_GOOGLE("oauthSignUp:google"),
+ OAUTH_SIGN_UP_APPLE("oauthSignUp:apple"),
+ FIND_USERNAME("username"),
+ FIND_PASSWORD("password"),
+ PHONE("phone");
+
+ private final String prefix;
+
+ public static PhoneCodeKeyType getOauthSignUpTypeByProvider(Provider provider) {
+ return switch (provider) {
+ case KAKAO -> OAUTH_SIGN_UP_KAKAO;
+ case GOOGLE -> OAUTH_SIGN_UP_GOOGLE;
+ case APPLE -> OAUTH_SIGN_UP_APPLE;
+ };
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneCodeRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneCodeRepository.java
new file mode 100644
index 000000000..68ee158f9
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneCodeRepository.java
@@ -0,0 +1,36 @@
+package kr.co.pennyway.domain.common.redis.phone;
+
+import kr.co.pennyway.domain.common.annotation.DomainRedisTemplate;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Repository;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.Objects;
+
+@Repository
+public class PhoneCodeRepository {
+ private final RedisTemplate redisTemplate;
+
+ public PhoneCodeRepository(@DomainRedisTemplate RedisTemplate redisTemplate) {
+ this.redisTemplate = redisTemplate;
+ }
+
+ public LocalDateTime save(String phone, String code, PhoneCodeKeyType codeType) {
+ LocalDateTime expiresAt = LocalDateTime.now().plusMinutes(5);
+ redisTemplate.opsForValue().set(codeType.getPrefix() + ":" + phone, code, Duration.between(LocalDateTime.now(), expiresAt));
+ return expiresAt;
+ }
+
+ public String findCodeByPhone(String phone, PhoneCodeKeyType codeType) throws NullPointerException {
+ return Objects.requireNonNull(redisTemplate.opsForValue().get(codeType.getPrefix() + ":" + phone)).toString();
+ }
+
+ public void extendTimeToLeave(String phone, PhoneCodeKeyType codeType) {
+ redisTemplate.expire(codeType.getPrefix() + ":" + phone, Duration.ofMinutes(5));
+ }
+
+ public void delete(String phone, PhoneCodeKeyType codeType) {
+ redisTemplate.opsForValue().getAndDelete(codeType.getPrefix() + ":" + phone);
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneCodeService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneCodeService.java
new file mode 100644
index 000000000..d5a26e93a
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneCodeService.java
@@ -0,0 +1,65 @@
+package kr.co.pennyway.domain.common.redis.phone;
+
+import kr.co.pennyway.common.annotation.DomainService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import java.time.LocalDateTime;
+
+@Slf4j
+@DomainService
+@RequiredArgsConstructor
+public class PhoneCodeService {
+ private final PhoneCodeRepository phoneCodeRepository;
+
+ /**
+ * 휴대폰 번호와 코드를 저장한다. (5분간 유효)
+ *
+ * redis에 저장되는 key는 codeType:phone, value는 code이다.
+ *
+ * @param phone String : 휴대폰 번호
+ * @param code String : 6자리 정수 코드
+ * @param codeType {@link PhoneCodeKeyType} : 코드 타입
+ * @return LocalDateTime : 만료 시간
+ */
+ public LocalDateTime create(String phone, String code, PhoneCodeKeyType codeType) {
+ return phoneCodeRepository.save(phone, code, codeType);
+ }
+
+ /**
+ * 휴대폰 번호로 저장된 코드를 조회한다.
+ *
+ * @param phone String : 휴대폰 번호
+ * @param codeType {@link PhoneCodeKeyType} : 코드 타입
+ * @return String : 6자리 정수 코드
+ * @throws IllegalArgumentException : 코드가 없을 경우
+ */
+ public String readByPhone(String phone, PhoneCodeKeyType codeType) throws IllegalArgumentException {
+ try {
+ return phoneCodeRepository.findCodeByPhone(phone, codeType);
+ } catch (NullPointerException e) {
+ log.error("{}:{}에 해당하는 키가 존재하지 않습니다.", phone, codeType);
+ throw new IllegalArgumentException(e);
+ }
+ }
+
+ /**
+ * 휴대폰 번호로 저장된 데이터의 ttl을 5분으로 연장(롤백)한다.
+ *
+ * @param phone String : 휴대폰 번호
+ * @param codeType {@link PhoneCodeKeyType} : 코드 타입
+ */
+ public void extendTimeToLeave(String phone, PhoneCodeKeyType codeType) {
+ phoneCodeRepository.extendTimeToLeave(phone, codeType);
+ }
+
+ /**
+ * 휴대폰 번호로 저장된 코드를 삭제한다.
+ *
+ * @param phone String : 휴대폰 번호
+ * @param codeType {@link PhoneCodeKeyType} : 코드 타입
+ */
+ public void delete(String phone, PhoneCodeKeyType codeType) {
+ phoneCodeRepository.delete(phone, codeType);
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshToken.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshToken.java
new file mode 100644
index 000000000..db38f7a4c
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshToken.java
@@ -0,0 +1,38 @@
+package kr.co.pennyway.domain.common.redis.refresh;
+
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.ToString;
+import org.springframework.data.annotation.Id;
+import org.springframework.data.redis.core.RedisHash;
+
+@RedisHash("refreshToken")
+@Getter
+@ToString(of = {"userId", "token", "ttl"})
+@EqualsAndHashCode(of = {"userId", "token"})
+public class RefreshToken {
+ @Id
+ private final Long userId;
+ private final long ttl;
+ private String token;
+
+ @Builder
+ private RefreshToken(String token, Long userId, long ttl) {
+ this.token = token;
+ this.userId = userId;
+ this.ttl = ttl;
+ }
+
+ public static RefreshToken of(Long userId, String token, long ttl) {
+ return RefreshToken.builder()
+ .userId(userId)
+ .token(token)
+ .ttl(ttl)
+ .build();
+ }
+
+ protected void rotation(String token) {
+ this.token = token;
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenRepository.java
new file mode 100644
index 000000000..35467af12
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenRepository.java
@@ -0,0 +1,6 @@
+package kr.co.pennyway.domain.common.redis.refresh;
+
+import org.springframework.data.repository.CrudRepository;
+
+public interface RefreshTokenRepository extends CrudRepository {
+}
\ No newline at end of file
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenService.java
new file mode 100644
index 000000000..98158f8b7
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenService.java
@@ -0,0 +1,31 @@
+package kr.co.pennyway.domain.common.redis.refresh;
+
+public interface RefreshTokenService {
+ /**
+ * refresh token을 redis에 저장한다.
+ *
+ * @param refreshToken : {@link RefreshToken}
+ */
+ void save(RefreshToken refreshToken);
+
+ /**
+ * 사용자가 보낸 refresh token으로 기존 refresh token과 비교 검증 후, 새로운 refresh token으로 저장한다.
+ *
+ * @param userId : 토큰 주인 pk
+ * @param oldRefreshToken : 사용자가 보낸 refresh token
+ * @param newRefreshToken : 교체할 refresh token
+ * @return {@link RefreshToken}
+ * @throws IllegalArgumentException : userId에 해당하는 refresh token이 없을 경우
+ * @throws IllegalStateException : 요청한 토큰과 저장된 토큰이 다르다면 토큰이 탈취되었다고 판단하여 값 삭제
+ */
+ RefreshToken refresh(Long userId, String oldRefreshToken, String newRefreshToken) throws IllegalArgumentException, IllegalStateException;
+
+ /**
+ * access token 으로 refresh token을 찾아서 제거 (로그아웃)
+ *
+ * @param userId : 토큰 주인 pk
+ * @param refreshToken : 검증용 refresh token
+ * @throws IllegalArgumentException : userId에 해당하는 refresh token이 없을 경우
+ */
+ void delete(Long userId, String refreshToken) throws IllegalArgumentException;
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceImpl.java
new file mode 100644
index 000000000..194ed90b9
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceImpl.java
@@ -0,0 +1,69 @@
+package kr.co.pennyway.domain.common.redis.refresh;
+
+import kr.co.pennyway.common.annotation.DomainService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@DomainService
+@RequiredArgsConstructor
+public class RefreshTokenServiceImpl implements RefreshTokenService {
+ private final RefreshTokenRepository refreshTokenRepository;
+
+ @Override
+ public void save(RefreshToken refreshToken) {
+ refreshTokenRepository.save(refreshToken);
+ log.debug("리프레시 토큰 저장 : {}", refreshToken);
+ }
+
+ @Override
+ public RefreshToken refresh(Long userId, String oldRefreshToken, String newRefreshToken) throws IllegalArgumentException, IllegalStateException {
+ RefreshToken refreshToken = findOrElseThrow(userId);
+
+ validateToken(oldRefreshToken, refreshToken);
+
+ refreshToken.rotation(newRefreshToken);
+ refreshTokenRepository.save(refreshToken);
+
+ log.info("사용자 {}의 리프레시 토큰 갱신", userId);
+ return refreshToken;
+ }
+
+ @Override
+ public void delete(Long userId, String refreshToken) throws IllegalArgumentException {
+ RefreshToken token = findOrElseThrow(userId);
+ refreshTokenRepository.delete(token);
+ log.info("사용자 {}의 리프레시 토큰 삭제", userId);
+ }
+
+ private RefreshToken findOrElseThrow(Long userId) {
+ return refreshTokenRepository.findById(userId)
+ .orElseThrow(() -> new IllegalArgumentException("refresh token not found"));
+ }
+
+ /**
+ * @param requestRefreshToken String : 사용자가 보낸 refresh token
+ * @param expectedRefreshToken String : Redis에 저장된 refresh token
+ * @throws IllegalStateException : 요청한 토큰과 저장된 토큰이 다르다면 토큰이 탈취되었다고 판단하여 값 삭제
+ */
+ private void validateToken(String requestRefreshToken, RefreshToken expectedRefreshToken) throws IllegalStateException {
+ if (isTakenAway(requestRefreshToken, expectedRefreshToken.getToken())) {
+ log.warn("리프레시 토큰 불일치(탈취). expected : {}, actual : {}", requestRefreshToken, expectedRefreshToken.getToken());
+ refreshTokenRepository.delete(expectedRefreshToken);
+ log.info("사용자 {}의 리프레시 토큰 삭제", expectedRefreshToken.getUserId());
+
+ throw new IllegalStateException("refresh token mismatched");
+ }
+ }
+
+ /**
+ * 토큰 탈취 여부 확인
+ *
+ * @param requestRefreshToken String : 사용자가 보낸 refresh token
+ * @param expectedRefreshToken String : Redis에 저장된 refresh token
+ * @return boolean : 탈취되었다면 true, 아니면 false
+ */
+ private boolean isTakenAway(String requestRefreshToken, String expectedRefreshToken) {
+ return !requestRefreshToken.equals(expectedRefreshToken);
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redisson/DistributedLock.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redisson/DistributedLock.java
new file mode 100644
index 000000000..b0bd55832
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redisson/DistributedLock.java
@@ -0,0 +1,40 @@
+package kr.co.pennyway.domain.common.redisson;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.util.concurrent.TimeUnit;
+
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface DistributedLock {
+ /**
+ * Lock 이름
+ */
+ String key();
+
+ /**
+ * Lock 유지 시간 (초)
+ */
+ TimeUnit timeUnit() default TimeUnit.SECONDS;
+
+ /**
+ * Lock 유지 시간 (DEFAULT: 10초)
+ * LOCK 획득을 위해 waitTime만큼 대기한다.
+ */
+ long waitTime() default 10L;
+
+ /**
+ * Lock 임대 시간 (DEFAULT: 5초)
+ * LOCK 획득 이후 leaseTime이 지나면 LOCK을 해제한다.
+ */
+ long leaseTime() default 5L;
+
+ /**
+ * 동일한 트랜잭션에서 Lock을 획득할지 여부 (DEFAULT: true)
+ * - true : Propagation.REQUIRES_NEW 전파 방식을 사용하여 새로운 트랜잭션에서 Lock을 획득한다.
+ * - false : Propagation.MANDATORY 전파 방식을 사용하여 동일한 트랜잭션에서 Lock을 획득한다.
+ */
+ boolean needNewTransaction() default true;
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redisson/DistributedLockPrefix.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redisson/DistributedLockPrefix.java
new file mode 100644
index 000000000..ba1b14788
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redisson/DistributedLockPrefix.java
@@ -0,0 +1,8 @@
+package kr.co.pennyway.domain.common.redisson;
+
+/**
+ * 분산 락을 위한 prefix
+ */
+public class DistributedLockPrefix {
+ public static final String TARGET_AMOUNT_USER = "TargetAmount_User_";
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/ExtendedRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/ExtendedRepository.java
new file mode 100644
index 000000000..3198523a9
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/ExtendedRepository.java
@@ -0,0 +1,10 @@
+package kr.co.pennyway.domain.common.repository;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.repository.NoRepositoryBean;
+
+import java.io.Serializable;
+
+@NoRepositoryBean
+public interface ExtendedRepository extends JpaRepository, QueryDslSearchRepository {
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/ExtendedRepositoryFactory.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/ExtendedRepositoryFactory.java
new file mode 100644
index 000000000..dd6574cb8
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/ExtendedRepositoryFactory.java
@@ -0,0 +1,53 @@
+package kr.co.pennyway.domain.common.repository;
+
+import jakarta.persistence.EntityManager;
+import org.springframework.data.jpa.repository.support.JpaRepositoryFactory;
+import org.springframework.data.jpa.repository.support.JpaRepositoryFactoryBean;
+import org.springframework.data.repository.Repository;
+import org.springframework.data.repository.core.RepositoryMetadata;
+import org.springframework.data.repository.core.support.RepositoryComposition;
+import org.springframework.data.repository.core.support.RepositoryFactorySupport;
+import org.springframework.lang.NonNull;
+
+public class ExtendedRepositoryFactory, E, ID> extends JpaRepositoryFactoryBean {
+ /**
+ * Creates a new {@link JpaRepositoryFactoryBean} for the given repository interface.
+ *
+ * @param repositoryInterface must not be {@literal null}.
+ */
+ public ExtendedRepositoryFactory(Class extends T> repositoryInterface) {
+ super(repositoryInterface);
+ }
+
+ @Override
+ @NonNull
+ protected RepositoryFactorySupport createRepositoryFactory(@NonNull EntityManager em) {
+ return new InnerRepositoryFactory(em);
+ }
+
+ private static class InnerRepositoryFactory extends JpaRepositoryFactory {
+ private final EntityManager em;
+
+ public InnerRepositoryFactory(EntityManager em) {
+ super(em);
+ this.em = em;
+ }
+
+ @Override
+ @NonNull
+ protected RepositoryComposition.RepositoryFragments getRepositoryFragments(@NonNull RepositoryMetadata metadata) {
+ RepositoryComposition.RepositoryFragments fragments = super.getRepositoryFragments(metadata);
+
+ if (QueryDslSearchRepository.class.isAssignableFrom(metadata.getRepositoryInterface())) {
+ var implExtendedJpa = super.instantiateClass(
+ QueryDslSearchRepositoryImpl.class,
+ this.getEntityInformation(metadata.getDomainType()),
+ this.em
+ );
+ fragments = fragments.append(RepositoryComposition.RepositoryFragments.just(implExtendedJpa));
+ }
+
+ return fragments;
+ }
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepository.java
new file mode 100644
index 000000000..5a525ea09
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepository.java
@@ -0,0 +1,183 @@
+package kr.co.pennyway.domain.common.repository;
+
+import com.querydsl.core.types.Expression;
+import com.querydsl.core.types.Predicate;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * QueryDsl을 이용한 검색 조건을 처리하는 기본적인 메서드를 선언한 인터페이스
+ *
+ * @author YANG JAESEO
+ * @version 1.1
+ */
+public interface QueryDslSearchRepository {
+
+ /**
+ * 검색 조건에 해당하는 도메인 리스트를 조회하는 메서드
+ *
+ * @param predicate : 검색 조건
+ * @param queryHandler : 검색 조건에 추가적으로 적용할 조건
+ * @param sort : 정렬 조건
+ *
+ * // @formatter:off
+ *
+ * {@code
+ * @Component
+ * class SearchService {
+ * private final QEntity entity = QEntity.entity;
+ * private final QEntityChild entityChild = QEntityChild.entityChild;
+ *
+ * private Entity select() {
+ * Predicate predicate = new BooleanBuilder();
+ * predicate.and(entity.id.eq(1L));
+ *
+ * QueryHandler queryHandler = query -> query.leftJoin(entityChild).on(entity.id.eq(entityChild.entity.id));
+ * Sort sort = Sort.by(Sort.Order.desc("entity.id"));
+ *
+ * return searchRepository.findList(predicate, queryHandler, sort);
+ * }
+ * }
+ * }
+ *
+ * // @formatter:on
+ *
+ * @see Predicate
+ * @see QueryHandler
+ * @see org.springframework.data.domain.PageRequest
+ */
+ List findList(Predicate predicate, QueryHandler queryHandler, Sort sort);
+
+ /**
+ * 검색 조건에 해당하는 도메인 페이지를 조회하는 메서드
+ *
+ * @param predicate : 검색 조건
+ * @param queryHandler : 검색 조건에 추가적으로 적용할 조건
+ * @param pageable : 페이지 정보
+ *
+ * // @formatter:off
+ *
+ * {@code
+ * @Component
+ * class SearchService {
+ * private final QEntity entity = QEntity.entity;
+ * private final QEntityChild entityChild = QEntityChild.entityChild;
+ *
+ * private Entity select() {
+ * Predicate predicate = new BooleanBuilder();
+ * predicate.and(entity.id.eq(1L));
+ *
+ * QueryHandler queryHandler = query -> query.leftJoin(entityChild).on(entity.id.eq(entityChild.entity.id));
+ * Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Order.desc("entity.id")));
+ *
+ * return searchRepository.findList(predicate, queryHandler, pageable);
+ * }
+ * }
+ * }
+ *
+ * // @formatter:on
+ *
+ * @see Predicate
+ * @see QueryHandler
+ * @see org.springframework.data.domain.PageRequest
+ */
+ Page findPage(Predicate predicate, QueryHandler queryHandler, Pageable pageable);
+
+ /**
+ * 검색 조건에 해당하는 DTO 리스트를 조회하는 메서드
+ * bindings가 {@link LinkedHashMap}을 구현체로 사용하는 경우 Dto 생성자 파라미터 순서에 맞게 삽입하면, Dto의 불변성을 유지할 수 있다.
+ * 만약 bindings가 삽입 순서를 보장하지 않을 경우, Dto는 기본 생성자와 setter 메서드를 제공해야 하며, 모든 필드의 final 키워드를 제거해야 한다.
+ *
+ * @param predicate : 검색 조건
+ * @param type : 조회할 도메인(혹은 DTO) 타입
+ * @param bindings : 검색 조건에 해당하는 도메인(혹은 DTO)의 필드. {@link LinkedHashMap}을 구현체로 사용하는 경우 Dto 생성자 파라미터 순서에 맞게 삽입해야 한다.
+ * @param queryHandler : 검색 조건에 추가적으로 적용할 조건
+ * @param sort : 정렬 조건
+ *
+ * // @formatter:off
+ *
+ * {@code
+ * @Component
+ * class SearchService {
+ * private final QEntity entity = QEntity.entity;
+ * private final QEntityChild entityChild = QEntityChild.entityChild;
+ *
+ * private EntityDto select() {
+ * Predicate predicate = new BooleanBuilder();
+ * predicate.and(entity.id.eq(1L));
+ *
+ * QueryHandler queryHandler = query -> query.leftJoin(entityChild).on(entity.id.eq(entityChild.entity.id));
+ * Sort sort = Sort.by(Sort.Order.desc("entity.id"));
+ *
+ * return searchRepository.findList(predicate, EntityDto.class, this.buildBindings(), queryHandler, sort);
+ * }
+ *
+ * private Map> buildBindings() {
+ * Map> bindings = new HashMap<>();
+ *
+ * bindings.put("id", entity.id);
+ * bindings.put("name", entity.name);
+ *
+ * return bindings;
+ * }
+ * }
+ * }
+ *
+ * // @formatter:on
+ *
+ * @see Predicate
+ * @see QueryHandler
+ * @see org.springframework.data.domain.PageRequest
+ */
+ List
selectList(Predicate predicate, Class
type, Map> bindings, QueryHandler queryHandler, Sort sort);
+
+ /**
+ * 검색 조건에 해당하는 DTO 페이지를 조회하는 메서드
+ * bindings가 {@link LinkedHashMap}을 구현체로 사용하는 경우 Dto 생성자 파라미터 순서에 맞게 삽입하면, Dto의 불변성을 유지할 수 있다.
+ * 만약 bindings가 삽입 순서를 보장하지 않을 경우, Dto는 기본 생성자와 setter 메서드를 제공해야 하며, 모든 필드의 final 키워드를 제거해야 한다.
+ *
+ * @param predicate : 검색 조건
+ * @param type : 조회할 도메인(혹은 DTO) 타입
+ * @param bindings : 검색 조건에 해당하는 도메인(혹은 DTO)의 필드. {@link LinkedHashMap}을 구현체로 사용하는 경우 Dto 생성자 파라미터 순서에 맞게 삽입해야 한다.
+ * @param queryHandler : 검색 조건에 추가적으로 적용할 조건
+ * @param pageable : 페이지 정보
+ *
+ * // @formatter:off
+ *
+ * {@code
+ * @Component
+ * class SearchService {
+ * private final QEntity entity = QEntity.entity;
+ * private final QEntityChild entityChild = QEntityChild.entityChild;
+ *
+ * private EntityDto select() {
+ * Predicate predicate = new BooleanBuilder();
+ * predicate.and(entity.id.eq(1L));
+ * QueryHandler queryHandler = query -> query.leftJoin(entityChild).on(entity.id.eq(entityChild.entity.id));
+ * Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Order.desc("entity.id")));
+ *
+ * return searchRepository.findPage(predicate, EntityDto.class, this.buildBindings(), queryHandler, pageable);
+ * }
+ *
+ * private Map> buildBindings() {
+ * Map> bindings = new HashMap<>();
+ * bindings.put("id", entity.id);
+ * bindings.put("name", entity.name);
+ * return bindings;
+ * }
+ * }
+ * }
+ *
+ * // @formatter:on
+ *
+ * @see Predicate
+ * @see QueryHandler
+ * @see org.springframework.data.domain.PageRequest
+ */
+ Page
selectPage(Predicate predicate, Class
type, Map> bindings, QueryHandler queryHandler, Pageable pageable);
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepositoryImpl.java
new file mode 100644
index 000000000..2c751efeb
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepositoryImpl.java
@@ -0,0 +1,136 @@
+package kr.co.pennyway.domain.common.repository;
+
+import com.querydsl.core.types.*;
+import com.querydsl.core.types.dsl.EntityPathBase;
+import com.querydsl.jpa.impl.JPAQuery;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import jakarta.persistence.EntityManager;
+import kr.co.pennyway.domain.common.util.QueryDslUtil;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.jpa.repository.support.JpaEntityInformation;
+import org.springframework.data.querydsl.QSort;
+import org.springframework.data.querydsl.SimpleEntityPathResolver;
+import org.springframework.util.Assert;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+public class QueryDslSearchRepositoryImpl implements QueryDslSearchRepository {
+ private final EntityManager em;
+ private final JPAQueryFactory queryFactory;
+ private final EntityPath path;
+
+ public QueryDslSearchRepositoryImpl(JpaEntityInformation entityInformation, EntityManager entityManager) {
+ this.em = entityManager;
+ this.queryFactory = new JPAQueryFactory(entityManager);
+ this.path = SimpleEntityPathResolver.INSTANCE.createPath(entityInformation.getJavaType());
+ }
+
+ public QueryDslSearchRepositoryImpl(Class type, EntityManager entityManager) {
+ this.em = entityManager;
+ this.queryFactory = new JPAQueryFactory(entityManager);
+ this.path = new EntityPathBase<>(type, "entity");
+ }
+
+ @Override
+ public List findList(Predicate predicate, QueryHandler queryHandler, Sort sort) {
+ return this.buildWithoutSelect(predicate, null, queryHandler, sort).select(path).fetch();
+ }
+
+ @Override
+ public Page findPage(Predicate predicate, QueryHandler queryHandler, Pageable pageable) {
+ Assert.notNull(pageable, "pageable must not be null!");
+
+ JPAQuery> query = this.buildWithoutSelect(predicate, null, queryHandler, pageable.getSort()).select(path);
+
+ int totalSize = query.fetch().size();
+ query = query.offset(pageable.getOffset()).limit(pageable.getPageSize());
+
+ return new PageImpl<>(query.select(path).fetch(), pageable, totalSize);
+ }
+
+ @Override
+ public List
selectList(Predicate predicate, Class
type, Map> bindings, QueryHandler queryHandler, Sort sort) {
+ JPAQuery> query = this.buildWithoutSelect(predicate, bindings, queryHandler, sort);
+
+ if (bindings instanceof LinkedHashMap) {
+ return query.select(Projections.constructor(type, bindings.values().toArray(new Expression>[0]))).fetch();
+ }
+
+ return query.select(Projections.bean(type, bindings)).fetch();
+ }
+
+ @Override
+ public Page
selectPage(Predicate predicate, Class
type, Map> bindings, QueryHandler queryHandler, Pageable pageable) {
+ Assert.notNull(pageable, "pageable must not be null!");
+
+ JPAQuery> query = this.buildWithoutSelect(predicate, bindings, queryHandler, pageable.getSort()).select(path);
+
+ int totalSize = query.fetch().size();
+ query = query.offset(pageable.getOffset()).limit(pageable.getPageSize());
+
+ if (bindings instanceof LinkedHashMap) {
+ return new PageImpl<>(query.select(Projections.constructor(type, bindings.values().toArray(new Expression>[0]))).fetch(), pageable, totalSize);
+ }
+
+ return new PageImpl<>(query.select(Projections.bean(type, bindings)).fetch(), pageable, totalSize);
+ }
+
+ /**
+ * 파라미터를 기반으로 Querydsl의 JPAQuery를 생성하는 메서드
+ */
+ private JPAQuery> buildWithoutSelect(Predicate predicate, Map> bindings, QueryHandler queryHandler, Sort sort) {
+ JPAQuery> query = queryFactory.from(path);
+
+ applyPredicate(predicate, query);
+ applyQueryHandler(queryHandler, query);
+ applySort(query, sort, bindings);
+
+ return query;
+ }
+
+ /**
+ * Querydsl의 JPAQuery에 Predicate를 적용하는 메서드
+ * Predicate가 null이 아닐 경우에만 적용
+ */
+ private void applyPredicate(Predicate predicate, JPAQuery> query) {
+ if (predicate != null) query.where(predicate);
+ }
+
+ /**
+ * Querydsl의 JPAQuery에 QueryHandler를 적용하는 메서드
+ * QueryHandler가 null이 아닐 경우에만 적용
+ */
+ private void applyQueryHandler(QueryHandler queryHandler, JPAQuery> query) {
+ if (queryHandler != null) queryHandler.apply(query);
+ }
+
+ /**
+ * Querydsl의 JPAQuery에 Sort를 적용하는 메서드
+ * Sort가 null이 아닐 경우에만 적용
+ * Sort가 QSort일 경우에는 OrderSpecifier를 적용하고, 그 외의 경우에는 OrderSpecifier를 생성하여 적용
+ */
+ private void applySort(JPAQuery> query, Sort sort, Map> bindings) {
+ if (sort != null) {
+ if (sort instanceof QSort qSort) {
+ query.orderBy(qSort.getOrderSpecifiers().toArray(new OrderSpecifier[0]));
+ } else {
+ applySortOrders(query, sort, bindings);
+ }
+ }
+ }
+
+ private void applySortOrders(JPAQuery> query, Sort sort, Map> bindings) {
+ for (Sort.Order order : sort) {
+ OrderSpecifier.NullHandling queryDslNullHandling = QueryDslUtil.getQueryDslNullHandling(order);
+
+ OrderSpecifier> os = QueryDslUtil.getOrderSpecifier(order, bindings, queryDslNullHandling);
+
+ query.orderBy(os);
+ }
+ }
+}
\ No newline at end of file
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryHandler.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryHandler.java
new file mode 100644
index 000000000..93af7feee
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryHandler.java
@@ -0,0 +1,13 @@
+package kr.co.pennyway.domain.common.repository;
+
+import com.querydsl.jpa.impl.JPAQuery;
+
+/**
+ * QueryDsl의 명시적 조인을 위한 함수형 인터페이스
+ *
+ * @author YANG JAESEO
+ */
+@FunctionalInterface
+public interface QueryHandler {
+ JPAQuery> apply(JPAQuery> query);
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/CustomSpringELParser.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/CustomSpringELParser.java
new file mode 100644
index 000000000..e202b5ac5
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/CustomSpringELParser.java
@@ -0,0 +1,30 @@
+package kr.co.pennyway.domain.common.util;
+
+import org.springframework.expression.ExpressionParser;
+import org.springframework.expression.spel.standard.SpelExpressionParser;
+import org.springframework.expression.spel.support.StandardEvaluationContext;
+
+/**
+ * Spring Expression Language (SpEL)을 사용한 커스텀 EL 파서
+ */
+public class CustomSpringELParser {
+ /**
+ * SpEL을 사용하여 동적으로 값을 평가한다.
+ *
+ * @param parameterNames : 메서드 파라미터 이름
+ * @param args : 메서드 파라미터 값
+ * @param key : SpEL 표현식
+ * @return : 평가된 값
+ */
+ public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
+ ExpressionParser parser = new SpelExpressionParser();
+ StandardEvaluationContext context = new StandardEvaluationContext();
+
+ // 메서드 파라미터 이름과 값을 SpEL 컨텍스트에 변수로 설정
+ for (int i = 0; i < parameterNames.length; i++) {
+ context.setVariable(parameterNames[i], args[i]);
+ }
+
+ return parser.parseExpression(key).getValue(context, Object.class);
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/LegacyEnumValueConvertUtil.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/LegacyEnumValueConvertUtil.java
new file mode 100644
index 000000000..d79b46c32
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/LegacyEnumValueConvertUtil.java
@@ -0,0 +1,28 @@
+package kr.co.pennyway.domain.common.util;
+
+import kr.co.pennyway.domain.common.converter.LegacyCommonType;
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+import org.springframework.util.StringUtils;
+
+import java.util.EnumSet;
+
+/**
+ * {@link LegacyCommonType} enum을 String과 상호 변환하는 유틸리티 클래스
+ */
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public class LegacyEnumValueConvertUtil {
+ public static & LegacyCommonType> T ofLegacyCode(Class enumClass, String code) {
+ if (!StringUtils.hasText(code)) return null;
+ return EnumSet.allOf(enumClass).stream()
+ .filter(e -> e.getCode().equals(code))
+ .findFirst()
+ .orElseThrow(() -> new IllegalArgumentException(
+ String.format("enum=[%s], code=[%s]가 존재하지 않습니다.", enumClass.getName(), code)));
+ }
+
+ public static & LegacyCommonType> String toLegacyCode(T enumValue) {
+ if (enumValue == null) return "";
+ return enumValue.getCode();
+ }
+}
\ No newline at end of file
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/QueryDslUtil.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/QueryDslUtil.java
new file mode 100644
index 000000000..5cc6b18f4
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/QueryDslUtil.java
@@ -0,0 +1,94 @@
+package kr.co.pennyway.domain.common.util;
+
+import com.querydsl.core.types.*;
+import com.querydsl.core.types.dsl.Expressions;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Sort;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+
+/**
+ * QueryDsl의 편의 기능을 제공하는 유틸리티 클래스
+ *
+ * @author YANG JAESEO
+ * @version 1.0
+ */
+@Slf4j
+public class QueryDslUtil {
+ private static final Function castToQueryDsl = nullHandling -> switch (nullHandling) {
+ case NATIVE -> OrderSpecifier.NullHandling.Default;
+ case NULLS_FIRST -> OrderSpecifier.NullHandling.NullsFirst;
+ case NULLS_LAST -> OrderSpecifier.NullHandling.NullsLast;
+ };
+
+ /**
+ * Pageable의 sort를 QueryDsl의 OrderSpecifier로 변환하는 메서드
+ *
+ * @param sort : {@link Sort}
+ */
+ public static List> getOrderSpecifier(Sort sort) {
+ List> orders = new ArrayList<>();
+
+ for (Sort.Order order : sort) {
+ OrderSpecifier.NullHandling nullHandling = castToQueryDsl.apply(order.getNullHandling());
+ orders.add(getOrderSpecifier(order, nullHandling));
+ }
+
+ return orders;
+ }
+
+ /**
+ * Sort.Order의 정보를 이용하여 OrderSpecifier.NullHandling을 반환하는 메서드
+ *
+ * @param order : {@link Sort.Order}
+ * @return {@link OrderSpecifier.NullHandling}
+ */
+ public static OrderSpecifier.NullHandling getQueryDslNullHandling(Sort.Order order) {
+ return castToQueryDsl.apply(order.getNullHandling());
+ }
+
+ /**
+ * OrderSpecifier를 생성할 때, Sort.Order의 정보를 이용하여 OrderSpecifier.NullHandling을 적용하는 메서드
+ *
+ * @param order : {@link Sort.Order}
+ * @param nullHandling : {@link OrderSpecifier.NullHandling}
+ * @return {@link OrderSpecifier}
+ */
+ public static OrderSpecifier> getOrderSpecifier(Sort.Order order, OrderSpecifier.NullHandling nullHandling) {
+ Order orderBy = order.isAscending() ? Order.ASC : Order.DESC;
+
+ return createOrderSpecifier(orderBy, Expressions.stringPath(order.getProperty()), nullHandling);
+ }
+
+ /**
+ * Expression이 Operation이고 Operator가 ALIAS일 경우, OrderSpecifier를 생성할 때, Expression을 StringPath로 변환하여 생성한다.
+ * 그 외의 경우에는 OrderSpecifier를 생성한다.
+ *
+ * @param order : {@link Sort.Order}
+ * @param bindings : 검색 조건에 해당하는 도메인(혹은 DTO)의 필드 정보. {@code binding}은 Map> 형태로 전달된다.
+ * @param queryDslNullHandling : {@link OrderSpecifier.NullHandling}
+ * @return {@link OrderSpecifier}
+ */
+ public static OrderSpecifier> getOrderSpecifier(Sort.Order order, Map> bindings, OrderSpecifier.NullHandling queryDslNullHandling) {
+ Order orderBy = order.isAscending() ? Order.ASC : Order.DESC;
+
+ if (bindings != null && bindings.containsKey(order.getProperty())) {
+ Expression> expression = bindings.get(order.getProperty());
+ return createOrderSpecifier(orderBy, expression, queryDslNullHandling);
+ } else {
+ return createOrderSpecifier(orderBy, Expressions.stringPath(order.getProperty()), queryDslNullHandling);
+ }
+ }
+
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ private static OrderSpecifier> createOrderSpecifier(Order orderBy, Expression> expression, OrderSpecifier.NullHandling queryDslNullHandling) {
+ if (expression instanceof Operation && ((Operation>) expression).getOperator() == Ops.ALIAS) {
+ return new OrderSpecifier<>(orderBy, Expressions.stringPath(((Operation>) expression).getArg(1).toString()), queryDslNullHandling);
+ } else {
+ return new OrderSpecifier(orderBy, expression, queryDslNullHandling);
+ }
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/SliceUtil.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/SliceUtil.java
new file mode 100644
index 000000000..7c5d3916e
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/SliceUtil.java
@@ -0,0 +1,35 @@
+package kr.co.pennyway.domain.common.util;
+
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+import org.springframework.data.domain.SliceImpl;
+
+import java.util.List;
+
+/**
+ * Slice를 생성하는 메서드를 제공하는 유틸리티 클래스
+ *
+ * @author YANG JAESEO
+ * @version 1.0
+ */
+public class SliceUtil {
+ /**
+ * List로 받은 contents를 Slice로 변환한다.
+ *
+ * @param contents : 변환할 List
+ * @param pageable : Pageable
+ * @return Slice : 변환된 Slice. 단, contents.size()가 pageable.getPageSize()보다 작을 경우 hasNext는 true이며, Slice의 size는 contents.size() - 1이다.
+ */
+ public static Slice toSlice(List contents, Pageable pageable) {
+ boolean hasNext = isContentSizeGreaterThanPageSize(contents, pageable);
+ return new SliceImpl<>(hasNext ? subListLastContent(contents, pageable) : contents, pageable, hasNext);
+ }
+
+ private static boolean isContentSizeGreaterThanPageSize(List content, Pageable pageable) {
+ return pageable.isPaged() && content.size() > pageable.getPageSize();
+ }
+
+ private static List subListLastContent(List content, Pageable pageable) {
+ return content.subList(0, pageable.getPageSize());
+ }
+}
\ No newline at end of file
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/JpaConfig.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/JpaConfig.java
new file mode 100644
index 000000000..c068ec90c
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/JpaConfig.java
@@ -0,0 +1,16 @@
+package kr.co.pennyway.domain.config;
+
+import kr.co.pennyway.domain.DomainPackageLocation;
+import kr.co.pennyway.domain.common.repository.ExtendedRepositoryFactory;
+import kr.co.pennyway.domain.domains.JpaPackageLocation;
+import org.springframework.boot.autoconfigure.domain.EntityScan;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
+
+@Configuration
+@EnableJpaAuditing
+@EntityScan(basePackageClasses = DomainPackageLocation.class)
+@EnableJpaRepositories(basePackageClasses = JpaPackageLocation.class, repositoryFactoryBeanClass = ExtendedRepositoryFactory.class)
+public class JpaConfig {
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/QueryDslConfig.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/QueryDslConfig.java
new file mode 100644
index 000000000..7b40ad85d
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/QueryDslConfig.java
@@ -0,0 +1,20 @@
+package kr.co.pennyway.domain.config;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.PersistenceContext;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Primary;
+
+@Configuration
+public class QueryDslConfig {
+ @PersistenceContext
+ private EntityManager entityManager;
+
+ @Bean
+ @Primary
+ public JPAQueryFactory jpaQueryFactory() {
+ return new JPAQueryFactory(entityManager);
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedisConfig.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedisConfig.java
new file mode 100644
index 000000000..e485eb36f
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedisConfig.java
@@ -0,0 +1,83 @@
+package kr.co.pennyway.domain.config;
+
+import kr.co.pennyway.domain.common.annotation.DomainRedisCacheManager;
+import kr.co.pennyway.domain.common.annotation.DomainRedisConnectionFactory;
+import kr.co.pennyway.domain.common.annotation.DomainRedisTemplate;
+import kr.co.pennyway.domain.common.redis.RedisPackageLocation;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Primary;
+import org.springframework.data.redis.cache.RedisCacheConfiguration;
+import org.springframework.data.redis.cache.RedisCacheManager;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
+import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
+import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
+import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
+import org.springframework.data.redis.serializer.RedisSerializationContext;
+import org.springframework.data.redis.serializer.StringRedisSerializer;
+import org.springframework.transaction.annotation.EnableTransactionManagement;
+
+import java.time.Duration;
+
+@Configuration
+@EnableRedisRepositories(basePackageClasses = RedisPackageLocation.class)
+@EnableTransactionManagement
+public class RedisConfig {
+ private final String host;
+ private final int port;
+ private final String password;
+
+ public RedisConfig(
+ @Value("${spring.data.redis.host}") String host,
+ @Value("${spring.data.redis.port}") int port,
+ @Value("${spring.data.redis.password}") String password
+ ) {
+ this.host = host;
+ this.port = port;
+ this.password = password;
+ }
+
+ @Bean
+ @DomainRedisConnectionFactory
+ public RedisConnectionFactory redisConnectionFactory() {
+ RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port);
+ config.setPassword(password);
+ LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder().build();
+ return new LettuceConnectionFactory(config, clientConfig);
+ }
+
+ @Bean
+ @Primary
+ @DomainRedisTemplate
+ public RedisTemplate redisTemplate() {
+ RedisTemplate template = new RedisTemplate<>();
+
+ template.setConnectionFactory(redisConnectionFactory());
+
+ template.setKeySerializer(new StringRedisSerializer());
+ template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
+ return template;
+ }
+
+ @Bean
+ @DomainRedisCacheManager
+ public RedisCacheManager redisCacheManager(@DomainRedisConnectionFactory RedisConnectionFactory cf) {
+ RedisCacheConfiguration redisCacheConfiguration =
+ RedisCacheConfiguration.defaultCacheConfig()
+ .serializeKeysWith(
+ RedisSerializationContext.SerializationPair.fromSerializer(
+ new StringRedisSerializer()))
+ .serializeValuesWith(
+ RedisSerializationContext.SerializationPair.fromSerializer(
+ new GenericJackson2JsonRedisSerializer()))
+ .entryTtl(Duration.ofHours(1L));
+
+ return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(cf)
+ .cacheDefaults(redisCacheConfiguration)
+ .build();
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedisUnitTest.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedisUnitTest.java
new file mode 100644
index 000000000..2a5f06660
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedisUnitTest.java
@@ -0,0 +1,16 @@
+package kr.co.pennyway.domain.config;
+
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
+
+import java.lang.annotation.*;
+
+@Target({ElementType.TYPE})
+@Retention(RetentionPolicy.RUNTIME)
+@ComponentScan(basePackages = "kr.co.pennyway.domain.common.redis")
+@EnableAutoConfiguration
+@EnableRedisRepositories(basePackages = "kr.co.pennyway.domain.common.redis")
+@Documented
+public @interface RedisUnitTest {
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedissonConfig.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedissonConfig.java
new file mode 100644
index 000000000..32e995f8e
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedissonConfig.java
@@ -0,0 +1,58 @@
+package kr.co.pennyway.domain.config;
+
+import kr.co.pennyway.domain.common.aop.CallTransactionFactory;
+import kr.co.pennyway.domain.common.aop.DistributedLockAspect;
+import kr.co.pennyway.domain.common.aop.RedissonCallNewTransaction;
+import kr.co.pennyway.domain.common.aop.RedissonCallSameTransaction;
+import kr.co.pennyway.domain.common.importer.PennywayDomainConfig;
+import org.redisson.Redisson;
+import org.redisson.api.RedissonClient;
+import org.redisson.config.Config;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+
+public class RedissonConfig implements PennywayDomainConfig {
+ private static final String REDISSON_HOST_PREFIX = "redis://";
+ private final String host;
+ private final int port;
+ private final String password;
+
+ public RedissonConfig(
+ @Value("${spring.data.redis.host}") String host,
+ @Value("${spring.data.redis.port}") int port,
+ @Value("${spring.data.redis.password}") String password
+ ) {
+ this.host = host;
+ this.port = port;
+ this.password = password;
+ }
+
+ @Bean
+ public RedissonClient redissonClient() {
+ Config config = new Config();
+ config.useSingleServer()
+ .setAddress(REDISSON_HOST_PREFIX + host + ":" + port)
+ .setPassword(password);
+ return Redisson.create(config);
+ }
+
+ @Bean
+ public RedissonCallNewTransaction redissonCallNewTransaction() {
+ return new RedissonCallNewTransaction();
+ }
+
+ @Bean
+ public RedissonCallSameTransaction redissonCallSameTransaction() {
+ return new RedissonCallSameTransaction();
+ }
+
+ @Bean
+ public CallTransactionFactory callTransactionFactory(RedissonCallNewTransaction redissonCallNewTransaction, RedissonCallSameTransaction redissonCallSameTransaction) {
+ return new CallTransactionFactory(redissonCallNewTransaction, redissonCallSameTransaction);
+ }
+
+ @Bean
+ public DistributedLockAspect distributedLockAspect(RedissonClient redissonClient, CallTransactionFactory callTransactionFactory) {
+ return new DistributedLockAspect(redissonClient, callTransactionFactory);
+ }
+}
\ No newline at end of file
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/JpaPackageLocation.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/JpaPackageLocation.java
new file mode 100644
index 000000000..c46f1eca0
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/JpaPackageLocation.java
@@ -0,0 +1,4 @@
+package kr.co.pennyway.domain.domains;
+
+public interface JpaPackageLocation {
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/DeviceToken.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/DeviceToken.java
new file mode 100644
index 000000000..b291ac360
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/DeviceToken.java
@@ -0,0 +1,84 @@
+package kr.co.pennyway.domain.domains.device.domain;
+
+import jakarta.persistence.*;
+import kr.co.pennyway.domain.domains.user.domain.User;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.hibernate.annotations.ColumnDefault;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import java.time.LocalDateTime;
+import java.util.Objects;
+
+@Entity
+@Getter
+@Table(name = "device_token")
+@EntityListeners(AuditingEntityListener.class)
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class DeviceToken {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ private String token;
+
+ @ColumnDefault("true")
+ private Boolean activated;
+
+ @CreatedDate
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+ private LocalDateTime lastSignedInAt;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id")
+ private User user;
+
+ private DeviceToken(String token, Boolean activated, User user) {
+ this.token = Objects.requireNonNull(token, "token은 null이 될 수 없습니다.");
+ this.activated = Objects.requireNonNull(activated, "activated는 null이 될 수 없습니다.");
+ this.user = Objects.requireNonNull(user, "user는 null이 될 수 없습니다.");
+ this.lastSignedInAt = LocalDateTime.now();
+ }
+
+ public static DeviceToken of(String token, User user) {
+ return new DeviceToken(token, Boolean.TRUE, user);
+ }
+
+ public Boolean isActivated() {
+ return activated;
+ }
+
+ public void activate() {
+ this.activated = Boolean.TRUE;
+ }
+
+ public void deactivate() {
+ this.activated = Boolean.FALSE;
+ }
+
+ public void updateLastSignedInAt() {
+ this.lastSignedInAt = LocalDateTime.now();
+ }
+
+ /**
+ * 디바이스 토큰이 만료되었는지 확인한다.
+ *
+ * @return 토큰이 갱신된지 7일이 지났으면 true, 그렇지 않으면 false
+ */
+ public boolean isExpired() {
+ LocalDateTime now = LocalDateTime.now();
+
+ return lastSignedInAt.plusDays(7).isBefore(now);
+ }
+
+ @Override
+ public String toString() {
+ return "DeviceToken {" +
+ "id=" + id +
+ ", token='" + token + '\'' +
+ ", activated=" + activated + '}';
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceTokenErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceTokenErrorCode.java
new file mode 100644
index 000000000..32d05abf9
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceTokenErrorCode.java
@@ -0,0 +1,27 @@
+package kr.co.pennyway.domain.domains.device.exception;
+
+import kr.co.pennyway.common.exception.BaseErrorCode;
+import kr.co.pennyway.common.exception.CausedBy;
+import kr.co.pennyway.common.exception.ReasonCode;
+import kr.co.pennyway.common.exception.StatusCode;
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+public enum DeviceTokenErrorCode implements BaseErrorCode {
+ /* 404 NOT_FOUND */
+ NOT_FOUND_DEVICE(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "디바이스를 찾을 수 없습니다.");
+
+ private final StatusCode statusCode;
+ private final ReasonCode reasonCode;
+ private final String message;
+
+ @Override
+ public CausedBy causedBy() {
+ return CausedBy.of(statusCode, reasonCode);
+ }
+
+ @Override
+ public String getExplainError() throws NoSuchFieldError {
+ return message;
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceTokenErrorException.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceTokenErrorException.java
new file mode 100644
index 000000000..2a83c00c1
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceTokenErrorException.java
@@ -0,0 +1,20 @@
+package kr.co.pennyway.domain.domains.device.exception;
+
+import kr.co.pennyway.common.exception.GlobalErrorException;
+
+public class DeviceTokenErrorException extends GlobalErrorException {
+ private final DeviceTokenErrorCode deviceTokenErrorCode;
+
+ public DeviceTokenErrorException(DeviceTokenErrorCode deviceTokenErrorCode) {
+ super(deviceTokenErrorCode);
+ this.deviceTokenErrorCode = deviceTokenErrorCode;
+ }
+
+ public String getExplainError() {
+ return deviceTokenErrorCode.getExplainError();
+ }
+
+ public String getErrorCode() {
+ return deviceTokenErrorCode.name();
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenRepository.java
new file mode 100644
index 000000000..4e2ace8b4
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenRepository.java
@@ -0,0 +1,22 @@
+package kr.co.pennyway.domain.domains.device.repository;
+
+import kr.co.pennyway.domain.domains.device.domain.DeviceToken;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface DeviceTokenRepository extends JpaRepository {
+ @Query("SELECT d FROM DeviceToken d WHERE d.user.id = :userId AND d.token = :token")
+ Optional findByUser_IdAndToken(Long userId, String token);
+
+ List findAllByUser_Id(Long userId);
+
+ @Modifying(clearAutomatically = true)
+ @Transactional
+ @Query("UPDATE DeviceToken d SET d.activated = false WHERE d.user.id = :userId")
+ void deleteAllByUserIdInQuery(Long userId);
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/service/DeviceTokenService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/service/DeviceTokenService.java
new file mode 100644
index 000000000..21af5877b
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/service/DeviceTokenService.java
@@ -0,0 +1,32 @@
+package kr.co.pennyway.domain.domains.device.service;
+
+import kr.co.pennyway.common.annotation.DomainService;
+import kr.co.pennyway.domain.domains.device.domain.DeviceToken;
+import kr.co.pennyway.domain.domains.device.repository.DeviceTokenRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Optional;
+
+@Slf4j
+@DomainService
+@RequiredArgsConstructor
+public class DeviceTokenService {
+ private final DeviceTokenRepository deviceTokenRepository;
+
+ @Transactional
+ public DeviceToken createDevice(DeviceToken deviceToken) {
+ return deviceTokenRepository.save(deviceToken);
+ }
+
+ @Transactional
+ public Optional readDeviceByUserIdAndToken(Long userId, String token) {
+ return deviceTokenRepository.findByUser_IdAndToken(userId, token);
+ }
+
+ @Transactional
+ public void deleteDevicesByUserIdInQuery(Long userId) {
+ deviceTokenRepository.deleteAllByUserIdInQuery(userId);
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/domain/Notification.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/domain/Notification.java
new file mode 100644
index 000000000..49034994b
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/domain/Notification.java
@@ -0,0 +1,138 @@
+package kr.co.pennyway.domain.domains.notification.domain;
+
+import jakarta.persistence.*;
+import kr.co.pennyway.domain.common.converter.AnnouncementConverter;
+import kr.co.pennyway.domain.common.converter.NoticeTypeConverter;
+import kr.co.pennyway.domain.common.model.DateAuditable;
+import kr.co.pennyway.domain.domains.notification.type.Announcement;
+import kr.co.pennyway.domain.domains.notification.type.NoticeType;
+import kr.co.pennyway.domain.domains.user.domain.User;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.Objects;
+
+@Entity
+@Getter
+@Table(name = "notification")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Notification extends DateAuditable {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ private LocalDateTime readAt;
+ @Convert(converter = NoticeTypeConverter.class)
+ private NoticeType type;
+ @Convert(converter = AnnouncementConverter.class)
+ private Announcement announcement; // 공지 종류
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "sender")
+ private User sender;
+ private String senderName;
+
+ private Long toId;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "receiver")
+ private User receiver;
+ private String receiverName;
+
+ private Notification(NoticeType type, Announcement announcement, User sender, String senderName, Long toId, User receiver, String receiverName) {
+ this.type = Objects.requireNonNull(type);
+ this.announcement = Objects.requireNonNull(announcement);
+ this.sender = (!type.equals(NoticeType.ANNOUNCEMENT)) ? Objects.requireNonNull(sender) : sender;
+ this.senderName = (!type.equals(NoticeType.ANNOUNCEMENT)) ? Objects.requireNonNull(senderName) : senderName;
+ this.toId = toId;
+ this.receiver = Objects.requireNonNull(receiver);
+ this.receiverName = Objects.requireNonNull(receiverName);
+ }
+
+ @Override
+ public String toString() {
+ return "Notification{" +
+ "id=" + id +
+ ", readAt=" + readAt +
+ ", type=" + type +
+ ", announcement=" + announcement +
+ ", senderName='" + senderName + '\'' +
+ ", toId=" + toId +
+ ", receiverName='" + receiverName + '\'' +
+ '}';
+ }
+
+ /**
+ * 공지 제목을 생성한다.
+ *
+ * 이 메서드는 내부적으로 알림 타입의 종류에 따라 공지 제목을 포맷팅한다.
+ *
+ * @apiNote 이 메서드는 {@link NoticeType#ANNOUNCEMENT} 타입에 대해서만 동작한다. 다른 타입의 알림을 포맷팅해야 하는 경우 해당 메서드를 확장해야 한다.
+ */
+ public String createFormattedTitle() {
+ if (!type.equals(NoticeType.ANNOUNCEMENT)) {
+ return ""; // TODO: 알림 종류가 신규로 추가될 때, 해당 로직을 구현해야 함.
+ }
+
+ return formatAnnouncementTitle();
+ }
+
+ private String formatAnnouncementTitle() {
+ if (announcement.equals(Announcement.MONTHLY_TARGET_AMOUNT)) {
+ return announcement.createFormattedTitle(String.valueOf(getCreatedAt().getMonthValue()));
+ }
+
+ return announcement.createFormattedTitle(receiverName);
+ }
+
+ /**
+ * 공지 내용을 생성한다.
+ *
+ * 이 메서드는 내부적으로 알림 타입의 종류에 따라 공지 내용을 포맷팅한다.
+ *
+ * @apiNote 이 메서드는 {@link NoticeType#ANNOUNCEMENT} 타입에 대해서만 동작한다. 다른 타입의 알림을 포맷팅해야 하는 경우 해당 메서드를 확장해야 한다.
+ */
+ public String createFormattedContent() {
+ if (!type.equals(NoticeType.ANNOUNCEMENT)) {
+ return ""; // TODO: 알림 종류가 신규로 추가될 때, 해당 로직을 구현해야 함.
+ }
+
+ return announcement.createFormattedContent(receiverName);
+ }
+
+ public static class Builder {
+ private final NoticeType type;
+ private final Announcement announcement;
+ private final User receiver;
+ private final String receiverName;
+
+ private User sender;
+ private String senderName;
+
+ private Long toId;
+
+ public Builder(NoticeType type, Announcement announcement, User receiver) {
+ this.type = type;
+ this.announcement = announcement;
+ this.receiver = receiver;
+ this.receiverName = receiver.getName();
+ }
+
+ public Builder sender(User sender) {
+ this.sender = sender;
+ this.senderName = sender.getName();
+ return this;
+ }
+
+ public Builder toId(Long toId) {
+ this.toId = toId;
+ return this;
+ }
+
+ public Notification build() {
+ return new Notification(type, announcement, sender, senderName, toId, receiver, receiverName);
+ }
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepository.java
new file mode 100644
index 000000000..e85b146b2
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepository.java
@@ -0,0 +1,36 @@
+package kr.co.pennyway.domain.domains.notification.repository;
+
+import kr.co.pennyway.domain.domains.notification.type.Announcement;
+
+import java.util.List;
+
+public interface NotificationCustomRepository {
+ boolean existsUnreadNotification(Long userId);
+
+ /**
+ * 사용자들에게 정기 지출 등록 알림을 저장한다. (발송이 아님)
+ * 만약 이미 전송하려는 데이터가 년-월-일에 해당하는 생성일을 가지고 있고, 그 알림의 announcement 타입까지 같다면 저장하지 않는다.
+ *
+ *
+ * {@code
+ * INSERT INTO notification(type, announcement, created_at, updated_at, receiver, receiver_name)
+ * SELECT ?, ?, NOW(), NOW(), u.id, u.name
+ * FROM user u
+ * WHERE u.id IN (?)
+ * AND NOT EXISTS (
+ * SELECT n.receiver
+ * FROM notification n
+ * WHERE n.receiver = u.id
+ * AND n.created_at >= CURDATE()
+ * AND n.created_at < CURDATE() + INTERVAL 1 DAY
+ * AND n.type = '0'
+ * AND n.announcement = 1
+ * );
+ * }
+ *
+ *
+ * @param userIds : 등록할 사용자 아이디 목록
+ * @param announcement : 공지 타입 {@link Announcement}
+ */
+ void saveDailySpendingAnnounceInBulk(List userIds, Announcement announcement);
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepositoryImpl.java
new file mode 100644
index 000000000..7e20aa278
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepositoryImpl.java
@@ -0,0 +1,93 @@
+package kr.co.pennyway.domain.domains.notification.repository;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import kr.co.pennyway.domain.domains.notification.domain.QNotification;
+import kr.co.pennyway.domain.domains.notification.type.Announcement;
+import kr.co.pennyway.domain.domains.notification.type.NoticeType;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.jdbc.core.BatchPreparedStatementSetter;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Repository;
+
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.List;
+
+@Slf4j
+@Repository
+@RequiredArgsConstructor
+public class NotificationCustomRepositoryImpl implements NotificationCustomRepository {
+ private final JPAQueryFactory queryFactory;
+ private final JdbcTemplate jdbcTemplate;
+
+ private final QNotification notification = QNotification.notification;
+
+ private final int BATCH_SIZE = 1000;
+
+ @Override
+ public boolean existsUnreadNotification(Long userId) {
+ return queryFactory
+ .select(notification.id)
+ .from(notification)
+ .where(notification.receiver.id.eq(userId)
+ .and(notification.readAt.isNull()))
+ .fetchFirst() != null;
+ }
+
+ @Override
+ public void saveDailySpendingAnnounceInBulk(List userIds, Announcement announcement) {
+ int batchCount = 0;
+ List subItems = new ArrayList<>();
+
+ for (int i = 0; i < userIds.size(); ++i) {
+ subItems.add(userIds.get(i));
+
+ if ((i + 1) % BATCH_SIZE == 0) {
+ batchCount = batchInsert(batchCount, subItems, NoticeType.ANNOUNCEMENT, announcement);
+ }
+ }
+
+ if (!subItems.isEmpty()) {
+ batchInsert(batchCount, subItems, NoticeType.ANNOUNCEMENT, announcement);
+ }
+
+ log.info("Notification saved. announcement: {}, count: {}", announcement, userIds.size());
+ }
+
+ private int batchInsert(int batchCount, List userIds, NoticeType noticeType, Announcement announcement) {
+ String sql = "INSERT INTO notification(id, read_at, type, announcement, created_at, updated_at, receiver, receiver_name) " +
+ "SELECT NULL, NULL, ?, ?, NOW(), NOW(), u.id, u.name " +
+ "FROM user u " +
+ "WHERE u.id IN (?) " +
+ "AND NOT EXISTS ( " +
+ " SELECT n.receiver " +
+ " FROM notification n " +
+ " WHERE n.receiver = u.id " +
+ " AND n.created_at >= CURDATE() " +
+ " AND n.created_at < CURDATE() + INTERVAL 1 DAY " +
+ " AND n.type = ? " +
+ " AND n.announcement = ? " +
+ ")";
+
+ jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
+ @Override
+ public void setValues(PreparedStatement ps, int i) throws SQLException {
+ ps.setString(1, noticeType.getCode());
+ ps.setString(2, announcement.getCode());
+ ps.setLong(3, userIds.get(i));
+ ps.setString(4, noticeType.getCode());
+ ps.setString(5, announcement.getCode());
+ }
+
+ @Override
+ public int getBatchSize() {
+ return userIds.size();
+ }
+ });
+
+ userIds.clear();
+ return ++batchCount;
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepository.java
new file mode 100644
index 000000000..ad7217f11
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepository.java
@@ -0,0 +1,20 @@
+package kr.co.pennyway.domain.domains.notification.repository;
+
+import kr.co.pennyway.domain.common.repository.ExtendedRepository;
+import kr.co.pennyway.domain.domains.notification.domain.Notification;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+public interface NotificationRepository extends ExtendedRepository, NotificationCustomRepository {
+ @Modifying(clearAutomatically = true)
+ @Transactional
+ @Query("update Notification n set n.readAt = current_timestamp where n.id in ?1")
+ void updateReadAtByIdsInBulk(List notificationIds);
+
+ @Transactional(readOnly = true)
+ @Query("select count(n) from Notification n where n.receiver.id = ?1 and n.id in ?2 and n.readAt is null")
+ long countUnreadNotificationsByIds(Long userId, List notificationIds);
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/service/NotificationService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/service/NotificationService.java
new file mode 100644
index 000000000..599a66cb4
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/service/NotificationService.java
@@ -0,0 +1,54 @@
+package kr.co.pennyway.domain.domains.notification.service;
+
+import com.querydsl.core.types.Predicate;
+import kr.co.pennyway.common.annotation.DomainService;
+import kr.co.pennyway.domain.common.repository.QueryHandler;
+import kr.co.pennyway.domain.common.util.SliceUtil;
+import kr.co.pennyway.domain.domains.notification.domain.Notification;
+import kr.co.pennyway.domain.domains.notification.domain.QNotification;
+import kr.co.pennyway.domain.domains.notification.repository.NotificationRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+import org.springframework.data.domain.Sort;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+@Slf4j
+@DomainService
+@RequiredArgsConstructor
+public class NotificationService {
+ private final NotificationRepository notificationRepository;
+
+ private final QNotification notification = QNotification.notification;
+
+ @Transactional(readOnly = true)
+ public Slice readNotificationsSlice(Long userId, Pageable pageable) {
+ Predicate predicate = notification.receiver.id.eq(userId);
+
+ QueryHandler queryHandler = query -> query
+ .offset(pageable.getOffset())
+ .limit(pageable.getPageSize() + 1);
+
+ Sort sort = pageable.getSort();
+
+ return SliceUtil.toSlice(notificationRepository.findList(predicate, queryHandler, sort), pageable);
+ }
+
+ @Transactional(readOnly = true)
+ public boolean isExistsUnreadNotification(Long userId) {
+ return notificationRepository.existsUnreadNotification(userId);
+ }
+
+ @Transactional(readOnly = true)
+ public long countUnreadNotifications(Long userId, List notificationIds) {
+ return notificationRepository.countUnreadNotificationsByIds(userId, notificationIds);
+ }
+
+ @Transactional
+ public void updateReadAtByIdsInBulk(List notificationIds) {
+ notificationRepository.updateReadAtByIdsInBulk(notificationIds);
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/type/Announcement.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/type/Announcement.java
new file mode 100644
index 000000000..30fd87df9
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/type/Announcement.java
@@ -0,0 +1,70 @@
+package kr.co.pennyway.domain.domains.notification.type;
+
+import kr.co.pennyway.domain.common.converter.LegacyCommonType;
+import lombok.Getter;
+import org.springframework.util.StringUtils;
+
+@Getter
+public enum Announcement implements LegacyCommonType {
+ NOT_ANNOUNCE("0", "", ""),
+
+ // 정기 지출 알림
+ DAILY_SPENDING("1", "%s님, 3분 카레보다 빨리 끝나요!", "많은 친구들이 소비 기록에 참여하고 있어요👀"),
+ MONTHLY_TARGET_AMOUNT("2", "%s월의 첫 시작! 두구두구..🥁", "%s님의 이번 달 목표 소비 금액은?");
+
+ private final String code;
+ private final String title;
+ private final String content;
+
+ Announcement(String code, String title, String content) {
+ this.code = code;
+ this.title = title;
+ this.content = content;
+ }
+
+ /**
+ * 수신자의 이름을 받아서 공지 제목을 생성한다.
+ *
+ * 만약 해당 타입의 제목에서 % 문자가 없다면 그대로 반환한다.
+ *
+ * @param name 수신자의 이름
+ * @return 포맷팅된 공지 제목
+ */
+ public String createFormattedTitle(String name) {
+ validateName(name);
+
+ if (this.title.indexOf("%") == -1) {
+ return this.title;
+ }
+
+ return String.format(title, name);
+ }
+
+ /**
+ * 수신자의 이름을 받아서 공지 내용을 생성한다.
+ *
+ * 만약 해당 타입의 내용에서 % 문자가 없다면 그대로 반환한다.
+ *
+ * @param name 수신자의 이름
+ * @return 포맷팅된 공지 내용
+ */
+ public String createFormattedContent(String name) {
+ validateName(name);
+
+ if (this.content.indexOf("%") == -1) {
+ return this.content;
+ }
+
+ return String.format(content, name);
+ }
+
+ private void validateName(String name) {
+ if (!StringUtils.hasText(name)) {
+ throw new IllegalArgumentException("name must not be empty");
+ }
+
+ if (this == NOT_ANNOUNCE) {
+ throw new IllegalArgumentException("NOT_ANNOUNCE type is not allowed");
+ }
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/type/NoticeType.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/type/NoticeType.java
new file mode 100644
index 000000000..05bc2646b
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/type/NoticeType.java
@@ -0,0 +1,32 @@
+package kr.co.pennyway.domain.domains.notification.type;
+
+import kr.co.pennyway.domain.common.converter.LegacyCommonType;
+import lombok.Getter;
+
+/**
+ * 알림 종류를 정의하기 위한 타입
+ *
+ *
+ * 알림 타입은 [도메인]_[액션]_[FROM]_[TO?] 형태로 정의한다.
+ * 각 알림 타입에 대한 이름, 제목, 내용 형식을 지정하며, 알림 타입에 따라 내용을 생성하는 기능을 제공한다.
+ *
+ *
+ * @author YANG JAESEO
+ * @since 2024-07-04
+ */
+@Getter
+public enum NoticeType implements LegacyCommonType {
+ ANNOUNCEMENT("0", "%s", "%s"); // 공지 사항은 별도 제목을 설정하여 사용한다.
+
+ private final String code;
+ private final String title;
+ private final String contentFormat;
+ private final String navigablePlaceholders = "{%s_%d}";
+ private final String plainTextPlaceholders = "%s";
+
+ NoticeType(String code, String title, String contentFormat) {
+ this.code = code;
+ this.title = title;
+ this.contentFormat = contentFormat;
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java
new file mode 100644
index 000000000..0a566a20d
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java
@@ -0,0 +1,81 @@
+package kr.co.pennyway.domain.domains.oauth.domain;
+
+import jakarta.persistence.*;
+import kr.co.pennyway.domain.common.converter.ProviderConverter;
+import kr.co.pennyway.domain.domains.oauth.type.Provider;
+import kr.co.pennyway.domain.domains.user.domain.User;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.hibernate.annotations.ColumnDefault;
+import org.hibernate.annotations.DynamicInsert;
+import org.hibernate.annotations.SQLDelete;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+import org.springframework.util.StringUtils;
+
+import java.time.LocalDateTime;
+import java.util.Objects;
+
+@Entity
+@Getter
+@Table(name = "oauth")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@EntityListeners(AuditingEntityListener.class)
+@DynamicInsert
+@SQLDelete(sql = "UPDATE oauth SET deleted_at = NOW() WHERE id = ?")
+public class Oauth {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Convert(converter = ProviderConverter.class)
+ private Provider provider;
+ private String oauthId;
+
+ @CreatedDate
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+
+ @ColumnDefault("NULL")
+ private LocalDateTime deletedAt;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id")
+ private User user;
+
+ @Builder(access = AccessLevel.PRIVATE)
+ private Oauth(Provider provider, String oauthId, User user) {
+ if (!StringUtils.hasText(oauthId)) {
+ throw new IllegalArgumentException("oauthId는 null이거나 빈 문자열이 될 수 없습니다.");
+ }
+
+ this.provider = Objects.requireNonNull(provider, "provider는 null이 될 수 없습니다.");
+ this.oauthId = oauthId;
+ this.user = Objects.requireNonNull(user, "user는 null이 될 수 없습니다.");
+ }
+
+ public static Oauth of(Provider provider, String oauthId, User user) {
+ return Oauth.builder()
+ .provider(provider)
+ .oauthId(oauthId)
+ .user(user)
+ .build();
+ }
+
+ public boolean isDeleted() {
+ return deletedAt != null;
+ }
+
+ @Override
+ public String toString() {
+ return "Oauth{" +
+ "id=" + id +
+ ", provider=" + provider +
+ ", oauthId='" + oauthId + '\'' +
+ ", createdAt=" + createdAt +
+ ", deletedAt=" + deletedAt +
+ '}';
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java
new file mode 100644
index 000000000..2c2657149
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java
@@ -0,0 +1,43 @@
+package kr.co.pennyway.domain.domains.oauth.exception;
+
+import kr.co.pennyway.common.exception.BaseErrorCode;
+import kr.co.pennyway.common.exception.CausedBy;
+import kr.co.pennyway.common.exception.ReasonCode;
+import kr.co.pennyway.common.exception.StatusCode;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public enum OauthErrorCode implements BaseErrorCode {
+ /* 400 Bad Request */
+ INVALID_OAUTH_SYNC_REQUEST(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "Oauth 동기화 요청이 잘못되었습니다."),
+
+ /* 401 Unauthorized */
+ NOT_MATCHED_OAUTH_ID(StatusCode.UNAUTHORIZED, ReasonCode.MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS, "OAuth ID가 일치하지 않습니다."),
+
+ /* 404 Not Found */
+ NOT_FOUND_OAUTH(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "해당 제공자로 가입된 이력을 찾을 수 없습니다."),
+
+ /* 409 Conflict */
+ CANNOT_UNLINK_OAUTH(StatusCode.CONFLICT, ReasonCode.REQUEST_CONFLICTS_WITH_CURRENT_STATE_OF_RESOURCE, "해당 제공자로만 가입된 사용자는 연동을 해제할 수 없습니다."),
+ ALREADY_USED_OAUTH(StatusCode.CONFLICT, ReasonCode.REQUEST_CONFLICTS_WITH_CURRENT_STATE_OF_RESOURCE, "이미 다른 계정에서 사용 중인 계정입니다."),
+ ALREADY_SIGNUP_OAUTH(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 해당 제공자로 가입된 사용자입니다."),
+
+ /* 422 Unprocessable Entity */
+ INVALID_PROVIDER(StatusCode.UNPROCESSABLE_CONTENT, ReasonCode.TYPE_MISMATCH_ERROR_IN_REQUEST_BODY, "유효하지 않은 제공자입니다.");
+
+ private final StatusCode statusCode;
+ private final ReasonCode reasonCode;
+ private final String message;
+
+ @Override
+ public CausedBy causedBy() {
+ return CausedBy.of(statusCode, reasonCode);
+ }
+
+ @Override
+ public String getExplainError() throws NoSuchFieldError {
+ return message;
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthException.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthException.java
new file mode 100644
index 000000000..2276a4478
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthException.java
@@ -0,0 +1,22 @@
+package kr.co.pennyway.domain.domains.oauth.exception;
+
+import kr.co.pennyway.common.exception.CausedBy;
+import kr.co.pennyway.common.exception.GlobalErrorException;
+
+public class OauthException extends GlobalErrorException {
+ private final OauthErrorCode errorCode;
+
+ public OauthException(OauthErrorCode errorCode) {
+ super(errorCode);
+ this.errorCode = errorCode;
+ }
+
+ @Override
+ public CausedBy causedBy() {
+ return errorCode.causedBy();
+ }
+
+ public String getExplainError() {
+ return errorCode.getExplainError();
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java
new file mode 100644
index 000000000..5012fbeaa
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java
@@ -0,0 +1,28 @@
+package kr.co.pennyway.domain.domains.oauth.repository;
+
+import kr.co.pennyway.domain.domains.oauth.domain.Oauth;
+import kr.co.pennyway.domain.domains.oauth.type.Provider;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Optional;
+import java.util.Set;
+
+public interface OauthRepository extends JpaRepository {
+ Optional findByOauthIdAndProviderAndDeletedAtIsNull(String oauthId, Provider provider);
+
+ Optional findByUser_IdAndProviderAndDeletedAtIsNull(Long userId, Provider provider);
+
+ Set findAllByUser_Id(Long userId);
+
+ boolean existsByUser_IdAndProviderAndDeletedAtIsNull(Long userId, Provider provider);
+
+ boolean existsByOauthIdAndProviderAndDeletedAtIsNull(String oauthId, Provider provider);
+
+ @Transactional
+ @Modifying(clearAutomatically = true)
+ @Query("UPDATE Oauth o SET o.deletedAt = NOW() WHERE o.user.id = :userId AND o.deletedAt IS NULL")
+ void deleteAllByUser_IdAndDeletedAtNullInQuery(Long userId);
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java
new file mode 100644
index 000000000..02ac5ca0c
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java
@@ -0,0 +1,66 @@
+package kr.co.pennyway.domain.domains.oauth.service;
+
+import kr.co.pennyway.common.annotation.DomainService;
+import kr.co.pennyway.domain.domains.oauth.domain.Oauth;
+import kr.co.pennyway.domain.domains.oauth.repository.OauthRepository;
+import kr.co.pennyway.domain.domains.oauth.type.Provider;
+import lombok.RequiredArgsConstructor;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Optional;
+import java.util.Set;
+
+@DomainService
+@RequiredArgsConstructor
+public class OauthService {
+ private final OauthRepository oauthRepository;
+
+ @Transactional
+ public Oauth createOauth(Oauth oauth) {
+ return oauthRepository.save(oauth);
+ }
+
+ @Transactional
+ public Optional readOauth(Long id) {
+ return oauthRepository.findById(id);
+ }
+
+ /**
+ * oauthId와 provider로 Oauth를 조회한다. 이 때, deletedAt이 null인 Oauth만 조회한다.
+ */
+ @Transactional(readOnly = true)
+ public Optional readOauthByOauthIdAndProvider(String oauthId, Provider provider) {
+ return oauthRepository.findByOauthIdAndProviderAndDeletedAtIsNull(oauthId, provider);
+ }
+
+ @Transactional(readOnly = true)
+ public Set readOauthsByUserId(Long userId) {
+ return oauthRepository.findAllByUser_Id(userId);
+ }
+
+ /**
+ * userId와 provider로 Oauth가 존재하는지 확인한다. 이 때, deletedAt이 null인 Oauth만 조회한다.
+ */
+ @Transactional(readOnly = true)
+ public boolean isExistOauthByUserIdAndProvider(Long userId, Provider provider) {
+ return oauthRepository.existsByUser_IdAndProviderAndDeletedAtIsNull(userId, provider);
+ }
+
+ /**
+ * oauthId와 provider로 Oauth가 존재하는지 확인한다. 이 때, deletedAt이 null인 Oauth만 조회한다.
+ */
+ @Transactional(readOnly = true)
+ public boolean isExistOauthByOauthIdAndProvider(String oauthId, Provider provider) {
+ return oauthRepository.existsByOauthIdAndProviderAndDeletedAtIsNull(oauthId, provider);
+ }
+
+ @Transactional
+ public void deleteOauth(Oauth oauth) {
+ oauthRepository.delete(oauth);
+ }
+
+ @Transactional
+ public void deleteOauthsByUserIdInQuery(Long userId) {
+ oauthRepository.deleteAllByUser_IdAndDeletedAtNullInQuery(userId);
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/type/Provider.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/type/Provider.java
new file mode 100644
index 000000000..e405919ae
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/type/Provider.java
@@ -0,0 +1,36 @@
+package kr.co.pennyway.domain.domains.oauth.type;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonValue;
+import kr.co.pennyway.domain.common.converter.LegacyCommonType;
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+public enum Provider implements LegacyCommonType {
+ KAKAO("1", "카카오"),
+ GOOGLE("2", "구글"),
+ APPLE("3", "애플");
+
+ private final String code;
+ private final String type;
+
+ @JsonCreator
+ public Provider fromString(String type) {
+ return valueOf(type.toUpperCase());
+ }
+
+ @Override
+ public String getCode() {
+ return code;
+ }
+
+ @JsonValue
+ public String getType() {
+ return type;
+ }
+
+ @Override
+ public String toString() {
+ return name().toLowerCase();
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/domain/Question.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/domain/Question.java
new file mode 100644
index 000000000..6f369708d
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/domain/Question.java
@@ -0,0 +1,50 @@
+package kr.co.pennyway.domain.domains.question.domain;
+
+import jakarta.persistence.*;
+import kr.co.pennyway.domain.common.converter.QuestionCategoryConverter;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.hibernate.annotations.SQLDelete;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+import org.springframework.util.StringUtils;
+
+import java.time.LocalDateTime;
+import java.util.Objects;
+
+@Entity
+@Getter
+@Table(name = "question")
+@SQLDelete(sql = "UPDATE question SET deleted_at = NOW() WHERE id = ?")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@EntityListeners(AuditingEntityListener.class)
+public class Question {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+ @Column(nullable = false)
+ private String email;
+ @Convert(converter = QuestionCategoryConverter.class)
+ @Column(nullable = false)
+ private QuestionCategory category;
+ private String content;
+ @CreatedDate
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+ private LocalDateTime deletedAt;
+
+ @Builder
+ private Question(String email, QuestionCategory category, String content) {
+ if (!StringUtils.hasText(email)) {
+ throw new IllegalArgumentException("email은 null이거나 빈 문자열이 될 수 없습니다.");
+ } else if (!StringUtils.hasText(content)) {
+ throw new IllegalArgumentException("content는 null이거나 빈 문자열이 될 수 없습니다.");
+ }
+
+ this.email = email;
+ this.category = Objects.requireNonNull(category, "category는 null이 될 수 없습니다.");
+ this.content = content;
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/domain/QuestionCategory.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/domain/QuestionCategory.java
new file mode 100644
index 000000000..6fabf68b1
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/domain/QuestionCategory.java
@@ -0,0 +1,19 @@
+package kr.co.pennyway.domain.domains.question.domain;
+
+import kr.co.pennyway.domain.common.converter.LegacyCommonType;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+
+@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
+@Getter
+public enum QuestionCategory implements LegacyCommonType {
+ UTILIZATION("1", "이용 관련"),
+ BUG_REPORT("2", "오류 신고"),
+ SUGGESTION("3", "서비스 제안"),
+ ETC("4", "기타");
+
+ private final String code;
+ private final String title;
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/exception/QuestionErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/exception/QuestionErrorCode.java
new file mode 100644
index 000000000..6b20844a1
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/exception/QuestionErrorCode.java
@@ -0,0 +1,26 @@
+package kr.co.pennyway.domain.domains.question.exception;
+
+import kr.co.pennyway.common.exception.BaseErrorCode;
+import kr.co.pennyway.common.exception.CausedBy;
+import kr.co.pennyway.common.exception.ReasonCode;
+import kr.co.pennyway.common.exception.StatusCode;
+import lombok.AllArgsConstructor;
+
+@AllArgsConstructor
+public enum QuestionErrorCode implements BaseErrorCode {
+ INTERNAL_MAIL_ERROR(StatusCode.INTERNAL_SERVER_ERROR, ReasonCode.UNEXPECTED_ERROR, "메일 발송에 실패했습니다.");
+
+ private final StatusCode statusCode;
+ private final ReasonCode reasonCode;
+ private final String message;
+
+ @Override
+ public CausedBy causedBy() {
+ return CausedBy.of(statusCode, reasonCode);
+ }
+
+ @Override
+ public String getExplainError() throws NoSuchFieldError {
+ return message;
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/exception/QuestionErrorException.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/exception/QuestionErrorException.java
new file mode 100644
index 000000000..3eb89c218
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/exception/QuestionErrorException.java
@@ -0,0 +1,24 @@
+package kr.co.pennyway.domain.domains.question.exception;
+
+import kr.co.pennyway.common.exception.BaseErrorCode;
+import kr.co.pennyway.common.exception.CausedBy;
+import kr.co.pennyway.common.exception.GlobalErrorException;
+import kr.co.pennyway.domain.domains.user.exception.UserErrorCode;
+import lombok.Getter;
+
+public class QuestionErrorException extends GlobalErrorException {
+ private final QuestionErrorCode questionErrorCode;
+
+ public QuestionErrorException(QuestionErrorCode questionErrorCode) {
+ super(questionErrorCode);
+ this.questionErrorCode = questionErrorCode;
+ }
+
+ public CausedBy causedBy() {
+ return questionErrorCode.causedBy();
+ }
+
+ public String getExplainError() {
+ return questionErrorCode.getExplainError();
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/repository/QuestionRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/repository/QuestionRepository.java
new file mode 100644
index 000000000..d50f22cdd
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/repository/QuestionRepository.java
@@ -0,0 +1,7 @@
+package kr.co.pennyway.domain.domains.question.repository;
+
+import kr.co.pennyway.domain.domains.question.domain.Question;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface QuestionRepository extends JpaRepository {
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/service/QuestionService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/service/QuestionService.java
new file mode 100644
index 000000000..a8e68ce4d
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/service/QuestionService.java
@@ -0,0 +1,19 @@
+package kr.co.pennyway.domain.domains.question.service;
+
+import kr.co.pennyway.common.annotation.DomainService;
+import kr.co.pennyway.domain.domains.question.domain.Question;
+import kr.co.pennyway.domain.domains.question.repository.QuestionRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.transaction.annotation.Transactional;
+
+@DomainService
+@RequiredArgsConstructor
+public class QuestionService {
+ private final QuestionRepository questionRepository;
+
+ @Transactional
+ public void createQuestion(Question question) {
+ questionRepository.save(question);
+ }
+
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java
new file mode 100644
index 000000000..bdad51667
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java
@@ -0,0 +1,108 @@
+package kr.co.pennyway.domain.domains.spending.domain;
+
+import jakarta.persistence.*;
+import kr.co.pennyway.domain.common.converter.SpendingCategoryConverter;
+import kr.co.pennyway.domain.common.model.DateAuditable;
+import kr.co.pennyway.domain.domains.spending.dto.CategoryInfo;
+import kr.co.pennyway.domain.domains.spending.type.SpendingCategory;
+import kr.co.pennyway.domain.domains.user.domain.User;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.hibernate.annotations.SQLDelete;
+import org.hibernate.annotations.SQLRestriction;
+
+import java.time.LocalDateTime;
+import java.util.Objects;
+
+@Entity
+@Getter
+@Table(name = "spending")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@SQLRestriction("deleted_at IS NULL")
+@SQLDelete(sql = "UPDATE spending SET deleted_at = NOW() WHERE id = ?")
+public class Spending extends DateAuditable {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ private Integer amount;
+ @Convert(converter = SpendingCategoryConverter.class)
+ private SpendingCategory category;
+ private LocalDateTime spendAt;
+ private String accountName;
+ private String memo;
+ private LocalDateTime deletedAt;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id")
+ private User user;
+
+ /* category가 OTHER일 경우 spendingCustomCategory를 참조 */
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "spending_custom_category_id")
+ private SpendingCustomCategory spendingCustomCategory;
+
+ @Builder
+ private Spending(Integer amount, SpendingCategory category, LocalDateTime spendAt, String accountName, String memo, User user, SpendingCustomCategory spendingCustomCategory) {
+ if (spendingCustomCategory == null && (category.equals(SpendingCategory.CUSTOM) || category.equals(SpendingCategory.OTHER))) {
+ throw new IllegalArgumentException("서비스 제공 아이콘을 등록할 때는 CUSTOM, OHTER 아이콘을 사용할 수 없습니다.");
+ } else if (spendingCustomCategory != null && !category.equals(SpendingCategory.CUSTOM)) {
+ throw new IllegalArgumentException("사용자 정의 아이콘을 등록할 때는 CUSTOM 아이콘이어야 합니다.");
+ }
+
+ this.amount = Objects.requireNonNull(amount, "amount는 null이 될 수 없습니다.");
+ this.category = Objects.requireNonNull(category, "category는 null이 될 수 없습니다.");
+ this.spendAt = Objects.requireNonNull(spendAt, "spendAt는 null이 될 수 없습니다.");
+ this.accountName = accountName;
+ this.memo = memo;
+ this.user = Objects.requireNonNull(user, "user는 null이 될 수 없습니다.");
+ this.spendingCustomCategory = spendingCustomCategory;
+ }
+
+ public int getDay() {
+ return spendAt.getDayOfMonth();
+ }
+
+ /**
+ * 지출 내역의 소비 카테고리를 조회하는 메서드
+ * SpendingCategory가 OTHER일 경우 SpendingCustomCategory를 정보를 조회하여 반환한다.
+ *
+ * @return {@link CategoryInfo}
+ */
+ public CategoryInfo getCategory() {
+ if (this.category.equals(SpendingCategory.CUSTOM)) {
+ SpendingCustomCategory category = getSpendingCustomCategory();
+ return CategoryInfo.of(category.getId(), category.getName(), category.getIcon());
+ }
+
+ return CategoryInfo.of(-1L, this.category.getType(), this.category);
+ }
+
+ public void update(Integer amount, SpendingCategory category, LocalDateTime spendAt, String accountName, String memo, SpendingCustomCategory spendingCustomCategory) {
+ if (spendingCustomCategory == null && (category.equals(SpendingCategory.CUSTOM) || category.equals(SpendingCategory.OTHER))) {
+ throw new IllegalArgumentException("서비스 제공 아이콘을 등록할 때는 CUSTOM, OHTER 아이콘을 사용할 수 없습니다.");
+ } else if (spendingCustomCategory != null && !category.equals(SpendingCategory.CUSTOM)) {
+ throw new IllegalArgumentException("사용자 정의 아이콘을 등록할 때는 CUSTOM 아이콘이어야 합니다.");
+ }
+
+ this.amount = Objects.requireNonNull(amount, "amount는 null이 될 수 없습니다.");
+ this.category = Objects.requireNonNull(category, "category는 null이 될 수 없습니다.");
+ this.spendAt = Objects.requireNonNull(spendAt, "spendAt는 null이 될 수 없습니다.");
+ this.accountName = accountName;
+ this.memo = memo;
+ this.spendingCustomCategory = spendingCustomCategory;
+ }
+
+ @Override
+ public String toString() {
+ return "Spending{" +
+ "id=" + id +
+ ", amount=" + amount +
+ ", category=" + category +
+ ", spendAt=" + spendAt +
+ ", accountName='" + accountName + '\'' +
+ ", memo='" + memo + "'}";
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java
new file mode 100644
index 000000000..083f6a3c3
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java
@@ -0,0 +1,67 @@
+package kr.co.pennyway.domain.domains.spending.domain;
+
+import jakarta.persistence.*;
+import kr.co.pennyway.domain.common.converter.SpendingCategoryConverter;
+import kr.co.pennyway.domain.common.model.DateAuditable;
+import kr.co.pennyway.domain.domains.spending.type.SpendingCategory;
+import kr.co.pennyway.domain.domains.user.domain.User;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.hibernate.annotations.SQLDelete;
+import org.hibernate.annotations.SQLRestriction;
+
+import java.time.LocalDateTime;
+
+@Entity
+@Getter
+@Table(name = "spending_custom_category")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@SQLRestriction("deleted_at IS NULL")
+@SQLDelete(sql = "UPDATE spending_custom_category SET deleted_at = NOW() WHERE id = ?")
+public class SpendingCustomCategory extends DateAuditable {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ private String name;
+ @Convert(converter = SpendingCategoryConverter.class)
+ private SpendingCategory icon;
+ private LocalDateTime deletedAt;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id")
+ private User user;
+
+ private SpendingCustomCategory(String name, SpendingCategory icon, User user) {
+ if (icon.equals(SpendingCategory.CUSTOM)) {
+ throw new IllegalArgumentException("OTHER 아이콘은 커스텀 카테고리의 icon으로 사용할 수 없습니다.");
+ }
+
+ this.name = name;
+ this.icon = icon;
+ this.user = user;
+ }
+
+ public static SpendingCustomCategory of(String name, SpendingCategory icon, User user) {
+ return new SpendingCustomCategory(name, icon, user);
+ }
+
+ public void update(String name, SpendingCategory icon) {
+ if (icon.equals(SpendingCategory.CUSTOM)) {
+ throw new IllegalArgumentException("OTHER 아이콘은 커스텀 카테고리의 icon으로 사용할 수 없습니다.");
+ }
+
+ this.name = name;
+ this.icon = icon;
+ }
+
+ @Override
+ public String toString() {
+ return "SpendingCustomCategory{" +
+ "id=" + id +
+ ", name='" + name + '\'' +
+ ", icon=" + icon +
+ ", deletedAt=" + deletedAt + '}';
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/CategoryInfo.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/CategoryInfo.java
new file mode 100644
index 000000000..9934bb592
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/CategoryInfo.java
@@ -0,0 +1,42 @@
+package kr.co.pennyway.domain.domains.spending.dto;
+
+import kr.co.pennyway.domain.domains.spending.type.SpendingCategory;
+import org.springframework.util.StringUtils;
+
+import java.util.Objects;
+
+/**
+ * 지출 카테고리 정보를 담은 DTO
+ *
+ * @param isCustom boolean : 사용자 정의 카테고리 여부
+ * @param id Long : 카테고리 ID. 사용자 정의 카테고리가 아니라면 -1, 사용자 정의 카테고리라면 0 이상의 값을 갖는다.
+ * @param name String : 카테고리 이름
+ * @param icon String : 카테고리 아이콘
+ */
+public record CategoryInfo(
+ boolean isCustom,
+ Long id,
+ String name,
+ SpendingCategory icon
+) {
+ public CategoryInfo {
+ Objects.requireNonNull(id, "id는 null일 수 없습니다.");
+ Objects.requireNonNull(icon, "icon은 null일 수 없습니다.");
+
+ if (isCustom && id < 0 || !isCustom && id != -1) {
+ throw new IllegalArgumentException("isCustom이 " + isCustom + "일 때 id는 " + (isCustom ? "0 이상" : "-1") + "이어야 합니다.");
+ }
+
+ if (isCustom && icon.equals(SpendingCategory.CUSTOM)) {
+ throw new IllegalArgumentException("사용자 정의 카테고리는 OTHER가 될 수 없습니다.");
+ }
+
+ if (!StringUtils.hasText(name)) {
+ throw new IllegalArgumentException("name은 null이거나 빈 문자열일 수 없습니다.");
+ }
+ }
+
+ public static CategoryInfo of(Long id, String name, SpendingCategory icon) {
+ return new CategoryInfo(!id.equals(-1L), id, name, icon);
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/TotalSpendingAmount.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/TotalSpendingAmount.java
new file mode 100644
index 000000000..a21c4504d
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/TotalSpendingAmount.java
@@ -0,0 +1,28 @@
+package kr.co.pennyway.domain.domains.spending.dto;
+
+import java.time.YearMonth;
+
+/**
+ * 사용자의 해당 년/월 총 지출 금액을 담는 DTO
+ */
+public record TotalSpendingAmount(
+ int year,
+ int month,
+ long totalSpending
+) {
+ public TotalSpendingAmount(int year, int month, long totalSpending) {
+ this.year = year;
+ this.month = month;
+ this.totalSpending = totalSpending;
+ }
+
+ /**
+ * YearMonth 객체로 변환하는 메서드
+ *
+ * @return 해당 년/월을 나타내는 YearMonth 객체
+ */
+ public YearMonth getYearMonth() {
+ return YearMonth.of(year, month);
+ }
+
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java
new file mode 100644
index 000000000..eb38dc5f4
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java
@@ -0,0 +1,36 @@
+package kr.co.pennyway.domain.domains.spending.exception;
+
+import kr.co.pennyway.common.exception.BaseErrorCode;
+import kr.co.pennyway.common.exception.CausedBy;
+import kr.co.pennyway.common.exception.ReasonCode;
+import kr.co.pennyway.common.exception.StatusCode;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public enum SpendingErrorCode implements BaseErrorCode {
+ /* 400 Bad Request */
+ INVALID_ICON(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "CUSTOM 아이콘은 커스텀 카테고리의 icon으로 사용할 수 없습니다."),
+ INVALID_ICON_WITH_CATEGORY_ID(StatusCode.BAD_REQUEST, ReasonCode.CLIENT_ERROR, "icon의 정보와 categoryId의 정보가 존재할 수 없는 조합입니다."),
+ INVALID_TYPE_WITH_CATEGORY_ID(StatusCode.BAD_REQUEST, ReasonCode.CLIENT_ERROR, "type의 정보와 categoryId의 정보가 존재할 수 없는 조합입니다."),
+ INVALID_CATEGORY_TYPE(StatusCode.BAD_REQUEST, ReasonCode.CLIENT_ERROR, "존재하지 않는 카테고리 타입입니다."),
+
+ /* 404 Not Found */
+ NOT_FOUND_SPENDING(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "존재하지 않는 지출 내역입니다."),
+ NOT_FOUND_CUSTOM_CATEGORY(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "존재하지 않는 커스텀 카테고리입니다.");
+
+ private final StatusCode statusCode;
+ private final ReasonCode reasonCode;
+ private final String message;
+
+ @Override
+ public CausedBy causedBy() {
+ return CausedBy.of(statusCode, reasonCode);
+ }
+
+ @Override
+ public String getExplainError() throws NoSuchFieldError {
+ return message;
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorException.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorException.java
new file mode 100644
index 000000000..74eb92be4
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorException.java
@@ -0,0 +1,22 @@
+package kr.co.pennyway.domain.domains.spending.exception;
+
+import kr.co.pennyway.common.exception.CausedBy;
+import kr.co.pennyway.common.exception.GlobalErrorException;
+
+public class SpendingErrorException extends GlobalErrorException {
+ private final SpendingErrorCode errorCode;
+
+ public SpendingErrorException(SpendingErrorCode errorCode) {
+ super(errorCode);
+ this.errorCode = errorCode;
+ }
+
+ @Override
+ public CausedBy causedBy() {
+ return errorCode.causedBy();
+ }
+
+ public String getExplainError() {
+ return errorCode.getExplainError();
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomCategoryRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomCategoryRepository.java
new file mode 100644
index 000000000..7632740da
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomCategoryRepository.java
@@ -0,0 +1,22 @@
+package kr.co.pennyway.domain.domains.spending.repository;
+
+import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+public interface SpendingCustomCategoryRepository extends JpaRepository {
+ @Transactional(readOnly = true)
+ boolean existsByIdAndUser_Id(Long id, Long userId);
+
+ @Transactional(readOnly = true)
+ List findAllByUser_Id(Long userId);
+
+ @Modifying(clearAutomatically = true)
+ @Transactional
+ @Query("UPDATE SpendingCustomCategory s SET s.deletedAt = NOW() WHERE s.user.id = :userId")
+ void deleteAllByUserIdInQuery(Long userId);
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepository.java
new file mode 100644
index 000000000..3c94a3481
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepository.java
@@ -0,0 +1,13 @@
+package kr.co.pennyway.domain.domains.spending.repository;
+
+import kr.co.pennyway.domain.domains.spending.domain.Spending;
+import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface SpendingCustomRepository {
+ Optional findTotalSpendingAmountByUserId(Long userId, int year, int month);
+
+ List findByYearAndMonth(Long userId, int year, int month);
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java
new file mode 100644
index 000000000..7c780895c
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java
@@ -0,0 +1,64 @@
+package kr.co.pennyway.domain.domains.spending.repository;
+
+import com.querydsl.core.types.OrderSpecifier;
+import com.querydsl.core.types.Projections;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import kr.co.pennyway.domain.common.util.QueryDslUtil;
+import kr.co.pennyway.domain.domains.spending.domain.QSpending;
+import kr.co.pennyway.domain.domains.spending.domain.QSpendingCustomCategory;
+import kr.co.pennyway.domain.domains.spending.domain.Spending;
+import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount;
+import kr.co.pennyway.domain.domains.user.domain.QUser;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Sort;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+@Slf4j
+@Repository
+@RequiredArgsConstructor
+public class SpendingCustomRepositoryImpl implements SpendingCustomRepository {
+ private final JPAQueryFactory queryFactory;
+
+ private final QUser user = QUser.user;
+ private final QSpending spending = QSpending.spending;
+ private final QSpendingCustomCategory spendingCustomCategory = QSpendingCustomCategory.spendingCustomCategory;
+
+ @Override
+ public Optional findTotalSpendingAmountByUserId(Long userId, int year, int month) {
+ TotalSpendingAmount result = queryFactory.select(
+ Projections.constructor(
+ TotalSpendingAmount.class,
+ spending.spendAt.year().intValue(),
+ spending.spendAt.month().intValue(),
+ spending.amount.sum().longValue()
+ )
+ ).from(user)
+ .leftJoin(spending).on(user.id.eq(spending.user.id))
+ .where(user.id.eq(userId)
+ .and(spending.spendAt.year().eq(year))
+ .and(spending.spendAt.month().eq(month)))
+ .groupBy(spending.spendAt.year(), spending.spendAt.month())
+ .fetchOne();
+
+ return Optional.ofNullable(result);
+ }
+
+ @Override
+ public List findByYearAndMonth(Long userId, int year, int month) {
+ Sort sort = Sort.by(Sort.Order.desc("spendAt"));
+ List> orderSpecifiers = QueryDslUtil.getOrderSpecifier(sort);
+
+ return queryFactory.selectFrom(spending)
+ .leftJoin(spending.spendingCustomCategory, spendingCustomCategory).fetchJoin()
+ .where(spending.spendAt.year().eq(year)
+ .and(spending.spendAt.month().eq(month))
+ .and(spending.user.id.eq(userId))
+ )
+ .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0]))
+ .fetch();
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java
new file mode 100644
index 000000000..f9e65322b
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java
@@ -0,0 +1,59 @@
+package kr.co.pennyway.domain.domains.spending.repository;
+
+import kr.co.pennyway.domain.common.repository.ExtendedRepository;
+import kr.co.pennyway.domain.domains.spending.domain.Spending;
+import kr.co.pennyway.domain.domains.spending.type.SpendingCategory;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+public interface SpendingRepository extends ExtendedRepository, SpendingCustomRepository {
+ @Transactional(readOnly = true)
+ boolean existsByIdAndUser_Id(Long id, Long userId);
+
+ @Transactional(readOnly = true)
+ int countByUser_IdAndSpendingCustomCategory_Id(Long userId, Long categoryId);
+
+ @Transactional(readOnly = true)
+ int countByUser_IdAndCategory(Long userId, SpendingCategory spendingCategory);
+
+ @Transactional(readOnly = true)
+ long countByUserIdAndIdIn(Long userId, List spendingIds);
+
+ @Modifying(clearAutomatically = true)
+ @Transactional
+ @Query("UPDATE Spending s SET s.spendingCustomCategory.id = :toCategoryId, s.category = :custom WHERE s.category = :fromCategory")
+ void updateCategoryByCustomCategoryInQuery(SpendingCategory fromCategory, Long toCategoryId, SpendingCategory custom);
+
+ @Modifying(clearAutomatically = true)
+ @Transactional
+ @Query("UPDATE Spending s SET s.category = :toCategory WHERE s.category = :fromCategory")
+ void updateCategoryByCategoryInQuery(SpendingCategory fromCategory, SpendingCategory toCategory);
+
+ @Modifying(clearAutomatically = true)
+ @Transactional
+ @Query("UPDATE Spending s SET s.spendingCustomCategory.id = :toCategoryId WHERE s.spendingCustomCategory.id = :fromCategoryId")
+ void updateCustomCategoryByCustomCategoryInQuery(Long fromCategoryId, Long toCategoryId);
+
+ @Modifying(clearAutomatically = true)
+ @Transactional
+ @Query("UPDATE Spending s SET s.spendingCustomCategory = null, s.category = :toCategory WHERE s.spendingCustomCategory.id = :fromCategoryId")
+ void updateCustomCategoryByCategoryInQuery(Long fromCategoryId, SpendingCategory toCategory);
+
+ @Modifying(clearAutomatically = true)
+ @Transactional
+ @Query("UPDATE Spending s SET s.deletedAt = NOW() where s.id IN :spendingIds")
+ void deleteAllByIdAndDeletedAtNullInQuery(List spendingIds);
+
+ @Modifying(clearAutomatically = true)
+ @Transactional
+ @Query("UPDATE Spending s SET s.deletedAt = NOW() WHERE s.user.id = :userId")
+ void deleteAllByUserIdInQuery(Long userId);
+
+ @Transactional
+ @Modifying(clearAutomatically = true)
+ @Query("UPDATE Spending s SET s.deletedAt = NOW() WHERE s.spendingCustomCategory.id = :categoryId")
+ void deleteAllByCategoryIdAndDeletedAtNullInQuery(Long categoryId);
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryService.java
new file mode 100644
index 000000000..57f0cd43e
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryService.java
@@ -0,0 +1,48 @@
+package kr.co.pennyway.domain.domains.spending.service;
+
+import kr.co.pennyway.common.annotation.DomainService;
+import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory;
+import kr.co.pennyway.domain.domains.spending.repository.SpendingCustomCategoryRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.Optional;
+
+@Slf4j
+@DomainService
+@RequiredArgsConstructor
+public class SpendingCustomCategoryService {
+ private final SpendingCustomCategoryRepository spendingCustomCategoryRepository;
+
+ @Transactional
+ public SpendingCustomCategory createSpendingCustomCategory(SpendingCustomCategory spendingCustomCategory) {
+ return spendingCustomCategoryRepository.save(spendingCustomCategory);
+ }
+
+ @Transactional(readOnly = true)
+ public Optional readSpendingCustomCategory(Long id) {
+ return spendingCustomCategoryRepository.findById(id);
+ }
+
+ @Transactional(readOnly = true)
+ public List readSpendingCustomCategories(Long userId) {
+ return spendingCustomCategoryRepository.findAllByUser_Id(userId);
+ }
+
+ @Transactional(readOnly = true)
+ public boolean isExistsSpendingCustomCategory(Long userId, Long categoryId) {
+ return spendingCustomCategoryRepository.existsByIdAndUser_Id(categoryId, userId);
+ }
+
+ @Transactional
+ public void deleteSpendingCustomCategory(Long categoryId) {
+ spendingCustomCategoryRepository.deleteById(categoryId);
+ }
+
+ @Transactional
+ public void deleteSpendingCustomCategoriesByUserIdInQuery(Long userId) {
+ spendingCustomCategoryRepository.deleteAllByUserIdInQuery(userId);
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java
new file mode 100644
index 000000000..f8bff76cd
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java
@@ -0,0 +1,177 @@
+package kr.co.pennyway.domain.domains.spending.service;
+
+import com.querydsl.core.types.Expression;
+import com.querydsl.core.types.Predicate;
+import kr.co.pennyway.common.annotation.DomainService;
+import kr.co.pennyway.domain.common.repository.QueryHandler;
+import kr.co.pennyway.domain.common.util.SliceUtil;
+import kr.co.pennyway.domain.domains.spending.domain.QSpending;
+import kr.co.pennyway.domain.domains.spending.domain.QSpendingCustomCategory;
+import kr.co.pennyway.domain.domains.spending.domain.Spending;
+import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount;
+import kr.co.pennyway.domain.domains.spending.repository.SpendingRepository;
+import kr.co.pennyway.domain.domains.spending.type.SpendingCategory;
+import kr.co.pennyway.domain.domains.user.domain.QUser;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+import org.springframework.data.domain.Sort;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDate;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+@Slf4j
+@DomainService
+@RequiredArgsConstructor
+public class SpendingService {
+ private final SpendingRepository spendingRepository;
+
+ private final QUser user = QUser.user;
+ private final QSpending spending = QSpending.spending;
+ private final QSpendingCustomCategory spendingCustomCategory = QSpendingCustomCategory.spendingCustomCategory;
+
+ @Transactional
+ public Spending createSpending(Spending spending) {
+ return spendingRepository.save(spending);
+ }
+
+ @Transactional(readOnly = true)
+ public Optional readSpending(Long spendingId) {
+ return spendingRepository.findById(spendingId);
+ }
+
+ @Transactional(readOnly = true)
+ public List readSpendings(Long userId, int year, int month) {
+ return spendingRepository.findByYearAndMonth(userId, year, month);
+ }
+
+ @Transactional(readOnly = true)
+ public int readSpendingTotalCountByCategoryId(Long userId, Long categoryId) {
+ return spendingRepository.countByUser_IdAndSpendingCustomCategory_Id(userId, categoryId);
+ }
+
+ @Transactional(readOnly = true)
+ public int readSpendingTotalCountByCategory(Long userId, SpendingCategory spendingCategory) {
+ return spendingRepository.countByUser_IdAndCategory(userId, spendingCategory);
+ }
+
+ /**
+ * 사용자 정의 카테고리 ID로 지출 내역 리스트를 조회한다.
+ *
+ * @return 지출 내역 리스트를 {@link Slice}에 담아서 반환한다.
+ */
+ @Transactional(readOnly = true)
+ public Slice readSpendingsSliceByCategoryId(Long userId, Long categoryId, Pageable pageable) {
+ Predicate predicate = spending.user.id.eq(userId).and(spendingCustomCategory.id.eq(categoryId));
+
+ QueryHandler queryHandler = query -> query
+ .leftJoin(spending.spendingCustomCategory, spendingCustomCategory).fetchJoin()
+ .offset(pageable.getOffset())
+ .limit(pageable.getPageSize() + 1);
+
+ Sort sort = pageable.getSort();
+
+ return SliceUtil.toSlice(spendingRepository.findList(predicate, queryHandler, sort), pageable);
+ }
+
+ /**
+ * 시스템 제공 카테고리 code로 지출 내역 리스트를 조회한다.
+ *
+ * @return 지출 내역 리스트를 {@link Slice}에 담아서 반환한다.
+ */
+ @Transactional(readOnly = true)
+ public Slice readSpendingsSliceByCategory(Long userId, SpendingCategory spendingCategory, Pageable pageable) {
+ if (spendingCategory.equals(SpendingCategory.CUSTOM) || spendingCategory.equals(SpendingCategory.OTHER)) {
+ throw new IllegalArgumentException("지출 카테고리가 시스템 제공 카테고리가 아닙니다.");
+ }
+
+ Predicate predicate = spending.user.id.eq(userId).and(spending.category.eq(spendingCategory));
+
+ QueryHandler queryHandler = query -> query
+ .offset(pageable.getOffset())
+ .limit(pageable.getPageSize() + 1);
+
+ Sort sort = pageable.getSort();
+
+ return SliceUtil.toSlice(spendingRepository.findList(predicate, queryHandler, sort), pageable);
+ }
+
+ @Transactional(readOnly = true)
+ public Optional readTotalSpendingAmountByUserId(Long userId, LocalDate date) {
+ return spendingRepository.findTotalSpendingAmountByUserId(userId, date.getYear(), date.getMonthValue());
+ }
+
+ @Transactional(readOnly = true)
+ public List readTotalSpendingsAmountByUserId(Long userId) {
+ Predicate predicate = user.id.eq(userId);
+
+ QueryHandler queryHandler = query -> query.leftJoin(spending).on(user.id.eq(spending.user.id))
+ .groupBy(spending.spendAt.year(), spending.spendAt.month());
+
+ Sort sort = Sort.by(Sort.Order.desc("year(spendAt)"), Sort.Order.desc("month(spendAt)"));
+
+ Map> bindings = new LinkedHashMap<>();
+ bindings.put("year", spending.spendAt.year().intValue());
+ bindings.put("month", spending.spendAt.month().intValue());
+ bindings.put("totalSpending", spending.amount.sum().longValue());
+
+ return spendingRepository.selectList(predicate, TotalSpendingAmount.class, bindings, queryHandler, sort);
+ }
+
+ @Transactional(readOnly = true)
+ public boolean isExistsSpending(Long userId, Long spendingId) {
+ return spendingRepository.existsByIdAndUser_Id(spendingId, userId);
+ }
+
+ @Transactional(readOnly = true)
+ public long countByUserIdAndIdIn(Long userId, List spendingIds) {
+ return spendingRepository.countByUserIdAndIdIn(userId, spendingIds);
+ }
+
+ @Transactional
+ public void updateCategoryByCustomCategory(SpendingCategory fromCategory, Long toId) {
+ SpendingCategory custom = SpendingCategory.CUSTOM;
+ spendingRepository.updateCategoryByCustomCategoryInQuery(fromCategory, toId, custom);
+ }
+
+ @Transactional
+ public void updateCategoryByCategory(SpendingCategory fromCategory, SpendingCategory toCategory) {
+
+ spendingRepository.updateCategoryByCategoryInQuery(fromCategory, toCategory);
+ }
+
+ @Transactional
+ public void updateCustomCategoryByCustomCategory(Long fromId, Long toId) {
+ spendingRepository.updateCustomCategoryByCustomCategoryInQuery(fromId, toId);
+ }
+
+ @Transactional
+ public void updateCustomCategoryByCategory(Long fromId, SpendingCategory toCategory) {
+ spendingRepository.updateCustomCategoryByCategoryInQuery(fromId, toCategory);
+ }
+
+ @Transactional
+ public void deleteSpending(Spending spending) {
+ spendingRepository.delete(spending);
+ }
+
+ @Transactional
+ public void deleteSpendingsInQuery(List spendingIds) {
+ spendingRepository.deleteAllByIdAndDeletedAtNullInQuery(spendingIds);
+ }
+
+ @Transactional
+ public void deleteSpendingsByUserIdInQuery(Long userId) {
+ spendingRepository.deleteAllByUserIdInQuery(userId);
+ }
+
+ @Transactional
+ public void deleteSpendingsByCategoryIdInQuery(Long categoryId) {
+ spendingRepository.deleteAllByCategoryIdAndDeletedAtNullInQuery(categoryId);
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/type/SpendingCategory.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/type/SpendingCategory.java
new file mode 100644
index 000000000..aa5ad5431
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/type/SpendingCategory.java
@@ -0,0 +1,35 @@
+package kr.co.pennyway.domain.domains.spending.type;
+
+import kr.co.pennyway.domain.common.converter.LegacyCommonType;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+import java.util.stream.Stream;
+
+@Getter
+@RequiredArgsConstructor
+public enum SpendingCategory implements LegacyCommonType {
+ CUSTOM("0", "사용자 정의"),
+ FOOD("1", "식비"),
+ TRANSPORTATION("2", "교통"),
+ BEAUTY_OR_FASHION("3", "뷰티/패션"),
+ CONVENIENCE_STORE("4", "편의점/마트"),
+ EDUCATION("5", "교육"),
+ LIVING("6", "생활"),
+ HEALTH("7", "건강"),
+ HOBBY("8", "취미/여가"),
+ TRAVEL("9", "여행/숙박"),
+ ALCOHOL_OR_ENTERTAINMENT("10", "술/유흥"),
+ MEMBERSHIP_OR_FAMILY_EVENT("11", "회비/경조사"),
+ OTHER("12", "기타");
+
+ private final String code;
+ private final String type;
+
+ public static SpendingCategory fromCode(String code) {
+ return Stream.of(values())
+ .filter(v -> v.getCode().equals(code))
+ .findFirst()
+ .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 카테고리 코드입니다."));
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/domain/TargetAmount.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/domain/TargetAmount.java
new file mode 100644
index 000000000..8576bdf77
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/domain/TargetAmount.java
@@ -0,0 +1,72 @@
+package kr.co.pennyway.domain.domains.target.domain;
+
+import jakarta.persistence.*;
+import kr.co.pennyway.domain.common.model.DateAuditable;
+import kr.co.pennyway.domain.domains.user.domain.User;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.hibernate.annotations.SQLDelete;
+
+import java.time.YearMonth;
+import java.util.Objects;
+
+@Entity
+@Getter
+@Table(name = "target_amount")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@SQLDelete(sql = "UPDATE target_amount SET amount = -1, is_read = 1 WHERE id = ?")
+public class TargetAmount extends DateAuditable {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ private int amount;
+ private boolean isRead;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id")
+ private User user;
+
+ private TargetAmount(int amount, User user) {
+ this.amount = amount;
+ this.user = Objects.requireNonNull(user, "user는 null이 될 수 없습니다.");
+ this.isRead = false;
+ }
+
+ /**
+ * @param amount 목표 금액은 null을 허용하지 않는다.
+ * @param user 사용자는 null을 허용하지 않는다.
+ * @throws NullPointerException amount가 null이거나 user가 null일 때
+ */
+ public static TargetAmount of(int amount, User user) {
+ return new TargetAmount(amount, user);
+ }
+
+ /**
+ * @param amount 변경할 목표 금액은 null을 허용하지 않는다.
+ */
+ public void updateAmount(int amount) {
+ this.amount = amount;
+ this.isRead = true;
+ }
+
+ public boolean isAllocatedAmount() {
+ return this.amount >= 0;
+ }
+
+ /**
+ * 해당 TargetAmount가 당월 데이터인지 확인한다.
+ *
+ * @return 당월 데이터라면 true, 아니라면 false
+ */
+ public boolean isThatMonth() {
+ YearMonth yearMonth = YearMonth.now();
+ return this.getCreatedAt().getYear() == yearMonth.getYear() && this.getCreatedAt().getMonth() == yearMonth.getMonth();
+ }
+
+ @Override
+ public String toString() {
+ return "TargetAmount(id=" + this.getId() + ", amount=" + this.getAmount() + ", year = " + this.getCreatedAt().getYear() + ", month = " + this.getCreatedAt().getMonthValue() + ")";
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorCode.java
new file mode 100644
index 000000000..2a85e2351
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorCode.java
@@ -0,0 +1,34 @@
+package kr.co.pennyway.domain.domains.target.exception;
+
+import kr.co.pennyway.common.exception.BaseErrorCode;
+import kr.co.pennyway.common.exception.CausedBy;
+import kr.co.pennyway.common.exception.ReasonCode;
+import kr.co.pennyway.common.exception.StatusCode;
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+public enum TargetAmountErrorCode implements BaseErrorCode {
+ /* 400 BAD_REQUEST */
+ INVALID_TARGET_AMOUNT_DATE(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "당월 목표 금액에 대한 요청이 아닙니다."),
+
+ /* 404 NOT_FOUND */
+ NOT_FOUND_TARGET_AMOUNT(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "해당 월의 목표 금액이 존재하지 않습니다."),
+
+ /* 409 Conflict */
+ ALREADY_EXIST_TARGET_AMOUNT(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 해당 월의 목표 금액 데이터가 존재합니다."),
+ ;
+
+ private final StatusCode statusCode;
+ private final ReasonCode reasonCode;
+ private final String message;
+
+ @Override
+ public CausedBy causedBy() {
+ return CausedBy.of(statusCode, reasonCode);
+ }
+
+ @Override
+ public String getExplainError() throws NoSuchFieldError {
+ return message;
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorException.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorException.java
new file mode 100644
index 000000000..669ab9b96
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorException.java
@@ -0,0 +1,21 @@
+package kr.co.pennyway.domain.domains.target.exception;
+
+import kr.co.pennyway.common.exception.CausedBy;
+import kr.co.pennyway.common.exception.GlobalErrorException;
+
+public class TargetAmountErrorException extends GlobalErrorException {
+ private final TargetAmountErrorCode targetAmountErrorCode;
+
+ public TargetAmountErrorException(TargetAmountErrorCode targetAmountErrorCode) {
+ super(targetAmountErrorCode);
+ this.targetAmountErrorCode = targetAmountErrorCode;
+ }
+
+ public CausedBy causedBy() {
+ return targetAmountErrorCode.causedBy();
+ }
+
+ public String getExplainError() {
+ return targetAmountErrorCode.getExplainError();
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepository.java
new file mode 100644
index 000000000..b97857b6f
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepository.java
@@ -0,0 +1,12 @@
+package kr.co.pennyway.domain.domains.target.repository;
+
+import kr.co.pennyway.domain.domains.target.domain.TargetAmount;
+
+import java.time.LocalDate;
+import java.util.Optional;
+
+public interface TargetAmountCustomRepository {
+ Optional findRecentOneByUserId(Long userId);
+
+ boolean existsByUserIdThatMonth(Long userId, LocalDate date);
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepositoryImpl.java
new file mode 100644
index 000000000..f3803491a
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepositoryImpl.java
@@ -0,0 +1,49 @@
+package kr.co.pennyway.domain.domains.target.repository;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import kr.co.pennyway.domain.domains.target.domain.QTargetAmount;
+import kr.co.pennyway.domain.domains.target.domain.TargetAmount;
+import kr.co.pennyway.domain.domains.user.domain.QUser;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Repository;
+
+import java.time.LocalDate;
+import java.util.Optional;
+
+@Slf4j
+@Repository
+@RequiredArgsConstructor
+public class TargetAmountCustomRepositoryImpl implements TargetAmountCustomRepository {
+ private final JPAQueryFactory queryFactory;
+
+ private final QUser user = QUser.user;
+ private final QTargetAmount targetAmount = QTargetAmount.targetAmount;
+
+ /**
+ * 사용자의 가장 최근 목표 금액을 조회한다.
+ *
+ * @return 최근 목표 금액이 존재하지 않을 경우 Optional.empty()를 반환하며, 당월 목표 금액 정보일 수도 있다.
+ */
+ @Override
+ public Optional findRecentOneByUserId(Long userId) {
+ TargetAmount result = queryFactory.selectFrom(targetAmount)
+ .innerJoin(user).on(targetAmount.user.id.eq(user.id))
+ .where(user.id.eq(userId)
+ .and(targetAmount.amount.gt(-1)))
+ .orderBy(targetAmount.createdAt.desc())
+ .fetchFirst();
+
+ return Optional.ofNullable(result);
+ }
+
+ @Override
+ public boolean existsByUserIdThatMonth(Long userId, LocalDate date) {
+ return queryFactory.selectOne().from(targetAmount)
+ .innerJoin(user).on(targetAmount.user.id.eq(user.id))
+ .where(user.id.eq(userId)
+ .and(targetAmount.createdAt.year().eq(date.getYear()))
+ .and(targetAmount.createdAt.month().eq(date.getMonthValue())))
+ .fetchFirst() != null;
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountRepository.java
new file mode 100644
index 000000000..1a3405763
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountRepository.java
@@ -0,0 +1,22 @@
+package kr.co.pennyway.domain.domains.target.repository;
+
+import kr.co.pennyway.domain.common.repository.ExtendedRepository;
+import kr.co.pennyway.domain.domains.target.domain.TargetAmount;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Optional;
+
+public interface TargetAmountRepository extends ExtendedRepository, TargetAmountCustomRepository {
+ @Transactional(readOnly = true)
+ @Query("SELECT ta FROM TargetAmount ta WHERE ta.user.id = :userId AND YEAR(ta.createdAt) = YEAR(:date) AND MONTH(ta.createdAt) = MONTH(:date)")
+ Optional findByUserIdThatMonth(Long userId, LocalDate date);
+
+ @Transactional(readOnly = true)
+ List findByUser_Id(Long userId);
+
+ @Transactional(readOnly = true)
+ boolean existsByIdAndUser_Id(Long id, Long userId);
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountService.java
new file mode 100644
index 000000000..115dd2f18
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountService.java
@@ -0,0 +1,60 @@
+package kr.co.pennyway.domain.domains.target.service;
+
+import kr.co.pennyway.common.annotation.DomainService;
+import kr.co.pennyway.domain.domains.target.domain.TargetAmount;
+import kr.co.pennyway.domain.domains.target.repository.TargetAmountRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Optional;
+
+@Slf4j
+@DomainService
+@RequiredArgsConstructor
+public class TargetAmountService {
+ private final TargetAmountRepository targetAmountRepository;
+
+ @Transactional
+ public TargetAmount createTargetAmount(TargetAmount targetAmount) {
+ return targetAmountRepository.save(targetAmount);
+ }
+
+ @Transactional(readOnly = true)
+ public Optional readTargetAmount(Long id) {
+ return targetAmountRepository.findById(id);
+ }
+
+ @Transactional(readOnly = true)
+ public Optional readTargetAmountThatMonth(Long userId, LocalDate date) {
+ return targetAmountRepository.findByUserIdThatMonth(userId, date);
+ }
+
+ @Transactional(readOnly = true)
+ public List readTargetAmountsByUserId(Long userId) {
+ return targetAmountRepository.findByUser_Id(userId);
+ }
+
+ @Transactional(readOnly = true)
+ public Optional readRecentTargetAmount(Long userId) {
+ return targetAmountRepository.findRecentOneByUserId(userId);
+ }
+
+ @Transactional(readOnly = true)
+ public boolean isExistsTargetAmountThatMonth(Long userId, LocalDate date) {
+ return targetAmountRepository.existsByUserIdThatMonth(userId, date);
+ }
+
+ @Transactional(readOnly = true)
+ public boolean isExistsTargetAmountByIdAndUserId(Long id, Long userId) {
+ return targetAmountRepository.existsByIdAndUser_Id(id, userId);
+ }
+
+
+ @Transactional
+ public void deleteTargetAmount(TargetAmount targetAmount) {
+ targetAmountRepository.delete(targetAmount);
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/NotifySetting.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/NotifySetting.java
new file mode 100644
index 000000000..373a4fe78
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/NotifySetting.java
@@ -0,0 +1,61 @@
+package kr.co.pennyway.domain.domains.user.domain;
+
+import jakarta.persistence.Embeddable;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.NoArgsConstructor;
+import lombok.ToString;
+import org.hibernate.annotations.ColumnDefault;
+import org.hibernate.annotations.DynamicInsert;
+
+@Embeddable
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@DynamicInsert
+@ToString(of = {"accountBookNotify", "feedNotify", "chatNotify"})
+public class NotifySetting {
+ @ColumnDefault("true")
+ private boolean accountBookNotify;
+ @ColumnDefault("true")
+ private boolean feedNotify;
+ @ColumnDefault("true")
+ private boolean chatNotify;
+
+ @Builder
+ private NotifySetting(boolean accountBookNotify, boolean feedNotify, boolean chatNotify) {
+ this.accountBookNotify = accountBookNotify;
+ this.feedNotify = feedNotify;
+ this.chatNotify = chatNotify;
+ }
+
+ public static NotifySetting of(boolean accountBookNotify, boolean feedNotify, boolean chatNotify) {
+ return NotifySetting.builder()
+ .accountBookNotify(accountBookNotify)
+ .feedNotify(feedNotify)
+ .chatNotify(chatNotify)
+ .build();
+ }
+
+ public void updateNotifySetting(NotifyType notifyType, boolean flag) {
+ switch (notifyType) {
+ case ACCOUNT_BOOK -> this.accountBookNotify = flag;
+ case FEED -> this.feedNotify = flag;
+ case CHAT -> this.chatNotify = flag;
+ }
+ }
+
+ public boolean isAccountBookNotify() {
+ return accountBookNotify;
+ }
+
+ public boolean isFeedNotify() {
+ return feedNotify;
+ }
+
+ public boolean isChatNotify() {
+ return chatNotify;
+ }
+
+ public enum NotifyType {
+ ACCOUNT_BOOK, FEED, CHAT
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java
new file mode 100644
index 000000000..6bf99ffe3
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java
@@ -0,0 +1,126 @@
+package kr.co.pennyway.domain.domains.user.domain;
+
+import jakarta.persistence.*;
+import kr.co.pennyway.domain.common.converter.ProfileVisibilityConverter;
+import kr.co.pennyway.domain.common.converter.RoleConverter;
+import kr.co.pennyway.domain.common.model.DateAuditable;
+import kr.co.pennyway.domain.domains.user.type.ProfileVisibility;
+import kr.co.pennyway.domain.domains.user.type.Role;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.hibernate.annotations.ColumnDefault;
+import org.hibernate.annotations.DynamicInsert;
+import org.hibernate.annotations.SQLDelete;
+import org.hibernate.annotations.SQLRestriction;
+import org.springframework.util.StringUtils;
+
+import java.time.LocalDateTime;
+import java.util.Objects;
+
+@Entity
+@Getter
+@Table(name = "user")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@DynamicInsert
+@SQLRestriction("deleted_at IS NULL")
+@SQLDelete(sql = "UPDATE user SET deleted_at = NOW() WHERE id = ?")
+public class User extends DateAuditable {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ private String username;
+ private String name;
+ @ColumnDefault("NULL")
+ private String password;
+ @ColumnDefault("NULL")
+ private LocalDateTime passwordUpdatedAt;
+ @ColumnDefault("NULL")
+ private String profileImageUrl;
+ private String phone;
+ @Convert(converter = RoleConverter.class)
+ private Role role;
+ @Convert(converter = ProfileVisibilityConverter.class)
+ private ProfileVisibility profileVisibility;
+ @ColumnDefault("false")
+ private boolean locked;
+ @Embedded
+ private NotifySetting notifySetting;
+ @ColumnDefault("NULL")
+ private LocalDateTime deletedAt;
+
+ @Builder
+ private User(String username, String name, String password, LocalDateTime passwordUpdatedAt, String profileImageUrl, String phone, Role role,
+ ProfileVisibility profileVisibility, NotifySetting notifySetting, boolean locked) {
+ if (!StringUtils.hasText(username)) {
+ throw new IllegalArgumentException("username은 null이거나 빈 문자열이 될 수 없습니다.");
+ } else if (!StringUtils.hasText(name)) {
+ throw new IllegalArgumentException("name은 null이거나 빈 문자열이 될 수 없습니다.");
+ }
+
+ this.username = username;
+ this.name = name;
+ this.password = password;
+ this.passwordUpdatedAt = passwordUpdatedAt;
+ this.profileImageUrl = profileImageUrl;
+ this.phone = Objects.requireNonNull(phone, "phone은 null이 될 수 없습니다.");
+ this.role = Objects.requireNonNull(role, "role은 null이 될 수 없습니다.");
+ this.profileVisibility = Objects.requireNonNull(profileVisibility, "profileVisibility는 null이 될 수 없습니다.");
+ this.notifySetting = Objects.requireNonNull(notifySetting, "notifySetting은 null이 될 수 없습니다.");
+ this.locked = locked;
+ }
+
+ public void updatePassword(String password) {
+ if (!StringUtils.hasText(password)) {
+ throw new IllegalArgumentException("password는 null이거나 빈 문자열이 될 수 없습니다.");
+ }
+
+ this.password = password;
+ this.passwordUpdatedAt = LocalDateTime.now();
+ }
+
+ public void updateName(String name) {
+ if (!StringUtils.hasText(name)) {
+ throw new IllegalArgumentException("name은 null이거나 빈 문자열이 될 수 없습니다.");
+ }
+
+ this.name = name;
+ }
+
+ public void updateUsername(String username) {
+ if (!StringUtils.hasText(username)) {
+ throw new IllegalArgumentException("username은 null이거나 빈 문자열이 될 수 없습니다.");
+ }
+
+ this.username = username;
+ }
+
+ public void updateProfileImageUrl(String profileImageUrl) {
+ this.profileImageUrl = profileImageUrl;
+ }
+
+ public void updatePhone(String phone) {
+ this.phone = phone;
+ }
+
+ public boolean isGeneralSignedUpUser() {
+ return password != null;
+ }
+
+ public boolean isLocked() {
+ return locked;
+ }
+
+ @Override
+ public String toString() {
+ return "User{" +
+ "id=" + id +
+ ", username='" + username + '\'' +
+ ", name='" + name + '\'' +
+ ", role=" + role +
+ ", deletedAt=" + deletedAt +
+ '}';
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java
new file mode 100644
index 000000000..a44a5b225
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java
@@ -0,0 +1,49 @@
+package kr.co.pennyway.domain.domains.user.exception;
+
+import kr.co.pennyway.common.exception.BaseErrorCode;
+import kr.co.pennyway.common.exception.CausedBy;
+import kr.co.pennyway.common.exception.ReasonCode;
+import kr.co.pennyway.common.exception.StatusCode;
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+public enum UserErrorCode implements BaseErrorCode {
+ /* 400 BAD_REQUEST */
+ NOT_MATCHED_PASSWORD(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "비밀번호가 일치하지 않습니다."),
+ PASSWORD_NOT_CHANGED(StatusCode.BAD_REQUEST, ReasonCode.CLIENT_ERROR, "현재 비밀번호와 동일한 비밀번호로 변경할 수 없습니다."),
+
+ /* 401 UNAUTHORIZED */
+ INVALID_USERNAME_OR_PASSWORD(StatusCode.UNAUTHORIZED, ReasonCode.MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS, "유효하지 않은 아이디 또는 비밀번호입니다."),
+
+ /* 403 FORBIDDEN */
+ ALREADY_WITHDRAWAL(StatusCode.FORBIDDEN, ReasonCode.ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN, "이미 탈퇴한 유저입니다."),
+ DO_NOT_GENERAL_SIGNED_UP(StatusCode.FORBIDDEN, ReasonCode.ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN, "일반 회원가입 계정이 아닙니다."),
+
+ /* 404 NOT_FOUND */
+ NOT_ALLOCATED_PROFILE_IMAGE(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "프로필 이미지가 할당되지 않았습니다."),
+
+ /* 409 Conflict */
+ ALREADY_SIGNUP(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 회원가입한 유저입니다."),
+ ALREADY_EXIST_USERNAME(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 존재하는 아이디입니다."),
+ ALREADY_EXIST_PHONE(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 존재하는 휴대폰 번호입니다."),
+
+ /* 404 NOT_FOUND */
+ NOT_FOUND(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "유저를 찾을 수 없습니다."),
+
+ /* 422 UNPROCESSABLE_ENTITY */
+ INVALID_NOTIFY_TYPE(StatusCode.UNPROCESSABLE_CONTENT, ReasonCode.TYPE_MISMATCH_ERROR_IN_REQUEST_BODY, "유효하지 않은 알림 타입입니다.");
+
+ private final StatusCode statusCode;
+ private final ReasonCode reasonCode;
+ private final String message;
+
+ @Override
+ public CausedBy causedBy() {
+ return CausedBy.of(statusCode, reasonCode);
+ }
+
+ @Override
+ public String getExplainError() throws NoSuchFieldError {
+ return message;
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorException.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorException.java
new file mode 100644
index 000000000..e0f352ed4
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorException.java
@@ -0,0 +1,21 @@
+package kr.co.pennyway.domain.domains.user.exception;
+
+import kr.co.pennyway.common.exception.CausedBy;
+import kr.co.pennyway.common.exception.GlobalErrorException;
+
+public class UserErrorException extends GlobalErrorException {
+ private final UserErrorCode userErrorCode;
+
+ public UserErrorException(UserErrorCode userErrorCode) {
+ super(userErrorCode);
+ this.userErrorCode = userErrorCode;
+ }
+
+ public CausedBy causedBy() {
+ return userErrorCode.causedBy();
+ }
+
+ public String getExplainError() {
+ return userErrorCode.getExplainError();
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java
new file mode 100644
index 000000000..8db6e9c33
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java
@@ -0,0 +1,24 @@
+package kr.co.pennyway.domain.domains.user.repository;
+
+import kr.co.pennyway.domain.common.repository.ExtendedRepository;
+import kr.co.pennyway.domain.domains.user.domain.User;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Optional;
+
+public interface UserRepository extends ExtendedRepository {
+ Optional findByPhone(String phone);
+
+ Optional findByUsername(String username);
+
+ boolean existsByUsername(String username);
+
+ boolean existsByPhone(String phone);
+
+ @Transactional
+ @Modifying(clearAutomatically = true)
+ @Query("UPDATE User u SET u.deletedAt = NOW() WHERE u.id = :userId")
+ void deleteByIdInQuery(Long userId);
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java
new file mode 100644
index 000000000..225ac2597
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java
@@ -0,0 +1,60 @@
+package kr.co.pennyway.domain.domains.user.service;
+
+import kr.co.pennyway.common.annotation.DomainService;
+import kr.co.pennyway.domain.domains.user.domain.User;
+import kr.co.pennyway.domain.domains.user.repository.UserRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Optional;
+
+@DomainService
+@RequiredArgsConstructor
+public class UserService {
+ private final UserRepository userRepository;
+
+ @Transactional
+ public User createUser(User user) {
+ return userRepository.save(user);
+ }
+
+ @Transactional(readOnly = true)
+ public Optional readUser(Long id) {
+ return userRepository.findById(id);
+ }
+
+ @Transactional(readOnly = true)
+ public Optional readUserByPhone(String phone) {
+ return userRepository.findByPhone(phone);
+ }
+
+ @Transactional(readOnly = true)
+ public Optional readUserByUsername(String username) {
+ return userRepository.findByUsername(username);
+ }
+
+ @Transactional(readOnly = true)
+ public boolean isExistUser(Long id) {
+ return userRepository.existsById(id);
+ }
+
+ @Transactional(readOnly = true)
+ public boolean isExistUsername(String username) {
+ return userRepository.existsByUsername(username);
+ }
+
+ @Transactional(readOnly = true)
+ public boolean isExistPhone(String phone) {
+ return userRepository.existsByPhone(phone);
+ }
+
+ @Transactional
+ public void deleteUser(User user) {
+ userRepository.delete(user);
+ }
+
+ @Transactional
+ public void deleteUser(Long userId) {
+ userRepository.deleteByIdInQuery(userId);
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/type/ProfileVisibility.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/type/ProfileVisibility.java
new file mode 100644
index 000000000..3d4e505b9
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/type/ProfileVisibility.java
@@ -0,0 +1,34 @@
+package kr.co.pennyway.domain.domains.user.type;
+
+import com.fasterxml.jackson.annotation.JsonValue;
+import kr.co.pennyway.domain.common.converter.LegacyCommonType;
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+public enum ProfileVisibility implements LegacyCommonType {
+ PUBLIC("0", "전체 공개"),
+ FRIEND("1", "친구 공개"),
+ PRIVATE("2", "비공개");
+
+ private final String code;
+ private final String type;
+
+ @Override
+ public String getCode() {
+ return code;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ @JsonValue
+ public String createJson() {
+ return name();
+ }
+
+ @Override
+ public String toString() {
+ return type;
+ }
+}
diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/type/Role.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/type/Role.java
new file mode 100644
index 000000000..1d46744f6
--- /dev/null
+++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/type/Role.java
@@ -0,0 +1,42 @@
+package kr.co.pennyway.domain.domains.user.type;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonValue;
+import kr.co.pennyway.domain.common.converter.LegacyCommonType;
+import lombok.RequiredArgsConstructor;
+
+import java.util.Map;
+import java.util.stream.Stream;
+
+import static java.util.stream.Collectors.toMap;
+
+@RequiredArgsConstructor
+public enum Role implements LegacyCommonType {
+ ADMIN("0", "ROLE_ADMIN"),
+ USER("1", "ROLE_USER");
+
+ private static final Map stringToEnum =
+ Stream.of(values()).collect(toMap(Object::toString, e -> e));
+ private final String code;
+ private final String type;
+
+ @JsonCreator
+ public static Role fromString(String type) {
+ return stringToEnum.get(type.toUpperCase());
+ }
+
+ @Override
+ public String getCode() {
+ return code;
+ }
+
+ @JsonValue
+ public String getType() {
+ return type;
+ }
+
+ @Override
+ public String toString() {
+ return type;
+ }
+}
diff --git a/pennyway-domain/src/main/resources/application-domain.yml b/pennyway-domain/src/main/resources/application-domain.yml
new file mode 100644
index 000000000..170b91654
--- /dev/null
+++ b/pennyway-domain/src/main/resources/application-domain.yml
@@ -0,0 +1,88 @@
+spring:
+ profiles:
+ group:
+ local: common
+ dev: common
+
+ datasource:
+ url: ${DB_URL:jdbc:mysql://localhost:3300/pennyway?serverTimezone=Asia/Seoul&characterEncoding=utf8&postfileSQL=true&logger=Slf4JLogger&rewriteBatchedStatements=true}
+ username: ${DB_USER_NAME:root}
+ password: ${DB_PASSWORD:password}
+ driver-class-name: com.mysql.cj.jdbc.Driver
+
+ data.redis:
+ host: ${REDIS_HOST:localhost}
+ port: ${REDIS_PORT:6379}
+ password: ${REDIS_PASSWORD:}
+
+ autoconfigure:
+ exclude:
+ - org.redisson.spring.starter.RedissonAutoConfigurationV2
+
+---
+spring:
+ config:
+ activate:
+ on-profile: local
+
+ jpa:
+ database: MySQL
+ open-in-view: false
+ generate-ddl: false
+ hibernate:
+ ddl-auto: update
+ show-sql: true
+ properties:
+ hibernate:
+ dialect: org.hibernate.dialect.MySQLDialect
+
+logging:
+ level:
+ ROOT: INFO
+ org.hibernate: DEBUG
+ org.hibernate.type.descriptor.sql.BasicBinder: TRACE
+ org.hibernate.sql: debug
+ org.hibernate.type: trace
+ com.zaxxer.hikari.HikariConfig: DEBUG
+ org.springframework.orm: TRACE
+ org.springframework.transaction: TRACE
+ com.zaxxer.hikari: TRACE
+ com.mysql.cj.jdbc: TRACE
+
+---
+spring:
+ config:
+ activate:
+ on-profile: dev
+
+ jpa:
+ database: MySQL
+ open-in-view: false
+ generate-ddl: false
+ hibernate:
+ ddl-auto: none
+ show-sql: true
+ properties:
+ hibernate:
+ dialect: org.hibernate.dialect.MySQLDialect
+
+---
+spring:
+ config:
+ activate:
+ on-profile: test
+
+ jpa:
+ database: MySQL
+ open-in-view: false
+ generate-ddl: true
+ hibernate:
+ ddl-auto: create
+ show-sql: true
+ properties:
+ hibernate:
+ dialect: org.hibernate.dialect.MySQLDialect
+
+logging:
+ level:
+ org.springframework.jdbc: debug
\ No newline at end of file
diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/phone/PhoneValidationDaoTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/phone/PhoneValidationDaoTest.java
new file mode 100644
index 000000000..9bb610d32
--- /dev/null
+++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/phone/PhoneValidationDaoTest.java
@@ -0,0 +1,83 @@
+package kr.co.pennyway.domain.common.redis.phone;
+
+import kr.co.pennyway.domain.config.ContainerRedisTestConfig;
+import kr.co.pennyway.domain.config.RedisConfig;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+@DisplayName("휴대폰 검증 Redis 서비스 테스트")
+@SpringBootTest(classes = {PhoneCodeRepository.class, RedisConfig.class})
+@ActiveProfiles("local")
+public class PhoneValidationDaoTest extends ContainerRedisTestConfig {
+ @Autowired
+ private PhoneCodeRepository phoneCodeRepository;
+ private String phone;
+ private String code;
+ private PhoneCodeKeyType codeType;
+
+ @BeforeEach
+ void setUp() {
+ phone = "01012345678";
+ code = "123456";
+ codeType = PhoneCodeKeyType.SIGN_UP;
+ }
+
+ @AfterEach
+ void tearDown() {
+ phoneCodeRepository.delete(phone, codeType);
+ }
+
+ @Test
+ @DisplayName("Redis에 데이터를 저장하면 {'codeType:phone':code}로 데이터가 저장된다.")
+ void codeSaveTest() {
+ // given
+ phoneCodeRepository.save(phone, code, codeType);
+
+ // when
+ String savedCode = phoneCodeRepository.findCodeByPhone(phone, codeType);
+
+ // then
+ assertEquals(code, savedCode);
+ System.out.println("savedCode = " + savedCode);
+ }
+
+ @Test
+ @DisplayName("Redis에 'codeType:phone'에 해당하는 값이 없으면 NullPointerException이 발생한다.")
+ void codeReadError() {
+ // given
+ phoneCodeRepository.delete(phone, codeType);
+ String wrongPhone = "01087654321";
+
+ // when - then
+ assertThrows(NullPointerException.class, () -> phoneCodeRepository.findCodeByPhone(wrongPhone, codeType));
+ }
+
+ @Test
+ @DisplayName("Redis에 저장된 데이터를 삭제하면 해당 데이터가 삭제된다.")
+ void codeRemoveTest() {
+ // given
+ phoneCodeRepository.save(phone, code, codeType);
+
+ // when
+ phoneCodeRepository.delete(phone, codeType);
+
+ // then
+ assertThrows(NullPointerException.class, () -> phoneCodeRepository.findCodeByPhone(phone, codeType));
+ }
+
+ @Test
+ @DisplayName("저장되지 않은 데이터를 삭제해도 에러가 발생하지 않는다.")
+ void codeRemoveError() {
+ // when - thengi
+ assertThrows(NullPointerException.class, () -> phoneCodeRepository.findCodeByPhone(phone, codeType));
+ phoneCodeRepository.delete(phone, codeType);
+ }
+}
diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceIntegrationTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceIntegrationTest.java
new file mode 100644
index 000000000..204755be6
--- /dev/null
+++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceIntegrationTest.java
@@ -0,0 +1,89 @@
+package kr.co.pennyway.domain.common.redis.refresh;
+
+import kr.co.pennyway.domain.config.ContainerRedisTestConfig;
+import kr.co.pennyway.domain.config.RedisConfig;
+import lombok.extern.slf4j.Slf4j;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.data.redis.DataRedisTest;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.ContextConfiguration;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.springframework.test.util.AssertionErrors.assertEquals;
+import static org.springframework.test.util.AssertionErrors.assertFalse;
+
+@Slf4j
+@DataRedisTest(properties = "spring.config.location=classpath:application-domain.yml")
+@ContextConfiguration(classes = {RedisConfig.class, RefreshTokenServiceImpl.class})
+@ActiveProfiles("test")
+public class RefreshTokenServiceIntegrationTest extends ContainerRedisTestConfig {
+ @Autowired
+ private RefreshTokenRepository refreshTokenRepository;
+ private RefreshTokenService refreshTokenService;
+
+ @BeforeEach
+ void setUp() {
+ this.refreshTokenService = new RefreshTokenServiceImpl(refreshTokenRepository);
+ }
+
+ @Test
+ @DisplayName("리프레시 토큰 저장 테스트")
+ void saveTest() {
+ // given
+ RefreshToken refreshToken = RefreshToken.builder()
+ .userId(1L)
+ .token("refreshToken")
+ .ttl(1000L)
+ .build();
+
+ // when
+ refreshTokenService.save(refreshToken);
+
+ // then
+ RefreshToken savedRefreshToken = refreshTokenRepository.findById(1L).orElse(null);
+ assertEquals("저장된 리프레시 토큰이 일치하지 않습니다.", refreshToken, savedRefreshToken);
+ log.info("저장된 리프레시 토큰 정보 : {}", savedRefreshToken);
+ }
+
+ @Test
+ @DisplayName("리프레시 토큰 갱신 테스트")
+ void refreshTest() {
+ // given
+ RefreshToken refreshToken = RefreshToken.builder()
+ .userId(1L)
+ .token("refreshToken")
+ .ttl(1000L)
+ .build();
+ refreshTokenService.save(refreshToken);
+
+ // when
+ refreshTokenService.refresh(1L, "refreshToken", "newRefreshToken");
+
+ // then
+ RefreshToken savedRefreshToken = refreshTokenRepository.findById(1L).orElse(null);
+ assertEquals("갱신된 리프레시 토큰이 일치하지 않습니다.", "newRefreshToken", savedRefreshToken.getToken());
+ log.info("갱신된 리프레시 토큰 정보 : {}", savedRefreshToken);
+ }
+
+ @Test
+ @DisplayName("요청한 리프레시 토큰과 저장된 리프레시 토큰이 다를 경우 토큰이 탈취되었다고 판단하여 값 삭제")
+ void validateTokenTest() {
+ // given
+ RefreshToken refreshToken = RefreshToken.builder()
+ .userId(1L)
+ .token("refreshToken")
+ .ttl(1000L)
+ .build();
+ refreshTokenService.save(refreshToken);
+
+ // when
+ IllegalStateException exception = assertThrows(IllegalStateException.class, () -> refreshTokenService.refresh(1L, "anotherRefreshToken", "newRefreshToken"));
+
+ // then
+ assertEquals("리프레시 토큰이 탈취되었을 때 예외가 발생해야 합니다.", "refresh token mismatched", exception.getMessage());
+ assertFalse("리프레시 토큰이 탈취되었을 때 저장된 리프레시 토큰이 삭제되어야 합니다.", refreshTokenRepository.existsById(1L));
+ }
+}
diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redisson/CouponDecreaseLockTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redisson/CouponDecreaseLockTest.java
new file mode 100644
index 000000000..f637ff6dd
--- /dev/null
+++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redisson/CouponDecreaseLockTest.java
@@ -0,0 +1,93 @@
+package kr.co.pennyway.domain.common.redisson;
+
+import kr.co.pennyway.domain.config.ContainerDBTestConfig;
+import kr.co.pennyway.domain.config.DomainIntegrationTest;
+import kr.co.pennyway.domain.config.TestJpaConfig;
+import kr.co.pennyway.domain.domains.coupon.TestCoupon;
+import kr.co.pennyway.domain.domains.coupon.TestCouponDecreaseService;
+import kr.co.pennyway.domain.domains.coupon.TestCouponRepository;
+import lombok.extern.slf4j.Slf4j;
+import org.junit.jupiter.api.*;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.domain.EntityScan;
+import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
+import org.springframework.context.annotation.Import;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@Slf4j
+@DomainIntegrationTest
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+@EntityScan(basePackageClasses = {TestCoupon.class})
+@Import(TestJpaConfig.class)
+public class CouponDecreaseLockTest extends ContainerDBTestConfig {
+ @Autowired
+ private TestCouponDecreaseService testCouponDecreaseService;
+ @Autowired
+ private TestCouponRepository testCouponRepository;
+ private TestCoupon coupon;
+
+ @BeforeEach
+ void setUp() {
+ coupon = new TestCoupon("COUPON_001", 300L);
+ testCouponRepository.save(coupon);
+ }
+
+ @Test
+ @Order(1)
+ @Disabled
+ void 쿠폰차감_분산락_적용_동시성_300명_테스트() throws InterruptedException {
+ // given
+ int threadCount = 300;
+ ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
+ CountDownLatch latch = new CountDownLatch(threadCount);
+
+ // when
+ for (int i = 0; i < threadCount; i++) {
+ executorService.submit(() -> {
+ try {
+ testCouponDecreaseService.decreaseStockWithLock(coupon.getId(), "COUPON_001");
+ } finally {
+ latch.countDown();
+ }
+ });
+ }
+ latch.await();
+
+ // then
+ TestCoupon persistedCoupon = testCouponRepository.findById(coupon.getId()).orElseThrow(IllegalArgumentException::new);
+ assertThat(persistedCoupon.getAvailableStock()).isZero();
+ log.debug("잔여 쿠폰 수량: " + persistedCoupon.getAvailableStock());
+ }
+
+ @Test
+ @Order(2)
+ @Disabled
+ void 쿠폰차감_분산락_미적용_동시성_300명_테스트() throws InterruptedException {
+ // given
+ int threadCount = 300;
+ ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
+ CountDownLatch latch = new CountDownLatch(threadCount);
+
+ // when
+ for (int i = 0; i < threadCount; i++) {
+ executorService.submit(() -> {
+ try {
+ testCouponDecreaseService.decreaseStock(coupon.getId());
+ } finally {
+ latch.countDown();
+ }
+ });
+ }
+ latch.await();
+
+ // then
+ TestCoupon persistedCoupon = testCouponRepository.findById(coupon.getId()).orElseThrow(IllegalArgumentException::new);
+ log.debug("잔여 쿠폰 수량: " + persistedCoupon.getAvailableStock());
+ }
+}
diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerDBTestConfig.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerDBTestConfig.java
new file mode 100644
index 000000000..fdfd1817c
--- /dev/null
+++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerDBTestConfig.java
@@ -0,0 +1,47 @@
+package kr.co.pennyway.domain.config;
+
+import com.redis.testcontainers.RedisContainer;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.testcontainers.containers.MySQLContainer;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.utility.DockerImageName;
+
+@Testcontainers
+@ActiveProfiles("test")
+public class ContainerDBTestConfig {
+ private static final String REDIS_CONTAINER_IMAGE = "redis:7.2.4-alpine";
+ private static final String MYSQL_CONTAINER_IMAGE = "mysql:8.0.26";
+
+ private static final RedisContainer REDIS_CONTAINER;
+ private static final MySQLContainer> MYSQL_CONTAINER;
+
+ static {
+ REDIS_CONTAINER =
+ new RedisContainer(DockerImageName.parse(REDIS_CONTAINER_IMAGE))
+ .withExposedPorts(6379)
+ .withCommand("redis-server", "--requirepass testpass")
+ .withReuse(true);
+ MYSQL_CONTAINER =
+ new MySQLContainer<>(DockerImageName.parse(MYSQL_CONTAINER_IMAGE))
+ .withDatabaseName("pennyway")
+ .withUsername("root")
+ .withPassword("testpass")
+ .withCommand("--sql_mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION")
+ .withReuse(true);
+
+ REDIS_CONTAINER.start();
+ MYSQL_CONTAINER.start();
+ }
+
+ @DynamicPropertySource
+ public static void setRedisProperties(DynamicPropertyRegistry registry) {
+ registry.add("spring.data.redis.host", REDIS_CONTAINER::getHost);
+ registry.add("spring.data.redis.port", () -> String.valueOf(REDIS_CONTAINER.getMappedPort(6379)));
+ registry.add("spring.data.redis.password", () -> "testpass");
+ registry.add("spring.datasource.url", () -> String.format("jdbc:mysql://%s:%s/pennyway?serverTimezone=UTC&characterEncoding=utf8", MYSQL_CONTAINER.getHost(), MYSQL_CONTAINER.getMappedPort(3306)));
+ registry.add("spring.datasource.username", () -> "root");
+ registry.add("spring.datasource.password", () -> "testpass");
+ }
+}
diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerMySqlTestConfig.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerMySqlTestConfig.java
new file mode 100644
index 000000000..4af33fca1
--- /dev/null
+++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerMySqlTestConfig.java
@@ -0,0 +1,35 @@
+package kr.co.pennyway.domain.config;
+
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.testcontainers.containers.MySQLContainer;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.utility.DockerImageName;
+
+@Testcontainers
+@ActiveProfiles("test")
+public class ContainerMySqlTestConfig {
+ private static final String MYSQL_CONTAINER_IMAGE = "mysql:8.0.26";
+
+ private static final MySQLContainer> MYSQL_CONTAINER;
+
+ static {
+ MYSQL_CONTAINER =
+ new MySQLContainer<>(DockerImageName.parse(MYSQL_CONTAINER_IMAGE))
+ .withDatabaseName("pennyway")
+ .withUsername("root")
+ .withPassword("testpass")
+ .withCommand("--sql_mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION")
+ .withReuse(true);
+
+ MYSQL_CONTAINER.start();
+ }
+
+ @DynamicPropertySource
+ public static void setRedisProperties(DynamicPropertyRegistry registry) {
+ registry.add("spring.datasource.url", () -> String.format("jdbc:mysql://%s:%s/pennyway?serverTimezone=UTC&characterEncoding=utf8", MYSQL_CONTAINER.getHost(), MYSQL_CONTAINER.getMappedPort(3306)));
+ registry.add("spring.datasource.username", () -> "root");
+ registry.add("spring.datasource.password", () -> "testpass");
+ }
+}
diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerRedisTestConfig.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerRedisTestConfig.java
new file mode 100644
index 000000000..95d0a0c31
--- /dev/null
+++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerRedisTestConfig.java
@@ -0,0 +1,30 @@
+package kr.co.pennyway.domain.config;
+
+import org.junit.jupiter.api.DisplayName;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.utility.DockerImageName;
+
+@DisplayName("Container Redis 설정")
+public abstract class ContainerRedisTestConfig {
+ private static final String REDIS_CONTAINER_NAME = "redis:7.2.4-alpine";
+ private static final GenericContainer> REDIS_CONTAINER;
+
+ static {
+ REDIS_CONTAINER =
+ new GenericContainer<>(DockerImageName.parse(REDIS_CONTAINER_NAME))
+ .withExposedPorts(6379)
+ .withCommand("redis-server", "--requirepass testpass")
+ .withReuse(true);
+
+ REDIS_CONTAINER.start();
+ }
+
+ @DynamicPropertySource
+ public static void setRedisProperties(DynamicPropertyRegistry registry) {
+ registry.add("spring.data.redis.host", REDIS_CONTAINER::getHost);
+ registry.add("spring.data.redis.port", () -> String.valueOf(REDIS_CONTAINER.getMappedPort(6379)));
+ registry.add("spring.data.redis.password", () -> "testpass");
+ }
+}
diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/DomainIntegrationProfileResolver.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/DomainIntegrationProfileResolver.java
new file mode 100644
index 000000000..bbfc35f0e
--- /dev/null
+++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/DomainIntegrationProfileResolver.java
@@ -0,0 +1,12 @@
+package kr.co.pennyway.domain.config;
+
+import org.springframework.lang.NonNull;
+import org.springframework.test.context.ActiveProfilesResolver;
+
+public class DomainIntegrationProfileResolver implements ActiveProfilesResolver {
+ @Override
+ @NonNull
+ public String[] resolve(@NonNull Class> testClass) {
+ return new String[]{"common", "domain"};
+ }
+}
diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/DomainIntegrationTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/DomainIntegrationTest.java
new file mode 100644
index 000000000..7f22c7d7f
--- /dev/null
+++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/DomainIntegrationTest.java
@@ -0,0 +1,14 @@
+package kr.co.pennyway.domain.config;
+
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+
+import java.lang.annotation.*;
+
+@Target({ElementType.TYPE})
+@Retention(RetentionPolicy.RUNTIME)
+@SpringBootTest(classes = DomainIntegrationTestConfig.class)
+@ActiveProfiles(profiles = {"test"}, resolver = DomainIntegrationProfileResolver.class)
+@Documented
+public @interface DomainIntegrationTest {
+}
diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/DomainIntegrationTestConfig.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/DomainIntegrationTestConfig.java
new file mode 100644
index 000000000..4d0f499d7
--- /dev/null
+++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/DomainIntegrationTestConfig.java
@@ -0,0 +1,16 @@
+package kr.co.pennyway.domain.config;
+
+import kr.co.pennyway.common.PennywayCommonApplication;
+import kr.co.pennyway.domain.DomainPackageLocation;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+@ComponentScan(
+ basePackageClasses = {
+ DomainPackageLocation.class,
+ PennywayCommonApplication.class
+ }
+)
+public class DomainIntegrationTestConfig {
+}
diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/TestJpaConfig.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/TestJpaConfig.java
new file mode 100644
index 000000000..793f11181
--- /dev/null
+++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/TestJpaConfig.java
@@ -0,0 +1,20 @@
+package kr.co.pennyway.domain.config;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.PersistenceContext;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.context.annotation.Bean;
+
+@TestConfiguration
+public class TestJpaConfig {
+ @PersistenceContext
+ private EntityManager em;
+
+ @Bean
+ @ConditionalOnMissingBean
+ public JPAQueryFactory testJpaQueryFactory() {
+ return new JPAQueryFactory(em);
+ }
+}
diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCoupon.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCoupon.java
new file mode 100644
index 000000000..878c4dac1
--- /dev/null
+++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCoupon.java
@@ -0,0 +1,40 @@
+package kr.co.pennyway.domain.domains.coupon;
+
+import jakarta.persistence.Entity;
+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 TestCoupon {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+ private String name;
+
+ /**
+ * 사용 가능 재고수량
+ */
+ private long availableStock;
+
+ public TestCoupon(String name, long availableStock) {
+ this.name = name;
+ this.availableStock = availableStock;
+ }
+
+ public void decreaseStock() {
+ validateStock();
+ this.availableStock--;
+ }
+
+ private void validateStock() {
+ if (availableStock < 1) {
+ throw new IllegalArgumentException("재고가 부족합니다.");
+ }
+ }
+}
diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCouponDecreaseService.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCouponDecreaseService.java
new file mode 100644
index 000000000..9cad14ca1
--- /dev/null
+++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCouponDecreaseService.java
@@ -0,0 +1,30 @@
+package kr.co.pennyway.domain.domains.coupon;
+
+import kr.co.pennyway.domain.common.redisson.DistributedLock;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.transaction.annotation.Transactional;
+
+@Component
+@ActiveProfiles("test")
+@RequiredArgsConstructor
+public class TestCouponDecreaseService {
+ private final TestCouponRepository couponRepository;
+
+ @Transactional
+ public void decreaseStock(Long couponId) {
+ TestCoupon coupon = couponRepository.findById(couponId)
+ .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 쿠폰입니다."));
+
+ coupon.decreaseStock();
+ }
+
+ @DistributedLock(key = "#lockName")
+ public void decreaseStockWithLock(Long couponId, String lockName) {
+ TestCoupon coupon = couponRepository.findById(couponId)
+ .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 쿠폰입니다."));
+
+ coupon.decreaseStock();
+ }
+}
diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCouponRepository.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCouponRepository.java
new file mode 100644
index 000000000..bccf946ff
--- /dev/null
+++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCouponRepository.java
@@ -0,0 +1,8 @@
+package kr.co.pennyway.domain.domains.coupon;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.test.context.ActiveProfiles;
+
+@ActiveProfiles("test")
+public interface TestCouponRepository extends JpaRepository {
+}
diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepositoryUnitTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepositoryUnitTest.java
new file mode 100644
index 000000000..c703263b8
--- /dev/null
+++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepositoryUnitTest.java
@@ -0,0 +1,190 @@
+package kr.co.pennyway.domain.domains.notification.repository;
+
+import kr.co.pennyway.domain.config.ContainerMySqlTestConfig;
+import kr.co.pennyway.domain.config.JpaConfig;
+import kr.co.pennyway.domain.config.TestJpaConfig;
+import kr.co.pennyway.domain.domains.notification.domain.Notification;
+import kr.co.pennyway.domain.domains.notification.type.Announcement;
+import kr.co.pennyway.domain.domains.notification.type.NoticeType;
+import kr.co.pennyway.domain.domains.user.domain.NotifySetting;
+import kr.co.pennyway.domain.domains.user.domain.User;
+import kr.co.pennyway.domain.domains.user.repository.UserRepository;
+import kr.co.pennyway.domain.domains.user.type.ProfileVisibility;
+import kr.co.pennyway.domain.domains.user.type.Role;
+import lombok.extern.slf4j.Slf4j;
+import org.junit.jupiter.api.DisplayName;
+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 org.springframework.context.annotation.Import;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.springframework.test.util.AssertionErrors.*;
+
+@Slf4j
+@DataJpaTest(properties = {"spring.jpa.hibernate.ddl-auto=create"})
+@ContextConfiguration(classes = JpaConfig.class)
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+@Import(TestJpaConfig.class)
+@ActiveProfiles("test")
+public class NotificationRepositoryUnitTest extends ContainerMySqlTestConfig {
+ @Autowired
+ private UserRepository userRepository;
+ @Autowired
+ private NotificationRepository notificationRepository;
+
+ @Test
+ @Transactional
+ @DisplayName("여러 사용자에게 일일 소비 알림을 저장할 수 있다.")
+ public void saveDailySpendingAnnounceInBulk() {
+ // given
+ User user1 = userRepository.save(createUser("jayang"));
+ User user2 = userRepository.save(createUser("mock"));
+ User user3 = userRepository.save(createUser("test"));
+
+ // when
+ notificationRepository.saveDailySpendingAnnounceInBulk(
+ List.of(user1.getId(), user2.getId(), user3.getId()),
+ Announcement.DAILY_SPENDING
+ );
+
+ // then
+ notificationRepository.findAll().forEach(notification -> {
+ log.info("notification: {}", notification);
+ assertEquals("알림 타입이 일일 소비 알림이어야 한다.", Announcement.DAILY_SPENDING, notification.getAnnouncement());
+ });
+ }
+
+ @Test
+ @Transactional
+ @DisplayName("이미 당일에 알림을 받은 사용자에게 데이터가 중복 저장되지 않아야 한다.")
+ public void notSaveDuplicateNotification() {
+ // given
+ User user1 = userRepository.save(createUser("jayang"));
+ User user2 = userRepository.save(createUser("mock"));
+
+ Notification notification = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user1)
+ .build();
+ notificationRepository.save(notification);
+
+ // when
+ notificationRepository.saveDailySpendingAnnounceInBulk(
+ List.of(user1.getId(), user2.getId()),
+ Announcement.DAILY_SPENDING
+ );
+
+ // then
+ List notifications = notificationRepository.findAll();
+ log.debug("notifications: {}", notifications);
+ assertEquals("알림이 중복 저장되지 않아야 한다.", 2, notifications.size());
+ }
+
+ @Test
+ @DisplayName("사용자의 여러 알림을 읽음 처리할 수 있다.")
+ void updateReadAtSuccessfully() {
+ // given
+ User user = userRepository.save(createUser("jayang"));
+
+ List notifications = notificationRepository.saveAll(List.of(
+ new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(),
+ new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(),
+ new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build()));
+
+ // when
+ notificationRepository.updateReadAtByIdsInBulk(notifications.stream().map(Notification::getId).toList());
+
+ // then
+ notificationRepository.findAll().forEach(notification -> {
+ log.info("notification: {}", notification);
+ assertNotNull("알림이 읽음 처리 되어야 한다.", notification.getReadAt());
+ });
+ }
+
+ @Test
+ @DisplayName("사용자의 읽지 않은 알림 개수를 조회할 수 있다.")
+ void countUnreadNotificationsByIds() {
+ // given
+ User user = userRepository.save(createUser("jayang"));
+
+ List notifications = notificationRepository.saveAll(List.of(
+ new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(),
+ new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(),
+ new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build()));
+ List ids = notifications.stream().map(Notification::getId).toList();
+
+ notificationRepository.updateReadAtByIdsInBulk(List.of(ids.get(1)));
+
+ // when
+ long count = notificationRepository.countUnreadNotificationsByIds(
+ user.getId(),
+ notifications.stream().map(Notification::getId).toList()
+ );
+
+ // then
+ assertEquals("읽지 않은 알림 개수가 2개여야 한다.", 2L, count);
+ }
+
+ @Test
+ @DisplayName("사용자의 읽지 않은 알림이 존재하면 true를 반환한다.")
+ void existsTopByReceiver_IdAndReadAtIsNull() {
+ // given
+ User user = userRepository.save(createUser("jayang"));
+
+ Notification notification1 = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build();
+ Notification notification2 = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build();
+ Notification notification3 = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build();
+
+ ReflectionTestUtils.setField(notification1, "readAt", LocalDateTime.now());
+ ReflectionTestUtils.setField(notification2, "readAt", LocalDateTime.now());
+
+ notificationRepository.saveAll(List.of(notification1, notification2, notification3));
+
+ // when
+ boolean exists = notificationRepository.existsUnreadNotification(user.getId());
+
+ // then
+ assertTrue("읽지 않은 알림이 존재하면 true를 반환해야 한다.", exists);
+ }
+
+ @Test
+ @DisplayName("사용자의 읽지 않은 알림이 존재하지 않으면 false를 반환한다.")
+ void notExistsTopByReceiver_IdAndReadAtIsNull() {
+ // given
+ User user = userRepository.save(createUser("jayang"));
+
+ Notification notification1 = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build();
+ Notification notification2 = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build();
+ Notification notification3 = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build();
+
+ ReflectionTestUtils.setField(notification1, "readAt", LocalDateTime.now());
+ ReflectionTestUtils.setField(notification2, "readAt", LocalDateTime.now());
+ ReflectionTestUtils.setField(notification3, "readAt", LocalDateTime.now());
+
+ notificationRepository.saveAll(List.of(notification1, notification2, notification3));
+
+ // when
+ boolean exists = notificationRepository.existsUnreadNotification(user.getId());
+
+ // then
+ assertFalse("읽지 않은 알림이 존재하지 않으면 false를 반환해야 한다.", exists);
+ }
+
+ private User createUser(String name) {
+ return User.builder()
+ .username("test")
+ .name(name)
+ .password("test")
+ .phone("010-1234-5678")
+ .role(Role.USER)
+ .profileVisibility(ProfileVisibility.PUBLIC)
+ .notifySetting(NotifySetting.of(true, true, true))
+ .build();
+ }
+}
diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/ReadNotificationsSliceUnitTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/ReadNotificationsSliceUnitTest.java
new file mode 100644
index 000000000..66aaaf358
--- /dev/null
+++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/ReadNotificationsSliceUnitTest.java
@@ -0,0 +1,121 @@
+package kr.co.pennyway.domain.domains.notification.repository;
+
+import kr.co.pennyway.domain.config.ContainerMySqlTestConfig;
+import kr.co.pennyway.domain.config.JpaConfig;
+import kr.co.pennyway.domain.config.TestJpaConfig;
+import kr.co.pennyway.domain.domains.notification.domain.Notification;
+import kr.co.pennyway.domain.domains.notification.service.NotificationService;
+import kr.co.pennyway.domain.domains.notification.type.Announcement;
+import kr.co.pennyway.domain.domains.notification.type.NoticeType;
+import kr.co.pennyway.domain.domains.user.domain.NotifySetting;
+import kr.co.pennyway.domain.domains.user.domain.User;
+import kr.co.pennyway.domain.domains.user.repository.UserRepository;
+import kr.co.pennyway.domain.domains.user.type.ProfileVisibility;
+import kr.co.pennyway.domain.domains.user.type.Role;
+import lombok.extern.slf4j.Slf4j;
+import org.junit.jupiter.api.DisplayName;
+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 org.springframework.context.annotation.Import;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+import org.springframework.data.domain.Sort;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.jdbc.core.namedparam.SqlParameterSource;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.springframework.test.util.AssertionErrors.assertEquals;
+import static org.springframework.test.util.AssertionErrors.assertTrue;
+
+@Slf4j
+@DataJpaTest(properties = {"spring.jpa.hibernate.ddl-auto=create"})
+@ContextConfiguration(classes = {JpaConfig.class, NotificationService.class})
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+@Import(TestJpaConfig.class)
+@ActiveProfiles("test")
+public class ReadNotificationsSliceUnitTest extends ContainerMySqlTestConfig {
+ @Autowired
+ private UserRepository userRepository;
+
+ @Autowired
+ private NotificationService notificationService;
+
+ @Autowired
+ private NamedParameterJdbcTemplate jdbcTemplate;
+
+ @Test
+ @Transactional
+ @DisplayName("특정 사용자의 알림 목록을 슬라이스로 조회하며, 결과는 최신순으로 정렬되어야 한다.")
+ public void readNotificationsSliceSorted() {
+ // given
+ User user = userRepository.save(createUser("jayang"));
+ Pageable pa = PageRequest.of(0, 5, Sort.by(Sort.Order.desc("notification.createdAt")));
+
+ List notifications = new ArrayList<>();
+ for (int i = 0; i < 10; i++) {
+ Notification notification = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build();
+ notifications.add(notification);
+ }
+ bulkInsertNotifications(notifications);
+
+ // when
+ Slice result = notificationService.readNotificationsSlice(user.getId(), pa);
+
+ // then
+ assertEquals("Slice 데이터 개수는 5개여야 한다.", 5, result.getNumberOfElements());
+ assertTrue("hasNext()는 true여야 한다.", result.hasNext());
+ for (int i = 0; i < result.getNumberOfElements() - 1; i++) {
+ Notification current = result.getContent().get(i);
+ Notification next = result.getContent().get(i + 1);
+ log.debug("current: {}, next: {}", current.getCreatedAt(), next.getCreatedAt());
+ log.debug("notification: {}", current);
+ assert current.getCreatedAt().isAfter(next.getCreatedAt());
+ }
+ }
+
+ private User createUser(String name) {
+ return User.builder()
+ .username("test")
+ .name(name)
+ .password("test")
+ .phone("010-1234-5678")
+ .role(Role.USER)
+ .profileVisibility(ProfileVisibility.PUBLIC)
+ .notifySetting(NotifySetting.of(true, true, true))
+ .build();
+ }
+
+ private void bulkInsertNotifications(List notifications) {
+ String sql = String.format("""
+ INSERT INTO `%s` (type, announcement, created_at, updated_at, receiver, receiver_name)
+ VALUES (:type, :announcement, :createdAt, :updatedAt, :receiver, :receiverName);
+ """, "notification");
+
+ LocalDateTime date = LocalDateTime.now();
+ SqlParameterSource[] params = new SqlParameterSource[notifications.size()];
+
+ for (int i = 0; i < notifications.size(); i++) {
+ Notification notification = notifications.get(i);
+ params[i] = new MapSqlParameterSource()
+ .addValue("type", notification.getType().getCode())
+ .addValue("announcement", notification.getAnnouncement().getCode())
+ .addValue("createdAt", date)
+ .addValue("updatedAt", date)
+ .addValue("receiver", notification.getReceiver().getId())
+ .addValue("receiverName", notification.getReceiverName());
+ date = date.minusDays(1);
+ }
+
+ jdbcTemplate.batchUpdate(sql, params);
+ }
+}
diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepositoryTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepositoryTest.java
new file mode 100644
index 000000000..946dc36c4
--- /dev/null
+++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepositoryTest.java
@@ -0,0 +1,81 @@
+package kr.co.pennyway.domain.domains.oauth.repository;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import kr.co.pennyway.domain.config.ContainerMySqlTestConfig;
+import kr.co.pennyway.domain.config.JpaConfig;
+import kr.co.pennyway.domain.domains.oauth.domain.Oauth;
+import kr.co.pennyway.domain.domains.oauth.type.Provider;
+import kr.co.pennyway.domain.domains.user.domain.NotifySetting;
+import kr.co.pennyway.domain.domains.user.domain.User;
+import kr.co.pennyway.domain.domains.user.repository.UserRepository;
+import kr.co.pennyway.domain.domains.user.type.ProfileVisibility;
+import kr.co.pennyway.domain.domains.user.type.Role;
+import lombok.extern.slf4j.Slf4j;
+import org.junit.jupiter.api.DisplayName;
+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 org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.transaction.annotation.Transactional;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+
+@Slf4j
+@DataJpaTest(properties = {"spring.jpa.hibernate.ddl-auto=create"})
+@ContextConfiguration(classes = JpaConfig.class)
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+@ActiveProfiles("test")
+public class OauthRepositoryTest extends ContainerMySqlTestConfig {
+ @Autowired
+ private UserRepository userRepository;
+
+ @Autowired
+ private OauthRepository oauthRepository;
+
+ @MockBean
+ private JPAQueryFactory jpaQueryFactory;
+
+ private User user;
+
+ @Test
+ @DisplayName("soft delete된 다른 user_id를 가지면서, 같은 oauth_id, provider를 갖는 정보가 존재해도, 하나의 결과만을 반환한다.")
+ @Transactional
+ public void test() {
+ // given
+ User user = createUser();
+ Oauth oauth = Oauth.of(Provider.KAKAO, "oauth_id", user);
+
+ User newUser = createUser();
+ Oauth newOauth = Oauth.of(Provider.KAKAO, "oauth_id", newUser);
+
+ // when (소셜 회원가입 ⇾ 회원 탈퇴 ⇾ 동일 정보 소셜 회원가입 ⇾ 조회 성공)
+ userRepository.save(user);
+ oauthRepository.save(oauth);
+ log.debug("user: {}, oauth: {}", user, oauth);
+
+ userRepository.delete(user);
+ oauthRepository.delete(oauth);
+
+ userRepository.save(newUser);
+ oauthRepository.save(newOauth);
+ log.debug("newUser: {}, newOauth: {}", newUser, newOauth);
+
+ // then
+ assertDoesNotThrow(() -> oauthRepository.findByOauthIdAndProviderAndDeletedAtIsNull(newOauth.getOauthId(), newOauth.getProvider()));
+ }
+
+ private User createUser() {
+ return User.builder()
+ .username("test")
+ .name("pannyway")
+ .password("test")
+ .phone("010-1234-5678")
+ .role(Role.USER)
+ .profileVisibility(ProfileVisibility.PUBLIC)
+ .notifySetting(NotifySetting.of(true, true, true))
+ .build();
+ }
+}
diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/target/repository/RecentTargetAmountSearchTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/target/repository/RecentTargetAmountSearchTest.java
new file mode 100644
index 000000000..326f78775
--- /dev/null
+++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/target/repository/RecentTargetAmountSearchTest.java
@@ -0,0 +1,124 @@
+package kr.co.pennyway.domain.domains.target.repository;
+
+import kr.co.pennyway.domain.config.ContainerMySqlTestConfig;
+import kr.co.pennyway.domain.config.JpaConfig;
+import kr.co.pennyway.domain.config.TestJpaConfig;
+import kr.co.pennyway.domain.domains.user.domain.NotifySetting;
+import kr.co.pennyway.domain.domains.user.domain.User;
+import kr.co.pennyway.domain.domains.user.repository.UserRepository;
+import kr.co.pennyway.domain.domains.user.type.ProfileVisibility;
+import kr.co.pennyway.domain.domains.user.type.Role;
+import lombok.extern.slf4j.Slf4j;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.DisplayName;
+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 org.springframework.context.annotation.Import;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.jdbc.core.namedparam.SqlParameterSource;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.Collection;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+@Slf4j
+@DataJpaTest(properties = {"spring.jpa.hibernate.ddl-auto=create"})
+@ContextConfiguration(classes = JpaConfig.class)
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+@ActiveProfiles("test")
+@Import(TestJpaConfig.class)
+public class RecentTargetAmountSearchTest extends ContainerMySqlTestConfig {
+ private final Collection mockTargetAmounts = List.of(
+ MockTargetAmount.of(10000, true, LocalDateTime.of(2021, 1, 1, 0, 0, 0), LocalDateTime.of(2021, 1, 1, 0, 0, 0)),
+ MockTargetAmount.of(-1, false, LocalDateTime.of(2022, 3, 1, 0, 0, 0), LocalDateTime.of(2022, 3, 1, 0, 0, 0)),
+ MockTargetAmount.of(20000, true, LocalDateTime.of(2022, 5, 1, 0, 0, 0), LocalDateTime.of(2022, 5, 1, 0, 0, 0)),
+ MockTargetAmount.of(30000, true, LocalDateTime.of(2023, 7, 1, 0, 0, 0), LocalDateTime.of(2023, 7, 1, 0, 0, 0)),
+ MockTargetAmount.of(-1, false, LocalDateTime.of(2024, 1, 1, 0, 0, 0), LocalDateTime.of(2024, 1, 1, 0, 0, 0)),
+ MockTargetAmount.of(-1, true, LocalDateTime.of(2024, 2, 1, 0, 0, 0), LocalDateTime.of(2024, 2, 1, 0, 0, 0))
+ );
+ private final Collection mockTargetAmountsMinus = List.of(
+ MockTargetAmount.of(-1, true, LocalDateTime.of(2022, 3, 1, 0, 0, 0), LocalDateTime.of(2022, 3, 1, 0, 0, 0)),
+ MockTargetAmount.of(-1, false, LocalDateTime.of(2024, 1, 1, 0, 0, 0), LocalDateTime.of(2024, 1, 1, 0, 0, 0)),
+ MockTargetAmount.of(-1, false, LocalDateTime.of(2024, 1, 1, 0, 0, 0), LocalDateTime.of(2024, 1, 1, 0, 0, 0)),
+ MockTargetAmount.of(-1, true, LocalDateTime.of(2024, 1, 1, 0, 0, 0), LocalDateTime.of(2024, 1, 1, 0, 0, 0))
+ );
+ @Autowired
+ private UserRepository userRepository;
+ @Autowired
+ private TargetAmountRepository targetAmountRepository;
+ @Autowired
+ private NamedParameterJdbcTemplate jdbcTemplate;
+
+ @Test
+ @DisplayName("사용자의 가장 최근 목표 금액을 조회할 수 있다.")
+ @Transactional
+ public void 가장_최근_사용자_목표_금액_조회() {
+ // given
+ User user = userRepository.save(createUser());
+ bulkInsertTargetAmount(user, mockTargetAmounts);
+
+ // when - then
+ targetAmountRepository.findRecentOneByUserId(user.getId())
+ .ifPresentOrElse(
+ targetAmount -> assertEquals(targetAmount.getAmount(), 30000),
+ () -> Assertions.fail("최근 목표 금액이 존재하지 않습니다.")
+ );
+ }
+
+ @Test
+ @DisplayName("사용자의 가장 최근 목표 금액이 존재하지 않으면 Optional.empty()를 반환한다.")
+ @Transactional
+ public void 가장_최근_사용자_목표_금액_미존재() {
+ // given
+ User user = userRepository.save(createUser());
+ bulkInsertTargetAmount(user, mockTargetAmountsMinus);
+
+ // when - then
+ targetAmountRepository.findRecentOneByUserId(user.getId())
+ .ifPresentOrElse(
+ targetAmount -> Assertions.fail("최근 목표 금액이 존재합니다."),
+ () -> log.info("최근 목표 금액이 존재하지 않습니다.")
+ );
+ }
+
+ private void bulkInsertTargetAmount(User user, Collection targetAmounts) {
+ String sql = String.format("""
+ INSERT INTO `%s` (amount, is_read, user_id, created_at, updated_at)
+ VALUES (:amount, true, :userId, :createdAt, :updatedAt)
+ """, "target_amount");
+ SqlParameterSource[] params = targetAmounts.stream()
+ .map(mockTargetAmount -> new MapSqlParameterSource()
+ .addValue("amount", mockTargetAmount.amount)
+ .addValue("userId", user.getId())
+ .addValue("createdAt", mockTargetAmount.createdAt)
+ .addValue("updatedAt", mockTargetAmount.updatedAt))
+ .toArray(SqlParameterSource[]::new);
+ jdbcTemplate.batchUpdate(sql, params);
+ }
+
+ private User createUser() {
+ return User.builder()
+ .username("test")
+ .name("pannyway")
+ .password("test")
+ .phone("010-1234-5678")
+ .role(Role.USER)
+ .profileVisibility(ProfileVisibility.PUBLIC)
+ .notifySetting(NotifySetting.of(true, true, true))
+ .build();
+ }
+
+ private record MockTargetAmount(int amount, boolean isRead, LocalDateTime createdAt, LocalDateTime updatedAt) {
+ public static MockTargetAmount of(int amount, boolean isRead, LocalDateTime createdAt, LocalDateTime updatedAt) {
+ return new MockTargetAmount(amount, isRead, createdAt, updatedAt);
+ }
+ }
+}
diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserExtendedRepositoryTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserExtendedRepositoryTest.java
new file mode 100644
index 000000000..6742972dc
--- /dev/null
+++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserExtendedRepositoryTest.java
@@ -0,0 +1,297 @@
+package kr.co.pennyway.domain.domains.user.repository;
+
+import com.querydsl.core.types.Expression;
+import com.querydsl.core.types.Predicate;
+import kr.co.pennyway.domain.common.repository.QueryHandler;
+import kr.co.pennyway.domain.config.ContainerMySqlTestConfig;
+import kr.co.pennyway.domain.config.JpaConfig;
+import kr.co.pennyway.domain.config.TestJpaConfig;
+import kr.co.pennyway.domain.domains.oauth.domain.Oauth;
+import kr.co.pennyway.domain.domains.oauth.domain.QOauth;
+import kr.co.pennyway.domain.domains.oauth.type.Provider;
+import kr.co.pennyway.domain.domains.user.domain.NotifySetting;
+import kr.co.pennyway.domain.domains.user.domain.QUser;
+import kr.co.pennyway.domain.domains.user.domain.User;
+import kr.co.pennyway.domain.domains.user.type.ProfileVisibility;
+import kr.co.pennyway.domain.domains.user.type.Role;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.extern.slf4j.Slf4j;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+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 org.springframework.context.annotation.Import;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import org.springframework.jdbc.core.namedparam.BeanPropertySqlParameterSource;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.jdbc.core.namedparam.SqlParameterSource;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.*;
+
+import static java.time.LocalDateTime.now;
+import static org.springframework.test.util.AssertionErrors.*;
+
+@Slf4j
+@DataJpaTest(properties = {"spring.jpa.hibernate.ddl-auto=create"})
+@ContextConfiguration(classes = {JpaConfig.class})
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+@ActiveProfiles("test")
+@Import(TestJpaConfig.class)
+public class UserExtendedRepositoryTest extends ContainerMySqlTestConfig {
+ private static final String USER_TABLE = "user";
+ private static final String OAUTH_TABLE = "oauth";
+
+ private final QUser qUser = QUser.user;
+ private final QOauth qOauth = QOauth.oauth;
+
+ @Autowired
+ private UserRepository userRepository;
+ @Autowired
+ private NamedParameterJdbcTemplate jdbcTemplate;
+
+ @BeforeEach
+ public void setUp() {
+ List users = getRandomUsers();
+ bulkInsertUser(users);
+
+ users = userRepository.findAll();
+
+ List oauths = getRandomOauths(users);
+ bulkInsertOauth(oauths);
+ }
+
+ @Test
+ @DisplayName("""
+ Entity findList 테스트: 이름이 양재서고, 일반 회원가입 이력이 존재하면서, lock이 걸려있지 않은 사용자 정보를 조회한다.
+ 이때, 결과는 id 내림차순으로 정렬한다.
+ """)
+ @Transactional
+ public void findList() {
+ // given
+ Predicate predicate = qUser.name.eq("양재서")
+ .and(qUser.password.isNotNull())
+ .and(qUser.locked.isFalse());
+
+ QueryHandler queryHandler = null; // queryHandler는 사용하지 않으므로 null로 설정
+
+ Sort sort = Sort.by(Sort.Order.desc("id"));
+
+ // when
+ List users = userRepository.findList(predicate, queryHandler, sort);
+
+ // then
+ Long maxValue = 100000L;
+ for (User user : users) {
+ log.info("user: {}", user);
+
+ assertTrue("id는 내림차순 정렬되어야 한다.", user.getId() <= maxValue);
+ assertTrue("일반 회원가입 이력이 존재해야 한다.", user.isGeneralSignedUpUser());
+ assertFalse("lock이 걸려있지 않아야 한다.", user.isLocked());
+
+ maxValue = user.getId();
+ }
+ }
+
+ @Test
+ @DisplayName("""
+ Entity findPage 테스트: 이름이 양재서고, Kakao로 가입한 Oauth 정보를 조회한다.
+ 단, 결과는 처음 5개만 조회하며, id 내림차순으로 정렬한다.
+ """)
+ @Transactional
+ public void findPage() {
+ // given
+ Predicate predicate = qUser.name.eq("양재서")
+ .and(qOauth.provider.eq(Provider.KAKAO));
+
+ QueryHandler queryHandler = query -> query.leftJoin(qOauth).on(qUser.id.eq(qOauth.user.id));
+ Sort sort = Sort.by(Sort.Order.desc("user.id"));
+
+ int pageNumber = 0, pageSize = 5;
+ Pageable pageable = PageRequest.of(pageNumber, pageSize, sort);
+
+ // when
+ Page users = userRepository.findPage(predicate, queryHandler, pageable);
+
+ // then
+ assertEquals("users의 크기는 5여야 한다.", 5, users.getSize());
+ Long maxValue = 100000L;
+ for (User user : users.getContent()) {
+ log.debug("user: {}", user);
+ assertTrue("id는 내림차순 정렬되어야 한다.", user.getId() <= maxValue);
+ assertEquals("이름이 양재서여야 한다.", "양재서", user.getName());
+ maxValue = user.getId();
+ }
+ }
+
+ @Test
+ @DisplayName("""
+ Dto selectList 테스트: 사용자 이름이 양재서인 사용자의 username, name, phone 그리고 연동된 Oauth 정보를 조회한다.
+ LinkedHashMap을 사용하여 Dto 생성자 파라미터 순서에 맞게 삽입하면, Dto의 불변성을 유지할 수 있다.
+ """)
+ @Transactional
+ public void selectListUseLinkedHashMap() {
+ // given
+ Predicate predicate = qUser.name.eq("양재서");
+
+ QueryHandler queryHandler = query -> query.leftJoin(qOauth).on(qUser.id.eq(qOauth.user.id));
+ Sort sort = null;
+
+ Map> bindings = new LinkedHashMap<>();
+
+ bindings.put("userId", qUser.id);
+ bindings.put("username", qUser.username);
+ bindings.put("name", qUser.name);
+ bindings.put("phone", qUser.phone);
+ bindings.put("oauthId", qOauth.id);
+ bindings.put("provider", qOauth.provider);
+
+ // when
+ List userAndOauthInfos = userRepository.selectList(predicate, UserAndOauthInfo.class, bindings, queryHandler, sort);
+
+ // then
+ userAndOauthInfos.forEach(userAndOauthInfo -> {
+ log.debug("userAndOauthInfo: {}", userAndOauthInfo);
+ assertEquals("이름이 양재서인 사용자만 조회되어야 한다.", "양재서", userAndOauthInfo.name());
+ assertEquals("provider는 KAKAO여야 한다.", Provider.KAKAO, userAndOauthInfo.provider());
+ });
+ }
+
+ @Test
+ @DisplayName("""
+ Dto selectList 테스트: 사용자 이름이 양재서인 사용자의 username, name, phone 그리고 연동된 Oauth 정보를 조회한다.
+ HashMap을 사용하더라도 Dto의 setter를 명시하고 final 키워드를 제거하면 결과를 조회할 수 있다.
+ """)
+ @Transactional
+ public void selectListUseHashMap() {
+ // given
+ Predicate predicate = qUser.name.eq("양재서");
+
+ QueryHandler queryHandler = query -> query.leftJoin(qOauth).on(qUser.id.eq(qOauth.user.id));
+ Sort sort = null;
+
+ Map> bindings = new HashMap<>();
+
+ bindings.put("userId", qUser.id);
+ bindings.put("username", qUser.username);
+ bindings.put("name", qUser.name);
+ bindings.put("phone", qUser.phone);
+ bindings.put("oauthId", qOauth.id);
+ bindings.put("provider", qOauth.provider);
+
+ // when
+ List userAndOauthInfos = userRepository.selectList(predicate, UserAndOauthInfoNotImmutable.class, bindings, queryHandler, sort);
+
+ // then
+ userAndOauthInfos.forEach(userAndOauthInfo -> {
+ log.debug("userAndOauthInfo: {}", userAndOauthInfo);
+ assertEquals("이름이 양재서인 사용자만 조회되어야 한다.", "양재서", userAndOauthInfo.getName());
+ assertEquals("provider는 KAKAO여야 한다.", Provider.KAKAO, userAndOauthInfo.getProvider());
+ });
+ }
+
+ private List getRandomUsers() {
+ List users = new ArrayList<>(100);
+ List name = List.of("양재서", "이진우", "안성윤", "최희진", "아우신얀", "강병준", "이의찬", "이수민", "이주원");
+
+ for (int i = 0; i < 100; ++i) {
+ User user = User.builder()
+ .username("jayang" + i)
+ .name(name.get(i % name.size()))
+ .password((i % 2 == 0) ? null : "password" + i)
+ .passwordUpdatedAt((i % 2 == 0) ? null : now())
+ .profileVisibility(ProfileVisibility.PUBLIC)
+ .phone("010-1111-1" + String.format("%03d", i))
+ .role(Role.USER)
+ .locked((i % 10 == 0))
+ .notifySetting(NotifySetting.of(true, true, true))
+ .build();
+
+ users.add(user);
+ }
+
+ return users;
+ }
+
+ private List getRandomOauths(Collection users) {
+ List oauths = new ArrayList<>(users.size());
+
+ for (User user : users) {
+ Oauth oauth = Oauth.of(Provider.KAKAO, "providerId" + user.getId(), user);
+ oauths.add(oauth);
+ }
+
+ return oauths;
+ }
+
+ private void bulkInsertUser(Collection users) {
+ String sql = String.format("""
+ INSERT INTO `%s` (username, name, password, password_updated_at, profile_image_url, phone, role, profile_visibility, locked, created_at, updated_at, account_book_notify, feed_notify, chat_notify, deleted_at)
+ VALUES (:username, :name, :password, :passwordUpdatedAt, :profileImageUrl, :phone, '1', '0', :locked, now(), now(), 1, 1, 1, :deletedAt)
+ """, USER_TABLE);
+ SqlParameterSource[] params = users.stream()
+ .map(BeanPropertySqlParameterSource::new)
+ .toArray(SqlParameterSource[]::new);
+ jdbcTemplate.batchUpdate(sql, params);
+ }
+
+ private void bulkInsertOauth(Collection oauths) {
+ String sql = String.format("""
+ INSERT INTO `%s` (provider, oauth_id, user_id, created_at, deleted_at)
+ VALUES (1, :oauthId, :user.id, now(), NULL)
+ """, OAUTH_TABLE);
+ SqlParameterSource[] params = oauths.stream()
+ .map(BeanPropertySqlParameterSource::new)
+ .toArray(SqlParameterSource[]::new);
+ jdbcTemplate.batchUpdate(sql, params);
+ }
+
+ public record UserAndOauthInfo(Long userId, String username, String name, String phone, Long oauthId,
+ Provider provider) {
+ @Override
+ public String toString() {
+ return "UserAndOauthInfo{" +
+ "userId=" + userId +
+ ", username='" + username + '\'' +
+ ", name='" + name + '\'' +
+ ", phone='" + phone + '\'' +
+ ", oauthId=" + oauthId +
+ ", provider=" + provider +
+ '}';
+ }
+ }
+
+ @Setter
+ @Getter
+ public static class UserAndOauthInfoNotImmutable {
+ private Long userId;
+ private String username;
+ private String name;
+ private String phone;
+ private Long oauthId;
+ private Provider provider;
+
+ public UserAndOauthInfoNotImmutable() {
+ }
+
+ @Override
+ public String toString() {
+ return "UserAndOauthInfoNotImmutable{" +
+ "userId=" + userId +
+ ", username='" + username + '\'' +
+ ", name='" + name + '\'' +
+ ", phone='" + phone + '\'' +
+ ", oauthId=" + oauthId +
+ ", provider=" + provider +
+ '}';
+ }
+ }
+}
diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserSoftDeleteTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserSoftDeleteTest.java
new file mode 100644
index 000000000..f708f45c8
--- /dev/null
+++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserSoftDeleteTest.java
@@ -0,0 +1,126 @@
+package kr.co.pennyway.domain.domains.user.repository;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import jakarta.persistence.EntityManager;
+import kr.co.pennyway.domain.config.ContainerMySqlTestConfig;
+import kr.co.pennyway.domain.config.JpaConfig;
+import kr.co.pennyway.domain.domains.user.domain.NotifySetting;
+import kr.co.pennyway.domain.domains.user.domain.User;
+import kr.co.pennyway.domain.domains.user.service.UserService;
+import kr.co.pennyway.domain.domains.user.type.ProfileVisibility;
+import kr.co.pennyway.domain.domains.user.type.Role;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+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 org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.transaction.annotation.Transactional;
+
+import static org.springframework.test.util.AssertionErrors.*;
+
+@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create")
+@ContextConfiguration(classes = {JpaConfig.class, UserService.class})
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+@ActiveProfiles("test")
+public class UserSoftDeleteTest extends ContainerMySqlTestConfig {
+ @Autowired
+ private UserService userService;
+
+ @Autowired
+ private EntityManager em;
+
+ @MockBean
+ private JPAQueryFactory jpaQueryFactory;
+
+ private User user;
+
+ @BeforeEach
+ public void setUp() {
+ user = User.builder()
+ .username("test")
+ .name("pannyway")
+ .password("test")
+ .phone("01012345678")
+ .role(Role.USER)
+ .profileVisibility(ProfileVisibility.PUBLIC)
+ .notifySetting(NotifySetting.of(true, true, true))
+ .build();
+ }
+
+ @Test
+ @DisplayName("[명제] em.createNativeQuery를 사용해도 영속성 컨텍스트에 저장된 엔티티를 조회할 수 있다.")
+ @Transactional
+ public void findByEntityMangerUsingNativeQuery() {
+ // given
+ User savedUser = userService.createUser(user);
+ Long userId = savedUser.getId();
+
+ // when
+ Object foundUser = em.createNativeQuery("SELECT * FROM user WHERE id = ?", User.class)
+ .setParameter(1, userId)
+ .getSingleResult();
+
+ // then
+ assertNotNull("foundUser는 nll이 아니어야 한다.", foundUser);
+ assertEquals("동등성 보장에 성공해야 한다.", savedUser, foundUser);
+ assertTrue("동일성 보장에 성공해야 한다.", savedUser == foundUser);
+ System.out.println("foundUser = " + foundUser);
+ }
+
+ @Test
+ @DisplayName("유저가 삭제되면 deletedAt이 업데이트된다.")
+ @Transactional
+ public void deleteUser() {
+ // given
+ User savedUser = userService.createUser(user);
+ Long userId = savedUser.getId();
+
+ // when
+ userService.deleteUser(savedUser);
+ Object deletedUser = em.createNativeQuery("SELECT * FROM user WHERE id = ?", User.class)
+ .setParameter(1, userId)
+ .getSingleResult();
+
+ // then
+ assertNotNull("유저가 삭제되면 deletedAt이 업데이트된다. ", ((User) deletedUser).getDeletedAt());
+ System.out.println("deletedUser = " + deletedUser);
+ }
+
+ @Test
+ @DisplayName("유저가 삭제되면 findBy와 existsBy로 조회할 수 없다.")
+ @Transactional
+ public void deleteUserAndFindById() {
+ // given
+ User savedUser = userService.createUser(user);
+ Long userId = savedUser.getId();
+
+ // when
+ userService.deleteUser(savedUser);
+
+ // then
+ assertFalse("유저가 삭제되면 existsById로 조회할 수 없다. ", userService.isExistUser(userId));
+ assertNull("유저가 삭제되면 findById로 조회할 수 없다. ", userService.readUser(userId).orElse(null));
+ System.out.println("after delete: savedUser = " + savedUser);
+ }
+
+ @Test
+ @DisplayName("유저가 삭제되지 않으면 findById로 조회할 수 있다.")
+ @Transactional
+ public void findUserNotDeleted() {
+ // given
+ User savedUser = userService.createUser(user);
+ Long userId = savedUser.getId();
+
+ // when
+ User foundUser = userService.readUser(userId).orElse(null);
+
+ // then
+ assertNotNull("foundUser는 null이 아니어야 한다.", foundUser);
+ assertEquals("foundUser는 savedUser와 같아야 한다.", savedUser, foundUser);
+ System.out.println("foundUser = " + foundUser);
+ }
+}
diff --git a/pennyway-domain/src/test/resources/logback-test.xml b/pennyway-domain/src/test/resources/logback-test.xml
new file mode 100644
index 000000000..198192602
--- /dev/null
+++ b/pennyway-domain/src/test/resources/logback-test.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ChatProfileUrlGenerator.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ChatProfileUrlGenerator.java
new file mode 100644
index 000000000..8cd66b54d
--- /dev/null
+++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ChatProfileUrlGenerator.java
@@ -0,0 +1,26 @@
+package kr.co.pennyway.infra.client.aws.s3;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import kr.co.pennyway.common.util.UUIDUtil;
+
+public class ChatProfileUrlGenerator implements UrlGenerator {
+ @Override
+ public Map generate(String ext, String userId, String chatId, String chatroomId) {
+ if (userId == null) {
+ throw new IllegalArgumentException("userId는 필수입니다.");
+ }
+ if (chatroomId == null) {
+ chatroomId = UUIDUtil.generateUUID();
+ }
+ Map variablesMap = new HashMap<>();
+ variablesMap.put("uuid", UUIDUtil.generateUUID());
+ variablesMap.put("timestamp", String.valueOf(System.currentTimeMillis()));
+ variablesMap.put("ext", ext);
+ variablesMap.put("chatroom_id", chatroomId);
+ variablesMap.put("user_id", userId);
+ return variablesMap;
+ }
+}
+
diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ChatUrlGenerator.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ChatUrlGenerator.java
new file mode 100644
index 000000000..54e446bff
--- /dev/null
+++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ChatUrlGenerator.java
@@ -0,0 +1,20 @@
+package kr.co.pennyway.infra.client.aws.s3;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import kr.co.pennyway.common.util.UUIDUtil;
+
+public class ChatUrlGenerator implements UrlGenerator {
+ @Override
+ public Map generate(String ext, String userId, String chatId, String chatroomId) {
+ Map variablesMap = new HashMap<>();
+ variablesMap.put("uuid", UUIDUtil.generateUUID());
+ variablesMap.put("timestamp", String.valueOf(System.currentTimeMillis()));
+ variablesMap.put("ext", ext);
+ variablesMap.put("chatroom_id", chatroomId);
+ variablesMap.put("chat_id", UUIDUtil.generateUUID());
+ return variablesMap;
+ }
+}
+
diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ChatroomProfileUrlGenerator.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ChatroomProfileUrlGenerator.java
new file mode 100644
index 000000000..98604d31f
--- /dev/null
+++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ChatroomProfileUrlGenerator.java
@@ -0,0 +1,22 @@
+package kr.co.pennyway.infra.client.aws.s3;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import kr.co.pennyway.common.util.UUIDUtil;
+
+public class ChatroomProfileUrlGenerator implements UrlGenerator {
+ @Override
+ public Map generate(String ext, String userId, String chatId, String chatroomId) {
+ if (chatroomId == null) {
+ chatroomId = UUIDUtil.generateUUID();
+ }
+ Map variablesMap = new HashMap<>();
+ variablesMap.put("uuid", UUIDUtil.generateUUID());
+ variablesMap.put("timestamp", String.valueOf(System.currentTimeMillis()));
+ variablesMap.put("ext", ext);
+ variablesMap.put("chatroom_id", chatroomId);
+ return variablesMap;
+ }
+}
+
diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/FeedUrlGenerator.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/FeedUrlGenerator.java
new file mode 100644
index 000000000..4f4e630be
--- /dev/null
+++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/FeedUrlGenerator.java
@@ -0,0 +1,18 @@
+package kr.co.pennyway.infra.client.aws.s3;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import kr.co.pennyway.common.util.UUIDUtil;
+
+public class FeedUrlGenerator implements UrlGenerator {
+ @Override
+ public Map generate(String type, String ext, String userId, String chatroomId) {
+ Map variablesMap = new HashMap<>();
+ variablesMap.put("uuid", UUIDUtil.generateUUID());
+ variablesMap.put("timestamp", String.valueOf(System.currentTimeMillis()));
+ variablesMap.put("ext", ext);
+ variablesMap.put("feed_id", UUIDUtil.generateUUID());
+ return variablesMap;
+ }
+}
diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyPattern.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyPattern.java
new file mode 100644
index 000000000..57e756997
--- /dev/null
+++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyPattern.java
@@ -0,0 +1,35 @@
+package kr.co.pennyway.infra.client.aws.s3;
+
+import java.util.regex.Pattern;
+
+public class ObjectKeyPattern {
+ public static final String USER_ID_PATTERN = "([^/]+)";
+ public static final String UUID_PATTERN = "([^_]+)";
+ public static final String TIMESTAMP_PATTERN = "([^\\.]+)";
+ public static final String EXT_PATTERN = "([^/]+)";
+ public static final String FEED_ID_PATTERN = "([^/]+)";
+ public static final String CHATROOM_ID_PATTERN = "([^/]+)";
+ public static final String CHAT_ID_PATTERN = "([^/]+)";
+
+ public static final Pattern PROFILE_PATTERN = Pattern.compile(
+ createRegex("delete/profile/{userId}/{uuid}_{timestamp}.{ext}"));
+ public static final Pattern FEED_PATTERN = Pattern.compile(
+ createRegex("delete/feed/{feed_id}/{uuid}_{timestamp}.{ext}"));
+ public static final Pattern CHATROOM_PROFILE_PATTERN = Pattern.compile(
+ createRegex("delete/chatroom/{chatroom_id}/{uuid}_{timestamp}.{ext}"));
+ public static final Pattern CHAT_PATTERN = Pattern.compile(
+ createRegex("delete/chatroom/{chatroom_id}/chat/{chat_id}/{uuid}_{timestamp}.{ext}"));
+ public static final Pattern CHAT_PROFILE_PATTERN = Pattern.compile(
+ createRegex("delete/chatroom/{chatroom_id}/chat_profile/{userId}/{uuid}_{timestamp}.{ext}"));
+
+ private static String createRegex(String template) {
+ return template
+ .replace("{userId}", USER_ID_PATTERN)
+ .replace("{uuid}", UUID_PATTERN)
+ .replace("{timestamp}", TIMESTAMP_PATTERN)
+ .replace("{ext}", EXT_PATTERN)
+ .replace("{feed_id}", FEED_ID_PATTERN)
+ .replace("{chatroom_id}", CHATROOM_ID_PATTERN)
+ .replace("{chat_id}", CHAT_ID_PATTERN);
+ }
+}
diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyTemplate.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyTemplate.java
new file mode 100644
index 000000000..da14b2a7c
--- /dev/null
+++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyTemplate.java
@@ -0,0 +1,18 @@
+package kr.co.pennyway.infra.client.aws.s3;
+
+import java.util.Map;
+
+import lombok.AllArgsConstructor;
+
+@AllArgsConstructor
+public class ObjectKeyTemplate {
+ private String template;
+
+ public String apply(Map variables) {
+ String result = template;
+ for (Map.Entry entry : variables.entrySet()) {
+ result = result.replace("{" + entry.getKey() + "}", entry.getValue());
+ }
+ return result;
+ }
+}
diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ProfileUrlGenerator.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ProfileUrlGenerator.java
new file mode 100644
index 000000000..00dbb45df
--- /dev/null
+++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ProfileUrlGenerator.java
@@ -0,0 +1,22 @@
+package kr.co.pennyway.infra.client.aws.s3;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import kr.co.pennyway.common.util.UUIDUtil;
+
+public class ProfileUrlGenerator implements UrlGenerator {
+ @Override
+ public Map generate(String type, String ext, String userId, String chatroomId) {
+ if (userId == null) {
+ throw new IllegalArgumentException("userId는 필수입니다.");
+ }
+ Map variablesMap = new HashMap<>();
+ variablesMap.put("type", type);
+ variablesMap.put("ext", ext);
+ variablesMap.put("userId", userId);
+ variablesMap.put("uuid", UUIDUtil.generateUUID());
+ variablesMap.put("timestamp", String.valueOf(System.currentTimeMillis()));
+ return variablesMap;
+ }
+}
diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/UrlGenerator.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/UrlGenerator.java
new file mode 100644
index 000000000..a55037584
--- /dev/null
+++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/UrlGenerator.java
@@ -0,0 +1,15 @@
+package kr.co.pennyway.infra.client.aws.s3;
+
+import java.util.Map;
+
+public interface UrlGenerator {
+ /**
+ * type에 해당하는 ObjectKeyTemplate을 적용하여 ObjectKey(S3에 저장하기 위한 정적 파일의 경로 및 이름)를 생성한다.
+ * @param type
+ * @param ext
+ * @param userId
+ * @param chatroomId
+ * @return ObjectKey
+ */
+ Map generate(String type, String ext, String userId, String chatroomId);
+}
diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/UrlGeneratorFactory.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/UrlGeneratorFactory.java
new file mode 100644
index 000000000..ac143541e
--- /dev/null
+++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/UrlGeneratorFactory.java
@@ -0,0 +1,23 @@
+package kr.co.pennyway.infra.client.aws.s3;
+
+import kr.co.pennyway.infra.common.exception.StorageErrorCode;
+import kr.co.pennyway.infra.common.exception.StorageException;
+
+public class UrlGeneratorFactory {
+ public static UrlGenerator getUrlGenerator(ObjectKeyType type) {
+ switch (type) {
+ case PROFILE:
+ return new ProfileUrlGenerator();
+ case FEED:
+ return new FeedUrlGenerator();
+ case CHATROOM_PROFILE:
+ return new ChatroomProfileUrlGenerator();
+ case CHAT:
+ return new ChatUrlGenerator();
+ case CHAT_PROFILE:
+ return new ChatProfileUrlGenerator();
+ default:
+ throw new StorageException(StorageErrorCode.INVALID_TYPE);
+ }
+ }
+}