init: 클린 기반 auth 서버 설계
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
# collections / immutability 예시
|
||||
|
||||
이 문서는 [collections / immutability 기준](../../standards/language/collections-immutability.md)을 코드 예시로 확인하기 위한 자료입니다.
|
||||
핵심 기준은 컬렉션을 immutable-first로 다루고, 변경이 필요한 로컬 조립 단계가 끝나면 경계를 넘기기 전에 수정 불가 snapshot으로 고정하는 것입니다.
|
||||
|
||||
## 좋은 예시 1: 생성자에서 defensive copy
|
||||
|
||||
```java
|
||||
public class RolePolicy {
|
||||
|
||||
private final List<String> allowedRoles;
|
||||
|
||||
public RolePolicy(List<String> allowedRoles) {
|
||||
this.allowedRoles = List.copyOf(allowedRoles);
|
||||
}
|
||||
|
||||
public List<String> allowedRoles() {
|
||||
return allowedRoles;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 외부에서 넘긴 mutable list를 그대로 보관하지 않는다.
|
||||
- 내부 필드를 안정된 snapshot으로 고정한다.
|
||||
|
||||
## 좋은 예시 2: 상수성 데이터는 of 사용
|
||||
|
||||
```java
|
||||
private static final Set<String> PUBLIC_PATHS = Set.of(
|
||||
"/",
|
||||
"/login",
|
||||
"/swagger-ui.html"
|
||||
);
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 상수 컬렉션 의도가 분명하다.
|
||||
- `null`과 중복을 조기에 차단한다.
|
||||
|
||||
주의:
|
||||
|
||||
- 순서를 기대하면 `List.of`가 더 적합할 수 있다.
|
||||
|
||||
## 좋은 예시 3: stream 결과를 수정 불가로 고정
|
||||
|
||||
```java
|
||||
List<String> activeEmails = users.stream()
|
||||
.filter(User::isActive)
|
||||
.map(User::getEmail)
|
||||
.toList();
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 결과가 읽기 전용이라는 의도가 분명하다.
|
||||
- 후속 코드가 실수로 수정하지 못한다.
|
||||
|
||||
## 좋은 예시 4: mutable 조립 후 경계에서 snapshot
|
||||
|
||||
```java
|
||||
List<String> buildScopes(User user) {
|
||||
List<String> scopes = new ArrayList<>();
|
||||
scopes.add("profile");
|
||||
if (user.isAdmin()) {
|
||||
scopes.add("admin");
|
||||
}
|
||||
return List.copyOf(scopes);
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 로컬 조립 단계에서는 mutable 컬렉션을 실용적으로 사용한다.
|
||||
- 반환 시점에는 안정된 snapshot으로 바꾼다.
|
||||
|
||||
## 좋은 예시 5: 구체 mutable 결과가 필요하면 명시
|
||||
|
||||
```java
|
||||
List<UserDto> result = users.stream()
|
||||
.map(UserMapper::toDto)
|
||||
.collect(Collectors.toCollection(ArrayList::new));
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- mutable 결과가 필요하다는 점을 코드에 드러낸다.
|
||||
- `Collectors.toList()`의 mutability를 가정하지 않는다.
|
||||
|
||||
## 나쁜 예시 1: 내부 mutable collection 그대로 노출
|
||||
|
||||
```java
|
||||
public class UserGroup {
|
||||
|
||||
private final List<User> users = new ArrayList<>();
|
||||
|
||||
public List<User> getUsers() {
|
||||
return users;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 외부에서 내부 상태를 직접 수정할 수 있다.
|
||||
- 캡슐화가 깨진다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
public List<User> getUsers() {
|
||||
return List.copyOf(users);
|
||||
}
|
||||
```
|
||||
|
||||
## 나쁜 예시 2: unmodifiable view를 immutable로 착각
|
||||
|
||||
```java
|
||||
List<String> source = new ArrayList<>();
|
||||
source.add("A");
|
||||
|
||||
List<String> readOnly = Collections.unmodifiableList(source);
|
||||
source.add("B");
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- `readOnly`는 immutable snapshot이 아니라 view다.
|
||||
- `source`가 바뀌면 `readOnly`도 바뀐다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
List<String> readOnly = List.copyOf(source);
|
||||
```
|
||||
|
||||
## 나쁜 예시 3: null collection 반환
|
||||
|
||||
```java
|
||||
public List<Role> findRoles(Long userId) {
|
||||
if (userId == null) {
|
||||
return null;
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 호출자마다 null-check를 강요한다.
|
||||
- 컬렉션 결과의 계약이 흐려진다.
|
||||
|
||||
개선:
|
||||
|
||||
- empty list를 반환한다.
|
||||
- 또는 입력 자체를 경계에서 검증한다.
|
||||
|
||||
## 나쁜 예시 4: 순서를 기대하면서 Set.of 사용
|
||||
|
||||
```java
|
||||
Set<String> statuses = Set.of("NEW", "PROCESSING", "DONE");
|
||||
String first = statuses.iterator().next();
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- `Set.of` iteration order를 비즈니스 로직에 기대고 있다.
|
||||
- JVM 실행마다 순서가 달라질 수 있다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
List<String> statuses = List.of("NEW", "PROCESSING", "DONE");
|
||||
String first = statuses.getFirst();
|
||||
```
|
||||
|
||||
## 나쁜 예시 5: shallow immutability 오해
|
||||
|
||||
```java
|
||||
List<UserProfile> profiles = List.copyOf(sourceProfiles);
|
||||
profiles.get(0).changeNickname("new-name");
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 컬렉션은 수정 불가지만 원소는 mutable이라 상태가 바뀔 수 있다.
|
||||
- 공유 상태 안정성을 보장하지 못한다.
|
||||
|
||||
개선 방향:
|
||||
|
||||
- immutable element를 사용한다.
|
||||
- mutable element는 공유하지 않도록 복사하거나 변환한다.
|
||||
@@ -0,0 +1,137 @@
|
||||
# duplication 예시
|
||||
|
||||
## 좋은 예시 1: 같은 정책 중복은 private method로 추출
|
||||
|
||||
```java
|
||||
private String normalizeEmail(String rawEmail) {
|
||||
return rawEmail.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
public User register(String rawEmail) {
|
||||
String email = normalizeEmail(rawEmail);
|
||||
...
|
||||
}
|
||||
|
||||
public User login(String rawEmail) {
|
||||
String email = normalizeEmail(rawEmail);
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 같은 정책이다
|
||||
- 같은 이유로 바뀐다
|
||||
- 한 곳에서 수정 가능하다
|
||||
|
||||
## 좋은 예시 2: 외부 API 예외 번역 중복 추출
|
||||
|
||||
```java
|
||||
private InfrastructureException vaultFailure(String message, Exception cause) {
|
||||
return new InfrastructureException(
|
||||
InfrastructureErrorCode.VAULT_TRANSIT_FAILED,
|
||||
message,
|
||||
cause
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 기술 실패 번역 정책이 한 곳에 모인다
|
||||
- 누락/불일치 위험이 줄어든다
|
||||
|
||||
## 좋은 예시 3: 테스트는 중복을 일부 허용
|
||||
|
||||
```java
|
||||
@Test
|
||||
void registers_two_users() {
|
||||
User user1 = new User("alice");
|
||||
User user2 = new User("bob");
|
||||
|
||||
forum.register(user1);
|
||||
forum.register(user2);
|
||||
|
||||
assertTrue(forum.hasRegisteredUser(user1));
|
||||
assertTrue(forum.hasRegisteredUser(user2));
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- helper/loop보다 읽기 쉽다
|
||||
- 테스트 의도가 바로 드러난다
|
||||
|
||||
## 좋은 예시 4: 3회 이상 반복되는 mapper 규칙 추출
|
||||
|
||||
```java
|
||||
private ApiResult<Void> failureOf(ApplicationException exception) {
|
||||
return ApiResult.failure(exception.getCode(), exception.getMessage());
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 응답 실패 조립 규칙이 공통 정책이다
|
||||
- presentation 전반에서 같은 이유로 바뀔 가능성이 높다
|
||||
|
||||
## 나쁜 예시 1: 우연한 유사성을 억지로 공통화
|
||||
|
||||
```java
|
||||
public Object process(Object input, String mode, Map<String, Object> options) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 맥락이 다른 두세 개 흐름을 한 메서드로 억지로 합친다
|
||||
- 이름이 모호해지고 분기만 늘어난다
|
||||
- 이후 독립 진화가 어렵다
|
||||
|
||||
## 나쁜 예시 2: 레이어를 넘는 공통화
|
||||
|
||||
```java
|
||||
public final class CommonValidationUtil {
|
||||
public static void validateUser(User user, CreateUserRequest request, UserJpaEntity entity) {
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- domain/presentation/infrastructure 경계를 한 곳에 섞는다
|
||||
- 중복 제거보다 아키텍처 손상이 더 크다
|
||||
|
||||
## 나쁜 예시 3: 테스트를 너무 DRY하게 만들어 의미 숨김
|
||||
|
||||
```java
|
||||
private void registerAll(List<User> users) { ... }
|
||||
|
||||
@Test
|
||||
void registers_users() {
|
||||
registerAll(defaultUsers());
|
||||
assertAllRegistered(defaultUsers());
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 테스트 본문만 보면 실제 행위가 잘 드러나지 않는다
|
||||
- helper를 따라가야 해서 검증이 어려워진다
|
||||
|
||||
## 나쁜 예시 4: common 모듈로 너무 빨리 이동
|
||||
|
||||
```text
|
||||
common/
|
||||
StringUtils.java
|
||||
DateUtils.java
|
||||
ErrorUtils.java
|
||||
ValidationUtils.java
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- “중복 제거” 명분으로 소유권 없는 잡동사니 모듈이 된다
|
||||
- 진짜 공통인지, 그냥 아직 설계가 안 된 것인지 구분이 사라진다
|
||||
@@ -0,0 +1,213 @@
|
||||
# enum / constants 예시
|
||||
|
||||
이 문서는 [enum / constants 기준](../../standards/language/enum-constants.md)을 코드 예시로 확인하기 위한 자료입니다.
|
||||
핵심 기준은 고정된 의미 집합을 enum으로 표현하고, external code / display label / internal name을 섞지 않는 것입니다.
|
||||
|
||||
## 좋은 예시 1: 문자열 상수 대신 enum
|
||||
|
||||
```java
|
||||
public enum AuthProvider {
|
||||
LOCAL,
|
||||
GOOGLE,
|
||||
KAKAO
|
||||
}
|
||||
|
||||
if (user.getProvider() == AuthProvider.LOCAL) {
|
||||
// local login flow
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 고정된 값 집합을 타입으로 표현한다.
|
||||
- 오타와 매직 스트링 분기를 줄인다.
|
||||
|
||||
## 좋은 예시 2: external code를 명시적 필드로 분리
|
||||
|
||||
```java
|
||||
public enum AuthProvider {
|
||||
LOCAL("local"),
|
||||
GOOGLE("google"),
|
||||
KAKAO("kakao");
|
||||
|
||||
private final String code;
|
||||
|
||||
AuthProvider(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String code() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- `name()`에 외부 계약을 맡기지 않는다.
|
||||
- 내부 enum 이름 변경과 외부 계약을 분리할 수 있다.
|
||||
|
||||
## 좋은 예시 3: EnumSet 사용
|
||||
|
||||
```java
|
||||
private static final EnumSet<AuthProvider> SOCIAL_PROVIDERS =
|
||||
EnumSet.of(AuthProvider.GOOGLE, AuthProvider.KAKAO);
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- enum 집합이라는 의도가 직접 드러난다.
|
||||
- 비트 플래그나 일반 `Set`보다 타입 안전하고 목적에 맞다.
|
||||
|
||||
## 좋은 예시 4: EnumMap 사용
|
||||
|
||||
```java
|
||||
private final EnumMap<AuthProvider, OAuthClient> clients =
|
||||
new EnumMap<>(AuthProvider.class);
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- enum key 전용 자료구조라는 점이 명확하다.
|
||||
- 일반 `HashMap`보다 목적에 더 잘 맞는다.
|
||||
|
||||
## 좋은 예시 5: 진짜 상수만 상수로 둠
|
||||
|
||||
```java
|
||||
private static final Duration ACCESS_TOKEN_TTL = Duration.ofMinutes(30);
|
||||
private static final List<String> PUBLIC_PATHS = List.of("/", "/login");
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 값이 immutable이다.
|
||||
- `static final`뿐 아니라 실제 의미도 안정적이다.
|
||||
|
||||
## 나쁜 예시 1: ordinal 저장/분기
|
||||
|
||||
```java
|
||||
int providerCode = provider.ordinal();
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- enum 순서 변경이나 값 추가에 취약하다.
|
||||
- stable contract가 아니다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
String providerCode = provider.code();
|
||||
```
|
||||
|
||||
## 나쁜 예시 2: name/toString 문자열 비교
|
||||
|
||||
```java
|
||||
if (provider.name().equals("GOOGLE")) {
|
||||
// google login flow
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- enum 의미 비교를 문자열 비교로 내린다.
|
||||
- 타입 안전성이 사라진다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
if (provider == AuthProvider.GOOGLE) {
|
||||
// google login flow
|
||||
}
|
||||
```
|
||||
|
||||
## 나쁜 예시 3: 잡다한 constants class
|
||||
|
||||
```java
|
||||
public final class AppConstants {
|
||||
|
||||
public static final String PROVIDER_LOCAL = "LOCAL";
|
||||
public static final String PROVIDER_GOOGLE = "GOOGLE";
|
||||
public static final String PROVIDER_KAKAO = "KAKAO";
|
||||
|
||||
private AppConstants() {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 고정된 의미 집합을 타입으로 표현하지 않는다.
|
||||
- 문자열 오타와 분기 누락에 취약하다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
public enum AuthProvider {
|
||||
LOCAL,
|
||||
GOOGLE,
|
||||
KAKAO
|
||||
}
|
||||
```
|
||||
|
||||
## 나쁜 예시 4: mutable collection을 상수처럼 사용
|
||||
|
||||
```java
|
||||
private static final Set<String> PUBLIC_PATHS = new HashSet<>();
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- `static final`이어도 내부 상태는 바뀔 수 있다.
|
||||
- 진짜 상수라고 보기 어렵다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
private static final Set<String> PUBLIC_PATHS = Set.of("/", "/login");
|
||||
```
|
||||
|
||||
## 나쁜 예시 5: default로 enum 추가 누락 숨김
|
||||
|
||||
```java
|
||||
return switch (provider) {
|
||||
case LOCAL -> localHandler();
|
||||
default -> socialHandler();
|
||||
};
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 새 enum 값이 생겨도 의도치 않게 `default`에 흡수될 수 있다.
|
||||
- 분기 누락이 컴파일 시점에 드러나기 어렵다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
return switch (provider) {
|
||||
case LOCAL -> localHandler();
|
||||
case GOOGLE, KAKAO -> socialHandler();
|
||||
};
|
||||
```
|
||||
|
||||
## 나쁜 예시 6: null 회피용 UNKNOWN 남용
|
||||
|
||||
```java
|
||||
public enum AuthProvider {
|
||||
UNKNOWN,
|
||||
LOCAL,
|
||||
GOOGLE,
|
||||
KAKAO
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- `UNKNOWN`이 실제 비즈니스 상태가 아니라면 의미 없는 상태가 생긴다.
|
||||
- 단순 null 회피가 enum 모델에 섞인다.
|
||||
|
||||
개선 방향:
|
||||
|
||||
- boundary 입력은 검증하거나 nullable로 명시한다.
|
||||
- 조회 결과의 부재는 필요하면 `Optional<AuthProvider>`로 표현한다.
|
||||
- 실제 비즈니스 상태일 때만 `UNKNOWN` 또는 `UNSPECIFIED`를 둔다.
|
||||
@@ -0,0 +1,216 @@
|
||||
# exceptions 예시
|
||||
|
||||
이 문서는 [exceptions 기준](../../standards/language/exceptions.md)을 코드 예시로 확인하기 위한 자료입니다.
|
||||
핵심 기준은 예외를 정상 흐름 제어가 아니라 예외 상황 전달 수단으로 사용하고, catch는 번역 / 문맥 추가 / 복구 목적이 있을 때만 두는 것입니다.
|
||||
|
||||
## 좋은 예시 1: 기술 예외를 계층 예외로 번역하면서 cause 보존
|
||||
|
||||
```java
|
||||
try {
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
return parse(response.body());
|
||||
} catch (IOException exception) {
|
||||
throw new InfrastructureException(
|
||||
InfrastructureErrorCode.EXTERNAL_API_FAILED,
|
||||
"Failed to call external API",
|
||||
exception
|
||||
);
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new InfrastructureException(
|
||||
InfrastructureErrorCode.EXTERNAL_API_FAILED,
|
||||
"External API call was interrupted",
|
||||
exception
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- broad catch가 아니다.
|
||||
- `InterruptedException`을 별도로 처리한다.
|
||||
- cause를 보존한다.
|
||||
- 기술 실패를 infrastructure 의미로 번역한다.
|
||||
|
||||
## 좋은 예시 2: try-with-resources 사용
|
||||
|
||||
```java
|
||||
try (InputStream in = Files.newInputStream(path)) {
|
||||
return objectMapper.readValue(in, Payload.class);
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 자원 해제를 자동화한다.
|
||||
- close 중 예외가 발생해도 suppressed exception으로 보존될 수 있다.
|
||||
|
||||
## 좋은 예시 3: 상위 경계에서만 broad catch
|
||||
|
||||
```java
|
||||
try {
|
||||
return useCase.execute(command);
|
||||
} catch (ApplicationException exception) {
|
||||
return errorResponse(exception.getCode(), exception.getMessage());
|
||||
} catch (Exception exception) {
|
||||
log.error("Unhandled exception while processing request", exception);
|
||||
return errorResponse("INTERNAL_SERVER_ERROR", "Unexpected server error");
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 최상위 boundary에서 마지막 방어선으로만 broad catch를 쓴다.
|
||||
- 내부 계층에서는 더 구체적인 예외 처리를 유지한다.
|
||||
- 예상 가능한 application 예외와 예상하지 못한 실패를 구분한다.
|
||||
|
||||
## 좋은 예시 4: checked 예외 rollback 필요 시 명시
|
||||
|
||||
```java
|
||||
@Transactional(rollbackFor = IOException.class)
|
||||
public void importUsers(Path path) throws IOException {
|
||||
// import users from file
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- Spring 기본 rollback 규칙을 명시적으로 보완한다.
|
||||
- checked exception이 rollback 대상인지 계약으로 드러난다.
|
||||
|
||||
## 좋은 예시 5: 테스트는 assertThrows 우선
|
||||
|
||||
```java
|
||||
IllegalArgumentException exception = assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> service.createUser(command)
|
||||
);
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- `try-catch + fail` 패턴보다 의도가 직접적이다.
|
||||
- 예외 객체를 받아 메시지나 상태를 추가로 검증할 수 있다.
|
||||
|
||||
## 나쁜 예시 1: broad catch + 삼키기
|
||||
|
||||
```java
|
||||
try {
|
||||
saveUser(user);
|
||||
} catch (Exception exception) {
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 예외가 사라진다.
|
||||
- 디버깅이 어려워진다.
|
||||
- interruption 같은 중요한 신호도 놓칠 수 있다.
|
||||
|
||||
개선 방향:
|
||||
|
||||
- 복구할 수 있는 구체 예외만 catch한다.
|
||||
- 계층 예외로 번역하거나, 문맥을 붙여 다시 던진다.
|
||||
- 정말 무시해야 한다면 이유를 남기고 logging / metrics / 상태 기록 중 하나를 수행한다.
|
||||
|
||||
## 나쁜 예시 2: printStackTrace 후 계속 진행
|
||||
|
||||
```java
|
||||
try {
|
||||
sync();
|
||||
} catch (IOException exception) {
|
||||
exception.printStackTrace();
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 운영 로그 정책을 깨뜨린다.
|
||||
- 실패를 구조적으로 전달하지 못한다.
|
||||
|
||||
개선 방향:
|
||||
|
||||
- logging framework로 기록한다.
|
||||
- 또는 계층 예외로 번역해 상위 boundary로 전달한다.
|
||||
|
||||
## 나쁜 예시 3: finally에서 return
|
||||
|
||||
```java
|
||||
try {
|
||||
return load();
|
||||
} finally {
|
||||
return fallback();
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- try 블록 결과와 예외를 덮어쓴다.
|
||||
- 실제 실패가 호출자에게 전달되지 않을 수 있다.
|
||||
|
||||
개선 방향:
|
||||
|
||||
- `finally`는 정리 작업만 수행한다.
|
||||
- fallback이 필요하면 catch나 명시적 분기에서 처리한다.
|
||||
|
||||
## 나쁜 예시 4: InterruptedException 뭉개기
|
||||
|
||||
```java
|
||||
try {
|
||||
queue.take();
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException(exception);
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- interruption을 별도 의미로 처리하지 않는다.
|
||||
- 스레드 인터럽트 상태를 잃을 수 있다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
try {
|
||||
queue.take();
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Interrupted while waiting for queue item", exception);
|
||||
}
|
||||
```
|
||||
|
||||
## 나쁜 예시 5: 너무 넓은 일반 예외 던지기
|
||||
|
||||
```java
|
||||
throw new RuntimeException("bad request");
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 의미가 너무 넓다.
|
||||
- 호출자가 어떤 실패인지 이해하기 어렵다.
|
||||
- 경계에서 일관된 에러 코드나 응답으로 번역하기 어렵다.
|
||||
|
||||
개선 방향:
|
||||
|
||||
- 계약 위반이면 `IllegalArgumentException` 같은 구체 예외를 사용한다.
|
||||
- 계층 의미가 있으면 domain / application / infrastructure 예외로 표현한다.
|
||||
|
||||
## 나쁜 예시 6: 정상적인 결과 없음에 예외 사용
|
||||
|
||||
```java
|
||||
public User findUser(Long userId) {
|
||||
return userRepository.findById(userId)
|
||||
.orElseThrow(() -> new RuntimeException("user not found"));
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 결과 없음이 정상적인 조회 결과일 수 있는데 예외로만 표현한다.
|
||||
- 호출자가 부재를 처리할 수 있는 선택지를 잃는다.
|
||||
|
||||
개선 방향:
|
||||
|
||||
- 단건 조회의 부재가 정상 흐름이면 `Optional<User>`를 반환한다.
|
||||
- 유스케이스 계약상 반드시 있어야 하는 값이면 구체적인 application 예외로 번역한다.
|
||||
@@ -0,0 +1,164 @@
|
||||
# Javadoc 예시
|
||||
|
||||
## 좋은 예시 1: 반환 계약과 예외 조건이 드러나는 메서드
|
||||
|
||||
```java
|
||||
/**
|
||||
* Returns the active user for the given email.
|
||||
*
|
||||
* @param email normalized user email, never {@code null}
|
||||
* @return the matching active user
|
||||
* @throws UserNotFoundException if no user exists for the given email
|
||||
* @throws InactiveUserException if the user exists but is inactive
|
||||
*/
|
||||
public User getActiveUserByEmail(String email) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 호출자가 믿을 수 있는 계약이 보인다
|
||||
- null 허용 여부와 실패 조건이 드러난다
|
||||
- 구현 세부가 아니라 API 의미를 설명한다
|
||||
|
||||
## 좋은 예시 2: value object 생성 제약 문서화
|
||||
|
||||
```java
|
||||
/**
|
||||
* Value object representing a normalized email address.
|
||||
*
|
||||
* <p>The value is always lowercase and trimmed.
|
||||
*/
|
||||
public record UserEmail(String value) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 타입의 핵심 invariant를 문서화한다
|
||||
- typical reader가 놓치기 쉬운 제약을 설명한다
|
||||
|
||||
## 좋은 예시 3: override는 문서 상속 활용
|
||||
|
||||
```java
|
||||
@Override
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 상위 계약이 충분하면 중복 문서를 쓰지 않는다
|
||||
- 불필요한 복붙 Javadoc을 줄인다
|
||||
|
||||
## 좋은 예시 4: package/class 수준에서 구조 설명
|
||||
|
||||
```java
|
||||
/**
|
||||
* HTTP request/response contracts and exception translation for the auth API.
|
||||
*
|
||||
* <p>This package owns controllers, request/response DTOs, and client-facing
|
||||
* error handling. It must not depend directly on infrastructure implementations.
|
||||
*/
|
||||
package com.project.auth.presentation;
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- package 책임과 금지사항이 드러난다
|
||||
- architecture 문서와 연결되는 설명이다
|
||||
|
||||
## 나쁜 예시 1: 자명한 getter 설명
|
||||
|
||||
```java
|
||||
/**
|
||||
* Returns the user name.
|
||||
*/
|
||||
public String getUserName() {
|
||||
return userName;
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 이름만 읽어도 알 수 있다
|
||||
- 유지보수 시 stale 될 가능성만 늘어난다
|
||||
|
||||
개선:
|
||||
|
||||
- 생략하거나
|
||||
- 정말 추가 계약이 있을 때만 적는다
|
||||
|
||||
## 나쁜 예시 2: 구현 설명만 적음
|
||||
|
||||
```java
|
||||
/**
|
||||
* Uses ArrayList internally and loops over all elements to find the user.
|
||||
*/
|
||||
public User findUser(String email) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 구현 세부에 과도하게 묶인다
|
||||
- 리팩터링 시 쉽게 거짓 문서가 된다
|
||||
|
||||
개선:
|
||||
|
||||
- 호출 계약, 검색 조건, 실패 조건을 설명한다
|
||||
|
||||
## 나쁜 예시 3: 태그만 채우는 문서
|
||||
|
||||
```java
|
||||
/**
|
||||
* @param email the email
|
||||
* @return the user
|
||||
*/
|
||||
public User findUser(String email) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 독자에게 새로운 정보가 없다
|
||||
- 형식만 있고 계약이 없다
|
||||
|
||||
개선:
|
||||
|
||||
- summary와 제약/의미를 써라
|
||||
- 아니면 생략하라
|
||||
|
||||
## 나쁜 예시 4: stale Javadoc 방치
|
||||
|
||||
```java
|
||||
/**
|
||||
* Returns a mutable list of authorities.
|
||||
*/
|
||||
public List<String> getAuthorities() {
|
||||
return List.copyOf(authorities);
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 코드와 문서가 충돌한다
|
||||
- 거짓 문서가 된다
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
/**
|
||||
* Returns an unmodifiable snapshot of authorities.
|
||||
*/
|
||||
public List<String> getAuthorities() {
|
||||
return List.copyOf(authorities);
|
||||
}
|
||||
```
|
||||
|
||||
또는 Javadoc 삭제 후 더 적절한 형태로 재작성
|
||||
@@ -0,0 +1,140 @@
|
||||
# null 처리 예시
|
||||
|
||||
이 문서는 [null 처리 기준](../../standards/language/null.md)을 코드 예시로 확인하기 위한 자료입니다.
|
||||
핵심 기준은 `null`을 경계에서만 제한적으로 받고, 내부 로직에 들어가기 전에 non-null 값이나 명시적 상태로 정리하는 것입니다.
|
||||
|
||||
## 좋은 예시 1: 생성자/경계에서 즉시 검증
|
||||
|
||||
```java
|
||||
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
|
||||
this.userRepository = Objects.requireNonNull(userRepository, "userRepository must not be null");
|
||||
this.passwordEncoder = Objects.requireNonNull(passwordEncoder, "passwordEncoder must not be null");
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- boundary에서 non-null 계약을 바로 강제한다.
|
||||
- 내부 필드는 이후 non-null로 다룰 수 있다.
|
||||
|
||||
## 좋은 예시 2: 외부 입력은 DTO에서 받고 내부에서 정리
|
||||
|
||||
```java
|
||||
public CreateUserCommand toCommand(CreateUserRequest request) {
|
||||
return new CreateUserCommand(
|
||||
UserEmail.from(request.email()),
|
||||
request.nickname() == null ? null : request.nickname().trim()
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
더 좋은 경우:
|
||||
|
||||
- nullable `nickname`을 value object 또는 명시적 규칙으로 바로 정리한다.
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- nullable 입력이 boundary에 머문다.
|
||||
- 내부 의미로 들어가기 전에 정리할 수 있다.
|
||||
|
||||
## 좋은 예시 3: 컬렉션은 null 대신 empty 반환
|
||||
|
||||
```java
|
||||
public List<Role> findRoles(Long userId) {
|
||||
List<Role> roles = roleRepository.findAllByUserId(userId);
|
||||
return roles == null ? List.of() : roles;
|
||||
}
|
||||
```
|
||||
|
||||
더 좋은 경우:
|
||||
|
||||
- repository 계약 자체를 null이 아닌 empty 반환으로 고정한다.
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 호출자가 불필요한 null-check를 하지 않아도 된다.
|
||||
|
||||
## 좋은 예시 4: persistence -> domain 변환에서 nullable 차단
|
||||
|
||||
```java
|
||||
public User toDomain(UserJpaEntity entity) {
|
||||
return User.restore(
|
||||
Objects.requireNonNull(entity.getId(), "id must not be null"),
|
||||
UserEmail.from(Objects.requireNonNull(entity.getEmail(), "email must not be null")),
|
||||
Objects.requireNonNull(entity.getEncodedPassword(), "encodedPassword must not be null"),
|
||||
UserName.from(Objects.requireNonNull(entity.getName(), "name must not be null"))
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- DB nullable/오염 상태를 domain으로 전파하지 않는다.
|
||||
- invariant 경계가 분명하다.
|
||||
|
||||
## 나쁜 예시 1: Optional과 null 혼용
|
||||
|
||||
```java
|
||||
public Optional<User> findByEmail(String email) {
|
||||
if (email == null) {
|
||||
return null;
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- `Optional` 반환 계약을 깨뜨린다.
|
||||
- 호출자는 `Optional`과 `null`을 동시에 처리해야 한다.
|
||||
|
||||
개선:
|
||||
|
||||
- null 입력 자체를 검증한다.
|
||||
- 또는 `Optional.empty()`를 반환한다.
|
||||
- 또는 파라미터를 non-null로 강제한다.
|
||||
|
||||
## 나쁜 예시 2: null을 business 의미로 사용
|
||||
|
||||
```java
|
||||
if (user.getProvider() == null) {
|
||||
// local user
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- business state가 `null`에 숨는다.
|
||||
- 의미가 타입으로 드러나지 않는다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
if (user.getProvider() == AuthProvider.LOCAL) {
|
||||
// local user
|
||||
}
|
||||
```
|
||||
|
||||
또는 명시적 enum/state를 사용한다.
|
||||
|
||||
## 나쁜 예시 3: 여러 계층으로 nullable 전파
|
||||
|
||||
```java
|
||||
public String handle(String nickname) {
|
||||
return service.process(nickname);
|
||||
}
|
||||
|
||||
public String process(String nickname) {
|
||||
return repository.saveNickname(nickname);
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- nullable 여부가 계약으로 명시되지 않는다.
|
||||
- 모든 계층이 방어 책임을 떠넘긴다.
|
||||
|
||||
개선:
|
||||
|
||||
- boundary에서 검증/정규화한다.
|
||||
- nullable이면 `Optional`, value object, 명시적 command로 변환한다.
|
||||
@@ -0,0 +1,183 @@
|
||||
# Optional 사용 예시
|
||||
|
||||
이 문서는 [Optional 사용 기준](../../standards/language/optional.md)을 코드 예시로 확인하기 위한 자료입니다.
|
||||
핵심 기준은 `Optional`을 값의 부재가 가능한 단건 반환 타입에 쓰고, 필드/파라미터/직렬화 경계에는 기본적으로 쓰지 않는 것입니다.
|
||||
|
||||
## 좋은 예시 1: 단건 조회 결과 없음 표현
|
||||
|
||||
```java
|
||||
public Optional<User> findByEmail(String email) {
|
||||
return userRepository.findByEmail(email);
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 단건 조회 결과의 부재를 반환 타입에서 명시한다.
|
||||
- 호출자에게 "없을 수 있음"을 강제한다.
|
||||
|
||||
## 좋은 예시 2: transform chain
|
||||
|
||||
```java
|
||||
public Optional<String> findActiveUserEmail(Long userId) {
|
||||
return userRepository.findById(userId)
|
||||
.filter(User::isActive)
|
||||
.map(User::getEmail);
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- `isPresent() + get()` 없이 선언적으로 표현한다.
|
||||
- 값이 없으면 자연스럽게 empty로 전파된다.
|
||||
|
||||
## 좋은 예시 3: nested Optional 방지
|
||||
|
||||
```java
|
||||
public Optional<Token> resolveToken(Long userId) {
|
||||
return userRepository.findById(userId)
|
||||
.flatMap(tokenService::findValidToken);
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- `flatMap`으로 `Optional<Optional<Token>>`를 만들지 않는다.
|
||||
|
||||
## 좋은 예시 4: expensive default는 orElseGet
|
||||
|
||||
```java
|
||||
UserProfile profile = profileRepository.findByUserId(userId)
|
||||
.orElseGet(() -> profileFactory.createDefault(userId));
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 기본값 생성 비용이 있을 때 lazy supplier를 사용한다.
|
||||
|
||||
## 좋은 예시 5: 단건은 Optional, 다건은 빈 컬렉션
|
||||
|
||||
```java
|
||||
public List<Role> findRoles(Long userId) {
|
||||
return roleRepository.findAllByUserId(userId);
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 다건 결과의 부재를 `Optional<List<Role>>`로 감싸지 않는다.
|
||||
- 호출자는 빈 컬렉션으로 처리하면 된다.
|
||||
|
||||
## 나쁜 예시 1: Optional 반환인데 null 반환
|
||||
|
||||
```java
|
||||
public Optional<User> findByEmail(String email) {
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- `Optional` 자체가 `null`이 되어 의미가 깨진다.
|
||||
- 호출자는 `Optional`과 `null`을 둘 다 처리해야 한다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
public Optional<User> findByEmail(String email) {
|
||||
return Optional.empty();
|
||||
}
|
||||
```
|
||||
|
||||
## 나쁜 예시 2: 필드에 Optional 저장
|
||||
|
||||
```java
|
||||
public class UserResponse {
|
||||
private Optional<String> nickname;
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- DTO 경계에서 표현이 복잡해진다.
|
||||
- 직렬화/스키마/API 계약이 불명확해질 수 있다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
public class UserResponse {
|
||||
private String nickname;
|
||||
}
|
||||
```
|
||||
|
||||
또는 nullable 여부를 API 계약에서 명시한다.
|
||||
|
||||
## 나쁜 예시 3: 파라미터에 Optional 사용
|
||||
|
||||
```java
|
||||
public User createUser(Optional<String> nickname) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 호출자가 `Optional.empty()`와 `null` 실수를 섞기 쉽다.
|
||||
- 오버로드/명시적 request object보다 의도가 약하다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
public User createUser(String nickname) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
또는:
|
||||
|
||||
```java
|
||||
public User createUser(CreateUserCommand command) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## 나쁜 예시 4: isPresent + get
|
||||
|
||||
```java
|
||||
if (userOpt.isPresent()) {
|
||||
return userOpt.get().getEmail();
|
||||
}
|
||||
return "unknown";
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- imperative null-check와 다를 바 없는 패턴이다.
|
||||
- `get()` 의존이 생긴다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
return userOpt.map(User::getEmail)
|
||||
.orElse("unknown");
|
||||
```
|
||||
|
||||
## 나쁜 예시 5: map 결과를 안 쓰고 side-effect
|
||||
|
||||
```java
|
||||
userOpt.map(user -> {
|
||||
audit(user.getId());
|
||||
return user;
|
||||
});
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- `map`은 값 변환인데 반환값을 사용하지 않는다.
|
||||
- side-effect 목적이면 `ifPresent`가 더 맞다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
userOpt.ifPresent(user -> audit(user.getId()));
|
||||
```
|
||||
@@ -0,0 +1,150 @@
|
||||
# Stream 사용 예시
|
||||
|
||||
이 문서는 [Stream 사용 기준](../../standards/language/stream.md)을 코드 예시로 확인하기 위한 자료입니다.
|
||||
핵심 기준은 Stream을 집계, 변환, 검색 파이프라인에 쓰고, 외부 상태 변경이나 필수 부작용에는 쓰지 않는 것입니다.
|
||||
|
||||
## 좋은 예시 1: 조회 + 변환 + 불변 결과
|
||||
|
||||
```java
|
||||
List<String> activeEmails = users.stream()
|
||||
.filter(User::isActive)
|
||||
.map(User::getEmail)
|
||||
.toList();
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 집계/변환 파이프라인이다.
|
||||
- side-effect가 없다.
|
||||
- 결과가 명확하다.
|
||||
- 읽는 사람이 "무엇을 만들었는지" 바로 이해할 수 있다.
|
||||
|
||||
## 좋은 예시 2: 존재 여부 판단
|
||||
|
||||
```java
|
||||
boolean hasExpiredToken = tokens.stream()
|
||||
.anyMatch(Token::isExpired);
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- for 루프보다 의도가 직접적이다.
|
||||
- short-circuit terminal operation이라 불필요한 순회를 줄일 수 있다.
|
||||
|
||||
## 좋은 예시 3: 그룹화
|
||||
|
||||
```java
|
||||
Map<AuthProvider, List<User>> usersByProvider = users.stream()
|
||||
.collect(Collectors.groupingBy(User::getProvider));
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- grouping이 핵심인 aggregate operation이다.
|
||||
- 외부 mutable map을 직접 관리하지 않는다.
|
||||
|
||||
## 좋은 예시 4: 숫자 집계는 primitive stream 사용
|
||||
|
||||
```java
|
||||
int totalQuantity = orderLines.stream()
|
||||
.mapToInt(OrderLine::getQuantity)
|
||||
.sum();
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 숫자 집계 의도가 분명하다.
|
||||
- boxed `Integer` stream보다 표현이 명확하다.
|
||||
|
||||
## 좋은 예시 5: 구체 컬렉션 타입이 필요할 때만 toCollection
|
||||
|
||||
```java
|
||||
LinkedHashSet<String> roles = authorities.stream()
|
||||
.map(Authority::getRole)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 결과 타입 요구사항이 있을 때만 명시적으로 선택한다.
|
||||
- `Collectors.toList()`의 구현/가변성에 기대지 않는다.
|
||||
|
||||
## 좋은 예시 6: I/O 기반 stream은 닫기
|
||||
|
||||
```java
|
||||
try (Stream<String> lines = Files.lines(path)) {
|
||||
List<String> words = lines
|
||||
.flatMap(line -> Stream.of(line.split("\\s+")))
|
||||
.filter(word -> !word.isBlank())
|
||||
.toList();
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- I/O 기반 stream을 명시적으로 닫는다.
|
||||
- transform pipeline과 resource lifecycle이 분리되어 있다.
|
||||
|
||||
## 나쁜 예시 1: 외부 mutable accumulator 사용
|
||||
|
||||
```java
|
||||
List<String> emails = new ArrayList<>();
|
||||
users.stream()
|
||||
.filter(User::isActive)
|
||||
.map(User::getEmail)
|
||||
.forEach(emails::add);
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 불필요한 side-effect가 있다.
|
||||
- 병렬화나 리팩터링에 취약하다.
|
||||
- reduction/collection으로 더 안전하게 표현할 수 있다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
List<String> emails = users.stream()
|
||||
.filter(User::isActive)
|
||||
.map(User::getEmail)
|
||||
.toList();
|
||||
```
|
||||
|
||||
## 나쁜 예시 2: 상태를 가진 람다
|
||||
|
||||
```java
|
||||
AtomicInteger seq = new AtomicInteger(0);
|
||||
|
||||
List<UserView> result = users.stream()
|
||||
.map(user -> new UserView(seq.incrementAndGet(), user.getEmail()))
|
||||
.toList();
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 람다가 외부 상태에 의존한다.
|
||||
- 병렬 스트림이나 리팩터링 시 의미가 불안정하다.
|
||||
|
||||
개선 방향:
|
||||
|
||||
- 순번이 비즈니스적으로 필요하면 스트림 밖에서 명시적으로 설계한다.
|
||||
- 단순 변환이면 순번 생성을 제거한다.
|
||||
|
||||
## 나쁜 예시 3: 비즈니스 로직에서 peek 사용
|
||||
|
||||
```java
|
||||
List<User> result = users.stream()
|
||||
.peek(user -> audit("USER_READ", user.getId()))
|
||||
.filter(User::isActive)
|
||||
.toList();
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- `peek`를 필수 부작용 채널로 쓰고 있다.
|
||||
- 최적화/재구성 시 기대가 깨질 수 있다.
|
||||
|
||||
개선 방향:
|
||||
|
||||
- audit가 필수라면 terminal boundary에서 명시적으로 처리한다.
|
||||
- `peek`는 임시 디버깅 용도로만 제한한다.
|
||||
@@ -0,0 +1,221 @@
|
||||
# time 타입 / 포맷 예시
|
||||
|
||||
이 문서는 [time 타입 / 포맷 기준](../../standards/language/time.md)을 코드 예시로 확인하기 위한 자료입니다.
|
||||
핵심 기준은 시간 값을 문자열이나 숫자로 들고 다니지 않고, 시점 / 날짜 / 시각 / 기간 / 시간대 의미에 맞는 `java.time` 타입으로 표현하는 것입니다.
|
||||
|
||||
## 좋은 예시 1: event timestamp는 Instant
|
||||
|
||||
```java
|
||||
public record AuditEvent(
|
||||
String action,
|
||||
Long userId,
|
||||
Instant occurredAt
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- timeline 위 한 점을 명확하게 표현한다.
|
||||
- 로깅, 저장, 비교에 적합하다.
|
||||
|
||||
## 좋은 예시 2: 사람 기준 날짜 의미는 LocalDate / YearMonth
|
||||
|
||||
```java
|
||||
public record UserProfile(
|
||||
LocalDate birthDate,
|
||||
YearMonth cardExpiry
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 시간대와 무관한 사람 기준 날짜 의미를 타입으로 드러낸다.
|
||||
- 생일과 카드 만료월처럼 서로 다른 날짜 의미를 구분한다.
|
||||
|
||||
## 좋은 예시 3: 현재 시각은 Clock 기반
|
||||
|
||||
```java
|
||||
public class TokenIssuer {
|
||||
|
||||
private final Clock clock;
|
||||
|
||||
public TokenIssuer(Clock clock) {
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public Instant issueTime() {
|
||||
return Instant.now(clock);
|
||||
}
|
||||
|
||||
public Instant expiryTime(Duration ttl) {
|
||||
return Instant.now(clock).plus(ttl);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 현재 시각을 고정해 테스트하기 쉽다.
|
||||
- static `now()` 호출이 코드 곳곳에 흩어지지 않는다.
|
||||
|
||||
## 좋은 예시 4: 시간 간격은 Duration
|
||||
|
||||
```java
|
||||
private static final Duration ACCESS_TOKEN_TTL = Duration.ofMinutes(30);
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- `1800` 같은 매직 숫자보다 의미가 분명하다.
|
||||
- 초, 밀리초, 분 단위 혼동이 줄어든다.
|
||||
|
||||
## 좋은 예시 5: 외부 응답 포맷은 경계에서 처리
|
||||
|
||||
```java
|
||||
String value = DateTimeFormatter.ISO_INSTANT.format(event.occurredAt());
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 내부 로직은 `Instant`를 유지한다.
|
||||
- 문자열 포맷은 serialization, logging, external API adapter 같은 boundary에서만 수행한다.
|
||||
|
||||
## 좋은 예시 6: 실제 zone 계산이 필요할 때만 ZonedDateTime
|
||||
|
||||
```java
|
||||
ZonedDateTime reservationTime = localReservationTime.atZone(ZoneId.of("Asia/Seoul"));
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 서울 지역 wall-clock 시간이라는 의미가 필요할 때만 zone을 붙인다.
|
||||
- 시간대 규칙이 필요한 계산임을 코드에 드러낸다.
|
||||
|
||||
## 나쁜 예시 1: createdAt을 LocalDateTime으로 저장
|
||||
|
||||
```java
|
||||
private LocalDateTime createdAt;
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 절대 시점이 아니라 zone/offset 없는 wall-clock 값이 된다.
|
||||
- 시스템 간 교환, 저장, 비교에서 의미가 흔들린다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
private Instant createdAt;
|
||||
```
|
||||
|
||||
## 나쁜 예시 2: business logic에서 기본 시스템 zone 의존
|
||||
|
||||
```java
|
||||
LocalDate today = LocalDate.now();
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- JVM 기본 time-zone에 암묵적으로 의존한다.
|
||||
- 테스트와 운영 환경에 따라 결과가 달라질 수 있다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
LocalDate today = LocalDate.now(clock);
|
||||
```
|
||||
|
||||
또는:
|
||||
|
||||
```java
|
||||
LocalDate today = LocalDate.now(zoneId);
|
||||
```
|
||||
|
||||
## 나쁜 예시 3: 문자열로 시간 비교
|
||||
|
||||
```java
|
||||
if (request.startTime().compareTo("09:00") >= 0) {
|
||||
// open
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 타입 의미가 사라진다.
|
||||
- 포맷 변화에 취약하다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
if (!request.startTime().isBefore(LocalTime.of(9, 0))) {
|
||||
// open
|
||||
}
|
||||
```
|
||||
|
||||
## 나쁜 예시 4: legacy API 사용
|
||||
|
||||
```java
|
||||
Date now = new Date();
|
||||
Timestamp expiresAt = new Timestamp(System.currentTimeMillis() + 1_800_000);
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 새 코드 기준으로 `java.time`보다 의미가 덜 명확하다.
|
||||
- 시간 단위와 시스템 clock 의존이 코드에 흩어진다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
Instant now = Instant.now(clock);
|
||||
Instant expiresAt = now.plus(Duration.ofMinutes(30));
|
||||
```
|
||||
|
||||
## 나쁜 예시 5: wall-clock 의미인데 Instant 남용
|
||||
|
||||
```java
|
||||
public record StoreHours(
|
||||
Instant opensAt,
|
||||
Instant closesAt
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 영업 시작/종료는 보통 지역 wall-clock 의미다.
|
||||
- 절대 시점 타입이 도메인 의미를 흐린다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
public record StoreHours(
|
||||
LocalTime opensAt,
|
||||
LocalTime closesAt
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
## 나쁜 예시 6: Instant를 DTO에서 문자열로 직접 조립
|
||||
|
||||
```java
|
||||
public record TokenResponse(
|
||||
String expiresAt
|
||||
) {
|
||||
public static TokenResponse from(Instant expiresAt) {
|
||||
return new TokenResponse(expiresAt.toString());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- DTO 조립 코드가 시간 포맷 정책을 직접 가진다.
|
||||
- 응답 포맷 변경이 여러 DTO 생성 코드로 퍼질 수 있다.
|
||||
|
||||
개선 방향:
|
||||
|
||||
- response serialization 설정이나 전용 formatter 경계에서 포맷한다.
|
||||
- 내부 모델과 유스케이스 결과는 `Instant` 같은 typed value를 유지한다.
|
||||
Reference in New Issue
Block a user