Skip to content

Latest commit

ย 

History

History
869 lines (686 loc) ยท 22.4 KB

File metadata and controls

869 lines (686 loc) ยท 22.4 KB

Code Style Guide

Java 21 & Spring Boot 3.4 ์ฝ”๋“œ ์ž‘์„ฑ ์Šคํƒ€์ผ ๊ฐ€์ด๋“œ์ž…๋‹ˆ๋‹ค.


ํ•ต์‹ฌ ๊ธฐ์ˆ  ์Šคํƒ

๊ธฐ์ˆ  ์ ์šฉ ์ด์œ 
record DTO โœ… ๋ถˆ๋ณ€์„ฑ ๋ณด์žฅ, ๋ณด์ผ๋Ÿฌํ”Œ๋ ˆ์ดํŠธ ์ œ๊ฑฐ
Virtual Threads โœ… ๋™์‹œ์„ฑ ์ฒ˜๋ฆฌ ์„ฑ๋Šฅ ํ–ฅ์ƒ
FixtureMonkey โœ… ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ ์ž๋™ ์ƒ์„ฑ

Virtual Threads ์„ค์ •

# application.yml
spring:
  threads:
    virtual:
      enabled: true

record vs class + Lombok ์‚ฌ์šฉ ๊ธฐ์ค€

ํ•œ๋ˆˆ์— ๋ณด๊ธฐ

๊ตฌ๋ถ„ ๊ถŒ์žฅ ์ด์œ 
Request/Response DTO record ๋ถˆ๋ณ€, ๊ฐ„๊ฒฐ, ์ˆœ์ˆ˜ Java
Entity class + Lombok JPA ๊ธฐ๋ณธ ์ƒ์„ฑ์ž ์š”๊ตฌ
Service/Controller Lombok @RequiredArgsConstructor
Config Lombok @RequiredArgsConstructor

๋ถˆ๋ณ€์„ฑ์ด๋ž€?

ํ•œ๋ฒˆ ์ƒ์„ฑ๋˜๋ฉด ๊ฐ’์„ ๋ฐ”๊ฟ€ ์ˆ˜ ์—†๋‹ค๋Š” ์˜๋ฏธ์ž…๋‹ˆ๋‹ค.

// record๋Š” setter๊ฐ€ ์—†์Œ
MemberResponse response = new MemberResponse(1L, "ํ™๊ธธ๋™");
response.setName("๊น€์ฒ ์ˆ˜");  // โŒ ์ปดํŒŒ์ผ ์—๋Ÿฌ - ์ด๋Ÿฐ ๋ฉ”์„œ๋“œ ์ž์ฒด๊ฐ€ ์—†์Œ

// ๊ฐ’์„ ๋ฐ”๊พธ๊ณ  ์‹ถ์œผ๋ฉด ์ƒˆ ๊ฐ์ฒด๋ฅผ ๋งŒ๋“ค์–ด์•ผ ํ•จ
MemberResponse updated = new MemberResponse(1L, "๊น€์ฒ ์ˆ˜");

์™œ DTO๋Š” record, Entity๋Š” class์ธ๊ฐ€?

// Entity - ๊ฐ’ ๋ณ€๊ฒฝ ๊ฐ€๋Šฅํ•ด์•ผ ํ•จ (๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง)
member.updateNickname("์ƒˆ๋‹‰๋„ค์ž„");  // โœ… ํ•„์š”ํ•จ

// DTO - ๊ฐ’ ๋ณ€๊ฒฝ ๋ถˆํ•„์š” (๋ฐ์ดํ„ฐ ์šด๋ฐ˜์šฉ)
// API ์š”์ฒญ ๋ฐ›๊ณ  โ†’ ์‘๋‹ต ๋ณด๋‚ด๋ฉด ๋. ์ค‘๊ฐ„์— ๊ฐ’ ๋ฐ”๊ฟ€ ์ผ ์—†์Œ

record + @Builder ์–ธ์ œ ์“ฐ๋‚˜?

// ํ•„๋“œ๊ฐ€ 5๊ฐœ ์ด์ƒ์ด๊ฑฐ๋‚˜ ์„ ํƒ์  ํ•„๋“œ๊ฐ€ ๋งŽ์„ ๋•Œ
@Builder
public record MemberDetailResponse(
    Long id,
    String nickname,
    String email,
    String phone,
    String profileImage,
    LocalDateTime createdAt
) {
    public static MemberDetailResponse from(Member member) {
        return MemberDetailResponse.builder()
                .id(member.getId())
                .nickname(member.getNickname())
                // ...
                .build();
    }
}

// ํ•„๋“œ๊ฐ€ 3๊ฐœ ์ดํ•˜๋ฉด ๊ทธ๋ƒฅ ์ƒ์„ฑ์ž ์‚ฌ์šฉ
public record MemberSummary(Long id, String nickname) {
    public static MemberSummary from(Member member) {
        return new MemberSummary(member.getId(), member.getNickname());
    }
}

Lombok์€ ์–ด๋””์„œ ์“ฐ์ด๋‚˜?

// Entity - ํ•„์ˆ˜ (JPA๊ฐ€ ๊ธฐ๋ณธ ์ƒ์„ฑ์ž ์š”๊ตฌ)
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member { }

// Service, Controller - @RequiredArgsConstructor
@Service
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;  // ์ž๋™ ์ฃผ์ž…
}

// Config ํด๋ž˜์Šค
@Configuration
@RequiredArgsConstructor
public class SecurityConfig { }

๊ฒฐ๋ก : Lombok์€ ํ”„๋กœ์ ํŠธ์—์„œ ๊ณ„์† ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. DTO์—์„œ๋งŒ record๋กœ ๋Œ€์ฒดํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.


์–ด๋…ธํ…Œ์ด์…˜ ์ปจ๋ฒค์…˜

๋ชจ๋“  ๋„๋ฉ”์ธ์—์„œ ๋™์ผํ•œ ์–ด๋…ธํ…Œ์ด์…˜ ์ˆœ์„œ์™€ ํŒจํ„ด์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

์ˆœ์„œ ์›์น™

1. ์Šคํ…Œ๋ ˆ์˜คํƒ€์ž… (@Entity, @Service, @RestController ๋“ฑ)
2. ์„ค์ • (@Table, @RequestMapping ๋“ฑ)
3. Lombok (@Getter, @Builder, @RequiredArgsConstructor ๋“ฑ)

Entity

@Entity
@Table(name = "member")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 20)
    private String nickname;

    @Column(nullable = false, unique = true)
    private String email;

    @Enumerated(EnumType.STRING)      // STRING ํ•„์ˆ˜
    @Column(nullable = false)
    private MemberStatus status;

    @ManyToOne(fetch = FetchType.LAZY) // LAZY ํ•„์ˆ˜
    @JoinColumn(name = "store_id")
    private Store store;

    // @Builder๋Š” private ์ƒ์„ฑ์ž์—๋งŒ
    @Builder
    private Member(String nickname, String email) {
        this.nickname = nickname;
        this.email = email;
        this.status = MemberStatus.ACTIVE;
    }

    // ๋น„์ฆˆ๋‹ˆ์Šค ๋ฉ”์„œ๋“œ
    public void updateNickname(String nickname) {
        this.nickname = nickname;
    }
}
์–ด๋…ธํ…Œ์ด์…˜ ํ•„์ˆ˜ ๋น„๊ณ 
@Entity, @Table O
@Getter O @Setter ๊ธˆ์ง€
@NoArgsConstructor(access = PROTECTED) O
@Builder O private ์ƒ์„ฑ์ž์—๋งŒ
@Enumerated(EnumType.STRING) O ORDINAL ๊ธˆ์ง€
@ManyToOne(fetch = LAZY) O EAGER ๊ธˆ์ง€

DTO (record ๊ธฐ๋ฐ˜)

Request DTO

public class MemberRequest {

    public record Create(
        @NotBlank(message = "๋‹‰๋„ค์ž„์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.")
        @Size(min = 2, max = 20, message = "๋‹‰๋„ค์ž„์€ 2~20์ž ์‚ฌ์ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.")
        String nickname,

        @NotBlank(message = "์ด๋ฉ”์ผ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.")
        @Email(message = "์˜ฌ๋ฐ”๋ฅธ ์ด๋ฉ”์ผ ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค.")
        String email
    ) {}

    public record Update(
        @Size(min = 2, max = 20)
        String nickname
    ) {}
}

Response DTO

public class MemberResponse {

    @Builder
    public record Detail(
        Long id,
        String nickname,
        String email,
        LocalDateTime createdAt
    ) {
        public static Detail from(Member member) {
            return Detail.builder()
                    .id(member.getId())
                    .nickname(member.getNickname())
                    .email(member.getEmail())
                    .createdAt(member.getCreatedAt())
                    .build();
        }
    }

    // ํ•„๋“œ 3๊ฐœ ์ดํ•˜ โ†’ ์ƒ์„ฑ์ž ์ง์ ‘ ์‚ฌ์šฉ
    public record Summary(
        Long id,
        String nickname
    ) {
        public static Summary from(Member member) {
            return new Summary(member.getId(), member.getNickname());
        }
    }
}
๊ตฌ๋ถ„ ํŒจํ„ด ๋น„๊ณ 
Request record + Validation ๋ถˆ๋ณ€, Jackson ์ž๋™ ์ง€์›
Response @Builder + record from() ํŒฉํ† ๋ฆฌ ๋ฉ”์„œ๋“œ

record ์žฅ์ : ๋ถˆ๋ณ€์„ฑ ๋ณด์žฅ, equals/hashCode/toString ์ž๋™ ์ƒ์„ฑ, ๋ฉ€ํ‹ฐ์Šค๋ ˆ๋“œ ์•ˆ์ „


Entity โ†’ DTO ๋ณ€ํ™˜ ํŒจํ„ด (Converter)

๊ฐœ์š”

ํ”„๋กœ์ ํŠธ์—์„œ๋Š” ๋ณ„๋„์˜ Converter ํด๋ž˜์Šค๋ฅผ ๋งŒ๋“ค์ง€ ์•Š๊ณ , DTO ๋‚ด๋ถ€์— from() ์ •์  ํŒฉํ† ๋ฆฌ ๋ฉ”์„œ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ Entity๋ฅผ DTO๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค.

ํŒจํ„ด ์„ค๋ช…

public class PaymentResponse {

    @Builder
    public record Detail(
        Long id,
        String paymentId,
        String orderName,
        Integer amount,
        PaymentStatus status
        // ... ๊ธฐํƒ€ ํ•„๋“œ
    ) {
        // โœ… ์ด๊ฒƒ์ด ์ปจ๋ฒ„ํ„ฐ ์—ญํ• ์„ ํ•ฉ๋‹ˆ๋‹ค
        public static Detail from(Payment payment) {
            return Detail.builder()
                    .id(payment.getId())
                    .paymentId(payment.getPaymentId())
                    .orderName(payment.getOrderName())
                    .amount(payment.getAmount())
                    .status(payment.getStatus())
                    .build();
        }
    }

    public record Summary(
        Long id,
        String paymentId,
        String orderName
    ) {
        // ํ•„๋“œ๊ฐ€ ์ ์œผ๋ฉด Builder ์—†์ด ์ƒ์„ฑ์ž ์‚ฌ์šฉ
        public static Summary from(Payment payment) {
            return new Summary(
                payment.getId(),
                payment.getPaymentId(),
                payment.getOrderName()
            );
        }
    }
}

์‚ฌ์šฉ ์˜ˆ์‹œ

// Service์—์„œ ์‚ฌ์šฉ
@Override
public PaymentResponse.Detail getPayment(Long memberId, String paymentId) {
    Payment payment = paymentRepository.findByPaymentId(paymentId)
            .orElseThrow(() -> new CustomException(ErrorCode.PAYMENT_NOT_FOUND));

    // โœ… ๊ฐ„๊ฒฐํ•œ ๋ณ€ํ™˜
    return PaymentResponse.Detail.from(payment);
}

// ๋ฆฌ์ŠคํŠธ ๋ณ€ํ™˜
@Override
public List<PaymentResponse.Summary> getMyPayments(Long memberId) {
    return paymentRepository.findByMemberIdOrderByCreatedAtDesc(memberId)
            .stream()
            .map(PaymentResponse.Summary::from)  // โœ… ๋ฉ”์„œ๋“œ ๋ ˆํผ๋Ÿฐ์Šค ์‚ฌ์šฉ
            .toList();
}

์ด ํŒจํ„ด์˜ ์žฅ์ 

์žฅ์  ์„ค๋ช…
์บก์Аํ™” ๋ณ€ํ™˜ ๋กœ์ง์ด DTO ๋‚ด๋ถ€์— ์‘์ง‘๋จ
ํƒ€์ž… ์•ˆ์ „์„ฑ ์ปดํŒŒ์ผ ํƒ€์ž„์— ํƒ€์ž… ์ฒดํฌ
๊ฐ„๊ฒฐ์„ฑ Service ์ฝ”๋“œ๊ฐ€ ๊น”๋”ํ•ด์ง
์ผ๊ด€์„ฑ ํ”„๋กœ์ ํŠธ ์ „์ฒด์—์„œ ๋™์ผํ•œ ํŒจํ„ด ์‚ฌ์šฉ
ํ…Œ์ŠคํŠธ ์šฉ์ด์„ฑ DTO ๋‹จ์œ„๋กœ ๋ณ€ํ™˜ ๋กœ์ง ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅ

๋ณ„๋„ Converter ํด๋ž˜์Šค๋ฅผ ๋งŒ๋“ค์ง€ ์•Š๋Š” ์ด์œ 

// โŒ ์ด๋Ÿฐ ํด๋ž˜์Šค๋ฅผ ๋งŒ๋“ค์ง€ ์•Š์Šต๋‹ˆ๋‹ค
@Component
public class PaymentConverter {
    public PaymentResponse.Detail toDetail(Payment payment) {
        return PaymentResponse.Detail.builder()
                .id(payment.getId())
                // ...
                .build();
    }
}

์ด์œ :

  1. ๋ถˆํ•„์š”ํ•œ ์ถ”์ƒํ™”: DTO ๋ณ€ํ™˜์€ ๋‹จ์ˆœํ•œ ๋งคํ•‘ ์ž‘์—…
  2. ํŒŒ์ผ ์ฆ๊ฐ€: ๋„๋ฉ”์ธ๋‹น Converter ํด๋ž˜์Šค ์ถ”๊ฐ€ ์‹œ ํŒŒ์ผ ์ˆ˜ 2๋ฐฐ ์ฆ๊ฐ€
  3. ์˜์กด์„ฑ ์ฃผ์ž… ๋ถˆํ•„์š”: ์ •์  ๋ฉ”์„œ๋“œ๋กœ ์ถฉ๋ถ„
  4. ์‘์ง‘๋„ ์ €ํ•˜: ๋ณ€ํ™˜ ๋กœ์ง์ด DTO์—์„œ ๋ถ„๋ฆฌ๋˜๋ฉด ๊ด€๋ฆฌ ํฌ์ธํŠธ ์ฆ๊ฐ€

ํ”„๋กœ์ ํŠธ ์ ์šฉ ํ˜„ํ™ฉ

ํ˜„์žฌ 18๊ฐœ ๋„๋ฉ”์ธ, 42๊ฐœ์˜ from() ๋ฉ”์„œ๋“œ๊ฐ€ ์ด ํŒจํ„ด์„ ๋”ฐ๋ฅด๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค:

  • โœ… payment (Detail, Summary)
  • โœ… community (PostResponse, CommentResponse)
  • โœ… photo (PhotoResponse, RestorationResponse)
  • โœ… store (PhotoLabResponse)
  • โœ… inquiry (InquiryResponse)
  • โœ… member (MemberResponse)
  • โœ… auth (AuthResponse)
  • โœ… reservation (ReservationResponse)

๋ณต์žกํ•œ ๋ณ€ํ™˜์ด ํ•„์š”ํ•œ ๊ฒฝ์šฐ

์™ธ๋ถ€ ๋ฐ์ดํ„ฐ๋‚˜ ์ถ”๊ฐ€ ๋กœ์ง์ด ํ•„์š”ํ•œ ๊ฒฝ์šฐ, ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

public record PostDetailResDTO(
    Long id,
    String content,
    boolean isLiked,
    boolean isMine,
    String profileImageUrl,
    List<PostImageResDTO> images
) {
    // โœ… ์ถ”๊ฐ€ ์ •๋ณด๋ฅผ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋ฐ›์Œ
    public static PostDetailResDTO from(
        Post post,
        boolean isLiked,
        boolean isMine,
        String profileImageUrl,
        List<PostImageResDTO> images
    ) {
        return new PostDetailResDTO(
            post.getId(),
            post.getContent(),
            isLiked,
            isMine,
            profileImageUrl,
            images
        );
    }
}

Controller

Controller๋Š” Query/Command Service๋ฅผ ๋ชจ๋‘ ์ฃผ์ž…๋ฐ›์•„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

@Tag(name = "Member", description = "ํšŒ์› API")
@RestController
@RequiredArgsConstructor
@RequestMapping("/members")
public class MemberController {

    private final MemberQueryService memberQueryService;
    private final MemberCommandService memberCommandService;

    @Operation(summary = "ํšŒ์› ์กฐํšŒ")
    @GetMapping("/{memberId}")
    public ApiResponse<MemberResponse.Detail> getMember(
            @PathVariable Long memberId
    ) {
        return ApiResponse.success(
                SuccessCode.MEMBER_FOUND,
                memberQueryService.getMember(memberId)
        );
    }

    @Operation(summary = "ํšŒ์› ์ƒ์„ฑ")
    @PostMapping
    public ApiResponse<Long> createMember(
            @Valid @RequestBody MemberRequest.Create request
    ) {
        return ApiResponse.success(
                SuccessCode.MEMBER_CREATED,
                memberCommandService.createMember(request)
        );
    }

    @Operation(summary = "ํšŒ์› ์ •๋ณด ์ˆ˜์ •")
    @PatchMapping("/{memberId}")
    public ApiResponse<Void> updateMember(
            @PathVariable Long memberId,
            @Valid @RequestBody MemberRequest.Update request
    ) {
        memberCommandService.updateMember(memberId, request);
        return ApiResponse.success(SuccessCode.MEMBER_UPDATED, null);
    }
}
์–ด๋…ธํ…Œ์ด์…˜ ์ˆœ์„œ ๋น„๊ณ 
@Tag 1 Swagger ๋ฌธ์„œํ™”
@RestController 2
@RequiredArgsConstructor 3
@RequestMapping 4
@Operation ๋ฉ”์„œ๋“œ 1 summary ํ•„์ˆ˜
@GetMapping ๋“ฑ ๋ฉ”์„œ๋“œ 2

Service (CQRS ํŒจํ„ด)

CQRS (Command Query Responsibility Segregation): ์กฐํšŒ(Query)์™€ ๋ช…๋ น(Command)์„ ๋ถ„๋ฆฌํ•˜์—ฌ ์ฑ…์ž„์„ ๋ช…ํ™•ํžˆ ํ•ฉ๋‹ˆ๋‹ค.

ํŒจํ‚ค์ง€ ๊ตฌ์กฐ

service/
โ”œโ”€โ”€ command/
โ”‚   โ”œโ”€โ”€ {Domain}CommandService.java      (interface)
โ”‚   โ””โ”€โ”€ {Domain}CommandServiceImpl.java  (๊ตฌํ˜„์ฒด)
โ””โ”€โ”€ query/
    โ”œโ”€โ”€ {Domain}QueryService.java        (interface)
    โ””โ”€โ”€ {Domain}QueryServiceImpl.java    (๊ตฌํ˜„์ฒด)

Interface + Implementation ํŒจํ„ด์„ ์‚ฌ์šฉํ•˜๋Š” ์ด์œ 

์žฅ์ :

์ธก๋ฉด ์„ค๋ช…
ํ…Œ์ŠคํŠธ ์šฉ์ด์„ฑ Interface๋ฅผ Mock์œผ๋กœ ์ฃผ์ž…ํ•˜์—ฌ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ ๊ฒฉ๋ฆฌ
ํ™•์žฅ์„ฑ ์บ์‹ฑ, ๋น„๋™๊ธฐ ๋“ฑ ๋‹ค๋ฅธ ๊ตฌํ˜„์ฒด ์ถ”๊ฐ€ ๊ฐ€๋Šฅ
๋ช…ํ™•ํ•œ ๊ณ„์•ฝ Interface๋กœ ์„œ๋น„์Šค ์ฑ…์ž„ ๋ช…์‹œ
์ผ๊ด€์„ฑ ํ”„๋กœ์ ํŠธ ์ „์ฒด 35๊ฐœ Service๊ฐ€ ๋™์ผ ํŒจํ„ด

๋‹จ์ :

  • ํŒŒ์ผ ์ˆ˜ ์ฆ๊ฐ€ (Service๋‹น 2๊ฐœ ํŒŒ์ผ)
  • ๊ตฌํ˜„์ฒด๊ฐ€ 1๊ฐœ๋ฟ์ด๋ฉด ๊ณผ๋„ํ•œ ์ถ”์ƒํ™”๋กœ ๋А๊ปด์งˆ ์ˆ˜ ์žˆ์Œ

๊ฒฐ๋ก :

  • ํ”„๋กœ์ ํŠธ ์ „์ฒด๊ฐ€ ์ด๋ฏธ ์ด ํŒจํ„ด์„ ๋”ฐ๋ฅด๊ณ  ์žˆ์Œ
  • Command/Query ๋ถ„๋ฆฌ์™€ ์ž˜ ๋งž์Œ
  • ํ…Œ์ŠคํŠธ ์ž‘์„ฑ ์‹œ ์‹ค์งˆ์ ์ธ ์ด์  ์กด์žฌ
  • ํ˜„์žฌ ๊ตฌ์กฐ ์œ ์ง€ ๊ถŒ์žฅ

๋‹จ์ผ Service ํด๋ž˜์Šค๋กœ ๋ณ€๊ฒฝํ•˜์ง€ ์•Š๋Š” ์ด์œ 

// โŒ ์ด๋ ‡๊ฒŒ ๋ณ€๊ฒฝํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค
service/
โ”œโ”€โ”€ command/
โ”‚   โ””โ”€โ”€ PaymentCommandService.java  (interface ์—†์ด ๋‹จ์ผ ํด๋ž˜์Šค)
โ””โ”€โ”€ query/
    โ””โ”€โ”€ PaymentQueryService.java    (interface ์—†์ด ๋‹จ์ผ ํด๋ž˜์Šค)

์ด์œ :

  1. ์ผ๊ด€์„ฑ ์šฐ์„ : 35๊ฐœ Service๋ฅผ ๋ชจ๋‘ ๋ณ€๊ฒฝํ•˜๋Š” ๋น„์šฉ์ด ํผ
  2. ํ…Œ์ŠคํŠธ ๋ณต์žก๋„: Mockito๋กœ ๊ตฌ์ฒด ํด๋ž˜์Šค Mock ์ƒ์„ฑ ํ•„์š”
  3. ํ™•์žฅ ๊ฐ€๋Šฅ์„ฑ: ํ–ฅํ›„ ๋‹ค๋ฅธ ๊ตฌํ˜„์ฒด ์ถ”๊ฐ€ ์‹œ ๋ฆฌํŒฉํ† ๋ง ๋น„์šฉ ๋ฐœ์ƒ

์ฐธ๊ณ :

  • ์†Œ๊ทœ๋ชจ ํ”„๋กœ์ ํŠธ์—์„œ๋Š” ๋‹จ์ผ ํด๋ž˜์Šค๊ฐ€ ๋” ์‹ค์šฉ์ ์ผ ์ˆ˜ ์žˆ์Œ
  • ํ•˜์ง€๋งŒ ํ˜„์žฌ ํ”„๋กœ์ ํŠธ ๊ทœ๋ชจ์™€ ํŒ€ ์ปจ๋ฒค์…˜์„ ๊ณ ๋ คํ•˜์—ฌ ํ˜„์žฌ ๊ตฌ์กฐ ์œ ์ง€

Query Service (์กฐํšŒ ์ „์šฉ)

// Interface
public interface MemberQueryService {
    MemberResponse.Detail getMember(Long memberId);
    List<MemberResponse.Summary> getMembers();
}

// Implementation
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberQueryServiceImpl implements MemberQueryService {

    private final MemberRepository memberRepository;
    private final MemberQueryRepository memberQueryRepository;

    @Override
    public MemberResponse.Detail getMember(Long memberId) {
        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND));

        return MemberResponse.Detail.from(member);
    }

    @Override
    public List<MemberResponse.Summary> getMembers() {
        return memberQueryRepository.findAllSummary();
    }
}

Command Service (CUD ์ „์šฉ)

// Interface
public interface MemberCommandService {
    Long createMember(MemberRequest.Create request);
    void updateMember(Long memberId, MemberRequest.Update request);
    void deleteMember(Long memberId);
}

// Implementation
@Service
@RequiredArgsConstructor
@Transactional
public class MemberCommandServiceImpl implements MemberCommandService {

    private final MemberRepository memberRepository;

    @Override
    public Long createMember(MemberRequest.Create request) {
        if (memberRepository.existsByEmail(request.email())) {
            throw new CustomException(ErrorCode.DUPLICATE_EMAIL);
        }

        Member member = Member.builder()
                .nickname(request.nickname())
                .email(request.email())
                .build();

        return memberRepository.save(member).getId();
    }

    @Override
    public void updateMember(Long memberId, MemberRequest.Update request) {
        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND));

        member.updateNickname(request.nickname());
    }

    @Override
    public void deleteMember(Long memberId) {
        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND));

        member.softDelete();
    }
}

Service ์–ด๋…ธํ…Œ์ด์…˜ ์ •๋ฆฌ

๊ตฌ๋ถ„ ํด๋ž˜์Šค ๋ ˆ๋ฒจ ํŠธ๋žœ์žญ์…˜ ์šฉ๋„
QueryServiceImpl @Transactional(readOnly = true) ์กฐํšŒ ์ „์šฉ (Read)
CommandServiceImpl @Transactional ์ƒ์„ฑ/์ˆ˜์ •/์‚ญ์ œ (CUD)
์–ด๋…ธํ…Œ์ด์…˜ ์œ„์น˜ ๋น„๊ณ 
@Service ๊ตฌํ˜„์ฒด ํด๋ž˜์Šค ํ•„์ˆ˜
@RequiredArgsConstructor ๊ตฌํ˜„์ฒด ํด๋ž˜์Šค ํ•„์ˆ˜
@Transactional(readOnly = true) QueryServiceImpl ํด๋ž˜์Šค ์กฐํšŒ ์ตœ์ ํ™”
@Transactional CommandServiceImpl ํด๋ž˜์Šค CUD ์ž‘์—…

์ฐธ๊ณ : ๊ธฐ์กด ๋„๋ฉ”์ธ(member, auth, store ๋“ฑ)์€ ์ ์ง„์ ์œผ๋กœ CQRS ํŒจํ„ด์œผ๋กœ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์˜ˆ์ •์ด๋ฉฐ, ์‹ ๊ทœ ๋„๋ฉ”์ธ์€ ์ƒˆ ํŒจํ„ด์„ ๋”ฐ๋ฆ…๋‹ˆ๋‹ค.


Repository

JPA Repository๋Š” interface๋กœ, QueryDSL Repository๋Š” class๋กœ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค.

๊ตฌ๋ถ„ ํƒ€์ž… ๋„ค์ด๋ฐ ์šฉ๋„
JPA Repository interface {Domain}Repository ๊ธฐ๋ณธ CRUD, ๋‹จ์ˆœ ์ฟผ๋ฆฌ
QueryDSL Repository class {Domain}QueryRepository ๋ณต์žกํ•œ ๋™์  ์ฟผ๋ฆฌ

JPA Repository

public interface MemberRepository extends JpaRepository<Member, Long> {

    Optional<Member> findByEmail(String email);

    boolean existsByEmail(String email);

    @Query("SELECT m FROM Member m JOIN FETCH m.store WHERE m.id = :id")
    Optional<Member> findByIdWithStore(@Param("id") Long id);

    @EntityGraph(attributePaths = {"store"})
    List<Member> findAllByStatus(MemberStatus status);
}

QueryDSL Repository

@Repository
@RequiredArgsConstructor
public class MemberQueryRepository {

    private final JPAQueryFactory queryFactory;

    public List<Member> searchMembers(MemberSearchCondition condition) {
        return queryFactory
                .selectFrom(member)
                .where(
                        nicknameContains(condition.nickname()),
                        statusEq(condition.status())
                )
                .orderBy(member.createdAt.desc())
                .fetch();
    }

    private BooleanExpression nicknameContains(String nickname) {
        return hasText(nickname) ? member.nickname.contains(nickname) : null;
    }

    private BooleanExpression statusEq(MemberStatus status) {
        return status != null ? member.status.eq(status) : null;
    }
}

๊ธˆ์ง€ ์‚ฌํ•ญ

// โŒ ํด๋ž˜์Šค ๋ ˆ๋ฒจ @Builder
@Entity
@Builder
@AllArgsConstructor
public class Member { }

// โŒ @Setter, @Data
@Setter
@Data
public class Member { }

// โŒ EAGER (๊ธฐ๋ณธ๊ฐ’)
@ManyToOne
private Store store;

// โŒ ORDINAL (๊ธฐ๋ณธ๊ฐ’)
@Enumerated
private MemberStatus status;

// โŒ Controller์—์„œ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ
@GetMapping("/{id}")
public ApiResponse<?> get(@PathVariable Long id) {
    try { ... } catch (Exception e) { ... }
}

Validation ๊ทœ์น™

๊ฒ€์ฆ ์œ ํ˜• ์œ„์น˜ ์˜ˆ์‹œ
ํ˜•์‹ ๊ฒ€์ฆ DTO @NotBlank, @Email, @Size
๋น„์ฆˆ๋‹ˆ์Šค ๊ฒ€์ฆ Service ์ค‘๋ณต ์ฒดํฌ, ๊ถŒํ•œ ๊ฒ€์‚ฌ
// Service์—์„œ ๋น„์ฆˆ๋‹ˆ์Šค ๊ฒ€์ฆ
if (memberRepository.existsByEmail(request.email())) {
    throw new CustomException(ErrorCode.DUPLICATE_EMAIL);
}

Null & Optional ์ฒ˜๋ฆฌ

// โœ… ๋‹จ๊ฑด: orElseThrow ์ฆ‰์‹œ ํ˜ธ์ถœ
Member member = memberRepository.findById(id)
    .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND));

// โœ… ๋ชฉ๋ก: ๋นˆ ๋ฆฌ์ŠคํŠธ ๋ฐ˜ํ™˜
public List<Member> getMembers() {
    return memberRepository.findAll();
}

// โŒ Optional ํŒŒ๋ผ๋ฏธํ„ฐ/ํ•„๋“œ ๊ธˆ์ง€
void doSomething(Optional<String> name);
private Optional<String> nickname;

ํ…Œ์ŠคํŠธ ์ฝ”๋“œ

๋„ค์ด๋ฐ

class MemberServiceTest { }

@Test
void ํšŒ์›_์กฐํšŒ_์„ฑ๊ณต() { }

@Test
void ์กด์žฌํ•˜์ง€_์•Š๋Š”_ํšŒ์›_์กฐํšŒ์‹œ_์˜ˆ์™ธ๋ฐœ์ƒ() { }

FixtureMonkey ํ™œ์šฉ

@ExtendWith(MockitoExtension.class)
class MemberServiceTest {

    private static final FixtureMonkey fixtureMonkey = FixtureMonkey.builder()
            .objectIntrospector(ConstructorPropertiesArbitraryIntrospector.INSTANCE)
            .build();

    @Test
    void ํšŒ์›_์กฐํšŒ_์„ฑ๊ณต() {
        // given
        Member member = fixtureMonkey.giveMeOne(Member.class);
        given(memberRepository.findById(anyLong()))
                .willReturn(Optional.of(member));

        // when
        MemberResponse.Detail result = memberService.getMember(1L);

        // then
        assertThat(result.id()).isEqualTo(member.getId());
    }
}

FixtureMonkey ์žฅ์ : ๋žœ๋ค ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ ์ž๋™ ์ƒ์„ฑ, ์—ฃ์ง€ ์ผ€์ด์Šค ๋ฐœ๊ฒฌ ์šฉ์ด

Given-When-Then ํŒจํ„ด

@Test
void ํšŒ์›_์ƒ์„ฑ_์„ฑ๊ณต() {
    // given - ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ ์ค€๋น„
    MemberRequest.Create request = new MemberRequest.Create("๋‹‰๋„ค์ž„", "test@test.com");

    // when - ํ…Œ์ŠคํŠธ ๋Œ€์ƒ ์‹คํ–‰
    MemberResponse.Detail result = memberService.createMember(request);

    // then - ๊ฒฐ๊ณผ ๊ฒ€์ฆ
    assertThat(result.nickname()).isEqualTo("๋‹‰๋„ค์ž„");
}

๋‚ ์งœ & ์‹œ๊ฐ„

// โœ… LocalDateTime ์‚ฌ์šฉ
private LocalDateTime createdAt;

// โŒ Date, Timestamp ๊ธˆ์ง€
private Date createdAt;

API ์‘๋‹ต ํฌ๋งท

// ISO 8601 (JacksonConfig ์ „์—ญ ์„ค์ •)
"createdAt": "2025-01-15T14:30:00"

// ํŠน์ • ํฌ๋งท ํ•„์š”์‹œ
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createdAt;

์ƒ์ˆ˜ ์ฒ˜๋ฆฌ

// โŒ ๋งค์ง ๋„˜๋ฒ„
if (retryCount > 3) { }

// โœ… ์ƒ์ˆ˜ ๋˜๋Š” Enum
private static final int MAX_RETRY_COUNT = 3;
if (retryCount > MAX_RETRY_COUNT) { }

๋กœ๊น…

log.debug()  // ๊ฐœ๋ฐœ ๋””๋ฒ„๊น…์šฉ
log.info()   // ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ํ๋ฆ„
log.warn()   // ์˜ˆ์ƒ๋œ ์˜ˆ์™ธ
log.error()  // ์‹œ์Šคํ…œ ์—๋Ÿฌ

ํŒจํ„ด

log.info("[MemberService.getMember] memberId: {}", memberId);

๋ฏผ๊ฐ์ •๋ณด ๊ธˆ์ง€

// โŒ ๊ธˆ์ง€
log.info("ํšŒ์›: {}", member);      // ์ „์ฒด ๊ฐ์ฒด
log.info("๋น„๋ฐ€๋ฒˆํ˜ธ: {}", password);
log.info("ํ† ํฐ: {}", accessToken);

๋ฉ”์„œ๋“œ ํŒŒ๋ผ๋ฏธํ„ฐ

// โŒ ํŒŒ๋ผ๋ฏธํ„ฐ 3๊ฐœ ์ดˆ๊ณผ
void create(Long a, Long b, LocalDateTime c, String d, int e);

// โœ… Command ๊ฐ์ฒด๋กœ ๋ฌถ๊ธฐ
void create(ReservationCommand command);

public record ReservationCommand(
    Long memberId,
    Long storeId,
    LocalDateTime date,
    String memo
) {}