본문 바로가기

우테코/오픈미션

오픈미션 8일차

오늘의 할 일은 서비스 계층을 설계하고 구현하는 것이다! 우선 Spring boot에서 서비스 계층이 어떤 역할인지부터 다시 정의해보았다. 

https://velog.io/@win-luck/Springboot-스프링-주요-계층별-잡지식-정리-2-Service-Dto-Exception

1. 서비스(Service) 

  • Service는 Springboot에서 가장 중추적인 부분이며, 비즈니스 로직을 총괄하는 심장부이다.
  • DB에 가하는 CRUD 작업을 지휘하고, 문제가 생기면 예외를 발생시켜 Springboot에 알린다.
  • @Service 어노테이션을 통해 스프링에 이 객체가 Service 계층임을 설정한다.

 

2. 의존성 

Service는 필연적으로 CRUD를 위해 Repository 계층에 의존하게 되며, Controller 역시 필연적으로 Service 계층에 의존한다.

  • 스프링은 이런 의존 관계를 해결해주기 위해 Service에 Repository를 외부에서 주입해줄 수 있다.
  • 이렇게 요구되는 의존 관계를 내부에서 직접 선언하거나 초기화하지 않고, 외부에서 주입받는 일련의 과정을 의존성 주입(Dependency Injection)이라고 한다.
  • 의존성 주입 방법은 크게 필드 주입, 생성자 주입, 수정자 주입으로 나눈다.

 

3. DTO (Data Transfer Object = 데이터 전송 객체)

  • 서버 → 클라, 혹은 클라 → 서버로의 데이터 전송을 목적으로 사용되는 객체
  • 비즈니스 로직을 가지지 않고, 데이터만을 저장하고 전송하는 데에만 사용
    • 외부에서 객체의 내부를 굳이 불필요하게 드러낼 이유가 없음
    • 클라이언트, 서버가 필요로 하는 데이터만을 포함하는 것이 바람직

우리가 domain 객체(예: MyLotto)에 붙인 @Entity, @Id, @OneToOne 같은 Annotation들은 내부 설계도이다. Service가 Controller에게 그대로 전달하면, Controller가 @Entity 등을 Client에게 실수로 유출할 수 있다. (예: id: 1 같은 내부 식별자, password 같은 민감 정보 등)


이를 바탕으로 내가 이해한 서비스 계층이라는 것은 아래와 같다. 

  1. 데이터가 필요하면 Repository 에서 가져오고 
  2. 계산이 필요하면 Domain (각 객체)에게, 각각 일을 하라고 명령하고
  3. 그 결과들을 조합해서 '하나의 작업(비즈니스 로직)'을 완성한 뒤, Controller에게 결과를 돌려주는 역할을 한다. 
  4. 여기서 돌려줄 때 사용되는 것이 DTO이다. 

 

그럼 이 서비스가 나의 로또 프로젝트에서는 어떤 역할을 해야 하는가? 에 대해서 고민해보면서 LottoService의 설계도를 작성하였다. 

LottoService UML

그리고 나서 설계한 것을 토대로 하나씩 구현해나가기 시작했다. 시작은 당연히 Lotto에 대한 Service.java를 만드는 것부터였다. 

1. LottoService 

서비스 계층(LottoService)은, 생성자(Constructor)를 통해, Repository 객체를 주입(Injection) 받는 것인데 생성자를 작성할때 레포지토리가 늘어갈 수록 계속해서 코드가 길어지고 복잡해지는 문제점이 발생했다 그래서 책이랑 다른 분들이 어떻게 했는 지 찾아보니 생성자 주입을 통해 자동으로 생성자를 만든다는 것을 알아냈다! 

 

해결책: 자동 생성자 주입 (Lombok @RequiredArgsConstructor 사용)

  • Spring은 공식적으로 생성자 주입을 권장하고 있다.
    • final을 통해 주입된 객체의 불변성을 보장한다.
    • @RequiredArgsConstructor 어노테이션 하나로 쉽고 다양한 의존관계에 대해 간단하게 주입 과정을 표현할 수 있다.
  • Lombok이 컴파일 시점에, final이 붙은 모든 필드를 인식해서 생성자를 자동으로 만들어준다. 
@RequiredArgsConstructor
@Transactional
@Service
public class LottoService {
    private final LottoRepository lottoRepository;
    private final MyLottoRepository myLottoRepository;
    private final PurchasedLottoRepository purchasedLottoRepository;
    private final WinningLottoRepository winningLottoRepository;
}

 

1. 랜덤 로또 생성 로직 (generateRandomLotto)

클라이언트가 로또를 생성해달라고 하면 -> 컨트롤러가 요청을 받아 서비스를 호출 -> 서비스야 로또를 생성해줘 -> 서비스는 객체(Lotto) 를 이용해서 로직 처리 -> 결과를 DTO 형태로 클라이언트에 반환 -> 클라이언트가 확인 

 

레퍼지토리를 사용하는 것이 아닌 3주차 미션에서 활용했던 라이브러리를 그대로 사용하는 로직이므로 첫 로직으로 선택했다. 

@RequiredArgsConstructor
@Transactional
@Service
public class LottoService {
    private final LottoRepository lottoRepository;
    private final MyLottoRepository myLottoRepository;
    private final PurchasedLottoRepository purchasedLottoRepository;
    private final WinningLottoRepository winningLottoRepository;

    @Transactional(readOnly = true)
    public Lotto generateRandomLotto() {
        List<Integer> numbers = Randoms.pickUniqueNumbersInRange(1, 45, 6);
        return new Lotto(numbers);
    }
}

 

 

2.  LottoServiceTest

@SpringBootTest
@Transactional
class LottoServiceTest {
    // 1. @Autowired: 스프링이 '자동'으로 만든 '진짜' LottoService Bean을 주입
    @Autowired
    private LottoService lottoService;

    @DisplayName("generateRandomLotto() 메서드가 6개의 숫자를 가진 Lotto 객체를 성공적으로 생성한다.")
    @Test
    void generateRandomLotto_test() {
        Lotto randomLotto = lottoService.generateRandomLotto();

        assertThat(randomLotto).isNotNull();

        assertThat(randomLotto.getNumbers()).isNotNull();
        assertThat(randomLotto.getNumbers().size()).isEqualTo(6);
    }

    // --- (TODO: 화요일의 계획 2단계) ---
    // 'saveToMyLotto_test()' 메서드를 추가
}

 

테스트까지 완료한 후 이제 진짜 레포지토리를 사용해 서비스를 구현할 차례이다. 

 

3. 나만의 로또 저장 로직 

클라이언트가 로또를 나만의 로또 리스트에 저장해달라고 하면 로또를 저장 -> 나만의 로또 리스트에 저장되며 이 리스트는 조회나 삭제가 가능하다. 로또를 수정할 필요는 없을 것 같아서 수정 로직은 구현하지 않을 것이다. 이때 구현해야 하는 saveToMyLotto는 Create이고, Service에 findAllMyLottos (R)와 deleteMyLotto (D) 메서드를 추가로 구현하면 될 것 같다. 

 

랜덤 생성된 번호 6개를 로또로 만들기 (Lotto 활용) -> 이름을 적어서 MyLotto를 만들기 -> MyLottoRepository에 저장하기 

 

근데 여기부터 갑자기 막히기 시작했다. DTO는 언제 만들어야 하는가..? 서비스가 dto를 활용해서 객체 자체를 내보내는 거니까 DTO를 만들고 그것을 반환하는 로직으로 서비스를 수정해야 하는건가..? DTO는 어떻게 구현해야 하는 건지 급하게 찾아봤지만 머리가 매우매우 복잡해졌다. 그러다가 나는 Controller, Service, Client(웹/앱) 간에 데이터를 주고받을 땐, DB에 연결된 순수한 Entity를 그대로 사용하지 않고 DTO라는 전송용 객체를 사용하니까 서비스를 만들면서 어떤 데이터를 주고 받을 지 동시에 구현해야 한다고 결론을 내렸다... 오마이갓!! 너무 어렵구만 


그래서 우선 DTO를 만들면서 서비스를 만들어보기로 했다. (다들 이렇게 하는건가..?) 

 

  • Request  ➡️: 클라이언트가 서버에게 요청하는 것. "이것 좀 해주세요" 
  • Response  ⬅️: 서버가 클라이언트에게 응답하는 것. "요청하신 결과입니다" 

 

1. 랜덤 번호 생성하는 DTO (Response)

2. 나만의 로또 번호를 저장하는 DTO (Response/Request) 

3. 구매한 로또 번호를 저장하는 DTO (Response/Request)

 

이렇게 총 5가지의 DTO를 만든 후 이를 활용해 서비스에서 3가지 기능을 구현한 후 

나만의 로또 리스트 조회/삭제 , 구매 로또 리스트 조회/삭제 기능까지 추가로 구현한 후에 컨트롤러로 넘어가서 컨트롤러 구현 후 뷰까지 구현한 후 통계를 추가해야할 것 같다. 안그럼 내 머리가 터질 것 같아서 우선 눈으로 보이는 것을 구현해봐야할 것 같다. 어려워 ㅠㅠㅠ

 

4. RandomLottoResponseDTO

package com.woowa.lotto.dto.response;

import com.woowa.lotto.domain.Lotto;
import lombok.Builder;
import lombok.Getter;
import java.util.List;

/**
 * '랜덤 로또' 생성 응답(Response) DTO (DB 저장 X)
 * Controller -> 클라이언트
 */
@Getter
@Builder // Service에서 DTO를 생성할 때 .build() 패턴을 사용
public class RandomLottoResponseDTO {

    private final List<Integer> numbers;

    // Service가 Lotto(값 객체)를 DTO로 변환할 때 사용하는 팩토리 메서드
    public static RandomLottoResponseDTO from(Lotto lotto) {
        return RandomLottoResponseDTO.builder()
                .numbers(lotto.getNumbers())
                .build();
    }
}

 

사용자가 번호를 요구하면 랜덤으로 번호를 생성하여 보여주는 DTO이다. (서버 -> 사용자) 

이 자체로는 데이터베이스에 저장될 필요가 없고 사용자가 나만의 로또에 추가하겠다고 하면 그때 저장하면 된다. 

 

5. MyLottoRequestDTO

package com.woowa.lotto.dto.request;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.util.List;

/**
 * '나만의 로또' 생성 요청(Request) DTO
 * 클라이언트(Postman의 Body) -> Controller
 */
@Getter
@Setter // Controller에서 JSON을 객체로 바인딩(deserialization)하기 위해 필요
@NoArgsConstructor // JSON 바인딩을 위한 기본 생성자
public class MyLottoRequestDTO {

    private List<Integer> numbers;
    private String lottoName;
}

사용자가 서버한테 로또와 이름을 주면서 이걸 나만의 로또에 추가해줘~ 할 때 사용되는 데이터이다. 

 

 

6. MyLottoResponseDTO

package com.woowa.lotto.dto.response;

import com.woowa.lotto.domain.MyLotto;
import lombok.Builder;
import lombok.Getter;
import java.util.List;

/**
 * '나만의 로또' 저장 결과 응답(Response) DTO (DB 저장 O)
 * Controller -> 클라이언트
 */
@Getter
@Builder // Service에서 DTO를 생성할 때 .build() 패턴을 사용
public class MyLottoResponseDTO {

    private final Long id;
    private final String lottoName;
    private final List<Integer> numbers;

    // Service가 MyLotto(엔티티)를 DTO로 변환할 때 사용하는 팩토리 메서드
    public static MyLottoResponseDTO from(MyLotto entity) {
        return MyLottoResponseDTO.builder()
                .id(entity.getId())
                .lottoName(entity.getLottoName())
                .numbers(entity.getMyLotto().getNumbers())
                .build();
    }
}

만들어진 나만의 로또를 서버가 사용자에게 보여줄 때 필요한 데이터 객체이다. 

 

 

여기서부터는 조금 설명이 필요한데 나만의 로또에 들어가는 기준은 두 가지이다. 첫 번째 랜덤 생성한 번호가 마음에 들때, 두 번째는 내가 원하는 번호를 넣고 싶을 때 (ex: 꿈에 나온 번호, 누가 알려준 번호, 마음에 들었던 번호, 당첨되었던 번호 등) 이렇게 두 가지이다. 구매한 로또도 두 가지의 방식으로 추가될 수 있는데 나만의 로또 리스트에서 내가 진짜로 이번주에 구매한 번호 (복사), 혹은 그냥 구매한 번호이다. 이 그냥 구매한 번호는 원래 리스트에 없던 번호이기 때문에 직접 추가해야 한다. 

7. PurchasedLottoRequestDTO

package com.woowa.lotto.dto.request;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.util.List;

// '수동' 번호로 로또를 '구매'할 때 사용하는 DTO (주문서)
@Getter
@Setter
@NoArgsConstructor // JSON 역직렬화를 위해 기본 생성자 필요
public class PurchasedLottoRequestDTO {
    private List<Integer> numbers;
}

그래서 이 DTO는 사용자가 직접 구매한 로또를 리스트에 넣을 때 필요한 데이터 정보이다. 이름이 없는 이유는 나만의 로또에서는 로또 별로 구분할 필요가 있어서 이름이 필요했지만 구매 로또는 주차별로 11월 1주차 구매 로또, 2주차 로또 등 날짜별로 묶기 때문에 따로 이름이필요없다. 

 

8. PurchasedLottoResponseDTO

package com.woowa.lotto.dto.response;

import com.woowa.lotto.domain.PurchasedLotto;
import lombok.Builder;
import lombok.Getter;

import java.time.LocalDate;
import java.util.List;

/**
 * 로또 '구매' 성공 시 공통으로 반환하는 DTO (영수증)
 * (수동 구매, '나만의 로또'에서 복사 구매 시 모두 사용)
 */
@Getter
public class PurchasedLottoResponseDTO {

    private final Long id;
    private final List<Integer> numbers;
    private final LocalDate purchaseDate;

    @Builder
    public PurchasedLottoResponseDTO(Long id, List<Integer> numbers, LocalDate purchaseDate) {
        this.id = id;
        this.numbers = numbers;
        this.purchaseDate = purchaseDate;
    }

    /**
     * PurchasedLotto 엔티티를 PurchasedLottoResponseDTO로 변환
     * entity (DB에서 저장되거나 조회된 엔티티)
     * @return 변환된 DTO
     */
    public static PurchasedLottoResponseDTO from(PurchasedLotto entity) {
        return PurchasedLottoResponseDTO.builder()
                .id(entity.getId())
                .numbers(entity.getPurchasedLotto().getNumbers()) // @Embedded된 Lotto 객체에서 번호 가져오기
                .purchaseDate(entity.getPurchaseDate())
                .build();
    }
}

 

구매로또 requestDTO는 사용자가 구매한 로또들을 보여줄 때 필요한 데이터들이다. 

 

이러한 DTO들을 사용해서 만든 서비스는 다음과 같다. 

9. LottoService 

@RequiredArgsConstructor
@Transactional
@Service
public class LottoService {
    private final MyLottoRepository myLottoRepository;
    private final PurchasedLottoRepository purchasedLottoRepository;
    private final WinningLottoRepository winningLottoRepository;

    /**
     * 1. 로또 번호 생성 (Generate)
     * (DB 저장 X)
     */
    @Transactional(readOnly = true)
    // [수정] 반환 타입 LottoResponseDTO -> RandomLottoResponseDTO
    public RandomLottoResponseDTO generateRandomLotto() {
        List<Integer> numbers = Randoms.pickUniqueNumbersInRange(1, 45, 6);
        Lotto lotto = new Lotto(numbers); // Lotto 값 객체 생성 (검증 포함)

        return RandomLottoResponseDTO.from(lotto);
    }

    /**
     * 2. 나만의 번호 리스트에 저장 (Favorites)
     * (랜덤/수동 공통 로직)
     * request (lottoName, numbers)가 들어있는 DTO
     * @return 저장된 결과 (id, lottoName, numbers) DTO
     */
    public MyLottoResponseDTO saveToMyLotto(MyLottoRequestDTO request) {
        // DTO의 데이터를 사용
        Lotto lotto = new Lotto(request.getNumbers());
        MyLotto myLotto = new MyLotto(lotto, request.getLottoName());

        MyLotto savedEntity = myLottoRepository.save(myLotto);

        // 컨트롤러(클라이언트)에는 'DTO'로 변환하여 반환
        return MyLottoResponseDTO.from(savedEntity);
    }

    // TODO: 3. 구매 번호 리스트 (Purchases) 기능

    //[Service] '수동' 번호로 로또 구매
    public PurchasedLottoResponseDTO purchaseManualLotto(PurchasedLottoRequestDTO request) {
        Lotto lotto = new Lotto(request.getNumbers());
        // 공통 구매 로직 호출
        return purchaseLottoInternal(lotto);
    }

    // [Service] '나만의 로또'에서 '복사'하여 로또 구매
    public PurchasedLottoResponseDTO purchaseFromMyLotto(Long myLottoId) {
        // 1. '나만의 로또'를 DB에서 찾는다. (없으면 예외 발생)
        MyLotto myLotto = myLottoRepository.findById(myLottoId)
                .orElseThrow(() -> new IllegalArgumentException("[ERROR] 존재하지 않는 '나만의 로또' ID입니다: " + myLottoId));

        // 2. '나만의 로또'에서 Lotto (값 객체)를 '읽어온다(복사)'
        Lotto lottoToCopy = myLotto.getMyLotto();

        // 3. 공통 구매 로직 호출
        return purchaseLottoInternal(lottoToCopy);
    }

    /**
     * 로또 구매 공통 로직
     * - 어떤 방식(수동/복사)이든, 'Lotto' 객체를 받아서
     * - '오늘 날짜'를 찍고 'PurchasedLotto' 엔티티로 저장
     */
    private PurchasedLottoResponseDTO purchaseLottoInternal(Lotto lotto) {
        // 1. '구매 날짜'는 서비스에서 오늘 날짜로 생성
        LocalDate today = LocalDate.now();

        // 2. '구매한 로또' 엔티티 생성
        PurchasedLotto purchasedLotto = new PurchasedLotto(lotto, today);

        // 3. DB에 저장
        PurchasedLotto savedLotto = purchasedLottoRepository.save(purchasedLotto);

        // 4. "영수증" DTO로 변환하여 반환
        return PurchasedLottoResponseDTO.from(savedLotto);
    }

    // TODO: 4. 당첨 결과 및 통계 (Statistics) 기능 구현
}

 


 

원래의 계획은 4번까지 구현 후에 컨트롤러와 뷰를 구현하는 거였으나 통계 부분에서 주차별로 묶는 다는 것이 너무 복잡해질 것 같아서 설계를 좀 더 고민해봐야할 것 같다. 그래서 그 전에 

1. 나만의 로또 리스트 조회/삭제 

2. 구매 로또 리스트 조회/삭제 

를 먼저 구현한 후 컨트롤러와 뷰를 구현해 우선 동작하는 로또 웹을 1차적으로 완성시킨 후에 

3. 주차를 골라서 당첨번호를 입력하면 

4. 당첨 번호가 입력된 주(week)는 당첨 결과 및 통계를 확인 가능 

이와 같은 3,4번을 추가적으로 구현할 것이다. 그래서 어제 계획해두었던 것을 조금 수정하자면 아래와 같이 진행해야할 것 같다. 

 

수: 1,2번 구현 및 컨트롤러 설계

목: 컨트롤러 구현

금: 뷰 구현 + 웹 동작까지 최대한 구현해보기 

토: 3번 구현

일: 4번 구현

월: 코드 정리 및 마무리 후 제출 


추가 수정 내용)

랜덤 생성된 번호를 저장하고 구매 리스트로 옮겨질 때 복사하기로 결정했었는데 @OnetoOne을 사용하면 계속해서 LottoRepository에 로또가 저장된다는 사실을 깨달아 우선 서비스를 구현하다가 @OnetoOne를 삭제하고 로또 객체를 @Embedded로  만들기로 결정했다. 왜냐하면 로또 객체는 나만의 로또에 로또 + 이름으로 저장되거나 구매리스트에 나만의 로또 + 구매 날짜로 저장되거나 할 뿐 로또 객체만 저장되는 경우는 없기 때문에 굳이 레포지토리로 관리할 필요가 없었기 때문이다! 

 

핵심 변경 사항 요약:

  1. Lotto.java:
    • @Entity -> @Embeddable
    • id 필드, @Id, @GeneratedValue 제거
    • getId() 메서드 제거
    • @CollectionTable(...) 어노테이션 제거 (JPA 기본값 사용)
  2. MyLotto.java:
    • @OneToOne(...) -> @Embedded
    • @JoinColumn(...) 제거
  3. PurchasedLotto.java:
    • @OneToOne(...) -> @Embedded
    • @JoinColumn(...) 제거
  4. WinningLotto.java:
    • @OneToOne(...) -> @Embedded

 

참고)

https://velog.io/@yeeeerim_/Spring-Boot-CRUD게시판-만들기

 

[Spring Boot🌿]_CRUD 게시판 만들기(2) (패키지 구조, DB,DTO)

프로젝트의 패키지는 다음과 같다.: Swagger의 설정파일이 담긴 패키지이다. : 웹 MVC의 컨트롤러 역할을 하는 클래스들이 담겨있다. : DTO(Data Transfer Object)란 계층간 데이터 교환을 위해 사용하는 객

velog.io

 

 

'우테코 > 오픈미션' 카테고리의 다른 글

오픈미션 10일차  (0) 2025.11.13
오픈미션 9일차  (0) 2025.11.12
오픈미션 7일차  (0) 2025.11.10
오픈미션 6일차  (0) 2025.11.09
오픈미션 5일차  (0) 2025.11.08