Skip to content

Commit ca7c7b2

Browse files
committed
refactor: 코드 품질 개선 및 테스트 안정성 강화
- datetime.now() → now_utc() 유틸리티로 통일 (repository, service) - 프로덕션 환경 보안 설정 검증 로직 추가 (config.py) - 벌크 동기화 예외 처리 및 로깅 개선 (service.py) - 테스트용 user_id_factory fixture 추가로 PK 충돌 방지 - 벌크 동기화 DB 반영 검증 테스트 추가 - 벌크 동기화 예외 발생 시 로그/전파 테스트 추가 - 프로덕션 환경 설정 검증 단위 테스트 추가
1 parent 45abcd4 commit ca7c7b2

8 files changed

Lines changed: 254 additions & 59 deletions

File tree

app/core/config.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from functools import lru_cache
22
from typing import List
33

4-
from pydantic import field_validator
4+
from pydantic import field_validator, model_validator
55
from pydantic_settings import BaseSettings, SettingsConfigDict
66

77

@@ -59,6 +59,39 @@ def parse_cors_origins(cls, v):
5959
return [origin.strip() for origin in v.split(",")]
6060
return v
6161

62+
@model_validator(mode="after")
63+
def validate_production_settings(self) -> "Settings":
64+
"""프로덕션 환경에서 보안 설정 검증"""
65+
if not self.is_production:
66+
return self
67+
68+
# Internal API Key 검증
69+
if self.internal_api_key == "your-internal-api-key-here":
70+
raise ValueError(
71+
"Production requires valid INTERNAL_API_KEY. "
72+
"Set it via environment variable."
73+
)
74+
75+
if len(self.internal_api_key) < 32:
76+
raise ValueError(
77+
"INTERNAL_API_KEY must be at least 32 characters long "
78+
"for security."
79+
)
80+
81+
# Secret Key 검증 (JWT 구현 시)
82+
if self.secret_key == "your-secret-key-here":
83+
raise ValueError(
84+
"Production requires valid SECRET_KEY. "
85+
"Set it via environment variable."
86+
)
87+
88+
if len(self.secret_key) < 32:
89+
raise ValueError(
90+
"SECRET_KEY must be at least 32 characters long for security."
91+
)
92+
93+
return self
94+
6295
@property
6396
def is_development(self) -> bool:
6497
return self.app_env == "development"

app/domains/users/repository.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
Spring Boot 사용자 동기화를 위한 데이터 접근 계층입니다.
44
"""
55

6-
from datetime import datetime
76
from typing import Optional, Sequence, cast
87

98
from sqlalchemy import func, select
109
from sqlalchemy.ext.asyncio import AsyncSession
1110

11+
from app.core.utils.datetime import now_utc
1212
from app.domains.users.models import User
1313

1414

@@ -120,7 +120,7 @@ async def soft_delete(self, user: User) -> User:
120120
Returns:
121121
삭제된 사용자 객체
122122
"""
123-
user.deleted_at = datetime.now()
123+
user.deleted_at = now_utc()
124124
await self.session.flush()
125125
await self.session.refresh(user)
126126
return user

app/domains/users/service.py

Lines changed: 37 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,11 @@
33
Spring Boot 사용자 동기화를 위한 비즈니스 로직 계층입니다.
44
"""
55

6-
from datetime import datetime
7-
86
from sqlalchemy.ext.asyncio import AsyncSession
97

108
from app.core.logging import get_logger
119
from app.core.middlewares.context import get_request_id
10+
from app.core.utils.datetime import now_utc
1211
from app.domains.users.exceptions import UserNotFoundException
1312
from app.domains.users.models import User
1413
from app.domains.users.repository import UserRepository
@@ -87,7 +86,7 @@ async def upsert_user(self, user_data: UserSync) -> User:
8786
if existing.deleted_at:
8887
existing.deleted_at = None # 복구
8988

90-
existing.last_sync_at = datetime.now()
89+
existing.last_sync_at = now_utc()
9190
user = await self.repository.update(existing)
9291

9392
logger.info(
@@ -101,7 +100,7 @@ async def upsert_user(self, user_data: UserSync) -> User:
101100
return user
102101
else:
103102
# 생성
104-
user = User(id=user_data.id, last_sync_at=datetime.now())
103+
user = User(id=user_data.id, last_sync_at=now_utc())
105104
created_user = await self.repository.create(user)
106105

107106
logger.info(
@@ -131,42 +130,43 @@ async def bulk_upsert_users(
131130
restored = 0
132131

133132
for user_data in users:
134-
existing = await self.repository.get_by_id(
135-
user_data.id, include_deleted=True
136-
)
137-
138-
if existing:
139-
was_deleted = existing.deleted_at is not None
140-
if was_deleted:
141-
existing.deleted_at = None
142-
restored += 1
133+
try:
134+
existing = await self.repository.get_by_id(
135+
user_data.id, include_deleted=True
136+
)
137+
138+
if existing:
139+
was_deleted = existing.deleted_at is not None
140+
if was_deleted:
141+
existing.deleted_at = None
142+
restored += 1
143+
else:
144+
updated += 1
145+
146+
existing.last_sync_at = now_utc()
147+
await self.repository.update(existing)
143148
else:
144-
updated += 1
145-
146-
existing.last_sync_at = datetime.now()
147-
await self.repository.update(existing)
148-
else:
149-
user = User(id=user_data.id, last_sync_at=datetime.now())
150-
await self.repository.create(user)
151-
created += 1
152-
153-
result = BulkSyncResponse(
154-
total=total, created=created, updated=updated, restored=restored
155-
)
156-
157-
logger.info(
158-
"Bulk sync completed",
159-
extra={
160-
"request_id": get_request_id(),
161-
"sync_total": total,
162-
"sync_created": created,
163-
"sync_updated": updated,
164-
"sync_restored": restored,
165-
},
149+
user = User(id=user_data.id, last_sync_at=now_utc())
150+
await self.repository.create(user)
151+
created += 1
152+
153+
except Exception:
154+
logger.exception(
155+
"Failed to sync user",
156+
extra={
157+
"request_id": get_request_id(),
158+
"user_id": getattr(user_data, "id", None),
159+
},
160+
)
161+
raise
162+
163+
return BulkSyncResponse(
164+
total=total,
165+
created=created,
166+
updated=updated,
167+
restored=restored,
166168
)
167169

168-
return result
169-
170170
async def delete_user(self, user_id: int) -> None:
171171
"""사용자 Soft Delete
172172

tests/conftest.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""테스트 설정"""
22

3+
import itertools
34
from typing import Generator
45

56
import pytest
@@ -11,9 +12,27 @@
1112

1213
from app.core.config import settings
1314
from app.core.database import Base, get_db
15+
from app.core.utils.datetime import now_utc
1416
from app.main import app
1517

1618

19+
@pytest.fixture(scope="session")
20+
def user_id_factory():
21+
"""
22+
테스트마다 겹치지 않는 PK를 만들기 위한 ID 팩토리.
23+
UTC 기준 현재 타임스탬프(ms)를 시작값으로 사용
24+
"""
25+
start = int(now_utc().timestamp())
26+
counter = itertools.count(start=start)
27+
28+
def _factory(n: int = 1):
29+
if n == 1:
30+
return next(counter)
31+
return [next(counter) for _ in range(n)]
32+
33+
return _factory
34+
35+
1736
@pytest.fixture(scope="session")
1837
def postgres_container() -> Generator[PostgresContainer, None, None]:
1938
"""PostgreSQL 테스트 컨테이너"""

tests/e2e/test_scenarios.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,9 @@ class TestUserSyncScenarios:
7474
"""사용자 동기화 E2E 시나리오 (DB 필요)"""
7575

7676
@pytest.mark.asyncio
77-
async def test_user_sync_full_lifecycle(self, client, api_key_header):
77+
async def test_user_sync_full_lifecycle(
78+
self, client, api_key_header, user_id_factory
79+
):
7880
"""사용자 동기화 전체 생명주기 테스트
7981
8082
시나리오:
@@ -86,7 +88,7 @@ async def test_user_sync_full_lifecycle(self, client, api_key_header):
8688
6. 복구된 사용자 조회 및 검증
8789
"""
8890
# 1. 새 사용자 동기화
89-
user_id = 10001
91+
user_id = user_id_factory()
9092
response = await client.post(
9193
"/api/v1/users",
9294
json={"id": user_id},
@@ -140,7 +142,9 @@ async def test_user_sync_full_lifecycle(self, client, api_key_header):
140142
assert response.json()["data"]["deleted_at"] is None
141143

142144
@pytest.mark.asyncio
143-
async def test_bulk_sync_scenario(self, client, api_key_header):
145+
async def test_bulk_sync_scenario(
146+
self, client, api_key_header, user_id_factory
147+
):
144148
"""벌크 동기화 시나리오 테스트
145149
146150
시나리오:
@@ -149,7 +153,7 @@ async def test_bulk_sync_scenario(self, client, api_key_header):
149153
3. 사용자 목록 조회로 확인
150154
"""
151155
# 1. 100명 사용자 생성
152-
user_ids = list(range(20001, 20101))
156+
user_ids = user_id_factory(n=100)
153157
users = [{"id": uid} for uid in user_ids]
154158

155159
response = await client.post(

tests/integration/test_users_api.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,34 @@ async def test_bulk_sync_valid_request_format(
143143

144144
assert response.status_code == 201
145145

146+
@pytest.mark.asyncio
147+
async def test_bulk_sync_persists_users(self, client, api_key_header):
148+
"""벌크 동기화 후 실제로 DB에 반영되는지 확인"""
149+
users = [{"id": 101}, {"id": 102}, {"id": 103}]
150+
151+
# When
152+
response = await client.post(
153+
"/api/v1/users/bulk", json={"users": users}, headers=api_key_header
154+
)
155+
156+
# Then
157+
assert response.status_code == 201
158+
data = response.json()["data"]
159+
assert data["created"] == 3
160+
assert data["total"] == 3
161+
162+
# 실제 조회로 검증
163+
list_response = await client.get(
164+
"/api/v1/users?page=1&size=100",
165+
headers=api_key_header,
166+
)
167+
assert list_response.status_code == 200
168+
169+
returned_ids = {u["id"] for u in list_response.json()["data"]}
170+
171+
for uid in [101, 102, 103]:
172+
assert uid in returned_ids
173+
146174

147175
class TestUserDeleteAPI:
148176
"""사용자 삭제 API 테스트"""

0 commit comments

Comments
 (0)