init: 클린 기반 auth 서버 설계

This commit is contained in:
DongHyeonka
2026-07-24 14:30:18 +09:00
parent 471db0203d
commit 8a1ac1e769
3642 changed files with 275893 additions and 1 deletions
@@ -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는 공유하지 않도록 복사하거나 변환한다.
+137
View File
@@ -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
```
문제:
- “중복 제거” 명분으로 소유권 없는 잡동사니 모듈이 된다
- 진짜 공통인지, 그냥 아직 설계가 안 된 것인지 구분이 사라진다
+213
View File
@@ -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`를 둔다.
+216
View File
@@ -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 예외로 번역한다.
+164
View File
@@ -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 삭제 후 더 적절한 형태로 재작성
+140
View File
@@ -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로 변환한다.
+183
View File
@@ -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()));
```
+150
View File
@@ -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`는 임시 디버깅 용도로만 제한한다.
+221
View File
@@ -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를 유지한다.