@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.
@@ -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 문서화