미니 배달 주문 서비스 프로젝트 회고 및 개념 정리 Spring Boot 4.1.1 · Java 21 · PostgreSQL · JWT 미니 배달 주문 서비스 회원가입부터 로그인, 메뉴, 주문, 결제까지 직접 구현하면서 Spring에서 배운 개념들을 실제 프로젝트에 적용해보는 과정 1. 프로젝트 구조 프로젝트는 도메인별로 user , menu , order , payment 를 나누고 각 도메인 내부에서 controller , service , repository , entity , dto 로 계층을 분리했다 3 Layer Architecture Controller ↓ Service ↓ Repository ↓ Database 각 계층의 역할을 분리하고 의존 방향을 한쪽으로 유지하는 구조 2. 회원가입 회원가입에서는 @RequestBody 를 통해 JSON 데이터를 받고 @Valid 를 사용해 요청 데이터를 검증 비밀번호는 BCrypt로 암호화한 후 저장 아이디와 이메일 중복의 경우 DuplicateException 을 사용해 409 Conflict 로 처리 @RequestBody HTTP Body로 전달된 JSON 데이터를 DTO 객체로 변환 @Valid @RequestBody SignupRequestDto requestDto Validation @Valid 를 사용하면 DTO에 설정한 검증 조건을 기준으로 요청 데이터 검증 검증에 실패하면 Controller 메서드 내부까지 들어오기 전에 예외 발생 3. Spring Security와 JWT 로그인과 인증은 Spring Security와 JWT를 사용 인증과 인가 Authentication : 사용자가 누구인지 확인하는 과정 Authorization : 인증된 사용자가 해당 기능에 접근할 수 있는지 확인하는 과정 JWT 인증 흐름 로그인 요청 ↓ JwtAuthenticationFilter ↓ 사용자 인증 ↓ JWT 발급 ↓ Response Header 이후 API 요청에서는 API 요청 ↓ JwtAuthorizationFilter ↓ JWT 검증 ↓ 사용자 정보 확인 ↓ Controller JwtAuthenticationFilter 는 로그인 시 JWT를 발급 하고, JwtAuthorizationFilter 는 요청마다 JWT를 검증 하는 역할 STATELESS 이번 프로젝트에서는 Session 대신 JWT를 사용하기 때문에 SessionCreationPolicy.STATELESS 설정 서버에 세션을 저장하지 않고 매 요청마다 전달된 JWT를 검증하는 방식 Client ↓ JWT Server ↓ JWT 검증 4. SecurityConfig에서 겪었던 문제 프로젝트를 진행하면서 HTTP 상태 코드가 예상과 다르게 반환되는 문제가 여러 번 발생 특히 403 Forbidden이 계속 발생하는 문제 가 있었음 /error Spring에서 예외가 발생하면 /error 로 다시 요청이 들어올 수 있는데, 이 경로가 Security 설정에 의해 막히면 원래 발생한 400이나 409 대신 403으로 응답되는 현상 발생 .requestMatchers("/error").permitAll() 따라서 /error 경로는 열어두는 것이 중요 Security 규칙 순서 Spring Security의 Request Matcher는 위에서부터 순서대로 적용 구체적인 규칙 ↓ 전체 규칙 예를 들어 GET /api/menus 를 인증 없이 허용하려면 anyRequest().authenticated() 보다 먼저 설정해야 함 5. 메뉴 기능 메뉴에서는 다음 기능을 구현 메뉴 등록 메뉴 전체 조회 메뉴 단건 조회 메뉴 수정 메뉴 삭제 메뉴 조회는 인증 없이 가능하도록 설정하고, 등록·수정·삭제는 OWNER 권한을 가진 사용자만 접근하도록 구성 Soft Delete 메뉴 삭제는 실제 DB에서 데이터를 삭제하는 대신 deleted = true 로 변경하는 Soft Delete 방식 조회할 때는 삭제되지 않은 데이터만 조회 findByIdAndDeletedFalse() 주문이 기존 메뉴를 참조하고 있기 때문에 데이터를 실제로 삭제하지 않고 _상태만 변경_하는 방식 6. JPA 연관관계 메뉴와 사용자처럼 Entity 간 관계가 필요한 경우 JPA 연관관계 사용 이번 프로젝트에서는 주로 N:1 관계 를 사용 User 1 ↑ │ N Menu @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") private User user; 연관관계의 특징 DB에서는 외래 키를 통해 관계를 표현하고, JPA에서는 Entity 간 참조를 통해 관계를 표현 이번 프로젝트에서는 양방향 관계를 만들기보다 필요한 방향에서만 참조하는 단방향 연관관계 사용 7. 지연 로딩 @ManyToOne 의 기본 FetchType은 EAGER이지만 이번 프로젝트에서는 직접 LAZY 로 설정 @ManyToOne(fetch = FetchType.LAZY) LAZY 연관된 Entity를 바로 조회하지 않고 실제로 사용하는 시점에 조회 불필요한 데이터를 한 번에 조회하는 것을 줄일 수 있음 단, LAZY로 설정한 연관 Entity를 사용하려면 영속성 컨텍스트가 유지되는 상태 여야 함 따라서 Service의 @Transactional 과 함께 이해할 필요가 있음 8. 더티 체킹 JPA에서는 @Transactional 내부에서 _Entity의 값을 변경_하면 별도의 save() 없이도 변경 사항을 DB에 반영할 수 있음 @Transactional public void updateMenu(...) { menu.setName(name); } 트랜잭션이 종료될 때 JPA가 Entity의 변경 내용을 확인하고 UPDATE 쿼리를 실행 이를 더티 체킹(Dirty Checking) 이라고 함 단, readOnly = true 인 트랜잭션에서는 변경 사항이 정상적으로 반영되지 않음 9. 주문 주문에서는 주문 상태에 따라 기능을 제한 ORDERED ↓ ACCEPTED ↓ DELIVERED 각 상태에 맞지 않는 요청이 들어오면 409 Conflict 로 처리 중요한 부분 주문 금액은 Client가 전달한 값을 그대로 사용하지 않고 서버에서 직접 계산 Client의 금액 ↓ 신뢰하지 않음 메뉴 가격 × 수량 ↓ Server에서 계산 Client에서 임의로 금액을 변경하더라도 서버의 계산 결과를 사용하도록 구현 10. 주문 상태 변경과 @Transactional 주문 수락이나 배달 완료처럼 DB의 상태를 변경하는 메서드에는 @Transactional 필요 @Transactional public void acceptOrder(...) { order.setStatus(OrderStatus.ACCEPTED); } @Transactional(readOnly = true) 가 적용된 클래스에서 상태 변경 메서드에 별도의 @Transactional 이 없으면 변경 사항이 DB에 반영되지 않을 수 있음 정리 조회 → readOnly = true 데이터 변경 → @Transactional 11. 결제 결제 API에서는 주문의 총 금액을 기준으로 결제 처리 결제 수단은 Enum 으로 관리 public enum PaymentMethod { CARD } DTO에서 PaymentMethod 타입으로 받으면 잘못된 값이 들어왔을 때 Enum 변환 과정에서 400 Bad Request 발생 단, 값 자체가 없는 경우는 별도의 검증이 필요하기 때문에 @NotNull 사용 @NotNull private PaymentMethod method; 12. 결제 중복 처리 결제는 한 주문에 대해 한 번만 가능하도록 처리 ORDERED ↓ 결제 ↓ PAID 이미 결제가 완료된 주문에 다시 결제를 요청하면 409 Conflict 반환 상태 변경 조건을 Service에서 검사하여 비즈니스 규칙 적용 프로젝트를 진행하면서 겪었던 문제 1. @RequestBody 누락 Postman에서 JSON을 전달했는데 DTO의 값이 전부 null 로 들어오는 문제가 발생 원인은 @RequestBody 누락 @Valid @RequestBody SignupRequestDto requestDto JSON 데이터를 Controller에서 받기 위해 @RequestBody 필요 2. Validation 예외가 try-catch 에 잡히지 않는 문제 Controller 메서드에 진입하기 전에 @Valid 검증이 실행되기 때문에 Validation 예외는 메서드 내부의 try-catch 에서 처리할 수 없음 Request ↓ @Valid ↓ Validation ↓ Controller 따라서 @Valid 검증 실패는 일반적인 Controller 내부 예외 처리와 동작 시점이 다름 3. JWT 검증 실패가 200으로 반환되는 문제 JWT 검증 Filter에서 단순히 return; 으로 종료하면 응답을 작성하지 않은 상태로 요청이 끝나 200 OK가 반환되는 문제 가 발생 인증 필터에서 요청을 적절하게 다음 Filter Chain으로 넘겨 Spring Security가 권한 검사를 처리하도록 해야 함 4. PostgreSQL 예약어 문제 order , user 와 같은 이름은 DB에서 예약어로 사용될 수 있음 따라서 주문 테이블은 @Table(name = "p_order") 처럼 별도의 이름을 지정 핵심 개념 정리 개념 정리 3 Layer Controller → Service → Repository @RequestBody HTTP Body의 JSON을 객체로 변환 @Valid 요청 데이터 검증 BCrypt 비밀번호 암호화 Authentication 사용자 인증 Authorization 권한 확인 JWT Stateless 인증 방식 SecurityFilterChain Spring Security의 인증·인가 처리 STATELESS 세션을 사용하지 않는 방식 @ManyToOne N:1 연관관계 LAZY 필요한 시점에 연관 Entity 조회 Dirty Checking Entity 변경 사항 자동 반영 Soft Delete 실제 삭제 대신 삭제 상태 저장 @Transactional 트랜잭션 단위 처리 PUT 전체 수정 PATCH 일부 수정 409 Conflict 비즈니스 상태 충돌 EnumType.STRING Enum 값을 문자열로 저장 느낀 점 강의에서 배울 때는 각각의 개념을 이해했다고 생각했지만, 직접 프로젝트를 구현해보니 개념 하나하나가 실제 코드에서 어떻게 연결되는지 를 이해하는 것이 더 중요하다는 것을 느꼈다 특히 403 에러가 발생했을 때 단순히 권한 문제라고 생각했지만, 실제로는 CSRF 설정이나 /error 접근 제한 등 여러 원인이 있을 수 있었다 결국 에러가 발생했을 때 단순히 상태 코드만 확인하는 것이 아니라 요청이 어느 계층과 Filter에서 처리되고 있는지 추적하는 과정이 중요 하다는 것을 배웠다 로그를 통해 해결해야 하니까 어디서 오류가 났는지 파악하는 눈을 길러야겠다 또한 요구사항을 기능 단위로 나누고 회원가입 → 로그인 → 메뉴 → 주문 → 결제 순서로 구현하면서 앞에서 만든 기능이 다음 기능의 기반이 된다는 것도 체감했다 오늘은 단순히 코드를 작성하는 것보다 왜 이 설정이 필요한지, 이 코드가 어떤 조건을 해결하기 위해 존재하는지 를 생각하면서 개발하는 것이 왜 중요한지 체감했다
Modern digital platforms collect huge amounts of data about user behavior every day: what people view, how often they return, what they respond to, and what actions they take. This data has become an important foundation for creating more personalized digital experiences. Traditional personalization often relied on simple rules. If a user showed interest in a particular product or category, the system would recommend something similar. Machine learning is changing this approach by allowing platforms to analyze multiple behavioral signals and predict what may be relevant to a particular user. From Data to Prediction The process can be simplified as data → analysis → prediction → personalized recommendation. Machine learning models can identify patterns in user behavior and estimate the likelihood of future actions. This is the foundation of recommendation systems, which can help platforms select relevant content, products, or offers for individual users. A practical example is MICoTeam - a platform that applies machine learning and predictive analytics in the casino AI space, including AI churn prediction to analyze behavioral patterns and identify users who may become less active. Why It Matters for Business Traditional analytics mainly answers the question: “What has already happened?” Predictive analytics takes the next step by asking: “What is likely to happen next?” This approach can help digital platforms better understand their audiences, adapt user experiences, and make data-driven decisions. At the same time, the effectiveness of machine learning depends not only on the complexity of an algorithm. Data quality, proper model testing, and responsible handling of user information are equally important. Machine learning is gradually transforming personalization from a set of simple rules into a dynamic prediction system. Digital platforms can now go beyond analyzing past behavior and use data to anticipate potential future needs. In this way, modern personalization follows a simple principle: from data to prediction, and from prediction to a more relevant digital experience.
[Outbreak Breaker] 코드 전체 정리, 구독 누수와 SetByCaller 갱신 버그 수정 오늘은 커밋을 13개 올렸다. 새 기능은 없고, 하루 종일 프로젝트 코드를 폴더 단위로 훑으면서 안 쓰는 코드를 걷어내고 주석을 달았다. 그런데 정리하다 보니 실제 버그가 몇 개 나왔다. 버그부터 적고, 구조를 바꾼 부분, 단순 정리 순으로 쓴다. 1. 버그와 누수 수정 스택된 GE가 옛날 SetByCaller 값을 들고 있던 문제 지뢰 둔화나 오라 디버프처럼 스택 제한 1 + 갱신 정책을 쓰는 GE가 있다. 이런 GE를 다시 적용하면 새 SetByCaller 값이 반영되지 않고 처음 값이 그대로 남았다. AOBEffectActor::ApplyEffectInternal 에서 적용 직후 UpdateActiveGameplayEffectSetByCallerMagnitudes 를 호출하도록 고쳤다. 핸들이 유효하지 않거나 대상 ASC가 없으면 바로 리턴하고, 무한 지속 + RemoveOnEndOverlap이면 핸들을 기억해 둔다. 지뢰 둔화 GE와 오라 GA BP의 독 GE, 공격 디버프 GE에 적용했다. 위젯 컨트롤러 델리게이트 정리 UOBEnemyWidgetController 는 적마다 FBoundEnemy (ASC, HP 핸들, MaxHP 핸들)를 저장하고, 해제할 때 Remove(Handle) 로 정확히 지운다. 풀링된 적이 같은 컨트롤러에 중복 등록되는 것도 막았다. 람다는 TWeakObjectPtr<AActor> 를 캡처한다. UOBHUDWidgetController 는 AddLambda 를 전부 AddUObject 핸들러( HandleHealthChanged , HandleExpChanged , HandleWeaponLevelChanged 등)로 바꿨다. 람다로는 RemoveAll(this) 가 안 돼서 정리할 방법이 없었다. 무기 6종 레벨 어트리뷰트를 GetWeaponLevelAttributes() 로 모았고, IsWeaponAvailable 기본값은 false로 바꿨다. WeaponComponent 구독 누수와 무기 스폰 실패 EndPlay 에서 캐릭터 공격력/공속 구독을 해제하지 않아서, 스테이지를 재시작할 때마다 구독이 누적되고 있었다. 해제를 추가했다. 풀에서 무기 액터를 못 가져왔을 때도 초기화 완료로 표시하던 문제가 있었다. 지금은 경고 로그를 남기고 재시도할 수 있는 상태로 둔다. 그 밖의 수정 ApplyUpgradeCard 뒤에 RefreshAllWeaponStats 가 호출되지 않아서 카드를 골라도 최종 스탯 캐시가 갱신되지 않던 걸 고쳤다. MaxWeaponLevel 상수도 만들고 OwnerActor null 체크 순서도 바로잡았다. 위치 캐싱 재시도( TryStartLocationCaching )가 동작하지 않던 문제를 고쳤다. FOBEnemySoundSet 에서 첫 호출 때 0번 사운드가 후보에서 빠지던 인덱스 버그를 고쳤다. 2. 구조를 바꾼 리팩터링 스테이지 타임라인 매니저 초기화할 때 DataTable 행 포인터를 캐싱해서, 매 틱 행 이름 배열을 복사하지 않는다. 랜덤 NavMesh 좌표 뽑기를 TryGetRandomNavLocation 으로 합쳤다. 캡슐 오프셋은 적 스폰에만 적용하고 아이템은 지면 좌표에 둔다. SpawnEnemyWave 에서 풀 조회를 루프 밖으로 뺐고, 실패 로그는 웨이브당 한 번만 찍는다. GameMode의 타이머 값은 StageSpawnDelay , StageUnloadDelay , WarmupDuration 상수로 뺐다. WarmupEffects 는 AdditionalWarmupEffects 로 이름을 바꾸고 풀링 액터의 워밍업 이펙트와 합쳐서 처리한다. DataTable Row 구조체 이름을 FOBEnemySpawnRow , FOBBGMScheduleRow , FOBItemSpawnTimeRow , FOBItemPoolRow 로 통일하고, 기존 에셋이 깨지지 않도록 DefaultEngine.ini 에 [CoreRedirects] StructRedirects 를 추가했다. 적 AI AOBEnemy::Tick 이 한 함수에 몰려 있어서 RotateTowardTarget , ChaseTarget , HoldAndTryAttack 세 개로 나눴다. 스턴이나 강제이동 중에는 AI 전체가 멈추는 건 그대로다. 공격에 성공하면 GetAttackDelay() 만큼, 실패하면 AttackRetryInterval 뒤에 다시 시도한다. AttackRange 멤버는 없애고 UOBEnemyAttributeSet::GetAttackRange() 를 쓰게 했다. 이제 공격 사거리도 GE로 바꿀 수 있다. EffectActor와 풀 이펙트 액터 정책별 적용 로직이 Instant, Duration, Infinite마다 복붙돼 있어서 ApplyEffectsForPolicy 하나로 합쳤다. 무한 지속 GE 핸들은 TWeakObjectPtr 로 보관하고, RemoveActiveEffects 를 새로 만들었다. 풀 이펙트 액터는 EndOverlap 없이 회수돼도 대상에게 GE가 남지 않도록, 회수 시점에 남은 GE를 제거하고 Target/Source 참조도 비운다. 무기 베이스는 회수할 때 SourceActor 도 초기화하고, 태그가 비어 있으면 부여하지 않도록 막았다. AbilitySystem OBMMC_XPGain 이 런타임에 RequestGameplayTag 로 태그를 찾던 걸 네이티브 태그 Data.Item.BountyXP 로 바꿨다. ASC는 UOBGameplayAbility 가 아닌 어빌리티를 지급하려 하면 경고만 찍고 건너뛴다. 입력 처리 순회에는 FScopedAbilityListLock 을 걸어서, 순회 중 어빌리티 목록이 바뀌어도 안전하게 했다. bUseStartupInput 옵션을 추가했다. GA_Weapon_Equip처럼 GameplayEvent로만 발동하는 어빌리티는 이걸 꺼서 입력 태그를 기록하지 않는다. 꺼두면 에디터에서 StartupInputTag 도 숨겨진다. ApplyOBCooldown 은 protected로 내렸다. 회피 어빌리티에는 ActorInfo null 체크를 넣었다. 누락돼 있던 네이티브 태그 선언 끝의 세미콜론도 채웠다. UI 서브시스템과 오디오/매니지먼트 JMUISubsystem 은 Deinitialize 에서 컨트롤러를 정리해서 구독이 해제되게 했다. 바인딩이 실패하면 캐시하지 않고 nullptr를 반환한다. 쓰지 않던 AttributeSet 탐색 코드는 지웠고, MakeWidgetControllerParams 는 MakeGASParams 로 이름을 바꿨다. 오디오 서브시스템은 bIsMusic = true 를 페이드인/재생 전에 세팅하게 했다. SoundMix/SoundClass 설정이 비어 있으면 경고를 찍고, BGM 타입이 없을 때는 타입 이름까지 로그에 남긴다. ManagementSubsystem은 bIsPaused 캐시를 없애고 IsGamePaused() 가 UGameplayStatics::IsGamePaused 를 직접 조회한다. 캐시와 실제 상태가 어긋날 여지를 없앴다. 오브젝트 풀 플러그인 액터를 만들고 대기 상태로 만드는 코드가 여러 군데 중복돼 있어서 SpawnPooledObject 와 ApplyInactiveState 로 통합했다. 사용 중이 아닌 오브젝트를 두 번 회수하는 걸 막는 가드, GetWorld null 가드, 파괴된 슬롯의 IsValid 판정도 넣었다. 3. 단순 정리 Actor, Component(Weapon/Upgrade/Effect), AttributeSet, Character, Enemy, Settings 헤더에서 쓰지 않는 멤버와 include, 오버라이드를 지웠다. 헤더에 있던 AttributeSet 함수 구현은 .cpp로 옮기고 역할 주석을 달았다. 예를 들어 GetEnemyAttackDelayMultiplier 는 1 / max(1 - 최종 공속 감소, 아주 작은 값) 으로 공격 간격 배율을 계산한다. FWeaponInputMapping 은 FOBWeaponInputMapping 으로 이름을 바꾸고 StructRedirects 를 추가했다. 무기 데이터 구조체에는 필드마다 주석을 달았다. 입력과 HUD 쪽은 InputAction 을 TObjectPtr 로 바꾸고, HUD에는 UI 서브시스템 조회 헬퍼와 null 체크를 넣었다. AnimNotifyState_OBGrantTag 에는 유효성 가드와 Super 호출을 추가했다. UEffectComponent는 컴포넌트 생성과 재생에서 중복되던 코드를 헬퍼 함수로 묶었다. 오늘 느낀 점 기능을 만들 때는 "일단 돌아가게" 짜느라 놓치던 것들이 폴더 단위로 다시 읽으니 한꺼번에 보였다. 특히 구독 누수와 SetByCaller 갱신 문제는 한동안 모르고 지나갈 뻔한 종류였다. 스테이지를 여러 번 재시작해야 드러나는 문제이고, 스택된 GE의 값이 안 바뀌는 건 수치가 조금 어긋나는 정도로만 보이기 때문이다. 풀링과 델리게이트를 많이 쓰는 구조라서, 앞으로는 새로 만드는 코드에도 "해제하는 쪽이 있는가"를 먼저 확인해야겠다. 다음에 할 것 StructRedirects 를 추가했으니 에디터를 열어서 이름이 바뀐 DataTable 에셋들이 정상적으로 로드되는지 확인 스택 GE의 SetByCaller 갱신이 인게임에서 제대로 반영되는지 확인 (지뢰 둔화, 오라 독/공격 디버프) 스테이지를 여러 번 재시작하면서 구독 누수가 사라졌는지 확인 그동안 미뤄둔 일시정지 메뉴 UI를 CommonUI + MVVM으로 옮기는 작업 일정 잡기