@su-record/vibe 0.4.4 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agent/rules/languages/go.md +396 -0
- package/.agent/rules/languages/java-spring.md +586 -0
- package/.agent/rules/languages/kotlin-android.md +491 -0
- package/.agent/rules/languages/python-django.md +371 -0
- package/.agent/rules/languages/rust.md +425 -0
- package/.agent/rules/languages/swift-ios.md +516 -0
- package/.agent/rules/languages/typescript-node.md +375 -0
- package/.agent/rules/languages/typescript-vue.md +353 -0
- package/bin/vibe +140 -24
- package/package.json +1 -1
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
# ☕ Java + Spring Boot 품질 규칙
|
|
2
|
+
|
|
3
|
+
## 핵심 원칙 (core에서 상속)
|
|
4
|
+
|
|
5
|
+
```markdown
|
|
6
|
+
✅ 단일 책임 (SRP)
|
|
7
|
+
✅ 중복 제거 (DRY)
|
|
8
|
+
✅ 재사용성
|
|
9
|
+
✅ 낮은 복잡도
|
|
10
|
+
✅ 메서드 ≤ 30줄
|
|
11
|
+
✅ 중첩 ≤ 3단계
|
|
12
|
+
✅ Cyclomatic complexity ≤ 10
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Spring Boot 특화 규칙
|
|
16
|
+
|
|
17
|
+
### 1. Controller 레이어
|
|
18
|
+
|
|
19
|
+
```java
|
|
20
|
+
// ✅ REST Controller
|
|
21
|
+
@RestController
|
|
22
|
+
@RequestMapping("/api/v1/users")
|
|
23
|
+
@RequiredArgsConstructor
|
|
24
|
+
@Tag(name = "User", description = "사용자 관리 API")
|
|
25
|
+
public class UserController {
|
|
26
|
+
|
|
27
|
+
private final UserService userService;
|
|
28
|
+
|
|
29
|
+
@GetMapping
|
|
30
|
+
@Operation(summary = "사용자 목록 조회")
|
|
31
|
+
public ResponseEntity<Page<UserResponse>> getUsers(
|
|
32
|
+
@RequestParam(defaultValue = "0") int page,
|
|
33
|
+
@RequestParam(defaultValue = "10") int size
|
|
34
|
+
) {
|
|
35
|
+
Pageable pageable = PageRequest.of(page, size);
|
|
36
|
+
Page<UserResponse> users = userService.getUsers(pageable);
|
|
37
|
+
return ResponseEntity.ok(users);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@GetMapping("/{id}")
|
|
41
|
+
@Operation(summary = "사용자 상세 조회")
|
|
42
|
+
public ResponseEntity<UserResponse> getUser(
|
|
43
|
+
@PathVariable Long id
|
|
44
|
+
) {
|
|
45
|
+
UserResponse user = userService.getUser(id);
|
|
46
|
+
return ResponseEntity.ok(user);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@PostMapping
|
|
50
|
+
@Operation(summary = "사용자 생성")
|
|
51
|
+
public ResponseEntity<UserResponse> createUser(
|
|
52
|
+
@Valid @RequestBody CreateUserRequest request
|
|
53
|
+
) {
|
|
54
|
+
UserResponse user = userService.createUser(request);
|
|
55
|
+
URI location = URI.create("/api/v1/users/" + user.getId());
|
|
56
|
+
return ResponseEntity.created(location).body(user);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@PutMapping("/{id}")
|
|
60
|
+
@Operation(summary = "사용자 수정")
|
|
61
|
+
public ResponseEntity<UserResponse> updateUser(
|
|
62
|
+
@PathVariable Long id,
|
|
63
|
+
@Valid @RequestBody UpdateUserRequest request
|
|
64
|
+
) {
|
|
65
|
+
UserResponse user = userService.updateUser(id, request);
|
|
66
|
+
return ResponseEntity.ok(user);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@DeleteMapping("/{id}")
|
|
70
|
+
@Operation(summary = "사용자 삭제")
|
|
71
|
+
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
|
|
72
|
+
userService.deleteUser(id);
|
|
73
|
+
return ResponseEntity.noContent().build();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### 2. Service 레이어
|
|
79
|
+
|
|
80
|
+
```java
|
|
81
|
+
// ✅ Service Interface
|
|
82
|
+
public interface UserService {
|
|
83
|
+
Page<UserResponse> getUsers(Pageable pageable);
|
|
84
|
+
UserResponse getUser(Long id);
|
|
85
|
+
UserResponse createUser(CreateUserRequest request);
|
|
86
|
+
UserResponse updateUser(Long id, UpdateUserRequest request);
|
|
87
|
+
void deleteUser(Long id);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ✅ Service 구현
|
|
91
|
+
@Service
|
|
92
|
+
@RequiredArgsConstructor
|
|
93
|
+
@Transactional(readOnly = true)
|
|
94
|
+
public class UserServiceImpl implements UserService {
|
|
95
|
+
|
|
96
|
+
private final UserRepository userRepository;
|
|
97
|
+
private final PasswordEncoder passwordEncoder;
|
|
98
|
+
private final UserMapper userMapper;
|
|
99
|
+
|
|
100
|
+
@Override
|
|
101
|
+
public Page<UserResponse> getUsers(Pageable pageable) {
|
|
102
|
+
return userRepository.findAll(pageable)
|
|
103
|
+
.map(userMapper::toResponse);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@Override
|
|
107
|
+
public UserResponse getUser(Long id) {
|
|
108
|
+
User user = findUserById(id);
|
|
109
|
+
return userMapper.toResponse(user);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
@Override
|
|
113
|
+
@Transactional
|
|
114
|
+
public UserResponse createUser(CreateUserRequest request) {
|
|
115
|
+
validateEmailNotExists(request.getEmail());
|
|
116
|
+
|
|
117
|
+
User user = User.builder()
|
|
118
|
+
.email(request.getEmail())
|
|
119
|
+
.name(request.getName())
|
|
120
|
+
.password(passwordEncoder.encode(request.getPassword()))
|
|
121
|
+
.build();
|
|
122
|
+
|
|
123
|
+
User savedUser = userRepository.save(user);
|
|
124
|
+
return userMapper.toResponse(savedUser);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
@Override
|
|
128
|
+
@Transactional
|
|
129
|
+
public UserResponse updateUser(Long id, UpdateUserRequest request) {
|
|
130
|
+
User user = findUserById(id);
|
|
131
|
+
user.update(request.getName(), request.getPhone());
|
|
132
|
+
return userMapper.toResponse(user);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
@Override
|
|
136
|
+
@Transactional
|
|
137
|
+
public void deleteUser(Long id) {
|
|
138
|
+
User user = findUserById(id);
|
|
139
|
+
userRepository.delete(user);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private User findUserById(Long id) {
|
|
143
|
+
return userRepository.findById(id)
|
|
144
|
+
.orElseThrow(() -> new ResourceNotFoundException("사용자", id));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private void validateEmailNotExists(String email) {
|
|
148
|
+
if (userRepository.existsByEmail(email)) {
|
|
149
|
+
throw new DuplicateResourceException("이미 존재하는 이메일입니다: " + email);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### 3. Entity 설계
|
|
156
|
+
|
|
157
|
+
```java
|
|
158
|
+
// ✅ 기본 Entity (Auditing)
|
|
159
|
+
@Getter
|
|
160
|
+
@MappedSuperclass
|
|
161
|
+
@EntityListeners(AuditingEntityListener.class)
|
|
162
|
+
public abstract class BaseEntity {
|
|
163
|
+
|
|
164
|
+
@CreatedDate
|
|
165
|
+
@Column(updatable = false)
|
|
166
|
+
private LocalDateTime createdAt;
|
|
167
|
+
|
|
168
|
+
@LastModifiedDate
|
|
169
|
+
private LocalDateTime updatedAt;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ✅ User Entity
|
|
173
|
+
@Entity
|
|
174
|
+
@Table(name = "users")
|
|
175
|
+
@Getter
|
|
176
|
+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
|
177
|
+
public class User extends BaseEntity {
|
|
178
|
+
|
|
179
|
+
@Id
|
|
180
|
+
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
181
|
+
private Long id;
|
|
182
|
+
|
|
183
|
+
@Column(nullable = false, unique = true)
|
|
184
|
+
private String email;
|
|
185
|
+
|
|
186
|
+
@Column(nullable = false)
|
|
187
|
+
private String name;
|
|
188
|
+
|
|
189
|
+
@Column(nullable = false)
|
|
190
|
+
private String password;
|
|
191
|
+
|
|
192
|
+
private String phone;
|
|
193
|
+
|
|
194
|
+
@Enumerated(EnumType.STRING)
|
|
195
|
+
@Column(nullable = false)
|
|
196
|
+
private UserStatus status = UserStatus.ACTIVE;
|
|
197
|
+
|
|
198
|
+
@OneToMany(mappedBy = "author", cascade = CascadeType.ALL)
|
|
199
|
+
private List<Post> posts = new ArrayList<>();
|
|
200
|
+
|
|
201
|
+
@Builder
|
|
202
|
+
public User(String email, String name, String password) {
|
|
203
|
+
this.email = email;
|
|
204
|
+
this.name = name;
|
|
205
|
+
this.password = password;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 비즈니스 메서드
|
|
209
|
+
public void update(String name, String phone) {
|
|
210
|
+
this.name = name;
|
|
211
|
+
this.phone = phone;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
public void changePassword(String newPassword) {
|
|
215
|
+
this.password = newPassword;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
public void deactivate() {
|
|
219
|
+
this.status = UserStatus.INACTIVE;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ✅ Enum
|
|
224
|
+
public enum UserStatus {
|
|
225
|
+
ACTIVE, INACTIVE, SUSPENDED
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### 4. Repository 레이어
|
|
230
|
+
|
|
231
|
+
```java
|
|
232
|
+
// ✅ JPA Repository
|
|
233
|
+
public interface UserRepository extends JpaRepository<User, Long> {
|
|
234
|
+
|
|
235
|
+
Optional<User> findByEmail(String email);
|
|
236
|
+
|
|
237
|
+
boolean existsByEmail(String email);
|
|
238
|
+
|
|
239
|
+
@Query("SELECT u FROM User u WHERE u.status = :status")
|
|
240
|
+
Page<User> findByStatus(@Param("status") UserStatus status, Pageable pageable);
|
|
241
|
+
|
|
242
|
+
// QueryDSL Custom Repository
|
|
243
|
+
Page<User> searchUsers(UserSearchCondition condition, Pageable pageable);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ✅ Custom Repository 구현 (QueryDSL)
|
|
247
|
+
@RequiredArgsConstructor
|
|
248
|
+
public class UserRepositoryCustomImpl implements UserRepositoryCustom {
|
|
249
|
+
|
|
250
|
+
private final JPAQueryFactory queryFactory;
|
|
251
|
+
|
|
252
|
+
@Override
|
|
253
|
+
public Page<User> searchUsers(UserSearchCondition condition, Pageable pageable) {
|
|
254
|
+
List<User> content = queryFactory
|
|
255
|
+
.selectFrom(user)
|
|
256
|
+
.where(
|
|
257
|
+
nameContains(condition.getName()),
|
|
258
|
+
emailContains(condition.getEmail()),
|
|
259
|
+
statusEq(condition.getStatus())
|
|
260
|
+
)
|
|
261
|
+
.offset(pageable.getOffset())
|
|
262
|
+
.limit(pageable.getPageSize())
|
|
263
|
+
.orderBy(user.createdAt.desc())
|
|
264
|
+
.fetch();
|
|
265
|
+
|
|
266
|
+
Long total = queryFactory
|
|
267
|
+
.select(user.count())
|
|
268
|
+
.from(user)
|
|
269
|
+
.where(
|
|
270
|
+
nameContains(condition.getName()),
|
|
271
|
+
emailContains(condition.getEmail()),
|
|
272
|
+
statusEq(condition.getStatus())
|
|
273
|
+
)
|
|
274
|
+
.fetchOne();
|
|
275
|
+
|
|
276
|
+
return new PageImpl<>(content, pageable, total != null ? total : 0);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private BooleanExpression nameContains(String name) {
|
|
280
|
+
return StringUtils.hasText(name) ? user.name.contains(name) : null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private BooleanExpression emailContains(String email) {
|
|
284
|
+
return StringUtils.hasText(email) ? user.email.contains(email) : null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private BooleanExpression statusEq(UserStatus status) {
|
|
288
|
+
return status != null ? user.status.eq(status) : null;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
### 5. DTO와 Validation
|
|
294
|
+
|
|
295
|
+
```java
|
|
296
|
+
// ✅ Request DTO
|
|
297
|
+
@Getter
|
|
298
|
+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
|
299
|
+
@Schema(description = "사용자 생성 요청")
|
|
300
|
+
public class CreateUserRequest {
|
|
301
|
+
|
|
302
|
+
@NotBlank(message = "이메일은 필수입니다")
|
|
303
|
+
@Email(message = "올바른 이메일 형식이 아닙니다")
|
|
304
|
+
@Schema(description = "이메일", example = "user@example.com")
|
|
305
|
+
private String email;
|
|
306
|
+
|
|
307
|
+
@NotBlank(message = "이름은 필수입니다")
|
|
308
|
+
@Size(min = 2, max = 50, message = "이름은 2~50자 사이여야 합니다")
|
|
309
|
+
@Schema(description = "이름", example = "홍길동")
|
|
310
|
+
private String name;
|
|
311
|
+
|
|
312
|
+
@NotBlank(message = "비밀번호는 필수입니다")
|
|
313
|
+
@Pattern(
|
|
314
|
+
regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&])[A-Za-z\\d@$!%*#?&]{8,}$",
|
|
315
|
+
message = "비밀번호는 8자 이상, 영문/숫자/특수문자를 포함해야 합니다"
|
|
316
|
+
)
|
|
317
|
+
@Schema(description = "비밀번호", example = "Password123!")
|
|
318
|
+
private String password;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ✅ Response DTO
|
|
322
|
+
@Getter
|
|
323
|
+
@Builder
|
|
324
|
+
@Schema(description = "사용자 응답")
|
|
325
|
+
public class UserResponse {
|
|
326
|
+
|
|
327
|
+
@Schema(description = "사용자 ID", example = "1")
|
|
328
|
+
private Long id;
|
|
329
|
+
|
|
330
|
+
@Schema(description = "이메일", example = "user@example.com")
|
|
331
|
+
private String email;
|
|
332
|
+
|
|
333
|
+
@Schema(description = "이름", example = "홍길동")
|
|
334
|
+
private String name;
|
|
335
|
+
|
|
336
|
+
@Schema(description = "상태", example = "ACTIVE")
|
|
337
|
+
private UserStatus status;
|
|
338
|
+
|
|
339
|
+
@Schema(description = "생성일시")
|
|
340
|
+
private LocalDateTime createdAt;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ✅ Mapper (MapStruct)
|
|
344
|
+
@Mapper(componentModel = "spring")
|
|
345
|
+
public interface UserMapper {
|
|
346
|
+
|
|
347
|
+
UserResponse toResponse(User user);
|
|
348
|
+
|
|
349
|
+
List<UserResponse> toResponseList(List<User> users);
|
|
350
|
+
}
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
### 6. 예외 처리
|
|
354
|
+
|
|
355
|
+
```java
|
|
356
|
+
// ✅ 커스텀 예외
|
|
357
|
+
@Getter
|
|
358
|
+
public class BusinessException extends RuntimeException {
|
|
359
|
+
private final ErrorCode errorCode;
|
|
360
|
+
|
|
361
|
+
public BusinessException(ErrorCode errorCode) {
|
|
362
|
+
super(errorCode.getMessage());
|
|
363
|
+
this.errorCode = errorCode;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
public BusinessException(ErrorCode errorCode, String message) {
|
|
367
|
+
super(message);
|
|
368
|
+
this.errorCode = errorCode;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
public class ResourceNotFoundException extends BusinessException {
|
|
373
|
+
public ResourceNotFoundException(String resource, Long id) {
|
|
374
|
+
super(ErrorCode.NOT_FOUND, String.format("%s을(를) 찾을 수 없습니다 (ID: %d)", resource, id));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
public class DuplicateResourceException extends BusinessException {
|
|
379
|
+
public DuplicateResourceException(String message) {
|
|
380
|
+
super(ErrorCode.DUPLICATE, message);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ✅ Error Code
|
|
385
|
+
@Getter
|
|
386
|
+
@RequiredArgsConstructor
|
|
387
|
+
public enum ErrorCode {
|
|
388
|
+
// Common
|
|
389
|
+
INVALID_INPUT(HttpStatus.BAD_REQUEST, "C001", "잘못된 입력입니다"),
|
|
390
|
+
NOT_FOUND(HttpStatus.NOT_FOUND, "C002", "리소스를 찾을 수 없습니다"),
|
|
391
|
+
DUPLICATE(HttpStatus.CONFLICT, "C003", "이미 존재하는 리소스입니다"),
|
|
392
|
+
INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "C004", "서버 오류가 발생했습니다"),
|
|
393
|
+
|
|
394
|
+
// Auth
|
|
395
|
+
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "A001", "인증이 필요합니다"),
|
|
396
|
+
FORBIDDEN(HttpStatus.FORBIDDEN, "A002", "접근 권한이 없습니다");
|
|
397
|
+
|
|
398
|
+
private final HttpStatus status;
|
|
399
|
+
private final String code;
|
|
400
|
+
private final String message;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ✅ Global Exception Handler
|
|
404
|
+
@RestControllerAdvice
|
|
405
|
+
@Slf4j
|
|
406
|
+
public class GlobalExceptionHandler {
|
|
407
|
+
|
|
408
|
+
@ExceptionHandler(BusinessException.class)
|
|
409
|
+
public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
|
|
410
|
+
log.warn("Business exception: {}", e.getMessage());
|
|
411
|
+
ErrorCode errorCode = e.getErrorCode();
|
|
412
|
+
return ResponseEntity
|
|
413
|
+
.status(errorCode.getStatus())
|
|
414
|
+
.body(ErrorResponse.of(errorCode, e.getMessage()));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
@ExceptionHandler(MethodArgumentNotValidException.class)
|
|
418
|
+
public ResponseEntity<ErrorResponse> handleValidationException(MethodArgumentNotValidException e) {
|
|
419
|
+
log.warn("Validation exception: {}", e.getMessage());
|
|
420
|
+
List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors().stream()
|
|
421
|
+
.map(error -> new FieldError(error.getField(), error.getDefaultMessage()))
|
|
422
|
+
.toList();
|
|
423
|
+
return ResponseEntity
|
|
424
|
+
.badRequest()
|
|
425
|
+
.body(ErrorResponse.of(ErrorCode.INVALID_INPUT, fieldErrors));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
@ExceptionHandler(Exception.class)
|
|
429
|
+
public ResponseEntity<ErrorResponse> handleException(Exception e) {
|
|
430
|
+
log.error("Unexpected exception", e);
|
|
431
|
+
return ResponseEntity
|
|
432
|
+
.internalServerError()
|
|
433
|
+
.body(ErrorResponse.of(ErrorCode.INTERNAL_ERROR));
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
### 7. 테스트
|
|
439
|
+
|
|
440
|
+
```java
|
|
441
|
+
// ✅ Service 단위 테스트
|
|
442
|
+
@ExtendWith(MockitoExtension.class)
|
|
443
|
+
class UserServiceTest {
|
|
444
|
+
|
|
445
|
+
@Mock
|
|
446
|
+
private UserRepository userRepository;
|
|
447
|
+
|
|
448
|
+
@Mock
|
|
449
|
+
private PasswordEncoder passwordEncoder;
|
|
450
|
+
|
|
451
|
+
@Mock
|
|
452
|
+
private UserMapper userMapper;
|
|
453
|
+
|
|
454
|
+
@InjectMocks
|
|
455
|
+
private UserServiceImpl userService;
|
|
456
|
+
|
|
457
|
+
@Test
|
|
458
|
+
@DisplayName("사용자 조회 성공")
|
|
459
|
+
void getUser_Success() {
|
|
460
|
+
// given
|
|
461
|
+
Long userId = 1L;
|
|
462
|
+
User user = createTestUser(userId);
|
|
463
|
+
UserResponse expectedResponse = createTestUserResponse(userId);
|
|
464
|
+
|
|
465
|
+
given(userRepository.findById(userId)).willReturn(Optional.of(user));
|
|
466
|
+
given(userMapper.toResponse(user)).willReturn(expectedResponse);
|
|
467
|
+
|
|
468
|
+
// when
|
|
469
|
+
UserResponse response = userService.getUser(userId);
|
|
470
|
+
|
|
471
|
+
// then
|
|
472
|
+
assertThat(response.getId()).isEqualTo(userId);
|
|
473
|
+
assertThat(response.getEmail()).isEqualTo("test@example.com");
|
|
474
|
+
then(userRepository).should().findById(userId);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
@Test
|
|
478
|
+
@DisplayName("존재하지 않는 사용자 조회시 예외 발생")
|
|
479
|
+
void getUser_NotFound_ThrowsException() {
|
|
480
|
+
// given
|
|
481
|
+
Long userId = 999L;
|
|
482
|
+
given(userRepository.findById(userId)).willReturn(Optional.empty());
|
|
483
|
+
|
|
484
|
+
// when & then
|
|
485
|
+
assertThatThrownBy(() -> userService.getUser(userId))
|
|
486
|
+
.isInstanceOf(ResourceNotFoundException.class)
|
|
487
|
+
.hasMessageContaining("사용자");
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private User createTestUser(Long id) {
|
|
491
|
+
return User.builder()
|
|
492
|
+
.email("test@example.com")
|
|
493
|
+
.name("테스트")
|
|
494
|
+
.password("encoded")
|
|
495
|
+
.build();
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ✅ Controller 통합 테스트
|
|
500
|
+
@WebMvcTest(UserController.class)
|
|
501
|
+
@Import(SecurityConfig.class)
|
|
502
|
+
class UserControllerTest {
|
|
503
|
+
|
|
504
|
+
@Autowired
|
|
505
|
+
private MockMvc mockMvc;
|
|
506
|
+
|
|
507
|
+
@MockBean
|
|
508
|
+
private UserService userService;
|
|
509
|
+
|
|
510
|
+
@Autowired
|
|
511
|
+
private ObjectMapper objectMapper;
|
|
512
|
+
|
|
513
|
+
@Test
|
|
514
|
+
@DisplayName("POST /api/v1/users - 사용자 생성 성공")
|
|
515
|
+
void createUser_Success() throws Exception {
|
|
516
|
+
// given
|
|
517
|
+
CreateUserRequest request = new CreateUserRequest(
|
|
518
|
+
"test@example.com", "테스트", "Password123!"
|
|
519
|
+
);
|
|
520
|
+
UserResponse response = UserResponse.builder()
|
|
521
|
+
.id(1L)
|
|
522
|
+
.email("test@example.com")
|
|
523
|
+
.name("테스트")
|
|
524
|
+
.status(UserStatus.ACTIVE)
|
|
525
|
+
.build();
|
|
526
|
+
|
|
527
|
+
given(userService.createUser(any())).willReturn(response);
|
|
528
|
+
|
|
529
|
+
// when & then
|
|
530
|
+
mockMvc.perform(post("/api/v1/users")
|
|
531
|
+
.contentType(MediaType.APPLICATION_JSON)
|
|
532
|
+
.content(objectMapper.writeValueAsString(request)))
|
|
533
|
+
.andExpect(status().isCreated())
|
|
534
|
+
.andExpect(jsonPath("$.id").value(1))
|
|
535
|
+
.andExpect(jsonPath("$.email").value("test@example.com"));
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
## 파일 구조
|
|
541
|
+
|
|
542
|
+
```
|
|
543
|
+
src/main/java/com/example/app/
|
|
544
|
+
├── config/ # 설정
|
|
545
|
+
│ ├── SecurityConfig.java
|
|
546
|
+
│ ├── JpaConfig.java
|
|
547
|
+
│ └── SwaggerConfig.java
|
|
548
|
+
├── domain/
|
|
549
|
+
│ └── user/
|
|
550
|
+
│ ├── controller/
|
|
551
|
+
│ │ └── UserController.java
|
|
552
|
+
│ ├── service/
|
|
553
|
+
│ │ ├── UserService.java
|
|
554
|
+
│ │ └── UserServiceImpl.java
|
|
555
|
+
│ ├── repository/
|
|
556
|
+
│ │ ├── UserRepository.java
|
|
557
|
+
│ │ └── UserRepositoryCustomImpl.java
|
|
558
|
+
│ ├── entity/
|
|
559
|
+
│ │ └── User.java
|
|
560
|
+
│ └── dto/
|
|
561
|
+
│ ├── CreateUserRequest.java
|
|
562
|
+
│ ├── UpdateUserRequest.java
|
|
563
|
+
│ └── UserResponse.java
|
|
564
|
+
├── global/
|
|
565
|
+
│ ├── common/
|
|
566
|
+
│ │ ├── BaseEntity.java
|
|
567
|
+
│ │ └── BaseResponse.java
|
|
568
|
+
│ ├── error/
|
|
569
|
+
│ │ ├── ErrorCode.java
|
|
570
|
+
│ │ ├── ErrorResponse.java
|
|
571
|
+
│ │ ├── BusinessException.java
|
|
572
|
+
│ │ └── GlobalExceptionHandler.java
|
|
573
|
+
│ └── util/
|
|
574
|
+
└── Application.java
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
## 체크리스트
|
|
578
|
+
|
|
579
|
+
- [ ] Controller → Service → Repository 레이어 분리
|
|
580
|
+
- [ ] @Valid로 요청 검증
|
|
581
|
+
- [ ] @Transactional 적절히 사용 (readOnly 포함)
|
|
582
|
+
- [ ] 커스텀 예외 + GlobalExceptionHandler
|
|
583
|
+
- [ ] DTO ↔ Entity 변환 (MapStruct 권장)
|
|
584
|
+
- [ ] JPA N+1 문제 방지 (fetch join, @EntityGraph)
|
|
585
|
+
- [ ] 단위 테스트 + 통합 테스트
|
|
586
|
+
- [ ] Swagger/OpenAPI 문서화
|